runcloud 0.1.6 → 0.1.9

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.
@@ -1,5 +1,14 @@
1
+ import { InvalidArgumentError } from 'commander';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { writeFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
1
5
  import { ApiClient, friendlyApiError } from '../api.js';
2
6
  import { requireCredentials } from '../config.js';
7
+ import { runSandboxShell } from '../shellSession.js';
8
+ import { registerBoxAccessCommands } from './boxAccess.js';
9
+ const MAX_SANDBOX_DISK_GIB = 2048;
10
+ const DEFAULT_BOX_CPU_CORES = 2;
11
+ const DEFAULT_BOX_MEMORY_MIB = 24 * 1024;
3
12
  function runCloudApi() {
4
13
  const credentials = requireCredentials();
5
14
  return new ApiClient(credentials.apiUrl, credentials.token);
@@ -36,23 +45,87 @@ async function run(fn) {
36
45
  function withOutput(cmd) {
37
46
  return cmd.option('--json', 'output JSON', false);
38
47
  }
48
+ function parseCpuCores(value) {
49
+ const n = Number(value);
50
+ if (!Number.isFinite(n) || n <= 0) {
51
+ throw new InvalidArgumentError('--cpu must be a positive number of cores, e.g. 0.5');
52
+ }
53
+ return n;
54
+ }
55
+ function parseMemoryMb(value) {
56
+ const n = Number(value);
57
+ if (!Number.isInteger(n) || n <= 0) {
58
+ throw new InvalidArgumentError('--memory must be a positive integer of MiB, e.g. 1024');
59
+ }
60
+ return n;
61
+ }
62
+ function parseDiskGb(value) {
63
+ const n = Number(value);
64
+ if (!Number.isSafeInteger(n) || n <= 0 || n > MAX_SANDBOX_DISK_GIB) {
65
+ throw new InvalidArgumentError(`--disk must be an integer from 1 to ${MAX_SANDBOX_DISK_GIB} GiB`);
66
+ }
67
+ return n;
68
+ }
69
+ function parseIdlePauseSeconds(value) {
70
+ const n = Number(value);
71
+ if (!Number.isInteger(n) || n < 0) {
72
+ throw new InvalidArgumentError('--idle-pause must be a non-negative integer of seconds');
73
+ }
74
+ return n;
75
+ }
76
+ function parsePort(value) {
77
+ const n = Number(value);
78
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
79
+ throw new InvalidArgumentError('--port must be an integer from 1 to 65535');
80
+ }
81
+ return n;
82
+ }
83
+ function parseCoordinate(value) {
84
+ const n = Number(value);
85
+ if (!Number.isSafeInteger(n) || n < 0) {
86
+ throw new InvalidArgumentError('coordinates must be non-negative integers');
87
+ }
88
+ return n;
89
+ }
90
+ export function openBrowserUrl(url, platform = process.platform) {
91
+ if (platform === 'darwin') {
92
+ execFileSync('open', [url], { stdio: 'ignore' });
93
+ return;
94
+ }
95
+ if (platform === 'win32') {
96
+ execFileSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
97
+ return;
98
+ }
99
+ execFileSync('xdg-open', [url], { stdio: 'ignore' });
100
+ }
39
101
  export function registerSandbox(program) {
40
102
  const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
41
103
  withOutput(sandbox
42
104
  .command('create')
43
105
  .description('Spawn a microVM sandbox')
44
- .option('--image <ref>', 'base image', 'newly/agent-base')
45
- .option('--size <class>', 'size preset', 's-2')
106
+ .option('--image <ref>', 'base image', 'runcloud/agent-base')
46
107
  .option('--region <region>', 'placement region')
47
108
  .option('--name <name>', 'human-readable name')
48
- .option('--org <id>', 'billing and ownership organization')).action((opts) => run(async () => {
49
- const body = { size: opts.size, image: opts.image };
109
+ .option('--org <id>', 'billing and ownership organization')
110
+ .option('--cpu <cores>', 'fractional vCPU allocation, e.g. 0.5', parseCpuCores)
111
+ .option('--memory <mb>', 'memory allocation in MiB', parseMemoryMb)
112
+ .option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)
113
+ .option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)).action((opts) => run(async () => {
114
+ const body = { image: opts.image };
50
115
  if (opts.name)
51
116
  body.name = opts.name;
52
117
  if (opts.region)
53
118
  body.region = opts.region;
54
119
  if (opts.org)
55
120
  body.orgId = opts.org;
121
+ if (opts.cpu !== undefined)
122
+ body.cpu = opts.cpu;
123
+ if (opts.memory !== undefined)
124
+ body.memory = opts.memory;
125
+ if (opts.disk !== undefined)
126
+ body.disk = opts.disk;
127
+ if (opts.idlePause !== undefined)
128
+ body.idlePauseSeconds = opts.idlePause;
56
129
  print(await runCloudApi().post('/run-cloud/sandboxes', body), opts);
57
130
  }));
58
131
  withOutput(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
@@ -79,6 +152,18 @@ export function registerSandbox(program) {
79
152
  }
80
153
  process.exitCode = r.exit_code ?? 0;
81
154
  }));
155
+ sandbox
156
+ .command('shell')
157
+ .description('Open an interactive shell in a running sandbox')
158
+ .argument('<id>', 'sandbox id')
159
+ .action((id) => run(async () => {
160
+ const credentials = requireCredentials();
161
+ process.exitCode = await runSandboxShell({
162
+ apiUrl: credentials.apiUrl,
163
+ token: credentials.token,
164
+ sandboxId: id,
165
+ });
166
+ }));
82
167
  withOutput(sandbox
83
168
  .command('logs')
84
169
  .description('Show recent sandbox logs (one-shot journalctl tail; streaming is a follow-up)')
@@ -99,9 +184,183 @@ export function registerSandbox(program) {
99
184
  process.stdout.write(r.stdout ?? '');
100
185
  process.stderr.write(r.stderr ?? '');
101
186
  }));
