Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/profile-policy-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/plugin-cli": minor
---

Adds `emdash-plugin policy set` for editing an existing package's signed release policy.
213 changes: 213 additions & 0 deletions packages/plugin-cli/src/commands/policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { FileCredentialStore, PublishingClient } from "@emdash-cms/registry-client";
import { defineCommand } from "citty";
import consola from "consola";

import { redirectConsolaToStderr } from "../cli-output.js";
import { loadManifest, MANIFEST_FILENAME, ManifestError } from "../manifest/load.js";
import { checkPublisher, PublisherCheckError } from "../manifest/publisher.js";
import { resumeSession } from "../oauth.js";
import { ProfilePolicyError, setProfilePolicy } from "../policy/api.js";

export const policySetArgs = {
manifest: {
type: "string",
description: `Manifest path or directory. Defaults to ./${MANIFEST_FILENAME}.`,
},
repository: {
type: "string",
description: "HTTPS repository anchor, required only when creating the profile extension.",
},
"require-provenance": {
type: "boolean",
description: "Require verifiable release provenance.",
},
confirmation: {
type: "string",
description: "Release confirmation: escalation-only or always.",
},
approver: {
type: "string",
description: "Approver DID. Repeat to replace the complete approver list.",
},
"clear-approvers": {
type: "boolean",
description: "Replace the complete approver list with an empty list.",
},
yes: {
type: "boolean",
default: false,
description: "Apply the policy change. The default is a dry-run.",
},
json: { type: "boolean", description: "Emit stable single-line JSON to stdout." },
} as const;

export const policySetCommand = defineCommand({
meta: { name: "set", description: "Edit the release policy on an existing package profile" },
args: policySetArgs,
async run({ args, rawArgs }) {
const json = args.json === true;
const restoreReporters = json ? redirectConsolaToStderr() : null;
let exitCode = 0;
try {
const commandInput = resolvePolicyCommandInput(args, rawArgs);
const { manifest, path } = await loadPolicyManifest(commandInput.manifestPath);
const credentials = new FileCredentialStore();
const session = await credentials.current();
if (!session)
throw new CliError(
"Not logged in. Run: emdash-plugin login <handle-or-did>",
"NOT_LOGGED_IN",
);
try {
const publisherCheck = await checkPublisher({
manifestPublisher: manifest.publisher,
sessionDid: session.did,
});
if (publisherCheck.kind === "mismatch") {
throw new CliError(
`Manifest publisher ${publisherCheck.pinnedDid} does not match the active session ${session.did}.`,
"MANIFEST_PUBLISHER_MISMATCH",
);
}
} catch (error) {
if (error instanceof PublisherCheckError) {
throw new CliError(error.message, error.code);
}
throw error;
}
consola.info(`Loaded manifest: ${path}`);
const oauthSession = await resumeSession(session.did);
const publisher = PublishingClient.fromHandler({
handler: oauthSession,
did: session.did,
pds: session.pds,
});
const result = await setProfilePolicy({
publisher,
slug: manifest.slug,
apply: commandInput.apply,
input: commandInput.input,
});
if (json) {
process.stdout.write(
`${JSON.stringify(formatPolicyJsonResult(result, commandInput.apply))}\n`,
);
return;
}
if (result.diffs.length === 0)
consola.success("Package release policy is already up to date.");
else if (result.written) consola.success(`Updated release policy: ${result.profileUri}`);
else consola.info("Dry-run complete. Re-run with --yes to write this policy change.");
} catch (error) {
const code =
error instanceof ProfilePolicyError || error instanceof CliError
? error.code
: "INTERNAL_ERROR";
const message = error instanceof Error ? error.message : String(error);
const detail =
error instanceof ProfilePolicyError || error instanceof CliError ? error.detail : undefined;
consola.error(message);
if (json) {
process.stdout.write(`${JSON.stringify(formatPolicyJsonError(code, message, detail))}\n`);
}
exitCode = error instanceof CliError ? error.exitCode : 1;
} finally {
restoreReporters?.();
}
if (exitCode !== 0) process.exit(exitCode);
},
});

