@miosa/cli 1.0.36 → 1.0.38

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.
@@ -7,6 +7,7 @@ import * as http from "node:http";
7
7
  import * as https from "node:https";
8
8
  import chalk from "chalk";
9
9
  import WebSocket from "ws";
10
+ import { loadAppManifest, manifestPort, manifestProbePath, manifestStartCommand, parseAppManifest, } from "../app-manifest.js";
10
11
  import { detectFramework } from "../framework-detector.js";
11
12
  import { addDataOption, client, apiPath, deleteAndPrint, enc, getAndPrint, postAndPrint, printValue, runAction, unwrap, } from "./enterprise-util.js";
12
13
  import { loadConfig } from "../config.js";
@@ -371,12 +372,16 @@ export function register(program) {
371
372
  // exec — positional command arg; --data body overrides when supplied
372
373
  addDataOption(sandbox
373
374
  .command("exec <sandbox-id> [command...]")
374
- .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()
375
378
  .option("--cwd <path>", "Working directory inside the Sandbox")
376
379
  .option("--workdir <path>", "Alias for --cwd")
377
380
  .option("--env <pair>", "Environment variable KEY=VALUE. Repeatable.", collectOption, [])
378
381
  .option("--background", "Start the command in the background and return immediately")
379
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")
380
385
  .option("--user <user>", "Run command as user")
381
386
  .option("--sudo", "Run command through sudo")
382
387
  .option("--tty", "Request TTY metadata for command resource")
@@ -384,11 +389,12 @@ export function register(program) {
384
389
  .option("--timeout <sec>", "Exec timeout in seconds", parseIntegerOption))
385
390
  .option("--json", "Output as JSON")
386
391
  .action((id, words, opts) => runAction(async () => {
392
+ opts.follow = opts.follow || opts.stream;
387
393
  if (opts.data) {
388
394
  await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, {});
389
395
  return;
390
396
  }
391
- const cmd = words.join(" ");
397
+ const cmd = joinCommandWords(words);
392
398
  const effectiveCommand = opts.background
393
399
  ? backgroundCommand(cmd)
394
400
  : cmd;
@@ -402,7 +408,7 @@ export function register(program) {
402
408
  }
403
409
  const env = parseEnvPairs(opts.env ?? []);
404
410
  if (opts.detached) {
405
- const result = await createSandboxCommand(id, cmd, {
411
+ const result = await createSandboxCommand(id, words.join(" "), {
406
412
  cwd,
407
413
  env,
408
414
  user: opts.user,
@@ -418,6 +424,10 @@ export function register(program) {
418
424
  console.log(String(result["id"] ?? result["command_id"] ?? ""));
419
425
  return;
420
426
  }
427
+ if (opts.follow) {
428
+ await runFollowExec(id, cmd, cwd, env, opts.timeout, opts);
429
+ return;
430
+ }
421
431
  if (Object.keys(env).length > 0)
422
432
  body["env"] = env;
423
433
  if (opts.timeout != null)
@@ -427,12 +437,16 @@ export function register(program) {
427
437
  // run — alias for exec with identical semantics
428
438
  addDataOption(sandbox
429
439
  .command("run <sandbox-id> [command...]")
430
- .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()
431
443
  .option("--cwd <path>", "Working directory inside the Sandbox")
432
444
  .option("--workdir <path>", "Alias for --cwd")
433
445
  .option("--env <pair>", "Environment variable KEY=VALUE. Repeatable.", collectOption, [])
434
446
  .option("--background", "Start the command in the background and return immediately")
435
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")
436
450
  .option("--user <user>", "Run command as user")
437
451
  .option("--sudo", "Run command through sudo")
438
452
  .option("--tty", "Request TTY metadata for command resource")
@@ -440,11 +454,12 @@ export function register(program) {
440
454
  .option("--timeout <sec>", "Exec timeout in seconds", parseIntegerOption))
441
455
  .option("--json", "Output as JSON")
442
456
  .action((id, words, opts) => runAction(async () => {
457
+ opts.follow = opts.follow || opts.stream;
443
458
  if (opts.data) {
444
459
  await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, {});
445
460
  return;
446
461
  }
447
- const cmd = words.join(" ");
462
+ const cmd = joinCommandWords(words);
448
463
  const effectiveCommand = opts.background
449
464
  ? backgroundCommand(cmd)
450
465
  : cmd;
@@ -458,7 +473,7 @@ export function register(program) {
458
473
  }
459
474
  const env = parseEnvPairs(opts.env ?? []);
460
475
  if (opts.detached) {
461
- const result = await createSandboxCommand(id, cmd, {
476
+ const result = await createSandboxCommand(id, words.join(" "), {
462
477
  cwd,
463
478
  env,
464
479
  user: opts.user,
@@ -474,12 +489,46 @@ export function register(program) {
474
489
  console.log(String(result["id"] ?? result["command_id"] ?? ""));
475
490
  return;
476
491
  }
492
+ if (opts.follow) {
493
+ await runFollowExec(id, cmd, cwd, env, opts.timeout, opts);
494
+ return;
495
+ }
477
496
  if (Object.keys(env).length > 0)
478
497
  body["env"] = env;
479
498
  if (opts.timeout != null)
480
499
  body["timeout"] = opts.timeout;
481
500
  await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
482
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
+ }));
483
532
  sandbox
484
533
  .command("deploy [local-dir]")
485
534
  .description("Upload an app directory, start it in a sandbox, expose a preview URL, and wait for readiness")
@@ -491,9 +540,12 @@ export function register(program) {
491
540
  .option("--start <command>", "Start command to run inside /workspace")
492
541
  .option("--install-command <command>", "Install command to run before start")
493
542
  .option("--no-install", "Skip automatic dependency install")
543
+ .option("--source <source>", "Source: git:https://... or tarball:https://... for repo-backed preview deploy")
544
+ .option("--revision <revision>", "Git revision/branch for --source git:...")
545
+ .option("--depth <n>", "Git clone depth for --source git:...", parseIntegerOption)
494
546
  .option("--wait", "Wait until the public preview returns a good HTTP status")
495
547
  .option("--timeout <duration>", "Wait timeout, e.g. 180s or 3m", parseDurationSec, 180)
496
- .option("--probe-path <path>", "HTTP path to probe", "/")
548
+ .option("--probe-path <path>", "HTTP path to probe")
497
549
  .option("--json", "Output as JSON")
498
550
  .action((localDir = ".", opts) => runAction(async () => {
499
551
  const result = await deploySandbox(localDir, opts);
@@ -591,7 +643,8 @@ export function register(program) {
591
643
  .option("--database <mode>", "none, create:postgres, postgres, or existing:<db-id>")
592
644
  .option("--port <port>", "Runtime port for dynamic deployments", parseIntegerOption)
593
645
  .option("--wait", "Wait for the production URL to answer")
594
- .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)
595
648
  .option("--json", "Output as JSON")
596
649
  .action((id, opts) => runAction(async () => {
597
650
  const result = await publishSandbox(id, opts);
@@ -736,15 +789,29 @@ export function register(program) {
736
789
  console.log(JSON.stringify(result, null, 2));
737
790
  return;
738
791
  }
739
- const rows = Array.isArray(result) ? result : [];
792
+ const rows = Array.isArray(result)
793
+ ? result
794
+ : [];
740
795
  if (rows.length === 0) {
741
796
  console.log(chalk.dim("No sandbox env vars."));
742
797
  return;
743
798
  }
744
799
  renderTable(rows, [
745
- { header: "NAME", key: "name", width: 32 },
746
- { header: "VALUE", key: "preview", width: 24 },
747
- { 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
+ },
748
815
  ]);
749
816
  }));
750
817
  env
@@ -1549,42 +1616,71 @@ function validateServiceName(name) {
1549
1616
  }
1550
1617
  async function deploySandbox(localDir, opts) {
1551
1618
  const sourceDir = path.resolve(localDir);
1552
- if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
1619
+ const sourceBacked = !!opts.source;
1620
+ if (!sourceBacked &&
1621
+ (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory())) {
1553
1622
  throw new UserError(`Local directory not found: ${sourceDir}`);
1554
1623
  }
1555
1624
  const c = client();
1556
- const detection = detectFramework(sourceDir);
1557
- const port = opts.port ?? opts.publishPort ?? detection?.port ?? 5173;
1558
- const start = opts.start ?? defaultStartCommand(detection?.framework, port);
1559
- const installCommand = opts.install === false
1625
+ let appManifest = sourceBacked
1560
1626
  ? null
1561
- : (opts.installCommand ?? defaultInstallCommand(sourceDir));
1627
+ : (loadAppManifest(sourceDir)?.manifest ?? null);
1628
+ const detection = sourceBacked ? null : detectFramework(sourceDir);
1629
+ const port = opts.port ??
1630
+ opts.publishPort ??
1631
+ manifestPort(appManifest) ??
1632
+ detection?.port ??
1633
+ 5173;
1634
+ const probePath = opts.probePath ?? manifestProbePath(appManifest) ?? "/";
1635
+ let remoteWorkdir = normalizeRemoteWorkdir(appManifest?.workdir ?? "/workspace");
1636
+ const start = opts.start ??
1637
+ manifestStartCommand(appManifest) ??
1638
+ defaultStartCommand(detection?.framework ?? appManifest?.framework, port);
1562
1639
  const sandboxId = opts.sandbox ??
1563
1640
  (deployStep(opts, "Creating sandbox"),
1564
- await createSandboxForDeploy(c, opts.template ?? "miosa-sandbox", opts.name));
1641
+ await createSandboxForDeploy(c, opts.template ?? appManifest?.template ?? "miosa-sandbox", opts.name, {
1642
+ source: opts.source,
1643
+ revision: opts.revision,
1644
+ depth: opts.depth,
1645
+ }));
1565
1646
  deployStep(opts, "Waiting for sandbox");
1566
1647
  await waitForSandboxRunning(c, sandboxId, Math.min(opts.timeout, 120));
1567
- deployStep(opts, "Uploading files");
1568
- const archivePath = createDeployArchive(sourceDir);
1569
- const remoteArchive = `/tmp/miosa-deploy-${Date.now()}.tgz`;
1570
- try {
1571
- await uploadFileToSandbox(c, sandboxId, archivePath, remoteArchive);
1648
+ if (sourceBacked) {
1649
+ deployStep(opts, "Waiting for source import");
1650
+ appManifest = await readRemoteAppManifest(c, sandboxId, opts.timeout);
1651
+ remoteWorkdir = normalizeRemoteWorkdir(appManifest?.workdir ?? "/workspace");
1572
1652
  }
1573
- finally {
1574
- fs.rmSync(archivePath, { force: true });
1653
+ else {
1654
+ deployStep(opts, "Uploading files");
1655
+ const archivePath = createDeployArchive(sourceDir);
1656
+ const remoteArchive = `/tmp/miosa-deploy-${Date.now()}.tgz`;
1657
+ try {
1658
+ await uploadFileToSandbox(c, sandboxId, archivePath, remoteArchive);
1659
+ deployStep(opts, "Extracting workspace");
1660
+ await execSandbox(c, sandboxId, `mkdir -p ${shellQuote(remoteWorkdir)} && tar -xzf ${shellQuote(remoteArchive)} -C ${shellQuote(remoteWorkdir)}`, "/");
1661
+ }
1662
+ finally {
1663
+ fs.rmSync(archivePath, { force: true });
1664
+ }
1575
1665
  }
1576
- deployStep(opts, "Extracting workspace");
1577
- await execSandbox(c, sandboxId, `mkdir -p /workspace && tar -xzf ${shellQuote(remoteArchive)} -C /workspace`, "/");
1666
+ const resolvedPort = opts.port ?? opts.publishPort ?? manifestPort(appManifest) ?? port;
1667
+ const resolvedProbePath = opts.probePath ?? manifestProbePath(appManifest) ?? probePath;
1668
+ const resolvedStart = opts.start ?? manifestStartCommand(appManifest) ?? start;
1669
+ const installCommand = opts.install === false
1670
+ ? null
1671
+ : (opts.installCommand ??
1672
+ (appManifest?.install === false ? null : appManifest?.install) ??
1673
+ (sourceBacked ? "npm install" : defaultInstallCommand(sourceDir)));
1578
1674
  if (installCommand) {
1579
1675
  deployStep(opts, `Installing dependencies: ${installCommand}`);
1580
- await execSandbox(c, sandboxId, installCommand, "/workspace", opts.timeout);
1676
+ await execSandbox(c, sandboxId, installCommand, remoteWorkdir, opts.timeout);
1581
1677
  }
1582
- deployStep(opts, `Starting app on port ${port}`);
1583
- 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");
1678
+ deployStep(opts, `Starting app on port ${resolvedPort}`);
1679
+ await execSandbox(c, sandboxId, `fuser -k ${resolvedPort}/tcp >/dev/null 2>&1 || true; nohup sh -lc ${shellQuote(resolvedStart)} > ${shellQuote(`/tmp/miosa-app-${resolvedPort}.log`)} 2>&1 & echo $!`, remoteWorkdir);
1584
1680
  deployStep(opts, "Checking internal app readiness");
1585
- const internal = await waitForInternalHttp(c, sandboxId, port, opts.probePath, Math.min(opts.timeout, 60));
1681
+ const internal = await waitForInternalHttp(c, sandboxId, resolvedPort, resolvedProbePath, Math.min(opts.timeout, 60));
1586
1682
  deployStep(opts, "Creating public preview route");
1587
- const exposed = await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), { port, title: "app preview" });
1683
+ const exposed = await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), { port: resolvedPort, title: "app preview" });
1588
1684
  const previewUrl = extractUrl(unwrap(exposed));
1589
1685
  if (!previewUrl) {
1590
1686
  throw new UserError("Sandbox expose did not return a preview URL.");
@@ -1592,11 +1688,11 @@ async function deploySandbox(localDir, opts) {
1592
1688
  if (opts.wait)
1593
1689
  deployStep(opts, "Checking public preview readiness");
1594
1690
  const edge = opts.wait
1595
- ? await waitForPublicPreview(previewUrl, opts.probePath, opts.timeout)
1691
+ ? await waitForPublicPreview(previewUrl, resolvedProbePath, opts.timeout)
1596
1692
  : { ok: false, status: null };
1597
1693
  return {
1598
1694
  sandbox_id: sandboxId,
1599
- port,
1695
+ port: resolvedPort,
1600
1696
  preview_url: previewUrl,
1601
1697
  preview_ready: edge.ok,
1602
1698
  internal_status: internal.status,
@@ -1604,6 +1700,41 @@ async function deploySandbox(localDir, opts) {
1604
1700
  latency_ms: edge.latency_ms ?? null,
1605
1701
  };
1606
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
+ }
1607
1738
  async function publishSandbox(sandboxId, opts) {
1608
1739
  const c = client();
1609
1740
  const body = {
@@ -1614,6 +1745,16 @@ async function publishSandbox(sandboxId, opts) {
1614
1745
  };
1615
1746
  if (opts.app)
1616
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
+ }
1617
1758
  if (opts.name)
1618
1759
  body["name"] = opts.name;
1619
1760
  if (opts.slug)
@@ -1696,7 +1837,8 @@ async function waitForDeploymentReady(c, deploymentId, timeoutSec) {
1696
1837
  }
1697
1838
  await sleep(2000);
1698
1839
  }
1699
- 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}`);
1700
1842
  }
1701
1843
  function parsePublishDatabase(value) {
1702
1844
  if (!value)
@@ -1773,16 +1915,48 @@ function renderDoctorReport(report) {
1773
1915
  console.log();
1774
1916
  }
1775
1917
  }
1776
- async function createSandboxForDeploy(c, template, name) {
1918
+ async function createSandboxForDeploy(c, template, name, source) {
1777
1919
  const body = { template_id: template };
1778
1920
  if (name)
1779
1921
  body["name"] = name;
1922
+ if (source?.source)
1923
+ body["source"] = source.source;
1924
+ if (source?.revision)
1925
+ body["revision"] = source.revision;
1926
+ if (source?.depth != null)
1927
+ body["depth"] = source.depth;
1780
1928
  const created = unwrap(await c.apiPost(apiPath("/sandboxes"), body));
1781
1929
  const sandboxId = typeof created["id"] === "string" ? created["id"] : "";
1782
1930
  if (!sandboxId)
1783
1931
  throw new UserError("Sandbox create did not return an id.");
1784
1932
  return sandboxId;
1785
1933
  }
1934
+ async function readRemoteAppManifest(c, sandboxId, timeoutSec) {
1935
+ const deadline = Date.now() + Math.min(timeoutSec, 120) * 1000;
1936
+ while (Date.now() < deadline) {
1937
+ for (const filename of [
1938
+ "miosa.app.yml",
1939
+ "miosa.app.yaml",
1940
+ "miosa.app.json",
1941
+ ]) {
1942
+ const result = await c
1943
+ .apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/exec`), {
1944
+ command: `test -f /workspace/${filename} && cat /workspace/${filename}`,
1945
+ cwd: "/workspace",
1946
+ timeout: 10,
1947
+ })
1948
+ .then(unwrap)
1949
+ .catch(() => null);
1950
+ const row = asRecord(result);
1951
+ if (Number(row?.["exit_code"] ?? 1) === 0 &&
1952
+ typeof row?.["stdout"] === "string") {
1953
+ return parseAppManifest(filename, row["stdout"]);
1954
+ }
1955
+ }
1956
+ await sleep(1500);
1957
+ }
1958
+ return null;
1959
+ }
1786
1960
  async function waitForSandboxRunning(c, sandboxId, timeoutSec) {
1787
1961
  const deadline = Date.now() + timeoutSec * 1000;
1788
1962
  while (Date.now() < deadline) {
@@ -1871,6 +2045,98 @@ async function execSandbox(c, sandboxId, command, cwd, timeout) {
1871
2045
  }
1872
2046
  return result;
1873
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
+ }
1874
2140
  async function waitForInternalHttp(c, sandboxId, port, probePath, timeoutSec) {
1875
2141
  const deadline = Date.now() + timeoutSec * 1000;
1876
2142
  let last = { ok: false, status: null };
@@ -1964,6 +2230,13 @@ function defaultStartCommand(framework, port) {
1964
2230
  return `python3 -m http.server ${port} --bind 0.0.0.0`;
1965
2231
  return `npm run dev -- --host 0.0.0.0 --port ${port}`;
1966
2232
  }
2233
+ function normalizeRemoteWorkdir(value) {
2234
+ if (!value || value === ".")
2235
+ return "/workspace";
2236
+ if (!value.startsWith("/"))
2237
+ return `/workspace/${value.replace(/^\.\//, "")}`;
2238
+ return value;
2239
+ }
1967
2240
  function extractUrl(value) {
1968
2241
  if (!value || typeof value !== "object" || Array.isArray(value))
1969
2242
  return null;
@@ -2175,6 +2448,15 @@ function buildNetworkPolicy(opts) {
2175
2448
  function shellQuote(value) {
2176
2449
  return `'${value.replace(/'/g, "'\\''")}'`;
2177
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
+ }
2178
2460
  function joinUrlPath(basePath, probePath) {
2179
2461
  const probe = probePath.startsWith("/") ? probePath : `/${probePath}`;
2180
2462
  if (!basePath || basePath === "/")