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 +18 -3
- package/dist/commands/boxAccess.js +1 -1
- package/dist/commands/image.js +145 -0
- package/dist/commands/sandbox.js +145 -15
- package/dist/commands/sandboxOutput.js +70 -0
- package/dist/run-cloud.js +2 -0
- package/dist/terminal.js +158 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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 (
|
|
180
|
-
return '
|
|
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
|
-
|
|
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}
|
|
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
|
+
}
|
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,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
|
-
|
|
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)}` : '';
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
.
|
|
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(
|
|
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
|
-
|
|
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);
|
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';
|