@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.
- package/dist/__tests__/api-test-server.d.ts +2 -0
- package/dist/__tests__/api-test-server.js +73 -0
- package/dist/index.js +41 -43
- package/dist/ops/deploy/upgrades/README.md +4 -4
- package/dist/ops/deploy/upgrades/steps/0.2.0/apply.sh +10 -6
- package/dist/ops/deploy/upgrades/steps/0.2.1/apply.sh +66 -0
- package/dist/output.d.ts +0 -1
- package/dist/remote-dispatch.d.ts +2 -4
- package/dist/remote-dispatch.js +288 -52
- package/dist/upgrade.d.ts +1 -114
- package/dist/upgrade.js +1 -262
- package/package.json +7 -7
|
@@ -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,
|
|
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
|
|
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>", "
|
|
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
|
|
450
|
-
?
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
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.
|
|
10
|
-
`deploy/upgrades/steps/0.2.
|
|
11
|
-
`0.1.4`, advances it to `0.2.
|
|
12
|
-
packages this hook under `dist/ops/deploy/upgrades/steps/0.2.
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { type ApiSession } from "./api-client.js";
|
|
3
|
-
import type
|
|
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" | "
|
|
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>;
|
package/dist/remote-dispatch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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, "
|
|
164
|
-
|
|
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":
|
|
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
|
-
|
|
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, "
|
|
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 ?
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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": "^
|
|
24
|
-
"@clearance/management": "0.1
|
|
23
|
+
"commander": "^14.0.3",
|
|
24
|
+
"@clearance/management": "0.2.1"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@types/node": "^22.
|
|
28
|
-
"tsx": "^4.
|
|
29
|
-
"typescript": "^
|
|
30
|
-
"vitest": "^4.1.
|
|
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",
|