runcloud 0.1.14 → 0.1.16

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/api.js CHANGED
@@ -172,12 +172,26 @@ function headline(err) {
172
172
  if (err.status === 403)
173
173
  return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
174
174
  if (err.status === 404) {
175
+ const snapshotRestoreMatch = err.path?.match(/^\/run-cloud\/snapshots\/([^/]+)\/restore$/);
176
+ if (snapshotRestoreMatch) {
177
+ const id = decodeURIComponent(snapshotRestoreMatch[1]);
178
+ if (/^sb(?:x)?_/.test(id)) {
179
+ return `Snapshot ID not found: ${id}. \`sandbox restore\` expects a snapshot ID; to restart this sandbox, run \`runcloud sandbox resume ${id}\`.`;
180
+ }
181
+ return `Snapshot ID not found: ${id}.`;
182
+ }
175
183
  const sandboxMatch = err.path?.match(/^\/run-cloud\/sandboxes\/([^/]+)/);
176
184
  if (sandboxMatch) {
177
185
  return `Sandbox ID not found: ${decodeURIComponent(sandboxMatch[1])}.`;
178
186
  }
179
- if (!err.detail || /^not found$/i.test(err.detail.trim()))
180
- return 'Resource not found.';
187
+ if (err.path?.startsWith('/run-cloud/images')) {
188
+ return 'Image not found — check the reference, and that it has been registered.';
189
+ }
190
+ if (!err.detail || /^not found$/i.test(err.detail.trim())) {
191
+ return err.path
192
+ ? "This version of the CLI asked for something the server doesn't have."
193
+ : 'Resource not found.';
194
+ }
181
195
  return err.detail;
182
196
  }
183
197
  if (err.status === 409)
@@ -190,5 +204,6 @@ function headline(err) {
190
204
  }
191
205
  export function friendlyApiError(err) {
192
206
  const message = headline(err);
193
- return err instanceof ApiError ? message : `${message}${detailSuffix(err)}`;
207
+ const debug = process.env.RUNCLOUD_DEBUG === '1' || process.env.NEWLY_DEBUG === '1';
208
+ return !debug || err instanceof ApiError ? message : `${message}${detailSuffix(err)}`;
194
209
  }
@@ -20,7 +20,7 @@ async function resolveBox(client, reference) {
20
20
  item.name === reference ||
21
21
  item.hostname === reference);
22
22
  if (!box) {
23
- throw new Error(`No exposed sandbox matches ${reference} — publish a hostname first: runcloud sandbox expose <id>`);
23
+ throw new Error(`No exposed sandbox matches ${reference}. Publish it first with \`runcloud sandbox expose ${reference} --name <name>\`, or use \`runcloud sandbox shell ${reference}\` without SSH.`);
24
24
  }
25
25
  return box;
26
26
  }
