@miosa/cli 1.0.9 → 1.0.10

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.
@@ -1,16 +1,18 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
- import { spawn } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
5
  import { createServer } from "node:net";
6
6
  import chalk from "chalk";
7
7
  import WebSocket from "ws";
8
+ import { detectFramework } from "../framework-detector.js";
8
9
  import { addDataOption, client, apiPath, deleteAndPrint, enc, getAndPrint, postAndPrint, printValue, runAction, unwrap, } from "./enterprise-util.js";
9
10
  import { loadConfig } from "../config.js";
10
11
  import { handleError } from "./util.js";
11
12
  import { renderTable } from "../ui/table.js";
12
13
  import { formatDuration, hintBlock, icon, kvPanel, printBanner, printElapsed, } from "../ui/render.js";
13
14
  import { formatBytes } from "../ui/progress.js";
15
+ import { UserError } from "../errors.js";
14
16
  export function register(program) {
15
17
  // -------------------------------------------------------------------------
16
18
  // sandbox / sandboxes command group — built manually to avoid subcommand
@@ -84,10 +86,15 @@ export function register(program) {
84
86
  sandbox
85
87
  .command("show <sandbox-id>")
86
88
  .description("Show a Sandbox by ID")
89
+ .option("--port <port>", "Include live preview readiness for this port", parseInt)
90
+ .option("--probe-path <path>", "HTTP path to probe when --port is set", "/")
87
91
  .option("--json", "Output as JSON")
88
92
  .action((id, opts) => runAction(async () => {
89
93
  if (opts.json) {
90
- await getAndPrint(`/sandboxes/${enc(id)}`, opts);
94
+ const data = opts.port != null
95
+ ? await showSandboxWithPreview(id, opts.port, opts.probePath)
96
+ : unwrap(await client().apiGet(apiPath(`/sandboxes/${enc(id)}`)));
97
+ console.log(JSON.stringify(data, null, 2));
91
98
  return;
92
99
  }
93
100
  const raw = unwrap(await client().apiGet(apiPath(`/sandboxes/${enc(id)}`)));
@@ -138,6 +145,7 @@ export function register(program) {
138
145
  console.log();
139
146
  console.log(hintBlock("Try", [
140
147
  `miosa sandbox exec ${str(sb["id"])} --command ...`,
148
+ `miosa sandbox preview ${str(sb["id"])} --port 5173 --wait`,
141
149
  `miosa sandbox destroy ${str(sb["id"])}`,
142
150
  ]));
143
151
  console.log();
@@ -158,6 +166,9 @@ export function register(program) {
158
166
  .option("--memory <mb>", "Memory in MB", parseInt)
159
167
  .option("--disk <mb>", "Disk size in MB", parseInt)
160
168
  .option("--timeout <sec>", "Idle timeout in seconds", parseInt)
169
+ .option("--publish-port <port>", "Expose this port after create", parseInt)
170
+ .option("--wait", "Wait for sandbox running and published port readiness")
171
+ .option("--probe-path <path>", "HTTP path to probe when --publish-port is set", "/")
161
172
  .option("--always-on", "Disable auto-destroy on idle"))
162
173
  .option("--json", "Output as JSON")
163
174
  .action((opts) => runAction(async () => {
@@ -186,12 +197,35 @@ export function register(program) {
186
197
  body["timeout_sec"] = opts.timeout;
187
198
  if (opts.alwaysOn)
188
199
  body["always_on"] = true;
200
+ const raw = unwrap(await client().apiPost(apiPath("/sandboxes"), body));
201
+ const sb = (raw ?? {});
202
+ const id = String(sb["id"] ?? "");
203
+ if (opts.publishPort != null && id) {
204
+ if (opts.wait) {
205
+ const preview = await waitSandboxReady(id, opts.publishPort, opts.probePath ?? "/", Math.max(opts.timeout ?? 120, 30));
206
+ sb["preview"] = preview;
207
+ sb["preview_url"] = preview.url;
208
+ sb["ready"] = preview.ready;
209
+ }
210
+ else {
211
+ const preview = await previewSandbox(id, opts.publishPort, {
212
+ wait: false,
213
+ timeout: 1,
214
+ probePath: opts.probePath ?? "/",
215
+ });
216
+ sb["preview"] = preview;
217
+ sb["preview_url"] = preview.url;
218
+ }
219
+ }
220
+ else if (opts.wait && id) {
221
+ await waitForSandboxRunning(client(), id, Math.max(opts.timeout ?? 120, 30));
222
+ sb["ready"] = true;
223
+ }
189
224
  if (opts.json) {
190
- await postAndPrint("/sandboxes", opts, body);
225
+ console.log(JSON.stringify(sb, null, 2));
191
226
  return;
192
227
  }
193
- const raw = unwrap(await client().apiPost(apiPath("/sandboxes"), body));
194
- renderCreateSuccess(raw, Date.now() - t0);
228
+ renderCreateSuccess(sb, Date.now() - t0);
195
229
  }));
196
230
  // delete
197
231
  sandbox
@@ -291,10 +325,12 @@ export function register(program) {
291
325
  const modelFlag = opts.model
292
326
  ? ` --model ${`'${opts.model.replace(/'/g, "'\\''")}'`}`
293
327
  : "";
294
- const command = `${provider}${modelFlag} ${`'${instruction.replace(/'/g, "'\\''")}'`}`;
328
+ const command = commandInCwd(`${provider}${modelFlag} ${shellQuote(instruction)}`, opts.cwd);
295
329
  const body = { command };
296
- if (opts.cwd)
330
+ if (opts.cwd) {
297
331
  body["cwd"] = opts.cwd;
332
+ body["dir"] = opts.cwd;
333
+ }
298
334
  if (opts.timeout != null)
299
335
  body["timeout"] = opts.timeout;
300
336
  await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
@@ -304,6 +340,7 @@ export function register(program) {
304
340
  .command("exec <sandbox-id> [command...]")
305
341
  .description("Run a command inside a Sandbox (positional args joined as shell command)")
306
342
  .option("--cwd <path>", "Working directory inside the Sandbox")
343
+ .option("--background", "Start the command in the background and return immediately")
307
344
  .option("--timeout <sec>", "Exec timeout in seconds", parseInt))
308
345
  .option("--json", "Output as JSON")
309
346
  .action((id, words, opts) => runAction(async () => {
@@ -312,9 +349,14 @@ export function register(program) {
312
349
  return;
313
350
  }
314
351
  const cmd = words.join(" ");
315
- const body = cmd ? { command: cmd } : {};
316
- if (opts.cwd)
352
+ const effectiveCommand = opts.background ? backgroundCommand(cmd) : cmd;
353
+ const body = cmd
354
+ ? { command: commandInCwd(effectiveCommand, opts.cwd) }
355
+ : {};
356
+ if (opts.cwd) {
317
357
  body["cwd"] = opts.cwd;
358
+ body["dir"] = opts.cwd;
359
+ }
318
360
  if (opts.timeout != null)
319
361
  body["timeout"] = opts.timeout;
320
362
  await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
@@ -324,6 +366,7 @@ export function register(program) {
324
366
  .command("run <sandbox-id> [command...]")
325
367
  .description("Run a command inside a Sandbox (alias for exec)")
326
368
  .option("--cwd <path>", "Working directory inside the Sandbox")
369
+ .option("--background", "Start the command in the background and return immediately")
327
370
  .option("--timeout <sec>", "Exec timeout in seconds", parseInt))
328
371
  .option("--json", "Output as JSON")
329
372
  .action((id, words, opts) => runAction(async () => {
@@ -332,13 +375,163 @@ export function register(program) {
332
375
  return;
333
376
  }
334
377
  const cmd = words.join(" ");
335
- const body = cmd ? { command: cmd } : {};
336
- if (opts.cwd)
378
+ const effectiveCommand = opts.background ? backgroundCommand(cmd) : cmd;
379
+ const body = cmd
380
+ ? { command: commandInCwd(effectiveCommand, opts.cwd) }
381
+ : {};
382
+ if (opts.cwd) {
337
383
  body["cwd"] = opts.cwd;
384
+ body["dir"] = opts.cwd;
385
+ }
338
386
  if (opts.timeout != null)
339
387
  body["timeout"] = opts.timeout;
340
388
  await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
341
389
  }));
390
+ sandbox
391
+ .command("deploy [local-dir]")
392
+ .description("Upload an app directory, start it in a sandbox, expose a preview URL, and wait for readiness")
393
+ .option("--sandbox <id>", "Existing sandbox ID. Creates one when omitted")
394
+ .option("--template <template>", "Template for new sandbox", "miosa-sandbox")
395
+ .option("--name <name>", "Name for a new sandbox")
396
+ .option("--port <port>", "Preview port", parseInt)
397
+ .option("--start <command>", "Start command to run inside /workspace")
398
+ .option("--install-command <command>", "Install command to run before start")
399
+ .option("--no-install", "Skip automatic dependency install")
400
+ .option("--wait", "Wait until the public preview returns a good HTTP status")
401
+ .option("--timeout <sec>", "Wait timeout in seconds", parseInt, 180)
402
+ .option("--probe-path <path>", "HTTP path to probe", "/")
403
+ .option("--json", "Output as JSON")
404
+ .action((localDir = ".", opts) => runAction(async () => {
405
+ const result = await deploySandbox(localDir, opts);
406
+ if (opts.json) {
407
+ console.log(JSON.stringify(result, null, 2));
408
+ return;
409
+ }
410
+ console.log();
411
+ console.log(` ${chalk.bold("Sandbox")} ${result.sandbox_id}`);
412
+ console.log(` ${chalk.bold("Port")} ${result.port}`);
413
+ console.log(` ${chalk.bold("Preview")} ${chalk.cyan(result.preview_url)}`);
414
+ console.log(` ${chalk.bold("Ready")} ${result.preview_ready ? chalk.green("yes") : chalk.yellow("not verified")}`);
415
+ console.log();
416
+ }));
417
+ sandbox
418
+ .command("preview <sandbox-id>")
419
+ .description("Expose a sandbox port and optionally wait for the public preview to answer")
420
+ .requiredOption("--port <port>", "Port inside the sandbox to expose", parseInt)
421
+ .option("--wait", "Wait until the public URL returns a good HTTP status")
422
+ .option("--timeout <sec>", "Wait timeout in seconds", parseInt, 120)
423
+ .option("--probe-path <path>", "HTTP path to probe", "/")
424
+ .option("--json", "Output as JSON")
425
+ .action((id, opts) => runAction(async () => {
426
+ const result = await previewSandbox(id, opts.port, {
427
+ wait: !!opts.wait,
428
+ timeout: opts.timeout,
429
+ probePath: opts.probePath,
430
+ });
431
+ if (opts.json) {
432
+ console.log(JSON.stringify(result, null, 2));
433
+ return;
434
+ }
435
+ console.log(result.url);
436
+ if (!result.ready) {
437
+ console.error(chalk.yellow(`Preview route created but not verified yet (${result.error ?? result.status ?? "pending"}).`));
438
+ }
439
+ }));
440
+ sandbox
441
+ .command("wait <sandbox-id>")
442
+ .description("Wait for sandbox VM, internal app port, edge route, TLS, and public preview readiness")
443
+ .requiredOption("--port <port>", "Port inside the sandbox to check", parseInt)
444
+ .option("--url", "Print only the ready public preview URL")
445
+ .option("--timeout <sec>", "Wait timeout in seconds", parseInt, 120)
446
+ .option("--probe-path <path>", "HTTP path to probe", "/")
447
+ .option("--json", "Output as JSON")
448
+ .action((id, opts) => runAction(async () => {
449
+ const result = await waitSandboxReady(id, opts.port, opts.probePath, opts.timeout);
450
+ if (opts.url && result.url) {
451
+ console.log(result.url);
452
+ return;
453
+ }
454
+ if (opts.json) {
455
+ console.log(JSON.stringify(result, null, 2));
456
+ return;
457
+ }
458
+ console.log(chalk.green("Ready"));
459
+ console.log(chalk.cyan(result.url));
460
+ }));
461
+ const service = sandbox
462
+ .command("service")
463
+ .description("Manage named long-running processes inside a sandbox");
464
+ service
465
+ .command("start <sandbox-id> <name>")
466
+ .requiredOption("--cmd <command>", "Command to start")
467
+ .option("--cwd <path>", "Working directory inside the sandbox", "/workspace")
468
+ .option("--port <port>", "Port served by this service", parseInt)
469
+ .option("--json", "Output as JSON")
470
+ .action((id, name, opts) => runAction(async () => {
471
+ const result = await startSandboxService(id, name, opts);
472
+ if (opts.json) {
473
+ console.log(JSON.stringify(result, null, 2));
474
+ return;
475
+ }
476
+ console.log(chalk.green(`Started ${name}`));
477
+ if (result.preview_url)
478
+ console.log(chalk.cyan(String(result.preview_url)));
479
+ }));
480
+ service
481
+ .command("restart <sandbox-id> <name>")
482
+ .requiredOption("--cmd <command>", "Command to start")
483
+ .option("--cwd <path>", "Working directory inside the sandbox", "/workspace")
484
+ .option("--port <port>", "Port served by this service", parseInt)
485
+ .option("--json", "Output as JSON")
486
+ .action((id, name, opts) => runAction(async () => {
487
+ await execSandbox(client(), id, `if [ -f ${shellQuote(servicePidPath(name))} ]; then kill $(cat ${shellQuote(servicePidPath(name))}) >/dev/null 2>&1 || true; fi`, "/");
488
+ const result = await startSandboxService(id, name, opts);
489
+ if (opts.json) {
490
+ console.log(JSON.stringify(result, null, 2));
491
+ return;
492
+ }
493
+ console.log(chalk.green(`Restarted ${name}`));
494
+ if (result.preview_url)
495
+ console.log(chalk.cyan(String(result.preview_url)));
496
+ }));
497
+ service
498
+ .command("status <sandbox-id> <name>")
499
+ .option("--json", "Output as JSON")
500
+ .action((id, name, opts) => runAction(async () => {
501
+ const result = await execSandbox(client(), id, `if [ -f ${shellQuote(servicePidPath(name))} ] && kill -0 $(cat ${shellQuote(servicePidPath(name))}) >/dev/null 2>&1; then echo running; else echo stopped; fi`, "/");
502
+ const status = String(result["stdout"] ?? "").trim() || "unknown";
503
+ if (opts.json) {
504
+ console.log(JSON.stringify({ sandbox_id: id, name, status }, null, 2));
505
+ return;
506
+ }
507
+ console.log(status === "running" ? chalk.green(status) : chalk.yellow(status));
508
+ }));
509
+ service
510
+ .command("logs <sandbox-id> <name>")
511
+ .option("--lines <n>", "Number of log lines", parseInt, 100)
512
+ .option("--json", "Output as JSON")
513
+ .action((id, name, opts) => runAction(async () => {
514
+ const result = await execSandbox(client(), id, `tail -n ${Number(opts.lines) || 100} ${shellQuote(serviceLogPath(name))}`, "/");
515
+ if (opts.json) {
516
+ console.log(JSON.stringify({ sandbox_id: id, name, logs: String(result["stdout"] ?? "") }, null, 2));
517
+ return;
518
+ }
519
+ process.stdout.write(String(result["stdout"] ?? ""));
520
+ }));
521
+ sandbox
522
+ .command("doctor <sandbox-id>")
523
+ .description("Diagnose sandbox app readiness across sandbox state, internal HTTP, public route, and TLS/edge reachability")
524
+ .requiredOption("--port <port>", "Port inside the sandbox to check", parseInt)
525
+ .option("--probe-path <path>", "HTTP path to probe", "/")
526
+ .option("--json", "Output as JSON")
527
+ .action((id, opts) => runAction(async () => {
528
+ const report = await doctorSandbox(id, opts.port, opts.probePath);
529
+ if (opts.json) {
530
+ console.log(JSON.stringify(report, null, 2));
531
+ return;
532
+ }
533
+ renderDoctorReport(report);
534
+ }));
342
535
  // write-file — POST /sandboxes/:id/files with base64 content.
343
536
  // If <content-or-file> is an existing local path, reads the file bytes;
344
537
  // otherwise treats the argument as literal UTF-8 text.
@@ -429,6 +622,82 @@ export function register(program) {
429
622
  handleError(err);
430
623
  }
431
624
  });
625
+ sandbox
626
+ .command("upload-dir <sandbox-id> <local-dir> <remote-dir>")
627
+ .description("Upload a local directory into a Sandbox")
628
+ .option("--delete", "Delete the remote directory contents before extracting")
629
+ .option("--json", "Output as JSON")
630
+ .action(async (id, localDir, remoteDir, opts) => {
631
+ try {
632
+ const result = await uploadDirToSandbox(id, localDir, remoteDir, {
633
+ delete: !!opts.delete,
634
+ });
635
+ if (opts.json) {
636
+ console.log(JSON.stringify(result, null, 2));
637
+ return;
638
+ }
639
+ console.log(chalk.green(`Uploaded ${result.files_label} → ${remoteDir}`));
640
+ }
641
+ catch (err) {
642
+ handleError(err);
643
+ }
644
+ });
645
+ sandbox
646
+ .command("sync <local-dir> <remote-dir>")
647
+ .description("Sync a local directory into a sandbox")
648
+ .requiredOption("--sandbox <id>", "Sandbox ID")
649
+ .option("--delete", "Delete the remote directory contents before extracting")
650
+ .option("--json", "Output as JSON")
651
+ .action(async (localDir, remoteDir, opts) => {
652
+ try {
653
+ const result = await uploadDirToSandbox(opts.sandbox, localDir, remoteDir, {
654
+ delete: !!opts.delete,
655
+ });
656
+ if (opts.json) {
657
+ console.log(JSON.stringify(result, null, 2));
658
+ return;
659
+ }
660
+ console.log(chalk.green(`Synced ${result.files_label} → ${remoteDir}`));
661
+ }
662
+ catch (err) {
663
+ handleError(err);
664
+ }
665
+ });
666
+ sandbox
667
+ .command("cp <source> <target>")
668
+ .description("Copy a local file or directory into a sandbox, e.g. ./app/. sbx_123:/workspace")
669
+ .option("--delete", "Delete the remote directory contents before extracting directories")
670
+ .option("--json", "Output as JSON")
671
+ .action(async (source, target, opts) => {
672
+ try {
673
+ const parsed = parseSandboxTarget(target);
674
+ const local = source.endsWith("/.") ? source.slice(0, -2) : source;
675
+ const stat = fs.statSync(local);
676
+ if (stat.isDirectory()) {
677
+ const result = await uploadDirToSandbox(parsed.sandboxId, local, parsed.remotePath, { delete: !!opts.delete });
678
+ if (opts.json) {
679
+ console.log(JSON.stringify(result, null, 2));
680
+ return;
681
+ }
682
+ console.log(chalk.green(`Copied ${result.files_label} → ${target}`));
683
+ return;
684
+ }
685
+ const c = client();
686
+ await uploadFileToSandbox(c, parsed.sandboxId, local, parsed.remotePath);
687
+ if (opts.json) {
688
+ console.log(JSON.stringify({
689
+ sandbox_id: parsed.sandboxId,
690
+ local_path: path.resolve(local),
691
+ remote_path: parsed.remotePath,
692
+ }, null, 2));
693
+ return;
694
+ }
695
+ console.log(chalk.green(`Copied ${path.basename(local)} → ${target}`));
696
+ }
697
+ catch (err) {
698
+ handleError(err);
699
+ }
700
+ });
432
701
  // download — GET /sandboxes/:id/files/:path; write to --output or stdout.
433
702
  sandbox
434
703
  .command("download <sandbox-id> <remote-path>")
@@ -510,10 +779,20 @@ export function register(program) {
510
779
  ].join("\n"))
511
780
  .requiredOption("--remote <port>", "Port inside the sandbox to reach", parseInt)
512
781
  .option("--local <port>", "Local port to listen on (default = remote port)", parseInt)
782
+ .option("--wait", "Probe the local forwarded URL before returning readiness")
783
+ .option("--timeout <sec>", "Wait timeout in seconds", parseInt, 30)
784
+ .option("--probe-path <path>", "HTTP path to probe when --wait is set", "/")
513
785
  .option("--json", "Output as JSON")
514
786
  .action(async (id, opts) => {
515
787
  try {
516
- await runSandboxPortForward(id, opts.remote, opts.local ?? opts.remote, !!opts.json);
788
+ await runSandboxPortForward(id, {
789
+ remotePort: opts.remote,
790
+ localPort: opts.local ?? opts.remote,
791
+ wait: !!opts.wait,
792
+ timeoutSec: opts.timeout,
793
+ probePath: opts.probePath,
794
+ json: !!opts.json,
795
+ });
517
796
  }
518
797
  catch (err) {
519
798
  handleError(err);
@@ -702,8 +981,7 @@ async function runSandboxSsh(id, opts) {
702
981
  server.close();
703
982
  process.exit(exitCode);
704
983
  }
705
- // ── sandbox port-forward implementation ───────────────────────────────────
706
- async function runSandboxPortForward(id, remotePort, localPort, json) {
984
+ async function runSandboxPortForward(id, opts) {
707
985
  const config = loadConfig();
708
986
  const apiKey = config.api_key;
709
987
  if (!apiKey)
@@ -711,7 +989,7 @@ async function runSandboxPortForward(id, remotePort, localPort, json) {
711
989
  const endpoint = config.endpoint ?? "https://api.miosa.ai";
712
990
  const base = endpoint.replace(/\/$/, "");
713
991
  const wsBase = base.replace(/^https?/, (p) => (p === "https" ? "wss" : "ws"));
714
- const wsUrl = `${wsBase}/api/v1/sandboxes/${encodeURIComponent(id)}/port-tunnel/${remotePort}`;
992
+ const wsUrl = `${wsBase}/api/v1/sandboxes/${encodeURIComponent(id)}/port-tunnel/${opts.remotePort}`;
715
993
  const stats = { active: 0, total: 0, bytesIn: 0, bytesOut: 0 };
716
994
  const server = createServer((socket) => {
717
995
  stats.active++;
@@ -748,27 +1026,34 @@ async function runSandboxPortForward(id, remotePort, localPort, json) {
748
1026
  await new Promise((resolve, reject) => {
749
1027
  server.on("error", (err) => {
750
1028
  if (err.code === "EADDRINUSE") {
751
- reject(new Error(`Port ${localPort} is already in use. Choose another with --local.`));
1029
+ reject(new Error(`Port ${opts.localPort} is already in use. Choose another with --local.`));
752
1030
  }
753
1031
  else {
754
1032
  reject(err);
755
1033
  }
756
1034
  });
757
- server.listen(localPort, "127.0.0.1", resolve);
1035
+ server.listen(opts.localPort, "127.0.0.1", resolve);
758
1036
  });
759
- if (json) {
1037
+ let localProbe = null;
1038
+ if (opts.wait) {
1039
+ localProbe = await waitForLocalHttp(opts.localPort, opts.probePath, opts.timeoutSec);
1040
+ }
1041
+ if (opts.json) {
760
1042
  console.log(JSON.stringify({
761
1043
  sandbox_id: id,
762
- remote_port: remotePort,
763
- local_port: localPort,
1044
+ remote_port: opts.remotePort,
1045
+ local_port: opts.localPort,
1046
+ ready: localProbe?.ok ?? !opts.wait,
1047
+ status: localProbe?.status ?? null,
1048
+ latency_ms: localProbe?.latency_ms ?? null,
764
1049
  }));
765
1050
  }
766
1051
  else {
767
- console.log(`${chalk.green("Forwarding")} ${chalk.cyan(`localhost:${localPort}`)} ${chalk.dim("→")} sandbox ${chalk.cyan(id)}:${chalk.bold(String(remotePort))}`);
1052
+ console.log(`${chalk.green(opts.wait ? "Forwarding ready" : "Forwarding")} ${chalk.cyan(`localhost:${opts.localPort}`)} ${chalk.dim("→")} sandbox ${chalk.cyan(id)}:${chalk.bold(String(opts.remotePort))}`);
768
1053
  console.log(chalk.dim("Press Ctrl+C to close.\n"));
769
1054
  }
770
1055
  const ticker = setInterval(() => {
771
- if (json || stats.active === 0)
1056
+ if (opts.json || stats.active === 0)
772
1057
  return;
773
1058
  process.stderr.write(chalk.dim(`\r[${new Date().toLocaleTimeString()}] connections: ${stats.active} ↑ ${formatBytes(stats.bytesIn)} ↓ ${formatBytes(stats.bytesOut)} `));
774
1059
  }, 5_000);
@@ -777,6 +1062,430 @@ async function runSandboxPortForward(id, remotePort, localPort, json) {
777
1062
  process.stderr.write("\n");
778
1063
  server.close();
779
1064
  }
1065
+ async function showSandboxWithPreview(sandboxId, port, probePath) {
1066
+ const c = client();
1067
+ const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
1068
+ let preview = null;
1069
+ try {
1070
+ preview = await previewSandbox(sandboxId, port, {
1071
+ wait: false,
1072
+ timeout: 1,
1073
+ probePath,
1074
+ });
1075
+ }
1076
+ catch (err) {
1077
+ preview = {
1078
+ url: "",
1079
+ ready: false,
1080
+ status: null,
1081
+ latency_ms: null,
1082
+ error: err instanceof Error ? err.message : String(err),
1083
+ };
1084
+ }
1085
+ return {
1086
+ ...sandbox,
1087
+ ready: preview?.ready ?? false,
1088
+ preview: {
1089
+ url: preview?.url || null,
1090
+ port,
1091
+ route_ready: Boolean(preview?.url),
1092
+ tls_ready: preview?.ready ?? false,
1093
+ last_status: preview?.status ?? null,
1094
+ latency_ms: preview?.latency_ms ?? null,
1095
+ error: preview?.error,
1096
+ },
1097
+ };
1098
+ }
1099
+ async function previewSandbox(sandboxId, port, opts) {
1100
+ const c = client();
1101
+ const exposed = unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), {
1102
+ port,
1103
+ title: "app preview",
1104
+ }));
1105
+ const url = extractUrl(exposed);
1106
+ if (!url)
1107
+ throw new UserError("Sandbox expose did not return a preview URL.");
1108
+ const edge = opts.wait
1109
+ ? await waitForPublicPreview(url, opts.probePath, opts.timeout)
1110
+ : await probePublicPreview(url, opts.probePath);
1111
+ return {
1112
+ url,
1113
+ ready: edge.ok,
1114
+ status: edge.status,
1115
+ latency_ms: edge.latency_ms ?? null,
1116
+ error: edge.error,
1117
+ };
1118
+ }
1119
+ async function waitSandboxReady(sandboxId, port, probePath, timeoutSec) {
1120
+ const c = client();
1121
+ await waitForSandboxRunning(c, sandboxId, Math.min(timeoutSec, 120));
1122
+ const internal = await waitForInternalHttp(c, sandboxId, port, probePath, Math.min(timeoutSec, 60));
1123
+ const preview = await previewSandbox(sandboxId, port, {
1124
+ wait: true,
1125
+ timeout: timeoutSec,
1126
+ probePath,
1127
+ });
1128
+ return {
1129
+ sandbox_id: sandboxId,
1130
+ port,
1131
+ internal_status: internal.status,
1132
+ ...preview,
1133
+ };
1134
+ }
1135
+ async function startSandboxService(sandboxId, name, opts) {
1136
+ validateServiceName(name);
1137
+ const c = client();
1138
+ const logPath = serviceLogPath(name);
1139
+ const pidPath = servicePidPath(name);
1140
+ const command = [
1141
+ "mkdir -p /tmp/miosa-services",
1142
+ `if [ -f ${shellQuote(pidPath)} ]; then kill $(cat ${shellQuote(pidPath)}) >/dev/null 2>&1 || true; fi`,
1143
+ `nohup sh -lc ${shellQuote(opts.cmd)} > ${shellQuote(logPath)} 2>&1 & echo $! > ${shellQuote(pidPath)}`,
1144
+ `cat ${shellQuote(pidPath)}`,
1145
+ ].join(" && ");
1146
+ const exec = await execSandbox(c, sandboxId, command, opts.cwd, 15);
1147
+ const pid = String(exec["stdout"] ?? "").trim().split(/\s+/).pop() ?? "";
1148
+ let previewUrl = null;
1149
+ if (opts.port != null) {
1150
+ const preview = await previewSandbox(sandboxId, opts.port, {
1151
+ wait: false,
1152
+ timeout: 1,
1153
+ probePath: "/",
1154
+ });
1155
+ previewUrl = preview.url;
1156
+ }
1157
+ return {
1158
+ sandbox_id: sandboxId,
1159
+ name,
1160
+ pid,
1161
+ cwd: opts.cwd,
1162
+ command: opts.cmd,
1163
+ port: opts.port ?? null,
1164
+ log_path: logPath,
1165
+ preview_url: previewUrl,
1166
+ };
1167
+ }
1168
+ function serviceLogPath(name) {
1169
+ return `/tmp/miosa-services/${name}.log`;
1170
+ }
1171
+ function servicePidPath(name) {
1172
+ return `/tmp/miosa-services/${name}.pid`;
1173
+ }
1174
+ function validateServiceName(name) {
1175
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
1176
+ throw new UserError(`Invalid service name: ${name}`, "Use only letters, numbers, dot, dash, and underscore.");
1177
+ }
1178
+ }
1179
+ async function deploySandbox(localDir, opts) {
1180
+ const sourceDir = path.resolve(localDir);
1181
+ if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
1182
+ throw new UserError(`Local directory not found: ${sourceDir}`);
1183
+ }
1184
+ const c = client();
1185
+ const detection = detectFramework(sourceDir);
1186
+ const port = opts.port ?? detection?.port ?? 5173;
1187
+ const start = opts.start ?? defaultStartCommand(detection?.framework, port);
1188
+ const installCommand = opts.install === false
1189
+ ? null
1190
+ : opts.installCommand ?? defaultInstallCommand(sourceDir);
1191
+ const sandboxId = opts.sandbox ??
1192
+ (await createSandboxForDeploy(c, opts.template ?? "miosa-sandbox", opts.name));
1193
+ await waitForSandboxRunning(c, sandboxId, Math.min(opts.timeout, 120));
1194
+ const archivePath = createDeployArchive(sourceDir);
1195
+ const remoteArchive = `/tmp/miosa-deploy-${Date.now()}.tgz`;
1196
+ try {
1197
+ await uploadFileToSandbox(c, sandboxId, archivePath, remoteArchive);
1198
+ }
1199
+ finally {
1200
+ fs.rmSync(archivePath, { force: true });
1201
+ }
1202
+ await execSandbox(c, sandboxId, `mkdir -p /workspace && tar -xzf ${shellQuote(remoteArchive)} -C /workspace`, "/");
1203
+ if (installCommand) {
1204
+ await execSandbox(c, sandboxId, installCommand, "/workspace", opts.timeout);
1205
+ }
1206
+ await execSandbox(c, sandboxId, `fuser -k ${port}/tcp >/dev/null 2>&1 || true; nohup sh -lc ${shellQuote(start)} > ${shellQuote(`/tmp/miosa-app-${port}.log`)} 2>&1 & echo $!`, "/workspace");
1207
+ const internal = await waitForInternalHttp(c, sandboxId, port, opts.probePath, Math.min(opts.timeout, 60));
1208
+ const exposed = await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), { port, title: "app preview" });
1209
+ const previewUrl = extractUrl(unwrap(exposed));
1210
+ if (!previewUrl) {
1211
+ throw new UserError("Sandbox expose did not return a preview URL.");
1212
+ }
1213
+ const edge = opts.wait
1214
+ ? await waitForPublicPreview(previewUrl, opts.probePath, opts.timeout)
1215
+ : { ok: false, status: null };
1216
+ return {
1217
+ sandbox_id: sandboxId,
1218
+ port,
1219
+ preview_url: previewUrl,
1220
+ preview_ready: edge.ok,
1221
+ internal_status: internal.status,
1222
+ edge_status: edge.status,
1223
+ latency_ms: edge.latency_ms ?? null,
1224
+ };
1225
+ }
1226
+ async function doctorSandbox(sandboxId, port, probePath) {
1227
+ const c = client();
1228
+ const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
1229
+ const internal = await probeInternalHttp(c, sandboxId, port, probePath);
1230
+ let exposeData = {};
1231
+ try {
1232
+ exposeData = unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), {
1233
+ port,
1234
+ title: "doctor probe",
1235
+ }));
1236
+ }
1237
+ catch (err) {
1238
+ exposeData = { error: err instanceof Error ? err.message : String(err) };
1239
+ }
1240
+ const previewUrl = extractUrl(exposeData);
1241
+ const edge = previewUrl
1242
+ ? await probePublicPreview(previewUrl, probePath)
1243
+ : { ok: false, status: null, error: "No preview URL returned" };
1244
+ return {
1245
+ sandbox_id: sandboxId,
1246
+ sandbox_state: sandbox["state"] ?? sandbox["status"] ?? "unknown",
1247
+ process: internal.ok ? "listening" : "not_ready",
1248
+ route: previewUrl ? "created" : "missing",
1249
+ tls: edge.error && /tls|certificate|ssl/i.test(edge.error) ? "not_ready" : previewUrl ? "checked" : "unknown",
1250
+ internal_probe: internal,
1251
+ edge_probe: edge,
1252
+ preview_ready: edge.ok,
1253
+ preview_url: previewUrl,
1254
+ expose: exposeData,
1255
+ };
1256
+ }
1257
+ function renderDoctorReport(report) {
1258
+ console.log();
1259
+ console.log(chalk.bold("Sandbox Doctor"));
1260
+ console.log();
1261
+ console.log(` ${chalk.bold("Sandbox")} ${report["sandbox_id"]}`);
1262
+ console.log(` ${chalk.bold("State")} ${report["sandbox_state"]}`);
1263
+ console.log(` ${chalk.bold("Process")} ${report["process"]}`);
1264
+ console.log(` ${chalk.bold("Route")} ${report["route"]}`);
1265
+ console.log(` ${chalk.bold("TLS/edge")} ${report["tls"]}`);
1266
+ console.log(` ${chalk.bold("Preview ready")} ${report["preview_ready"] ? chalk.green("yes") : chalk.red("no")}`);
1267
+ if (report["preview_url"]) {
1268
+ console.log(` ${chalk.bold("Preview URL")} ${chalk.cyan(String(report["preview_url"]))}`);
1269
+ }
1270
+ console.log();
1271
+ if (!report["preview_ready"]) {
1272
+ console.log(chalk.yellow(" Preview is not externally ready yet. Internal app health and edge probe details are in --json output."));
1273
+ console.log();
1274
+ }
1275
+ }
1276
+ async function createSandboxForDeploy(c, template, name) {
1277
+ const body = { template_id: template };
1278
+ if (name)
1279
+ body["name"] = name;
1280
+ const created = unwrap(await c.apiPost(apiPath("/sandboxes"), body));
1281
+ const sandboxId = typeof created["id"] === "string" ? created["id"] : "";
1282
+ if (!sandboxId)
1283
+ throw new UserError("Sandbox create did not return an id.");
1284
+ return sandboxId;
1285
+ }
1286
+ async function waitForSandboxRunning(c, sandboxId, timeoutSec) {
1287
+ const deadline = Date.now() + timeoutSec * 1000;
1288
+ while (Date.now() < deadline) {
1289
+ const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
1290
+ const state = String(sandbox["state"] ?? sandbox["status"] ?? "").toLowerCase();
1291
+ if (state === "running" || state === "active")
1292
+ return;
1293
+ if (state === "error" || state === "failed") {
1294
+ throw new UserError(`Sandbox ${sandboxId} entered ${state} state.`);
1295
+ }
1296
+ await sleep(1500);
1297
+ }
1298
+ throw new UserError(`Sandbox ${sandboxId} did not become running within ${timeoutSec}s.`);
1299
+ }
1300
+ function createDeployArchive(sourceDir) {
1301
+ const archivePath = path.join(os.tmpdir(), `miosa-deploy-${process.pid}-${Date.now()}.tgz`);
1302
+ const result = spawnSync("tar", [
1303
+ "--exclude",
1304
+ ".git",
1305
+ "--exclude",
1306
+ "node_modules",
1307
+ "--exclude",
1308
+ ".next",
1309
+ "--exclude",
1310
+ "dist",
1311
+ "-czf",
1312
+ archivePath,
1313
+ "-C",
1314
+ sourceDir,
1315
+ ".",
1316
+ ], { stdio: "pipe" });
1317
+ if (result.status !== 0) {
1318
+ throw new UserError(`Could not archive ${sourceDir}: ${result.stderr.toString().trim() || "tar failed"}`);
1319
+ }
1320
+ return archivePath;
1321
+ }
1322
+ async function uploadFileToSandbox(c, sandboxId, localPath, remotePath) {
1323
+ await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/files`), {
1324
+ path: remotePath,
1325
+ content: fs.readFileSync(localPath).toString("base64"),
1326
+ });
1327
+ }
1328
+ async function uploadDirToSandbox(sandboxId, localDir, remoteDir, opts) {
1329
+ const sourceDir = path.resolve(localDir);
1330
+ if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
1331
+ throw new UserError(`Local directory not found: ${sourceDir}`);
1332
+ }
1333
+ const c = client();
1334
+ const archivePath = createDeployArchive(sourceDir);
1335
+ const remoteArchive = `/tmp/miosa-upload-${Date.now()}.tgz`;
1336
+ try {
1337
+ await uploadFileToSandbox(c, sandboxId, archivePath, remoteArchive);
1338
+ const clean = opts.delete
1339
+ ? `rm -rf ${shellQuote(remoteDir)} && mkdir -p ${shellQuote(remoteDir)}`
1340
+ : `mkdir -p ${shellQuote(remoteDir)}`;
1341
+ await execSandbox(c, sandboxId, `${clean} && tar -xzf ${shellQuote(remoteArchive)} -C ${shellQuote(remoteDir)} && rm -f ${shellQuote(remoteArchive)}`, "/");
1342
+ }
1343
+ finally {
1344
+ fs.rmSync(archivePath, { force: true });
1345
+ }
1346
+ return {
1347
+ sandbox_id: sandboxId,
1348
+ local_dir: sourceDir,
1349
+ remote_dir: remoteDir,
1350
+ files_label: path.basename(sourceDir) || sourceDir,
1351
+ };
1352
+ }
1353
+ async function execSandbox(c, sandboxId, command, cwd, timeout) {
1354
+ const body = { command: commandInCwd(command, cwd) };
1355
+ if (cwd) {
1356
+ body["cwd"] = cwd;
1357
+ body["dir"] = cwd;
1358
+ }
1359
+ if (timeout != null)
1360
+ body["timeout"] = timeout;
1361
+ const result = unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/exec`), body));
1362
+ const exitCode = Number(result["exit_code"] ?? 0);
1363
+ if (exitCode !== 0) {
1364
+ throw new UserError(`Sandbox command failed with exit code ${exitCode}: ${command}`, String(result["stderr"] ?? result["stdout"] ?? ""));
1365
+ }
1366
+ return result;
1367
+ }
1368
+ async function waitForInternalHttp(c, sandboxId, port, probePath, timeoutSec) {
1369
+ const deadline = Date.now() + timeoutSec * 1000;
1370
+ let last = { ok: false, status: null };
1371
+ while (Date.now() < deadline) {
1372
+ last = await probeInternalHttp(c, sandboxId, port, probePath);
1373
+ if (last.ok)
1374
+ return last;
1375
+ await sleep(1500);
1376
+ }
1377
+ throw new UserError(`App did not answer inside the sandbox on port ${port} within ${timeoutSec}s.`, last.error);
1378
+ }
1379
+ async function probeInternalHttp(c, sandboxId, port, probePath) {
1380
+ try {
1381
+ const result = await execSandbox(c, sandboxId, `python3 - <<'PY'\nimport urllib.request, sys\nurl = 'http://127.0.0.1:${port}${probePath.startsWith("/") ? probePath : `/${probePath}`}'\ntry:\n r = urllib.request.urlopen(url, timeout=3)\n print(r.status)\n sys.exit(0 if 200 <= r.status < 400 else 1)\nexcept Exception as e:\n print(e)\n sys.exit(1)\nPY`, "/", 10);
1382
+ const status = Number(String(result["stdout"] ?? "").trim().split(/\s+/)[0]);
1383
+ return { ok: Number.isFinite(status) && status >= 200 && status < 400, status };
1384
+ }
1385
+ catch (err) {
1386
+ return { ok: false, status: null, error: err instanceof Error ? err.message : String(err) };
1387
+ }
1388
+ }
1389
+ async function waitForPublicPreview(previewUrl, probePath, timeoutSec) {
1390
+ const deadline = Date.now() + timeoutSec * 1000;
1391
+ let last = { ok: false, status: null };
1392
+ while (Date.now() < deadline) {
1393
+ last = await probePublicPreview(previewUrl, probePath);
1394
+ if (last.ok)
1395
+ return last;
1396
+ await sleep(2000);
1397
+ }
1398
+ throw new UserError(`Preview URL was created but did not become publicly ready within ${timeoutSec}s.`, last.error ?? (last.status ? `Last HTTP status: ${last.status}` : undefined));
1399
+ }
1400
+ async function probePublicPreview(previewUrl, probePath) {
1401
+ try {
1402
+ const url = new URL(previewUrl);
1403
+ url.pathname = joinUrlPath(url.pathname, probePath);
1404
+ const t0 = Date.now();
1405
+ const res = await fetch(url, { method: "GET", redirect: "manual" });
1406
+ return {
1407
+ ok: (res.status >= 200 && res.status < 400) || res.status === 401 || res.status === 403,
1408
+ status: res.status,
1409
+ latency_ms: Date.now() - t0,
1410
+ };
1411
+ }
1412
+ catch (err) {
1413
+ return { ok: false, status: null, error: err instanceof Error ? err.message : String(err) };
1414
+ }
1415
+ }
1416
+ async function waitForLocalHttp(localPort, probePath, timeoutSec) {
1417
+ const deadline = Date.now() + timeoutSec * 1000;
1418
+ let last = { ok: false, status: null };
1419
+ const url = `http://127.0.0.1:${localPort}${probePath.startsWith("/") ? probePath : `/${probePath}`}`;
1420
+ while (Date.now() < deadline) {
1421
+ last = await probePublicPreview(url, "/");
1422
+ if (last.ok)
1423
+ return last;
1424
+ await sleep(1000);
1425
+ }
1426
+ throw new UserError(`Local forwarded URL did not answer within ${timeoutSec}s.`, last.error ?? (last.status ? `Last HTTP status: ${last.status}` : undefined));
1427
+ }
1428
+ function defaultInstallCommand(sourceDir) {
1429
+ if (fs.existsSync(path.join(sourceDir, "package.json")))
1430
+ return "npm install";
1431
+ if (fs.existsSync(path.join(sourceDir, "requirements.txt")))
1432
+ return "pip install -r requirements.txt";
1433
+ return null;
1434
+ }
1435
+ function defaultStartCommand(framework, port) {
1436
+ if (framework === "nextjs")
1437
+ return `npm run dev -- -H 0.0.0.0 -p ${port}`;
1438
+ if (framework === "vite-react")
1439
+ return `npm run dev -- --host 0.0.0.0 --port ${port}`;
1440
+ if (framework === "static")
1441
+ return `python3 -m http.server ${port} --bind 0.0.0.0`;
1442
+ return `npm run dev -- --host 0.0.0.0 --port ${port}`;
1443
+ }
1444
+ function extractUrl(value) {
1445
+ if (!value || typeof value !== "object" || Array.isArray(value))
1446
+ return null;
1447
+ const row = value;
1448
+ for (const key of ["url", "preview_url", "public_url"]) {
1449
+ if (typeof row[key] === "string" && row[key])
1450
+ return row[key];
1451
+ }
1452
+ return null;
1453
+ }
1454
+ function parseSandboxTarget(target) {
1455
+ const idx = target.indexOf(":");
1456
+ if (idx <= 0 || idx === target.length - 1) {
1457
+ throw new UserError(`Invalid sandbox target: ${target}`, "Use the form <sandbox-id>:/absolute/path");
1458
+ }
1459
+ const sandboxId = target.slice(0, idx);
1460
+ const remotePath = target.slice(idx + 1);
1461
+ if (!remotePath.startsWith("/")) {
1462
+ throw new UserError(`Invalid remote path: ${remotePath}`, "Sandbox copy targets must use an absolute path, e.g. sbx_123:/workspace");
1463
+ }
1464
+ return { sandboxId, remotePath };
1465
+ }
1466
+ function commandInCwd(command, cwd) {
1467
+ if (!cwd)
1468
+ return command;
1469
+ return `cd ${shellQuote(cwd)} && ${command}`;
1470
+ }
1471
+ function backgroundCommand(command) {
1472
+ if (!command.trim())
1473
+ return command;
1474
+ const logPath = `/tmp/miosa-bg-${Date.now()}.log`;
1475
+ return `nohup sh -lc ${shellQuote(command)} > ${shellQuote(logPath)} 2>&1 & echo $!`;
1476
+ }
1477
+ function shellQuote(value) {
1478
+ return `'${value.replace(/'/g, "'\\''")}'`;
1479
+ }
1480
+ function joinUrlPath(basePath, probePath) {
1481
+ const probe = probePath.startsWith("/") ? probePath : `/${probePath}`;
1482
+ if (!basePath || basePath === "/")
1483
+ return probe;
1484
+ return `${basePath.replace(/\/$/, "")}${probe}`;
1485
+ }
1486
+ function sleep(ms) {
1487
+ return new Promise((resolve) => setTimeout(resolve, ms));
1488
+ }
780
1489
  // ── shared helpers ─────────────────────────────────────────────────────────
781
1490
  function pickFreePort() {
782
1491
  return new Promise((resolve, reject) => {