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 +135 -0
- package/dist/api.js +22 -11
- package/dist/commands/boxAccess.js +246 -0
- package/dist/commands/environment.js +1 -1
- package/dist/commands/login.js +149 -21
- package/dist/commands/run-cloud.js +85 -15
- package/dist/commands/sandbox.js +263 -4
- package/dist/config.js +6 -14
- package/dist/prompt.js +150 -0
- package/dist/run-cloud.js +2 -3
- package/dist/runCloudAuth.js +79 -0
- package/dist/{environmentShell.js → shellSession.js} +17 -3
- package/dist/version.js +1 -0
- 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,13 @@
|
|
|
1
|
+
import { loginCommandName } from './config.js';
|
|
1
2
|
export class ApiError extends Error {
|
|
2
3
|
status;
|
|
3
4
|
detail;
|
|
4
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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 '../
|
|
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;
|