@miosa/cli 1.1.16 → 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.
Files changed (40) hide show
  1. package/README.md +47 -0
  2. package/dist/api-validation.d.ts +53 -0
  3. package/dist/api-validation.d.ts.map +1 -0
  4. package/dist/api-validation.js +200 -0
  5. package/dist/api-validation.js.map +1 -0
  6. package/dist/client.d.ts +15 -1
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +118 -44
  9. package/dist/client.js.map +1 -1
  10. package/dist/commands/databases.d.ts.map +1 -1
  11. package/dist/commands/databases.js +10 -33
  12. package/dist/commands/databases.js.map +1 -1
  13. package/dist/commands/deploy.d.ts.map +1 -1
  14. package/dist/commands/deploy.js +227 -77
  15. package/dist/commands/deploy.js.map +1 -1
  16. package/dist/commands/devices.d.ts.map +1 -1
  17. package/dist/commands/devices.js +8 -9
  18. package/dist/commands/devices.js.map +1 -1
  19. package/dist/commands/templates.d.ts.map +1 -1
  20. package/dist/commands/templates.js +540 -91
  21. package/dist/commands/templates.js.map +1 -1
  22. package/dist/commands/util.d.ts +3 -0
  23. package/dist/commands/util.d.ts.map +1 -1
  24. package/dist/commands/util.js +45 -25
  25. package/dist/commands/util.js.map +1 -1
  26. package/dist/commands/watch.d.ts.map +1 -1
  27. package/dist/commands/watch.js +5 -1
  28. package/dist/commands/watch.js.map +1 -1
  29. package/dist/endpoint.d.ts +37 -0
  30. package/dist/endpoint.d.ts.map +1 -0
  31. package/dist/endpoint.js +123 -0
  32. package/dist/endpoint.js.map +1 -0
  33. package/dist/errors.d.ts +18 -2
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js +37 -4
  36. package/dist/errors.js.map +1 -1
  37. package/dist/types.d.ts +8 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js.map +1 -1
  40. 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" || state === "running" || state === "pending")
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 config = loadConfig();
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 config = loadConfig();
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 && (!opts.product || candidate.product === opts.product));
283
+ const template = rows.find((candidate) => candidate.id === id &&
284
+ (!opts.product || candidate.product === opts.product));
118
285
  if (!template)
119
- throw new Error(`Product template not found: ${id}`);
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 config = loadConfig();
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
- const rows = unwrapTemplates(await client.apiGet("/api/v1/sandbox-templates"));
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(rows);
366
+ printJson(selected);
169
367
  return;
170
368
  }
171
- if (rows.length === 0) {
172
- console.log(chalk.dim("No templates found."));
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
- renderTable(rows, [
176
- { header: "ID", key: (t) => t.id.slice(0, 12), width: 14 },
177
- { header: "NAME", key: "name", width: 28 },
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: 12,
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 > 32
189
- ? `${image.slice(0, 29)}...`
415
+ ? image.length > 28
416
+ ? `${image.slice(0, 25)}...`
190
417
  : image
191
418
  : chalk.dim("-");
192
419
  },
193
- width: 34,
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 config = loadConfig();
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
- const tmpl = unwrapTemplate(await client.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}`));
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(tmpl);
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
- console.log();
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
- let dockerfileContent;
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
- dockerfileContent = readFileSync(opts.dockerfile, "utf8");
586
+ existing = await fetchTemplate(api, id);
258
587
  }
259
588
  catch (err) {
260
- console.error(chalk.red(`Cannot read Dockerfile at ${opts.dockerfile}: ${err instanceof Error ? err.message : String(err)}`));
261
- process.exit(1);
262
- }
263
- const config = loadConfig();
264
- const client = new MiosaClient(config);
265
- const spinner = spin(`Creating template ${opts.name}...`);
266
- const tmpl = unwrapTemplate(await client.apiPost("/api/v1/sandbox-templates", {
267
- name: opts.name,
268
- dockerfile: dockerfileContent,
269
- }));
270
- spinner.succeed(`Created template ${tmpl.name}`);
271
- if (opts.json) {
272
- console.log(JSON.stringify(tmpl, null, 2));
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(` ${chalk.bold("ID")} ${tmpl.id}`);
277
- console.log(` ${chalk.bold("Name")} ${tmpl.name}`);
278
- console.log(` ${chalk.bold("State")} ${fmtTemplateState(tmpl.state)}`);
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.dim(` Run "miosa templates builds ${tmpl.id}" to track the build.`));
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 config = loadConfig();
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
- const rows = unwrapBuilds(await client.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}/builds`));
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.slice(0, 12), width: 14 },
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: 12,
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) => b.error
332
- ? chalk.red(b.error.length > 30
333
- ? `${b.error.slice(0, 27)}...`
334
- : b.error)
335
- : chalk.dim("-"),
336
- width: 32,
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 config = loadConfig();
368
- const client = new MiosaClient(config);
369
- const spinner = spin("Deleting template...");
370
- const result = await client.apiDelete(`/api/v1/sandbox-templates/${encodeURIComponent(id)}`);
371
- spinner.succeed("Template deleted");
372
- if (opts.json)
373
- console.log(JSON.stringify(result ?? { ok: true }, null, 2));
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
  }