@miosa/cli 1.1.18 → 1.1.19

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 (50) hide show
  1. package/README.md +0 -21
  2. package/dist/client.d.ts +1 -15
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +44 -118
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands/databases.d.ts.map +1 -1
  7. package/dist/commands/databases.js +33 -10
  8. package/dist/commands/databases.js.map +1 -1
  9. package/dist/commands/devices.js +2 -5
  10. package/dist/commands/devices.js.map +1 -1
  11. package/dist/commands/doctor.d.ts.map +1 -1
  12. package/dist/commands/doctor.js +127 -58
  13. package/dist/commands/doctor.js.map +1 -1
  14. package/dist/commands/sandbox.d.ts.map +1 -1
  15. package/dist/commands/sandbox.js +91 -23
  16. package/dist/commands/sandbox.js.map +1 -1
  17. package/dist/commands/templates.d.ts.map +1 -1
  18. package/dist/commands/templates.js +220 -526
  19. package/dist/commands/templates.js.map +1 -1
  20. package/dist/commands/util.d.ts +0 -1
  21. package/dist/commands/util.d.ts.map +1 -1
  22. package/dist/commands/util.js +2 -15
  23. package/dist/commands/util.js.map +1 -1
  24. package/dist/commands/watch.d.ts.map +1 -1
  25. package/dist/commands/watch.js +1 -5
  26. package/dist/commands/watch.js.map +1 -1
  27. package/dist/config.d.ts +1 -1
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +4 -0
  30. package/dist/config.js.map +1 -1
  31. package/dist/errors.d.ts +2 -18
  32. package/dist/errors.d.ts.map +1 -1
  33. package/dist/errors.js +4 -37
  34. package/dist/errors.js.map +1 -1
  35. package/dist/net-diagnosis.d.ts +87 -0
  36. package/dist/net-diagnosis.d.ts.map +1 -0
  37. package/dist/net-diagnosis.js +408 -0
  38. package/dist/net-diagnosis.js.map +1 -0
  39. package/dist/types.d.ts +6 -8
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/package.json +2 -2
  43. package/dist/api-validation.d.ts +0 -53
  44. package/dist/api-validation.d.ts.map +0 -1
  45. package/dist/api-validation.js +0 -200
  46. package/dist/api-validation.js.map +0 -1
  47. package/dist/endpoint.d.ts +0 -37
  48. package/dist/endpoint.d.ts.map +0 -1
  49. package/dist/endpoint.js +0 -123
  50. package/dist/endpoint.js.map +0 -1
