runcloud 0.1.6 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,39 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
+ import { hostname } from 'node:os';
3
4
  import { spawn } from 'node:child_process';
4
- import { clearCredentials, credentialsDisplayPath, saveCredentials } from '../config.js';
5
+ import { ApiClient, ApiError } from '../api.js';
6
+ import { clearCredentials, credentialsDisplayPath, normalizeApiUrl, saveCredentials } from '../config.js';
7
+ import { acquirePromptIO, ask, PromptAbortedError, select, } from '../prompt.js';
8
+ import { AuthError, createApiKey, fetchProviders, isValidEmail, isValidOtp, normalizeOtp, sendEmailCode, verifyEmailCode, } from '../runCloudAuth.js';
5
9
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
10
+ export const DEFAULT_RUN_CLOUD_API_URL = 'https://api.run.cloud';
11
+ export function loginChoices(providers) {
12
+ const choices = [];
13
+ if (providers.emailOtp) {
14
+ choices.push({ value: 'email', label: 'Email', hint: 'a code is sent to your inbox' });
15
+ }
16
+ if (providers.google) {
17
+ choices.push({ value: 'google', label: 'Google', hint: 'opens your browser' });
18
+ }
19
+ if (providers.github) {
20
+ choices.push({ value: 'github', label: 'GitHub', hint: 'opens your browser' });
21
+ }
22
+ return choices;
23
+ }
24
+ export function methodFromFlags(flags) {
25
+ const chosen = [];
26
+ if (flags.email)
27
+ chosen.push('email');
28
+ if (flags.google)
29
+ chosen.push('google');
30
+ if (flags.github)
31
+ chosen.push('github');
32
+ if (chosen.length > 1) {
33
+ throw new Error(`Pick one sign-in method: ${chosen.map((m) => `--${m}`).join(' and ')} conflict.`);
34
+ }
35
+ return chosen[0] ?? null;
36
+ }
6
37
  export async function startCallbackServer(state, appOrigin, timeoutMs) {
7
38
  let resolvePayload;
8
39
  let rejectPayload;
@@ -10,7 +41,10 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
10
41
  resolvePayload = resolve;
11
42
  rejectPayload = reject;
12
43
  });
13
- const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s`)), timeoutMs);
44
+ const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s.\n` +
45
+ "If the browser said the local handoff was blocked, Chrome gates HTTPS→localhost behind a permission: " +
46
+ 'click the icon at the left of the address bar, allow "access to apps on this device", ' +
47
+ 'then run login again (or use --manual).')), timeoutMs);
14
48
  const corsHeaders = {
15
49
  'Access-Control-Allow-Origin': appOrigin,
16
50
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
@@ -91,12 +125,54 @@ function openBrowser(url) {
91
125
  });
92
126
  child.unref();
93
127
  }
