diff --git a/src/commands/env-active.ts b/src/commands/env-active.ts new file mode 100644 index 0000000..eaa5e05 --- /dev/null +++ b/src/commands/env-active.ts @@ -0,0 +1,13 @@ +import { getEnvironment } from "../environments"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic env active", + description: "Print the active environment.", +} satisfies CommandConfig; + +export default createCommand(config, async () => { + const activeEnvironment = (await getEnvironment()) ?? (await getRepositoryName()); + console.info(activeEnvironment); +}); diff --git a/src/commands/env-list.ts b/src/commands/env-list.ts new file mode 100644 index 0000000..34dba52 --- /dev/null +++ b/src/commands/env-list.ts @@ -0,0 +1,44 @@ +import { getHost, getToken } from "../auth"; +import { getEnvironment, getUserEnvironments } from "../environments"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { formatTable } from "../lib/string"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic env list", + description: "List the environments available for the project.", + options: { + json: { type: "boolean", description: "Output as JSON" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { json, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const environments = await getUserEnvironments({ repo, token, host }); + + const activeEnvironment = (await getEnvironment()) ?? (await getRepositoryName()); + + if (json) { + const results = environments.map((environment) => ({ + domain: environment.domain, + kind: environment.kind, + active: environment.domain === activeEnvironment, + })); + console.info(stringify(results)); + return; + } + + const rows = environments.map((environment) => { + return [ + environment.domain, + environment.kind, + environment.domain === activeEnvironment ? "*" : "", + ]; + }); + console.info(formatTable(rows, { headers: ["DOMAIN", "KIND", "ACTIVE"] })); +}); diff --git a/src/commands/env-set.ts b/src/commands/env-set.ts new file mode 100644 index 0000000..4cf1334 --- /dev/null +++ b/src/commands/env-set.ts @@ -0,0 +1,33 @@ +import { getHost, getToken } from "../auth"; +import { getUserEnvironments, InvalidEnvironmentError, saveEnvironment } from "../environments"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic env set", + description: "Set the active environment.", + positionals: { + env: { description: "Environment domain", required: true }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals }) => { + const [env] = positionals; + + const repo = await getRepositoryName(); + const token = await getToken(); + const host = await getHost(); + const environments = await getUserEnvironments({ repo, token, host }); + + const validEnvironment = environments.some((environment) => environment.domain === env); + if (!validEnvironment) throw new InvalidEnvironmentError(env, environments, repo); + + if (env === repo) { + await saveEnvironment(undefined); + console.info(`Reset to the production environment "${repo}".`); + return; + } + + await saveEnvironment(env); + console.info(`Set the active environment to "${env}".`); +}); diff --git a/src/commands/env-unset.ts b/src/commands/env-unset.ts new file mode 100644 index 0000000..2e1f167 --- /dev/null +++ b/src/commands/env-unset.ts @@ -0,0 +1,14 @@ +import { saveEnvironment } from "../environments"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { getRepositoryName } 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 repo = await getRepositoryName(); + await saveEnvironment(undefined); + console.info(`Reset to the production environment "${repo}".`); +}); diff --git a/src/commands/env.ts b/src/commands/env.ts new file mode 100644 index 0000000..d2e75f4 --- /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/config.ts b/src/config.ts index 84c80fa..b877968 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,4 +4,5 @@ import { getConfigDir } from "./lib/config-dir"; export const CONFIG_DIR = getConfigDir("prismic", env.PRISMIC_CONFIG_DIR); export const CREDENTIALS_PATH = new URL("credentials.json", CONFIG_DIR); +export const ENVIRONMENTS_PATH = new URL("environments.json", CONFIG_DIR); export const UPDATE_NOTIFIER_STATE_PATH = new URL("update-notifier.json", CONFIG_DIR); diff --git a/src/environments.ts b/src/environments.ts index 86de69f..3ed8a75 100644 --- a/src/environments.ts +++ b/src/environments.ts @@ -1,26 +1,70 @@ +import { readFile } from "node:fs/promises"; +import * as z from "zod/mini"; + import { type Environment, getEnvironments } from "./clients/core"; import { getProfile } from "./clients/user"; +import { ENVIRONMENTS_PATH } from "./config"; +import { writeFileRecursive } from "./lib/file"; +import { stringify } from "./lib/json"; +import { findProjectRoot } from "./project"; -export async function resolveEnvironment( - env: string, - config: { repo: string; token: string | undefined; host: string }, -): Promise { - const { repo, token, host } = config; +const EnvironmentsSchema = z.partialRecord(z.string(), z.string()); +type Environments = z.infer; +export async function getEnvironment(): Promise { + const projectRoot = await findProjectRoot(); + const environments = await readEnvironments(); + return environments[projectRoot.toString()]; +} + +export async function saveEnvironment(environment: string | undefined): Promise { + const projectRoot = await findProjectRoot(); + const environments = await readEnvironments(); + if (environment) { + environments[projectRoot.toString()] = environment; + } else { + delete environments[projectRoot.toString()]; + } + await writeFileRecursive(ENVIRONMENTS_PATH, stringify(environments)); +} + +async function readEnvironments(): Promise { + try { + const contents = await readFile(ENVIRONMENTS_PATH, "utf-8"); + const json = JSON.parse(contents); + return z.parse(EnvironmentsSchema, json); + } catch { + return {}; + } +} + +export async function getUserEnvironments(config: { + repo: string; + token: string | undefined; + host: string; +}): Promise { + const { repo, token, host } = config; const [profile, environments] = await Promise.all([ getProfile({ token, host }), getEnvironments({ repo, token, host }), ]); - - const availableEnvironments = environments.filter( + const userEnvironments = environments.filter( (environment) => (environment.kind === "prod" || environment.kind === "stage") && environment.users.some((user) => user.id === profile.shortId), ); + return userEnvironments; +} + +export async function resolveEnvironment( + env: string, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const availableEnvironments = await getUserEnvironments(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/index.ts b/src/index.ts index 8e7c819..d22a37f 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/test/env-active.test.ts b/test/env-active.test.ts new file mode 100644 index 0000000..b90c6ab --- /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 a configured environment", async ({ expect, prismic, home, project }) => { + await writeFile( + new URL(".config/prismic/environments.json", home), + JSON.stringify({ [project.toString()]: "my-stage-env" }), + ); + + const { stdout, exitCode } = await prismic("env", ["active"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("my-stage-env"); +}); + +it("defaults to the production environment", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("env", ["active"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(repo); +}); diff --git a/test/env-list.test.ts b/test/env-list.test.ts new file mode 100644 index 0000000..2247bf6 --- /dev/null +++ b/test/env-list.test.ts @@ -0,0 +1,25 @@ +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", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("env", ["list"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(repo); +}); + +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, kind: "prod" })]), + ); +}); + +// TODO: test listing with a stage environment present and marked active once we +// can create environments on the test repo. diff --git a/test/env-set.test.ts b/test/env-set.test.ts new file mode 100644 index 0000000..3d2f2be --- /dev/null +++ b/test/env-set.test.ts @@ -0,0 +1,34 @@ +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 the active environment to a stage environment (the common +// happy path) once we can create environments on the test repo. + +it("resets to the production environment", async ({ expect, prismic, repo, home, project }) => { + await writeFile( + new URL(".config/prismic/environments.json", home), + JSON.stringify({ [project.toString()]: "my-stage-env" }), + ); + + const { stdout, exitCode } = await prismic("env", ["set", repo]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Reset to the production environment "${repo}".`); + + const after = await readFile(new URL(".config/prismic/environments.json", home), "utf8").catch( + () => "{}", + ); + expect(after).not.toContain("my-stage-env"); +}); + +it("errors on an invalid environment", async ({ expect, prismic, repo }) => { + const { stderr, exitCode } = await prismic("env", ["set", "not-a-real-env"]); + expect(exitCode).toBe(1); + expect(stderr).toContain(repo); +}); diff --git a/test/env-unset.test.ts b/test/env-unset.test.ts new file mode 100644 index 0000000..2d46a8a --- /dev/null +++ b/test/env-unset.test.ts @@ -0,0 +1,25 @@ +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("resets to the production environment", async ({ expect, prismic, repo, home, project }) => { + await writeFile( + new URL(".config/prismic/environments.json", home), + JSON.stringify({ [project.toString()]: "my-stage-env" }), + ); + + const { stdout, exitCode } = await prismic("env", ["unset"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Reset to the production environment "${repo}".`); + + const after = await readFile(new URL(".config/prismic/environments.json", home), "utf8").catch( + () => "{}", + ); + expect(after).not.toContain("my-stage-env"); +}); diff --git a/test/env.test.ts b/test/env.test.ts new file mode 100644 index 0000000..1af8902 --- /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]"); +}); diff --git a/test/it.ts b/test/it.ts index f071994..8dccbdc 100644 --- a/test/it.ts +++ b/test/it.ts @@ -2,7 +2,7 @@ import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/cust import type { Result } from "tinyexec"; import { pascalCase } from "change-case"; -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -33,8 +33,8 @@ export const it = test.extend({ }, // oxlint-disable-next-line no-empty-pattern home: async ({}, use) => { - const dir = await mkdtemp(join(tmpdir(), "prismic-test-")); - await use(pathToFileURL(dir + "/")); + const dir = await realpath(await mkdtemp(join(tmpdir(), "prismic-test-"))); + await use(pathToFileURL(`${dir}/`)); try { await rm(dir, { recursive: true, force: true }); } catch {