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 +135 -0
- package/dist/api.js +26 -13
- package/dist/commands/boxAccess.js +246 -0
- package/dist/commands/login.js +70 -12
- package/dist/commands/run-cloud.js +83 -13
- package/dist/commands/sandbox.js +270 -53
- package/dist/config.js +2 -2
- package/examples/eight-device-mosaic/README.md +1 -1
- package/examples/eight-device-mosaic/demo.mjs +1 -1
- package/examples/eight-device-mosaic/verify.mjs +2 -1
- package/examples/live-camera-relay/App.tsx +2 -2
- package/examples/live-camera-relay/README.md +1 -1
- package/examples/live-camera-relay/demo.mjs +1 -1
- package/examples/live-camera-relay/verify.mjs +2 -0
- package/package.json +7 -2
- package/examples/parallel-simulators/README.md +0 -33
- package/examples/parallel-simulators/demo.mjs +0 -185
- package/examples/parallel-simulators/package.json +0 -13
- package/examples/parallel-simulators/verify.mjs +0 -78
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/commands/login.js
CHANGED
|
@@ -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 {
|
|
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`
|
|
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
|
|
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(
|
|
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(
|
|
160
|
+
new URL(apiUrl);
|
|
114
161
|
}
|
|
115
162
|
catch {
|
|
116
|
-
throw new Error(`--api-url is not a valid URL: ${
|
|
163
|
+
throw new Error(`--api-url is not a valid URL: ${apiUrl}`);
|
|
117
164
|
}
|
|
118
165
|
saveCredentials({
|
|
119
166
|
token: opts.token,
|
|
120
|
-
apiUrl
|
|
167
|
+
apiUrl,
|
|
121
168
|
appUrl: opts.appUrl,
|
|
122
|
-
expiresAt:
|
|
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 ${
|
|
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 =
|
|
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,
|