@miosa/cli 1.0.94 → 1.1.0

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 (79) hide show
  1. package/README.md +144 -5
  2. package/dist/app-manifest.d.ts +40 -0
  3. package/dist/app-manifest.d.ts.map +1 -1
  4. package/dist/app-manifest.js +168 -46
  5. package/dist/app-manifest.js.map +1 -1
  6. package/dist/bin/miosa.js +1 -0
  7. package/dist/bin/miosa.js.map +1 -1
  8. package/dist/client.d.ts +1 -1
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +6 -5
  11. package/dist/client.js.map +1 -1
  12. package/dist/commands/cloud.d.ts +3 -0
  13. package/dist/commands/cloud.d.ts.map +1 -0
  14. package/dist/commands/cloud.js +192 -0
  15. package/dist/commands/cloud.js.map +1 -0
  16. package/dist/commands/completion.d.ts.map +1 -1
  17. package/dist/commands/completion.js +2 -0
  18. package/dist/commands/completion.js.map +1 -1
  19. package/dist/commands/context.d.ts.map +1 -1
  20. package/dist/commands/context.js +2 -19
  21. package/dist/commands/context.js.map +1 -1
  22. package/dist/commands/databases.d.ts.map +1 -1
  23. package/dist/commands/databases.js +3 -2
  24. package/dist/commands/databases.js.map +1 -1
  25. package/dist/commands/db.js +1 -1
  26. package/dist/commands/deploy.d.ts.map +1 -1
  27. package/dist/commands/deploy.js +31 -11
  28. package/dist/commands/deploy.js.map +1 -1
  29. package/dist/commands/desktop.d.ts.map +1 -1
  30. package/dist/commands/desktop.js +2 -1
  31. package/dist/commands/desktop.js.map +1 -1
  32. package/dist/commands/env-input.d.ts +26 -0
  33. package/dist/commands/env-input.d.ts.map +1 -0
  34. package/dist/commands/env-input.js +68 -0
  35. package/dist/commands/env-input.js.map +1 -0
  36. package/dist/commands/env.d.ts.map +1 -1
  37. package/dist/commands/env.js +15 -3
  38. package/dist/commands/env.js.map +1 -1
  39. package/dist/commands/mcp.js +1 -1
  40. package/dist/commands/sandbox-delete-guard.d.ts +4 -0
  41. package/dist/commands/sandbox-delete-guard.d.ts.map +1 -0
  42. package/dist/commands/sandbox-delete-guard.js +32 -0
  43. package/dist/commands/sandbox-delete-guard.js.map +1 -0
  44. package/dist/commands/sandbox-dev.d.ts +20 -0
  45. package/dist/commands/sandbox-dev.d.ts.map +1 -0
  46. package/dist/commands/sandbox-dev.js +583 -0
  47. package/dist/commands/sandbox-dev.js.map +1 -0
  48. package/dist/commands/sandbox.d.ts +5 -0
  49. package/dist/commands/sandbox.d.ts.map +1 -1
  50. package/dist/commands/sandbox.js +247 -87
  51. package/dist/commands/sandbox.js.map +1 -1
  52. package/dist/commands/shell.d.ts.map +1 -1
  53. package/dist/commands/shell.js +3 -2
  54. package/dist/commands/shell.js.map +1 -1
  55. package/dist/commands/storage.d.ts.map +1 -1
  56. package/dist/commands/storage.js +3 -2
  57. package/dist/commands/storage.js.map +1 -1
  58. package/dist/commands/templates.d.ts.map +1 -1
  59. package/dist/commands/templates.js +94 -0
  60. package/dist/commands/templates.js.map +1 -1
  61. package/dist/commands/tenant.d.ts.map +1 -1
  62. package/dist/commands/tenant.js +6 -5
  63. package/dist/commands/tenant.js.map +1 -1
  64. package/dist/commands/util.d.ts.map +1 -1
  65. package/dist/commands/util.js +28 -6
  66. package/dist/commands/util.js.map +1 -1
  67. package/dist/config.d.ts +2 -0
  68. package/dist/config.d.ts.map +1 -1
  69. package/dist/config.js +17 -0
  70. package/dist/config.js.map +1 -1
  71. package/dist/errors.d.ts +1 -1
  72. package/dist/errors.d.ts.map +1 -1
  73. package/dist/errors.js +3 -3
  74. package/dist/errors.js.map +1 -1
  75. package/dist/version.d.ts +3 -0
  76. package/dist/version.d.ts.map +1 -0
  77. package/dist/version.js +5 -0
  78. package/dist/version.js.map +1 -0
  79. package/package.json +4 -3
