@miosa/cli 1.1.4 → 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.
- package/README.md +49 -0
- package/dist/app-operation.d.ts +23 -0
- package/dist/app-operation.d.ts.map +1 -0
- package/dist/app-operation.js +49 -0
- package/dist/app-operation.js.map +1 -0
- package/dist/app-release.d.ts +99 -0
- package/dist/app-release.d.ts.map +1 -0
- package/dist/app-release.js +229 -0
- package/dist/app-release.js.map +1 -0
- package/dist/bin/miosa.js +5 -0
- package/dist/bin/miosa.js.map +1 -1
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +710 -1
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/capabilities.d.ts.map +1 -1
- package/dist/commands/capabilities.js +24 -6
- package/dist/commands/capabilities.js.map +1 -1
- package/dist/commands/docker-deploy.d.ts.map +1 -1
- package/dist/commands/docker-deploy.js +106 -10
- package/dist/commands/docker-deploy.js.map +1 -1
- package/dist/commands/pull.d.ts +11 -0
- package/dist/commands/pull.d.ts.map +1 -1
- package/dist/commands/pull.js +3 -3
- package/dist/commands/pull.js.map +1 -1
- package/dist/commands/releases.d.ts.map +1 -1
- package/dist/commands/releases.js +12 -4
- package/dist/commands/releases.js.map +1 -1
- package/dist/commands/sandbox.d.ts +28 -0
- package/dist/commands/sandbox.d.ts.map +1 -1
- package/dist/commands/sandbox.js +1 -1
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/app.js
CHANGED
|
@@ -1,6 +1,296 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import
|
|
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
|