94
- export function cliAuthUrl(appUrl, port, state) {
128
+ export function cliAuthUrl(appUrl, port, state, provider) {
95
129
  const url = new URL('/cli-auth', appUrl);
96
130
  url.searchParams.set('port', String(port));
97
131
  url.searchParams.set('state', state);
132
+ if (provider)
133
+ url.searchParams.set('provider', provider);
98
134
  return url.toString();
99
135
  }
136
+ export async function emailLogin(apiUrl, presetEmail, io) {
137
+ const email = presetEmail?.trim()
138
+ ? presetEmail.trim()
139
+ : await ask(' Email: ', {
140
+ io,
141
+ validate: (value) => (isValidEmail(value) ? null : 'That does not look like an email address.'),
142
+ });
143
+ if (!isValidEmail(email))
144
+ throw new Error(`Not a valid email address: ${email}`);
145
+ await sendEmailCode(apiUrl, email);
146
+ console.log(` Code sent to ${email}\n`);
147
+ let session = null;
148
+ for (let attempt = 1; attempt <= 3 && !session; attempt += 1) {
149
+ const code = await ask(' Code: ', {
150
+ io,
151
+ validate: (value) => (isValidOtp(value) ? null : 'Codes are six digits.'),
152
+ });
153
+ try {
154
+ session = await verifyEmailCode(apiUrl, email, normalizeOtp(code));
155
+ }
156
+ catch (e) {
157
+ const wrongCode = e instanceof AuthError && (e.status === 400 || e.status === 401);
158
+ if (!wrongCode || attempt === 3)
159
+ throw e;
160
+ console.log(` ${e.message} — try again.\n`);
161
+ }
162
+ }
163
+ if (!session)
164
+ throw new Error('Could not verify the code.');
165
+ const token = await createApiKey(apiUrl, session.sessionToken, `runcloud CLI (${hostLabel()})`);
166
+ return { email, token };
167
+ }
168
+ function hostLabel() {
169
+ try {
170
+ return hostname() || 'cli';
171
+ }
172
+ catch {
173
+ return 'cli';
174
+ }
175
+ }
100
176
  export function parseManualCredential(blob) {
101
177
  let parsed;
102
178
  try {
@@ -112,6 +188,19 @@ export function parseManualCredential(blob) {
112
188
  }
113
189
  return { token: c.token, expiresIn: c.expiresIn, apiUrl: c.apiUrl };
114
190
  }
191
+ export async function verifyRunCloudCredential(apiUrl, token) {
192
+ try {
193
+ await new ApiClient(normalizeApiUrl(apiUrl), token).get('/run-cloud/account');
194
+ }
195
+ catch (e) {
196
+ if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
197
+ throw new Error(`${apiUrl} rejected this credential (${e.status}). Make sure you signed in to the account ` +
198
+ 'you want the CLI to use, then run login again.');
199
+ }
200
+ console.warn(`Warning: could not verify the credential against ${apiUrl} ` +
201
+ `(${e instanceof Error ? e.message : String(e)}). Saving it anyway.`);
202
+ }
203
+ }
115
204
  export function registerLogin(program) {
116
205
  const isRunCloud = program.name() === 'runcloud';
117
206
  const defaultAppUrl = isRunCloud
@@ -119,12 +208,18 @@ export function registerLogin(program) {
119
208
  : process.env.NEWLY_APP_URL ?? 'https://newly.app';
120
209
  program
121
210
  .command('login')
122
- .description(`Log in with your ${isRunCloud ? 'run.cloud' : 'Newly'} account via the browser`)
211
+ .description(isRunCloud
212
+ ? 'Log in with your run.cloud account: email, Google, or GitHub'
213
+ : 'Log in with your Newly account via the browser')
123
214
  .option('--app-url <url>', 'product web app URL', defaultAppUrl)
124
215
  .option('--manual', 'paste the credential from the browser instead of using the local callback', false)
125
216
  .option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
126
217
  .option('--token <token>', 'explicit credential token (skips browser; use with --api-url)')
127
218
  .option('--expires-in <seconds>', 'session lifetime when using --token (default 7 days)', (v) => Number(v), 604800)
219
+ .option('--email [address]', 'sign in with an emailed code, without a browser')
220
+ .option('--google', 'sign in with Google', false)
221
+ .option('--github', 'sign in with GitHub', false)
222
+ .option('--browser', 'skip the picker and use the browser handoff', false)
128
223
  .action(async (opts) => {
129
224
  try {
130
225
  const hasApiUrl = typeof opts.apiUrl === 'string' && opts.apiUrl.length > 0;
@@ -136,11 +231,8 @@ export function registerLogin(program) {
136
231
  throw new Error('--api-url requires --token for headless login.');
137
232
  }
138
233
  if (hasToken) {
139
- if (isRunCloud && !opts.token.startsWith('rc_live_')) {
140
- throw new Error('runcloud login requires a Better Auth API key beginning with rc_live_.');
141
- }
142
234
  const apiUrl = isRunCloud
143
- ? opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? 'https://api.newly.app'
235
+ ? opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? 'https://api.run.cloud'
144
236
  : opts.apiUrl;
145
237
  try {
146
238
  new URL(apiUrl);
@@ -170,39 +262,70 @@ export function registerLogin(program) {
170
262
  const blob = await rl.question('Paste the credential from the browser: ');
171
263
  rl.close();
172
264
  const parsed = parseManualCredential(blob);
173
- if (isRunCloud && !parsed.token.startsWith('rc_live_')) {
174
- throw new Error('The browser did not return a Better Auth run.cloud API key.');
175
- }
265
+ if (isRunCloud)
266
+ await verifyRunCloudCredential(parsed.apiUrl, parsed.token);
176
267
  saveCredentials({
177
268
  token: parsed.token,
178
269
  apiUrl: parsed.apiUrl,
179
270
  appUrl: opts.appUrl,
180
- expiresAt: isRunCloud
181
- ? Number.MAX_SAFE_INTEGER
182
- : Math.floor(Date.now() / 1000) + parsed.expiresIn,
271
+ expiresAt: Math.floor(Date.now() / 1000) + parsed.expiresIn,
183
272
  });
184
273
  console.log('Logged in.');
185
274
  return;
186
275
  }
276
+ let socialProvider;
277
+ if (isRunCloud && !opts.browser) {
278
+ const authApiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
279
+ let method = methodFromFlags(opts);
280
+ const terminal = method === 'email' || !method ? acquirePromptIO() : null;
281
+ try {
282
+ if (!method && terminal) {
283
+ const providers = await fetchProviders(authApiUrl);
284
+ const choices = loginChoices(providers);
285
+ if (choices.length === 0) {
286
+ throw new Error(`${authApiUrl} has no sign-in methods enabled.`);
287
+ }
288
+ method = await select(' How do you want to sign in?', choices, terminal.io);
289
+ }
290
+ if (method === 'email') {
291
+ const preset = typeof opts.email === 'string' ? opts.email : undefined;
292
+ const { email, token } = await emailLogin(authApiUrl, preset, terminal?.io);
293
+ saveCredentials({
294
+ token,
295
+ apiUrl: authApiUrl,
296
+ appUrl: opts.appUrl,
297
+ expiresAt: Number.MAX_SAFE_INTEGER,
298
+ });
299
+ console.log(`\n Signed in as ${email}`);
300
+ console.log(` Credential saved to ${credentialsDisplayPath()}\n`);
301
+ console.log(' Try:');
302
+ console.log(' runcloud ios create --open');
303
+ console.log(' runcloud sandbox run "echo hello"');
304
+ return;
305
+ }
306
+ }
307
+ finally {
308
+ terminal?.close();
309
+ }
310
+ if (method === 'google' || method === 'github')
311
+ socialProvider = method;
312
+ }
187
313
  const state = crypto.randomBytes(16).toString('hex');
188
314
  const appOrigin = new URL(opts.appUrl).origin;
189
315
  const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
190
- const loginUrl = cliAuthUrl(opts.appUrl, port, state);
316
+ const loginUrl = cliAuthUrl(opts.appUrl, port, state, socialProvider);
191
317
  console.log(`Opening your browser to authorize the CLI…\n ${loginUrl}\n`);
192
318
  console.log("If the browser doesn't open, paste the URL above into it manually.");
193
319
  openBrowser(loginUrl);
194
320
  try {
195
321
  const payload = await waitForToken();
196
- if (isRunCloud && !payload.token.startsWith('rc_live_')) {
197
- throw new Error('The browser did not return a Better Auth run.cloud API key.');
198
- }
322
+ if (isRunCloud)
323
+ await verifyRunCloudCredential(payload.apiUrl, payload.token);
199
324
  saveCredentials({
200
325
  token: payload.token,
201
326
  apiUrl: payload.apiUrl,
202
327
  appUrl: opts.appUrl,
203
- expiresAt: isRunCloud
204
- ? Number.MAX_SAFE_INTEGER
205
- : Math.floor(Date.now() / 1000) + payload.expiresIn,
328
+ expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
206
329
  });
207
330
  console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
208
331
  }
@@ -211,6 +334,11 @@ export function registerLogin(program) {
211
334
  }
212
335
  }
213
336
  catch (e) {
337
+ if (e instanceof PromptAbortedError) {
338
+ console.log('Cancelled.');
339
+ process.exitCode = 130;
340
+ return;
341
+ }
214
342
  console.error(e instanceof Error ? e.message : String(e));
215
343
  process.exitCode = 1;
216
344
  }
@@ -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 = `---
@@ -17,10 +18,10 @@ Use this skill when a user asks an agent to create, inspect, smoke test, debug,
17
18
 
18
19
  ## Requirements
19
20
 
20
- - 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
+ - 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.run.cloud\`.
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
@@ -75,7 +76,7 @@ Or authenticate non-interactively with both required environment variables:
75
76
 
76
77
  \`\`\`bash
77
78
  export RUN_CLOUD_API_KEY="rc_live_..."
78
- export RUN_CLOUD_API_URL="https://api.newly.app"
79
+ export RUN_CLOUD_API_URL="https://api.run.cloud"
79
80
  \`\`\`
80
81
 
81
82
  Then inspect the account:
@@ -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);