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 ADDED
@@ -0,0 +1,135 @@
1
+ # runcloud
2
+
3
+ Create and control **remote mobile simulators** and **cloud sandboxes** from your terminal.
4
+
5
+ - **Simulators** — boot real iOS simulators and Android emulators in the cloud, install your app, open deep links, and tunnel a local dev server into them.
6
+ - **Sandboxes** — start a fresh, isolated Linux environment in seconds, run commands in it, snapshot a ready setup, and restore it later as a warm start.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm i -g runcloud
12
+ ```
13
+
14
+ Installs the `runcloud` binary. Requires **Node ≥ 20**.
15
+
16
+ ## Log in
17
+
18
+ ```bash
19
+ runcloud login # authenticate in the browser; stores a token in ~/.run-cloud/credentials
20
+ runcloud login --manual # paste the credential instead of using the browser callback
21
+ runcloud logout # delete the stored credential
22
+ runcloud account # show your entitlement and metered balance
23
+ ```
24
+
25
+ The token expires — re-run `runcloud login` when a command reports you're logged out.
26
+
27
+ ## Remote simulators
28
+
29
+ ```bash
30
+ # iOS
31
+ runcloud ios create --model iphone --install ./MyApp.app # boot a simulator and install an app
32
+ runcloud ios list # list active sessions
33
+ runcloud ios get <id> # inspect a session (viewer URL, status)
34
+ runcloud ios open-url myapp://path --id <id> # open a URL or deep link
35
+ runcloud ios delete <id> # release the session
36
+
37
+ # Android works the same under `runcloud android …`
38
+ runcloud android create --model pixel
39
+ ```
40
+
41
+ Useful `create` flags: `--region`, `--display-name`, `--inactivity-timeout 3m`, `--hard-timeout 1h`, `--rm` (release when the command exits), `--json`.
42
+
43
+ ### Tunnel a local dev server into a simulator
44
+
45
+ ```bash
46
+ runcloud tunnel <id> --service metro # expose your local Metro/dev server to the remote iOS session
47
+ runcloud tunnel-status
48
+ ```
49
+
50
+ ## Sandboxes
51
+
52
+ ```bash
53
+ runcloud sandbox create # start a sandbox (default image: runcloud/agent-base)
54
+ runcloud sandbox create --cpu 0.5 --memory 1024 # allocate CPU cores and memory in MiB
55
+ runcloud sandbox create --disk 80 # request an 80 GiB root filesystem
56
+ runcloud sandbox exec <id> npm run build # run a command inside it
57
+ runcloud sandbox logs <id> -n 200 # tail its logs
58
+ runcloud sandbox list
59
+ runcloud sandbox rm <id> # destroy it
60
+ ```
61
+
62
+ ### Browser desktops and computer use
63
+
64
+ Create a desktop sandbox, then open its short-lived signed noVNC URL:
65
+
66
+ ```bash
67
+ SANDBOX_ID=$(runcloud sandbox create --image runcloud/desktop --json | jq -r '.id')
68
+
69
+ runcloud sandbox desktop "$SANDBOX_ID" # open the desktop in a browser
70
+ runcloud sandbox desktop "$SANDBOX_ID" --url # print the signed URL
71
+ runcloud sandbox desktop "$SANDBOX_ID" --view-only # open without keyboard or pointer input
72
+ ```
73
+
74
+ Agents can capture and control the same desktop:
75
+
76
+ ```bash
77
+ runcloud sandbox screenshot "$SANDBOX_ID" --output screen.png
78
+ runcloud sandbox click "$SANDBOX_ID" 420 300
79
+ runcloud sandbox type "$SANDBOX_ID" "hello world"
80
+ runcloud sandbox key "$SANDBOX_ID" "ctrl+l"
81
+ ```
82
+
83
+ Treat a signed desktop URL as a secret. It grants temporary access to that
84
+ desktop without asking the browser to send your CLI credential.
85
+
86
+ ### Snapshot warm-start
87
+
88
+ Freeze a "repo + deps ready" sandbox, then fork a new one from it in a fraction of a second instead of re-cloning and re-installing:
89
+
90
+ ```bash
91
+ runcloud sandbox snapshot create <id> --label warm # snapshot a sandbox
92
+ runcloud sandbox snapshot list --sandbox <id>
93
+ runcloud sandbox restore <snapshot-id> --name fork # fork a NEW sandbox from the snapshot
94
+ runcloud sandbox snapshot rm <snapshot-id>
95
+ ```
96
+
97
+ ## Demos
98
+
99
+ ```bash
100
+ runcloud demo run --open # run the default mosaic from any directory
101
+ runcloud demo run live-camera-relay --open # run the camera relay from any directory
102
+ runcloud demo init eight-device-mosaic # optionally scaffold a standalone project
103
+ ```
104
+
105
+ Bundled demos: `eight-device-mosaic`, `live-camera-relay`.
106
+
107
+ ## Onboarding sample apps
108
+
109
+ Download checksum-verified apps that are ready for the remote runtimes:
110
+
111
+ ```bash
112
+ runcloud sample download ios
113
+ runcloud ios create --install ./run-cloud-sample-ios.app.tar.gz
114
+
115
+ runcloud sample download android
116
+ runcloud android create --install ./run-cloud-sample-android.apk
117
+ ```
118
+
119
+ ## AI coding agents
120
+
121
+ ```bash
122
+ runcloud skills # install run.cloud skills for AI coding agents
123
+ ```
124
+
125
+ Every command accepts `--json` for scripting.
126
+
127
+ ## Credentials & environment
128
+
129
+ - `~/.run-cloud/credentials` — your session token (mode `600`).
130
+
131
+ | Env var | Effect |
132
+ |---|---|
133
+ | `RUN_CLOUD_API_TOKEN` / `RUN_CLOUD_API_KEY` | Use this token instead of the stored credential (CI / headless). |
134
+ | `RUN_CLOUD_API_URL` | Override the control-plane API base URL. |
135
+ | `RUN_CLOUD_APP_URL` | Override the web app URL used for browser login (default `https://run.cloud`). |
package/dist/api.js CHANGED
@@ -1,10 +1,12 @@
1
1
  export class ApiError extends Error {
2
2
  status;
3
3
  detail;
4
- constructor(status, detail) {
4
+ path;
5
+ constructor(status, detail, path) {
5
6
  super(`API ${status}: ${detail}`);
6
7
  this.status = status;
7
8
  this.detail = detail;
9
+ this.path = path;
8
10
  }
9
11
  }
10
12
  function parsedErrorDetail(body) {
@@ -38,7 +40,7 @@ export class ApiClient {
38
40
  });
39
41
  if (!res.ok) {
40
42
  const detail = parsedErrorDetail(await res.text());
41
- throw new ApiError(res.status, detail);
43
+ throw new ApiError(res.status, detail, path);
42
44
  }
43
45
  const contentType = res.headers.get('content-type') ?? '';
44
46
  return contentType.includes('application/json') ? res.json() : res;
@@ -61,7 +63,7 @@ export class ApiClient {
61
63
  });