@@ -5,7 +5,6 @@ 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";
9
8
  function productTemplates(raw) {
10
9
  const rows = Array.isArray(raw["data"])
11
10
  ? raw["data"]
@@ -36,7 +35,7 @@ function fmtTemplateState(state) {
36
35
  return chalk.dim("-");
37
36
  if (state === "ready" || state === "active")
38
37
  return chalk.green(state);
39
- if (state === "building" || state === "pending" || state === "draft")
38
+ if (state === "building" || state === "pending")
40
39
  return chalk.yellow(state);
41
40
  if (state === "failed" || state === "error")
42
41
  return chalk.red(state);
@@ -46,191 +45,49 @@ function templateState(template) {
46
45
  return template.state ?? template.status;
47
46
  }
48
47
  function templateImage(template) {
49
- return template.image ?? template.image_id ?? undefined;
48
+ return template.image ?? template.image_id;
50
49
  }
51
50
  function templateCreatedAt(template) {
52
51
  return template.created_at ?? template.inserted_at;
53
52
  }
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) {
53
+ function templatePresence(template) {
74
54
  const state = templateState(template);
75
55
  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" };
56
+ if (image) {
57
+ return { presence: "present", why: `image ${image} is recorded on the template` };
86
58
  }
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
- };
59
+ // A draft or failed build has no image BY DEFINITION - that is a confirmed
60
+ // absence, not a gap in our knowledge.
61
+ if (state === "draft" || state === "failed" || state === "error") {
62
+ return { presence: "absent", why: `template is ${state}, so no image was ever published` };
100
63
  }
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" };
64
+ if (state === "building" || state === "pending" || state === "queued") {
65
+ return { presence: "absent", why: `build is ${state}, no image published yet` };
120
66
  }
67
+ // Ready, but no image recorded. We genuinely cannot tell.
121
68
  return {
122
- usable: false,
123
- reason: `no completed build yet (state ${state ?? "unknown"})`,
69
+ presence: "unknown",
70
+ why: `template reports ${state ?? "no state"} but records no image - cannot confirm either way`,
124
71
  };
125
72
  }
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;
73
+ function fmtPresence(p) {
74
+ if (p === "present")
75
+ return chalk.green("present");
76
+ if (p === "absent")
77
+ return chalk.yellow("absent");
78
+ return chalk.dim("unverified");
144
79
  }
145
80
  function fmtBuildState(state) {
146
81
  if (!state)
147
82
  return chalk.dim("-");
148
- if (state === "ready" || state === "success" || state === "complete")
83
+ if (state === "success" || state === "complete")
149
84
  return chalk.green(state);
150
- if (state === "building" ||
151
- state === "running" ||
152
- state === "pending" ||
153
- state === "queued" ||
154
- state === "certifying" ||
155
- state === "snapshotting")
85
+ if (state === "building" || state === "running" || state === "pending")
156
86
  return chalk.yellow(state);
157
- if (state === "failed" || state === "error" || state === "cancelled")
87
+ if (state === "failed" || state === "error")
158
88
  return chalk.red(state);
159
89
  return chalk.dim(state);
160
90
  }
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
- }
234
91
  export function register(program) {
235
92
  const templates = program
236
93
  .command("templates")
@@ -242,7 +99,9 @@ export function register(program) {
242
99
  .option("--json", "Output the complete catalog as JSON")
243
100
  .action(async (opts) => {
244
101
  try {
245
- const raw = await client().apiGet("/api/v1/templates");
102
+ const config = loadConfig();
103
+ const client = new MiosaClient(config);
104
+ const raw = await client.apiGet("/api/v1/templates");
246
105
  const rows = productTemplates(raw);
247
106
  const filtered = opts.product
248
107
  ? rows.filter((template) => template.product === opts.product)
@@ -268,7 +127,7 @@ export function register(program) {
268
127
  ]);
269
128
  }
270
129
  catch (err) {
271
- handleError(err, opts);
130
+ handleError(err);
272
131
  }
273
132
  });
274
133
  templates
@@ -278,12 +137,13 @@ export function register(program) {
278
137
  .option("--json", "Output raw readiness rows")
279
138
  .action(async (id, opts) => {
280
139
  try {
281
- const raw = await client().apiGet("/api/v1/templates");
140
+ const config = loadConfig();
141
+ const client = new MiosaClient(config);
142
+ const raw = await client.apiGet("/api/v1/templates");
282
143
  const rows = productTemplates(raw);
283
- const template = rows.find((candidate) => candidate.id === id &&
284
- (!opts.product || candidate.product === opts.product));
144
+ const template = rows.find((candidate) => candidate.id === id && (!opts.product || candidate.product === opts.product));
285
145
  if (!template)
286
- throw new UserError(`Product template not found: ${id}`);
146
+ throw new Error(`Product template not found: ${id}`);
287
147
  const readiness = template.sizes ?? [];
288
148
  if (isJsonMode(opts)) {
289
149
  printJson(readiness);
@@ -315,109 +175,99 @@ export function register(program) {
315
175
  ]);
316
176
  }
317
177
  catch (err) {
318
- handleError(err, opts);
178
+ handleError(err);
319
179
  }
320
180
  });
321
181
  // list
322
182
  templates
323
183
  .command("list")
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")
184
+ .description("List sandbox templates, distinguishing confirmed-absent from cannot-verify")
328
185
  .option("--json", "Output raw JSON")
329
186
  .action(async (opts) => {
187
+ const config = loadConfig();
188
+ const client = new MiosaClient(config);
189
+ const json = isJsonMode(opts);
190
+ const spinner = json ? null : spin("Fetching templates...");
191
+ const checkedAt = new Date().toISOString();
192
+ let rows;
330
193
  try {
331
- const api = client();
332
- const json = isJsonMode(opts);
333
- const spinner = json ? null : spin("Fetching 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
- }
194
+ rows = unwrapTemplates(await client.apiGet("/api/v1/sandbox-templates"));
195
+ }
196
+ catch (err) {
197
+ // The listing could NOT be verified. This is emphatically not "no
198
+ // templates" - reporting an empty list here would tell a customer their
199
+ // templates are gone when we simply failed to ask.
359
200
  spinner?.stop();
360
- const selected = opts.mine
361
- ? mine
362
- : opts.builtIn
363
- ? platform
364
- : [...mine, ...platform];
201
+ const reason = err instanceof Error ? err.message : String(err);
202
+ if (json) {
203
+ printJson({
204
+ listing: {
205
+ state: "unverified",
206
+ source: "live",
207
+ checkedAt,
208
+ reason,
209
+ },
210
+ templates: null,
211
+ });
212
+ }
213
+ else {
214
+ console.log();
215
+ console.log(` ${chalk.yellow("Cannot verify")} - the template list could not be retrieved.`);
216
+ console.log(` ${chalk.dim(reason)}`);
217
+ console.log(` ${chalk.dim("This is NOT the same as having no templates. Run: miosa doctor")}`);
218
+ }
219
+ // Non-zero: a script must be able to tell an unverified listing from an
220
+ // empty one.
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+ spinner?.stop();
225
+ try {
365
226
  if (json) {
366
- printJson(selected);
227
+ printJson({
228
+ // Never presented as current without saying when it was checked,
229
+ // and never cached: this is a live read every time.
230
+ listing: {
231
+ state: "confirmed",
232
+ source: "live",
233
+ checkedAt,
234
+ count: rows.length,
235
+ },
236
+ templates: rows.map((t) => {
237
+ const { presence, why } = templatePresence(t);
238
+ return { ...t, presence, presenceReason: why };
239
+ }),
240
+ });
367
241
  return;
368
242
  }
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
- }
243
+ if (rows.length === 0) {
244
+ console.log(chalk.dim(`No templates. Confirmed empty by a live read at ${checkedAt}.`));
377
245
  return;
378
246
  }
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 },
247
+ renderTable(rows, [
248
+ { header: "ID", key: (t) => t.id.slice(0, 12), width: 14 },
249
+ { header: "NAME", key: "name", width: 28 },
400
250
  {
401
251
  header: "STATE",
402
252
  key: (t) => fmtTemplateState(templateState(t)),
403
- width: 10,
253
+ width: 12,
404
254
  },
405
255
  {
406
- header: "USABLE",
407
- key: (t) => usability(t).usable ? chalk.green("yes") : chalk.yellow("no"),
408
- width: 8,
256
+ header: "IMAGE",
257
+ key: (t) => fmtPresence(templatePresence(t).presence),
258
+ width: 12,
409
259
  },
410
260
  {
411
- header: "IMAGE",
261
+ header: "IMAGE ID",
412
262
  key: (t) => {
413
263
  const image = templateImage(t);
414
264
  return image
415
- ? image.length > 28
416
- ? `${image.slice(0, 25)}...`
265
+ ? image.length > 26
266
+ ? `${image.slice(0, 23)}...`
417
267
  : image
418
268
  : chalk.dim("-");
419
269
  },
420
- width: 30,
270
+ width: 28,
421
271
  },
422
272
  {
423
273
  header: "CREATED",
@@ -428,280 +278,153 @@ export function register(program) {
428
278
  width: 12,
429
279
  },
430
280
  ]);
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
- }
281
+ const unverified = rows.filter((t) => templatePresence(t).presence === "unknown");
282
+ if (unverified.length > 0) {
283
+ console.log();
284
+ console.log(chalk.dim(` ${unverified.length} template(s) report no image and could not be confirmed either way.`));
455
285
  }
456
- console.log();
457
286
  }
458
287
  catch (err) {
459
- handleError(err, opts);
288
+ handleError(err);
460
289
  }
461
290
  });
462
- // get
291
+ // versions
463
292
  templates
464
- .command("get <id>")
465
- .description("Get sandbox template details, including whether it is usable")
293
+ .command("versions <id>")
294
+ .description("List a template's versions (a version is a build; specs are immutable)")
466
295
  .option("--json", "Output raw JSON")
467
296
  .action(async (id, opts) => {
468
297
  try {
469
- const api = client();
298
+ const config = loadConfig();
299
+ const client = new MiosaClient(config);
470
300
  const json = isJsonMode(opts);
471
- const spinner = json ? null : spin("Fetching template...");
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;
301
+ const spinner = json ? null : spin("Fetching versions...");
302
+ const raw = (await client.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}/versions`));
486
303
  spinner?.stop();
304
+ const rows = Array.isArray(raw) ? raw : (raw.data ?? []);
487
305
  if (json) {
488
- printJson({
489
- ...template,
490
- usable: usability(template, builds).usable,
491
- usable_reason: usability(template, builds).reason,
492
- ...(builds ? { builds } : {}),
493
- });
306
+ printJson(rows);
494
307
  return;
495
308
  }
496
- printTemplateDetail(template, builds, { verified: false });
497
- }
498
- catch (err) {
499
- handleError(err, opts);
500
- }
501
- });
502
- // create
503
- templates
504
- .command("create")
505
- .description("Create a sandbox template from a Dockerfile")
506
- .requiredOption("--name <name>", "Template name")
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")
510
- .option("--json", "Output raw JSON")
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
- });
309
+ if (rows.length === 0) {
310
+ console.log(chalk.dim("No versions yet."));
558
311
  return;
559
312
  }
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
- }
313
+ renderTable(rows, [
314
+ {
315
+ header: "VERSION",
316
+ key: (v) => (v.current ? chalk.bold(`v${v.version} *`) : `v${v.version}`),
317
+ width: 10,
318
+ },
319
+ { header: "REF", key: (v) => v.ref ?? chalk.dim("-"), width: 26 },
320
+ {
321
+ header: "STATE",
322
+ key: (v) => fmtBuildState(v.state),
323
+ width: 12,
324
+ },
325
+ {
326
+ // Whether this version can actually be run or migrated to. A failed
327
+ // build is listed - you need to see it to understand why the next
328
+ // version is unavailable - but it is not usable.
329
+ header: "USABLE",
330
+ key: (v) => (v.usable ? chalk.green("yes") : chalk.dim("no")),
331
+ width: 8,
332
+ },
333
+ {
334
+ header: "IMAGE",
335
+ key: (v) => v.image_id ?? chalk.dim("-"),
336
+ width: 26,
337
+ },
338
+ {
339
+ header: "CREATED",
340
+ key: (v) => (v.created_at ? String(v.created_at).slice(0, 10) : chalk.dim("-")),
341
+ width: 12,
342
+ },
343
+ ]);
344
+ console.log();
345
+ console.log(chalk.dim(" * = current. Specs are immutable: editing a template mints a new version,"));
346
+ console.log(chalk.dim(" and existing sandboxes keep running the version they were created from."));
566
347
  }
567
348
  catch (err) {
568
- handleError(err, opts);
349
+ handleError(err);
569
350
  }
570
351
  });
571
- // update
352
+ // get
572
353
  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")
354
+ .command("get <id>")
355
+ .description("Get sandbox template details")
577
356
  .option("--json", "Output raw JSON")
578
357
  .action(async (id, opts) => {
579
- const json = isJsonMode(opts);
580
358
  try {
581
- const dockerfile = readDockerfile(opts.dockerfile);
582
- const api = client();
583
- const spinner = json ? null : spin(`Reading template ${id}...`);
584
- let existing;
585
- try {
586
- existing = await fetchTemplate(api, id);
587
- }
588
- catch (err) {
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);
359
+ const config = loadConfig();
360
+ const client = new MiosaClient(config);
361
+ const json = isJsonMode(opts);
362
+ const spinner = json ? null : spin("Fetching template...");
363
+ const tmpl = unwrapTemplate(await client.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}`));
364
+ spinner?.stop();
643
365
  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
- });
366
+ printJson(tmpl);
652
367
  return;
