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.
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,15 +1,18 @@
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) {
11
13
  try {
12
- const detail = JSON.parse(body).detail;
14
+ const parsed = JSON.parse(body);
15
+ const detail = parsed.detail ?? parsed.error ?? parsed.message;
13
16
  if (typeof detail === 'string')
14
17
  return detail;
15
18
  if (detail !== undefined)
@@ -37,7 +40,7 @@ export class ApiClient {
37
40
  });
38
41
  if (!res.ok) {
39
42
  const detail = parsedErrorDetail(await res.text());
40
- throw new ApiError(res.status, detail);
43
+ throw new ApiError(res.status, detail, path);
41
44
  }
42
45
  const contentType = res.headers.get('content-type') ?? '';
43
46
  return contentType.includes('application/json') ? res.json() : res;
@@ -60,7 +63,7 @@ export class ApiClient {
60
63
  });
61
64
  if (!res.ok) {
62
65
  const detail = parsedErrorDetail(await res.text());
63
- throw new ApiError(res.status, detail);
66
+ throw new ApiError(res.status, detail, path);
64
67
  }
65
68
  const contentType = res.headers.get('content-type') ?? '';
66
69
  return contentType.includes('application/json') ? res.json() : res;
@@ -82,7 +85,7 @@ export class ApiClient {
82
85
  });
83
86
  if (!res.ok) {
84
87
  const detail = parsedErrorDetail(await res.text());
85
- throw new ApiError(res.status, detail);
88
+ throw new ApiError(res.status, detail, path);
86
89
  }
87
90
  const contentType_ = res.headers.get('content-type') ?? '';
88
91
  return contentType_.includes('application/json') ? res.json() : res;
@@ -97,7 +100,7 @@ export class ApiClient {
97
100
  });
98
101
  if (!res.ok) {
99
102
  const detail = parsedErrorDetail(await res.text());
100
- throw new ApiError(res.status, detail);
103
+ throw new ApiError(res.status, detail, path);
101
104
  }
102
105
  const contentType = res.headers.get('content-type') ?? '';
103
106
  return contentType.includes('application/json') ? res.json() : res;
@@ -109,7 +112,7 @@ export class ApiClient {
109
112
  });
110
113
  if (!res.ok) {
111
114
  const detail = await res.text().catch(() => '');
112
- throw new ApiError(res.status, detail || res.statusText);
115
+ throw new ApiError(res.status, detail || res.statusText, path);
113
116
  }
114
117
  return Buffer.from(await res.arrayBuffer());
115
118
  }
@@ -120,7 +123,7 @@ export class ApiClient {
120
123
  });
121
124
  if (!res.ok) {
122
125
  const detail = parsedErrorDetail(await res.text().catch(() => ''));
123
- throw new ApiError(res.status, detail || res.statusText);
126
+ throw new ApiError(res.status, detail || res.statusText, path);
124
127
  }
125
128
  const decoder = new TextDecoder();
