@miosa/cli 1.1.14 → 1.1.18
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 +47 -0
- package/dist/api-validation.d.ts +53 -0
- package/dist/api-validation.d.ts.map +1 -0
- package/dist/api-validation.js +200 -0
- package/dist/api-validation.js.map +1 -0
- package/dist/bin/miosa.js +2 -0
- package/dist/bin/miosa.js.map +1 -1
- package/dist/client.d.ts +20 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +141 -43
- package/dist/client.js.map +1 -1
- package/dist/commands/databases.d.ts.map +1 -1
- package/dist/commands/databases.js +11 -34
- package/dist/commands/databases.js.map +1 -1
- package/dist/commands/db.js +7 -7
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +227 -77
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/devices.d.ts.map +1 -1
- package/dist/commands/devices.js +8 -9
- package/dist/commands/devices.js.map +1 -1
- package/dist/commands/forge.d.ts +3 -0
- package/dist/commands/forge.d.ts.map +1 -0
- package/dist/commands/forge.js +254 -0
- package/dist/commands/forge.js.map +1 -0
- package/dist/commands/login.js +1 -1
- package/dist/commands/logout.js +3 -3
- package/dist/commands/logout.js.map +1 -1
- package/dist/commands/templates.d.ts.map +1 -1
- package/dist/commands/templates.js +540 -91
- package/dist/commands/templates.js.map +1 -1
- package/dist/commands/util.d.ts +6 -1
- package/dist/commands/util.d.ts.map +1 -1
- package/dist/commands/util.js +47 -27
- package/dist/commands/util.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +5 -1
- package/dist/commands/watch.js.map +1 -1
- package/dist/endpoint.d.ts +37 -0
- package/dist/endpoint.d.ts.map +1 -0
- package/dist/endpoint.js +123 -0
- package/dist/endpoint.js.map +1 -0
- package/dist/errors.d.ts +18 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +37 -4
- package/dist/errors.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import { MiosaClient } from "../client.js";
|
|
|
5
5
|
import { renderTable } from "../ui/table.js";
|
|
6
6
|
import { spin } from "../ui/spinner.js";
|
|
7
7
|
import { handleError, isJsonMode, printJson } from "./util.js";
|
|
8
|
+
import { ApiResponseError, UserError } from "../errors.js";
|
|
8
9
|
function productTemplates(raw) {
|
|
9
10
|
const rows = Array.isArray(raw["data"])
|
|
10
11
|
? raw["data"]
|
|
@@ -35,7 +36,7 @@ function fmtTemplateState(state) {
|
|
|
35
36
|
return chalk.dim("-");
|
|
36
37
|
if (state === "ready" || state === "active")
|
|
37
38
|
return chalk.green(state);
|
|
38
|
-
if (state === "building" || state === "pending")
|
|
39
|
+
if (state === "building" || state === "pending" || state === "draft")
|
|
39
40
|
return chalk.yellow(state);
|
|
40
41
|
if (state === "failed" || state === "error")
|
|
41
42
|
return chalk.red(state);
|
|
@@ -45,22 +46,191 @@ function templateState(template) {
|
|
|
45
46
|
return template.state ?? template.status;
|
|
46
47
|
}
|
|
47
48
|
function templateImage(template) {
|
|
48
|
-
return template.image ?? template.image_id;
|
|
49
|
+
return template.image ?? template.image_id ?? undefined;
|
|
49
50
|
}
|
|
50
51
|
function templateCreatedAt(template) {
|
|
51
52
|
return template.created_at ?? template.inserted_at;
|
|
52
53
|
}
|
|
54
|
+
/** A tenant-owned template, as opposed to a platform built-in. */
|
|
55
|
+
function isCustom(template) {
|
|
56
|
+
if (typeof template.built_in === "boolean")
|
|
57
|
+
return !template.built_in;
|
|
58
|
+
// Older payloads without `built_in`: a custom row is the only kind with a
|
|
59
|
+
// UUID id and the "custom" category.
|
|
60
|
+
return template.category === "custom" || isUuid(template.id);
|
|
61
|
+
}
|
|
62
|
+
function isUuid(value) {
|
|
63
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A definitive answer to "can I boot a sandbox from this right now?".
|
|
67
|
+
*
|
|
68
|
+
* A template row exists the moment `POST /api/v1/sandbox-templates` returns
|
|
69
|
+
* 201, but it has no bootable image until a build reaches "ready". Reporting
|
|
70
|
+
* the row's mere existence as readiness is what left the customer unable to
|
|
71
|
+
* tell whether his template had been created (2026-08-26 live customer call).
|
|
72
|
+
*/
|
|
73
|
+
function usability(template, builds) {
|
|
74
|
+
const state = templateState(template);
|
|
75
|
+
const image = templateImage(template);
|
|
76
|
+
if (state === "ready" || state === "active") {
|
|
77
|
+
return image
|
|
78
|
+
? { usable: true, reason: `build complete, image ${image}` }
|
|
79
|
+
: {
|
|
80
|
+
usable: false,
|
|
81
|
+
reason: "state is ready but no image was reported - contact support",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (state === "archived") {
|
|
85
|
+
return { usable: false, reason: "archived" };
|
|
86
|
+
}
|
|
87
|
+
const active = builds?.find((build) => [
|
|
88
|
+
"queued",
|
|
89
|
+
"building",
|
|
90
|
+
"certifying",
|
|
91
|
+
"snapshotting",
|
|
92
|
+
"running",
|
|
93
|
+
"pending",
|
|
94
|
+
].includes(build.state ?? ""));
|
|
95
|
+
if (active) {
|
|
96
|
+
return {
|
|
97
|
+
usable: false,
|
|
98
|
+
reason: `build ${shortId(active.id)} is ${active.state} - not finished yet`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// A failed build explains the template better than the template's own status
|
|
102
|
+
// does, and the two disagree on purpose: when the platform build gate is off,
|
|
103
|
+
// fail_build/2 records BUILDS_TEMPORARILY_UNAVAILABLE on the build while
|
|
104
|
+
// template_status_after_failure(:builds_disabled) keeps the template "draft"
|
|
105
|
+
// so its name stays reusable. Reporting only "no completed build yet (state
|
|
106
|
+
// draft)" there would hide the one fact the caller needs.
|
|
107
|
+
const failed = builds?.find((build) => build.state === "failed");
|
|
108
|
+
if (failed) {
|
|
109
|
+
const why = buildError(failed);
|
|
110
|
+
return {
|
|
111
|
+
usable: false,
|
|
112
|
+
reason: why ? `last build failed: ${why}` : "last build failed",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (state === "failed") {
|
|
116
|
+
return { usable: false, reason: "last build failed" };
|
|
117
|
+
}
|
|
118
|
+
if (builds && builds.length === 0) {
|
|
119
|
+
return { usable: false, reason: "no build has been started yet" };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
usable: false,
|
|
123
|
+
reason: `no completed build yet (state ${state ?? "unknown"})`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The platform build gate being off is not the caller's fault and has a
|
|
128
|
+
* different remedy from a broken Dockerfile: nothing to fix, retry later, and
|
|
129
|
+
* the name is still reusable because the row stays a draft.
|
|
130
|
+
*/
|
|
131
|
+
function buildsGatedOff(builds) {
|
|
132
|
+
return Boolean(builds?.some((build) => build.error_code === "BUILDS_TEMPORARILY_UNAVAILABLE"));
|
|
133
|
+
}
|
|
134
|
+
function buildError(build) {
|
|
135
|
+
if (!build)
|
|
136
|
+
return undefined;
|
|
137
|
+
const message = build.error_message ?? build.error;
|
|
138
|
+
if (message && build.error_code)
|
|
139
|
+
return `${build.error_code}: ${message}`;
|
|
140
|
+
return message ?? build.error_code ?? undefined;
|
|
141
|
+
}
|
|
142
|
+
function shortId(id) {
|
|
143
|
+
return id.length > 12 ? `${id.slice(0, 8)}...` : id;
|
|
144
|
+
}
|
|
53
145
|
function fmtBuildState(state) {
|
|
54
146
|
if (!state)
|
|
55
147
|
return chalk.dim("-");
|
|
56
|
-
if (state === "success" || state === "complete")
|
|
148
|
+
if (state === "ready" || state === "success" || state === "complete")
|
|
57
149
|
return chalk.green(state);
|
|
58
|
-
if (state === "building" ||
|
|
150
|
+
if (state === "building" ||
|
|
151
|
+
state === "running" ||
|
|
152
|
+
state === "pending" ||
|
|
153
|
+
state === "queued" ||
|
|
154
|
+
state === "certifying" ||
|
|
155
|
+
state === "snapshotting")
|
|
59
156
|
return chalk.yellow(state);
|
|
60
|
-
if (state === "failed" || state === "error")
|
|
157
|
+
if (state === "failed" || state === "error" || state === "cancelled")
|
|
61
158
|
return chalk.red(state);
|
|
62
159
|
return chalk.dim(state);
|
|
63
160
|
}
|
|
161
|
+
function client() {
|
|
162
|
+
return new MiosaClient(loadConfig());
|
|
163
|
+
}
|
|
164
|
+
function readDockerfile(path) {
|
|
165
|
+
try {
|
|
166
|
+
const contents = readFileSync(path, "utf8");
|
|
167
|
+
if (contents.trim() === "") {
|
|
168
|
+
throw new UserError(`The Dockerfile at ${path} is empty.`, "A template needs at least a FROM line.");
|
|
169
|
+
}
|
|
170
|
+
return contents;
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
if (err instanceof UserError)
|
|
174
|
+
throw err;
|
|
175
|
+
throw new UserError(`Cannot read Dockerfile at ${path}: ${err instanceof Error ? err.message : String(err)}`, "Pass --dockerfile with a path to a readable Dockerfile.");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Fetch the tenant's own templates, resolved to their full shape. */
|
|
179
|
+
async function fetchTemplate(api, id) {
|
|
180
|
+
return unwrapTemplate(await api.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}`));
|
|
181
|
+
}
|
|
182
|
+
async function fetchBuilds(api, id) {
|
|
183
|
+
return unwrapBuilds(await api.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}/builds`));
|
|
184
|
+
}
|
|
185
|
+
/** Print the definitive existence/usability block for one custom template. */
|
|
186
|
+
function printTemplateDetail(template, builds, opts) {
|
|
187
|
+
const status = usability(template, builds);
|
|
188
|
+
const reference = template.slug ?? template.id;
|
|
189
|
+
console.log();
|
|
190
|
+
console.log(` ${chalk.bold("ID")} ${template.id}`);
|
|
191
|
+
console.log(` ${chalk.bold("Name")} ${template.name}`);
|
|
192
|
+
if (template.slug)
|
|
193
|
+
console.log(` ${chalk.bold("Slug")} ${template.slug}`);
|
|
194
|
+
console.log(` ${chalk.bold("State")} ${fmtTemplateState(templateState(template))}`);
|
|
195
|
+
console.log(` ${chalk.bold("Exists")} ${opts.verified
|
|
196
|
+
? chalk.green("yes (confirmed by a follow-up read)")
|
|
197
|
+
: chalk.green("yes")}`);
|
|
198
|
+
console.log(` ${chalk.bold("Usable")} ${status.usable
|
|
199
|
+
? chalk.green(`yes - ${status.reason}`)
|
|
200
|
+
: chalk.yellow(`no - ${status.reason}`)}`);
|
|
201
|
+
const image = templateImage(template);
|
|
202
|
+
console.log(` ${chalk.bold("Image")} ${image ?? chalk.dim("none yet")}`);
|
|
203
|
+
if (builds) {
|
|
204
|
+
console.log(` ${chalk.bold("Builds")} ${builds.length === 0
|
|
205
|
+
? chalk.dim("none")
|
|
206
|
+
: `${builds.length} (latest ${shortId(builds[0]?.id ?? "")} ${builds[0]?.state ?? "unknown"})`}`);
|
|
207
|
+
const failure = buildError(builds.find((build) => build.state === "failed"));
|
|
208
|
+
if (failure)
|
|
209
|
+
console.log(` ${chalk.bold("Failure")} ${chalk.red(failure)}`);
|
|
210
|
+
}
|
|
211
|
+
const createdAt = templateCreatedAt(template);
|
|
212
|
+
if (createdAt)
|
|
213
|
+
console.log(` ${chalk.bold("Created")} ${createdAt}`);
|
|
214
|
+
if (template.updated_at)
|
|
215
|
+
console.log(` ${chalk.bold("Updated")} ${template.updated_at}`);
|
|
216
|
+
console.log();
|
|
217
|
+
if (status.usable) {
|
|
218
|
+
console.log(chalk.dim(` Boot it: miosa sandbox create --template ${reference}`));
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
console.log(chalk.yellow(` Not bootable yet. "miosa sandbox create --template ${reference}" will fail until a build reaches "ready".`));
|
|
222
|
+
if (buildsGatedOff(builds)) {
|
|
223
|
+
// Nothing for the caller to fix, and re-running create with the same
|
|
224
|
+
// name works because the row stays a draft and create_template/3 upserts
|
|
225
|
+
// over a draft for the same tenant+slug.
|
|
226
|
+
console.log(chalk.dim(" This is a platform build gate, not a problem with your Dockerfile. Nothing to fix on your side."));
|
|
227
|
+
console.log(chalk.dim(` The name stays yours: re-run the same create, or "miosa templates rebuild ${reference}", once builds are re-enabled.`));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
console.log(chalk.dim(` Builds: miosa templates builds ${template.id}`));
|
|
231
|
+
console.log(chalk.dim(` Re-verify: miosa templates get ${reference}`));
|
|
232
|
+
console.log();
|
|
233
|
+
}
|
|
64
234
|
export function register(program) {
|
|
65
235
|
const templates = program
|
|
66
236
|
.command("templates")
|
|
@@ -72,9 +242,7 @@ export function register(program) {
|
|
|
72
242
|
.option("--json", "Output the complete catalog as JSON")
|
|
73
243
|
.action(async (opts) => {
|
|
74
244
|
try {
|
|
75
|
-
const
|
|
76
|
-
const client = new MiosaClient(config);
|
|
77
|
-
const raw = await client.apiGet("/api/v1/templates");
|
|
245
|
+
const raw = await client().apiGet("/api/v1/templates");
|
|
78
246
|
const rows = productTemplates(raw);
|
|
79
247
|
const filtered = opts.product
|
|
80
248
|
? rows.filter((template) => template.product === opts.product)
|
|
@@ -100,7 +268,7 @@ export function register(program) {
|
|
|
100
268
|
]);
|
|
101
269
|
}
|
|
102
270
|
catch (err) {
|
|
103
|
-
handleError(err);
|
|
271
|
+
handleError(err, opts);
|
|
104
272
|
}
|
|
105
273
|
});
|
|
106
274
|
templates
|
|
@@ -110,13 +278,12 @@ export function register(program) {
|
|
|
110
278
|
.option("--json", "Output raw readiness rows")
|
|
111
279
|
.action(async (id, opts) => {
|
|
112
280
|
try {
|
|
113
|
-
const
|
|
114
|
-
const client = new MiosaClient(config);
|
|
115
|
-
const raw = await client.apiGet("/api/v1/templates");
|
|
281
|
+
const raw = await client().apiGet("/api/v1/templates");
|
|
116
282
|
const rows = productTemplates(raw);
|
|
117
|
-
const template = rows.find((candidate) => candidate.id === id &&
|
|
283
|
+
const template = rows.find((candidate) => candidate.id === id &&
|
|
284
|
+
(!opts.product || candidate.product === opts.product));
|
|
118
285
|
if (!template)
|
|
119
|
-
throw new
|
|
286
|
+
throw new UserError(`Product template not found: ${id}`);
|
|
120
287
|
const readiness = template.sizes ?? [];
|
|
121
288
|
if (isJsonMode(opts)) {
|
|
122
289
|
printJson(readiness);
|
|
@@ -148,49 +315,109 @@ export function register(program) {
|
|
|
148
315
|
]);
|
|
149
316
|
}
|
|
150
317
|
catch (err) {
|
|
151
|
-
handleError(err);
|
|
318
|
+
handleError(err, opts);
|
|
152
319
|
}
|
|
153
320
|
});
|
|
154
321
|
// list
|
|
155
322
|
templates
|
|
156
323
|
.command("list")
|
|
157
|
-
.description("List sandbox templates")
|
|
324
|
+
.description("List sandbox templates. Your own templates are listed first and marked yours.")
|
|
325
|
+
.option("--mine", "Show only templates this workspace created")
|
|
326
|
+
.option("--built-in", "Show only platform built-in templates")
|
|
327
|
+
.option("--verify", "Read each of your templates back individually to confirm it exists and fill in the slug and created date the catalog omits")
|
|
158
328
|
.option("--json", "Output raw JSON")
|
|
159
329
|
.action(async (opts) => {
|
|
160
330
|
try {
|
|
161
|
-
const
|
|
162
|
-
const client = new MiosaClient(config);
|
|
331
|
+
const api = client();
|
|
163
332
|
const json = isJsonMode(opts);
|
|
164
333
|
const spinner = json ? null : spin("Fetching templates...");
|
|
165
|
-
|
|
334
|
+
let rows;
|
|
335
|
+
try {
|
|
336
|
+
rows = unwrapTemplates(await api.apiGet("/api/v1/sandbox-templates"));
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
spinner?.fail("Could not fetch templates");
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
let mine = rows.filter(isCustom);
|
|
343
|
+
const platform = rows.filter((row) => !isCustom(row));
|
|
344
|
+
if (opts.verify && mine.length > 0) {
|
|
345
|
+
spinner?.stop();
|
|
346
|
+
// The catalog rendering drops `slug` and `inserted_at` from custom
|
|
347
|
+
// rows, so the only way to report them is a per-row read. This also
|
|
348
|
+
// proves each row is individually retrievable, not just present in
|
|
349
|
+
// an aggregate list.
|
|
350
|
+
mine = await Promise.all(mine.map(async (row) => {
|
|
351
|
+
try {
|
|
352
|
+
return await fetchTemplate(api, row.id);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return row;
|
|
356
|
+
}
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
166
359
|
spinner?.stop();
|
|
360
|
+
const selected = opts.mine
|
|
361
|
+
? mine
|
|
362
|
+
: opts.builtIn
|
|
363
|
+
? platform
|
|
364
|
+
: [...mine, ...platform];
|
|
167
365
|
if (json) {
|
|
168
|
-
printJson(
|
|
366
|
+
printJson(selected);
|
|
169
367
|
return;
|
|
170
368
|
}
|
|
171
|
-
if (
|
|
172
|
-
|
|
369
|
+
if (selected.length === 0) {
|
|
370
|
+
if (opts.mine) {
|
|
371
|
+
console.log(chalk.dim("This workspace has not created any sandbox templates."));
|
|
372
|
+
console.log(chalk.dim(" Create one: miosa templates create --name my-template --dockerfile ./Dockerfile"));
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
console.log(chalk.dim("No templates found."));
|
|
376
|
+
}
|
|
173
377
|
return;
|
|
174
378
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
379
|
+
// The slug is what `sandbox create --template` accepts, and the
|
|
380
|
+
// catalog omits it for custom rows, so only offer the column when a
|
|
381
|
+
// value is actually known.
|
|
382
|
+
const anySlug = selected.some((row) => Boolean(row.slug));
|
|
383
|
+
renderTable(selected, [
|
|
384
|
+
{
|
|
385
|
+
header: "SOURCE",
|
|
386
|
+
key: (t) => isCustom(t) ? chalk.cyan("yours") : chalk.dim("built-in"),
|
|
387
|
+
width: 10,
|
|
388
|
+
},
|
|
389
|
+
{ header: "ID", key: (t) => t.id, width: 38 },
|
|
390
|
+
...(anySlug
|
|
391
|
+
? [
|
|
392
|
+
{
|
|
393
|
+
header: "TEMPLATE REF",
|
|
394
|
+
key: (t) => t.slug ?? (isCustom(t) ? chalk.dim("-") : t.id),
|
|
395
|
+
width: 24,
|
|
396
|
+
},
|
|
397
|
+
]
|
|
398
|
+
: []),
|
|
399
|
+
{ header: "NAME", key: "name", width: 26 },
|
|
178
400
|
{
|
|
179
401
|
header: "STATE",
|
|
180
402
|
key: (t) => fmtTemplateState(templateState(t)),
|
|
181
|
-
width:
|
|
403
|
+
width: 10,
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
header: "USABLE",
|
|
407
|
+
key: (t) => usability(t).usable ? chalk.green("yes") : chalk.yellow("no"),
|
|
408
|
+
width: 8,
|
|
182
409
|
},
|
|
183
410
|
{
|
|
184
411
|
header: "IMAGE",
|
|
185
412
|
key: (t) => {
|
|
186
413
|
const image = templateImage(t);
|
|
187
414
|
return image
|
|
188
|
-
? image.length >
|
|
189
|
-
? `${image.slice(0,
|
|
415
|
+
? image.length > 28
|
|
416
|
+
? `${image.slice(0, 25)}...`
|
|
190
417
|
: image
|
|
191
418
|
: chalk.dim("-");
|
|
192
419
|
},
|
|
193
|
-
width:
|
|
420
|
+
width: 30,
|
|
194
421
|
},
|
|
195
422
|
{
|
|
196
423
|
header: "CREATED",
|
|
@@ -201,46 +428,75 @@ export function register(program) {
|
|
|
201
428
|
width: 12,
|
|
202
429
|
},
|
|
203
430
|
]);
|
|
431
|
+
console.log();
|
|
432
|
+
if (mine.length === 0) {
|
|
433
|
+
console.log(chalk.yellow(" None of these are yours - this workspace has not created any templates."));
|
|
434
|
+
console.log(chalk.dim(" Create one: miosa templates create --name my-template --dockerfile ./Dockerfile"));
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
console.log(opts.mine
|
|
438
|
+
? chalk.dim(` ${mine.length} template${mine.length === 1 ? "" : "s"} created by this workspace. Platform built-ins are hidden; drop --mine to see them.`)
|
|
439
|
+
: chalk.dim(` ${mine.length} of these ${mine.length === 1 ? "is" : "are"} yours (SOURCE=yours); the other ${platform.length} are platform built-ins.`));
|
|
440
|
+
if (!opts.mine) {
|
|
441
|
+
console.log(chalk.dim(" Only yours: miosa templates list --mine"));
|
|
442
|
+
}
|
|
443
|
+
const unusable = mine.filter((row) => !usability(row).usable);
|
|
444
|
+
if (unusable.length > 0) {
|
|
445
|
+
// The catalog carries no build history, so the reason a row is
|
|
446
|
+
// not usable takes a second request. Point at it rather than
|
|
447
|
+
// leaving a bare "no" in the table.
|
|
448
|
+
const first = unusable[0];
|
|
449
|
+
console.log(chalk.yellow(` ${unusable.length} of yours ${unusable.length === 1 ? "has" : "have"} no usable build yet, so ${unusable.length === 1 ? "it cannot" : "they cannot"} boot a sandbox.`));
|
|
450
|
+
console.log(chalk.dim(` Reason for each: miosa templates get ${first?.slug ?? first?.id ?? "<id>"}`));
|
|
451
|
+
}
|
|
452
|
+
if (!opts.verify) {
|
|
453
|
+
console.log(chalk.dim(" The catalog omits the slug and created date of your own rows; add --verify to read them back individually."));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
console.log();
|
|
204
457
|
}
|
|
205
458
|
catch (err) {
|
|
206
|
-
handleError(err);
|
|
459
|
+
handleError(err, opts);
|
|
207
460
|
}
|
|
208
461
|
});
|
|
209
462
|
// get
|
|
210
463
|
templates
|
|
211
464
|
.command("get <id>")
|
|
212
|
-
.description("Get sandbox template details")
|
|
465
|
+
.description("Get sandbox template details, including whether it is usable")
|
|
213
466
|
.option("--json", "Output raw JSON")
|
|
214
467
|
.action(async (id, opts) => {
|
|
215
468
|
try {
|
|
216
|
-
const
|
|
217
|
-
const client = new MiosaClient(config);
|
|
469
|
+
const api = client();
|
|
218
470
|
const json = isJsonMode(opts);
|
|
219
471
|
const spinner = json ? null : spin("Fetching template...");
|
|
220
|
-
|
|
472
|
+
let template;
|
|
473
|
+
try {
|
|
474
|
+
template = await fetchTemplate(api, id);
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
spinner?.fail(`Template not found: ${id}`);
|
|
478
|
+
throw err;
|
|
479
|
+
}
|
|
480
|
+
// Builds are what decide usability, and the read is scoped to the same
|
|
481
|
+
// template, so fetch them rather than guessing from `status` alone.
|
|
482
|
+
// Built-ins have no build history endpoint of their own.
|
|
483
|
+
const builds = isCustom(template)
|
|
484
|
+
? await fetchBuilds(api, template.id).catch(() => undefined)
|
|
485
|
+
: undefined;
|
|
221
486
|
spinner?.stop();
|
|
222
487
|
if (json) {
|
|
223
|
-
printJson(
|
|
488
|
+
printJson({
|
|
489
|
+
...template,
|
|
490
|
+
usable: usability(template, builds).usable,
|
|
491
|
+
usable_reason: usability(template, builds).reason,
|
|
492
|
+
...(builds ? { builds } : {}),
|
|
493
|
+
});
|
|
224
494
|
return;
|
|
225
495
|
}
|
|
226
|
-
|
|
227
|
-
console.log(` ${chalk.bold("ID")} ${tmpl.id}`);
|
|
228
|
-
console.log(` ${chalk.bold("Name")} ${tmpl.name}`);
|
|
229
|
-
console.log(` ${chalk.bold("State")} ${fmtTemplateState(templateState(tmpl))}`);
|
|
230
|
-
const image = templateImage(tmpl);
|
|
231
|
-
if (image)
|
|
232
|
-
console.log(` ${chalk.bold("Image")} ${image}`);
|
|
233
|
-
const createdAt = templateCreatedAt(tmpl);
|
|
234
|
-
if (createdAt)
|
|
235
|
-
console.log(` ${chalk.bold("Created")} ${createdAt}`);
|
|
236
|
-
if (tmpl.updated_at)
|
|
237
|
-
console.log(` ${chalk.bold("Updated")} ${tmpl.updated_at}`);
|
|
238
|
-
console.log();
|
|
239
|
-
console.log(chalk.dim(` Run "miosa templates builds ${tmpl.id}" to view build history.`));
|
|
240
|
-
console.log();
|
|
496
|
+
printTemplateDetail(template, builds, { verified: false });
|
|
241
497
|
}
|
|
242
498
|
catch (err) {
|
|
243
|
-
handleError(err);
|
|
499
|
+
handleError(err, opts);
|
|
244
500
|
}
|
|
245
501
|
});
|
|
246
502
|
// create
|
|
@@ -249,39 +505,203 @@ export function register(program) {
|
|
|
249
505
|
.description("Create a sandbox template from a Dockerfile")
|
|
250
506
|
.requiredOption("--name <name>", "Template name")
|
|
251
507
|
.requiredOption("--dockerfile <path>", "Path to Dockerfile to build the template from")
|
|
508
|
+
.option("--description <text>", "Human description of the template")
|
|
509
|
+
.option("--no-verify", "Skip the follow-up read that confirms the template exists")
|
|
252
510
|
.option("--json", "Output raw JSON")
|
|
253
511
|
.action(async (opts) => {
|
|
512
|
+
const json = isJsonMode(opts);
|
|
513
|
+
try {
|
|
514
|
+
const dockerfile = readDockerfile(opts.dockerfile);
|
|
515
|
+
const api = client();
|
|
516
|
+
const spinner = json
|
|
517
|
+
? null
|
|
518
|
+
: spin(`Creating template ${opts.name}...`);
|
|
519
|
+
let template;
|
|
520
|
+
try {
|
|
521
|
+
template = unwrapTemplate(await api.apiPost("/api/v1/sandbox-templates", {
|
|
522
|
+
name: opts.name,
|
|
523
|
+
dockerfile,
|
|
524
|
+
...(opts.description ? { description: opts.description } : {}),
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
// Leaving the spinner spinning under an error message was how the
|
|
529
|
+
// customer's failed create looked like a hung command.
|
|
530
|
+
spinner?.fail(`Could not create template ${opts.name}`);
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
533
|
+
spinner?.succeed(`Created template ${template.name}`);
|
|
534
|
+
// The create response is rendered from the in-memory row, so its
|
|
535
|
+
// `current_build_id` is still null even though the controller has
|
|
536
|
+
// already enqueued the initial build. Read the row and its builds
|
|
537
|
+
// back to give a definitive answer instead of an optimistic one.
|
|
538
|
+
let verified = false;
|
|
539
|
+
let builds;
|
|
540
|
+
if (opts.verify) {
|
|
541
|
+
try {
|
|
542
|
+
template = await fetchTemplate(api, template.id);
|
|
543
|
+
verified = true;
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
verified = false;
|
|
547
|
+
}
|
|
548
|
+
builds = await fetchBuilds(api, template.id).catch(() => undefined);
|
|
549
|
+
}
|
|
550
|
+
if (json) {
|
|
551
|
+
printJson({
|
|
552
|
+
...template,
|
|
553
|
+
verified,
|
|
554
|
+
usable: usability(template, builds).usable,
|
|
555
|
+
usable_reason: usability(template, builds).reason,
|
|
556
|
+
...(builds ? { builds } : {}),
|
|
557
|
+
});
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
printTemplateDetail(template, builds, { verified });
|
|
561
|
+
if (opts.verify && !verified) {
|
|
562
|
+
console.log(chalk.yellow(" Warning: the create call returned 201 but the follow-up read did not find this template."));
|
|
563
|
+
console.log(chalk.dim(` Check again: miosa templates get ${template.id} (or: miosa templates list --mine)`));
|
|
564
|
+
console.log();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
handleError(err, opts);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
// update
|
|
572
|
+
templates
|
|
573
|
+
.command("update <id>")
|
|
574
|
+
.description("Update a template's Dockerfile. Replaces the spec in place while it has no usable build; otherwise starts a new build from the new Dockerfile.")
|
|
575
|
+
.requiredOption("--dockerfile <path>", "Path to the new Dockerfile")
|
|
576
|
+
.option("--description <text>", "Replace the description")
|
|
577
|
+
.option("--json", "Output raw JSON")
|
|
578
|
+
.action(async (id, opts) => {
|
|
579
|
+
const json = isJsonMode(opts);
|
|
254
580
|
try {
|
|
255
|
-
|
|
581
|
+
const dockerfile = readDockerfile(opts.dockerfile);
|
|
582
|
+
const api = client();
|
|
583
|
+
const spinner = json ? null : spin(`Reading template ${id}...`);
|
|
584
|
+
let existing;
|
|
256
585
|
try {
|
|
257
|
-
|
|
586
|
+
existing = await fetchTemplate(api, id);
|
|
258
587
|
}
|
|
259
588
|
catch (err) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
589
|
+
spinner?.fail(`Template not found: ${id}`);
|
|
590
|
+
throw err;
|
|
591
|
+
}
|
|
592
|
+
if (!isCustom(existing)) {
|
|
593
|
+
spinner?.fail(`${existing.name} is a platform built-in`);
|
|
594
|
+
throw new UserError(`${existing.id} is a platform built-in template and cannot be updated.`, "Create your own template instead: miosa templates create --name <name> --dockerfile ./Dockerfile");
|
|
595
|
+
}
|
|
596
|
+
const state = templateState(existing);
|
|
597
|
+
// The API has two update mechanisms and which one applies depends on
|
|
598
|
+
// whether the row already owns a usable image:
|
|
599
|
+
//
|
|
600
|
+
// draft / failed -> POST /api/v1/sandbox-templates upserts the row
|
|
601
|
+
// in place for the same tenant+slug, replacing build_spec and
|
|
602
|
+
// clearing image_id/current_build_id.
|
|
603
|
+
// ready / building / archived -> that upsert is a real uniqueness
|
|
604
|
+
// collision, so the supported path is a new build from the new
|
|
605
|
+
// spec: POST /api/v1/sandbox-templates/:id/builds.
|
|
606
|
+
const replaceInPlace = state === "draft" || state === "failed";
|
|
607
|
+
let template;
|
|
608
|
+
let build;
|
|
609
|
+
if (replaceInPlace) {
|
|
610
|
+
spinner?.start(`Replacing the spec of ${existing.name}...`);
|
|
611
|
+
try {
|
|
612
|
+
template = unwrapTemplate(await api.apiPost("/api/v1/sandbox-templates", {
|
|
613
|
+
name: existing.name,
|
|
614
|
+
...(existing.slug ? { slug: existing.slug } : {}),
|
|
615
|
+
dockerfile,
|
|
616
|
+
...(opts.description !== undefined
|
|
617
|
+
? { description: opts.description }
|
|
618
|
+
: existing.description
|
|
619
|
+
? { description: existing.description }
|
|
620
|
+
: {}),
|
|
621
|
+
}));
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
spinner?.fail(`Could not update ${existing.name}`);
|
|
625
|
+
throw err;
|
|
626
|
+
}
|
|
627
|
+
spinner?.succeed(`Replaced the spec of ${template.name} in place (it had no usable build)`);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
spinner?.start(`Starting a new build of ${existing.name}...`);
|
|
631
|
+
try {
|
|
632
|
+
const raw = await api.apiPost(`/api/v1/sandbox-templates/${encodeURIComponent(existing.id)}/builds`, { dockerfile });
|
|
633
|
+
build = raw.data ?? raw;
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
spinner?.fail(`Could not start a new build of ${existing.name}`);
|
|
637
|
+
throw err;
|
|
638
|
+
}
|
|
639
|
+
spinner?.succeed(`Started build ${shortId(build.id)} of ${existing.name} from the new Dockerfile`);
|
|
640
|
+
template = await fetchTemplate(api, existing.id).catch(() => existing);
|
|
641
|
+
}
|
|
642
|
+
const builds = await fetchBuilds(api, template.id).catch(() => undefined);
|
|
643
|
+
if (json) {
|
|
644
|
+
printJson({
|
|
645
|
+
...template,
|
|
646
|
+
mechanism: replaceInPlace ? "spec_replaced" : "new_build",
|
|
647
|
+
...(build ? { build } : {}),
|
|
648
|
+
usable: usability(template, builds).usable,
|
|
649
|
+
usable_reason: usability(template, builds).reason,
|
|
650
|
+
...(builds ? { builds } : {}),
|
|
651
|
+
});
|
|
273
652
|
return;
|
|
274
653
|
}
|
|
654
|
+
// Which of the two mechanisms ran is the one thing a caller must not
|
|
655
|
+
// have to guess, so state it on stdout rather than only in the
|
|
656
|
+
// spinner line (which ora writes to stderr and which is lost the
|
|
657
|
+
// moment output is redirected).
|
|
275
658
|
console.log();
|
|
276
|
-
console.log(
|
|
277
|
-
|
|
278
|
-
|
|
659
|
+
console.log(replaceInPlace
|
|
660
|
+
? chalk.dim(` ${existing.name} had no usable build, so its stored Dockerfile was replaced in place\n and a fresh build was queued. Same template ID, same name.`)
|
|
661
|
+
: chalk.dim(` ${existing.name} already had a usable image, so its stored spec was not overwritten;\n the new Dockerfile is building as a new build instead.`));
|
|
662
|
+
printTemplateDetail(template, builds, { verified: true });
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
handleError(err, opts);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
// rebuild
|
|
669
|
+
templates
|
|
670
|
+
.command("rebuild <id>")
|
|
671
|
+
.description("Start a new build of an existing template, optionally from a new Dockerfile")
|
|
672
|
+
.option("--dockerfile <path>", "Build from this Dockerfile instead of the template's stored spec")
|
|
673
|
+
.option("--json", "Output raw JSON")
|
|
674
|
+
.action(async (id, opts) => {
|
|
675
|
+
const json = isJsonMode(opts);
|
|
676
|
+
try {
|
|
677
|
+
const api = client();
|
|
678
|
+
const body = opts.dockerfile
|
|
679
|
+
? { dockerfile: readDockerfile(opts.dockerfile) }
|
|
680
|
+
: {};
|
|
681
|
+
const spinner = json ? null : spin(`Starting a build of ${id}...`);
|
|
682
|
+
let build;
|
|
683
|
+
try {
|
|
684
|
+
const raw = await api.apiPost(`/api/v1/sandbox-templates/${encodeURIComponent(id)}/builds`, body);
|
|
685
|
+
build = raw.data ?? raw;
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
spinner?.fail(`Could not start a build of ${id}`);
|
|
689
|
+
throw err;
|
|
690
|
+
}
|
|
691
|
+
spinner?.succeed(`Started build ${shortId(build.id)}`);
|
|
692
|
+
if (json) {
|
|
693
|
+
printJson(build);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
279
696
|
console.log();
|
|
280
|
-
console.log(chalk.
|
|
697
|
+
console.log(` ${chalk.bold("Build")} ${build.id}`);
|
|
698
|
+
console.log(` ${chalk.bold("State")} ${fmtBuildState(build.state)}`);
|
|
699
|
+
console.log();
|
|
700
|
+
console.log(chalk.dim(` Track it: miosa templates builds ${id}`));
|
|
281
701
|
console.log();
|
|
282
702
|
}
|
|
283
703
|
catch (err) {
|
|
284
|
-
handleError(err);
|
|
704
|
+
handleError(err, opts);
|
|
285
705
|
}
|
|
286
706
|
});
|
|
287
707
|
// builds
|
|
@@ -291,11 +711,17 @@ export function register(program) {
|
|
|
291
711
|
.option("--json", "Output raw JSON")
|
|
292
712
|
.action(async (id, opts) => {
|
|
293
713
|
try {
|
|
294
|
-
const
|
|
295
|
-
const client = new MiosaClient(config);
|
|
714
|
+
const api = client();
|
|
296
715
|
const json = isJsonMode(opts);
|
|
297
716
|
const spinner = json ? null : spin("Fetching builds...");
|
|
298
|
-
|
|
717
|
+
let rows;
|
|
718
|
+
try {
|
|
719
|
+
rows = await fetchBuilds(api, id);
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
spinner?.fail(`Could not fetch builds for ${id}`);
|
|
723
|
+
throw err;
|
|
724
|
+
}
|
|
299
725
|
spinner?.stop();
|
|
300
726
|
if (json) {
|
|
301
727
|
printJson(rows);
|
|
@@ -303,14 +729,20 @@ export function register(program) {
|
|
|
303
729
|
}
|
|
304
730
|
if (rows.length === 0) {
|
|
305
731
|
console.log(chalk.dim("No builds found."));
|
|
732
|
+
console.log(chalk.dim(` Start one: miosa templates rebuild ${id}`));
|
|
306
733
|
return;
|
|
307
734
|
}
|
|
308
735
|
renderTable(rows, [
|
|
309
|
-
{ header: "BUILD ID", key: (b) => b.id
|
|
736
|
+
{ header: "BUILD ID", key: (b) => b.id, width: 38 },
|
|
310
737
|
{
|
|
311
738
|
header: "STATE",
|
|
312
739
|
key: (b) => fmtBuildState(b.state),
|
|
313
|
-
width:
|
|
740
|
+
width: 14,
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
header: "IMAGE",
|
|
744
|
+
key: (b) => b.image_id ?? b.image_digest ?? chalk.dim("-"),
|
|
745
|
+
width: 30,
|
|
314
746
|
},
|
|
315
747
|
{
|
|
316
748
|
header: "STARTED",
|
|
@@ -328,17 +760,22 @@ export function register(program) {
|
|
|
328
760
|
},
|
|
329
761
|
{
|
|
330
762
|
header: "ERROR",
|
|
331
|
-
key: (b) =>
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
763
|
+
key: (b) => {
|
|
764
|
+
// The API reports failures as error_code/error_message; reading
|
|
765
|
+
// only `error` meant every failed build showed a blank column.
|
|
766
|
+
const message = buildError(b);
|
|
767
|
+
return message
|
|
768
|
+
? chalk.red(message.length > 40
|
|
769
|
+
? `${message.slice(0, 37)}...`
|
|
770
|
+
: message)
|
|
771
|
+
: chalk.dim("-");
|
|
772
|
+
},
|
|
773
|
+
width: 42,
|
|
337
774
|
},
|
|
338
775
|
]);
|
|
339
776
|
}
|
|
340
777
|
catch (err) {
|
|
341
|
-
handleError(err);
|
|
778
|
+
handleError(err, opts);
|
|
342
779
|
}
|
|
343
780
|
});
|
|
344
781
|
// delete
|
|
@@ -364,16 +801,28 @@ export function register(program) {
|
|
|
364
801
|
process.exit(0);
|
|
365
802
|
}
|
|
366
803
|
}
|
|
367
|
-
const
|
|
368
|
-
const
|
|
369
|
-
const spinner = spin("Deleting template...");
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
804
|
+
const api = client();
|
|
805
|
+
const json = isJsonMode(opts);
|
|
806
|
+
const spinner = json ? null : spin("Deleting template...");
|
|
807
|
+
let result;
|
|
808
|
+
try {
|
|
809
|
+
result = await api.apiDelete(`/api/v1/sandbox-templates/${encodeURIComponent(id)}`);
|
|
810
|
+
}
|
|
811
|
+
catch (err) {
|
|
812
|
+
spinner?.fail(`Could not delete template ${id}`);
|
|
813
|
+
// TEMPLATE_IN_USE is a refusal with a specific remedy, not a bug.
|
|
814
|
+
if (err instanceof ApiResponseError &&
|
|
815
|
+
err.code === "TEMPLATE_IN_USE") {
|
|
816
|
+
throw new UserError(err.message, "Stop the sandboxes booted from this template, or wait for its build to finish, then retry.");
|
|
817
|
+
}
|
|
818
|
+
throw err;
|
|
819
|
+
}
|
|
820
|
+
spinner?.succeed("Template deleted");
|
|
821
|
+
if (json)
|
|
822
|
+
printJson(result ?? { ok: true });
|
|
374
823
|
}
|
|
375
824
|
catch (err) {
|
|
376
|
-
handleError(err);
|
|
825
|
+
handleError(err, opts);
|
|
377
826
|
}
|
|
378
827
|
});
|
|
379
828
|
}
|