From 037e32ed01561353cb552b2c2a2f8882926eb507 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 3 Jul 2026 01:41:48 +0000 Subject: [PATCH 1/5] feat: add `prismic env` commands and CLI-owned active environment Add `prismic env set/unset/active/list` to pick a project's active environment. The choice lives in CLI state keyed by project, so nothing lands in the repo. `getRepositoryName` resolves it, so every command honors it. Sites read it at build time via the `prismic/env` and `prismic/env/register` exports; `prismic init` wires the register import into the framework config and generated `prismicio` file for Next.js and SvelteKit. Co-Authored-By: Claude Opus 4.8 --- package.json | 4 ++ src/active-environment.ts | 99 +++++++++++++++++++++++++++++ src/adapters/index.ts | 19 +++++- src/adapters/nextjs.templates.ts | 4 +- src/adapters/nextjs.ts | 4 +- src/adapters/sveltekit.templates.ts | 4 +- src/adapters/sveltekit.ts | 4 +- src/commands/env-active.ts | 15 +++++ src/commands/env-list.ts | 50 +++++++++++++++ src/commands/env-set.ts | 38 +++++++++++ src/commands/env-unset.ts | 18 ++++++ src/commands/env.ts | 28 ++++++++ src/environments.ts | 21 ++++-- src/exports/env.ts | 1 + src/exports/register.ts | 11 ++++ src/index.ts | 5 ++ src/project.ts | 4 ++ test/env-active.test.ts | 13 ++++ test/env-list.test.ts | 23 +++++++ test/env-set.test.ts | 23 +++++++ test/env-unset.test.ts | 13 ++++ test/env.test.ts | 13 ++++ tsdown.config.ts | 2 + 23 files changed, 402 insertions(+), 14 deletions(-) create mode 100644 src/active-environment.ts create mode 100644 src/commands/env-active.ts create mode 100644 src/commands/env-list.ts create mode 100644 src/commands/env-set.ts create mode 100644 src/commands/env-unset.ts create mode 100644 src/commands/env.ts create mode 100644 src/exports/env.ts create mode 100644 src/exports/register.ts create mode 100644 test/env-active.test.ts create mode 100644 test/env-list.test.ts create mode 100644 test/env-set.test.ts create mode 100644 test/env-unset.test.ts create mode 100644 test/env.test.ts diff --git a/package.json b/package.json index 12a62877..50dc0bcd 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "./dist" ], "type": "module", + "exports": { + "./env": "./dist/env.mjs", + "./env/register": "./dist/env/register.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/src/active-environment.ts b/src/active-environment.ts new file mode 100644 index 00000000..24b47101 --- /dev/null +++ b/src/active-environment.ts @@ -0,0 +1,99 @@ +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { getConfigDir } from "./lib/config-dir"; +import { stringify } from "./lib/json"; + +// The active environment lives in the CLI, keyed by project so nothing lands in +// the repo. Reads are synchronous so a framework config can set the environment +// variable before the framework reads it at build time. This module is published +// as `prismic/env`, so it stays dependency-light. + +const CONFIG_FILENAME = "prismic.config.json"; + +const ENVIRONMENTS_PATH = new URL( + "environments.json", + getConfigDir("prismic", process.env.PRISMIC_CONFIG_DIR), +); + +const FRAMEWORK_ENV_VARS: Record = { + next: "NEXT_PUBLIC_PRISMIC_ENVIRONMENT", + nuxt: "NUXT_PUBLIC_PRISMIC_ENVIRONMENT", + "@sveltejs/kit": "VITE_PRISMIC_ENVIRONMENT", +}; + +export function getActiveEnvironment(): string | undefined { + const project = findProjectRoot(); + if (!project) return undefined; + return readState()[project]; +} + +export function setActiveEnvironment(domain: string): void { + const project = findProjectRoot(); + if (!project) return; + const state = readState(); + state[project] = domain; + writeState(state); +} + +export function unsetActiveEnvironment(): void { + const project = findProjectRoot(); + if (!project) return; + const state = readState(); + if (!(project in state)) return; + delete state[project]; + writeState(state); +} + +export function getFrameworkEnvVar(): string | undefined { + const packageJson = readPackageJson(); + if (!packageJson) return undefined; + const dependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.peerDependencies, + }; + for (const dependency in FRAMEWORK_ENV_VARS) { + if (dependency in dependencies) return FRAMEWORK_ENV_VARS[dependency]; + } + return undefined; +} + +function readState(): Record { + try { + return JSON.parse(readFileSync(ENVIRONMENTS_PATH, "utf8")); + } catch { + return {}; + } +} + +function writeState(state: Record): void { + mkdirSync(new URL(".", ENVIRONMENTS_PATH), { recursive: true }); + writeFileSync(ENVIRONMENTS_PATH, stringify(state)); +} + +function findProjectRoot(): string | undefined { + return findUpward(CONFIG_FILENAME, (dir) => realpathSync(dir)); +} + +type PackageJson = { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +}; + +function readPackageJson(): PackageJson | undefined { + return findUpward("package.json", (dir) => + JSON.parse(readFileSync(join(dir, "package.json"), "utf8")), + ); +} + +function findUpward(filename: string, onFound: (dir: string) => T): T | undefined { + let dir = process.cwd(); + while (true) { + if (existsSync(join(dir, filename))) return onFound(dir); + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 4e3fc7df..f6fba543 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,12 +1,12 @@ import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes"; import { pascalCase } from "change-case"; -import { rm } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { generateTypes } from "prismic-ts-codegen"; import { glob } from "tinyglobby"; -import { readJsonFile, writeFileRecursive } from "../lib/file"; +import { exists, readJsonFile, writeFileRecursive } from "../lib/file"; import { stringify } from "../lib/json"; import { readPackageJson } from "../lib/packageJson"; import { appendTrailingSlash } from "../lib/url"; @@ -42,6 +42,21 @@ export class NoSupportedFrameworkError extends Error { "No supported framework found. Run this command in a Next.js, Nuxt, or SvelteKit project."; } +// Adds `import "prismic/env/register"` to the first existing framework config so +// the active environment is applied at build time. +export async function addRegisterImport(candidates: string[]): Promise { + const projectRoot = await findProjectRoot(); + for (const candidate of candidates) { + const configUrl = new URL(candidate, projectRoot); + if (!(await exists(configUrl))) continue; + const contents = await readFile(configUrl, "utf8"); + if (!contents.includes("prismic/env/register")) { + await writeFile(configUrl, `import "prismic/env/register";\n${contents}`); + } + return; + } +} + export abstract class Adapter { abstract readonly id: string; diff --git a/src/adapters/nextjs.templates.ts b/src/adapters/nextjs.templates.ts index fce35bca..09c8d8a3 100644 --- a/src/adapters/nextjs.templates.ts +++ b/src/adapters/nextjs.templates.ts @@ -357,7 +357,7 @@ export function prismicIOFileTemplate(args: { /** * The project's Prismic repository name. */ - export const repositoryName = prismicConfig.repositoryName; + export const repositoryName = process.env.NEXT_PUBLIC_PRISMIC_ENVIRONMENT || prismicConfig.repositoryName; ${createClientContents} `; @@ -369,7 +369,7 @@ export function prismicIOFileTemplate(args: { /** * The project's Prismic repository name. */ - export const repositoryName = prismicConfig.repositoryName; + export const repositoryName = process.env.NEXT_PUBLIC_PRISMIC_ENVIRONMENT || prismicConfig.repositoryName; ${createClientContents} `; diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index c21d83b5..22aa230e 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -5,7 +5,7 @@ import { createRequire } from "node:module"; import { relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { Adapter } from "."; +import { Adapter, addRegisterImport } from "."; import { getHost, getToken } from "../auth"; import { addPreview, getPreviews, getSimulatorUrl, setSimulatorUrl } from "../clients/core"; import { exists, writeFileRecursive } from "../lib/file"; @@ -29,6 +29,7 @@ export class NextJsAdapter extends Adapter { async setupProject(): Promise { await addDependencies({ + prismic: `^${await getNpmPackageVersion("prismic")}`, "@prismicio/client": `^${await getNpmPackageVersion("@prismicio/client")}`, "@prismicio/react": `^${await getNpmPackageVersion("@prismicio/react")}`, "@prismicio/next": `^${await getNpmPackageVersion("@prismicio/next")}`, @@ -38,6 +39,7 @@ export class NextJsAdapter extends Adapter { await createPreviewRoute(); await createExitPreviewRoute(); await createRevalidateRoute(); + await addRegisterImport(["next.config.ts", "next.config.mjs", "next.config.js"]); } async onProjectInitialized(): Promise { diff --git a/src/adapters/sveltekit.templates.ts b/src/adapters/sveltekit.templates.ts index 9a07509a..364ca0e8 100644 --- a/src/adapters/sveltekit.templates.ts +++ b/src/adapters/sveltekit.templates.ts @@ -16,7 +16,7 @@ export function prismicIOFileTemplate(args: { typescript: boolean }): string { /** * The project's Prismic repository name. */ - export const repositoryName = prismicConfig.repositoryName; + export const repositoryName = import.meta.env.VITE_PRISMIC_ENVIRONMENT || prismicConfig.repositoryName; /** * Creates a Prismic client for the project's repository. The client is used to @@ -45,7 +45,7 @@ export function prismicIOFileTemplate(args: { typescript: boolean }): string { /** * The project's Prismic repository name. */ - export const repositoryName = prismicConfig.repositoryName; + export const repositoryName = import.meta.env.VITE_PRISMIC_ENVIRONMENT || prismicConfig.repositoryName; /** * Creates a Prismic client for the project's repository. The client is used to diff --git a/src/adapters/sveltekit.ts b/src/adapters/sveltekit.ts index d200626a..7bc80b27 100644 --- a/src/adapters/sveltekit.ts +++ b/src/adapters/sveltekit.ts @@ -7,7 +7,7 @@ import { createRequire } from "node:module"; import { relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { Adapter } from "."; +import { Adapter, addRegisterImport } from "."; import { getHost, getToken } from "../auth"; import { addPreview, getPreviews, getSimulatorUrl, setSimulatorUrl } from "../clients/core"; import { exists, writeFileRecursive } from "../lib/file"; @@ -31,6 +31,7 @@ export class SvelteKitAdapter extends Adapter { async setupProject(): Promise { await addDependencies({ + prismic: `^${await getNpmPackageVersion("prismic")}`, "@prismicio/client": `^${await getNpmPackageVersion("@prismicio/client")}`, "@prismicio/svelte": `^${await getNpmPackageVersion("@prismicio/svelte")}`, }); @@ -42,6 +43,7 @@ export class SvelteKitAdapter extends Adapter { await createRootLayoutServerFile(); await createRootLayoutFile(); await modifyViteConfig(); + await addRegisterImport(["vite.config.ts", "vite.config.js"]); } async onProjectInitialized(): Promise { diff --git a/src/commands/env-active.ts b/src/commands/env-active.ts new file mode 100644 index 00000000..e477af00 --- /dev/null +++ b/src/commands/env-active.ts @@ -0,0 +1,15 @@ +import { createCommand, type CommandConfig } from "../lib/command"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic env active", + description: ` + Print the active environment. + + Prints the production environment when no environment is active. + `, +} satisfies CommandConfig; + +export default createCommand(config, async () => { + console.info(await getRepositoryName()); +}); diff --git a/src/commands/env-list.ts b/src/commands/env-list.ts new file mode 100644 index 00000000..50be4133 --- /dev/null +++ b/src/commands/env-list.ts @@ -0,0 +1,50 @@ +import { getActiveEnvironment } from "../active-environment"; +import { getHost, getToken } from "../auth"; +import { listAvailableEnvironments } from "../environments"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { formatTable } from "../lib/string"; +import { readConfig } from "../project"; + +const config = { + name: "prismic env list", + description: ` + List the environments available for the project, marking the active one. + `, + options: { + json: { type: "boolean", description: "Output as JSON" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { json } = values; + + const { repositoryName } = await readConfig(); + const token = await getToken(); + const host = await getHost(); + + const environments = await listAvailableEnvironments({ repo: repositoryName, token, host }); + const activeDomain = getActiveEnvironment() ?? repositoryName; + + if (json) { + const results = environments.map((environment) => ({ + kind: environment.kind, + name: environment.name, + domain: environment.domain, + active: environment.domain === activeDomain, + })); + console.info(stringify(results)); + return; + } + + if (environments.length === 0) { + console.info("No environments found."); + return; + } + + const rows = environments.map((environment) => { + const activeLabel = environment.domain === activeDomain ? " (active)" : ""; + return [environment.domain, `${environment.name}${activeLabel}`]; + }); + console.info(formatTable(rows, { headers: ["DOMAIN", "NAME"] })); +}); diff --git a/src/commands/env-set.ts b/src/commands/env-set.ts new file mode 100644 index 00000000..53f6a527 --- /dev/null +++ b/src/commands/env-set.ts @@ -0,0 +1,38 @@ +import { setActiveEnvironment, unsetActiveEnvironment } from "../active-environment"; +import { getHost, getToken } from "../auth"; +import { resolveEnvironment } from "../environments"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { readConfig } from "../project"; + +const config = { + name: "prismic env set", + description: ` + Set the active environment for the project. + + The active environment is stored by the CLI and used by every command and by + the project at build time. Setting the production environment is the same as + \`prismic env unset\`. + `, + positionals: { + name: { description: "Environment domain", required: true }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals }) => { + const [name] = positionals; + + const { repositoryName } = await readConfig(); + const token = await getToken(); + const host = await getHost(); + + const domain = await resolveEnvironment(name, { repo: repositoryName, token, host }); + + if (domain === repositoryName) { + unsetActiveEnvironment(); + console.info(`Reset to the production environment "${repositoryName}".`); + return; + } + + setActiveEnvironment(domain); + console.info(`Set the active environment to "${domain}".`); +}); diff --git a/src/commands/env-unset.ts b/src/commands/env-unset.ts new file mode 100644 index 00000000..98d0574e --- /dev/null +++ b/src/commands/env-unset.ts @@ -0,0 +1,18 @@ +import { unsetActiveEnvironment } from "../active-environment"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { readConfig } from "../project"; + +const config = { + name: "prismic env unset", + description: ` + Reset the active environment to the production environment. + `, +} satisfies CommandConfig; + +export default createCommand(config, async () => { + const { repositoryName } = await readConfig(); + + unsetActiveEnvironment(); + + console.info(`Reset to the production environment "${repositoryName}".`); +}); diff --git a/src/commands/env.ts b/src/commands/env.ts new file mode 100644 index 00000000..d2e75f4c --- /dev/null +++ b/src/commands/env.ts @@ -0,0 +1,28 @@ +import { createCommandRouter } from "../lib/command"; +import envActive from "./env-active"; +import envList from "./env-list"; +import envSet from "./env-set"; +import envUnset from "./env-unset"; + +export default createCommandRouter({ + name: "prismic env", + description: "Manage the active environment for a Prismic project.", + commands: { + set: { + handler: envSet, + description: "Set the active environment", + }, + unset: { + handler: envUnset, + description: "Reset to the production environment", + }, + active: { + handler: envActive, + description: "Print the active environment", + }, + list: { + handler: envList, + description: "List environments", + }, + }, +}); diff --git a/src/environments.ts b/src/environments.ts index 86de69f0..cc959dc1 100644 --- a/src/environments.ts +++ b/src/environments.ts @@ -1,10 +1,11 @@ import { type Environment, getEnvironments } from "./clients/core"; import { getProfile } from "./clients/user"; -export async function resolveEnvironment( - env: string, - config: { repo: string; token: string | undefined; host: string }, -): Promise { +export async function listAvailableEnvironments(config: { + repo: string; + token: string | undefined; + host: string; +}): Promise { const { repo, token, host } = config; const [profile, environments] = await Promise.all([ @@ -12,15 +13,23 @@ export async function resolveEnvironment( getEnvironments({ repo, token, host }), ]); - const availableEnvironments = environments.filter( + return environments.filter( (environment) => (environment.kind === "prod" || environment.kind === "stage") && environment.users.some((user) => user.id === profile.shortId), ); +} + +export async function resolveEnvironment( + env: string, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const availableEnvironments = await listAvailableEnvironments(config); + const match = availableEnvironments.find((environment) => environment.domain === env); if (match) return match.domain; - throw new InvalidEnvironmentError(env, availableEnvironments, repo); + throw new InvalidEnvironmentError(env, availableEnvironments, config.repo); } export class InvalidEnvironmentError extends Error { diff --git a/src/exports/env.ts b/src/exports/env.ts new file mode 100644 index 00000000..1b79689c --- /dev/null +++ b/src/exports/env.ts @@ -0,0 +1 @@ +export { getActiveEnvironment } from "../active-environment"; diff --git a/src/exports/register.ts b/src/exports/register.ts new file mode 100644 index 00000000..8849c235 --- /dev/null +++ b/src/exports/register.ts @@ -0,0 +1,11 @@ +import { getActiveEnvironment, getFrameworkEnvVar } from "../active-environment"; + +// Side-effect import for a framework config (e.g. `import "prismic/env/register"`). +// Sets the framework's environment variable to the active environment, leaving an +// existing value (e.g. from CI) untouched. + +const variable = getFrameworkEnvVar(); +const active = getActiveEnvironment(); +if (variable && active && process.env[variable] == null) { + process.env[variable] = active; +} diff --git a/src/index.ts b/src/index.ts index 8e7c8194..d22a37f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { getAdapter, NoSupportedFrameworkError } from "./adapters"; import { cleanupLegacyAuthFile, getHost, getToken, spawnTokenRefresh } from "./auth"; import { getProfile } from "./clients/user"; import docs from "./commands/docs"; +import envCommand from "./commands/env"; import field from "./commands/field"; import gen from "./commands/gen"; import init from "./commands/init"; @@ -94,6 +95,10 @@ const router = createCommandRouter({ handler: status, description: "Show local vs remote model differences", }, + env: { + handler: envCommand, + description: "Manage the active environment", + }, locale: { handler: locale, description: "Manage locales", diff --git a/src/project.ts b/src/project.ts index cfbd1b47..3f392b50 100644 --- a/src/project.ts +++ b/src/project.ts @@ -4,6 +4,7 @@ import { readFile, realpath, rm, writeFile } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as z from "zod/mini"; +import { getActiveEnvironment } from "./active-environment"; import { getRepository } from "./clients/repository"; import { env } from "./env"; import { exists, findUpward } from "./lib/file"; @@ -212,6 +213,9 @@ export async function safeGetRepositoryName(): Promise { } export async function getRepositoryName(): Promise { + const activeEnvironment = getActiveEnvironment(); + if (activeEnvironment) return activeEnvironment; + try { const config = await readConfig(); return config.repositoryName; diff --git a/test/env-active.test.ts b/test/env-active.test.ts new file mode 100644 index 00000000..d4873ece --- /dev/null +++ b/test/env-active.test.ts @@ -0,0 +1,13 @@ +import { it } from "./it"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("env", ["active", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic env active [options]"); +}); + +it("prints the production environment by default", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("env", ["active"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(repo); +}); diff --git a/test/env-list.test.ts b/test/env-list.test.ts new file mode 100644 index 00000000..4847a475 --- /dev/null +++ b/test/env-list.test.ts @@ -0,0 +1,23 @@ +import { it } from "./it"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("env", ["list", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic env list [options]"); +}); + +it("lists environments and marks the active one", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("env", ["list"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(repo); + expect(stdout).toContain("(active)"); +}); + +it("lists environments as JSON", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("env", ["list", "--json"]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed).toEqual( + expect.arrayContaining([expect.objectContaining({ domain: repo, active: true })]), + ); +}); diff --git a/test/env-set.test.ts b/test/env-set.test.ts new file mode 100644 index 00000000..5ee241fc --- /dev/null +++ b/test/env-set.test.ts @@ -0,0 +1,23 @@ +import { it } from "./it"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("env", ["set", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic env set [options]"); +}); + +it("rejects an unknown environment", async ({ expect, prismic, repo }) => { + const { stderr, exitCode } = await prismic("env", ["set", "does-not-exist"]); + expect(exitCode).toBe(1); + expect(stderr).toContain(`No environments available on repository "${repo}".`); +}); + +it("resets to production when set to the production environment", async ({ + expect, + prismic, + repo, +}) => { + const { stdout, exitCode } = await prismic("env", ["set", repo]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Reset to the production environment "${repo}".`); +}); diff --git a/test/env-unset.test.ts b/test/env-unset.test.ts new file mode 100644 index 00000000..fa6ac656 --- /dev/null +++ b/test/env-unset.test.ts @@ -0,0 +1,13 @@ +import { it } from "./it"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("env", ["unset", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic env unset [options]"); +}); + +it("resets to the production environment", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("env", ["unset"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Reset to the production environment "${repo}".`); +}); diff --git a/test/env.test.ts b/test/env.test.ts new file mode 100644 index 00000000..58a3ceda --- /dev/null +++ b/test/env.test.ts @@ -0,0 +1,13 @@ +import { it } from "./it"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("env", ["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic env [options]"); +}); + +it("prints help by default", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("env"); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic env [options]"); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 241d2f17..b449a087 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -6,6 +6,8 @@ const TEST = MODE === "test"; export default defineConfig({ entry: { index: "./src/index.ts", + env: "./src/exports/env.ts", + "env/register": "./src/exports/register.ts", "subprocesses/refreshToken": "./src/subprocesses/refreshToken.ts", "subprocesses/sendSegmentEvents": "./src/subprocesses/sendSegmentEvents.ts", "subprocesses/updateVersionState": "./src/subprocesses/updateVersionState.ts", From 801961e6611851b4089f72323c6bd9b05894edac Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 3 Jul 2026 01:59:19 +0000 Subject: [PATCH 2/5] chore: blank line after the register import Co-Authored-By: Claude Opus 4.8 --- src/adapters/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/index.ts b/src/adapters/index.ts index f6fba543..e89878ba 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -51,7 +51,7 @@ export async function addRegisterImport(candidates: string[]): Promise { if (!(await exists(configUrl))) continue; const contents = await readFile(configUrl, "utf8"); if (!contents.includes("prismic/env/register")) { - await writeFile(configUrl, `import "prismic/env/register";\n${contents}`); + await writeFile(configUrl, `import "prismic/env/register";\n\n${contents}`); } return; } From abefdfba732757830c5fffbd44d12d4f180921c0 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 3 Jul 2026 02:19:14 +0000 Subject: [PATCH 3/5] refactor: address review feedback - update-notifier: when the CLI is installed as a project dependency, suggest the package manager's add command instead of the npx message - rename `listAvailableEnvironments` to `getEnvironments` - reuse a shared `findUpwardSync` helper in `active-environment` Co-Authored-By: Claude Opus 4.8 --- src/active-environment.ts | 27 ++++++++++++--------------- src/commands/env-list.ts | 4 ++-- src/environments.ts | 12 ++++++------ src/lib/file.ts | 17 +++++++++++++++++ src/lib/packageJson.ts | 12 ++++++++++++ src/lib/update-notifier.ts | 24 ++++++++++++++++++++++-- 6 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/active-environment.ts b/src/active-environment.ts index 24b47101..ee8ee20e 100644 --- a/src/active-environment.ts +++ b/src/active-environment.ts @@ -1,7 +1,8 @@ -import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { getConfigDir } from "./lib/config-dir"; +import { findUpwardSync } from "./lib/file"; import { stringify } from "./lib/json"; // The active environment lives in the CLI, keyed by project so nothing lands in @@ -73,7 +74,9 @@ function writeState(state: Record): void { } function findProjectRoot(): string | undefined { - return findUpward(CONFIG_FILENAME, (dir) => realpathSync(dir)); + const configPath = findUpwardSync(CONFIG_FILENAME); + if (!configPath) return undefined; + return realpathSync(fileURLToPath(new URL(".", configPath))); } type PackageJson = { @@ -83,17 +86,11 @@ type PackageJson = { }; function readPackageJson(): PackageJson | undefined { - return findUpward("package.json", (dir) => - JSON.parse(readFileSync(join(dir, "package.json"), "utf8")), - ); -} - -function findUpward(filename: string, onFound: (dir: string) => T): T | undefined { - let dir = process.cwd(); - while (true) { - if (existsSync(join(dir, filename))) return onFound(dir); - const parent = dirname(dir); - if (parent === dir) return undefined; - dir = parent; + const packageJsonPath = findUpwardSync("package.json"); + if (!packageJsonPath) return undefined; + try { + return JSON.parse(readFileSync(packageJsonPath, "utf8")); + } catch { + return undefined; } } diff --git a/src/commands/env-list.ts b/src/commands/env-list.ts index 50be4133..8ff4ce09 100644 --- a/src/commands/env-list.ts +++ b/src/commands/env-list.ts @@ -1,6 +1,6 @@ import { getActiveEnvironment } from "../active-environment"; import { getHost, getToken } from "../auth"; -import { listAvailableEnvironments } from "../environments"; +import { getEnvironments } from "../environments"; import { createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; import { formatTable } from "../lib/string"; @@ -23,7 +23,7 @@ export default createCommand(config, async ({ values }) => { const token = await getToken(); const host = await getHost(); - const environments = await listAvailableEnvironments({ repo: repositoryName, token, host }); + const environments = await getEnvironments({ repo: repositoryName, token, host }); const activeDomain = getActiveEnvironment() ?? repositoryName; if (json) { diff --git a/src/environments.ts b/src/environments.ts index cc959dc1..591acdaf 100644 --- a/src/environments.ts +++ b/src/environments.ts @@ -1,7 +1,7 @@ -import { type Environment, getEnvironments } from "./clients/core"; +import { type Environment, getEnvironments as getAllEnvironments } from "./clients/core"; import { getProfile } from "./clients/user"; -export async function listAvailableEnvironments(config: { +export async function getEnvironments(config: { repo: string; token: string | undefined; host: string; @@ -10,7 +10,7 @@ export async function listAvailableEnvironments(config: { const [profile, environments] = await Promise.all([ getProfile({ token, host }), - getEnvironments({ repo, token, host }), + getAllEnvironments({ repo, token, host }), ]); return environments.filter( @@ -24,12 +24,12 @@ export async function resolveEnvironment( env: string, config: { repo: string; token: string | undefined; host: string }, ): Promise { - const availableEnvironments = await listAvailableEnvironments(config); + const environments = await getEnvironments(config); - const match = availableEnvironments.find((environment) => environment.domain === env); + const match = environments.find((environment) => environment.domain === env); if (match) return match.domain; - throw new InvalidEnvironmentError(env, availableEnvironments, config.repo); + throw new InvalidEnvironmentError(env, environments, config.repo); } export class InvalidEnvironmentError extends Error { diff --git a/src/lib/file.ts b/src/lib/file.ts index bf644731..d95d0372 100644 --- a/src/lib/file.ts +++ b/src/lib/file.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import * as z from "zod/mini"; @@ -40,6 +41,22 @@ export async function findUpward( } } +export function findUpwardSync(name: string, config: { start?: URL } = {}): URL | undefined { + const { start = pathToFileURL(process.cwd()) } = config; + + let dir = appendTrailingSlash(start); + + while (true) { + const path = new URL(name, dir); + if (existsSync(path)) return path; + + const parent = new URL("..", dir); + if (parent.href === dir.href) return undefined; + + dir = parent; + } +} + export async function exists(path: URL): Promise { try { await access(path); diff --git a/src/lib/packageJson.ts b/src/lib/packageJson.ts index 9b71f119..e530e2aa 100644 --- a/src/lib/packageJson.ts +++ b/src/lib/packageJson.ts @@ -75,6 +75,18 @@ const INSTALL_COMMANDS = { bun: ["bun", "install"], }; +const ADD_COMMANDS: Record = { + npm: "npm install", + yarn: "yarn add", + pnpm: "pnpm add", + bun: "bun add", +}; + +export async function getAddCommand(pkg: string): Promise { + const packageManager = await detectPackageManager(); + return `${ADD_COMMANDS[packageManager]} ${pkg}`; +} + export async function installDependencies(): Promise { const packageJsonPath = await findPackageJson(); const cwd = new URL(".", packageJsonPath); diff --git a/src/lib/update-notifier.ts b/src/lib/update-notifier.ts index fbd4ee5c..b8f6518e 100644 --- a/src/lib/update-notifier.ts +++ b/src/lib/update-notifier.ts @@ -6,7 +6,7 @@ import * as z from "zod/mini"; import packageJson from "../../package.json" with { type: "json" }; import { stringify } from "./json"; -import { getNpmPackageVersion } from "./packageJson"; +import { getAddCommand, getNpmPackageVersion, readPackageJson } from "./packageJson"; const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -29,7 +29,14 @@ export async function initUpdateNotifier(options: UpdateNotifierOptions): Promis const currentVersion = packageJson.version; if (state?.latestKnownVersion && isNewer(state.latestKnownVersion, currentVersion)) { - const message = `Update available: ${currentVersion} → ${state.latestKnownVersion}. Run \`npx ${options.npmPackageName}@latest --version\` to update.`; + const isInstalled = await isInstalledAsDependency(options.npmPackageName); + + let updateCommand = `npx ${options.npmPackageName}@latest --version`; + if (isInstalled) { + updateCommand = await getAddCommand(`${options.npmPackageName}@latest`); + } + + const message = `Update available: ${currentVersion} → ${state.latestKnownVersion}. Run \`${updateCommand}\` to update.`; process.on("exit", () => { try { console.error(`\n${message}`); @@ -49,6 +56,19 @@ export async function initUpdateNotifier(options: UpdateNotifierOptions): Promis } } +async function isInstalledAsDependency(name: string): Promise { + try { + const packageJson = await readPackageJson(); + return Boolean( + packageJson.dependencies?.[name] || + packageJson.devDependencies?.[name] || + packageJson.peerDependencies?.[name], + ); + } catch { + return false; + } +} + function shouldSkip(): boolean { if (process.env.NO_UPDATE_NOTIFIER === "0") return false; if (process.env.NO_UPDATE_NOTIFIER === "1") return true; From f75bf93cf77561072eb2f539fd9066203cf3c137 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 3 Jul 2026 02:23:41 +0000 Subject: [PATCH 4/5] refactor: rename getEnvironments to getUserEnvironments Makes it clear at the call site that the function returns the prod/stage environments the logged-in user can access, not the raw client list. Co-Authored-By: Claude Opus 4.8 --- src/commands/env-list.ts | 4 ++-- src/environments.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/env-list.ts b/src/commands/env-list.ts index 8ff4ce09..0151bb12 100644 --- a/src/commands/env-list.ts +++ b/src/commands/env-list.ts @@ -1,6 +1,6 @@ import { getActiveEnvironment } from "../active-environment"; import { getHost, getToken } from "../auth"; -import { getEnvironments } from "../environments"; +import { getUserEnvironments } from "../environments"; import { createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; import { formatTable } from "../lib/string"; @@ -23,7 +23,7 @@ export default createCommand(config, async ({ values }) => { const token = await getToken(); const host = await getHost(); - const environments = await getEnvironments({ repo: repositoryName, token, host }); + const environments = await getUserEnvironments({ repo: repositoryName, token, host }); const activeDomain = getActiveEnvironment() ?? repositoryName; if (json) { diff --git a/src/environments.ts b/src/environments.ts index 591acdaf..c73a9628 100644 --- a/src/environments.ts +++ b/src/environments.ts @@ -1,7 +1,7 @@ -import { type Environment, getEnvironments as getAllEnvironments } from "./clients/core"; +import { type Environment, getEnvironments } from "./clients/core"; import { getProfile } from "./clients/user"; -export async function getEnvironments(config: { +export async function getUserEnvironments(config: { repo: string; token: string | undefined; host: string; @@ -10,7 +10,7 @@ export async function getEnvironments(config: { const [profile, environments] = await Promise.all([ getProfile({ token, host }), - getAllEnvironments({ repo, token, host }), + getEnvironments({ repo, token, host }), ]); return environments.filter( @@ -24,7 +24,7 @@ export async function resolveEnvironment( env: string, config: { repo: string; token: string | undefined; host: string }, ): Promise { - const environments = await getEnvironments(config); + const environments = await getUserEnvironments(config); const match = environments.find((environment) => environment.domain === env); if (match) return match.domain; From ef8b75b4090598c9dbb687c7cf4df619fa5eab38 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 3 Jul 2026 02:37:05 +0000 Subject: [PATCH 5/5] refactor: clearer names for env register import and active env Rename addRegisterImport to addEnvRegisterImport and activeDomain to activeEnvironment. Co-Authored-By: Claude Opus 4.8 --- src/adapters/index.ts | 2 +- src/adapters/nextjs.ts | 4 ++-- src/adapters/sveltekit.ts | 4 ++-- src/commands/env-list.ts | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e89878ba..0fa362ca 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -44,7 +44,7 @@ export class NoSupportedFrameworkError extends Error { // Adds `import "prismic/env/register"` to the first existing framework config so // the active environment is applied at build time. -export async function addRegisterImport(candidates: string[]): Promise { +export async function addEnvRegisterImport(candidates: string[]): Promise { const projectRoot = await findProjectRoot(); for (const candidate of candidates) { const configUrl = new URL(candidate, projectRoot); diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index 22aa230e..3a0feb03 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -5,7 +5,7 @@ import { createRequire } from "node:module"; import { relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { Adapter, addRegisterImport } from "."; +import { Adapter, addEnvRegisterImport } from "."; import { getHost, getToken } from "../auth"; import { addPreview, getPreviews, getSimulatorUrl, setSimulatorUrl } from "../clients/core"; import { exists, writeFileRecursive } from "../lib/file"; @@ -39,7 +39,7 @@ export class NextJsAdapter extends Adapter { await createPreviewRoute(); await createExitPreviewRoute(); await createRevalidateRoute(); - await addRegisterImport(["next.config.ts", "next.config.mjs", "next.config.js"]); + await addEnvRegisterImport(["next.config.ts", "next.config.mjs", "next.config.js"]); } async onProjectInitialized(): Promise { diff --git a/src/adapters/sveltekit.ts b/src/adapters/sveltekit.ts index 7bc80b27..679cd3b6 100644 --- a/src/adapters/sveltekit.ts +++ b/src/adapters/sveltekit.ts @@ -7,7 +7,7 @@ import { createRequire } from "node:module"; import { relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { Adapter, addRegisterImport } from "."; +import { Adapter, addEnvRegisterImport } from "."; import { getHost, getToken } from "../auth"; import { addPreview, getPreviews, getSimulatorUrl, setSimulatorUrl } from "../clients/core"; import { exists, writeFileRecursive } from "../lib/file"; @@ -43,7 +43,7 @@ export class SvelteKitAdapter extends Adapter { await createRootLayoutServerFile(); await createRootLayoutFile(); await modifyViteConfig(); - await addRegisterImport(["vite.config.ts", "vite.config.js"]); + await addEnvRegisterImport(["vite.config.ts", "vite.config.js"]); } async onProjectInitialized(): Promise { diff --git a/src/commands/env-list.ts b/src/commands/env-list.ts index 0151bb12..7c162afb 100644 --- a/src/commands/env-list.ts +++ b/src/commands/env-list.ts @@ -24,14 +24,14 @@ export default createCommand(config, async ({ values }) => { const host = await getHost(); const environments = await getUserEnvironments({ repo: repositoryName, token, host }); - const activeDomain = getActiveEnvironment() ?? repositoryName; + const activeEnvironment = getActiveEnvironment() ?? repositoryName; if (json) { const results = environments.map((environment) => ({ kind: environment.kind, name: environment.name, domain: environment.domain, - active: environment.domain === activeDomain, + active: environment.domain === activeEnvironment, })); console.info(stringify(results)); return; @@ -43,7 +43,7 @@ export default createCommand(config, async ({ values }) => { } const rows = environments.map((environment) => { - const activeLabel = environment.domain === activeDomain ? " (active)" : ""; + const activeLabel = environment.domain === activeEnvironment ? " (active)" : ""; return [environment.domain, `${environment.name}${activeLabel}`]; }); console.info(formatTable(rows, { headers: ["DOMAIN", "NAME"] }));