@miosa/cli 1.1.18 → 1.1.20

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 +252 -521
  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 +1 -1
  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,190 +45,77 @@ 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;
91
+ // The whole point of a template is to reference it from your own code. Print
92
+ // ready-to-paste create() calls for both SDKs and the CLI, using this exact
93
+ // template's id, so a customer never has to guess the field name or hunt for
94
+ // the id in a truncated table. If the template has no usable image yet, we say
95
+ // so instead of implying the snippet will boot today.
96
+ function printIntegrationSnippet(t) {
97
+ const ref = t.id;
98
+ const isReady = (templateState(t) ?? "") === "ready" && !!templateImage(t);
99
+ console.log(` ${chalk.bold("Use this template")}`);
189
100
  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}`);
101
+ console.log(chalk.dim(" # Python SDK"));
102
+ console.log(" from miosa import Miosa");
103
+ console.log(" client = Miosa()");
104
+ console.log(` sandbox = client.sandboxes.create(template_id=${JSON.stringify(ref)})`);
216
105
  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}`));
106
+ console.log(chalk.dim(" # TypeScript SDK"));
107
+ console.log(' import { Miosa } from "@miosa/sdk";');
108
+ console.log(" const client = new Miosa();");
109
+ console.log(` const sandbox = await client.sandboxes.create({ templateId: ${JSON.stringify(ref)} });`);
232
110
  console.log();
111
+ console.log(chalk.dim(" # CLI"));
112
+ console.log(` miosa sandbox create --template ${ref}`);
113
+ console.log();
114
+ if (!isReady) {
115
+ console.log(chalk.yellow(" Note: this template has no usable image yet, so create() will not boot it"));
116
+ console.log(chalk.dim(` until its build completes. Check: miosa templates builds ${ref}`));
117
+ console.log();
118
+ }
233
119
  }