653
368
  }
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).
658
369
  console.log();
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 });
370
+ console.log(` ${chalk.bold("ID")} ${tmpl.id}`);
371
+ console.log(` ${chalk.bold("Name")} ${tmpl.name}`);
372
+ console.log(` ${chalk.bold("State")} ${fmtTemplateState(templateState(tmpl))}`);
373
+ const image = templateImage(tmpl);
374
+ if (image)
375
+ console.log(` ${chalk.bold("Image")} ${image}`);
376
+ const createdAt = templateCreatedAt(tmpl);
377
+ if (createdAt)
378
+ console.log(` ${chalk.bold("Created")} ${createdAt}`);
379
+ if (tmpl.updated_at)
380
+ console.log(` ${chalk.bold("Updated")} ${tmpl.updated_at}`);
381
+ console.log();
382
+ console.log(chalk.dim(` Run "miosa templates builds ${tmpl.id}" to view build history.`));
383
+ console.log();
663
384
  }
664
385
  catch (err) {
665
- handleError(err, opts);
386
+ handleError(err);
666
387
  }
667
388
  });
668
- // rebuild
389
+ // create
669
390
  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")
391
+ .command("create")
392
+ .description("Create a sandbox template from a Dockerfile")
393
+ .requiredOption("--name <name>", "Template name")
394
+ .requiredOption("--dockerfile <path>", "Path to Dockerfile to build the template from")
673
395
  .option("--json", "Output raw JSON")
