@clearance/cli 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # Post-apply upgrade verification: plan state, backup reference, schema health, optional HTTP.
3
+ set -Eeuo pipefail
4
+
5
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
6
+ # shellcheck source=lib/ops-common.sh
7
+ source "$ROOT/scripts/lib/ops-common.sh"
8
+
9
+ usage() {
10
+ cat <<'EOF'
11
+ Usage: upgrade-verify.sh --plan PLAN_ID_OR_PATH [--dir DIR] [--health-url URL]
12
+ EOF
13
+ }
14
+
15
+ PLAN_DIR="${CLEARANCE_UPGRADE_DIR:-$ROOT/.clearance/upgrades}"
16
+ PLAN_REF=""
17
+ HEALTH_URL="${CLEARANCE_HEALTH_URL:-}"
18
+
19
+ while [[ $# -gt 0 ]]; do
20
+ case "$1" in
21
+ --plan) PLAN_REF="$2"; shift 2 ;;
22
+ --dir) PLAN_DIR="$2"; shift 2 ;;
23
+ --health-url) HEALTH_URL="$2"; shift 2 ;;
24
+ -h|--help) usage; exit 0 ;;
25
+ *) die "unknown argument: $1" ;;
26
+ esac
27
+ done
28
+
29
+ [[ -n "$PLAN_REF" ]] || die "--plan is required"
30
+ if [[ -f "$PLAN_REF" ]]; then
31
+ PLAN_PATH="$PLAN_REF"
32
+ else
33
+ PLAN_PATH="$PLAN_DIR/${PLAN_REF}.plan.json"
34
+ fi
35
+ [[ -f "$PLAN_PATH" ]] || die "plan not found"
36
+ require_cmd node
37
+
38
+ PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN_PATH")"
39
+ STATE_PATH="$PLAN_DIR/${PLAN_ID}.state.json"
40
+ [[ -f "$STATE_PATH" ]] || die "state missing"
41
+
42
+ STATUS="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.status||"")' "$STATE_PATH")"
43
+ [[ "$STATUS" == "applied" || "$STATUS" == "verified" ]] || die "plan state is '$STATUS' (expected applied|verified)"
44
+
45
+ BACKUP_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupId||"")' "$STATE_PATH")"
46
+ [[ -n "$BACKUP_ID" ]] || die "missing backupId rollback reference in state"
47
+ BACKUP_META="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupMeta||"")' "$STATE_PATH")"
48
+ [[ -f "$BACKUP_META" ]] || die "backup meta missing: $BACKUP_META"
49
+ bash "$ROOT/scripts/backup-verify.sh" --dump "$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupDump)' "$STATE_PATH")" --meta "$BACKUP_META" >/dev/null
50
+
51
+ # Schema still queryable
52
+ if [[ -n "${DATABASE_URL:-}" ]]; then
53
+ require_pg_client
54
+ URL="$(resolve_database_url)"
55
+ psql_q "$URL" -c 'SELECT 1' >/dev/null || die "post-apply database health query failed"
56
+ TARGET="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.targetVersion)' "$PLAN_PATH")"
57
+ require_application_release "$URL" "$TARGET"
58
+ assert_safe_version "$TARGET" "target version"
59
+ LEDGER_PLAN="$(psql_q "$URL" -At -c "SELECT plan_id FROM clearance_schema_migrations WHERE version = '${TARGET}'")"
60
+ [[ "$LEDGER_PLAN" == "$PLAN_ID" ]] || die "migration ledger missing plan/version transition"
61
+ # Fingerprint may change after real migrations; ensure DB still has public schema access
62
+ EV="$(mktemp "${TMPDIR:-/tmp}/clearance-post-ev.XXXXXX")"
63
+ collect_db_evidence "$URL" "$EV"
64
+ [[ -s "$EV" ]] || die "failed to collect post-apply schema evidence"
65
+ rm -f "$EV"
66
+ printf 'upgrade-verify: database evidence collected\n' >&2
67
+ fi
68
+
69
+ # Optional HTTP health
70
+ if [[ -n "$HEALTH_URL" ]]; then
71
+ require_cmd curl
72
+ code="$(curl -sS -o /dev/null -w '%{http_code}' "$HEALTH_URL" || true)"
73
+ [[ "$code" == "200" ]] || die "health URL $HEALTH_URL returned HTTP $code"
74
+ printf 'upgrade-verify: health URL OK\n' >&2
75
+ fi
76
+
77
+ # Applied marker exists
78
+ MARKER="$PLAN_DIR/markers/${PLAN_ID}.applied"
79
+ [[ -f "$MARKER" ]] || die "apply marker missing"
80
+
81
+ TARGET="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.targetVersion)' "$PLAN_PATH")"
82
+ MARKER_TO="$(node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.stdout.write(j.toVersion||"")' "$MARKER")"
83
+ [[ "$MARKER_TO" == "$TARGET" ]] || die "marker version mismatch"
84
+
85
+ node -e '
86
+ const fs=require("fs");
87
+ const p=process.argv[1];
88
+ const j=JSON.parse(fs.readFileSync(p,"utf8"));
89
+ const at=new Date().toISOString().replace(/\.\d{3}Z$/,"Z");
90
+ j.status="verified";
91
+ j.updatedAt=at;
92
+ j.applyJournal=j.applyJournal||[];
93
+ j.applyJournal.push({at, event:"verified"});
94
+ fs.writeFileSync(p, JSON.stringify(j,null,2)+"\n");
95
+ ' "$STATE_PATH"
96
+
97
+ printf 'upgrade-verify: OK plan=%s target=%s\n' "$PLAN_ID" "$TARGET"
98
+ printf 'VERIFY_OK=1\n'
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env bash
2
+ # Fail-closed production environment validation for Compose/Helm/TF operators.
3
+ # Does not print secret values. Exits non-zero on any missing/weak/default secret.
4
+ set -Eeuo pipefail
5
+
6
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+ # shellcheck source=lib/ops-common.sh
8
+ source "$ROOT/scripts/lib/ops-common.sh"
9
+ require_cmd node
10
+
11
+ usage() {
12
+ cat <<'EOF'
13
+ Usage: validate-production-env.sh
14
+
15
+ Validates operator-supplied environment for production Compose overlay:
16
+ deploy/compose/docker-compose.production.yml
17
+
18
+ Required (strong secrets, no defaults):
19
+ CLEARANCE_OPERATOR_TOKEN
20
+ CLEARANCE_SECRET
21
+ CLEARANCE_CREDENTIAL_KEY
22
+ CLEARANCE_CREDENTIAL_KEY_ID
23
+ CLEARANCE_CONSOLE_ADMIN_USER
24
+ CLEARANCE_CONSOLE_ADMIN_PASSWORD
25
+ CLEARANCE_CONSOLE_SESSION_SECRET
26
+ CLEARANCE_DB_USER
27
+ CLEARANCE_DB_PASSWORD
28
+ CLEARANCE_DB_NAME
29
+ DATABASE_URL # full postgres URL; no compose password interpolation
30
+ CLEARANCE_BASE_URL
31
+ CLEARANCE_CONSOLE_URL
32
+ CLEARANCE_CORS_ORIGINS
33
+ CLEARANCE_API_PORT
34
+ CLEARANCE_CONSOLE_PORT
35
+ CLEARANCE_SAMPLE_PORT
36
+ CLEARANCE_PG_VOLUME
37
+ CLEARANCE_BACKUP_VOLUME
38
+ CLEARANCE_IMAGE_REPOSITORY
39
+ CLEARANCE_IMAGE_DIGEST # sha256:... from the signed release
40
+ CLEARANCE_BACKUP_IMAGE_REPOSITORY
41
+ CLEARANCE_BACKUP_IMAGE_DIGEST # sha256:... from the signed release
42
+
43
+ Optional:
44
+ CLEARANCE_POSTGRES_PORT # only if intentionally publishing Postgres to the host
45
+ CLEARANCE_GITHUB_CLIENT_ID + CLEARANCE_GITHUB_CLIENT_SECRET
46
+ CLEARANCE_GOOGLE_CLIENT_ID + CLEARANCE_GOOGLE_CLIENT_SECRET
47
+
48
+ Fails closed on missing, empty, short, or known-weak values.
49
+ EOF
50
+ }
51
+
52
+ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
53
+ usage
54
+ exit 0
55
+ fi
56
+
57
+ errors=0
58
+ note() { printf 'ok: %s\n' "$*"; }
59
+ fail() { printf 'fail: %s\n' "$*" >&2; errors=$((errors + 1)); }
60
+
61
+ check_secret() {
62
+ local label="$1"
63
+ local value="${2-}"
64
+ if is_forbidden_secret "$value"; then
65
+ fail "$label is missing, empty, short (<16), or a known weak default"
66
+ else
67
+ note "$label present and not a known weak default (len=${#value})"
68
+ fi
69
+ }
70
+
71
+ check_present() {
72
+ local label="$1"
73
+ local value="${2-}"
74
+ if [[ -z "$value" ]]; then
75
+ fail "$label is required and must be non-empty"
76
+ else
77
+ note "$label is set"
78
+ fi
79
+ }
80
+
81
+ check_port() {
82
+ local label="$1"
83
+ local value="$2"
84
+ if [[ ! "$value" =~ ^[0-9]+$ ]] || (( 10#$value < 1 || 10#$value > 65535 )); then
85
+ fail "$label must be an integer from 1 through 65535"
86
+ else
87
+ note "$label is a valid TCP port"
88
+ fi
89
+ }
90
+
91
+ check_https_url() {
92
+ local label="$1"
93
+ local value="$2"
94
+ if [[ "$value" =~ ^https://[^[:space:]]+$ ]]; then
95
+ note "$label uses HTTPS"
96
+ elif [[ "${CLEARANCE_ALLOW_LOCALHOST_PRODUCTION:-}" == "1" \
97
+ && "$value" =~ ^http://(localhost|127\.0\.0\.1)(:[0-9]+)?(/.*)?$ ]]; then
98
+ note "$label uses explicitly allowed local HTTP"
99
+ else
100
+ fail "$label must be an absolute HTTPS URL (local HTTP requires CLEARANCE_ALLOW_LOCALHOST_PRODUCTION=1)"
101
+ fi
102
+ }
103
+
104
+ # Secrets / credentials
105
+ check_secret CLEARANCE_OPERATOR_TOKEN "${CLEARANCE_OPERATOR_TOKEN-}"
106
+ check_secret CLEARANCE_SECRET "${CLEARANCE_SECRET-}"
107
+ check_secret CLEARANCE_CREDENTIAL_KEY "${CLEARANCE_CREDENTIAL_KEY-}"
108
+ check_present CLEARANCE_CREDENTIAL_KEY_ID "${CLEARANCE_CREDENTIAL_KEY_ID-}"
109
+ check_present CLEARANCE_CONSOLE_ADMIN_USER "${CLEARANCE_CONSOLE_ADMIN_USER-}"
110
+ check_secret CLEARANCE_CONSOLE_ADMIN_PASSWORD "${CLEARANCE_CONSOLE_ADMIN_PASSWORD-}"
111
+ check_secret CLEARANCE_CONSOLE_SESSION_SECRET "${CLEARANCE_CONSOLE_SESSION_SECRET-}"
112
+ check_secret CLEARANCE_DB_PASSWORD "${CLEARANCE_DB_PASSWORD-}"
113
+
114
+ # Non-secret required production knobs (no compose defaults in overlay)
115
+ check_present CLEARANCE_DB_USER "${CLEARANCE_DB_USER-}"
116
+ check_present CLEARANCE_DB_NAME "${CLEARANCE_DB_NAME-}"
117
+ check_present CLEARANCE_BASE_URL "${CLEARANCE_BASE_URL-}"
118
+ check_present CLEARANCE_CONSOLE_URL "${CLEARANCE_CONSOLE_URL-}"
119
+ check_present CLEARANCE_CORS_ORIGINS "${CLEARANCE_CORS_ORIGINS-}"
120
+ check_port CLEARANCE_API_PORT "${CLEARANCE_API_PORT-}"
121
+ check_port CLEARANCE_CONSOLE_PORT "${CLEARANCE_CONSOLE_PORT-}"
122
+ check_port CLEARANCE_SAMPLE_PORT "${CLEARANCE_SAMPLE_PORT-}"
123
+ check_present CLEARANCE_PG_VOLUME "${CLEARANCE_PG_VOLUME-}"
124
+ check_present CLEARANCE_BACKUP_VOLUME "${CLEARANCE_BACKUP_VOLUME-}"
125
+ check_present CLEARANCE_IMAGE_REPOSITORY "${CLEARANCE_IMAGE_REPOSITORY-}"
126
+ check_present CLEARANCE_BACKUP_IMAGE_REPOSITORY "${CLEARANCE_BACKUP_IMAGE_REPOSITORY-}"
127
+ if [[ "${CLEARANCE_IMAGE_DIGEST-}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
128
+ note "CLEARANCE_IMAGE_DIGEST is an immutable sha256 digest"
129
+ else
130
+ fail "CLEARANCE_IMAGE_DIGEST must be sha256 followed by 64 lowercase hex characters"
131
+ fi
132
+ if [[ "${CLEARANCE_BACKUP_IMAGE_DIGEST-}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
133
+ note "CLEARANCE_BACKUP_IMAGE_DIGEST is an immutable sha256 digest"
134
+ else
135
+ fail "CLEARANCE_BACKUP_IMAGE_DIGEST must be sha256 followed by 64 lowercase hex characters"
136
+ fi
137
+
138
+ check_optional_pair() {
139
+ local provider="$1"
140
+ local client_id="$2"
141
+ local client_secret="$3"
142
+ if [[ -n "$client_id" && -n "$client_secret" ]]; then
143
+ check_secret "${provider} client secret" "$client_secret"
144
+ note "${provider} social credentials are configured as a complete pair"
145
+ elif [[ -n "$client_id" || -n "$client_secret" ]]; then
146
+ fail "${provider} social credentials must set both client id and client secret"
147
+ else
148
+ note "${provider} social provider is disabled"
149
+ fi
150
+ }
151
+
152
+ check_optional_pair GitHub "${CLEARANCE_GITHUB_CLIENT_ID-}" "${CLEARANCE_GITHUB_CLIENT_SECRET-}"
153
+ check_optional_pair Google "${CLEARANCE_GOOGLE_CLIENT_ID-}" "${CLEARANCE_GOOGLE_CLIENT_SECRET-}"
154
+
155
+ # DATABASE_URL: full string required — refuse weak defaults and incomplete forms.
156
+ if is_weak_database_url "${DATABASE_URL-}"; then
157
+ fail "DATABASE_URL missing, not a postgres URL, or uses weak/default credentials (redacted=$(redact_url "${DATABASE_URL-}"))"
158
+ else
159
+ note "DATABASE_URL looks like a non-default postgres URL (redacted=$(redact_url "$DATABASE_URL"))"
160
+ fi
161
+
162
+ # The database container credentials and application URL must describe the same
163
+ # user, password, and database. Compare via process environment; print no values.
164
+ if [[ -n "${DATABASE_URL-}" ]]; then
165
+ if DATABASE_URL_CHECK="$DATABASE_URL" \
166
+ EXPECT_DB_USER="${CLEARANCE_DB_USER-}" \
167
+ EXPECT_DB_PASSWORD="${CLEARANCE_DB_PASSWORD-}" \
168
+ EXPECT_DB_NAME="${CLEARANCE_DB_NAME-}" \
169
+ node -e '
170
+ const u=new URL(process.env.DATABASE_URL_CHECK);
171
+ if(!/^postgres(ql)?:$/.test(u.protocol)) process.exit(1);
172
+ if(decodeURIComponent(u.username)!==process.env.EXPECT_DB_USER) process.exit(2);
173
+ if(decodeURIComponent(u.password)!==process.env.EXPECT_DB_PASSWORD) process.exit(3);
174
+ if(decodeURIComponent(u.pathname.replace(/^\//,""))!==process.env.EXPECT_DB_NAME) process.exit(4);
175
+ ' 2>/dev/null; then
176
+ note "DATABASE_URL credentials and database match Compose Postgres settings"
177
+ else
178
+ fail "DATABASE_URL user/password/database must match CLEARANCE_DB_USER/CLEARANCE_DB_PASSWORD/CLEARANCE_DB_NAME"
179
+ fi
180
+ fi
181
+
182
+ check_https_url CLEARANCE_BASE_URL "${CLEARANCE_BASE_URL-}"
183
+ check_https_url CLEARANCE_CONSOLE_URL "${CLEARANCE_CONSOLE_URL-}"
184
+ IFS=',' read -r -a cors_origins <<<"${CLEARANCE_CORS_ORIGINS-}"
185
+ for origin in "${cors_origins[@]}"; do
186
+ check_https_url "CLEARANCE_CORS_ORIGINS entry" "${origin//[[:space:]]/}"
187
+ done
188
+
189
+ # Refuse localhost-only defaults that are fine for dev but not production profiles
190
+ if [[ "${CLEARANCE_BASE_URL-}" == *"localhost"* || "${CLEARANCE_BASE_URL-}" == *"127.0.0.1"* ]]; then
191
+ if [[ "${CLEARANCE_ALLOW_LOCALHOST_PRODUCTION:-}" != "1" ]]; then
192
+ fail "CLEARANCE_BASE_URL points at localhost; set CLEARANCE_ALLOW_LOCALHOST_PRODUCTION=1 only for intentional local prod-profile tests"
193
+ else
194
+ note "CLEARANCE_BASE_URL is localhost (explicitly allowed for local prod-profile tests)"
195
+ fi
196
+ fi
197
+
198
+ # NODE_ENV must not be development when operators claim production
199
+ if [[ "${NODE_ENV-}" == "development" || "${CLEARANCE_NODE_ENV-}" == "development" ]]; then
200
+ fail "NODE_ENV/CLEARANCE_NODE_ENV must not be 'development' for production validation"
201
+ else
202
+ note "NODE_ENV is not development"
203
+ fi
204
+
205
+ # Compose production overlay must exist
206
+ overlay="$ROOT/deploy/compose/docker-compose.production.yml"
207
+ if [[ ! -f "$overlay" ]]; then
208
+ fail "missing production overlay: deploy/compose/docker-compose.production.yml"
209
+ else
210
+ # Static fail-closed markers in overlay
211
+ if grep -qE 'CLEARANCE_SECRET:-\$\{|:-dev|:-secret|:-change-me|:-clearance\}' "$overlay"; then
212
+ fail "production overlay appears to embed weak defaults"
213
+ fi
214
+ if grep -qE 'DATABASE_URL:-\$\{' "$overlay"; then
215
+ fail "production overlay must not construct DATABASE_URL from password parts"
216
+ fi
217
+ if ! grep -q 'DATABASE_URL: \${DATABASE_URL:?' "$overlay"; then
218
+ fail "production overlay must require DATABASE_URL with fail-closed \${DATABASE_URL:?...}"
219
+ fi
220
+ if ! grep -q 'NODE_ENV: production' "$overlay"; then
221
+ fail "production overlay must force NODE_ENV: production"
222
+ fi
223
+ note "production overlay present with fail-closed DATABASE_URL and NODE_ENV"
224
+ fi
225
+
226
+ if [[ "$errors" -ne 0 ]]; then
227
+ printf '\nvalidate-production-env: FAILED (%s checks)\n' "$errors" >&2
228
+ exit 1
229
+ fi
230
+
231
+ printf '\nvalidate-production-env: OK\n'
232
+ exit 0
@@ -0,0 +1,19 @@
1
+ import { ClearanceError } from "@clearance/management";
2
+ export interface GlobalOpts {
3
+ json?: boolean;
4
+ noInput?: boolean;
5
+ yes?: boolean;
6
+ dryRun?: boolean;
7
+ dataPath?: string;
8
+ localDirect?: boolean;
9
+ profile?: string;
10
+ apiUrl?: string;
11
+ }
12
+ export declare class CliExitError extends Error {
13
+ readonly exitCode: number;
14
+ constructor(exitCode?: number);
15
+ }
16
+ export declare function printResult(opts: GlobalOpts, data: unknown, human?: string): void;
17
+ export declare function fail(err: unknown, opts: GlobalOpts): never;
18
+ export declare function exitCodeFromDoctor(ok: boolean): number;
19
+ export declare function asClearanceError(err: unknown): ClearanceError | null;
package/dist/output.js ADDED
@@ -0,0 +1,52 @@
1
+ import { isClearanceError } from "@clearance/management";
2
+ export class CliExitError extends Error {
3
+ exitCode;
4
+ constructor(exitCode = 1) {
5
+ super("CLI command failed");
6
+ this.name = "CliExitError";
7
+ this.exitCode = exitCode;
8
+ }
9
+ }
10
+ export function printResult(opts, data, human) {
11
+ if (opts.json) {
12
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
13
+ return;
14
+ }
15
+ if (human) {
16
+ process.stdout.write(`${human}\n`);
17
+ return;
18
+ }
19
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
20
+ }
21
+ export function fail(err, opts) {
22
+ // A CliExitError means fail() already emitted a structured document and is
23
+ // unwinding. Re-throw untouched so a catch block that funnels back into
24
+ // fail() can never emit a second JSON document on stdout (--json contract:
25
+ // exactly one document per invocation).
26
+ if (err instanceof CliExitError) {
27
+ throw err;
28
+ }
29
+ if (isClearanceError(err)) {
30
+ if (opts.json) {
31
+ process.stdout.write(`${JSON.stringify(err.toJSON(), null, 2)}\n`);
32
+ }
33
+ else {
34
+ process.stderr.write(`Error [${err.code}] stage=${err.stage}: ${err.message}\nRemediation: ${err.remediation}\n`);
35
+ }
36
+ throw new CliExitError(1);
37
+ }
38
+ const message = err instanceof Error ? err.message : String(err);
39
+ if (opts.json) {
40
+ process.stdout.write(`${JSON.stringify({ error: { code: "INTERNAL", message, stage: "unknown", retryable: false } }, null, 2)}\n`);
41
+ }
42
+ else {
43
+ process.stderr.write(`Error: ${message}\n`);
44
+ }
45
+ throw new CliExitError(1);
46
+ }
47
+ export function exitCodeFromDoctor(ok) {
48
+ return ok ? 0 : 2;
49
+ }
50
+ export function asClearanceError(err) {
51
+ return isClearanceError(err) ? err : null;
52
+ }
@@ -0,0 +1,10 @@
1
+ import type { Command } from "commander";
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>;
5
+ export declare const REMOTE_COMMANDS: Set<string>;
6
+ export type CommandExecution = "authentication" | "remote-api" | "host-local" | "unavailable";
7
+ export declare function classifyCommandPath(path: string): CommandExecution;
8
+ export declare function commandPath(command: Command): string;
9
+ export declare function isHostLocalCommand(path: string): boolean;
10
+ export declare function dispatchRemoteCommand(session: ApiSession, path: string, args: unknown[], opts: Record<string, unknown>, global: GlobalOpts): Promise<unknown>;
@@ -0,0 +1,232 @@
1
+ import { ClearanceError } from "@clearance/management";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
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
+ ]);
29
+ export const REMOTE_COMMANDS = new Set([
30
+ "init", "doctor", "overview",
31
+ "project list", "project inspect", "project create",
32
+ "env list", "env inspect", "env create", "env promote",
33
+ "users list", "users inspect", "users create", "users update", "users disable", "users delete",
34
+ "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",
37
+ "keys list", "keys create", "keys rotate", "keys revoke",
38
+ "sessions list", "sessions revoke",
39
+ "roles list", "roles validate", "roles create", "roles update",
40
+ "sso create", "sso configure", "sso test", "sso list", "sso setup-link", "sso rotate", "sso disable",
41
+ "scim create", "scim test", "scim list", "scim setup-link", "scim rotate", "scim disable", "scim replay",
42
+ "readiness check", "readiness report",
43
+ "config get", "config set", "config validate", "config diff",
44
+ ]);
45
+ export function classifyCommandPath(path) {
46
+ if (path === "login" || path === "logout" || path === "whoami")
47
+ return "authentication";
48
+ if (HOST_LOCAL_COMMANDS.has(path))
49
+ return "host-local";
50
+ if (REMOTE_COMMANDS.has(path))
51
+ return "remote-api";
52
+ return "unavailable";
53
+ }
54
+ function error(code, message, remediation) {
55
+ return new ClearanceError({ code, message, stage: "cli.dispatch", remediation });
56
+ }
57
+ function query(path, values) {
58
+ const params = new URLSearchParams();
59
+ for (const [key, value] of Object.entries(values)) {
60
+ if (value !== undefined && value !== false && value !== "")
61
+ params.set(key, String(value));
62
+ }
63
+ return `${path}${params.size ? `?${params}` : ""}`;
64
+ }
65
+ function body(values) {
66
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
67
+ }
68
+ export function commandPath(command) {
69
+ const names = [];
70
+ let current = command;
71
+ while (current?.parent) {
72
+ names.unshift(current.name());
73
+ current = current.parent;
74
+ }
75
+ return names.join(" ");
76
+ }
77
+ export function isHostLocalCommand(path) {
78
+ return HOST_LOCAL_COMMANDS.has(path);
79
+ }
80
+ function requireRemoteMutation(global, path) {
81
+ 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.");
83
+ }
84
+ }
85
+ function requireConfirmation(global, code, label) {
86
+ if (!global.yes && !global.dryRun) {
87
+ throw error(code, `${label} requires --yes.`, "Review the target, then pass --yes to confirm.");
88
+ }
89
+ }
90
+ function requireLiveTestMode(global, code, label) {
91
+ if (global.dryRun) {
92
+ throw error(code, `${label} cannot combine --live with --dry-run.`, "Remove --dry-run, review the live target, then pass --yes to confirm.");
93
+ }
94
+ requireConfirmation(global, code, label);
95
+ }
96
+ export async function dispatchRemoteCommand(session, path, args, opts, global) {
97
+ const id = typeof args[0] === "string" ? encodeURIComponent(args[0]) : "";
98
+ switch (path) {
99
+ case "init":
100
+ requireRemoteMutation(global, path);
101
+ return requestManagementApi(session, { method: "POST", path: "/v1/init", body: body({ name: opts.name, environment: opts.environment }) });
102
+ case "doctor": return requestManagementApi(session, { path: "/v1/doctor" });
103
+ case "overview": return requestManagementApi(session, { path: "/v1/overview" });
104
+ case "project list": return requestManagementApi(session, { path: "/v1/projects" });
105
+ case "project inspect": return requestManagementApi(session, { path: id ? `/v1/projects/${id}` : "/v1/projects/current" });
106
+ case "project create":
107
+ return requestManagementApi(session, { method: "POST", path: "/v1/projects", body: { name: opts.name, dryRun: global.dryRun } });
108
+ case "env list": return requestManagementApi(session, { path: "/v1/environments" });
109
+ case "env inspect": return requestManagementApi(session, { path: id ? `/v1/environments/${id}` : "/v1/environments/current" });
110
+ case "env create":
111
+ return requestManagementApi(session, { method: "POST", path: "/v1/environments", body: body({ name: opts.name, projectId: opts.projectId, kind: opts.kind, dryRun: global.dryRun }) });
112
+ case "env promote":
113
+ return requestManagementApi(session, { method: "POST", path: "/v1/environments/promote", body: body({ to: opts.to, from: opts.from, dryRun: global.dryRun || !global.yes, confirm: global.yes && !global.dryRun }) });
114
+ case "users list": return requestManagementApi(session, { path: query("/v1/users", { limit: opts.limit, cursor: opts.cursor }) });
115
+ case "users inspect": return requestManagementApi(session, { path: `/v1/users/${id}` });
116
+ case "users create":
117
+ return requestManagementApi(session, { method: "POST", path: "/v1/users", body: body({ email: opts.email, name: opts.name, password: opts.password, dryRun: global.dryRun }) });
118
+ case "users update":
119
+ return requestManagementApi(session, { method: "PATCH", path: `/v1/users/${id}`, body: body({ email: opts.email, name: opts.name, status: opts.status, dryRun: global.dryRun }) });
120
+ case "users disable":
121
+ return requestManagementApi(session, { method: "POST", path: `/v1/users/${id}/disable`, body: { dryRun: global.dryRun } });
122
+ case "users delete":
123
+ requireConfirmation(global, "USER_DELETE_CONFIRM_REQUIRED", "User deletion");
124
+ requireRemoteMutation(global, path);
125
+ return requestManagementApi(session, { method: "DELETE", path: `/v1/users/${id}` });
126
+ case "orgs list": return requestManagementApi(session, { path: query("/v1/organizations", { limit: opts.limit, cursor: opts.cursor }) });
127
+ case "orgs inspect": return requestManagementApi(session, { path: `/v1/organizations/${id}` });
128
+ case "orgs create":
129
+ requireRemoteMutation(global, path);
130
+ return requestManagementApi(session, { method: "POST", path: "/v1/organizations", body: body({ name: opts.name, slug: opts.slug, ownerUserId: opts.ownerUser }) });
131
+ case "orgs update":
132
+ return requestManagementApi(session, { method: "PATCH", path: `/v1/organizations/${id}`, body: body({ name: opts.name, slug: opts.slug, dryRun: global.dryRun }) });
133
+ case "orgs archive":
134
+ return requestManagementApi(session, { method: "POST", path: `/v1/organizations/${id}/archive`, body: { dryRun: global.dryRun || !global.yes, confirm: global.yes && !global.dryRun } });
135
+ case "orgs members list": return requestManagementApi(session, { path: `/v1/organizations/${encodeURIComponent(String(opts.org))}/members` });
136
+ case "orgs members add":
137
+ 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
+ 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 } });
142
+ }
143
+ case "orgs members remove": {
144
+ 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 } });
148
+ }
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
+ case "events inspect": return requestManagementApi(session, { path: `/v1/events/${id}` });
151
+ 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
+ case "keys list": return requestManagementApi(session, { path: query("/v1/keys", { includeRevoked: opts.includeRevoked }) });
153
+ case "keys create":
154
+ return requestManagementApi(session, { method: "POST", path: "/v1/keys", body: { name: opts.name, scopes: opts.scope, dryRun: global.dryRun } });
155
+ case "keys rotate":
156
+ requireConfirmation(global, "API_KEY_CONFIRMATION_REQUIRED", "API key rotation");
157
+ return requestManagementApi(session, { method: "POST", path: `/v1/keys/${id}/rotate`, body: { dryRun: global.dryRun } });
158
+ case "keys revoke":
159
+ requireConfirmation(global, "API_KEY_CONFIRMATION_REQUIRED", "API key revocation");
160
+ return requestManagementApi(session, { method: "POST", path: `/v1/keys/${id}/revoke`, body: { dryRun: global.dryRun } });
161
+ case "sessions list": return requestManagementApi(session, { path: query("/v1/sessions", { limit: opts.limit, cursor: opts.cursor, userId: opts.user, status: opts.status }) });
162
+ 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` });
166
+ 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 }) });
168
+ case "roles create":
169
+ 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
+ case "roles update":
171
+ return requestManagementApi(session, { method: "PATCH", path: `/v1/roles/${id}`, body: body({ name: opts.name, description: opts.description, permissions: opts.permission, dryRun: global.dryRun }) });
172
+ case "sso create":
173
+ requireRemoteMutation(global, path);
174
+ 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
+ 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 }) });
178
+ case "sso test":
179
+ if (opts.live && opts.fixture)
180
+ throw error("SSO_TEST_MODE_CONFLICT", "--live and --fixture are mutually exclusive.", "Use one SSO test mode.");
181
+ if (opts.live)
182
+ requireLiveTestMode(global, "SSO_LIVE_CONFIRM_REQUIRED", "Live SSO conformance");
183
+ return requestManagementApi(session, { method: "POST", path: `/v1/sso/${id}/test`, body: body({ fixture: opts.fixture, live: opts.live }) });
184
+ case "sso list": return requestManagementApi(session, { path: query("/v1/sso", { organizationId: opts.org }) });
185
+ case "sso setup-link":
186
+ requireRemoteMutation(global, path);
187
+ return requestManagementApi(session, { method: "POST", path: "/v1/sso/setup-links", body: { organizationId: opts.org } });
188
+ case "sso rotate":
189
+ requireConfirmation(global, "SSO_CONFIRM_REQUIRED", "SSO credential rotation");
190
+ return requestManagementApi(session, { method: "POST", path: `/v1/sso/${id}/rotate`, body: { dryRun: global.dryRun } });
191
+ case "sso disable":
192
+ requireConfirmation(global, "SSO_DISABLE_CONFIRM_REQUIRED", "SSO disable");
193
+ return requestManagementApi(session, { method: "POST", path: `/v1/sso/${id}/disable`, body: { dryRun: global.dryRun } });
194
+ case "scim create":
195
+ requireRemoteMutation(global, path);
196
+ return requestManagementApi(session, { method: "POST", path: "/v1/scim", body: body({ organizationId: opts.org, provider: opts.provider, endpoint: opts.endpoint }) });
197
+ case "scim test":
198
+ if (opts.live && opts.fixture)
199
+ throw error("SCIM_TEST_MODE_CONFLICT", "--live and --fixture are mutually exclusive.", "Use one SCIM test mode.");
200
+ if (opts.live)
201
+ requireLiveTestMode(global, "SCIM_LIVE_CONFIRM_REQUIRED", "Live SCIM conformance");
202
+ return requestManagementApi(session, { method: "POST", path: `/v1/scim/${id}/test`, body: body({ fixture: opts.fixture, live: opts.live, dryRun: !opts.apply }) });
203
+ case "scim list": return requestManagementApi(session, { path: query("/v1/scim", { organizationId: opts.org }) });
204
+ case "scim setup-link":
205
+ requireRemoteMutation(global, path);
206
+ return requestManagementApi(session, { method: "POST", path: "/v1/scim/setup-links", body: { organizationId: opts.org } });
207
+ case "scim rotate":
208
+ requireConfirmation(global, "SCIM_CONFIRM_REQUIRED", "SCIM credential rotation");
209
+ return requestManagementApi(session, { method: "POST", path: `/v1/scim/${id}/rotate`, body: { dryRun: global.dryRun } });
210
+ case "scim disable":
211
+ requireConfirmation(global, "SCIM_DISABLE_CONFIRM_REQUIRED", "SCIM disable");
212
+ return requestManagementApi(session, { method: "POST", path: `/v1/scim/${id}/disable`, body: { dryRun: global.dryRun } });
213
+ case "scim replay":
214
+ return requestManagementApi(session, { method: "POST", path: `/v1/scim/traces/${encodeURIComponent(String(args[0]))}/replay`, body: { dryRun: global.dryRun || !global.yes, confirm: global.yes && !global.dryRun } });
215
+ case "readiness check":
216
+ requireRemoteMutation(global, path);
217
+ return requestManagementApi(session, { method: "POST", path: "/v1/readiness/check", body: { organizationId: opts.org } });
218
+ case "readiness report": return requestManagementApi(session, { path: `/v1/readiness/${encodeURIComponent(String(opts.org))}` });
219
+ case "config get": return requestManagementApi(session, { path: query("/v1/config", { key: args[0] }) });
220
+ case "config set": return requestManagementApi(session, { method: "PATCH", path: `/v1/config/${encodeURIComponent(String(args[0]))}`, body: { value: args[1], dryRun: global.dryRun } });
221
+ case "config validate": {
222
+ const config = opts.file ? JSON.parse(readFileSync(resolve(String(opts.file)), "utf8")) : undefined;
223
+ return requestManagementApi(session, { method: "POST", path: "/v1/config/validate", body: body({ config }) });
224
+ }
225
+ case "config diff": {
226
+ const config = JSON.parse(readFileSync(resolve(String(opts.file)), "utf8"));
227
+ return requestManagementApi(session, { method: "POST", path: "/v1/config/diff", body: { config } });
228
+ }
229
+ 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.");
231
+ }
232
+ }