export const policyCommand = defineCommand({
meta: { name: "policy", description: "Manage a package profile's release policy" },
subCommands: { set: policySetCommand },
});

export function resolvePolicyCommandInput(args: Record<string, unknown>, rawArgs: string[]) {
const provenanceFlags = rawArgs.filter(
(arg) => arg === "--require-provenance" || arg === "--no-require-provenance",
);
if (provenanceFlags.length > 1 || Array.isArray(args["require-provenance"])) {
throw new CliError(
"Specify --require-provenance or --no-require-provenance at most once.",
"INVALID_POLICY_FLAGS",
);
}
const clearApproverFlags = rawArgs.filter((arg) => arg === "--clear-approvers");
if (clearApproverFlags.length > 1 || Array.isArray(args["clear-approvers"])) {
throw new CliError("Specify --clear-approvers at most once.", "INVALID_POLICY_FLAGS");
}
const approvers = toStringArray(args.approver);
if (args["clear-approvers"] === true && approvers !== undefined) {
throw new CliError(
"--clear-approvers cannot be combined with --approver.",
"INVALID_POLICY_FLAGS",
);
}
const provenance = args["require-provenance"];
if (provenance !== undefined && typeof provenance !== "boolean") {
throw new CliError("Invalid --require-provenance value.", "INVALID_POLICY_FLAGS");
}
return {
manifestPath: typeof args.manifest === "string" ? args.manifest : `./${MANIFEST_FILENAME}`,
apply: args.yes === true,
input: {
repository: typeof args.repository === "string" ? args.repository : undefined,
requireProvenance: provenance,
confirmation: typeof args.confirmation === "string" ? args.confirmation : undefined,
approvers: args["clear-approvers"] === true ? [] : approvers,
},
};
}

export function formatPolicyJsonResult(
result: Awaited<ReturnType<typeof setProfilePolicy>>,
applied: boolean,
) {
return {
profile: result.profileUri,
written: result.written,
applied,
diffs: result.diffs.map((diff) => ({
field: diff.field,
before: diff.before ?? null,
after: diff.after ?? null,
})),
...(result.cid ? { cid: result.cid } : {}),
};
}

export function formatPolicyJsonError(
code: string,
message: string,
detail?: Record<string, unknown>,
) {
return { error: { code, message, ...(detail === undefined ? {} : { detail }) } };
}

async function loadPolicyManifest(path: string) {
try {
return await loadManifest(path);
} catch (error) {
if (error instanceof ManifestError) throw new CliError(error.message, error.code);
throw error;
}
}

function toStringArray(value: unknown): string[] | undefined {
if (value === undefined) return undefined;
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string");
return typeof value === "string" ? [value] : undefined;
}

class CliError extends Error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] CliError here is new CliError(message, code), but the equivalent classes in publish.ts and update-package.ts are new CliError(message, exitCode, code). That lets those commands return 2 for usage/flag errors and 1 for runtime failures. Aligning the constructor (and passing 2 for INVALID_POLICY_FLAGS, 1 for auth/publisher/manifest errors) would keep the CLI's error handling consistent and make scripts more reliable.

override readonly name = "CliError";
constructor(
message: string,
readonly code: string,
readonly exitCode = 1,
readonly detail?: Record<string, unknown>,
) {
super(message);
}
}
2 changes: 2 additions & 0 deletions packages/plugin-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { infoCommand } from "./commands/info.js";
import { initCommand } from "./commands/init.js";
import { loginCommand } from "./commands/login.js";
import { logoutCommand } from "./commands/logout.js";
import { policyCommand } from "./commands/policy.js";
import { publishCommand } from "./commands/publish.js";
import { searchCommand } from "./commands/search.js";
import { switchCommand } from "./commands/switch.js";
Expand All @@ -56,6 +57,7 @@ const main = defineCommand({
dev: devCommand,
bundle: bundleCommand,
publish: publishCommand,
policy: policyCommand,
"update-package": updatePackageCommand,
validate: validateCommand,
},
Expand Down
Loading
Loading