674
- .action(async (id, opts) => {
675
- const json = isJsonMode(opts);
396
+ .action(async (opts) => {
676
397
  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;
398
+ let dockerfileContent;
683
399
  try {
684
- const raw = await api.apiPost(`/api/v1/sandbox-templates/${encodeURIComponent(id)}/builds`, body);
685
- build = raw.data ?? raw;
400
+ dockerfileContent = readFileSync(opts.dockerfile, "utf8");
686
401
  }
687
402
  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);
403
+ console.error(chalk.red(`Cannot read Dockerfile at ${opts.dockerfile}: ${err instanceof Error ? err.message : String(err)}`));
404
+ process.exit(1);
405
+ }
406
+ const config = loadConfig();
407
+ const client = new MiosaClient(config);
408
+ const spinner = spin(`Creating template ${opts.name}...`);
409
+ const tmpl = unwrapTemplate(await client.apiPost("/api/v1/sandbox-templates", {
410
+ name: opts.name,
411
+ dockerfile: dockerfileContent,
412
+ }));
413
+ spinner.succeed(`Created template ${tmpl.name}`);
414
+ if (opts.json) {
415
+ console.log(JSON.stringify(tmpl, null, 2));
694
416
  return;
695
417
  }
696
418
  console.log();
697
- console.log(` ${chalk.bold("Build")} ${build.id}`);
698
- console.log(` ${chalk.bold("State")} ${fmtBuildState(build.state)}`);
419
+ console.log(` ${chalk.bold("ID")} ${tmpl.id}`);
420
+ console.log(` ${chalk.bold("Name")} ${tmpl.name}`);
421
+ console.log(` ${chalk.bold("State")} ${fmtTemplateState(tmpl.state)}`);
699
422
  console.log();
700
- console.log(chalk.dim(` Track it: miosa templates builds ${id}`));
423
+ console.log(chalk.dim(` Run "miosa templates builds ${tmpl.id}" to track the build.`));
701
424
  console.log();
702
425
  }
703
426
  catch (err) {
704
- handleError(err, opts);
427
+ handleError(err);
705
428
  }
706
429
  });