234
120
  export function register(program) {
235
121
  const templates = program
@@ -242,7 +128,9 @@ export function register(program) {
242
128
  .option("--json", "Output the complete catalog as JSON")
243
129
  .action(async (opts) => {
244
130
  try {
245
- const raw = await client().apiGet("/api/v1/templates");
131
+ const config = loadConfig();
132
+ const client = new MiosaClient(config);
133
+ const raw = await client.apiGet("/api/v1/templates");
246
134
  const rows = productTemplates(raw);
247
135
  const filtered = opts.product
248
136
  ? rows.filter((template) => template.product === opts.product)
@@ -268,7 +156,7 @@ export function register(program) {
268
156
  ]);
269
157
  }
270
158
  catch (err) {
271
- handleError(err, opts);
159
+ handleError(err);
272
160
  }
273
161
  });
274
162
  templates
@@ -278,12 +166,13 @@ export function register(program) {
278
166
  .option("--json", "Output raw readiness rows")
279
167
  .action(async (id, opts) => {
280
168
  try {
281
- const raw = await client().apiGet("/api/v1/templates");
169
+ const config = loadConfig();
170
+ const client = new MiosaClient(config);
171
+ const raw = await client.apiGet("/api/v1/templates");
282
172
  const rows = productTemplates(raw);
283
- const template = rows.find((candidate) => candidate.id === id &&
284
- (!opts.product || candidate.product === opts.product));
173
+ const template = rows.find((candidate) => candidate.id === id && (!opts.product || candidate.product === opts.product));
285
174
  if (!template)
286
- throw new UserError(`Product template not found: ${id}`);
175
+ throw new Error(`Product template not found: ${id}`);
287
176
  const readiness = template.sizes ?? [];
288
177
  if (isJsonMode(opts)) {
289
178
  printJson(readiness);
@@ -315,109 +204,99 @@ export function register(program) {
315
204
  ]);
316
205
  }
317
206
  catch (err) {
318
- handleError(err, opts);
207
+ handleError(err);
319
208
  }
320
209
  });
321
210
  // list
322
211
  templates
323
212
  .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")
213
+ .description("List sandbox templates, distinguishing confirmed-absent from cannot-verify")
328
214
  .option("--json", "Output raw JSON")
329
215
  .action(async (opts) => {
216
+ const config = loadConfig();
217
+ const client = new MiosaClient(config);
218
+ const json = isJsonMode(opts);
219
+ const spinner = json ? null : spin("Fetching templates...");
220
+ const checkedAt = new Date().toISOString();
221
+ let rows;
330
222
  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
- }
223
+ rows = unwrapTemplates(await client.apiGet("/api/v1/sandbox-templates"));
224
+ }
225
+ catch (err) {
226
+ // The listing could NOT be verified. This is emphatically not "no
227
+ // templates" - reporting an empty list here would tell a customer their
228
+ // templates are gone when we simply failed to ask.
359
229
  spinner?.stop();
360
- const selected = opts.mine
361
- ? mine
362
- : opts.builtIn
363
- ? platform
364
- : [...mine, ...platform];
230
+ const reason = err instanceof Error ? err.message : String(err);
231
+ if (json) {
232
+ printJson({
233
+ listing: {
234
+ state: "unverified",
235
+ source: "live",
236
+ checkedAt,
237
+ reason,
238
+ },
239
+ templates: null,
240
+ });
241
+ }
242
+ else {
243
+ console.log();
244
+ console.log(` ${chalk.yellow("Cannot verify")} - the template list could not be retrieved.`);
245
+ console.log(` ${chalk.dim(reason)}`);
246
+ console.log(` ${chalk.dim("This is NOT the same as having no templates. Run: miosa doctor")}`);
247
+ }
248
+ // Non-zero: a script must be able to tell an unverified listing from an
249
+ // empty one.
250
+ process.exitCode = 1;
251
+ return;
252
+ }
253
+ spinner?.stop();
254
+ try {
365
255
  if (json) {
366
- printJson(selected);
256
+ printJson({
257
+ // Never presented as current without saying when it was checked,
258
+ // and never cached: this is a live read every time.
259
+ listing: {
260
+ state: "confirmed",
261
+ source: "live",
262
+ checkedAt,
263
+ count: rows.length,
264
+ },
265
+ templates: rows.map((t) => {
266
+ const { presence, why } = templatePresence(t);
267
+ return { ...t, presence, presenceReason: why };
268
+ }),
269
+ });
367
270
  return;
368
271
  }
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
- }
272
+ if (rows.length === 0) {
273
+ console.log(chalk.dim(`No templates. Confirmed empty by a live read at ${checkedAt}.`));
377
274
  return;
378
275
  }
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 },
276
+ renderTable(rows, [
277
+ { header: "ID", key: (t) => t.id.slice(0, 12), width: 14 },
278
+ { header: "NAME", key: "name", width: 28 },
400
279
  {
401
280
  header: "STATE",
402
281
  key: (t) => fmtTemplateState(templateState(t)),
403
- width: 10,
282
+ width: 12,
404
283
  },
405
284
  {
406
- header: "USABLE",
407
- key: (t) => usability(t).usable ? chalk.green("yes") : chalk.yellow("no"),
408
- width: 8,
285
+ header: "IMAGE",
286
+ key: (t) => fmtPresence(templatePresence(t).presence),
287
+ width: 12,
409
288
  },
410
289
  {
411
- header: "IMAGE",
290
+ header: "IMAGE ID",
412
291
  key: (t) => {
413
292
  const image = templateImage(t);
414
293
  return image
415
- ? image.length > 28
416
- ? `${image.slice(0, 25)}...`
294
+ ? image.length > 26
295
+ ? `${image.slice(0, 23)}...`
417
296
  : image
418
297
  : chalk.dim("-");
419
298
  },
420
- width: 30,
299
+ width: 28,
421
300
  },
422
301
  {
423
302
  header: "CREATED",
@@ -428,280 +307,161 @@ export function register(program) {
428
307
  width: 12,
429
308
  },
430
309
  ]);
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
- }
310
+ const unverified = rows.filter((t) => templatePresence(t).presence === "unknown");
311
+ if (unverified.length > 0) {
312
+ console.log();
313
+ console.log(chalk.dim(` ${unverified.length} template(s) report no image and could not be confirmed either way.`));
455
314
  }
315
+ // The ID column is truncated to keep the table readable. Point users at
316
+ // the one command that prints the FULL, copyable id plus paste-ready
317
+ // SDK/CLI code for wiring the template into their own app.
456
318
  console.log();
319
+ console.log(chalk.dim(` Run "miosa templates get <id>" for the full id and copy-paste integration code.`));
457
320
  }
458
321
  catch (err) {
459
- handleError(err, opts);
322
+ handleError(err);
460
323
  }
461
324
  });
462
- // get
325
+ // versions
463
326
  templates
464
- .command("get <id>")
465
- .description("Get sandbox template details, including whether it is usable")
327
+ .command("versions <id>")
328
+ .description("List a template's versions (a version is a build; specs are immutable)")
466
329
  .option("--json", "Output raw JSON")
