runcloud 0.1.15 → 0.1.17

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.
@@ -1,21 +1,30 @@
1
- import { InvalidArgumentError } from 'commander';
1
+ import { Help, InvalidArgumentError } from 'commander';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { writeFileSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import { ApiClient, ApiError, friendlyApiError } from '../api.js';
6
6
  import { requireCredentials } from '../config.js';
7
+ import { runSandboxCommand, shellCommand } from '../sandboxExec.js';
7
8
  import { runSandboxShell } from '../shellSession.js';
9
+ import { printJson, StatusLine, style } from '../terminal.js';
8
10
  import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
11
+ import { renderSandbox, renderSandboxList, } from './sandboxOutput.js';
9
12
  const MAX_SANDBOX_DISK_GIB = 2048;
10
13
  const DEFAULT_BOX_CPU_CORES = 2;
11
14
  const DEFAULT_BOX_MEMORY_MIB = 24 * 1024;
15
+ const SANDBOX_POLL_INTERVAL_MS = 1_000;
16
+ const SANDBOX_WAIT_TIMEOUT_MS = 20 * 60 * 1_000;
17
+ const READY_STATES = new Set(['running']);
18
+ const FAILED_STATES = new Set(['interrupted', 'stopped', 'destroyed']);
19
+ const METRIC_RANGES = new Set(['1h', '6h', '24h', '7d', '30d', 'all']);
20
+ const BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
12
21
  function runCloudApi() {
13
22
  const credentials = requireCredentials();
14
23
  return new ApiClient(credentials.apiUrl, credentials.token);
15
24
  }
16
25
  function print(value, opts) {
17
26
  if (opts.json) {
18
- console.log(JSON.stringify(value, null, 2));
27
+ printJson(value);
19
28
  return;
20
29
  }
21
30
  if (Array.isArray(value)) {
@@ -25,6 +34,53 @@ function print(value, opts) {
25
34
  }
26
35
  console.log(formatRecord(value));
27
36
  }
37
+ function sandboxStatusMessage(state) {
38
+ if (state === 'building_image')
39
+ return 'Building image';
40
+ if (state === 'pending')
41
+ return 'Waiting for capacity';
42
+ if (state === 'starting')
43
+ return 'Starting sandbox';
44
+ if (state === 'archived')
45
+ return 'Restoring sandbox';
46
+ return `Sandbox is ${state.replaceAll('_', ' ')}`;
47
+ }
48
+ export async function pollSandboxReady(api, id, opts = {}) {
49
+ const intervalMs = opts.intervalMs ?? SANDBOX_POLL_INTERVAL_MS;
50
+ const timeoutMs = opts.timeoutMs ?? SANDBOX_WAIT_TIMEOUT_MS;
51
+ const deadline = Date.now() + timeoutMs;
52
+ for (;;) {
53
+ const record = (await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`));
54
+ const state = record.state ?? 'unknown';
55
+ opts.onState?.(state);
56
+ if (READY_STATES.has(state))
57
+ return record;
58
+ if (FAILED_STATES.has(state)) {
59
+ throw new Error(`sandbox ${id} entered ${state.replaceAll('_', ' ')} state`);
60
+ }
61
+ if (Date.now() > deadline) {
62
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for sandbox ${id} to start`);
63
+ }
64
+ await new Promise((done) => setTimeout(done, intervalMs));
65
+ }
66
+ }
67
+ export async function prepareSandboxShell(api, id, status = new StatusLine()) {
68
+ let record = (await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`));
69
+ if (record.state === 'paused' || record.state === 'archived') {
70
+ status.update(`Resuming ${id}`);
71
+ record = (await api.post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/resume`));
72
+ if (record.state !== 'running') {
73
+ record = await pollSandboxReady(api, id, {
74
+ onState: (state) => status.update(sandboxStatusMessage(state)),
75
+ });
76
+ }
77
+ status.succeed(`Resumed ${id}`);
78
+ }
79
+ if (record.state !== 'running') {
80
+ status.clear();
81
+ throw new Error(`sandbox ${id} is ${String(record.state).replaceAll('_', ' ')}; wait until it is running`);
82
+ }
83
+ }
28
84
  function formatRecord(value) {
29
85
  if (!value || typeof value !== 'object')
30
86
  return String(value);
@@ -73,6 +129,20 @@ function parseIdlePauseSeconds(value) {
73
129
  }
74
130
  return n;
75
131
  }
