runcloud 0.1.9 → 0.1.11
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 +31 -3
- package/dist/commands/boxAccess.js +127 -89
- package/dist/commands/login.js +50 -13
- package/dist/commands/sandbox.js +187 -68
- package/dist/deviceLogin.js +48 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,12 +16,15 @@ Installs the `runcloud` binary. Requires **Node ≥ 20**.
|
|
|
16
16
|
## Log in
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
runcloud login
|
|
20
|
-
runcloud
|
|
21
|
-
runcloud logout # delete the stored credential
|
|
19
|
+
runcloud login # sign in with email, Google, or GitHub; stores a token in ~/.run-cloud/credentials
|
|
20
|
+
runcloud logout # delete the stored credential
|
|
22
21
|
runcloud account # show your entitlement and metered balance
|
|
23
22
|
```
|
|
24
23
|
|
|
24
|
+
Email signs you in without leaving the terminal. Google and GitHub open a browser and
|
|
25
|
+
show a short code to confirm, so login works over SSH and on headless machines too —
|
|
26
|
+
pick with `--email`, `--google`, or `--github` to skip the prompt.
|
|
27
|
+
|
|
25
28
|
The token expires — re-run `runcloud login` when a command reports you're logged out.
|
|
26
29
|
|
|
27
30
|
## Remote simulators
|
|
@@ -83,6 +86,31 @@ runcloud sandbox key "$SANDBOX_ID" "ctrl+l"
|
|
|
83
86
|
Treat a signed desktop URL as a secret. It grants temporary access to that
|
|
84
87
|
desktop without asking the browser to send your CLI credential.
|
|
85
88
|
|
|
89
|
+
### Persistence and a public hostname
|
|
90
|
+
|
|
91
|
+
A sandbox pauses when idle by default. Turn persistence on with `--persistent`,
|
|
92
|
+
and publish a guest port at a stable `<name>-box.run.cloud` hostname with
|
|
93
|
+
`--expose`:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
runcloud sandbox create --name my-project --persistent # never pauses
|
|
97
|
+
runcloud sandbox create --name my-project --expose 3000 # persistent + https://my-project-box.run.cloud
|
|
98
|
+
runcloud sandbox expose <id> --port 8080 # expose later, or move the published port
|
|
99
|
+
runcloud sandbox get <id> # inspect, including the hostname
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Exposed sandboxes support SSH, file transfer, VS Code, and custom domains:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
runcloud sandbox ssh my-project # shell over authenticated SSH (no public port 22)
|
|
106
|
+
runcloud sandbox cp my-project ./f :/workspace/f # scp; prefix the remote path with :
|
|
107
|
+
runcloud sandbox code my-project # VS Code Remote SSH
|
|
108
|
+
runcloud sandbox domain add my-project app.example.com
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`runcloud box …` from earlier releases keeps working as a deprecated alias for
|
|
112
|
+
these commands.
|
|
113
|
+
|
|
86
114
|
### Snapshot warm-start
|
|
87
115
|
|
|
88
116
|
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:
|
|
@@ -5,6 +5,9 @@ import { join } from 'node:path';
|
|
|
5
5
|
import WebSocket, { createWebSocketStream } from 'ws';
|
|
6
6
|
import { ApiClient, friendlyApiError } from '../api.js';
|
|
7
7
|
import { requireCredentials } from '../config.js';
|
|
8
|
+
export function noteDeprecated(oldCommand, replacement) {
|
|
9
|
+
process.stderr.write(`runcloud: '${oldCommand}' is deprecated — use '${replacement}'\n`);
|
|
10
|
+
}
|
|
8
11
|
function api() {
|
|
9
12
|
const credentials = requireCredentials();
|
|
10
13
|
return new ApiClient(credentials.apiUrl, credentials.token);
|
|
@@ -13,10 +16,12 @@ async function resolveBox(client, reference) {
|
|
|
13
16
|
const response = await client.get('/run-cloud/boxes');
|
|
14
17
|
const boxes = (response?.items ?? []);
|
|
15
18
|
const box = boxes.find((item) => item.id === reference ||
|
|
19
|
+
item.sandboxId === reference ||
|
|
16
20
|
item.name === reference ||
|
|
17
21
|
item.hostname === reference);
|
|
18
|
-
if (!box)
|
|
19
|
-
throw new Error(`
|
|
22
|
+
if (!box) {
|
|
23
|
+
throw new Error(`No exposed sandbox matches ${reference} — publish a hostname first: runcloud sandbox expose <id>`);
|
|
24
|
+
}
|
|
20
25
|
return box;
|
|
21
26
|
}
|
|
22
27
|
function sshHome() {
|
|
@@ -75,7 +80,7 @@ function writeSshConfig(box, privateKey) {
|
|
|
75
80
|
' IdentitiesOnly yes',
|
|
76
81
|
' StrictHostKeyChecking accept-new',
|
|
77
82
|
` UserKnownHostsFile "${join(sshHome(), 'known_hosts')}"`,
|
|
78
|
-
` ProxyCommand runcloud
|
|
83
|
+
` ProxyCommand runcloud sandbox proxy ${box.id}`,
|
|
79
84
|
end,
|
|
80
85
|
].join('\n');
|
|
81
86
|
const pattern = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n?`, 'g');
|
|
@@ -114,19 +119,27 @@ function openUrl(url) {
|
|
|
114
119
|
return exec('cmd', ['/c', 'start', '', url]);
|
|
115
120
|
return exec('xdg-open', [url]);
|
|
116
121
|
}
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
const REFERENCE_HELP = 'sandbox id, box id, name, or hostname';
|
|
123
|
+
export function registerAccessCommands(parent, opts = {}) {
|
|
124
|
+
const note = (verb) => {
|
|
125
|
+
if (opts.deprecated)
|
|
126
|
+
noteDeprecated(`box ${verb}`, `sandbox ${verb}`);
|
|
127
|
+
};
|
|
128
|
+
parent
|
|
119
129
|
.command('setup-ssh')
|
|
120
|
-
.description('Install a dedicated key and write this
|
|
121
|
-
.argument('<
|
|
122
|
-
.action((reference) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
.description('Install a dedicated key and write this host to ~/.ssh/config')
|
|
131
|
+
.argument('<sandbox>', REFERENCE_HELP)
|
|
132
|
+
.action((reference) => {
|
|
133
|
+
note('setup-ssh');
|
|
134
|
+
return action(async () => {
|
|
135
|
+
const configured = await setupSsh(reference);
|
|
136
|
+
console.log(`SSH configured: ssh ${configured.alias}`);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
parent
|
|
127
140
|
.command('proxy', { hidden: true })
|
|
128
141
|
.description('Authenticated WebSocket transport used by SSH ProxyCommand')
|
|
129
|
-
.argument('<
|
|
142
|
+
.argument('<sandbox>', REFERENCE_HELP)
|
|
130
143
|
.action((reference) => action(async () => {
|
|
131
144
|
const client = api();
|
|
132
145
|
const resolved = await resolveBox(client, reference);
|
|
@@ -145,102 +158,127 @@ export function registerBoxAccessCommands(box) {
|
|
|
145
158
|
socket.once('error', reject);
|
|
146
159
|
});
|
|
147
160
|
}));
|
|
148
|
-
|
|
161
|
+
parent
|
|
149
162
|
.command('ssh')
|
|
150
|
-
.description('Open a shell in
|
|
151
|
-
.argument('<
|
|
163
|
+
.description('Open a shell in an exposed sandbox')
|
|
164
|
+
.argument('<sandbox>', REFERENCE_HELP)
|
|
152
165
|
.argument('[command...]', 'optional remote command')
|
|
153
|
-
.action((reference, command) =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
.action((reference, command) => {
|
|
167
|
+
note('ssh');
|
|
168
|
+
return action(async () => {
|
|
169
|
+
const configured = await setupSsh(reference);
|
|
170
|
+
const code = await exec('ssh', [configured.alias, ...command]);
|
|
171
|
+
if (code !== 0)
|
|
172
|
+
process.exitCode = code;
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
parent
|
|
160
176
|
.command('cp')
|
|
161
177
|
.description('Copy files with scp; prefix the remote path with a colon')
|
|
162
|
-
.argument('<
|
|
178
|
+
.argument('<sandbox>', REFERENCE_HELP)
|
|
163
179
|
.argument('<source>', 'local path or :/remote/path')
|
|
164
180
|
.argument('<destination>', 'local path or :/remote/path')
|
|
165
181
|
.option('-r, --recursive', 'copy directories recursively', false)
|
|
166
|
-
.action((reference, source, destination,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
.action((reference, source, destination, cmdOpts) => {
|
|
183
|
+
note('cp');
|
|
184
|
+
return action(async () => {
|
|
185
|
+
if (source.startsWith(':') === destination.startsWith(':')) {
|
|
186
|
+
throw new Error('Exactly one path must be remote (prefix it with :)');
|
|
187
|
+
}
|
|
188
|
+
const configured = await setupSsh(reference);
|
|
189
|
+
const remote = (path) => path.startsWith(':') ? `${configured.alias}:${path.slice(1)}` : path;
|
|
190
|
+
const args = [
|
|
191
|
+
...(cmdOpts.recursive ? ['-r'] : []),
|
|
192
|
+
'--',
|
|
193
|
+
remote(source),
|
|
194
|
+
remote(destination),
|
|
195
|
+
];
|
|
196
|
+
const code = await exec('scp', args);
|
|
197
|
+
if (code !== 0)
|
|
198
|
+
process.exitCode = code;
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
parent
|
|
183
202
|
.command('code')
|
|
184
|
-
.description('Open
|
|
185
|
-
.argument('<
|
|
203
|
+
.description('Open an exposed sandbox in VS Code Remote SSH')
|
|
204
|
+
.argument('<sandbox>', REFERENCE_HELP)
|
|
186
205
|
.argument('[path]', 'remote folder', '/workspace')
|
|
187
206
|
.option('--no-open', 'print the VS Code URL without opening it')
|
|
188
|
-
.action((reference, path,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
remotePath.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
.action((reference, path, cmdOpts) => {
|
|
208
|
+
note('code');
|
|
209
|
+
return action(async () => {
|
|
210
|
+
const configured = await setupSsh(reference);
|
|
211
|
+
const remotePath = path.startsWith('/') ? path : `/${path}`;
|
|
212
|
+
const url = `vscode://vscode-remote/ssh-remote+${encodeURIComponent(configured.alias)}` +
|
|
213
|
+
remotePath.split('/').map(encodeURIComponent).join('/');
|
|
214
|
+
if (cmdOpts.open === false)
|
|
215
|
+
console.log(url);
|
|
216
|
+
else
|
|
217
|
+
await openUrl(url);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
const domain = parent.command('domain').description('Manage custom domains');
|
|
221
|
+
const domainNote = (verb) => {
|
|
222
|
+
if (opts.deprecated)
|
|
223
|
+
noteDeprecated(`box domain ${verb}`, `sandbox domain ${verb}`);
|
|
224
|
+
};
|
|
199
225
|
domain
|
|
200
226
|
.command('add')
|
|
201
|
-
.argument('<
|
|
227
|
+
.argument('<sandbox>', REFERENCE_HELP)
|
|
202
228
|
.argument('<hostname>', 'custom hostname')
|
|
203
229
|
.option('--json', 'output JSON', false)
|
|
204
|
-
.action((reference, hostname,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
230
|
+
.action((reference, hostname, cmdOpts) => {
|
|
231
|
+
domainNote('add');
|
|
232
|
+
return action(async () => {
|
|
233
|
+
const client = api();
|
|
234
|
+
const resolved = await resolveBox(client, reference);
|
|
235
|
+
output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`, {
|
|
236
|
+
hostname,
|
|
237
|
+
}), cmdOpts);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
211
240
|
domain
|
|
212
241
|
.command('list')
|
|
213
|
-
.argument('<
|
|
242
|
+
.argument('<sandbox>', REFERENCE_HELP)
|
|
214
243
|
.option('--json', 'output JSON', false)
|
|
215
|
-
.action((reference,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
244
|
+
.action((reference, cmdOpts) => {
|
|
245
|
+
domainNote('list');
|
|
246
|
+
return action(async () => {
|
|
247
|
+
const client = api();
|
|
248
|
+
const resolved = await resolveBox(client, reference);
|
|
249
|
+
const response = await client.get(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`);
|
|
250
|
+
const items = response?.items ?? [];
|
|
251
|
+
if (cmdOpts.json)
|
|
252
|
+
console.log(JSON.stringify(items, null, 2));
|
|
253
|
+
else
|
|
254
|
+
for (const item of items)
|
|
255
|
+
output(item, cmdOpts);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
226
258
|
domain
|
|
227
259
|
.command('status')
|
|
228
|
-
.argument('<
|
|
229
|
-
.argument('<domain-id>', 'domain id from `
|
|
260
|
+
.argument('<sandbox>', REFERENCE_HELP)
|
|
261
|
+
.argument('<domain-id>', 'domain id from `domain list`')
|
|
230
262
|
.option('--json', 'output JSON', false)
|
|
231
|
-
.action((reference, domainId,
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
263
|
+
.action((reference, domainId, cmdOpts) => {
|
|
264
|
+
domainNote('status');
|
|
265
|
+
return action(async () => {
|
|
266
|
+
const client = api();
|
|
267
|
+
const resolved = await resolveBox(client, reference);
|
|
268
|
+
output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}/refresh`), cmdOpts);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
236
271
|
domain
|
|
237
272
|
.command('rm')
|
|
238
|
-
.argument('<
|
|
239
|
-
.argument('<domain-id>', 'domain id from `
|
|
240
|
-
.action((reference, domainId) =>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
273
|
+
.argument('<sandbox>', REFERENCE_HELP)
|
|
274
|
+
.argument('<domain-id>', 'domain id from `domain list`')
|
|
275
|
+
.action((reference, domainId) => {
|
|
276
|
+
domainNote('rm');
|
|
277
|
+
return action(async () => {
|
|
278
|
+
const client = api();
|
|
279
|
+
const resolved = await resolveBox(client, reference);
|
|
280
|
+
await client.delete(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}`);
|
|
281
|
+
console.log(`removed custom domain ${domainId}`);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
246
284
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -3,7 +3,7 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { ApiClient, ApiError } from '../api.js';
|
|
6
|
-
import { clearCredentials, credentialsDisplayPath, normalizeApiUrl, saveCredentials } from '../config.js';
|
|
6
|
+
import { clearCredentials, credentialsDisplayPath, loadCredentials, normalizeApiUrl, saveCredentials, } from '../config.js';
|
|
7
7
|
import { acquirePromptIO, ask, PromptAbortedError, select, } from '../prompt.js';
|
|
8
8
|
import { AuthError, createApiKey, fetchProviders, isValidEmail, isValidOtp, normalizeOtp, sendEmailCode, verifyEmailCode, } from '../runCloudAuth.js';
|
|
9
9
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -114,7 +114,7 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
|
|
|
114
114
|
},
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
|
-
function openBrowser(url) {
|
|
117
|
+
export function openBrowser(url) {
|
|
118
118
|
const [cmd, args] = process.platform === 'darwin'
|
|
119
119
|
? ['open', [url]]
|
|
120
120
|
: process.platform === 'win32'
|
|
@@ -201,6 +201,20 @@ export async function verifyRunCloudCredential(apiUrl, token) {
|
|
|
201
201
|
`(${e instanceof Error ? e.message : String(e)}). Saving it anyway.`);
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
|
+
export async function revokeRunCloudCredential(apiUrl, token) {
|
|
205
|
+
try {
|
|
206
|
+
await new ApiClient(normalizeApiUrl(apiUrl), token).delete('/run-cloud/api-keys/self');
|
|
207
|
+
return 'Credential revoked.';
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
|
|
211
|
+
return 'Credential was already invalid.';
|
|
212
|
+
}
|
|
213
|
+
return (`Warning: could not reach ${apiUrl} to revoke this credential ` +
|
|
214
|
+
`(${e instanceof Error ? e.message : String(e)}). It may still be valid — ` +
|
|
215
|
+
'revoke it from the run.cloud dashboard.');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
204
218
|
export function registerLogin(program) {
|
|
205
219
|
const isRunCloud = program.name() === 'runcloud';
|
|
206
220
|
const defaultAppUrl = isRunCloud
|
|
@@ -212,7 +226,7 @@ export function registerLogin(program) {
|
|
|
212
226
|
? 'Log in with your run.cloud account: email, Google, or GitHub'
|
|
213
227
|
: 'Log in with your Newly account via the browser')
|
|
214
228
|
.option('--app-url <url>', 'product web app URL', defaultAppUrl)
|
|
215
|
-
.option('--manual', 'paste the credential from the browser instead of using the local callback', false)
|
|
229
|
+
.option('--manual', 'newly skin only: paste the credential from the browser instead of using the local callback', false)
|
|
216
230
|
.option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
|
|
217
231
|
.option('--token <token>', 'explicit credential token (skips browser; use with --api-url)')
|
|
218
232
|
.option('--expires-in <seconds>', 'session lifetime when using --token (default 7 days)', (v) => Number(v), 604800)
|
|
@@ -251,19 +265,12 @@ export function registerLogin(program) {
|
|
|
251
265
|
console.log(`Logged in headlessly. Credentials written for API ${apiUrl}.`);
|
|
252
266
|
return;
|
|
253
267
|
}
|
|
254
|
-
if (opts.manual) {
|
|
255
|
-
if (isRunCloud) {
|
|
256
|
-
const manualUrl = `${opts.appUrl.replace(/\/$/, '')}/cli-auth?manual=1`;
|
|
257
|
-
console.log(`Opening your browser to authorize the CLI…\n ${manualUrl}\n`);
|
|
258
|
-
openBrowser(manualUrl);
|
|
259
|
-
}
|
|
268
|
+
if (opts.manual && !isRunCloud) {
|
|
260
269
|
const { createInterface } = await import('node:readline/promises');
|
|
261
270
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
262
271
|
const blob = await rl.question('Paste the credential from the browser: ');
|
|
263
272
|
rl.close();
|
|
264
273
|
const parsed = parseManualCredential(blob);
|
|
265
|
-
if (isRunCloud)
|
|
266
|
-
await verifyRunCloudCredential(parsed.apiUrl, parsed.token);
|
|
267
274
|
saveCredentials({
|
|
268
275
|
token: parsed.token,
|
|
269
276
|
apiUrl: parsed.apiUrl,
|
|
@@ -295,6 +302,7 @@ export function registerLogin(program) {
|
|
|
295
302
|
apiUrl: authApiUrl,
|
|
296
303
|
appUrl: opts.appUrl,
|
|
297
304
|
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
305
|
+
revocable: true,
|
|
298
306
|
});
|
|
299
307
|
console.log(`\n Signed in as ${email}`);
|
|
300
308
|
console.log(` Credential saved to ${credentialsDisplayPath()}\n`);
|
|
@@ -310,6 +318,29 @@ export function registerLogin(program) {
|
|
|
310
318
|
if (method === 'google' || method === 'github')
|
|
311
319
|
socialProvider = method;
|
|
312
320
|
}
|
|
321
|
+
if (isRunCloud) {
|
|
322
|
+
if (opts.manual) {
|
|
323
|
+
console.log('Note: --manual is no longer needed for run.cloud — `runcloud login` works over SSH and headless via device flow.');
|
|
324
|
+
}
|
|
325
|
+
const apiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
|
|
326
|
+
const { runDeviceLogin } = await import('../deviceLogin.js');
|
|
327
|
+
const payload = await runDeviceLogin({
|
|
328
|
+
apiUrl,
|
|
329
|
+
appUrl: opts.appUrl,
|
|
330
|
+
provider: socialProvider,
|
|
331
|
+
openBrowser,
|
|
332
|
+
});
|
|
333
|
+
await verifyRunCloudCredential(payload.apiUrl, payload.token);
|
|
334
|
+
saveCredentials({
|
|
335
|
+
token: payload.token,
|
|
336
|
+
apiUrl: payload.apiUrl,
|
|
337
|
+
appUrl: opts.appUrl,
|
|
338
|
+
expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
|
|
339
|
+
revocable: true,
|
|
340
|
+
});
|
|
341
|
+
console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
313
344
|
const state = crypto.randomBytes(16).toString('hex');
|
|
314
345
|
const appOrigin = new URL(opts.appUrl).origin;
|
|
315
346
|
const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
|
|
@@ -345,8 +376,14 @@ export function registerLogin(program) {
|
|
|
345
376
|
});
|
|
346
377
|
program
|
|
347
378
|
.command('logout')
|
|
348
|
-
.description(
|
|
349
|
-
|
|
379
|
+
.description(isRunCloud
|
|
380
|
+
? 'Revoke this machine’s credential and delete it locally'
|
|
381
|
+
: 'Delete the stored CLI credential')
|
|
382
|
+
.action(async () => {
|
|
383
|
+
const creds = loadCredentials();
|
|
384
|
+
if (isRunCloud && creds?.revocable) {
|
|
385
|
+
console.log(await revokeRunCloudCredential(creds.apiUrl, creds.token));
|
|
386
|
+
}
|
|
350
387
|
clearCredentials();
|
|
351
388
|
console.log('Logged out.');
|
|
352
389
|
});
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -2,10 +2,10 @@ import { InvalidArgumentError } from 'commander';
|
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { writeFileSync } from 'node:fs';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
|
-
import { ApiClient, friendlyApiError } from '../api.js';
|
|
5
|
+
import { ApiClient, ApiError, friendlyApiError } from '../api.js';
|
|
6
6
|
import { requireCredentials } from '../config.js';
|
|
7
7
|
import { runSandboxShell } from '../shellSession.js';
|
|
8
|
-
import {
|
|
8
|
+
import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
|
|
9
9
|
const MAX_SANDBOX_DISK_GIB = 2048;
|
|
10
10
|
const DEFAULT_BOX_CPU_CORES = 2;
|
|
11
11
|
const DEFAULT_BOX_MEMORY_MIB = 24 * 1024;
|
|
@@ -98,40 +98,126 @@ export function openBrowserUrl(url, platform = process.platform) {
|
|
|
98
98
|
}
|
|
99
99
|
execFileSync('xdg-open', [url], { stdio: 'ignore' });
|
|
100
100
|
}
|
|
101
|
+
async function findBoxForSandbox(api, sandboxId) {
|
|
102
|
+
const data = await api.get('/run-cloud/boxes');
|
|
103
|
+
return (data?.items ?? []).find((box) => box.sandboxId === sandboxId);
|
|
104
|
+
}
|
|
105
|
+
function attachHostname(api, sandboxId, name, port) {
|
|
106
|
+
return api.post('/run-cloud/boxes', { sandboxId, name, port });
|
|
107
|
+
}
|
|
101
108
|
export function registerSandbox(program) {
|
|
102
109
|
const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
|
|
103
110
|
withOutput(sandbox
|
|
104
111
|
.command('create')
|
|
105
112
|
.description('Spawn a microVM sandbox')
|
|
106
|
-
.option('--image <ref>', 'base image
|
|
113
|
+
.option('--image <ref>', 'base image (default runcloud/agent-base, or runcloud/box with --expose)')
|
|
107
114
|
.option('--region <region>', 'placement region')
|
|
108
|
-
.option('--name <name>', 'human-readable name')
|
|
115
|
+
.option('--name <name>', 'human-readable name; with --expose, the hostname prefix <name>-box.run.cloud')
|
|
109
116
|
.option('--org <id>', 'billing and ownership organization')
|
|
110
117
|
.option('--cpu <cores>', 'fractional vCPU allocation, e.g. 0.5', parseCpuCores)
|
|
111
118
|
.option('--memory <mb>', 'memory allocation in MiB', parseMemoryMb)
|
|
112
119
|
.option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)
|
|
113
|
-
.option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
|
|
114
|
-
|
|
120
|
+
.option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
|
|
121
|
+
.option('--persistent', 'never pause when idle (same as --idle-pause 0)', false)
|
|
122
|
+
.option('--expose <port>', 'publish a guest port at <name>-box.run.cloud; implies --persistent', parsePort)).action((opts) => run(async () => {
|
|
123
|
+
const exposed = opts.expose !== undefined;
|
|
124
|
+
if (exposed && !opts.name) {
|
|
125
|
+
throw new Error('--expose needs --name <name>, the hostname prefix for <name>-box.run.cloud');
|
|
126
|
+
}
|
|
127
|
+
if ((exposed || opts.persistent) && opts.idlePause !== undefined && opts.idlePause !== 0) {
|
|
128
|
+
throw new Error('--persistent and --expose keep the sandbox running; drop the non-zero --idle-pause');
|
|
129
|
+
}
|
|
130
|
+
const body = {
|
|
131
|
+
image: opts.image ?? (exposed ? 'runcloud/box' : 'runcloud/agent-base'),
|
|
132
|
+
};
|
|
115
133
|
if (opts.name)
|
|
116
134
|
body.name = opts.name;
|
|
117
135
|
if (opts.region)
|
|
118
136
|
body.region = opts.region;
|
|
119
137
|
if (opts.org)
|
|
120
138
|
body.orgId = opts.org;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
body.
|
|
139
|
+
const cpu = opts.cpu ?? (exposed ? DEFAULT_BOX_CPU_CORES : undefined);
|
|
140
|
+
const memory = opts.memory ?? (exposed ? DEFAULT_BOX_MEMORY_MIB : undefined);
|
|
141
|
+
if (cpu !== undefined)
|
|
142
|
+
body.cpu = cpu;
|
|
143
|
+
if (memory !== undefined)
|
|
144
|
+
body.memory = memory;
|
|
125
145
|
if (opts.disk !== undefined)
|
|
126
146
|
body.disk = opts.disk;
|
|
127
|
-
if (opts.
|
|
147
|
+
if (exposed || opts.persistent)
|
|
148
|
+
body.idlePauseSeconds = 0;
|
|
149
|
+
else if (opts.idlePause !== undefined)
|
|
128
150
|
body.idlePauseSeconds = opts.idlePause;
|
|
129
|
-
|
|
151
|
+
const api = runCloudApi();
|
|
152
|
+
const created = await api.post('/run-cloud/sandboxes', body);
|
|
153
|
+
if (!exposed) {
|
|
154
|
+
print(created, opts);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (typeof created?.id !== 'string')
|
|
158
|
+
throw new Error('sandbox create response did not include an id');
|
|
159
|
+
const box = await attachHostname(api, created.id, opts.name, opts.expose);
|
|
160
|
+
print({ ...created, box }, opts);
|
|
130
161
|
}));
|
|
131
162
|
withOutput(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
|
|
132
163
|
const qs = opts.state ? `?state=${encodeURIComponent(opts.state)}` : '';
|
|
133
|
-
const
|
|
134
|
-
|
|
164
|
+
const api = runCloudApi();
|
|
165
|
+
const [data, boxes] = await Promise.all([
|
|
166
|
+
api.get(`/run-cloud/sandboxes${qs}`),
|
|
167
|
+
api.get('/run-cloud/boxes'),
|
|
168
|
+
]);
|
|
169
|
+
const bySandbox = new Map((boxes?.items ?? []).map((box) => [box.sandboxId, box]));
|
|
170
|
+
const items = (data?.items ?? []).map((item) => {
|
|
171
|
+
const box = bySandbox.get(item.id);
|
|
172
|
+
return box ? { ...item, hostname: box.hostname, exposedPort: box.port } : item;
|
|
173
|
+
});
|
|
174
|
+
print(items, opts);
|
|
175
|
+
}));
|
|
176
|
+
withOutput(sandbox
|
|
177
|
+
.command('get')
|
|
178
|
+
.description('Inspect a sandbox, including its public hostname when exposed')
|
|
179
|
+
.argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
|
|
180
|
+
const api = runCloudApi();
|
|
181
|
+
const [record, box] = await Promise.all([
|
|
182
|
+
api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`),
|
|
183
|
+
findBoxForSandbox(api, id),
|
|
184
|
+
]);
|
|
185
|
+
print(box ? { ...record, box } : record, opts);
|
|
186
|
+
}));
|
|
187
|
+
withOutput(sandbox
|
|
188
|
+
.command('expose')
|
|
189
|
+
.description('Publish a sandbox guest port at a stable hostname, or change the published port')
|
|
190
|
+
.argument('<id>', 'sandbox id')
|
|
191
|
+
.option('--port <port>', 'guest port to publish (default 3000)', parsePort)
|
|
192
|
+
.option('--name <name>', 'hostname prefix for a first exposure: <name>-box.run.cloud (defaults to the sandbox name)')
|
|
193
|
+
.option('--reconcile', 'retry or reconcile hostname routing', false)).action((id, opts) => run(async () => {
|
|
194
|
+
const api = runCloudApi();
|
|
195
|
+
const existing = await findBoxForSandbox(api, id);
|
|
196
|
+
if (!existing) {
|
|
197
|
+
if (opts.reconcile)
|
|
198
|
+
throw new Error(`sandbox ${id} has no hostname to reconcile`);
|
|
199
|
+
let name = opts.name;
|
|
200
|
+
if (!name) {
|
|
201
|
+
const record = await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
|
|
202
|
+
name = typeof record?.name === 'string' && record.name ? record.name : undefined;
|
|
203
|
+
}
|
|
204
|
+
if (!name)
|
|
205
|
+
throw new Error('this sandbox has no name; pass --name <name> to mint <name>-box.run.cloud');
|
|
206
|
+
print(await attachHostname(api, id, name, opts.port ?? 3000), opts);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (opts.name && opts.name !== existing.name) {
|
|
210
|
+
throw new Error(`hostname is fixed at ${existing.hostname}; run 'sandbox rm ${id}' and expose again to rename`);
|
|
211
|
+
}
|
|
212
|
+
if (opts.reconcile) {
|
|
213
|
+
print(await api.post(`/run-cloud/boxes/${encodeURIComponent(existing.id)}/route`), opts);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (opts.port !== undefined) {
|
|
217
|
+
print(await api.patch(`/run-cloud/boxes/${encodeURIComponent(existing.id)}`, { port: opts.port }), opts);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
print(existing, opts);
|
|
135
221
|
}));
|
|
136
222
|
withOutput(sandbox
|
|
137
223
|
.command('exec')
|
|
@@ -249,8 +335,22 @@ export function registerSandbox(program) {
|
|
|
249
335
|
const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'key', keys });
|
|
250
336
|
print(result ?? { ok: true }, opts);
|
|
251
337
|
}));
|
|
252
|
-
withOutput(sandbox
|
|
253
|
-
|
|
338
|
+
withOutput(sandbox
|
|
339
|
+
.command('rm')
|
|
340
|
+
.description('Destroy a sandbox, along with its public hostname when exposed')
|
|
341
|
+
.argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
|
|
342
|
+
const api = runCloudApi();
|
|
343
|
+
try {
|
|
344
|
+
await api.delete(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
if (!(err instanceof ApiError) || err.status !== 409)
|
|
348
|
+
throw err;
|
|
349
|
+
const box = await findBoxForSandbox(api, id);
|
|
350
|
+
if (!box)
|
|
351
|
+
throw err;
|
|
352
|
+
await api.delete(`/run-cloud/boxes/${encodeURIComponent(box.id)}`);
|
|
353
|
+
}
|
|
254
354
|
if (!opts.json)
|
|
255
355
|
console.log(`destroyed ${id}`);
|
|
256
356
|
}));
|
|
@@ -294,10 +394,13 @@ export function registerSandbox(program) {
|
|
|
294
394
|
body.region = opts.region;
|
|
295
395
|
print(await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body), opts);
|
|
296
396
|
}));
|
|
297
|
-
|
|
397
|
+
registerAccessCommands(sandbox);
|
|
398
|
+
const box = program
|
|
399
|
+
.command('box')
|
|
400
|
+
.description('Deprecated: a box is a sandbox with --persistent and --expose — use `runcloud sandbox`');
|
|
298
401
|
withOutput(box
|
|
299
402
|
.command('create')
|
|
300
|
-
.description('
|
|
403
|
+
.description('Deprecated: use `sandbox create --expose <port> --name <name>`')
|
|
301
404
|
.argument('<name>', 'hostname prefix: <name>-box.run.cloud')
|
|
302
405
|
.option('--sandbox <id>', 'attach an existing sandbox created with --idle-pause 0')
|
|
303
406
|
.option('--port <port>', 'guest port to publish', parsePort, 3000)
|
|
@@ -306,61 +409,77 @@ export function registerSandbox(program) {
|
|
|
306
409
|
.option('--org <id>', 'billing and ownership organization for a new sandbox')
|
|
307
410
|
.option('--cpu <cores>', 'vCPU allocation for a new box', parseCpuCores, DEFAULT_BOX_CPU_CORES)
|
|
308
411
|
.option('--memory <mb>', 'memory allocation in MiB for a new box', parseMemoryMb, DEFAULT_BOX_MEMORY_MIB)
|
|
309
|
-
.option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)).action((name, opts) =>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
412
|
+
.option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)).action((name, opts) => {
|
|
413
|
+
noteDeprecated('box create', opts.sandbox
|
|
414
|
+
? `sandbox expose ${opts.sandbox} --name ${name}`
|
|
415
|
+
: `sandbox create --expose ${opts.port} --name ${name}`);
|
|
416
|
+
return run(async () => {
|
|
417
|
+
const api = runCloudApi();
|
|
418
|
+
let sandboxId = opts.sandbox;
|
|
419
|
+
if (!sandboxId) {
|
|
420
|
+
const sandboxBody = {
|
|
421
|
+
image: opts.image,
|
|
422
|
+
name,
|
|
423
|
+
idlePauseSeconds: 0,
|
|
424
|
+
};
|
|
425
|
+
if (opts.region)
|
|
426
|
+
sandboxBody.region = opts.region;
|
|
427
|
+
if (opts.org)
|
|
428
|
+
sandboxBody.orgId = opts.org;
|
|
429
|
+
sandboxBody.cpu = opts.cpu;
|
|
430
|
+
sandboxBody.memory = opts.memory;
|
|
431
|
+
if (opts.disk !== undefined)
|
|
432
|
+
sandboxBody.disk = opts.disk;
|
|
433
|
+
const created = await api.post('/run-cloud/sandboxes', sandboxBody);
|
|
434
|
+
sandboxId = typeof created?.id === 'string' ? created.id : undefined;
|
|
435
|
+
if (!sandboxId)
|
|
436
|
+
throw new Error('sandbox create response did not include an id');
|
|
437
|
+
}
|
|
438
|
+
print(await attachHostname(api, sandboxId, name, opts.port), opts);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
withOutput(box.command('list').description('Deprecated: use `sandbox list`')).action((opts) => {
|
|
442
|
+
noteDeprecated('box list', 'sandbox list');
|
|
443
|
+
return run(async () => {
|
|
444
|
+
const data = await runCloudApi().get('/run-cloud/boxes');
|
|
445
|
+
print(data?.items ?? [], opts);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
withOutput(box.command('get').description('Deprecated: use `sandbox get <sandbox-id>`').argument('<id>', 'box id')).action((id, opts) => {
|
|
449
|
+
noteDeprecated('box get', 'sandbox get <sandbox-id>');
|
|
450
|
+
return run(async () => {
|
|
451
|
+
print(await runCloudApi().get(`/run-cloud/boxes/${encodeURIComponent(id)}`), opts);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
344
454
|
withOutput(box
|
|
345
455
|
.command('port')
|
|
346
|
-
.description('
|
|
456
|
+
.description('Deprecated: use `sandbox expose <sandbox-id> --port <port>`')
|
|
347
457
|
.argument('<id>', 'box id')
|
|
348
|
-
.argument('<port>', 'guest port', parsePort)).action((id, port, opts) =>
|
|
349
|
-
|
|
350
|
-
|
|
458
|
+
.argument('<port>', 'guest port', parsePort)).action((id, port, opts) => {
|
|
459
|
+
noteDeprecated('box port', `sandbox expose <sandbox-id> --port ${port}`);
|
|
460
|
+
return run(async () => {
|
|
461
|
+
print(await runCloudApi().patch(`/run-cloud/boxes/${encodeURIComponent(id)}`, { port }), opts);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
351
464
|
withOutput(box
|
|
352
465
|
.command('route')
|
|
353
|
-
.description('
|
|
354
|
-
.argument('<id>', 'box id')).action((id, opts) =>
|
|
355
|
-
|
|
356
|
-
|
|
466
|
+
.description('Deprecated: use `sandbox expose <sandbox-id> --reconcile`')
|
|
467
|
+
.argument('<id>', 'box id')).action((id, opts) => {
|
|
468
|
+
noteDeprecated('box route', 'sandbox expose <sandbox-id> --reconcile');
|
|
469
|
+
return run(async () => {
|
|
470
|
+
print(await runCloudApi().post(`/run-cloud/boxes/${encodeURIComponent(id)}/route`), opts);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
357
473
|
withOutput(box
|
|
358
474
|
.command('rm')
|
|
359
|
-
.description('
|
|
360
|
-
.argument('<id>', 'box id')).action((id, opts) =>
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
475
|
+
.description('Deprecated: use `sandbox rm <sandbox-id>`')
|
|
476
|
+
.argument('<id>', 'box id')).action((id, opts) => {
|
|
477
|
+
noteDeprecated('box rm', 'sandbox rm <sandbox-id>');
|
|
478
|
+
return run(async () => {
|
|
479
|
+
await runCloudApi().delete(`/run-cloud/boxes/${encodeURIComponent(id)}`);
|
|
480
|
+
if (!opts.json)
|
|
481
|
+
console.log(`destroyed box ${id}`);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
registerAccessCommands(box, { deprecated: true });
|
|
366
485
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { normalizeApiUrl } from "./config.js";
|
|
2
|
+
export async function runDeviceLogin(deps) {
|
|
3
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
4
|
+
const log = deps.log ?? ((m) => console.log(m));
|
|
5
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
6
|
+
const now = deps.now ?? (() => Date.now());
|
|
7
|
+
const base = normalizeApiUrl(deps.apiUrl);
|
|
8
|
+
const startRes = await fetchFn(`${base}/run-cloud/cli-auth/device`, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "content-type": "application/json" },
|
|
11
|
+
body: "{}",
|
|
12
|
+
});
|
|
13
|
+
if (!startRes.ok)
|
|
14
|
+
throw new Error(`Could not start login (${startRes.status}).`);
|
|
15
|
+
const start = (await startRes.json());
|
|
16
|
+
log(`\nTo authorize this terminal, confirm the code below in your browser:\n\n ${start.user_code}\n`);
|
|
17
|
+
log(`Opening ${start.verification_uri} … (if it doesn't open, go there and enter the code)\n`);
|
|
18
|
+
const approvalUrl = new URL(start.verification_uri_complete);
|
|
19
|
+
if (deps.provider)
|
|
20
|
+
approvalUrl.searchParams.set("provider", deps.provider);
|
|
21
|
+
deps.openBrowser?.(approvalUrl.toString());
|
|
22
|
+
let intervalMs = Math.max(0, start.interval) * 1000;
|
|
23
|
+
const deadline = now() + start.expires_in * 1000;
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (now() >= deadline)
|
|
26
|
+
throw new Error("Login timed out. Run `runcloud login` again.");
|
|
27
|
+
await sleep(intervalMs);
|
|
28
|
+
const res = await fetchFn(`${base}/run-cloud/cli-auth/token`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "content-type": "application/json" },
|
|
31
|
+
body: JSON.stringify({ device_code: start.device_code }),
|
|
32
|
+
});
|
|
33
|
+
if (res.ok) {
|
|
34
|
+
const body = (await res.json());
|
|
35
|
+
return { token: body.token, apiUrl: body.apiUrl, expiresIn: body.expiresIn };
|
|
36
|
+
}
|
|
37
|
+
const { error } = (await res.json().catch(() => ({ error: "unknown" })));
|
|
38
|
+
if (error === "authorization_pending")
|
|
39
|
+
continue;
|
|
40
|
+
if (error === "slow_down") {
|
|
41
|
+
intervalMs += 5000;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (error === "access_denied")
|
|
45
|
+
throw new Error("Authorization was denied. Run `runcloud login` again.");
|
|
46
|
+
throw new Error("The login code expired. Run `runcloud login` again.");
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = '0.1.
|
|
1
|
+
export const CLI_VERSION = '0.1.11';
|