@newly/cli 0.1.2 → 0.1.27
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 +20 -19
- package/README.runcloud.md +253 -0
- package/dist/api.js +123 -43
- package/dist/commands/appStore.js +162 -0
- package/dist/commands/appStoreEvaluate.js +515 -0
- package/dist/commands/boxAccess.js +349 -0
- package/dist/commands/build.js +261 -13
- package/dist/commands/create.js +106 -30
- package/dist/commands/deploy.js +100 -129
- package/dist/commands/desktop.js +55 -0
- package/dist/commands/environment.js +290 -0
- package/dist/commands/image.js +145 -0
- package/dist/commands/login.js +265 -18
- package/dist/commands/run-cloud.js +318 -108
- package/dist/commands/sandbox.js +787 -0
- package/dist/commands/sandboxOutput.js +70 -0
- package/dist/commands/secrets.js +203 -0
- package/dist/config.js +56 -17
- package/dist/deviceLogin.js +48 -0
- package/dist/index.js +6 -0
- package/dist/prompt.js +196 -0
- package/dist/run-cloud.js +9 -2
- package/dist/runCloudAuth.js +79 -0
- package/dist/sandboxExec.js +91 -0
- package/dist/services-manifest.js +30 -1
- package/dist/shellSession.js +86 -0
- package/dist/terminal.js +158 -0
- package/dist/version.js +1 -0
- package/package.json +13 -7
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { InvalidArgumentError, Option } from 'commander';
|
|
2
|
+
import { ApiClient, friendlyApiError } from '../api.js';
|
|
3
|
+
import { requireCredentials } from '../config.js';
|
|
4
|
+
import { formatAge, formatState, printJson, renderDetails, renderTable, shortDigest, StatusLine, style, } from '../terminal.js';
|
|
5
|
+
const POLL_INTERVAL_MS = 3_000;
|
|
6
|
+
const DEFAULT_WAIT_TIMEOUT_SECONDS = 1_200;
|
|
7
|
+
function runCloudApi() {
|
|
8
|
+
const credentials = requireCredentials();
|
|
9
|
+
return new ApiClient(credentials.apiUrl, credentials.token);
|
|
10
|
+
}
|
|
11
|
+
function print(value, opts) {
|
|
12
|
+
if (opts.json) {
|
|
13
|
+
printJson(value);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
console.log(renderImage(value));
|
|
17
|
+
}
|
|
18
|
+
function renderImage(row, title) {
|
|
19
|
+
const heading = title ??
|
|
20
|
+
(row.state === 'ready'
|
|
21
|
+
? '✓ Image ready'
|
|
22
|
+
: row.state === 'failed'
|
|
23
|
+
? '✗ Image build failed'
|
|
24
|
+
: '◐ Image build started');
|
|
25
|
+
const footer = row.state === 'building'
|
|
26
|
+
? `${style.dim('Follow:')} runcloud image list`
|
|
27
|
+
: undefined;
|
|
28
|
+
return renderDetails(heading, [
|
|
29
|
+
{ label: 'Reference', value: row.ref ?? row.name },
|
|
30
|
+
{ label: 'State', value: formatState(row.state) },
|
|
31
|
+
{ label: 'Digest', value: shortDigest(row.digest) },
|
|
32
|
+
{ label: 'Created', value: formatAge(row.createdAt) },
|
|
33
|
+
{ label: 'Error', value: row.error },
|
|
34
|
+
], footer);
|
|
35
|
+
}
|
|
36
|
+
function renderImageList(rows) {
|
|
37
|
+
if (rows.length === 0) {
|
|
38
|
+
return `${style.bold('Images')}\n\nNo images registered.\n\n${style.dim('Register one:')} runcloud image create --ref python:3.12-slim`;
|
|
39
|
+
}
|
|
40
|
+
return renderTable(`Images (${rows.length})`, [
|
|
41
|
+
{ key: 'ref', label: 'REFERENCE', maxWidth: 44 },
|
|
42
|
+
{ key: 'state', label: 'STATE', maxWidth: 12 },
|
|
43
|
+
{ key: 'digest', label: 'DIGEST' },
|
|
44
|
+
{ key: 'age', label: 'AGE' },
|
|
45
|
+
], rows.map((row) => ({
|
|
46
|
+
ref: row.ref ?? row.name,
|
|
47
|
+
state: formatState(row.state),
|
|
48
|
+
digest: shortDigest(row.digest) ?? '—',
|
|
49
|
+
age: formatAge(row.createdAt) ?? '—',
|
|
50
|
+
})));
|
|
51
|
+
}
|
|
52
|
+
async function run(fn) {
|
|
53
|
+
try {
|
|
54
|
+
await fn();
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.error(friendlyApiError(err));
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function withOutput(cmd) {
|
|
62
|
+
return cmd.option('--json', 'output JSON', false);
|
|
63
|
+
}
|
|
64
|
+
function parseTimeoutSeconds(value) {
|
|
65
|
+
const n = Number(value);
|
|
66
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
67
|
+
throw new InvalidArgumentError('--timeout must be a positive number of seconds');
|
|
68
|
+
}
|
|
69
|
+
return n;
|
|
70
|
+
}
|
|
71
|
+
const TERMINAL_STATES = new Set(['ready', 'failed']);
|
|
72
|
+
export async function pollImageRegistration(api, ref, opts = {}) {
|
|
73
|
+
const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
|
|
74
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_SECONDS * 1000;
|
|
75
|
+
const deadline = Date.now() + timeoutMs;
|
|
76
|
+
for (;;) {
|
|
77
|
+
const data = await api.get('/run-cloud/images');
|
|
78
|
+
const items = (data?.items ?? []);
|
|
79
|
+
const row = items.find((item) => item.ref === ref || item.name === ref);
|
|
80
|
+
if (row)
|
|
81
|
+
opts.onState?.(row.state);
|
|
82
|
+
if (row && TERMINAL_STATES.has(row.state))
|
|
83
|
+
return row;
|
|
84
|
+
if (Date.now() > deadline) {
|
|
85
|
+
throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for image ${ref} to become ready`);
|
|
86
|
+
}
|
|
87
|
+
await new Promise((done) => setTimeout(done, intervalMs));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function registerImage(program) {
|
|
91
|
+
const image = program
|
|
92
|
+
.command('image')
|
|
93
|
+
.description('Register and manage the OCI images run.cloud sandboxes boot from');
|
|
94
|
+
withOutput(image
|
|
95
|
+
.command('create')
|
|
96
|
+
.description('Register (build-once) an OCI image ref for run.cloud sandboxes')
|
|
97
|
+
.requiredOption('--ref <ref>', 'OCI image ref to register, e.g. python:3.12-slim')
|
|
98
|
+
.option('--no-wait', 'return after scheduling instead of waiting for the image build')
|
|
99
|
+
.option('--timeout <seconds>', 'max time to wait in seconds', parseTimeoutSeconds, DEFAULT_WAIT_TIMEOUT_SECONDS)
|
|
100
|
+
.addOption(new Option('--wait').hideHelp())).action((opts) => run(async () => {
|
|
101
|
+
const api = runCloudApi();
|
|
102
|
+
const created = await api.post('/run-cloud/images', { ref: opts.ref });
|
|
103
|
+
if (!opts.wait || created?.state === 'ready') {
|
|
104
|
+
print(created, opts);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const status = opts.json ? undefined : new StatusLine();
|
|
108
|
+
status?.update(`Building ${opts.ref}`);
|
|
109
|
+
const finalRow = await pollImageRegistration(api, opts.ref, {
|
|
110
|
+
timeoutMs: opts.timeout * 1000,
|
|
111
|
+
onState: (state) => status?.update(state === 'building' ? `Building ${opts.ref}` : `${opts.ref} is ${state}`),
|
|
112
|
+
});
|
|
113
|
+
if (finalRow.state === 'failed') {
|
|
114
|
+
status?.fail(`Image build failed`);
|
|
115
|
+
throw new Error(finalRow.error
|
|
116
|
+
? `image ${opts.ref} failed to build: ${finalRow.error}`
|
|
117
|
+
: `image ${opts.ref} failed to build`);
|
|
118
|
+
}
|
|
119
|
+
status?.clear();
|
|
120
|
+
print(finalRow, opts);
|
|
121
|
+
}));
|
|
122
|
+
withOutput(image.command('list').description('List images your organization has registered')).action((opts) => run(async () => {
|
|
123
|
+
const data = await runCloudApi().get('/run-cloud/images');
|
|
124
|
+
const rows = (data?.items ?? []);
|
|
125
|
+
if (opts.json)
|
|
126
|
+
printJson(rows);
|
|
127
|
+
else
|
|
128
|
+
console.log(renderImageList(rows));
|
|
129
|
+
}));
|
|
130
|
+
withOutput(image
|
|
131
|
+
.command('refresh')
|
|
132
|
+
.description("Move a tag's pin to its current upstream digest")
|
|
133
|
+
.argument('<ref>', 'OCI image ref to refresh')).action((ref, opts) => run(async () => {
|
|
134
|
+
const refreshed = await runCloudApi().post('/run-cloud/images/refresh', { ref });
|
|
135
|
+
if (opts.json)
|
|
136
|
+
printJson(refreshed);
|
|
137
|
+
else {
|
|
138
|
+
console.log(renderDetails('✓ Image refreshed', [
|
|
139
|
+
{ label: 'Reference', value: refreshed?.ref ?? ref },
|
|
140
|
+
{ label: 'Previous', value: shortDigest(refreshed?.old_digest) },
|
|
141
|
+
{ label: 'Digest', value: shortDigest(refreshed?.new_digest ?? refreshed?.digest) },
|
|
142
|
+
]));
|
|
143
|
+
}
|
|
144
|
+
}));
|
|
145
|
+
}
|
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
|
-
import {
|
|
5
|
-
|
|
5
|
+
import { ApiClient, ApiError } from '../api.js';
|
|
6
|
+
import { clearCredentials, credentialsDisplayPath, loadCredentials, 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;
|
|
@@ -11,11 +41,15 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
|
|
|
11
41
|
resolvePayload = resolve;
|
|
12
42
|
rejectPayload = reject;
|
|
13
43
|
});
|
|
14
|
-
const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s`
|
|
44
|
+
const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s.\n` +
|
|
45
|
+
"If the browser said the local handoff was blocked, Chrome gates HTTPS→localhost behind a permission: " +
|
|
46
|
+
'click the icon at the left of the address bar, allow "access to apps on this device", ' +
|
|
47
|
+
'then run login again (or use --manual).')), timeoutMs);
|
|
15
48
|
const corsHeaders = {
|
|
16
49
|
'Access-Control-Allow-Origin': appOrigin,
|
|
17
50
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
18
51
|
'Access-Control-Allow-Headers': 'content-type',
|
|
52
|
+
'Access-Control-Allow-Private-Network': 'true',
|
|
19
53
|
};
|
|
20
54
|
const server = http.createServer((req, res) => {
|
|
21
55
|
if (req.method === 'OPTIONS') {
|
|
@@ -38,6 +72,22 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
|
|
|
38
72
|
res.end('state mismatch');
|
|
39
73
|
return;
|
|
40
74
|
}
|
|
75
|
+
if (typeof parsed.token !== 'string' ||
|
|
76
|
+
parsed.token.length === 0 ||
|
|
77
|
+
typeof parsed.expiresIn !== 'number' ||
|
|
78
|
+
!Number.isFinite(parsed.expiresIn) ||
|
|
79
|
+
parsed.expiresIn <= 0 ||
|
|
80
|
+
typeof parsed.apiUrl !== 'string') {
|
|
81
|
+
res.writeHead(400, corsHeaders);
|
|
82
|
+
res.end('invalid credential payload');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const apiUrl = new URL(parsed.apiUrl);
|
|
86
|
+
if (!['http:', 'https:'].includes(apiUrl.protocol)) {
|
|
87
|
+
res.writeHead(400, corsHeaders);
|
|
88
|
+
res.end('invalid API URL');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
41
91
|
res.writeHead(200, { ...corsHeaders, 'Content-Type': 'application/json' });
|
|
42
92
|
res.end(JSON.stringify({ ok: true }));
|
|
43
93
|
clearTimeout(timer);
|
|
@@ -64,7 +114,7 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
|
|
|
64
114
|
},
|
|
65
115
|
};
|
|
66
116
|
}
|
|
67
|
-
function openBrowser(url) {
|
|
117
|
+
export function openBrowser(url) {
|
|
68
118
|
const [cmd, args] = process.platform === 'darwin'
|
|
69
119
|
? ['open', [url]]
|
|
70
120
|
: process.platform === 'win32'
|
|
@@ -75,6 +125,54 @@ function openBrowser(url) {
|
|
|
75
125
|
});
|
|
76
126
|
child.unref();
|
|
77
127
|
}
|
|
128
|
+
export function cliAuthUrl(appUrl, port, state, provider) {
|
|
129
|
+
const url = new URL('/cli-auth', appUrl);
|
|
130
|
+
url.searchParams.set('port', String(port));
|
|
131
|
+
url.searchParams.set('state', state);
|
|
132
|
+
if (provider)
|
|
133
|
+
url.searchParams.set('provider', provider);
|
|
134
|
+
return url.toString();
|
|
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
|
+
}
|
|
78
176
|
export function parseManualCredential(blob) {
|
|
79
177
|
let parsed;
|
|
80
178
|
try {
|
|
@@ -90,39 +188,113 @@ export function parseManualCredential(blob) {
|
|
|
90
188
|
}
|
|
91
189
|
return { token: c.token, expiresIn: c.expiresIn, apiUrl: c.apiUrl };
|
|
92
190
|
}
|
|
191
|
+
export async function verifyRunCloudCredential(apiUrl, token) {
|
|
192
|
+
try {
|
|
193
|
+
await new ApiClient(normalizeApiUrl(apiUrl), token).get('/run-cloud/account');
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
|
|
197
|
+
throw new Error(`${apiUrl} rejected this credential (${e.status}). Make sure you signed in to the account ` +
|
|
198
|
+
'you want the CLI to use, then run login again.');
|
|
199
|
+
}
|
|
200
|
+
console.warn(`Warning: could not verify the credential against ${apiUrl} ` +
|
|
201
|
+
`(${e instanceof Error ? e.message : String(e)}). Saving it anyway.`);
|
|
202
|
+
}
|
|
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
|
+
}
|
|
218
|
+
export function existingLoginSummary(creds) {
|
|
219
|
+
const who = creds.account ? ` as ${creds.account}` : '';
|
|
220
|
+
const expiry = creds.expiresAt < Number.MAX_SAFE_INTEGER
|
|
221
|
+
? `, valid until ${new Date(creds.expiresAt * 1000).toLocaleString()}`
|
|
222
|
+
: '';
|
|
223
|
+
return `You are already logged in${who} (${creds.apiUrl}${expiry}).`;
|
|
224
|
+
}
|
|
225
|
+
export async function confirmReplaceExistingLogin(creds) {
|
|
226
|
+
if (!creds)
|
|
227
|
+
return true;
|
|
228
|
+
const terminal = acquirePromptIO();
|
|
229
|
+
if (!terminal)
|
|
230
|
+
return true;
|
|
231
|
+
try {
|
|
232
|
+
console.log(`\n ${existingLoginSummary(creds)}`);
|
|
233
|
+
const choice = await select(' Do you want to continue?', [
|
|
234
|
+
{ value: 'keep', label: 'Keep this session', hint: 'nothing to do' },
|
|
235
|
+
{ value: 'again', label: 'Log in again', hint: 'replaces the saved credential' },
|
|
236
|
+
], terminal.io);
|
|
237
|
+
return choice === 'again';
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
terminal.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
93
243
|
export function registerLogin(program) {
|
|
244
|
+
const isRunCloud = program.name() === 'runcloud';
|
|
245
|
+
const defaultAppUrl = isRunCloud
|
|
246
|
+
? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
|
|
247
|
+
: process.env.NEWLY_APP_URL ?? 'https://newly.app';
|
|
94
248
|
program
|
|
95
249
|
.command('login')
|
|
96
|
-
.description(
|
|
97
|
-
|
|
98
|
-
|
|
250
|
+
.description(isRunCloud
|
|
251
|
+
? 'Log in with your run.cloud account: email, Google, or GitHub'
|
|
252
|
+
: 'Log in with your Newly account via the browser')
|
|
253
|
+
.option('--app-url <url>', 'product web app URL', defaultAppUrl)
|
|
254
|
+
.option('--manual', 'newly skin only: paste the credential from the browser instead of using the local callback', false)
|
|
99
255
|
.option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
|
|
100
256
|
.option('--token <token>', 'explicit credential token (skips browser; use with --api-url)')
|
|
101
257
|
.option('--expires-in <seconds>', 'session lifetime when using --token (default 7 days)', (v) => Number(v), 604800)
|
|
258
|
+
.option('--email [address]', 'sign in with an emailed code, without a browser')
|
|
259
|
+
.option('--google', 'sign in with Google', false)
|
|
260
|
+
.option('--github', 'sign in with GitHub', false)
|
|
261
|
+
.option('--browser', 'skip the picker and use the browser handoff', false)
|
|
102
262
|
.action(async (opts) => {
|
|
103
263
|
try {
|
|
104
264
|
const hasApiUrl = typeof opts.apiUrl === 'string' && opts.apiUrl.length > 0;
|
|
105
265
|
const hasToken = typeof opts.token === 'string' && opts.token.length > 0;
|
|
106
|
-
if (hasApiUrl !== hasToken) {
|
|
266
|
+
if (!isRunCloud && hasApiUrl !== hasToken) {
|
|
107
267
|
throw new Error('--api-url and --token must be provided together for headless login.');
|
|
108
268
|
}
|
|
109
|
-
if (hasApiUrl && hasToken) {
|
|
269
|
+
if (isRunCloud && hasApiUrl && !hasToken) {
|
|
270
|
+
throw new Error('--api-url requires --token for headless login.');
|
|
271
|
+
}
|
|
272
|
+
if (hasToken) {
|
|
273
|
+
const apiUrl = isRunCloud
|
|
274
|
+
? opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? 'https://api.run.cloud'
|
|
275
|
+
: opts.apiUrl;
|
|
110
276
|
try {
|
|
111
|
-
new URL(
|
|
277
|
+
new URL(apiUrl);
|
|
112
278
|
}
|
|
113
279
|
catch {
|
|
114
|
-
throw new Error(`--api-url is not a valid URL: ${
|
|
280
|
+
throw new Error(`--api-url is not a valid URL: ${apiUrl}`);
|
|
115
281
|
}
|
|
116
282
|
saveCredentials({
|
|
117
283
|
token: opts.token,
|
|
118
|
-
apiUrl
|
|
284
|
+
apiUrl,
|
|
119
285
|
appUrl: opts.appUrl,
|
|
120
|
-
expiresAt:
|
|
286
|
+
expiresAt: isRunCloud
|
|
287
|
+
? Number.MAX_SAFE_INTEGER
|
|
288
|
+
: Math.floor(Date.now() / 1000) + opts.expiresIn,
|
|
121
289
|
});
|
|
122
|
-
console.log(`Logged in headlessly. Credentials written for API ${
|
|
290
|
+
console.log(`Logged in headlessly. Credentials written for API ${apiUrl}.`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (!(await confirmReplaceExistingLogin(loadCredentials()))) {
|
|
294
|
+
console.log(` Keeping the credential at ${credentialsDisplayPath()}\n`);
|
|
123
295
|
return;
|
|
124
296
|
}
|
|
125
|
-
if (opts.manual) {
|
|
297
|
+
if (opts.manual && !isRunCloud) {
|
|
126
298
|
const { createInterface } = await import('node:readline/promises');
|
|
127
299
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
128
300
|
const blob = await rl.question('Paste the credential from the browser: ');
|
|
@@ -137,15 +309,79 @@ export function registerLogin(program) {
|
|
|
137
309
|
console.log('Logged in.');
|
|
138
310
|
return;
|
|
139
311
|
}
|
|
312
|
+
let socialProvider;
|
|
313
|
+
if (isRunCloud && !opts.browser) {
|
|
314
|
+
const authApiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
|
|
315
|
+
let method = methodFromFlags(opts);
|
|
316
|
+
const terminal = method === 'email' || !method ? acquirePromptIO() : null;
|
|
317
|
+
try {
|
|
318
|
+
if (!method && terminal) {
|
|
319
|
+
const providers = await fetchProviders(authApiUrl);
|
|
320
|
+
const choices = loginChoices(providers);
|
|
321
|
+
if (choices.length === 0) {
|
|
322
|
+
throw new Error(`${authApiUrl} has no sign-in methods enabled.`);
|
|
323
|
+
}
|
|
324
|
+
method = await select(' How do you want to sign in?', choices, terminal.io);
|
|
325
|
+
}
|
|
326
|
+
if (method === 'email') {
|
|
327
|
+
const preset = typeof opts.email === 'string' ? opts.email : undefined;
|
|
328
|
+
const { email, token } = await emailLogin(authApiUrl, preset, terminal?.io);
|
|
329
|
+
saveCredentials({
|
|
330
|
+
token,
|
|
331
|
+
apiUrl: authApiUrl,
|
|
332
|
+
appUrl: opts.appUrl,
|
|
333
|
+
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
334
|
+
revocable: true,
|
|
335
|
+
account: email,
|
|
336
|
+
});
|
|
337
|
+
console.log(`\n Signed in as ${email}`);
|
|
338
|
+
console.log(` Credential saved to ${credentialsDisplayPath()}\n`);
|
|
339
|
+
console.log(' Try:');
|
|
340
|
+
console.log(' runcloud ios create --open');
|
|
341
|
+
console.log(' runcloud sandbox run "echo hello"');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
terminal?.close();
|
|
347
|
+
}
|
|
348
|
+
if (method === 'google' || method === 'github')
|
|
349
|
+
socialProvider = method;
|
|
350
|
+
}
|
|
351
|
+
if (isRunCloud) {
|
|
352
|
+
if (opts.manual) {
|
|
353
|
+
console.log('Note: --manual is no longer needed for run.cloud — `runcloud login` works over SSH and headless via device flow.');
|
|
354
|
+
}
|
|
355
|
+
const apiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
|
|
356
|
+
const { runDeviceLogin } = await import('../deviceLogin.js');
|
|
357
|
+
const payload = await runDeviceLogin({
|
|
358
|
+
apiUrl,
|
|
359
|
+
appUrl: opts.appUrl,
|
|
360
|
+
provider: socialProvider,
|
|
361
|
+
openBrowser,
|
|
362
|
+
});
|
|
363
|
+
await verifyRunCloudCredential(payload.apiUrl, payload.token);
|
|
364
|
+
saveCredentials({
|
|
365
|
+
token: payload.token,
|
|
366
|
+
apiUrl: payload.apiUrl,
|
|
367
|
+
appUrl: opts.appUrl,
|
|
368
|
+
expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
|
|
369
|
+
revocable: true,
|
|
370
|
+
});
|
|
371
|
+
console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
140
374
|
const state = crypto.randomBytes(16).toString('hex');
|
|
141
375
|
const appOrigin = new URL(opts.appUrl).origin;
|
|
142
376
|
const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
|
|
143
|
-
const loginUrl =
|
|
377
|
+
const loginUrl = cliAuthUrl(opts.appUrl, port, state, socialProvider);
|
|
144
378
|
console.log(`Opening your browser to authorize the CLI…\n ${loginUrl}\n`);
|
|
145
379
|
console.log("If the browser doesn't open, paste the URL above into it manually.");
|
|
146
380
|
openBrowser(loginUrl);
|
|
147
381
|
try {
|
|
148
382
|
const payload = await waitForToken();
|
|
383
|
+
if (isRunCloud)
|
|
384
|
+
await verifyRunCloudCredential(payload.apiUrl, payload.token);
|
|
149
385
|
saveCredentials({
|
|
150
386
|
token: payload.token,
|
|
151
387
|
apiUrl: payload.apiUrl,
|
|
@@ -159,14 +395,25 @@ export function registerLogin(program) {
|
|
|
159
395
|
}
|
|
160
396
|
}
|
|
161
397
|
catch (e) {
|
|
398
|
+
if (e instanceof PromptAbortedError) {
|
|
399
|
+
console.log('Cancelled.');
|
|
400
|
+
process.exitCode = 130;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
162
403
|
console.error(e instanceof Error ? e.message : String(e));
|
|
163
404
|
process.exitCode = 1;
|
|
164
405
|
}
|
|
165
406
|
});
|
|
166
407
|
program
|
|
167
408
|
.command('logout')
|
|
168
|
-
.description(
|
|
169
|
-
|
|
409
|
+
.description(isRunCloud
|
|
410
|
+
? 'Revoke this machine’s credential and delete it locally'
|
|
411
|
+
: 'Delete the stored CLI credential')
|
|
412
|
+
.action(async () => {
|
|
413
|
+
const creds = loadCredentials();
|
|
414
|
+
if (isRunCloud && creds?.revocable) {
|
|
415
|
+
console.log(await revokeRunCloudCredential(creds.apiUrl, creds.token));
|
|
416
|
+
}
|
|
170
417
|
clearCredentials();
|
|
171
418
|
console.log('Logged out.');
|
|
172
419
|
});
|