@clearance/cli 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ import { type ManagementStore } from "@clearance/management";
2
+ import type { GlobalOpts } from "./output.js";
3
+ /**
4
+ * Open the shared management store used by CLI (same services as API).
5
+ * DATABASE_URL → Postgres SoT; otherwise local JSON for one-command local dev.
6
+ */
7
+ export declare function openStore(opts: GlobalOpts): Promise<ManagementStore>;
8
+ export declare function flushStore(store: ManagementStore): Promise<void>;
9
+ export declare function closeStores(): Promise<void>;
package/dist/store.js ADDED
@@ -0,0 +1,43 @@
1
+ import { createManagementStore, } from "@clearance/management";
2
+ const openStores = new Set();
3
+ let hooksInstalled = false;
4
+ function installFlushHooks() {
5
+ if (hooksInstalled)
6
+ return;
7
+ hooksInstalled = true;
8
+ const flushAll = () => {
9
+ for (const s of openStores) {
10
+ // fire-and-forget best effort on sync exit paths
11
+ void s.ready();
12
+ }
13
+ };
14
+ process.once("beforeExit", () => {
15
+ void Promise.all([...openStores].map((s) => s.ready()));
16
+ });
17
+ process.once("exit", flushAll);
18
+ }
19
+ /**
20
+ * Open the shared management store used by CLI (same services as API).
21
+ * DATABASE_URL → Postgres SoT; otherwise local JSON for one-command local dev.
22
+ */
23
+ export async function openStore(opts) {
24
+ installFlushHooks();
25
+ const store = await createManagementStore({
26
+ dataPath: opts.dataPath,
27
+ databaseUrl: process.env.DATABASE_URL || undefined,
28
+ });
29
+ openStores.add(store);
30
+ return store;
31
+ }
32
+ export async function flushStore(store) {
33
+ await store.ready();
34
+ }
35
+ export async function closeStores() {
36
+ for (const store of openStores) {
37
+ await store.ready();
38
+ if ("destroy" in store && typeof store.destroy === "function") {
39
+ await store.destroy();
40
+ }
41
+ }
42
+ openStores.clear();
43
+ }
@@ -0,0 +1,114 @@
1
+ export interface UpgradeOptions {
2
+ target?: string;
3
+ plan?: string;
4
+ dir?: string;
5
+ healthUrl?: string;
6
+ current?: string;
7
+ dryRun?: boolean;
8
+ yes?: boolean;
9
+ restoreActive?: boolean;
10
+ confirm?: string;
11
+ backupDir?: string;
12
+ }
13
+ export declare function planUpgrade(opts: UpgradeOptions): Promise<{
14
+ schemaVersion: string;
15
+ operation: string;
16
+ dryRun: boolean;
17
+ plan: {
18
+ id: string;
19
+ path: string;
20
+ currentVersion: string;
21
+ targetVersion: string;
22
+ status: string;
23
+ };
24
+ } | {
25
+ schemaVersion: string;
26
+ operation: string;
27
+ dryRun: boolean;
28
+ plan: {
29
+ targetVersion: string;
30
+ currentVersion: string | null;
31
+ directory: string;
32
+ createsArtifacts: boolean;
33
+ };
34
+ }>;
35
+ export declare function applyUpgrade(opts: UpgradeOptions): Promise<{
36
+ operation: string;
37
+ wouldRun: string[];
38
+ schemaVersion: string;
39
+ dryRun: boolean;
40
+ plan: {
41
+ id: string;
42
+ path: string;
43
+ currentVersion: string;
44
+ targetVersion: string;
45
+ status: string;
46
+ };
47
+ } | {
48
+ schemaVersion: string;
49
+ operation: string;
50
+ dryRun: boolean;
51
+ plan: {
52
+ id: string;
53
+ targetVersion: string;
54
+ status: string;
55
+ backupId: string | null;
56
+ rollbackReference: {} | null;
57
+ };
58
+ }>;
59
+ export declare function verifyUpgrade(opts: UpgradeOptions): Promise<{
60
+ schemaVersion: string;
61
+ operation: string;
62
+ dryRun: boolean;
63
+ plan: {
64
+ id: string;
65
+ targetVersion: string;
66
+ status: string;
67
+ updatedAt?: undefined;
68
+ backupId?: undefined;
69
+ };
70
+ wouldRun: string[];
71
+ } | {
72
+ schemaVersion: string;
73
+ operation: string;
74
+ plan: {
75
+ id: string;
76
+ targetVersion: string;
77
+ status: string;
78
+ updatedAt: string | null;
79
+ backupId: string | null;
80
+ };
81
+ dryRun?: undefined;
82
+ wouldRun?: undefined;
83
+ }>;
84
+ export declare function rollbackUpgrade(opts: UpgradeOptions): Promise<{
85
+ schemaVersion: string;
86
+ operation: string;
87
+ dryRun: boolean;
88
+ mode: string;
89
+ activeDatabaseUntouched: boolean;
90
+ wouldModifyActiveDatabase: boolean;
91
+ plan: {
92
+ id: string;
93
+ targetVersion: string;
94
+ status: string;
95
+ };
96
+ wouldRun: string[];
97
+ rollbackReceipt?: undefined;
98
+ receipt?: undefined;
99
+ } | {
100
+ schemaVersion: string;
101
+ operation: string;
102
+ dryRun: boolean;
103
+ mode: string;
104
+ activeDatabaseUntouched: boolean;
105
+ plan: {
106
+ id: string;
107
+ targetVersion: string;
108
+ status: string;
109
+ };
110
+ rollbackReceipt: string;
111
+ receipt: Record<string, unknown>;
112
+ wouldModifyActiveDatabase?: undefined;
113
+ wouldRun?: undefined;
114
+ }>;
@@ -0,0 +1,262 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { constants, existsSync, promises as fs } from "node:fs";
4
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import { ClearanceError } from "@clearance/management";
8
+ const execFileAsync = promisify(execFile);
9
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
10
+ const SOURCE_ROOT = resolve(MODULE_DIR, "../../..");
11
+ const PACKAGED_ROOT = resolve(MODULE_DIR, "ops");
12
+ const OPS_ROOT = existsSync(resolve(PACKAGED_ROOT, "scripts/upgrade-plan.sh"))
13
+ ? PACKAGED_ROOT
14
+ : SOURCE_ROOT;
15
+ const SCRIPTS = {
16
+ plan: resolve(OPS_ROOT, "scripts/upgrade-plan.sh"),
17
+ apply: resolve(OPS_ROOT, "scripts/upgrade-apply.sh"),
18
+ verify: resolve(OPS_ROOT, "scripts/upgrade-verify.sh"),
19
+ rollback: resolve(OPS_ROOT, "scripts/upgrade-rollback.sh"),
20
+ };
21
+ const PLAN_ID = /^upg_[A-Za-z0-9_-]+$/;
22
+ const VERSION = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$/;
23
+ const SHA256 = /^[a-f0-9]{64}$/;
24
+ const MAX_ARTIFACT_BYTES = 1024 * 1024;
25
+ const SCRIPT_TIMEOUT_MS = 30 * 60_000;
26
+ function upgradeError(code, message, stage, remediation) {
27
+ return new ClearanceError({ code, message, stage, remediation, status: 400 });
28
+ }
29
+ function safeDirectory(input, stage) {
30
+ if (!input || input.includes("\0") || !isAbsolute(input)) {
31
+ throw upgradeError("UPGRADE_DIR_INVALID", "Upgrade directory must be an absolute path.", stage, "Pass --dir with an absolute, dedicated upgrade artifact directory.");
32
+ }
33
+ return resolve(input);
34
+ }
35
+ function safeVersion(value, option, stage) {
36
+ if (!value || !VERSION.test(value)) {
37
+ throw upgradeError("UPGRADE_VERSION_INVALID", `--${option} must be a simple release version.`, stage, "Use letters, numbers, dots, underscores, plus, or hyphens only.");
38
+ }
39
+ return value;
40
+ }
41
+ function safeHealthUrl(value) {
42
+ if (!value)
43
+ return undefined;
44
+ try {
45
+ const url = new URL(value);
46
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.hash || url.search)
47
+ throw new Error();
48
+ return url.toString();
49
+ }
50
+ catch {
51
+ throw upgradeError("UPGRADE_HEALTH_URL_INVALID", "--health-url must be a credential-free HTTP(S) URL.", "upgrade.verify", "Pass an absolute http:// or https:// health endpoint without credentials, query parameters, or a fragment.");
52
+ }
53
+ }
54
+ function isInside(parent, child) {
55
+ const path = relative(parent, child);
56
+ return path === "" || (!path.startsWith("..") && !isAbsolute(path));
57
+ }
58
+ function planPaths(planRef, dir, stage) {
59
+ if (!planRef || planRef.includes("\0")) {
60
+ throw upgradeError("UPGRADE_PLAN_INVALID", "--plan is required.", stage, "Pass a plan ID returned by upgrade plan.");
61
+ }
62
+ const planPath = isAbsolute(planRef)
63
+ ? resolve(planRef)
64
+ : PLAN_ID.test(planRef)
65
+ ? resolve(dir, `${planRef}.plan.json`)
66
+ : "";
67
+ if (!planPath || !isInside(dir, planPath) || !planPath.endsWith(".plan.json")) {
68
+ throw upgradeError("UPGRADE_PLAN_INVALID", "--plan must be a plan ID or a plan file inside --dir.", stage, "Use a plan ID returned by upgrade plan and the matching --dir.");
69
+ }
70
+ const id = planPath.slice(planPath.lastIndexOf("/") + 1, -".plan.json".length);
71
+ if (!PLAN_ID.test(id)) {
72
+ throw upgradeError("UPGRADE_PLAN_INVALID", "Plan filename is invalid.", stage, "Use an unmodified plan generated by clearance upgrade plan.");
73
+ }
74
+ return { planPath, statePath: resolve(dir, `${id}.state.json`) };
75
+ }
76
+ async function readArtifact(path, code, stage) {
77
+ let handle;
78
+ try {
79
+ handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
80
+ const stat = await handle.stat();
81
+ if (!stat.isFile() || stat.size < 2 || stat.size > MAX_ARTIFACT_BYTES)
82
+ throw new Error();
83
+ const bytes = await handle.readFile();
84
+ return { parsed: JSON.parse(bytes.toString("utf8")), bytes };
85
+ }
86
+ catch {
87
+ throw upgradeError(code, "Upgrade artifact is missing or invalid JSON.", stage, "Create a new plan with clearance upgrade plan; do not edit upgrade artifacts.");
88
+ }
89
+ finally {
90
+ await handle?.close().catch(() => undefined);
91
+ }
92
+ }
93
+ async function listDirectory(path, stage, allowMissing = false) {
94
+ try {
95
+ return await fs.readdir(path);
96
+ }
97
+ catch (error) {
98
+ if (allowMissing && error.code === "ENOENT")
99
+ return [];
100
+ throw upgradeError("UPGRADE_DIR_UNREADABLE", "Upgrade artifact directory cannot be read.", stage, "Use a readable, dedicated upgrade artifact directory.");
101
+ }
102
+ }
103
+ async function assertArtifactDirectory(path, stage, allowMissing = false) {
104
+ try {
105
+ const stat = await fs.lstat(path);
106
+ if (stat.isSymbolicLink() || !stat.isDirectory())
107
+ throw new Error();
108
+ }
109
+ catch (error) {
110
+ if (allowMissing && error.code === "ENOENT")
111
+ return;
112
+ throw upgradeError("UPGRADE_DIR_UNSAFE", "Upgrade artifact directory must be a real directory, not a file or symlink.", stage, "Use a dedicated local directory owned by the current operator.");
113
+ }
114
+ }
115
+ async function readArtifacts(planRef, dir, stage) {
116
+ await assertArtifactDirectory(dir, stage);
117
+ const { planPath, statePath } = planPaths(planRef, dir, stage);
118
+ const planArtifact = await readArtifact(planPath, "UPGRADE_PLAN_INVALID", stage);
119
+ const stateArtifact = await readArtifact(statePath, "UPGRADE_STATE_INVALID", stage);
120
+ const plan = planArtifact.parsed;
121
+ const state = stateArtifact.parsed;
122
+ const planId = planPath.slice(planPath.lastIndexOf("/") + 1, -".plan.json".length);
123
+ if (plan.immutable !== true || plan.planId !== planId || state.planId !== planId || typeof plan.currentVersion !== "string" || typeof plan.targetVersion !== "string" || typeof state.status !== "string" || typeof state.planSha256 !== "string" || !SHA256.test(state.planSha256)) {
124
+ throw upgradeError("UPGRADE_PLAN_INVALID", "Upgrade plan and state artifacts are inconsistent.", stage, "Create a new plan with clearance upgrade plan; do not edit upgrade artifacts.");
125
+ }
126
+ safeVersion(plan.currentVersion, "current", stage);
127
+ safeVersion(plan.targetVersion, "target", stage);
128
+ if (plan.currentVersion === plan.targetVersion) {
129
+ throw upgradeError("UPGRADE_PLAN_INVALID", "Upgrade plan current and target versions must differ.", stage, "Create a new plan for a different target release.");
130
+ }
131
+ const checksum = createHash("sha256").update(planArtifact.bytes).digest("hex");
132
+ if (checksum !== state.planSha256) {
133
+ throw upgradeError("UPGRADE_PLAN_TAMPERED", "Upgrade plan checksum does not match its state artifact.", stage, "Create a new plan with clearance upgrade plan; do not edit upgrade artifacts.");
134
+ }
135
+ return { planPath, statePath, plan, state };
136
+ }
137
+ async function runScript(operation, args, stage) {
138
+ try {
139
+ await execFileAsync("bash", [SCRIPTS[operation], ...args], {
140
+ cwd: OPS_ROOT,
141
+ env: process.env,
142
+ maxBuffer: 1024 * 1024,
143
+ timeout: SCRIPT_TIMEOUT_MS,
144
+ });
145
+ }
146
+ catch {
147
+ throw upgradeError("UPGRADE_SCRIPT_FAILED", `Upgrade ${operation} failed.`, stage, "Inspect the upgrade plan/state artifacts and protected operational logs, correct the issue, then create a new plan if required.");
148
+ }
149
+ }
150
+ function planResult(plan, state, planPath, dryRun = false) {
151
+ return {
152
+ schemaVersion: "v1",
153
+ operation: "upgrade.plan",
154
+ dryRun,
155
+ plan: {
156
+ id: plan.planId,
157
+ path: planPath,
158
+ currentVersion: plan.currentVersion,
159
+ targetVersion: plan.targetVersion,
160
+ status: state.status,
161
+ },
162
+ };
163
+ }
164
+ export async function planUpgrade(opts) {
165
+ const target = safeVersion(opts.target, "target", "upgrade.plan");
166
+ const dir = safeDirectory(opts.dir, "upgrade.plan");
167
+ const current = opts.current ? safeVersion(opts.current, "current", "upgrade.plan") : undefined;
168
+ if (opts.dryRun) {
169
+ return { schemaVersion: "v1", operation: "upgrade.plan", dryRun: true, plan: { targetVersion: target, currentVersion: current ?? null, directory: dir, createsArtifacts: false } };
170
+ }
171
+ const args = ["--target", target, "--dir", dir];
172
+ if (current)
173
+ args.push("--current", current);
174
+ await assertArtifactDirectory(dir, "upgrade.plan", true);
175
+ const existingPlans = new Set(await listDirectory(dir, "upgrade.plan", true));
176
+ await runScript("plan", args, "upgrade.plan");
177
+ await assertArtifactDirectory(dir, "upgrade.plan");
178
+ const entries = await listDirectory(dir, "upgrade.plan");
179
+ const createdPlans = entries.filter((entry) => entry.endsWith(".plan.json") && !existingPlans.has(entry));
180
+ if (createdPlans.length !== 1)
181
+ throw upgradeError("UPGRADE_ARTIFACT_AMBIGUOUS", "Plan script did not create exactly one identifiable plan artifact.", "upgrade.plan", "Retry in an empty, dedicated upgrade artifact directory with no concurrent upgrade planning process.");
182
+ const artifacts = await readArtifacts(resolve(dir, createdPlans[0]), dir, "upgrade.plan");
183
+ return planResult(artifacts.plan, artifacts.state, artifacts.planPath);
184
+ }
185
+ export async function applyUpgrade(opts) {
186
+ const dir = safeDirectory(opts.dir, "upgrade.apply");
187
+ const artifacts = await readArtifacts(opts.plan ?? "", dir, "upgrade.apply");
188
+ if (opts.dryRun)
189
+ return { ...planResult(artifacts.plan, artifacts.state, artifacts.planPath, true), operation: "upgrade.apply", wouldRun: ["preflight", "verified_backup", "version_hook"] };
190
+ if (!opts.yes)
191
+ throw upgradeError("UPGRADE_APPLY_CONFIRMATION_REQUIRED", "upgrade apply requires --yes.", "upgrade.apply", "Review the plan or run upgrade apply with --dry-run, then rerun with --yes to apply.");
192
+ await runScript("apply", ["--plan", artifacts.planPath, "--dir", dir], "upgrade.apply");
193
+ const result = await readArtifacts(artifacts.planPath, dir, "upgrade.apply");
194
+ return { schemaVersion: "v1", operation: "upgrade.apply", dryRun: false, plan: { id: result.plan.planId, targetVersion: result.plan.targetVersion, status: result.state.status, backupId: result.state.backupId ?? null, rollbackReference: result.state.rollbackReference ?? null } };
195
+ }
196
+ export async function verifyUpgrade(opts) {
197
+ const dir = safeDirectory(opts.dir, "upgrade.verify");
198
+ const healthUrl = safeHealthUrl(opts.healthUrl);
199
+ const artifacts = await readArtifacts(opts.plan ?? "", dir, "upgrade.verify");
200
+ if (opts.dryRun) {
201
+ return {
202
+ schemaVersion: "v1",
203
+ operation: "upgrade.verify",
204
+ dryRun: true,
205
+ plan: { id: artifacts.plan.planId, targetVersion: artifacts.plan.targetVersion, status: artifacts.state.status },
206
+ wouldRun: ["backup_reference_check", "apply_marker_check", ...(healthUrl ? ["health_url_check"] : [])],
207
+ };
208
+ }
209
+ await runScript("verify", ["--plan", artifacts.planPath, "--dir", dir, ...(healthUrl ? ["--health-url", healthUrl] : [])], "upgrade.verify");
210
+ const result = await readArtifacts(artifacts.planPath, dir, "upgrade.verify");
211
+ return { schemaVersion: "v1", operation: "upgrade.verify", plan: { id: result.plan.planId, targetVersion: result.plan.targetVersion, status: result.state.status, updatedAt: result.state.updatedAt ?? null, backupId: result.state.backupId ?? null } };
212
+ }
213
+ export async function rollbackUpgrade(opts) {
214
+ const dir = safeDirectory(opts.dir, "upgrade.rollback");
215
+ const artifacts = await readArtifacts(opts.plan ?? "", dir, "upgrade.rollback");
216
+ const mode = opts.restoreActive ? "active_database_restore" : "isolated_verify_only";
217
+ if (opts.dryRun) {
218
+ return {
219
+ schemaVersion: "v1",
220
+ operation: "upgrade.rollback",
221
+ dryRun: true,
222
+ mode,
223
+ activeDatabaseUntouched: true,
224
+ wouldModifyActiveDatabase: Boolean(opts.restoreActive),
225
+ plan: { id: artifacts.plan.planId, targetVersion: artifacts.plan.targetVersion, status: artifacts.state.status },
226
+ wouldRun: opts.restoreActive
227
+ ? ["advisory_lock", "safety_backup", "staging_restore", "database_swap", "live_verification", "rollback_receipt"]
228
+ : ["backup_checksum_check", "isolated_restore", "reconciliation", "rollback_receipt"],
229
+ };
230
+ }
231
+ if (!opts.yes) {
232
+ throw upgradeError("UPGRADE_ROLLBACK_CONFIRMATION_REQUIRED", "upgrade rollback requires --yes.", "upgrade.rollback", "Review with upgrade rollback --dry-run, then rerun with --yes to verify the rollback reference.");
233
+ }
234
+ const scriptArgs = ["--plan", artifacts.planPath, "--dir", dir];
235
+ if (opts.restoreActive) {
236
+ if (!opts.confirm || !new RegExp(`^RESTORE_ACTIVE:${artifacts.plan.planId}:[A-Za-z_][A-Za-z0-9_]{0,62}$`).test(opts.confirm)) {
237
+ throw upgradeError("UPGRADE_ACTIVE_ROLLBACK_CONFIRMATION_REQUIRED", "Active rollback requires the exact plan and database confirmation token.", "upgrade.rollback", `Pass --restore-active --confirm RESTORE_ACTIVE:${artifacts.plan.planId}:<database> with --yes.`);
238
+ }
239
+ scriptArgs.push("--restore-active", "--confirm", opts.confirm);
240
+ if (opts.backupDir)
241
+ scriptArgs.push("--backup-dir", safeDirectory(opts.backupDir, "upgrade.rollback"));
242
+ }
243
+ await runScript("rollback", scriptArgs, "upgrade.rollback");
244
+ const result = await readArtifacts(artifacts.planPath, dir, "upgrade.rollback");
245
+ const receiptPath = opts.restoreActive
246
+ ? result.state.rollbackReceipt
247
+ : resolve(dir, "rollbacks", `${result.plan.planId}.rollback-drill.json`);
248
+ if (typeof receiptPath !== "string" || !isInside(dir, resolve(receiptPath))) {
249
+ throw upgradeError("UPGRADE_ROLLBACK_RECEIPT_INVALID", "Rollback receipt is missing or escaped the upgrade directory.", "upgrade.rollback", "Inspect protected rollback logs and preserve the upgrade artifact directory for recovery.");
250
+ }
251
+ const receipt = await readArtifact(resolve(receiptPath), "UPGRADE_ROLLBACK_RECEIPT_INVALID", "upgrade.rollback");
252
+ return {
253
+ schemaVersion: "v1",
254
+ operation: "upgrade.rollback",
255
+ dryRun: false,
256
+ mode,
257
+ activeDatabaseUntouched: !opts.restoreActive,
258
+ plan: { id: result.plan.planId, targetVersion: result.plan.targetVersion, status: result.state.status },
259
+ rollbackReceipt: resolve(receiptPath),
260
+ receipt: receipt.parsed,
261
+ };
262
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@clearance/cli",
3
+ "version": "0.1.4",
4
+ "description": "Clearance CLI — operate auth projects, enterprise connections, migrations, and deploys",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/clearance-auth/clearance",
10
+ "directory": "packages/clearance-cli"
11
+ },
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "bin": {
16
+ "clearance": "./dist/index.js"
17
+ },
18
+ "main": "./dist/index.js",
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "dependencies": {
23
+ "commander": "^13.1.0",
24
+ "@clearance/management": "0.1.4"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.13.10",
28
+ "tsx": "^4.19.3",
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^4.1.5"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.json && node scripts/stage-ops.mjs && chmod +x dist/index.js",
34
+ "dev": "tsx src/index.ts",
35
+ "test": "vitest run --maxWorkers=1",
36
+ "typecheck": "tsc -p tsconfig.json --noEmit"
37
+ }
38
+ }