@@ -0,0 +1,145 @@
1
+ import { InvalidArgumentError, Option } from 'commander';
2
+ import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { requireCredentials } from '../config.js';
4
+ import { formatAge, formatState, printJson, renderDetails, renderTable, shortDigest, StatusLine, style, } from '../terminal.js';
5
+ const POLL_INTERVAL_MS = 3_000;
6
+ const DEFAULT_WAIT_TIMEOUT_SECONDS = 1_200;
7
+ function runCloudApi() {
8
+ const credentials = requireCredentials();
9
+ return new ApiClient(credentials.apiUrl, credentials.token);
10
+ }
11
+ function print(value, opts) {
12
+ if (opts.json) {
13
+ printJson(value);
14
+ return;
15
+ }
16
+ console.log(renderImage(value));
17
+ }
18
+ function renderImage(row, title) {
19
+ const heading = title ??
20
+ (row.state === 'ready'
21
+ ? '✓ Image ready'
22
+ : row.state === 'failed'
23
+ ? '✗ Image build failed'
24
+ : '◐ Image build started');
25
+ const footer = row.state === 'building'
26
+ ? `${style.dim('Follow:')} runcloud image list`
27
+ : undefined;
28
+ return renderDetails(heading, [
29
+ { label: 'Reference', value: row.ref ?? row.name },
30
+ { label: 'State', value: formatState(row.state) },
31
+ { label: 'Digest', value: shortDigest(row.digest) },
32
+ { label: 'Created', value: formatAge(row.createdAt) },
33
+ { label: 'Error', value: row.error },
34
+ ], footer);
35
+ }
36
+ function renderImageList(rows) {
37
+ if (rows.length === 0) {
38
+ return `${style.bold('Images')}\n\nNo images registered.\n\n${style.dim('Register one:')} runcloud image create --ref python:3.12-slim`;
39
+ }
40
+ return renderTable(`Images (${rows.length})`, [
41
+ { key: 'ref', label: 'REFERENCE', maxWidth: 44 },
42
+ { key: 'state', label: 'STATE', maxWidth: 12 },
43
+ { key: 'digest', label: 'DIGEST' },
44
+ { key: 'age', label: 'AGE' },
45
+ ], rows.map((row) => ({
46
+ ref: row.ref ?? row.name,
47
+ state: formatState(row.state),
48
+ digest: shortDigest(row.digest) ?? '—',
49
+ age: formatAge(row.createdAt) ?? '—',
50
+ })));
51
+ }
52
+ async function run(fn) {
53
+ try {
54
+ await fn();
55
+ }
56
+ catch (err) {
57
+ console.error(friendlyApiError(err));
58
+ process.exitCode = 1;
59
+ }
60
+ }
61
+ function withOutput(cmd) {
62
+ return cmd.option('--json', 'output JSON', false);
63
+ }
64
+ function parseTimeoutSeconds(value) {
65
+ const n = Number(value);
66
+ if (!Number.isFinite(n) || n <= 0) {
67
+ throw new InvalidArgumentError('--timeout must be a positive number of seconds');
68
+ }
69
+ return n;
70
+ }
71
+ const TERMINAL_STATES = new Set(['ready', 'failed']);
72
+ export async function pollImageRegistration(api, ref, opts = {}) {
73
+ const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
74
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_SECONDS * 1000;
75
+ const deadline = Date.now() + timeoutMs;
76
+ for (;;) {
77
+ const data = await api.get('/run-cloud/images');
78
+ const items = (data?.items ?? []);
79
+ const row = items.find((item) => item.ref === ref || item.name === ref);
80
+ if (row)
81
+ opts.onState?.(row.state);
82
+ if (row && TERMINAL_STATES.has(row.state))
83
+ return row;
84
+ if (Date.now() > deadline) {
85
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for image ${ref} to become ready`);
86
+ }
87
+ await new Promise((done) => setTimeout(done, intervalMs));
88
+ }
89
+ }
90
+ export function registerImage(program) {
91
+ const image = program
92
+ .command('image')
93
+ .description('Register and manage the OCI images run.cloud sandboxes boot from');
94
+ withOutput(image
95
+ .command('create')
96
+ .description('Register (build-once) an OCI image ref for run.cloud sandboxes')
97
+ .requiredOption('--ref <ref>', 'OCI image ref to register, e.g. python:3.12-slim')
98
+ .option('--no-wait', 'return after scheduling instead of waiting for the image build')
99
+ .option('--timeout <seconds>', 'max time to wait in seconds', parseTimeoutSeconds, DEFAULT_WAIT_TIMEOUT_SECONDS)
100
+ .addOption(new Option('--wait').hideHelp())).action((opts) => run(async () => {
101
+ const api = runCloudApi();
102
+ const created = await api.post('/run-cloud/images', { ref: opts.ref });
103
+ if (!opts.wait || created?.state === 'ready') {
104
+ print(created, opts);
105
+ return;
106
+ }
107
+ const status = opts.json ? undefined : new StatusLine();
108
+ status?.update(`Building ${opts.ref}`);
109
+ const finalRow = await pollImageRegistration(api, opts.ref, {
110
+ timeoutMs: opts.timeout * 1000,
111
+ onState: (state) => status?.update(state === 'building' ? `Building ${opts.ref}` : `${opts.ref} is ${state}`),
112
+ });
113
+ if (finalRow.state === 'failed') {
114
+ status?.fail(`Image build failed`);
115
+ throw new Error(finalRow.error
116
+ ? `image ${opts.ref} failed to build: ${finalRow.error}`
117
+ : `image ${opts.ref} failed to build`);
118
+ }
119
+ status?.clear();
120
+ print(finalRow, opts);
121
+ }));
122
+ withOutput(image.command('list').description('List images your organization has registered')).action((opts) => run(async () => {
123
+ const data = await runCloudApi().get('/run-cloud/images');
124
+ const rows = (data?.items ?? []);
125
+ if (opts.json)
126
+ printJson(rows);
127
+ else
128
+ console.log(renderImageList(rows));
129
+ }));
130
+ withOutput(image
131
+ .command('refresh')
132
+ .description("Move a tag's pin to its current upstream digest")
133
+ .argument('<ref>', 'OCI image ref to refresh')).action((ref, opts) => run(async () => {
134
+ const refreshed = await runCloudApi().post('/run-cloud/images/refresh', { ref });
135
+ if (opts.json)
136
+ printJson(refreshed);
137
+ else {
138
+ console.log(renderDetails('✓ Image refreshed', [
139
+ { label: 'Reference', value: refreshed?.ref ?? ref },
140
+ { label: 'Previous', value: shortDigest(refreshed?.old_digest) },
141
+ { label: 'Digest', value: shortDigest(refreshed?.new_digest ?? refreshed?.digest) },
142
+ ]));
143
+ }
144
+ }));
145
+ }
@@ -1,21 +1,27 @@
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
7
  import { runSandboxShell } from '../shellSession.js';
8
+ import { printJson, StatusLine, style } from '../terminal.js';
8
9
  import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
10
+ import { renderSandbox, renderSandboxList, } from './sandboxOutput.js';
9
11
  const MAX_SANDBOX_DISK_GIB = 2048;
10
12
  const DEFAULT_BOX_CPU_CORES = 2;
11
13
  const DEFAULT_BOX_MEMORY_MIB = 24 * 1024;
14
+ const SANDBOX_POLL_INTERVAL_MS = 1_000;
15
+ const SANDBOX_WAIT_TIMEOUT_MS = 20 * 60 * 1_000;
16
+ const READY_STATES = new Set(['running']);
17
+ const FAILED_STATES = new Set(['interrupted', 'stopped', 'destroyed']);
12
18
  function runCloudApi() {
13
19
  const credentials = requireCredentials();
14
20
  return new ApiClient(credentials.apiUrl, credentials.token);
15
21
  }
16
22
  function print(value, opts) {
17
23
  if (opts.json) {
18
- console.log(JSON.stringify(value, null, 2));
24
+ printJson(value);
19
25
  return;
20
26
  }
21
27
  if (Array.isArray(value)) {
@@ -25,6 +31,53 @@ function print(value, opts) {
25
31
  }
26
32
  console.log(formatRecord(value));
27
33
  }
34
+ function sandboxStatusMessage(state) {
35
+ if (state === 'building_image')
36
+ return 'Building image';
37
+ if (state === 'pending')
38
+ return 'Waiting for capacity';
39
+ if (state === 'starting')
40
+ return 'Starting sandbox';
41
+ if (state === 'archived')
42
+ return 'Restoring sandbox';
43
+ return `Sandbox is ${state.replaceAll('_', ' ')}`;
44
+ }
45
+ export async function pollSandboxReady(api, id, opts = {}) {
46
+ const intervalMs = opts.intervalMs ?? SANDBOX_POLL_INTERVAL_MS;
47
+ const timeoutMs = opts.timeoutMs ?? SANDBOX_WAIT_TIMEOUT_MS;
48
+ const deadline = Date.now() + timeoutMs;
49
+ for (;;) {
50
+ const record = (await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`));
51
+ const state = record.state ?? 'unknown';
52
+ opts.onState?.(state);
53
+ if (READY_STATES.has(state))
54
+ return record;
55
+ if (FAILED_STATES.has(state)) {
56
+ throw new Error(`sandbox ${id} entered ${state.replaceAll('_', ' ')} state`);
57
+ }
58
+ if (Date.now() > deadline) {
59
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for sandbox ${id} to start`);
60
+ }
61
+ await new Promise((done) => setTimeout(done, intervalMs));
62
+ }
63
+ }
64
+ export async function prepareSandboxShell(api, id, status = new StatusLine()) {
65
+ let record = (await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`));
66
+ if (record.state === 'paused' || record.state === 'archived') {
67
+ status.update(`Resuming ${id}`);
68
+ record = (await api.post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/resume`));
69
+ if (record.state !== 'running') {
70
+ record = await pollSandboxReady(api, id, {
71
+ onState: (state) => status.update(sandboxStatusMessage(state)),
72
+ });
73
+ }
74
+ status.succeed(`Resumed ${id}`);
75
+ }
76
+ if (record.state !== 'running') {
77
+ status.clear();
78
+ throw new Error(`sandbox ${id} is ${String(record.state).replaceAll('_', ' ')}; wait until it is running`);
79
+ }
80
+ }
28
81
  function formatRecord(value) {
29
82
  if (!value || typeof value !== 'object')
30
83
  return String(value);
@@ -107,6 +160,16 @@ function attachHostname(api, sandboxId, name, port) {
107
160
  }
108
161
  export function registerSandbox(program) {
109
162
  const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
163
+ const defaultHelp = new Help();
164
+ sandbox.configureHelp({
165
+ subcommandTerm: (command) => {
166
+ const term = defaultHelp.subcommandTerm(command);
167
+ const aliases = command.aliases();
168
+ if (aliases.length < 2)
169
+ return term;
170
+ return term.replace(`${command.name()}|${aliases[0]}`, `${command.name()}|${aliases.join('|')}`);
171
+ },
172
+ });
110
173
  withOutput(sandbox
111
174
  .command('create')
112
175
  .description('Spawn a microVM sandbox')
@@ -119,7 +182,8 @@ export function registerSandbox(program) {
119
182
  .option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)
120
183
  .option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
121
184
  .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 () => {
185
+ .option('--expose <port>', 'publish a guest port at <name>-box.run.cloud; implies --persistent', parsePort)
186
+ .option('--no-wait', 'return before the sandbox finishes building and starting')).action((opts) => run(async () => {
123
187
  const exposed = opts.expose !== undefined;
124
188
  if (exposed && !opts.name) {
125
189
  throw new Error('--expose needs --name <name>, the hostname prefix for <name>-box.run.cloud');
@@ -149,29 +213,58 @@ export function registerSandbox(program) {
149
213
  else if (opts.idlePause !== undefined)
150
214
  body.idlePauseSeconds = opts.idlePause;
151
215
  const api = runCloudApi();
152
- const created = await api.post('/run-cloud/sandboxes', body);
216
+ let created = (await api.post('/run-cloud/sandboxes', body));
217
+ if (typeof created.id !== 'string') {
218
+ throw new Error('sandbox create response did not include an id');
219
+ }
220
+ if (!opts.json && opts.wait && created.state && !READY_STATES.has(created.state)) {
221
+ const status = new StatusLine();
222
+ status.update(sandboxStatusMessage(created.state));
223
+ try {
224
+ created = await pollSandboxReady(api, created.id, {
225
+ onState: (state) => status.update(sandboxStatusMessage(state)),
226
+ });
227
+ status.clear();
228
+ }
229
+ catch (err) {
230
+ status.fail('Sandbox failed to start');
231
+ throw err;
232
+ }
233
+ }
153
234
  if (!exposed) {
154
- print(created, opts);
235
+ if (opts.json)
236
+ printJson(created);
237
+ else
238
+ console.log(renderSandbox(created, 'created'));
155
239
  return;
156
240
  }
157
- if (typeof created?.id !== 'string')
158
- throw new Error('sandbox create response did not include an id');
159
241
  const box = await attachHostname(api, created.id, opts.name, opts.expose);
160
- print({ ...created, box }, opts);
242
+ const exposedRecord = { ...created, box };
243
+ if (opts.json)
244
+ printJson(exposedRecord);
245
+ else
246
+ console.log(renderSandbox(exposedRecord, 'created'));
161
247
  }));
162
248
  withOutput(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
163
249
  const qs = opts.state ? `?state=${encodeURIComponent(opts.state)}` : '';
164
250
  const api = runCloudApi();
165
251
  const [data, boxes] = await Promise.all([
166
252
  api.get(`/run-cloud/sandboxes${qs}`),
167
- api.get('/run-cloud/boxes'),
253
+ api.get('/run-cloud/boxes').catch((err) => {
254
+ if (err instanceof ApiError && err.status === 404)
255
+ return { items: [] };
256
+ throw err;
257
+ }),
168
258
  ]);
169
259
  const bySandbox = new Map((boxes?.items ?? []).map((box) => [box.sandboxId, box]));
170
260
  const items = (data?.items ?? []).map((item) => {
171
261
  const box = bySandbox.get(item.id);
172
262
  return box ? { ...item, hostname: box.hostname, exposedPort: box.port } : item;
173
263
  });
174
- print(items, opts);
264
+ if (opts.json)
265
+ printJson(items);
266
+ else
267
+ console.log(renderSandboxList(items));
175
268
  }));
176
269
  withOutput(sandbox
177
270
  .command('get')
@@ -182,7 +275,11 @@ export function registerSandbox(program) {
182
275
  api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`),
183
276
  findBoxForSandbox(api, id),
184
277
  ]);
