runcloud 0.1.6 → 0.1.8
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 +20 -10
- package/dist/commands/boxAccess.js +246 -0
- package/dist/commands/login.js +25 -17
- package/dist/commands/run-cloud.js +83 -13
- package/dist/commands/sandbox.js +250 -4
- package/dist/config.js +0 -4
- 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
|
@@ -3,6 +3,7 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFil
|
|
|
3
3
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
6
7
|
import { ApiClient, friendlyApiError } from '../api.js';
|
|
7
8
|
import { requireCredentials } from '../config.js';
|
|
8
9
|
export const RUN_CLOUD_SKILL = `---
|
|
@@ -20,7 +21,7 @@ Use this skill when a user asks an agent to create, inspect, smoke test, debug,
|
|
|
20
21
|
- Read SDK credentials from \`RUN_CLOUD_API_KEY\`. Never print it, commit it, or write it into a skill file. The SDK uses \`RUN_CLOUD_API_URL\` when set and otherwise defaults to \`https://api.newly.app\`.
|
|
21
22
|
- Authenticate the CLI with either a saved \`runcloud login\` credential or \`RUN_CLOUD_API_KEY\` together with \`RUN_CLOUD_API_URL\`. Do not require both a saved login and an API key.
|
|
22
23
|
- The TypeScript SDK requires Node.js 20 or newer.
|
|
23
|
-
- The account must have run.cloud access, available capacity, and
|
|
24
|
+
- The account must have run.cloud access, available capacity, and organization credit.
|
|
24
25
|
- App artifacts must match the target platform. iOS sessions need simulator-compatible builds; Android sessions need Android-compatible artifacts such as APKs.
|
|
25
26
|
|
|
26
27
|
## TypeScript SDK
|
|
@@ -95,6 +96,17 @@ runcloud ios delete "$SESSION_ID" --json
|
|
|
95
96
|
|
|
96
97
|
Use the corresponding \`runcloud android\` commands with an Android artifact for Android emulator sessions.
|
|
97
98
|
|
|
99
|
+
Download checksum-verified onboarding artifacts when no local build is
|
|
100
|
+
available:
|
|
101
|
+
|
|
102
|
+
\`\`\`bash
|
|
103
|
+
runcloud sample download ios
|
|
104
|
+
runcloud ios create --install ./run-cloud-sample-ios.app.tar.gz
|
|
105
|
+
|
|
106
|
+
runcloud sample download android
|
|
107
|
+
runcloud android create --install ./run-cloud-sample-android.apk
|
|
108
|
+
\`\`\`
|
|
109
|
+
|
|
98
110
|
## Runnable SDK Example
|
|
99
111
|
|
|
100
112
|
The maintained example checks account state, creates iOS and Android sessions, opens a URL on each, and releases both sessions:
|
|
@@ -113,7 +125,6 @@ Use \`--platform ios\` or \`--platform android\` for one platform. Use \`--json\
|
|
|
113
125
|
These published demos exercise multi-simulator workflows:
|
|
114
126
|
|
|
115
127
|
\`\`\`bash
|
|
116
|
-
runcloud demo run parallel-simulators --open
|
|
117
128
|
runcloud demo run eight-device-mosaic --open
|
|
118
129
|
runcloud demo run live-camera-relay --open
|
|
119
130
|
\`\`\`
|
|
@@ -219,10 +230,58 @@ function installSkill(opts) {
|
|
|
219
230
|
return results;
|
|
220
231
|
}
|
|
221
232
|
const RUN_CLOUD_DEMOS = [
|
|
222
|
-
'parallel-simulators',
|
|
223
233
|
'eight-device-mosaic',
|
|
224
234
|
'live-camera-relay',
|
|
225
235
|
];
|
|
236
|
+
export const RUN_CLOUD_SAMPLES = {
|
|
237
|
+
ios: {
|
|
238
|
+
filename: 'run-cloud-sample-ios.app.tar.gz',
|
|
239
|
+
url: 'https://run.cloud/downloads/run-cloud-sample-ios.app.tar.gz',
|
|
240
|
+
sha256: '09c437b1f299d41f79a8453611d99b8b1e4a3f5198dd871f4dad183362f944b3',
|
|
241
|
+
byteSize: 19_516,
|
|
242
|
+
},
|
|
243
|
+
android: {
|
|
244
|
+
filename: 'run-cloud-sample-android.apk',
|
|
245
|
+
url: 'https://run.cloud/downloads/run-cloud-sample-android.apk',
|
|
246
|
+
sha256: '892d4441a24757fc88852d29abe87d42d14f04921cbe89802e369dad6e46edba',
|
|
247
|
+
byteSize: 6_521_225,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
function samplePlatform(value) {
|
|
251
|
+
if (value === 'ios' || value === 'android')
|
|
252
|
+
return value;
|
|
253
|
+
throw new Error(`Unknown sample platform: ${value}. Choose ios or android.`);
|
|
254
|
+
}
|
|
255
|
+
function shellQuote(value) {
|
|
256
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
257
|
+
}
|
|
258
|
+
export async function downloadRunCloudSample(rawPlatform, opts = {}, fetchSample = fetch, samples = RUN_CLOUD_SAMPLES) {
|
|
259
|
+
const platform = samplePlatform(rawPlatform);
|
|
260
|
+
const sample = samples[platform];
|
|
261
|
+
const target = resolve(opts.output ?? sample.filename);
|
|
262
|
+
if (existsSync(target) && !opts.force) {
|
|
263
|
+
throw new Error(`${target} already exists. Pass --force to overwrite it.`);
|
|
264
|
+
}
|
|
265
|
+
const response = await fetchSample(sample.url);
|
|
266
|
+
if (!response.ok) {
|
|
267
|
+
throw new Error(`Could not download the ${platform} sample: HTTP ${response.status}`);
|
|
268
|
+
}
|
|
269
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
270
|
+
const sha256 = createHash('sha256').update(body).digest('hex');
|
|
271
|
+
if (body.byteLength !== sample.byteSize || sha256 !== sample.sha256) {
|
|
272
|
+
throw new Error(`The ${platform} sample failed its checksum verification.`);
|
|
273
|
+
}
|
|
274
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
275
|
+
writeFileSync(target, body);
|
|
276
|
+
return {
|
|
277
|
+
platform,
|
|
278
|
+
path: target,
|
|
279
|
+
url: sample.url,
|
|
280
|
+
sha256,
|
|
281
|
+
byteSize: body.byteLength,
|
|
282
|
+
next: `runcloud ${platform} create --install ${shellQuote(target)}`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
226
285
|
function experimentalWebRtcEnabled() {
|
|
227
286
|
return ['1', 'true', 'yes', 'on'].includes((process.env.RUN_CLOUD_EXPERIMENTAL_WEBRTC ?? '').trim().toLowerCase());
|
|
228
287
|
}
|
|
@@ -247,7 +306,7 @@ function demoDirectory(name) {
|
|
|
247
306
|
throw new Error(`The ${name} demo is missing from this runcloud package.`);
|
|
248
307
|
return source;
|
|
249
308
|
}
|
|
250
|
-
export function initRunCloudDemo(name = '
|
|
309
|
+
export function initRunCloudDemo(name = 'eight-device-mosaic', directory, opts = {}) {
|
|
251
310
|
const source = demoDirectory(name);
|
|
252
311
|
const target = resolve(directory ?? `run-cloud-${name}`);
|
|
253
312
|
if (existsSync(target)) {
|
|
@@ -258,13 +317,11 @@ export function initRunCloudDemo(name = 'parallel-simulators', directory, opts =
|
|
|
258
317
|
cpSync(source, target, { recursive: true });
|
|
259
318
|
return { example: name, path: target, status: 'created' };
|
|
260
319
|
}
|
|
261
|
-
export async function runCloudDemo(name = '
|
|
320
|
+
export async function runCloudDemo(name = 'eight-device-mosaic', opts = {}, loadDemo = async (url) => (await import(url))) {
|
|
262
321
|
if (name === 'eight-device-mosaic' && opts.duration) {
|
|
263
322
|
throw new Error('The eight-device mosaic uses a fixed 10-minute lease and does not accept --duration.');
|
|
264
323
|
}
|
|
265
324
|
const args = [];
|
|
266
|
-
if (opts.count)
|
|
267
|
-
args.push('--count', opts.count);
|
|
268
325
|
if (opts.duration)
|
|
269
326
|
args.push('--duration', opts.duration);
|
|
270
327
|
if (opts.open)
|
|
@@ -323,7 +380,7 @@ function registerSimulatorCommands(program, platform) {
|
|
|
323
380
|
.option('--codec <auto|mjpeg|webrtc>', 'force the simulator viewer stream codec', 'auto')
|
|
324
381
|
.option('--rm', 'release the session when this command exits', false)
|
|
325
382
|
.option('--json', 'output JSON', false)
|
|
326
|
-
.addOption(new Option('--dev-host <host>', 'dev-only: target a specific
|
|
383
|
+
.addOption(new Option('--dev-host <host>', 'dev-only: target a specific simulator fleet host').hideHelp())
|
|
327
384
|
.action((opts) => action(async () => {
|
|
328
385
|
print(await createSimulatorSession(platform, opts), opts);
|
|
329
386
|
}));
|
|
@@ -373,11 +430,25 @@ async function action(fn) {
|
|
|
373
430
|
}
|
|
374
431
|
}
|
|
375
432
|
export function registerRunCloud(program) {
|
|
433
|
+
const sample = program
|
|
434
|
+
.command('sample')
|
|
435
|
+
.alias('samples')
|
|
436
|
+
.description('Download installable onboarding sample apps');
|
|
437
|
+
sample
|
|
438
|
+
.command('download')
|
|
439
|
+
.description('Download a checksum-verified iOS or Android sample app')
|
|
440
|
+
.argument('<ios|android>', 'sample platform')
|
|
441
|
+
.option('-o, --output <path>', 'output file')
|
|
442
|
+
.option('--force', 'overwrite an existing output file', false)
|
|
443
|
+
.option('--json', 'output JSON', false)
|
|
444
|
+
.action((platform, opts) => action(async () => {
|
|
445
|
+
print(await downloadRunCloudSample(platform, opts), opts);
|
|
446
|
+
}));
|
|
376
447
|
const demo = program.command('demo').description('Create and run run.cloud demo projects');
|
|
377
448
|
demo
|
|
378
449
|
.command('init')
|
|
379
450
|
.description('Scaffold a standalone demo project')
|
|
380
|
-
.argument('[name]', 'demo to create', '
|
|
451
|
+
.argument('[name]', 'demo to create', 'eight-device-mosaic')
|
|
381
452
|
.argument('[directory]', 'output directory')
|
|
382
453
|
.option('--force', 'overwrite an existing output directory', false)
|
|
383
454
|
.option('--json', 'output JSON', false)
|
|
@@ -387,16 +458,15 @@ export function registerRunCloud(program) {
|
|
|
387
458
|
demo
|
|
388
459
|
.command('run')
|
|
389
460
|
.description('Run a bundled demo without scaffolding a project')
|
|
390
|
-
.argument('[name]', 'demo to run', '
|
|
391
|
-
.option('--
|
|
392
|
-
.option('--duration <seconds>', 'duration for parallel-simulators and live-camera-relay')
|
|
461
|
+
.argument('[name]', 'demo to run', 'eight-device-mosaic')
|
|
462
|
+
.option('--duration <seconds>', 'duration for live-camera-relay')
|
|
393
463
|
.option('--open', 'open the demo viewer or live sessions in the browser', false)
|
|
394
464
|
.action((name, opts) => action(async () => {
|
|
395
465
|
await runCloudDemo(name, opts);
|
|
396
466
|
}));
|
|
397
467
|
program
|
|
398
468
|
.command('account')
|
|
399
|
-
.description('Show run.cloud
|
|
469
|
+
.description('Show run.cloud account and organization access')
|
|
400
470
|
.option('--json', 'output JSON', false)
|
|
401
471
|
.action((opts) => action(async () => {
|
|
402
472
|
print(await client().get('/run-cloud/account'), opts);
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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 { registerBoxAccessCommands } from './boxAccess.js';
|
|
8
|
+
const MAX_SANDBOX_DISK_GIB = 2048;
|
|
9
|
+
const DEFAULT_BOX_CPU_CORES = 2;
|
|
10
|
+
const DEFAULT_BOX_MEMORY_MIB = 24 * 1024;
|
|
3
11
|
function runCloudApi() {
|
|
4
12
|
const credentials = requireCredentials();
|
|
5
13
|
return new ApiClient(credentials.apiUrl, credentials.token);
|
|
@@ -36,23 +44,87 @@ async function run(fn) {
|
|
|
36
44
|
function withOutput(cmd) {
|
|
37
45
|
return cmd.option('--json', 'output JSON', false);
|
|
38
46
|
}
|
|
47
|
+
function parseCpuCores(value) {
|
|
48
|
+
const n = Number(value);
|
|
49
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
50
|
+
throw new InvalidArgumentError('--cpu must be a positive number of cores, e.g. 0.5');
|
|
51
|
+
}
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
function parseMemoryMb(value) {
|
|
55
|
+
const n = Number(value);
|
|
56
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
57
|
+
throw new InvalidArgumentError('--memory must be a positive integer of MiB, e.g. 1024');
|
|
58
|
+
}
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
function parseDiskGb(value) {
|
|
62
|
+
const n = Number(value);
|
|
63
|
+
if (!Number.isSafeInteger(n) || n <= 0 || n > MAX_SANDBOX_DISK_GIB) {
|
|
64
|
+
throw new InvalidArgumentError(`--disk must be an integer from 1 to ${MAX_SANDBOX_DISK_GIB} GiB`);
|
|
65
|
+
}
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
function parseIdlePauseSeconds(value) {
|
|
69
|
+
const n = Number(value);
|
|
70
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
71
|
+
throw new InvalidArgumentError('--idle-pause must be a non-negative integer of seconds');
|
|
72
|
+
}
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
75
|
+
function parsePort(value) {
|
|
76
|
+
const n = Number(value);
|
|
77
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
78
|
+
throw new InvalidArgumentError('--port must be an integer from 1 to 65535');
|
|
79
|
+
}
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
function parseCoordinate(value) {
|
|
83
|
+
const n = Number(value);
|
|
84
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
85
|
+
throw new InvalidArgumentError('coordinates must be non-negative integers');
|
|
86
|
+
}
|
|
87
|
+
return n;
|
|
88
|
+
}
|
|
89
|
+
export function openBrowserUrl(url, platform = process.platform) {
|
|
90
|
+
if (platform === 'darwin') {
|
|
91
|
+
execFileSync('open', [url], { stdio: 'ignore' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (platform === 'win32') {
|
|
95
|
+
execFileSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
execFileSync('xdg-open', [url], { stdio: 'ignore' });
|
|
99
|
+
}
|
|
39
100
|
export function registerSandbox(program) {
|
|
40
101
|
const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
|
|
41
102
|
withOutput(sandbox
|
|
42
103
|
.command('create')
|
|
43
104
|
.description('Spawn a microVM sandbox')
|
|
44
|
-
.option('--image <ref>', 'base image', '
|
|
45
|
-
.option('--size <class>', 'size preset', 's-2')
|
|
105
|
+
.option('--image <ref>', 'base image', 'runcloud/agent-base')
|
|
46
106
|
.option('--region <region>', 'placement region')
|
|
47
107
|
.option('--name <name>', 'human-readable name')
|
|
48
|
-
.option('--org <id>', 'billing and ownership organization')
|
|
49
|
-
|
|
108
|
+
.option('--org <id>', 'billing and ownership organization')
|
|
109
|
+
.option('--cpu <cores>', 'fractional vCPU allocation, e.g. 0.5', parseCpuCores)
|
|
110
|
+
.option('--memory <mb>', 'memory allocation in MiB', parseMemoryMb)
|
|
111
|
+
.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 };
|
|
50
114
|
if (opts.name)
|
|
51
115
|
body.name = opts.name;
|
|
52
116
|
if (opts.region)
|
|
53
117
|
body.region = opts.region;
|
|
54
118
|
if (opts.org)
|
|
55
119
|
body.orgId = opts.org;
|
|
120
|
+
if (opts.cpu !== undefined)
|
|
121
|
+
body.cpu = opts.cpu;
|
|
122
|
+
if (opts.memory !== undefined)
|
|
123
|
+
body.memory = opts.memory;
|
|
124
|
+
if (opts.disk !== undefined)
|
|
125
|
+
body.disk = opts.disk;
|
|
126
|
+
if (opts.idlePause !== undefined)
|
|
127
|
+
body.idlePauseSeconds = opts.idlePause;
|
|
56
128
|
print(await runCloudApi().post('/run-cloud/sandboxes', body), opts);
|
|
57
129
|
}));
|
|
58
130
|
withOutput(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
|
|
@@ -99,9 +171,183 @@ export function registerSandbox(program) {
|
|
|
99
171
|
process.stdout.write(r.stdout ?? '');
|
|
100
172
|
process.stderr.write(r.stderr ?? '');
|
|
101
173
|
}));
|
|
174
|
+
withOutput(sandbox
|
|
175
|
+
.command('desktop')
|
|
176
|
+
.description('Open a signed browser desktop for a runcloud/desktop sandbox')
|
|
177
|
+
.argument('<id>', 'sandbox id')
|
|
178
|
+
.option('--url', 'print the signed noVNC URL instead of opening it', false)
|
|
179
|
+
.option('--view-only', 'disable keyboard and pointer input', false)).action((id, opts) => run(async () => {
|
|
180
|
+
const desktop = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/desktop`, { viewOnly: opts.viewOnly });
|
|
181
|
+
if (typeof desktop?.url !== 'string' || !desktop.url.startsWith('https://')) {
|
|
182
|
+
throw new Error('sandbox desktop response did not include a secure URL');
|
|
183
|
+
}
|
|
184
|
+
if (opts.url) {
|
|
185
|
+
console.log(desktop.url);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (opts.json) {
|
|
189
|
+
print(desktop, opts);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
openBrowserUrl(desktop.url);
|
|
193
|
+
console.log(`Opened ${opts.viewOnly ? 'view-only ' : ''}desktop for ${id}`);
|
|
194
|
+
}));
|
|
195
|
+
withOutput(sandbox
|
|
196
|
+
.command('screenshot')
|
|
197
|
+
.description('Capture the sandbox desktop as a PNG')
|
|
198
|
+
.argument('<id>', 'sandbox id')
|
|
199
|
+
.option('--output <path>', 'PNG output path', 'screen.png')).action((id, opts) => run(async () => {
|
|
200
|
+
const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'screenshot' });
|
|
201
|
+
if (typeof result?.image !== 'string' || !result.image) {
|
|
202
|
+
throw new Error('sandbox screenshot response did not include a PNG image');
|
|
203
|
+
}
|
|
204
|
+
const image = Buffer.from(result.image, 'base64');
|
|
205
|
+
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
206
|
+
if (image.length < pngSignature.length ||
|
|
207
|
+
!image.subarray(0, pngSignature.length).equals(pngSignature)) {
|
|
208
|
+
throw new Error('sandbox screenshot response was not a valid PNG');
|
|
209
|
+
}
|
|
210
|
+
const output = resolve(opts.output);
|
|
211
|
+
writeFileSync(output, image, { mode: 0o600 });
|
|
212
|
+
print({ sandboxId: id, output, bytes: image.length }, opts);
|
|
213
|
+
}));
|
|
214
|
+
withOutput(sandbox
|
|
215
|
+
.command('click')
|
|
216
|
+
.description('Click a desktop pixel coordinate')
|
|
217
|
+
.argument('<id>', 'sandbox id')
|
|
218
|
+
.argument('<x>', 'horizontal pixel coordinate', parseCoordinate)
|
|
219
|
+
.argument('<y>', 'vertical pixel coordinate', parseCoordinate)).action((id, x, y, opts) => run(async () => {
|
|
220
|
+
const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'click', x, y });
|
|
221
|
+
print(result ?? { ok: true }, opts);
|
|
222
|
+
}));
|
|
223
|
+
withOutput(sandbox
|
|
224
|
+
.command('type')
|
|
225
|
+
.description('Type text into the sandbox desktop')
|
|
226
|
+
.argument('<id>', 'sandbox id')
|
|
227
|
+
.argument('<text>', 'text to type')).action((id, text, opts) => run(async () => {
|
|
228
|
+
const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'type', text });
|
|
229
|
+
print(result ?? { ok: true }, opts);
|
|
230
|
+
}));
|
|
231
|
+
withOutput(sandbox
|
|
232
|
+
.command('key')
|
|
233
|
+
.description('Send an xdotool key chord to the sandbox desktop')
|
|
234
|
+
.argument('<id>', 'sandbox id')
|
|
235
|
+
.argument('<keys>', 'key or chord, e.g. ctrl+l')).action((id, keys, opts) => run(async () => {
|
|
236
|
+
const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'key', keys });
|
|
237
|
+
print(result ?? { ok: true }, opts);
|
|
238
|
+
}));
|
|
102
239
|
withOutput(sandbox.command('rm').description('Destroy a sandbox').argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
|
|
103
240
|
await runCloudApi().delete(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
|
|
104
241
|
if (!opts.json)
|
|
105
242
|
console.log(`destroyed ${id}`);
|
|
106
243
|
}));
|
|
244
|
+
const snapshot = sandbox.command('snapshot').description('Capture and manage sandbox snapshots');
|
|
245
|
+
withOutput(snapshot
|
|
246
|
+
.command('create')
|
|
247
|
+
.description('Snapshot a sandbox for later restore')
|
|
248
|
+
.argument('<id>', 'sandbox id to snapshot')
|
|
249
|
+
.option('--label <label>', 'human-readable label')).action((id, opts) => run(async () => {
|
|
250
|
+
const body = {};
|
|
251
|
+
if (opts.label)
|
|
252
|
+
body.label = opts.label;
|
|
253
|
+
print(await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/snapshots`, body), opts);
|
|
254
|
+
}));
|
|
255
|
+
withOutput(snapshot
|
|
256
|
+
.command('list')
|
|
257
|
+
.description('List snapshots')
|
|
258
|
+
.option('--sandbox <id>', 'only snapshots taken from this sandbox')).action((opts) => run(async () => {
|
|
259
|
+
const qs = opts.sandbox ? `?sandbox_id=${encodeURIComponent(opts.sandbox)}` : '';
|
|
260
|
+
const data = await runCloudApi().get(`/run-cloud/snapshots${qs}`);
|
|
261
|
+
print(data?.items ?? [], opts);
|
|
262
|
+
}));
|
|
263
|
+
withOutput(snapshot.command('rm').description('Delete a snapshot').argument('<id>', 'snapshot id')).action((id, opts) => run(async () => {
|
|
264
|
+
await runCloudApi().delete(`/run-cloud/snapshots/${encodeURIComponent(id)}`);
|
|
265
|
+
if (!opts.json)
|
|
266
|
+
console.log(`deleted ${id}`);
|
|
267
|
+
}));
|
|
268
|
+
withOutput(sandbox
|
|
269
|
+
.command('restore')
|
|
270
|
+
.description('Fork a new sandbox from a snapshot (warm start)')
|
|
271
|
+
.argument('<snapshot-id>', 'snapshot id to restore from')
|
|
272
|
+
.option('--name <name>', 'human-readable name for the new sandbox')
|
|
273
|
+
.option('--disk <gb>', 'root disk capacity in GiB (may only grow)', parseDiskGb)
|
|
274
|
+
.option('--region <region>', 'placement region')).action((snapshotId, opts) => run(async () => {
|
|
275
|
+
const body = {};
|
|
276
|
+
if (opts.name)
|
|
277
|
+
body.name = opts.name;
|
|
278
|
+
if (opts.disk !== undefined)
|
|
279
|
+
body.disk = opts.disk;
|
|
280
|
+
if (opts.region)
|
|
281
|
+
body.region = opts.region;
|
|
282
|
+
print(await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body), opts);
|
|
283
|
+
}));
|
|
284
|
+
const box = program.command('box').description('Run a persistent sandbox at a stable public URL');
|
|
285
|
+
withOutput(box
|
|
286
|
+
.command('create')
|
|
287
|
+
.description('Create a persistent box or attach a hostname to an existing sandbox')
|
|
288
|
+
.argument('<name>', 'hostname prefix: <name>-box.run.cloud')
|
|
289
|
+
.option('--sandbox <id>', 'attach an existing sandbox created with --idle-pause 0')
|
|
290
|
+
.option('--port <port>', 'guest port to publish', parsePort, 3000)
|
|
291
|
+
.option('--image <ref>', 'base image for a new sandbox (runcloud/box or runcloud/box-agent)', 'runcloud/box')
|
|
292
|
+
.option('--region <region>', 'placement region for a new sandbox')
|
|
293
|
+
.option('--org <id>', 'billing and ownership organization for a new sandbox')
|
|
294
|
+
.option('--cpu <cores>', 'vCPU allocation for a new box', parseCpuCores, DEFAULT_BOX_CPU_CORES)
|
|
295
|
+
.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
|
+
}));
|
|
331
|
+
withOutput(box
|
|
332
|
+
.command('port')
|
|
333
|
+
.description('Move a box hostname to another guest port')
|
|
334
|
+
.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
|
+
}));
|
|
338
|
+
withOutput(box
|
|
339
|
+
.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
|
+
}));
|
|
344
|
+
withOutput(box
|
|
345
|
+
.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);
|
|
107
353
|
}
|
package/dist/config.js
CHANGED
|
@@ -105,10 +105,6 @@ export function requireCredentials() {
|
|
|
105
105
|
if (!creds) {
|
|
106
106
|
throw new Error(`Not logged in (or session expired). Run: ${loginCommandName()}`);
|
|
107
107
|
}
|
|
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
108
|
return creds;
|
|
113
109
|
}
|
|
114
110
|
export function writeProjectLink(dir, link) {
|
|
@@ -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.
|
|
@@ -35,7 +35,8 @@ test('renders eight signed embeds without printing them into the viewer URL', as
|
|
|
35
35
|
}));
|
|
36
36
|
const html = mosaicViewerHtml(sessions, 601_000);
|
|
37
37
|
assert.equal((html.match(/<iframe /g) || []).length, 8);
|
|
38
|
-
assert.match(html, /
|
|
38
|
+
assert.match(html, /embed=1/);
|
|
39
|
+
assert.doesNotMatch(html, /loadingGuard=1/);
|
|
39
40
|
assert.match(html, /const deadline = 601000/);
|
|
40
41
|
|
|
41
42
|
const viewer = await startMosaicViewer(sessions, 601_000);
|
|
@@ -239,13 +239,13 @@ export default function App() {
|
|
|
239
239
|
let stream: MediaStream;
|
|
240
240
|
try {
|
|
241
241
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
242
|
-
video:
|
|
242
|
+
video: true,
|
|
243
243
|
audio: true,
|
|
244
244
|
});
|
|
245
245
|
} catch (withAudioError) {
|
|
246
246
|
try {
|
|
247
247
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
248
|
-
video:
|
|
248
|
+
video: true,
|
|
249
249
|
audio: false,
|
|
250
250
|
});
|
|
251
251
|
} catch (videoError) {
|
|
@@ -11,7 +11,7 @@ and sends the real WebRTC video stream to both receiver simulators.
|
|
|
11
11
|
- `runcloud` 0.1.3 or newer: `npm install -g runcloud`
|
|
12
12
|
- A simulator account authenticated either with `runcloud login` or with both
|
|
13
13
|
`RUN_CLOUD_API_KEY` and `RUN_CLOUD_API_URL`
|
|
14
|
-
- run.cloud simulator access, capacity, and enough
|
|
14
|
+
- run.cloud simulator access, capacity, and enough organization credit for three sessions
|
|
15
15
|
- A browser with webcam permission
|
|
16
16
|
|
|
17
17
|
The local viewer binds only to `127.0.0.1`. Signed simulator URLs and camera
|
|
@@ -67,6 +67,8 @@ test('renders three signed embeds while keeping credentials out of the local URL
|
|
|
67
67
|
const demoSessions = sessions();
|
|
68
68
|
const html = cameraViewerHtml(demoSessions, 60, 'room-12345678', 1_000);
|
|
69
69
|
assert.equal((html.match(/<iframe /g) || []).length, 3);
|
|
70
|
+
assert.match(html, /embed=1/);
|
|
71
|
+
assert.doesNotMatch(html, /loadingGuard=1/);
|
|
70
72
|
assert.match(html, /Connect webcam/);
|
|
71
73
|
assert.match(html, /com\.apple\.mobilesafari/);
|
|
72
74
|
assert.match(html, /audio: false/);
|
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runcloud",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Create and control run.cloud remote mobile
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/newly-app/newly.git",
|
|
10
|
+
"directory": "newly-cli"
|
|
11
|
+
},
|
|
7
12
|
"bin": {
|
|
8
13
|
"runcloud": "dist/run-cloud.js"
|
|
9
14
|
},
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# Parallel iOS simulators
|
|
2
|
-
|
|
3
|
-
Start two or three run.cloud iOS simulator sessions at the same time, open a
|
|
4
|
-
different site in each simulator, and view the live sessions side by side.
|
|
5
|
-
The demo releases every session automatically after two minutes. A ten-minute
|
|
6
|
-
server-side timeout is also set in case the local process is interrupted.
|
|
7
|
-
|
|
8
|
-
## Requirements
|
|
9
|
-
|
|
10
|
-
- Node.js 20 or newer
|
|
11
|
-
- `runcloud` 0.1.3 or newer: `npm install -g runcloud`
|
|
12
|
-
- A simulator account authenticated either with `runcloud login` or with both
|
|
13
|
-
`RUN_CLOUD_API_KEY` and `RUN_CLOUD_API_URL`
|
|
14
|
-
- run.cloud simulator access and enough balance for two or three sessions
|
|
15
|
-
|
|
16
|
-
The demo uses the CLI credential resolution path. It does not require both a
|
|
17
|
-
saved login and an API key.
|
|
18
|
-
|
|
19
|
-
## Run
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
npm run demo -- --open
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
The default starts three sessions, opens the session URLs in browser tabs, and
|
|
26
|
-
releases the sessions after 120 seconds. To run a smaller or shorter demo:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
npm run demo -- --count 2 --duration 60 --open
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Press Ctrl+C to release the sessions early. Omit `--open` when you only want
|
|
33
|
-
the live session URLs printed to the terminal.
|