@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.
- package/dist/app-manifest.d.ts +31 -0
- package/dist/app-manifest.d.ts.map +1 -0
- package/dist/app-manifest.js +122 -0
- package/dist/app-manifest.js.map +1 -0
- package/dist/bin/miosa.js +1 -0
- package/dist/bin/miosa.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +5 -2
- package/dist/client.js.map +1 -1
- package/dist/commands/cp.d.ts.map +1 -1
- package/dist/commands/cp.js +9 -0
- package/dist/commands/cp.js.map +1 -1
- package/dist/commands/enterprise-util.d.ts.map +1 -1
- package/dist/commands/enterprise-util.js +12 -1
- package/dist/commands/enterprise-util.js.map +1 -1
- package/dist/commands/ls.d.ts.map +1 -1
- package/dist/commands/ls.js +5 -0
- package/dist/commands/ls.js.map +1 -1
- package/dist/commands/{machines.d.ts → new.d.ts} +1 -1
- package/dist/commands/new.d.ts.map +1 -0
- package/dist/commands/new.js +296 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/sandbox.d.ts.map +1 -1
- package/dist/commands/sandbox.js +319 -37
- package/dist/commands/sandbox.js.map +1 -1
- package/package.json +1 -1
- package/dist/commands/machines.d.ts.map +0 -1
- package/dist/commands/machines.js +0 -29
- package/dist/commands/machines.js.map +0 -1
- package/dist/miosa-linux-x64 +0 -0
package/dist/commands/sandbox.js
CHANGED
|
@@ -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(
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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("--
|
|
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)
|
|
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
|
-
{
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
: (
|
|
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
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
-
|
|
1574
|
-
|
|
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
|
-
|
|
1577
|
-
|
|
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,
|
|
1676
|
+
await execSandbox(c, sandboxId, installCommand, remoteWorkdir, opts.timeout);
|
|
1581
1677
|
}
|
|
1582
|
-
deployStep(opts, `Starting app on port ${
|
|
1583
|
-
await execSandbox(c, sandboxId, `fuser -k ${
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 === "/")
|