runcloud 0.1.5 → 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.
@@ -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 a positive run.cloud balance.
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 = 'parallel-simulators', directory, opts = {}) {
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 = 'parallel-simulators', opts = {}, loadDemo = async (url) => (await import(url))) {
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 serve-sim fleet host').hideHelp())
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', 'parallel-simulators')
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', 'parallel-simulators')
391
- .option('--count <2|3>', 'number of sessions (parallel-simulators only)')
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 entitlement and metered balance')
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);
@@ -1,37 +1,16 @@
1
- import { friendlyApiError } from '../api.js';
2
- class CpClient {
3
- baseUrl;
4
- token;
5
- constructor(baseUrl, token) {
6
- this.baseUrl = baseUrl;
7
- this.token = token;
8
- }
9
- async request(method, path, body) {
10
- const res = await fetch(`${this.baseUrl}${path}`, {
11
- method,
12
- headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
13
- body: body !== undefined ? JSON.stringify(body) : undefined,
14
- });
15
- const text = await res.text();
16
- if (!res.ok) {
17
- let msg = text;
18
- try {
19
- msg = JSON.parse(text).error ?? text;
20
- }
21
- catch {
22
- }
23
- throw new Error(`control plane ${res.status}: ${msg}`);
24
- }
25
- return text ? JSON.parse(text) : null;
26
- }
27
- }
28
- function cp(opts) {
29
- const baseUrl = (opts.cpUrl || process.env.SBX_API_URL || 'http://127.0.0.1:8080').replace(/\/+$/, '');
30
- const token = opts.cpToken || process.env.SBX_TOKEN;
31
- if (!token) {
32
- throw new Error('No control-plane token: set SBX_TOKEN or pass --cp-token.');
33
- }
34
- return new CpClient(baseUrl, token);
1
+ import { InvalidArgumentError } from 'commander';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { writeFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { ApiClient, friendlyApiError } from '../api.js';
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;
11
+ function runCloudApi() {
12
+ const credentials = requireCredentials();
13
+ return new ApiClient(credentials.apiUrl, credentials.token);
35
14
  }
36
15
  function print(value, opts) {
37
16
  if (opts.json) {
@@ -62,39 +41,103 @@ async function run(fn) {
62
41
  process.exitCode = 1;
63
42
  }
64
43
  }
65
- function withCp(cmd) {
66
- return cmd
67
- .option('--cp-url <url>', 'control-plane base URL (default $SBX_API_URL or http://127.0.0.1:8080)')
68
- .option('--cp-token <token>', 'control-plane org token (or $SBX_TOKEN; required)')
69
- .option('--json', 'output JSON', false);
44
+ function withOutput(cmd) {
45
+ return cmd.option('--json', 'output JSON', false);
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' });
70
99
  }
71
100
  export function registerSandbox(program) {
72
101
  const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
73
- withCp(sandbox
102
+ withOutput(sandbox
74
103
  .command('create')
75
104
  .description('Spawn a microVM sandbox')
76
- .option('--image <ref>', 'base image', 'newly/agent-base')
77
- .option('--size <class>', 'size preset', 's-2')
105
+ .option('--image <ref>', 'base image', 'runcloud/agent-base')
78
106
  .option('--region <region>', 'placement region')
79
- .option('--name <name>', 'human-readable name')).action((opts) => run(async () => {
80
- const body = { size_class: opts.size, image: opts.image };
107
+ .option('--name <name>', 'human-readable name')
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 };
81
114
  if (opts.name)
82
115
  body.name = opts.name;
83
116
  if (opts.region)
84
117
  body.region = opts.region;
85
- print(await cp(opts).request('POST', '/v1/sandboxes', body), opts);
118
+ if (opts.org)
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;
128
+ print(await runCloudApi().post('/run-cloud/sandboxes', body), opts);
86
129
  }));
87
- withCp(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
130
+ withOutput(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
88
131
  const qs = opts.state ? `?state=${encodeURIComponent(opts.state)}` : '';
89
- const data = await cp(opts).request('GET', `/v1/sandboxes${qs}`);
132
+ const data = await runCloudApi().get(`/run-cloud/sandboxes${qs}`);
90
133
  print(data?.items ?? [], opts);
91
134
  }));
92
- withCp(sandbox
135
+ withOutput(sandbox
93
136
  .command('exec')
94
137
  .description('Run a command in a sandbox (via /bin/sh -c)')
95
138
  .argument('<id>', 'sandbox id')
96
139
  .argument('<cmd...>', 'command to run, e.g. npm run build')).action((id, cmdParts, opts) => run(async () => {
97
- const r = await cp(opts).request('POST', `/v1/sandboxes/${encodeURIComponent(id)}/exec`, {
140
+ const r = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/exec`, {
98
141
  cmd: ['/bin/sh', '-c', cmdParts.join(' ')],
99
142
  });
100
143
  if (opts.json) {
@@ -108,13 +151,13 @@ export function registerSandbox(program) {
108
151
  }
109
152
  process.exitCode = r.exit_code ?? 0;
110
153
  }));
111
- withCp(sandbox
154
+ withOutput(sandbox
112
155
  .command('logs')
113
156
  .description('Show recent sandbox logs (one-shot journalctl tail; streaming is a follow-up)')
114
157
  .argument('<id>', 'sandbox id')
115
158
  .option('--lines <n>', 'number of lines to tail', '200')).action((id, opts) => run(async () => {
116
159
  const n = Number(opts.lines) || 200;
117
- const r = await cp(opts).request('POST', `/v1/sandboxes/${encodeURIComponent(id)}/exec`, {
160
+ const r = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/exec`, {
118
161
  cmd: [
119
162
  '/bin/sh',
120
163
  '-c',
@@ -128,9 +171,183 @@ export function registerSandbox(program) {
128
171
  process.stdout.write(r.stdout ?? '');
129
172
  process.stderr.write(r.stderr ?? '');
130
173
  }));
131
- withCp(sandbox.command('rm').description('Destroy a sandbox').argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
132
- await cp(opts).request('DELETE', `/v1/sandboxes/${encodeURIComponent(id)}`);
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
+ }));
239
+ withOutput(sandbox.command('rm').description('Destroy a sandbox').argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
240
+ await runCloudApi().delete(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
133
241
  if (!opts.json)
134
242
  console.log(`destroyed ${id}`);
135
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);
136
353
  }
package/dist/config.js CHANGED
@@ -41,10 +41,10 @@ export function loadCredentials() {
41
41
  const envApiUrl = isRunCloud()
42
42
  ? process.env.RUN_CLOUD_API_URL
43
43
  : process.env.NEWLY_API_URL;
44
- if (envToken?.trim() && envApiUrl?.trim()) {
44
+ if (envToken?.trim() && (isRunCloud() || envApiUrl?.trim())) {
45
45
  return {
46
46
  token: envToken.trim(),
47
- apiUrl: normalizeApiUrl(envApiUrl.trim()),
47
+ apiUrl: normalizeApiUrl(envApiUrl?.trim() || 'https://api.newly.app'),
48
48
  appUrl: isRunCloud()
49
49
  ? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
50
50
  : process.env.NEWLY_APP_URL ?? 'https://newly.app',
@@ -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
 
@@ -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, /loadingGuard=1/);
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: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } },
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: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } },
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 balance for three sessions
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
@@ -86,7 +86,7 @@ function scriptData(value) {
86
86
 
87
87
  function embedUrl(value) {
88
88
  const url = new URL(value);
89
- url.searchParams.set('loadingGuard', '1');
89
+ url.searchParams.set('embed', '1');
90
90
  return url.toString();
91
91
  }
92
92
 
@@ -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/);