132
+ function parseTimeoutSeconds(value) {
133
+ const timeout = Number(value);
134
+ if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 86_400) {
135
+ throw new InvalidArgumentError('timeout must be an integer from 0 to 86400 seconds');
136
+ }
137
+ return timeout;
138
+ }
139
+ function parseExecTimeoutSeconds(value) {
140
+ const timeout = parseTimeoutSeconds(value);
141
+ if (timeout === 0) {
142
+ throw new InvalidArgumentError('command timeout must be between 1 and 86400 seconds');
143
+ }
144
+ return timeout;
145
+ }
76
146
  function parsePort(value) {
77
147
  const n = Number(value);
78
148
  if (!Number.isInteger(n) || n < 1 || n > 65535) {
@@ -80,6 +150,66 @@ function parsePort(value) {
80
150
  }
81
151
  return n;
82
152
  }
153
+ function parseMetricRange(value) {
154
+ if (!METRIC_RANGES.has(value)) {
155
+ throw new InvalidArgumentError('range must be one of 1h, 6h, 24h, 7d, 30d, all');
156
+ }
157
+ return value;
158
+ }
159
+ function parseMetricInterval(value) {
160
+ const seconds = Number(value);
161
+ if (!Number.isFinite(seconds) || seconds < 1) {
162
+ throw new InvalidArgumentError('--interval must be at least 1 second');
163
+ }
164
+ return seconds;
165
+ }
166
+ function formatMetricBytes(value) {
167
+ if (!Number.isFinite(value) || value <= 0)
168
+ return '0 B';
169
+ const unit = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1);
170
+ const amount = value / 1024 ** unit;
171
+ return `${amount.toFixed(amount >= 10 || unit === 0 ? 0 : 1)} ${BYTE_UNITS[unit]}`;
172
+ }
173
+ function formatMetricCpu(millicores) {
174
+ return millicores >= 1000
175
+ ? `${(millicores / 1000).toFixed(2).replace(/\.?0+$/, '')} cores`
176
+ : `${Math.round(millicores)}m`;
177
+ }
178
+ function metricPercent(used, total) {
179
+ return total > 0 ? `${((used / total) * 100).toFixed(1)}%` : '—';
180
+ }
181
+ function metricSparkline(values) {
182
+ if (values.length === 0)
183
+ return '—';
184
+ const blocks = '▁▂▃▄▅▆▇█';
185
+ const max = Math.max(...values, 1);
186
+ const stride = Math.max(1, Math.ceil(values.length / 48));
187
+ return values
188
+ .filter((_, index) => index % stride === 0 || index === values.length - 1)
189
+ .map((value) => blocks[Math.min(blocks.length - 1, Math.floor((Math.max(0, value) / max) * (blocks.length - 1)))])
190
+ .join('');
191
+ }
192
+ export function renderSandboxMetrics(id, metrics) {
193
+ const point = metrics.current;
194
+ if (!point)
195
+ return `Sandbox metrics · ${id}\nNo samples recorded yet.`;
196
+ const history = metrics.history;
197
+ return [
198
+ `Sandbox metrics · ${id} · ${metrics.range}`,
199
+ `State ${point.state} · ${new Date(point.timestamp).toLocaleString()}`,
200
+ `CPU ${formatMetricCpu(point.cpu_millicores)}`,
201
+ `Memory ${formatMetricBytes(point.memory_bytes)}`,
202
+ `Network ↓ ${formatMetricBytes(point.network_rx_bytes_per_second)}/s · ↑ ${formatMetricBytes(point.network_tx_bytes_per_second)}/s`,
203
+ ` totals ↓ ${formatMetricBytes(point.network_rx_bytes)} · ↑ ${formatMetricBytes(point.network_tx_bytes)}`,
204
+ `Disk ${formatMetricBytes(point.disk_used_bytes)} / ${formatMetricBytes(point.disk_total_bytes)} (${metricPercent(point.disk_used_bytes, point.disk_total_bytes)})`,
205
+ `History ${history.length} samples`,
206
+ `CPU ${metricSparkline(history.map((sample) => sample.cpu_millicores))}`,
207
+ `Memory ${metricSparkline(history.map((sample) => sample.memory_bytes))}`,
208
+ `Network ↓ ${metricSparkline(history.map((sample) => sample.network_rx_bytes_per_second))}`,
209
+ `Network ↑ ${metricSparkline(history.map((sample) => sample.network_tx_bytes_per_second))}`,
210
+ `Disk ${metricSparkline(history.map((sample) => sample.disk_used_bytes))}`,
211
+ ].join('\n');
212
+ }
83
213
  function parseCoordinate(value) {
84
214
  const n = Number(value);
85
215
  if (!Number.isSafeInteger(n) || n < 0) {
@@ -105,8 +235,43 @@ async function findBoxForSandbox(api, sandboxId) {
105
235
  function attachHostname(api, sandboxId, name, port) {
106
236
  return api.post('/run-cloud/boxes', { sandboxId, name, port });
107
237
  }
238
+ function secretSelector(opts) {
239
+ const ordered = [...(opts.secretGroup ?? []), ...(opts.secret ?? [])];
240
+ const env = {};
241
+ for (const pair of opts.env ?? []) {
242
+ const eq = pair.indexOf('=');
243
+ if (eq <= 0)
244
+ throw new Error(`--env expects NAME=VALUE, got "${pair}"`);
245
+ env[pair.slice(0, eq)] = pair.slice(eq + 1);
246
+ }
247
+ if (opts.noSecrets) {
248
+ if (ordered.length > 0) {
249
+ throw new Error('--no-secrets cannot be combined with --secret-group or --secret');
250
+ }
251
+ return Object.keys(env).length > 0 ? { secrets: 'none', env } : { secrets: 'none' };
252
+ }
253
+ const out = {};
254
+ if (ordered.length > 0)
255
+ out.secrets = ordered;
256
+ if (Object.keys(env).length > 0)
257
+ out.env = env;
258
+ return out;
259
+ }
260
+ function collectRepeatable(value, previous) {
261
+ return [...previous, value];
262
+ }
108
263
  export function registerSandbox(program) {
109
264
  const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
265
+ const defaultHelp = new Help();
266
+ sandbox.configureHelp({
267
+ subcommandTerm: (command) => {
268
+ const term = defaultHelp.subcommandTerm(command);
269
+ const aliases = command.aliases();
270
+ if (aliases.length < 2)
271
+ return term;
272
+ return term.replace(`${command.name()}|${aliases[0]}`, `${command.name()}|${aliases.join('|')}`);
273
+ },
274
+ });
110
275
  withOutput(sandbox
111
276
  .command('create')
112
277
  .description('Spawn a microVM sandbox')
@@ -118,8 +283,14 @@ export function registerSandbox(program) {
118
283
  .option('--memory <mb>', 'memory allocation in MiB', parseMemoryMb)
119
284
  .option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)
120
285
  .option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
286
+ .option('--timeout <seconds>', 'maximum sandbox lifetime; 0 disables the lifetime timer', parseTimeoutSeconds, 0)
121
287
  .option('--persistent', 'never pause when idle (same as --idle-pause 0)', false)
122
- .option('--expose <port>', 'publish a guest port at <name>-box.run.cloud; implies --persistent', parsePort)).action((opts) => run(async () => {
288
+ .option('--expose <port>', 'publish a guest port at <name>-box.run.cloud; implies --persistent', parsePort)
289
+ .option('--secret-group <group>', 'attach a secret group; repeatable and ORDER MATTERS (a later group wins on a name collision)', collectRepeatable, [])
290
+ .option('--secret <group/name>', 'attach one secret out of a group; repeatable, same ordering', collectRepeatable, [])
291
+ .option('--env <NAME=VALUE>', 'literal value for this sandbox; applied last, so it wins; repeatable', collectRepeatable, [])
292
+ .option('--no-secrets', 'state explicitly that the sandbox holds no secrets')
293
+ .option('--no-wait', 'return before the sandbox finishes building and starting')).action((opts) => run(async () => {
123
294
  const exposed = opts.expose !== undefined;
124
295
  if (exposed && !opts.name) {
125
296
  throw new Error('--expose needs --name <name>, the hostname prefix for <name>-box.run.cloud');
@@ -144,20 +315,44 @@ export function registerSandbox(program) {
144
315
  body.memory = memory;
145
316
  if (opts.disk !== undefined)
146
317
  body.disk = opts.disk;
318
+ body.timeoutSeconds = opts.timeout;
147
319
  if (exposed || opts.persistent)
148
320
  body.idlePauseSeconds = 0;
149
321
  else if (opts.idlePause !== undefined)
150
322
  body.idlePauseSeconds = opts.idlePause;
323
+ Object.assign(body, secretSelector({ ...opts, noSecrets: opts.secrets === false }));
151
324
  const api = runCloudApi();
152
- const created = await api.post('/run-cloud/sandboxes', body);
325
+ let created = (await api.post('/run-cloud/sandboxes', body));
326
+ if (typeof created.id !== 'string') {
327
+ throw new Error('sandbox create response did not include an id');
328
+ }
329
+ if (!opts.json && opts.wait && created.state && !READY_STATES.has(created.state)) {
330
+ const status = new StatusLine();
331
+ status.update(sandboxStatusMessage(created.state));
332
+ try {
333
+ created = await pollSandboxReady(api, created.id, {
334
+ onState: (state) => status.update(sandboxStatusMessage(state)),
335
+ });
336
+ status.clear();
337
+ }
338
+ catch (err) {
339
+ status.fail('Sandbox failed to start');
340
+ throw err;
341
+ }
342
+ }
153
343
  if (!exposed) {
154
- print(created, opts);
344
+ if (opts.json)
345
+ printJson(created);
346
+ else
347
+ console.log(renderSandbox(created, 'created'));
155
348
  return;
156
349
  }
157
- if (typeof created?.id !== 'string')
158
- throw new Error('sandbox create response did not include an id');
159
350
  const box = await attachHostname(api, created.id, opts.name, opts.expose);
160
- print({ ...created, box }, opts);
351
+ const exposedRecord = { ...created, box };
352
+ if (opts.json)
353
+ printJson(exposedRecord);
354
+ else
355
+ console.log(renderSandbox(exposedRecord, 'created'));
161
356
  }));
162
357
  withOutput(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
163
358
  const qs = opts.state ? `?state=${encodeURIComponent(opts.state)}` : '';
@@ -175,7 +370,10 @@ export function registerSandbox(program) {
175
370
  const box = bySandbox.get(item.id);
176
371
  return box ? { ...item, hostname: box.hostname, exposedPort: box.port } : item;
177
372
  });
178
- print(items, opts);
373
+ if (opts.json)
374
+ printJson(items);
375
+ else
376
+ console.log(renderSandboxList(items));
179
377
  }));
180
378
  withOutput(sandbox
181
379
  .command('get')
@@ -186,7 +384,43 @@ export function registerSandbox(program) {
186
384
  api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`),
187
385
  findBoxForSandbox(api, id),
188
386
  ]);
189
- print(box ? { ...record, box } : record, opts);
387
+ const sandboxRecord = (box ? { ...record, box } : record);
388
+ if (opts.json)
389
+ printJson(sandboxRecord);
390
+ else
391
+ console.log(renderSandbox(sandboxRecord));
392
+ }));
393
+ withOutput(sandbox
394
+ .command('metrics')
395
+ .description('Show current and historical CPU, memory, network, and disk usage')
396
+ .argument('<id>', 'sandbox id')
397
+ .option('--range <range>', 'history range: 1h, 6h, 24h, 7d, 30d, all', parseMetricRange, '24h')
398
+ .option('--watch', 'refresh continuously', false)
399
+ .option('--interval <seconds>', 'watch refresh interval', parseMetricInterval, 5)).action((id, opts) => run(async () => {
400
+ const api = runCloudApi();
401
+ for (;;) {
402
+ const metrics = (await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}/metrics?range=${encodeURIComponent(opts.range)}`));
403
+ if (opts.json) {
404
+ console.log(opts.watch ? JSON.stringify(metrics) : JSON.stringify(metrics, null, 2));
405
+ }
406
+ else {
407
+ console.log(renderSandboxMetrics(id, metrics));
408
+ }
409
+ if (!opts.watch)
410
+ return;
411
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, opts.interval * 1_000));
412
+ }
413
+ }));
414
+ withOutput(sandbox
415
+ .command('secrets')
416
+ .description('Replace the secrets a running sandbox holds (a full replacement, not a merge)')
417
+ .argument('<id>', 'sandbox id')
418
+ .option('--secret-group <group>', 'attach a secret group; repeatable and ORDER MATTERS (a later group wins on a name collision)', collectRepeatable, [])
419
+ .option('--secret <group/name>', 'attach one secret out of a group; repeatable, same ordering', collectRepeatable, [])
420
+ .option('--env <NAME=VALUE>', 'literal value; applied last, so it wins; repeatable', collectRepeatable, [])).action((id, opts) => run(async () => {
421
+ const body = secretSelector(opts);
422
+ await runCloudApi().put(`/run-cloud/sandboxes/${encodeURIComponent(id)}/secrets`, body);
423
+ console.log(`${id}: secrets replaced`);
190
424
  }));
