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.
@@ -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 { registerBoxAccessCommands } from './boxAccess.js';
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', 'runcloud/agent-base')
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)).action((opts) => run(async () => {
113
- const body = { image: opts.image };
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
- if (opts.cpu !== undefined)
121
- body.cpu = opts.cpu;
122
- if (opts.memory !== undefined)
123
- body.memory = opts.memory;
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.idlePause !== undefined)
147
+ if (exposed || opts.persistent)
148
+ body.idlePauseSeconds = 0;
149
+ else if (opts.idlePause !== undefined)
127
150
  body.idlePauseSeconds = opts.idlePause;
128
- print(await runCloudApi().post('/run-cloud/sandboxes', body), opts);
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 data = await runCloudApi().get(`/run-cloud/sandboxes${qs}`);
133
- print(data?.items ?? [], opts);
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.command('rm').description('Destroy a sandbox').argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
240
- await runCloudApi().delete(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
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
- const box = program.command('box').description('Run a persistent sandbox at a stable public URL');
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('Create a persistent box or attach a hostname to an existing sandbox')
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) => run(async () => {
297
- const api = runCloudApi();
298
- let sandboxId = opts.sandbox;
299
- if (!sandboxId) {
300
- const sandboxBody = {
301
- image: opts.image,
302
- name,
303
- idlePauseSeconds: 0,
304
- };
305
- if (opts.region)
306
- sandboxBody.region = opts.region;
307
- if (opts.org)
308
- sandboxBody.orgId = opts.org;
309
- sandboxBody.cpu = opts.cpu;
310
- sandboxBody.memory = opts.memory;
311
- if (opts.disk !== undefined)
312
- sandboxBody.disk = opts.disk;
313
- const created = await api.post('/run-cloud/sandboxes', sandboxBody);
314
- sandboxId = typeof created?.id === 'string' ? created.id : undefined;
315
- if (!sandboxId)
316
- throw new Error('sandbox create response did not include an id');
317
- }
318
- print(await api.post('/run-cloud/boxes', {
319
- sandboxId,
320
- name,
321
- port: opts.port,
322
- }), opts);
323
- }));
324
- withOutput(box.command('list').description('List persistent boxes')).action((opts) => run(async () => {
325
- const data = await runCloudApi().get('/run-cloud/boxes');
326
- print(data?.items ?? [], opts);
327
- }));
328
- withOutput(box.command('get').description('Inspect a persistent box').argument('<id>', 'box id')).action((id, opts) => run(async () => {
329
- print(await runCloudApi().get(`/run-cloud/boxes/${encodeURIComponent(id)}`), opts);
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('Move a box hostname to another guest port')
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) => run(async () => {
336
- print(await runCloudApi().patch(`/run-cloud/boxes/${encodeURIComponent(id)}`, { port }), opts);
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('Retry or reconcile a box hostname')
341
- .argument('<id>', 'box id')).action((id, opts) => run(async () => {
342
- print(await runCloudApi().post(`/run-cloud/boxes/${encodeURIComponent(id)}/route`), opts);
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('Remove a box hostname and destroy its sandbox')
347
- .argument('<id>', 'box id')).action((id, opts) => run(async () => {
348
- await runCloudApi().delete(`/run-cloud/boxes/${encodeURIComponent(id)}`);
349
- if (!opts.json)
350
- console.log(`destroyed box ${id}`);
351
- }));
352
- registerBoxAccessCommands(box);
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 invoked = basename(process.argv[1] ?? '');
6
- return invoked === 'runcloud' || invoked === 'run-cloud.js';
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.newly.app'),
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
- const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
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(version);
9
+ .version(CLI_VERSION);
11
10
  registerLogin(program);
12
11
  registerRunCloud(program);
13
12
  registerSandbox(program);