runcloud 0.1.15 → 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,6 +172,14 @@ 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])}.`;
@@ -196,5 +204,6 @@ function headline(err) {
196
204
  }
197
205
  export function friendlyApiError(err) {
198
206
  const message = headline(err);
199
- 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)}`;
200
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
  }
@@ -1,6 +1,7 @@
1
- import { InvalidArgumentError } from 'commander';
1
+ import { InvalidArgumentError, Option } from 'commander';
2
2
  import { ApiClient, friendlyApiError } from '../api.js';
3
3
  import { requireCredentials } from '../config.js';
4
+ import { formatAge, formatState, printJson, renderDetails, renderTable, shortDigest, StatusLine, style, } from '../terminal.js';
4
5
  const POLL_INTERVAL_MS = 3_000;
5
6
  const DEFAULT_WAIT_TIMEOUT_SECONDS = 1_200;
6
7
  function runCloudApi() {
@@ -9,23 +10,44 @@ function runCloudApi() {
9
10
  }
10
11
  function print(value, opts) {
11
12
  if (opts.json) {
12
- console.log(JSON.stringify(value, null, 2));
13
+ printJson(value);
13
14
  return;
14
15
  }
15
- if (Array.isArray(value)) {
16
- for (const item of value)
17
- console.log(formatRecord(item));
18
- return;
19
- }
20
- console.log(formatRecord(value));
16
+ console.log(renderImage(value));
21
17
  }
22
- function formatRecord(value) {
23
- if (!value || typeof value !== 'object')
24
- return String(value);
25
- return Object.entries(value)
26
- .filter(([, v]) => v !== undefined && v !== null)
27
- .map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
28
- .join('\n');
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
+ })));
29
51
  }
30
52
  async function run(fn) {
31
53
  try {
@@ -55,6 +77,8 @@ export async function pollImageRegistration(api, ref, opts = {}) {
55
77
  const data = await api.get('/run-cloud/images');
56
78
  const items = (data?.items ?? []);
57
79
  const row = items.find((item) => item.ref === ref || item.name === ref);
80
+ if (row)
81
+ opts.onState?.(row.state);
58
82
  if (row && TERMINAL_STATES.has(row.state))
59
83
  return row;
60
84
  if (Date.now() > deadline) {
@@ -71,30 +95,51 @@ export function registerImage(program) {
71
95
  .command('create')
72
96
  .description('Register (build-once) an OCI image ref for run.cloud sandboxes')
73
97
  .requiredOption('--ref <ref>', 'OCI image ref to register, e.g. python:3.12-slim')
74
- .option('--wait', 'poll until the image is ready or failed', false)
75
- .option('--timeout <seconds>', `max time to wait with --wait, in seconds (default ${DEFAULT_WAIT_TIMEOUT_SECONDS})`, parseTimeoutSeconds, DEFAULT_WAIT_TIMEOUT_SECONDS)).action((opts) => run(async () => {
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 () => {
76
101
  const api = runCloudApi();
77
102
  const created = await api.post('/run-cloud/images', { ref: opts.ref });
78
- if (!opts.wait) {
103
+ if (!opts.wait || created?.state === 'ready') {
79
104
  print(created, opts);
80
105
  return;
81
106
  }
82
- const finalRow = await pollImageRegistration(api, opts.ref, { timeoutMs: opts.timeout * 1000 });
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
+ });
83
113
  if (finalRow.state === 'failed') {
114
+ status?.fail(`Image build failed`);
84
115
  throw new Error(finalRow.error
85
116
  ? `image ${opts.ref} failed to build: ${finalRow.error}`
86
117
  : `image ${opts.ref} failed to build`);
87
118
  }
119
+ status?.clear();
88
120
  print(finalRow, opts);
89
121
  }));
90
122
  withOutput(image.command('list').description('List images your organization has registered')).action((opts) => run(async () => {
91
123
  const data = await runCloudApi().get('/run-cloud/images');
92
- print(data?.items ?? [], opts);
124
+ const rows = (data?.items ?? []);
125
+ if (opts.json)
126
+ printJson(rows);
127
+ else
128
+ console.log(renderImageList(rows));
93
129
  }));
94
130
  withOutput(image
95
131
  .command('refresh')
96
132
  .description("Move a tag's pin to its current upstream digest")
97
133
  .argument('<ref>', 'OCI image ref to refresh')).action((ref, opts) => run(async () => {
98
- print(await runCloudApi().post('/run-cloud/images/refresh', { ref }), opts);
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
+ }
99
144
  }));
100
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,15 +213,37 @@ 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)}` : '';
@@ -175,7 +261,10 @@ export function registerSandbox(program) {
175
261
  const box = bySandbox.get(item.id);
176
262
  return box ? { ...item, hostname: box.hostname, exposedPort: box.port } : item;
177
263
  });
178
- print(items, opts);
264
+ if (opts.json)
265
+ printJson(items);
266
+ else
267
+ console.log(renderSandboxList(items));
179
268
  }));
180
269
  withOutput(sandbox
181
270
  .command('get')
@@ -186,7 +275,11 @@ export function registerSandbox(program) {
186
275
  api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`),
187
276
  findBoxForSandbox(api, id),
188
277
  ]);
189
- 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));
190
283
  }));
191
284
  withOutput(sandbox
192
285
  .command('expose')
@@ -244,16 +337,44 @@ export function registerSandbox(program) {
244
337
  }));
245
338
  sandbox
246
339
  .command('shell')
247
- .description('Open an interactive shell in a running sandbox')
340
+ .description('Open an interactive shell (resumes paused sandboxes)')
248
341
  .argument('<id>', 'sandbox id')
249
342
  .action((id) => run(async () => {
250
343
  const credentials = requireCredentials();
344
+ const api = new ApiClient(credentials.apiUrl, credentials.token);
345
+ await prepareSandboxShell(api, id);
251
346
  process.exitCode = await runSandboxShell({
252
347
  apiUrl: credentials.apiUrl,
253
348
  token: credentials.token,
254
349
  sandboxId: id,
255
350
  });
256
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
+ }
257
378
  withOutput(sandbox
258
379
  .command('logs')
259
380
  .description('Show recent sandbox logs (one-shot journalctl tail; streaming is a follow-up)')
@@ -341,7 +462,8 @@ export function registerSandbox(program) {
341
462
  }));
342
463
  withOutput(sandbox
343
464
  .command('rm')
344
- .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')
345
467
  .argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
346
468
  const api = runCloudApi();
347
469
  try {
@@ -356,7 +478,7 @@ export function registerSandbox(program) {
356
478
  await api.delete(`/run-cloud/boxes/${encodeURIComponent(box.id)}`);
357
479
  }
358
480
  if (!opts.json)
359
- console.log(`destroyed ${id}`);
481
+ console.log(`${style.green('✓')} Destroyed ${id}`);
360
482
  }));
361
483
  const snapshot = sandbox.command('snapshot').description('Capture and manage sandbox snapshots');
362
484
  withOutput(snapshot
@@ -396,7 +518,11 @@ export function registerSandbox(program) {
396
518
  body.disk = opts.disk;
397
519
  if (opts.region)
398
520
  body.region = opts.region;
399
- 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'));
400
526
  }));
401
527
  registerAccessCommands(sandbox);
402
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
+ }
@@ -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.15';
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.15",
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": [