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.
- package/README.md +135 -0
- package/dist/api.js +22 -11
- package/dist/commands/boxAccess.js +246 -0
- package/dist/commands/environment.js +1 -1
- package/dist/commands/login.js +149 -21
- package/dist/commands/run-cloud.js +85 -15
- package/dist/commands/sandbox.js +263 -4
- package/dist/config.js +6 -14
- 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/examples/eight-device-mosaic/README.md +1 -1
- package/examples/eight-device-mosaic/demo.mjs +1 -1
- package/examples/eight-device-mosaic/verify.mjs +2 -1
- package/examples/live-camera-relay/App.tsx +2 -2
- package/examples/live-camera-relay/README.md +1 -1
- package/examples/live-camera-relay/demo.mjs +1 -1
- package/examples/live-camera-relay/verify.mjs +2 -0
- package/package.json +7 -2
- package/examples/parallel-simulators/README.md +0 -33
- package/examples/parallel-simulators/demo.mjs +0 -185
- package/examples/parallel-simulators/package.json +0 -13
- package/examples/parallel-simulators/verify.mjs +0 -78
package/dist/commands/sandbox.js
CHANGED
|
@@ -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', '
|
|
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')
|
|
49
|
-
|
|
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
|
|
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',
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -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
|
|
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(
|
|
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() ||
|
|
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
|
+
}
|
package/dist/version.js
ADDED
|
@@ -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
|
|
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.
|