runcloud 0.1.8 → 0.1.10
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/api.js +2 -1
- package/dist/commands/boxAccess.js +127 -89
- package/dist/commands/environment.js +1 -1
- package/dist/commands/login.js +149 -14
- package/dist/commands/run-cloud.js +2 -2
- package/dist/commands/sandbox.js +200 -68
- package/dist/config.js +6 -10
- package/dist/deviceLogin.js +48 -0
- 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/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:
|
package/dist/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loginCommandName } from './config.js';
|
|
1
2
|
export class ApiError extends Error {
|
|
2
3
|
status;
|
|
3
4
|
detail;
|
|
@@ -165,7 +166,7 @@ function detailSuffix(err) {
|
|
|
165
166
|
function headline(err) {
|
|
166
167
|
if (err instanceof ApiError) {
|
|
167
168
|
if (err.status === 401) {
|
|
168
|
-
const command = (
|
|
169
|
+
const command = loginCommandName();
|
|
169
170
|
return `Session expired or invalid — run \`${command}\`.`;
|
|
170
171
|
}
|
|
171
172
|
if (err.status === 403)
|
|
@@ -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
|
}
|
|
@@ -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;
|
package/dist/commands/login.js
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
3
4
|
import { spawn } from 'node:child_process';
|
|
4
5
|
import { ApiClient, ApiError } from '../api.js';
|
|
5
6
|
import { clearCredentials, credentialsDisplayPath, normalizeApiUrl, saveCredentials } from '../config.js';
|
|
7
|
+
import { acquirePromptIO, ask, PromptAbortedError, select, } from '../prompt.js';
|
|
8
|
+
import { AuthError, createApiKey, fetchProviders, isValidEmail, isValidOtp, normalizeOtp, sendEmailCode, verifyEmailCode, } from '../runCloudAuth.js';
|
|
6
9
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
export const DEFAULT_RUN_CLOUD_API_URL = 'https://api.run.cloud';
|
|
11
|
+
export function loginChoices(providers) {
|
|
12
|
+
const choices = [];
|
|
13
|
+
if (providers.emailOtp) {
|
|
14
|
+
choices.push({ value: 'email', label: 'Email', hint: 'a code is sent to your inbox' });
|
|
15
|
+
}
|
|
16
|
+
if (providers.google) {
|
|
17
|
+
choices.push({ value: 'google', label: 'Google', hint: 'opens your browser' });
|
|
18
|
+
}
|
|
19
|
+
if (providers.github) {
|
|
20
|
+
choices.push({ value: 'github', label: 'GitHub', hint: 'opens your browser' });
|
|
21
|
+
}
|
|
22
|
+
return choices;
|
|
23
|
+
}
|
|
24
|
+
export function methodFromFlags(flags) {
|
|
25
|
+
const chosen = [];
|
|
26
|
+
if (flags.email)
|
|
27
|
+
chosen.push('email');
|
|
28
|
+
if (flags.google)
|
|
29
|
+
chosen.push('google');
|
|
30
|
+
if (flags.github)
|
|
31
|
+
chosen.push('github');
|
|
32
|
+
if (chosen.length > 1) {
|
|
33
|
+
throw new Error(`Pick one sign-in method: ${chosen.map((m) => `--${m}`).join(' and ')} conflict.`);
|
|
34
|
+
}
|
|
35
|
+
return chosen[0] ?? null;
|
|
36
|
+
}
|
|
7
37
|
export async function startCallbackServer(state, appOrigin, timeoutMs) {
|
|
8
38
|
let resolvePayload;
|
|
9
39
|
let rejectPayload;
|
|
@@ -84,7 +114,7 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
|
|
|
84
114
|
},
|
|
85
115
|
};
|
|
86
116
|
}
|
|
87
|
-
function openBrowser(url) {
|
|
117
|
+
export function openBrowser(url) {
|
|
88
118
|
const [cmd, args] = process.platform === 'darwin'
|
|
89
119
|
? ['open', [url]]
|
|
90
120
|
: process.platform === 'win32'
|
|
@@ -95,12 +125,54 @@ function openBrowser(url) {
|
|
|
95
125
|
});
|
|
96
126
|
child.unref();
|
|
97
127
|
}
|
|
98
|
-
export function cliAuthUrl(appUrl, port, state) {
|
|
128
|
+
export function cliAuthUrl(appUrl, port, state, provider) {
|
|
99
129
|
const url = new URL('/cli-auth', appUrl);
|
|
100
130
|
url.searchParams.set('port', String(port));
|
|
101
131
|
url.searchParams.set('state', state);
|
|
132
|
+
if (provider)
|
|
133
|
+
url.searchParams.set('provider', provider);
|
|
102
134
|
return url.toString();
|
|
103
135
|
}
|
|
136
|
+
export async function emailLogin(apiUrl, presetEmail, io) {
|
|
137
|
+
const email = presetEmail?.trim()
|
|
138
|
+
? presetEmail.trim()
|
|
139
|
+
: await ask(' Email: ', {
|
|
140
|
+
io,
|
|
141
|
+
validate: (value) => (isValidEmail(value) ? null : 'That does not look like an email address.'),
|
|
142
|
+
});
|
|
143
|
+
if (!isValidEmail(email))
|
|
144
|
+
throw new Error(`Not a valid email address: ${email}`);
|
|
145
|
+
await sendEmailCode(apiUrl, email);
|
|
146
|
+
console.log(` Code sent to ${email}\n`);
|
|
147
|
+
let session = null;
|
|
148
|
+
for (let attempt = 1; attempt <= 3 && !session; attempt += 1) {
|
|
149
|
+
const code = await ask(' Code: ', {
|
|
150
|
+
io,
|
|
151
|
+
validate: (value) => (isValidOtp(value) ? null : 'Codes are six digits.'),
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
session = await verifyEmailCode(apiUrl, email, normalizeOtp(code));
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
const wrongCode = e instanceof AuthError && (e.status === 400 || e.status === 401);
|
|
158
|
+
if (!wrongCode || attempt === 3)
|
|
159
|
+
throw e;
|
|
160
|
+
console.log(` ${e.message} — try again.\n`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!session)
|
|
164
|
+
throw new Error('Could not verify the code.');
|
|
165
|
+
const token = await createApiKey(apiUrl, session.sessionToken, `runcloud CLI (${hostLabel()})`);
|
|
166
|
+
return { email, token };
|
|
167
|
+
}
|
|
168
|
+
function hostLabel() {
|
|
169
|
+
try {
|
|
170
|
+
return hostname() || 'cli';
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return 'cli';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
104
176
|
export function parseManualCredential(blob) {
|
|
105
177
|
let parsed;
|
|
106
178
|
try {
|
|
@@ -136,12 +208,18 @@ export function registerLogin(program) {
|
|
|
136
208
|
: process.env.NEWLY_APP_URL ?? 'https://newly.app';
|
|
137
209
|
program
|
|
138
210
|
.command('login')
|
|
139
|
-
.description(
|
|
211
|
+
.description(isRunCloud
|
|
212
|
+
? 'Log in with your run.cloud account: email, Google, or GitHub'
|
|
213
|
+
: 'Log in with your Newly account via the browser')
|
|
140
214
|
.option('--app-url <url>', 'product web app URL', defaultAppUrl)
|
|
141
|
-
.option('--manual', 'paste the credential from the browser instead of using the local callback', false)
|
|
215
|
+
.option('--manual', 'newly skin only: paste the credential from the browser instead of using the local callback', false)
|
|
142
216
|
.option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
|
|
143
217
|
.option('--token <token>', 'explicit credential token (skips browser; use with --api-url)')
|
|
144
218
|
.option('--expires-in <seconds>', 'session lifetime when using --token (default 7 days)', (v) => Number(v), 604800)
|
|
219
|
+
.option('--email [address]', 'sign in with an emailed code, without a browser')
|
|
220
|
+
.option('--google', 'sign in with Google', false)
|
|
221
|
+
.option('--github', 'sign in with GitHub', false)
|
|
222
|
+
.option('--browser', 'skip the picker and use the browser handoff', false)
|
|
145
223
|
.action(async (opts) => {
|
|
146
224
|
try {
|
|
147
225
|
const hasApiUrl = typeof opts.apiUrl === 'string' && opts.apiUrl.length > 0;
|
|
@@ -154,7 +232,7 @@ export function registerLogin(program) {
|
|
|
154
232
|
}
|
|
155
233
|
if (hasToken) {
|
|
156
234
|
const apiUrl = isRunCloud
|
|
157
|
-
? opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? 'https://api.
|
|
235
|
+
? opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? 'https://api.run.cloud'
|
|
158
236
|
: opts.apiUrl;
|
|
159
237
|
try {
|
|
160
238
|
new URL(apiUrl);
|
|
@@ -173,19 +251,12 @@ export function registerLogin(program) {
|
|
|
173
251
|
console.log(`Logged in headlessly. Credentials written for API ${apiUrl}.`);
|
|
174
252
|
return;
|
|
175
253
|
}
|
|
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
|
-
}
|
|
254
|
+
if (opts.manual && !isRunCloud) {
|
|
182
255
|
const { createInterface } = await import('node:readline/promises');
|
|
183
256
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
184
257
|
const blob = await rl.question('Paste the credential from the browser: ');
|
|
185
258
|
rl.close();
|
|
186
259
|
const parsed = parseManualCredential(blob);
|
|
187
|
-
if (isRunCloud)
|
|
188
|
-
await verifyRunCloudCredential(parsed.apiUrl, parsed.token);
|
|
189
260
|
saveCredentials({
|
|
190
261
|
token: parsed.token,
|
|
191
262
|
apiUrl: parsed.apiUrl,
|
|
@@ -195,10 +266,69 @@ export function registerLogin(program) {
|
|
|
195
266
|
console.log('Logged in.');
|
|
196
267
|
return;
|
|
197
268
|
}
|
|
269
|
+
let socialProvider;
|
|
270
|
+
if (isRunCloud && !opts.browser) {
|
|
271
|
+
const authApiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
|
|
272
|
+
let method = methodFromFlags(opts);
|
|
273
|
+
const terminal = method === 'email' || !method ? acquirePromptIO() : null;
|
|
274
|
+
try {
|
|
275
|
+
if (!method && terminal) {
|
|
276
|
+
const providers = await fetchProviders(authApiUrl);
|
|
277
|
+
const choices = loginChoices(providers);
|
|
278
|
+
if (choices.length === 0) {
|
|
279
|
+
throw new Error(`${authApiUrl} has no sign-in methods enabled.`);
|
|
280
|
+
}
|
|
281
|
+
method = await select(' How do you want to sign in?', choices, terminal.io);
|
|
282
|
+
}
|
|
283
|
+
if (method === 'email') {
|
|
284
|
+
const preset = typeof opts.email === 'string' ? opts.email : undefined;
|
|
285
|
+
const { email, token } = await emailLogin(authApiUrl, preset, terminal?.io);
|
|
286
|
+
saveCredentials({
|
|
287
|
+
token,
|
|
288
|
+
apiUrl: authApiUrl,
|
|
289
|
+
appUrl: opts.appUrl,
|
|
290
|
+
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
291
|
+
});
|
|
292
|
+
console.log(`\n Signed in as ${email}`);
|
|
293
|
+
console.log(` Credential saved to ${credentialsDisplayPath()}\n`);
|
|
294
|
+
console.log(' Try:');
|
|
295
|
+
console.log(' runcloud ios create --open');
|
|
296
|
+
console.log(' runcloud sandbox run "echo hello"');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
terminal?.close();
|
|
302
|
+
}
|
|
303
|
+
if (method === 'google' || method === 'github')
|
|
304
|
+
socialProvider = method;
|
|
305
|
+
}
|
|
306
|
+
if (isRunCloud) {
|
|
307
|
+
if (opts.manual) {
|
|
308
|
+
console.log('Note: --manual is no longer needed for run.cloud — `runcloud login` works over SSH and headless via device flow.');
|
|
309
|
+
}
|
|
310
|
+
const apiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
|
|
311
|
+
const { runDeviceLogin } = await import('../deviceLogin.js');
|
|
312
|
+
const payload = await runDeviceLogin({
|
|
313
|
+
apiUrl,
|
|
314
|
+
appUrl: opts.appUrl,
|
|
315
|
+
provider: socialProvider,
|
|
316
|
+
openBrowser,
|
|
317
|
+
});
|
|
318
|
+
await verifyRunCloudCredential(payload.apiUrl, payload.token);
|
|
319
|
+
saveCredentials({
|
|
320
|
+
token: payload.token,
|
|
321
|
+
apiUrl: payload.apiUrl,
|
|
322
|
+
appUrl: opts.appUrl,
|
|
323
|
+
expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
|
|
324
|
+
});
|
|
325
|
+
console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
198
328
|
const state = crypto.randomBytes(16).toString('hex');
|
|
199
329
|
const appOrigin = new URL(opts.appUrl).origin;
|
|
200
330
|
const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
|
|
201
|
-
const loginUrl = cliAuthUrl(opts.appUrl, port, state);
|
|
331
|
+
const loginUrl = cliAuthUrl(opts.appUrl, port, state, socialProvider);
|
|
202
332
|
console.log(`Opening your browser to authorize the CLI…\n ${loginUrl}\n`);
|
|
203
333
|
console.log("If the browser doesn't open, paste the URL above into it manually.");
|
|
204
334
|
openBrowser(loginUrl);
|
|
@@ -219,6 +349,11 @@ export function registerLogin(program) {
|
|
|
219
349
|
}
|
|
220
350
|
}
|
|
221
351
|
catch (e) {
|
|
352
|
+
if (e instanceof PromptAbortedError) {
|
|
353
|
+
console.log('Cancelled.');
|
|
354
|
+
process.exitCode = 130;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
222
357
|
console.error(e instanceof Error ? e.message : String(e));
|
|
223
358
|
process.exitCode = 1;
|
|
224
359
|
}
|
|
@@ -18,7 +18,7 @@ Use this skill when a user asks an agent to create, inspect, smoke test, debug,
|
|
|
18
18
|
|
|
19
19
|
## Requirements
|
|
20
20
|
|
|
21
|
-
- Read SDK credentials from \`RUN_CLOUD_API_KEY\`. Never print it, commit it, or write it into a skill file. The SDK uses \`RUN_CLOUD_API_URL\` when set and otherwise defaults to \`https://api.
|
|
21
|
+
- Read SDK credentials from \`RUN_CLOUD_API_KEY\`. Never print it, commit it, or write it into a skill file. The SDK uses \`RUN_CLOUD_API_URL\` when set and otherwise defaults to \`https://api.run.cloud\`.
|
|
22
22
|
- Authenticate the CLI with either a saved \`runcloud login\` credential or \`RUN_CLOUD_API_KEY\` together with \`RUN_CLOUD_API_URL\`. Do not require both a saved login and an API key.
|
|
23
23
|
- The TypeScript SDK requires Node.js 20 or newer.
|
|
24
24
|
- The account must have run.cloud access, available capacity, and organization credit.
|
|
@@ -76,7 +76,7 @@ Or authenticate non-interactively with both required environment variables:
|
|
|
76
76
|
|
|
77
77
|
\`\`\`bash
|
|
78
78
|
export RUN_CLOUD_API_KEY="rc_live_..."
|
|
79
|
-
export RUN_CLOUD_API_URL="https://api.
|
|
79
|
+
export RUN_CLOUD_API_URL="https://api.run.cloud"
|
|
80
80
|
\`\`\`
|
|
81
81
|
|
|
82
82
|
Then inspect the account:
|