467
330
  .action(async (id, opts) => {
468
331
  try {
469
- const api = client();
332
+ const config = loadConfig();
333
+ const client = new MiosaClient(config);
470
334
  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;
335
+ const spinner = json ? null : spin("Fetching versions...");
336
+ const raw = (await client.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}/versions`));
486
337
  spinner?.stop();
338
+ const rows = Array.isArray(raw) ? raw : (raw.data ?? []);
487
339
  if (json) {
488
- printJson({
489
- ...template,
490
- usable: usability(template, builds).usable,
491
- usable_reason: usability(template, builds).reason,
492
- ...(builds ? { builds } : {}),
493
- });
340
+ printJson(rows);
494
341
  return;
495
342
  }
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
- });
343
+ if (rows.length === 0) {
344
+ console.log(chalk.dim("No versions yet."));
558
345
  return;
559
346
  }
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
- }
347
+ renderTable(rows, [
348
+ {
349
+ header: "VERSION",
350
+ key: (v) => (v.current ? chalk.bold(`v${v.version} *`) : `v${v.version}`),
351
+ width: 10,
352
+ },
353
+ { header: "REF", key: (v) => v.ref ?? chalk.dim("-"), width: 26 },
354
+ {
355
+ header: "STATE",
356
+ key: (v) => fmtBuildState(v.state),
357
+ width: 12,
358
+ },
359
+ {
360
+ // Whether this version can actually be run or migrated to. A failed
361
+ // build is listed - you need to see it to understand why the next
362
+ // version is unavailable - but it is not usable.
363
+ header: "USABLE",
364
+ key: (v) => (v.usable ? chalk.green("yes") : chalk.dim("no")),
365
+ width: 8,
366
+ },
367
+ {
368
+ header: "IMAGE",
369
+ key: (v) => v.image_id ?? chalk.dim("-"),
370
+ width: 26,
371
+ },
372
+ {
373
+ header: "CREATED",
374
+ key: (v) => (v.created_at ? String(v.created_at).slice(0, 10) : chalk.dim("-")),
375
+ width: 12,
376
+ },
377
+ ]);
378
+ console.log();
379
+ console.log(chalk.dim(" * = current. Specs are immutable: editing a template mints a new version,"));
380
+ console.log(chalk.dim(" and existing sandboxes keep running the version they were created from."));
566
381
  }
567
382
  catch (err) {
568
- handleError(err, opts);
383
+ handleError(err);
569
384
  }
570
385
  });
571
- // update
386
+ // get
572
387
  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")
388
+ .command("get <id>")
389
+ .description("Get sandbox template details")
577
390
  .option("--json", "Output raw JSON")
578
391
  .action(async (id, opts) => {
579
- const json = isJsonMode(opts);
580
392
  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);
393
+ const config = loadConfig();
394
+ const client = new MiosaClient(config);
395
+ const json = isJsonMode(opts);
396
+ const spinner = json ? null : spin("Fetching template...");
397
+ const tmpl = unwrapTemplate(await client.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}`));
398
+ spinner?.stop();
643
399
  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
- });
400
+ printJson(tmpl);
652
401
  return;
653
402
  }
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
403
  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 });
404
+ console.log(` ${chalk.bold("ID")} ${tmpl.id}`);
405
+ console.log(` ${chalk.bold("Name")} ${tmpl.name}`);
406
+ if (tmpl.slug)
407
+ console.log(` ${chalk.bold("Slug")} ${tmpl.slug}`);
408
+ console.log(` ${chalk.bold("State")} ${fmtTemplateState(templateState(tmpl))}`);
409
+ const image = templateImage(tmpl);
410
+ if (image)
411
+ console.log(` ${chalk.bold("Image")} ${image}`);
412
+ const createdAt = templateCreatedAt(tmpl);
413
+ if (createdAt)
414
+ console.log(` ${chalk.bold("Created")} ${createdAt}`);
415
+ if (tmpl.updated_at)
416
+ console.log(` ${chalk.bold("Updated")} ${tmpl.updated_at}`);
417
+ console.log();
418
+ printIntegrationSnippet(tmpl);
419
+ console.log(chalk.dim(` Run "miosa templates builds ${tmpl.id}" to view build history.`));
420
+ console.log();
663
421
  }
664
422
  catch (err) {
665
- handleError(err, opts);
423
+ handleError(err);
666
424
  }
667
425
  });
668
- // rebuild
426
+ // create
669
427
  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")
428
+ .command("create")
429
+ .description("Create a sandbox template from a Dockerfile")
430
+ .requiredOption("--name <name>", "Template name")
431
+ .requiredOption("--dockerfile <path>", "Path to Dockerfile to build the template from")
673
432
  .option("--json", "Output raw JSON")
