Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d3d8428
feat: add instant-start CLI command
jomifepe Jul 20, 2026
9a217be
refactor: simplify instant-start implementation
jomifepe Jul 20, 2026
2863720
Merge remote-tracking branch 'origin/main' into jp/instant-start
cursoragent Jul 21, 2026
70949f0
chore: improve Instant Start completion message
jomifepe Jul 21, 2026
df32846
refactor: limit instant-start to exports
jomifepe Jul 21, 2026
60352fa
feat: route instant start through init
jomifepe Jul 22, 2026
fac31ec
chore: refine instant init description
jomifepe Jul 22, 2026
9eb8dbf
feat: clean up instant start previews
jomifepe Jul 23, 2026
842a8a0
Merge remote-tracking branch 'origin/main' into jp/instant-start
jomifepe Jul 23, 2026
43b72a6
refactor(instant-start): download public starter
jomifepe Jul 23, 2026
a50e1df
refactor(cli): replace init instant with starter download
jomifepe Jul 24, 2026
3dd0d26
fix(starter-download): skip duplicate Development preview
jomifepe Jul 24, 2026
3378f5d
Merge main into jp/instant-start-github-starter
jomifepe Jul 28, 2026
ae38848
feat(starter): download archive from repository starter metadata
jomifepe Jul 30, 2026
31e452e
refactor(starter): trust repository starter provenance for archive URL
jomifepe Jul 30, 2026
88032e1
feat(starter): complete local handoff after download
jomifepe Jul 30, 2026
cf16d38
fix(starter): derive hosted preview from provenance
jomifepe Jul 31, 2026
2423c08
feat(init): replace starter download with degit handoff
jomifepe Jul 31, 2026
01dd3e2
Merge remote-tracking branch 'origin/main' into jp/instant-start-gith…
jomifepe Jul 31, 2026
6fd2b04
fix(core): restore preview URL helpers for starter handoff
jomifepe Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 105 additions & 40 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { rm } from "node:fs/promises";

import type { Profile } from "../lib/prismic/clients/user";