126
129
  for await (const chunk of res.body) {
@@ -161,12 +164,21 @@ function detailSuffix(err) {
161
164
  }
162
165
  function headline(err) {
163
166
  if (err instanceof ApiError) {
164
- if (err.status === 401)
165
- return 'Session expired or invalid — run `newly login`.';
167
+ if (err.status === 401) {
168
+ const command = (process.argv[1] ?? '').includes('run-cloud') ? 'runcloud login' : 'newly login';
169
+ return `Session expired or invalid — run \`${command}\`.`;
170
+ }
166
171
  if (err.status === 403)
167
172
  return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
168
- if (err.status === 404)
169
- 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
+ }
170
182
  if (err.status === 409)
171
183
  return err.detail;
172
184
  if (err.status === 503)
@@ -176,5 +188,6 @@ function headline(err) {
176
188
  return networkError(err) ?? (err instanceof Error ? err.message : String(err));
177
189
  }
178
190
  export function friendlyApiError(err) {
179
- return `${headline(err)}${detailSuffix(err)}`;
191
+ const message = headline(err);
192
+ return err instanceof ApiError ? message : `${message}${detailSuffix(err)}`;
180
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,11 +11,15 @@ 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',
17
21
  'Access-Control-Allow-Headers': 'content-type',
22
+ 'Access-Control-Allow-Private-Network': 'true',
18
23
  };
19
24
  const server = http.createServer((req, res) => {
20
25
  if (req.method === 'OPTIONS') {
@@ -37,6 +42,22 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
37
42
  res.end('state mismatch');
38
43
  return;
39
44
  }
45
+ if (typeof parsed.token !== 'string' ||
46
+ parsed.token.length === 0 ||
47
+ typeof parsed.expiresIn !== 'number' ||
48
+ !Number.isFinite(parsed.expiresIn) ||
49
+ parsed.expiresIn <= 0 ||
50
+ typeof parsed.apiUrl !== 'string') {
51
+ res.writeHead(400, corsHeaders);
52
+ res.end('invalid credential payload');
53
+ return;
54
+ }
55
+ const apiUrl = new URL(parsed.apiUrl);
56
+ if (!['http:', 'https:'].includes(apiUrl.protocol)) {
57
+ res.writeHead(400, corsHeaders);
58
+ res.end('invalid API URL');
59
+ return;
60
+ }
40
61
  res.writeHead(200, { ...corsHeaders, 'Content-Type': 'application/json' });
41
62
  res.end(JSON.stringify({ ok: true }));
42
63
  clearTimeout(timer);
@@ -74,6 +95,12 @@ function openBrowser(url) {
74
95
  });
75
96
  child.unref();
76
97
  }
98
+ export function cliAuthUrl(appUrl, port, state) {
99
+ const url = new URL('/cli-auth', appUrl);
100
+ url.searchParams.set('port', String(port));
101
+ url.searchParams.set('state', state);
102
+ return url.toString();
103
+ }
77
104
  export function parseManualCredential(blob) {
78
105
  let parsed;
79
106
  try {
@@ -89,13 +116,27 @@ export function parseManualCredential(blob) {
89
116
  }
90
117
  return { token: c.token, expiresIn: c.expiresIn, apiUrl: c.apiUrl };
91
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
+ }
92
132
  export function registerLogin(program) {
93
- const defaultAppUrl = program.name() === 'runcloud'
133
+ const isRunCloud = program.name() === 'runcloud';
134
+ const defaultAppUrl = isRunCloud
94
135
  ? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
95
136
  : process.env.NEWLY_APP_URL ?? 'https://newly.app';
96
137
  program
97
138
  .command('login')
98
- .description('Log in with your Newly account via the browser')
139
+ .description(`Log in with your ${isRunCloud ? 'run.cloud' : 'Newly'} account via the browser`)
99
140
  .option('--app-url <url>', 'product web app URL', defaultAppUrl)
100
141
  .option('--manual', 'paste the credential from the browser instead of using the local callback', false)
101
142
  .option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
@@ -105,31 +146,46 @@ export function registerLogin(program) {
105
146
  try {
106
147
  const hasApiUrl = typeof opts.apiUrl === 'string' && opts.apiUrl.length > 0;
107
148
  const hasToken = typeof opts.token === 'string' && opts.token.length > 0;
108
- if (hasApiUrl !== hasToken) {
149
+ if (!isRunCloud && hasApiUrl !== hasToken) {
109
150
  throw new Error('--api-url and --token must be provided together for headless login.');
110
151
  }
111
- if (hasApiUrl && hasToken) {
152
+ if (isRunCloud && hasApiUrl && !hasToken) {
153
+ throw new Error('--api-url requires --token for headless login.');
154
+ }
155
+ if (hasToken) {
156
+ const apiUrl = isRunCloud
157
+ ? opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? 'https://api.newly.app'
158
+ : opts.apiUrl;
112
159
  try {
113
- new URL(opts.apiUrl);
160
+ new URL(apiUrl);
114
161
  }
115
162
  catch {
116
- throw new Error(`--api-url is not a valid URL: ${opts.apiUrl}`);
163
+ throw new Error(`--api-url is not a valid URL: ${apiUrl}`);
117
164
  }
118
165
  saveCredentials({
119
166
  token: opts.token,
120
- apiUrl: opts.apiUrl,
167
+ apiUrl,
121
168
  appUrl: opts.appUrl,
122
- expiresAt: Math.floor(Date.now() / 1000) + opts.expiresIn,
169
+ expiresAt: isRunCloud
170
+ ? Number.MAX_SAFE_INTEGER
171
+ : Math.floor(Date.now() / 1000) + opts.expiresIn,
123
172
  });
124
- console.log(`Logged in headlessly. Credentials written for API ${opts.apiUrl}.`);
173
+ console.log(`Logged in headlessly. Credentials written for API ${apiUrl}.`);
125
174
  return;
126
175
  }
127
176
  if (opts.manual) {
177
+ if (isRunCloud) {
178
+ const manualUrl = `${opts.appUrl.replace(/\/$/, '')}/cli-auth?manual=1`;
179
+ console.log(`Opening your browser to authorize the CLI…\n ${manualUrl}\n`);
180
+ openBrowser(manualUrl);
181
+ }
128
182
  const { createInterface } = await import('node:readline/promises');
129
183
  const rl = createInterface({ input: process.stdin, output: process.stdout });
130
184
  const blob = await rl.question('Paste the credential from the browser: ');
131
185
  rl.close();
132
186
  const parsed = parseManualCredential(blob);
187
+ if (isRunCloud)
188
+ await verifyRunCloudCredential(parsed.apiUrl, parsed.token);
133
189
  saveCredentials({
134
190
  token: parsed.token,
135
191
  apiUrl: parsed.apiUrl,
@@ -142,12 +198,14 @@ export function registerLogin(program) {
142
198
  const state = crypto.randomBytes(16).toString('hex');
143
199
  const appOrigin = new URL(opts.appUrl).origin;
144
200
  const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
145
- const loginUrl = `${opts.appUrl}/cli-auth?port=${port}&state=${state}`;
201
+ const loginUrl = cliAuthUrl(opts.appUrl, port, state);
146
202
  console.log(`Opening your browser to authorize the CLI…\n ${loginUrl}\n`);
147
203
  console.log("If the browser doesn't open, paste the URL above into it manually.");
148
204
  openBrowser(loginUrl);
149
205
  try {
150
206
  const payload = await waitForToken();
207
+ if (isRunCloud)
208
+ await verifyRunCloudCredential(payload.apiUrl, payload.token);
151
209
  saveCredentials({
152
210
  token: payload.token,
153
211
  apiUrl: payload.apiUrl,