674
- .action(async (id, opts) => {
675
- const json = isJsonMode(opts);
433
+ .action(async (opts) => {
676
434
  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;
435
+ let dockerfileContent;
683
436
  try {
684
- const raw = await api.apiPost(`/api/v1/sandbox-templates/${encodeURIComponent(id)}/builds`, body);
685
- build = raw.data ?? raw;
437
+ dockerfileContent = readFileSync(opts.dockerfile, "utf8");
686
438
  }
687
439
  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);
440
+ console.error(chalk.red(`Cannot read Dockerfile at ${opts.dockerfile}: ${err instanceof Error ? err.message : String(err)}`));
441
+ process.exit(1);
442
+ }
443
+ const config = loadConfig();
444
+ const client = new MiosaClient(config);
445
+ const spinner = spin(`Creating template ${opts.name}...`);
446
+ const tmpl = unwrapTemplate(await client.apiPost("/api/v1/sandbox-templates", {
447
+ name: opts.name,
448
+ dockerfile: dockerfileContent,
449
+ }));
450
+ spinner.succeed(`Created template ${tmpl.name}`);
451
+ if (opts.json) {
452
+ console.log(JSON.stringify(tmpl, null, 2));
694
453
  return;
695
454
  }
696
455
  console.log();
697
- console.log(` ${chalk.bold("Build")} ${build.id}`);
698
- console.log(` ${chalk.bold("State")} ${fmtBuildState(build.state)}`);
456
+ console.log(` ${chalk.bold("ID")} ${tmpl.id}`);
457
+ console.log(` ${chalk.bold("Name")} ${tmpl.name}`);
458
+ console.log(` ${chalk.bold("State")} ${fmtTemplateState(tmpl.state)}`);
699
459
  console.log();
700
- console.log(chalk.dim(` Track it: miosa templates builds ${id}`));
460
+ console.log(chalk.dim(` Run "miosa templates builds ${tmpl.id}" to track the build.`));
701
461
  console.log();
702
462
  }
703
463
  catch (err) {
704
- handleError(err, opts);
464
+ handleError(err);
705
465
  }
706
466
  });
707
467
  // builds
@@ -711,17 +471,11 @@ export function register(program) {
711
471
  .option("--json", "Output raw JSON")
712
472
  .action(async (id, opts) => {
713
473
  try {
714
- const api = client();
474
+ const config = loadConfig();
475
+ const client = new MiosaClient(config);
715
476
  const json = isJsonMode(opts);
716
477
  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
- }
478
+ const rows = unwrapBuilds(await client.apiGet(`/api/v1/sandbox-templates/${encodeURIComponent(id)}/builds`));
725
479
  spinner?.stop();
726
480
  if (json) {
727
481
  printJson(rows);
@@ -729,20 +483,14 @@ export function register(program) {
729
483
  }
730
484
  if (rows.length === 0) {
731
485
  console.log(chalk.dim("No builds found."));
732
- console.log(chalk.dim(` Start one: miosa templates rebuild ${id}`));
733
486
  return;
734
487
  }
735
488
  renderTable(rows, [
736
- { header: "BUILD ID", key: (b) => b.id, width: 38 },
489
+ { header: "BUILD ID", key: (b) => b.id.slice(0, 12), width: 14 },
737
490
  {
738
491
  header: "STATE",
739
492
  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,
493
+ width: 12,
746
494
  },
747
495
  {
748
496
  header: "STARTED",
@@ -760,22 +508,17 @@ export function register(program) {
760
508
  },
761
509
  {
762
510
  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,
511
+ key: (b) => b.error
512
+ ? chalk.red(b.error.length > 30
513
+ ? `${b.error.slice(0, 27)}...`
514
+ : b.error)
515
+ : chalk.dim("-"),
516
+ width: 32,
774
517
  },
775
518
  ]);
776
519
  }
777
520
  catch (err) {
778
- handleError(err, opts);
521
+ handleError(err);
779
522
  }
780
523
  });
781
524
  // delete
@@ -801,28 +544,16 @@ export function register(program) {
801
544
  process.exit(0);
802
545
  }
803
546
  }
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 });
547
+ const config = loadConfig();
548
+ const client = new MiosaClient(config);
549
+ const spinner = spin("Deleting template...");
550
+ const result = await client.apiDelete(`/api/v1/sandbox-templates/${encodeURIComponent(id)}`);
551
+ spinner.succeed("Template deleted");
552
+ if (opts.json)
553
+ console.log(JSON.stringify(result ?? { ok: true }, null, 2));
823
554
  }
824
555
  catch (err) {
825
- handleError(err, opts);
556
+ handleError(err);
826
557
  }
827
558
  });
828
559
  }