185
- print(box ? { ...record, box } : record, opts);
278
+ const sandboxRecord = (box ? { ...record, box } : record);
279
+ if (opts.json)
280
+ printJson(sandboxRecord);
281
+ else
282
+ console.log(renderSandbox(sandboxRecord));
186
283
  }));
187
284
  withOutput(sandbox
188
285
  .command('expose')
@@ -240,16 +337,44 @@ export function registerSandbox(program) {
240
337
  }));
241
338
  sandbox
242
339
  .command('shell')
243
- .description('Open an interactive shell in a running sandbox')
340
+ .description('Open an interactive shell (resumes paused sandboxes)')
244
341
  .argument('<id>', 'sandbox id')
245
342
  .action((id) => run(async () => {
246
343
  const credentials = requireCredentials();
344
+ const api = new ApiClient(credentials.apiUrl, credentials.token);
345
+ await prepareSandboxShell(api, id);
247
346
  process.exitCode = await runSandboxShell({
248
347
  apiUrl: credentials.apiUrl,
249
348
  token: credentials.token,
250
349
  sandboxId: id,
251
350
  });
252
351
  }));
352
+ for (const lifecycle of [
353
+ {
354
+ name: 'pause',
355
+ alias: 'stop',
356
+ description: 'Pause a sandbox so it can be resumed later',
357
+ pastTense: 'Paused',
358
+ },
359
+ {
360
+ name: 'resume',
361
+ alias: 'start',
362
+ description: 'Resume a paused sandbox',
363
+ pastTense: 'Resumed',
364
+ },
365
+ ]) {
366
+ withOutput(sandbox
367
+ .command(lifecycle.name)
368
+ .alias(lifecycle.alias)
369
+ .description(lifecycle.description)
370
+ .argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
371
+ const record = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/${lifecycle.name}`);
372
+ if (opts.json)
373
+ printJson(record);
374
+ else
375
+ console.log(`${style.green('✓')} ${lifecycle.pastTense} ${id}`);
376
+ }));
377
+ }
253
378
  withOutput(sandbox
254
379
  .command('logs')
255
380
  .description('Show recent sandbox logs (one-shot journalctl tail; streaming is a follow-up)')
@@ -337,7 +462,8 @@ export function registerSandbox(program) {
337
462
  }));
338
463
  withOutput(sandbox
339
464
  .command('rm')
340
- .description('Destroy a sandbox, along with its public hostname when exposed')
465
+ .aliases(['kill', 'terminate'])
466
+ .description('Permanently destroy a sandbox and its public hostname when exposed')
341
467
  .argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
342
468
  const api = runCloudApi();
343
469
  try {
@@ -352,7 +478,7 @@ export function registerSandbox(program) {
352
478
  await api.delete(`/run-cloud/boxes/${encodeURIComponent(box.id)}`);
353
479
  }
354
480
  if (!opts.json)
355
- console.log(`destroyed ${id}`);
481
+ console.log(`${style.green('✓')} Destroyed ${id}`);
356
482
  }));
357
483
  const snapshot = sandbox.command('snapshot').description('Capture and manage sandbox snapshots');
358
484
  withOutput(snapshot
@@ -392,7 +518,11 @@ export function registerSandbox(program) {
392
518
  body.disk = opts.disk;
393
519
  if (opts.region)
394
520
  body.region = opts.region;
395
- print(await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body), opts);
521
+ const restored = await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body);
522
+ if (opts.json)
523
+ printJson(restored);
524
+ else
525
+ console.log(renderSandbox(restored, 'restored'));
396
526
  }));
397
527
  registerAccessCommands(sandbox);
398
528
  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
+ }
package/dist/run-cloud.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { registerImage } from './commands/image.js';
3
4
  import { registerLogin } from './commands/login.js';
4
5
  import { registerRunCloud } from './commands/run-cloud.js';
5
6
  import { registerSandbox } from './commands/sandbox.js';
@@ -10,4 +11,5 @@ const program = new Command('runcloud')
10
11
  registerLogin(program);
11
12
  registerRunCloud(program);
12
13
  registerSandbox(program);
14
+ registerImage(program);
13
15
  await program.parseAsync(process.argv);
@@ -0,0 +1,158 @@
1
+ const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
2
+ function supportsColor(stream) {
3
+ return stream.isTTY === true && process.env.NO_COLOR === undefined;
4
+ }
5
+ function paint(code, value, stream = process.stdout) {
6
+ return supportsColor(stream) ? `\u001b[${code}m${value}\u001b[0m` : value;
7
+ }
8
+ export const style = {
9
+ bold: (value, stream) => paint(1, value, stream),
10
+ dim: (value, stream) => paint(2, value, stream),
11
+ red: (value, stream) => paint(31, value, stream),
12
+ green: (value, stream) => paint(32, value, stream),
13
+ yellow: (value, stream) => paint(33, value, stream),
14
+ blue: (value, stream) => paint(34, value, stream),
15
+ cyan: (value, stream) => paint(36, value, stream),
16
+ };
17
+ function visibleLength(value) {
18
+ return value.replace(ANSI_PATTERN, '').length;
19
+ }
20
+ function truncate(value, maxWidth) {
21
+ if (!maxWidth || visibleLength(value) <= maxWidth)
22
+ return value;
23
+ if (maxWidth <= 1)
24
+ return '…';
25
+ return `${value.slice(0, maxWidth - 1)}…`;
26
+ }
27
+ function pad(value, width) {
28
+ return `${value}${' '.repeat(Math.max(0, width - visibleLength(value)))}`;
29
+ }
30
+ export function renderTable(title, columns, rows) {
31
+ if (rows.length === 0)
32
+ return title;
33
+ const cells = rows.map((row) => Object.fromEntries(columns.map((column) => [
34
+ column.key,
35
+ truncate(row[column.key] === undefined || row[column.key] === null ? '—' : String(row[column.key]), column.maxWidth),
36
+ ])));
37
+ const widths = Object.fromEntries(columns.map((column) => [
38
+ column.key,
39
+ Math.max(visibleLength(column.label), ...cells.map((row) => visibleLength(row[column.key]))),
40
+ ]));
41
+ const line = (row) => columns
42
+ .map((column, index) => {
43
+ const value = row[column.key];
44
+ return index === columns.length - 1 ? value : pad(value, widths[column.key]);
45
+ })
46
+ .join(' ')
47
+ .trimEnd();
48
+ const header = Object.fromEntries(columns.map((column) => [column.key, style.dim(column.label)]));
49
+ return `${style.bold(title)}\n\n${line(header)}\n${cells.map((row) => line(row)).join('\n')}`;
50
+ }
51
+ export function renderDetails(title, fields, footer) {
52
+ const visibleFields = fields.filter(({ value }) => value !== undefined && value !== null && value !== '');
53
+ const labelWidth = Math.max(...visibleFields.map(({ label }) => label.length), 0);
54
+ const lines = visibleFields.map(({ label, value }) => ` ${style.dim(pad(label, labelWidth))} ${String(value)}`);
55
+ return [style.bold(title), ...lines, footer ? `\n${footer}` : undefined]
56
+ .filter((value) => value !== undefined)
57
+ .join('\n');
58
+ }
59
+ export function formatState(state) {
60
+ const value = typeof state === 'string' && state ? state : 'unknown';
61
+ if (value === 'running' || value === 'ready')
62
+ return style.green(value);
63
+ if (['building', 'building_image', 'pending', 'starting', 'destroying'].includes(value)) {
64
+ return style.yellow(value.replaceAll('_', ' '));
65
+ }
66
+ if (value === 'paused' || value === 'archived' || value === 'stopped') {
67
+ return style.blue(value);
68
+ }
69
+ if (value === 'failed' || value === 'interrupted' || value === 'destroyed') {
70
+ return style.red(value);
71
+ }
72
+ return value.replaceAll('_', ' ');
73
+ }
74
+ function rounded(value, fractionDigits = 1) {
75
+ return Number.isInteger(value) ? String(value) : value.toFixed(fractionDigits).replace(/\.0$/, '');
76
+ }
77
+ export function formatCpu(milliCpu) {
78
+ if (typeof milliCpu !== 'number' || !Number.isFinite(milliCpu))
79
+ return undefined;
80
+ return `${rounded(milliCpu / 1000, 3)} CPU`;
81
+ }
82
+ export function formatMib(mebibytes) {
83
+ if (typeof mebibytes !== 'number' || !Number.isFinite(mebibytes))
84
+ return undefined;
85
+ if (mebibytes >= 1024)
86
+ return `${rounded(mebibytes / 1024)} GiB`;
87
+ return `${rounded(mebibytes)} MiB`;
88
+ }
89
+ export function formatSeconds(seconds) {
90
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds))
91
+ return undefined;
92
+ if (seconds === 0)
93
+ return 'never';
94
+ if (seconds < 60)
95
+ return `${rounded(seconds)}s`;
96
+ if (seconds < 3600)
97
+ return `${rounded(seconds / 60)}m`;
98
+ if (seconds < 86_400)
99
+ return `${rounded(seconds / 3600)}h`;
100
+ return `${rounded(seconds / 86_400)}d`;
101
+ }
102
+ export function formatAge(value, now = Date.now()) {
103
+ if (typeof value !== 'string')
104
+ return undefined;
105
+ const timestamp = Date.parse(value);
106
+ if (!Number.isFinite(timestamp))
107
+ return value;
108
+ const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
109
+ if (seconds < 5)
110
+ return 'just now';
111
+ return `${formatSeconds(seconds)} ago`;
112
+ }
113
+ export function shortDigest(value) {
114
+ if (typeof value !== 'string' || !value)
115
+ return undefined;
116
+ const digest = value.startsWith('sha256:') ? value.slice(7) : value;
117
+ return digest.slice(0, 12);
118
+ }
119
+ export function printJson(value) {
120
+ console.log(JSON.stringify(value, null, 2));
121
+ }
122
+ export class StatusLine {
123
+ lastMessage;
124
+ frame = 0;
125
+ update(message) {
126
+ if (process.stderr.isTTY) {
127
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
128
+ const prefix = style.cyan(frames[this.frame++ % frames.length], process.stderr);
129
+ process.stderr.write(`\r\u001b[2K${prefix} ${message}`);
130
+ this.lastMessage = message;
131
+ return;
132
+ }
133
+ if (message !== this.lastMessage) {
134
+ process.stderr.write(`→ ${message}\n`);
135
+ this.lastMessage = message;
136
+ }
137
+ }
138
+ succeed(message) {
139
+ this.finish(`${style.green('✓', process.stderr)} ${message}`);
140
+ }
141
+ fail(message) {
142
+ this.finish(`${style.red('✗', process.stderr)} ${message}`);
143
+ }
144
+ clear() {
145
+ if (process.stderr.isTTY && this.lastMessage)
146
+ process.stderr.write('\r\u001b[2K');
147
+ this.lastMessage = undefined;
148
+ }
149
+ finish(message) {
150
+ if (process.stderr.isTTY && this.lastMessage) {
151
+ process.stderr.write(`\r\u001b[2K${message}\n`);
152
+ }
153
+ else {
154
+ process.stderr.write(`${message}\n`);
155
+ }
156
+ this.lastMessage = undefined;
157
+ }
158
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.1.14';
1
+ export const CLI_VERSION = '0.1.16';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [