Skip to content

Commit 6daa538

Browse files
feat: support Slice Machine projects (#84)
* feat: add Slice Machine backward compatibility and Type Builder gate Support Slice Machine projects by reading legacy auth format (cookies/base) from ~/.prismic and falling back to slicemachine.config.json for repository name resolution. Gate init and sync commands behind a Type Builder feature flag to direct users to Slice Machine when their repository hasn't been upgraded yet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: bypass Type Builder gate in tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback for auth token handling and config fallback Fix stale token after login in init, cookie value truncation with `=` chars, empty host string fallback using `||`, and config error propagation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use env var for Type Builder gate and fix whoami test token handling Use PRISMIC_TYPE_BUILDER_ENABLED env var instead of TEST to control the Type Builder gate, allowing explicit testing of both enabled and disabled states. Fix whoami Slice Machine auth test to use login fixture for token instead of env var. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 08ea7b4 commit 6daa538

13 files changed

Lines changed: 231 additions & 10 deletions

File tree

src/auth.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,44 @@ import { DEFAULT_PRISMIC_HOST, env } from "./env";
99
import { exists } from "./lib/file";
1010
import { stringify } from "./lib/json";
1111
import { appendTrailingSlash } from "./lib/url";
12+
import { checkIsSliceMachineProject } from "./project";
1213

1314
const AUTH_FILE_PATH = new URL(".prismic", appendTrailingSlash(pathToFileURL(homedir())));
1415
const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
1516
const PREFERRED_PORT = 5555;
1617
const LOGIN_SOURCE = "prismic-cli";
1718

18-
const AuthFileSchema = z.object({
19+
const AuthFileSchema = z.looseObject({
1920
token: z.optional(z.string().check(z.minLength(1))),
2021
host: z.optional(z.string().check(z.minLength(1))),
22+
23+
// Backward compatibility with Slice Machine's .prismic
24+
base: z.optional(z.string()),
25+
cookies: z.optional(z.string()),
2126
});
2227
type AuthFile = z.infer<typeof AuthFileSchema>;
2328

2429
export async function getToken(): Promise<string | undefined> {
2530
const auth = await readAuthFile();
31+
const isSliceMachineProject = await checkIsSliceMachineProject();
32+
if (isSliceMachineProject && auth?.cookies) {
33+
for (const cookie of auth.cookies.split("; ")) {
34+
if (cookie.startsWith("prismic-auth=")) return cookie.replace(/^prismic-auth=/, "");
35+
}
36+
}
2637
return auth?.token;
2738
}
2839

2940
export async function getHost(): Promise<string> {
3041
if (env.PRISMIC_HOST) return env.PRISMIC_HOST;
3142
const auth = await readAuthFile();
32-
return auth?.host ?? DEFAULT_PRISMIC_HOST;
43+
const isSliceMachineProject = await checkIsSliceMachineProject();
44+
if (isSliceMachineProject && auth?.base) {
45+
try {
46+
return new URL(auth.base).host || DEFAULT_PRISMIC_HOST;
47+
} catch {}
48+
}
49+
return auth?.host || DEFAULT_PRISMIC_HOST;
3350
}
3451

3552
export async function refreshToken(): Promise<string | undefined> {
@@ -64,7 +81,15 @@ async function readAuthFile(): Promise<AuthFile | undefined> {
6481
}
6582

6683
async function saveAuthFile(auth: AuthFile): Promise<void> {
67-
await writeFile(AUTH_FILE_PATH, stringify(auth));
84+
const existingAuthFile = await readAuthFile();
85+
const newAuthFile: AuthFile = { ...existingAuthFile, ...auth };
86+
const isSliceMachineProject = await checkIsSliceMachineProject();
87+
if (isSliceMachineProject) {
88+
if (newAuthFile.host) newAuthFile.base = `https://${newAuthFile.host}/`;
89+
if (newAuthFile.token)
90+
newAuthFile.cookies = `prismic-auth=${newAuthFile.token}; Path=/; SameSite=none; SESSION=fake_session; Path=/; SameSite=none`;
91+
}
92+
await writeFile(AUTH_FILE_PATH, stringify(newAuthFile));
6893
}
6994

7095
export async function createLoginSession(options?: {

src/clients/repository.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as z from "zod/mini";
2+
3+
import { request } from "../lib/request";
4+
5+
const RepositorySchema = z.object({
6+
quotas: z.optional(
7+
z.object({
8+
sliceMachineEnabled: z.boolean(),
9+
}),
10+
),
11+
});
12+
13+
export type Repository = z.infer<typeof RepositorySchema>;
14+
15+
export async function getRepository(config: {
16+
repo: string;
17+
token: string | undefined;
18+
host: string;
19+
}): Promise<Repository> {
20+
const { repo, token, host } = config;
21+
const url = getRepositoryServiceUrl(host);
22+
url.searchParams.set("repository", repo);
23+
const response = await request(url, {
24+
headers: {
25+
Authorization: `Bearer ${token}`,
26+
repository: repo,
27+
},
28+
schema: RepositorySchema,
29+
});
30+
return response;
31+
}
32+
33+
function getRepositoryServiceUrl(host: string): URL {
34+
return new URL(`https://api.internal.${host}/repository/`);
35+
}

src/commands/init.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { openBrowser } from "../lib/browser";
1717
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
1818
import { installDependencies } from "../lib/packageJson";
1919
import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request";
20+
import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project";
2021
import { syncCustomTypes, syncSlices } from "./sync";
2122

2223
const config = {
@@ -69,7 +70,7 @@ export default createCommand(config, async ({ values }) => {
6970
}
7071

7172
// Validate repo membership
72-
const token = await getToken();
73+
let token = await getToken();
7374
const host = await getHost();
7475
let profile: Profile;
7576
try {
@@ -89,7 +90,7 @@ export default createCommand(config, async ({ values }) => {
8990
},
9091
});
9192
console.info(`Logged in as ${email}`);
92-
const token = await getToken();
93+
token = await getToken();
9394
profile = await getProfile({ token, host });
9495
} else {
9596
throw error;
@@ -103,6 +104,11 @@ export default createCommand(config, async ({ values }) => {
103104
);
104105
}
105106

107+
const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host });
108+
if (!isTypeBuilderEnabled) {
109+
throw new TypeBuilderRequiredError();
110+
}
111+
106112
const adapter = await getAdapter();
107113

108114
// Create prismic.config.json

src/commands/sync.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { env } from "../env";
88
import { createCommand, type CommandConfig } from "../lib/command";
99
import { segmentTrackEnd, segmentTrackStart } from "../lib/segment";
1010
import { dedent } from "../lib/string";
11-
import { getRepositoryName } from "../project";
11+
import { checkIsTypeBuilderEnabled, getRepositoryName, TypeBuilderRequiredError } from "../project";
1212

1313
// 5 seconds balances responsiveness with API load
1414
const POLL_INTERVAL_MS = env.TEST ? 500 : 5000;
@@ -32,6 +32,13 @@ const config = {
3232
export default createCommand(config, async ({ values }) => {
3333
const { repo = await getRepositoryName(), watch } = values;
3434

35+
const token = await getToken();
36+
const host = await getHost();
37+
const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host });
38+
if (!isTypeBuilderEnabled) {
39+
throw new TypeBuilderRequiredError();
40+
}
41+
3542
const adapter = await getAdapter();
3643

3744
console.info(`Syncing from repository: ${repo}`);

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export async function deleteLegacySliceMachineConfig(): Promise<void> {
170170
await rm(configPath);
171171
}
172172

173-
async function findLegacySliceMachineConfigPath(): Promise<URL> {
173+
export async function findLegacySliceMachineConfigPath(): Promise<URL> {
174174
const configPath = await findUpward(LEGACY_SLICE_MACHINE_CONFIG_FILENAME, {
175175
stop: "package.json",
176176
});

src/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const Env = z.object({
1414
PRISMIC_SENTRY_ENVIRONMENT: z.optional(z.string()),
1515
PRISMIC_SENTRY_ENABLED: z.optional(z.stringbool()),
1616
PRISMIC_HOST: z.optional(z.string()),
17+
PRISMIC_TYPE_BUILDER_ENABLED: z.optional(z.stringbool()),
1718
});
1819

1920
export const env = z.parse(Env, {

src/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ import {
3434
sentrySetUser,
3535
setupSentry,
3636
} from "./lib/sentry";
37-
import { safeGetRepositoryName } from "./project";
37+
import { dedent } from "./lib/string";
38+
import { safeGetRepositoryName, TypeBuilderRequiredError } from "./project";
3839

3940
const UNTRACKED_COMMANDS = ["login", "logout", "whoami", "sync"];
4041
const SKIP_REFRESH_COMMANDS = ["login", "logout"];
@@ -181,6 +182,19 @@ async function main(): Promise<void> {
181182
return;
182183
}
183184

185+
if (error instanceof TypeBuilderRequiredError) {
186+
console.error(dedent`
187+
This command requires the Type Builder in your repository.
188+
189+
As of March 2026, the Type Builder is rolling out incrementally as Slice
190+
Machine's replacement. Your repository may not have access yet. Continue using
191+
Slice Machine until your repository can upgrade.
192+
193+
Learn more at https://prismic.io/docs/type-builder
194+
`);
195+
return;
196+
}
197+
184198
await sentryCaptureError(error);
185199
throw error;
186200
}

src/lib/amplitude.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as z from "zod/mini";
2+
3+
import { env } from "../env";
4+
5+
const AMPLITUDE_API_KEY = env.PROD
6+
? "client-q1CoIFNVeFUqxmRSCGXVqO3vK2zQ6bDa"
7+
: "client-Gx378hyvV904fpcQbJnWy7i5p4nBkMZa";
8+
9+
const AMPLITUDE_SERVER_URL = env.PROD
10+
? "https://amplitude.prismic.io/"
11+
: "https://amplitude.wroom.io/";
12+
13+
const FlagResponseSchema = z.record(z.string(), z.object({ value: z.optional(z.string()) }));
14+
15+
export async function evaluateFlag(
16+
flag: string,
17+
args: {
18+
userId: string;
19+
groups?: Record<string, string[]>;
20+
},
21+
): Promise<boolean> {
22+
const user: Record<string, unknown> = { user_id: args.userId };
23+
if (args.groups) {
24+
user.groups = args.groups;
25+
}
26+
27+
const encodedUser = btoa(JSON.stringify(user)).replace(/\+/g, "-").replace(/\//g, "_");
28+
29+
const url = new URL("sdk/v2/vardata?v=0", AMPLITUDE_SERVER_URL);
30+
const response = await fetch(url, {
31+
headers: {
32+
Authorization: `Api-Key ${AMPLITUDE_API_KEY}`,
33+
"X-Amp-Exp-User": encodedUser,
34+
},
35+
});
36+
37+
if (!response.ok) {
38+
throw new Error(`Amplitude flag evaluation failed: ${response.status}`);
39+
}
40+
41+
const data = z.parse(FlagResponseSchema, await response.json());
42+
43+
return data[flag]?.value === "on";
44+
}

src/project.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import { getRepository } from "./clients/repository";
2+
import { getProfile } from "./clients/user";
13
import {
24
findConfigPath,
5+
findLegacySliceMachineConfigPath,
36
findSuggestedConfigPath,
47
MissingPrismicConfig,
58
readConfig,
9+
readLegacySliceMachineConfig,
610
} from "./config";
11+
import { env } from "./env";
12+
import { evaluateFlag } from "./lib/amplitude";
713
import { exists } from "./lib/file";
814
import { appendTrailingSlash } from "./lib/url";
915

@@ -31,8 +37,18 @@ export async function safeGetRepositoryName(): Promise<string | undefined> {
3137
}
3238

3339
export async function getRepositoryName(): Promise<string> {
34-
const config = await readConfig();
35-
return config.repositoryName;
40+
try {
41+
const config = await readConfig();
42+
return config.repositoryName;
43+
} catch (error) {
44+
if (error instanceof MissingPrismicConfig) {
45+
try {
46+
const legacySliceMachineConfig = await readLegacySliceMachineConfig();
47+
return legacySliceMachineConfig.repositoryName;
48+
} catch {}
49+
}
50+
throw error;
51+
}
3652
}
3753

3854
export async function getLibraries(): Promise<URL[] | undefined> {
@@ -52,3 +68,34 @@ export async function checkIsTypeScriptProject(): Promise<boolean> {
5268
const isTypeScriptProject = await exists(tsconfigPath);
5369
return isTypeScriptProject;
5470
}
71+
72+
export async function checkIsSliceMachineProject(): Promise<boolean> {
73+
try {
74+
await findLegacySliceMachineConfigPath();
75+
return true;
76+
} catch {
77+
return false;
78+
}
79+
}
80+
81+
export async function checkIsTypeBuilderEnabled(
82+
repo: string,
83+
config: { token: string | undefined; host: string },
84+
): Promise<boolean> {
85+
if (env.PRISMIC_TYPE_BUILDER_ENABLED !== undefined) return env.PRISMIC_TYPE_BUILDER_ENABLED;
86+
87+
const { token, host } = config;
88+
const profile = await getProfile({ token, host });
89+
const [flagEnabled, repository] = await Promise.all([
90+
evaluateFlag("dev-tools-types-builder-cloud", {
91+
userId: profile.shortId,
92+
groups: { Repository: [repo] },
93+
}),
94+
getRepository({ repo, token, host }),
95+
]);
96+
return flagEnabled && repository.quotas?.sliceMachineEnabled === true;
97+
}
98+
99+
export class TypeBuilderRequiredError extends Error {
100+
name = "TypeBuilderRequired";
101+
}

test/init.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ it("migrates slicemachine.config.json", async ({ expect, project, prismic, repo
9393
await expect(access(new URL("slicemachine.config.json", project))).rejects.toThrow();
9494
});
9595

96+
it("fails when Type Builder is not enabled", async ({ expect, project, prismic, repo }) => {
97+
await rm(new URL("prismic.config.json", project));
98+
const { exitCode, stderr } = await prismic("init", ["--repo", repo], {
99+
nodeOptions: { env: { PRISMIC_TYPE_BUILDER_ENABLED: "false" } },
100+
});
101+
expect(exitCode).toBe(1);
102+
expect(stderr).toContain("Type Builder");
103+
});
104+
96105
it("installs dependencies", async ({ expect, project, prismic, repo }) => {
97106
await rm(new URL("prismic.config.json", project));
98107

0 commit comments

Comments
 (0)