191
425
  withOutput(sandbox
192
426
  .command('expose')
@@ -227,33 +461,70 @@ export function registerSandbox(program) {
227
461
  .command('exec')
228
462
  .description('Run a command in a sandbox (via /bin/sh -c)')
229
463
  .argument('<id>', 'sandbox id')
230
- .argument('<cmd...>', 'command to run, e.g. npm run build')).action((id, cmdParts, opts) => run(async () => {
231
- const r = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/exec`, {
232
- cmd: ['/bin/sh', '-c', cmdParts.join(' ')],
464
+ .argument('<cmd...>', 'command to run, e.g. npm run build')
465
+ .option('--timeout <seconds>', 'command timeout', parseExecTimeoutSeconds)
466
+ .allowUnknownOption(true)).action((id, cmdParts, opts) => run(async () => {
467
+ const credentials = requireCredentials();
468
+ const r = await runSandboxCommand({
469
+ apiUrl: credentials.apiUrl,
470
+ token: credentials.token,
471
+ sandboxId: id,
472
+ request: {
473
+ cmd: ['/bin/sh', '-c', shellCommand(cmdParts)],
474
+ ...(opts.timeout === undefined ? {} : { timeout_seconds: opts.timeout }),
475
+ },
476
+ ...(opts.json
477
+ ? {}
478
+ : {
479
+ onStdout: (chunk) => process.stdout.write(chunk),
480
+ onStderr: (chunk) => process.stderr.write(chunk),
481
+ }),
233
482
  });
234
483
  if (opts.json) {
235
484
  console.log(JSON.stringify(r, null, 2));
236
485
  }
237
- else {
238
- if (r.stdout)
239
- process.stdout.write(r.stdout);
240
- if (r.stderr)
241
- process.stderr.write(r.stderr);
242
- }
243
486
  process.exitCode = r.exit_code ?? 0;
244
487
  }));
245
488
  sandbox
246
489
  .command('shell')
247
- .description('Open an interactive shell in a running sandbox')
490
+ .description('Open an interactive shell (resumes paused sandboxes)')
248
491
  .argument('<id>', 'sandbox id')
249
492
  .action((id) => run(async () => {
250
493
  const credentials = requireCredentials();
494
+ const api = new ApiClient(credentials.apiUrl, credentials.token);
495
+ await prepareSandboxShell(api, id);
251
496
  process.exitCode = await runSandboxShell({
252
497
  apiUrl: credentials.apiUrl,
253
498
  token: credentials.token,
254
499
  sandboxId: id,
255
500
  });
256
501
  }));
502
+ for (const lifecycle of [
503
+ {
504
+ name: 'pause',
505
+ alias: 'stop',
506
+ description: 'Pause a sandbox so it can be resumed later',
507
+ pastTense: 'Paused',
508
+ },
509
+ {
510
+ name: 'resume',
511
+ alias: 'start',
512
+ description: 'Resume a paused sandbox',
513
+ pastTense: 'Resumed',
514
+ },
515
+ ]) {
516
+ withOutput(sandbox
517
+ .command(lifecycle.name)
518
+ .alias(lifecycle.alias)
519
+ .description(lifecycle.description)
520
+ .argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
521
+ const record = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/${lifecycle.name}`);
522
+ if (opts.json)
523
+ printJson(record);
524
+ else
525
+ console.log(`${style.green('✓')} ${lifecycle.pastTense} ${id}`);
526
+ }));
527
+ }
257
528
  withOutput(sandbox
258
529
  .command('logs')
259
530
  .description('Show recent sandbox logs (one-shot journalctl tail; streaming is a follow-up)')
@@ -341,7 +612,8 @@ export function registerSandbox(program) {
341
612
  }));
