runcloud 0.1.8 → 0.1.10
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/README.md +31 -3
- package/dist/api.js +2 -1
- package/dist/commands/boxAccess.js +127 -89
- package/dist/commands/environment.js +1 -1
- package/dist/commands/login.js +149 -14
- package/dist/commands/run-cloud.js +2 -2
- package/dist/commands/sandbox.js +200 -68
- package/dist/config.js +6 -10
- package/dist/deviceLogin.js +48 -0
- package/dist/prompt.js +150 -0
- package/dist/run-cloud.js +2 -3
- package/dist/runCloudAuth.js +79 -0
- package/dist/{environmentShell.js → shellSession.js} +17 -3
- package/dist/version.js +1 -0
- package/package.json +1 -1
package/dist/commands/sandbox.js
CHANGED
|
@@ -2,9 +2,10 @@ import { 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
|
-
import { ApiClient, friendlyApiError } from '../api.js';
|
|
5
|
+
import { ApiClient, ApiError, friendlyApiError } from '../api.js';
|
|
6
6
|
import { requireCredentials } from '../config.js';
|
|
7
|
-
import {
|
|
7
|
+
import { runSandboxShell } from '../shellSession.js';
|
|
8
|
+
import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
|
|
8
9
|
const MAX_SANDBOX_DISK_GIB = 2048;
|
|
9
10
|
const DEFAULT_BOX_CPU_CORES = 2;
|
|
10
11
|
const DEFAULT_BOX_MEMORY_MIB = 24 * 1024;
|
|
@@ -97,40 +98,126 @@ export function openBrowserUrl(url, platform = process.platform) {
|
|
|
97
98
|
}
|
|
98
99
|
execFileSync('xdg-open', [url], { stdio: 'ignore' });
|
|
99
100
|
}
|
|
101
|
+
async function findBoxForSandbox(api, sandboxId) {
|
|
102
|
+
const data = await api.get('/run-cloud/boxes');
|
|
103
|
+
return (data?.items ?? []).find((box) => box.sandboxId === sandboxId);
|
|
104
|
+
}
|
|
105
|
+
function attachHostname(api, sandboxId, name, port) {
|
|
106
|
+
return api.post('/run-cloud/boxes', { sandboxId, name, port });
|
|
107
|
+
}
|
|
100
108
|
export function registerSandbox(program) {
|
|
101
109
|
const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
|
|
102
110
|
withOutput(sandbox
|
|
103
111
|
.command('create')
|
|
104
112
|
.description('Spawn a microVM sandbox')
|
|
105
|
-
.option('--image <ref>', 'base image
|
|
113
|
+
.option('--image <ref>', 'base image (default runcloud/agent-base, or runcloud/box with --expose)')
|
|
106
114
|
.option('--region <region>', 'placement region')
|
|
107
|
-
.option('--name <name>', 'human-readable name')
|
|
115
|
+
.option('--name <name>', 'human-readable name; with --expose, the hostname prefix <name>-box.run.cloud')
|
|
108
116
|
.option('--org <id>', 'billing and ownership organization')
|
|
109
117
|
.option('--cpu <cores>', 'fractional vCPU allocation, e.g. 0.5', parseCpuCores)
|
|
110
118
|
.option('--memory <mb>', 'memory allocation in MiB', parseMemoryMb)
|
|
111
119
|
.option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)
|
|
112
|
-
.option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
|
|
113
|
-
|
|
120
|
+
.option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
|
|
121
|
+
.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 () => {
|
|
123
|
+
const exposed = opts.expose !== undefined;
|
|
124
|
+
if (exposed && !opts.name) {
|
|
125
|
+
throw new Error('--expose needs --name <name>, the hostname prefix for <name>-box.run.cloud');
|
|
126
|
+
}
|
|
127
|
+
if ((exposed || opts.persistent) && opts.idlePause !== undefined && opts.idlePause !== 0) {
|
|
128
|
+
throw new Error('--persistent and --expose keep the sandbox running; drop the non-zero --idle-pause');
|
|
129
|
+
}
|
|
130
|
+
const body = {
|
|
131
|
+
image: opts.image ?? (exposed ? 'runcloud/box' : 'runcloud/agent-base'),
|
|
132
|
+
};
|
|
114
133
|
if (opts.name)
|
|
115
134
|
body.name = opts.name;
|
|
116
135
|
if (opts.region)
|
|
117
136
|
body.region = opts.region;
|
|
118
137
|
if (opts.org)
|
|
119
138
|
body.orgId = opts.org;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
body.
|
|
139
|
+
const cpu = opts.cpu ?? (exposed ? DEFAULT_BOX_CPU_CORES : undefined);
|
|
140
|
+
const memory = opts.memory ?? (exposed ? DEFAULT_BOX_MEMORY_MIB : undefined);
|
|
141
|
+
if (cpu !== undefined)
|
|
142
|
+
body.cpu = cpu;
|
|
143
|
+
if (memory !== undefined)
|
|
144
|
+
body.memory = memory;
|
|
124
145
|
if (opts.disk !== undefined)
|
|
125
146
|
body.disk = opts.disk;
|
|
126
|
-
if (opts.
|
|
147
|
+
if (exposed || opts.persistent)
|
|
148
|
+
body.idlePauseSeconds = 0;
|
|
149
|
+
else if (opts.idlePause !== undefined)
|
|
127
150
|
body.idlePauseSeconds = opts.idlePause;
|
|
128
|
-
|
|
151
|
+
const api = runCloudApi();
|
|
152
|
+
const created = await api.post('/run-cloud/sandboxes', body);
|
|
153
|
+
if (!exposed) {
|
|
154
|
+
print(created, opts);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (typeof created?.id !== 'string')
|
|
158
|
+
throw new Error('sandbox create response did not include an id');
|
|
159
|
+
const box = await attachHostname(api, created.id, opts.name, opts.expose);
|
|
160
|
+
print({ ...created, box }, opts);
|
|
129
161
|
}));
|
|
130
162
|
withOutput(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
|
|
131
163
|
const qs = opts.state ? `?state=${encodeURIComponent(opts.state)}` : '';
|
|
132
|
-
const
|
|
133
|
-
|
|
164
|
+
const api = runCloudApi();
|
|
165
|
+
const [data, boxes] = await Promise.all([
|
|
166
|
+
api.get(`/run-cloud/sandboxes${qs}`),
|
|
167
|
+
api.get('/run-cloud/boxes'),
|
|
168
|
+
]);
|
|
169
|
+
const bySandbox = new Map((boxes?.items ?? []).map((box) => [box.sandboxId, box]));
|
|
170
|
+
const items = (data?.items ?? []).map((item) => {
|
|
171
|
+
const box = bySandbox.get(item.id);
|
|
172
|
+
return box ? { ...item, hostname: box.hostname, exposedPort: box.port } : item;
|
|
173
|
+
});
|
|
174
|
+
print(items, opts);
|
|
175
|
+
}));
|
|
176
|
+
withOutput(sandbox
|
|
177
|
+
.command('get')
|
|
178
|
+
.description('Inspect a sandbox, including its public hostname when exposed')
|
|
179
|
+
.argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
|
|
180
|
+
const api = runCloudApi();
|
|
181
|
+
const [record, box] = await Promise.all([
|
|
182
|
+
api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`),
|
|
183
|
+
findBoxForSandbox(api, id),
|
|
184
|
+
]);
|
|
185
|
+
print(box ? { ...record, box } : record, opts);
|
|
186
|
+
}));
|
|
187
|
+
withOutput(sandbox
|
|
188
|
+
.command('expose')
|
|
189
|
+
.description('Publish a sandbox guest port at a stable hostname, or change the published port')
|
|
190
|
+
.argument('<id>', 'sandbox id')
|
|
191
|
+
.option('--port <port>', 'guest port to publish (default 3000)', parsePort)
|
|
192
|
+
.option('--name <name>', 'hostname prefix for a first exposure: <name>-box.run.cloud (defaults to the sandbox name)')
|
|
193
|
+
.option('--reconcile', 'retry or reconcile hostname routing', false)).action((id, opts) => run(async () => {
|
|
194
|
+
const api = runCloudApi();
|
|
195
|
+
const existing = await findBoxForSandbox(api, id);
|
|
196
|
+
if (!existing) {
|
|
197
|
+
if (opts.reconcile)
|
|
198
|
+
throw new Error(`sandbox ${id} has no hostname to reconcile`);
|
|
199
|
+
let name = opts.name;
|
|
200
|
+
if (!name) {
|
|
201
|
+
const record = await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
|
|
202
|
+
name = typeof record?.name === 'string' && record.name ? record.name : undefined;
|
|
203
|
+
}
|
|
204
|
+
if (!name)
|
|
205
|
+
throw new Error('this sandbox has no name; pass --name <name> to mint <name>-box.run.cloud');
|
|
206
|
+
print(await attachHostname(api, id, name, opts.port ?? 3000), opts);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (opts.name && opts.name !== existing.name) {
|
|
210
|
+
throw new Error(`hostname is fixed at ${existing.hostname}; run 'sandbox rm ${id}' and expose again to rename`);
|
|
211
|
+
}
|
|
212
|
+
if (opts.reconcile) {
|
|
213
|
+
print(await api.post(`/run-cloud/boxes/${encodeURIComponent(existing.id)}/route`), opts);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (opts.port !== undefined) {
|
|
217
|
+
print(await api.patch(`/run-cloud/boxes/${encodeURIComponent(existing.id)}`, { port: opts.port }), opts);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
print(existing, opts);
|
|
134
221
|
}));
|
|
135
222
|
withOutput(sandbox
|
|
136
223
|
.command('exec')
|
|
@@ -151,6 +238,18 @@ export function registerSandbox(program) {
|
|
|
151
238
|
}
|
|
152
239
|
process.exitCode = r.exit_code ?? 0;
|
|
153
240
|
}));
|
|
241
|
+
sandbox
|
|
242
|
+
.command('shell')
|
|
243
|
+
.description('Open an interactive shell in a running sandbox')
|
|
244
|
+
.argument('<id>', 'sandbox id')
|
|
245
|
+
.action((id) => run(async () => {
|
|
246
|
+
const credentials = requireCredentials();
|
|
247
|
+
process.exitCode = await runSandboxShell({
|
|
248
|
+
apiUrl: credentials.apiUrl,
|
|
249
|
+
token: credentials.token,
|
|
250
|
+
sandboxId: id,
|
|
251
|
+
});
|
|
252
|
+
}));
|
|
154
253
|
withOutput(sandbox
|
|
155
254
|
.command('logs')
|
|
156
255
|
.description('Show recent sandbox logs (one-shot journalctl tail; streaming is a follow-up)')
|
|
@@ -236,8 +335,22 @@ export function registerSandbox(program) {
|
|
|
236
335
|
const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'key', keys });
|
|
237
336
|
print(result ?? { ok: true }, opts);
|
|
238
337
|
}));
|
|
239
|
-
withOutput(sandbox
|
|
240
|
-
|
|
338
|
+
withOutput(sandbox
|
|
339
|
+
.command('rm')
|
|
340
|
+
.description('Destroy a sandbox, along with its public hostname when exposed')
|
|
341
|
+
.argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
|
|
342
|
+
const api = runCloudApi();
|
|
343
|
+
try {
|
|
344
|
+
await api.delete(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
if (!(err instanceof ApiError) || err.status !== 409)
|
|
348
|
+
throw err;
|
|
349
|
+
const box = await findBoxForSandbox(api, id);
|
|
350
|
+
if (!box)
|
|
351
|
+
throw err;
|
|
352
|
+
await api.delete(`/run-cloud/boxes/${encodeURIComponent(box.id)}`);
|
|
353
|
+
}
|
|
241
354
|
if (!opts.json)
|
|
242
355
|
console.log(`destroyed ${id}`);
|
|
243
356
|
}));
|
|
@@ -281,10 +394,13 @@ export function registerSandbox(program) {
|
|
|
281
394
|
body.region = opts.region;
|
|
282
395
|
print(await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body), opts);
|
|
283
396
|
}));
|
|
284
|
-
|
|
397
|
+
registerAccessCommands(sandbox);
|
|
398
|
+
const box = program
|
|
399
|
+
.command('box')
|
|
400
|
+
.description('Deprecated: a box is a sandbox with --persistent and --expose — use `runcloud sandbox`');
|
|
285
401
|
withOutput(box
|
|
286
402
|
.command('create')
|
|
287
|
-
.description('
|
|
403
|
+
.description('Deprecated: use `sandbox create --expose <port> --name <name>`')
|
|
288
404
|
.argument('<name>', 'hostname prefix: <name>-box.run.cloud')
|
|
289
405
|
.option('--sandbox <id>', 'attach an existing sandbox created with --idle-pause 0')
|
|
290
406
|
.option('--port <port>', 'guest port to publish', parsePort, 3000)
|
|
@@ -293,61 +409,77 @@ export function registerSandbox(program) {
|
|
|
293
409
|
.option('--org <id>', 'billing and ownership organization for a new sandbox')
|
|
294
410
|
.option('--cpu <cores>', 'vCPU allocation for a new box', parseCpuCores, DEFAULT_BOX_CPU_CORES)
|
|
295
411
|
.option('--memory <mb>', 'memory allocation in MiB for a new box', parseMemoryMb, DEFAULT_BOX_MEMORY_MIB)
|
|
296
|
-
.option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)).action((name, opts) =>
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
412
|
+
.option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)).action((name, opts) => {
|
|
413
|
+
noteDeprecated('box create', opts.sandbox
|
|
414
|
+
? `sandbox expose ${opts.sandbox} --name ${name}`
|
|
415
|
+
: `sandbox create --expose ${opts.port} --name ${name}`);
|
|
416
|
+
return run(async () => {
|
|
417
|
+
const api = runCloudApi();
|
|
418
|
+
let sandboxId = opts.sandbox;
|
|
419
|
+
if (!sandboxId) {
|
|
420
|
+
const sandboxBody = {
|
|
421
|
+
image: opts.image,
|
|
422
|
+
name,
|
|
423
|
+
idlePauseSeconds: 0,
|
|
424
|
+
};
|
|
425
|
+
if (opts.region)
|
|
426
|
+
sandboxBody.region = opts.region;
|
|
427
|
+
if (opts.org)
|
|
428
|
+
sandboxBody.orgId = opts.org;
|
|
429
|
+
sandboxBody.cpu = opts.cpu;
|
|
430
|
+
sandboxBody.memory = opts.memory;
|
|
431
|
+
if (opts.disk !== undefined)
|
|
432
|
+
sandboxBody.disk = opts.disk;
|
|
433
|
+
const created = await api.post('/run-cloud/sandboxes', sandboxBody);
|
|
434
|
+
sandboxId = typeof created?.id === 'string' ? created.id : undefined;
|
|
435
|
+
if (!sandboxId)
|
|
436
|
+
throw new Error('sandbox create response did not include an id');
|
|
437
|
+
}
|
|
438
|
+
print(await attachHostname(api, sandboxId, name, opts.port), opts);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
withOutput(box.command('list').description('Deprecated: use `sandbox list`')).action((opts) => {
|
|
442
|
+
noteDeprecated('box list', 'sandbox list');
|
|
443
|
+
return run(async () => {
|
|
444
|
+
const data = await runCloudApi().get('/run-cloud/boxes');
|
|
445
|
+
print(data?.items ?? [], opts);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
withOutput(box.command('get').description('Deprecated: use `sandbox get <sandbox-id>`').argument('<id>', 'box id')).action((id, opts) => {
|
|
449
|
+
noteDeprecated('box get', 'sandbox get <sandbox-id>');
|
|
450
|
+
return run(async () => {
|
|
451
|
+
print(await runCloudApi().get(`/run-cloud/boxes/${encodeURIComponent(id)}`), opts);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
331
454
|
withOutput(box
|
|
332
455
|
.command('port')
|
|
333
|
-
.description('
|
|
456
|
+
.description('Deprecated: use `sandbox expose <sandbox-id> --port <port>`')
|
|
334
457
|
.argument('<id>', 'box id')
|
|
335
|
-
.argument('<port>', 'guest port', parsePort)).action((id, port, opts) =>
|
|
336
|
-
|
|
337
|
-
|
|
458
|
+
.argument('<port>', 'guest port', parsePort)).action((id, port, opts) => {
|
|
459
|
+
noteDeprecated('box port', `sandbox expose <sandbox-id> --port ${port}`);
|
|
460
|
+
return run(async () => {
|
|
461
|
+
print(await runCloudApi().patch(`/run-cloud/boxes/${encodeURIComponent(id)}`, { port }), opts);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
338
464
|
withOutput(box
|
|
339
465
|
.command('route')
|
|
340
|
-
.description('
|
|
341
|
-
.argument('<id>', 'box id')).action((id, opts) =>
|
|
342
|
-
|
|
343
|
-
|
|
466
|
+
.description('Deprecated: use `sandbox expose <sandbox-id> --reconcile`')
|
|
467
|
+
.argument('<id>', 'box id')).action((id, opts) => {
|
|
468
|
+
noteDeprecated('box route', 'sandbox expose <sandbox-id> --reconcile');
|
|
469
|
+
return run(async () => {
|
|
470
|
+
print(await runCloudApi().post(`/run-cloud/boxes/${encodeURIComponent(id)}/route`), opts);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
344
473
|
withOutput(box
|
|
345
474
|
.command('rm')
|
|
346
|
-
.description('
|
|
347
|
-
.argument('<id>', 'box id')).action((id, opts) =>
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
475
|
+
.description('Deprecated: use `sandbox rm <sandbox-id>`')
|
|
476
|
+
.argument('<id>', 'box id')).action((id, opts) => {
|
|
477
|
+
noteDeprecated('box rm', 'sandbox rm <sandbox-id>');
|
|
478
|
+
return run(async () => {
|
|
479
|
+
await runCloudApi().delete(`/run-cloud/boxes/${encodeURIComponent(id)}`);
|
|
480
|
+
if (!opts.json)
|
|
481
|
+
console.log(`destroyed box ${id}`);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
registerAccessCommands(box, { deprecated: true });
|
|
353
485
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
-
function isRunCloud() {
|
|
5
|
-
const
|
|
6
|
-
return
|
|
4
|
+
export function isRunCloud() {
|
|
5
|
+
const names = [process.argv[1], process.execPath].map((path) => basename(path ?? ''));
|
|
6
|
+
return names.some((name) => name === 'runcloud' || name === 'run-cloud' || name === 'run-cloud.js');
|
|
7
7
|
}
|
|
8
8
|
function credentialsHome() {
|
|
9
|
-
if (process.env.RUN_CLOUD_HOME)
|
|
10
|
-
return process.env.RUN_CLOUD_HOME;
|
|
11
|
-
if (process.env.NEWLY_HOME)
|
|
12
|
-
return process.env.NEWLY_HOME;
|
|
13
9
|
if (isRunCloud())
|
|
14
|
-
return join(homedir(), '.run-cloud');
|
|
10
|
+
return process.env.RUN_CLOUD_HOME ?? join(homedir(), '.run-cloud');
|
|
15
11
|
return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
|
|
16
12
|
}
|
|
17
13
|
function credentialsPath() {
|
|
@@ -22,7 +18,7 @@ export function credentialsDisplayPath() {
|
|
|
22
18
|
const home = homedir();
|
|
23
19
|
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
24
20
|
}
|
|
25
|
-
function loginCommandName() {
|
|
21
|
+
export function loginCommandName() {
|
|
26
22
|
return isRunCloud() ? 'runcloud login' : 'newly login';
|
|
27
23
|
}
|
|
28
24
|
export function normalizeApiUrl(url) {
|
|
@@ -44,7 +40,7 @@ export function loadCredentials() {
|
|
|
44
40
|
if (envToken?.trim() && (isRunCloud() || envApiUrl?.trim())) {
|
|
45
41
|
return {
|
|
46
42
|
token: envToken.trim(),
|
|
47
|
-
apiUrl: normalizeApiUrl(envApiUrl?.trim() || 'https://api.
|
|
43
|
+
apiUrl: normalizeApiUrl(envApiUrl?.trim() || 'https://api.run.cloud'),
|
|
48
44
|
appUrl: isRunCloud()
|
|
49
45
|
? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
|
|
50
46
|
: process.env.NEWLY_APP_URL ?? 'https://newly.app',
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { normalizeApiUrl } from "./config.js";
|
|
2
|
+
export async function runDeviceLogin(deps) {
|
|
3
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
4
|
+
const log = deps.log ?? ((m) => console.log(m));
|
|
5
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
6
|
+
const now = deps.now ?? (() => Date.now());
|
|
7
|
+
const base = normalizeApiUrl(deps.apiUrl);
|
|
8
|
+
const startRes = await fetchFn(`${base}/run-cloud/cli-auth/device`, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "content-type": "application/json" },
|
|
11
|
+
body: "{}",
|
|
12
|
+
});
|
|
13
|
+
if (!startRes.ok)
|
|
14
|
+
throw new Error(`Could not start login (${startRes.status}).`);
|
|
15
|
+
const start = (await startRes.json());
|
|
16
|
+
log(`\nTo authorize this terminal, confirm the code below in your browser:\n\n ${start.user_code}\n`);
|
|
17
|
+
log(`Opening ${start.verification_uri} … (if it doesn't open, go there and enter the code)\n`);
|
|
18
|
+
const approvalUrl = new URL(start.verification_uri_complete);
|
|
19
|
+
if (deps.provider)
|
|
20
|
+
approvalUrl.searchParams.set("provider", deps.provider);
|
|
21
|
+
deps.openBrowser?.(approvalUrl.toString());
|
|
22
|
+
let intervalMs = Math.max(0, start.interval) * 1000;
|
|
23
|
+
const deadline = now() + start.expires_in * 1000;
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (now() >= deadline)
|
|
26
|
+
throw new Error("Login timed out. Run `runcloud login` again.");
|
|
27
|
+
await sleep(intervalMs);
|
|
28
|
+
const res = await fetchFn(`${base}/run-cloud/cli-auth/token`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "content-type": "application/json" },
|
|
31
|
+
body: JSON.stringify({ device_code: start.device_code }),
|
|
32
|
+
});
|
|
33
|
+
if (res.ok) {
|
|
34
|
+
const body = (await res.json());
|
|
35
|
+
return { token: body.token, apiUrl: body.apiUrl, expiresIn: body.expiresIn };
|
|
36
|
+
}
|
|
37
|
+
const { error } = (await res.json().catch(() => ({ error: "unknown" })));
|
|
38
|
+
if (error === "authorization_pending")
|
|
39
|
+
continue;
|
|
40
|
+
if (error === "slow_down") {
|
|
41
|
+
intervalMs += 5000;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (error === "access_denied")
|
|
45
|
+
throw new Error("Authorization was denied. Run `runcloud login` again.");
|
|
46
|
+
throw new Error("The login code expired. Run `runcloud login` again.");
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { openSync, closeSync } from 'node:fs';
|
|
2
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
3
|
+
import { ReadStream } from 'node:tty';
|
|
4
|
+
function defaultIO() {
|
|
5
|
+
return { input: process.stdin, output: process.stdout };
|
|
6
|
+
}
|
|
7
|
+
export function openControllingTerminal() {
|
|
8
|
+
let fd;
|
|
9
|
+
try {
|
|
10
|
+
fd = openSync('/dev/tty', 'r+');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const input = new ReadStream(fd);
|
|
17
|
+
if (!input.isTTY)
|
|
18
|
+
throw new Error('/dev/tty is not a terminal');
|
|
19
|
+
return {
|
|
20
|
+
io: { input, output: process.stdout },
|
|
21
|
+
close: () => {
|
|
22
|
+
try {
|
|
23
|
+
input.destroy();
|
|
24
|
+
closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
try {
|
|
33
|
+
closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function acquirePromptIO() {
|
|
41
|
+
const stdinIO = defaultIO();
|
|
42
|
+
if (isInteractive(stdinIO))
|
|
43
|
+
return { io: stdinIO, close: () => { } };
|
|
44
|
+
return openControllingTerminal();
|
|
45
|
+
}
|
|
46
|
+
export function isInteractive(io = defaultIO()) {
|
|
47
|
+
return Boolean(io.input.isTTY && typeof io.input.setRawMode === 'function');
|
|
48
|
+
}
|
|
49
|
+
export function canPrompt() {
|
|
50
|
+
if (isInteractive())
|
|
51
|
+
return true;
|
|
52
|
+
const terminal = openControllingTerminal();
|
|
53
|
+
if (!terminal)
|
|
54
|
+
return false;
|
|
55
|
+
terminal.close();
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
export class PromptAbortedError extends Error {
|
|
59
|
+
constructor() {
|
|
60
|
+
super('Cancelled.');
|
|
61
|
+
this.name = 'PromptAbortedError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function render(io, question, choices, selected, first) {
|
|
65
|
+
if (!first)
|
|
66
|
+
io.output.write(`\x1b[${choices.length + 1}A\x1b[J`);
|
|
67
|
+
else
|
|
68
|
+
io.output.write(`\n${question}\n\n`);
|
|
69
|
+
if (!first)
|
|
70
|
+
io.output.write('\n');
|
|
71
|
+
const width = Math.max(...choices.map((choice) => choice.label.length));
|
|
72
|
+
for (const [index, choice] of choices.entries()) {
|
|
73
|
+
const active = index === selected;
|
|
74
|
+
const marker = active ? '❯' : ' ';
|
|
75
|
+
const label = active ? `\x1b[36m${choice.label}\x1b[0m` : choice.label;
|
|
76
|
+
const padding = ' '.repeat(width - choice.label.length + 4);
|
|
77
|
+
const hint = choice.hint ? `\x1b[90m${padding}${choice.hint}\x1b[0m` : '';
|
|
78
|
+
io.output.write(` ${marker} ${label}${hint}\n`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function select(question, choices, io = defaultIO()) {
|
|
82
|
+
if (choices.length === 0)
|
|
83
|
+
throw new Error('select() needs at least one choice');
|
|
84
|
+
if (choices.length === 1)
|
|
85
|
+
return choices[0].value;
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
let selected = 0;
|
|
88
|
+
let first = true;
|
|
89
|
+
emitKeypressEvents(io.input);
|
|
90
|
+
io.input.setRawMode?.(true);
|
|
91
|
+
io.input.resume();
|
|
92
|
+
render(io, question, choices, selected, first);
|
|
93
|
+
first = false;
|
|
94
|
+
const finish = (action) => {
|
|
95
|
+
io.input.setRawMode?.(false);
|
|
96
|
+
io.input.pause();
|
|
97
|
+
io.input.removeListener('keypress', onKeypress);
|
|
98
|
+
io.output.write('\n');
|
|
99
|
+
action();
|
|
100
|
+
};
|
|
101
|
+
function onKeypress(chunk, key) {
|
|
102
|
+
const name = key?.name ?? '';
|
|
103
|
+
if ((key?.ctrl && name === 'c') || name === 'escape') {
|
|
104
|
+
finish(() => reject(new PromptAbortedError()));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (name === 'return' || name === 'enter') {
|
|
108
|
+
finish(() => resolve(choices[selected].value));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (name === 'up' || name === 'k') {
|
|
112
|
+
selected = (selected - 1 + choices.length) % choices.length;
|
|
113
|
+
}
|
|
114
|
+
else if (name === 'down' || name === 'j' || name === 'tab') {
|
|
115
|
+
selected = (selected + 1) % choices.length;
|
|
116
|
+
}
|
|
117
|
+
else if (/^[1-9]$/.test(chunk ?? '')) {
|
|
118
|
+
const index = Number(chunk) - 1;
|
|
119
|
+
if (index < choices.length)
|
|
120
|
+
selected = index;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
render(io, question, choices, selected, first);
|
|
126
|
+
}
|
|
127
|
+
io.input.on('keypress', onKeypress);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export async function ask(question, options = {}) {
|
|
131
|
+
const { validate, attempts = 3, io = defaultIO() } = options;
|
|
132
|
+
const { createInterface } = await import('node:readline/promises');
|
|
133
|
+
const rl = createInterface({ input: io.input, output: io.output });
|
|
134
|
+
try {
|
|
135
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
136
|
+
const answer = (await rl.question(question)).trim();
|
|
137
|
+
const problem = validate?.(answer) ?? null;
|
|
138
|
+
if (!problem)
|
|
139
|
+
return answer;
|
|
140
|
+
io.output.write(` ${problem}\n`);
|
|
141
|
+
if (attempt === attempts) {
|
|
142
|
+
throw new Error(`${problem} Giving up after ${attempts} attempts.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new Error('unreachable');
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
rl.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/run-cloud.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
2
|
import { Command } from 'commander';
|
|
4
3
|
import { registerLogin } from './commands/login.js';
|
|
5
4
|
import { registerRunCloud } from './commands/run-cloud.js';
|
|
6
5
|
import { registerSandbox } from './commands/sandbox.js';
|
|
7
|
-
|
|
6
|
+
import { CLI_VERSION } from './version.js';
|
|
8
7
|
const program = new Command('runcloud')
|
|
9
8
|
.description('Create and control remote mobile simulators and microVM sandboxes')
|
|
10
|
-
.version(
|
|
9
|
+
.version(CLI_VERSION);
|
|
11
10
|
registerLogin(program);
|
|
12
11
|
registerRunCloud(program);
|
|
13
12
|
registerSandbox(program);
|