From e66f04932cfeb589ca534414bc82a4c836759f47 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 16 Apr 2026 02:35:30 +0000 Subject: [PATCH 1/2] feat: report file and remote mutations via structured action logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `src/lib/logger.ts` — a module-level action collector with a discriminated union for file/remote created/updated/deleted actions, `reportAction`, `flushActions`, and `formatAction`. Adapters and commands report mutations; commands flush and print them alongside their summary. Lays groundwork for future `--json`/`--quiet` modes. Resolves: prismicio/cli#142 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/adapters/index.ts | 8 ++++++++ src/adapters/nextjs.ts | 9 +++++++++ src/adapters/nuxt.ts | 7 +++++++ src/adapters/sveltekit.ts | 13 +++++++++++++ src/commands/gen-setup.ts | 6 ++++++ src/commands/gen-types.ts | 10 +++++----- src/commands/init.ts | 6 ++++++ src/commands/slice-create.ts | 8 +++++++- src/commands/slice-remove.ts | 8 +++++++- src/commands/sync.ts | 20 +++++++++++++++++++- src/commands/type-create.ts | 8 +++++++- src/commands/type-remove.ts | 8 +++++++- src/lib/logger.ts | 36 ++++++++++++++++++++++++++++++++++++ test/gen-types.test.ts | 4 ++-- 14 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 src/lib/logger.ts diff --git a/src/adapters/index.ts b/src/adapters/index.ts index ea5a02b..e45d95d 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -9,6 +9,7 @@ import { glob } from "tinyglobby"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { readJsonFile, writeFileRecursive } from "../lib/file"; import { stringify } from "../lib/json"; +import { reportAction } from "../lib/logger"; import { readPackageJson } from "../lib/packageJson"; import { appendTrailingSlash } from "../lib/url"; import { addRoute, removeRoute, updateRoute } from "../project"; @@ -110,6 +111,7 @@ export abstract class Adapter { const sliceDirectory = new URL(sliceDirectoryName, appendTrailingSlash(library)); const modelPath = new URL("model.json", appendTrailingSlash(sliceDirectory)); await writeFileRecursive(modelPath, stringify(model)); + reportAction({ type: "file-created", url: modelPath }); await this.createSliceIndexFile(library); await this.onSliceCreated(model, library); } @@ -118,12 +120,14 @@ export abstract class Adapter { const slice = await this.getSlice(model.id); const modelPath = new URL("model.json", appendTrailingSlash(slice.directory)); await writeFileRecursive(modelPath, stringify(model)); + reportAction({ type: "file-updated", url: modelPath }); await this.onSliceUpdated(model); } async deleteSlice(id: string): Promise { const slice = await this.getSlice(id); await rm(slice.directory, { recursive: true }); + reportAction({ type: "file-deleted", url: slice.directory }); await this.createSliceIndexFile(slice.library); await this.onSliceDeleted(id); } @@ -162,6 +166,7 @@ export abstract class Adapter { const customTypesDirectory = new URL("customtypes/", projectRoot); const modelPath = new URL(`${model.id}/index.json`, customTypesDirectory); await writeFileRecursive(modelPath, stringify(model)); + reportAction({ type: "file-created", url: modelPath }); if (model.format === "page") await addRoute(model); await this.onCustomTypeCreated(model); } @@ -170,6 +175,7 @@ export abstract class Adapter { const customType = await this.getCustomType(model.id); const modelPath = new URL("index.json", appendTrailingSlash(customType.directory)); await writeFileRecursive(modelPath, stringify(model)); + reportAction({ type: "file-updated", url: modelPath }); await updateRoute(model); await this.onCustomTypeUpdated(model); } @@ -177,6 +183,7 @@ export abstract class Adapter { async deleteCustomType(id: string): Promise { const customType = await this.getCustomType(id); await rm(customType.directory, { recursive: true }); + reportAction({ type: "file-deleted", url: customType.directory }); await removeRoute(id); await this.onCustomTypeDeleted(id); } @@ -280,6 +287,7 @@ export abstract class Adapter { typesProvider: "@prismicio/client", }); await writeFileRecursive(output, types); + reportAction({ type: "file-updated", url: output }); return output; } } diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index a445a41..397f5b0 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { Adapter } from "."; import { exists, writeFileRecursive } from "../lib/file"; +import { reportAction } from "../lib/logger"; import { addDependencies, findPackageJson, getNpmPackageVersion } from "../lib/packageJson"; import { dedent } from "../lib/string"; import { appendTrailingSlash } from "../lib/url"; @@ -52,6 +53,7 @@ export class NextJsAdapter extends Adapter { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(componentPath, contents); + reportAction({ type: "file-created", url: componentPath }); } onSliceUpdated(): void {} @@ -91,6 +93,7 @@ export class NextJsAdapter extends Adapter { const filename = `index.${extension}`; const indexPath = new URL(filename, library); await writeFileRecursive(indexPath, contents); + reportAction({ type: "file-updated", url: indexPath }); } async getDefaultSliceLibrary(): Promise { @@ -116,6 +119,7 @@ async function createRevalidateRoute(): Promise { const contents = revalidateRouteTemplate({ supportsCacheLife }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createExitPreviewRoute(): Promise { @@ -132,6 +136,7 @@ async function createExitPreviewRoute(): Promise { const contents = exitPreviewRouteTemplate({ typescript, appRouter }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createPreviewRoute(): Promise { @@ -148,6 +153,7 @@ async function createPreviewRoute(): Promise { const contents = previewRouteTemplate({ typescript, appRouter }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createSliceSimulatorPage(): Promise { @@ -164,6 +170,7 @@ async function createSliceSimulatorPage(): Promise { const contents = sliceSimulatorPageTemplate({ typescript, appRouter }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createPrismicIoFile(): Promise { @@ -182,6 +189,7 @@ async function createPrismicIoFile(): Promise { hasSrcDirectory, }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createPageFile(model: CustomType): Promise { @@ -206,6 +214,7 @@ async function createPageFile(model: CustomType): Promise { appRouter: usesAppRouter, }); await writeFileRecursive(pageFilePath, contents); + reportAction({ type: "file-created", url: pageFilePath }); } async function checkUsesAppRouter() { diff --git a/src/adapters/nuxt.ts b/src/adapters/nuxt.ts index 050bbc8..a609e06 100644 --- a/src/adapters/nuxt.ts +++ b/src/adapters/nuxt.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { Adapter } from "."; import { exists, writeFileRecursive } from "../lib/file"; +import { reportAction } from "../lib/logger"; import { addDependencies, getNpmPackageVersion } from "../lib/packageJson"; import { dedent } from "../lib/string"; import { appendTrailingSlash } from "../lib/url"; @@ -43,6 +44,7 @@ export class NuxtAdapter extends Adapter { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(componentPath, contents); + reportAction({ type: "file-created", url: componentPath }); } onSliceUpdated(): void {} @@ -78,6 +80,7 @@ export class NuxtAdapter extends Adapter { const filename = `index.${extension}`; const indexPath = new URL(filename, library); await writeFileRecursive(indexPath, contents); + reportAction({ type: "file-updated", url: indexPath }); } async getDefaultSliceLibrary(): Promise { @@ -174,6 +177,7 @@ async function createSliceSimulatorPage(): Promise { const contents = sliceSimulatorPageTemplate({ typescript }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function moveOrDeleteAppVue(): Promise { @@ -194,9 +198,11 @@ async function moveOrDeleteAppVue(): Promise { if (!(await exists(indexVuePath))) { await writeFileRecursive(indexVuePath, contents); + reportAction({ type: "file-created", url: indexVuePath }); } await rm(appVuePath); + reportAction({ type: "file-deleted", url: appVuePath }); } async function modifySliceLibraryPath(adapter: NuxtAdapter): Promise { @@ -246,6 +252,7 @@ async function createPageFile(model: CustomType): Promise { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(pageFilePath, contents); + reportAction({ type: "file-created", url: pageFilePath }); } async function getJsFileExtension(): Promise { diff --git a/src/adapters/sveltekit.ts b/src/adapters/sveltekit.ts index 65ca248..4630f7d 100644 --- a/src/adapters/sveltekit.ts +++ b/src/adapters/sveltekit.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { Adapter } from "."; import { exists, writeFileRecursive } from "../lib/file"; +import { reportAction } from "../lib/logger"; import { addDependencies, findPackageJson, getNpmPackageVersion } from "../lib/packageJson"; import { dedent } from "../lib/string"; import { appendTrailingSlash } from "../lib/url"; @@ -55,6 +56,7 @@ export class SvelteKitAdapter extends Adapter { version: await getSvelteMajor(), }); await writeFileRecursive(componentPath, contents); + reportAction({ type: "file-created", url: componentPath }); } onSliceUpdated(): void {} @@ -94,6 +96,7 @@ export class SvelteKitAdapter extends Adapter { const filename = `index.${extension}`; const indexPath = new URL(filename, library); await writeFileRecursive(indexPath, contents); + reportAction({ type: "file-updated", url: indexPath }); } async getDefaultSliceLibrary(): Promise { @@ -111,6 +114,7 @@ async function createPrismicIoFile(): Promise { const typescript = await checkIsTypeScriptProject(); const contents = prismicIOFileTemplate({ typescript }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createSliceSimulatorPage(): Promise { @@ -122,6 +126,7 @@ async function createSliceSimulatorPage(): Promise { version: await getSvelteMajor(), }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createPreviewRouteMatcher(): Promise { @@ -136,6 +141,7 @@ async function createPreviewRouteMatcher(): Promise { } `; await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createPreviewAPIRoute(): Promise { @@ -147,6 +153,7 @@ async function createPreviewAPIRoute(): Promise { const typescript = await checkIsTypeScriptProject(); const contents = previewAPIRouteTemplate({ typescript }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createPreviewRouteDirectory(): Promise { @@ -165,6 +172,7 @@ async function createPreviewRouteDirectory(): Promise { See for more information. `; await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createRootLayoutServerFile(): Promise { @@ -177,6 +185,7 @@ async function createRootLayoutServerFile(): Promise { export const prerender = "auto"; `; await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createRootLayoutFile(): Promise { @@ -188,6 +197,7 @@ async function createRootLayoutFile(): Promise { version: await getSvelteMajor(), }); await writeFileRecursive(filePath, contents); + reportAction({ type: "file-created", url: filePath }); } async function createPageFile(model: CustomType): Promise { @@ -206,6 +216,7 @@ async function createPageFile(model: CustomType): Promise { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(pageFilePath, contents); + reportAction({ type: "file-created", url: pageFilePath }); } const serverFilePath = new URL(`+page.server.${extension}`, fullRoutePath); @@ -215,6 +226,7 @@ async function createPageFile(model: CustomType): Promise { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(serverFilePath, contents); + reportAction({ type: "file-created", url: serverFilePath }); } } @@ -244,6 +256,7 @@ async function modifyViteConfig(): Promise { const contents = mod.generate().code.replace(/\n\s*\n(?=\s*server:)/, "\n"); await writeFile(configUrl, contents); + reportAction({ type: "file-updated", url: configUrl }); } async function getJsFileExtension(): Promise { diff --git a/src/commands/gen-setup.ts b/src/commands/gen-setup.ts index e2223db..5bcbb6d 100644 --- a/src/commands/gen-setup.ts +++ b/src/commands/gen-setup.ts @@ -1,6 +1,8 @@ import { getAdapter } from "../adapters"; import { createCommand, type CommandConfig } from "../lib/command"; +import { flushActions, formatAction } from "../lib/logger"; import { installDependencies } from "../lib/packageJson"; +import { findProjectRoot } from "../project"; const config = { name: "prismic gen setup", @@ -34,5 +36,9 @@ export default createCommand(config, async ({ values }) => { } } + const projectRoot = await findProjectRoot(); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } console.info("Generated setup files."); }); diff --git a/src/commands/gen-types.ts b/src/commands/gen-types.ts index 6d9856b..541f8b1 100644 --- a/src/commands/gen-types.ts +++ b/src/commands/gen-types.ts @@ -1,6 +1,6 @@ import { getAdapter } from "../adapters"; import { createCommand, type CommandConfig } from "../lib/command"; -import { relativePathname } from "../lib/url"; +import { flushActions, formatAction } from "../lib/logger"; import { findProjectRoot } from "../project"; const config = { @@ -10,10 +10,10 @@ const config = { export default createCommand(config, async () => { const adapter = await getAdapter(); - const typesPath = await adapter.generateTypes(); + await adapter.generateTypes(); const projectRoot = await findProjectRoot(); - const relativeOutput = relativePathname(projectRoot, typesPath); - - console.info(`Generated types at ${relativeOutput}`); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } }); diff --git a/src/commands/init.ts b/src/commands/init.ts index f403af1..24f774d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -6,11 +6,13 @@ import { getProfile } from "../clients/user"; import { DEFAULT_PRISMIC_HOST } from "../env"; import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushActions, formatAction } from "../lib/logger"; import { installDependencies } from "../lib/packageJson"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; import { createConfig, deleteLegacySliceMachineConfig, + findProjectRoot, InvalidLegacySliceMachineConfigError, MissingPrismicConfigError, readConfig, @@ -152,5 +154,9 @@ export default createCommand(config, async ({ values }) => { // Sync models from remote and generate types await adapter.syncModels({ repo, token, host }); + const projectRoot = await findProjectRoot(); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } console.info(`\nInitialized Prismic for repository "${repo}".`); }); diff --git a/src/commands/slice-create.ts b/src/commands/slice-create.ts index c8f71a8..4910843 100644 --- a/src/commands/slice-create.ts +++ b/src/commands/slice-create.ts @@ -6,8 +6,9 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { insertSlice } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushActions, formatAction, reportAction } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; +import { findProjectRoot, getRepositoryName } from "../project"; const config = { name: "prismic slice create", @@ -55,9 +56,14 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } + reportAction({ type: "remote-created", id, message: `slice "${name}"` }); await adapter.createSlice(model); await adapter.generateTypes(); + const projectRoot = await findProjectRoot(); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } console.info(`Created slice "${name}" (id: "${id}")`); }); diff --git a/src/commands/slice-remove.ts b/src/commands/slice-remove.ts index 8a49da7..09aa698 100644 --- a/src/commands/slice-remove.ts +++ b/src/commands/slice-remove.ts @@ -2,8 +2,9 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { getSlice, removeSlice } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushActions, formatAction, reportAction } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; +import { findProjectRoot, getRepositoryName } from "../project"; const config = { name: "prismic slice remove", @@ -34,11 +35,16 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } + reportAction({ type: "remote-deleted", id: slice.id, message: `slice "${id}"` }); try { await adapter.deleteSlice(slice.id); } catch {} await adapter.generateTypes(); + const projectRoot = await findProjectRoot(); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } console.info(`Slice removed: ${id}`); }); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 74266ec..d41faa5 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -6,9 +6,15 @@ import { getHost, getToken } from "../auth"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { env } from "../env"; import { createCommand, type CommandConfig } from "../lib/command"; +import { flushActions, formatAction } from "../lib/logger"; import { segmentTrackEnd, segmentTrackStart } from "../lib/segment"; import { dedent } from "../lib/string"; -import { checkIsTypeBuilderEnabled, getRepositoryName, TypeBuilderRequiredError } from "../project"; +import { + checkIsTypeBuilderEnabled, + findProjectRoot, + getRepositoryName, + TypeBuilderRequiredError, +} from "../project"; // 5 seconds balances responsiveness with API load const POLL_INTERVAL_MS = env.TEST ? 500 : 5000; @@ -53,6 +59,10 @@ export default createCommand(config, async ({ values }) => { await adapter.syncModels({ repo, token, host }); segmentTrackEnd("sync", { watch }); + const projectRoot = await findProjectRoot(); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } console.info("Sync complete"); } }); @@ -60,12 +70,17 @@ export default createCommand(config, async ({ values }) => { async function watchForChanges(repo: string, adapter: Adapter) { const token = await getToken(); const host = await getHost(); + const projectRoot = await findProjectRoot(); const initialRemoteSlices = await getSlices({ repo, token, host }); const initialRemoteCustomTypes = await getCustomTypes({ repo, token, host }); await adapter.syncModels({ repo, token, host }); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } + console.info(dedent` Initial sync completed! @@ -117,6 +132,9 @@ async function watchForChanges(repo: string, adapter: Adapter) { const timestamp = new Date().toLocaleTimeString(); console.info(`[${timestamp}] Changes detected in ${changed.join(" and ")}`); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } } // Reset error count on success diff --git a/src/commands/type-create.ts b/src/commands/type-create.ts index db6f364..499ce86 100644 --- a/src/commands/type-create.ts +++ b/src/commands/type-create.ts @@ -6,8 +6,9 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { insertCustomType } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushActions, formatAction, reportAction } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; +import { findProjectRoot, getRepositoryName } from "../project"; const config = { name: "prismic type create", @@ -108,9 +109,14 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } + reportAction({ type: "remote-created", id, message: `type "${name}"` }); await adapter.createCustomType(model); await adapter.generateTypes(); + const projectRoot = await findProjectRoot(); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } console.info(`Created type "${name}" (id: "${id}", format: "${format}")`); }); diff --git a/src/commands/type-remove.ts b/src/commands/type-remove.ts index 84257b4..a8e8ace 100644 --- a/src/commands/type-remove.ts +++ b/src/commands/type-remove.ts @@ -2,8 +2,9 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { getCustomType, removeCustomType } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushActions, formatAction, reportAction } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; +import { findProjectRoot, getRepositoryName } from "../project"; const config = { name: "prismic type remove", @@ -39,11 +40,16 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } + reportAction({ type: "remote-deleted", id: customType.id, message: `type "${id}"` }); try { await adapter.deleteCustomType(customType.id); } catch {} await adapter.generateTypes(); + const projectRoot = await findProjectRoot(); + for (const action of flushActions()) { + console.info(formatAction(action, projectRoot)); + } console.info(`Type removed: ${id}`); }); diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..c3dec7e --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,36 @@ +import { relativePathname } from "./url"; + +export type Action = + | { type: "file-created"; url: URL } + | { type: "file-updated"; url: URL } + | { type: "file-deleted"; url: URL } + | { type: "remote-created"; id: string; message: string } + | { type: "remote-updated"; id: string; message: string } + | { type: "remote-deleted"; id: string; message: string }; + +const actions: Action[] = []; + +export function reportAction(action: Action): void { + actions.push(action); +} + +export function flushActions(): Action[] { + return actions.splice(0); +} + +export function formatAction(action: Action, projectRoot: URL): string { + switch (action.type) { + case "file-created": + return `Created ${relativePathname(projectRoot, action.url)}`; + case "file-updated": + return `Updated ${relativePathname(projectRoot, action.url)}`; + case "file-deleted": + return `Deleted ${relativePathname(projectRoot, action.url)}`; + case "remote-created": + return `Created remote ${action.message}`; + case "remote-updated": + return `Updated remote ${action.message}`; + case "remote-deleted": + return `Deleted remote ${action.message}`; + } +} diff --git a/test/gen-types.test.ts b/test/gen-types.test.ts index 75b7188..dd67c29 100644 --- a/test/gen-types.test.ts +++ b/test/gen-types.test.ts @@ -21,7 +21,7 @@ it("generates types from local models", async ({ expect, project, prismic }) => const { exitCode, stdout } = await prismic("gen", ["types"]); expect(exitCode).toBe(0); - expect(stdout).toContain("Generated types"); + expect(stdout).toContain("Updated prismicio-types.d.ts"); await expect(project).toHaveFile("prismicio-types.d.ts", { contains: customType.id, @@ -31,7 +31,7 @@ it("generates types from local models", async ({ expect, project, prismic }) => it("generates types with no models", async ({ expect, project, prismic }) => { const { exitCode, stdout } = await prismic("gen", ["types"]); expect(exitCode).toBe(0); - expect(stdout).toContain("Generated types"); + expect(stdout).toContain("Updated prismicio-types.d.ts"); await expect(project).toHaveFile("prismicio-types.d.ts"); }); From 74c034c75c8504f6e8c8ee9b1f336a9aa9fcfb0a Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 16 Apr 2026 21:37:46 +0000 Subject: [PATCH 2/2] refactor: format change logs as grouped summary with counts Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/adapters/index.ts | 69 +++++++++++++++++++++--------- src/adapters/nextjs.ts | 18 ++++---- src/adapters/nuxt.ts | 14 +++--- src/adapters/sveltekit.ts | 26 +++++------ src/commands/gen-setup.ts | 9 ++-- src/commands/gen-types.ts | 6 +-- src/commands/init.ts | 12 +++--- src/commands/slice-create.ts | 14 +++--- src/commands/slice-remove.ts | 14 +++--- src/commands/sync.ts | 25 +++++------ src/commands/type-create.ts | 14 +++--- src/commands/type-remove.ts | 14 +++--- src/lib/logger.ts | 83 +++++++++++++++++++++++++++--------- src/lib/string.ts | 14 +++++- 15 files changed, 205 insertions(+), 129 deletions(-) diff --git a/package.json b/package.json index 8d9da58..c9691cd 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,6 @@ } }, "engines": { - "node": ">=20" + "node": ">=20.12" } } diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e45d95d..8267d4e 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -9,7 +9,7 @@ import { glob } from "tinyglobby"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { readJsonFile, writeFileRecursive } from "../lib/file"; import { stringify } from "../lib/json"; -import { reportAction } from "../lib/logger"; +import { log } from "../lib/logger"; import { readPackageJson } from "../lib/packageJson"; import { appendTrailingSlash } from "../lib/url"; import { addRoute, removeRoute, updateRoute } from "../project"; @@ -111,7 +111,7 @@ export abstract class Adapter { const sliceDirectory = new URL(sliceDirectoryName, appendTrailingSlash(library)); const modelPath = new URL("model.json", appendTrailingSlash(sliceDirectory)); await writeFileRecursive(modelPath, stringify(model)); - reportAction({ type: "file-created", url: modelPath }); + log({ type: "file-created", url: modelPath }); await this.createSliceIndexFile(library); await this.onSliceCreated(model, library); } @@ -120,14 +120,14 @@ export abstract class Adapter { const slice = await this.getSlice(model.id); const modelPath = new URL("model.json", appendTrailingSlash(slice.directory)); await writeFileRecursive(modelPath, stringify(model)); - reportAction({ type: "file-updated", url: modelPath }); + log({ type: "file-updated", url: modelPath }); await this.onSliceUpdated(model); } async deleteSlice(id: string): Promise { const slice = await this.getSlice(id); await rm(slice.directory, { recursive: true }); - reportAction({ type: "file-deleted", url: slice.directory }); + log({ type: "file-deleted", url: slice.directory }); await this.createSliceIndexFile(slice.library); await this.onSliceDeleted(id); } @@ -166,7 +166,7 @@ export abstract class Adapter { const customTypesDirectory = new URL("customtypes/", projectRoot); const modelPath = new URL(`${model.id}/index.json`, customTypesDirectory); await writeFileRecursive(modelPath, stringify(model)); - reportAction({ type: "file-created", url: modelPath }); + log({ type: "file-created", url: modelPath }); if (model.format === "page") await addRoute(model); await this.onCustomTypeCreated(model); } @@ -175,7 +175,7 @@ export abstract class Adapter { const customType = await this.getCustomType(model.id); const modelPath = new URL("index.json", appendTrailingSlash(customType.directory)); await writeFileRecursive(modelPath, stringify(model)); - reportAction({ type: "file-updated", url: modelPath }); + log({ type: "file-updated", url: modelPath }); await updateRoute(model); await this.onCustomTypeUpdated(model); } @@ -183,7 +183,7 @@ export abstract class Adapter { async deleteCustomType(id: string): Promise { const customType = await this.getCustomType(id); await rm(customType.directory, { recursive: true }); - reportAction({ type: "file-deleted", url: customType.directory }); + log({ type: "file-deleted", url: customType.directory }); await removeRoute(id); await this.onCustomTypeDeleted(id); } @@ -194,11 +194,11 @@ export abstract class Adapter { host: string; }): Promise { const { repo, token, host } = config; - await Promise.all([ + const [syncSlicesResult, syncCustomTypesResult] = await Promise.all([ this.syncSlices({ repo, token, host, generateTypes: false }), this.syncCustomTypes({ repo, token, host, generateTypes: false }), ]); - await this.generateTypes(); + if (syncSlicesResult.didSync || syncCustomTypesResult.didSync) await this.generateTypes(); } async syncSlices(config: { @@ -206,31 +206,44 @@ export abstract class Adapter { token: string | undefined; host: string; generateTypes?: boolean; - }): Promise { + }): Promise<{ didSync: boolean }> { const { repo, token, host, generateTypes = true } = config; + let didSync = false; + const remoteSlices = await getSlices({ repo, token, host }); const localSlices = await this.getSlices(); // Handle slices update for (const remoteSlice of remoteSlices) { const localSlice = localSlices.find((slice) => slice.model.id === remoteSlice.id); - if (localSlice) await this.updateSlice(remoteSlice); + if (localSlice && JSON.stringify(remoteSlice) !== JSON.stringify(localSlice.model)) { + await this.updateSlice(remoteSlice); + didSync = true; + } } // Handle slices deletion for (const localSlice of localSlices) { const existsRemotely = remoteSlices.some((slice) => slice.id === localSlice.model.id); - if (!existsRemotely) await this.deleteSlice(localSlice.model.id); + if (!existsRemotely) { + await this.deleteSlice(localSlice.model.id); + didSync = true; + } } // Handle slices creation for (const remoteSlice of remoteSlices) { const existsLocally = localSlices.some((slice) => slice.model.id === remoteSlice.id); - if (!existsLocally) await this.createSlice(remoteSlice); + if (!existsLocally) { + await this.createSlice(remoteSlice); + didSync = true; + } } - if (generateTypes) await this.generateTypes(); + if (didSync && generateTypes) await this.generateTypes(); + + return { didSync }; } async syncCustomTypes(config: { @@ -238,9 +251,11 @@ export abstract class Adapter { token: string | undefined; host: string; generateTypes?: boolean; - }): Promise { + }): Promise<{ didSync: boolean }> { const { repo, token, host, generateTypes = true } = config; + let didSync = false; + const remoteCustomTypes = await getCustomTypes({ repo, token, host }); const localCustomTypes = await this.getCustomTypes(); @@ -249,7 +264,13 @@ export abstract class Adapter { const localCustomType = localCustomTypes.find( (customType) => customType.model.id === remoteCustomType.id, ); - if (localCustomType) await this.updateCustomType(remoteCustomType); + if ( + localCustomType && + JSON.stringify(remoteCustomType) !== JSON.stringify(localCustomType.model) + ) { + await this.updateCustomType(remoteCustomType); + didSync = true; + } } // Handle custom types deletion @@ -257,7 +278,10 @@ export abstract class Adapter { const existsRemotely = remoteCustomTypes.some( (customType) => customType.id === localCustomType.model.id, ); - if (!existsRemotely) await this.deleteCustomType(localCustomType.model.id); + if (!existsRemotely) { + await this.deleteCustomType(localCustomType.model.id); + didSync = true; + } } // Handle custom types creation @@ -265,10 +289,15 @@ export abstract class Adapter { const existsLocally = localCustomTypes.some( (customType) => customType.model.id === remoteCustomType.id, ); - if (!existsLocally) await this.createCustomType(remoteCustomType); + if (!existsLocally) { + await this.createCustomType(remoteCustomType); + didSync = true; + } } - if (generateTypes) await this.generateTypes(); + if (didSync && generateTypes) await this.generateTypes(); + + return { didSync }; } async generateTypes(): Promise { @@ -287,7 +316,7 @@ export abstract class Adapter { typesProvider: "@prismicio/client", }); await writeFileRecursive(output, types); - reportAction({ type: "file-updated", url: output }); + log({ type: "file-updated", url: output }); return output; } } diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index 397f5b0..3086551 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; import { Adapter } from "."; import { exists, writeFileRecursive } from "../lib/file"; -import { reportAction } from "../lib/logger"; +import { log } from "../lib/logger"; import { addDependencies, findPackageJson, getNpmPackageVersion } from "../lib/packageJson"; import { dedent } from "../lib/string"; import { appendTrailingSlash } from "../lib/url"; @@ -53,7 +53,7 @@ export class NextJsAdapter extends Adapter { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(componentPath, contents); - reportAction({ type: "file-created", url: componentPath }); + log({ type: "file-created", url: componentPath }); } onSliceUpdated(): void {} @@ -93,7 +93,7 @@ export class NextJsAdapter extends Adapter { const filename = `index.${extension}`; const indexPath = new URL(filename, library); await writeFileRecursive(indexPath, contents); - reportAction({ type: "file-updated", url: indexPath }); + log({ type: "file-updated", url: indexPath }); } async getDefaultSliceLibrary(): Promise { @@ -119,7 +119,7 @@ async function createRevalidateRoute(): Promise { const contents = revalidateRouteTemplate({ supportsCacheLife }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createExitPreviewRoute(): Promise { @@ -136,7 +136,7 @@ async function createExitPreviewRoute(): Promise { const contents = exitPreviewRouteTemplate({ typescript, appRouter }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createPreviewRoute(): Promise { @@ -153,7 +153,7 @@ async function createPreviewRoute(): Promise { const contents = previewRouteTemplate({ typescript, appRouter }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createSliceSimulatorPage(): Promise { @@ -170,7 +170,7 @@ async function createSliceSimulatorPage(): Promise { const contents = sliceSimulatorPageTemplate({ typescript, appRouter }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createPrismicIoFile(): Promise { @@ -189,7 +189,7 @@ async function createPrismicIoFile(): Promise { hasSrcDirectory, }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createPageFile(model: CustomType): Promise { @@ -214,7 +214,7 @@ async function createPageFile(model: CustomType): Promise { appRouter: usesAppRouter, }); await writeFileRecursive(pageFilePath, contents); - reportAction({ type: "file-created", url: pageFilePath }); + log({ type: "file-created", url: pageFilePath }); } async function checkUsesAppRouter() { diff --git a/src/adapters/nuxt.ts b/src/adapters/nuxt.ts index a609e06..34b34f6 100644 --- a/src/adapters/nuxt.ts +++ b/src/adapters/nuxt.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import { Adapter } from "."; import { exists, writeFileRecursive } from "../lib/file"; -import { reportAction } from "../lib/logger"; +import { log } from "../lib/logger"; import { addDependencies, getNpmPackageVersion } from "../lib/packageJson"; import { dedent } from "../lib/string"; import { appendTrailingSlash } from "../lib/url"; @@ -44,7 +44,7 @@ export class NuxtAdapter extends Adapter { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(componentPath, contents); - reportAction({ type: "file-created", url: componentPath }); + log({ type: "file-created", url: componentPath }); } onSliceUpdated(): void {} @@ -80,7 +80,7 @@ export class NuxtAdapter extends Adapter { const filename = `index.${extension}`; const indexPath = new URL(filename, library); await writeFileRecursive(indexPath, contents); - reportAction({ type: "file-updated", url: indexPath }); + log({ type: "file-updated", url: indexPath }); } async getDefaultSliceLibrary(): Promise { @@ -177,7 +177,7 @@ async function createSliceSimulatorPage(): Promise { const contents = sliceSimulatorPageTemplate({ typescript }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function moveOrDeleteAppVue(): Promise { @@ -198,11 +198,11 @@ async function moveOrDeleteAppVue(): Promise { if (!(await exists(indexVuePath))) { await writeFileRecursive(indexVuePath, contents); - reportAction({ type: "file-created", url: indexVuePath }); + log({ type: "file-created", url: indexVuePath }); } await rm(appVuePath); - reportAction({ type: "file-deleted", url: appVuePath }); + log({ type: "file-deleted", url: appVuePath }); } async function modifySliceLibraryPath(adapter: NuxtAdapter): Promise { @@ -252,7 +252,7 @@ async function createPageFile(model: CustomType): Promise { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(pageFilePath, contents); - reportAction({ type: "file-created", url: pageFilePath }); + log({ type: "file-created", url: pageFilePath }); } async function getJsFileExtension(): Promise { diff --git a/src/adapters/sveltekit.ts b/src/adapters/sveltekit.ts index 4630f7d..3e9d6f3 100644 --- a/src/adapters/sveltekit.ts +++ b/src/adapters/sveltekit.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; import { Adapter } from "."; import { exists, writeFileRecursive } from "../lib/file"; -import { reportAction } from "../lib/logger"; +import { log } from "../lib/logger"; import { addDependencies, findPackageJson, getNpmPackageVersion } from "../lib/packageJson"; import { dedent } from "../lib/string"; import { appendTrailingSlash } from "../lib/url"; @@ -56,7 +56,7 @@ export class SvelteKitAdapter extends Adapter { version: await getSvelteMajor(), }); await writeFileRecursive(componentPath, contents); - reportAction({ type: "file-created", url: componentPath }); + log({ type: "file-created", url: componentPath }); } onSliceUpdated(): void {} @@ -96,7 +96,7 @@ export class SvelteKitAdapter extends Adapter { const filename = `index.${extension}`; const indexPath = new URL(filename, library); await writeFileRecursive(indexPath, contents); - reportAction({ type: "file-updated", url: indexPath }); + log({ type: "file-updated", url: indexPath }); } async getDefaultSliceLibrary(): Promise { @@ -114,7 +114,7 @@ async function createPrismicIoFile(): Promise { const typescript = await checkIsTypeScriptProject(); const contents = prismicIOFileTemplate({ typescript }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createSliceSimulatorPage(): Promise { @@ -126,7 +126,7 @@ async function createSliceSimulatorPage(): Promise { version: await getSvelteMajor(), }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createPreviewRouteMatcher(): Promise { @@ -141,7 +141,7 @@ async function createPreviewRouteMatcher(): Promise { } `; await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createPreviewAPIRoute(): Promise { @@ -153,7 +153,7 @@ async function createPreviewAPIRoute(): Promise { const typescript = await checkIsTypeScriptProject(); const contents = previewAPIRouteTemplate({ typescript }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createPreviewRouteDirectory(): Promise { @@ -172,7 +172,7 @@ async function createPreviewRouteDirectory(): Promise { See for more information. `; await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createRootLayoutServerFile(): Promise { @@ -185,7 +185,7 @@ async function createRootLayoutServerFile(): Promise { export const prerender = "auto"; `; await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createRootLayoutFile(): Promise { @@ -197,7 +197,7 @@ async function createRootLayoutFile(): Promise { version: await getSvelteMajor(), }); await writeFileRecursive(filePath, contents); - reportAction({ type: "file-created", url: filePath }); + log({ type: "file-created", url: filePath }); } async function createPageFile(model: CustomType): Promise { @@ -216,7 +216,7 @@ async function createPageFile(model: CustomType): Promise { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(pageFilePath, contents); - reportAction({ type: "file-created", url: pageFilePath }); + log({ type: "file-created", url: pageFilePath }); } const serverFilePath = new URL(`+page.server.${extension}`, fullRoutePath); @@ -226,7 +226,7 @@ async function createPageFile(model: CustomType): Promise { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(serverFilePath, contents); - reportAction({ type: "file-created", url: serverFilePath }); + log({ type: "file-created", url: serverFilePath }); } } @@ -256,7 +256,7 @@ async function modifyViteConfig(): Promise { const contents = mod.generate().code.replace(/\n\s*\n(?=\s*server:)/, "\n"); await writeFile(configUrl, contents); - reportAction({ type: "file-updated", url: configUrl }); + log({ type: "file-updated", url: configUrl }); } async function getJsFileExtension(): Promise { diff --git a/src/commands/gen-setup.ts b/src/commands/gen-setup.ts index 5bcbb6d..9ae4c19 100644 --- a/src/commands/gen-setup.ts +++ b/src/commands/gen-setup.ts @@ -1,6 +1,6 @@ import { getAdapter } from "../adapters"; import { createCommand, type CommandConfig } from "../lib/command"; -import { flushActions, formatAction } from "../lib/logger"; +import { flushLogs, formatChanges } from "../lib/logger"; import { installDependencies } from "../lib/packageJson"; import { findProjectRoot } from "../project"; @@ -37,8 +37,7 @@ export default createCommand(config, async ({ values }) => { } const projectRoot = await findProjectRoot(); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } - console.info("Generated setup files."); + console.info( + formatChanges(flushLogs(), { title: "Generated setup files", root: projectRoot }), + ); }); diff --git a/src/commands/gen-types.ts b/src/commands/gen-types.ts index 541f8b1..c271747 100644 --- a/src/commands/gen-types.ts +++ b/src/commands/gen-types.ts @@ -1,6 +1,6 @@ import { getAdapter } from "../adapters"; import { createCommand, type CommandConfig } from "../lib/command"; -import { flushActions, formatAction } from "../lib/logger"; +import { flushLogs, formatChanges } from "../lib/logger"; import { findProjectRoot } from "../project"; const config = { @@ -13,7 +13,5 @@ export default createCommand(config, async () => { await adapter.generateTypes(); const projectRoot = await findProjectRoot(); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } + console.info(formatChanges(flushLogs(), { title: "Generated types", root: projectRoot })); }); diff --git a/src/commands/init.ts b/src/commands/init.ts index 24f774d..6f96035 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -6,7 +6,7 @@ import { getProfile } from "../clients/user"; import { DEFAULT_PRISMIC_HOST } from "../env"; import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { flushActions, formatAction } from "../lib/logger"; +import { flushLogs, formatChanges } from "../lib/logger"; import { installDependencies } from "../lib/packageJson"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; import { @@ -155,8 +155,10 @@ export default createCommand(config, async ({ values }) => { await adapter.syncModels({ repo, token, host }); const projectRoot = await findProjectRoot(); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } - console.info(`\nInitialized Prismic for repository "${repo}".`); + console.info( + formatChanges(flushLogs(), { + title: `Initialized Prismic for repository "${repo}"`, + root: projectRoot, + }), + ); }); diff --git a/src/commands/slice-create.ts b/src/commands/slice-create.ts index 4910843..287143e 100644 --- a/src/commands/slice-create.ts +++ b/src/commands/slice-create.ts @@ -6,7 +6,7 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { insertSlice } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { flushActions, formatAction, reportAction } from "../lib/logger"; +import { flushLogs, formatChanges } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; import { findProjectRoot, getRepositoryName } from "../project"; @@ -56,14 +56,14 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } - reportAction({ type: "remote-created", id, message: `slice "${name}"` }); - await adapter.createSlice(model); await adapter.generateTypes(); const projectRoot = await findProjectRoot(); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } - console.info(`Created slice "${name}" (id: "${id}")`); + console.info( + formatChanges(flushLogs(), { + title: `Created slice "${name}" (ID: ${id})`, + root: projectRoot, + }), + ); }); diff --git a/src/commands/slice-remove.ts b/src/commands/slice-remove.ts index 09aa698..33c7675 100644 --- a/src/commands/slice-remove.ts +++ b/src/commands/slice-remove.ts @@ -2,7 +2,7 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { getSlice, removeSlice } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { flushActions, formatAction, reportAction } from "../lib/logger"; +import { flushLogs, formatChanges } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; import { findProjectRoot, getRepositoryName } from "../project"; @@ -35,16 +35,16 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } - reportAction({ type: "remote-deleted", id: slice.id, message: `slice "${id}"` }); - try { await adapter.deleteSlice(slice.id); } catch {} await adapter.generateTypes(); const projectRoot = await findProjectRoot(); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } - console.info(`Slice removed: ${id}`); + console.info( + formatChanges(flushLogs(), { + title: `Removed slice "${slice.name}" (ID: ${slice.id})`, + root: projectRoot, + }), + ); }); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index d41faa5..7f07731 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -6,7 +6,7 @@ import { getHost, getToken } from "../auth"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { env } from "../env"; import { createCommand, type CommandConfig } from "../lib/command"; -import { flushActions, formatAction } from "../lib/logger"; +import { flushLogs, formatChanges } from "../lib/logger"; import { segmentTrackEnd, segmentTrackStart } from "../lib/segment"; import { dedent } from "../lib/string"; import { @@ -60,10 +60,7 @@ export default createCommand(config, async ({ values }) => { segmentTrackEnd("sync", { watch }); const projectRoot = await findProjectRoot(); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } - console.info("Sync complete"); + console.info(formatChanges(flushLogs(), { title: "Sync complete", root: projectRoot })); } }); @@ -77,15 +74,12 @@ async function watchForChanges(repo: string, adapter: Adapter) { await adapter.syncModels({ repo, token, host }); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } + console.info(formatChanges(flushLogs(), { title: "Initial sync complete", root: projectRoot })); console.info(dedent` - Initial sync completed! Watching for changes (polling every ${POLL_INTERVAL_MS / 1000}s), - Press Ctrl+C to stop\n + Press Ctrl+C to stop `); let lastRemoteSlicesHash = hash(initialRemoteSlices); @@ -131,10 +125,13 @@ async function watchForChanges(repo: string, adapter: Adapter) { await adapter.generateTypes(); const timestamp = new Date().toLocaleTimeString(); - console.info(`[${timestamp}] Changes detected in ${changed.join(" and ")}`); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } + console.info(); + console.info( + formatChanges(flushLogs(), { + title: `[${timestamp}] Changes detected in ${changed.join(" and ")}`, + root: projectRoot, + }), + ); } // Reset error count on success diff --git a/src/commands/type-create.ts b/src/commands/type-create.ts index 499ce86..b726da4 100644 --- a/src/commands/type-create.ts +++ b/src/commands/type-create.ts @@ -6,7 +6,7 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { insertCustomType } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { flushActions, formatAction, reportAction } from "../lib/logger"; +import { flushLogs, formatChanges } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; import { findProjectRoot, getRepositoryName } from "../project"; @@ -109,14 +109,14 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } - reportAction({ type: "remote-created", id, message: `type "${name}"` }); - await adapter.createCustomType(model); await adapter.generateTypes(); const projectRoot = await findProjectRoot(); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } - console.info(`Created type "${name}" (id: "${id}", format: "${format}")`); + console.info( + formatChanges(flushLogs(), { + title: `Created type "${name}" (ID: ${id})`, + root: projectRoot, + }), + ); }); diff --git a/src/commands/type-remove.ts b/src/commands/type-remove.ts index a8e8ace..a6752ee 100644 --- a/src/commands/type-remove.ts +++ b/src/commands/type-remove.ts @@ -2,7 +2,7 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { getCustomType, removeCustomType } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { flushActions, formatAction, reportAction } from "../lib/logger"; +import { flushLogs, formatChanges } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; import { findProjectRoot, getRepositoryName } from "../project"; @@ -40,16 +40,16 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } - reportAction({ type: "remote-deleted", id: customType.id, message: `type "${id}"` }); - try { await adapter.deleteCustomType(customType.id); } catch {} await adapter.generateTypes(); const projectRoot = await findProjectRoot(); - for (const action of flushActions()) { - console.info(formatAction(action, projectRoot)); - } - console.info(`Type removed: ${id}`); + console.info( + formatChanges(flushLogs(), { + title: `Removed type "${customType.label}" (ID: ${customType.id})`, + root: projectRoot, + }), + ); }); diff --git a/src/lib/logger.ts b/src/lib/logger.ts index c3dec7e..09f3f7f 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,36 +1,77 @@ +import { styleText } from "node:util"; + +import { formatTable } from "./string"; import { relativePathname } from "./url"; -export type Action = +type Log = | { type: "file-created"; url: URL } | { type: "file-updated"; url: URL } - | { type: "file-deleted"; url: URL } - | { type: "remote-created"; id: string; message: string } - | { type: "remote-updated"; id: string; message: string } - | { type: "remote-deleted"; id: string; message: string }; + | { type: "file-deleted"; url: URL }; + +type Verb = "Created" | "Updated" | "Deleted"; -const actions: Action[] = []; +const logs: Log[] = []; -export function reportAction(action: Action): void { - actions.push(action); +export function log(payload: Log): void { + logs.push(payload); } -export function flushActions(): Action[] { - return actions.splice(0); +export function flushLogs(): Log[] { + return logs.splice(0); } -export function formatAction(action: Action, projectRoot: URL): string { - switch (action.type) { +const VERB_ORDER: Verb[] = ["Created", "Updated", "Deleted"]; + +const VERB_COLOR = { Created: "green", Updated: "blue", Deleted: "red" } as const; + +function getVerb(log: Log): Verb { + switch (log.type) { case "file-created": - return `Created ${relativePathname(projectRoot, action.url)}`; + return "Created"; case "file-updated": - return `Updated ${relativePathname(projectRoot, action.url)}`; + return "Updated"; case "file-deleted": - return `Deleted ${relativePathname(projectRoot, action.url)}`; - case "remote-created": - return `Created remote ${action.message}`; - case "remote-updated": - return `Updated remote ${action.message}`; - case "remote-deleted": - return `Deleted remote ${action.message}`; + return "Deleted"; } } + +export function formatChanges(logs: Log[], config: { title: string; root?: URL }): string { + const { title, root } = config; + + const boldTitle = title; + + if (logs.length === 0) return boldTitle; + + const sorted = [...logs].sort( + (a, b) => VERB_ORDER.indexOf(getVerb(a)) - VERB_ORDER.indexOf(getVerb(b)), + ); + + const counts: Record = { Created: 0, Updated: 0, Deleted: 0 }; + for (const entry of logs) { + counts[getVerb(entry)]++; + } + + const parts: string[] = []; + for (const verb of VERB_ORDER) { + const count = counts[verb]; + if (count > 0) { + parts.push(`${count} ${count === 1 ? "file" : "files"} ${verb.toLowerCase()}`); + } + } + + const header = [boldTitle, "", styleText("dim", parts.join(", "))]; + + const rows = sorted.map((entry): [string, string] => { + const verb = getVerb(entry); + const coloredVerb = styleText(VERB_COLOR[verb], verb); + return [coloredVerb, root ? relativePathname(root, entry.url) : entry.url.href]; + }); + + const table = formatTable(rows, { separator: " " }); + const indented = table + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + + return [...header, indented].join("\n"); +} diff --git a/src/lib/string.ts b/src/lib/string.ts index bd8890d..b4cb706 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -2,6 +2,12 @@ import baseDedent from "dedent"; export const dedent = baseDedent.withOptions({ alignValues: true }); +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function visualLength(s: string): number { + return s.replace(ANSI_RE, "").length; +} + export function formatTable( rows: string[][], config?: { headers?: string[]; separator?: string }, @@ -11,13 +17,17 @@ export function formatTable( const columnWidths: number[] = []; for (const row of allRows) { for (let i = 0; i < row.length; i++) { - columnWidths[i] = Math.max(columnWidths[i] ?? 0, row[i].length); + columnWidths[i] = Math.max(columnWidths[i] ?? 0, visualLength(row[i])); } } return allRows .map((row) => { const line = row - .map((cell, i) => (i < row.length - 1 ? cell.padEnd(columnWidths[i]) : cell)) + .map((cell, i) => + i < row.length - 1 + ? cell + " ".repeat(columnWidths[i] - visualLength(cell)) + : cell, + ) .join(separator) .trimEnd(); return line;