From 4a511c65fa08579c42cbaff0c9af37044e502167 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 2 Jul 2026 21:39:55 +0000 Subject: [PATCH] feat: persist the active environment in .env.local Add `prismic env list/set/unset/active` to manage the active environment, persisted as a framework-specific variable in `.env.local`. The CLI reads it as the default repository (after `--env` and `--repo`, before the config default), and generated Next.js and SvelteKit projects read it natively. Nuxt reads it through `@nuxtjs/prismic`. Resolves: #209 Co-Authored-By: Claude Opus 4.8 --- src/adapters/index.ts | 1 + src/adapters/nextjs.templates.ts | 6 ++-- src/adapters/nextjs.ts | 1 + src/adapters/nuxt.ts | 1 + src/adapters/sveltekit.templates.ts | 6 ++-- src/adapters/sveltekit.ts | 1 + src/commands/env-active.ts | 19 +++++++++++ src/commands/env-list.ts | 32 ++++++++++++++++++ src/commands/env-set.ts | 43 ++++++++++++++++++++++++ src/commands/env-unset.ts | 18 ++++++++++ src/commands/env.ts | 28 ++++++++++++++++ src/commands/locale-add.ts | 5 ++- src/commands/locale-list.ts | 5 ++- src/commands/locale-remove.ts | 5 ++- src/commands/locale-set-master.ts | 5 ++- src/commands/preview-add.ts | 5 ++- src/commands/preview-list.ts | 5 ++- src/commands/preview-remove.ts | 5 ++- src/commands/preview-set-simulator.ts | 5 ++- src/commands/pull.ts | 6 ++-- src/commands/push.ts | 6 ++-- src/commands/status.ts | 6 ++-- src/commands/sync.ts | 5 ++- src/commands/token-create.ts | 5 ++- src/commands/token-delete.ts | 5 ++- src/commands/token-list.ts | 5 ++- src/commands/webhook-create.ts | 5 ++- src/commands/webhook-disable.ts | 5 ++- src/commands/webhook-enable.ts | 5 ++- src/commands/webhook-list.ts | 5 ++- src/commands/webhook-remove.ts | 5 ++- src/commands/webhook-set-triggers.ts | 5 ++- src/commands/webhook-view.ts | 5 ++- src/environments.ts | 48 +++++++++++++++++++++++---- src/index.ts | 5 +++ src/lib/env-file.ts | 38 +++++++++++++++++++++ test/env-active.test.ts | 26 +++++++++++++++ test/env-list.test.ts | 14 ++++++++ test/env-set.test.ts | 29 ++++++++++++++++ test/env-unset.test.ts | 18 ++++++++++ test/env.test.ts | 13 ++++++++ 41 files changed, 384 insertions(+), 76 deletions(-) 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/lib/env-file.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/src/adapters/index.ts b/src/adapters/index.ts index 4e3fc7df..e2e55c58 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -44,6 +44,7 @@ export class NoSupportedFrameworkError extends Error { export abstract class Adapter { abstract readonly id: string; + abstract readonly repositoryEnvVar: string; abstract onProjectInitialized(): Promise | void; abstract onSliceCreated(model: SharedSlice, library: URL): Promise | void; diff --git a/src/adapters/nextjs.templates.ts b/src/adapters/nextjs.templates.ts index fce35bca..1dd4d08b 100644 --- a/src/adapters/nextjs.templates.ts +++ b/src/adapters/nextjs.templates.ts @@ -357,7 +357,8 @@ 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 +370,8 @@ 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..167ffa40 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -26,6 +26,7 @@ import { export class NextJsAdapter extends Adapter { readonly id = "next"; + readonly repositoryEnvVar = "NEXT_PUBLIC_PRISMIC_ENVIRONMENT"; async setupProject(): Promise { await addDependencies({ diff --git a/src/adapters/nuxt.ts b/src/adapters/nuxt.ts index 7d358f4e..5dbe2874 100644 --- a/src/adapters/nuxt.ts +++ b/src/adapters/nuxt.ts @@ -21,6 +21,7 @@ const NUXT_PRISMIC = "@nuxtjs/prismic"; export class NuxtAdapter extends Adapter { readonly id = "nuxt"; + readonly repositoryEnvVar = "NUXT_PUBLIC_PRISMIC_ENVIRONMENT"; async setupProject(): Promise { await addDependencies({ diff --git a/src/adapters/sveltekit.templates.ts b/src/adapters/sveltekit.templates.ts index 9a07509a..4be84488 100644 --- a/src/adapters/sveltekit.templates.ts +++ b/src/adapters/sveltekit.templates.ts @@ -16,7 +16,8 @@ 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 +46,8 @@ 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..57610f79 100644 --- a/src/adapters/sveltekit.ts +++ b/src/adapters/sveltekit.ts @@ -28,6 +28,7 @@ import { export class SvelteKitAdapter extends Adapter { readonly id = "sveltekit"; + readonly repositoryEnvVar = "VITE_PRISMIC_ENVIRONMENT"; async setupProject(): Promise { await addDependencies({ diff --git a/src/commands/env-active.ts b/src/commands/env-active.ts new file mode 100644 index 00000000..84ab2380 --- /dev/null +++ b/src/commands/env-active.ts @@ -0,0 +1,19 @@ +import { getActiveRepositoryName } from "../environments"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic env active", + description: ` + Print the active environment, or production if none is set. + `, +} satisfies CommandConfig; + +export default createCommand(config, async () => { + const active = await getActiveRepositoryName(); + if (active) { + console.info(active); + return; + } + console.info(`${await getRepositoryName()} (production)`); +}); diff --git a/src/commands/env-list.ts b/src/commands/env-list.ts new file mode 100644 index 00000000..2fa8d4aa --- /dev/null +++ b/src/commands/env-list.ts @@ -0,0 +1,32 @@ +import { getHost, getToken } from "../auth"; +import { getActiveRepositoryName, getAvailableEnvironments } from "../environments"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { formatTable } from "../lib/string"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic env list", + description: ` + List environments available on a Prismic repository, including production. + `, + options: { + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + + const environments = await getAvailableEnvironments({ repo, token, host }); + const active = (await getActiveRepositoryName()) ?? repo; + + const rows = environments.map((environment) => { + const label = environment.kind === "prod" ? "production" : environment.name; + const marker = environment.domain === active ? " (active)" : ""; + return [environment.domain, `${label}${marker}`]; + }); + 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..9a214e17 --- /dev/null +++ b/src/commands/env-set.ts @@ -0,0 +1,43 @@ +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { resolveEnvironment } from "../environments"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { removeEnvVar, setEnvVar } from "../lib/env-file"; +import { findProjectRoot, getRepositoryName } from "../project"; + +const config = { + name: "prismic env set", + description: ` + Set the active environment for local development. + + Writes the environment to .env.local. The website and CLI read it as the + active repository. Setting production removes it. + `, + positionals: { + environment: { description: "Environment domain", required: true }, + }, + options: { + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [environment] = positionals; + const { repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + + const domain = await resolveEnvironment(environment, { repo, token, host }); + const adapter = await getAdapter(); + const path = new URL(".env.local", await findProjectRoot()); + + if (domain === repo) { + await removeEnvVar(path, adapter.repositoryEnvVar); + console.info(`Active environment set to production: ${domain}`); + return; + } + + await setEnvVar(path, adapter.repositoryEnvVar, domain); + console.info(`Active environment set: ${domain}`); +}); diff --git a/src/commands/env-unset.ts b/src/commands/env-unset.ts new file mode 100644 index 00000000..9527bc7d --- /dev/null +++ b/src/commands/env-unset.ts @@ -0,0 +1,18 @@ +import { getAdapter } from "../adapters"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { removeEnvVar } from "../lib/env-file"; +import { findProjectRoot } from "../project"; + +const config = { + name: "prismic env unset", + description: ` + Revert to production by removing the active environment from .env.local. + `, +} satisfies CommandConfig; + +export default createCommand(config, async () => { + const adapter = await getAdapter(); + const projectRoot = await findProjectRoot(); + await removeEnvVar(new URL(".env.local", projectRoot), adapter.repositoryEnvVar); + console.info("Active environment reverted to production."); +}); diff --git a/src/commands/env.ts b/src/commands/env.ts new file mode 100644 index 00000000..437912a5 --- /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 Prismic environment.", + commands: { + list: { + handler: envList, + description: "List environments", + }, + set: { + handler: envSet, + description: "Set the active environment", + }, + unset: { + handler: envUnset, + description: "Revert to production", + }, + active: { + handler: envActive, + description: "Print the active environment", + }, + }, +}); diff --git a/src/commands/locale-add.ts b/src/commands/locale-add.ts index 3ded8b82..d6117217 100644 --- a/src/commands/locale-add.ts +++ b/src/commands/locale-add.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { upsertLocale } from "../clients/locale"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic locale add", @@ -26,7 +25,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [code] = positionals; - const { repo: parentRepo = await getRepositoryName(), env, master = false, name } = values; + const { repo: parentRepo = await resolveRepositoryName(), env, master = false, name } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/locale-list.ts b/src/commands/locale-list.ts index 2e704801..7b5a737b 100644 --- a/src/commands/locale-list.ts +++ b/src/commands/locale-list.ts @@ -1,11 +1,10 @@ import { getHost, getToken } from "../auth"; import { getLocales } from "../clients/locale"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; import { UnknownRequestError } from "../lib/request"; import { formatTable } from "../lib/string"; -import { getRepositoryName } from "../project"; const config = { name: "prismic locale list", @@ -23,7 +22,7 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { repo: parentRepo = await getRepositoryName(), env, json } = values; + const { repo: parentRepo = await resolveRepositoryName(), env, json } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/locale-remove.ts b/src/commands/locale-remove.ts index 5522aa3d..2d6866f6 100644 --- a/src/commands/locale-remove.ts +++ b/src/commands/locale-remove.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { removeLocale } from "../clients/locale"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic locale remove", @@ -24,7 +23,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [code] = positionals; - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/locale-set-master.ts b/src/commands/locale-set-master.ts index 01eb422e..e44f036b 100644 --- a/src/commands/locale-set-master.ts +++ b/src/commands/locale-set-master.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { getLocales, upsertLocale } from "../clients/locale"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic locale set-master", @@ -24,7 +23,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [code] = positionals; - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/preview-add.ts b/src/commands/preview-add.ts index cff60ec7..35b120e3 100644 --- a/src/commands/preview-add.ts +++ b/src/commands/preview-add.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { addPreview } from "../clients/core"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic preview add", @@ -25,7 +24,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [previewUrl] = positionals; - const { repo: parentRepo = await getRepositoryName(), env, name } = values; + const { repo: parentRepo = await resolveRepositoryName(), env, name } = values; let parsed: URL; try { diff --git a/src/commands/preview-list.ts b/src/commands/preview-list.ts index b6476154..7960163b 100644 --- a/src/commands/preview-list.ts +++ b/src/commands/preview-list.ts @@ -1,11 +1,10 @@ import { getHost, getToken } from "../auth"; import { getPreviews, getSimulatorUrl } from "../clients/core"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; import { UnknownRequestError } from "../lib/request"; import { formatTable } from "../lib/string"; -import { getRepositoryName } from "../project"; const config = { name: "prismic preview list", @@ -23,7 +22,7 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { repo: parentRepo = await getRepositoryName(), env, json } = values; + const { repo: parentRepo = await resolveRepositoryName(), env, json } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/preview-remove.ts b/src/commands/preview-remove.ts index fa292ed7..0ab2fc05 100644 --- a/src/commands/preview-remove.ts +++ b/src/commands/preview-remove.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { getPreviews, removePreview } from "../clients/core"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic preview remove", @@ -24,7 +23,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [previewUrl] = positionals; - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/preview-set-simulator.ts b/src/commands/preview-set-simulator.ts index 1332e520..40615867 100644 --- a/src/commands/preview-set-simulator.ts +++ b/src/commands/preview-set-simulator.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { setSimulatorUrl } from "../clients/core"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic preview set-simulator", @@ -30,7 +29,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [urlArg] = positionals; - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; let parsed: URL; try { diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 866609b1..14e9170b 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -2,13 +2,13 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { completeOnboardingStepsSilently } from "../clients/repository"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { diffArrays } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; import { isDescendant, relativePathname } from "../lib/url"; import { canonicalizeModel } from "../models"; -import { findProjectRoot, getRepositoryName } from "../project"; +import { findProjectRoot } from "../project"; const config = { name: "prismic pull", @@ -26,7 +26,7 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { force = false, repo: parentRepo = await getRepositoryName(), env } = values; + const { force = false, repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/push.ts b/src/commands/push.ts index 1d204f50..d9fc7aaf 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -21,14 +21,14 @@ import { type OnboardingStep, } from "../clients/repository"; import { getWorkingDocumentsUrlForCustomType, getCustomTypeListUrl } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { diffArrays } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; import { BadRequestError } from "../lib/request"; import { appendTrailingSlash, isDescendant, relativePathname } from "../lib/url"; import { canonicalizeModel } from "../models"; -import { findProjectRoot, getRepositoryName } from "../project"; +import { findProjectRoot } from "../project"; const config = { name: "prismic push", @@ -46,7 +46,7 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { force = false, repo: parentRepo = await getRepositoryName(), env } = values; + const { force = false, repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/status.ts b/src/commands/status.ts index f148559f..003d2bd2 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -4,14 +4,14 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { getProfile } from "../clients/user"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { createCommand, type CommandConfig } from "../lib/command"; import { diffArrays, type ArrayDiff } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; import { dedent } from "../lib/string"; import { isDescendant, relativePathname } from "../lib/url"; import { canonicalizeModel } from "../models"; -import { findProjectRoot, getRepositoryName } from "../project"; +import { findProjectRoot } from "../project"; const config = { name: "prismic status", @@ -28,7 +28,7 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 18492690..1cd2e79d 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -6,10 +6,9 @@ import { getHost, getToken } from "../auth"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { completeOnboardingStepsSilently } from "../clients/repository"; import { env } from "../env"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { createCommand, type CommandConfig } from "../lib/command"; import { diffArrays } from "../lib/diff"; -import { getRepositoryName } from "../project"; import { trackCommandStart, trackCommandEnd } from "../tracking"; const POLL_INTERVAL_MS = env.TEST ? 500 : 5000; @@ -35,7 +34,7 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { repo: parentRepo = await getRepositoryName(), env: envFlag } = values; + const { repo: parentRepo = await resolveRepositoryName(), env: envFlag } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/token-create.ts b/src/commands/token-create.ts index 50d9dc7f..fe4ed6ea 100644 --- a/src/commands/token-create.ts +++ b/src/commands/token-create.ts @@ -5,11 +5,10 @@ import { createWriteToken, getOAuthApps, } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const CLI_APP_NAME = "Prismic CLI"; @@ -40,7 +39,7 @@ const config = { export default createCommand(config, async ({ values }) => { const { - repo: parentRepo = await getRepositoryName(), + repo: parentRepo = await resolveRepositoryName(), env, write, "allow-releases": allowReleases, diff --git a/src/commands/token-delete.ts b/src/commands/token-delete.ts index 663fc9a7..083cb06b 100644 --- a/src/commands/token-delete.ts +++ b/src/commands/token-delete.ts @@ -5,10 +5,9 @@ import { getOAuthApps, getWriteTokens, } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic token delete", @@ -29,7 +28,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [tokenValue] = positionals; - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/token-list.ts b/src/commands/token-list.ts index e9841fcd..22d4ca48 100644 --- a/src/commands/token-list.ts +++ b/src/commands/token-list.ts @@ -1,11 +1,10 @@ import { getHost, getToken } from "../auth"; import { getOAuthApps, getWriteTokens } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; import { UnknownRequestError } from "../lib/request"; import { formatTable } from "../lib/string"; -import { getRepositoryName } from "../project"; const config = { name: "prismic token list", @@ -23,7 +22,7 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { repo: parentRepo = await getRepositoryName(), env, json } = values; + const { repo: parentRepo = await resolveRepositoryName(), env, json } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/webhook-create.ts b/src/commands/webhook-create.ts index a8425b4f..63178e6d 100644 --- a/src/commands/webhook-create.ts +++ b/src/commands/webhook-create.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { createWebhook, WEBHOOK_TRIGGERS } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic webhook create", @@ -54,7 +53,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; - const { repo: parentRepo = await getRepositoryName(), env, name, secret, trigger = [] } = values; + const { repo: parentRepo = await resolveRepositoryName(), env, name, secret, trigger = [] } = values; // Validate triggers for (const t of trigger) { diff --git a/src/commands/webhook-disable.ts b/src/commands/webhook-disable.ts index 203d7c40..1aa15765 100644 --- a/src/commands/webhook-disable.ts +++ b/src/commands/webhook-disable.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { getWebhooks, updateWebhook } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic webhook disable", @@ -24,7 +23,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/webhook-enable.ts b/src/commands/webhook-enable.ts index 9a26d9ee..2cab5f80 100644 --- a/src/commands/webhook-enable.ts +++ b/src/commands/webhook-enable.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { getWebhooks, updateWebhook } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic webhook enable", @@ -24,7 +23,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/webhook-list.ts b/src/commands/webhook-list.ts index c6ada2ef..3ffd6cc0 100644 --- a/src/commands/webhook-list.ts +++ b/src/commands/webhook-list.ts @@ -1,11 +1,10 @@ import { getHost, getToken } from "../auth"; import { getWebhooks } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; import { UnknownRequestError } from "../lib/request"; import { formatTable } from "../lib/string"; -import { getRepositoryName } from "../project"; const config = { name: "prismic webhook list", @@ -23,7 +22,7 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { repo: parentRepo = await getRepositoryName(), env, json } = values; + const { repo: parentRepo = await resolveRepositoryName(), env, json } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/webhook-remove.ts b/src/commands/webhook-remove.ts index f5e850aa..1075deb3 100644 --- a/src/commands/webhook-remove.ts +++ b/src/commands/webhook-remove.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { deleteWebhook, getWebhooks } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic webhook remove", @@ -24,7 +23,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/commands/webhook-set-triggers.ts b/src/commands/webhook-set-triggers.ts index aa897525..dcc9f60e 100644 --- a/src/commands/webhook-set-triggers.ts +++ b/src/commands/webhook-set-triggers.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { getWebhooks, updateWebhook, WEBHOOK_TRIGGERS } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic webhook set-triggers", @@ -41,7 +40,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; - const { repo: parentRepo = await getRepositoryName(), env, trigger = [] } = values; + const { repo: parentRepo = await resolveRepositoryName(), env, trigger = [] } = values; // Validate triggers for (const t of trigger) { diff --git a/src/commands/webhook-view.ts b/src/commands/webhook-view.ts index b8bbe371..7b1443dc 100644 --- a/src/commands/webhook-view.ts +++ b/src/commands/webhook-view.ts @@ -1,9 +1,8 @@ import { getHost, getToken } from "../auth"; import { getWebhooks, WEBHOOK_TRIGGERS } from "../clients/wroom"; -import { resolveEnvironment } from "../environments"; +import { resolveEnvironment, resolveRepositoryName } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; const config = { name: "prismic webhook view", @@ -24,7 +23,7 @@ const config = { export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; - const { repo: parentRepo = await getRepositoryName(), env } = values; + const { repo: parentRepo = await resolveRepositoryName(), env } = values; const token = await getToken(); const host = await getHost(); diff --git a/src/environments.ts b/src/environments.ts index 86de69f0..09aad474 100644 --- a/src/environments.ts +++ b/src/environments.ts @@ -1,10 +1,14 @@ +import { getAdapter } from "./adapters"; import { type Environment, getEnvironments } from "./clients/core"; import { getProfile } from "./clients/user"; +import { readEnvFile } from "./lib/env-file"; +import { findProjectRoot, getRepositoryName } from "./project"; -export async function resolveEnvironment( - env: string, - config: { repo: string; token: string | undefined; host: string }, -): Promise { +export async function getAvailableEnvironments(config: { + repo: string; + token: string | undefined; + host: string; +}): Promise { const { repo, token, host } = config; const [profile, environments] = await Promise.all([ @@ -12,15 +16,47 @@ 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 getAvailableEnvironments(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); +} + +/** + * The repository name persisted as the active environment, read from + * `process.env` then `.env.local`. Returns `undefined` when none is set. + */ +export async function getActiveRepositoryName(): Promise { + let key: string; + try { + const adapter = await getAdapter(); + key = adapter.repositoryEnvVar; + } catch { + return undefined; + } + + const fromProcess = process.env[key]; + if (fromProcess) return fromProcess; + + const localEnv = await readEnvFile(new URL(".env.local", await findProjectRoot())); + return localEnv[key]; +} + +/** The repository name to target by default, honoring the active environment. */ +export async function resolveRepositoryName(): Promise { + return (await getActiveRepositoryName()) ?? (await getRepositoryName()); } export class InvalidEnvironmentError extends Error { diff --git a/src/index.ts b/src/index.ts index 8e7c8194..a87a3ebe 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 environment from "./commands/env"; import field from "./commands/field"; import gen from "./commands/gen"; import init from "./commands/init"; @@ -102,6 +103,10 @@ const router = createCommandRouter({ handler: repo, description: "Manage repositories", }, + env: { + handler: environment, + description: "Manage the active environment", + }, type: { handler: type_, description: "Manage content types", diff --git a/src/lib/env-file.ts b/src/lib/env-file.ts new file mode 100644 index 00000000..5d457df8 --- /dev/null +++ b/src/lib/env-file.ts @@ -0,0 +1,38 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { parseEnv } from "node:util"; + +export async function readEnvFile(path: URL): Promise> { + try { + return parseEnv(await readFile(path, "utf8")); + } catch { + return {}; + } +} + +export async function setEnvVar(path: URL, key: string, value: string): Promise { + let contents = ""; + try { + contents = await readFile(path, "utf8"); + } catch {} + + const line = `${key}=${value}`; + const pattern = new RegExp(`^${key}=.*$`, "m"); + if (pattern.test(contents)) { + contents = contents.replace(pattern, line); + } else { + if (contents && !contents.endsWith("\n")) contents += "\n"; + contents += `${line}\n`; + } + + await writeFile(path, contents); +} + +export async function removeEnvVar(path: URL, key: string): Promise { + let contents: string; + try { + contents = await readFile(path, "utf8"); + } catch { + return; + } + await writeFile(path, contents.replace(new RegExp(`^${key}=.*\\n?`, "m"), "")); +} diff --git a/test/env-active.test.ts b/test/env-active.test.ts new file mode 100644 index 00000000..e3642aed --- /dev/null +++ b/test/env-active.test.ts @@ -0,0 +1,26 @@ +import { writeFile } from "node:fs/promises"; + +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 production when no environment is active", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("env", ["active"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`${repo} (production)`); +}); + +it("reads the active environment from .env.local", async ({ expect, prismic, project }) => { + await writeFile( + new URL(".env.local", project), + "NEXT_PUBLIC_PRISMIC_ENVIRONMENT=my-repo-staging\n", + ); + + const { stdout, exitCode } = await prismic("env", ["active"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("my-repo-staging"); +}); diff --git a/test/env-list.test.ts b/test/env-list.test.ts new file mode 100644 index 00000000..9a99eb1c --- /dev/null +++ b/test/env-list.test.ts @@ -0,0 +1,14 @@ +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 including production", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("env", ["list"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(repo); + expect(stdout).toContain("production"); +}); diff --git a/test/env-set.test.ts b/test/env-set.test.ts new file mode 100644 index 00000000..4e9565f7 --- /dev/null +++ b/test/env-set.test.ts @@ -0,0 +1,29 @@ +import { readFile, writeFile } from "node:fs/promises"; + +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]"); +}); + +// TODO: Test setting a non-production environment once the e2e setup can create +// one. It should write the environment's domain to .env.local as the active +// repository. The test repository only has production, so it can't be covered yet. + +it("setting production clears the active environment", async ({ expect, prismic, project, repo }) => { + const path = new URL(".env.local", project); + await writeFile(path, "NEXT_PUBLIC_PRISMIC_ENVIRONMENT=my-repo-staging\n"); + + const { stdout, exitCode } = await prismic("env", ["set", repo]); + expect(exitCode).toBe(0); + expect(stdout).toContain("production"); + expect(await readFile(path, "utf8")).not.toContain("my-repo-staging"); +}); + +it("rejects an unknown environment", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("env", ["set", "does-not-exist"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("No environments available"); +}); diff --git a/test/env-unset.test.ts b/test/env-unset.test.ts new file mode 100644 index 00000000..dd718e84 --- /dev/null +++ b/test/env-unset.test.ts @@ -0,0 +1,18 @@ +import { readFile, writeFile } from "node:fs/promises"; + +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("unsets the active environment", async ({ expect, prismic, project }) => { + const path = new URL(".env.local", project); + await writeFile(path, "NEXT_PUBLIC_PRISMIC_ENVIRONMENT=my-repo-staging\n"); + + const { exitCode } = await prismic("env", ["unset"]); + expect(exitCode).toBe(0); + expect(await readFile(path, "utf8")).not.toContain("my-repo-staging"); +}); diff --git a/test/env.test.ts b/test/env.test.ts new file mode 100644 index 00000000..1af8902e --- /dev/null +++ b/test/env.test.ts @@ -0,0 +1,13 @@ +import { it } from "./it"; + +it("prints help by default", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("env"); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic env [options]"); +}); + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("env", ["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic env [options]"); +});