import { getAdapter } from "../adapters";
Expand All @@ -8,16 +10,26 @@ import { CommandError, createCommand, type CommandConfig } from "../lib/command"
import { diffArrays } from "../lib/diff";
import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson";
import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types";
import { getRepository, type Repository } from "../lib/prismic/clients/repository";
import { getProfile } from "../lib/prismic/clients/user";
import { canonicalizeCustomType, canonicalizeSlice } from "../lib/prismic/models";
import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request";
import {
assertStarterRepositoryHasModels,
completeStarterHandoff,
hasUnsafeStarterModelChanges,
} from "../lib/starter";
import {
type Config,
createConfig,
deleteLegacySliceMachineConfig,
findProjectRoot,
InvalidLegacySliceMachineConfigError,
MissingPrismicConfigError,
readConfig,
readLegacySliceMachineConfig,
UnknownProjectRootError,
updateConfig,
} from "../project";
import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project";
import { createRepo } from "./repo-create";
Expand Down Expand Up @@ -54,27 +66,28 @@ const config = {
export default createCommand(config, async ({ values }) => {
const { repo: explicitRepo, lang, "no-browser": noBrowser, "no-setup": noSetup } = values;

// Check for existing prismic.config.json
let existingConfig: Config | undefined;
try {
await readConfig();
existingConfig = await readConfig();
} catch (error) {
if (!(error instanceof MissingPrismicConfigError)) throw error;
}
if (existingConfig && !explicitRepo) {
throw new CommandError(
"A prismic.config.json file exists. This project is already initialized.",
"A prismic.config.json file exists. Use `prismic init --repo <repository>` to connect it to an existing repository.",
);
} catch (error) {
if (error instanceof MissingPrismicConfigError) {
// No config found — proceed with initialization.
} else {
throw error;
}
}
const isExistingProjectHandoff = existingConfig !== undefined && explicitRepo !== undefined;

// Load legacy slicemachine.config.json
let legacySliceMachineConfig;
try {
legacySliceMachineConfig = await readLegacySliceMachineConfig();
} catch (error) {
if (error instanceof InvalidLegacySliceMachineConfigError) {
console.warn("Could not read slicemachine.config.json, ignoring.");
if (!existingConfig) {
try {
legacySliceMachineConfig = await readLegacySliceMachineConfig();
} catch (error) {
if (error instanceof InvalidLegacySliceMachineConfigError) {
console.warn("Could not read slicemachine.config.json, ignoring.");
}
}
}

Expand Down Expand Up @@ -112,6 +125,7 @@ export default createCommand(config, async ({ values }) => {
}

let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase();
let connectedRepository: Repository | undefined;
if (repo) {
const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo);
if (!hasRepoAccess) {
Expand All @@ -124,6 +138,8 @@ export default createCommand(config, async ({ values }) => {
if (!isTypeBuilderEnabled) {
throw new TypeBuilderRequiredError(repo, host);
}

connectedRepository = await getRepository({ repo, token, host });
}

const adapter = await getAdapter();
Expand All @@ -133,16 +149,20 @@ export default createCommand(config, async ({ values }) => {
console.info(`Created repository: ${repo}`);
}

// Create prismic.config.json
// Create or reconnect prismic.config.json
try {
const documentAPIEndpoint =
host !== DEFAULT_PRISMIC_HOST ? `https://${repo}.cdn.${host}/api/v2/` : undefined;
await createConfig({
repositoryName: repo,
documentAPIEndpoint,
libraries: legacySliceMachineConfig?.libraries,
routes: [],
});
if (existingConfig) {
await updateConfig({ repositoryName: repo, documentAPIEndpoint });
} else {
await createConfig({
repositoryName: repo,
documentAPIEndpoint,
libraries: legacySliceMachineConfig?.libraries,
routes: [],
});
}
} catch (error) {
if (error instanceof UnknownProjectRootError) {
throw new CommandError(
Expand Down Expand Up @@ -171,7 +191,7 @@ export default createCommand(config, async ({ values }) => {
}

// Install dependencies and create framework files
await adapter.initProject({ setup: !noSetup });
await adapter.initProject({ setup: !noSetup && !existingConfig });

// Run package manager install
if (!noSetup) {
Expand All @@ -195,33 +215,78 @@ export default createCommand(config, async ({ values }) => {
const localCustomTypeModels = localCustomTypes.map((c) => c.model);
const localSliceModels = localSlices.map((s) => s.model);

const sliceOps = diffArrays(remoteSlices, localSliceModels, { getKey: (m) => m.id });
for (const slice of sliceOps.update) {
await adapter.updateSlice(slice);
}
for (const slice of sliceOps.delete) {
await adapter.deleteSlice(slice.id);
}
for (const slice of sliceOps.insert) {
await adapter.createSlice(slice);
}
const sliceOps = diffArrays(remoteSlices, localSliceModels, {
getKey: (model) => model.id,
equals: (a, b) => JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)),
});

const customTypeOps = diffArrays(remoteCustomTypes, localCustomTypeModels, {
getKey: (m) => m.id,
getKey: (model) => model.id,
equals: (a, b) =>
JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)),
});
for (const customType of customTypeOps.update) {
await adapter.updateCustomType(customType);
}
for (const customType of customTypeOps.delete) {
await adapter.deleteCustomType(customType.id);

if (isExistingProjectHandoff && connectedRepository?.starter) {
assertStarterRepositoryHasModels(repo, remoteCustomTypes, remoteSlices);
await removeStarterDocuments(connectedRepository.starter);
}
for (const customType of customTypeOps.insert) {
await adapter.createCustomType(customType);

const hasUnsafeModelChanges =
isExistingProjectHandoff && hasUnsafeStarterModelChanges(customTypeOps, sliceOps);

if (!hasUnsafeModelChanges) {
for (const slice of sliceOps.update) {
await adapter.updateSlice(slice);
}
for (const slice of sliceOps.delete) {
await adapter.deleteSlice(slice.id);
}
for (const slice of sliceOps.insert) {
await adapter.createSlice(slice);
}

for (const customType of customTypeOps.update) {
await adapter.updateCustomType(customType);
}
for (const customType of customTypeOps.delete) {
await adapter.deleteCustomType(customType.id);
}
for (const customType of customTypeOps.insert) {
await adapter.createCustomType(customType);
}
}

await adapter.generateTypes();

if (hasUnsafeModelChanges) {
console.warn(`
Local and remote models differ, so no model files were changed. The project is connected.

Choose the source of truth:
prismic pull --force Adopt remote models
prismic push --force Keep local models
`);
}

if (isExistingProjectHandoff && connectedRepository?.starter) {
await completeStarterHandoff(connectedRepository.starter, { repo, token, host });
}

console.info(`\nInitialized Prismic for repository "${repo}".`);
console.info("Run `prismic type create <name>` to create a content type.");
console.info("Run `prismic pull` to pull models from Prismic.");
});

async function removeStarterDocuments(starter: NonNullable<Repository["starter"]>): Promise<void> {
const packageJson = await readPackageJson();
const starterPackageName = starter.id.split("/").at(-1);
if (!starterPackageName || packageJson.name !== starterPackageName) {

@angeloashmore angeloashmore Jul 31, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 #idea: The name-vs-starter-id check for deleting documents/ is good. As a follow-up, once the handoff completes we could rewrite name to the repo name so the project gets its own identity instead of the starter's.

console.warn(
"Starter seed documents were not removed because the local package does not match the repository starter.",
);
return;
}

const projectRoot = await findProjectRoot();
await rm(new URL("documents/", projectRoot), { recursive: true, force: true });
}
1 change: 1 addition & 0 deletions src/lib/packageJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { exists, findUpward, readJsonFile } from "./file";
import { request } from "./request";

const PackageJsonSchema = z.object({
name: z.optional(z.string()),
dependencies: z.optional(z.record(z.string(), z.string())),
devDependencies: z.optional(z.record(z.string(), z.string())),
peerDependencies: z.optional(z.record(z.string(), z.string())),
Expand Down
18 changes: 18 additions & 0 deletions src/lib/prismic/clients/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ export async function removePreview(id: string, config: CoreConfig): Promise<voi
});
}

export async function hasPreviewByURL(url: string, config: CoreConfig): Promise<boolean> {
const previews = await getPreviews(config);
return previews.some((preview) => preview.url === url);
}

export async function removePreviewsByURL(
urls: Iterable<string>,
config: CoreConfig,
): Promise<void> {
const previews = await getPreviews(config);
const urlSet = new Set(urls);
await Promise.all(
previews
.filter((preview) => urlSet.has(preview.url))
.map((preview) => removePreview(preview.id, config)),
);
}

const EnvironmentSchema = z.object({
kind: z.enum(["prod", "stage", "dev"]),
name: z.string(),
Expand Down
29 changes: 22 additions & 7 deletions src/lib/prismic/clients/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,28 @@ type RepositoryConfig = {
host: string;
};

const RepositorySchema = z.object({
quotas: z.optional(
z.object({
sliceMachineEnabled: z.boolean(),
}),
),
const RepositoryStarterSchema = z.object({
id: z.string(),
revision: z.string(),
framework: z.string(),
deploymentUrl: z.url(),
});

const RepositorySchema = z.pipe(
z.object({
starter: z.optional(z.nullable(RepositoryStarterSchema)),
quotas: z.optional(
z.object({
sliceMachineEnabled: z.boolean(),
}),
),
}),
z.transform((repository) => ({
...repository,
starter: repository.starter ?? null,
})),
);

export type Repository = z.infer<typeof RepositorySchema>;

export function getRepository(config: RepositoryConfig): Promise<Repository> {
Expand All @@ -27,7 +41,8 @@ export type OnboardingStep =
| "createPrismicProject"
| "createPageType"
| "createSlice"
| "connectPrismic";
| "connectPrismic"
| "instantStart_continueBuildingLocally";

const OnboardingStateSchema = z.object({
completedSteps: z.array(z.string()),
Expand Down
Loading
Loading