@@ -11,15 +11,25 @@ import { loadAppManifest, manifestPort, manifestProbePath, manifestStartCommand,
11
11
  import { detectFramework } from "../framework-detector.js";
12
12
  import { addDataOption, client, apiPath, deleteAndPrint, enc, getAndPrint, postAndPrint, printValue, runAction, unwrap, } from "./enterprise-util.js";
13
13
  import { loadConfig } from "../config.js";
14
+ import { assertDeletableRemoteDir } from "./sandbox-delete-guard.js";
15
+ import { ENV_FILE_OPTION_HELP, ENV_INLINE_SHELL_WARNING, ENV_STDIN_OPTION_HELP, resolveEnvInputs, } from "./env-input.js";
14
16
  import { handleError, isJsonMode } from "./util.js";
15
17
  import { renderTable } from "../ui/table.js";
16
18
  import { formatDuration, hintBlock, icon, kvPanel, printBanner, printElapsed, } from "../ui/render.js";
17
19
  import { formatBytes } from "../ui/progress.js";
18
- import { ApiResponseError, MiosaError, NetworkError, ServerError, UserError, } from "../errors.js";
20
+ import { ApiResponseError, MiosaError, NetworkError, ServerError, UserError, mapHttpError, } from "../errors.js";
19
21
  import { EXIT_USER_ERROR } from "../types.js";
22
+ import { registerSandboxDevCommands, runFullSandboxDoctor, } from "./sandbox-dev.js";
20
23
  const DEFAULT_INTERACTIVE_SANDBOX_TIMEOUT_SEC = 3_600;
21
24
  const DEFAULT_CREATE_WAIT_TIMEOUT_SEC = 120;
22
25
  const EXPIRING_SANDBOX_THRESHOLD_SEC = 5 * 60;
26
+ const SANDBOX_SIZE_CONTRACTS = {
27
+ xs: { cpu: 1, memory: 2_048, disk: 10_240 },
28
+ small: { cpu: 2, memory: 4_096, disk: 10_240 },
29
+ medium: { cpu: 4, memory: 8_192, disk: 20_480 },
30
+ large: { cpu: 8, memory: 16_384, disk: 40_960 },
31
+ xl: { cpu: 16, memory: 32_768, disk: 81_920 },
32
+ };
23
33
  export function register(program) {
24
34
  // -------------------------------------------------------------------------
25
35
  // sandbox / sandboxes command group — built manually to avoid subcommand
@@ -30,6 +40,7 @@ export function register(program) {
30
40
  .command("sandbox")
31
41
  .alias("sandboxes")
32
42
  .description("Manage Sandboxes — lightweight code-only Computers (Firecracker microVMs without a desktop)");
43
+ registerSandboxDevCommands(sandbox);
33
44
  // list
34
45
  sandbox
35
46
  .command("list")
@@ -63,7 +74,7 @@ export function register(program) {
63
74
  { header: "NAME", key: "name" },
64
75
  {
65
76
  header: "STATUS",
66
- key: "status",
77
+ key: "state",
67
78
  color: (val) => statusColor(val.trim()),
68
79
  },
69
80
  {
@@ -112,7 +123,7 @@ export function register(program) {
112
123
  { label: "Name", value: str(sb["name"]) },
113
124
  {
114
125
  label: "Status",
115
- value: statusColor(str(sb["status"])),
126
+ value: statusColor(str(sb["state"])),
116
127
  },
117
128
  ];
118
129
  if (sb["template_id"]) {
@@ -170,10 +181,13 @@ export function register(program) {
170
181
  .description("Create a new Sandbox")
171
182
  .option("--template <template>", "Template / image ID (default: miosa-sandbox)")
172
183
  .option("--name <name>", "Human-readable name for the Sandbox")
184
+ .option("--size <size>", "Named size: xs, small, medium, large, or xl (default: small)", parseSandboxSize)
173
185
  .option("--cpu <n>", "vCPU count", parseIntegerOption)
174
186
  .option("--memory <size>", "Memory size, e.g. 4096mb or 4gb", parseSizeMb)
175
187
  .option("--disk <size>", "Disk size, e.g. 10240mb or 10gb", parseSizeMb)
176
188
  .option("--timeout <duration>", "Wall-clock timeout, e.g. 300s, 1h", parseDurationSec)
189
+ .option("--idle-timeout <duration>", "Idle timeout before pause; 0 disables it (default: 0)", parseDurationSec)
190
+ .option("--idempotency-key <key>", "Retry-safe create key retained by the service for 24 hours")
177
191
  .option("--publish-port <port>", "Expose this port after create", parseIntegerOption)
178
192
  .option("--wait", "Wait for sandbox running and published port readiness")
179
193
  .option("--probe-path <path>", "HTTP path to probe when --publish-port is set", "/")
@@ -182,6 +196,9 @@ export function register(program) {
182
196
  .option("--depth <n>", "Git clone depth when --source git: is used", parseIntegerOption)
183
197
  .option("--snapshot <id>", "Create from a sandbox snapshot")
184
198
  .option("--workspace <id-or-slug>", "Workspace ID/slug")
199
+ .option("--external-workspace <id>", "White-label workspace/customer ID for billing attribution")
200
+ .option("--external-user <id>", "White-label user ID for billing attribution")
201
+ .option("--external-project <id>", "White-label project ID for billing attribution")
185
202
  .option("--agent-profile <id>", "Agent runtime profile ID to mount into the sandbox")
186
203
  .option("--skip-agent-profile", "Do not apply the tenant/workspace default agent runtime profile")
187
204
  .option("--network-policy <policy>", "Network policy: allow-all or deny-all")
@@ -192,6 +209,12 @@ export function register(program) {
192
209
  .option("--non-persistent", "Discard filesystem state on timeout instead of pausing for resume")
193
210
  .option("--auto-start", "Seed and start the template app after the sandbox reaches running"))
194
211
  .option("--json", "Output as JSON")
212
+ .addHelpText("after", `
213
+ Note:
214
+ White-label preview domains require external attribution at creation.
215
+ Pass --external-workspace / --external-user / --external-project when the
216
+ sandbox will be exposed on a white-label preview domain.
217
+ `)
195
218
  .action((opts) => runAction(async () => {
196
219
  const t0 = Date.now();
197
220
  const json = !!isJsonMode(opts) || process.env["MIOSA_JSON"] === "1";
@@ -204,19 +227,21 @@ export function register(program) {
204
227
  renderCreateSuccess(raw, Date.now() - t0);
205
228
  return;
206
229
  }
207
- const body = {};
208
- if (opts.template)
209
- body["template_id"] = opts.template;
230
+ const body = {
231
+ template_id: opts.template ?? "miosa-sandbox",
232
+ };
210
233
  if (opts.name)
211
234
  body["name"] = opts.name;
212
- if (opts.cpu != null)
213
- body["cpu_count"] = opts.cpu;
214
- if (opts.memory != null)
215
- body["memory_mb"] = opts.memory;
216
- if (opts.disk != null)
217
- body["disk_size_mb"] = opts.disk;
235
+ const resources = resolveSandboxResources(opts);
236
+ body["size"] = resources.size;
237
+ if (resources.legacy) {
238
+ body["cpu_count"] = resources.legacy.cpu;
239
+ body["memory_mb"] = resources.legacy.memory;
240
+ body["disk_size_mb"] = resources.legacy.disk;
241
+ }
218
242
  const timeoutSec = opts.timeout ?? DEFAULT_INTERACTIVE_SANDBOX_TIMEOUT_SEC;
219
243
  body["timeout_sec"] = timeoutSec;
244
+ body["idle_timeout_sec"] = opts.idleTimeout ?? 0;
220
245
  if (opts.source)
221
246
  body["source"] = opts.source;
222
247
  if (opts.revision)
@@ -236,6 +261,15 @@ export function register(program) {
236
261
  process.env["MIOSA_WORKSPACE"];
237
262
  if (workspace)
238
263
  body["workspace_id"] = workspace;
264
+ if (opts.externalWorkspace) {
265
+ body["external_workspace_id"] = opts.externalWorkspace;
266
+ }
267
+ if (opts.externalUser) {
268
+ body["external_user_id"] = opts.externalUser;
269
+ }
270
+ if (opts.externalProject) {
271
+ body["external_project_id"] = opts.externalProject;
272
+ }
239
273
  const networkPolicy = buildNetworkPolicy(opts);
240
274
  if (networkPolicy) {
241
275
  body["metadata"] = {
@@ -249,7 +283,9 @@ export function register(program) {
249
283
  body["persistent"] = opts.nonPersistent ? false : true;
250
284
  if (opts.autoStart)
251
285
  body["auto_start"] = true;
252
- const raw = unwrap(await client().apiPost(apiPath("/sandboxes"), body));
286
+ const raw = unwrap(await client().apiPost(apiPath("/sandboxes"), body, opts.idempotencyKey
287
+ ? { "Idempotency-Key": opts.idempotencyKey }
288
+ : undefined));
253
289
  const sb = (raw ?? {});
254
290
  const id = String(sb["id"] ?? "");
255
291
  if (opts.publishPort != null && id) {
@@ -285,14 +321,16 @@ export function register(program) {
285
321
  // delete
286
322
  sandbox
287
323
  .command("delete <sandbox-id>")
288
- .description("Delete a Sandbox")
324
+ .description("Permanently delete a Sandbox (legacy API extension)")
325
+ .requiredOption("--force", "Confirm permanent filesystem deletion")
289
326
  .option("--json", "Output as JSON")
290
327
  .action((id, opts) => runAction(() => deleteAndPrint(`/sandboxes/${enc(id)}`, opts)));
291
328
  // destroy / rm — aliases for delete
292
329
  sandbox
293
330
  .command("destroy <sandbox-id>")
294
331
  .alias("rm")
295
- .description("Destroy a Sandbox (alias for delete)")
332
+ .description("Permanently destroy a Sandbox (legacy API extension)")
333
+ .requiredOption("--force", "Confirm permanent filesystem deletion")
296
334
  .option("--json", "Output as JSON")
297
335
  .action((id, opts) => runAction(() => deleteAndPrint(`/sandboxes/${enc(id)}`, opts)));
298
336
  // action — lifecycle operations: pause, resume
@@ -307,6 +345,11 @@ export function register(program) {
307
345
  }
308
346
  await postAndPrint(`/sandboxes/${enc(id)}/${enc(action)}`, opts, {});
309
347
  }));
348
+ sandbox
349
+ .command("pause <sandbox-id>")
350
+ .description("Pause a running persistent Sandbox and preserve its workspace")
351
+ .option("--json", "Output as JSON")
352
+ .action((id, opts) => runAction(() => postAndPrint(`/sandboxes/${enc(id)}/pause`, opts, {})));
310
353
  // stop — snapshot + pause (mirrors `box stop`)
311
354
  sandbox
312
355
  .command("stop <sandbox-id>")
@@ -314,7 +357,7 @@ export function register(program) {
314
357
  .option("--no-snapshot", "Deprecated compatibility flag; stop still preserves state server-side")
315
358
  .option("--json", "Output as JSON")
316
359
  .action((id, opts) => runAction(async () => {
317
- const stopped = unwrap(await client().apiPost(apiPath(`/sandboxes/${enc(id)}/stop`), {}));
360
+ const stopped = unwrap(await client().apiPost(apiPath(`/sandboxes/${enc(id)}/pause`), {}));
318
361
  if (isJsonMode(opts)) {
319
362
  console.log(JSON.stringify(stopped, null, 2));
320
363
  return;
@@ -335,19 +378,27 @@ export function register(program) {
335
378
  sandbox
336
379
  .command("resume <sandbox-id>")
337
380
  .description("Resume a paused Sandbox")
381
+ .option("--idempotency-key <key>", "Retry-safe lifecycle key sent as Idempotency-Key")
338
382
  .option("--json", "Output as JSON")
339
383
  .action((id, opts) => runAction(() => resumeSandboxAndPrint(id, opts)));
340
384
  // fork — clone from snapshot in one call (mirrors `box fork`)
341
385
  sandbox
342
386
  .command("fork <sandbox-id>")
343
- .description("Clone (fork) a Sandbox from its current state")
344
- .option("--name <name>", "Optional name for the forked Sandbox")
387
+ .description("Snapshot and fork a running Sandbox into a new Sandbox")
388
+ .option("--template <template>", "Template or immutable image override")
389
+ .option("--timeout <duration>", "Fork timeout", parseDurationSec)
390
+ .option("--idempotency-key <key>", "Retry-safe fork key sent as Idempotency-Key")
345
391
  .option("--json", "Output as JSON")
346
392
  .action((id, opts) => runAction(async () => {
347
393
  const body = {};
348
- if (opts.name)
349
- body["name"] = opts.name;
350
- await postAndPrint(`/sandboxes/${enc(id)}/fork`, opts, body);
394
+ if (opts.template)
395
+ body["template_id"] = opts.template;
396
+ if (opts.timeout != null)
397
+ body["timeout_sec"] = opts.timeout;
398
+ const result = unwrap(await client().apiPost(apiPath(`/sandboxes/${enc(id)}/fork`), body, opts.idempotencyKey
399
+ ? { "Idempotency-Key": opts.idempotencyKey }
400
+ : undefined));
401
+ printValue(result, opts);
351
402
  }));
352
403
  // desktop — open the Sandbox's web desktop URL (mirrors `box desktop`).
353
404
  // Sandboxes are headless by default; only templates that expose a desktop
@@ -385,7 +436,9 @@ export function register(program) {
385
436
  .option("--connector <uid>", "MIOSA Connect connector UID to preflight before running the agent")
386
437
  .option("--preflight", "Verify the Sandbox has the requested provider connector before exec")
387
438
  .option("--cwd <path>", "Working directory inside the Sandbox")
388
- .option("--env <KEY=VALUE>", "Environment variable for the run. Repeatable.", collectOption, [])
439
+ .option("--env <KEY=VALUE>", `Environment variable for the Agent Run. Repeatable. ${ENV_INLINE_SHELL_WARNING}`, collectOption, [])
440
+ .option("--env-file <path>", ENV_FILE_OPTION_HELP)
441
+ .option("--env-stdin", ENV_STDIN_OPTION_HELP)
389
442
  .option("--agent-profile <id>", "Agent runtime profile ID")
390
443
  .option("--skip-agent-profile", "Do not apply the default agent runtime profile")
391
444
  .option("--external-workspace <id>", "White-label workspace/customer ID for billing attribution")
@@ -423,8 +476,9 @@ export function register(program) {
423
476
  body["model"] = opts.model;
424
477
  if (opts.cwd)
425
478
  body["cwd"] = opts.cwd;
426
- if (opts.env && opts.env.length > 0) {
427
- body["env"] = parseEnvPairs(opts.env);
479
+ const env = await resolveEnvInputs(parseEnvPairs(opts.env ?? []), opts);
480
+ if (Object.keys(env).length > 0) {
481
+ body["env"] = env;
428
482
  }
429
483
  if (opts.agentProfile) {
430
484
  body["agent_runtime_profile_id"] = opts.agentProfile;
@@ -479,7 +533,9 @@ export function register(program) {
479
533
  .option("--cmd <command>", "Explicit command string to run; avoids CLI parsing command flags")
480
534
  .option("--command <command>", "Alias for --cmd")
481
535
  .option("--shell-cmd <shell>", "Run --cmd through this shell, e.g. 'bash -lc'")
482
- .option("--env <pair>", "Environment variable KEY=VALUE. Repeatable.", collectOption, [])
536
+ .option("--env <pair>", `Environment variable KEY=VALUE. Repeatable. ${ENV_INLINE_SHELL_WARNING}`, collectOption, [])
537
+ .option("--env-file <path>", ENV_FILE_OPTION_HELP)
538
+ .option("--env-stdin", ENV_STDIN_OPTION_HELP)
483
539
  .option("--background", "Start the command in the background and return immediately")
484
540
  .option("--detached", "Create a durable backend command and return command_id immediately")
485
541
  .option("--follow", "Stream command output until it exits (alias --stream)")
@@ -508,7 +564,7 @@ export function register(program) {
508
564
  body["cwd"] = cwd;
509
565
  body["dir"] = cwd;
510
566
  }
511
- const env = parseEnvPairs(opts.env ?? []);
567
+ const env = await resolveEnvInputs(parseEnvPairs(opts.env ?? []), opts);
512
568
  if (opts.detached) {
513
569
  const result = await createSandboxCommand(id, cmd, {
514
570
  cwd,
@@ -547,7 +603,9 @@ export function register(program) {
547
603
  .option("--cmd <command>", "Explicit command string to run; avoids CLI parsing command flags")
548
604
  .option("--command <command>", "Alias for --cmd")
549
605
  .option("--shell-cmd <shell>", "Run --cmd through this shell, e.g. 'bash -lc'")
550
- .option("--env <pair>", "Environment variable KEY=VALUE. Repeatable.", collectOption, [])
606
+ .option("--env <pair>", `Environment variable KEY=VALUE. Repeatable. ${ENV_INLINE_SHELL_WARNING}`, collectOption, [])
607
+ .option("--env-file <path>", ENV_FILE_OPTION_HELP)
608
+ .option("--env-stdin", ENV_STDIN_OPTION_HELP)
551
609
  .option("--background", "Start the command in the background and return immediately")
552
610
  .option("--detached", "Create a durable backend command and return command_id immediately")
553
611
  .option("--follow", "Stream command output until it exits (alias --stream)")
@@ -576,7 +634,7 @@ export function register(program) {
576
634
  body["cwd"] = cwd;
577
635
  body["dir"] = cwd;
578
636
  }
579
- const env = parseEnvPairs(opts.env ?? []);
637
+ const env = await resolveEnvInputs(parseEnvPairs(opts.env ?? []), opts);
580
638
  if (opts.detached) {
581
639
  const result = await createSandboxCommand(id, cmd, {
582
640
  cwd,
@@ -963,11 +1021,17 @@ export function register(program) {
963
1021
  ]);
964
1022
  }));
965
1023
  env
966
- .command("set <sandbox-id> <pairs...>")
967
- .description("Set encrypted sandbox env vars as KEY=VALUE")
1024
+ .command("set <sandbox-id> [pairs...]")
1025
+ .description(`Set encrypted sandbox env vars as KEY=VALUE. ${ENV_INLINE_SHELL_WARNING}`)
1026
+ .option("--env-file <path>", ENV_FILE_OPTION_HELP)
1027
+ .option("--env-stdin", ENV_STDIN_OPTION_HELP)
968
1028
  .option("--json", "Output as JSON")
969
1029
  .action((id, pairs, opts) => runAction(async () => {
970
- const vars = Object.entries(parseEnvPairs(pairs)).map(([key, value]) => ({
1030
+ const values = await resolveEnvInputs(parseEnvPairs(pairs ?? []), opts);
1031
+ if (Object.keys(values).length === 0) {
1032
+ throw new UserError("No env vars provided.", "Pass KEY=VALUE pairs, --env-file <path>, or --env-stdin.");
1033
+ }
1034
+ const vars = Object.entries(values).map(([key, value]) => ({
971
1035
  key,
972
1036
  value,
973
1037
  }));
@@ -1024,12 +1088,34 @@ export function register(program) {
1024
1088
  console.log(chalk.green(`Attached database ${databaseId} to sandbox ${id}.`));
1025
1089
  }));
1026
1090
  sandbox
1027
- .command("doctor <sandbox-id>")
1091
+ .command("doctor [sandbox-id]")
1028
1092
  .description("Diagnose sandbox app readiness across sandbox state, internal HTTP, public route, and TLS/edge reachability")
1029
- .requiredOption("--port <port>", "Port inside the sandbox to check", parseIntegerOption)
1093
+ .option("--port <port>", "Port inside the sandbox to check", parseIntegerOption)
1030
1094
  .option("--probe-path <path>", "HTTP path to probe", "/")
1095
+ .option("--full", "Inspect the complete canonical developer contract")
1096
+ .option("--dir <path>", "Project directory for --full", ".")
1031
1097
  .option("--json", "Output as JSON")
1032
1098
  .action((id, opts) => runAction(async () => {
1099
+ if (opts.full) {
1100
+ const report = await runFullSandboxDoctor(opts.dir, id);
1101
+ if (!report.ok)
1102
+ process.exitCode = 1;
1103
+ if (isJsonMode(opts)) {
1104
+ console.log(JSON.stringify(report, null, 2));
1105
+ return;
1106
+ }
1107
+ console.log();
1108
+ console.log(chalk.bold("Sandbox Doctor Full"));
1109
+ console.log();
1110
+ for (const check of report.checks) {
1111
+ console.log(` ${check.ok ? chalk.green("ok") : chalk.red("fail")} ${check.id}: ${check.message}`);
1112
+ }
1113
+ console.log();
1114
+ return;
1115
+ }
1116
+ if (!id || opts.port == null) {
1117
+ throw new UserError("Sandbox ID and --port are required unless --full is used.", "Use miosa sandbox doctor <sandbox-id> --port <port>, or miosa sandbox doctor --full.");
1118
+ }
1033
1119
  const report = await doctorSandbox(id, opts.port, opts.probePath);
1034
1120
  if (isJsonMode(opts)) {
1035
1121
  console.log(JSON.stringify(report, null, 2));
@@ -1082,7 +1168,7 @@ export function register(program) {
1082
1168
  .option("--json", "Output raw JSON response")
1083
1169
  .action(async (id, remotePath, opts) => {
1084
1170
  try {
1085
- const result = await fetchApiRaw(apiPath(`/sandboxes/${enc(id)}/files/read?path=${enc(remotePath)}`));
1171
+ const result = await client().apiGet(apiPath(`/sandboxes/${enc(id)}/files/read?path=${enc(remotePath)}`));
1086
1172
  if (isJsonMode(opts)) {
1087
1173
  console.log(JSON.stringify(typeof result === "string" ? { content: result } : result, null, 2));
1088
1174
  return;
@@ -1142,11 +1228,14 @@ export function register(program) {
1142
1228
  .command("upload-dir <sandbox-id> <local-dir> <remote-dir>")
1143
1229
  .description("Upload a local directory into a Sandbox")
1144
1230
  .option("--delete", "Delete the remote directory contents before extracting")
1231
+ .option("--force", "Skip the --delete confirmation prompt")
1232
+ .option("--yes", "Alias for --force")
1145
1233
  .option("--json", "Output as JSON")
1146
1234
  .action(async (id, localDir, remoteDir, opts) => {
1147
1235
  try {
1148
1236
  const result = await uploadDirToSandbox(id, localDir, remoteDir, {
1149
1237
  delete: !!opts.delete,
1238
+ force: !!(opts.force || opts.yes),
1150
1239
  });
1151
1240
  if (isJsonMode(opts)) {
1152
1241
  console.log(JSON.stringify(result, null, 2));
@@ -1163,11 +1252,14 @@ export function register(program) {
1163
1252
  .description("Sync a local directory into a sandbox")
1164
1253
  .requiredOption("--sandbox <id>", "Sandbox ID")
1165
1254
  .option("--delete", "Delete the remote directory contents before extracting")
1255
+ .option("--force", "Skip the --delete confirmation prompt")
1256
+ .option("--yes", "Alias for --force")
1166
1257
  .option("--json", "Output as JSON")
1167
1258
  .action(async (localDir, remoteDir, opts) => {
1168
1259
  try {
1169
1260
  const result = await uploadDirToSandbox(opts.sandbox, localDir, remoteDir, {
1170
1261
  delete: !!opts.delete,
1262
+ force: !!(opts.force || opts.yes),
1171
1263
  });
1172
1264
  if (isJsonMode(opts)) {
1173
1265
  console.log(JSON.stringify(result, null, 2));
@@ -1184,6 +1276,8 @@ export function register(program) {
1184
1276
  .alias("copy")
1185
1277
  .description("Copy a local file or directory into a sandbox, e.g. ./app/. sbx_123:/workspace")
1186
1278
  .option("--delete", "Delete the remote directory contents before extracting directories")
1279
+ .option("--force", "Skip the --delete confirmation prompt")
1280
+ .option("--yes", "Alias for --force")
1187
1281
  .option("--json", "Output as JSON")
1188
1282
  .action(async (source, target, opts) => {
1189
1283
  try {
@@ -1204,7 +1298,10 @@ export function register(program) {
1204
1298
  const local = source.endsWith("/.") ? source.slice(0, -2) : source;
1205
1299
  const stat = fs.statSync(local);
1206
1300
  if (stat.isDirectory()) {
1207
- const result = await uploadDirToSandbox(parsed.sandboxId, local, parsed.remotePath, { delete: !!opts.delete });
1301
+ const result = await uploadDirToSandbox(parsed.sandboxId, local, parsed.remotePath, {
1302
+ delete: !!opts.delete,
1303
+ force: !!(opts.force || opts.yes),
1304
+ });
1208
1305
  if (isJsonMode(opts)) {
1209
1306
  console.log(JSON.stringify(result, null, 2));
1210
1307
  return;
@@ -1314,7 +1411,10 @@ export function register(program) {
1314
1411
  console.log(kvPanel([
1315
1412
  { label: "Export", value: chalk.bold(str(exportData["id"])) },
1316
1413
  { label: "Sandbox", value: id },
1317
- { label: "Status", value: statusColor(str(exportData["status"])) },
1414
+ {
1415
+ label: "Status",
1416
+ value: statusColor(str(exportData["status"])),
1417
+ },
1318
1418
  {
1319
1419
  label: "Archive",
1320
1420
  value: str(exportData["archive_download_url"]),
@@ -1555,7 +1655,7 @@ function normalizeConnectorMode(value) {
1555
1655
  // 4. Spawn `ssh` pointing at localhost:<port> with the key and user.
1556
1656
  // 5. On ssh exit, close the TCP server and exit.
1557
1657
  const SANDBOX_KEY_PATH = path.join(os.homedir(), ".ssh", "miosa_sandbox_ed25519");
1558
- async function ensureSandboxSshKey(id, apiKey, endpoint) {
1658
+ async function ensureSandboxSshKey(id, config) {
1559
1659
  if (!fs.existsSync(SANDBOX_KEY_PATH)) {
1560
1660
  console.log(chalk.dim("Generating SSH keypair for MIOSA sandbox access..."));
1561
1661
  fs.mkdirSync(path.join(os.homedir(), ".ssh"), { recursive: true });
@@ -1583,22 +1683,54 @@ async function ensureSandboxSshKey(id, apiKey, endpoint) {
1583
1683
  // already exist from a previous sandbox, but a fresh sandbox still needs it
1584
1684
  // installed in authorized_keys before SSH auth can succeed.
1585
1685
  const pubKey = fs.readFileSync(`${SANDBOX_KEY_PATH}.pub`, "utf8").trim();
1586
- const base = endpoint.replace(/\/$/, "");
1587
- const res = await fetch(`${base}/api/v1/sandboxes/${encodeURIComponent(id)}/ssh-keys`, {
1686
+ const endpoint = config.endpoint.replace(/\/$/, "");
1687
+ const response = await fetch(`${endpoint}${apiPath(`/sandboxes/${encodeURIComponent(id)}/ssh-keys`)}`, {
1588
1688
  method: "POST",
1589
1689
  headers: {
1690
+ ...sandboxTransportHeaders(config),
1590
1691
  "Content-Type": "application/json",
1591
- Authorization: `Bearer ${apiKey}`,
1592
1692
  },
1593
1693
  body: JSON.stringify({ public_key: pubKey }),
1594
1694
  });
1595
- if (!res.ok) {
1596
- const body = await res.text();
1597
- throw new Error(`Failed to register SSH key: ${res.status} ${body}`);
1695
+ if (!response.ok) {
1696
+ const rawBody = await response.text();
1697
+ let body = {};
1698
+ try {
1699
+ body = JSON.parse(rawBody);
1700
+ }
1701
+ catch {
1702
+ body = { message: rawBody || `HTTP ${response.status}` };
1703
+ }
1704
+ throw mapHttpError(response.status, body, rawBody, response.headers.get("x-request-id"));
1598
1705
  }
1599
1706
  console.log(chalk.green("SSH key registered."));
1600
1707
  }
1601
- function bridgeSandboxWs(socket, wsUrl, apiKey) {
1708
+ function sandboxTransportHeaders(config) {
1709
+ if (!config.api_key)
1710
+ throw new Error("Not authenticated. Run: miosa login");
1711
+ const headers = {
1712
+ Authorization: `Bearer ${String(config.api_key)}`,
1713
+ };
1714
+ if (config.tenant)
1715
+ headers["X-MIOSA-Tenant"] = config.tenant;
1716
+ if (config.workspace)
1717
+ headers["X-MIOSA-Workspace"] = config.workspace;
1718
+ return headers;
1719
+ }
1720
+ export function buildSandboxWebSocketRequest(config, id) {
1721
+ const base = config.endpoint.replace(/\/$/, "");
1722
+ const wsBase = base.replace(/^https?/, (protocol) => protocol === "https" ? "wss" : "ws");
1723
+ const url = new URL(`${wsBase}/api/v1/sandboxes/${encodeURIComponent(id)}/ssh-tunnel`);
1724
+ const headers = sandboxTransportHeaders(config);
1725
+ if (config.tenant) {
1726
+ url.searchParams.set("tenant", config.tenant);
1727
+ }
1728
+ if (config.workspace) {
1729
+ url.searchParams.set("workspace", config.workspace);
1730
+ }
1731
+ return { url: url.toString(), headers };
1732
+ }
1733
+ function bridgeSandboxWs(socket, request) {
1602
1734
  let closed = false;
1603
1735
  function cleanup() {
1604
1736
  if (closed)
@@ -1607,8 +1739,8 @@ function bridgeSandboxWs(socket, wsUrl, apiKey) {
1607
1739
  if (!socket.destroyed)
1608
1740
  socket.destroy();
1609
1741
  }
1610
- const ws = new WebSocket(wsUrl, {
1611
- headers: { Authorization: `Bearer ${apiKey}` },
1742
+ const ws = new WebSocket(request.url, {
1743
+ headers: request.headers,
1612
1744
  });
1613
1745
  ws.on("open", () => {
1614
1746
  socket.on("data", (chunk) => {
@@ -1634,14 +1766,8 @@ function bridgeSandboxWs(socket, wsUrl, apiKey) {
1634
1766
  }
1635
1767
  async function runSandboxSsh(id, opts) {
1636
1768
  const config = loadConfig();
1637
- const apiKey = config.api_key;
1638
- if (!apiKey)
1639
- throw new Error("Not authenticated. Run: miosa login");
1640
- const endpoint = config.endpoint ?? "https://api.miosa.ai";
1641
- const base = endpoint.replace(/\/$/, "");
1642
- const wsBase = base.replace(/^https?/, (p) => (p === "https" ? "wss" : "ws"));
1643
- const wsUrl = `${wsBase}/api/v1/sandboxes/${encodeURIComponent(id)}/ssh-tunnel`;
1644
- await ensureSandboxSshKey(id, String(apiKey), endpoint);
1769
+ const wsRequest = buildSandboxWebSocketRequest(config, id);
1770
+ await ensureSandboxSshKey(id, config);
1645
1771
  // Pick a free local port
1646
1772
  const localPort = opts.port ?? (await pickFreePort());
1647
1773
  const user = opts.user ?? "root";
@@ -1650,13 +1776,13 @@ async function runSandboxSsh(id, opts) {
1650
1776
  sandbox_id: id,
1651
1777
  local_port: localPort,
1652
1778
  user,
1653
- ws_url: wsUrl,
1779
+ ws_url: wsRequest.url,
1654
1780
  key_path: SANDBOX_KEY_PATH,
1655
1781
  }));
1656
1782
  return;
1657
1783
  }
1658
1784
  const server = createServer((socket) => {
1659
- bridgeSandboxWs(socket, wsUrl, String(apiKey));
1785
+ bridgeSandboxWs(socket, wsRequest);
1660
1786
  });
1661
1787
  await new Promise((resolve, reject) => {
1662
1788
  server.on("error", reject);
@@ -2196,7 +2322,7 @@ function parsePublishDatabase(value) {
2196
2322
  normalized === "postgresql" ||
2197
2323
  normalized === "create:postgres" ||
2198
2324
  normalized === "create:postgresql") {
2199
- return { engine: "postgresql", engine_version: "15" };
2325
+ return { engine: "postgresql", engine_version: "16" };
2200
2326
  }
2201
2327
  if (normalized.startsWith("existing:")) {
2202
2328
  return { existing_database_id: value.slice("existing:".length) };
@@ -2506,7 +2632,10 @@ async function waitForSandboxRunning(c, sandboxId, timeoutSec) {
2506
2632
  }
2507
2633
  async function resumeSandboxAndPrint(sandboxId, opts) {
2508
2634
  try {
2509
- await postAndPrint(`/sandboxes/${enc(sandboxId)}/resume`, opts, {});
2635
+ const result = unwrap(await client().apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/resume`), {}, opts.idempotencyKey
2636
+ ? { "Idempotency-Key": opts.idempotencyKey }
2637
+ : undefined));
2638
+ printValue(result, opts);
2510
2639
  }
2511
2640
  catch (err) {
2512
2641
  if (err instanceof ApiResponseError && err.code === "SANDBOX_NOT_PAUSED") {
@@ -2783,13 +2912,16 @@ async function uploadDirToSandbox(sandboxId, localDir, remoteDir, opts) {
2783
2912
  if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
2784
2913
  throw new UserError(`Local directory not found: ${sourceDir}`);
2785
2914
  }
2915
+ const deleteTarget = opts.delete
2916
+ ? await confirmRemoteDeleteDir(sandboxId, remoteDir, !!opts.force)
2917
+ : null;
2786
2918
  const c = client();
2787
2919
  const archivePath = createDeployArchive(sourceDir);
2788
2920
  const remoteArchive = `/tmp/miosa-upload-${Date.now()}.tgz`;
2789
2921
  try {
2790
2922
  await uploadFileToSandbox(c, sandboxId, archivePath, remoteArchive);
2791
- const clean = opts.delete
2792
- ? `rm -rf ${shellQuote(remoteDir)} && mkdir -p ${shellQuote(remoteDir)}`
2923
+ const clean = deleteTarget != null
2924
+ ? `rm -rf ${shellQuote(deleteTarget)} && mkdir -p ${shellQuote(deleteTarget)}`
2793
2925
  : `mkdir -p ${shellQuote(remoteDir)}`;
2794
2926
  await execSandbox(c, sandboxId, `${clean} && tar -xzf ${shellQuote(remoteArchive)} -C ${shellQuote(remoteDir)} && rm -f ${shellQuote(remoteArchive)}`, "/");
2795
2927
  }
@@ -2803,6 +2935,31 @@ async function uploadDirToSandbox(sandboxId, localDir, remoteDir, opts) {
2803
2935
  files_label: path.basename(sourceDir) || sourceDir,
2804
2936
  };
2805
2937
  }
2938
+ // --delete runs `rm -rf` inside the sandbox. Refuse protected roots, then
2939
+ // require an explicit confirmation (interactive prompt, or --force/--yes)
2940
+ // before anything is wiped. Returns the normalized remote dir to delete.
2941
+ async function confirmRemoteDeleteDir(sandboxId, remoteDir, force) {
2942
+ const target = assertDeletableRemoteDir(remoteDir);
2943
+ if (force)
2944
+ return target;
2945
+ if (!process.stdin.isTTY) {
2946
+ throw new UserError(`--delete will permanently wipe ${target} on sandbox ${sandboxId}.`, "Non-interactive session: re-run with --force (or --yes) to confirm the wipe.");
2947
+ }
2948
+ console.log(chalk.yellow(`--delete will permanently wipe sandbox ${sandboxId}:${target} before uploading.`));
2949
+ const { default: inquirer } = await import("inquirer");
2950
+ const { confirmed } = await inquirer.prompt([
2951
+ {
2952
+ type: "confirm",
2953
+ name: "confirmed",
2954
+ message: `Wipe ${sandboxId}:${target}?`,
2955
+ default: false,
2956
+ },
2957
+ ]);
2958
+ if (!confirmed) {
2959
+ throw new UserError("Cancelled - nothing was deleted or uploaded.");
2960
+ }
2961
+ return target;
2962
+ }
2806
2963
  async function execSandbox(c, sandboxId, command, cwd, timeout) {
2807
2964
  const body = { command: commandInCwd(command, cwd) };
2808
2965
  if (cwd) {
@@ -3170,33 +3327,6 @@ async function readSandboxFile(c, sandboxId, remotePath) {
3170
3327
  }
3171
3328
  throw new UserError(`Could not read sandbox file: ${remotePath}`);
3172
3329
  }
3173
- async function fetchApiRaw(path, body) {
3174
- const config = loadConfig();
3175
- const apiKey = config.api_key;
3176
- if (!apiKey)
3177
- throw new UserError("Not authenticated. Run: miosa login");
3178
- const endpoint = (config.endpoint ?? "https://api.miosa.ai").replace(/\/$/, "");
3179
- const res = await fetch(`${endpoint}${path}`, {
3180
- method: body === undefined ? "GET" : "POST",
3181
- headers: {
3182
- Authorization: `Bearer ${String(apiKey)}`,
3183
- Accept: "application/json, text/plain, */*",
3184
- "Content-Type": "application/json",
3185
- "User-Agent": "@miosa/cli",
3186
- },
3187
- body: body === undefined ? undefined : JSON.stringify(body),
3188
- });
3189
- const text = await res.text();
3190
- if (!res.ok) {
3191
- throw new UserError(`Server error (${res.status}): HTTP ${res.status}`, text || res.statusText);
3192
- }
3193
- try {
3194
- return JSON.parse(text);
3195
- }
3196
- catch {
3197
- return text;
3198
- }
3199
- }
3200
3330
  function commandInCwd(command, cwd) {
3201
3331
  if (!cwd)
3202
3332
  return command;
@@ -3289,6 +3419,36 @@ function parseIntegerOption(value) {
3289
3419
  throw new UserError(`Invalid integer: ${value}`);
3290
3420
  return n;
3291
3421
  }
3422
+ function parseSandboxSize(value) {
3423
+ const size = String(value).trim().toLowerCase();
3424
+ if (size in SANDBOX_SIZE_CONTRACTS)
3425
+ return size;
3426
+ throw new UserError(`Invalid sandbox size: ${value}. Expected xs, small, medium, large, or xl.`);
3427
+ }
3428
+ function resolveSandboxResources(opts) {
3429
+ const legacyValues = [opts.cpu, opts.memory, opts.disk];
3430
+ const legacyCount = legacyValues.filter((value) => value != null).length;
3431
+ if (legacyCount === 0)
3432
+ return { size: opts.size ?? "small" };
3433
+ if (legacyCount !== legacyValues.length) {
3434
+ throw new UserError("Legacy resource overrides require --cpu, --memory, and --disk together. Prefer --size.");
3435
+ }
3436
+ const legacy = {
3437
+ cpu: opts.cpu,
3438
+ memory: opts.memory,
3439
+ disk: opts.disk,
3440
+ };
3441
+ const matchingSize = Object.entries(SANDBOX_SIZE_CONTRACTS).find(([, contract]) => contract.cpu === legacy.cpu &&
3442
+ contract.memory === legacy.memory &&
3443
+ contract.disk === legacy.disk)?.[0];
3444
+ if (!matchingSize) {
3445
+ throw new UserError("Legacy --cpu/--memory/--disk values must exactly match a named sandbox size. Prefer --size.");
3446
+ }
3447
+ if (opts.size && opts.size !== matchingSize) {
3448
+ throw new UserError(`Legacy resource overrides match ${matchingSize}, not requested size ${opts.size}.`);
3449
+ }
3450
+ return { size: matchingSize, legacy };
3451
+ }
3292
3452
  function parseSizeMb(value) {
3293
3453
  const match = String(value)
3294
3454
  .trim()