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 +10 -1
- package/dist/commands/boxAccess.js +1 -1
- package/dist/commands/image.js +66 -21
- package/dist/commands/sandbox.js +140 -14
- package/dist/commands/sandboxOutput.js +70 -0
- package/dist/terminal.js +158 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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}
|
|
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
|
}
|
package/dist/commands/image.js
CHANGED
|
@@ -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
|
-
|
|
13
|
+
printJson(value);
|
|
13
14
|
return;
|
|
14
15
|
}
|
|
15
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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', '
|
|
75
|
-
.option('--timeout <seconds>',
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
.
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/terminal.js
ADDED
|
@@ -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.
|
|
1
|
+
export const CLI_VERSION = '0.1.16';
|