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.
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,13 @@
1
+ import { loginCommandName } from './config.js';
1
2
  export class ApiError extends Error {
2
3
  status;
3
4
  detail;
4
- constructor(status, detail) {
5
+ path;
6
+ constructor(status, detail, path) {
5
7
  super(`API ${status}: ${detail}`);
6
8
  this.status = status;
7
9
  this.detail = detail;
10
+ this.path = path;
8
11
  }
9
12
  }
10
13
  function parsedErrorDetail(body) {
@@ -38,7 +41,7 @@ export class ApiClient {
38
41
  });
39
42
  if (!res.ok) {
40
43
  const detail = parsedErrorDetail(await res.text());
41
- throw new ApiError(res.status, detail);
44
+ throw new ApiError(res.status, detail, path);
42
45
  }
43
46
  const contentType = res.headers.get('content-type') ?? '';
44
47
  return contentType.includes('application/json') ? res.json() : res;
@@ -61,7 +64,7 @@ export class ApiClient {
61
64
  });
62
65
  if (!res.ok) {
63
66
  const detail = parsedErrorDetail(await res.text());
64
- throw new ApiError(res.status, detail);
67
+ throw new ApiError(res.status, detail, path);
65
68
  }
66
69
  const contentType = res.headers.get('content-type') ?? '';
67
70
  return contentType.includes('application/json') ? res.json() : res;
@@ -83,7 +86,7 @@ export class ApiClient {
83
86
  });
84
87
  if (!res.ok) {
85
88
  const detail = parsedErrorDetail(await res.text());
86
- throw new ApiError(res.status, detail);
89
+ throw new ApiError(res.status, detail, path);
87
90
  }
88
91
  const contentType_ = res.headers.get('content-type') ?? '';
89
92
  return contentType_.includes('application/json') ? res.json() : res;
@@ -98,7 +101,7 @@ export class ApiClient {
98
101
  });
99
102
  if (!res.ok) {
100
103
  const detail = parsedErrorDetail(await res.text());
101
- throw new ApiError(res.status, detail);
104
+ throw new ApiError(res.status, detail, path);
102
105
  }
103
106
  const contentType = res.headers.get('content-type') ?? '';
104
107
  return contentType.includes('application/json') ? res.json() : res;
@@ -110,7 +113,7 @@ export class ApiClient {
110
113
  });
111
114
  if (!res.ok) {
112
115
  const detail = await res.text().catch(() => '');
113
- throw new ApiError(res.status, detail || res.statusText);
116
+ throw new ApiError(res.status, detail || res.statusText, path);
114
117
  }
115
118
  return Buffer.from(await res.arrayBuffer());
116
119
  }
@@ -121,7 +124,7 @@ export class ApiClient {
121
124
  });
122
125
  if (!res.ok) {
123
126
  const detail = parsedErrorDetail(await res.text().catch(() => ''));
124
- throw new ApiError(res.status, detail || res.statusText);
127
+ throw new ApiError(res.status, detail || res.statusText, path);
125
128
  }
126
129
  const decoder = new TextDecoder();
127
130
  for await (const chunk of res.body) {
@@ -163,13 +166,20 @@ function detailSuffix(err) {
163
166
  function headline(err) {
164
167
  if (err instanceof ApiError) {
165
168
  if (err.status === 401) {
166
- const command = (process.argv[1] ?? '').includes('run-cloud') ? 'runcloud login' : 'newly login';
169
+ const command = loginCommandName();
167
170
  return `Session expired or invalid — run \`${command}\`.`;
168
171
  }
169
172
  if (err.status === 403)
170
173
  return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
171
- if (err.status === 404)
172
- return `Not found: ${err.detail}`;
174
+ if (err.status === 404) {
175
+ const sandboxMatch = err.path?.match(/^\/run-cloud\/sandboxes\/([^/]+)/);
176
+ if (sandboxMatch) {
177
+ return `Sandbox ID not found: ${decodeURIComponent(sandboxMatch[1])}.`;
178
+ }
179
+ if (!err.detail || /^not found$/i.test(err.detail.trim()))
180
+ return 'Resource not found.';
181
+ return err.detail;
182
+ }
173
183
  if (err.status === 409)
174
184
  return err.detail;
175
185
  if (err.status === 503)
@@ -179,5 +189,6 @@ function headline(err) {
179
189
  return networkError(err) ?? (err instanceof Error ? err.message : String(err));
180
190
  }
181
191
  export function friendlyApiError(err) {
182
- return `${headline(err)}${detailSuffix(err)}`;
192
+ const message = headline(err);
193
+ return err instanceof ApiError ? message : `${message}${detailSuffix(err)}`;
183
194
  }
@@ -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,6 +1,6 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { ApiClient, friendlyApiError } from '../api.js';
3
- import { runEnvironmentDatabaseShell } from '../environmentShell.js';
3
+ import { runEnvironmentDatabaseShell } from '../shellSession.js';
4
4
  import { clearEnvironmentSelection, readEnvironmentSelection, requireCredentials, requireEnvironmentSelection, resolveProject, writeEnvironmentSelection, } from '../config.js';
5
5
  const POLL_INTERVAL_MS = 2_000;
6
6
  const POLL_TIMEOUT_MS = 10 * 60 * 1_000;