62
64
  if (!res.ok) {
63
65
  const detail = parsedErrorDetail(await res.text());
64
- throw new ApiError(res.status, detail);
66
+ throw new ApiError(res.status, detail, path);
65
67
  }
66
68
  const contentType = res.headers.get('content-type') ?? '';
67
69
  return contentType.includes('application/json') ? res.json() : res;
@@ -83,7 +85,7 @@ export class ApiClient {
83
85
  });
84
86
  if (!res.ok) {
85
87
  const detail = parsedErrorDetail(await res.text());
86
- throw new ApiError(res.status, detail);
88
+ throw new ApiError(res.status, detail, path);
87
89
  }
88
90
  const contentType_ = res.headers.get('content-type') ?? '';
89
91
  return contentType_.includes('application/json') ? res.json() : res;
@@ -98,7 +100,7 @@ export class ApiClient {
98
100
  });
99
101
  if (!res.ok) {
100
102
  const detail = parsedErrorDetail(await res.text());
101
- throw new ApiError(res.status, detail);
103
+ throw new ApiError(res.status, detail, path);
102
104
  }
103
105
  const contentType = res.headers.get('content-type') ?? '';
104
106
  return contentType.includes('application/json') ? res.json() : res;
@@ -110,7 +112,7 @@ export class ApiClient {
110
112
  });