187
+ withOutput(sandbox
188
+ .command('desktop')
189
+ .description('Open a signed browser desktop for a runcloud/desktop sandbox')
190
+ .argument('<id>', 'sandbox id')
191
+ .option('--url', 'print the signed noVNC URL instead of opening it', false)
192
+ .option('--view-only', 'disable keyboard and pointer input', false)).action((id, opts) => run(async () => {
193
+ const desktop = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/desktop`, { viewOnly: opts.viewOnly });
194
+ if (typeof desktop?.url !== 'string' || !desktop.url.startsWith('https://')) {
195
+ throw new Error('sandbox desktop response did not include a secure URL');
196
+ }
197
+ if (opts.url) {
198
+ console.log(desktop.url);
199
+ return;
200
+ }
201
+ if (opts.json) {
202
+ print(desktop, opts);
203
+ return;
204
+ }
205
+ openBrowserUrl(desktop.url);
206
+ console.log(`Opened ${opts.viewOnly ? 'view-only ' : ''}desktop for ${id}`);
207
+ }));
208
+ withOutput(sandbox
209
+ .command('screenshot')
210
+ .description('Capture the sandbox desktop as a PNG')
211
+ .argument('<id>', 'sandbox id')
212
+ .option('--output <path>', 'PNG output path', 'screen.png')).action((id, opts) => run(async () => {
213
+ const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'screenshot' });
214
+ if (typeof result?.image !== 'string' || !result.image) {
215
+ throw new Error('sandbox screenshot response did not include a PNG image');
216
+ }
217
+ const image = Buffer.from(result.image, 'base64');
218
+ const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
219
+ if (image.length < pngSignature.length ||
220
+ !image.subarray(0, pngSignature.length).equals(pngSignature)) {
221
+ throw new Error('sandbox screenshot response was not a valid PNG');
222
+ }
223
+ const output = resolve(opts.output);
224
+ writeFileSync(output, image, { mode: 0o600 });
225
+ print({ sandboxId: id, output, bytes: image.length }, opts);
226
+ }));
227
+ withOutput(sandbox
228
+ .command('click')
229
+ .description('Click a desktop pixel coordinate')
230
+ .argument('<id>', 'sandbox id')
231
+ .argument('<x>', 'horizontal pixel coordinate', parseCoordinate)
232
+ .argument('<y>', 'vertical pixel coordinate', parseCoordinate)).action((id, x, y, opts) => run(async () => {
233
+ const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'click', x, y });
234
+ print(result ?? { ok: true }, opts);
235
+ }));
236
+ withOutput(sandbox
237
+ .command('type')
238
+ .description('Type text into the sandbox desktop')
239
+ .argument('<id>', 'sandbox id')
240
+ .argument('<text>', 'text to type')).action((id, text, opts) => run(async () => {
241
+ const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'type', text });
242
+ print(result ?? { ok: true }, opts);
243
+ }));
244
+ withOutput(sandbox
245
+ .command('key')
246
+ .description('Send an xdotool key chord to the sandbox desktop')
247
+ .argument('<id>', 'sandbox id')
248
+ .argument('<keys>', 'key or chord, e.g. ctrl+l')).action((id, keys, opts) => run(async () => {
249
+ const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'key', keys });
250
+ print(result ?? { ok: true }, opts);
251
+ }));
102
252
  withOutput(sandbox.command('rm').description('Destroy a sandbox').argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
103
253
  await runCloudApi().delete(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
104
254
  if (!opts.json)
105
255
  console.log(`destroyed ${id}`);
106
256
  }));
257
+ const snapshot = sandbox.command('snapshot').description('Capture and manage sandbox snapshots');
258
+ withOutput(snapshot
259
+ .command('create')
260
+ .description('Snapshot a sandbox for later restore')
261
+ .argument('<id>', 'sandbox id to snapshot')
262
+ .option('--label <label>', 'human-readable label')).action((id, opts) => run(async () => {
263
+ const body = {};
264
+ if (opts.label)
265
+ body.label = opts.label;
266
+ print(await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/snapshots`, body), opts);
267
+ }));
268
+ withOutput(snapshot
269
+ .command('list')
270
+ .description('List snapshots')
271
+ .option('--sandbox <id>', 'only snapshots taken from this sandbox')).action((opts) => run(async () => {
272
+ const qs = opts.sandbox ? `?sandbox_id=${encodeURIComponent(opts.sandbox)}` : '';
273
+ const data = await runCloudApi().get(`/run-cloud/snapshots${qs}`);
274
+ print(data?.items ?? [], opts);
275
+ }));
276
+ withOutput(snapshot.command('rm').description('Delete a snapshot').argument('<id>', 'snapshot id')).action((id, opts) => run(async () => {
277
+ await runCloudApi().delete(`/run-cloud/snapshots/${encodeURIComponent(id)}`);
278
+ if (!opts.json)
279
+ console.log(`deleted ${id}`);
280
+ }));
281
+ withOutput(sandbox
282
+ .command('restore')
283
+ .description('Fork a new sandbox from a snapshot (warm start)')
284
+ .argument('<snapshot-id>', 'snapshot id to restore from')
285
+ .option('--name <name>', 'human-readable name for the new sandbox')
286
+ .option('--disk <gb>', 'root disk capacity in GiB (may only grow)', parseDiskGb)
287
+ .option('--region <region>', 'placement region')).action((snapshotId, opts) => run(async () => {
288
+ const body = {};
289
+ if (opts.name)
290
+ body.name = opts.name;
291
+ if (opts.disk !== undefined)
292
+ body.disk = opts.disk;
293
+ if (opts.region)
294
+ body.region = opts.region;
295
+ print(await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body), opts);
296
+ }));
297
+ const box = program.command('box').description('Run a persistent sandbox at a stable public URL');
298
+ withOutput(box
299
+ .command('create')
300
+ .description('Create a persistent box or attach a hostname to an existing sandbox')
301
+ .argument('<name>', 'hostname prefix: <name>-box.run.cloud')
302
+ .option('--sandbox <id>', 'attach an existing sandbox created with --idle-pause 0')
303
+ .option('--port <port>', 'guest port to publish', parsePort, 3000)
304
+ .option('--image <ref>', 'base image for a new sandbox (runcloud/box or runcloud/box-agent)', 'runcloud/box')
305
+ .option('--region <region>', 'placement region for a new sandbox')
306
+ .option('--org <id>', 'billing and ownership organization for a new sandbox')
307
+ .option('--cpu <cores>', 'vCPU allocation for a new box', parseCpuCores, DEFAULT_BOX_CPU_CORES)
308
+ .option('--memory <mb>', 'memory allocation in MiB for a new box', parseMemoryMb, DEFAULT_BOX_MEMORY_MIB)
309
+ .option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)).action((name, opts) => run(async () => {
310
+ const api = runCloudApi();
311
+ let sandboxId = opts.sandbox;
312
+ if (!sandboxId) {
313
+ const sandboxBody = {
314
+ image: opts.image,
315
+ name,
316
+ idlePauseSeconds: 0,
317
+ };
318
+ if (opts.region)
319
+ sandboxBody.region = opts.region;
320
+ if (opts.org)
321
+ sandboxBody.orgId = opts.org;
322
+ sandboxBody.cpu = opts.cpu;
323
+ sandboxBody.memory = opts.memory;
324
+ if (opts.disk !== undefined)
325
+ sandboxBody.disk = opts.disk;
326
+ const created = await api.post('/run-cloud/sandboxes', sandboxBody);
327
+ sandboxId = typeof created?.id === 'string' ? created.id : undefined;
328
+ if (!sandboxId)
329
+ throw new Error('sandbox create response did not include an id');
330
+ }
331
+ print(await api.post('/run-cloud/boxes', {
332
+ sandboxId,
333
+ name,
334
+ port: opts.port,
335
+ }), opts);
336
+ }));
337
+ withOutput(box.command('list').description('List persistent boxes')).action((opts) => run(async () => {
338
+ const data = await runCloudApi().get('/run-cloud/boxes');
339
+ print(data?.items ?? [], opts);
340
+ }));
341
+ withOutput(box.command('get').description('Inspect a persistent box').argument('<id>', 'box id')).action((id, opts) => run(async () => {
342
+ print(await runCloudApi().get(`/run-cloud/boxes/${encodeURIComponent(id)}`), opts);
343
+ }));
344
+ withOutput(box
345
+ .command('port')
346
+ .description('Move a box hostname to another guest port')
347
+ .argument('<id>', 'box id')
348
+ .argument('<port>', 'guest port', parsePort)).action((id, port, opts) => run(async () => {
349
+ print(await runCloudApi().patch(`/run-cloud/boxes/${encodeURIComponent(id)}`, { port }), opts);
350
+ }));
351
+ withOutput(box
352
+ .command('route')
353
+ .description('Retry or reconcile a box hostname')
354
+ .argument('<id>', 'box id')).action((id, opts) => run(async () => {
355
+ print(await runCloudApi().post(`/run-cloud/boxes/${encodeURIComponent(id)}/route`), opts);
356
+ }));
357
+ withOutput(box
358
+ .command('rm')
359
+ .description('Remove a box hostname and destroy its sandbox')
360
+ .argument('<id>', 'box id')).action((id, opts) => run(async () => {
361
+ await runCloudApi().delete(`/run-cloud/boxes/${encodeURIComponent(id)}`);
362
+ if (!opts.json)
363
+ console.log(`destroyed box ${id}`);
364
+ }));
365
+ registerBoxAccessCommands(box);
107
366
  }
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',
@@ -105,10 +101,6 @@ export function requireCredentials() {
105
101
  if (!creds) {
106
102
  throw new Error(`Not logged in (or session expired). Run: ${loginCommandName()}`);
107
103
  }
108
- if (isRunCloud() && !creds.token.startsWith('rc_live_')) {
109
- throw new Error('Legacy run.cloud credentials are no longer supported. Run `runcloud login` to sign in ' +
110
- 'with Better Auth.');
111
- }
112
104
  return creds;
113
105
  }
