@clearance/cli 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export declare function authenticatedApiEnv(dataPath: string): NodeJS.ProcessEnv;
2
+ export declare function stopAuthenticatedApiServers(): void;
@@ -0,0 +1,73 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const apiEntry = resolve(dirname(fileURLToPath(import.meta.url)), "../../../clearance-api/dist/server.js");
5
+ const OPERATOR_TOKEN = "test-operator-token-for-cli-api-32chars!!";
6
+ const servers = new Map();
7
+ function allocatePort() {
8
+ return Number(execFileSync(process.execPath, [
9
+ "-e",
10
+ "const net=require('node:net');const s=net.createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})",
11
+ ], { encoding: "utf8" }).trim());
12
+ }
13
+ function pause(milliseconds) {
14
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
15
+ }
16
+ export function authenticatedApiEnv(dataPath) {
17
+ const existing = servers.get(dataPath);
18
+ if (existing) {
19
+ return {
20
+ CLEARANCE_OPERATOR_TOKEN: OPERATOR_TOKEN,
21
+ CLEARANCE_API_URL: existing.apiUrl,
22
+ };
23
+ }
24
+ const port = allocatePort();
25
+ const apiUrl = `http://127.0.0.1:${port}`;
26
+ const child = spawn(process.execPath, [apiEntry], {
27
+ env: {
28
+ ...process.env,
29
+ DATABASE_URL: "",
30
+ CLEARANCE_DATA_PATH: dataPath,
31
+ CLEARANCE_API_PORT: String(port),
32
+ CLEARANCE_OPERATOR_TOKEN: OPERATOR_TOKEN,
33
+ CLEARANCE_SECRET: "unit-test-secret-value-not-default!!",
34
+ CLEARANCE_BASE_URL: apiUrl,
35
+ CLEARANCE_CORS_ORIGINS: "http://localhost:3100",
36
+ CLEARANCE_CREDENTIAL_KEY: "unit-test-credential-key-material-32b!!",
37
+ CLEARANCE_CREDENTIAL_KEY_ID: "k1",
38
+ NODE_ENV: "development",
39
+ },
40
+ stdio: ["ignore", "ignore", "ignore"],
41
+ });
42
+ servers.set(dataPath, { process: child, apiUrl });
43
+ let ready = false;
44
+ for (let attempt = 0; attempt < 100; attempt += 1) {
45
+ if (child.exitCode !== null)
46
+ break;
47
+ try {
48
+ execFileSync("curl", ["--fail", "--silent", "--max-time", "1", `${apiUrl}/livez`], {
49
+ stdio: "ignore",
50
+ });
51
+ ready = true;
52
+ break;
53
+ }
54
+ catch {
55
+ pause(50);
56
+ }
57
+ }
58
+ if (!ready) {
59
+ child.kill("SIGTERM");
60
+ servers.delete(dataPath);
61
+ throw new Error(`Clearance API test server failed to start at ${apiUrl}`);
62
+ }
63
+ return {
64
+ CLEARANCE_OPERATOR_TOKEN: OPERATOR_TOKEN,
65
+ CLEARANCE_API_URL: apiUrl,
66
+ };
67
+ }
68
+ export function stopAuthenticatedApiServers() {
69
+ for (const server of servers.values()) {
70
+ server.process.kill("SIGTERM");
71
+ }
72
+ servers.clear();
73
+ }
package/dist/index.js CHANGED
@@ -1,15 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { randomBytes } from "node:crypto";
4
3
  import { readFileSync } from "node:fs";
5
4
  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";
5
+ 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, createUserWithPasswordSetupInAuth, 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
6
  import { CliExitError, fail, printResult } from "./output.js";
8
7
  import { closeStores, flushStore, openStore } from "./store.js";
9
8
  import { applyUpgrade, planUpgrade, rollbackUpgrade, verifyUpgrade } from "./upgrade.js";
10
9
  import { deleteSavedCredential, environmentToken, fetchWhoami, normalizeApiUrl, normalizeProfile, readTokenFromStdin, validateAndSaveCredential, } from "./operator-auth.js";
11
10
  import { resolveApiSession } from "./api-client.js";
