@miosa/cli 1.0.37 → 1.0.39

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.
@@ -372,12 +372,16 @@ export function register(program) {
372
372
  // exec — positional command arg; --data body overrides when supplied
373
373
  addDataOption(sandbox
374
374
  .command("exec <sandbox-id> [command...]")
375
- .description("Run a command inside a Sandbox (positional args joined as shell command)")
375
+ .description('Run a command inside a Sandbox (positional args joined as shell command). Use `--` before the command to pass flags, e.g. `sandbox exec <id> -- bash -c "cd x && y"`.')
376
+ .allowUnknownOption()
377
+ .allowExcessArguments()
376
378
  .option("--cwd <path>", "Working directory inside the Sandbox")
377
379
  .option("--workdir <path>", "Alias for --cwd")
378
380
  .option("--env <pair>", "Environment variable KEY=VALUE. Repeatable.", collectOption, [])
379
381
  .option("--background", "Start the command in the background and return immediately")
380
382
  .option("--detached", "Create a durable backend command and return command_id immediately")
383
+ .option("--follow", "Stream command output until it exits (alias --stream)")
384
+ .option("--stream", "Alias for --follow")
381
385
  .option("--user <user>", "Run command as user")
382
386
  .option("--sudo", "Run command through sudo")
383
387
  .option("--tty", "Request TTY metadata for command resource")
@@ -385,11 +389,12 @@ export function register(program) {
385
389
  .option("--timeout <sec>", "Exec timeout in seconds", parseIntegerOption))
386
390
  .option("--json", "Output as JSON")
387
391
  .action((id, words, opts) => runAction(async () => {
392
+ opts.follow = opts.follow || opts.stream;
388
393
  if (opts.data) {
389
394
  await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, {});
390
395
  return;
391
396
  }
392
- const cmd = words.join(" ");
397
+ const cmd = joinCommandWords(words);
393
398
  const effectiveCommand = opts.background
394
399
  ? backgroundCommand(cmd)
395
400
  : cmd;
@@ -403,7 +408,7 @@ export function register(program) {
403
408
  }
404
409
  const env = parseEnvPairs(opts.env ?? []);
405
410
  if (opts.detached) {
406
- const result = await createSandboxCommand(id, cmd, {
411
+ const result = await createSandboxCommand(id, words.join(" "), {
407
412
  cwd,
408
413
  env,
409
414
  user: opts.user,
@@ -419,6 +424,10 @@ export function register(program) {
419
424
  console.log(String(result["id"] ?? result["command_id"] ?? ""));
420
425
  return;
421
426
  }
427
+ if (opts.follow) {
428
+ await runFollowExec(id, cmd, cwd, env, opts.timeout, opts);
429
+ return;
430
+ }
422
431
  if (Object.keys(env).length > 0)
423
432
  body["env"] = env;
424
433
  if (opts.timeout != null)
@@ -428,12 +437,16 @@ export function register(program) {
428
437
  // run — alias for exec with identical semantics
429
438
  addDataOption(sandbox
430
439
  .command("run <sandbox-id> [command...]")
431
- .description("Run a command inside a Sandbox (alias for exec)")
440
+ .description("Run a command inside a Sandbox (alias for exec). Use `--` before the command to pass flags.")
441
+ .allowUnknownOption()
442
+ .allowExcessArguments()
432
443
  .option("--cwd <path>", "Working directory inside the Sandbox")
433
444
  .option("--workdir <path>", "Alias for --cwd")
434
445
  .option("--env <pair>", "Environment variable KEY=VALUE. Repeatable.", collectOption, [])
435
446
  .option("--background", "Start the command in the background and return immediately")
436
447
  .option("--detached", "Create a durable backend command and return command_id immediately")
448
+ .option("--follow", "Stream command output until it exits (alias --stream)")
449
+ .option("--stream", "Alias for --follow")
437
450
  .option("--user <user>", "Run command as user")
438
451
  .option("--sudo", "Run command through sudo")
439
452
  .option("--tty", "Request TTY metadata for command resource")
@@ -441,11 +454,12 @@ export function register(program) {
441
454
  .option("--timeout <sec>", "Exec timeout in seconds", parseIntegerOption))
442
455
  .option("--json", "Output as JSON")
443
456
  .action((id, words, opts) => runAction(async () => {
457
+ opts.follow = opts.follow || opts.stream;
444
458
  if (opts.data) {
445
459
  await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, {});
446
460
  return;
447
461
  }
448
- const cmd = words.join(" ");
462
+ const cmd = joinCommandWords(words);
449
463
  const effectiveCommand = opts.background
450
464
  ? backgroundCommand(cmd)
451
465
  : cmd;
@@ -459,7 +473,7 @@ export function register(program) {
459
473
  }
460
474
  const env = parseEnvPairs(opts.env ?? []);
461
475
  if (opts.detached) {
462
- const result = await createSandboxCommand(id, cmd, {
476
+ const result = await createSandboxCommand(id, words.join(" "), {
463
477
  cwd,
464
478
  env,
465
479
  user: opts.user,
@@ -475,12 +489,46 @@ export function register(program) {
475
489
  console.log(String(result["id"] ?? result["command_id"] ?? ""));
476
490
  return;
477
491
  }
492
+ if (opts.follow) {
493
+ await runFollowExec(id, cmd, cwd, env, opts.timeout, opts);
494
+ return;
495
+ }
478
496
  if (Object.keys(env).length > 0)
479
497
  body["env"] = env;
480
498
  if (opts.timeout != null)
481
499
  body["timeout"] = opts.timeout;
482
500
  await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
483
501
  }));
502
+ sandbox
503
+ .command("ports <sandbox-id>")
504
+ .description("List listening TCP ports inside a Sandbox")
505
+ .option("--json", "Output as JSON")
506
+ .action((id, opts) => runAction(async () => {
507
+ const c = client();
508
+ const result = await execSandboxRaw(c, id, "ss -ltnH 2>/dev/null || cat /proc/net/tcp /proc/net/tcp6 2>/dev/null");
509
+ const stdout = String(result["stdout"] ?? "");
510
+ const ports = parseListeningPorts(stdout);
511
+ if (isJsonMode(opts)) {
512
+ console.log(JSON.stringify(ports, null, 2));
513
+ return;
514
+ }
515
+ if (ports.length === 0) {
516
+ console.log(chalk.dim("(no listening ports)"));
517
+ return;
518
+ }
519
+ renderTable(ports, [
520
+ {
521
+ header: "PORT",
522
+ key: "port",
523
+ width: 8,
524
+ },
525
+ {
526
+ header: "ADDRESS",
527
+ key: "address",
528
+ width: 24,
529
+ },
530
+ ]);
531
+ }));
484
532
  sandbox
485
533
  .command("deploy [local-dir]")
486
534
  .description("Upload an app directory, start it in a sandbox, expose a preview URL, and wait for readiness")
@@ -595,7 +643,8 @@ export function register(program) {
595
643
  .option("--database <mode>", "none, create:postgres, postgres, or existing:<db-id>")
596
644
  .option("--port <port>", "Runtime port for dynamic deployments", parseIntegerOption)
597
645
  .option("--wait", "Wait for the production URL to answer")
598
- .option("--timeout <duration>", "Wait timeout", parseDurationSec, 180)
646
+ .option("--no-wait", "Return immediately without waiting (this is the default)")
647
+ .option("--timeout <duration>", "Wait timeout", parseDurationSec, 600)
599
648
  .option("--json", "Output as JSON")
600
649
  .action((id, opts) => runAction(async () => {
601
650
  const result = await publishSandbox(id, opts);
@@ -740,15 +789,29 @@ export function register(program) {
740
789
  console.log(JSON.stringify(result, null, 2));
741
790
  return;
742
791
  }
743
- const rows = Array.isArray(result) ? result : [];
792
+ const rows = Array.isArray(result)
793
+ ? result
794
+ : [];
744
795
  if (rows.length === 0) {
745
796
  console.log(chalk.dim("No sandbox env vars."));
746
797
  return;
747
798
  }
748
799
  renderTable(rows, [
749
- { header: "NAME", key: "name", width: 32 },
750
- { header: "VALUE", key: "preview", width: 24 },
751
- { header: "UPDATED", key: "updated_at", width: 28 },
800
+ {
801
+ header: "NAME",
802
+ key: "name",
803
+ width: 32,
804
+ },
805
+ {
806
+ header: "VALUE",
807
+ key: "preview",
808
+ width: 24,
809
+ },
810
+ {
811
+ header: "UPDATED",
812
+ key: "updated_at",
813
+ width: 28,
814
+ },
752
815
  ]);
753
816
  }));
754
817
  env
@@ -1554,13 +1617,20 @@ function validateServiceName(name) {
1554
1617
  async function deploySandbox(localDir, opts) {
1555
1618
  const sourceDir = path.resolve(localDir);
1556
1619
  const sourceBacked = !!opts.source;
1557
- if (!sourceBacked && (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory())) {
1620
+ if (!sourceBacked &&
1621
+ (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory())) {
1558
1622
  throw new UserError(`Local directory not found: ${sourceDir}`);
1559
1623
  }
1560
1624
  const c = client();
1561
- let appManifest = sourceBacked ? null : loadAppManifest(sourceDir)?.manifest ?? null;
1625
+ let appManifest = sourceBacked
1626
+ ? null
1627
+ : (loadAppManifest(sourceDir)?.manifest ?? null);
1562
1628
  const detection = sourceBacked ? null : detectFramework(sourceDir);
1563
- const port = opts.port ?? opts.publishPort ?? manifestPort(appManifest) ?? detection?.port ?? 5173;
1629
+ const port = opts.port ??
1630
+ opts.publishPort ??
1631
+ manifestPort(appManifest) ??
1632
+ detection?.port ??
1633
+ 5173;
1564
1634
  const probePath = opts.probePath ?? manifestProbePath(appManifest) ?? "/";
1565
1635
  let remoteWorkdir = normalizeRemoteWorkdir(appManifest?.workdir ?? "/workspace");
1566
1636
  const start = opts.start ??
@@ -1595,9 +1665,7 @@ async function deploySandbox(localDir, opts) {
1595
1665
  }
1596
1666
  const resolvedPort = opts.port ?? opts.publishPort ?? manifestPort(appManifest) ?? port;
1597
1667
  const resolvedProbePath = opts.probePath ?? manifestProbePath(appManifest) ?? probePath;
1598
- const resolvedStart = opts.start ??
1599
- manifestStartCommand(appManifest) ??
1600
- start;
1668
+ const resolvedStart = opts.start ?? manifestStartCommand(appManifest) ?? start;
1601
1669
  const installCommand = opts.install === false
1602
1670
  ? null
1603
1671
  : (opts.installCommand ??
@@ -1632,6 +1700,41 @@ async function deploySandbox(localDir, opts) {
1632
1700
  latency_ms: edge.latency_ms ?? null,
1633
1701
  };
1634
1702
  }
1703
+ const NON_TERMINAL_DEPLOY_STATES = new Set([
1704
+ "building",
1705
+ "pending",
1706
+ "queued",
1707
+ "deploying",
1708
+ ]);
1709
+ // Bug 9: look for an existing non-terminal deployment with a matching name so
1710
+ // retries attach a new release instead of creating a duplicate app. Defensive:
1711
+ // returns null (fall back to create) if the list call fails or finds nothing.
1712
+ async function findExistingDeploymentByName(c, name) {
1713
+ try {
1714
+ const raw = unwrap(await c.apiGet(apiPath("/deployments")));
1715
+ const items = Array.isArray(raw)
1716
+ ? raw
1717
+ : Array.isArray((asRecord(raw) ?? {})["data"])
1718
+ ? (asRecord(raw) ?? {})["data"]
1719
+ : [];
1720
+ for (const item of items) {
1721
+ const rec = asRecord(item);
1722
+ if (!rec)
1723
+ continue;
1724
+ if (stringField(rec, "name") !== name)
1725
+ continue;
1726
+ const state = (stringField(rec, "state") ?? "").toLowerCase();
1727
+ const id = stringField(rec, "id");
1728
+ if (id && NON_TERMINAL_DEPLOY_STATES.has(state)) {
1729
+ return { id, state };
1730
+ }
1731
+ }
1732
+ return null;
1733
+ }
1734
+ catch {
1735
+ return null;
1736
+ }
1737
+ }
1635
1738
  async function publishSandbox(sandboxId, opts) {
1636
1739
  const c = client();
1637
1740
  const body = {
@@ -1642,6 +1745,16 @@ async function publishSandbox(sandboxId, opts) {
1642
1745
  };
1643
1746
  if (opts.app)
1644
1747
  body["deployment_id"] = opts.app;
1748
+ // Bug 9: avoid creating a duplicate deployment on retry. When publishing by
1749
+ // name (no explicit app id), reuse an existing non-terminal deployment with
1750
+ // the same name by attaching a new release to it.
1751
+ if (opts.name && !opts.app) {
1752
+ const existing = await findExistingDeploymentByName(c, opts.name);
1753
+ if (existing) {
1754
+ body["deployment_id"] = existing.id;
1755
+ deployStep(opts, chalk.dim(`Attaching to existing deployment ${existing.id} (${existing.state})`));
1756
+ }
1757
+ }
1645
1758
  if (opts.name)
1646
1759
  body["name"] = opts.name;
1647
1760
  if (opts.slug)
@@ -1724,7 +1837,8 @@ async function waitForDeploymentReady(c, deploymentId, timeoutSec) {
1724
1837
  }
1725
1838
  await sleep(2000);
1726
1839
  }
1727
- throw new UserError(`Deployment ${deploymentId} did not become ready within ${timeoutSec}s.`, last ? `Last state: ${String(last["state"] ?? "unknown")}` : undefined);
1840
+ const lastState = last ? String(last["state"] ?? "unknown") : "unknown";
1841
+ throw new UserError(`Deployment still building after ${timeoutSec}s — it may still finish. Re-check with \`miosa sandbox show ${deploymentId}\` or \`miosa deploy logs\`.`, `Last state: ${lastState}`);
1728
1842
  }
1729
1843
  function parsePublishDatabase(value) {
1730
1844
  if (!value)
@@ -1820,7 +1934,11 @@ async function createSandboxForDeploy(c, template, name, source) {
1820
1934
  async function readRemoteAppManifest(c, sandboxId, timeoutSec) {
1821
1935
  const deadline = Date.now() + Math.min(timeoutSec, 120) * 1000;
1822
1936
  while (Date.now() < deadline) {
1823
- for (const filename of ["miosa.app.yml", "miosa.app.yaml", "miosa.app.json"]) {
1937
+ for (const filename of [
1938
+ "miosa.app.yml",
1939
+ "miosa.app.yaml",
1940
+ "miosa.app.json",
1941
+ ]) {
1824
1942
  const result = await c
1825
1943
  .apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/exec`), {
1826
1944
  command: `test -f /workspace/${filename} && cat /workspace/${filename}`,
@@ -1830,7 +1948,8 @@ async function readRemoteAppManifest(c, sandboxId, timeoutSec) {
1830
1948
  .then(unwrap)
1831
1949
  .catch(() => null);
1832
1950
  const row = asRecord(result);
1833
- if (Number(row?.["exit_code"] ?? 1) === 0 && typeof row?.["stdout"] === "string") {
1951
+ if (Number(row?.["exit_code"] ?? 1) === 0 &&
1952
+ typeof row?.["stdout"] === "string") {
1834
1953
  return parseAppManifest(filename, row["stdout"]);
1835
1954
  }
1836
1955
  }
@@ -1926,6 +2045,98 @@ async function execSandbox(c, sandboxId, command, cwd, timeout) {
1926
2045
  }
1927
2046
  return result;
1928
2047
  }
2048
+ // Like execSandbox but does NOT throw on a non-zero exit code.
2049
+ async function execSandboxRaw(c, sandboxId, command, cwd, timeout) {
2050
+ const body = { command: commandInCwd(command, cwd) };
2051
+ if (cwd) {
2052
+ body["cwd"] = cwd;
2053
+ body["dir"] = cwd;
2054
+ }
2055
+ if (timeout != null)
2056
+ body["timeout"] = timeout;
2057
+ return unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/exec`), body));
2058
+ }
2059
+ function parseListeningPorts(stdout) {
2060
+ const lines = stdout.split("\n").map((l) => l.trim());
2061
+ const seen = new Set();
2062
+ const out = [];
2063
+ // ss -ltnH columns: State Recv-Q Send-Q Local-Address:Port Peer-Address:Port
2064
+ const ssLine = /^LISTEN\s+\d+\s+\d+\s+(\S+):(\d+)\s+/;
2065
+ // /proc/net/tcp: sl local_address rem_address st ... (hex)
2066
+ const procLine = /^\d+:\s+([0-9A-Fa-f]+):([0-9A-Fa-f]+)\s+\S+\s+([0-9A-Fa-f]+)/;
2067
+ for (const line of lines) {
2068
+ if (!line)
2069
+ continue;
2070
+ const ss = ssLine.exec(line);
2071
+ if (ss && ss[2]) {
2072
+ const port = Number(ss[2]);
2073
+ if (!seen.has(port)) {
2074
+ seen.add(port);
2075
+ out.push({ port, address: ss[1] ?? "*" });
2076
+ }
2077
+ continue;
2078
+ }
2079
+ const proc = procLine.exec(line);
2080
+ if (proc && proc[3] === "0A" && proc[2]) {
2081
+ const port = parseInt(proc[2], 16);
2082
+ if (!Number.isNaN(port) && !seen.has(port)) {
2083
+ seen.add(port);
2084
+ out.push({ port, address: "*" });
2085
+ }
2086
+ }
2087
+ }
2088
+ out.sort((a, b) => a.port - b.port);
2089
+ return out;
2090
+ }
2091
+ // Stream exec output: run in background to a log file, poll-read new bytes
2092
+ // until the process exits, then print the final exit code.
2093
+ async function runFollowExec(sandboxId, cmd, cwd, env, timeoutSec, opts) {
2094
+ if (!cmd) {
2095
+ throw new UserError("No command given to follow.");
2096
+ }
2097
+ const c = client();
2098
+ const logPath = `/tmp/miosa-follow-${Date.now()}.log`;
2099
+ const envPrefix = Object.entries(env)
2100
+ .map(([k, v]) => `${k}=${shellQuote(v)} `)
2101
+ .join("");
2102
+ // Run command, capture exit code into a sentinel file, in the background.
2103
+ const exitPath = `${logPath}.exit`;
2104
+ const inner = `${envPrefix}${cmd}`;
2105
+ const launch = `nohup sh -lc ${shellQuote(`( ${inner} ) > ${shellQuote(logPath)} 2>&1; echo $? > ${shellQuote(exitPath)}`)} >/dev/null 2>&1 & echo $!`;
2106
+ const started = await execSandboxRaw(c, sandboxId, launch, cwd, timeoutSec);
2107
+ const pid = String(started["stdout"] ?? "").trim();
2108
+ if (!pid) {
2109
+ throw new UserError("Could not start background command for --follow.");
2110
+ }
2111
+ const deadline = timeoutSec ? Date.now() + timeoutSec * 1000 : Infinity;
2112
+ let offset = 0;
2113
+ for (;;) {
2114
+ const read = await execSandboxRaw(c, sandboxId, `tail -c +${offset + 1} ${shellQuote(logPath)} 2>/dev/null`);
2115
+ const chunk = String(read["stdout"] ?? "");
2116
+ if (chunk.length > 0) {
2117
+ process.stdout.write(chunk);
2118
+ offset += Buffer.byteLength(chunk);
2119
+ }
2120
+ const alive = await execSandboxRaw(c, sandboxId, `kill -0 ${shellQuote(pid)} 2>/dev/null && echo alive || echo done`);
2121
+ if (String(alive["stdout"] ?? "").trim() === "done")
2122
+ break;
2123
+ if (Date.now() > deadline) {
2124
+ console.error(chalk.yellow(`\nTimed out after ${timeoutSec}s — command may still be running (pid ${pid}).`));
2125
+ return;
2126
+ }
2127
+ await sleep(1000);
2128
+ }
2129
+ // Drain any trailing output.
2130
+ const tail = await execSandboxRaw(c, sandboxId, `tail -c +${offset + 1} ${shellQuote(logPath)} 2>/dev/null`);
2131
+ const tailChunk = String(tail["stdout"] ?? "");
2132
+ if (tailChunk.length > 0)
2133
+ process.stdout.write(tailChunk);
2134
+ const exitRead = await execSandboxRaw(c, sandboxId, `cat ${shellQuote(exitPath)} 2>/dev/null`);
2135
+ const exitCode = Number(String(exitRead["stdout"] ?? "0").trim() || "0");
2136
+ if (!isJsonMode(opts) && exitCode !== 0) {
2137
+ console.error(chalk.red(`\nexit code: ${exitCode}`));
2138
+ }
2139
+ }
1929
2140
  async function waitForInternalHttp(c, sandboxId, port, probePath, timeoutSec) {
1930
2141
  const deadline = Date.now() + timeoutSec * 1000;
1931
2142
  let last = { ok: false, status: null };
@@ -2237,6 +2448,15 @@ function buildNetworkPolicy(opts) {
2237
2448
  function shellQuote(value) {
2238
2449
  return `'${value.replace(/'/g, "'\\''")}'`;
2239
2450
  }
2451
+ // Join argv words back into a shell command, quoting only words that contain
2452
+ // whitespace or shell-significant characters. Simple tokens stay bare so
2453
+ // `exec <id> ls -la` reads naturally, while `bash -c "cd x && y"` survives.
2454
+ function joinCommandWords(words) {
2455
+ const SAFE = /^[A-Za-z0-9_./:=@%+-]+$/;
2456
+ return words
2457
+ .map((word) => word.length > 0 && SAFE.test(word) ? word : shellQuote(word))
2458
+ .join(" ");
2459
+ }
2240
2460
  function joinUrlPath(basePath, probePath) {
2241
2461
  const probe = probePath.startsWith("/") ? probePath : `/${probePath}`;
2242
2462
  if (!basePath || basePath === "/")