114
106
  export function writeProjectLink(dir, link) {
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);
@@ -0,0 +1,79 @@
1
+ const SESSION_TOKEN_HEADER = 'set-auth-token';
2
+ export class AuthError extends Error {
3
+ status;
4
+ constructor(message, status) {
5
+ super(message);
6
+ this.status = status;
7
+ this.name = 'AuthError';
8
+ }
9
+ }
10
+ function apiBase(apiUrl) {
11
+ return apiUrl.replace(/\/+$/, '');
12
+ }
13
+ async function readError(response) {
14
+ const body = await response.text();
15
+ try {
16
+ const parsed = JSON.parse(body);
17
+ return parsed.error ?? parsed.message ?? body.slice(0, 200);
18
+ }
19
+ catch {
20
+ return body.slice(0, 200) || response.statusText;
21
+ }
22
+ }
23
+ async function post(apiUrl, path, body, token) {
24
+ const response = await fetch(`${apiBase(apiUrl)}${path}`, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'content-type': 'application/json',
28
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
29
+ },
30
+ body: JSON.stringify(body),
31
+ });
32
+ if (!response.ok)
33
+ throw new AuthError(await readError(response), response.status);
34
+ const sessionToken = response.headers.get(SESSION_TOKEN_HEADER);
35
+ const text = await response.text();
36
+ const data = (text ? JSON.parse(text) : {});
37
+ return { data, sessionToken: sessionToken?.trim() || null };
38
+ }
39
+ export async function fetchProviders(apiUrl) {
40
+ const response = await fetch(`${apiBase(apiUrl)}/auth/web/providers`);
41
+ if (!response.ok)
42
+ throw new AuthError(await readError(response), response.status);
43
+ const payload = (await response.json());
44
+ return {
45
+ emailOtp: payload.emailOtp === true,
46
+ google: payload.google === true,
47
+ github: payload.github === true,
48
+ apple: payload.apple === true,
49
+ };
50
+ }
51
+ export async function sendEmailCode(apiUrl, email) {
52
+ await post(apiUrl, '/api/auth/email-otp/send-verification-otp', { email, type: 'sign-in' });
53
+ }
54
+ export async function verifyEmailCode(apiUrl, email, otp) {
55
+ const { data, sessionToken } = await post(apiUrl, '/api/auth/sign-in/email-otp', { email, otp, name: email.split('@')[0] || 'Run Cloud user' });
56
+ if (!sessionToken) {
57
+ throw new AuthError(`${apiUrl} accepted the code but did not return a session token.`, 502);
58
+ }
59
+ return {
60
+ user: { id: data.user?.id ?? '', email: data.user?.email ?? email },
61
+ sessionToken,
62
+ };
63
+ }
64
+ export async function createApiKey(apiUrl, sessionToken, name) {
65
+ const { data } = await post(apiUrl, '/run-cloud/api-keys', { name }, sessionToken);
66
+ const key = data.apiKey?.key;
67
+ if (!key)
68
+ throw new AuthError(`${apiUrl} did not return an API key.`, 502);
69
+ return key;
70
+ }
71
+ export function isValidEmail(value) {
72
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
73
+ }
74
+ export function normalizeOtp(value) {
75
+ return value.replace(/[\s-]/g, '');
76
+ }
77
+ export function isValidOtp(value) {
78
+ return /^\d{6}$/.test(normalizeOtp(value));
79
+ }
@@ -1,9 +1,9 @@
1
1
  import WebSocket from 'ws';
