runcloud 0.1.8 → 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/dist/api.js +2 -1
- package/dist/commands/environment.js +1 -1
- package/dist/commands/login.js +124 -4
- package/dist/commands/run-cloud.js +2 -2
- package/dist/commands/sandbox.js +13 -0
- package/dist/config.js +6 -10
- 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/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)
|
|
@@ -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;
|
|
@@ -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
215
|
.option('--manual', '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);
|
|
@@ -195,10 +273,47 @@ export function registerLogin(program) {
|
|
|
195
273
|
console.log('Logged in.');
|
|
196
274
|
return;
|
|
197
275
|
}
|
|
276
|
+
let socialProvider;
|
|
277
|
+
if (isRunCloud && !opts.browser) {
|
|
278
|
+
const authApiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
|
|
279
|
+
let method = methodFromFlags(opts);
|
|
280
|
+
const terminal = method === 'email' || !method ? acquirePromptIO() : null;
|
|
281
|
+
try {
|
|
282
|
+
if (!method && terminal) {
|
|
283
|
+
const providers = await fetchProviders(authApiUrl);
|
|
284
|
+
const choices = loginChoices(providers);
|
|
285
|
+
if (choices.length === 0) {
|
|
286
|
+
throw new Error(`${authApiUrl} has no sign-in methods enabled.`);
|
|
287
|
+
}
|
|
288
|
+
method = await select(' How do you want to sign in?', choices, terminal.io);
|
|
289
|
+
}
|
|
290
|
+
if (method === 'email') {
|
|
291
|
+
const preset = typeof opts.email === 'string' ? opts.email : undefined;
|
|
292
|
+
const { email, token } = await emailLogin(authApiUrl, preset, terminal?.io);
|
|
293
|
+
saveCredentials({
|
|
294
|
+
token,
|
|
295
|
+
apiUrl: authApiUrl,
|
|
296
|
+
appUrl: opts.appUrl,
|
|
297
|
+
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
298
|
+
});
|
|
299
|
+
console.log(`\n Signed in as ${email}`);
|
|
300
|
+
console.log(` Credential saved to ${credentialsDisplayPath()}\n`);
|
|
301
|
+
console.log(' Try:');
|
|
302
|
+
console.log(' runcloud ios create --open');
|
|
303
|
+
console.log(' runcloud sandbox run "echo hello"');
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
terminal?.close();
|
|
309
|
+
}
|
|
310
|
+
if (method === 'google' || method === 'github')
|
|
311
|
+
socialProvider = method;
|
|
312
|
+
}
|
|
198
313
|
const state = crypto.randomBytes(16).toString('hex');
|
|
199
314
|
const appOrigin = new URL(opts.appUrl).origin;
|
|
200
315
|
const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
|
|
201
|
-
const loginUrl = cliAuthUrl(opts.appUrl, port, state);
|
|
316
|
+
const loginUrl = cliAuthUrl(opts.appUrl, port, state, socialProvider);
|
|
202
317
|
console.log(`Opening your browser to authorize the CLI…\n ${loginUrl}\n`);
|
|
203
318
|
console.log("If the browser doesn't open, paste the URL above into it manually.");
|
|
204
319
|
openBrowser(loginUrl);
|
|
@@ -219,6 +334,11 @@ export function registerLogin(program) {
|
|
|
219
334
|
}
|
|
220
335
|
}
|
|
221
336
|
catch (e) {
|
|
337
|
+
if (e instanceof PromptAbortedError) {
|
|
338
|
+
console.log('Cancelled.');
|
|
339
|
+
process.exitCode = 130;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
222
342
|
console.error(e instanceof Error ? e.message : String(e));
|
|
223
343
|
process.exitCode = 1;
|
|
224
344
|
}
|
|
@@ -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:
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -4,6 +4,7 @@ import { writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
5
|
import { ApiClient, friendlyApiError } from '../api.js';
|
|
6
6
|
import { requireCredentials } from '../config.js';
|
|
7
|
+
import { runSandboxShell } from '../shellSession.js';
|
|
7
8
|
import { registerBoxAccessCommands } from './boxAccess.js';
|
|
8
9
|
const MAX_SANDBOX_DISK_GIB = 2048;
|
|
9
10
|
const DEFAULT_BOX_CPU_CORES = 2;
|
|
@@ -151,6 +152,18 @@ export function registerSandbox(program) {
|
|
|
151
152
|
}
|
|
152
153
|
process.exitCode = r.exit_code ?? 0;
|
|
153
154
|
}));
|
|
155
|
+
sandbox
|
|
156
|
+
.command('shell')
|
|
157
|
+
.description('Open an interactive shell in a running sandbox')
|
|
158
|
+
.argument('<id>', 'sandbox id')
|
|
159
|
+
.action((id) => run(async () => {
|
|
160
|
+
const credentials = requireCredentials();
|
|
161
|
+
process.exitCode = await runSandboxShell({
|
|
162
|
+
apiUrl: credentials.apiUrl,
|
|
163
|
+
token: credentials.token,
|
|
164
|
+
sandboxId: id,
|
|
165
|
+
});
|
|
166
|
+
}));
|
|
154
167
|
withOutput(sandbox
|
|
155
168
|
.command('logs')
|
|
156
169
|
.description('Show recent sandbox logs (one-shot journalctl tail; streaming is a follow-up)')
|
package/dist/config.js
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
-
function isRunCloud() {
|
|
5
|
-
const
|
|
6
|
-
return
|
|
4
|
+
export function isRunCloud() {
|
|
5
|
+
const names = [process.argv[1], process.execPath].map((path) => basename(path ?? ''));
|
|
6
|
+
return names.some((name) => name === 'runcloud' || name === 'run-cloud' || name === 'run-cloud.js');
|
|
7
7
|
}
|
|
8
8
|
function credentialsHome() {
|
|
9
|
-
if (process.env.RUN_CLOUD_HOME)
|
|
10
|
-
return process.env.RUN_CLOUD_HOME;
|
|
11
|
-
if (process.env.NEWLY_HOME)
|
|
12
|
-
return process.env.NEWLY_HOME;
|
|
13
9
|
if (isRunCloud())
|
|
14
|
-
return join(homedir(), '.run-cloud');
|
|
10
|
+
return process.env.RUN_CLOUD_HOME ?? join(homedir(), '.run-cloud');
|
|
15
11
|
return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
|
|
16
12
|
}
|
|
17
13
|
function credentialsPath() {
|
|
@@ -22,7 +18,7 @@ export function credentialsDisplayPath() {
|
|
|
22
18
|
const home = homedir();
|
|
23
19
|
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
24
20
|
}
|
|
25
|
-
function loginCommandName() {
|
|
21
|
+
export function loginCommandName() {
|
|
26
22
|
return isRunCloud() ? 'runcloud login' : 'newly login';
|
|
27
23
|
}
|
|
28
24
|
export function normalizeApiUrl(url) {
|
|
@@ -44,7 +40,7 @@ export function loadCredentials() {
|
|
|
44
40
|
if (envToken?.trim() && (isRunCloud() || envApiUrl?.trim())) {
|
|
45
41
|
return {
|
|
46
42
|
token: envToken.trim(),
|
|
47
|
-
apiUrl: normalizeApiUrl(envApiUrl?.trim() || 'https://api.
|
|
43
|
+
apiUrl: normalizeApiUrl(envApiUrl?.trim() || 'https://api.run.cloud'),
|
|
48
44
|
appUrl: isRunCloud()
|
|
49
45
|
? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
|
|
50
46
|
: process.env.NEWLY_APP_URL ?? 'https://newly.app',
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { openSync, closeSync } from 'node:fs';
|
|
2
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
3
|
+
import { ReadStream } from 'node:tty';
|
|
4
|
+
function defaultIO() {
|
|
5
|
+
return { input: process.stdin, output: process.stdout };
|
|
6
|
+
}
|
|
7
|
+
export function openControllingTerminal() {
|
|
8
|
+
let fd;
|
|
9
|
+
try {
|
|
10
|
+
fd = openSync('/dev/tty', 'r+');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const input = new ReadStream(fd);
|
|
17
|
+
if (!input.isTTY)
|
|
18
|
+
throw new Error('/dev/tty is not a terminal');
|
|
19
|
+
return {
|
|
20
|
+
io: { input, output: process.stdout },
|
|
21
|
+
close: () => {
|
|
22
|
+
try {
|
|
23
|
+
input.destroy();
|
|
24
|
+
closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
try {
|
|
33
|
+
closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function acquirePromptIO() {
|
|
41
|
+
const stdinIO = defaultIO();
|
|
42
|
+
if (isInteractive(stdinIO))
|
|
43
|
+
return { io: stdinIO, close: () => { } };
|
|
44
|
+
return openControllingTerminal();
|
|
45
|
+
}
|
|
46
|
+
export function isInteractive(io = defaultIO()) {
|
|
47
|
+
return Boolean(io.input.isTTY && typeof io.input.setRawMode === 'function');
|
|
48
|
+
}
|
|
49
|
+
export function canPrompt() {
|
|
50
|
+
if (isInteractive())
|
|
51
|
+
return true;
|
|
52
|
+
const terminal = openControllingTerminal();
|
|
53
|
+
if (!terminal)
|
|
54
|
+
return false;
|
|
55
|
+
terminal.close();
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
export class PromptAbortedError extends Error {
|
|
59
|
+
constructor() {
|
|
60
|
+
super('Cancelled.');
|
|
61
|
+
this.name = 'PromptAbortedError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function render(io, question, choices, selected, first) {
|
|
65
|
+
if (!first)
|
|
66
|
+
io.output.write(`\x1b[${choices.length + 1}A\x1b[J`);
|
|
67
|
+
else
|
|
68
|
+
io.output.write(`\n${question}\n\n`);
|
|
69
|
+
if (!first)
|
|
70
|
+
io.output.write('\n');
|
|
71
|
+
const width = Math.max(...choices.map((choice) => choice.label.length));
|
|
72
|
+
for (const [index, choice] of choices.entries()) {
|
|
73
|
+
const active = index === selected;
|
|
74
|
+
const marker = active ? '❯' : ' ';
|
|
75
|
+
const label = active ? `\x1b[36m${choice.label}\x1b[0m` : choice.label;
|
|
76
|
+
const padding = ' '.repeat(width - choice.label.length + 4);
|
|
77
|
+
const hint = choice.hint ? `\x1b[90m${padding}${choice.hint}\x1b[0m` : '';
|
|
78
|
+
io.output.write(` ${marker} ${label}${hint}\n`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function select(question, choices, io = defaultIO()) {
|
|
82
|
+
if (choices.length === 0)
|
|
83
|
+
throw new Error('select() needs at least one choice');
|
|
84
|
+
if (choices.length === 1)
|
|
85
|
+
return choices[0].value;
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
let selected = 0;
|
|
88
|
+
let first = true;
|
|
89
|
+
emitKeypressEvents(io.input);
|
|
90
|
+
io.input.setRawMode?.(true);
|
|
91
|
+
io.input.resume();
|
|
92
|
+
render(io, question, choices, selected, first);
|
|
93
|
+
first = false;
|
|
94
|
+
const finish = (action) => {
|
|
95
|
+
io.input.setRawMode?.(false);
|
|
96
|
+
io.input.pause();
|
|
97
|
+
io.input.removeListener('keypress', onKeypress);
|
|
98
|
+
io.output.write('\n');
|
|
99
|
+
action();
|
|
100
|
+
};
|
|
101
|
+
function onKeypress(chunk, key) {
|
|
102
|
+
const name = key?.name ?? '';
|
|
103
|
+
if ((key?.ctrl && name === 'c') || name === 'escape') {
|
|
104
|
+
finish(() => reject(new PromptAbortedError()));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (name === 'return' || name === 'enter') {
|
|
108
|
+
finish(() => resolve(choices[selected].value));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (name === 'up' || name === 'k') {
|
|
112
|
+
selected = (selected - 1 + choices.length) % choices.length;
|
|
113
|
+
}
|
|
114
|
+
else if (name === 'down' || name === 'j' || name === 'tab') {
|
|
115
|
+
selected = (selected + 1) % choices.length;
|
|
116
|
+
}
|
|
117
|
+
else if (/^[1-9]$/.test(chunk ?? '')) {
|
|
118
|
+
const index = Number(chunk) - 1;
|
|
119
|
+
if (index < choices.length)
|
|
120
|
+
selected = index;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
render(io, question, choices, selected, first);
|
|
126
|
+
}
|
|
127
|
+
io.input.on('keypress', onKeypress);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export async function ask(question, options = {}) {
|
|
131
|
+
const { validate, attempts = 3, io = defaultIO() } = options;
|
|
132
|
+
const { createInterface } = await import('node:readline/promises');
|
|
133
|
+
const rl = createInterface({ input: io.input, output: io.output });
|
|
134
|
+
try {
|
|
135
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
136
|
+
const answer = (await rl.question(question)).trim();
|
|
137
|
+
const problem = validate?.(answer) ?? null;
|
|
138
|
+
if (!problem)
|
|
139
|
+
return answer;
|
|
140
|
+
io.output.write(` ${problem}\n`);
|
|
141
|
+
if (attempt === attempts) {
|
|
142
|
+
throw new Error(`${problem} Giving up after ${attempts} attempts.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new Error('unreachable');
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
rl.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/run-cloud.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
2
|
import { Command } from 'commander';
|
|
4
3
|
import { registerLogin } from './commands/login.js';
|
|
5
4
|
import { registerRunCloud } from './commands/run-cloud.js';
|
|
6
5
|
import { registerSandbox } from './commands/sandbox.js';
|
|
7
|
-
|
|
6
|
+
import { CLI_VERSION } from './version.js';
|
|
8
7
|
const program = new Command('runcloud')
|
|
9
8
|
.description('Create and control remote mobile simulators and microVM sandboxes')
|
|
10
|
-
.version(
|
|
9
|
+
.version(CLI_VERSION);
|
|
11
10
|
registerLogin(program);
|
|
12
11
|
registerRunCloud(program);
|
|
13
12
|
registerSandbox(program);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const SESSION_TOKEN_HEADER = 'set-auth-token';
|
|
2
|
+
export class AuthError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.name = 'AuthError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function apiBase(apiUrl) {
|
|
11
|
+
return apiUrl.replace(/\/+$/, '');
|
|
12
|
+
}
|
|
13
|
+
async function readError(response) {
|
|
14
|
+
const body = await response.text();
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(body);
|
|
17
|
+
return parsed.error ?? parsed.message ?? body.slice(0, 200);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return body.slice(0, 200) || response.statusText;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function post(apiUrl, path, body, token) {
|
|
24
|
+
const response = await fetch(`${apiBase(apiUrl)}${path}`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
'content-type': 'application/json',
|
|
28
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify(body),
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok)
|
|
33
|
+
throw new AuthError(await readError(response), response.status);
|
|
34
|
+
const sessionToken = response.headers.get(SESSION_TOKEN_HEADER);
|
|
35
|
+
const text = await response.text();
|
|
36
|
+
const data = (text ? JSON.parse(text) : {});
|
|
37
|
+
return { data, sessionToken: sessionToken?.trim() || null };
|
|
38
|
+
}
|
|
39
|
+
export async function fetchProviders(apiUrl) {
|
|
40
|
+
const response = await fetch(`${apiBase(apiUrl)}/auth/web/providers`);
|
|
41
|
+
if (!response.ok)
|
|
42
|
+
throw new AuthError(await readError(response), response.status);
|
|
43
|
+
const payload = (await response.json());
|
|
44
|
+
return {
|
|
45
|
+
emailOtp: payload.emailOtp === true,
|
|
46
|
+
google: payload.google === true,
|
|
47
|
+
github: payload.github === true,
|
|
48
|
+
apple: payload.apple === true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function sendEmailCode(apiUrl, email) {
|
|
52
|
+
await post(apiUrl, '/api/auth/email-otp/send-verification-otp', { email, type: 'sign-in' });
|
|
53
|
+
}
|
|
54
|
+
export async function verifyEmailCode(apiUrl, email, otp) {
|
|
55
|
+
const { data, sessionToken } = await post(apiUrl, '/api/auth/sign-in/email-otp', { email, otp, name: email.split('@')[0] || 'Run Cloud user' });
|
|
56
|
+
if (!sessionToken) {
|
|
57
|
+
throw new AuthError(`${apiUrl} accepted the code but did not return a session token.`, 502);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
user: { id: data.user?.id ?? '', email: data.user?.email ?? email },
|
|
61
|
+
sessionToken,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export async function createApiKey(apiUrl, sessionToken, name) {
|
|
65
|
+
const { data } = await post(apiUrl, '/run-cloud/api-keys', { name }, sessionToken);
|
|
66
|
+
const key = data.apiKey?.key;
|
|
67
|
+
if (!key)
|
|
68
|
+
throw new AuthError(`${apiUrl} did not return an API key.`, 502);
|
|
69
|
+
return key;
|
|
70
|
+
}
|
|
71
|
+
export function isValidEmail(value) {
|
|
72
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
73
|
+
}
|
|
74
|
+
export function normalizeOtp(value) {
|
|
75
|
+
return value.replace(/[\s-]/g, '');
|
|
76
|
+
}
|
|
77
|
+
export function isValidOtp(value) {
|
|
78
|
+
return /^\d{6}$/.test(normalizeOtp(value));
|
|
79
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
|
-
export async function
|
|
2
|
+
export async function runShellSession(input) {
|
|
3
3
|
const stdin = input.stdin ?? process.stdin;
|
|
4
4
|
const stdout = input.stdout ?? process.stdout;
|
|
5
5
|
const stderr = input.stderr ?? process.stderr;
|
|
6
|
-
const url = new URL(
|
|
6
|
+
const url = new URL(input.path, input.apiUrl);
|
|
7
7
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
8
8
|
const createWebSocket = input.createWebSocket ?? ((address, options) => new WebSocket(address, options));
|
|
9
9
|
const socket = createWebSocket(url.toString(), {
|
|
@@ -60,7 +60,7 @@ export async function runEnvironmentDatabaseShell(input) {
|
|
|
60
60
|
socket.once('close', (code, reason) => {
|
|
61
61
|
restore();
|
|
62
62
|
if (code >= 4400) {
|
|
63
|
-
reject(new Error(reason.toString() ||
|
|
63
|
+
reject(new Error(reason.toString() || `${input.label} rejected (${code})`));
|
|
64
64
|
}
|
|
65
65
|
else {
|
|
66
66
|
resolve(exitCode);
|
|
@@ -68,3 +68,17 @@ export async function runEnvironmentDatabaseShell(input) {
|
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
|
+
export function runEnvironmentDatabaseShell(input) {
|
|
72
|
+
return runShellSession({
|
|
73
|
+
...input,
|
|
74
|
+
path: `/environment-databases/${encodeURIComponent(input.databaseId)}/shell`,
|
|
75
|
+
label: 'Database shell',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export function runSandboxShell(input) {
|
|
79
|
+
return runShellSession({
|
|
80
|
+
...input,
|
|
81
|
+
path: `/run-cloud/sandboxes/${encodeURIComponent(input.sandboxId)}/exec/stream`,
|
|
82
|
+
label: 'Sandbox shell',
|
|
83
|
+
});
|
|
84
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CLI_VERSION = '0.1.9';
|