@miosa/cli 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/miosa.js +0 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -1
- package/dist/client.js.map +1 -1
- 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/login.d.ts.map +1 -1
- package/dist/commands/login.js +10 -2
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +277 -1445
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/sandbox.d.ts.map +1 -1
- package/dist/commands/sandbox.js +1084 -23
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/whoami.js +2 -2
- package/dist/commands/whoami.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/miosa-linux-x64 +0 -0
- package/dist/tui/dashboard.d.ts.map +1 -1
- package/dist/tui/dashboard.js +1 -1
- package/dist/tui/dashboard.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/commands/sandbox.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { createServer } from "node:net";
|
|
4
6
|
import chalk from "chalk";
|
|
7
|
+
import WebSocket from "ws";
|
|
8
|
+
import { detectFramework } from "../framework-detector.js";
|
|
5
9
|
import { addDataOption, client, apiPath, deleteAndPrint, enc, getAndPrint, postAndPrint, printValue, runAction, unwrap, } from "./enterprise-util.js";
|
|
6
10
|
import { loadConfig } from "../config.js";
|
|
7
11
|
import { handleError } from "./util.js";
|
|
8
12
|
import { renderTable } from "../ui/table.js";
|
|
9
13
|
import { formatDuration, hintBlock, icon, kvPanel, printBanner, printElapsed, } from "../ui/render.js";
|
|
14
|
+
import { formatBytes } from "../ui/progress.js";
|
|
15
|
+
import { UserError } from "../errors.js";
|
|
10
16
|
export function register(program) {
|
|
11
17
|
// -------------------------------------------------------------------------
|
|
12
18
|
// sandbox / sandboxes command group — built manually to avoid subcommand
|
|
@@ -80,10 +86,15 @@ export function register(program) {
|
|
|
80
86
|
sandbox
|
|
81
87
|
.command("show <sandbox-id>")
|
|
82
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", "/")
|
|
83
91
|
.option("--json", "Output as JSON")
|
|
84
92
|
.action((id, opts) => runAction(async () => {
|
|
85
93
|
if (opts.json) {
|
|
86
|
-
|
|
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));
|
|
87
98
|
return;
|
|
88
99
|
}
|
|
89
100
|
const raw = unwrap(await client().apiGet(apiPath(`/sandboxes/${enc(id)}`)));
|
|
@@ -134,6 +145,7 @@ export function register(program) {
|
|
|
134
145
|
console.log();
|
|
135
146
|
console.log(hintBlock("Try", [
|
|
136
147
|
`miosa sandbox exec ${str(sb["id"])} --command ...`,
|
|
148
|
+
`miosa sandbox preview ${str(sb["id"])} --port 5173 --wait`,
|
|
137
149
|
`miosa sandbox destroy ${str(sb["id"])}`,
|
|
138
150
|
]));
|
|
139
151
|
console.log();
|
|
@@ -154,6 +166,9 @@ export function register(program) {
|
|
|
154
166
|
.option("--memory <mb>", "Memory in MB", parseInt)
|
|
155
167
|
.option("--disk <mb>", "Disk size in MB", parseInt)
|
|
156
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", "/")
|
|
157
172
|
.option("--always-on", "Disable auto-destroy on idle"))
|
|
158
173
|
.option("--json", "Output as JSON")
|
|
159
174
|
.action((opts) => runAction(async () => {
|
|
@@ -182,12 +197,35 @@ export function register(program) {
|
|
|
182
197
|
body["timeout_sec"] = opts.timeout;
|
|
183
198
|
if (opts.alwaysOn)
|
|
184
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
|
+
}
|
|
185
224
|
if (opts.json) {
|
|
186
|
-
|
|
225
|
+
console.log(JSON.stringify(sb, null, 2));
|
|
187
226
|
return;
|
|
188
227
|
}
|
|
189
|
-
|
|
190
|
-
renderCreateSuccess(raw, Date.now() - t0);
|
|
228
|
+
renderCreateSuccess(sb, Date.now() - t0);
|
|
191
229
|
}));
|
|
192
230
|
// delete
|
|
193
231
|
sandbox
|
|
@@ -214,11 +252,95 @@ export function register(program) {
|
|
|
214
252
|
}
|
|
215
253
|
await postAndPrint(`/sandboxes/${enc(id)}/${enc(action)}`, opts, {});
|
|
216
254
|
}));
|
|
255
|
+
// stop — snapshot + pause (mirrors `box stop`)
|
|
256
|
+
sandbox
|
|
257
|
+
.command("stop <sandbox-id>")
|
|
258
|
+
.description("Snapshot the Sandbox and pause billing")
|
|
259
|
+
.option("--no-snapshot", "Skip the snapshot step (pause only)")
|
|
260
|
+
.option("--json", "Output as JSON")
|
|
261
|
+
.action((id, opts) => runAction(async () => {
|
|
262
|
+
if (opts.snapshot !== false) {
|
|
263
|
+
await postAndPrint(`/sandboxes/${enc(id)}/snapshots`, opts, {});
|
|
264
|
+
}
|
|
265
|
+
await postAndPrint(`/sandboxes/${enc(id)}/pause`, opts, {});
|
|
266
|
+
}));
|
|
267
|
+
// resume — direct shortcut (mirrors `box resume`)
|
|
268
|
+
sandbox
|
|
269
|
+
.command("resume <sandbox-id>")
|
|
270
|
+
.description("Resume a paused Sandbox")
|
|
271
|
+
.option("--json", "Output as JSON")
|
|
272
|
+
.action((id, opts) => runAction(() => postAndPrint(`/sandboxes/${enc(id)}/resume`, opts, {})));
|
|
273
|
+
// fork — clone from snapshot in one call (mirrors `box fork`)
|
|
274
|
+
sandbox
|
|
275
|
+
.command("fork <sandbox-id>")
|
|
276
|
+
.description("Clone (fork) a Sandbox from its current state")
|
|
277
|
+
.option("--name <name>", "Optional name for the forked Sandbox")
|
|
278
|
+
.option("--json", "Output as JSON")
|
|
279
|
+
.action((id, opts) => runAction(async () => {
|
|
280
|
+
const body = {};
|
|
281
|
+
if (opts.name)
|
|
282
|
+
body["name"] = opts.name;
|
|
283
|
+
await postAndPrint(`/sandboxes/${enc(id)}/fork`, opts, body);
|
|
284
|
+
}));
|
|
285
|
+
// desktop — open the Sandbox's web desktop URL (mirrors `box desktop`).
|
|
286
|
+
// Sandboxes are headless by default; only templates that expose a desktop
|
|
287
|
+
// port (e.g. `miosa-sandbox` w/ Kasm) emit a `preview_url`. We surface that.
|
|
288
|
+
sandbox
|
|
289
|
+
.command("desktop <sandbox-id>")
|
|
290
|
+
.description("Print the Sandbox's web desktop URL (when the template exposes one)")
|
|
291
|
+
.option("--json", "Output as JSON")
|
|
292
|
+
.action((id, opts) => runAction(async () => {
|
|
293
|
+
if (opts.json) {
|
|
294
|
+
await getAndPrint(`/sandboxes/${enc(id)}`, opts);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const sb = (await getAndPrint(`/sandboxes/${enc(id)}`, {
|
|
298
|
+
json: true,
|
|
299
|
+
}));
|
|
300
|
+
if (sb?.preview_url) {
|
|
301
|
+
console.log(sb.preview_url);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
console.error(chalk.yellow("No desktop URL on this Sandbox. Use `miosa desktop open <computer-id>` for Computers, or expose a port via `miosa sandbox` for headless previews."));
|
|
305
|
+
process.exitCode = 1;
|
|
306
|
+
}
|
|
307
|
+
}));
|
|
308
|
+
// prompt — invoke an in-Sandbox AI agent CLI (mirrors `box prompt`).
|
|
309
|
+
// Implemented as `exec claude/codex/claude-code <instruction>`.
|
|
310
|
+
sandbox
|
|
311
|
+
.command("prompt <sandbox-id> <instruction...>")
|
|
312
|
+
.description("Run an in-Sandbox AI agent (claude/codex) with the given instruction")
|
|
313
|
+
.option("--provider <name>", "AI provider: claude (default), codex, claude-code")
|
|
314
|
+
.option("--model <name>", "Provider-specific model name")
|
|
315
|
+
.option("--cwd <path>", "Working directory inside the Sandbox")
|
|
316
|
+
.option("--timeout <sec>", "Exec timeout in seconds", parseInt)
|
|
317
|
+
.option("--json", "Output as JSON")
|
|
318
|
+
.action((id, words, opts) => runAction(async () => {
|
|
319
|
+
const provider = opts.provider ?? "claude";
|
|
320
|
+
const allowedProviders = ["claude", "codex", "claude-code"];
|
|
321
|
+
if (!allowedProviders.includes(provider)) {
|
|
322
|
+
throw new Error(`Unsupported provider "${provider}". Use: ${allowedProviders.join(", ")}`);
|
|
323
|
+
}
|
|
324
|
+
const instruction = words.join(" ");
|
|
325
|
+
const modelFlag = opts.model
|
|
326
|
+
? ` --model ${`'${opts.model.replace(/'/g, "'\\''")}'`}`
|
|
327
|
+
: "";
|
|
328
|
+
const command = commandInCwd(`${provider}${modelFlag} ${shellQuote(instruction)}`, opts.cwd);
|
|
329
|
+
const body = { command };
|
|
330
|
+
if (opts.cwd) {
|
|
331
|
+
body["cwd"] = opts.cwd;
|
|
332
|
+
body["dir"] = opts.cwd;
|
|
333
|
+
}
|
|
334
|
+
if (opts.timeout != null)
|
|
335
|
+
body["timeout"] = opts.timeout;
|
|
336
|
+
await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
|
|
337
|
+
}));
|
|
217
338
|
// exec — positional command arg; --data body overrides when supplied
|
|
218
339
|
addDataOption(sandbox
|
|
219
340
|
.command("exec <sandbox-id> [command...]")
|
|
220
341
|
.description("Run a command inside a Sandbox (positional args joined as shell command)")
|
|
221
342
|
.option("--cwd <path>", "Working directory inside the Sandbox")
|
|
343
|
+
.option("--background", "Start the command in the background and return immediately")
|
|
222
344
|
.option("--timeout <sec>", "Exec timeout in seconds", parseInt))
|
|
223
345
|
.option("--json", "Output as JSON")
|
|
224
346
|
.action((id, words, opts) => runAction(async () => {
|
|
@@ -227,9 +349,14 @@ export function register(program) {
|
|
|
227
349
|
return;
|
|
228
350
|
}
|
|
229
351
|
const cmd = words.join(" ");
|
|
230
|
-
const
|
|
231
|
-
|
|
352
|
+
const effectiveCommand = opts.background ? backgroundCommand(cmd) : cmd;
|
|
353
|
+
const body = cmd
|
|
354
|
+
? { command: commandInCwd(effectiveCommand, opts.cwd) }
|
|
355
|
+
: {};
|
|
356
|
+
if (opts.cwd) {
|
|
232
357
|
body["cwd"] = opts.cwd;
|
|
358
|
+
body["dir"] = opts.cwd;
|
|
359
|
+
}
|
|
233
360
|
if (opts.timeout != null)
|
|
234
361
|
body["timeout"] = opts.timeout;
|
|
235
362
|
await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
|
|
@@ -239,6 +366,7 @@ export function register(program) {
|
|
|
239
366
|
.command("run <sandbox-id> [command...]")
|
|
240
367
|
.description("Run a command inside a Sandbox (alias for exec)")
|
|
241
368
|
.option("--cwd <path>", "Working directory inside the Sandbox")
|
|
369
|
+
.option("--background", "Start the command in the background and return immediately")
|
|
242
370
|
.option("--timeout <sec>", "Exec timeout in seconds", parseInt))
|
|
243
371
|
.option("--json", "Output as JSON")
|
|
244
372
|
.action((id, words, opts) => runAction(async () => {
|
|
@@ -247,13 +375,163 @@ export function register(program) {
|
|
|
247
375
|
return;
|
|
248
376
|
}
|
|
249
377
|
const cmd = words.join(" ");
|
|
250
|
-
const
|
|
251
|
-
|
|
378
|
+
const effectiveCommand = opts.background ? backgroundCommand(cmd) : cmd;
|
|
379
|
+
const body = cmd
|
|
380
|
+
? { command: commandInCwd(effectiveCommand, opts.cwd) }
|
|
381
|
+
: {};
|
|
382
|
+
if (opts.cwd) {
|
|
252
383
|
body["cwd"] = opts.cwd;
|
|
384
|
+
body["dir"] = opts.cwd;
|
|
385
|
+
}
|
|
253
386
|
if (opts.timeout != null)
|
|
254
387
|
body["timeout"] = opts.timeout;
|
|
255
388
|
await postAndPrint(`/sandboxes/${enc(id)}/exec`, opts, body);
|
|
256
389
|
}));
|
|
390
|
+
sandbox
|
|
391
|
+
.command("deploy [local-dir]")
|
|
392
|
+
.description("Upload an app directory, start it in a sandbox, expose a preview URL, and wait for readiness")
|
|
393
|
+
.option("--sandbox <id>", "Existing sandbox ID. Creates one when omitted")
|
|
394
|
+
.option("--template <template>", "Template for new sandbox", "miosa-sandbox")
|
|
395
|
+
.option("--name <name>", "Name for a new sandbox")
|
|
396
|
+
.option("--port <port>", "Preview port", parseInt)
|
|
397
|
+
.option("--start <command>", "Start command to run inside /workspace")
|
|
398
|
+
.option("--install-command <command>", "Install command to run before start")
|
|
399
|
+
.option("--no-install", "Skip automatic dependency install")
|
|
400
|
+
.option("--wait", "Wait until the public preview returns a good HTTP status")
|
|
401
|
+
.option("--timeout <sec>", "Wait timeout in seconds", parseInt, 180)
|
|
402
|
+
.option("--probe-path <path>", "HTTP path to probe", "/")
|
|
403
|
+
.option("--json", "Output as JSON")
|
|
404
|
+
.action((localDir = ".", opts) => runAction(async () => {
|
|
405
|
+
const result = await deploySandbox(localDir, opts);
|
|
406
|
+
if (opts.json) {
|
|
407
|
+
console.log(JSON.stringify(result, null, 2));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
console.log();
|
|
411
|
+
console.log(` ${chalk.bold("Sandbox")} ${result.sandbox_id}`);
|
|
412
|
+
console.log(` ${chalk.bold("Port")} ${result.port}`);
|
|
413
|
+
console.log(` ${chalk.bold("Preview")} ${chalk.cyan(result.preview_url)}`);
|
|
414
|
+
console.log(` ${chalk.bold("Ready")} ${result.preview_ready ? chalk.green("yes") : chalk.yellow("not verified")}`);
|
|
415
|
+
console.log();
|
|
416
|
+
}));
|
|
417
|
+
sandbox
|
|
418
|
+
.command("preview <sandbox-id>")
|
|
419
|
+
.description("Expose a sandbox port and optionally wait for the public preview to answer")
|
|
420
|
+
.requiredOption("--port <port>", "Port inside the sandbox to expose", parseInt)
|
|
421
|
+
.option("--wait", "Wait until the public URL returns a good HTTP status")
|
|
422
|
+
.option("--timeout <sec>", "Wait timeout in seconds", parseInt, 120)
|
|
423
|
+
.option("--probe-path <path>", "HTTP path to probe", "/")
|
|
424
|
+
.option("--json", "Output as JSON")
|
|
425
|
+
.action((id, opts) => runAction(async () => {
|
|
426
|
+
const result = await previewSandbox(id, opts.port, {
|
|
427
|
+
wait: !!opts.wait,
|
|
428
|
+
timeout: opts.timeout,
|
|
429
|
+
probePath: opts.probePath,
|
|
430
|
+
});
|
|
431
|
+
if (opts.json) {
|
|
432
|
+
console.log(JSON.stringify(result, null, 2));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
console.log(result.url);
|
|
436
|
+
if (!result.ready) {
|
|
437
|
+
console.error(chalk.yellow(`Preview route created but not verified yet (${result.error ?? result.status ?? "pending"}).`));
|
|
438
|
+
}
|
|
439
|
+
}));
|
|
440
|
+
sandbox
|
|
441
|
+
.command("wait <sandbox-id>")
|
|
442
|
+
.description("Wait for sandbox VM, internal app port, edge route, TLS, and public preview readiness")
|
|
443
|
+
.requiredOption("--port <port>", "Port inside the sandbox to check", parseInt)
|
|
444
|
+
.option("--url", "Print only the ready public preview URL")
|
|
445
|
+
.option("--timeout <sec>", "Wait timeout in seconds", parseInt, 120)
|
|
446
|
+
.option("--probe-path <path>", "HTTP path to probe", "/")
|
|
447
|
+
.option("--json", "Output as JSON")
|
|
448
|
+
.action((id, opts) => runAction(async () => {
|
|
449
|
+
const result = await waitSandboxReady(id, opts.port, opts.probePath, opts.timeout);
|
|
450
|
+
if (opts.url && result.url) {
|
|
451
|
+
console.log(result.url);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (opts.json) {
|
|
455
|
+
console.log(JSON.stringify(result, null, 2));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
console.log(chalk.green("Ready"));
|
|
459
|
+
console.log(chalk.cyan(result.url));
|
|
460
|
+
}));
|
|
461
|
+
const service = sandbox
|
|
462
|
+
.command("service")
|
|
463
|
+
.description("Manage named long-running processes inside a sandbox");
|
|
464
|
+
service
|
|
465
|
+
.command("start <sandbox-id> <name>")
|
|
466
|
+
.requiredOption("--cmd <command>", "Command to start")
|
|
467
|
+
.option("--cwd <path>", "Working directory inside the sandbox", "/workspace")
|
|
468
|
+
.option("--port <port>", "Port served by this service", parseInt)
|
|
469
|
+
.option("--json", "Output as JSON")
|
|
470
|
+
.action((id, name, opts) => runAction(async () => {
|
|
471
|
+
const result = await startSandboxService(id, name, opts);
|
|
472
|
+
if (opts.json) {
|
|
473
|
+
console.log(JSON.stringify(result, null, 2));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
console.log(chalk.green(`Started ${name}`));
|
|
477
|
+
if (result.preview_url)
|
|
478
|
+
console.log(chalk.cyan(String(result.preview_url)));
|
|
479
|
+
}));
|
|
480
|
+
service
|
|
481
|
+
.command("restart <sandbox-id> <name>")
|
|
482
|
+
.requiredOption("--cmd <command>", "Command to start")
|
|
483
|
+
.option("--cwd <path>", "Working directory inside the sandbox", "/workspace")
|
|
484
|
+
.option("--port <port>", "Port served by this service", parseInt)
|
|
485
|
+
.option("--json", "Output as JSON")
|
|
486
|
+
.action((id, name, opts) => runAction(async () => {
|
|
487
|
+
await execSandbox(client(), id, `if [ -f ${shellQuote(servicePidPath(name))} ]; then kill $(cat ${shellQuote(servicePidPath(name))}) >/dev/null 2>&1 || true; fi`, "/");
|
|
488
|
+
const result = await startSandboxService(id, name, opts);
|
|
489
|
+
if (opts.json) {
|
|
490
|
+
console.log(JSON.stringify(result, null, 2));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
console.log(chalk.green(`Restarted ${name}`));
|
|
494
|
+
if (result.preview_url)
|
|
495
|
+
console.log(chalk.cyan(String(result.preview_url)));
|
|
496
|
+
}));
|
|
497
|
+
service
|
|
498
|
+
.command("status <sandbox-id> <name>")
|
|
499
|
+
.option("--json", "Output as JSON")
|
|
500
|
+
.action((id, name, opts) => runAction(async () => {
|
|
501
|
+
const result = await execSandbox(client(), id, `if [ -f ${shellQuote(servicePidPath(name))} ] && kill -0 $(cat ${shellQuote(servicePidPath(name))}) >/dev/null 2>&1; then echo running; else echo stopped; fi`, "/");
|
|
502
|
+
const status = String(result["stdout"] ?? "").trim() || "unknown";
|
|
503
|
+
if (opts.json) {
|
|
504
|
+
console.log(JSON.stringify({ sandbox_id: id, name, status }, null, 2));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
console.log(status === "running" ? chalk.green(status) : chalk.yellow(status));
|
|
508
|
+
}));
|
|
509
|
+
service
|
|
510
|
+
.command("logs <sandbox-id> <name>")
|
|
511
|
+
.option("--lines <n>", "Number of log lines", parseInt, 100)
|
|
512
|
+
.option("--json", "Output as JSON")
|
|
513
|
+
.action((id, name, opts) => runAction(async () => {
|
|
514
|
+
const result = await execSandbox(client(), id, `tail -n ${Number(opts.lines) || 100} ${shellQuote(serviceLogPath(name))}`, "/");
|
|
515
|
+
if (opts.json) {
|
|
516
|
+
console.log(JSON.stringify({ sandbox_id: id, name, logs: String(result["stdout"] ?? "") }, null, 2));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
process.stdout.write(String(result["stdout"] ?? ""));
|
|
520
|
+
}));
|
|
521
|
+
sandbox
|
|
522
|
+
.command("doctor <sandbox-id>")
|
|
523
|
+
.description("Diagnose sandbox app readiness across sandbox state, internal HTTP, public route, and TLS/edge reachability")
|
|
524
|
+
.requiredOption("--port <port>", "Port inside the sandbox to check", parseInt)
|
|
525
|
+
.option("--probe-path <path>", "HTTP path to probe", "/")
|
|
526
|
+
.option("--json", "Output as JSON")
|
|
527
|
+
.action((id, opts) => runAction(async () => {
|
|
528
|
+
const report = await doctorSandbox(id, opts.port, opts.probePath);
|
|
529
|
+
if (opts.json) {
|
|
530
|
+
console.log(JSON.stringify(report, null, 2));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
renderDoctorReport(report);
|
|
534
|
+
}));
|
|
257
535
|
// write-file — POST /sandboxes/:id/files with base64 content.
|
|
258
536
|
// If <content-or-file> is an existing local path, reads the file bytes;
|
|
259
537
|
// otherwise treats the argument as literal UTF-8 text.
|
|
@@ -344,6 +622,82 @@ export function register(program) {
|
|
|
344
622
|
handleError(err);
|
|
345
623
|
}
|
|
346
624
|
});
|
|
625
|
+
sandbox
|
|
626
|
+
.command("upload-dir <sandbox-id> <local-dir> <remote-dir>")
|
|
627
|
+
.description("Upload a local directory into a Sandbox")
|
|
628
|
+
.option("--delete", "Delete the remote directory contents before extracting")
|
|
629
|
+
.option("--json", "Output as JSON")
|
|
630
|
+
.action(async (id, localDir, remoteDir, opts) => {
|
|
631
|
+
try {
|
|
632
|
+
const result = await uploadDirToSandbox(id, localDir, remoteDir, {
|
|
633
|
+
delete: !!opts.delete,
|
|
634
|
+
});
|
|
635
|
+
if (opts.json) {
|
|
636
|
+
console.log(JSON.stringify(result, null, 2));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
console.log(chalk.green(`Uploaded ${result.files_label} → ${remoteDir}`));
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
handleError(err);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
sandbox
|
|
646
|
+
.command("sync <local-dir> <remote-dir>")
|
|
647
|
+
.description("Sync a local directory into a sandbox")
|
|
648
|
+
.requiredOption("--sandbox <id>", "Sandbox ID")
|
|
649
|
+
.option("--delete", "Delete the remote directory contents before extracting")
|
|
650
|
+
.option("--json", "Output as JSON")
|
|
651
|
+
.action(async (localDir, remoteDir, opts) => {
|
|
652
|
+
try {
|
|
653
|
+
const result = await uploadDirToSandbox(opts.sandbox, localDir, remoteDir, {
|
|
654
|
+
delete: !!opts.delete,
|
|
655
|
+
});
|
|
656
|
+
if (opts.json) {
|
|
657
|
+
console.log(JSON.stringify(result, null, 2));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
console.log(chalk.green(`Synced ${result.files_label} → ${remoteDir}`));
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
handleError(err);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
sandbox
|
|
667
|
+
.command("cp <source> <target>")
|
|
668
|
+
.description("Copy a local file or directory into a sandbox, e.g. ./app/. sbx_123:/workspace")
|
|
669
|
+
.option("--delete", "Delete the remote directory contents before extracting directories")
|
|
670
|
+
.option("--json", "Output as JSON")
|
|
671
|
+
.action(async (source, target, opts) => {
|
|
672
|
+
try {
|
|
673
|
+
const parsed = parseSandboxTarget(target);
|
|
674
|
+
const local = source.endsWith("/.") ? source.slice(0, -2) : source;
|
|
675
|
+
const stat = fs.statSync(local);
|
|
676
|
+
if (stat.isDirectory()) {
|
|
677
|
+
const result = await uploadDirToSandbox(parsed.sandboxId, local, parsed.remotePath, { delete: !!opts.delete });
|
|
678
|
+
if (opts.json) {
|
|
679
|
+
console.log(JSON.stringify(result, null, 2));
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
console.log(chalk.green(`Copied ${result.files_label} → ${target}`));
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const c = client();
|
|
686
|
+
await uploadFileToSandbox(c, parsed.sandboxId, local, parsed.remotePath);
|
|
687
|
+
if (opts.json) {
|
|
688
|
+
console.log(JSON.stringify({
|
|
689
|
+
sandbox_id: parsed.sandboxId,
|
|
690
|
+
local_path: path.resolve(local),
|
|
691
|
+
remote_path: parsed.remotePath,
|
|
692
|
+
}, null, 2));
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
console.log(chalk.green(`Copied ${path.basename(local)} → ${target}`));
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
handleError(err);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
347
701
|
// download — GET /sandboxes/:id/files/:path; write to --output or stdout.
|
|
348
702
|
sandbox
|
|
349
703
|
.command("download <sandbox-id> <remote-path>")
|
|
@@ -396,26 +750,53 @@ export function register(program) {
|
|
|
396
750
|
const qs = opts.lines != null ? `?lines=${opts.lines}` : "";
|
|
397
751
|
await getAndPrint(`/sandboxes/${enc(id)}/logs${qs}`, opts);
|
|
398
752
|
}));
|
|
399
|
-
// ssh —
|
|
753
|
+
// ssh — WS tunnel → local listener → spawn system ssh client
|
|
400
754
|
sandbox
|
|
401
755
|
.command("ssh <sandbox-id>")
|
|
402
|
-
.description("Open an
|
|
403
|
-
.option("--
|
|
756
|
+
.description("Open an SSH session into a running Sandbox via the platform WS tunnel")
|
|
757
|
+
.option("-p, --port <n>", "Local port for the tunnel listener", parseInt)
|
|
758
|
+
.option("-l, --user <name>", "SSH user (default: root)")
|
|
759
|
+
.option("--no-spawn", "Print connection info instead of spawning the ssh client")
|
|
404
760
|
.option("--json", "Output as JSON")
|
|
405
761
|
.action(async (id, opts) => {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const url = `${baseUrl}/api/v1/sandboxes/${enc(id)}/terminal`;
|
|
409
|
-
if (opts.json) {
|
|
410
|
-
console.log(JSON.stringify({ url }, null, 2));
|
|
411
|
-
return;
|
|
762
|
+
try {
|
|
763
|
+
await runSandboxSsh(id, opts);
|
|
412
764
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
765
|
+
catch (err) {
|
|
766
|
+
handleError(err);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
// port-forward — TCP listener → WS tunnel → sandbox port
|
|
770
|
+
sandbox
|
|
771
|
+
.command("port-forward <sandbox-id>")
|
|
772
|
+
.alias("forward")
|
|
773
|
+
.description([
|
|
774
|
+
"Forward a local TCP port to a port inside a Sandbox via the platform WS tunnel.",
|
|
775
|
+
"",
|
|
776
|
+
"Examples:",
|
|
777
|
+
" miosa sandbox port-forward sbx_123 --remote 5173",
|
|
778
|
+
" miosa sandbox port-forward sbx_123 --remote 5432 --local 15432",
|
|
779
|
+
].join("\n"))
|
|
780
|
+
.requiredOption("--remote <port>", "Port inside the sandbox to reach", parseInt)
|
|
781
|
+
.option("--local <port>", "Local port to listen on (default = remote port)", parseInt)
|
|
782
|
+
.option("--wait", "Probe the local forwarded URL before returning readiness")
|
|
783
|
+
.option("--timeout <sec>", "Wait timeout in seconds", parseInt, 30)
|
|
784
|
+
.option("--probe-path <path>", "HTTP path to probe when --wait is set", "/")
|
|
785
|
+
.option("--json", "Output as JSON")
|
|
786
|
+
.action(async (id, opts) => {
|
|
787
|
+
try {
|
|
788
|
+
await runSandboxPortForward(id, {
|
|
789
|
+
remotePort: opts.remote,
|
|
790
|
+
localPort: opts.local ?? opts.remote,
|
|
791
|
+
wait: !!opts.wait,
|
|
792
|
+
timeoutSec: opts.timeout,
|
|
793
|
+
probePath: opts.probePath,
|
|
794
|
+
json: !!opts.json,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
catch (err) {
|
|
798
|
+
handleError(err);
|
|
416
799
|
}
|
|
417
|
-
openUrl(url);
|
|
418
|
-
console.log(`Opening terminal for sandbox ${id}...`);
|
|
419
800
|
});
|
|
420
801
|
// checkpoint — POST /sandboxes/:id/snapshots
|
|
421
802
|
addDataOption(sandbox
|
|
@@ -451,6 +832,686 @@ export function register(program) {
|
|
|
451
832
|
.option("--json", "Output as JSON")
|
|
452
833
|
.action((id, opts) => runAction(() => getAndPrint(`/sandbox-templates/${enc(id)}`, opts)));
|
|
453
834
|
}
|
|
835
|
+
// ── sandbox ssh implementation ─────────────────────────────────────────────
|
|
836
|
+
//
|
|
837
|
+
// Protocol:
|
|
838
|
+
// 1. Ensure the user has an SSH keypair at ~/.ssh/miosa_sandbox_ed25519.
|
|
839
|
+
// If not, generate one and register it via POST /sandboxes/:id/ssh-keys.
|
|
840
|
+
// 2. Open a local TCP server on a random (or chosen) port.
|
|
841
|
+
// 3. Each accepted connection is bridged bidirectionally to the platform WS
|
|
842
|
+
// endpoint wss://<api>/api/v1/sandboxes/:id/ssh-tunnel.
|
|
843
|
+
// 4. Spawn `ssh` pointing at localhost:<port> with the key and user.
|
|
844
|
+
// 5. On ssh exit, close the TCP server and exit.
|
|
845
|
+
const SANDBOX_KEY_PATH = path.join(os.homedir(), ".ssh", "miosa_sandbox_ed25519");
|
|
846
|
+
async function ensureSandboxSshKey(id, apiKey, endpoint) {
|
|
847
|
+
if (fs.existsSync(SANDBOX_KEY_PATH))
|
|
848
|
+
return;
|
|
849
|
+
console.log(chalk.dim("Generating SSH keypair for MIOSA sandbox access..."));
|
|
850
|
+
fs.mkdirSync(path.join(os.homedir(), ".ssh"), { recursive: true });
|
|
851
|
+
await new Promise((resolve, reject) => {
|
|
852
|
+
const child = spawn("ssh-keygen", [
|
|
853
|
+
"-t",
|
|
854
|
+
"ed25519",
|
|
855
|
+
"-f",
|
|
856
|
+
SANDBOX_KEY_PATH,
|
|
857
|
+
"-N",
|
|
858
|
+
"",
|
|
859
|
+
"-C",
|
|
860
|
+
"miosa-sandbox",
|
|
861
|
+
], { stdio: "pipe" });
|
|
862
|
+
child.on("close", (code) => {
|
|
863
|
+
if (code === 0)
|
|
864
|
+
resolve();
|
|
865
|
+
else
|
|
866
|
+
reject(new Error(`ssh-keygen exited with code ${code}`));
|
|
867
|
+
});
|
|
868
|
+
child.on("error", reject);
|
|
869
|
+
});
|
|
870
|
+
// Register the public key with the sandbox
|
|
871
|
+
const pubKey = fs.readFileSync(`${SANDBOX_KEY_PATH}.pub`, "utf8").trim();
|
|
872
|
+
const base = endpoint.replace(/\/$/, "");
|
|
873
|
+
const res = await fetch(`${base}/api/v1/sandboxes/${encodeURIComponent(id)}/ssh-keys`, {
|
|
874
|
+
method: "POST",
|
|
875
|
+
headers: {
|
|
876
|
+
"Content-Type": "application/json",
|
|
877
|
+
Authorization: `Bearer ${apiKey}`,
|
|
878
|
+
},
|
|
879
|
+
body: JSON.stringify({ public_key: pubKey }),
|
|
880
|
+
});
|
|
881
|
+
if (!res.ok) {
|
|
882
|
+
const body = await res.text();
|
|
883
|
+
throw new Error(`Failed to register SSH key: ${res.status} ${body}`);
|
|
884
|
+
}
|
|
885
|
+
console.log(chalk.green("SSH key registered."));
|
|
886
|
+
}
|
|
887
|
+
function bridgeSandboxWs(socket, wsUrl, apiKey) {
|
|
888
|
+
let closed = false;
|
|
889
|
+
function cleanup() {
|
|
890
|
+
if (closed)
|
|
891
|
+
return;
|
|
892
|
+
closed = true;
|
|
893
|
+
if (!socket.destroyed)
|
|
894
|
+
socket.destroy();
|
|
895
|
+
}
|
|
896
|
+
const ws = new WebSocket(wsUrl, {
|
|
897
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
898
|
+
});
|
|
899
|
+
ws.on("open", () => {
|
|
900
|
+
socket.on("data", (chunk) => {
|
|
901
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
902
|
+
ws.send(chunk);
|
|
903
|
+
});
|
|
904
|
+
socket.on("close", () => {
|
|
905
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
906
|
+
ws.close();
|
|
907
|
+
});
|
|
908
|
+
socket.on("error", () => ws.close());
|
|
909
|
+
});
|
|
910
|
+
ws.on("message", (data) => {
|
|
911
|
+
if (!socket.destroyed) {
|
|
912
|
+
socket.write(typeof data === "string" ? Buffer.from(data) : data);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
ws.on("close", cleanup);
|
|
916
|
+
ws.on("error", (err) => {
|
|
917
|
+
process.stderr.write(`\r\nWS error for sandbox tunnel: ${err.message}\r\n`);
|
|
918
|
+
cleanup();
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
async function runSandboxSsh(id, opts) {
|
|
922
|
+
const config = loadConfig();
|
|
923
|
+
const apiKey = config.api_key;
|
|
924
|
+
if (!apiKey)
|
|
925
|
+
throw new Error("Not authenticated. Run: miosa login");
|
|
926
|
+
const endpoint = config.endpoint ?? "https://api.miosa.ai";
|
|
927
|
+
const base = endpoint.replace(/\/$/, "");
|
|
928
|
+
const wsBase = base.replace(/^https?/, (p) => (p === "https" ? "wss" : "ws"));
|
|
929
|
+
const wsUrl = `${wsBase}/api/v1/sandboxes/${encodeURIComponent(id)}/ssh-tunnel`;
|
|
930
|
+
await ensureSandboxSshKey(id, String(apiKey), endpoint);
|
|
931
|
+
// Pick a free local port
|
|
932
|
+
const localPort = opts.port ?? (await pickFreePort());
|
|
933
|
+
const user = opts.user ?? "root";
|
|
934
|
+
if (opts.json) {
|
|
935
|
+
console.log(JSON.stringify({
|
|
936
|
+
sandbox_id: id,
|
|
937
|
+
local_port: localPort,
|
|
938
|
+
user,
|
|
939
|
+
ws_url: wsUrl,
|
|
940
|
+
key_path: SANDBOX_KEY_PATH,
|
|
941
|
+
}));
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const server = createServer((socket) => {
|
|
945
|
+
bridgeSandboxWs(socket, wsUrl, String(apiKey));
|
|
946
|
+
});
|
|
947
|
+
await new Promise((resolve, reject) => {
|
|
948
|
+
server.on("error", reject);
|
|
949
|
+
server.listen(localPort, "127.0.0.1", resolve);
|
|
950
|
+
});
|
|
951
|
+
console.log(chalk.green("Tunnel ready.") +
|
|
952
|
+
chalk.dim(` 127.0.0.1:${localPort} → sandbox ${id}`));
|
|
953
|
+
if (opts.spawn === false) {
|
|
954
|
+
console.log(chalk.dim(`Connect manually: ssh -i ${SANDBOX_KEY_PATH} -p ${localPort} ${user}@127.0.0.1`));
|
|
955
|
+
console.log(chalk.dim("Press Ctrl+C to close."));
|
|
956
|
+
await waitForSignal();
|
|
957
|
+
server.close();
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const sshArgs = [
|
|
961
|
+
"-i",
|
|
962
|
+
SANDBOX_KEY_PATH,
|
|
963
|
+
"-p",
|
|
964
|
+
String(localPort),
|
|
965
|
+
"-o",
|
|
966
|
+
"StrictHostKeyChecking=no",
|
|
967
|
+
"-o",
|
|
968
|
+
"UserKnownHostsFile=/dev/null",
|
|
969
|
+
"-o",
|
|
970
|
+
"LogLevel=ERROR",
|
|
971
|
+
`${user}@127.0.0.1`,
|
|
972
|
+
];
|
|
973
|
+
const sshProc = spawn("ssh", sshArgs, { stdio: "inherit" });
|
|
974
|
+
const exitCode = await new Promise((resolve) => {
|
|
975
|
+
sshProc.on("close", (code) => resolve(code ?? 1));
|
|
976
|
+
sshProc.on("error", (err) => {
|
|
977
|
+
console.error(chalk.red(`Failed to spawn ssh: ${err.message}`));
|
|
978
|
+
resolve(1);
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
server.close();
|
|
982
|
+
process.exit(exitCode);
|
|
983
|
+
}
|
|
984
|
+
async function runSandboxPortForward(id, opts) {
|
|
985
|
+
const config = loadConfig();
|
|
986
|
+
const apiKey = config.api_key;
|
|
987
|
+
if (!apiKey)
|
|
988
|
+
throw new Error("Not authenticated. Run: miosa login");
|
|
989
|
+
const endpoint = config.endpoint ?? "https://api.miosa.ai";
|
|
990
|
+
const base = endpoint.replace(/\/$/, "");
|
|
991
|
+
const wsBase = base.replace(/^https?/, (p) => (p === "https" ? "wss" : "ws"));
|
|
992
|
+
const wsUrl = `${wsBase}/api/v1/sandboxes/${encodeURIComponent(id)}/port-tunnel/${opts.remotePort}`;
|
|
993
|
+
const stats = { active: 0, total: 0, bytesIn: 0, bytesOut: 0 };
|
|
994
|
+
const server = createServer((socket) => {
|
|
995
|
+
stats.active++;
|
|
996
|
+
stats.total++;
|
|
997
|
+
const ws = new WebSocket(wsUrl, {
|
|
998
|
+
headers: { Authorization: `Bearer ${String(apiKey)}` },
|
|
999
|
+
});
|
|
1000
|
+
ws.on("open", () => {
|
|
1001
|
+
socket.on("data", (chunk) => {
|
|
1002
|
+
stats.bytesIn += chunk.length;
|
|
1003
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
1004
|
+
ws.send(chunk);
|
|
1005
|
+
});
|
|
1006
|
+
socket.on("close", () => ws.readyState === WebSocket.OPEN && ws.close());
|
|
1007
|
+
socket.on("error", () => ws.close());
|
|
1008
|
+
});
|
|
1009
|
+
ws.on("message", (data) => {
|
|
1010
|
+
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
1011
|
+
stats.bytesOut += buf.length;
|
|
1012
|
+
if (!socket.destroyed)
|
|
1013
|
+
socket.write(buf);
|
|
1014
|
+
});
|
|
1015
|
+
ws.on("close", () => {
|
|
1016
|
+
stats.active = Math.max(0, stats.active - 1);
|
|
1017
|
+
if (!socket.destroyed)
|
|
1018
|
+
socket.destroy();
|
|
1019
|
+
});
|
|
1020
|
+
ws.on("error", (err) => {
|
|
1021
|
+
process.stderr.write(`\r\nWS error: ${err.message}\r\n`);
|
|
1022
|
+
if (!socket.destroyed)
|
|
1023
|
+
socket.destroy();
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
await new Promise((resolve, reject) => {
|
|
1027
|
+
server.on("error", (err) => {
|
|
1028
|
+
if (err.code === "EADDRINUSE") {
|
|
1029
|
+
reject(new Error(`Port ${opts.localPort} is already in use. Choose another with --local.`));
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
reject(err);
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
server.listen(opts.localPort, "127.0.0.1", resolve);
|
|
1036
|
+
});
|
|
1037
|
+
let localProbe = null;
|
|
1038
|
+
if (opts.wait) {
|
|
1039
|
+
localProbe = await waitForLocalHttp(opts.localPort, opts.probePath, opts.timeoutSec);
|
|
1040
|
+
}
|
|
1041
|
+
if (opts.json) {
|
|
1042
|
+
console.log(JSON.stringify({
|
|
1043
|
+
sandbox_id: id,
|
|
1044
|
+
remote_port: opts.remotePort,
|
|
1045
|
+
local_port: opts.localPort,
|
|
1046
|
+
ready: localProbe?.ok ?? !opts.wait,
|
|
1047
|
+
status: localProbe?.status ?? null,
|
|
1048
|
+
latency_ms: localProbe?.latency_ms ?? null,
|
|
1049
|
+
}));
|
|
1050
|
+
}
|
|
1051
|
+
else {
|
|
1052
|
+
console.log(`${chalk.green(opts.wait ? "Forwarding ready" : "Forwarding")} ${chalk.cyan(`localhost:${opts.localPort}`)} ${chalk.dim("→")} sandbox ${chalk.cyan(id)}:${chalk.bold(String(opts.remotePort))}`);
|
|
1053
|
+
console.log(chalk.dim("Press Ctrl+C to close.\n"));
|
|
1054
|
+
}
|
|
1055
|
+
const ticker = setInterval(() => {
|
|
1056
|
+
if (opts.json || stats.active === 0)
|
|
1057
|
+
return;
|
|
1058
|
+
process.stderr.write(chalk.dim(`\r[${new Date().toLocaleTimeString()}] connections: ${stats.active} ↑ ${formatBytes(stats.bytesIn)} ↓ ${formatBytes(stats.bytesOut)} `));
|
|
1059
|
+
}, 5_000);
|
|
1060
|
+
await waitForSignal();
|
|
1061
|
+
clearInterval(ticker);
|
|
1062
|
+
process.stderr.write("\n");
|
|
1063
|
+
server.close();
|
|
1064
|
+
}
|
|
1065
|
+
async function showSandboxWithPreview(sandboxId, port, probePath) {
|
|
1066
|
+
const c = client();
|
|
1067
|
+
const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
|
|
1068
|
+
let preview = null;
|
|
1069
|
+
try {
|
|
1070
|
+
preview = await previewSandbox(sandboxId, port, {
|
|
1071
|
+
wait: false,
|
|
1072
|
+
timeout: 1,
|
|
1073
|
+
probePath,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
catch (err) {
|
|
1077
|
+
preview = {
|
|
1078
|
+
url: "",
|
|
1079
|
+
ready: false,
|
|
1080
|
+
status: null,
|
|
1081
|
+
latency_ms: null,
|
|
1082
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
return {
|
|
1086
|
+
...sandbox,
|
|
1087
|
+
ready: preview?.ready ?? false,
|
|
1088
|
+
preview: {
|
|
1089
|
+
url: preview?.url || null,
|
|
1090
|
+
port,
|
|
1091
|
+
route_ready: Boolean(preview?.url),
|
|
1092
|
+
tls_ready: preview?.ready ?? false,
|
|
1093
|
+
last_status: preview?.status ?? null,
|
|
1094
|
+
latency_ms: preview?.latency_ms ?? null,
|
|
1095
|
+
error: preview?.error,
|
|
1096
|
+
},
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
async function previewSandbox(sandboxId, port, opts) {
|
|
1100
|
+
const c = client();
|
|
1101
|
+
const exposed = unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), {
|
|
1102
|
+
port,
|
|
1103
|
+
title: "app preview",
|
|
1104
|
+
}));
|
|
1105
|
+
const url = extractUrl(exposed);
|
|
1106
|
+
if (!url)
|
|
1107
|
+
throw new UserError("Sandbox expose did not return a preview URL.");
|
|
1108
|
+
const edge = opts.wait
|
|
1109
|
+
? await waitForPublicPreview(url, opts.probePath, opts.timeout)
|
|
1110
|
+
: await probePublicPreview(url, opts.probePath);
|
|
1111
|
+
return {
|
|
1112
|
+
url,
|
|
1113
|
+
ready: edge.ok,
|
|
1114
|
+
status: edge.status,
|
|
1115
|
+
latency_ms: edge.latency_ms ?? null,
|
|
1116
|
+
error: edge.error,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
async function waitSandboxReady(sandboxId, port, probePath, timeoutSec) {
|
|
1120
|
+
const c = client();
|
|
1121
|
+
await waitForSandboxRunning(c, sandboxId, Math.min(timeoutSec, 120));
|
|
1122
|
+
const internal = await waitForInternalHttp(c, sandboxId, port, probePath, Math.min(timeoutSec, 60));
|
|
1123
|
+
const preview = await previewSandbox(sandboxId, port, {
|
|
1124
|
+
wait: true,
|
|
1125
|
+
timeout: timeoutSec,
|
|
1126
|
+
probePath,
|
|
1127
|
+
});
|
|
1128
|
+
return {
|
|
1129
|
+
sandbox_id: sandboxId,
|
|
1130
|
+
port,
|
|
1131
|
+
internal_status: internal.status,
|
|
1132
|
+
...preview,
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
async function startSandboxService(sandboxId, name, opts) {
|
|
1136
|
+
validateServiceName(name);
|
|
1137
|
+
const c = client();
|
|
1138
|
+
const logPath = serviceLogPath(name);
|
|
1139
|
+
const pidPath = servicePidPath(name);
|
|
1140
|
+
const command = [
|
|
1141
|
+
"mkdir -p /tmp/miosa-services",
|
|
1142
|
+
`if [ -f ${shellQuote(pidPath)} ]; then kill $(cat ${shellQuote(pidPath)}) >/dev/null 2>&1 || true; fi`,
|
|
1143
|
+
`nohup sh -lc ${shellQuote(opts.cmd)} > ${shellQuote(logPath)} 2>&1 & echo $! > ${shellQuote(pidPath)}`,
|
|
1144
|
+
`cat ${shellQuote(pidPath)}`,
|
|
1145
|
+
].join(" && ");
|
|
1146
|
+
const exec = await execSandbox(c, sandboxId, command, opts.cwd, 15);
|
|
1147
|
+
const pid = String(exec["stdout"] ?? "").trim().split(/\s+/).pop() ?? "";
|
|
1148
|
+
let previewUrl = null;
|
|
1149
|
+
if (opts.port != null) {
|
|
1150
|
+
const preview = await previewSandbox(sandboxId, opts.port, {
|
|
1151
|
+
wait: false,
|
|
1152
|
+
timeout: 1,
|
|
1153
|
+
probePath: "/",
|
|
1154
|
+
});
|
|
1155
|
+
previewUrl = preview.url;
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
sandbox_id: sandboxId,
|
|
1159
|
+
name,
|
|
1160
|
+
pid,
|
|
1161
|
+
cwd: opts.cwd,
|
|
1162
|
+
command: opts.cmd,
|
|
1163
|
+
port: opts.port ?? null,
|
|
1164
|
+
log_path: logPath,
|
|
1165
|
+
preview_url: previewUrl,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
function serviceLogPath(name) {
|
|
1169
|
+
return `/tmp/miosa-services/${name}.log`;
|
|
1170
|
+
}
|
|
1171
|
+
function servicePidPath(name) {
|
|
1172
|
+
return `/tmp/miosa-services/${name}.pid`;
|
|
1173
|
+
}
|
|
1174
|
+
function validateServiceName(name) {
|
|
1175
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
|
|
1176
|
+
throw new UserError(`Invalid service name: ${name}`, "Use only letters, numbers, dot, dash, and underscore.");
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
async function deploySandbox(localDir, opts) {
|
|
1180
|
+
const sourceDir = path.resolve(localDir);
|
|
1181
|
+
if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
|
|
1182
|
+
throw new UserError(`Local directory not found: ${sourceDir}`);
|
|
1183
|
+
}
|
|
1184
|
+
const c = client();
|
|
1185
|
+
const detection = detectFramework(sourceDir);
|
|
1186
|
+
const port = opts.port ?? detection?.port ?? 5173;
|
|
1187
|
+
const start = opts.start ?? defaultStartCommand(detection?.framework, port);
|
|
1188
|
+
const installCommand = opts.install === false
|
|
1189
|
+
? null
|
|
1190
|
+
: opts.installCommand ?? defaultInstallCommand(sourceDir);
|
|
1191
|
+
const sandboxId = opts.sandbox ??
|
|
1192
|
+
(await createSandboxForDeploy(c, opts.template ?? "miosa-sandbox", opts.name));
|
|
1193
|
+
await waitForSandboxRunning(c, sandboxId, Math.min(opts.timeout, 120));
|
|
1194
|
+
const archivePath = createDeployArchive(sourceDir);
|
|
1195
|
+
const remoteArchive = `/tmp/miosa-deploy-${Date.now()}.tgz`;
|
|
1196
|
+
try {
|
|
1197
|
+
await uploadFileToSandbox(c, sandboxId, archivePath, remoteArchive);
|
|
1198
|
+
}
|
|
1199
|
+
finally {
|
|
1200
|
+
fs.rmSync(archivePath, { force: true });
|
|
1201
|
+
}
|
|
1202
|
+
await execSandbox(c, sandboxId, `mkdir -p /workspace && tar -xzf ${shellQuote(remoteArchive)} -C /workspace`, "/");
|
|
1203
|
+
if (installCommand) {
|
|
1204
|
+
await execSandbox(c, sandboxId, installCommand, "/workspace", opts.timeout);
|
|
1205
|
+
}
|
|
1206
|
+
await execSandbox(c, sandboxId, `fuser -k ${port}/tcp >/dev/null 2>&1 || true; nohup sh -lc ${shellQuote(start)} > ${shellQuote(`/tmp/miosa-app-${port}.log`)} 2>&1 & echo $!`, "/workspace");
|
|
1207
|
+
const internal = await waitForInternalHttp(c, sandboxId, port, opts.probePath, Math.min(opts.timeout, 60));
|
|
1208
|
+
const exposed = await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), { port, title: "app preview" });
|
|
1209
|
+
const previewUrl = extractUrl(unwrap(exposed));
|
|
1210
|
+
if (!previewUrl) {
|
|
1211
|
+
throw new UserError("Sandbox expose did not return a preview URL.");
|
|
1212
|
+
}
|
|
1213
|
+
const edge = opts.wait
|
|
1214
|
+
? await waitForPublicPreview(previewUrl, opts.probePath, opts.timeout)
|
|
1215
|
+
: { ok: false, status: null };
|
|
1216
|
+
return {
|
|
1217
|
+
sandbox_id: sandboxId,
|
|
1218
|
+
port,
|
|
1219
|
+
preview_url: previewUrl,
|
|
1220
|
+
preview_ready: edge.ok,
|
|
1221
|
+
internal_status: internal.status,
|
|
1222
|
+
edge_status: edge.status,
|
|
1223
|
+
latency_ms: edge.latency_ms ?? null,
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
async function doctorSandbox(sandboxId, port, probePath) {
|
|
1227
|
+
const c = client();
|
|
1228
|
+
const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
|
|
1229
|
+
const internal = await probeInternalHttp(c, sandboxId, port, probePath);
|
|
1230
|
+
let exposeData = {};
|
|
1231
|
+
try {
|
|
1232
|
+
exposeData = unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/expose`), {
|
|
1233
|
+
port,
|
|
1234
|
+
title: "doctor probe",
|
|
1235
|
+
}));
|
|
1236
|
+
}
|
|
1237
|
+
catch (err) {
|
|
1238
|
+
exposeData = { error: err instanceof Error ? err.message : String(err) };
|
|
1239
|
+
}
|
|
1240
|
+
const previewUrl = extractUrl(exposeData);
|
|
1241
|
+
const edge = previewUrl
|
|
1242
|
+
? await probePublicPreview(previewUrl, probePath)
|
|
1243
|
+
: { ok: false, status: null, error: "No preview URL returned" };
|
|
1244
|
+
return {
|
|
1245
|
+
sandbox_id: sandboxId,
|
|
1246
|
+
sandbox_state: sandbox["state"] ?? sandbox["status"] ?? "unknown",
|
|
1247
|
+
process: internal.ok ? "listening" : "not_ready",
|
|
1248
|
+
route: previewUrl ? "created" : "missing",
|
|
1249
|
+
tls: edge.error && /tls|certificate|ssl/i.test(edge.error) ? "not_ready" : previewUrl ? "checked" : "unknown",
|
|
1250
|
+
internal_probe: internal,
|
|
1251
|
+
edge_probe: edge,
|
|
1252
|
+
preview_ready: edge.ok,
|
|
1253
|
+
preview_url: previewUrl,
|
|
1254
|
+
expose: exposeData,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
function renderDoctorReport(report) {
|
|
1258
|
+
console.log();
|
|
1259
|
+
console.log(chalk.bold("Sandbox Doctor"));
|
|
1260
|
+
console.log();
|
|
1261
|
+
console.log(` ${chalk.bold("Sandbox")} ${report["sandbox_id"]}`);
|
|
1262
|
+
console.log(` ${chalk.bold("State")} ${report["sandbox_state"]}`);
|
|
1263
|
+
console.log(` ${chalk.bold("Process")} ${report["process"]}`);
|
|
1264
|
+
console.log(` ${chalk.bold("Route")} ${report["route"]}`);
|
|
1265
|
+
console.log(` ${chalk.bold("TLS/edge")} ${report["tls"]}`);
|
|
1266
|
+
console.log(` ${chalk.bold("Preview ready")} ${report["preview_ready"] ? chalk.green("yes") : chalk.red("no")}`);
|
|
1267
|
+
if (report["preview_url"]) {
|
|
1268
|
+
console.log(` ${chalk.bold("Preview URL")} ${chalk.cyan(String(report["preview_url"]))}`);
|
|
1269
|
+
}
|
|
1270
|
+
console.log();
|
|
1271
|
+
if (!report["preview_ready"]) {
|
|
1272
|
+
console.log(chalk.yellow(" Preview is not externally ready yet. Internal app health and edge probe details are in --json output."));
|
|
1273
|
+
console.log();
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
async function createSandboxForDeploy(c, template, name) {
|
|
1277
|
+
const body = { template_id: template };
|
|
1278
|
+
if (name)
|
|
1279
|
+
body["name"] = name;
|
|
1280
|
+
const created = unwrap(await c.apiPost(apiPath("/sandboxes"), body));
|
|
1281
|
+
const sandboxId = typeof created["id"] === "string" ? created["id"] : "";
|
|
1282
|
+
if (!sandboxId)
|
|
1283
|
+
throw new UserError("Sandbox create did not return an id.");
|
|
1284
|
+
return sandboxId;
|
|
1285
|
+
}
|
|
1286
|
+
async function waitForSandboxRunning(c, sandboxId, timeoutSec) {
|
|
1287
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
1288
|
+
while (Date.now() < deadline) {
|
|
1289
|
+
const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
|
|
1290
|
+
const state = String(sandbox["state"] ?? sandbox["status"] ?? "").toLowerCase();
|
|
1291
|
+
if (state === "running" || state === "active")
|
|
1292
|
+
return;
|
|
1293
|
+
if (state === "error" || state === "failed") {
|
|
1294
|
+
throw new UserError(`Sandbox ${sandboxId} entered ${state} state.`);
|
|
1295
|
+
}
|
|
1296
|
+
await sleep(1500);
|
|
1297
|
+
}
|
|
1298
|
+
throw new UserError(`Sandbox ${sandboxId} did not become running within ${timeoutSec}s.`);
|
|
1299
|
+
}
|
|
1300
|
+
function createDeployArchive(sourceDir) {
|
|
1301
|
+
const archivePath = path.join(os.tmpdir(), `miosa-deploy-${process.pid}-${Date.now()}.tgz`);
|
|
1302
|
+
const result = spawnSync("tar", [
|
|
1303
|
+
"--exclude",
|
|
1304
|
+
".git",
|
|
1305
|
+
"--exclude",
|
|
1306
|
+
"node_modules",
|
|
1307
|
+
"--exclude",
|
|
1308
|
+
".next",
|
|
1309
|
+
"--exclude",
|
|
1310
|
+
"dist",
|
|
1311
|
+
"-czf",
|
|
1312
|
+
archivePath,
|
|
1313
|
+
"-C",
|
|
1314
|
+
sourceDir,
|
|
1315
|
+
".",
|
|
1316
|
+
], { stdio: "pipe" });
|
|
1317
|
+
if (result.status !== 0) {
|
|
1318
|
+
throw new UserError(`Could not archive ${sourceDir}: ${result.stderr.toString().trim() || "tar failed"}`);
|
|
1319
|
+
}
|
|
1320
|
+
return archivePath;
|
|
1321
|
+
}
|
|
1322
|
+
async function uploadFileToSandbox(c, sandboxId, localPath, remotePath) {
|
|
1323
|
+
await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/files`), {
|
|
1324
|
+
path: remotePath,
|
|
1325
|
+
content: fs.readFileSync(localPath).toString("base64"),
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
async function uploadDirToSandbox(sandboxId, localDir, remoteDir, opts) {
|
|
1329
|
+
const sourceDir = path.resolve(localDir);
|
|
1330
|
+
if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
|
|
1331
|
+
throw new UserError(`Local directory not found: ${sourceDir}`);
|
|
1332
|
+
}
|
|
1333
|
+
const c = client();
|
|
1334
|
+
const archivePath = createDeployArchive(sourceDir);
|
|
1335
|
+
const remoteArchive = `/tmp/miosa-upload-${Date.now()}.tgz`;
|
|
1336
|
+
try {
|
|
1337
|
+
await uploadFileToSandbox(c, sandboxId, archivePath, remoteArchive);
|
|
1338
|
+
const clean = opts.delete
|
|
1339
|
+
? `rm -rf ${shellQuote(remoteDir)} && mkdir -p ${shellQuote(remoteDir)}`
|
|
1340
|
+
: `mkdir -p ${shellQuote(remoteDir)}`;
|
|
1341
|
+
await execSandbox(c, sandboxId, `${clean} && tar -xzf ${shellQuote(remoteArchive)} -C ${shellQuote(remoteDir)} && rm -f ${shellQuote(remoteArchive)}`, "/");
|
|
1342
|
+
}
|
|
1343
|
+
finally {
|
|
1344
|
+
fs.rmSync(archivePath, { force: true });
|
|
1345
|
+
}
|
|
1346
|
+
return {
|
|
1347
|
+
sandbox_id: sandboxId,
|
|
1348
|
+
local_dir: sourceDir,
|
|
1349
|
+
remote_dir: remoteDir,
|
|
1350
|
+
files_label: path.basename(sourceDir) || sourceDir,
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
async function execSandbox(c, sandboxId, command, cwd, timeout) {
|
|
1354
|
+
const body = { command: commandInCwd(command, cwd) };
|
|
1355
|
+
if (cwd) {
|
|
1356
|
+
body["cwd"] = cwd;
|
|
1357
|
+
body["dir"] = cwd;
|
|
1358
|
+
}
|
|
1359
|
+
if (timeout != null)
|
|
1360
|
+
body["timeout"] = timeout;
|
|
1361
|
+
const result = unwrap(await c.apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/exec`), body));
|
|
1362
|
+
const exitCode = Number(result["exit_code"] ?? 0);
|
|
1363
|
+
if (exitCode !== 0) {
|
|
1364
|
+
throw new UserError(`Sandbox command failed with exit code ${exitCode}: ${command}`, String(result["stderr"] ?? result["stdout"] ?? ""));
|
|
1365
|
+
}
|
|
1366
|
+
return result;
|
|
1367
|
+
}
|
|
1368
|
+
async function waitForInternalHttp(c, sandboxId, port, probePath, timeoutSec) {
|
|
1369
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
1370
|
+
let last = { ok: false, status: null };
|
|
1371
|
+
while (Date.now() < deadline) {
|
|
1372
|
+
last = await probeInternalHttp(c, sandboxId, port, probePath);
|
|
1373
|
+
if (last.ok)
|
|
1374
|
+
return last;
|
|
1375
|
+
await sleep(1500);
|
|
1376
|
+
}
|
|
1377
|
+
throw new UserError(`App did not answer inside the sandbox on port ${port} within ${timeoutSec}s.`, last.error);
|
|
1378
|
+
}
|
|
1379
|
+
async function probeInternalHttp(c, sandboxId, port, probePath) {
|
|
1380
|
+
try {
|
|
1381
|
+
const result = await execSandbox(c, sandboxId, `python3 - <<'PY'\nimport urllib.request, sys\nurl = 'http://127.0.0.1:${port}${probePath.startsWith("/") ? probePath : `/${probePath}`}'\ntry:\n r = urllib.request.urlopen(url, timeout=3)\n print(r.status)\n sys.exit(0 if 200 <= r.status < 400 else 1)\nexcept Exception as e:\n print(e)\n sys.exit(1)\nPY`, "/", 10);
|
|
1382
|
+
const status = Number(String(result["stdout"] ?? "").trim().split(/\s+/)[0]);
|
|
1383
|
+
return { ok: Number.isFinite(status) && status >= 200 && status < 400, status };
|
|
1384
|
+
}
|
|
1385
|
+
catch (err) {
|
|
1386
|
+
return { ok: false, status: null, error: err instanceof Error ? err.message : String(err) };
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
async function waitForPublicPreview(previewUrl, probePath, timeoutSec) {
|
|
1390
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
1391
|
+
let last = { ok: false, status: null };
|
|
1392
|
+
while (Date.now() < deadline) {
|
|
1393
|
+
last = await probePublicPreview(previewUrl, probePath);
|
|
1394
|
+
if (last.ok)
|
|
1395
|
+
return last;
|
|
1396
|
+
await sleep(2000);
|
|
1397
|
+
}
|
|
1398
|
+
throw new UserError(`Preview URL was created but did not become publicly ready within ${timeoutSec}s.`, last.error ?? (last.status ? `Last HTTP status: ${last.status}` : undefined));
|
|
1399
|
+
}
|
|
1400
|
+
async function probePublicPreview(previewUrl, probePath) {
|
|
1401
|
+
try {
|
|
1402
|
+
const url = new URL(previewUrl);
|
|
1403
|
+
url.pathname = joinUrlPath(url.pathname, probePath);
|
|
1404
|
+
const t0 = Date.now();
|
|
1405
|
+
const res = await fetch(url, { method: "GET", redirect: "manual" });
|
|
1406
|
+
return {
|
|
1407
|
+
ok: (res.status >= 200 && res.status < 400) || res.status === 401 || res.status === 403,
|
|
1408
|
+
status: res.status,
|
|
1409
|
+
latency_ms: Date.now() - t0,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
catch (err) {
|
|
1413
|
+
return { ok: false, status: null, error: err instanceof Error ? err.message : String(err) };
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
async function waitForLocalHttp(localPort, probePath, timeoutSec) {
|
|
1417
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
1418
|
+
let last = { ok: false, status: null };
|
|
1419
|
+
const url = `http://127.0.0.1:${localPort}${probePath.startsWith("/") ? probePath : `/${probePath}`}`;
|
|
1420
|
+
while (Date.now() < deadline) {
|
|
1421
|
+
last = await probePublicPreview(url, "/");
|
|
1422
|
+
if (last.ok)
|
|
1423
|
+
return last;
|
|
1424
|
+
await sleep(1000);
|
|
1425
|
+
}
|
|
1426
|
+
throw new UserError(`Local forwarded URL did not answer within ${timeoutSec}s.`, last.error ?? (last.status ? `Last HTTP status: ${last.status}` : undefined));
|
|
1427
|
+
}
|
|
1428
|
+
function defaultInstallCommand(sourceDir) {
|
|
1429
|
+
if (fs.existsSync(path.join(sourceDir, "package.json")))
|
|
1430
|
+
return "npm install";
|
|
1431
|
+
if (fs.existsSync(path.join(sourceDir, "requirements.txt")))
|
|
1432
|
+
return "pip install -r requirements.txt";
|
|
1433
|
+
return null;
|
|
1434
|
+
}
|
|
1435
|
+
function defaultStartCommand(framework, port) {
|
|
1436
|
+
if (framework === "nextjs")
|
|
1437
|
+
return `npm run dev -- -H 0.0.0.0 -p ${port}`;
|
|
1438
|
+
if (framework === "vite-react")
|
|
1439
|
+
return `npm run dev -- --host 0.0.0.0 --port ${port}`;
|
|
1440
|
+
if (framework === "static")
|
|
1441
|
+
return `python3 -m http.server ${port} --bind 0.0.0.0`;
|
|
1442
|
+
return `npm run dev -- --host 0.0.0.0 --port ${port}`;
|
|
1443
|
+
}
|
|
1444
|
+
function extractUrl(value) {
|
|
1445
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1446
|
+
return null;
|
|
1447
|
+
const row = value;
|
|
1448
|
+
for (const key of ["url", "preview_url", "public_url"]) {
|
|
1449
|
+
if (typeof row[key] === "string" && row[key])
|
|
1450
|
+
return row[key];
|
|
1451
|
+
}
|
|
1452
|
+
return null;
|
|
1453
|
+
}
|
|
1454
|
+
function parseSandboxTarget(target) {
|
|
1455
|
+
const idx = target.indexOf(":");
|
|
1456
|
+
if (idx <= 0 || idx === target.length - 1) {
|
|
1457
|
+
throw new UserError(`Invalid sandbox target: ${target}`, "Use the form <sandbox-id>:/absolute/path");
|
|
1458
|
+
}
|
|
1459
|
+
const sandboxId = target.slice(0, idx);
|
|
1460
|
+
const remotePath = target.slice(idx + 1);
|
|
1461
|
+
if (!remotePath.startsWith("/")) {
|
|
1462
|
+
throw new UserError(`Invalid remote path: ${remotePath}`, "Sandbox copy targets must use an absolute path, e.g. sbx_123:/workspace");
|
|
1463
|
+
}
|
|
1464
|
+
return { sandboxId, remotePath };
|
|
1465
|
+
}
|
|
1466
|
+
function commandInCwd(command, cwd) {
|
|
1467
|
+
if (!cwd)
|
|
1468
|
+
return command;
|
|
1469
|
+
return `cd ${shellQuote(cwd)} && ${command}`;
|
|
1470
|
+
}
|
|
1471
|
+
function backgroundCommand(command) {
|
|
1472
|
+
if (!command.trim())
|
|
1473
|
+
return command;
|
|
1474
|
+
const logPath = `/tmp/miosa-bg-${Date.now()}.log`;
|
|
1475
|
+
return `nohup sh -lc ${shellQuote(command)} > ${shellQuote(logPath)} 2>&1 & echo $!`;
|
|
1476
|
+
}
|
|
1477
|
+
function shellQuote(value) {
|
|
1478
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
1479
|
+
}
|
|
1480
|
+
function joinUrlPath(basePath, probePath) {
|
|
1481
|
+
const probe = probePath.startsWith("/") ? probePath : `/${probePath}`;
|
|
1482
|
+
if (!basePath || basePath === "/")
|
|
1483
|
+
return probe;
|
|
1484
|
+
return `${basePath.replace(/\/$/, "")}${probe}`;
|
|
1485
|
+
}
|
|
1486
|
+
function sleep(ms) {
|
|
1487
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1488
|
+
}
|
|
1489
|
+
// ── shared helpers ─────────────────────────────────────────────────────────
|
|
1490
|
+
function pickFreePort() {
|
|
1491
|
+
return new Promise((resolve, reject) => {
|
|
1492
|
+
const srv = createServer();
|
|
1493
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
1494
|
+
const addr = srv.address();
|
|
1495
|
+
srv.close(() => {
|
|
1496
|
+
if (addr && typeof addr === "object")
|
|
1497
|
+
resolve(addr.port);
|
|
1498
|
+
else
|
|
1499
|
+
reject(new Error("Could not pick a free port"));
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
function waitForSignal() {
|
|
1505
|
+
return new Promise((resolve) => {
|
|
1506
|
+
function done() {
|
|
1507
|
+
process.off("SIGINT", done);
|
|
1508
|
+
process.off("SIGTERM", done);
|
|
1509
|
+
resolve();
|
|
1510
|
+
}
|
|
1511
|
+
process.on("SIGINT", done);
|
|
1512
|
+
process.on("SIGTERM", done);
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
454
1515
|
function openUrl(url) {
|
|
455
1516
|
const command = process.platform === "darwin"
|
|
456
1517
|
? "open"
|