@miosa/cli 1.1.3 → 1.1.5

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.
@@ -1,6 +1,296 @@
1
1
  import chalk from "chalk";
2
- import { inspectApp, planApp, } from "../app-advisor.js";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { request } from "undici";
5
+ import { inspectApp, planApp } from "../app-advisor.js";
6
+ import { MiosaClient } from "../client.js";
7
+ import { loadConfig } from "../config.js";
8
+ import { UserError } from "../errors.js";
9
+ import { deploySandbox } from "./sandbox.js";
10
+ import { ensureGitignored, fetchSecretValues, toDotenv } from "./pull.js";
11
+ import { loadLocalLink } from "./link.js";
12
+ import { loadAcceptanceContract, saveReleaseReceipt, verifyApplicationRelease, } from "../app-release.js";
13
+ import { applicationIdempotencyKey, createApplicationOperation, loadApplicationOperation, updateApplicationOperation, } from "../app-operation.js";
3
14
  import { handleError, isJsonMode, printJson } from "./util.js";
15
+ function requireApplicationLink(dir) {
16
+ const link = loadLocalLink(dir);
17
+ if (!link?.deploymentId || !link.name) {
18
+ throw new UserError("This directory is not linked to a MIOSA application.", "Run `miosa app link --app <deployment-id>` first.");
19
+ }
20
+ return {
21
+ version: 2,
22
+ deploymentId: link.deploymentId,
23
+ name: link.name,
24
+ environment: link.environment,
25
+ workspaceId: link.workspaceId,
26
+ projectId: link.projectId,
27
+ };
28
+ }
29
+ function record(value) {
30
+ return value !== null && typeof value === "object" && !Array.isArray(value)
31
+ ? value
32
+ : {};
33
+ }
34
+ function stringValue(value, key) {
35
+ const found = record(value)[key];
36
+ return typeof found === "string" && found.trim() ? found.trim() : undefined;
37
+ }
38
+ function deploymentPayload(value) {
39
+ const outer = record(value);
40
+ const data = record(outer["data"]);
41
+ return Object.keys(data).length > 0 ? data : outer;
42
+ }
43
+ function boolValue(value, key) {
44
+ const found = record(value)[key];
45
+ return typeof found === "boolean" ? found : undefined;
46
+ }
47
+ async function getRelease(client, deploymentId, releaseId) {
48
+ const raw = await client.apiGet(`/api/v1/deployments/${encodeURIComponent(deploymentId)}/releases/${encodeURIComponent(releaseId)}`);
49
+ return deploymentPayload(raw);
50
+ }
51
+ function releaseVersionId(release) {
52
+ return (stringValue(release, "deployment_version_id") ??
53
+ stringValue(release, "version_id") ??
54
+ stringValue(release, "id"));
55
+ }
56
+ async function inspectLinkedRelease(client, deploymentId, releaseId, contract) {
57
+ const [rawDeployment, release, env] = await Promise.all([
58
+ client.apiGet(`/api/v1/deployments/${encodeURIComponent(deploymentId)}`),
59
+ getRelease(client, deploymentId, releaseId),
60
+ client.apiGet(`/api/v1/deployments/${encodeURIComponent(deploymentId)}/env`),
61
+ ]);
62
+ const deployment = deploymentPayload(rawDeployment);
63
+ const dockerApp = record(deployment["docker_deploy_app"]);
64
+ const metadata = record(deployment["metadata"]);
65
+ const releaseMetadata = record(release["metadata"]);
66
+ const versionId = releaseVersionId(release);
67
+ if (!versionId) {
68
+ throw new UserError(`Release ${releaseId} has no immutable version ID.`, "The publish operation did not create a promotable immutable version.");
69
+ }
70
+ const envNames = (env.data ?? [])
71
+ .map((item) => item.name)
72
+ .filter((name) => typeof name === "string");
73
+ const hostId = stringValue(deployment, "docker_deploy_host_id") ??
74
+ stringValue(dockerApp, "docker_deploy_host_id");
75
+ const product = stringValue(deployment, "deployment_product") ?? "miosa_deploy";
76
+ const activeVersion = stringValue(deployment, "active_version_id") ??
77
+ stringValue(dockerApp, "deployment_version_id");
78
+ const activeRelease = stringValue(deployment, "active_release_id") ??
79
+ stringValue(metadata, "active_release_id") ??
80
+ (activeVersion === versionId ? releaseId : undefined);
81
+ const expectedDigest = stringValue(release, "artifact_sha256") ??
82
+ stringValue(release, "archive_sha256") ??
83
+ stringValue(releaseMetadata, "artifact_sha256");
84
+ const runningDigest = stringValue(deployment, "running_artifact_sha256") ??
85
+ stringValue(dockerApp, "artifact_sha256") ??
86
+ stringValue(metadata, "running_artifact_sha256") ??
87
+ (activeVersion === versionId
88
+ ? stringValue(metadata, "artifact_sha256")
89
+ : undefined);
90
+ const publicUrl = stringValue(deployment, "public_url") ??
91
+ stringValue(dockerApp, "public_url") ??
92
+ stringValue(deployment, "auto_subdomain");
93
+ const host = hostId && product === "docker_deploy"
94
+ ? deploymentPayload(await client
95
+ .apiGet(`/api/v1/docker-deploy/hosts/${encodeURIComponent(hostId)}`)
96
+ .catch(() => ({})))
97
+ : {};
98
+ const healthyConnectorIds = (await Promise.all((contract.connectors ?? []).map(async (connector) => {
99
+ const response = deploymentPayload(await client
100
+ .apiPost(`/api/v1/deployments/${encodeURIComponent(deploymentId)}/connectors/preflight`, { connector: connector.id })
101
+ .catch(() => ({})));
102
+ const connectorStatus = record(response["status"]);
103
+ return boolValue(connectorStatus, "bound") ? connector.id : null;
104
+ }))).filter((id) => Boolean(id));
105
+ const declaredJobIds = new Set((contract.scheduled_jobs ?? []).map((job) => job.id));
106
+ const healthyScheduledJobIds = [
107
+ deployment["scheduled_jobs"],
108
+ metadata["scheduled_jobs"],
109
+ dockerApp["scheduled_jobs"],
110
+ ]
111
+ .flatMap((value) => (Array.isArray(value) ? value : []))
112
+ .map((value) => record(value))
113
+ .map((job) => {
114
+ const id = stringValue(job, "id") ?? stringValue(job, "name");
115
+ if (!id || !declaredJobIds.has(id))
116
+ return null;
117
+ const healthy = boolValue(job, "enabled") !== false &&
118
+ boolValue(job, "paused") !== true &&
119
+ (stringValue(job, "status") ??
120
+ stringValue(job, "state") ??
121
+ "active") !== "failed" &&
122
+ (stringValue(job, "last_run_status") ?? "ok") !== "failed";
123
+ return healthy ? id : null;
124
+ })
125
+ .filter((id) => Boolean(id))
126
+ .filter((id, index, all) => all.indexOf(id) === index);
127
+ return {
128
+ versionId,
129
+ inspection: {
130
+ deployment_id: stringValue(deployment, "id") ?? deploymentId,
131
+ deployment_name: stringValue(deployment, "name") ?? deploymentId,
132
+ tenant_id: stringValue(deployment, "tenant_id") ?? null,
133
+ workspace_id: stringValue(deployment, "workspace_id") ?? null,
134
+ deployment_state: stringValue(deployment, "state") ?? "unknown",
135
+ deployment_product: product,
136
+ public_url: publicUrl ?? null,
137
+ active_version_id: activeVersion ?? null,
138
+ active_release_id: activeRelease ?? null,
139
+ running_artifact_sha256: runningDigest ?? null,
140
+ expected_artifact_sha256: expectedDigest ?? null,
141
+ host_id: hostId ?? null,
142
+ host_status: stringValue(host, "status") ??
143
+ stringValue(metadata, "docker_deploy_host_status") ??
144
+ (hostId ? "unknown" : null),
145
+ appliance_status: stringValue(host, "appliance_status") ??
146
+ stringValue(dockerApp, "last_health_status") ??
147
+ stringValue(dockerApp, "status") ??
148
+ null,
149
+ database_attached: envNames.includes("DATABASE_URL") ||
150
+ Boolean(stringValue(deployment, "database_id") ??
151
+ stringValue(deployment, "linked_database_id") ??
152
+ stringValue(metadata, "database_id") ??
153
+ boolValue(metadata, "database_attached")),
154
+ effective_env_names: envNames,
155
+ healthy_connector_ids: healthyConnectorIds,
156
+ healthy_scheduled_job_ids: healthyScheduledJobIds,
157
+ },
158
+ };
159
+ }
160
+ async function verifyLinkedRelease(client, dir, link, releaseId, contractPath) {
161
+ const contract = loadAcceptanceContract(dir, contractPath);
162
+ const { inspection, versionId } = await inspectLinkedRelease(client, link.deploymentId, releaseId, contract);
163
+ const receipt = await verifyApplicationRelease({
164
+ application: link.name,
165
+ environment: link.environment,
166
+ expected_release_id: releaseId,
167
+ expected_version_id: versionId,
168
+ expected_workspace_id: link.workspaceId,
169
+ contract,
170
+ }, {
171
+ inspect: async () => inspection,
172
+ probe: async (baseUrl, routePath) => {
173
+ const started = Date.now();
174
+ const url = new URL(routePath, baseUrl);
175
+ const response = await request(url, {
176
+ method: "GET",
177
+ headersTimeout: 15_000,
178
+ bodyTimeout: 15_000,
179
+ maxRedirections: 0,
180
+ });
181
+ return {
182
+ status: response.statusCode,
183
+ body: await response.body.text(),
184
+ content_type: typeof response.headers["content-type"] === "string"
185
+ ? response.headers["content-type"]
186
+ : null,
187
+ latency_ms: Date.now() - started,
188
+ };
189
+ },
190
+ });
191
+ return { receipt, receiptPath: saveReleaseReceipt(dir, receipt) };
192
+ }
193
+ async function waitForActiveVersion(client, deploymentId, versionId, timeoutSeconds) {
194
+ const deadline = Date.now() + timeoutSeconds * 1_000;
195
+ do {
196
+ const deployment = deploymentPayload(await client.apiGet(`/api/v1/deployments/${encodeURIComponent(deploymentId)}`));
197
+ const dockerApp = record(deployment["docker_deploy_app"]);
198
+ const active = stringValue(deployment, "active_version_id") ??
199
+ stringValue(dockerApp, "deployment_version_id");
200
+ if (active === versionId &&
201
+ stringValue(deployment, "state") === "running") {
202
+ return;
203
+ }
204
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
205
+ } while (Date.now() < deadline);
206
+ throw new UserError(`Timed out waiting for immutable version ${versionId} to become active.`, "Run `miosa app recover <operation-id>` to inspect and resume safely.");
207
+ }
208
+ function releaseArtifactDigest(release) {
209
+ const metadata = record(release["metadata"]);
210
+ return (stringValue(release, "artifact_sha256") ??
211
+ stringValue(release, "archive_sha256") ??
212
+ stringValue(metadata, "artifact_sha256"));
213
+ }
214
+ async function activateLinkedRelease(input) {
215
+ const client = new MiosaClient(loadConfig());
216
+ const release = await getRelease(client, input.link.deploymentId, input.releaseId);
217
+ const state = stringValue(release, "state");
218
+ if (input.action === "promote" && state !== "ready" && state !== "active") {
219
+ throw new UserError(`Release ${input.releaseId} is ${state ?? "unknown"}, expected ready.`, "Only a complete immutable release can be promoted.");
220
+ }
221
+ const versionId = releaseVersionId(release);
222
+ if (!versionId) {
223
+ throw new UserError(`Release ${input.releaseId} has no immutable version ID.`);
224
+ }
225
+ if (!releaseArtifactDigest(release)) {
226
+ throw new UserError(`Release ${input.releaseId} has no artifact digest.`, "Publish must record artifact_sha256 before promotion is safe.");
227
+ }
228
+ const current = deploymentPayload(await client.apiGet(`/api/v1/deployments/${encodeURIComponent(input.link.deploymentId)}`));
229
+ const idempotencyKey = input.idempotencyKey ??
230
+ applicationIdempotencyKey(input.action, input.link.deploymentId, input.releaseId, versionId);
231
+ let operation = createApplicationOperation(input.dir, {
232
+ idempotency_key: idempotencyKey,
233
+ action: input.action,
234
+ deployment_id: input.link.deploymentId,
235
+ release_id: input.releaseId,
236
+ previous_version_id: stringValue(current, "active_version_id") ?? null,
237
+ target_version_id: versionId,
238
+ state: "pending",
239
+ });
240
+ try {
241
+ const endpoint = input.action === "promote"
242
+ ? `/api/v1/deployments/${encodeURIComponent(input.link.deploymentId)}/releases/${encodeURIComponent(input.releaseId)}/promote`
243
+ : `/api/v1/deployments/${encodeURIComponent(input.link.deploymentId)}/rollback`;
244
+ const activationResponse = record(await client.apiPost(endpoint, input.action === "rollback" ? { version_id: versionId } : undefined, { "Idempotency-Key": idempotencyKey }));
245
+ const serverOperationId = stringValue(activationResponse, "operation_id") ??
246
+ stringValue(record(activationResponse["operation"]), "id");
247
+ if (serverOperationId) {
248
+ operation = updateApplicationOperation(input.dir, operation, {
249
+ server_operation_id: serverOperationId,
250
+ });
251
+ }
252
+ await waitForActiveVersion(client, input.link.deploymentId, versionId, input.timeout);
253
+ const { receipt, receiptPath } = await verifyLinkedRelease(client, input.dir, input.link, input.releaseId, input.contractPath);
254
+ let rollbackPerformed = false;
255
+ let rollbackConfirmed = false;
256
+ if (receipt.result === "blocked" &&
257
+ operation.previous_version_id &&
258
+ operation.previous_version_id !== operation.target_version_id) {
259
+ await client.apiPost(`/api/v1/deployments/${encodeURIComponent(input.link.deploymentId)}/rollback`, { version_id: operation.previous_version_id }, {
260
+ "Idempotency-Key": applicationIdempotencyKey("rollback", input.link.deploymentId, input.releaseId, operation.previous_version_id),
261
+ });
262
+ rollbackPerformed = true;
263
+ try {
264
+ await waitForActiveVersion(client, input.link.deploymentId, operation.previous_version_id, input.timeout);
265
+ rollbackConfirmed = true;
266
+ }
267
+ catch {
268
+ rollbackConfirmed = false;
269
+ }
270
+ }
271
+ const blockedError = rollbackConfirmed
272
+ ? "The activated release failed acceptance and production was restored to the previous version."
273
+ : rollbackPerformed
274
+ ? "The activated release failed acceptance; restoration of the previous version was requested but did not become active within the timeout. Production may require manual recovery."
275
+ : "The activated release failed acceptance and no previous version was available for automatic restoration.";
276
+ operation = updateApplicationOperation(input.dir, operation, {
277
+ state: receipt.result === "verified" ? "succeeded" : "blocked",
278
+ receipt_id: receipt.receipt_id,
279
+ ...(receipt.result === "blocked" ? { error: blockedError } : {}),
280
+ });
281
+ return { operation, receipt, receiptPath };
282
+ }
283
+ catch (error) {
284
+ updateApplicationOperation(input.dir, operation, {
285
+ state: "failed",
286
+ error: error instanceof Error ? error.message : String(error),
287
+ });
288
+ throw error;
289
+ }
290
+ }
291
+ function saveApplicationLink(dir, link) {
292
+ fs.writeFileSync(path.join(dir, ".miosa.json"), `${JSON.stringify(link, null, 2)}\n`, { mode: 0o600 });
293
+ }
4
294
  function validGoal(value) {
5
295
  if (value === "preview" || value === "deploy" || value === "docker-deploy") {
6
296
  return value;
@@ -111,5 +401,424 @@ export function register(program) {
111
401
  handleError(err);
112
402
  }
113
403
  });