2
- export async function runEnvironmentDatabaseShell(input) {
2
+ export async function runShellSession(input) {
3
3
  const stdin = input.stdin ?? process.stdin;
4
4
  const stdout = input.stdout ?? process.stdout;
5
5
  const stderr = input.stderr ?? process.stderr;
6
- const url = new URL(`/environment-databases/${encodeURIComponent(input.databaseId)}/shell`, input.apiUrl);
6
+ const url = new URL(input.path, input.apiUrl);
7
7
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
8
8
  const createWebSocket = input.createWebSocket ?? ((address, options) => new WebSocket(address, options));
9
9
  const socket = createWebSocket(url.toString(), {
@@ -60,7 +60,7 @@ export async function runEnvironmentDatabaseShell(input) {
60
60
  socket.once('close', (code, reason) => {
61
61
  restore();
62
62
  if (code >= 4400) {
63
- reject(new Error(reason.toString() || `Database shell rejected (${code})`));
63
+ reject(new Error(reason.toString() || `${input.label} rejected (${code})`));
64
64
  }
65
65
  else {
66
66
  resolve(exitCode);
@@ -68,3 +68,17 @@ export async function runEnvironmentDatabaseShell(input) {
68
68
  });
69
69
  });
70
70
  }
71
+ export function runEnvironmentDatabaseShell(input) {
72
+ return runShellSession({
73
+ ...input,
74
+ path: `/environment-databases/${encodeURIComponent(input.databaseId)}/shell`,
75
+ label: 'Database shell',
76
+ });
77
+ }
78
+ export function runSandboxShell(input) {
79
+ return runShellSession({
80
+ ...input,
81
+ path: `/run-cloud/sandboxes/${encodeURIComponent(input.sandboxId)}/exec/stream`,
82
+ label: 'Sandbox shell',
83
+ });
84
+ }
@@ -0,0 +1 @@
1
+ export const CLI_VERSION = '0.1.9';
@@ -12,7 +12,7 @@ interrupted local process.
12
12
  - `runcloud` 0.1.3 or newer: `npm install -g runcloud`
13
13
  - A simulator account authenticated either with `runcloud login` or with both
14
14
  `RUN_CLOUD_API_KEY` and `RUN_CLOUD_API_URL`
15
- - run.cloud simulator access, capacity, and enough balance for eight sessions
15
+ - run.cloud simulator access, capacity, and enough organization credit for eight sessions
16
16
 
17
17
  The local viewer binds only to `127.0.0.1`. Signed simulator URLs remain in the
18
18
  viewer process and are not printed to the terminal.
@@ -73,7 +73,7 @@ function escapeHtml(value) {
73
73
 
74
74
  function embedUrl(value) {
75
75
  const url = new URL(value);
76
- url.searchParams.set('loadingGuard', '1');
76
+ url.searchParams.set('embed', '1');
77
77
  return url.toString();
78
78
  }
79
79