@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/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
+ });