111
113
  if (!res.ok) {
112
114
  const detail = await res.text().catch(() => '');
113
- throw new ApiError(res.status, detail || res.statusText);
115
+ throw new ApiError(res.status, detail || res.statusText, path);
114
116
  }
115
117
  return Buffer.from(await res.arrayBuffer());
116
118
  }
@@ -121,7 +123,7 @@ export class ApiClient {
121
123
  });
122
124
  if (!res.ok) {
123
125
  const detail = parsedErrorDetail(await res.text().catch(() => ''));
124
- throw new ApiError(res.status, detail || res.statusText);
126
+ throw new ApiError(res.status, detail || res.statusText, path);
125
127
  }
126
128
  const decoder = new TextDecoder();
127
129
  for await (const chunk of res.body) {
@@ -168,8 +170,15 @@ function headline(err) {
168
170
  }
169
171
  if (err.status === 403)
170
172
  return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
171
- if (err.status === 404)
172
- return `Not found: ${err.detail}`;
173
+ if (err.status === 404) {
174
+ const sandboxMatch = err.path?.match(/^\/run-cloud\/sandboxes\/([^/]+)/);
175
+ if (sandboxMatch) {
176
+ return `Sandbox ID not found: ${decodeURIComponent(sandboxMatch[1])}.`;
177
+ }
178
+ if (!err.detail || /^not found$/i.test(err.detail.trim()))
179
+ return 'Resource not found.';
180
+ return err.detail;
181
+ }
173
182
  if (err.status === 409)
174
183
  return err.detail;
175
184
  if (err.status === 503)
@@ -179,5 +188,6 @@ function headline(err) {
179
188
  return networkError(err) ?? (err instanceof Error ? err.message : String(err));
180
189
  }
181
190
  export function friendlyApiError(err) {
182
- return `${headline(err)}${detailSuffix(err)}`;
191
+ const message = headline(err);
192
+ return err instanceof ApiError ? message : `${message}${detailSuffix(err)}`;
183
193
  }
@@ -0,0 +1,246 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
3
+ import { homedir, hostname as localHostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import WebSocket, { createWebSocketStream } from 'ws';
6
+ import { ApiClient, friendlyApiError } from '../api.js';
7
+ import { requireCredentials } from '../config.js';
8
+ function api() {
9
+ const credentials = requireCredentials();
10
+ return new ApiClient(credentials.apiUrl, credentials.token);
11
+ }
12
+ async function resolveBox(client, reference) {
13
+ const response = await client.get('/run-cloud/boxes');
14
+ const boxes = (response?.items ?? []);
15
+ const box = boxes.find((item) => item.id === reference ||
16
+ item.name === reference ||
17
+ item.hostname === reference);
18
+ if (!box)
19
+ throw new Error(`Box not found: ${reference}`);
20
+ return box;
21
+ }
22
+ function sshHome() {
23
+ const base = process.env.RUN_CLOUD_HOME || join(homedir(), '.run-cloud');
24
+ return join(base, 'ssh');
25
+ }
26
+ function keyPath() {
27
+ return join(sshHome(), 'id_ed25519');
28
+ }
29
+ function exec(command, args) {
30
+ return new Promise((resolve, reject) => {
31
+ const child = spawn(command, args, { stdio: 'inherit' });
32
+ child.once('error', reject);
33
+ child.once('exit', (code, signal) => {
34
+ if (signal)
35
+ reject(new Error(`${command} exited on signal ${signal}`));
36
+ else
37
+ resolve(code ?? 1);
38
+ });
39
+ });
40
+ }
41
+ async function ensureSshKey(client, box) {
42
+ mkdirSync(sshHome(), { recursive: true, mode: 0o700 });
43
+ const privateKey = keyPath();
44
+ const publicKey = `${privateKey}.pub`;
45
+ if (!existsSync(privateKey) || !existsSync(publicKey)) {
46
+ const generated = spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', `runcloud@${localHostname()}`, '-f', privateKey], { stdio: 'inherit' });
47
+ if (generated.error)
48
+ throw generated.error;
49
+ if (generated.status !== 0)
50
+ throw new Error(`ssh-keygen failed (${generated.status})`);
51
+ }
52
+ chmodSync(privateKey, 0o600);
53
+ await client.post(`/run-cloud/boxes/${encodeURIComponent(box.id)}/ssh-keys`, {
54
+ publicKey: readFileSync(publicKey, 'utf8').trim(),
55
+ label: `${localHostname()} (Run Cloud CLI)`,
56
+ });
57
+ return privateKey;
58
+ }
59
+ function sshAlias(box) {
60
+ return `runcloud-${box.name}`;
61
+ }
62
+ function writeSshConfig(box, privateKey) {
63
+ const sshDir = join(homedir(), '.ssh');
64
+ const configPath = join(sshDir, 'config');
65
+ mkdirSync(sshDir, { recursive: true, mode: 0o700 });
66
+ const current = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
67
+ const start = `# BEGIN RUN CLOUD ${box.id}`;
68
+ const end = `# END RUN CLOUD ${box.id}`;
69
+ const block = [
70
+ start,
71
+ `Host ${sshAlias(box)}`,
72
+ ` HostName ${box.hostname}`,
73
+ ' User runcloud',
74
+ ` IdentityFile "${privateKey}"`,
75
+ ' IdentitiesOnly yes',
76
+ ' StrictHostKeyChecking accept-new',
77
+ ` UserKnownHostsFile "${join(sshHome(), 'known_hosts')}"`,
78
+ ` ProxyCommand runcloud box proxy ${box.id}`,
79
+ end,
80
+ ].join('\n');
81
+ const pattern = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n?`, 'g');
82
+ const next = `${current.replace(pattern, '').trimEnd()}${current.trim() ? '\n\n' : ''}${block}\n`;
83
+ writeFileSync(configPath, next, { mode: 0o600 });
84
+ chmodSync(configPath, 0o600);
85
+ return sshAlias(box);
86
+ }
87
+ async function setupSsh(reference) {
88
+ const client = api();
89
+ const box = await resolveBox(client, reference);
90
+ const privateKey = await ensureSshKey(client, box);
91
+ return { box, alias: writeSshConfig(box, privateKey) };
92
+ }
93
+ function output(value, opts) {
94
+ if (opts.json)
95
+ console.log(JSON.stringify(value, null, 2));
96
+ else if (typeof value === 'string')
97
+ console.log(value);
98
+ else {
99
+ for (const [key, item] of Object.entries(value)) {
100
+ console.log(`${key}: ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`);
101
+ }
102
+ }
103
+ }
104
+ function action(fn) {
105
+ return fn().catch((error) => {
106
+ console.error(friendlyApiError(error));
107
+ process.exitCode = 1;
108
+ });
109
+ }
110
+ function openUrl(url) {
111
+ if (process.platform === 'darwin')
112
+ return exec('open', [url]);
113
+ if (process.platform === 'win32')
114
+ return exec('cmd', ['/c', 'start', '', url]);
115
+ return exec('xdg-open', [url]);
116
+ }
117
+ export function registerBoxAccessCommands(box) {
118
+ box
119
+ .command('setup-ssh')
120
+ .description('Install a dedicated key and write this box to ~/.ssh/config')
121
+ .argument('<box>', 'box id, name, or hostname')
122
+ .action((reference) => action(async () => {
123
+ const configured = await setupSsh(reference);
124
+ console.log(`SSH configured: ssh ${configured.alias}`);
125
+ }));
126
+ box
127
+ .command('proxy', { hidden: true })
128
+ .description('Authenticated WebSocket transport used by SSH ProxyCommand')
129
+ .argument('<box>', 'box id, name, or hostname')
130
+ .action((reference) => action(async () => {
131
+ const client = api();
132
+ const resolved = await resolveBox(client, reference);
133
+ const access = (await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/access-tickets`, { purpose: 'ssh' }));
134
+ if (!access.websocketUrl)
135
+ throw new Error('SSH ticket response has no WebSocket URL');
136
+ const socket = new WebSocket(access.websocketUrl, {
137
+ headers: { 'x-run-cloud-access-ticket': access.ticket },
138
+ });
139
+ const stream = createWebSocketStream(socket, { encoding: undefined });
140
+ process.stdin.pipe(stream);
141
+ stream.pipe(process.stdout);
142
+ await new Promise((resolve, reject) => {
143
+ stream.once('close', resolve);
144
+ stream.once('error', reject);
145
+ socket.once('error', reject);
146
+ });
147
+ }));
148
+ box
149
+ .command('ssh')
150
+ .description('Open a shell in a box')
151
+ .argument('<box>', 'box id, name, or hostname')
152
+ .argument('[command...]', 'optional remote command')
153
+ .action((reference, command) => action(async () => {
154
+ const configured = await setupSsh(reference);
155
+ const code = await exec('ssh', [configured.alias, ...command]);
156
+ if (code !== 0)
157
+ process.exitCode = code;
158
+ }));
159
+ box
160
+ .command('cp')
161
+ .description('Copy files with scp; prefix the remote path with a colon')
162
+ .argument('<box>', 'box id, name, or hostname')
163
+ .argument('<source>', 'local path or :/remote/path')
164
+ .argument('<destination>', 'local path or :/remote/path')
165
+ .option('-r, --recursive', 'copy directories recursively', false)
166
+ .action((reference, source, destination, opts) => action(async () => {
167
+ if (source.startsWith(':') === destination.startsWith(':')) {
168
+ throw new Error('Exactly one path must be remote (prefix it with :)');
169
+ }
170
+ const configured = await setupSsh(reference);
171
+ const remote = (path) => path.startsWith(':') ? `${configured.alias}:${path.slice(1)}` : path;
172
+ const args = [
173
+ ...(opts.recursive ? ['-r'] : []),
174
+ '--',
175
+ remote(source),
176
+ remote(destination),
177
+ ];
178
+ const code = await exec('scp', args);
179
+ if (code !== 0)
180
+ process.exitCode = code;
181
+ }));
182
+ box
183
+ .command('code')
184
+ .description('Open a box in VS Code Remote SSH')
185
+ .argument('<box>', 'box id, name, or hostname')
186
+ .argument('[path]', 'remote folder', '/workspace')
187
+ .option('--no-open', 'print the VS Code URL without opening it')
188
+ .action((reference, path, opts) => action(async () => {
189
+ const configured = await setupSsh(reference);
190
+ const remotePath = path.startsWith('/') ? path : `/${path}`;
191
+ const url = `vscode://vscode-remote/ssh-remote+${encodeURIComponent(configured.alias)}` +
192
+ remotePath.split('/').map(encodeURIComponent).join('/');
193
+ if (opts.open === false)
194
+ console.log(url);
195
+ else
196
+ await openUrl(url);
197
+ }));
198
+ const domain = box.command('domain').description('Manage custom domains');
199
+ domain
200
+ .command('add')
201
+ .argument('<box>', 'box id, name, or hostname')
202
+ .argument('<hostname>', 'custom hostname')
203
+ .option('--json', 'output JSON', false)
204
+ .action((reference, hostname, opts) => action(async () => {
205
+ const client = api();
206
+ const resolved = await resolveBox(client, reference);
207
+ output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`, {
208
+ hostname,
209
+ }), opts);
210
+ }));
211
+ domain
212
+ .command('list')
213
+ .argument('<box>', 'box id, name, or hostname')
214
+ .option('--json', 'output JSON', false)
215
+ .action((reference, opts) => action(async () => {
216
+ const client = api();
217
+ const resolved = await resolveBox(client, reference);
218
+ const response = await client.get(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`);
219
+ const items = response?.items ?? [];
220
+ if (opts.json)
221
+ console.log(JSON.stringify(items, null, 2));
222
+ else
223
+ for (const item of items)
224
+ output(item, opts);
225
+ }));
226
+ domain
227
+ .command('status')
228
+ .argument('<box>', 'box id, name, or hostname')
229
+ .argument('<domain-id>', 'domain id from `box domain list`')
230
+ .option('--json', 'output JSON', false)
231
+ .action((reference, domainId, opts) => action(async () => {
232
+ const client = api();
233
+ const resolved = await resolveBox(client, reference);
234
+ output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}/refresh`), opts);
235
+ }));
236
+ domain
237
+ .command('rm')
238
+ .argument('<box>', 'box id, name, or hostname')
239
+ .argument('<domain-id>', 'domain id from `box domain list`')
240
+ .action((reference, domainId) => action(async () => {
241
+ const client = api();
242
+ const resolved = await resolveBox(client, reference);
243
+ await client.delete(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}`);
244
+ console.log(`removed custom domain ${domainId}`);
245
+ }));
246
+ }
@@ -1,7 +1,8 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
3
  import { spawn } from 'node:child_process';
