diff --git a/src/adapters/index.ts b/src/adapters/index.ts index ea5a02b..dd7b8fc 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -16,8 +16,8 @@ import { findProjectRoot, getLibraries } from "../project"; const TYPES_FILENAME = "prismicio-types.d.ts"; -type CustomTypeMeta = { model: CustomType; directory: URL }; -type SharedSliceMeta = { model: SharedSlice; directory: URL; library: URL }; +export type CustomTypeMeta = { model: CustomType; file: URL; directory: URL }; +export type SharedSliceMeta = { model: SharedSlice; file: URL; directory: URL; library: URL }; export async function getAdapter(): Promise { const { dependencies, devDependencies, peerDependencies } = await readPackageJson(); @@ -86,7 +86,7 @@ export abstract class Adapter { sliceModelPaths.map(async (sliceModelPath) => { const directory = new URL(".", sliceModelPath); const model = await readJsonFile(sliceModelPath); - return { library, directory, model }; + return { library, directory, file: sliceModelPath, model }; }), ); allSlices.push(...slices); @@ -141,7 +141,7 @@ export abstract class Adapter { customTypeModelPaths.map(async (customTypeModelPath) => { const directory = new URL(".", customTypeModelPath); const model = await readJsonFile(customTypeModelPath); - return { directory, model }; + return { directory, file: customTypeModelPath, model }; }), ); @@ -181,26 +181,28 @@ export abstract class Adapter { await this.onCustomTypeDeleted(id); } - async syncModels(config: { + async pullModels(config: { repo: string; token: string | undefined; host: string; + force?: boolean; }): Promise { - const { repo, token, host } = config; + const { repo, token, host, force } = config; await Promise.all([ - this.syncSlices({ repo, token, host, generateTypes: false }), - this.syncCustomTypes({ repo, token, host, generateTypes: false }), + this.pullSlices({ repo, token, host, generateTypes: false, force }), + this.pullCustomTypes({ repo, token, host, generateTypes: false, force }), ]); await this.generateTypes(); } - async syncSlices(config: { + async pullSlices(config: { repo: string; token: string | undefined; host: string; generateTypes?: boolean; + force?: boolean; }): Promise { - const { repo, token, host, generateTypes = true } = config; + const { repo, token, host, generateTypes = true, force = false } = config; const remoteSlices = await getSlices({ repo, token, host }); const localSlices = await this.getSlices(); @@ -226,13 +228,14 @@ export abstract class Adapter { if (generateTypes) await this.generateTypes(); } - async syncCustomTypes(config: { + async pullCustomTypes(config: { repo: string; token: string | undefined; host: string; generateTypes?: boolean; + force?: boolean; }): Promise { - const { repo, token, host, generateTypes = true } = config; + const { repo, token, host, generateTypes = true, force = false } = config; const remoteCustomTypes = await getCustomTypes({ repo, token, host }); const localCustomTypes = await this.getCustomTypes(); diff --git a/src/commands/init.ts b/src/commands/init.ts index 2057ed6..cadf93d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -150,7 +150,7 @@ export default createCommand(config, async ({ values }) => { } // Sync models from remote and generate types - await adapter.syncModels({ repo, token, host }); + await adapter.pullModels({ repo, token, host }); console.info(`\nInitialized Prismic for repository "${repo}".`); console.info("Run `prismic type create ` to create a content type."); diff --git a/src/commands/pull.ts b/src/commands/pull.ts new file mode 100644 index 0000000..0baea40 --- /dev/null +++ b/src/commands/pull.ts @@ -0,0 +1,146 @@ +import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +import { type CustomTypeMeta, getAdapter, type SharedSliceMeta, type Adapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getCustomTypes, getSlices } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { dedent, formatTable } from "../lib/string"; +import { relativePathname } from "../lib/url"; +import { findProjectRoot, getRepositoryName } from "../project"; + +const config = { + name: "prismic pull", + description: ` + Pull content types and slices from Prismic to local files. + + Remote models are the source of truth. Local files are created, updated, + or deleted to match. + `, + options: { + force: { type: "boolean", description: "Force pull remote changes" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { force = false, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const adapter = await getAdapter(); + + console.info(`Pulling from repository: ${repo}`); + + const [sliceOperations, customTypeOperations] = await Promise.all([ + getSliceOperations({ adapter, repo, token, host }), + getCustomTypeOperations({ adapter, repo, token, host }), + ]); + + if ( + (!force && sliceOperations.update.length > 0) || + sliceOperations.delete.length > 0 || + customTypeOperations.update.length > 0 || + customTypeOperations.delete.length > 0 + ) { + const projectRoot = await findProjectRoot(); + const destructiveOperationRows: string[][] = [ + ...sliceOperations.update.map((operation) => [ + "Overwrite slice", + operation.model.id, + relativePathname(projectRoot, operation.file), + ]), + ...sliceOperations.delete.map((operation) => [ + "Delete slice", + operation.model.id, + relativePathname(projectRoot, operation.file), + ]), + ]; + + throw new CommandError(dedent` + The following destructive changes will happen: + + ${formatTable(destructiveOperationRows, { headers: ["OPERATION", "ID", "FILE"] })} + + Re-run the command with \`--force\` to confirm. + `); + } + + console.info("Pull complete"); +}); + +type Operations = { + insert: TInsert[]; + update: TUpdate[]; + delete: TDelete[]; +}; + +async function getSliceOperations(config: { + adapter: Adapter; + repo: string; + token: string | undefined; + host: string; +}): Promise> { + const { adapter, repo, token, host } = config; + + const operations: Operations = { + insert: [], + update: [], + delete: [], + }; + + const remoteSlices = await getSlices({ repo, token, host }); + const localSlices = await adapter.getSlices(); + for (const remoteSlice of remoteSlices) { + const localSlice = localSlices.find((slice) => slice.model.id === remoteSlice.id); + if (localSlice) { + const isSame = localSlice && JSON.stringify(remoteSlice) === JSON.stringify(localSlice); + if (!isSame) operations.update.push(localSlice); + } else { + operations.insert.push(remoteSlice); + } + } + for (const localSlice of localSlices) { + const remoteSlice = remoteSlices.find((slice) => slice.id === localSlice.model.id); + if (!remoteSlice) operations.delete.push(localSlice); + } + + return operations; +} + +async function getCustomTypeOperations(config: { + adapter: Adapter; + repo: string; + token: string | undefined; + host: string; +}): Promise> { + const { adapter, repo, token, host } = config; + + const operations: Operations = { + insert: [], + update: [], + delete: [], + }; + + const remoteCustomTypes = await getCustomTypes({ repo, token, host }); + const localCustomTypes = await adapter.getCustomTypes(); + for (const remoteCustomType of remoteCustomTypes) { + const localCustomType = localCustomTypes.find( + (customType) => customType.model.id === remoteCustomType.id, + ); + if (localCustomType) { + const isSame = + localCustomType && JSON.stringify(remoteCustomType) === JSON.stringify(localCustomType); + if (!isSame) operations.update.push(localCustomType); + } else { + operations.insert.push(remoteCustomType); + } + } + for (const localCustomType of localCustomTypes) { + const remoteCustomType = remoteCustomTypes.find( + (customType) => customType.id === localCustomType.model.id, + ); + if (!remoteCustomType) operations.delete.push(localCustomType); + } + + return operations; +} diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 74266ec..473e7c7 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -50,7 +50,7 @@ export default createCommand(config, async ({ values }) => { } else { const token = await getToken(); const host = await getHost(); - await adapter.syncModels({ repo, token, host }); + await adapter.pullModels({ repo, token, host }); segmentTrackEnd("sync", { watch }); console.info("Sync complete"); @@ -64,7 +64,7 @@ async function watchForChanges(repo: string, adapter: Adapter) { const initialRemoteSlices = await getSlices({ repo, token, host }); const initialRemoteCustomTypes = await getCustomTypes({ repo, token, host }); - await adapter.syncModels({ repo, token, host }); + await adapter.pullModels({ repo, token, host }); console.info(dedent` Initial sync completed! @@ -103,12 +103,12 @@ async function watchForChanges(repo: string, adapter: Adapter) { const changed = []; if (slicesChanged) { - await adapter.syncSlices({ repo, token, host, generateTypes: false }); + await adapter.pullSlices({ repo, token, host, generateTypes: false }); lastRemoteSlicesHash = remoteSlicesHash; changed.push("slices"); } if (customTypesChanged) { - await adapter.syncCustomTypes({ repo, token, host, generateTypes: false }); + await adapter.pullCustomTypes({ repo, token, host, generateTypes: false }); lastRemoteCustomTypesHash = remoteCustomTypesHash; changed.push("custom types"); } diff --git a/src/index.ts b/src/index.ts index 563aa53..d0a1563 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import locale from "./commands/locale"; import login from "./commands/login"; import logout from "./commands/logout"; import preview from "./commands/preview"; +import pull from "./commands/pull"; import repo from "./commands/repo"; import slice from "./commands/slice"; import sync from "./commands/sync"; @@ -23,6 +24,7 @@ import webhook from "./commands/webhook"; import whoami from "./commands/whoami"; import { UPDATE_NOTIFIER_STATE_PATH } from "./config"; import { CommandError, createCommandRouter } from "./lib/command"; +import { decodePayload } from "./lib/jwt"; import { ForbiddenRequestError, NotFoundRequestError, @@ -42,7 +44,6 @@ import { sentrySetUser, setupSentry, } from "./lib/sentry"; -import { decodePayload } from "./lib/jwt"; import { dedent } from "./lib/string"; import { initUpdateNotifier } from "./lib/update-notifier"; import { InvalidPrismicConfigError, MissingPrismicConfigError } from "./project"; @@ -100,6 +101,10 @@ const router = createCommandRouter({ handler: preview, description: "Manage preview configurations", }, + pull: { + handler: pull, + description: "Pull types and slices from Prismic", + }, token: { handler: token, description: "Manage API tokens",