@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.
- package/LICENSE +29 -0
- package/LICENSE.md +20 -0
- package/dist/api-client.d.ts +18 -0
- package/dist/api-client.js +88 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2494 -0
- package/dist/operator-auth.d.ts +28 -0
- package/dist/operator-auth.js +310 -0
- package/dist/ops/deploy/compose/docker-compose.production.yml +129 -0
- package/dist/ops/deploy/upgrades/README.md +24 -0
- package/dist/ops/deploy/upgrades/steps/0.2.0/apply.sh +62 -0
- package/dist/ops/scripts/backup-create.sh +197 -0
- package/dist/ops/scripts/backup-restore-verify.sh +129 -0
- package/dist/ops/scripts/backup-verify.sh +121 -0
- package/dist/ops/scripts/lib/ops-common.sh +375 -0
- package/dist/ops/scripts/scim-legacy-preflight.sh +103 -0
- package/dist/ops/scripts/upgrade-apply.sh +168 -0
- package/dist/ops/scripts/upgrade-plan.sh +129 -0
- package/dist/ops/scripts/upgrade-preflight.sh +136 -0
- package/dist/ops/scripts/upgrade-rollback.sh +394 -0
- package/dist/ops/scripts/upgrade-verify.sh +98 -0
- package/dist/ops/scripts/validate-production-env.sh +232 -0
- package/dist/output.d.ts +19 -0
- package/dist/output.js +52 -0
- package/dist/remote-dispatch.d.ts +10 -0
- package/dist/remote-dispatch.js +232 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +43 -0
- package/dist/upgrade.d.ts +114 -0
- package/dist/upgrade.js +262 -0
- package/package.json +38 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2494 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { addMember, addMemberInAuth, executeMemberImportPlan, planMemberImport, archiveOrganization, archiveOrganizationInAuth, createBackup, createEnvironment, createProject, createOrganization, createOrgInAuth, createScimConnection, createSetupLink, createSsoConnection, createSsoConnectionReal, testSsoConnectionReal, testSsoConnectionLive, testScimConnectionLive, createScimConnectionReal, testScimConnectionReal, ensureAuthMigrated, createPostgresBackup, createRole, createApiKey, verifyPostgresBackup, restorePostgresBackup, upgradeCheckWithDb, createUser, createUserInAuth, configureSsoConnection, closeAuthBundle, deleteUser, deleteUserInAuth, disableScimConnection, disableScimConnectionReal, disableSsoConnection, disableSsoConnectionReal, disableUser, disableUserInAuth, exportUsers, USERS_EXPORT_MAX_LIMIT, getLatestReadiness, initProject, inspectEnvironment, inspectApiKey, inspectScimConnection, inspectSsoConnection, inspectSession, inspectSessionInAuth, inspectOrganization, inspectUser, listEnvironments, listEventsPage, exportEvents, EVENTS_EXPORT_MAX_LIMIT, EVENTS_TAIL_MAX_LIMIT, beginEventsTail, pollEventsTail, inspectEvent, replayDiagnosticTrace, listMembers, listOrganizations, listOrganizationsPage, listProjects, listRoles, listApiKeys, normalizeAndValidateApiKeyScopes, listScimConnections, listSessionsPage, listSessionsPageInAuth, listSsoConnections, listUsers, listUsersPage, ClearanceError, promoteEnvironment, removeMember, removeMemberInAuth, resolveMembershipId, revokeSession, revokeSessionInAuth, revokeApiKey, rotateScimCredential, rotateSsoCredential, rotateApiKey, updateMember, updateMemberInAuth, updateOrganization, updateOrganizationInAuth, updateUser, updateUserInAuth, loadLegacyFixture, migrationStatus, overviewStats, planMigration, previewMigration, planEnvironmentCreate, planProjectCreate, restoreBackup, rollbackMigrationDurable, runDoctor, runMigrationDurable, runReadinessCheck, testScimConnection, testSsoConnection, updateRole, validateSamlProviderConfig, syncRuntimeOrganizationToManagementDurable, upgradeCheck, verifyBackup, verifyMigrationDurable, validateRole, parseConfigJson, publicConfig, setConfig, validateConfig, validateApiKeyName, diffConfig, generateRuntimeSchema, getRuntimeSchemaStatus, migrateRuntimeSchema, } from "@clearance/management";
|
|
7
|
+
import { CliExitError, fail, printResult } from "./output.js";
|
|
8
|
+
import { closeStores, flushStore, openStore } from "./store.js";
|
|
9
|
+
import { applyUpgrade, planUpgrade, rollbackUpgrade, verifyUpgrade } from "./upgrade.js";
|
|
10
|
+
import { deleteSavedCredential, environmentToken, fetchWhoami, normalizeApiUrl, normalizeProfile, readTokenFromStdin, validateAndSaveCredential, } from "./operator-auth.js";
|
|
11
|
+
import { resolveApiSession } from "./api-client.js";
|
|
12
|
+
import { commandPath, dispatchRemoteCommand, isHostLocalCommand, } from "./remote-dispatch.js";
|
|
13
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
14
|
+
function globals(cmd) {
|
|
15
|
+
const opts = cmd.optsWithGlobals();
|
|
16
|
+
return {
|
|
17
|
+
json: Boolean(opts.json),
|
|
18
|
+
noInput: Boolean(opts.noInput),
|
|
19
|
+
yes: Boolean(opts.yes),
|
|
20
|
+
dryRun: Boolean(opts.dryRun),
|
|
21
|
+
dataPath: opts.dataPath,
|
|
22
|
+
localDirect: Boolean(opts.localDirect) || process.env.CLEARANCE_LOCAL_DIRECT === "1",
|
|
23
|
+
profile: opts.profile,
|
|
24
|
+
apiUrl: opts.apiUrl,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function readConfigCandidate(path) {
|
|
28
|
+
try {
|
|
29
|
+
return parseConfigJson(readFileSync(resolve(path), "utf8"));
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof ClearanceError)
|
|
33
|
+
throw error;
|
|
34
|
+
throw new ClearanceError({
|
|
35
|
+
code: "CONFIG_FILE_UNREADABLE",
|
|
36
|
+
message: "Config file could not be read.",
|
|
37
|
+
stage: "config.parse",
|
|
38
|
+
remediation: "Provide a readable JSON config file.",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const EVENTS_TAIL_MIN_POLL_INTERVAL_MS = 100;
|
|
43
|
+
const EVENTS_TAIL_MAX_POLL_INTERVAL_MS = 60_000;
|
|
44
|
+
/**
|
|
45
|
+
* Shared fail-closed numeric option parser. Invalid input is a structured
|
|
46
|
+
* error, never a silent coercion. Codes are stage-scoped so shipped contracts
|
|
47
|
+
* (EVENTS_TAIL_OPTION_INVALID) stay stable while new sites get their own.
|
|
48
|
+
*/
|
|
49
|
+
function parseBoundedInteger(value, spec) {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return spec.defaultValue;
|
|
52
|
+
if (!/^(?:0|[1-9]\d*)$/.test(value)) {
|
|
53
|
+
throw new ClearanceError({
|
|
54
|
+
code: spec.code,
|
|
55
|
+
message: `--${spec.name} must be an integer`,
|
|
56
|
+
stage: spec.stage,
|
|
57
|
+
status: 400,
|
|
58
|
+
remediation: `Pass an integer from ${spec.minimum} through ${spec.maximum}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const parsed = Number(value);
|
|
62
|
+
if (!Number.isSafeInteger(parsed) ||
|
|
63
|
+
parsed < spec.minimum ||
|
|
64
|
+
parsed > spec.maximum) {
|
|
65
|
+
throw new ClearanceError({
|
|
66
|
+
code: spec.code,
|
|
67
|
+
message: `--${spec.name} must be an integer between ${spec.minimum} and ${spec.maximum}`,
|
|
68
|
+
stage: spec.stage,
|
|
69
|
+
status: 400,
|
|
70
|
+
remediation: `Pass an integer from ${spec.minimum} through ${spec.maximum}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
function parseTailInteger(value, name, minimum, maximum, defaultValue) {
|
|
76
|
+
return parseBoundedInteger(value, {
|
|
77
|
+
name,
|
|
78
|
+
stage: "events.tail",
|
|
79
|
+
code: "EVENTS_TAIL_OPTION_INVALID",
|
|
80
|
+
minimum,
|
|
81
|
+
maximum,
|
|
82
|
+
defaultValue,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function createTailStopSignal() {
|
|
86
|
+
let stopped = false;
|
|
87
|
+
let wake;
|
|
88
|
+
const stop = () => {
|
|
89
|
+
stopped = true;
|
|
90
|
+
wake?.();
|
|
91
|
+
};
|
|
92
|
+
process.once("SIGINT", stop);
|
|
93
|
+
process.once("SIGTERM", stop);
|
|
94
|
+
return {
|
|
95
|
+
get stopped() {
|
|
96
|
+
return stopped;
|
|
97
|
+
},
|
|
98
|
+
wait(intervalMs) {
|
|
99
|
+
if (stopped)
|
|
100
|
+
return Promise.resolve(true);
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
wake = undefined;
|
|
104
|
+
resolve(false);
|
|
105
|
+
}, intervalMs);
|
|
106
|
+
wake = () => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
wake = undefined;
|
|
109
|
+
resolve(true);
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
dispose() {
|
|
114
|
+
process.removeListener("SIGINT", stop);
|
|
115
|
+
process.removeListener("SIGTERM", stop);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function writeTailEvent(json, event) {
|
|
120
|
+
if (json) {
|
|
121
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
process.stdout.write(`${event.createdAt} ${event.action} actor=${event.actor} outcome=${event.outcome} id=${event.id}\n`);
|
|
125
|
+
}
|
|
126
|
+
async function main() {
|
|
127
|
+
const program = new Command("clearance");
|
|
128
|
+
program
|
|
129
|
+
.version(VERSION)
|
|
130
|
+
.description("Clearance CLI — open-source auth operations")
|
|
131
|
+
.option("--json", "Stable JSON output", false)
|
|
132
|
+
.option("--no-input", "Disable prompts (CI/agents)", false)
|
|
133
|
+
.option("--yes", "Confirm destructive actions", false)
|
|
134
|
+
.option("--dry-run", "Preview mutations", false)
|
|
135
|
+
.option("--data-path <path>", "Path to Clearance data store")
|
|
136
|
+
.option("--profile <name>", "Saved API profile")
|
|
137
|
+
.option("--api-url <url>", "Clearance management API origin override")
|
|
138
|
+
.option("--local-direct", "Explicitly operate the local JSON/Postgres store (development and host operations only)", false);
|
|
139
|
+
program.hook("preAction", async (_root, actionCommand) => {
|
|
140
|
+
const g = globals(actionCommand);
|
|
141
|
+
try {
|
|
142
|
+
const path = commandPath(actionCommand);
|
|
143
|
+
if (path === "login" || path === "logout" || path === "whoami")
|
|
144
|
+
return;
|
|
145
|
+
if (g.localDirect)
|
|
146
|
+
return;
|
|
147
|
+
if (isHostLocalCommand(path)) {
|
|
148
|
+
throw new ClearanceError({
|
|
149
|
+
code: "CLI_LOCAL_DIRECT_REQUIRED",
|
|
150
|
+
message: `${path} is an explicitly host-local workflow.`,
|
|
151
|
+
stage: "cli.dispatch",
|
|
152
|
+
remediation: "Rerun with --local-direct after confirming the selected host and database.",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const session = await resolveApiSession({ profile: g.profile, apiUrl: g.apiUrl });
|
|
156
|
+
if (!session) {
|
|
157
|
+
throw new ClearanceError({
|
|
158
|
+
code: "CLI_LOGIN_REQUIRED",
|
|
159
|
+
message: "An authenticated Clearance API profile is required.",
|
|
160
|
+
stage: "cli.dispatch",
|
|
161
|
+
remediation: "Run clearance login --profile <name>, or choose --local-direct for local development.",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const result = await dispatchRemoteCommand(session, path, actionCommand.processedArgs, actionCommand.opts(), g);
|
|
165
|
+
printResult(g, result);
|
|
166
|
+
throw new CliExitError(0);
|
|
167
|
+
}
|
|
168
|
+
catch (cause) {
|
|
169
|
+
if (cause instanceof CliExitError && cause.exitCode === 0)
|
|
170
|
+
throw cause;
|
|
171
|
+
fail(cause, g);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
program
|
|
175
|
+
.command("init")
|
|
176
|
+
.description("Initialize a Clearance project and development environment")
|
|
177
|
+
.option("--name <name>", "Project name", "clearance-app")
|
|
178
|
+
.option("--environment <name>", "Environment name", "development")
|
|
179
|
+
.action(async (opts, cmd) => {
|
|
180
|
+
const g = globals(cmd);
|
|
181
|
+
try {
|
|
182
|
+
const store = await openStore(g);
|
|
183
|
+
if (g.dryRun) {
|
|
184
|
+
printResult(g, { dryRun: true, name: opts.name }, `Would init ${opts.name}`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (process.env.DATABASE_URL) {
|
|
188
|
+
await ensureAuthMigrated();
|
|
189
|
+
}
|
|
190
|
+
const result = initProject(store, {
|
|
191
|
+
name: opts.name,
|
|
192
|
+
environment: opts.environment,
|
|
193
|
+
});
|
|
194
|
+
await flushStore(store);
|
|
195
|
+
printResult(g, {
|
|
196
|
+
ok: true,
|
|
197
|
+
...result,
|
|
198
|
+
database: process.env.DATABASE_URL ? "postgres" : store.backend,
|
|
199
|
+
storeBackend: store.backend,
|
|
200
|
+
}, `Initialized project ${result.project.name} (${result.project.id}) env ${result.environment.slug}`);
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
fail(e, g);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
program
|
|
207
|
+
.command("doctor")
|
|
208
|
+
.description("Installation and configuration health checks")
|
|
209
|
+
.action(async (_, cmd) => {
|
|
210
|
+
const g = globals(cmd);
|
|
211
|
+
try {
|
|
212
|
+
const store = await openStore(g);
|
|
213
|
+
const result = await runDoctor(store);
|
|
214
|
+
printResult(g, result, result.ok ? "Doctor: all critical checks passed" : "Doctor: failures detected");
|
|
215
|
+
if (!result.ok)
|
|
216
|
+
process.exitCode = 2;
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
fail(e, g);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
program
|
|
223
|
+
.command("dev")
|
|
224
|
+
.description("Show verified local development startup paths")
|
|
225
|
+
.action(async (_, cmd) => {
|
|
226
|
+
const g = globals(cmd);
|
|
227
|
+
printResult(g, {
|
|
228
|
+
commands: [
|
|
229
|
+
"clearance init --name my-app",
|
|
230
|
+
"pnpm stack:smoke",
|
|
231
|
+
"pnpm stack:up # persistent; export the required README variables first",
|
|
232
|
+
"pnpm --filter @clearance/sample-b2b dev",
|
|
233
|
+
"pnpm --filter @clearance/api dev",
|
|
234
|
+
"pnpm --filter @clearance/console dev",
|
|
235
|
+
],
|
|
236
|
+
}, "Validate locally with pnpm stack:smoke; use the README persistent-stack variables with pnpm stack:up.");
|
|
237
|
+
});
|
|
238
|
+
// project
|
|
239
|
+
const project = program.command("project").description("Project resources");
|
|
240
|
+
project
|
|
241
|
+
.command("list")
|
|
242
|
+
.action(async (_, cmd) => {
|
|
243
|
+
const g = globals(cmd);
|
|
244
|
+
try {
|
|
245
|
+
const store = await openStore(g);
|
|
246
|
+
printResult(g, { projects: listProjects(store) });
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
fail(e, g);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
project
|
|
253
|
+
.command("inspect")
|
|
254
|
+
.argument("[id]")
|
|
255
|
+
.action(async (id, _, cmd) => {
|
|
256
|
+
const g = globals(cmd);
|
|
257
|
+
try {
|
|
258
|
+
const store = await openStore(g);
|
|
259
|
+
const projects = listProjects(store);
|
|
260
|
+
const p = id ? projects.find((x) => x.id === id) : projects[0];
|
|
261
|
+
if (id && !p) {
|
|
262
|
+
throw new ClearanceError({
|
|
263
|
+
code: "PROJECT_NOT_FOUND",
|
|
264
|
+
message: "Project not found.",
|
|
265
|
+
stage: "project.inspect",
|
|
266
|
+
status: 404,
|
|
267
|
+
remediation: "Pass an existing project id.",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
printResult(g, { project: p, overview: overviewStats(store) });
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
fail(e, g);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
project
|
|
277
|
+
.command("create")
|
|
278
|
+
.requiredOption("--name <name>")
|
|
279
|
+
.action(async (opts, cmd) => {
|
|
280
|
+
const g = globals(cmd);
|
|
281
|
+
try {
|
|
282
|
+
const store = await openStore(g);
|
|
283
|
+
if (g.dryRun) {
|
|
284
|
+
const project = planProjectCreate({ name: opts.name }, store.snapshot.projects);
|
|
285
|
+
printResult(g, { dryRun: true, project }, `Would create project ${project.name}`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const project = createProject(store, { name: opts.name });
|
|
289
|
+
await flushStore(store);
|
|
290
|
+
printResult(g, { project });
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
fail(e, g);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
// env
|
|
297
|
+
const env = program.command("env").description("Environments");
|
|
298
|
+
env
|
|
299
|
+
.command("list")
|
|
300
|
+
.action(async (_, cmd) => {
|
|
301
|
+
const g = globals(cmd);
|
|
302
|
+
try {
|
|
303
|
+
const store = await openStore(g);
|
|
304
|
+
await store.refresh();
|
|
305
|
+
printResult(g, { environments: listEnvironments(store) });
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
fail(e, g);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
env
|
|
312
|
+
.command("inspect")
|
|
313
|
+
.description("Inspect environment and local configuration status (no secrets)")
|
|
314
|
+
.argument("[id]", "Environment id/slug (defaults to operator principal env)")
|
|
315
|
+
.action(async (id, _, cmd) => {
|
|
316
|
+
const g = globals(cmd);
|
|
317
|
+
try {
|
|
318
|
+
const store = await openStore(g);
|
|
319
|
+
await store.refresh();
|
|
320
|
+
const result = inspectEnvironment(store, id);
|
|
321
|
+
printResult(g, result, `Environment ${result.environment.slug} (${result.environment.id})${result.local.active ? " [active]" : ""}`);
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
fail(e, g);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
env
|
|
328
|
+
.command("create")
|
|
329
|
+
.requiredOption("--name <name>")
|
|
330
|
+
.option("--project-id <id>")
|
|
331
|
+
.option("--kind <kind>", "development|preview|production", "development")
|
|
332
|
+
.action(async (opts, cmd) => {
|
|
333
|
+
const g = globals(cmd);
|
|
334
|
+
try {
|
|
335
|
+
const store = await openStore(g);
|
|
336
|
+
const projectId = opts.projectId ??
|
|
337
|
+
store.snapshot.meta.config.projectId ??
|
|
338
|
+
store.snapshot.projects[0]?.id;
|
|
339
|
+
if (g.dryRun) {
|
|
340
|
+
const preview = planEnvironmentCreate(store, {
|
|
341
|
+
projectId,
|
|
342
|
+
name: opts.name,
|
|
343
|
+
kind: opts.kind,
|
|
344
|
+
});
|
|
345
|
+
printResult(g, { dryRun: true, environment: preview }, `Would create environment ${opts.name}`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const environment = createEnvironment(store, {
|
|
349
|
+
projectId,
|
|
350
|
+
name: opts.name,
|
|
351
|
+
kind: opts.kind,
|
|
352
|
+
});
|
|
353
|
+
await flushStore(store);
|
|
354
|
+
printResult(g, { environment });
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
fail(e, g);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
env
|
|
361
|
+
.command("promote")
|
|
362
|
+
.description("Plan environment promotion (validated plan/dry-run; apply blocked without Deployment resource)")
|
|
363
|
+
.requiredOption("--to <id>", "Target environment id or slug")
|
|
364
|
+
.option("--from <id>", "Source environment id/slug (defaults to principal env)")
|
|
365
|
+
.action(async (opts, cmd) => {
|
|
366
|
+
const g = globals(cmd);
|
|
367
|
+
try {
|
|
368
|
+
const store = await openStore(g);
|
|
369
|
+
await store.refresh();
|
|
370
|
+
// Default dry-run unless --yes. Explicit --dry-run always previews.
|
|
371
|
+
const dryRun = g.dryRun || !g.yes;
|
|
372
|
+
const result = promoteEnvironment(store, {
|
|
373
|
+
to: opts.to,
|
|
374
|
+
...(opts.from ? { from: opts.from } : {}),
|
|
375
|
+
dryRun,
|
|
376
|
+
confirm: g.yes && !g.dryRun,
|
|
377
|
+
actor: "cli",
|
|
378
|
+
source: "cli",
|
|
379
|
+
});
|
|
380
|
+
if (!result.dryRun) {
|
|
381
|
+
await flushStore(store);
|
|
382
|
+
}
|
|
383
|
+
printResult(g, result, result.dryRun
|
|
384
|
+
? result.blocked
|
|
385
|
+
? `Would promote ${result.source.slug} → ${result.target.slug} (blocked: no deployment model)`
|
|
386
|
+
: `Would promote ${result.source.slug} → ${result.target.slug}`
|
|
387
|
+
: result.idempotent
|
|
388
|
+
? `Promote no-op (already ${result.source.slug})`
|
|
389
|
+
: `Promote blocked: deployment model unavailable (${result.source.slug} → ${result.target.slug})`);
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
fail(e, g);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
// users — same canonical management ops as API (ManagementStore, not auth runtime tables)
|
|
396
|
+
const users = program.command("users").description("Users / principals");
|
|
397
|
+
users
|
|
398
|
+
.command("list")
|
|
399
|
+
.option("--limit <n>", "Page size (enables keyset cursor pagination)")
|
|
400
|
+
.option("--cursor <cursor>", "Opaque cursor from a previous page's nextCursor")
|
|
401
|
+
.action(async (opts, cmd) => {
|
|
402
|
+
const g = globals(cmd);
|
|
403
|
+
try {
|
|
404
|
+
const store = await openStore(g);
|
|
405
|
+
await store.refresh();
|
|
406
|
+
// Without --limit/--cursor the legacy full-list contract is unchanged.
|
|
407
|
+
if (opts.limit !== undefined || opts.cursor !== undefined) {
|
|
408
|
+
const page = listUsersPage(store, {
|
|
409
|
+
...(opts.limit !== undefined ? { limit: Number(opts.limit) } : {}),
|
|
410
|
+
...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
|
|
411
|
+
});
|
|
412
|
+
printResult(g, { users: page.users, nextCursor: page.nextCursor });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
printResult(g, { users: listUsers(store) });
|
|
416
|
+
}
|
|
417
|
+
catch (e) {
|
|
418
|
+
fail(e, g);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
users
|
|
422
|
+
.command("inspect")
|
|
423
|
+
.argument("<id>")
|
|
424
|
+
.action(async (id, _, cmd) => {
|
|
425
|
+
const g = globals(cmd);
|
|
426
|
+
try {
|
|
427
|
+
const store = await openStore(g);
|
|
428
|
+
await store.refresh();
|
|
429
|
+
printResult(g, { user: inspectUser(store, id) });
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
fail(e, g);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
users
|
|
436
|
+
.command("create")
|
|
437
|
+
.requiredOption("--email <email>")
|
|
438
|
+
.requiredOption("--name <name>")
|
|
439
|
+
.option("--password <password>", "Initial password; generated and returned once when omitted")
|
|
440
|
+
.action(async (opts, cmd) => {
|
|
441
|
+
const g = globals(cmd);
|
|
442
|
+
try {
|
|
443
|
+
const store = await openStore(g);
|
|
444
|
+
if (g.dryRun) {
|
|
445
|
+
printResult(g, { dryRun: true, email: opts.email });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
await store.refresh();
|
|
449
|
+
const generatedPassword = opts.password
|
|
450
|
+
? undefined
|
|
451
|
+
: `Tmp!${randomBytes(18).toString("base64url")}aA1`;
|
|
452
|
+
const password = opts.password ?? generatedPassword;
|
|
453
|
+
const user = process.env.DATABASE_URL
|
|
454
|
+
? await createUserInAuth({
|
|
455
|
+
email: opts.email,
|
|
456
|
+
name: opts.name,
|
|
457
|
+
password,
|
|
458
|
+
managementStore: store,
|
|
459
|
+
})
|
|
460
|
+
: createUser(store, {
|
|
461
|
+
email: opts.email,
|
|
462
|
+
name: opts.name,
|
|
463
|
+
source: "cli",
|
|
464
|
+
});
|
|
465
|
+
await flushStore(store);
|
|
466
|
+
printResult(g, {
|
|
467
|
+
user,
|
|
468
|
+
...(generatedPassword ? { temporaryPassword: generatedPassword } : {}),
|
|
469
|
+
storeBackend: store.backend,
|
|
470
|
+
}, `Created ${user.email} (${user.id})`);
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
fail(e, g);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
users
|
|
477
|
+
.command("update")
|
|
478
|
+
.argument("<id>")
|
|
479
|
+
.option("--name <name>", "Display name")
|
|
480
|
+
.option("--email <email>", "Primary email")
|
|
481
|
+
.option("--status <status>", "active|disabled")
|
|
482
|
+
.action(async (id, opts, cmd) => {
|
|
483
|
+
const g = globals(cmd);
|
|
484
|
+
try {
|
|
485
|
+
const store = await openStore(g);
|
|
486
|
+
await store.refresh();
|
|
487
|
+
// Fail closed before dry-run / mutation (service layer also validates).
|
|
488
|
+
const statusRaw = opts.status;
|
|
489
|
+
if (statusRaw !== undefined &&
|
|
490
|
+
statusRaw !== "active" &&
|
|
491
|
+
statusRaw !== "disabled") {
|
|
492
|
+
throw new Error("Invalid --status; use active or disabled");
|
|
493
|
+
}
|
|
494
|
+
const status = statusRaw;
|
|
495
|
+
if (g.dryRun) {
|
|
496
|
+
printResult(g, {
|
|
497
|
+
dryRun: true,
|
|
498
|
+
id,
|
|
499
|
+
name: opts.name,
|
|
500
|
+
email: opts.email,
|
|
501
|
+
status,
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const user = process.env.DATABASE_URL
|
|
506
|
+
? await updateUserInAuth(store, id, {
|
|
507
|
+
name: opts.name,
|
|
508
|
+
email: opts.email,
|
|
509
|
+
status,
|
|
510
|
+
actor: "cli",
|
|
511
|
+
source: "cli",
|
|
512
|
+
})
|
|
513
|
+
: updateUser(store, id, {
|
|
514
|
+
name: opts.name,
|
|
515
|
+
email: opts.email,
|
|
516
|
+
status,
|
|
517
|
+
actor: "cli",
|
|
518
|
+
source: "cli",
|
|
519
|
+
});
|
|
520
|
+
await flushStore(store);
|
|
521
|
+
printResult(g, { user }, `Updated ${user.email} (${user.id})`);
|
|
522
|
+
}
|
|
523
|
+
catch (e) {
|
|
524
|
+
fail(e, g);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
users
|
|
528
|
+
.command("disable")
|
|
529
|
+
.argument("<id>")
|
|
530
|
+
.action(async (id, _, cmd) => {
|
|
531
|
+
const g = globals(cmd);
|
|
532
|
+
try {
|
|
533
|
+
const store = await openStore(g);
|
|
534
|
+
await store.refresh();
|
|
535
|
+
if (g.dryRun) {
|
|
536
|
+
printResult(g, { dryRun: true, id });
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const user = process.env.DATABASE_URL
|
|
540
|
+
? await disableUserInAuth(store, id, {
|
|
541
|
+
actor: "cli",
|
|
542
|
+
source: "cli",
|
|
543
|
+
})
|
|
544
|
+
: disableUser(store, id, {
|
|
545
|
+
actor: "cli",
|
|
546
|
+
source: "cli",
|
|
547
|
+
});
|
|
548
|
+
await flushStore(store);
|
|
549
|
+
printResult(g, { user }, `Disabled ${user.email} (${user.id})`);
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
fail(e, g);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
users
|
|
556
|
+
.command("delete")
|
|
557
|
+
.argument("<id>")
|
|
558
|
+
.action(async (id, _, cmd) => {
|
|
559
|
+
const g = globals(cmd);
|
|
560
|
+
try {
|
|
561
|
+
if (!g.yes) {
|
|
562
|
+
throw new ClearanceError({
|
|
563
|
+
code: "USER_DELETE_CONFIRM_REQUIRED",
|
|
564
|
+
message: "Refusing user delete without --yes (destructive soft-delete)",
|
|
565
|
+
stage: "users.delete",
|
|
566
|
+
status: 400,
|
|
567
|
+
remediation: "Pass --yes to confirm, or --dry-run to preview",
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
const store = await openStore(g);
|
|
571
|
+
await store.refresh();
|
|
572
|
+
if (g.dryRun) {
|
|
573
|
+
printResult(g, { dryRun: true, id });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const user = process.env.DATABASE_URL
|
|
577
|
+
? await deleteUserInAuth(store, id, {
|
|
578
|
+
actor: "cli",
|
|
579
|
+
source: "cli",
|
|
580
|
+
})
|
|
581
|
+
: deleteUser(store, id, {
|
|
582
|
+
actor: "cli",
|
|
583
|
+
source: "cli",
|
|
584
|
+
});
|
|
585
|
+
await flushStore(store);
|
|
586
|
+
printResult(g, { user }, `Deleted ${user.email} (${user.id})`);
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
fail(e, g);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
users
|
|
593
|
+
.command("export")
|
|
594
|
+
.description("Export scoped users (bounded, redacted, deterministic)")
|
|
595
|
+
.requiredOption("--output <path>", "Output file path (required; refuse overwrite unless --force)")
|
|
596
|
+
.option("--format <fmt>", "json|jsonl", "json")
|
|
597
|
+
.option("--limit <n>", `Max records (1-${USERS_EXPORT_MAX_LIMIT})`, "100")
|
|
598
|
+
.option("--status <status>", "Filter active|disabled")
|
|
599
|
+
.option("--force", "Overwrite existing output file", false)
|
|
600
|
+
.action(async (opts, cmd) => {
|
|
601
|
+
const g = globals(cmd);
|
|
602
|
+
try {
|
|
603
|
+
const store = await openStore(g);
|
|
604
|
+
await store.refresh();
|
|
605
|
+
const limit = Number(opts.limit);
|
|
606
|
+
const envelope = exportUsers(store, {
|
|
607
|
+
outputPath: opts.output,
|
|
608
|
+
format: opts.format,
|
|
609
|
+
limit,
|
|
610
|
+
force: Boolean(opts.force),
|
|
611
|
+
...(opts.status ? { status: opts.status } : {}),
|
|
612
|
+
actor: "cli",
|
|
613
|
+
source: "cli",
|
|
614
|
+
});
|
|
615
|
+
await flushStore(store);
|
|
616
|
+
printResult(g, envelope, `Exported ${envelope.count} user(s) to ${envelope.outputPath}`);
|
|
617
|
+
}
|
|
618
|
+
catch (e) {
|
|
619
|
+
fail(e, g);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
// orgs — same canonical management ops as API
|
|
623
|
+
const orgs = program.command("orgs").description("Organizations");
|
|
624
|
+
orgs
|
|
625
|
+
.command("list")
|
|
626
|
+
.option("--limit <n>", "Page size (enables keyset cursor pagination)")
|
|
627
|
+
.option("--cursor <cursor>", "Opaque cursor from a previous page's nextCursor")
|
|
628
|
+
.action(async (opts, cmd) => {
|
|
629
|
+
const g = globals(cmd);
|
|
630
|
+
try {
|
|
631
|
+
const store = await openStore(g);
|
|
632
|
+
await store.refresh();
|
|
633
|
+
// Without --limit/--cursor the legacy full-list contract is unchanged.
|
|
634
|
+
if (opts.limit !== undefined || opts.cursor !== undefined) {
|
|
635
|
+
const page = listOrganizationsPage(store, {
|
|
636
|
+
...(opts.limit !== undefined ? { limit: Number(opts.limit) } : {}),
|
|
637
|
+
...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
|
|
638
|
+
});
|
|
639
|
+
printResult(g, {
|
|
640
|
+
organizations: page.organizations,
|
|
641
|
+
nextCursor: page.nextCursor,
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
printResult(g, { organizations: listOrganizations(store) });
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
fail(e, g);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
orgs
|
|
652
|
+
.command("inspect")
|
|
653
|
+
.argument("<id>")
|
|
654
|
+
.action(async (id, _, cmd) => {
|
|
655
|
+
const g = globals(cmd);
|
|
656
|
+
try {
|
|
657
|
+
const store = await openStore(g);
|
|
658
|
+
await store.refresh();
|
|
659
|
+
printResult(g, { organization: inspectOrganization(store, id) });
|
|
660
|
+
}
|
|
661
|
+
catch (e) {
|
|
662
|
+
fail(e, g);
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
orgs
|
|
666
|
+
.command("create")
|
|
667
|
+
.requiredOption("--name <name>")
|
|
668
|
+
.option("--slug <slug>")
|
|
669
|
+
.option("--owner-user <id>", "Runtime owner user id (defaults to first active principal)")
|
|
670
|
+
.action(async (opts, cmd) => {
|
|
671
|
+
const g = globals(cmd);
|
|
672
|
+
try {
|
|
673
|
+
const store = await openStore(g);
|
|
674
|
+
await store.refresh();
|
|
675
|
+
let organization;
|
|
676
|
+
if (process.env.DATABASE_URL) {
|
|
677
|
+
const ownerUserId = opts.ownerUser ??
|
|
678
|
+
store.snapshot.principals.find((p) => p.status === "active")?.id;
|
|
679
|
+
if (!ownerUserId) {
|
|
680
|
+
throw new Error("Create a user first or pass --owner-user for runtime organization ownership");
|
|
681
|
+
}
|
|
682
|
+
const runtimeOrg = await createOrgInAuth({
|
|
683
|
+
name: opts.name,
|
|
684
|
+
slug: opts.slug,
|
|
685
|
+
userId: ownerUserId,
|
|
686
|
+
});
|
|
687
|
+
organization = await syncRuntimeOrganizationToManagementDurable(store, runtimeOrg, ownerUserId, { actor: "cli", role: "owner" });
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
organization = createOrganization(store, {
|
|
691
|
+
name: opts.name,
|
|
692
|
+
slug: opts.slug,
|
|
693
|
+
source: "cli",
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
await flushStore(store);
|
|
697
|
+
printResult(g, {
|
|
698
|
+
organization,
|
|
699
|
+
storeBackend: store.backend,
|
|
700
|
+
}, `Created org ${organization.name}`);
|
|
701
|
+
}
|
|
702
|
+
catch (e) {
|
|
703
|
+
fail(e, g);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
orgs
|
|
707
|
+
.command("update")
|
|
708
|
+
.argument("<id>")
|
|
709
|
+
.option("--name <name>", "Display name")
|
|
710
|
+
.option("--slug <slug>", "URL slug (lowercase)")
|
|
711
|
+
.action(async (id, opts, cmd) => {
|
|
712
|
+
const g = globals(cmd);
|
|
713
|
+
try {
|
|
714
|
+
const store = await openStore(g);
|
|
715
|
+
await store.refresh();
|
|
716
|
+
if (g.dryRun) {
|
|
717
|
+
printResult(g, {
|
|
718
|
+
dryRun: true,
|
|
719
|
+
id,
|
|
720
|
+
name: opts.name,
|
|
721
|
+
slug: opts.slug,
|
|
722
|
+
});
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const organization = process.env.DATABASE_URL
|
|
726
|
+
? await updateOrganizationInAuth(store, id, {
|
|
727
|
+
name: opts.name,
|
|
728
|
+
slug: opts.slug,
|
|
729
|
+
actor: "cli",
|
|
730
|
+
source: "cli",
|
|
731
|
+
})
|
|
732
|
+
: updateOrganization(store, id, {
|
|
733
|
+
name: opts.name,
|
|
734
|
+
slug: opts.slug,
|
|
735
|
+
actor: "cli",
|
|
736
|
+
source: "cli",
|
|
737
|
+
});
|
|
738
|
+
await flushStore(store);
|
|
739
|
+
printResult(g, { organization, storeBackend: store.backend }, `Updated org ${organization.name} (${organization.id})`);
|
|
740
|
+
}
|
|
741
|
+
catch (e) {
|
|
742
|
+
fail(e, g);
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
orgs
|
|
746
|
+
.command("archive")
|
|
747
|
+
.argument("<id>")
|
|
748
|
+
.description("Archive an organization (requires --yes; supports --dry-run)")
|
|
749
|
+
.action(async (id, _, cmd) => {
|
|
750
|
+
const g = globals(cmd);
|
|
751
|
+
try {
|
|
752
|
+
const store = await openStore(g);
|
|
753
|
+
await store.refresh();
|
|
754
|
+
// Default dry-run unless --yes. Explicit --dry-run always previews.
|
|
755
|
+
// --no-input without --yes stays non-mutating (plan only).
|
|
756
|
+
const dryRun = g.dryRun || !g.yes;
|
|
757
|
+
const result = process.env.DATABASE_URL
|
|
758
|
+
? await archiveOrganizationInAuth(store, id, {
|
|
759
|
+
dryRun,
|
|
760
|
+
confirm: g.yes && !g.dryRun,
|
|
761
|
+
actor: "cli",
|
|
762
|
+
source: "cli",
|
|
763
|
+
})
|
|
764
|
+
: archiveOrganization(store, id, {
|
|
765
|
+
dryRun,
|
|
766
|
+
confirm: g.yes && !g.dryRun,
|
|
767
|
+
actor: "cli",
|
|
768
|
+
source: "cli",
|
|
769
|
+
});
|
|
770
|
+
if (!result.dryRun) {
|
|
771
|
+
await flushStore(store);
|
|
772
|
+
}
|
|
773
|
+
printResult(g, { ...result, storeBackend: store.backend }, result.dryRun
|
|
774
|
+
? result.wouldChange
|
|
775
|
+
? `Would archive organization ${result.organization.name}`
|
|
776
|
+
: `Would archive organization ${result.organization.name} (already archived)`
|
|
777
|
+
: result.idempotent
|
|
778
|
+
? `Organization ${result.organization.name} already archived`
|
|
779
|
+
: `Archived organization ${result.organization.name}`);
|
|
780
|
+
}
|
|
781
|
+
catch (e) {
|
|
782
|
+
fail(e, g);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
const members = orgs.command("members").description("Organization members");
|
|
786
|
+
members
|
|
787
|
+
.command("import")
|
|
788
|
+
.requiredOption("--org <id>")
|
|
789
|
+
.requiredOption("--file <path>")
|
|
790
|
+
.option("--format <format>", "json|csv (defaults from file extension)")
|
|
791
|
+
.action(async (opts, cmd) => {
|
|
792
|
+
const g = globals(cmd);
|
|
793
|
+
try {
|
|
794
|
+
const explicitFormat = opts.format;
|
|
795
|
+
const inferredFormat = opts.file.toLowerCase().endsWith(".json") ? "json" : opts.file.toLowerCase().endsWith(".csv") ? "csv" : undefined;
|
|
796
|
+
const format = explicitFormat ?? inferredFormat;
|
|
797
|
+
if (format !== "json" && format !== "csv") {
|
|
798
|
+
throw new ClearanceError({ code: "MEMBER_IMPORT_FORMAT_REQUIRED", message: "Member import format is required", stage: "orgs.members.import", remediation: "Use a .json or .csv file, or pass --format json|csv." });
|
|
799
|
+
}
|
|
800
|
+
let content;
|
|
801
|
+
try {
|
|
802
|
+
content = readFileSync(resolve(opts.file), "utf8");
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
throw new ClearanceError({ code: "MEMBER_IMPORT_FILE_UNREADABLE", message: "Member import file could not be read", stage: "orgs.members.import", remediation: "Provide a readable member import file." });
|
|
806
|
+
}
|
|
807
|
+
const store = await openStore(g);
|
|
808
|
+
await store.refresh();
|
|
809
|
+
const plan = planMemberImport(store, { organizationId: opts.org, content, format: format });
|
|
810
|
+
if (g.dryRun) {
|
|
811
|
+
printResult(g, { dryRun: true, ...plan, storeBackend: store.backend }, `Would import ${plan.summary.wouldAdd} members (${plan.summary.idempotent} already members)`);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (!g.yes) {
|
|
815
|
+
throw new ClearanceError({ code: "MEMBER_IMPORT_CONFIRMATION_REQUIRED", message: "Member import requires --yes", stage: "orgs.members.import", remediation: "Review with --dry-run, then rerun with --yes to apply." });
|
|
816
|
+
}
|
|
817
|
+
const result = await executeMemberImportPlan(plan, async (row) => {
|
|
818
|
+
const membership = process.env.DATABASE_URL
|
|
819
|
+
? await addMemberInAuth(store, { organizationId: plan.organizationId, principalId: row.principalId, role: row.role, source: "import", actor: "cli", auditSource: "import" })
|
|
820
|
+
: addMember(store, { organizationId: plan.organizationId, principalId: row.principalId, role: row.role, source: "import", actor: "cli", auditSource: "import" });
|
|
821
|
+
await flushStore(store);
|
|
822
|
+
return membership;
|
|
823
|
+
});
|
|
824
|
+
printResult(g, { ...result, storeBackend: store.backend }, result.partial ? `Imported ${result.success} members; ${result.failure} failed` : `Imported ${result.success} members`);
|
|
825
|
+
if (result.partial)
|
|
826
|
+
process.exitCode = 1;
|
|
827
|
+
}
|
|
828
|
+
catch (e) {
|
|
829
|
+
fail(e, g);
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
members
|
|
833
|
+
.command("list")
|
|
834
|
+
.requiredOption("--org <id>")
|
|
835
|
+
.action(async (opts, cmd) => {
|
|
836
|
+
const g = globals(cmd);
|
|
837
|
+
try {
|
|
838
|
+
const store = await openStore(g);
|
|
839
|
+
await store.refresh();
|
|
840
|
+
printResult(g, {
|
|
841
|
+
members: listMembers(store, opts.org),
|
|
842
|
+
storeBackend: store.backend,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
catch (e) {
|
|
846
|
+
fail(e, g);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
members
|
|
850
|
+
.command("add")
|
|
851
|
+
.requiredOption("--org <id>")
|
|
852
|
+
.requiredOption("--user <id>")
|
|
853
|
+
.option("--role <role>", "Role slug (default: member)", "member")
|
|
854
|
+
.action(async (opts, cmd) => {
|
|
855
|
+
const g = globals(cmd);
|
|
856
|
+
try {
|
|
857
|
+
const store = await openStore(g);
|
|
858
|
+
await store.refresh();
|
|
859
|
+
if (g.dryRun) {
|
|
860
|
+
printResult(g, {
|
|
861
|
+
dryRun: true,
|
|
862
|
+
organizationId: opts.org,
|
|
863
|
+
principalId: opts.user,
|
|
864
|
+
role: opts.role ?? "member",
|
|
865
|
+
});
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const membership = process.env.DATABASE_URL
|
|
869
|
+
? await addMemberInAuth(store, {
|
|
870
|
+
organizationId: opts.org,
|
|
871
|
+
principalId: opts.user,
|
|
872
|
+
role: opts.role,
|
|
873
|
+
actor: "cli",
|
|
874
|
+
auditSource: "cli",
|
|
875
|
+
})
|
|
876
|
+
: addMember(store, {
|
|
877
|
+
organizationId: opts.org,
|
|
878
|
+
principalId: opts.user,
|
|
879
|
+
role: opts.role,
|
|
880
|
+
actor: "cli",
|
|
881
|
+
auditSource: "cli",
|
|
882
|
+
});
|
|
883
|
+
await flushStore(store);
|
|
884
|
+
printResult(g, { membership, storeBackend: store.backend }, `Added ${opts.user} to ${opts.org} as ${membership.role}`);
|
|
885
|
+
}
|
|
886
|
+
catch (e) {
|
|
887
|
+
fail(e, g);
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
members
|
|
891
|
+
.command("update")
|
|
892
|
+
.requiredOption("--org <id>")
|
|
893
|
+
.option("--user <id>", "Principal id of the member")
|
|
894
|
+
.option("--member <id>", "Membership id")
|
|
895
|
+
.requiredOption("--role <role>", "New role slug")
|
|
896
|
+
.action(async (opts, cmd) => {
|
|
897
|
+
const g = globals(cmd);
|
|
898
|
+
try {
|
|
899
|
+
const store = await openStore(g);
|
|
900
|
+
await store.refresh();
|
|
901
|
+
const membershipId = resolveMembershipId(store, {
|
|
902
|
+
organizationId: opts.org,
|
|
903
|
+
principalId: opts.user,
|
|
904
|
+
membershipId: opts.member,
|
|
905
|
+
}, "orgs.members.update");
|
|
906
|
+
if (g.dryRun) {
|
|
907
|
+
printResult(g, {
|
|
908
|
+
dryRun: true,
|
|
909
|
+
organizationId: opts.org,
|
|
910
|
+
membershipId,
|
|
911
|
+
role: opts.role,
|
|
912
|
+
});
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const membership = process.env.DATABASE_URL
|
|
916
|
+
? await updateMemberInAuth(store, membershipId, {
|
|
917
|
+
role: opts.role,
|
|
918
|
+
actor: "cli",
|
|
919
|
+
auditSource: "cli",
|
|
920
|
+
})
|
|
921
|
+
: updateMember(store, membershipId, {
|
|
922
|
+
role: opts.role,
|
|
923
|
+
actor: "cli",
|
|
924
|
+
auditSource: "cli",
|
|
925
|
+
});
|
|
926
|
+
await flushStore(store);
|
|
927
|
+
printResult(g, { membership, storeBackend: store.backend }, `Updated membership ${membership.id} → ${membership.role}`);
|
|
928
|
+
}
|
|
929
|
+
catch (e) {
|
|
930
|
+
fail(e, g);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
members
|
|
934
|
+
.command("remove")
|
|
935
|
+
.requiredOption("--org <id>")
|
|
936
|
+
.option("--user <id>", "Principal id of the member")
|
|
937
|
+
.option("--member <id>", "Membership id")
|
|
938
|
+
.action(async (opts, cmd) => {
|
|
939
|
+
const g = globals(cmd);
|
|
940
|
+
try {
|
|
941
|
+
if (!g.yes) {
|
|
942
|
+
throw new ClearanceError({
|
|
943
|
+
code: "MEMBER_REMOVE_CONFIRM_REQUIRED",
|
|
944
|
+
message: "Refusing membership remove without --yes (destructive)",
|
|
945
|
+
stage: "orgs.members.remove",
|
|
946
|
+
status: 400,
|
|
947
|
+
remediation: "Pass --yes to confirm removal",
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
const store = await openStore(g);
|
|
951
|
+
await store.refresh();
|
|
952
|
+
const membershipId = resolveMembershipId(store, {
|
|
953
|
+
organizationId: opts.org,
|
|
954
|
+
principalId: opts.user,
|
|
955
|
+
membershipId: opts.member,
|
|
956
|
+
}, "orgs.members.remove");
|
|
957
|
+
if (g.dryRun) {
|
|
958
|
+
printResult(g, {
|
|
959
|
+
dryRun: true,
|
|
960
|
+
organizationId: opts.org,
|
|
961
|
+
membershipId,
|
|
962
|
+
});
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const membership = process.env.DATABASE_URL
|
|
966
|
+
? await removeMemberInAuth(store, membershipId, {
|
|
967
|
+
actor: "cli",
|
|
968
|
+
auditSource: "cli",
|
|
969
|
+
})
|
|
970
|
+
: removeMember(store, membershipId, {
|
|
971
|
+
actor: "cli",
|
|
972
|
+
auditSource: "cli",
|
|
973
|
+
});
|
|
974
|
+
await flushStore(store);
|
|
975
|
+
printResult(g, { membership, storeBackend: store.backend }, `Removed membership ${membership.id}`);
|
|
976
|
+
}
|
|
977
|
+
catch (e) {
|
|
978
|
+
fail(e, g);
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
// events — list / tail / inspect / export / replay (shared management services)
|
|
982
|
+
const events = program.command("events").description("Audit events");
|
|
983
|
+
events
|
|
984
|
+
.command("list")
|
|
985
|
+
.option("--limit <n>", "50")
|
|
986
|
+
.option("--action <action>", "Filter by action")
|
|
987
|
+
.option("--org <id>", "Filter by organization id")
|
|
988
|
+
.option("--cursor <cursor>", "Opaque cursor from a previous page's nextCursor")
|
|
989
|
+
.action(async (opts, cmd) => {
|
|
990
|
+
const g = globals(cmd);
|
|
991
|
+
try {
|
|
992
|
+
const store = await openStore(g);
|
|
993
|
+
await store.refresh();
|
|
994
|
+
const limit = parseBoundedInteger(opts.limit, {
|
|
995
|
+
name: "limit",
|
|
996
|
+
stage: "events.list",
|
|
997
|
+
code: "EVENTS_LIST_OPTION_INVALID",
|
|
998
|
+
minimum: 1,
|
|
999
|
+
maximum: 1000,
|
|
1000
|
+
defaultValue: 50,
|
|
1001
|
+
});
|
|
1002
|
+
const page = listEventsPage(store, {
|
|
1003
|
+
limit,
|
|
1004
|
+
...(opts.action ? { action: opts.action } : {}),
|
|
1005
|
+
...(opts.org ? { organizationId: opts.org } : {}),
|
|
1006
|
+
...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
|
|
1007
|
+
});
|
|
1008
|
+
printResult(g, { events: page.events, nextCursor: page.nextCursor });
|
|
1009
|
+
}
|
|
1010
|
+
catch (e) {
|
|
1011
|
+
fail(e, g);
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
events
|
|
1015
|
+
.command("tail")
|
|
1016
|
+
.description("Stream scoped audit events by polling the shared store")
|
|
1017
|
+
.option("--limit <n>", `Initial history (1-${EVENTS_TAIL_MAX_LIMIT})`, "20")
|
|
1018
|
+
.option("--poll-interval <milliseconds>", `Refresh interval (${EVENTS_TAIL_MIN_POLL_INTERVAL_MS}-${EVENTS_TAIL_MAX_POLL_INTERVAL_MS}ms)`, "1000")
|
|
1019
|
+
.option("--max-events <n>", "Exit after N events; 0 means unlimited", "0")
|
|
1020
|
+
.option("--once", "Emit initial history and exit", false)
|
|
1021
|
+
.option("--action <action>", "Filter by action")
|
|
1022
|
+
.option("--org <id>", "Filter by organization id")
|
|
1023
|
+
.action(async (opts, cmd) => {
|
|
1024
|
+
const g = globals(cmd);
|
|
1025
|
+
const stopSignal = createTailStopSignal();
|
|
1026
|
+
try {
|
|
1027
|
+
const limit = parseTailInteger(opts.limit, "limit", 1, EVENTS_TAIL_MAX_LIMIT, 20);
|
|
1028
|
+
const pollInterval = parseTailInteger(opts.pollInterval, "poll-interval", EVENTS_TAIL_MIN_POLL_INTERVAL_MS, EVENTS_TAIL_MAX_POLL_INTERVAL_MS, 1000);
|
|
1029
|
+
// 0 is explicitly unlimited; positive values cap every output path.
|
|
1030
|
+
const maxEvents = parseTailInteger(opts.maxEvents, "max-events", 0, Number.MAX_SAFE_INTEGER, 0);
|
|
1031
|
+
const store = await openStore(g);
|
|
1032
|
+
await store.refresh();
|
|
1033
|
+
if (stopSignal.stopped)
|
|
1034
|
+
return;
|
|
1035
|
+
const { cursor, events: initial } = beginEventsTail(store, {
|
|
1036
|
+
limit,
|
|
1037
|
+
...(opts.action ? { action: opts.action } : {}),
|
|
1038
|
+
...(opts.org ? { organizationId: opts.org } : {}),
|
|
1039
|
+
});
|
|
1040
|
+
let emitted = 0;
|
|
1041
|
+
const emit = (batch) => {
|
|
1042
|
+
for (const event of batch) {
|
|
1043
|
+
if (maxEvents !== 0 && emitted >= maxEvents)
|
|
1044
|
+
return true;
|
|
1045
|
+
writeTailEvent(Boolean(g.json), event);
|
|
1046
|
+
emitted += 1;
|
|
1047
|
+
}
|
|
1048
|
+
return maxEvents !== 0 && emitted >= maxEvents;
|
|
1049
|
+
};
|
|
1050
|
+
if (emit(initial) || opts.once)
|
|
1051
|
+
return;
|
|
1052
|
+
while (true) {
|
|
1053
|
+
if (await stopSignal.wait(pollInterval))
|
|
1054
|
+
return;
|
|
1055
|
+
await store.refresh();
|
|
1056
|
+
if (stopSignal.stopped)
|
|
1057
|
+
return;
|
|
1058
|
+
if (emit(pollEventsTail(store, cursor)))
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
catch (e) {
|
|
1063
|
+
fail(e, g);
|
|
1064
|
+
}
|
|
1065
|
+
finally {
|
|
1066
|
+
stopSignal.dispose();
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
events
|
|
1070
|
+
.command("inspect")
|
|
1071
|
+
.argument("<id>", "Event id or diagnostic trace id")
|
|
1072
|
+
.action(async (id, _, cmd) => {
|
|
1073
|
+
const g = globals(cmd);
|
|
1074
|
+
try {
|
|
1075
|
+
const store = await openStore(g);
|
|
1076
|
+
await store.refresh();
|
|
1077
|
+
const result = inspectEvent(store, id);
|
|
1078
|
+
printResult(g, result, result.event
|
|
1079
|
+
? `Event ${result.event.id}`
|
|
1080
|
+
: result.trace
|
|
1081
|
+
? `Trace ${result.trace.id}`
|
|
1082
|
+
: "Inspect");
|
|
1083
|
+
}
|
|
1084
|
+
catch (e) {
|
|
1085
|
+
fail(e, g);
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
events
|
|
1089
|
+
.command("export")
|
|
1090
|
+
.description("Export scoped audit events (bounded, redacted, deterministic)")
|
|
1091
|
+
.requiredOption("--output <path>", "Output file path (required; refuse overwrite unless --force)")
|
|
1092
|
+
.option("--format <fmt>", "json|jsonl", "json")
|
|
1093
|
+
.option("--limit <n>", `Max records (1-${EVENTS_EXPORT_MAX_LIMIT})`, "100")
|
|
1094
|
+
.option("--action <action>", "Filter by action")
|
|
1095
|
+
.option("--org <id>", "Filter by organization id")
|
|
1096
|
+
.option("--before <iso-timestamp>", "Export only events created strictly before this ISO-8601 timestamp (archival bound)")
|
|
1097
|
+
.option("--force", "Overwrite existing output file", false)
|
|
1098
|
+
.action(async (opts, cmd) => {
|
|
1099
|
+
const g = globals(cmd);
|
|
1100
|
+
try {
|
|
1101
|
+
const store = await openStore(g);
|
|
1102
|
+
await store.refresh();
|
|
1103
|
+
const limit = Number(opts.limit);
|
|
1104
|
+
const envelope = exportEvents(store, {
|
|
1105
|
+
outputPath: opts.output,
|
|
1106
|
+
format: opts.format,
|
|
1107
|
+
limit,
|
|
1108
|
+
force: Boolean(opts.force),
|
|
1109
|
+
...(opts.action ? { action: opts.action } : {}),
|
|
1110
|
+
...(opts.org ? { organizationId: opts.org } : {}),
|
|
1111
|
+
...(opts.before ? { before: opts.before } : {}),
|
|
1112
|
+
actor: "cli",
|
|
1113
|
+
source: "cli",
|
|
1114
|
+
});
|
|
1115
|
+
await flushStore(store);
|
|
1116
|
+
printResult(g, envelope, `Exported ${envelope.count} event(s) to ${envelope.outputPath}`);
|
|
1117
|
+
}
|
|
1118
|
+
catch (e) {
|
|
1119
|
+
fail(e, g);
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
events
|
|
1123
|
+
.command("replay")
|
|
1124
|
+
.description("Re-record a SCIM diagnostic trace (default dry-run; --yes to apply)")
|
|
1125
|
+
.argument("<id>", "SCIM diagnostic trace id")
|
|
1126
|
+
.action(async (id, _, cmd) => {
|
|
1127
|
+
const g = globals(cmd);
|
|
1128
|
+
try {
|
|
1129
|
+
const store = await openStore(g);
|
|
1130
|
+
await store.refresh();
|
|
1131
|
+
// Default dry-run unless --yes. Explicit --dry-run always previews.
|
|
1132
|
+
const dryRun = g.dryRun || !g.yes;
|
|
1133
|
+
const result = replayDiagnosticTrace(store, id, {
|
|
1134
|
+
dryRun,
|
|
1135
|
+
confirm: g.yes && !g.dryRun,
|
|
1136
|
+
actor: "cli",
|
|
1137
|
+
source: "cli",
|
|
1138
|
+
});
|
|
1139
|
+
if (!result.dryRun) {
|
|
1140
|
+
await flushStore(store);
|
|
1141
|
+
}
|
|
1142
|
+
printResult(g, result, result.dryRun
|
|
1143
|
+
? `Would replay diagnostic trace ${result.original.id}`
|
|
1144
|
+
: result.idempotent
|
|
1145
|
+
? `Replay already present for ${result.original.id}`
|
|
1146
|
+
: `Replayed diagnostic trace ${result.original.id} → ${result.trace.id}`);
|
|
1147
|
+
}
|
|
1148
|
+
catch (e) {
|
|
1149
|
+
fail(e, g);
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
// keys — digest-only project/environment scoped API-key lifecycle
|
|
1153
|
+
const keys = program.command("keys").description("Project and environment API keys");
|
|
1154
|
+
keys.command("list").option("--include-revoked", "Include revoked keys", false).action(async (opts, cmd) => {
|
|
1155
|
+
const g = globals(cmd);
|
|
1156
|
+
try {
|
|
1157
|
+
const store = await openStore(g);
|
|
1158
|
+
await store.refresh();
|
|
1159
|
+
const apiKeys = listApiKeys(store, { includeRevoked: Boolean(opts.includeRevoked) });
|
|
1160
|
+
printResult(g, { apiKeys }, `API keys: ${apiKeys.length}`);
|
|
1161
|
+
}
|
|
1162
|
+
catch (e) {
|
|
1163
|
+
fail(e, g);
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
keys.command("create").requiredOption("--name <name>", "Human-readable key name")
|
|
1167
|
+
.option("--scope <scope>", "Repeatable resource:action scope", (value, previous = []) => [...previous, value], [])
|
|
1168
|
+
.action(async (opts, cmd) => {
|
|
1169
|
+
const g = globals(cmd);
|
|
1170
|
+
try {
|
|
1171
|
+
const store = await openStore(g);
|
|
1172
|
+
await store.refresh();
|
|
1173
|
+
if (g.dryRun) {
|
|
1174
|
+
const name = validateApiKeyName(opts.name, "keys.create");
|
|
1175
|
+
const scopes = normalizeAndValidateApiKeyScopes(opts.scope, "keys.create");
|
|
1176
|
+
printResult(g, { dryRun: true, apiKey: { name, scopes }, secretGenerated: false }, `Would create API key ${name}`);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const result = await createApiKey(store, { name: opts.name, scopes: opts.scope, actor: "cli", source: "cli" });
|
|
1180
|
+
await flushStore(store);
|
|
1181
|
+
printResult(g, result, `Created API key ${result.apiKey.id}\nSecret: ${result.secret}\nSave this secret now; it will not be shown again.`);
|
|
1182
|
+
}
|
|
1183
|
+
catch (e) {
|
|
1184
|
+
fail(e, g);
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
keys.command("rotate").argument("<id>", "API key id").action(async (id, _, cmd) => {
|
|
1188
|
+
const g = globals(cmd);
|
|
1189
|
+
try {
|
|
1190
|
+
if (!g.yes && !g.dryRun)
|
|
1191
|
+
throw new ClearanceError({ code: "API_KEY_CONFIRMATION_REQUIRED", message: "API key rotate requires confirmation", stage: "keys.rotate", status: 400, remediation: "Pass --yes to rotate, or --dry-run to preview" });
|
|
1192
|
+
const store = await openStore(g);
|
|
1193
|
+
await store.refresh();
|
|
1194
|
+
if (g.dryRun) {
|
|
1195
|
+
const apiKey = inspectApiKey(store, id);
|
|
1196
|
+
if (apiKey.status === "revoked")
|
|
1197
|
+
throw new ClearanceError({ code: "API_KEY_REVOKED", message: "Revoked API keys cannot be rotated", stage: "keys.rotate", status: 409 });
|
|
1198
|
+
printResult(g, { dryRun: true, apiKey, secretGenerated: false }, `Would rotate API key ${id}`);
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
const result = await rotateApiKey(store, id, { actor: "cli", source: "cli" });
|
|
1202
|
+
await flushStore(store);
|
|
1203
|
+
printResult(g, result, `Rotated API key ${id}\nSecret: ${result.secret}\nSave this secret now; it will not be shown again.`);
|
|
1204
|
+
}
|
|
1205
|
+
catch (e) {
|
|
1206
|
+
fail(e, g);
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
keys.command("revoke").argument("<id>", "API key id").action(async (id, _, cmd) => {
|
|
1210
|
+
const g = globals(cmd);
|
|
1211
|
+
try {
|
|
1212
|
+
if (!g.yes && !g.dryRun)
|
|
1213
|
+
throw new ClearanceError({ code: "API_KEY_CONFIRMATION_REQUIRED", message: "API key revoke requires confirmation", stage: "keys.revoke", status: 400, remediation: "Pass --yes to revoke the API key" });
|
|
1214
|
+
const store = await openStore(g);
|
|
1215
|
+
await store.refresh();
|
|
1216
|
+
if (g.dryRun) {
|
|
1217
|
+
const apiKey = inspectApiKey(store, id);
|
|
1218
|
+
printResult(g, { dryRun: true, apiKey, wouldChange: apiKey.status === "active" }, `Would revoke API key ${id}`);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const result = await revokeApiKey(store, id, { actor: "cli", source: "cli" });
|
|
1222
|
+
await flushStore(store);
|
|
1223
|
+
printResult(g, result, result.idempotent ? `API key ${id} already revoked` : `Revoked API key ${id}`);
|
|
1224
|
+
}
|
|
1225
|
+
catch (e) {
|
|
1226
|
+
fail(e, g);
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
// sessions — list / revoke under principal-derived scope (runtime when DATABASE_URL)
|
|
1230
|
+
const sessions = program.command("sessions").description("Auth sessions");
|
|
1231
|
+
sessions
|
|
1232
|
+
.command("list")
|
|
1233
|
+
.option("--limit <n>", "Max sessions to return (page size)", "100")
|
|
1234
|
+
.option("--cursor <cursor>", "Opaque cursor from a previous page's nextCursor")
|
|
1235
|
+
.action(async (opts, cmd) => {
|
|
1236
|
+
const g = globals(cmd);
|
|
1237
|
+
try {
|
|
1238
|
+
const store = await openStore(g);
|
|
1239
|
+
await store.refresh();
|
|
1240
|
+
// Code matches the shipped service-level validator (sessions.ts
|
|
1241
|
+
// SESSION_LIMIT_INVALID) so the observable contract is unchanged;
|
|
1242
|
+
// this CLI-layer check just fails closed before any store access.
|
|
1243
|
+
const limit = parseBoundedInteger(opts.limit, {
|
|
1244
|
+
name: "limit",
|
|
1245
|
+
stage: "sessions.list",
|
|
1246
|
+
code: "SESSION_LIMIT_INVALID",
|
|
1247
|
+
minimum: 1,
|
|
1248
|
+
maximum: 1000,
|
|
1249
|
+
defaultValue: 100,
|
|
1250
|
+
});
|
|
1251
|
+
const page = process.env.DATABASE_URL
|
|
1252
|
+
? await listSessionsPageInAuth(store, {
|
|
1253
|
+
limit,
|
|
1254
|
+
...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
|
|
1255
|
+
})
|
|
1256
|
+
: listSessionsPage(store, {
|
|
1257
|
+
limit,
|
|
1258
|
+
...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
|
|
1259
|
+
});
|
|
1260
|
+
printResult(g, { sessions: page.sessions, nextCursor: page.nextCursor }, `Sessions: ${page.sessions.length}`);
|
|
1261
|
+
}
|
|
1262
|
+
catch (e) {
|
|
1263
|
+
fail(e, g);
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
sessions
|
|
1267
|
+
.command("revoke")
|
|
1268
|
+
.argument("<id>", "Stable session id")
|
|
1269
|
+
.action(async (id, _, cmd) => {
|
|
1270
|
+
const g = globals(cmd);
|
|
1271
|
+
try {
|
|
1272
|
+
if (!g.yes && !g.dryRun) {
|
|
1273
|
+
throw new ClearanceError({
|
|
1274
|
+
code: "SESSION_CONFIRM_REQUIRED",
|
|
1275
|
+
message: "Session revoke requires confirmation",
|
|
1276
|
+
stage: "sessions.revoke",
|
|
1277
|
+
status: 400,
|
|
1278
|
+
remediation: "Pass --yes to revoke the session",
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
const store = await openStore(g);
|
|
1282
|
+
await store.refresh();
|
|
1283
|
+
if (g.dryRun) {
|
|
1284
|
+
const session = process.env.DATABASE_URL
|
|
1285
|
+
? await inspectSessionInAuth(store, id)
|
|
1286
|
+
: inspectSession(store, id);
|
|
1287
|
+
printResult(g, {
|
|
1288
|
+
dryRun: true,
|
|
1289
|
+
session,
|
|
1290
|
+
wouldChange: session.status === "active",
|
|
1291
|
+
}, `Would revoke session ${id}`);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const result = process.env.DATABASE_URL
|
|
1295
|
+
? await revokeSessionInAuth(store, id, {
|
|
1296
|
+
actor: "cli",
|
|
1297
|
+
source: "cli",
|
|
1298
|
+
})
|
|
1299
|
+
: revokeSession(store, id, {
|
|
1300
|
+
actor: "cli",
|
|
1301
|
+
source: "cli",
|
|
1302
|
+
});
|
|
1303
|
+
await flushStore(store);
|
|
1304
|
+
printResult(g, result, result.idempotent
|
|
1305
|
+
? `Session ${result.session.id} already revoked`
|
|
1306
|
+
: `Revoked session ${result.session.id}`);
|
|
1307
|
+
}
|
|
1308
|
+
catch (e) {
|
|
1309
|
+
fail(e, g);
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
// roles — canonical project/environment-scoped role services shared with API/console
|
|
1313
|
+
const roles = program.command("roles").description("Custom access-control roles");
|
|
1314
|
+
roles
|
|
1315
|
+
.command("list")
|
|
1316
|
+
.action(async (_, cmd) => {
|
|
1317
|
+
const g = globals(cmd);
|
|
1318
|
+
try {
|
|
1319
|
+
const store = await openStore(g);
|
|
1320
|
+
await store.refresh();
|
|
1321
|
+
printResult(g, { roles: listRoles(store) });
|
|
1322
|
+
}
|
|
1323
|
+
catch (e) {
|
|
1324
|
+
fail(e, g);
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
roles
|
|
1328
|
+
.command("validate")
|
|
1329
|
+
.option("--name <name>")
|
|
1330
|
+
.option("--slug <slug>")
|
|
1331
|
+
.option("--permission <permission...>", "One or more resource:action permissions")
|
|
1332
|
+
.action(async (opts, cmd) => {
|
|
1333
|
+
const g = globals(cmd);
|
|
1334
|
+
try {
|
|
1335
|
+
const store = await openStore(g);
|
|
1336
|
+
await store.refresh();
|
|
1337
|
+
const validation = validateRole(store, {
|
|
1338
|
+
name: opts.name,
|
|
1339
|
+
slug: opts.slug,
|
|
1340
|
+
permissions: opts.permission,
|
|
1341
|
+
});
|
|
1342
|
+
printResult(g, { validation }, "Role definition is valid");
|
|
1343
|
+
}
|
|
1344
|
+
catch (e) {
|
|
1345
|
+
fail(e, g);
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
roles
|
|
1349
|
+
.command("create")
|
|
1350
|
+
.requiredOption("--name <name>")
|
|
1351
|
+
.option("--slug <slug>")
|
|
1352
|
+
.option("--description <description>")
|
|
1353
|
+
.requiredOption("--permission <permission...>", "One or more resource:action permissions")
|
|
1354
|
+
.action(async (opts, cmd) => {
|
|
1355
|
+
const g = globals(cmd);
|
|
1356
|
+
try {
|
|
1357
|
+
const store = await openStore(g);
|
|
1358
|
+
await store.refresh();
|
|
1359
|
+
const draft = {
|
|
1360
|
+
name: opts.name,
|
|
1361
|
+
slug: opts.slug,
|
|
1362
|
+
description: opts.description,
|
|
1363
|
+
permissions: opts.permission,
|
|
1364
|
+
};
|
|
1365
|
+
if (g.dryRun) {
|
|
1366
|
+
const validation = validateRole(store, draft);
|
|
1367
|
+
printResult(g, { dryRun: true, validation });
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
const role = await createRole(store, {
|
|
1371
|
+
...draft,
|
|
1372
|
+
actor: "cli",
|
|
1373
|
+
source: "cli",
|
|
1374
|
+
});
|
|
1375
|
+
await flushStore(store);
|
|
1376
|
+
printResult(g, { role }, `Created role ${role.slug}`);
|
|
1377
|
+
}
|
|
1378
|
+
catch (e) {
|
|
1379
|
+
fail(e, g);
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
roles
|
|
1383
|
+
.command("update")
|
|
1384
|
+
.argument("<id>")
|
|
1385
|
+
.option("--name <name>")
|
|
1386
|
+
.option("--description <description>")
|
|
1387
|
+
.option("--permission <permission...>", "Replacement resource:action permissions")
|
|
1388
|
+
.action(async (id, opts, cmd) => {
|
|
1389
|
+
const g = globals(cmd);
|
|
1390
|
+
try {
|
|
1391
|
+
const store = await openStore(g);
|
|
1392
|
+
await store.refresh();
|
|
1393
|
+
const patch = {
|
|
1394
|
+
name: opts.name,
|
|
1395
|
+
description: opts.description,
|
|
1396
|
+
permissions: opts.permission,
|
|
1397
|
+
};
|
|
1398
|
+
if (g.dryRun) {
|
|
1399
|
+
const validation = validateRole(store, patch);
|
|
1400
|
+
printResult(g, { dryRun: true, id, validation });
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
const role = await updateRole(store, id, {
|
|
1404
|
+
...patch,
|
|
1405
|
+
actor: "cli",
|
|
1406
|
+
source: "cli",
|
|
1407
|
+
});
|
|
1408
|
+
await flushStore(store);
|
|
1409
|
+
printResult(g, { role }, `Updated role ${role.slug}`);
|
|
1410
|
+
}
|
|
1411
|
+
catch (e) {
|
|
1412
|
+
fail(e, g);
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
// sso
|
|
1416
|
+
const sso = program.command("sso").description("Enterprise SSO connections");
|
|
1417
|
+
sso
|
|
1418
|
+
.command("create")
|
|
1419
|
+
.requiredOption("--org <id>")
|
|
1420
|
+
.requiredOption("--provider <name>")
|
|
1421
|
+
.option("--protocol <protocol>", "oidc")
|
|
1422
|
+
.requiredOption("--issuer <url>")
|
|
1423
|
+
.option("--audience <aud>")
|
|
1424
|
+
.option("--domain <domain>")
|
|
1425
|
+
.option("--entry-point <url>", "SAML identity provider SSO URL")
|
|
1426
|
+
.option("--certificate <path>", "SAML identity provider signing certificate PEM")
|
|
1427
|
+
.action(async (opts, cmd) => {
|
|
1428
|
+
const g = globals(cmd);
|
|
1429
|
+
try {
|
|
1430
|
+
const store = await openStore(g);
|
|
1431
|
+
const saml = opts.protocol === "saml"
|
|
1432
|
+
? validateSamlProviderConfig({
|
|
1433
|
+
entryPoint: opts.entryPoint,
|
|
1434
|
+
certificate: opts.certificate
|
|
1435
|
+
? readFileSync(resolve(opts.certificate), "utf8")
|
|
1436
|
+
: undefined,
|
|
1437
|
+
})
|
|
1438
|
+
: undefined;
|
|
1439
|
+
const connection = process.env.DATABASE_URL
|
|
1440
|
+
? await createSsoConnectionReal(store, {
|
|
1441
|
+
organizationId: opts.org,
|
|
1442
|
+
provider: opts.provider,
|
|
1443
|
+
protocol: opts.protocol === "saml" ? "saml" : "oidc",
|
|
1444
|
+
issuer: opts.issuer ?? "https://login.microsoftonline.com/common/v2.0",
|
|
1445
|
+
audience: opts.audience,
|
|
1446
|
+
domain: opts.domain,
|
|
1447
|
+
samlEntryPoint: saml?.entryPoint,
|
|
1448
|
+
samlCertificate: saml?.certificate,
|
|
1449
|
+
})
|
|
1450
|
+
: createSsoConnection(store, {
|
|
1451
|
+
organizationId: opts.org,
|
|
1452
|
+
provider: opts.provider,
|
|
1453
|
+
protocol: opts.protocol === "saml" ? "saml" : "oidc",
|
|
1454
|
+
issuer: opts.issuer,
|
|
1455
|
+
audience: opts.audience,
|
|
1456
|
+
domains: opts.domain ? [opts.domain] : [],
|
|
1457
|
+
samlEntryPoint: saml?.entryPoint,
|
|
1458
|
+
samlCertificate: saml?.certificate,
|
|
1459
|
+
});
|
|
1460
|
+
await flushStore(store);
|
|
1461
|
+
printResult(g, { connection });
|
|
1462
|
+
}
|
|
1463
|
+
catch (e) {
|
|
1464
|
+
fail(e, g);
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
sso
|
|
1468
|
+
.command("configure")
|
|
1469
|
+
.argument("<id>")
|
|
1470
|
+
.option("--issuer <url>")
|
|
1471
|
+
.option("--audience <aud>")
|
|
1472
|
+
.option("--domain <domain>")
|
|
1473
|
+
.action(async (id, opts, cmd) => {
|
|
1474
|
+
const g = globals(cmd);
|
|
1475
|
+
try {
|
|
1476
|
+
const store = await openStore(g);
|
|
1477
|
+
if (g.dryRun) {
|
|
1478
|
+
const current = inspectSsoConnection(store, id);
|
|
1479
|
+
printResult(g, {
|
|
1480
|
+
dryRun: true,
|
|
1481
|
+
connection: current,
|
|
1482
|
+
proposed: {
|
|
1483
|
+
issuer: opts.issuer ?? current.issuer,
|
|
1484
|
+
audience: opts.audience ?? current.audience,
|
|
1485
|
+
domains: opts.domain ? [opts.domain] : current.domains,
|
|
1486
|
+
},
|
|
1487
|
+
}, `Would configure SSO connection ${id}.`);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
const connection = configureSsoConnection(store, id, {
|
|
1491
|
+
issuer: opts.issuer,
|
|
1492
|
+
audience: opts.audience,
|
|
1493
|
+
domains: opts.domain ? [opts.domain] : undefined,
|
|
1494
|
+
}, { actor: "cli", source: "cli" });
|
|
1495
|
+
await flushStore(store);
|
|
1496
|
+
printResult(g, { connection });
|
|
1497
|
+
}
|
|
1498
|
+
catch (e) {
|
|
1499
|
+
fail(e, g);
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
sso
|
|
1503
|
+
.command("test")
|
|
1504
|
+
.argument("<id>")
|
|
1505
|
+
.option("--fixture <name>", "ok|wrong-issuer|wrong-audience|malformed|expired|clock-skew|replay")
|
|
1506
|
+
.option("--live", "Probe the REAL configured issuer (read-only discovery/JWKS conformance). Requires --yes, HTTPS, non-loopback.", false)
|
|
1507
|
+
.action(async (id, opts, cmd) => {
|
|
1508
|
+
const g = globals(cmd);
|
|
1509
|
+
try {
|
|
1510
|
+
if (opts.live && opts.fixture) {
|
|
1511
|
+
throw new ClearanceError({
|
|
1512
|
+
code: "SSO_TEST_MODE_CONFLICT",
|
|
1513
|
+
message: "--live and --fixture are mutually exclusive",
|
|
1514
|
+
stage: "sso.test",
|
|
1515
|
+
status: 400,
|
|
1516
|
+
remediation: "Use --live for the real tenant, or --fixture for the simulation lab",
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
if (opts.live && !g.yes) {
|
|
1520
|
+
throw new ClearanceError({
|
|
1521
|
+
code: "SSO_LIVE_CONFIRM_REQUIRED",
|
|
1522
|
+
message: "Live conformance contacts the real external IdP and requires confirmation",
|
|
1523
|
+
stage: "sso.test",
|
|
1524
|
+
status: 400,
|
|
1525
|
+
remediation: "Pass --yes to arm the live probe (read-only; no tenant mutation)",
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
const store = await openStore(g);
|
|
1529
|
+
const result = opts.live
|
|
1530
|
+
? await testSsoConnectionLive(store, id)
|
|
1531
|
+
: process.env.DATABASE_URL
|
|
1532
|
+
? testSsoConnectionReal(store, id, { fixture: opts.fixture ?? "ok" })
|
|
1533
|
+
: testSsoConnection(store, id, { fixture: opts.fixture ?? "ok" });
|
|
1534
|
+
if (opts.live)
|
|
1535
|
+
await flushStore(store);
|
|
1536
|
+
printResult(g, result, "SSO test passed");
|
|
1537
|
+
}
|
|
1538
|
+
catch (e) {
|
|
1539
|
+
fail(e, g);
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
sso
|
|
1543
|
+
.command("list")
|
|
1544
|
+
.option("--org <id>")
|
|
1545
|
+
.action(async (opts, cmd) => {
|
|
1546
|
+
const g = globals(cmd);
|
|
1547
|
+
try {
|
|
1548
|
+
const store = await openStore(g);
|
|
1549
|
+
printResult(g, { connections: listSsoConnections(store, opts.org) });
|
|
1550
|
+
}
|
|
1551
|
+
catch (e) {
|
|
1552
|
+
fail(e, g);
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
sso
|
|
1556
|
+
.command("setup-link")
|
|
1557
|
+
.requiredOption("--org <id>")
|
|
1558
|
+
.action(async (opts, cmd) => {
|
|
1559
|
+
const g = globals(cmd);
|
|
1560
|
+
try {
|
|
1561
|
+
const store = await openStore(g);
|
|
1562
|
+
const link = createSetupLink(store, { organizationId: opts.org, kind: "sso" });
|
|
1563
|
+
await flushStore(store);
|
|
1564
|
+
printResult(g, link);
|
|
1565
|
+
}
|
|
1566
|
+
catch (e) {
|
|
1567
|
+
fail(e, g);
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
sso
|
|
1571
|
+
.command("rotate")
|
|
1572
|
+
.description("Rotate SSO client-secret credential envelope under the current key")
|
|
1573
|
+
.argument("<id>", "SSO connection id")
|
|
1574
|
+
.action(async (id, _, cmd) => {
|
|
1575
|
+
const g = globals(cmd);
|
|
1576
|
+
try {
|
|
1577
|
+
if (!g.yes && !g.dryRun) {
|
|
1578
|
+
throw new ClearanceError({
|
|
1579
|
+
code: "SSO_CONFIRM_REQUIRED",
|
|
1580
|
+
message: "SSO credential rotate requires confirmation",
|
|
1581
|
+
stage: "sso.rotate",
|
|
1582
|
+
status: 400,
|
|
1583
|
+
remediation: "Pass --yes to rotate, or --dry-run to preview",
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
const store = await openStore(g);
|
|
1587
|
+
await store.refresh();
|
|
1588
|
+
if (g.dryRun) {
|
|
1589
|
+
const connection = inspectSsoConnection(store, id);
|
|
1590
|
+
const hasSecret = Boolean(connection.hasClientSecret ??
|
|
1591
|
+
connection.clientSecretFingerprint);
|
|
1592
|
+
if (!hasSecret) {
|
|
1593
|
+
throw new ClearanceError({
|
|
1594
|
+
code: "SSO_NO_SECRET",
|
|
1595
|
+
message: "No encrypted client secret to rotate",
|
|
1596
|
+
stage: "sso.rotate",
|
|
1597
|
+
status: 400,
|
|
1598
|
+
remediation: "Configure a client secret before rotating",
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
printResult(g, {
|
|
1602
|
+
dryRun: true,
|
|
1603
|
+
connection,
|
|
1604
|
+
wouldChange: true,
|
|
1605
|
+
}, `Would rotate SSO credential for ${id}`);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
const connection = rotateSsoCredential(store, id, {
|
|
1609
|
+
actor: "cli",
|
|
1610
|
+
source: "cli",
|
|
1611
|
+
});
|
|
1612
|
+
await flushStore(store);
|
|
1613
|
+
printResult(g, { connection }, `Rotated SSO credential for ${id}`);
|
|
1614
|
+
}
|
|
1615
|
+
catch (e) {
|
|
1616
|
+
fail(e, g);
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
sso
|
|
1620
|
+
.command("disable")
|
|
1621
|
+
.description("Disable an SSO connection")
|
|
1622
|
+
.argument("<id>", "SSO connection id")
|
|
1623
|
+
.action(async (id, _, cmd) => {
|
|
1624
|
+
const g = globals(cmd);
|
|
1625
|
+
try {
|
|
1626
|
+
if (!g.yes && !g.dryRun) {
|
|
1627
|
+
throw new ClearanceError({
|
|
1628
|
+
code: "SSO_CONFIRM_REQUIRED",
|
|
1629
|
+
message: "SSO disable requires confirmation",
|
|
1630
|
+
stage: "sso.disable",
|
|
1631
|
+
status: 400,
|
|
1632
|
+
remediation: "Pass --yes to disable, or --dry-run to preview",
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
const store = await openStore(g);
|
|
1636
|
+
await store.refresh();
|
|
1637
|
+
if (g.dryRun) {
|
|
1638
|
+
const connection = inspectSsoConnection(store, id);
|
|
1639
|
+
printResult(g, {
|
|
1640
|
+
dryRun: true,
|
|
1641
|
+
connection,
|
|
1642
|
+
wouldChange: connection.status !== "disabled",
|
|
1643
|
+
}, `Would disable SSO connection ${id}`);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const result = process.env.DATABASE_URL
|
|
1647
|
+
? await disableSsoConnectionReal(store, id, {
|
|
1648
|
+
actor: "cli",
|
|
1649
|
+
source: "cli",
|
|
1650
|
+
})
|
|
1651
|
+
: disableSsoConnection(store, id, {
|
|
1652
|
+
actor: "cli",
|
|
1653
|
+
source: "cli",
|
|
1654
|
+
});
|
|
1655
|
+
await flushStore(store);
|
|
1656
|
+
printResult(g, result, result.idempotent
|
|
1657
|
+
? `SSO connection ${result.connection.id} already disabled`
|
|
1658
|
+
: `Disabled SSO connection ${result.connection.id}`);
|
|
1659
|
+
}
|
|
1660
|
+
catch (e) {
|
|
1661
|
+
fail(e, g);
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
// scim
|
|
1665
|
+
const scim = program.command("scim").description("SCIM directory connections");
|
|
1666
|
+
scim
|
|
1667
|
+
.command("create")
|
|
1668
|
+
.requiredOption("--org <id>")
|
|
1669
|
+
.requiredOption("--provider <name>")
|
|
1670
|
+
.option("--endpoint <url>", "External SCIM base URL (required for live conformance probes)")
|
|
1671
|
+
.action(async (opts, cmd) => {
|
|
1672
|
+
const g = globals(cmd);
|
|
1673
|
+
try {
|
|
1674
|
+
const store = await openStore(g);
|
|
1675
|
+
const connection = process.env.DATABASE_URL
|
|
1676
|
+
? await createScimConnectionReal(store, {
|
|
1677
|
+
organizationId: opts.org,
|
|
1678
|
+
provider: opts.provider,
|
|
1679
|
+
...(opts.endpoint ? { endpoint: opts.endpoint } : {}),
|
|
1680
|
+
})
|
|
1681
|
+
: createScimConnection(store, {
|
|
1682
|
+
organizationId: opts.org,
|
|
1683
|
+
provider: opts.provider,
|
|
1684
|
+
...(opts.endpoint ? { endpoint: opts.endpoint } : {}),
|
|
1685
|
+
});
|
|
1686
|
+
await flushStore(store);
|
|
1687
|
+
printResult(g, { connection });
|
|
1688
|
+
}
|
|
1689
|
+
catch (e) {
|
|
1690
|
+
fail(e, g);
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
scim
|
|
1694
|
+
.command("test")
|
|
1695
|
+
.argument("<id>")
|
|
1696
|
+
.option("--apply", "Apply instead of dry-run", false)
|
|
1697
|
+
.option("--fixture <name>", "ok|malformed|unauthorized")
|
|
1698
|
+
.option("--live", "Probe the REAL configured SCIM endpoint (read-only GETs). Requires --yes, HTTPS, non-loopback.", false)
|
|
1699
|
+
.action(async (id, opts, cmd) => {
|
|
1700
|
+
const g = globals(cmd);
|
|
1701
|
+
try {
|
|
1702
|
+
if (opts.live && opts.fixture) {
|
|
1703
|
+
throw new ClearanceError({
|
|
1704
|
+
code: "SCIM_TEST_MODE_CONFLICT",
|
|
1705
|
+
message: "--live and --fixture are mutually exclusive",
|
|
1706
|
+
stage: "scim.test",
|
|
1707
|
+
status: 400,
|
|
1708
|
+
remediation: "Use --live for the real tenant, or --fixture for the simulation lab",
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
if (opts.live && !g.yes) {
|
|
1712
|
+
throw new ClearanceError({
|
|
1713
|
+
code: "SCIM_LIVE_CONFIRM_REQUIRED",
|
|
1714
|
+
message: "Live conformance contacts the real external SCIM endpoint and requires confirmation",
|
|
1715
|
+
stage: "scim.test",
|
|
1716
|
+
status: 400,
|
|
1717
|
+
remediation: "Pass --yes to arm the live probe (read-only; no tenant mutation)",
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
const store = await openStore(g);
|
|
1721
|
+
const result = opts.live
|
|
1722
|
+
? await testScimConnectionLive(store, id)
|
|
1723
|
+
: process.env.DATABASE_URL
|
|
1724
|
+
? await testScimConnectionReal(store, id, {
|
|
1725
|
+
dryRun: !opts.apply,
|
|
1726
|
+
fixture: opts.fixture ?? "ok",
|
|
1727
|
+
})
|
|
1728
|
+
: testScimConnection(store, id, {
|
|
1729
|
+
dryRun: !opts.apply,
|
|
1730
|
+
fixture: opts.fixture ?? "ok",
|
|
1731
|
+
});
|
|
1732
|
+
if (opts.live)
|
|
1733
|
+
await flushStore(store);
|
|
1734
|
+
printResult(g, result, "SCIM test passed");
|
|
1735
|
+
}
|
|
1736
|
+
catch (e) {
|
|
1737
|
+
fail(e, g);
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
scim
|
|
1741
|
+
.command("list")
|
|
1742
|
+
.option("--org <id>")
|
|
1743
|
+
.action(async (opts, cmd) => {
|
|
1744
|
+
const g = globals(cmd);
|
|
1745
|
+
try {
|
|
1746
|
+
const store = await openStore(g);
|
|
1747
|
+
printResult(g, { connections: listScimConnections(store, opts.org) });
|
|
1748
|
+
}
|
|
1749
|
+
catch (e) {
|
|
1750
|
+
fail(e, g);
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1753
|
+
scim
|
|
1754
|
+
.command("setup-link")
|
|
1755
|
+
.requiredOption("--org <id>")
|
|
1756
|
+
.action(async (opts, cmd) => {
|
|
1757
|
+
const g = globals(cmd);
|
|
1758
|
+
try {
|
|
1759
|
+
const store = await openStore(g);
|
|
1760
|
+
const link = createSetupLink(store, { organizationId: opts.org, kind: "scim" });
|
|
1761
|
+
await flushStore(store);
|
|
1762
|
+
printResult(g, link);
|
|
1763
|
+
}
|
|
1764
|
+
catch (e) {
|
|
1765
|
+
fail(e, g);
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
scim
|
|
1769
|
+
.command("rotate")
|
|
1770
|
+
.description("Rotate SCIM bearer credential envelope under the current key")
|
|
1771
|
+
.argument("<id>", "SCIM connection id")
|
|
1772
|
+
.action(async (id, _, cmd) => {
|
|
1773
|
+
const g = globals(cmd);
|
|
1774
|
+
try {
|
|
1775
|
+
if (!g.yes && !g.dryRun) {
|
|
1776
|
+
throw new ClearanceError({
|
|
1777
|
+
code: "SCIM_CONFIRM_REQUIRED",
|
|
1778
|
+
message: "SCIM credential rotate requires confirmation",
|
|
1779
|
+
stage: "scim.rotate",
|
|
1780
|
+
status: 400,
|
|
1781
|
+
remediation: "Pass --yes to rotate, or --dry-run to preview",
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
const store = await openStore(g);
|
|
1785
|
+
await store.refresh();
|
|
1786
|
+
if (g.dryRun) {
|
|
1787
|
+
const connection = inspectScimConnection(store, id);
|
|
1788
|
+
const hasToken = Boolean(connection.hasBearerToken ??
|
|
1789
|
+
connection.bearerTokenFingerprint);
|
|
1790
|
+
if (!hasToken) {
|
|
1791
|
+
throw new ClearanceError({
|
|
1792
|
+
code: "SCIM_NO_TOKEN",
|
|
1793
|
+
message: "No encrypted bearer token to rotate",
|
|
1794
|
+
stage: "scim.rotate",
|
|
1795
|
+
status: 400,
|
|
1796
|
+
remediation: "Recreate the SCIM connection to mint a bearer token",
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
printResult(g, {
|
|
1800
|
+
dryRun: true,
|
|
1801
|
+
connection,
|
|
1802
|
+
wouldChange: true,
|
|
1803
|
+
}, `Would rotate SCIM credential for ${id}`);
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
const connection = rotateScimCredential(store, id, {
|
|
1807
|
+
actor: "cli",
|
|
1808
|
+
source: "cli",
|
|
1809
|
+
});
|
|
1810
|
+
await flushStore(store);
|
|
1811
|
+
printResult(g, { connection }, `Rotated SCIM credential for ${id}`);
|
|
1812
|
+
}
|
|
1813
|
+
catch (e) {
|
|
1814
|
+
fail(e, g);
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
scim
|
|
1818
|
+
.command("disable")
|
|
1819
|
+
.description("Disable a SCIM directory connection")
|
|
1820
|
+
.argument("<id>", "SCIM connection id")
|
|
1821
|
+
.action(async (id, _, cmd) => {
|
|
1822
|
+
const g = globals(cmd);
|
|
1823
|
+
try {
|
|
1824
|
+
if (!g.yes && !g.dryRun) {
|
|
1825
|
+
throw new ClearanceError({
|
|
1826
|
+
code: "SCIM_CONFIRM_REQUIRED",
|
|
1827
|
+
message: "SCIM disable requires confirmation",
|
|
1828
|
+
stage: "scim.disable",
|
|
1829
|
+
status: 400,
|
|
1830
|
+
remediation: "Pass --yes to disable, or --dry-run to preview",
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
const store = await openStore(g);
|
|
1834
|
+
await store.refresh();
|
|
1835
|
+
if (g.dryRun) {
|
|
1836
|
+
const connection = inspectScimConnection(store, id);
|
|
1837
|
+
printResult(g, {
|
|
1838
|
+
dryRun: true,
|
|
1839
|
+
connection,
|
|
1840
|
+
wouldChange: connection.status !== "disabled",
|
|
1841
|
+
}, `Would disable SCIM connection ${id}`);
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
const result = process.env.DATABASE_URL
|
|
1845
|
+
? await disableScimConnectionReal(store, id, {
|
|
1846
|
+
actor: "cli",
|
|
1847
|
+
source: "cli",
|
|
1848
|
+
})
|
|
1849
|
+
: disableScimConnection(store, id, {
|
|
1850
|
+
actor: "cli",
|
|
1851
|
+
source: "cli",
|
|
1852
|
+
});
|
|
1853
|
+
await flushStore(store);
|
|
1854
|
+
printResult(g, result, result.idempotent
|
|
1855
|
+
? `SCIM connection ${result.connection.id} already disabled`
|
|
1856
|
+
: `Disabled SCIM connection ${result.connection.id}`);
|
|
1857
|
+
}
|
|
1858
|
+
catch (e) {
|
|
1859
|
+
fail(e, g);
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
scim
|
|
1863
|
+
.command("replay")
|
|
1864
|
+
.description("Re-record a SCIM diagnostic trace (default dry-run; --yes to apply)")
|
|
1865
|
+
.argument("<traceId>", "SCIM diagnostic trace id")
|
|
1866
|
+
.action(async (traceId, _, cmd) => {
|
|
1867
|
+
const g = globals(cmd);
|
|
1868
|
+
try {
|
|
1869
|
+
const store = await openStore(g);
|
|
1870
|
+
await store.refresh();
|
|
1871
|
+
// Shared service: validates scope, defaults to dry-run unless --yes.
|
|
1872
|
+
const dryRun = g.dryRun || !g.yes;
|
|
1873
|
+
const result = replayDiagnosticTrace(store, traceId, {
|
|
1874
|
+
dryRun,
|
|
1875
|
+
confirm: g.yes && !g.dryRun,
|
|
1876
|
+
actor: "cli",
|
|
1877
|
+
source: "cli",
|
|
1878
|
+
});
|
|
1879
|
+
if (!result.dryRun) {
|
|
1880
|
+
await flushStore(store);
|
|
1881
|
+
}
|
|
1882
|
+
printResult(g, result, result.dryRun
|
|
1883
|
+
? `Would replay SCIM trace ${result.original.id}`
|
|
1884
|
+
: result.idempotent
|
|
1885
|
+
? `Replay already present for ${result.original.id}`
|
|
1886
|
+
: `Replayed SCIM trace ${result.original.id} → ${result.trace.id}`);
|
|
1887
|
+
}
|
|
1888
|
+
catch (e) {
|
|
1889
|
+
fail(e, g);
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
// readiness
|
|
1893
|
+
const readiness = program.command("readiness").description("Enterprise readiness");
|
|
1894
|
+
readiness
|
|
1895
|
+
.command("check")
|
|
1896
|
+
.requiredOption("--org <id>")
|
|
1897
|
+
.action(async (opts, cmd) => {
|
|
1898
|
+
const g = globals(cmd);
|
|
1899
|
+
try {
|
|
1900
|
+
const store = await openStore(g);
|
|
1901
|
+
const report = runReadinessCheck(store, opts.org);
|
|
1902
|
+
await flushStore(store);
|
|
1903
|
+
printResult(g, { report });
|
|
1904
|
+
}
|
|
1905
|
+
catch (e) {
|
|
1906
|
+
fail(e, g);
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
readiness
|
|
1910
|
+
.command("report")
|
|
1911
|
+
.requiredOption("--org <id>")
|
|
1912
|
+
.action(async (opts, cmd) => {
|
|
1913
|
+
const g = globals(cmd);
|
|
1914
|
+
try {
|
|
1915
|
+
const store = await openStore(g);
|
|
1916
|
+
const report = getLatestReadiness(store, opts.org);
|
|
1917
|
+
printResult(g, { report });
|
|
1918
|
+
}
|
|
1919
|
+
catch (e) {
|
|
1920
|
+
fail(e, g);
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
// migration
|
|
1924
|
+
const imports = program.command("import").description("Import supported auth exports");
|
|
1925
|
+
imports
|
|
1926
|
+
.command("legacy")
|
|
1927
|
+
.description("Preview or import a validated legacy export")
|
|
1928
|
+
.requiredOption("--file <path>", "Local legacy JSON export")
|
|
1929
|
+
.action(async (opts, cmd) => {
|
|
1930
|
+
const g = globals(cmd);
|
|
1931
|
+
try {
|
|
1932
|
+
const fixture = loadLegacyFixture(resolve(opts.file));
|
|
1933
|
+
const store = await openStore(g);
|
|
1934
|
+
await store.refresh();
|
|
1935
|
+
const preview = previewMigration(store, fixture);
|
|
1936
|
+
if (g.dryRun) {
|
|
1937
|
+
printResult(g, { schemaVersion: "v1", dryRun: true, source: "legacy", preview, storeBackend: store.backend }, `Would import ${preview.wouldCreate.users} users, ${preview.wouldCreate.organizations} organizations, and ${preview.wouldCreate.members} memberships`);
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
if (!g.yes) {
|
|
1941
|
+
throw new ClearanceError({
|
|
1942
|
+
code: "CLEARANCE_IMPORT_CONFIRMATION_REQUIRED",
|
|
1943
|
+
message: "Legacy import requires --yes",
|
|
1944
|
+
stage: "import.legacy",
|
|
1945
|
+
remediation: "Review with --dry-run, then rerun with --yes to import.",
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
const planned = planMigration(store, fixture);
|
|
1949
|
+
await flushStore(store);
|
|
1950
|
+
await store.refresh();
|
|
1951
|
+
await runMigrationDurable(store, planned.id, fixture);
|
|
1952
|
+
const verification = await verifyMigrationDurable(store, planned.id, fixture);
|
|
1953
|
+
await flushStore(store);
|
|
1954
|
+
const result = {
|
|
1955
|
+
schemaVersion: "v1",
|
|
1956
|
+
dryRun: false,
|
|
1957
|
+
source: "legacy",
|
|
1958
|
+
migration: verification.plan,
|
|
1959
|
+
preview,
|
|
1960
|
+
verification: { reconciled: verification.reconciled, expected: verification.expected, actual: verification.actual },
|
|
1961
|
+
storeBackend: store.backend,
|
|
1962
|
+
};
|
|
1963
|
+
const idempotent = preview.wouldCreate.users + preview.wouldCreate.organizations + preview.wouldCreate.members === 0;
|
|
1964
|
+
printResult(g, result, idempotent ? "Legacy import already reconciled" : `Imported legacy export: ${verification.actual.users} users, ${verification.actual.organizations} organizations, ${verification.actual.members} memberships`);
|
|
1965
|
+
}
|
|
1966
|
+
catch (e) {
|
|
1967
|
+
fail(e, g);
|
|
1968
|
+
}
|
|
1969
|
+
});
|
|
1970
|
+
const migration = program.command("migration").description("Tenant migration");
|
|
1971
|
+
migration
|
|
1972
|
+
.command("plan")
|
|
1973
|
+
.requiredOption("--source <source>", "legacy")
|
|
1974
|
+
.requiredOption("--fixture <path>")
|
|
1975
|
+
.action(async (opts, cmd) => {
|
|
1976
|
+
const g = globals(cmd);
|
|
1977
|
+
try {
|
|
1978
|
+
const store = await openStore(g);
|
|
1979
|
+
if (opts.source !== "legacy")
|
|
1980
|
+
throw new ClearanceError({ code: "CLEARANCE_IMPORT_SOURCE_INVALID", message: "Only legacy imports are supported", stage: "migration.plan", remediation: "Use --source legacy." });
|
|
1981
|
+
const fixture = loadLegacyFixture(opts.fixture);
|
|
1982
|
+
const plan = planMigration(store, fixture);
|
|
1983
|
+
await flushStore(store);
|
|
1984
|
+
printResult(g, { plan });
|
|
1985
|
+
}
|
|
1986
|
+
catch (e) {
|
|
1987
|
+
fail(e, g);
|
|
1988
|
+
}
|
|
1989
|
+
});
|
|
1990
|
+
migration
|
|
1991
|
+
.command("run")
|
|
1992
|
+
.requiredOption("--id <planId>")
|
|
1993
|
+
.requiredOption("--fixture <path>")
|
|
1994
|
+
.action(async (opts, cmd) => {
|
|
1995
|
+
const g = globals(cmd);
|
|
1996
|
+
try {
|
|
1997
|
+
const store = await openStore(g);
|
|
1998
|
+
const fixture = loadLegacyFixture(opts.fixture);
|
|
1999
|
+
const plan = await runMigrationDurable(store, opts.id, fixture, {
|
|
2000
|
+
dryRun: g.dryRun,
|
|
2001
|
+
});
|
|
2002
|
+
await flushStore(store);
|
|
2003
|
+
printResult(g, { plan });
|
|
2004
|
+
}
|
|
2005
|
+
catch (e) {
|
|
2006
|
+
fail(e, g);
|
|
2007
|
+
}
|
|
2008
|
+
});
|
|
2009
|
+
migration
|
|
2010
|
+
.command("verify")
|
|
2011
|
+
.requiredOption("--id <planId>")
|
|
2012
|
+
.requiredOption("--fixture <path>")
|
|
2013
|
+
.action(async (opts, cmd) => {
|
|
2014
|
+
const g = globals(cmd);
|
|
2015
|
+
try {
|
|
2016
|
+
const store = await openStore(g);
|
|
2017
|
+
const fixture = loadLegacyFixture(opts.fixture);
|
|
2018
|
+
const result = await verifyMigrationDurable(store, opts.id, fixture);
|
|
2019
|
+
await flushStore(store);
|
|
2020
|
+
printResult(g, result);
|
|
2021
|
+
}
|
|
2022
|
+
catch (e) {
|
|
2023
|
+
fail(e, g);
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
migration
|
|
2027
|
+
.command("rollback")
|
|
2028
|
+
.requiredOption("--id <planId>")
|
|
2029
|
+
.requiredOption("--fixture <path>")
|
|
2030
|
+
.action(async (opts, cmd) => {
|
|
2031
|
+
const g = globals(cmd);
|
|
2032
|
+
try {
|
|
2033
|
+
if (!g.yes) {
|
|
2034
|
+
throw new ClearanceError({
|
|
2035
|
+
code: "MIGRATION_ROLLBACK_CONFIRM_REQUIRED",
|
|
2036
|
+
message: "Refusing rollback without --yes",
|
|
2037
|
+
stage: "migration.rollback",
|
|
2038
|
+
status: 400,
|
|
2039
|
+
remediation: "Pass --yes to confirm rollback",
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
const store = await openStore(g);
|
|
2043
|
+
const fixture = loadLegacyFixture(opts.fixture);
|
|
2044
|
+
const plan = await rollbackMigrationDurable(store, opts.id, fixture);
|
|
2045
|
+
await flushStore(store);
|
|
2046
|
+
printResult(g, { plan });
|
|
2047
|
+
}
|
|
2048
|
+
catch (e) {
|
|
2049
|
+
fail(e, g);
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
migration
|
|
2053
|
+
.command("status")
|
|
2054
|
+
.requiredOption("--id <planId>")
|
|
2055
|
+
.action(async (opts, cmd) => {
|
|
2056
|
+
const g = globals(cmd);
|
|
2057
|
+
try {
|
|
2058
|
+
const store = await openStore(g);
|
|
2059
|
+
printResult(g, { plan: migrationStatus(store, opts.id) });
|
|
2060
|
+
}
|
|
2061
|
+
catch (e) {
|
|
2062
|
+
fail(e, g);
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
// backup
|
|
2066
|
+
const backup = program.command("backup").description("Backup and restore");
|
|
2067
|
+
backup
|
|
2068
|
+
.command("create")
|
|
2069
|
+
.option("--dir <path>")
|
|
2070
|
+
.action(async (opts, cmd) => {
|
|
2071
|
+
const g = globals(cmd);
|
|
2072
|
+
try {
|
|
2073
|
+
const store = await openStore(g);
|
|
2074
|
+
const record = process.env.DATABASE_URL
|
|
2075
|
+
? createPostgresBackup(store, opts.dir)
|
|
2076
|
+
: createBackup(store, opts.dir);
|
|
2077
|
+
await flushStore(store);
|
|
2078
|
+
printResult(g, { backup: record });
|
|
2079
|
+
}
|
|
2080
|
+
catch (e) {
|
|
2081
|
+
fail(e, g);
|
|
2082
|
+
}
|
|
2083
|
+
});
|
|
2084
|
+
backup
|
|
2085
|
+
.command("verify")
|
|
2086
|
+
.requiredOption("--id <backupId>")
|
|
2087
|
+
.action(async (opts, cmd) => {
|
|
2088
|
+
const g = globals(cmd);
|
|
2089
|
+
try {
|
|
2090
|
+
const store = await openStore(g);
|
|
2091
|
+
const backup = process.env.DATABASE_URL
|
|
2092
|
+
? await verifyPostgresBackup(store, opts.id)
|
|
2093
|
+
: verifyBackup(store, opts.id);
|
|
2094
|
+
await flushStore(store);
|
|
2095
|
+
printResult(g, { backup });
|
|
2096
|
+
}
|
|
2097
|
+
catch (e) {
|
|
2098
|
+
fail(e, g);
|
|
2099
|
+
}
|
|
2100
|
+
});
|
|
2101
|
+
backup
|
|
2102
|
+
.command("restore")
|
|
2103
|
+
.requiredOption("--id <backupId>")
|
|
2104
|
+
.option("--target <path>", "Restore target path or isolated Postgres database name")
|
|
2105
|
+
.action(async (opts, cmd) => {
|
|
2106
|
+
const g = globals(cmd);
|
|
2107
|
+
try {
|
|
2108
|
+
const store = await openStore(g);
|
|
2109
|
+
if (process.env.DATABASE_URL) {
|
|
2110
|
+
const r = await restorePostgresBackup(store, opts.id, opts.target);
|
|
2111
|
+
await flushStore(store);
|
|
2112
|
+
printResult(g, r);
|
|
2113
|
+
}
|
|
2114
|
+
else {
|
|
2115
|
+
if (!opts.target) {
|
|
2116
|
+
throw new ClearanceError({
|
|
2117
|
+
code: "BACKUP_RESTORE_TARGET_REQUIRED",
|
|
2118
|
+
message: "A restore target path is required when Postgres is not configured",
|
|
2119
|
+
stage: "backup.restore",
|
|
2120
|
+
status: 400,
|
|
2121
|
+
remediation: "Pass --target <path>",
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
const restored = restoreBackup(store, opts.id, opts.target);
|
|
2125
|
+
await flushStore(store);
|
|
2126
|
+
printResult(g, restored);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
catch (e) {
|
|
2130
|
+
fail(e, g);
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
// upgrade
|
|
2134
|
+
const upgrade = program.command("upgrade").description("Upgrade tooling");
|
|
2135
|
+
upgrade
|
|
2136
|
+
.command("check")
|
|
2137
|
+
.action(async (_, cmd) => {
|
|
2138
|
+
const g = globals(cmd);
|
|
2139
|
+
try {
|
|
2140
|
+
const store = await openStore(g);
|
|
2141
|
+
const result = process.env.DATABASE_URL
|
|
2142
|
+
? await upgradeCheckWithDb(store)
|
|
2143
|
+
: upgradeCheck(store);
|
|
2144
|
+
await flushStore(store);
|
|
2145
|
+
printResult(g, result);
|
|
2146
|
+
}
|
|
2147
|
+
catch (e) {
|
|
2148
|
+
fail(e, g);
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
upgrade
|
|
2152
|
+
.command("plan")
|
|
2153
|
+
.requiredOption("--target <version>", "Target release version")
|
|
2154
|
+
.requiredOption("--dir <path>", "Absolute upgrade artifact directory")
|
|
2155
|
+
.option("--current <version>", "Current release version override")
|
|
2156
|
+
.action(async (opts, cmd) => {
|
|
2157
|
+
const g = globals(cmd);
|
|
2158
|
+
try {
|
|
2159
|
+
const result = await planUpgrade({ ...opts, dryRun: g.dryRun });
|
|
2160
|
+
const human = "id" in result.plan
|
|
2161
|
+
? `Upgrade plan ${result.plan.id}: ${result.plan.currentVersion} → ${result.plan.targetVersion}`
|
|
2162
|
+
: `Would create upgrade plan for ${opts.target}`;
|
|
2163
|
+
printResult(g, result, human);
|
|
2164
|
+
}
|
|
2165
|
+
catch (e) {
|
|
2166
|
+
fail(e, g);
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
upgrade
|
|
2170
|
+
.command("apply")
|
|
2171
|
+
.requiredOption("--plan <id-or-path>", "Plan ID or plan path")
|
|
2172
|
+
.requiredOption("--dir <path>", "Absolute upgrade artifact directory")
|
|
2173
|
+
.action(async (opts, cmd) => {
|
|
2174
|
+
const g = globals(cmd);
|
|
2175
|
+
try {
|
|
2176
|
+
const result = await applyUpgrade({ ...opts, dryRun: g.dryRun, yes: g.yes });
|
|
2177
|
+
printResult(g, result, g.dryRun ? `Would apply upgrade ${result.plan.id}` : `Applied upgrade ${result.plan.id} (${result.plan.status})`);
|
|
2178
|
+
}
|
|
2179
|
+
catch (e) {
|
|
2180
|
+
fail(e, g);
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
upgrade
|
|
2184
|
+
.command("verify")
|
|
2185
|
+
.requiredOption("--plan <id-or-path>", "Plan ID or plan path")
|
|
2186
|
+
.requiredOption("--dir <path>", "Absolute upgrade artifact directory")
|
|
2187
|
+
.option("--health-url <url>", "Optional credential-free HTTP(S) health endpoint")
|
|
2188
|
+
.action(async (opts, cmd) => {
|
|
2189
|
+
const g = globals(cmd);
|
|
2190
|
+
try {
|
|
2191
|
+
const result = await verifyUpgrade({ ...opts, dryRun: g.dryRun });
|
|
2192
|
+
printResult(g, result, g.dryRun ? `Would verify upgrade ${result.plan.id}` : `Verified upgrade ${result.plan.id} (${result.plan.status})`);
|
|
2193
|
+
}
|
|
2194
|
+
catch (e) {
|
|
2195
|
+
fail(e, g);
|
|
2196
|
+
}
|
|
2197
|
+
});
|
|
2198
|
+
upgrade
|
|
2199
|
+
.command("rollback")
|
|
2200
|
+
.description("Verify a rollback in isolation, or explicitly restore the active database")
|
|
2201
|
+
.requiredOption("--plan <id-or-path>", "Plan ID or plan path")
|
|
2202
|
+
.requiredOption("--dir <path>", "Absolute upgrade artifact directory")
|
|
2203
|
+
.option("--restore-active", "Restore the rollback backup into the active database", false)
|
|
2204
|
+
.option("--confirm <token>", "Exact RESTORE_ACTIVE:<plan-id>:<database> confirmation")
|
|
2205
|
+
.option("--backup-dir <path>", "Absolute directory for the pre-restore safety backup")
|
|
2206
|
+
.action(async (opts, cmd) => {
|
|
2207
|
+
const g = globals(cmd);
|
|
2208
|
+
try {
|
|
2209
|
+
const result = await rollbackUpgrade({ ...opts, dryRun: g.dryRun, yes: g.yes });
|
|
2210
|
+
printResult(g, result, g.dryRun
|
|
2211
|
+
? `Would run ${result.mode} for ${result.plan.id}`
|
|
2212
|
+
: result.mode === "active_database_restore"
|
|
2213
|
+
? `Restored the active database for ${result.plan.id}; receipt ${result.rollbackReceipt}`
|
|
2214
|
+
: `Rollback reference verified for ${result.plan.id}; active database unchanged`);
|
|
2215
|
+
}
|
|
2216
|
+
catch (e) {
|
|
2217
|
+
fail(e, g);
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2220
|
+
// schema
|
|
2221
|
+
const schema = program.command("schema").description("Management and runtime schema lifecycle");
|
|
2222
|
+
schema
|
|
2223
|
+
.command("status")
|
|
2224
|
+
.action(async (_, cmd) => {
|
|
2225
|
+
const g = globals(cmd);
|
|
2226
|
+
try {
|
|
2227
|
+
const store = await openStore(g);
|
|
2228
|
+
printResult(g, {
|
|
2229
|
+
management: {
|
|
2230
|
+
schemaVersion: store.snapshot.meta.schemaVersion,
|
|
2231
|
+
releaseVersion: store.snapshot.releaseVersion,
|
|
2232
|
+
initializedAt: store.snapshot.meta.initializedAt,
|
|
2233
|
+
},
|
|
2234
|
+
runtime: await getRuntimeSchemaStatus(),
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
catch (e) {
|
|
2238
|
+
fail(e, g);
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
schema
|
|
2242
|
+
.command("generate")
|
|
2243
|
+
.description("Compile pending Clearance Postgres SQL without applying it")
|
|
2244
|
+
.option("--output <path>", "Output SQL file path (required)")
|
|
2245
|
+
.option("--force", "Overwrite an existing output artifact", false)
|
|
2246
|
+
.action(async (opts, cmd) => {
|
|
2247
|
+
const g = globals(cmd);
|
|
2248
|
+
try {
|
|
2249
|
+
if (!opts.output) {
|
|
2250
|
+
throw new ClearanceError({
|
|
2251
|
+
code: "SCHEMA_GENERATE_OUTPUT_REQUIRED",
|
|
2252
|
+
message: "schema generate requires an explicit --output path.",
|
|
2253
|
+
stage: "schema.generate",
|
|
2254
|
+
remediation: "Provide --output <path> for the generated SQL artifact.",
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
printResult(g, await generateRuntimeSchema({
|
|
2258
|
+
outputPath: opts.output,
|
|
2259
|
+
force: Boolean(opts.force),
|
|
2260
|
+
dryRun: Boolean(g.dryRun),
|
|
2261
|
+
}));
|
|
2262
|
+
}
|
|
2263
|
+
catch (e) {
|
|
2264
|
+
fail(e, g);
|
|
2265
|
+
}
|
|
2266
|
+
});
|
|
2267
|
+
schema
|
|
2268
|
+
.command("migrate")
|
|
2269
|
+
.description("Apply pending Clearance migrations and lifecycle compatibility ensures")
|
|
2270
|
+
.action(async (_, cmd) => {
|
|
2271
|
+
const g = globals(cmd);
|
|
2272
|
+
try {
|
|
2273
|
+
if (!g.yes && !g.dryRun) {
|
|
2274
|
+
throw new ClearanceError({
|
|
2275
|
+
code: "SCHEMA_MIGRATE_CONFIRMATION_REQUIRED",
|
|
2276
|
+
message: "schema migrate requires --yes before applying changes.",
|
|
2277
|
+
stage: "schema.migrate",
|
|
2278
|
+
remediation: "Re-run with --dry-run to inspect, or add --yes to apply.",
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
printResult(g, await migrateRuntimeSchema({ dryRun: Boolean(g.dryRun) }));
|
|
2282
|
+
}
|
|
2283
|
+
catch (e) {
|
|
2284
|
+
fail(e, g);
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
// config
|
|
2288
|
+
const config = program.command("config").description("Config");
|
|
2289
|
+
config
|
|
2290
|
+
.command("get")
|
|
2291
|
+
.argument("[key]")
|
|
2292
|
+
.action(async (key, _, cmd) => {
|
|
2293
|
+
const g = globals(cmd);
|
|
2294
|
+
try {
|
|
2295
|
+
const store = await openStore(g);
|
|
2296
|
+
printResult(g, publicConfig(store.snapshot.meta.config, key));
|
|
2297
|
+
}
|
|
2298
|
+
catch (e) {
|
|
2299
|
+
fail(e, g);
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
config
|
|
2303
|
+
.command("set")
|
|
2304
|
+
.argument("<key>")
|
|
2305
|
+
.argument("<value>")
|
|
2306
|
+
.action(async (key, value, _, cmd) => {
|
|
2307
|
+
const g = globals(cmd);
|
|
2308
|
+
try {
|
|
2309
|
+
const store = await openStore(g);
|
|
2310
|
+
const candidate = { ...store.snapshot.meta.config, [key]: value };
|
|
2311
|
+
validateConfig(store, candidate);
|
|
2312
|
+
const changed = store.snapshot.meta.config[key] !== value;
|
|
2313
|
+
if (g.dryRun) {
|
|
2314
|
+
printResult(g, {
|
|
2315
|
+
dryRun: true,
|
|
2316
|
+
changed,
|
|
2317
|
+
key,
|
|
2318
|
+
config: publicConfig(candidate).config,
|
|
2319
|
+
});
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
const result = setConfig(store, key, value);
|
|
2323
|
+
if (result.changed)
|
|
2324
|
+
await flushStore(store);
|
|
2325
|
+
printResult(g, {
|
|
2326
|
+
ok: true,
|
|
2327
|
+
changed: result.changed,
|
|
2328
|
+
key,
|
|
2329
|
+
config: publicConfig(result.config).config,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
catch (e) {
|
|
2333
|
+
fail(e, g);
|
|
2334
|
+
}
|
|
2335
|
+
});
|
|
2336
|
+
config
|
|
2337
|
+
.command("validate")
|
|
2338
|
+
.option("--file <json-file>", "Candidate config JSON file")
|
|
2339
|
+
.action(async (opts, cmd) => {
|
|
2340
|
+
const g = globals(cmd);
|
|
2341
|
+
try {
|
|
2342
|
+
const store = await openStore(g);
|
|
2343
|
+
let candidate = store.snapshot.meta.config;
|
|
2344
|
+
if (opts.file)
|
|
2345
|
+
candidate = readConfigCandidate(opts.file);
|
|
2346
|
+
validateConfig(store, candidate);
|
|
2347
|
+
const visible = publicConfig(candidate);
|
|
2348
|
+
printResult(g, {
|
|
2349
|
+
ok: true,
|
|
2350
|
+
source: opts.file ? "file" : "current",
|
|
2351
|
+
config: visible.config,
|
|
2352
|
+
redactedKeys: visible.redactedKeys,
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
catch (e) {
|
|
2356
|
+
fail(e, g);
|
|
2357
|
+
}
|
|
2358
|
+
});
|
|
2359
|
+
config
|
|
2360
|
+
.command("diff")
|
|
2361
|
+
.requiredOption("--file <json-file>", "Candidate config JSON file")
|
|
2362
|
+
.action(async (opts, cmd) => {
|
|
2363
|
+
const g = globals(cmd);
|
|
2364
|
+
try {
|
|
2365
|
+
const store = await openStore(g);
|
|
2366
|
+
const candidate = readConfigCandidate(opts.file);
|
|
2367
|
+
validateConfig(store, candidate);
|
|
2368
|
+
printResult(g, diffConfig(store.snapshot.meta.config, candidate));
|
|
2369
|
+
}
|
|
2370
|
+
catch (e) {
|
|
2371
|
+
fail(e, g);
|
|
2372
|
+
}
|
|
2373
|
+
});
|
|
2374
|
+
// overview for console parity
|
|
2375
|
+
program
|
|
2376
|
+
.command("overview")
|
|
2377
|
+
.description("Dashboard overview stats")
|
|
2378
|
+
.action(async (_, cmd) => {
|
|
2379
|
+
const g = globals(cmd);
|
|
2380
|
+
try {
|
|
2381
|
+
const store = await openStore(g);
|
|
2382
|
+
printResult(g, overviewStats(store));
|
|
2383
|
+
}
|
|
2384
|
+
catch (e) {
|
|
2385
|
+
fail(e, g);
|
|
2386
|
+
}
|
|
2387
|
+
});
|
|
2388
|
+
program
|
|
2389
|
+
.command("login")
|
|
2390
|
+
.description("Validate and save an operator credential for API-backed commands")
|
|
2391
|
+
.option("--url <url>", "Clearance API URL")
|
|
2392
|
+
.option("--token-stdin", "Read an operator token from standard input", false)
|
|
2393
|
+
.action(async (opts, cmd) => {
|
|
2394
|
+
const g = globals(cmd);
|
|
2395
|
+
try {
|
|
2396
|
+
const profile = normalizeProfile(g.profile);
|
|
2397
|
+
const apiUrl = normalizeApiUrl(opts.url);
|
|
2398
|
+
const token = opts.tokenStdin ? await readTokenFromStdin() : environmentToken();
|
|
2399
|
+
if (!token) {
|
|
2400
|
+
throw new ClearanceError({
|
|
2401
|
+
code: "CLI_TOKEN_REQUIRED",
|
|
2402
|
+
message: "An operator token is required for login.",
|
|
2403
|
+
stage: "operator-auth.login",
|
|
2404
|
+
remediation: "Set CLEARANCE_OPERATOR_TOKEN or CLEARANCE_API_TOKEN, or pass --token-stdin.",
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
const whoami = await validateAndSaveCredential(apiUrl, token, process.env, profile);
|
|
2408
|
+
printResult(g, {
|
|
2409
|
+
authenticated: true,
|
|
2410
|
+
credentialSaved: true,
|
|
2411
|
+
credentialSource: opts.tokenStdin ? "stdin" : "environment",
|
|
2412
|
+
profile,
|
|
2413
|
+
apiUrl,
|
|
2414
|
+
whoami,
|
|
2415
|
+
}, `Authenticated to ${apiUrl} as operator (${whoami.projectId}/${whoami.environmentId}).`);
|
|
2416
|
+
}
|
|
2417
|
+
catch (e) {
|
|
2418
|
+
fail(e, g);
|
|
2419
|
+
}
|
|
2420
|
+
});
|
|
2421
|
+
program
|
|
2422
|
+
.command("logout")
|
|
2423
|
+
.description("Remove the saved operator credential")
|
|
2424
|
+
.action(async (_, cmd) => {
|
|
2425
|
+
const g = globals(cmd);
|
|
2426
|
+
try {
|
|
2427
|
+
const profile = normalizeProfile(g.profile);
|
|
2428
|
+
const credentialRemoved = await deleteSavedCredential(process.env, profile);
|
|
2429
|
+
const environmentCredentialPresent = Boolean(environmentToken());
|
|
2430
|
+
const result = {
|
|
2431
|
+
credentialRemoved,
|
|
2432
|
+
idempotent: !credentialRemoved,
|
|
2433
|
+
environmentCredentialPresent,
|
|
2434
|
+
credentialSource: environmentCredentialPresent ? "environment" : "none",
|
|
2435
|
+
profile,
|
|
2436
|
+
};
|
|
2437
|
+
const status = credentialRemoved ? "Saved operator credential removed." : "No saved operator credential was present.";
|
|
2438
|
+
const environmentNote = environmentCredentialPresent ? " An environment credential remains active." : "";
|
|
2439
|
+
printResult(g, result, `${status}${environmentNote}`);
|
|
2440
|
+
}
|
|
2441
|
+
catch (e) {
|
|
2442
|
+
fail(e, g);
|
|
2443
|
+
}
|
|
2444
|
+
});
|
|
2445
|
+
program
|
|
2446
|
+
.command("whoami")
|
|
2447
|
+
.description("Verify the current operator credential and scope")
|
|
2448
|
+
.option("--url <url>", "Clearance API URL override")
|
|
2449
|
+
.action(async (opts, cmd) => {
|
|
2450
|
+
const g = globals(cmd);
|
|
2451
|
+
try {
|
|
2452
|
+
const session = await resolveApiSession({
|
|
2453
|
+
...(environmentToken() ? {} : { profile: g.profile }),
|
|
2454
|
+
apiUrl: opts.url ?? g.apiUrl,
|
|
2455
|
+
});
|
|
2456
|
+
if (session) {
|
|
2457
|
+
const whoami = await fetchWhoami(session.apiUrl, session.token);
|
|
2458
|
+
const via = session.credentialSource === "saved" ? session.profile : "environment";
|
|
2459
|
+
printResult(g, {
|
|
2460
|
+
authenticated: true,
|
|
2461
|
+
credentialSource: session.credentialSource,
|
|
2462
|
+
...(session.credentialSource === "saved" ? { profile: session.profile } : {}),
|
|
2463
|
+
apiUrl: session.apiUrl,
|
|
2464
|
+
...whoami,
|
|
2465
|
+
}, `operator ${whoami.projectId}/${whoami.environmentId} via ${via} (${session.apiUrl})`);
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
printResult(g, {
|
|
2469
|
+
authenticated: false,
|
|
2470
|
+
mode: "local-direct",
|
|
2471
|
+
credentialSource: "none",
|
|
2472
|
+
storeBackend: process.env.DATABASE_URL ? "postgres" : "json",
|
|
2473
|
+
}, "Local direct mode; no remote operator credential is configured.");
|
|
2474
|
+
}
|
|
2475
|
+
catch (e) {
|
|
2476
|
+
fail(e, g);
|
|
2477
|
+
}
|
|
2478
|
+
});
|
|
2479
|
+
try {
|
|
2480
|
+
await program.parseAsync();
|
|
2481
|
+
}
|
|
2482
|
+
finally {
|
|
2483
|
+
await closeStores();
|
|
2484
|
+
await closeAuthBundle();
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
main().catch((err) => {
|
|
2488
|
+
if (err instanceof CliExitError) {
|
|
2489
|
+
process.exitCode = err.exitCode;
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
console.error(err);
|
|
2493
|
+
process.exitCode = 1;
|
|
2494
|
+
});
|