4
- import { clearCredentials, credentialsDisplayPath, saveCredentials } from '../config.js';
4
+ import { ApiClient, ApiError } from '../api.js';
5
+ import { clearCredentials, credentialsDisplayPath, normalizeApiUrl, saveCredentials } from '../config.js';
5
6
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
6
7
  export async function startCallbackServer(state, appOrigin, timeoutMs) {
7
8
  let resolvePayload;
@@ -10,7 +11,10 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
10
11
  resolvePayload = resolve;
11
12
  rejectPayload = reject;
12
13
  });
13
- const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s`)), timeoutMs);
14
+ const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s.\n` +
15
+ "If the browser said the local handoff was blocked, Chrome gates HTTPS→localhost behind a permission: " +
16
+ 'click the icon at the left of the address bar, allow "access to apps on this device", ' +
17
+ 'then run login again (or use --manual).')), timeoutMs);
14
18
  const corsHeaders = {
15
19
  'Access-Control-Allow-Origin': appOrigin,
16
20
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
@@ -112,6 +116,19 @@ export function parseManualCredential(blob) {
112
116
  }
113
117
  return { token: c.token, expiresIn: c.expiresIn, apiUrl: c.apiUrl };
114
118
  }
119
+ export async function verifyRunCloudCredential(apiUrl, token) {
120
+ try {
121
+ await new ApiClient(normalizeApiUrl(apiUrl), token).get('/run-cloud/account');
122
+ }
123
+ catch (e) {
124
+ if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
125
+ throw new Error(`${apiUrl} rejected this credential (${e.status}). Make sure you signed in to the account ` +
126
+ 'you want the CLI to use, then run login again.');
127
+ }
128
+ console.warn(`Warning: could not verify the credential against ${apiUrl} ` +
129
+ `(${e instanceof Error ? e.message : String(e)}). Saving it anyway.`);
130
+ }
131
+ }
115
132
  export function registerLogin(program) {
116
133
  const isRunCloud = program.name() === 'runcloud';
117
134
  const defaultAppUrl = isRunCloud
@@ -136,9 +153,6 @@ export function registerLogin(program) {
136
153
  throw new Error('--api-url requires --token for headless login.');
137
154
  }
138
155
  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
156
  const apiUrl = isRunCloud
143
157
  ? opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? 'https://api.newly.app'
144
158
  : opts.apiUrl;
@@ -170,16 +184,13 @@ export function registerLogin(program) {
170
184
  const blob = await rl.question('Paste the credential from the browser: ');
171
185
  rl.close();
172
186
  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
- }
187
+ if (isRunCloud)
188
+ await verifyRunCloudCredential(parsed.apiUrl, parsed.token);
176
189
  saveCredentials({
177
190
  token: parsed.token,
178
191
  apiUrl: parsed.apiUrl,
179
192
  appUrl: opts.appUrl,
180
- expiresAt: isRunCloud
181
- ? Number.MAX_SAFE_INTEGER
182
- : Math.floor(Date.now() / 1000) + parsed.expiresIn,
193
+ expiresAt: Math.floor(Date.now() / 1000) + parsed.expiresIn,
183
194
  });
184
195
  console.log('Logged in.');
185
196
  return;
@@ -193,16 +204,13 @@ export function registerLogin(program) {
193
204
  openBrowser(loginUrl);
194
205
  try {
195
206
  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
- }
207
+ if (isRunCloud)
208
+ await verifyRunCloudCredential(payload.apiUrl, payload.token);
199
209
  saveCredentials({
200
210
  token: payload.token,
201
211
  apiUrl: payload.apiUrl,
202
212
  appUrl: opts.appUrl,
203
- expiresAt: isRunCloud
204
- ? Number.MAX_SAFE_INTEGER
205
- : Math.floor(Date.now() / 1000) + payload.expiresIn,
213
+ expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
206
214
  });
207
215
  console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
208
216
  }