Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/commands/env-active.ts
Original file line number Diff line number Diff line change
@@ -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);
});
44 changes: 44 additions & 0 deletions src/commands/env-list.ts
Original file line number Diff line number Diff line change
@@ -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"] }));
});
33 changes: 33 additions & 0 deletions src/commands/env-set.ts
Original file line number Diff line number Diff line change
@@ -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}".`);
});
14 changes: 14 additions & 0 deletions src/commands/env-unset.ts
Original file line number Diff line number Diff line change
@@ -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}".`);
});
28 changes: 28 additions & 0 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
60 changes: 52 additions & 8 deletions src/environments.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const { repo, token, host } = config;
const EnvironmentsSchema = z.partialRecord(z.string(), z.string());
type Environments = z.infer<typeof EnvironmentsSchema>;

export async function getEnvironment(): Promise<string | undefined> {
const projectRoot = await findProjectRoot();
const environments = await readEnvironments();
return environments[projectRoot.toString()];
}

export async function saveEnvironment(environment: string | undefined): Promise<void> {
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<Environments> {
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<Environment[]> {
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<string> {
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 {
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions test/env-active.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
25 changes: 25 additions & 0 deletions test/env-list.test.ts
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions test/env-set.test.ts
Original file line number Diff line number Diff line change
@@ -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 <env> [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);
});
25 changes: 25 additions & 0 deletions test/env-unset.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
13 changes: 13 additions & 0 deletions test/env.test.ts
Original file line number Diff line number Diff line change
@@ -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 <command> [options]");
});

it("supports --help", async ({ expect, prismic }) => {
const { stdout, exitCode } = await prismic("env", ["--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("prismic env <command> [options]");
});
6 changes: 3 additions & 3 deletions test/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -33,8 +33,8 @@ export const it = test.extend<Fixtures>({
},
// 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 {
Expand Down
Loading