342
613
  withOutput(sandbox
343
614
  .command('rm')
344
- .description('Destroy a sandbox, along with its public hostname when exposed')
615
+ .aliases(['kill', 'terminate'])
616
+ .description('Permanently destroy a sandbox and its public hostname when exposed')
345
617
  .argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
346
618
  const api = runCloudApi();
347
619
  try {
@@ -356,18 +628,32 @@ export function registerSandbox(program) {
356
628
  await api.delete(`/run-cloud/boxes/${encodeURIComponent(box.id)}`);
357
629
  }
358
630
  if (!opts.json)
359
- console.log(`destroyed ${id}`);
631
+ console.log(`${style.green('✓')} Destroyed ${id}`);
360
632
  }));
361
633
  const snapshot = sandbox.command('snapshot').description('Capture and manage sandbox snapshots');
362
634
  withOutput(snapshot
363
635
  .command('create')
364
636
  .description('Snapshot a sandbox for later restore')
365
637
  .argument('<id>', 'sandbox id to snapshot')
366
- .option('--label <label>', 'human-readable label')).action((id, opts) => run(async () => {
638
+ .option('--label <label>', 'human-readable label')
639
+ .option('--timeout <seconds>', 'maximum time to wait for snapshot completion', parseExecTimeoutSeconds, 3600)).action((id, opts) => run(async () => {
367
640
  const body = {};
368
641
  if (opts.label)
369
642
  body.label = opts.label;
370
- print(await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/snapshots`, body), opts);
643
+ const api = runCloudApi();
644
+ let result = await api.post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/snapshots`, body);
645
+ const deadline = Date.now() + opts.timeout * 1000;
646
+ while (result.state === 'pending') {
647
+ if (!result.id)
648
+ throw new Error('snapshot job did not return an id');
649
+ if (Date.now() >= deadline)
650
+ throw new Error(`snapshot ${result.id} did not finish before the timeout`);
651
+ await new Promise((resolve) => setTimeout(resolve, 1000));
652
+ result = await api.get(`/run-cloud/snapshots/${encodeURIComponent(result.id)}`);
653
+ }
654
+ if (result.state === 'failed')
655
+ throw new Error(`snapshot ${result.id ?? ''} failed`);
656
+ print(result, opts);
371
657
  }));
372
658
  withOutput(snapshot
373
659
  .command('list')
@@ -387,16 +673,33 @@ export function registerSandbox(program) {
387
673
  .description('Fork a new sandbox from a snapshot (warm start)')
388
674
  .argument('<snapshot-id>', 'snapshot id to restore from')
389
675
  .option('--name <name>', 'human-readable name for the new sandbox')
676
+ .option('--size <size>', 'size preset for the restored sandbox')
677
+ .option('--cpu <cores>', 'CPU cores, including fractional values', parseCpuCores)
678
+ .option('--memory <mb>', 'memory in MiB', parseMemoryMb)
390
679
  .option('--disk <gb>', 'root disk capacity in GiB (may only grow)', parseDiskGb)
391
- .option('--region <region>', 'placement region')).action((snapshotId, opts) => run(async () => {
680
+ .option('--region <region>', 'placement region')
681
+ .option('--secret-group <group>', 'attach a secret group to the fork; a fork inherits NONE by default (secrets live in memory, so a snapshot cannot carry them)', collectRepeatable, [])
682
+ .option('--secret <group/name>', 'attach one secret out of a group; repeatable, order matters', collectRepeatable, [])
683
+ .option('--env <NAME=VALUE>', 'literal value for the fork; applied last; repeatable', collectRepeatable, [])).action((snapshotId, opts) => run(async () => {
392
684
  const body = {};
393
685
  if (opts.name)
394
686
  body.name = opts.name;
687
+ if (opts.size)
688
+ body.size = opts.size;
689
+ if (opts.cpu !== undefined)
690
+ body.cpu = opts.cpu;
691
+ if (opts.memory !== undefined)
692
+ body.memory = opts.memory;
395
693
  if (opts.disk !== undefined)
396
694
  body.disk = opts.disk;
397
695
  if (opts.region)
398
696
  body.region = opts.region;
399
- print(await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body), opts);
697
+ Object.assign(body, secretSelector(opts));
698
+ const restored = await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body);
699
+ if (opts.json)
700
+ printJson(restored);
701
+ else
702
+ console.log(renderSandbox(restored, 'restored'));
400
703
  }));
401
704
  registerAccessCommands(sandbox);
402
705
  const box = program
@@ -0,0 +1,70 @@
1
+ import { formatAge, formatCpu, formatMib, formatSeconds, formatState, renderDetails, renderTable, shortDigest, style, } from '../terminal.js';
2
+ function resources(record) {
3
+ const memory = formatMib(record.mem_mb);
4
+ const disk = formatMib(record.disk_mb);
5
+ return [
6
+ formatCpu(record.milli_cpu),
7
+ memory ? `${memory} memory` : undefined,
8
+ disk ? `${disk} disk` : undefined,
9
+ ]
10
+ .filter(Boolean)
11
+ .join(' · ') || undefined;
12
+ }
13
+ function hostname(record) {
14
+ const host = record.hostname ?? record.box?.hostname;
15
+ const port = record.exposedPort ?? record.box?.port;
16
+ return host && port ? `${host}:${port}` : host;
17
+ }
18
+ function title(record, verb) {
19
+ if (verb === 'details')
20
+ return 'Sandbox';
21
+ if (record.state === 'running')
22
+ return `✓ Sandbox ${verb === 'restored' ? 'restored' : 'ready'}`;
23
+ if (record.state === 'building_image')
24
+ return '◐ Sandbox is building its image';
25
+ if (record.state === 'paused' || record.state === 'archived')
26
+ return '○ Sandbox paused';
27
+ return verb === 'restored' ? 'Sandbox restored' : 'Sandbox created';
28
+ }
29
+ export function renderSandbox(record, verb = 'details') {
30
+ const next = verb === 'details'
31
+ ? undefined
32
+ : record.state === 'running'
33
+ ? `${style.dim('Next:')} runcloud sandbox shell ${record.id}`
34
+ : `${style.dim('Follow:')} runcloud sandbox get ${record.id}`;
35
+ return renderDetails(title(record, verb), [
36
+ { label: 'ID', value: record.id },
37
+ { label: 'Name', value: record.name },
38
+ { label: 'State', value: formatState(record.state) },
39
+ { label: 'Image', value: record.image },
40
+ { label: 'Resources', value: resources(record) },
41
+ { label: 'Idle pause', value: formatSeconds(record.idle_pause_seconds) },
42
+ { label: 'Timeout', value: formatSeconds(record.timeout_seconds) },
43
+ { label: 'Hostname', value: hostname(record) },
44
+ { label: 'Image digest', value: shortDigest(record.image_digest) },
45
+ { label: 'Created', value: formatAge(record.created_at) },
46
+ { label: 'Last active', value: verb === 'details' ? formatAge(record.last_active_at) : undefined },
47
+ ], next);
48
+ }
49
+ export function renderSandboxList(records) {
50
+ if (records.length === 0) {
51
+ return `${style.bold('Sandboxes')}\n\nNo sandboxes found.\n\n${style.dim('Create one:')} runcloud sandbox create`;
52
+ }
53
+ return renderTable(`Sandboxes (${records.length})`, [
54
+ { key: 'id', label: 'ID', maxWidth: 24 },
55
+ { key: 'name', label: 'NAME / HOSTNAME', maxWidth: 20 },
56
+ { key: 'state', label: 'STATE', maxWidth: 14 },
57
+ { key: 'image', label: 'IMAGE', maxWidth: 20 },
58
+ { key: 'resources', label: 'CPU / MEMORY', maxWidth: 18 },
59
+ { key: 'age', label: 'AGE' },
60
+ ], records.map((record) => ({
61
+ id: record.id,
62
+ name: hostname(record) ?? record.name ?? '—',
63
+ state: formatState(record.state),
64
+ image: record.image ?? '—',
65
+ resources: [formatCpu(record.milli_cpu)?.replace(' CPU', ''), formatMib(record.mem_mb)]
66
+ .filter(Boolean)
67
+ .join(' / ') || '—',
68
+ age: formatAge(record.created_at) ?? '—',
69
+ })));
70
+ }