@miosa/cli 1.0.9 → 1.0.11
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/commands/databases.d.ts.map +1 -1
- package/dist/commands/databases.js +69 -20
- package/dist/commands/databases.js.map +1 -1
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +160 -7
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +26 -6
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/sandbox.d.ts.map +1 -1
- package/dist/commands/sandbox.js +907 -24
- package/dist/commands/sandbox.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/sandbox.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
225
|
+
console.log(JSON.stringify(sb, null, 2));
|
|
191
226
|
return;
|
|
192
227
|
}
|
|
193
|
-
|
|
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} ${
|
|
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,9 @@ 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("--workdir <path>", "Alias for --cwd")
|
|
344
|
+
.option("--env <pair>", "Environment variable KEY=VALUE. Repeatable.", collectOption, [])
|
|
345
|
+
.option("--background", "Start the command in the background and return immediately")
|
|
307
346
|
.option("--timeout <sec>", "Exec timeout in seconds", parseInt))
|
|
308
347
|
.option("--json", "Output as JSON")
|
|
309
348
|
.action((id, words, opts) => runAction(async () => {
|
|
@@ -312,9 +351,18 @@ export function register(program) {
|
|
|
312
351
|
return;
|
|
313
352
|
}
|
|
314
353
|
const cmd = words.join(" ");
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
354
|
+
const effectiveCommand = opts.background ? backgroundCommand(cmd) : cmd;
|
|
355
|
+
const cwd = opts.cwd ?? opts.workdir;
|
|
356
|
+
const body = cmd
|
|
357
|
+
? { command: commandInCwd(effectiveCommand, cwd) }
|
|
358
|
+
: {};
|
|
359
|
+
if (cwd) {
|
|
360
|
+
body["cwd"] = cwd;
|
|
361
|
+
body["dir"] = cwd;
|
|
362
|
+
}
|
|
363
|
+
const env = parseEnvPairs(opts.env ?? []);
|
|
364
|
+
if (Object.keys(env).length > 0)
|
|
365
|
+
body["env"] = env;
|
|
318
366
|
if (opts.timeout != null)
|
|
319
367
|
body["timeout"] = opts.timeout;
|
|
320
368
|
await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
|
|
@@ -324,6 +372,9 @@ export function register(program) {
|
|
|
324
372
|
.command("run <sandbox-id> [command...]")
|
|
325
373
|
.description("Run a command inside a Sandbox (alias for exec)")
|
|
326
374
|
.option("--cwd <path>", "Working directory inside the Sandbox")
|
|
375
|
+
.option("--workdir <path>", "Alias for --cwd")
|
|
376
|
+
.option("--env <pair>", "Environment variable KEY=VALUE. Repeatable.", collectOption, [])
|
|
377
|
+
.option("--background", "Start the command in the background and return immediately")
|
|
327
378
|
.option("--timeout <sec>", "Exec timeout in seconds", parseInt))
|
|
328
379
|
.option("--json", "Output as JSON")
|
|
329
380
|
.action((id, words, opts) => runAction(async () => {
|
|
@@ -332,13 +383,168 @@ export function register(program) {
|
|
|
332
383
|
return;
|
|
333
384
|
}
|
|
334
385
|
const cmd = words.join(" ");
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
386
|
+
const effectiveCommand = opts.background ? backgroundCommand(cmd) : cmd;
|
|
387
|
+
const cwd = opts.cwd ?? opts.workdir;
|
|
388
|
+
const body = cmd
|
|
389
|
+
? { command: commandInCwd(effectiveCommand, cwd) }
|
|
390
|
+
: {};
|
|
391
|
+
if (cwd) {
|
|
392
|
+
body["cwd"] = cwd;
|
|
393
|
+
body["dir"] = cwd;
|
|
394
|
+
}
|
|
395
|
+
const env = parseEnvPairs(opts.env ?? []);
|
|
396
|
+
if (Object.keys(env).length > 0)
|
|
397
|
+
body["env"] = env;
|
|
338
398
|
if (opts.timeout != null)
|
|
339
399
|
body["timeout"] = opts.timeout;
|
|
340
400
|
await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
|
|
341
401
|
}));
|
|
402
|
+
sandbox
|
|
403
|
+
.command("deploy [local-dir]")
|
|
404
|
+
.description("Upload an app directory, start it in a sandbox, expose a preview URL, and wait for readiness")
|
|
405
|
+
.option("--sandbox <id>", "Existing sandbox ID. Creates one when omitted")
|
|
406
|
+
.option("--template <template>", "Template for new sandbox", "miosa-sandbox")
|
|
407
|
+
.option("--name <name>", "Name for a new sandbox")
|
|
408
|
+
.option("--port <port>", "Preview port", parseInt)
|
|
409
|
+
.option("--publish-port <port>", "Alias for --port", parseInt)
|
|
410
|
+
.option("--start <command>", "Start command to run inside /workspace")
|
|
411
|
+
.option("--install-command <command>", "Install command to run before start")
|
|
412
|
+
.option("--no-install", "Skip automatic dependency install")
|
|
413
|
+
.option("--wait", "Wait until the public preview returns a good HTTP status")
|
|
414
|
+
.option("--timeout <sec>", "Wait timeout in seconds", parseInt, 180)
|
|
415
|
+
.option("--probe-path <path>", "HTTP path to probe", "/")
|
|
416
|
+
.option("--json", "Output as JSON")
|
|
417
|
+
.action((localDir = ".", opts) => runAction(async () => {
|
|
418
|
+
const result = await deploySandbox(localDir, opts);
|
|
419
|
+
if (opts.json) {
|
|
420
|
+
console.log(JSON.stringify(result, null, 2));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
console.log();
|
|
424
|
+
console.log(` ${chalk.bold("Sandbox")} ${result.sandbox_id}`);
|
|
425
|
+
console.log(` ${chalk.bold("Port")} ${result.port}`);
|
|
426
|
+
console.log(` ${chalk.bold("Preview")} ${chalk.cyan(result.preview_url)}`);
|
|
427
|
+
console.log(` ${chalk.bold("Ready")} ${result.preview_ready ? chalk.green("yes") : chalk.yellow("not verified")}`);
|
|
428
|
+
console.log();
|
|
429
|
+
}));
|
|
430
|
+
sandbox
|
|
431
|
+
.command("preview <sandbox-id>")
|
|
432
|
+
.description("Expose a sandbox port and optionally wait for the public preview to answer")
|
|
433
|
+
.requiredOption("--port <port>", "Port inside the sandbox to expose", parseInt)
|
|
434
|
+
.option("--wait", "Wait until the public URL returns a good HTTP status")
|
|
435
|
+
.option("--timeout <sec>", "Wait timeout in seconds", parseInt, 120)
|
|
436
|
+
.option("--probe-path <path>", "HTTP path to probe", "/")
|
|
437
|
+
.option("--json", "Output as JSON")
|
|
438
|
+
.action((id, opts) => runAction(async () => {
|
|
439
|
+
const result = await previewSandbox(id, opts.port, {
|
|
440
|
+
wait: !!opts.wait,
|
|
441
|
+
timeout: opts.timeout,
|
|
442
|
+
probePath: opts.probePath,
|
|
443
|
+
});
|
|
444
|
+
if (opts.json) {
|
|
445
|
+
console.log(JSON.stringify(result, null, 2));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
console.log(result.url);
|
|
449
|
+
if (!result.ready) {
|
|
450
|
+
console.error(chalk.yellow(`Preview route created but not verified yet (${result.error ?? result.status ?? "pending"}).`));
|
|
451
|
+
}
|
|
452
|
+
}));
|
|
453
|
+
sandbox
|
|
454
|
+
.command("wait <sandbox-id>")
|
|
455
|
+
.description("Wait for sandbox VM, internal app port, edge route, TLS, and public preview readiness")
|
|
456
|
+
.requiredOption("--port <port>", "Port inside the sandbox to check", parseInt)
|
|
457
|
+
.option("--url", "Print only the ready public preview URL")
|
|
458
|
+
.option("--timeout <sec>", "Wait timeout in seconds", parseInt, 120)
|
|
459
|
+
.option("--probe-path <path>", "HTTP path to probe", "/")
|
|
460
|
+
.option("--json", "Output as JSON")
|
|
461
|
+
.action((id, opts) => runAction(async () => {
|
|
462
|
+
const result = await waitSandboxReady(id, opts.port, opts.probePath, opts.timeout);
|
|
463
|
+
if (opts.url && result.url) {
|
|
464
|
+
console.log(result.url);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (opts.json) {
|
|
468
|
+
console.log(JSON.stringify(result, null, 2));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
console.log(chalk.green("Ready"));
|
|
472
|
+
console.log(chalk.cyan(result.url));
|
|
473
|
+
}));
|
|
474
|
+
const service = sandbox
|
|
475
|
+
.command("service")
|
|
476
|
+
.description("Manage named long-running processes inside a sandbox");
|
|
477
|
+
service
|
|
478
|
+
.command("start <sandbox-id> <name>")
|
|
479
|
+
.requiredOption("--cmd <command>", "Command to start")
|
|
480
|
+
.option("--cwd <path>", "Working directory inside the sandbox", "/workspace")
|
|
481
|
+
.option("--port <port>", "Port served by this service", parseInt)
|
|
482
|
+
.option("--json", "Output as JSON")
|
|
483
|
+
.action((id, name, opts) => runAction(async () => {
|
|
484
|
+
const result = await startSandboxService(id, name, opts);
|
|
485
|
+
if (opts.json) {
|
|
486
|
+
console.log(JSON.stringify(result, null, 2));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
console.log(chalk.green(`Started ${name}`));
|
|
490
|
+
if (result.preview_url)
|
|
491
|
+
console.log(chalk.cyan(String(result.preview_url)));
|
|
492
|
+
}));
|
|
493
|
+
service
|
|
494
|
+
.command("restart <sandbox-id> <name>")
|
|
495
|
+
.requiredOption("--cmd <command>", "Command to start")
|
|
496
|
+
.option("--cwd <path>", "Working directory inside the sandbox", "/workspace")
|
|
497
|
+
.option("--port <port>", "Port served by this service", parseInt)
|
|
498
|
+
.option("--json", "Output as JSON")
|
|
499
|
+
.action((id, name, opts) => runAction(async () => {
|
|
500
|
+
await execSandbox(client(), id, `if [ -f ${shellQuote(servicePidPath(name))} ]; then kill $(cat ${shellQuote(servicePidPath(name))}) >/dev/null 2>&1 || true; fi`, "/");
|
|
501
|
+
const result = await startSandboxService(id, name, opts);
|
|
502
|
+
if (opts.json) {
|
|
503
|
+
console.log(JSON.stringify(result, null, 2));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
console.log(chalk.green(`Restarted ${name}`));
|
|
507
|
+
if (result.preview_url)
|
|
508
|
+
console.log(chalk.cyan(String(result.preview_url)));
|
|
509
|
+
}));
|
|
510
|
+
service
|
|
511
|
+
.command("status <sandbox-id> <name>")
|
|
512
|
+
.option("--json", "Output as JSON")
|
|
513
|
+
.action((id, name, opts) => runAction(async () => {
|
|
514
|
+
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`, "/");
|
|
515
|
+
const status = String(result["stdout"] ?? "").trim() || "unknown";
|
|
516
|
+
if (opts.json) {
|
|
517
|
+
console.log(JSON.stringify({ sandbox_id: id, name, status }, null, 2));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
console.log(status === "running" ? chalk.green(status) : chalk.yellow(status));
|
|
521
|
+
}));
|
|
522
|
+
service
|
|
523
|
+
.command("logs <sandbox-id> <name>")
|
|
524
|
+
.option("--lines <n>", "Number of log lines", parseInt, 100)
|
|
525
|
+
.option("--json", "Output as JSON")
|
|
526
|
+
.action((id, name, opts) => runAction(async () => {
|
|
527
|
+
const result = await execSandbox(client(), id, `tail -n ${Number(opts.lines) || 100} ${shellQuote(serviceLogPath(name))}`, "/");
|
|
528
|
+
if (opts.json) {
|
|
529
|
+
console.log(JSON.stringify({ sandbox_id: id, name, logs: String(result["stdout"] ?? "") }, null, 2));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
process.stdout.write(String(result["stdout"] ?? ""));
|
|
533
|
+
}));
|
|
534
|
+
sandbox
|
|
535
|
+
.command("doctor <sandbox-id>")
|
|
536
|
+
.description("Diagnose sandbox app readiness across sandbox state, internal HTTP, public route, and TLS/edge reachability")
|
|
537
|
+
.requiredOption("--port <port>", "Port inside the sandbox to check", parseInt)
|
|
538
|
+
.option("--probe-path <path>", "HTTP path to probe", "/")
|
|
539
|
+
.option("--json", "Output as JSON")
|
|
540
|
+
.action((id, opts) => runAction(async () => {
|
|
541
|
+
const report = await doctorSandbox(id, opts.port, opts.probePath);
|
|
542
|
+
if (opts.json) {
|
|
543
|
+
console.log(JSON.stringify(report, null, 2));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
renderDoctorReport(report);
|
|
547
|
+
}));
|
|
342
548
|
// write-file — POST /sandboxes/:id/files with base64 content.
|
|
343
549
|
// If <content-or-file> is an existing local path, reads the file bytes;
|
|
344
550
|
// otherwise treats the argument as literal UTF-8 text.
|
|
@@ -429,6 +635,96 @@ export function register(program) {
|
|
|
429
635
|
handleError(err);
|
|
430
636
|
}
|
|
431
637
|
});
|
|
638
|
+
sandbox
|
|
639
|
+
.command("upload-dir <sandbox-id> <local-dir> <remote-dir>")
|
|
640
|
+
.description("Upload a local directory into a Sandbox")
|
|
641
|
+
.option("--delete", "Delete the remote directory contents before extracting")
|
|
642
|
+
.option("--json", "Output as JSON")
|
|
643
|
+
.action(async (id, localDir, remoteDir, opts) => {
|
|
644
|
+
try {
|
|
645
|
+
const result = await uploadDirToSandbox(id, localDir, remoteDir, {
|
|
646
|
+
delete: !!opts.delete,
|
|
647
|
+
});
|
|
648
|
+
if (opts.json) {
|
|
649
|
+
console.log(JSON.stringify(result, null, 2));
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
console.log(chalk.green(`Uploaded ${result.files_label} → ${remoteDir}`));
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
handleError(err);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
sandbox
|
|
659
|
+
.command("sync <local-dir> <remote-dir>")
|
|
660
|
+
.description("Sync a local directory into a sandbox")
|
|
661
|
+
.requiredOption("--sandbox <id>", "Sandbox ID")
|
|
662
|
+
.option("--delete", "Delete the remote directory contents before extracting")
|
|
663
|
+
.option("--json", "Output as JSON")
|
|
664
|
+
.action(async (localDir, remoteDir, opts) => {
|
|
665
|
+
try {
|
|
666
|
+
const result = await uploadDirToSandbox(opts.sandbox, localDir, remoteDir, {
|
|
667
|
+
delete: !!opts.delete,
|
|
668
|
+
});
|
|
669
|
+
if (opts.json) {
|
|
670
|
+
console.log(JSON.stringify(result, null, 2));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
console.log(chalk.green(`Synced ${result.files_label} → ${remoteDir}`));
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
handleError(err);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
sandbox
|
|
680
|
+
.command("cp <source> <target>")
|
|
681
|
+
.alias("copy")
|
|
682
|
+
.description("Copy a local file or directory into a sandbox, e.g. ./app/. sbx_123:/workspace")
|
|
683
|
+
.option("--delete", "Delete the remote directory contents before extracting directories")
|
|
684
|
+
.option("--json", "Output as JSON")
|
|
685
|
+
.action(async (source, target, opts) => {
|
|
686
|
+
try {
|
|
687
|
+
if (isSandboxTarget(source) && !isSandboxTarget(target)) {
|
|
688
|
+
const parsedSource = parseSandboxTarget(source);
|
|
689
|
+
const result = await downloadSandboxPath(parsedSource.sandboxId, parsedSource.remotePath, target);
|
|
690
|
+
if (opts.json) {
|
|
691
|
+
console.log(JSON.stringify(result, null, 2));
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
console.log(chalk.green(`Copied ${source} → ${target}`));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (isSandboxTarget(source) || !isSandboxTarget(target)) {
|
|
698
|
+
throw new UserError("Invalid copy direction.", "Use local → sandbox (`./app/. sbx_123:/workspace`) or sandbox → local (`sbx_123:/workspace/dist ./dist`).");
|
|
699
|
+
}
|
|
700
|
+
const parsed = parseSandboxTarget(target);
|
|
701
|
+
const local = source.endsWith("/.") ? source.slice(0, -2) : source;
|
|
702
|
+
const stat = fs.statSync(local);
|
|
703
|
+
if (stat.isDirectory()) {
|
|
704
|
+
const result = await uploadDirToSandbox(parsed.sandboxId, local, parsed.remotePath, { delete: !!opts.delete });
|
|
705
|
+
if (opts.json) {
|
|
706
|
+
console.log(JSON.stringify(result, null, 2));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
console.log(chalk.green(`Copied ${result.files_label} → ${target}`));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const c = client();
|
|
713
|
+
await uploadFileToSandbox(c, parsed.sandboxId, local, parsed.remotePath);
|
|
714
|
+
if (opts.json) {
|
|
715
|
+
console.log(JSON.stringify({
|
|
716
|
+
sandbox_id: parsed.sandboxId,
|
|
717
|
+
local_path: path.resolve(local),
|
|
718
|
+
remote_path: parsed.remotePath,
|
|
719
|
+
}, null, 2));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
console.log(chalk.green(`Copied ${path.basename(local)} → ${target}`));
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
handleError(err);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
432
728
|
// download — GET /sandboxes/:id/files/:path; write to --output or stdout.
|
|
433
729
|
sandbox
|
|
434
730
|
.command("download <sandbox-id> <remote-path>")
|
|
@@ -497,6 +793,20 @@ export function register(program) {
|
|
|
497
793
|
handleError(err);
|
|
498
794
|
}
|
|
499
795
|
});
|
|
796
|
+
sandbox
|
|
797
|
+
.command("shell <sandbox-id>")
|
|
798
|
+
.description("Open an interactive shell into a running Sandbox")
|
|
799
|
+
.option("-p, --port <n>", "Local port for the tunnel listener", parseInt)
|
|
800
|
+
.option("-l, --user <name>", "SSH user (default: root)")
|
|
801
|
+
.option("--json", "Output connection info as JSON")
|
|
802
|
+
.action(async (id, opts) => {
|
|
803
|
+
try {
|
|
804
|
+
await runSandboxSsh(id, { ...opts, spawn: !opts.json });
|
|
805
|
+
}
|
|
806
|
+
catch (err) {
|
|
807
|
+
handleError(err);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
500
810
|
// port-forward — TCP listener → WS tunnel → sandbox port
|
|
501
811
|
sandbox
|
|
502
812
|
.command("port-forward <sandbox-id>")
|
|
@@ -510,10 +820,20 @@ export function register(program) {
|
|
|
510
820
|
].join("\n"))
|
|
511
821
|
.requiredOption("--remote <port>", "Port inside the sandbox to reach", parseInt)
|
|
512
822
|
.option("--local <port>", "Local port to listen on (default = remote port)", parseInt)
|
|
823
|
+
.option("--wait", "Probe the local forwarded URL before returning readiness")
|
|
824
|
+
.option("--timeout <sec>", "Wait timeout in seconds", parseInt, 30)
|
|
825
|
+
.option("--probe-path <path>", "HTTP path to probe when --wait is set", "/")
|
|
513
826
|
.option("--json", "Output as JSON")
|
|
514
827
|
.action(async (id, opts) => {
|
|
515
828
|
try {
|
|
516
|
-
await runSandboxPortForward(id,
|
|
829
|
+
await runSandboxPortForward(id, {
|
|
830
|
+
remotePort: opts.remote,
|
|
831
|
+
localPort: opts.local ?? opts.remote,
|
|
832
|
+
wait: !!opts.wait,
|
|
833
|
+
timeoutSec: opts.timeout,
|
|
834
|
+
probePath: opts.probePath,
|
|
835
|
+
json: !!opts.json,
|
|
836
|
+
});
|
|
517
837
|
}
|
|
518
838
|
catch (err) {
|
|
519
839
|
handleError(err);
|
|
@@ -525,6 +845,55 @@ export function register(program) {
|
|
|
525
845
|
.description("Create a Checkpoint (memory state snapshot) of a Sandbox"))
|
|
526
846
|
.option("--json", "Output as JSON")
|
|
527
847
|
.action((id, opts) => runAction(() => postAndPrint(`/sandboxes/${enc(id)}/snapshots`, opts, {})));
|
|
848
|
+
sandbox
|
|
849
|
+
.command("snapshot <sandbox-id>")
|
|
850
|
+
.description("Create a Sandbox snapshot")
|
|
851
|
+
.option("--name <name>", "Human-readable snapshot name/comment")
|
|
852
|
+
.option("--comment <comment>", "Snapshot comment")
|
|
853
|
+
.option("--stop", "Pause the sandbox after snapshot creation")
|
|
854
|
+
.option("--expiration <duration>", "Requested retention duration, e.g. 14d")
|
|
855
|
+
.option("--json", "Output as JSON")
|
|
856
|
+
.action((id, opts) => runAction(async () => {
|
|
857
|
+
const body = {};
|
|
858
|
+
const comment = opts.comment ?? opts.name;
|
|
859
|
+
if (comment)
|
|
860
|
+
body["comment"] = comment;
|
|
861
|
+
if (opts.expiration)
|
|
862
|
+
body["expiration"] = opts.expiration;
|
|
863
|
+
const snapshot = unwrap(await client().apiPost(apiPath(`/sandboxes/${enc(id)}/snapshots`), body));
|
|
864
|
+
if (opts.stop) {
|
|
865
|
+
await client().apiPost(apiPath(`/sandboxes/${enc(id)}/pause`), {});
|
|
866
|
+
}
|
|
867
|
+
if (opts.json) {
|
|
868
|
+
console.log(JSON.stringify({ snapshot, stopped: !!opts.stop }, null, 2));
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const snap = (snapshot ?? {});
|
|
872
|
+
console.log(chalk.green("Snapshot requested"));
|
|
873
|
+
if (snap["id"])
|
|
874
|
+
console.log(String(snap["id"]));
|
|
875
|
+
if (opts.stop)
|
|
876
|
+
console.log(chalk.dim("Sandbox pause requested."));
|
|
877
|
+
}));
|
|
878
|
+
const snapshots = sandbox
|
|
879
|
+
.command("snapshots")
|
|
880
|
+
.description("List, inspect, and delete Sandbox snapshots");
|
|
881
|
+
snapshots
|
|
882
|
+
.command("list <sandbox-id>")
|
|
883
|
+
.description("List snapshots for a sandbox")
|
|
884
|
+
.option("--json", "Output as JSON")
|
|
885
|
+
.action((id, opts) => runAction(() => getAndPrint(`/sandboxes/${enc(id)}/snapshots`, opts)));
|
|
886
|
+
snapshots
|
|
887
|
+
.command("get <sandbox-id> <snapshot-id>")
|
|
888
|
+
.description("Get a sandbox snapshot")
|
|
889
|
+
.option("--json", "Output as JSON")
|
|
890
|
+
.action((id, snapshotId, opts) => runAction(() => getAndPrint(`/sandboxes/${enc(id)}/snapshots/${enc(snapshotId)}`, opts)));
|
|
891
|
+
snapshots
|
|
892
|
+
.command("delete <sandbox-id> <snapshot-id>")
|
|
893
|
+
.alias("rm")
|
|
894
|
+
.description("Delete a sandbox snapshot")
|
|
895
|
+
.option("--json", "Output as JSON")
|
|
896
|
+
.action((id, snapshotId, opts) => runAction(() => deleteAndPrint(`/sandboxes/${enc(id)}/snapshots/${enc(snapshotId)}`, opts)));
|
|
528
897
|
// delete-checkpoint / delete-snapshot
|
|
529
898
|
sandbox
|
|
530
899
|
.command("delete-checkpoint <sandbox-id> <checkpoint-id>")
|
|
@@ -702,8 +1071,7 @@ async function runSandboxSsh(id, opts) {
|
|
|
702
1071
|
server.close();
|
|
703
1072
|
process.exit(exitCode);
|
|
704
1073
|
}
|
|
705
|
-
|
|
706
|
-
async function runSandboxPortForward(id, remotePort, localPort, json) {
|
|
1074
|
+
async function runSandboxPortForward(id, opts) {
|
|
707
1075
|
const config = loadConfig();
|
|
708
1076
|
const apiKey = config.api_key;
|
|
709
1077
|
if (!apiKey)
|
|
@@ -711,7 +1079,7 @@ async function runSandboxPortForward(id, remotePort, localPort, json) {
|
|
|
711
1079
|
const endpoint = config.endpoint ?? "https://api.miosa.ai";
|
|
712
1080
|
const base = endpoint.replace(/\/$/, "");
|
|
713
1081
|
const wsBase = base.replace(/^https?/, (p) => (p === "https" ? "wss" : "ws"));
|
|
714
|
-
const wsUrl = `${wsBase}/api/v1/sandboxes/${encodeURIComponent(id)}/port-tunnel/${remotePort}`;
|
|
1082
|
+
const wsUrl = `${wsBase}/api/v1/sandboxes/${encodeURIComponent(id)}/port-tunnel/${opts.remotePort}`;
|
|
715
1083
|
const stats = { active: 0, total: 0, bytesIn: 0, bytesOut: 0 };
|
|
716
1084
|
const server = createServer((socket) => {
|
|
717
1085
|
stats.active++;
|
|
@@ -748,27 +1116,34 @@ async function runSandboxPortForward(id, remotePort, localPort, json) {
|
|
|
748
1116
|
await new Promise((resolve, reject) => {
|
|
749
1117
|
server.on("error", (err) => {
|
|
750
1118
|
if (err.code === "EADDRINUSE") {
|
|
751
|
-
reject(new Error(`Port ${localPort} is already in use. Choose another with --local.`));
|
|
1119
|
+
reject(new Error(`Port ${opts.localPort} is already in use. Choose another with --local.`));
|
|
752
1120
|
}
|
|
753
1121
|
else {
|
|
754
1122
|
reject(err);
|
|
755
1123
|
}
|
|
756
1124
|
});
|
|
757
|
-
server.listen(localPort, "127.0.0.1", resolve);
|
|
1125
|
+
server.listen(opts.localPort, "127.0.0.1", resolve);
|
|
758
1126
|
});
|
|
759
|
-
|
|
1127
|
+
let localProbe = null;
|
|
1128
|
+
if (opts.wait) {
|
|
1129
|
+
localProbe = await waitForLocalHttp(opts.localPort, opts.probePath, opts.timeoutSec);
|
|
1130
|
+
}
|
|
1131
|
+
if (opts.json) {
|
|
760
1132
|
console.log(JSON.stringify({
|
|
761
1133
|
sandbox_id: id,
|
|
762
|
-
remote_port: remotePort,
|
|
763
|
-
local_port: localPort,
|
|
1134
|
+
remote_port: opts.remotePort,
|
|
1135
|
+
local_port: opts.localPort,
|
|
1136
|
+
ready: localProbe?.ok ?? !opts.wait,
|
|
1137
|
+
status: localProbe?.status ?? null,
|
|
1138
|
+
latency_ms: localProbe?.latency_ms ?? null,
|
|
764
1139
|
}));
|
|
765
1140
|
}
|
|
766
1141
|
else {
|
|
767
|
-
console.log(`${chalk.green("Forwarding")} ${chalk.cyan(`localhost:${localPort}`)} ${chalk.dim("→")} sandbox ${chalk.cyan(id)}:${chalk.bold(String(remotePort))}`);
|
|
1142
|
+
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
1143
|
console.log(chalk.dim("Press Ctrl+C to close.\n"));
|
|
769
1144
|
}
|
|
770
1145
|
const ticker = setInterval(() => {
|
|
771
|
-
if (json || stats.active === 0)
|
|
1146
|
+
if (opts.json || stats.active === 0)
|
|
772
1147
|
return;
|
|
773
1148
|
process.stderr.write(chalk.dim(`\r[${new Date().toLocaleTimeString()}] connections: ${stats.active} ↑ ${formatBytes(stats.bytesIn)} ↓ ${formatBytes(stats.bytesOut)} `));
|
|
774
1149
|
}, 5_000);
|
|
@@ -777,6 +1152,514 @@ async function runSandboxPortForward(id, remotePort, localPort, json) {
|
|
|
777
1152
|
process.stderr.write("\n");
|
|
778
1153
|
server.close();
|
|
779
1154
|
}
|
|
1155
|
+
async function showSandboxWithPreview(sandboxId, port, probePath) {
|
|
1156
|
+
const c = client();
|
|
1157
|
+
const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
|
|
1158
|
+
let preview = null;
|
|
1159
|
+
try {
|
|
1160
|
+
preview = await previewSandbox(sandboxId, port, {
|
|
1161
|
+
wait: false,
|
|
1162
|
+
timeout: 1,
|
|
1163
|
+
probePath,
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
catch (err) {
|
|
1167
|
+
preview = {
|
|
1168
|
+
url: "",
|
|
1169
|
+
ready: false,
|
|
1170
|
+
status: null,
|
|
1171
|
+
latency_ms: null,
|
|
1172
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
return {
|
|
1176
|
+
...sandbox,
|
|
1177
|
+
ready: preview?.ready ?? false,
|
|
1178
|
+
preview: {
|
|
1179
|
+
url: preview?.url || null,
|
|
1180
|
+
port,
|
|
1181
|
+
route_ready: Boolean(preview?.url),
|
|
1182
|
+
tls_ready: preview?.ready ?? false,
|
|
1183
|
+
last_status: preview?.status ?? null,
|
|
1184
|
+
latency_ms: preview?.latency_ms ?? null,
|
|
1185
|
+
error: preview?.error,
|
|
1186
|
+
},
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
async function previewSandbox(sandboxId, port, opts) {
|
|
1190
|
+
const c = client();
|
|
1191
|
+
const exposed = unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), {
|
|
1192
|
+
port,
|
|
1193
|
+
title: "app preview",
|
|
1194
|
+
}));
|
|
1195
|
+
const url = extractUrl(exposed);
|
|
1196
|
+
if (!url)
|
|
1197
|
+
throw new UserError("Sandbox expose did not return a preview URL.");
|
|
1198
|
+
const edge = opts.wait
|
|
1199
|
+
? await waitForPublicPreview(url, opts.probePath, opts.timeout)
|
|
1200
|
+
: await probePublicPreview(url, opts.probePath);
|
|
1201
|
+
return {
|
|
1202
|
+
url,
|
|
1203
|
+
ready: edge.ok,
|
|
1204
|
+
status: edge.status,
|
|
1205
|
+
latency_ms: edge.latency_ms ?? null,
|
|
1206
|
+
error: edge.error,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
async function waitSandboxReady(sandboxId, port, probePath, timeoutSec) {
|
|
1210
|
+
const c = client();
|
|
1211
|
+
await waitForSandboxRunning(c, sandboxId, Math.min(timeoutSec, 120));
|
|
1212
|
+
const internal = await waitForInternalHttp(c, sandboxId, port, probePath, Math.min(timeoutSec, 60));
|
|
1213
|
+
const preview = await previewSandbox(sandboxId, port, {
|
|
1214
|
+
wait: true,
|
|
1215
|
+
timeout: timeoutSec,
|
|
1216
|
+
probePath,
|
|
1217
|
+
});
|
|
1218
|
+
return {
|
|
1219
|
+
sandbox_id: sandboxId,
|
|
1220
|
+
port,
|
|
1221
|
+
internal_status: internal.status,
|
|
1222
|
+
...preview,
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
async function startSandboxService(sandboxId, name, opts) {
|
|
1226
|
+
validateServiceName(name);
|
|
1227
|
+
const c = client();
|
|
1228
|
+
const logPath = serviceLogPath(name);
|
|
1229
|
+
const pidPath = servicePidPath(name);
|
|
1230
|
+
const command = [
|
|
1231
|
+
"mkdir -p /tmp/miosa-services",
|
|
1232
|
+
`if [ -f ${shellQuote(pidPath)} ]; then kill $(cat ${shellQuote(pidPath)}) >/dev/null 2>&1 || true; fi`,
|
|
1233
|
+
`nohup sh -lc ${shellQuote(opts.cmd)} > ${shellQuote(logPath)} 2>&1 & echo $! > ${shellQuote(pidPath)}`,
|
|
1234
|
+
`cat ${shellQuote(pidPath)}`,
|
|
1235
|
+
].join(" && ");
|
|
1236
|
+
const exec = await execSandbox(c, sandboxId, command, opts.cwd, 15);
|
|
1237
|
+
const pid = String(exec["stdout"] ?? "").trim().split(/\s+/).pop() ?? "";
|
|
1238
|
+
let previewUrl = null;
|
|
1239
|
+
if (opts.port != null) {
|
|
1240
|
+
const preview = await previewSandbox(sandboxId, opts.port, {
|
|
1241
|
+
wait: false,
|
|
1242
|
+
timeout: 1,
|
|
1243
|
+
probePath: "/",
|
|
1244
|
+
});
|
|
1245
|
+
previewUrl = preview.url;
|
|
1246
|
+
}
|
|
1247
|
+
return {
|
|
1248
|
+
sandbox_id: sandboxId,
|
|
1249
|
+
name,
|
|
1250
|
+
pid,
|
|
1251
|
+
cwd: opts.cwd,
|
|
1252
|
+
command: opts.cmd,
|
|
1253
|
+
port: opts.port ?? null,
|
|
1254
|
+
log_path: logPath,
|
|
1255
|
+
preview_url: previewUrl,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
function serviceLogPath(name) {
|
|
1259
|
+
return `/tmp/miosa-services/${name}.log`;
|
|
1260
|
+
}
|
|
1261
|
+
function servicePidPath(name) {
|
|
1262
|
+
return `/tmp/miosa-services/${name}.pid`;
|
|
1263
|
+
}
|
|
1264
|
+
function validateServiceName(name) {
|
|
1265
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
|
|
1266
|
+
throw new UserError(`Invalid service name: ${name}`, "Use only letters, numbers, dot, dash, and underscore.");
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
async function deploySandbox(localDir, opts) {
|
|
1270
|
+
const sourceDir = path.resolve(localDir);
|
|
1271
|
+
if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
|
|
1272
|
+
throw new UserError(`Local directory not found: ${sourceDir}`);
|
|
1273
|
+
}
|
|
1274
|
+
const c = client();
|
|
1275
|
+
const detection = detectFramework(sourceDir);
|
|
1276
|
+
const port = opts.port ?? opts.publishPort ?? detection?.port ?? 5173;
|
|
1277
|
+
const start = opts.start ?? defaultStartCommand(detection?.framework, port);
|
|
1278
|
+
const installCommand = opts.install === false
|
|
1279
|
+
? null
|
|
1280
|
+
: opts.installCommand ?? defaultInstallCommand(sourceDir);
|
|
1281
|
+
const sandboxId = opts.sandbox ??
|
|
1282
|
+
(await createSandboxForDeploy(c, opts.template ?? "miosa-sandbox", opts.name));
|
|
1283
|
+
await waitForSandboxRunning(c, sandboxId, Math.min(opts.timeout, 120));
|
|
1284
|
+
const archivePath = createDeployArchive(sourceDir);
|
|
1285
|
+
const remoteArchive = `/tmp/miosa-deploy-${Date.now()}.tgz`;
|
|
1286
|
+
try {
|
|
1287
|
+
await uploadFileToSandbox(c, sandboxId, archivePath, remoteArchive);
|
|
1288
|
+
}
|
|
1289
|
+
finally {
|
|
1290
|
+
fs.rmSync(archivePath, { force: true });
|
|
1291
|
+
}
|
|
1292
|
+
await execSandbox(c, sandboxId, `mkdir -p /workspace && tar -xzf ${shellQuote(remoteArchive)} -C /workspace`, "/");
|
|
1293
|
+
if (installCommand) {
|
|
1294
|
+
await execSandbox(c, sandboxId, installCommand, "/workspace", opts.timeout);
|
|
1295
|
+
}
|
|
1296
|
+
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");
|
|
1297
|
+
const internal = await waitForInternalHttp(c, sandboxId, port, opts.probePath, Math.min(opts.timeout, 60));
|
|
1298
|
+
const exposed = await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), { port, title: "app preview" });
|
|
1299
|
+
const previewUrl = extractUrl(unwrap(exposed));
|
|
1300
|
+
if (!previewUrl) {
|
|
1301
|
+
throw new UserError("Sandbox expose did not return a preview URL.");
|
|
1302
|
+
}
|
|
1303
|
+
const edge = opts.wait
|
|
1304
|
+
? await waitForPublicPreview(previewUrl, opts.probePath, opts.timeout)
|
|
1305
|
+
: { ok: false, status: null };
|
|
1306
|
+
return {
|
|
1307
|
+
sandbox_id: sandboxId,
|
|
1308
|
+
port,
|
|
1309
|
+
preview_url: previewUrl,
|
|
1310
|
+
preview_ready: edge.ok,
|
|
1311
|
+
internal_status: internal.status,
|
|
1312
|
+
edge_status: edge.status,
|
|
1313
|
+
latency_ms: edge.latency_ms ?? null,
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
async function doctorSandbox(sandboxId, port, probePath) {
|
|
1317
|
+
const c = client();
|
|
1318
|
+
const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
|
|
1319
|
+
const internal = await probeInternalHttp(c, sandboxId, port, probePath);
|
|
1320
|
+
let exposeData = {};
|
|
1321
|
+
try {
|
|
1322
|
+
exposeData = unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), {
|
|
1323
|
+
port,
|
|
1324
|
+
title: "doctor probe",
|
|
1325
|
+
}));
|
|
1326
|
+
}
|
|
1327
|
+
catch (err) {
|
|
1328
|
+
exposeData = { error: err instanceof Error ? err.message : String(err) };
|
|
1329
|
+
}
|
|
1330
|
+
const previewUrl = extractUrl(exposeData);
|
|
1331
|
+
const edge = previewUrl
|
|
1332
|
+
? await probePublicPreview(previewUrl, probePath)
|
|
1333
|
+
: { ok: false, status: null, error: "No preview URL returned" };
|
|
1334
|
+
return {
|
|
1335
|
+
sandbox_id: sandboxId,
|
|
1336
|
+
sandbox_state: sandbox["state"] ?? sandbox["status"] ?? "unknown",
|
|
1337
|
+
process: internal.ok ? "listening" : "not_ready",
|
|
1338
|
+
route: previewUrl ? "created" : "missing",
|
|
1339
|
+
tls: edge.error && /tls|certificate|ssl/i.test(edge.error) ? "not_ready" : previewUrl ? "checked" : "unknown",
|
|
1340
|
+
internal_probe: internal,
|
|
1341
|
+
edge_probe: edge,
|
|
1342
|
+
preview_ready: edge.ok,
|
|
1343
|
+
preview_url: previewUrl,
|
|
1344
|
+
expose: exposeData,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
function renderDoctorReport(report) {
|
|
1348
|
+
console.log();
|
|
1349
|
+
console.log(chalk.bold("Sandbox Doctor"));
|
|
1350
|
+
console.log();
|
|
1351
|
+
console.log(` ${chalk.bold("Sandbox")} ${report["sandbox_id"]}`);
|
|
1352
|
+
console.log(` ${chalk.bold("State")} ${report["sandbox_state"]}`);
|
|
1353
|
+
console.log(` ${chalk.bold("Process")} ${report["process"]}`);
|
|
1354
|
+
console.log(` ${chalk.bold("Route")} ${report["route"]}`);
|
|
1355
|
+
console.log(` ${chalk.bold("TLS/edge")} ${report["tls"]}`);
|
|
1356
|
+
console.log(` ${chalk.bold("Preview ready")} ${report["preview_ready"] ? chalk.green("yes") : chalk.red("no")}`);
|
|
1357
|
+
if (report["preview_url"]) {
|
|
1358
|
+
console.log(` ${chalk.bold("Preview URL")} ${chalk.cyan(String(report["preview_url"]))}`);
|
|
1359
|
+
}
|
|
1360
|
+
console.log();
|
|
1361
|
+
if (!report["preview_ready"]) {
|
|
1362
|
+
console.log(chalk.yellow(" Preview is not externally ready yet. Internal app health and edge probe details are in --json output."));
|
|
1363
|
+
console.log();
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
async function createSandboxForDeploy(c, template, name) {
|
|
1367
|
+
const body = { template_id: template };
|
|
1368
|
+
if (name)
|
|
1369
|
+
body["name"] = name;
|
|
1370
|
+
const created = unwrap(await c.apiPost(apiPath("/sandboxes"), body));
|
|
1371
|
+
const sandboxId = typeof created["id"] === "string" ? created["id"] : "";
|
|
1372
|
+
if (!sandboxId)
|
|
1373
|
+
throw new UserError("Sandbox create did not return an id.");
|
|
1374
|
+
return sandboxId;
|
|
1375
|
+
}
|
|
1376
|
+
async function waitForSandboxRunning(c, sandboxId, timeoutSec) {
|
|
1377
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
1378
|
+
while (Date.now() < deadline) {
|
|
1379
|
+
const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
|
|
1380
|
+
const state = String(sandbox["state"] ?? sandbox["status"] ?? "").toLowerCase();
|
|
1381
|
+
if (state === "running" || state === "active")
|
|
1382
|
+
return;
|
|
1383
|
+
if (state === "error" || state === "failed") {
|
|
1384
|
+
throw new UserError(`Sandbox ${sandboxId} entered ${state} state.`);
|
|
1385
|
+
}
|
|
1386
|
+
await sleep(1500);
|
|
1387
|
+
}
|
|
1388
|
+
throw new UserError(`Sandbox ${sandboxId} did not become running within ${timeoutSec}s.`);
|
|
1389
|
+
}
|
|
1390
|
+
function createDeployArchive(sourceDir) {
|
|
1391
|
+
const archivePath = path.join(os.tmpdir(), `miosa-deploy-${process.pid}-${Date.now()}.tgz`);
|
|
1392
|
+
const result = spawnSync("tar", [
|
|
1393
|
+
"--exclude",
|
|
1394
|
+
".git",
|
|
1395
|
+
"--exclude",
|
|
1396
|
+
"node_modules",
|
|
1397
|
+
"--exclude",
|
|
1398
|
+
".next",
|
|
1399
|
+
"--exclude",
|
|
1400
|
+
"dist",
|
|
1401
|
+
"-czf",
|
|
1402
|
+
archivePath,
|
|
1403
|
+
"-C",
|
|
1404
|
+
sourceDir,
|
|
1405
|
+
".",
|
|
1406
|
+
], { stdio: "pipe" });
|
|
1407
|
+
if (result.status !== 0) {
|
|
1408
|
+
throw new UserError(`Could not archive ${sourceDir}: ${result.stderr.toString().trim() || "tar failed"}`);
|
|
1409
|
+
}
|
|
1410
|
+
return archivePath;
|
|
1411
|
+
}
|
|
1412
|
+
async function uploadFileToSandbox(c, sandboxId, localPath, remotePath) {
|
|
1413
|
+
await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/files`), {
|
|
1414
|
+
path: remotePath,
|
|
1415
|
+
content: fs.readFileSync(localPath).toString("base64"),
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
async function uploadDirToSandbox(sandboxId, localDir, remoteDir, opts) {
|
|
1419
|
+
const sourceDir = path.resolve(localDir);
|
|
1420
|
+
if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
|
|
1421
|
+
throw new UserError(`Local directory not found: ${sourceDir}`);
|
|
1422
|
+
}
|
|
1423
|
+
const c = client();
|
|
1424
|
+
const archivePath = createDeployArchive(sourceDir);
|
|
1425
|
+
const remoteArchive = `/tmp/miosa-upload-${Date.now()}.tgz`;
|
|
1426
|
+
try {
|
|
1427
|
+
await uploadFileToSandbox(c, sandboxId, archivePath, remoteArchive);
|
|
1428
|
+
const clean = opts.delete
|
|
1429
|
+
? `rm -rf ${shellQuote(remoteDir)} && mkdir -p ${shellQuote(remoteDir)}`
|
|
1430
|
+
: `mkdir -p ${shellQuote(remoteDir)}`;
|
|
1431
|
+
await execSandbox(c, sandboxId, `${clean} && tar -xzf ${shellQuote(remoteArchive)} -C ${shellQuote(remoteDir)} && rm -f ${shellQuote(remoteArchive)}`, "/");
|
|
1432
|
+
}
|
|
1433
|
+
finally {
|
|
1434
|
+
fs.rmSync(archivePath, { force: true });
|
|
1435
|
+
}
|
|
1436
|
+
return {
|
|
1437
|
+
sandbox_id: sandboxId,
|
|
1438
|
+
local_dir: sourceDir,
|
|
1439
|
+
remote_dir: remoteDir,
|
|
1440
|
+
files_label: path.basename(sourceDir) || sourceDir,
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
async function execSandbox(c, sandboxId, command, cwd, timeout) {
|
|
1444
|
+
const body = { command: commandInCwd(command, cwd) };
|
|
1445
|
+
if (cwd) {
|
|
1446
|
+
body["cwd"] = cwd;
|
|
1447
|
+
body["dir"] = cwd;
|
|
1448
|
+
}
|
|
1449
|
+
if (timeout != null)
|
|
1450
|
+
body["timeout"] = timeout;
|
|
1451
|
+
const result = unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/exec`), body));
|
|
1452
|
+
const exitCode = Number(result["exit_code"] ?? 0);
|
|
1453
|
+
if (exitCode !== 0) {
|
|
1454
|
+
throw new UserError(`Sandbox command failed with exit code ${exitCode}: ${command}`, String(result["stderr"] ?? result["stdout"] ?? ""));
|
|
1455
|
+
}
|
|
1456
|
+
return result;
|
|
1457
|
+
}
|
|
1458
|
+
async function waitForInternalHttp(c, sandboxId, port, probePath, timeoutSec) {
|
|
1459
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
1460
|
+
let last = { ok: false, status: null };
|
|
1461
|
+
while (Date.now() < deadline) {
|
|
1462
|
+
last = await probeInternalHttp(c, sandboxId, port, probePath);
|
|
1463
|
+
if (last.ok)
|
|
1464
|
+
return last;
|
|
1465
|
+
await sleep(1500);
|
|
1466
|
+
}
|
|
1467
|
+
throw new UserError(`App did not answer inside the sandbox on port ${port} within ${timeoutSec}s.`, last.error);
|
|
1468
|
+
}
|
|
1469
|
+
async function probeInternalHttp(c, sandboxId, port, probePath) {
|
|
1470
|
+
try {
|
|
1471
|
+
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);
|
|
1472
|
+
const status = Number(String(result["stdout"] ?? "").trim().split(/\s+/)[0]);
|
|
1473
|
+
return { ok: Number.isFinite(status) && status >= 200 && status < 400, status };
|
|
1474
|
+
}
|
|
1475
|
+
catch (err) {
|
|
1476
|
+
return { ok: false, status: null, error: err instanceof Error ? err.message : String(err) };
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
async function waitForPublicPreview(previewUrl, probePath, timeoutSec) {
|
|
1480
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
1481
|
+
let last = { ok: false, status: null };
|
|
1482
|
+
while (Date.now() < deadline) {
|
|
1483
|
+
last = await probePublicPreview(previewUrl, probePath);
|
|
1484
|
+
if (last.ok)
|
|
1485
|
+
return last;
|
|
1486
|
+
await sleep(2000);
|
|
1487
|
+
}
|
|
1488
|
+
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));
|
|
1489
|
+
}
|
|
1490
|
+
async function probePublicPreview(previewUrl, probePath) {
|
|
1491
|
+
try {
|
|
1492
|
+
const url = new URL(previewUrl);
|
|
1493
|
+
url.pathname = joinUrlPath(url.pathname, probePath);
|
|
1494
|
+
const t0 = Date.now();
|
|
1495
|
+
const res = await fetch(url, { method: "GET", redirect: "manual" });
|
|
1496
|
+
return {
|
|
1497
|
+
ok: (res.status >= 200 && res.status < 400) || res.status === 401 || res.status === 403,
|
|
1498
|
+
status: res.status,
|
|
1499
|
+
latency_ms: Date.now() - t0,
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
catch (err) {
|
|
1503
|
+
return { ok: false, status: null, error: err instanceof Error ? err.message : String(err) };
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
async function waitForLocalHttp(localPort, probePath, timeoutSec) {
|
|
1507
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
1508
|
+
let last = { ok: false, status: null };
|
|
1509
|
+
const url = `http://127.0.0.1:${localPort}${probePath.startsWith("/") ? probePath : `/${probePath}`}`;
|
|
1510
|
+
while (Date.now() < deadline) {
|
|
1511
|
+
last = await probePublicPreview(url, "/");
|
|
1512
|
+
if (last.ok)
|
|
1513
|
+
return last;
|
|
1514
|
+
await sleep(1000);
|
|
1515
|
+
}
|
|
1516
|
+
throw new UserError(`Local forwarded URL did not answer within ${timeoutSec}s.`, last.error ?? (last.status ? `Last HTTP status: ${last.status}` : undefined));
|
|
1517
|
+
}
|
|
1518
|
+
function defaultInstallCommand(sourceDir) {
|
|
1519
|
+
if (fs.existsSync(path.join(sourceDir, "package.json")))
|
|
1520
|
+
return "npm install";
|
|
1521
|
+
if (fs.existsSync(path.join(sourceDir, "requirements.txt")))
|
|
1522
|
+
return "pip install -r requirements.txt";
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
function defaultStartCommand(framework, port) {
|
|
1526
|
+
if (framework === "nextjs")
|
|
1527
|
+
return `npm run dev -- -H 0.0.0.0 -p ${port}`;
|
|
1528
|
+
if (framework === "vite-react")
|
|
1529
|
+
return `npm run dev -- --host 0.0.0.0 --port ${port}`;
|
|
1530
|
+
if (framework === "static")
|
|
1531
|
+
return `python3 -m http.server ${port} --bind 0.0.0.0`;
|
|
1532
|
+
return `npm run dev -- --host 0.0.0.0 --port ${port}`;
|
|
1533
|
+
}
|
|
1534
|
+
function extractUrl(value) {
|
|
1535
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1536
|
+
return null;
|
|
1537
|
+
const row = value;
|
|
1538
|
+
for (const key of ["url", "preview_url", "public_url"]) {
|
|
1539
|
+
if (typeof row[key] === "string" && row[key])
|
|
1540
|
+
return row[key];
|
|
1541
|
+
}
|
|
1542
|
+
return null;
|
|
1543
|
+
}
|
|
1544
|
+
function isSandboxTarget(value) {
|
|
1545
|
+
const idx = value.indexOf(":");
|
|
1546
|
+
if (idx <= 0)
|
|
1547
|
+
return false;
|
|
1548
|
+
return value.slice(idx + 1).startsWith("/");
|
|
1549
|
+
}
|
|
1550
|
+
function parseSandboxTarget(target) {
|
|
1551
|
+
const idx = target.indexOf(":");
|
|
1552
|
+
if (idx <= 0 || idx === target.length - 1) {
|
|
1553
|
+
throw new UserError(`Invalid sandbox target: ${target}`, "Use the form <sandbox-id>:/absolute/path");
|
|
1554
|
+
}
|
|
1555
|
+
const sandboxId = target.slice(0, idx);
|
|
1556
|
+
const remotePath = target.slice(idx + 1);
|
|
1557
|
+
if (!remotePath.startsWith("/")) {
|
|
1558
|
+
throw new UserError(`Invalid remote path: ${remotePath}`, "Sandbox copy targets must use an absolute path, e.g. sbx_123:/workspace");
|
|
1559
|
+
}
|
|
1560
|
+
return { sandboxId, remotePath };
|
|
1561
|
+
}
|
|
1562
|
+
async function downloadSandboxPath(sandboxId, remotePath, localTarget) {
|
|
1563
|
+
const c = client();
|
|
1564
|
+
const kind = await remotePathKind(c, sandboxId, remotePath);
|
|
1565
|
+
if (kind === "directory") {
|
|
1566
|
+
const remoteArchive = `/tmp/miosa-copy-${Date.now()}.tgz`;
|
|
1567
|
+
await execSandbox(c, sandboxId, `tar -czf ${shellQuote(remoteArchive)} -C ${shellQuote(remotePath)} .`, "/");
|
|
1568
|
+
const archiveBytes = await readSandboxFile(c, sandboxId, remoteArchive);
|
|
1569
|
+
const localDir = path.resolve(localTarget);
|
|
1570
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
1571
|
+
const localArchive = path.join(os.tmpdir(), `miosa-copy-${process.pid}-${Date.now()}.tgz`);
|
|
1572
|
+
fs.writeFileSync(localArchive, archiveBytes);
|
|
1573
|
+
try {
|
|
1574
|
+
const result = spawnSync("tar", ["-xzf", localArchive, "-C", localDir], {
|
|
1575
|
+
stdio: "pipe",
|
|
1576
|
+
});
|
|
1577
|
+
if (result.status !== 0) {
|
|
1578
|
+
throw new UserError(`Could not extract ${remotePath}: ${result.stderr.toString().trim() || "tar failed"}`);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
finally {
|
|
1582
|
+
fs.rmSync(localArchive, { force: true });
|
|
1583
|
+
await execSandbox(c, sandboxId, `rm -f ${shellQuote(remoteArchive)}`, "/").catch(() => ({}));
|
|
1584
|
+
}
|
|
1585
|
+
return {
|
|
1586
|
+
sandbox_id: sandboxId,
|
|
1587
|
+
remote_path: remotePath,
|
|
1588
|
+
local_path: localDir,
|
|
1589
|
+
type: "directory",
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
const bytes = await readSandboxFile(c, sandboxId, remotePath);
|
|
1593
|
+
const localPath = fs.existsSync(localTarget) && fs.statSync(localTarget).isDirectory()
|
|
1594
|
+
? path.join(localTarget, path.basename(remotePath))
|
|
1595
|
+
: localTarget;
|
|
1596
|
+
fs.mkdirSync(path.dirname(path.resolve(localPath)), { recursive: true });
|
|
1597
|
+
fs.writeFileSync(localPath, bytes);
|
|
1598
|
+
return {
|
|
1599
|
+
sandbox_id: sandboxId,
|
|
1600
|
+
remote_path: remotePath,
|
|
1601
|
+
local_path: path.resolve(localPath),
|
|
1602
|
+
type: "file",
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
async function remotePathKind(c, sandboxId, remotePath) {
|
|
1606
|
+
const result = await execSandbox(c, sandboxId, `if [ -d ${shellQuote(remotePath)} ]; then echo directory; elif [ -f ${shellQuote(remotePath)} ]; then echo file; else exit 2; fi`, "/");
|
|
1607
|
+
const kind = String(result["stdout"] ?? "").trim();
|
|
1608
|
+
if (kind === "directory")
|
|
1609
|
+
return "directory";
|
|
1610
|
+
return "file";
|
|
1611
|
+
}
|
|
1612
|
+
async function readSandboxFile(c, sandboxId, remotePath) {
|
|
1613
|
+
const encoded = enc(remotePath.replace(/^\//, ""));
|
|
1614
|
+
const result = await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}/files/${encoded}`));
|
|
1615
|
+
const data = result !== null && typeof result === "object" && !Array.isArray(result)
|
|
1616
|
+
? (result["data"] ?? result)
|
|
1617
|
+
: result;
|
|
1618
|
+
if (data !== null &&
|
|
1619
|
+
typeof data === "object" &&
|
|
1620
|
+
!Array.isArray(data) &&
|
|
1621
|
+
typeof data["content"] === "string") {
|
|
1622
|
+
return Buffer.from(data["content"], "base64");
|
|
1623
|
+
}
|
|
1624
|
+
throw new UserError(`Could not read sandbox file: ${remotePath}`);
|
|
1625
|
+
}
|
|
1626
|
+
function commandInCwd(command, cwd) {
|
|
1627
|
+
if (!cwd)
|
|
1628
|
+
return command;
|
|
1629
|
+
return `cd ${shellQuote(cwd)} && ${command}`;
|
|
1630
|
+
}
|
|
1631
|
+
function backgroundCommand(command) {
|
|
1632
|
+
if (!command.trim())
|
|
1633
|
+
return command;
|
|
1634
|
+
const logPath = `/tmp/miosa-bg-${Date.now()}.log`;
|
|
1635
|
+
return `nohup sh -lc ${shellQuote(command)} > ${shellQuote(logPath)} 2>&1 & echo $!`;
|
|
1636
|
+
}
|
|
1637
|
+
function collectOption(value, previous) {
|
|
1638
|
+
return [...previous, value];
|
|
1639
|
+
}
|
|
1640
|
+
function parseEnvPairs(pairs) {
|
|
1641
|
+
const env = {};
|
|
1642
|
+
for (const pair of pairs) {
|
|
1643
|
+
const idx = pair.indexOf("=");
|
|
1644
|
+
if (idx <= 0) {
|
|
1645
|
+
throw new UserError(`Invalid --env value: ${pair}`, "Use KEY=VALUE, for example --env DATABASE_URL=postgresql://...");
|
|
1646
|
+
}
|
|
1647
|
+
env[pair.slice(0, idx)] = pair.slice(idx + 1);
|
|
1648
|
+
}
|
|
1649
|
+
return env;
|
|
1650
|
+
}
|
|
1651
|
+
function shellQuote(value) {
|
|
1652
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
1653
|
+
}
|
|
1654
|
+
function joinUrlPath(basePath, probePath) {
|
|
1655
|
+
const probe = probePath.startsWith("/") ? probePath : `/${probePath}`;
|
|
1656
|
+
if (!basePath || basePath === "/")
|
|
1657
|
+
return probe;
|
|
1658
|
+
return `${basePath.replace(/\/$/, "")}${probe}`;
|
|
1659
|
+
}
|
|
1660
|
+
function sleep(ms) {
|
|
1661
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1662
|
+
}
|
|
780
1663
|
// ── shared helpers ─────────────────────────────────────────────────────────
|
|
781
1664
|
function pickFreePort() {
|
|
782
1665
|
return new Promise((resolve, reject) => {
|