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 +135 -0
- package/dist/api.js +20 -10
- package/dist/commands/boxAccess.js +246 -0
- package/dist/commands/login.js +25 -17
- package/dist/commands/run-cloud.js +83 -13
- package/dist/commands/sandbox.js +250 -4
- package/dist/config.js +0 -4
- 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,10 +1,12 @@
|
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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,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`
|
|
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
|
|
174
|
-
|
|
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:
|
|
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
|
|
197
|
-
|
|
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:
|
|
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
|
}
|