707
430
  // builds
@@ -711,17 +434,11 @@ export function register(program) {
711
434
  .option("--json", "Output raw JSON")
712
435
  .action(async (id, opts) => {
713
436
  try {
714
- const api = client();
437
+ const config = loadConfig();
438
+ const client = new MiosaClient(config);
715
439
  const json = isJsonMode(opts);
716
440
  const spinner = json ? null : spin("Fetching 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
- }
441
+ const rows = unwrapBuilds(await client.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}/builds`));
725
442
  spinner?.stop();
726
443
  if (json) {
727
444
  printJson(rows);
@@ -729,20 +446,14 @@ export function register(program) {
729
446
  }
730
447
  if (rows.length === 0) {
731
448
  console.log(chalk.dim("No builds found."));
732
- console.log(chalk.dim(` Start one: miosa templates rebuild ${id}`));
733
449
  return;
734
450
  }
735
451
  renderTable(rows, [
736
- { header: "BUILD ID", key: (b) => b.id, width: 38 },
452
+ { header: "BUILD ID", key: (b) => b.id.slice(0, 12), width: 14 },
737
453
  {
738
454
  header: "STATE",
739
455
  key: (b) => fmtBuildState(b.state),
740
- width: 14,
741
- },
742
- {
743
- header: "IMAGE",
744
- key: (b) => b.image_id ?? b.image_digest ?? chalk.dim("-"),
745
- width: 30,
456
+ width: 12,
746
457
  },
747
458
  {
748
459
  header: "STARTED",
@@ -760,22 +471,17 @@ export function register(program) {
760
471
  },
761
472
  {
762
473
  header: "ERROR",
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,
474
+ key: (b) => b.error
475
+ ? chalk.red(b.error.length > 30
476
+ ? `${b.error.slice(0, 27)}...`
477
+ : b.error)
478
+ : chalk.dim("-"),
479
+ width: 32,
774
480
  },
775
481
  ]);
776
482
  }
777
483
  catch (err) {
778
- handleError(err, opts);
484
+ handleError(err);
779
485
  }
780
486
  });
781
487
  // delete
@@ -801,28 +507,16 @@ export function register(program) {
801
507
  process.exit(0);
802
508
  }
803
509
  }
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 });
510
+ const config = loadConfig();
511
+ const client = new MiosaClient(config);
512
+ const spinner = spin("Deleting template...");
513
+ const result = await client.apiDelete(`/api/v1/sandbox-templates/${encodeURIComponent(id)}`);
514
+ spinner.succeed("Template deleted");
515
+ if (opts.json)
516
+ console.log(JSON.stringify(result ?? { ok: true }, null, 2));
823
517
  }
824
518
  catch (err) {
825
- handleError(err, opts);
519
+ handleError(err);
826
520
  }
827
521
  });
828
522
  }