404
+ app
405
+ .command("link")
406
+ .argument("[path]", "Local app directory", ".")
407
+ .description("Bind this source directory to one exact MIOSA application")
408
+ .requiredOption("--app <id>", "Deployment/application ID")
409
+ .option("--environment <environment>", "Linked environment label", "production")
410
+ .option("--json", "Output compact machine-readable JSON")
411
+ .action(async (inputPath, opts) => {
412
+ try {
413
+ const dir = path.resolve(inputPath);
414
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
415
+ throw new UserError(`Local app directory not found: ${dir}`);
416
+ }
417
+ const client = new MiosaClient(loadConfig());
418
+ const raw = await client.apiGet(`/api/v1/deployments/${encodeURIComponent(opts.app)}`);
419
+ const deployment = deploymentPayload(raw);
420
+ const id = stringValue(deployment, "id");
421
+ const name = stringValue(deployment, "name");
422
+ if (!id || !name) {
423
+ throw new UserError("MIOSA returned an invalid deployment while linking the app.");
424
+ }
425
+ const link = {
426
+ version: 2,
427
+ deploymentId: id,
428
+ name,
429
+ environment: opts.environment,
430
+ ...(stringValue(deployment, "workspace_id")
431
+ ? { workspaceId: stringValue(deployment, "workspace_id") }
432
+ : {}),
433
+ ...(stringValue(deployment, "project_id")
434
+ ? { projectId: stringValue(deployment, "project_id") }
435
+ : {}),
436
+ };
437
+ saveApplicationLink(dir, link);
438
+ const result = {
439
+ path: dir,
440
+ deployment_id: id,
441
+ application: name,
442
+ environment: opts.environment,
443
+ workspace_id: link.workspaceId ?? null,
444
+ project_id: link.projectId ?? null,
445
+ config: path.join(dir, ".miosa.json"),
446
+ };
447
+ if (isJsonMode(opts)) {
448
+ printJson({ ok: true, data: result, error: null });
449
+ return;
450
+ }
451
+ console.log(chalk.green(`Linked ${name} to ${dir}.`));
452
+ console.log(chalk.dim(`Deployment: ${id}`));
453
+ console.log(chalk.dim(`Environment: ${opts.environment}`));
454
+ }
455
+ catch (err) {
456
+ handleError(err);
457
+ }
458
+ });
459
+ app
460
+ .command("pull")
461
+ .argument("[path]", "Local app directory", ".")
462
+ .description("Pull the linked application's configuration for local use")
463
+ .option("--output <file>", "Output file relative to the app", ".env.local")
464
+ .option("--overwrite", "Replace an existing output file")
465
+ .option("--json", "Output configuration as JSON without writing a file")
466
+ .action(async (inputPath, opts) => {
467
+ try {
468
+ const dir = path.resolve(inputPath);
469
+ const link = requireApplicationLink(dir);
470
+ const secrets = await fetchSecretValues(new MiosaClient(loadConfig()), link.deploymentId);
471
+ if (isJsonMode(opts)) {
472
+ printJson({
473
+ ok: true,
474
+ data: {
475
+ application: link.name,
476
+ deployment_id: link.deploymentId,
477
+ environment: link.environment,
478
+ configuration: Object.fromEntries(secrets.map(({ name, value }) => [name, value])),
479
+ },
480
+ error: null,
481
+ });
482
+ return;
483
+ }
484
+ const output = path.resolve(dir, opts.output);
485
+ if (fs.existsSync(output) && !opts.overwrite) {
486
+ throw new UserError(`${opts.output} already exists.`, "Pass --overwrite to replace it.");
487
+ }
488
+ fs.writeFileSync(output, toDotenv(secrets), { mode: 0o600 });
489
+ fs.chmodSync(output, 0o600);
490
+ ensureGitignored(dir, path.relative(dir, output));
491
+ console.log(chalk.green(`Pulled ${secrets.length} configuration values to ${path.relative(dir, output)}.`));
492
+ }
493
+ catch (err) {
494
+ handleError(err);
495
+ }
496
+ });
497
+ app
498
+ .command("preview")
499
+ .argument("[path]", "Local app directory", ".")
500
+ .description("Create a disposable preview from the current source")
501
+ .option("--sandbox <id>", "Reuse an existing healthy sandbox")
502
+ .option("--template <template>", "Sandbox template")
503
+ .option("--name <name>", "Preview sandbox name")
504
+ .option("--port <port>", "Application port", (value) => Number(value))
505
+ .option("--start <command>", "Application start command")
506
+ .option("--install-command <command>", "Dependency install command")
507
+ .option("--no-install", "Skip dependency installation")
508
+ .option("--timeout <seconds>", "Readiness timeout", (value) => Number(value), 600)
509
+ .option("--probe-path <path>", "Readiness probe path", "/")
510
+ .option("--json", "Output compact machine-readable JSON")
511
+ .action(async (inputPath, opts) => {
512
+ try {
513
+ const dir = path.resolve(inputPath);
514
+ const link = loadLocalLink(dir);
515
+ const result = await deploySandbox(dir, {
516
+ ...opts,
517
+ wait: true,
518
+ });
519
+ const candidate = link && result.preview_ready
520
+ ? deploymentPayload(await new MiosaClient(loadConfig()).apiPost(`/api/v1/deployments/${encodeURIComponent(link.deploymentId)}/publish`, {
521
+ source_sandbox_id: result.sandbox_id,
522
+ output_path: "/workspace",
523
+ kind: "dynamic",
524
+ start_command: opts.start,
525
+ port: result.port,
526
+ health_check_path: opts.probePath,
527
+ promote: false,
528
+ }, {
529
+ "Idempotency-Key": `preview:${link.deploymentId}:${result.sandbox_id}`,
530
+ }))
531
+ : {};
532
+ const release = record(candidate["release"]);
533
+ const version = record(candidate["version"]);
534
+ const payload = {
535
+ schema_version: 1,
536
+ application: link?.name ?? path.basename(dir),
537
+ environment: "preview",
538
+ sandbox_id: result.sandbox_id,
539
+ release_id: stringValue(release, "id") ?? null,
540
+ version_id: stringValue(version, "id") ?? null,
541
+ artifact_sha256: stringValue(release, "artifact_sha256") ??
542
+ stringValue(version, "artifact_sha256") ??
543
+ null,
544
+ url: result.preview_url,
545
+ status: result.preview_ready ? "ready" : "blocked",
546
+ promotion_allowed: false,
547
+ next_actions: result.preview_ready
548
+ ? stringValue(release, "id")
549
+ ? [
550
+ `miosa app promote ${stringValue(release, "id")} . --yes --json`,
551
+ ]
552
+ : [
553
+ "Run `miosa app link --app <deployment-id>` to create an immutable candidate release.",
554
+ ]
555
+ : [
556
+ `miosa sandbox doctor ${result.sandbox_id} --port ${result.port} --json`,
557
+ ],
558
+ };
559
+ if (isJsonMode(opts)) {
560
+ printJson({ ok: result.preview_ready, data: payload, error: null });
561
+ return;
562
+ }
563
+ console.log(result.preview_ready
564
+ ? chalk.green(`Preview ready: ${result.preview_url}`)
565
+ : chalk.yellow(`Preview is not ready: ${result.preview_url}`));
566
+ }
567
+ catch (err) {
568
+ handleError(err);
569
+ }
570
+ });
571
+ app
572
+ .command("verify <release-id>")
573
+ .argument("[path]", "Local app directory", ".")
574
+ .description("Prove that one exact immutable release and its declared capabilities are live")
575
+ .option("--contract <file>", "Acceptance contract JSON path")
576
+ .option("--json", "Output the stable release receipt as JSON")
577
+ .action(async (releaseId, inputPath, opts) => {
578
+ try {
579
+ const dir = path.resolve(inputPath);
580
+ const link = requireApplicationLink(dir);
581
+ const { receipt, receiptPath } = await verifyLinkedRelease(new MiosaClient(loadConfig()), dir, link, releaseId, opts.contract);
582
+ if (isJsonMode(opts)) {
583
+ printJson({
584
+ ok: receipt.result === "verified",
585
+ data: { ...receipt, receipt_path: receiptPath },
586
+ error: receipt.result === "verified"
587
+ ? null
588
+ : {
589
+ code: "RELEASE_ACCEPTANCE_BLOCKED",
590
+ message: "The exact release did not satisfy its acceptance contract.",
591
+ },
592
+ });
593
+ return;
594
+ }
595
+ console.log();
596
+ console.log(receipt.result === "verified"
597
+ ? chalk.green.bold("Release verified")
598
+ : chalk.red.bold("Release blocked"));
599
+ for (const item of receipt.checks) {
600
+ const marker = item.status === "pass"
601
+ ? chalk.green("PASS")
602
+ : item.status === "warning"
603
+ ? chalk.yellow("WARN")
604
+ : chalk.red("FAIL");
605
+ console.log(` ${marker} ${item.id}: ${item.message}`);
606
+ }
607
+ console.log(chalk.dim(`Receipt: ${receiptPath}`));
608
+ if (receipt.result !== "verified")
609
+ process.exitCode = 1;
610
+ }
611
+ catch (err) {
612
+ handleError(err);
613
+ }
614
+ });
615
+ app
616
+ .command("promote <release-id>")
617
+ .argument("[path]", "Local app directory", ".")
618
+ .description("Atomically promote one exact immutable release and verify it end to end")
619
+ .option("--contract <file>", "Acceptance contract JSON path")
620
+ .option("--timeout <seconds>", "Activation timeout", (value) => Number(value), 600)
621
+ .option("--idempotency-key <key>", "Reuse an operation idempotency key")
622
+ .option("-y, --yes", "Skip confirmation")
623
+ .option("--json", "Output the stable operation and release receipt")
624
+ .action(async (releaseId, inputPath, opts) => {
625
+ try {
626
+ const dir = path.resolve(inputPath);
627
+ const link = requireApplicationLink(dir);
628
+ if (!opts.yes) {
629
+ const { default: inquirer } = await import("inquirer");
630
+ const { ok } = await inquirer.prompt([
631
+ {
632
+ type: "confirm",
633
+ name: "ok",
634
+ message: `Promote exact release ${releaseId} to ${link.name} (${link.environment})?`,
635
+ default: false,
636
+ },
637
+ ]);
638
+ if (!ok)
639
+ return;
640
+ }
641
+ const result = await activateLinkedRelease({
642
+ action: "promote",
643
+ dir,
644
+ link,
645
+ releaseId,
646
+ contractPath: opts.contract,
647
+ timeout: opts.timeout,
648
+ idempotencyKey: opts.idempotencyKey,
649
+ });
650
+ if (isJsonMode(opts)) {
651
+ printJson({
652
+ ok: result.receipt.result === "verified",
653
+ data: {
654
+ operation: result.operation,
655
+ receipt: result.receipt,
656
+ receipt_path: result.receiptPath,
657
+ },
658
+ error: result.receipt.result === "verified"
659
+ ? null
660
+ : {
661
+ code: "RELEASE_ACCEPTANCE_BLOCKED",
662
+ message: "Promotion completed but acceptance was blocked.",
663
+ },
664
+ });
665
+ return;
666
+ }
667
+ console.log(result.receipt.result === "verified"
668
+ ? chalk.green.bold(`Release ${releaseId} is active and verified.`)
669
+ : chalk.red.bold(`Release ${releaseId} activated but failed acceptance.`));
670
+ console.log(chalk.dim(`Operation: ${result.operation.operation_id}`));
671
+ console.log(chalk.dim(`Receipt: ${result.receiptPath}`));
672
+ if (result.receipt.result !== "verified")
673
+ process.exitCode = 1;
674
+ }
675
+ catch (err) {
676
+ handleError(err);
677
+ }
678
+ });
679
+ app
680
+ .command("rollback <release-id>")
681
+ .argument("[path]", "Local app directory", ".")
682
+ .description("Rollback code to one exact immutable release and verify it")
683
+ .option("--contract <file>", "Acceptance contract JSON path")
684
+ .option("--timeout <seconds>", "Activation timeout", (value) => Number(value), 600)
685
+ .option("--idempotency-key <key>", "Reuse an operation idempotency key")
686
+ .option("--acknowledge-data-risk", "Acknowledge that code rollback does not reverse database migrations")
687
+ .option("-y, --yes", "Skip confirmation")
688
+ .option("--json", "Output the stable operation and release receipt")
689
+ .action(async (releaseId, inputPath, opts) => {
690
+ try {
691
+ const dir = path.resolve(inputPath);
692
+ const link = requireApplicationLink(dir);
693
+ const client = new MiosaClient(loadConfig());
694
+ const release = await getRelease(client, link.deploymentId, releaseId);
695
+ const migrationCompatibility = stringValue(release, "migration_compatibility") ??
696
+ stringValue(record(release["metadata"]), "migration_compatibility");
697
+ if (migrationCompatibility === "incompatible" &&
698
+ !opts.acknowledgeDataRisk) {
699
+ throw new UserError("Rollback is blocked because this release declares incompatible database migrations.", "Restore or migrate the database first, or pass --acknowledge-data-risk after reviewing the impact.");
700
+ }
701
+ if (!opts.yes) {
702
+ const { default: inquirer } = await import("inquirer");
703
+ const { ok } = await inquirer.prompt([
704
+ {
705
+ type: "confirm",
706
+ name: "ok",
707
+ message: `Rollback ${link.name} to exact release ${releaseId}? Database migrations are not reversed.`,
708
+ default: false,
709
+ },
710
+ ]);
711
+ if (!ok)
712
+ return;
713
+ }
714
+ const result = await activateLinkedRelease({
715
+ action: "rollback",
716
+ dir,
717
+ link,
718
+ releaseId,
719
+ contractPath: opts.contract,
720
+ timeout: opts.timeout,
721
+ idempotencyKey: opts.idempotencyKey,
722
+ });
723
+ if (isJsonMode(opts)) {
724
+ printJson({
725
+ ok: result.receipt.result === "verified",
726
+ data: {
727
+ operation: result.operation,
728
+ receipt: result.receipt,
729
+ receipt_path: result.receiptPath,
730
+ },
731
+ error: result.receipt.result === "verified"
732
+ ? null
733
+ : {
734
+ code: "ROLLBACK_ACCEPTANCE_BLOCKED",
735
+ message: "Rollback completed but acceptance was blocked.",
736
+ },
737
+ });
738
+ return;
739
+ }
740
+ console.log(chalk.green.bold(`Rollback to ${releaseId} is active and verified.`));
741
+ console.log(chalk.dim(`Operation: ${result.operation.operation_id}`));
742
+ console.log(chalk.dim(`Receipt: ${result.receiptPath}`));
743
+ }
744
+ catch (err) {
745
+ handleError(err);
746
+ }
747
+ });
748
+ app
749
+ .command("status <operation-id>")
750
+ .description("Read durable deployment operation status from MIOSA")
751
+ .option("--json", "Output machine-readable operation state")
752
+ .action(async (operationId, opts) => {
753
+ try {
754
+ const payload = deploymentPayload(await new MiosaClient(loadConfig()).apiGet(`/api/v1/operations/${encodeURIComponent(operationId)}`));
755
+ if (isJsonMode(opts)) {
756
+ printJson({ ok: true, data: payload, error: null });
757
+ return;
758
+ }
759
+ console.log(JSON.stringify(payload, null, 2));
760
+ }
761
+ catch (err) {
762
+ handleError(err);
763
+ }
764
+ });
765
+ app
766
+ .command("recover <operation-id>")
767
+ .argument("[path]", "Local app directory", ".")
768
+ .description("Inspect or safely resume an interrupted app operation")
769
+ .option("--resume", "Retry the operation with its original idempotency key")
770
+ .option("--contract <file>", "Acceptance contract JSON path")
771
+ .option("--timeout <seconds>", "Activation timeout", (value) => Number(value), 600)
772
+ .option("--json", "Output machine-readable operation state")
773
+ .action(async (operationId, inputPath, opts) => {
774
+ try {
775
+ const dir = path.resolve(inputPath);
776
+ const existing = loadApplicationOperation(dir, operationId);
777
+ if (!existing) {
778
+ throw new UserError(`Application operation not found: ${operationId}`);
779
+ }
780
+ if (existing.server_operation_id && !opts.resume) {
781
+ const remote = deploymentPayload(await new MiosaClient(loadConfig()).apiGet(`/api/v1/operations/${encodeURIComponent(existing.server_operation_id)}`));
782
+ if (isJsonMode(opts)) {
783
+ printJson({
784
+ ok: true,
785
+ data: { local: existing, remote },
786
+ error: null,
787
+ });
788
+ }
789
+ else {
790
+ console.log(JSON.stringify({ local: existing, remote }, null, 2));
791
+ }
792
+ return;
793
+ }
794
+ if (!opts.resume || existing.state === "succeeded") {
795
+ if (isJsonMode(opts)) {
796
+ printJson({ ok: true, data: existing, error: null });
797
+ }
798
+ else {
799
+ console.log(JSON.stringify(existing, null, 2));
800
+ }
801
+ return;
802
+ }
803
+ const result = await activateLinkedRelease({
804
+ action: existing.action,
805
+ dir,
806
+ link: requireApplicationLink(dir),
807
+ releaseId: existing.release_id,
808
+ contractPath: opts.contract,
809
+ timeout: opts.timeout,
810
+ idempotencyKey: existing.idempotency_key,
811
+ });
812
+ if (isJsonMode(opts)) {
813
+ printJson({ ok: true, data: result, error: null });
814
+ }
815
+ else {
816
+ console.log(chalk.green(`Operation resumed and ${result.receipt.result}.`));
817
+ }
818
+ }
819
+ catch (err) {
820
+ handleError(err);
821
+ }
822
+ });
114
823
  }
115
824
  //# sourceMappingURL=app.js.map