12
- import { commandPath, dispatchRemoteCommand, isHostLocalCommand, } from "./remote-dispatch.js";
11
+ import { commandPath, dispatchRemoteCommand, } from "./remote-dispatch.js";
13
12
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
14
13
  function globals(cmd) {
15
14
  const opts = cmd.optsWithGlobals();
@@ -19,7 +18,6 @@ function globals(cmd) {
19
18
  yes: Boolean(opts.yes),
20
19
  dryRun: Boolean(opts.dryRun),
21
20
  dataPath: opts.dataPath,
22
- localDirect: Boolean(opts.localDirect) || process.env.CLEARANCE_LOCAL_DIRECT === "1",
23
21
  profile: opts.profile,
24
22
  apiUrl: opts.apiUrl,
25
23
  };
@@ -134,31 +132,20 @@ async function main() {
134
132
  .option("--dry-run", "Preview mutations", false)
135
133
  .option("--data-path <path>", "Path to Clearance data store")
136
134
  .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);
135
+ .option("--api-url <url>", "Clearance management API origin override");
139
136
  program.hook("preAction", async (_root, actionCommand) => {
140
137
  const g = globals(actionCommand);
141
138
  try {
142
139
  const path = commandPath(actionCommand);
143
140
  if (path === "login" || path === "logout" || path === "whoami")
144
141
  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
142
  const session = await resolveApiSession({ profile: g.profile, apiUrl: g.apiUrl });
156
143
  if (!session) {
157
144
  throw new ClearanceError({
158
145
  code: "CLI_LOGIN_REQUIRED",
159
146
  message: "An authenticated Clearance API profile is required.",
160
147
  stage: "cli.dispatch",
161
- remediation: "Run clearance login --profile <name>, or choose --local-direct for local development.",
148
+ remediation: "Run clearance login --profile <name> for the intended API origin.",
162
149
  });
163
150
  }
164
151
  const result = await dispatchRemoteCommand(session, path, actionCommand.processedArgs, actionCommand.opts(), g);
@@ -436,7 +423,7 @@ async function main() {
436
423
  .command("create")
437
424
  .requiredOption("--email <email>")
438
425
  .requiredOption("--name <name>")
439
- .option("--password <password>", "Initial password; generated and returned once when omitted")
426
+ .option("--password <password>", "Explicit initial password; omitted creates an expiring single-use setup token")
440
427
  .action(async (opts, cmd) => {
441
428
  const g = globals(cmd);
442
429
  try {
@@ -446,28 +433,38 @@ async function main() {
446
433
  return;
447
434
  }
448
435
  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
- });
436
+ const provisioned = process.env.DATABASE_URL
437
+ ? typeof opts.password === "string" && opts.password.length > 0
438
+ ? {
439
+ user: await createUserInAuth({
440
+ email: opts.email,
441
+ name: opts.name,
442
+ password: opts.password,
443
+ managementStore: store,
444
+ }),
445
+ passwordSetup: undefined,
446
+ }
447
+ : await createUserWithPasswordSetupInAuth({
448
+ email: opts.email,
449
+ name: opts.name,
450
+ managementStore: store,
451
+ })
452
+ : { user: createUser(store, {
453
+ email: opts.email,
454
+ name: opts.name,
455
+ source: "cli",
456
+ }), passwordSetup: undefined };
465
457
  await flushStore(store);
466
458
  printResult(g, {
467
- user,
468
- ...(generatedPassword ? { temporaryPassword: generatedPassword } : {}),
459
+ user: provisioned.user,
460
+ ...(provisioned.passwordSetup
461
+ ? {
462
+ passwordSetupToken: provisioned.passwordSetup.token,
463
+ passwordSetupExpiresAt: provisioned.passwordSetup.expiresAt,
464
+ }
465
+ : {}),
469
466
  storeBackend: store.backend,
470
- }, `Created ${user.email} (${user.id})`);
467
+ }, `Created ${provisioned.user.email} (${provisioned.user.id})`);
471
468
  }
472
469
  catch (e) {
473
470
  fail(e, g);
@@ -2465,12 +2462,13 @@ async function main() {
2465
2462
  }, `operator ${whoami.projectId}/${whoami.environmentId} via ${via} (${session.apiUrl})`);
2466
2463
  return;
2467
2464
  }
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.");
2465
+ throw new ClearanceError({
2466
+ code: "CLI_LOGIN_REQUIRED",
2467
+ message: "No authenticated Clearance API profile is configured.",
2468
+ stage: "cli.auth",
2469
+ status: 401,
2470
+ remediation: "Run clearance login --profile <name> for the intended API origin.",
2471
+ });
2474
2472
  }
2475
2473
  catch (e) {
2476
2474
  fail(e, g);
@@ -6,10 +6,10 @@
6
6
  deploy/upgrades/steps/<targetVersion>/apply.sh
7
7
  ```
8
8
 
9
- The currently supported transition is `0.1.4` to `0.2.0`. Its shipped hook is
10
- `deploy/upgrades/steps/0.2.0/apply.sh`; it verifies the release marker is at
11
- `0.1.4`, advances it to `0.2.0`, and verifies the result. The Clearance CLI
12
- packages this hook under `dist/ops/deploy/upgrades/steps/0.2.0/apply.sh`.
9
+ The currently supported transition is `0.1.4` to `0.2.1`. Its shipped hook is
10
+ `deploy/upgrades/steps/0.2.1/apply.sh`; it verifies the release marker is at
11
+ `0.1.4`, advances it to `0.2.1`, and verifies the result. The Clearance CLI
12
+ packages this hook under `dist/ops/deploy/upgrades/steps/0.2.1/apply.sh`.
13
13
 
14
14
  ## Contract
15
15
 
@@ -47,13 +47,17 @@ CREATE TABLE IF NOT EXISTS clearance_schema_migrations (
47
47
  INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
48
48
  VALUES (:'from_version', 'application-baseline', :'plan_sha', NULL)
49
49
  ON CONFLICT (version) DO NOTHING;
50
- UPDATE clearance_management_snapshot
51
- SET data = jsonb_set(data, '{releaseVersion}', to_jsonb(:'to_version'::text), false),
52
- revision = revision + 1,
53
- updated_at = now()
54
- WHERE id = 1 AND data->>'releaseVersion' = :'from_version';
50
+ WITH advanced AS (
51
+ UPDATE clearance_management_snapshot
52
+ SET data = jsonb_set(data, '{releaseVersion}', to_jsonb(:'to_version'::text), false),
53
+ revision = revision + 1,
54
+ updated_at = now()
55
+ WHERE id = 1 AND data->>'releaseVersion' = :'from_version'
56
+ RETURNING 1
57
+ )
55
58
  INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
56
- VALUES (:'to_version', :'plan_id', :'plan_sha', :'from_version');
59
+ SELECT :'to_version', :'plan_id', :'plan_sha', :'from_version'
60
+ FROM advanced;
57
61
  COMMIT;
58
62
  SQL
59
63
 
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # Shipped 0.1.4 -> 0.2.1 database transition.
3
+ set -Eeuo pipefail
4
+
5
+ ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
6
+ # shellcheck source=scripts/lib/ops-common.sh
7
+ source "$ROOT/scripts/lib/ops-common.sh"
8
+
9
+ PLAN=""
10
+ FROM=""
11
+ TO=""
12
+ while [[ $# -gt 0 ]]; do
13
+ case "$1" in
14
+ --plan) PLAN="$2"; shift 2 ;;
15
+ --from) FROM="$2"; shift 2 ;;
16
+ --to) TO="$2"; shift 2 ;;
17
+ *) die "unknown upgrade hook argument: $1" ;;
18
+ esac
19
+ done
20
+
21
+ [[ -f "$PLAN" ]] || die "upgrade plan is missing"
22
+ [[ "$FROM" == "0.1.4" && "$TO" == "0.2.1" ]] \
23
+ || die "this hook only supports 0.1.4 to 0.2.1"
24
+
25
+ require_pg_client
26
+ require_cmd node
27
+ URL="$(resolve_database_url)"
28
+ PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN")"
29
+ PLAN_SHA="$(sha256_file "$PLAN")"
30
+ [[ "$PLAN_ID" =~ ^upg_[0-9TZ]+_[a-f0-9]+$ ]] || die "unsafe plan id"
31
+ require_application_release "$URL" "$FROM"
32
+
33
+ # This is the application-owned migration contract: the durable management
34
+ # snapshot used by the running API advances atomically with an append-only
35
+ # migration ledger. It cannot pass against the old test-only ops marker.
36
+ psql_q "$URL" \
37
+ -v from_version="$FROM" -v to_version="$TO" \
38
+ -v plan_id="$PLAN_ID" -v plan_sha="$PLAN_SHA" <<'SQL' >/dev/null
39
+ BEGIN;
40
+ CREATE TABLE IF NOT EXISTS clearance_schema_migrations (
41
+ version text PRIMARY KEY,
42
+ applied_at timestamptz NOT NULL DEFAULT now(),
43
+ plan_id text NOT NULL,
44
+ plan_sha256 text NOT NULL,
45
+ from_version text
46
+ );
47
+ INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
48
+ VALUES (:'from_version', 'application-baseline', :'plan_sha', NULL)
49
+ ON CONFLICT (version) DO NOTHING;
50
+ WITH advanced AS (
51
+ UPDATE clearance_management_snapshot
52
+ SET data = jsonb_set(data, '{releaseVersion}', to_jsonb(:'to_version'::text), false),
53
+ revision = revision + 1,
54
+ updated_at = now()
55
+ WHERE id = 1 AND data->>'releaseVersion' = :'from_version'
56
+ RETURNING 1
57
+ )
58
+ INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
59
+ SELECT :'to_version', :'plan_id', :'plan_sha', :'from_version'
60
+ FROM advanced;
61
+ COMMIT;
62
+ SQL
63
+
64
+ require_application_release "$URL" "$TO"
65
+ ledger_plan="$(psql_q "$URL" -At -c "SELECT plan_id FROM clearance_schema_migrations WHERE version = '${TO}'")"
66
+ [[ "$ledger_plan" == "$PLAN_ID" ]] || die "migration ledger did not record plan $PLAN_ID"
package/dist/output.d.ts CHANGED
@@ -5,7 +5,6 @@ export interface GlobalOpts {
5
5
  yes?: boolean;
6
6
  dryRun?: boolean;
7
7
  dataPath?: string;
8
- localDirect?: boolean;
9
8
  profile?: string;
10
9
  apiUrl?: string;
11
10
  }
@@ -1,10 +1,8 @@
1
1
  import type { Command } from "commander";
2
2
  import { type ApiSession } from "./api-client.js";
3
- import type { GlobalOpts } from "./output.js";
4
- export declare const HOST_LOCAL_COMMANDS: Map<string, string>;
3
+ import { type GlobalOpts } from "./output.js";
5
4
  export declare const REMOTE_COMMANDS: Set<string>;
6
- export type CommandExecution = "authentication" | "remote-api" | "host-local" | "unavailable";
5
+ export type CommandExecution = "authentication" | "remote-api" | "unavailable";
7
6
  export declare function classifyCommandPath(path: string): CommandExecution;
8
7
  export declare function commandPath(command: Command): string;
9
- export declare function isHostLocalCommand(path: string): boolean;
10
8
  export declare function dispatchRemoteCommand(session: ApiSession, path: string, args: unknown[], opts: Record<string, unknown>, global: GlobalOpts): Promise<unknown>;
@@ -1,39 +1,16 @@
1
- import { ClearanceError } from "@clearance/management";
1
+ import { ClearanceError, parseConfigJson, writeExportArtifact } from "@clearance/management";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { requestManagementApi } from "./api-client.js";
5
- export const HOST_LOCAL_COMMANDS = new Map([
6
- ["dev", "prints and launches host development paths"],
7
- ["users export", "writes a bounded export to a caller-selected host path"],
8
- ["orgs members import", "reads a caller-selected host file"],
9
- ["events export", "writes a bounded export to a caller-selected host path"],
10
- ["events tail", "owns a long-running terminal stream"],
11
- ["import legacy", "reads a migration fixture from the host"],
12
- ["migration plan", "creates host migration artifacts"],
13
- ["migration run", "executes a host migration artifact"],
14
- ["migration verify", "verifies a host migration fixture"],
15
- ["migration rollback", "rolls back a host migration fixture"],
16
- ["migration status", "inspects host migration artifacts"],
17
- ["backup create", "runs database tooling and writes a host backup"],
18
- ["backup verify", "verifies a host backup artifact"],
19
- ["backup restore", "runs database tooling against the selected host"],
20
- ["upgrade check", "inspects host deployment and database state"],
21
- ["upgrade plan", "creates signed host upgrade artifacts"],
22
- ["upgrade apply", "runs host deployment and database upgrade tooling"],
23
- ["upgrade verify", "verifies host upgrade artifacts and database state"],
24
- ["upgrade rollback", "restores or verifies a host database backup"],
25
- ["schema status", "inspects the directly selected runtime database"],
26
- ["schema generate", "writes SQL to a caller-selected host path"],
27
- ["schema migrate", "runs migrations against the directly selected runtime database"],
28
- ]);
5
+ import { CliExitError } from "./output.js";
29
6
  export const REMOTE_COMMANDS = new Set([
30
- "init", "doctor", "overview",
7
+ "init", "doctor", "dev", "overview",
31
8
  "project list", "project inspect", "project create",
32
9
  "env list", "env inspect", "env create", "env promote",
33
- "users list", "users inspect", "users create", "users update", "users disable", "users delete",
10
+ "users list", "users inspect", "users create", "users update", "users disable", "users delete", "users export",
34
11
  "orgs list", "orgs inspect", "orgs create", "orgs update", "orgs archive",
35
- "orgs members list", "orgs members add", "orgs members update", "orgs members remove",
36
- "events list", "events inspect", "events replay",
12
+ "orgs members list", "orgs members add", "orgs members update", "orgs members remove", "orgs members import",
13
+ "events list", "events tail", "events inspect", "events export", "events replay",
37
14
  "keys list", "keys create", "keys rotate", "keys revoke",
38
15
  "sessions list", "sessions revoke",
39
16
  "roles list", "roles validate", "roles create", "roles update",
@@ -41,12 +18,13 @@ export const REMOTE_COMMANDS = new Set([
41
18
  "scim create", "scim test", "scim list", "scim setup-link", "scim rotate", "scim disable", "scim replay",
42
19
  "readiness check", "readiness report",
43
20
  "config get", "config set", "config validate", "config diff",
21
+ "import legacy", "migration plan", "migration run", "migration verify", "migration rollback", "migration status",
22
+ "backup create", "backup verify", "backup restore", "upgrade check", "upgrade plan", "upgrade apply", "upgrade verify", "upgrade rollback",
23
+ "schema status", "schema generate", "schema migrate",
44
24
  ]);
45
25
  export function classifyCommandPath(path) {
46
26
  if (path === "login" || path === "logout" || path === "whoami")
47
27
  return "authentication";
48
- if (HOST_LOCAL_COMMANDS.has(path))
49
- return "host-local";
50
28
  if (REMOTE_COMMANDS.has(path))
51
29
  return "remote-api";
52
30
  return "unavailable";
@@ -65,6 +43,71 @@ function query(path, values) {
65
43
  function body(values) {
66
44
  return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
67
45
  }
46
+ function localFile(path, code, label) {
47
+ try {
48
+ return readFileSync(resolve(String(path)), "utf8");
49
+ }
50
+ catch {
51
+ throw error(code, `${label} could not be read.`, "Provide a readable local file and retry.");
52
+ }
53
+ }
54
+ function configCandidate(path) {
55
+ let contents;
56
+ try {
57
+ contents = readFileSync(resolve(String(path)), "utf8");
58
+ }
59
+ catch {
60
+ throw new ClearanceError({
61
+ code: "CONFIG_FILE_UNREADABLE",
62
+ message: "Config file could not be read.",
63
+ stage: "config.parse",
64
+ remediation: "Provide a readable JSON config file.",
65
+ });
66
+ }
67
+ return parseConfigJson(contents);
68
+ }
69
+ function writeRemoteExport(envelope, options, collection) {
70
+ const format = options.format === "jsonl" ? "jsonl" : "json";
71
+ const values = Array.isArray(envelope[collection]) ? envelope[collection] : [];
72
+ const contents = format === "jsonl"
73
+ ? values.length === 0
74
+ ? ""
75
+ : `${values.map((value) => JSON.stringify(value)).join("\n")}\n`
76
+ : `${JSON.stringify(envelope, null, 2)}\n`;
77
+ const outputPath = writeExportArtifact(String(options.output), contents, Boolean(options.force), {
78
+ stage: `${collection}.export`,
79
+ existsCode: `${collection.toUpperCase()}_EXPORT_EXISTS`,
80
+ writeFailedCode: `${collection.toUpperCase()}_EXPORT_WRITE_FAILED`,
81
+ });
82
+ return { ...envelope, outputPath };
83
+ }
84
+ function emitTailEvent(json, event) {
85
+ process.stdout.write(json
86
+ ? `${JSON.stringify(event)}\n`
87
+ : `${event.createdAt} ${event.action} actor=${event.actor} outcome=${event.outcome} id=${event.id}\n`);
88
+ }
89
+ function integerOption(value, fallback, minimum, maximum, name, code = "CLI_OPTION_INVALID") {
90
+ const parsed = value === undefined ? fallback : Number(value);
91
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
92
+ throw error(code, `${name} must be an integer from ${minimum} to ${maximum}.`, `Pass a valid --${name} value.`);
93
+ }
94
+ return parsed;
95
+ }
96
+ async function resolveRemoteMembershipId(session, organizationId, options) {
97
+ if (typeof options.member === "string" && options.member.trim())
98
+ return options.member;
99
+ if (typeof options.user !== "string" || !options.user.trim()) {
100
+ throw error("MEMBER_ID_REQUIRED", "Membership update or removal requires --member or --user.", "List organization members, then pass a membership id or principal id.");
101
+ }
102
+ const response = await requestManagementApi(session, {
103
+ path: `/v1/organizations/${encodeURIComponent(String(organizationId))}/members`,
104
+ });
105
+ const membership = (response.members ?? []).find((candidate) => candidate.principalId === options.user && candidate.status !== "removed");
106
+ if (!membership) {
107
+ throw error("MEMBER_NOT_FOUND", "Membership not found.", "List organization members and verify the principal id.");
108
+ }
109
+ return membership.id;
110
+ }
68
111
  export function commandPath(command) {
69
112
  const names = [];
70
113
  let current = command;
@@ -74,12 +117,9 @@ export function commandPath(command) {
74
117
  }
75
118
  return names.join(" ");
76
119
  }
77
- export function isHostLocalCommand(path) {
78
- return HOST_LOCAL_COMMANDS.has(path);
79
- }
80
120
  function requireRemoteMutation(global, path) {
81
121
  if (global.dryRun) {
82
- throw error("CLI_REMOTE_DRY_RUN_UNSUPPORTED", `${path} does not yet expose a server-side dry-run contract.`, "Use the command without --dry-run, or choose --local-direct for a local development preview.");
122
+ throw error("CLI_REMOTE_DRY_RUN_UNSUPPORTED", `${path} does not yet expose a server-side dry-run contract.`, "Use the command without --dry-run after reviewing the target.");
83
123
  }
84
124
  }
85
125
  function requireConfirmation(global, code, label) {
@@ -100,6 +140,7 @@ export async function dispatchRemoteCommand(session, path, args, opts, global) {
100
140
  requireRemoteMutation(global, path);
101
141
  return requestManagementApi(session, { method: "POST", path: "/v1/init", body: body({ name: opts.name, environment: opts.environment }) });
102
142
  case "doctor": return requestManagementApi(session, { path: "/v1/doctor" });
143
+ case "dev": return requestManagementApi(session, { path: "/v1/dev" });
103
144
  case "overview": return requestManagementApi(session, { path: "/v1/overview" });
104
145
  case "project list": return requestManagementApi(session, { path: "/v1/projects" });
105
146
  case "project inspect": return requestManagementApi(session, { path: id ? `/v1/projects/${id}` : "/v1/projects/current" });
@@ -123,6 +164,14 @@ export async function dispatchRemoteCommand(session, path, args, opts, global) {
123
164
  requireConfirmation(global, "USER_DELETE_CONFIRM_REQUIRED", "User deletion");
124
165
  requireRemoteMutation(global, path);
125
166
  return requestManagementApi(session, { method: "DELETE", path: `/v1/users/${id}` });
167
+ case "users export": {
168
+ const envelope = await requestManagementApi(session, {
169
+ method: "POST",
170
+ path: "/v1/users/export",
171
+ body: body({ format: opts.format, limit: opts.limit, status: opts.status }),
172
+ });
173
+ return writeRemoteExport(envelope, opts, "users");
174
+ }
126
175
  case "orgs list": return requestManagementApi(session, { path: query("/v1/organizations", { limit: opts.limit, cursor: opts.cursor }) });
127
176
  case "orgs inspect": return requestManagementApi(session, { path: `/v1/organizations/${id}` });
128
177
  case "orgs create":
@@ -136,18 +185,74 @@ export async function dispatchRemoteCommand(session, path, args, opts, global) {
136
185
  case "orgs members add":
137
186
  return requestManagementApi(session, { method: "POST", path: `/v1/organizations/${encodeURIComponent(String(opts.org))}/members`, body: body({ principalId: opts.user, role: opts.role, dryRun: global.dryRun }) });
138
187
  case "orgs members update": {
139
- if (!opts.member)
140
- throw error("CLI_REMOTE_MEMBER_ID_REQUIRED", "Remote member update requires --member.", "List organization members and pass the membership id with --member.");
141
- return requestManagementApi(session, { method: "PATCH", path: `/v1/organizations/${encodeURIComponent(String(opts.org))}/members/${encodeURIComponent(String(opts.member))}`, body: { role: opts.role, dryRun: global.dryRun } });
188
+ const membershipId = await resolveRemoteMembershipId(session, opts.org, opts);
189
+ return requestManagementApi(session, { method: "PATCH", path: `/v1/organizations/${encodeURIComponent(String(opts.org))}/members/${encodeURIComponent(membershipId)}`, body: { role: opts.role, dryRun: global.dryRun } });
142
190
  }
143
191
  case "orgs members remove": {
144
192
  requireConfirmation(global, "MEMBER_REMOVE_CONFIRM_REQUIRED", "Membership removal");
145
- if (!opts.member)
146
- throw error("CLI_REMOTE_MEMBER_ID_REQUIRED", "Remote member removal requires --member.", "List organization members and pass the membership id with --member.");
147
- return requestManagementApi(session, { method: "DELETE", path: `/v1/organizations/${encodeURIComponent(String(opts.org))}/members/${encodeURIComponent(String(opts.member))}`, body: { dryRun: global.dryRun } });
193
+ const membershipId = await resolveRemoteMembershipId(session, opts.org, opts);
194
+ return requestManagementApi(session, { method: "DELETE", path: `/v1/organizations/${encodeURIComponent(String(opts.org))}/members/${encodeURIComponent(membershipId)}`, body: { dryRun: global.dryRun } });
195
+ }
196
+ case "orgs members import": {
197
+ requireConfirmation(global, "MEMBER_IMPORT_CONFIRMATION_REQUIRED", "Member import");
198
+ const filename = String(opts.file);
199
+ const format = opts.format ?? (filename.toLowerCase().endsWith(".json")
200
+ ? "json"
201
+ : filename.toLowerCase().endsWith(".csv")
202
+ ? "csv"
203
+ : undefined);
204
+ if (format !== "json" && format !== "csv") {
205
+ throw error("MEMBER_IMPORT_FORMAT_REQUIRED", "Member import format is required.", "Use a .json or .csv file, or pass --format json|csv.");
206
+ }
207
+ return requestManagementApi(session, {
208
+ method: "POST",
209
+ path: `/v1/organizations/${encodeURIComponent(String(opts.org))}/members/import`,
210
+ body: {
211
+ content: localFile(opts.file, "MEMBER_IMPORT_FILE_UNREADABLE", "Member import file"),
212
+ format,
213
+ dryRun: global.dryRun || !global.yes,
214
+ confirm: global.yes && !global.dryRun,
215
+ },
216
+ });
217
+ }
218
+ case "events list": return requestManagementApi(session, { path: query("/v1/events", { limit: opts.limit, cursor: opts.cursor, action: opts.action, organizationId: opts.org }) });
219
+ case "events tail": {
220
+ const limit = integerOption(opts.limit, 20, 1, 1000, "limit", "EVENTS_TAIL_OPTION_INVALID");
221
+ const pollInterval = integerOption(opts.pollInterval, 1000, 100, 60_000, "poll-interval", "EVENTS_TAIL_OPTION_INVALID");
222
+ const maxEvents = integerOption(opts.maxEvents, 0, 0, Number.MAX_SAFE_INTEGER, "max-events", "EVENTS_TAIL_OPTION_INVALID");
223
+ const tailPath = query("/v1/events", { limit, action: opts.action, organizationId: opts.org });
224
+ const seen = new Set();
225
+ let emitted = 0;
226
+ const poll = async () => {
227
+ const response = await requestManagementApi(session, { path: tailPath });
228
+ const fresh = (response.events ?? []).filter((event) => !seen.has(event.id)).reverse();
229
+ for (const event of fresh) {
230
+ seen.add(event.id);
231
+ if (maxEvents !== 0 && emitted >= maxEvents)
232
+ break;
233
+ emitTailEvent(Boolean(global.json), event);
234
+ emitted += 1;
235
+ }
236
+ return response;
237
+ };
238
+ await poll();
239
+ if (opts.once || (maxEvents !== 0 && emitted >= maxEvents))
240
+ throw new CliExitError(0);
241
+ while (maxEvents === 0 || emitted < maxEvents) {
242
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, pollInterval));
243
+ await poll();
244
+ }
245
+ throw new CliExitError(0);
148
246
  }
149
- case "events list": return requestManagementApi(session, { path: query("/v1/events", { limit: opts.limit, cursor: opts.cursor, action: opts.action, actor: opts.actor, outcome: opts.outcome }) });
150
247
  case "events inspect": return requestManagementApi(session, { path: `/v1/events/${id}` });
248
+ case "events export": {
249
+ const envelope = await requestManagementApi(session, {
250
+ method: "POST",
251
+ path: "/v1/events/export",
252
+ body: body({ format: opts.format, limit: opts.limit, action: opts.action, organizationId: opts.org, before: opts.before }),
253
+ });
254
+ return writeRemoteExport(envelope, opts, "events");
255
+ }
151
256
  case "events replay": return requestManagementApi(session, { method: "POST", path: "/v1/events/replay", body: { id: args[0], dryRun: global.dryRun || !global.yes, confirm: global.yes && !global.dryRun } });
152
257
  case "keys list": return requestManagementApi(session, { path: query("/v1/keys", { includeRevoked: opts.includeRevoked }) });
153
258
  case "keys create":
@@ -160,11 +265,13 @@ export async function dispatchRemoteCommand(session, path, args, opts, global) {
160
265
  return requestManagementApi(session, { method: "POST", path: `/v1/keys/${id}/revoke`, body: { dryRun: global.dryRun } });
161
266
  case "sessions list": return requestManagementApi(session, { path: query("/v1/sessions", { limit: opts.limit, cursor: opts.cursor, userId: opts.user, status: opts.status }) });
162
267
  case "sessions revoke":
163
- requireConfirmation(global, "SESSION_REVOKE_CONFIRM_REQUIRED", "Session revocation");
164
- requireRemoteMutation(global, path);
165
- return requestManagementApi(session, { method: "POST", path: `/v1/sessions/${id}/revoke` });
268
+ requireConfirmation(global, "SESSION_CONFIRM_REQUIRED", "Session revocation");
269
+ return requestManagementApi(session, { method: "POST", path: `/v1/sessions/${id}/revoke`, body: { dryRun: global.dryRun } });
166
270
  case "roles list": return requestManagementApi(session, { path: "/v1/roles" });
167
- case "roles validate": return requestManagementApi(session, { method: "POST", path: "/v1/roles/validate", body: body({ name: opts.name, slug: opts.slug, permissions: opts.permission }) });
271
+ case "roles validate": {
272
+ const validation = await requestManagementApi(session, { method: "POST", path: "/v1/roles/validate", body: body({ name: opts.name, slug: opts.slug, permissions: opts.permission }) });
273
+ return { validation };
274
+ }
168
275
  case "roles create":
169
276
  return requestManagementApi(session, { method: "POST", path: "/v1/roles", body: body({ name: opts.name, slug: opts.slug, description: opts.description, permissions: opts.permission, dryRun: global.dryRun }) });
170
277
  case "roles update":
@@ -173,8 +280,7 @@ export async function dispatchRemoteCommand(session, path, args, opts, global) {
173
280
  requireRemoteMutation(global, path);
174
281
  return requestManagementApi(session, { method: "POST", path: "/v1/sso", body: body({ organizationId: opts.org, provider: opts.provider, protocol: opts.protocol, issuer: opts.issuer, audience: opts.audience, domain: opts.domain, samlEntryPoint: opts.entryPoint, samlCertificate: opts.certificate ? readFileSync(resolve(String(opts.certificate)), "utf8") : undefined }) });
175
282
  case "sso configure":
176
- requireRemoteMutation(global, path);
177
- return requestManagementApi(session, { method: "PATCH", path: `/v1/sso/${id}`, body: body({ issuer: opts.issuer, audience: opts.audience, domain: opts.domain }) });
283
+ return requestManagementApi(session, { method: "PATCH", path: `/v1/sso/${id}`, body: body({ issuer: opts.issuer, audience: opts.audience, domain: opts.domain, dryRun: global.dryRun }) });
178
284
  case "sso test":
179
285
  if (opts.live && opts.fixture)
180
286
  throw error("SSO_TEST_MODE_CONFLICT", "--live and --fixture are mutually exclusive.", "Use one SSO test mode.");
@@ -189,7 +295,7 @@ export async function dispatchRemoteCommand(session, path, args, opts, global) {
189
295
  requireConfirmation(global, "SSO_CONFIRM_REQUIRED", "SSO credential rotation");
190
296
  return requestManagementApi(session, { method: "POST", path: `/v1/sso/${id}/rotate`, body: { dryRun: global.dryRun } });
191
297
  case "sso disable":
192
- requireConfirmation(global, "SSO_DISABLE_CONFIRM_REQUIRED", "SSO disable");
298
+ requireConfirmation(global, "SSO_CONFIRM_REQUIRED", "SSO disable");
193
299
  return requestManagementApi(session, { method: "POST", path: `/v1/sso/${id}/disable`, body: { dryRun: global.dryRun } });
194
300
  case "scim create":
195
301
  requireRemoteMutation(global, path);
@@ -216,17 +322,147 @@ export async function dispatchRemoteCommand(session, path, args, opts, global) {
216
322
  requireRemoteMutation(global, path);
217
323
  return requestManagementApi(session, { method: "POST", path: "/v1/readiness/check", body: { organizationId: opts.org } });
218
324
  case "readiness report": return requestManagementApi(session, { path: `/v1/readiness/${encodeURIComponent(String(opts.org))}` });
325
+ case "import legacy":
326
+ requireConfirmation(global, "CLEARANCE_IMPORT_CONFIRMATION_REQUIRED", "Legacy import");
327
+ return requestManagementApi(session, {
328
+ method: "POST",
329
+ path: "/v1/import/legacy",
330
+ body: {
331
+ fixture: localFile(opts.file, "CLEARANCE_IMPORT_FILE_UNREADABLE", "Legacy import file"),
332
+ dryRun: global.dryRun || !global.yes,
333
+ confirm: global.yes && !global.dryRun,
334
+ },
335
+ });
336
+ case "migration plan":
337
+ return requestManagementApi(session, {
338
+ method: "POST",
339
+ path: "/v1/migrations/plan",
340
+ body: {
341
+ source: opts.source,
342
+ fixture: localFile(opts.fixture, "CLEARANCE_IMPORT_FILE_UNREADABLE", "Migration fixture"),
343
+ },
344
+ });
345
+ case "migration run":
346
+ return requestManagementApi(session, {
347
+ method: "POST",
348
+ path: `/v1/migrations/${encodeURIComponent(String(opts.id))}/run`,
349
+ body: {
350
+ fixture: localFile(opts.fixture, "CLEARANCE_IMPORT_FILE_UNREADABLE", "Migration fixture"),
351
+ dryRun: global.dryRun,
352
+ },
353
+ });
354
+ case "migration verify":
355
+ return requestManagementApi(session, {
356
+ method: "POST",
357
+ path: `/v1/migrations/${encodeURIComponent(String(opts.id))}/verify`,
358
+ body: {
359
+ fixture: localFile(opts.fixture, "CLEARANCE_IMPORT_FILE_UNREADABLE", "Migration fixture"),
360
+ },
361
+ });
362
+ case "migration rollback":
363
+ requireConfirmation(global, "MIGRATION_ROLLBACK_CONFIRM_REQUIRED", "Migration rollback");
364
+ return requestManagementApi(session, {
365
+ method: "POST",
366
+ path: `/v1/migrations/${encodeURIComponent(String(opts.id))}/rollback`,
367
+ body: {
368
+ fixture: localFile(opts.fixture, "CLEARANCE_IMPORT_FILE_UNREADABLE", "Migration fixture"),
369
+ confirm: global.yes && !global.dryRun,
370
+ },
371
+ });
372
+ case "migration status":
373
+ return requestManagementApi(session, { path: `/v1/migrations/${encodeURIComponent(String(opts.id))}` });
374
+ case "backup create":
375
+ if (opts.dir !== undefined) {
376
+ throw error("BACKUP_DIRECTORY_SERVER_MANAGED", "Backup storage is configured by the API deployment.", "Set CLEARANCE_BACKUP_DIR on the API and mount durable storage there.");
377
+ }
378
+ return requestManagementApi(session, { method: "POST", path: "/v1/backups", body: {} });
379
+ case "backup verify":
380
+ return requestManagementApi(session, { method: "POST", path: `/v1/backups/${encodeURIComponent(String(opts.id))}/verify`, body: {} });
381
+ case "backup restore":
382
+ requireConfirmation(global, "BACKUP_RESTORE_CONFIRM_REQUIRED", "Backup restore");
383
+ return requestManagementApi(session, {
384
+ method: "POST",
385
+ path: `/v1/backups/${encodeURIComponent(String(opts.id))}/restore`,
386
+ body: { target: opts.target, confirm: global.yes && !global.dryRun },
387
+ });
388
+ case "upgrade check":
389
+ return requestManagementApi(session, { path: "/v1/upgrades/check" });
390
+ case "upgrade plan":
391
+ return requestManagementApi(session, {
392
+ method: "POST",
393
+ path: "/v1/upgrades/plan",
394
+ body: body({ target: opts.target, dir: opts.dir, current: opts.current, dryRun: global.dryRun }),
395
+ });
396
+ case "upgrade apply":
397
+ requireConfirmation(global, "UPGRADE_APPLY_CONFIRMATION_REQUIRED", "Upgrade apply");
398
+ return requestManagementApi(session, {
399
+ method: "POST",
400
+ path: "/v1/upgrades/apply",
401
+ body: { plan: opts.plan, dir: opts.dir, dryRun: global.dryRun, confirm: global.yes && !global.dryRun },
402
+ });
403
+ case "upgrade verify":
404
+ return requestManagementApi(session, {
405
+ method: "POST",
406
+ path: "/v1/upgrades/verify",
407
+ body: body({ plan: opts.plan, dir: opts.dir, healthUrl: opts.healthUrl, dryRun: global.dryRun }),
408
+ });
409
+ case "upgrade rollback":
410
+ requireConfirmation(global, "UPGRADE_ROLLBACK_CONFIRMATION_REQUIRED", "Upgrade rollback");
411
+ return requestManagementApi(session, {
412
+ method: "POST",
413
+ path: "/v1/upgrades/rollback",
414
+ body: body({
415
+ plan: opts.plan,
416
+ dir: opts.dir,
417
+ dryRun: global.dryRun,
418
+ confirm: global.yes && !global.dryRun,
419
+ restoreActive: opts.restoreActive,
420
+ activeDatabaseConfirmation: opts.confirm,
421
+ backupDir: opts.backupDir,
422
+ }),
423
+ });
424
+ case "schema status":
425
+ return requestManagementApi(session, { path: "/v1/schema/status" });
426
+ case "schema generate": {
427
+ if (!opts.output) {
428
+ throw error("SCHEMA_GENERATE_OUTPUT_REQUIRED", "schema generate requires an explicit --output path.", "Provide --output <path> for the generated SQL artifact.");
429
+ }
430
+ const result = await requestManagementApi(session, {
431
+ method: "POST",
432
+ path: "/v1/schema/generate",
433
+ body: {},
434
+ });
435
+ const { sql, ...metadata } = result;
436
+ if (typeof sql !== "string") {
437
+ throw error("SCHEMA_GENERATE_RESPONSE_INVALID", "The API did not return generated SQL.", "Upgrade the Clearance API and retry.");
438
+ }
439
+ if (global.dryRun)
440
+ return { ...metadata, dryRun: true };
441
+ const outputPath = writeExportArtifact(String(opts.output), sql, Boolean(opts.force), {
442
+ stage: "schema.generate",
443
+ existsCode: "SCHEMA_GENERATE_EXISTS",
444
+ writeFailedCode: "SCHEMA_GENERATE_WRITE_FAILED",
445
+ });
446
+ return { ...metadata, dryRun: false, outputPath };
447
+ }
448
+ case "schema migrate":
449
+ requireConfirmation(global, "SCHEMA_MIGRATE_CONFIRMATION_REQUIRED", "Schema migration");
450
+ return requestManagementApi(session, {
451
+ method: "POST",
452
+ path: "/v1/schema/migrate",
453
+ body: { dryRun: global.dryRun, confirm: global.yes && !global.dryRun },
454
+ });
219
455
  case "config get": return requestManagementApi(session, { path: query("/v1/config", { key: args[0] }) });
220
456
  case "config set": return requestManagementApi(session, { method: "PATCH", path: `/v1/config/${encodeURIComponent(String(args[0]))}`, body: { value: args[1], dryRun: global.dryRun } });
221
457
  case "config validate": {
222
- const config = opts.file ? JSON.parse(readFileSync(resolve(String(opts.file)), "utf8")) : undefined;
458
+ const config = opts.file ? configCandidate(opts.file) : undefined;
223
459
  return requestManagementApi(session, { method: "POST", path: "/v1/config/validate", body: body({ config }) });
224
460
  }
225
461
  case "config diff": {
226
- const config = JSON.parse(readFileSync(resolve(String(opts.file)), "utf8"));
462
+ const config = configCandidate(opts.file);
227
463
  return requestManagementApi(session, { method: "POST", path: "/v1/config/diff", body: { config } });
228
464
  }
229
465
  default:
230
- throw error("CLI_REMOTE_COMMAND_UNAVAILABLE", `${path} has no versioned management API contract in this release.`, "Upgrade the Clearance API, or use --local-direct only for a development or host-local workflow.");
466
+ throw error("CLI_REMOTE_COMMAND_UNAVAILABLE", `${path} has no versioned management API contract in this release.`, "Upgrade the Clearance API to a version that exposes this workflow.");
231
467
  }
232
468
  }
package/dist/upgrade.d.ts CHANGED
@@ -1,114 +1 @@
1
- export interface UpgradeOptions {
2
- target?: string;
3
- plan?: string;
4
- dir?: string;
5
- healthUrl?: string;
6
- current?: string;
7
- dryRun?: boolean;
8
- yes?: boolean;
9
- restoreActive?: boolean;
10
- confirm?: string;
11
- backupDir?: string;
12
- }
13
- export declare function planUpgrade(opts: UpgradeOptions): Promise<{
14
- schemaVersion: string;
15
- operation: string;
16
- dryRun: boolean;
17
- plan: {
18
- id: string;
19
- path: string;
20
- currentVersion: string;
21
- targetVersion: string;
22
- status: string;
23
- };
24
- } | {
25
- schemaVersion: string;
26
- operation: string;
27
- dryRun: boolean;
28
- plan: {
29
- targetVersion: string;
30
- currentVersion: string | null;
31
- directory: string;
32
- createsArtifacts: boolean;
33
- };
34
- }>;
35
- export declare function applyUpgrade(opts: UpgradeOptions): Promise<{
36
- operation: string;
37
- wouldRun: string[];
38
- schemaVersion: string;
39
- dryRun: boolean;
40
- plan: {
41
- id: string;
42
- path: string;
43
- currentVersion: string;
44
- targetVersion: string;
45
- status: string;
46
- };
47
- } | {
48
- schemaVersion: string;
49
- operation: string;
50
- dryRun: boolean;
51
- plan: {
52
- id: string;
53
- targetVersion: string;
54
- status: string;
55
- backupId: string | null;
56
- rollbackReference: {} | null;
57
- };
58
- }>;
59
- export declare function verifyUpgrade(opts: UpgradeOptions): Promise<{
60
- schemaVersion: string;
61
- operation: string;
62
- dryRun: boolean;
63
- plan: {
64
- id: string;
65
- targetVersion: string;
66
- status: string;
67
- updatedAt?: undefined;
68
- backupId?: undefined;
69
- };
70
- wouldRun: string[];
71
- } | {
72
- schemaVersion: string;
73
- operation: string;
74
- plan: {
75
- id: string;
76
- targetVersion: string;
77
- status: string;
78
- updatedAt: string | null;
79
- backupId: string | null;
80
- };
81
- dryRun?: undefined;
82
- wouldRun?: undefined;
83
- }>;
84
- export declare function rollbackUpgrade(opts: UpgradeOptions): Promise<{
85
- schemaVersion: string;
86
- operation: string;
87
- dryRun: boolean;
88
- mode: string;
89
- activeDatabaseUntouched: boolean;
90
- wouldModifyActiveDatabase: boolean;
91
- plan: {
92
- id: string;
93
- targetVersion: string;
94
- status: string;
95
- };
96
- wouldRun: string[];
97
- rollbackReceipt?: undefined;
98
- receipt?: undefined;
99
- } | {
100
- schemaVersion: string;
101
- operation: string;
102
- dryRun: boolean;
103
- mode: string;
104
- activeDatabaseUntouched: boolean;
105
- plan: {
106
- id: string;
107
- targetVersion: string;
108
- status: string;
109
- };
110
- rollbackReceipt: string;
111
- receipt: Record<string, unknown>;
112
- wouldModifyActiveDatabase?: undefined;
113
- wouldRun?: undefined;
114
- }>;
1
+ export { applyUpgrade, planUpgrade, rollbackUpgrade, verifyUpgrade, type UpgradeOptions, } from "@clearance/management";
package/dist/upgrade.js CHANGED
@@ -1,262 +1 @@
1
- import { execFile } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { constants, existsSync, promises as fs } from "node:fs";
4
- import { dirname, isAbsolute, relative, resolve } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { promisify } from "node:util";
7
- import { ClearanceError } from "@clearance/management";
8
- const execFileAsync = promisify(execFile);
9
- const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
10
- const SOURCE_ROOT = resolve(MODULE_DIR, "../../..");
11
- const PACKAGED_ROOT = resolve(MODULE_DIR, "ops");
12
- const OPS_ROOT = existsSync(resolve(PACKAGED_ROOT, "scripts/upgrade-plan.sh"))
13
- ? PACKAGED_ROOT
14
- : SOURCE_ROOT;
15
- const SCRIPTS = {
16
- plan: resolve(OPS_ROOT, "scripts/upgrade-plan.sh"),
17
- apply: resolve(OPS_ROOT, "scripts/upgrade-apply.sh"),
18
- verify: resolve(OPS_ROOT, "scripts/upgrade-verify.sh"),
19
- rollback: resolve(OPS_ROOT, "scripts/upgrade-rollback.sh"),
20
- };
21
- const PLAN_ID = /^upg_[A-Za-z0-9_-]+$/;
22
- const VERSION = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$/;
23
- const SHA256 = /^[a-f0-9]{64}$/;
24
- const MAX_ARTIFACT_BYTES = 1024 * 1024;
25
- const SCRIPT_TIMEOUT_MS = 30 * 60_000;
26
- function upgradeError(code, message, stage, remediation) {
27
- return new ClearanceError({ code, message, stage, remediation, status: 400 });
28
- }
29
- function safeDirectory(input, stage) {
30
- if (!input || input.includes("\0") || !isAbsolute(input)) {
31
- throw upgradeError("UPGRADE_DIR_INVALID", "Upgrade directory must be an absolute path.", stage, "Pass --dir with an absolute, dedicated upgrade artifact directory.");
32
- }
33
- return resolve(input);
34
- }
35
- function safeVersion(value, option, stage) {
36
- if (!value || !VERSION.test(value)) {
37
- throw upgradeError("UPGRADE_VERSION_INVALID", `--${option} must be a simple release version.`, stage, "Use letters, numbers, dots, underscores, plus, or hyphens only.");
38
- }
39
- return value;
40
- }
41
- function safeHealthUrl(value) {
42
- if (!value)
43
- return undefined;
44
- try {
45
- const url = new URL(value);
46
- if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.hash || url.search)
47
- throw new Error();
48
- return url.toString();
49
- }
50
- catch {
51
- throw upgradeError("UPGRADE_HEALTH_URL_INVALID", "--health-url must be a credential-free HTTP(S) URL.", "upgrade.verify", "Pass an absolute http:// or https:// health endpoint without credentials, query parameters, or a fragment.");
52
- }
53
- }
54
- function isInside(parent, child) {
55
- const path = relative(parent, child);
56
- return path === "" || (!path.startsWith("..") && !isAbsolute(path));
57
- }
58
- function planPaths(planRef, dir, stage) {
59
- if (!planRef || planRef.includes("\0")) {
60
- throw upgradeError("UPGRADE_PLAN_INVALID", "--plan is required.", stage, "Pass a plan ID returned by upgrade plan.");
61
- }
62
- const planPath = isAbsolute(planRef)
63
- ? resolve(planRef)
64
- : PLAN_ID.test(planRef)
65
- ? resolve(dir, `${planRef}.plan.json`)
66
- : "";
67
- if (!planPath || !isInside(dir, planPath) || !planPath.endsWith(".plan.json")) {
68
- throw upgradeError("UPGRADE_PLAN_INVALID", "--plan must be a plan ID or a plan file inside --dir.", stage, "Use a plan ID returned by upgrade plan and the matching --dir.");
69
- }
70
- const id = planPath.slice(planPath.lastIndexOf("/") + 1, -".plan.json".length);
71
- if (!PLAN_ID.test(id)) {
72
- throw upgradeError("UPGRADE_PLAN_INVALID", "Plan filename is invalid.", stage, "Use an unmodified plan generated by clearance upgrade plan.");
73
- }
74
- return { planPath, statePath: resolve(dir, `${id}.state.json`) };
75
- }
76
- async function readArtifact(path, code, stage) {
77
- let handle;
78
- try {
79
- handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
80
- const stat = await handle.stat();
81
- if (!stat.isFile() || stat.size < 2 || stat.size > MAX_ARTIFACT_BYTES)
82
- throw new Error();
83
- const bytes = await handle.readFile();
84
- return { parsed: JSON.parse(bytes.toString("utf8")), bytes };
85
- }
86
- catch {
87
- throw upgradeError(code, "Upgrade artifact is missing or invalid JSON.", stage, "Create a new plan with clearance upgrade plan; do not edit upgrade artifacts.");
88
- }
89
- finally {
90
- await handle?.close().catch(() => undefined);
91
- }
92
- }
93
- async function listDirectory(path, stage, allowMissing = false) {
94
- try {
95
- return await fs.readdir(path);
96
- }
97
- catch (error) {
98
- if (allowMissing && error.code === "ENOENT")
99
- return [];
100
- throw upgradeError("UPGRADE_DIR_UNREADABLE", "Upgrade artifact directory cannot be read.", stage, "Use a readable, dedicated upgrade artifact directory.");
101
- }
102
- }
103
- async function assertArtifactDirectory(path, stage, allowMissing = false) {
104
- try {
105
- const stat = await fs.lstat(path);
106
- if (stat.isSymbolicLink() || !stat.isDirectory())
107
- throw new Error();
108
- }
109
- catch (error) {
110
- if (allowMissing && error.code === "ENOENT")
111
- return;
112
- throw upgradeError("UPGRADE_DIR_UNSAFE", "Upgrade artifact directory must be a real directory, not a file or symlink.", stage, "Use a dedicated local directory owned by the current operator.");
113
- }
114
- }
115
- async function readArtifacts(planRef, dir, stage) {
116
- await assertArtifactDirectory(dir, stage);
117
- const { planPath, statePath } = planPaths(planRef, dir, stage);
118
- const planArtifact = await readArtifact(planPath, "UPGRADE_PLAN_INVALID", stage);
119
- const stateArtifact = await readArtifact(statePath, "UPGRADE_STATE_INVALID", stage);
120
- const plan = planArtifact.parsed;
121
- const state = stateArtifact.parsed;
122
- const planId = planPath.slice(planPath.lastIndexOf("/") + 1, -".plan.json".length);
123
- if (plan.immutable !== true || plan.planId !== planId || state.planId !== planId || typeof plan.currentVersion !== "string" || typeof plan.targetVersion !== "string" || typeof state.status !== "string" || typeof state.planSha256 !== "string" || !SHA256.test(state.planSha256)) {
124
- throw upgradeError("UPGRADE_PLAN_INVALID", "Upgrade plan and state artifacts are inconsistent.", stage, "Create a new plan with clearance upgrade plan; do not edit upgrade artifacts.");
125
- }
126
- safeVersion(plan.currentVersion, "current", stage);
127
- safeVersion(plan.targetVersion, "target", stage);
128
- if (plan.currentVersion === plan.targetVersion) {
129
- throw upgradeError("UPGRADE_PLAN_INVALID", "Upgrade plan current and target versions must differ.", stage, "Create a new plan for a different target release.");
130
- }
131
- const checksum = createHash("sha256").update(planArtifact.bytes).digest("hex");
132
- if (checksum !== state.planSha256) {
133
- throw upgradeError("UPGRADE_PLAN_TAMPERED", "Upgrade plan checksum does not match its state artifact.", stage, "Create a new plan with clearance upgrade plan; do not edit upgrade artifacts.");
134
- }
135
- return { planPath, statePath, plan, state };
136
- }
137
- async function runScript(operation, args, stage) {
138
- try {
139
- await execFileAsync("bash", [SCRIPTS[operation], ...args], {
140
- cwd: OPS_ROOT,
141
- env: process.env,
142
- maxBuffer: 1024 * 1024,
143
- timeout: SCRIPT_TIMEOUT_MS,
144
- });
145
- }
146
- catch {
147
- throw upgradeError("UPGRADE_SCRIPT_FAILED", `Upgrade ${operation} failed.`, stage, "Inspect the upgrade plan/state artifacts and protected operational logs, correct the issue, then create a new plan if required.");
148
- }
149
- }
150
- function planResult(plan, state, planPath, dryRun = false) {
151
- return {
152
- schemaVersion: "v1",
153
- operation: "upgrade.plan",
154
- dryRun,
155
- plan: {
156
- id: plan.planId,
157
- path: planPath,
158
- currentVersion: plan.currentVersion,
159
- targetVersion: plan.targetVersion,
160
- status: state.status,
161
- },
162
- };
163
- }
164
- export async function planUpgrade(opts) {
165
- const target = safeVersion(opts.target, "target", "upgrade.plan");
166
- const dir = safeDirectory(opts.dir, "upgrade.plan");
167
- const current = opts.current ? safeVersion(opts.current, "current", "upgrade.plan") : undefined;
168
- if (opts.dryRun) {
169
- return { schemaVersion: "v1", operation: "upgrade.plan", dryRun: true, plan: { targetVersion: target, currentVersion: current ?? null, directory: dir, createsArtifacts: false } };
170
- }
171
- const args = ["--target", target, "--dir", dir];
172
- if (current)
173
- args.push("--current", current);
174
- await assertArtifactDirectory(dir, "upgrade.plan", true);
175
- const existingPlans = new Set(await listDirectory(dir, "upgrade.plan", true));
176
- await runScript("plan", args, "upgrade.plan");
177
- await assertArtifactDirectory(dir, "upgrade.plan");
178
- const entries = await listDirectory(dir, "upgrade.plan");
179
- const createdPlans = entries.filter((entry) => entry.endsWith(".plan.json") && !existingPlans.has(entry));
180
- if (createdPlans.length !== 1)
181
- throw upgradeError("UPGRADE_ARTIFACT_AMBIGUOUS", "Plan script did not create exactly one identifiable plan artifact.", "upgrade.plan", "Retry in an empty, dedicated upgrade artifact directory with no concurrent upgrade planning process.");
182
- const artifacts = await readArtifacts(resolve(dir, createdPlans[0]), dir, "upgrade.plan");
183
- return planResult(artifacts.plan, artifacts.state, artifacts.planPath);
184
- }
185
- export async function applyUpgrade(opts) {
186
- const dir = safeDirectory(opts.dir, "upgrade.apply");
187
- const artifacts = await readArtifacts(opts.plan ?? "", dir, "upgrade.apply");
188
- if (opts.dryRun)
189
- return { ...planResult(artifacts.plan, artifacts.state, artifacts.planPath, true), operation: "upgrade.apply", wouldRun: ["preflight", "verified_backup", "version_hook"] };
190
- if (!opts.yes)
191
- throw upgradeError("UPGRADE_APPLY_CONFIRMATION_REQUIRED", "upgrade apply requires --yes.", "upgrade.apply", "Review the plan or run upgrade apply with --dry-run, then rerun with --yes to apply.");
192
- await runScript("apply", ["--plan", artifacts.planPath, "--dir", dir], "upgrade.apply");
193
- const result = await readArtifacts(artifacts.planPath, dir, "upgrade.apply");
194
- return { schemaVersion: "v1", operation: "upgrade.apply", dryRun: false, plan: { id: result.plan.planId, targetVersion: result.plan.targetVersion, status: result.state.status, backupId: result.state.backupId ?? null, rollbackReference: result.state.rollbackReference ?? null } };
195
- }
196
- export async function verifyUpgrade(opts) {
197
- const dir = safeDirectory(opts.dir, "upgrade.verify");
198
- const healthUrl = safeHealthUrl(opts.healthUrl);
199
- const artifacts = await readArtifacts(opts.plan ?? "", dir, "upgrade.verify");
200
- if (opts.dryRun) {
201
- return {
202
- schemaVersion: "v1",
203
- operation: "upgrade.verify",
204
- dryRun: true,
205
- plan: { id: artifacts.plan.planId, targetVersion: artifacts.plan.targetVersion, status: artifacts.state.status },
206
- wouldRun: ["backup_reference_check", "apply_marker_check", ...(healthUrl ? ["health_url_check"] : [])],
207
- };
208
- }
209
- await runScript("verify", ["--plan", artifacts.planPath, "--dir", dir, ...(healthUrl ? ["--health-url", healthUrl] : [])], "upgrade.verify");
210
- const result = await readArtifacts(artifacts.planPath, dir, "upgrade.verify");
211
- return { schemaVersion: "v1", operation: "upgrade.verify", plan: { id: result.plan.planId, targetVersion: result.plan.targetVersion, status: result.state.status, updatedAt: result.state.updatedAt ?? null, backupId: result.state.backupId ?? null } };
212
- }
213
- export async function rollbackUpgrade(opts) {
214
- const dir = safeDirectory(opts.dir, "upgrade.rollback");
215
- const artifacts = await readArtifacts(opts.plan ?? "", dir, "upgrade.rollback");
216
- const mode = opts.restoreActive ? "active_database_restore" : "isolated_verify_only";
217
- if (opts.dryRun) {
218
- return {
219
- schemaVersion: "v1",
220
- operation: "upgrade.rollback",
221
- dryRun: true,
222
- mode,
223
- activeDatabaseUntouched: true,
224
- wouldModifyActiveDatabase: Boolean(opts.restoreActive),
225
- plan: { id: artifacts.plan.planId, targetVersion: artifacts.plan.targetVersion, status: artifacts.state.status },
226
- wouldRun: opts.restoreActive
227
- ? ["advisory_lock", "safety_backup", "staging_restore", "database_swap", "live_verification", "rollback_receipt"]
228
- : ["backup_checksum_check", "isolated_restore", "reconciliation", "rollback_receipt"],
229
- };
230
- }
231
- if (!opts.yes) {
232
- throw upgradeError("UPGRADE_ROLLBACK_CONFIRMATION_REQUIRED", "upgrade rollback requires --yes.", "upgrade.rollback", "Review with upgrade rollback --dry-run, then rerun with --yes to verify the rollback reference.");
233
- }
234
- const scriptArgs = ["--plan", artifacts.planPath, "--dir", dir];
235
- if (opts.restoreActive) {
236
- if (!opts.confirm || !new RegExp(`^RESTORE_ACTIVE:${artifacts.plan.planId}:[A-Za-z_][A-Za-z0-9_]{0,62}$`).test(opts.confirm)) {
237
- throw upgradeError("UPGRADE_ACTIVE_ROLLBACK_CONFIRMATION_REQUIRED", "Active rollback requires the exact plan and database confirmation token.", "upgrade.rollback", `Pass --restore-active --confirm RESTORE_ACTIVE:${artifacts.plan.planId}:<database> with --yes.`);
238
- }
239
- scriptArgs.push("--restore-active", "--confirm", opts.confirm);
240
- if (opts.backupDir)
241
- scriptArgs.push("--backup-dir", safeDirectory(opts.backupDir, "upgrade.rollback"));
242
- }
243
- await runScript("rollback", scriptArgs, "upgrade.rollback");
244
- const result = await readArtifacts(artifacts.planPath, dir, "upgrade.rollback");
245
- const receiptPath = opts.restoreActive
246
- ? result.state.rollbackReceipt
247
- : resolve(dir, "rollbacks", `${result.plan.planId}.rollback-drill.json`);
248
- if (typeof receiptPath !== "string" || !isInside(dir, resolve(receiptPath))) {
249
- throw upgradeError("UPGRADE_ROLLBACK_RECEIPT_INVALID", "Rollback receipt is missing or escaped the upgrade directory.", "upgrade.rollback", "Inspect protected rollback logs and preserve the upgrade artifact directory for recovery.");
250
- }
251
- const receipt = await readArtifact(resolve(receiptPath), "UPGRADE_ROLLBACK_RECEIPT_INVALID", "upgrade.rollback");
252
- return {
253
- schemaVersion: "v1",
254
- operation: "upgrade.rollback",
255
- dryRun: false,
256
- mode,
257
- activeDatabaseUntouched: !opts.restoreActive,
258
- plan: { id: result.plan.planId, targetVersion: result.plan.targetVersion, status: result.state.status },
259
- rollbackReceipt: resolve(receiptPath),
260
- receipt: receipt.parsed,
261
- };
262
- }
1
+ export { applyUpgrade, planUpgrade, rollbackUpgrade, verifyUpgrade, } from "@clearance/management";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearance/cli",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Clearance CLI — operate auth projects, enterprise connections, migrations, and deploys",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,14 +20,14 @@
20
20
  "dist"
21
21
  ],
22
22
  "dependencies": {
23
- "commander": "^13.1.0",
24
- "@clearance/management": "0.1.4"
23
+ "commander": "^14.0.3",
24
+ "@clearance/management": "0.2.1"
25
25
  },
26
26
  "devDependencies": {
27
- "@types/node": "^22.13.10",
28
- "tsx": "^4.19.3",
29
- "typescript": "^5.9.3",
30
- "vitest": "^4.1.5"
27
+ "@types/node": "^22.20.1",
28
+ "tsx": "^4.23.1",
29
+ "typescript": "^6.0.3",
30
+ "vitest": "^4.1.10"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json && node scripts/stage-ops.mjs && chmod +x dist/index.js",