@newly/cli 0.1.1 → 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 +489 -3
- 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 +266 -19
- package/dist/commands/run-cloud.js +482 -0
- 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 +72 -7
- package/dist/deviceLogin.js +48 -0
- package/dist/diagnose.js +159 -0
- package/dist/index.js +8 -0
- package/dist/prompt.js +196 -0
- package/dist/run-cloud.js +17 -0
- 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 +23 -7
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
input.on('error', () => {
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
io: { input, output: process.stdout },
|
|
23
|
+
close: () => {
|
|
24
|
+
try {
|
|
25
|
+
input.setRawMode?.(false);
|
|
26
|
+
input.destroy();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
try {
|
|
35
|
+
closeSync(fd);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function acquirePromptIO() {
|
|
43
|
+
const stdinIO = defaultIO();
|
|
44
|
+
if (isInteractive(stdinIO))
|
|
45
|
+
return { io: stdinIO, close: () => { } };
|
|
46
|
+
return openControllingTerminal();
|
|
47
|
+
}
|
|
48
|
+
export function isInteractive(io = defaultIO()) {
|
|
49
|
+
return Boolean(io.input.isTTY && typeof io.input.setRawMode === 'function');
|
|
50
|
+
}
|
|
51
|
+
export function canPrompt() {
|
|
52
|
+
if (isInteractive())
|
|
53
|
+
return true;
|
|
54
|
+
const terminal = openControllingTerminal();
|
|
55
|
+
if (!terminal)
|
|
56
|
+
return false;
|
|
57
|
+
terminal.close();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
export class PromptAbortedError extends Error {
|
|
61
|
+
constructor() {
|
|
62
|
+
super('Cancelled.');
|
|
63
|
+
this.name = 'PromptAbortedError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function render(io, question, choices, selected, first) {
|
|
67
|
+
if (!first)
|
|
68
|
+
io.output.write(`\x1b[${choices.length + 1}A\x1b[J`);
|
|
69
|
+
else
|
|
70
|
+
io.output.write(`\n${question}\n\n`);
|
|
71
|
+
if (!first)
|
|
72
|
+
io.output.write('\n');
|
|
73
|
+
const width = Math.max(...choices.map((choice) => choice.label.length));
|
|
74
|
+
for (const [index, choice] of choices.entries()) {
|
|
75
|
+
const active = index === selected;
|
|
76
|
+
const marker = active ? '❯' : ' ';
|
|
77
|
+
const label = active ? `\x1b[36m${choice.label}\x1b[0m` : choice.label;
|
|
78
|
+
const padding = ' '.repeat(width - choice.label.length + 4);
|
|
79
|
+
const hint = choice.hint ? `\x1b[90m${padding}${choice.hint}\x1b[0m` : '';
|
|
80
|
+
io.output.write(` ${marker} ${label}${hint}\n`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function select(question, choices, io = defaultIO()) {
|
|
84
|
+
if (choices.length === 0)
|
|
85
|
+
throw new Error('select() needs at least one choice');
|
|
86
|
+
if (choices.length === 1)
|
|
87
|
+
return choices[0].value;
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
let selected = 0;
|
|
90
|
+
let first = true;
|
|
91
|
+
emitKeypressEvents(io.input);
|
|
92
|
+
io.input.setRawMode?.(true);
|
|
93
|
+
io.input.resume();
|
|
94
|
+
render(io, question, choices, selected, first);
|
|
95
|
+
first = false;
|
|
96
|
+
const finish = (action) => {
|
|
97
|
+
io.input.setRawMode?.(false);
|
|
98
|
+
io.input.pause();
|
|
99
|
+
io.input.removeListener('keypress', onKeypress);
|
|
100
|
+
io.output.write('\n');
|
|
101
|
+
action();
|
|
102
|
+
};
|
|
103
|
+
function onKeypress(chunk, key) {
|
|
104
|
+
const name = key?.name ?? '';
|
|
105
|
+
if ((key?.ctrl && name === 'c') || name === 'escape') {
|
|
106
|
+
finish(() => reject(new PromptAbortedError()));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (name === 'return' || name === 'enter') {
|
|
110
|
+
finish(() => resolve(choices[selected].value));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (name === 'up' || name === 'k') {
|
|
114
|
+
selected = (selected - 1 + choices.length) % choices.length;
|
|
115
|
+
}
|
|
116
|
+
else if (name === 'down' || name === 'j' || name === 'tab') {
|
|
117
|
+
selected = (selected + 1) % choices.length;
|
|
118
|
+
}
|
|
119
|
+
else if (/^[1-9]$/.test(chunk ?? '')) {
|
|
120
|
+
const index = Number(chunk) - 1;
|
|
121
|
+
if (index < choices.length)
|
|
122
|
+
selected = index;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
render(io, question, choices, selected, first);
|
|
128
|
+
}
|
|
129
|
+
io.input.on('keypress', onKeypress);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
export async function ask(question, options = {}) {
|
|
133
|
+
const { validate, attempts = 3, io = defaultIO() } = options;
|
|
134
|
+
const { createInterface } = await import('node:readline/promises');
|
|
135
|
+
const rl = createInterface({ input: io.input, output: io.output });
|
|
136
|
+
try {
|
|
137
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
138
|
+
const answer = (await rl.question(question)).trim();
|
|
139
|
+
const problem = validate?.(answer) ?? null;
|
|
140
|
+
if (!problem)
|
|
141
|
+
return answer;
|
|
142
|
+
io.output.write(` ${problem}\n`);
|
|
143
|
+
if (attempt === attempts) {
|
|
144
|
+
throw new Error(`${problem} Giving up after ${attempts} attempts.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new Error('unreachable');
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
rl.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export async function promptHidden(label) {
|
|
154
|
+
const acquired = acquirePromptIO();
|
|
155
|
+
if (!acquired) {
|
|
156
|
+
const chunks = [];
|
|
157
|
+
for await (const chunk of process.stdin)
|
|
158
|
+
chunks.push(Buffer.from(chunk));
|
|
159
|
+
return chunks.join('').replace(/\n$/, '');
|
|
160
|
+
}
|
|
161
|
+
const { io, close } = acquired;
|
|
162
|
+
const input = io.input;
|
|
163
|
+
const output = io.output;
|
|
164
|
+
output.write(label);
|
|
165
|
+
input.setRawMode?.(true);
|
|
166
|
+
emitKeypressEvents(input);
|
|
167
|
+
input.resume();
|
|
168
|
+
return await new Promise((resolve, reject) => {
|
|
169
|
+
let value = '';
|
|
170
|
+
const finish = (fn) => {
|
|
171
|
+
input.setRawMode?.(false);
|
|
172
|
+
input.pause();
|
|
173
|
+
input.removeListener('keypress', onKeypress);
|
|
174
|
+
output.write('\n');
|
|
175
|
+
close();
|
|
176
|
+
fn();
|
|
177
|
+
};
|
|
178
|
+
function onKeypress(_str, key) {
|
|
179
|
+
if (key.ctrl && key.name === 'c') {
|
|
180
|
+
finish(() => reject(new Error('cancelled')));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
184
|
+
finish(() => resolve(value));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (key.name === 'backspace') {
|
|
188
|
+
value = value.slice(0, -1);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (key.sequence && !key.ctrl && !key.meta)
|
|
192
|
+
value += key.sequence;
|
|
193
|
+
}
|
|
194
|
+
input.on('keypress', onKeypress);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { registerImage } from './commands/image.js';
|
|
4
|
+
import { registerLogin } from './commands/login.js';
|
|
5
|
+
import { registerRunCloud } from './commands/run-cloud.js';
|
|
6
|
+
import { registerSandbox } from './commands/sandbox.js';
|
|
7
|
+
import { registerSecrets } from './commands/secrets.js';
|
|
8
|
+
import { CLI_VERSION } from './version.js';
|
|
9
|
+
const program = new Command('runcloud')
|
|
10
|
+
.description('Create and control remote mobile simulators and microVM sandboxes')
|
|
11
|
+
.version(CLI_VERSION);
|
|
12
|
+
registerLogin(program);
|
|
13
|
+
registerRunCloud(program);
|
|
14
|
+
registerSandbox(program);
|
|
15
|
+
registerSecrets(program);
|
|
16
|
+
registerImage(program);
|
|
17
|
+
await program.parseAsync(process.argv);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
export function streamExitCode(frame) {
|
|
3
|
+
const code = frame.code ?? frame.exit_code ?? 0;
|
|
4
|
+
return Number.isSafeInteger(code) ? code : undefined;
|
|
5
|
+
}
|
|
6
|
+
export function shellCommand(argv) {
|
|
7
|
+
if (argv.length === 1)
|
|
8
|
+
return argv[0] ?? '';
|
|
9
|
+
return argv
|
|
10
|
+
.map((part) => `'${part.replaceAll("'", "'\"'\"'")}'`)
|
|
11
|
+
.join(' ');
|
|
12
|
+
}
|
|
13
|
+
export function runSandboxCommand(input) {
|
|
14
|
+
const url = new URL(`/run-cloud/sandboxes/${encodeURIComponent(input.sandboxId)}/exec/stream?mode=command`, input.apiUrl);
|
|
15
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
16
|
+
const socket = input.createSocket?.(url.toString(), {
|
|
17
|
+
headers: { Authorization: `Bearer ${input.token}` },
|
|
18
|
+
}) ??
|
|
19
|
+
new WebSocket(url, {
|
|
20
|
+
headers: { Authorization: `Bearer ${input.token}` },
|
|
21
|
+
});
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const stdout = [];
|
|
24
|
+
const stderr = [];
|
|
25
|
+
let exitCode;
|
|
26
|
+
let settled = false;
|
|
27
|
+
const finish = (error) => {
|
|
28
|
+
if (settled)
|
|
29
|
+
return;
|
|
30
|
+
settled = true;
|
|
31
|
+
if (error) {
|
|
32
|
+
reject(error);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (exitCode === undefined) {
|
|
36
|
+
reject(new Error('sandbox exec stream closed before reporting an exit code'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
resolve({
|
|
40
|
+
exit_code: exitCode,
|
|
41
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
42
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
socket.once('open', () => socket.send(JSON.stringify(input.request)));
|
|
46
|
+
socket.on('message', (raw) => {
|
|
47
|
+
let frame;
|
|
48
|
+
try {
|
|
49
|
+
frame = JSON.parse(raw.toString());
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
socket.close(1002, 'invalid exec frame');
|
|
53
|
+
finish(new Error('sandbox exec stream returned invalid JSON'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (frame.stream === 'stdout' || frame.stream === 'stderr') {
|
|
57
|
+
if (typeof frame.data !== 'string')
|
|
58
|
+
return;
|
|
59
|
+
const chunk = Buffer.from(frame.data, 'base64');
|
|
60
|
+
if (frame.stream === 'stdout') {
|
|
61
|
+
stdout.push(chunk);
|
|
62
|
+
input.onStdout?.(chunk);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
stderr.push(chunk);
|
|
66
|
+
input.onStderr?.(chunk);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (frame.stream === 'exit') {
|
|
71
|
+
const code = streamExitCode(frame);
|
|
72
|
+
if (code === undefined) {
|
|
73
|
+
socket.close(1002, 'invalid exit code');
|
|
74
|
+
finish(new Error('sandbox exec stream returned an invalid exit code'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
exitCode = code;
|
|
78
|
+
socket.close(1000);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
socket.once('error', (error) => finish(error));
|
|
82
|
+
socket.once('close', (code, reason) => {
|
|
83
|
+
if (exitCode !== undefined)
|
|
84
|
+
finish();
|
|
85
|
+
else {
|
|
86
|
+
finish(new Error(reason.toString() ||
|
|
87
|
+
`sandbox exec stream closed before completion (${code})`));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -2,7 +2,6 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
export const DEFAULT_BACKEND_SERVICES = [
|
|
4
4
|
{ name: 'api', path: 'api-service', type: 'worker', schema: 'api', secrets: 'api', public: true },
|
|
5
|
-
{ name: 'auth', path: 'auth-service', type: 'worker', schema: 'auth', secrets: 'auth', public: true },
|
|
6
5
|
];
|
|
7
6
|
const SERVICE_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
8
7
|
export function readBackendServices(dir) {
|
|
@@ -33,9 +32,39 @@ export function readBackendServices(dir) {
|
|
|
33
32
|
schema: entry.schema === undefined ? undefined : entry.schema === null ? null : String(entry.schema),
|
|
34
33
|
secrets: entry.secrets === undefined ? undefined : String(entry.secrets),
|
|
35
34
|
public: entry.public === undefined ? true : Boolean(entry.public),
|
|
35
|
+
bindings: parseBindings(name, entry.bindings),
|
|
36
36
|
};
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
+
function parseBindings(serviceName, value) {
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
43
|
+
throw new Error(`Service ${serviceName} bindings must be an object`);
|
|
44
|
+
}
|
|
45
|
+
const bindings = {};
|
|
46
|
+
for (const [bindingName, raw] of Object.entries(value)) {
|
|
47
|
+
if (!/^[A-Z][A-Z0-9_]{0,127}$/.test(bindingName)) {
|
|
48
|
+
throw new Error(`Invalid binding name for service ${serviceName}: ${bindingName}`);
|
|
49
|
+
}
|
|
50
|
+
if (bindingName.startsWith('NEWLY_')) {
|
|
51
|
+
throw new Error(`Service ${serviceName} cannot override reserved binding ${bindingName}`);
|
|
52
|
+
}
|
|
53
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
54
|
+
throw new Error(`Binding ${bindingName} for service ${serviceName} must be an object`);
|
|
55
|
+
}
|
|
56
|
+
const entries = Object.entries(raw);
|
|
57
|
+
if (entries.length !== 1 || !['database', 'secret', 'storage'].includes(entries[0][0])) {
|
|
58
|
+
throw new Error(`Binding ${bindingName} for service ${serviceName} must declare exactly one database, secret, or storage`);
|
|
59
|
+
}
|
|
60
|
+
const [kind, logicalName] = entries[0];
|
|
61
|
+
if (typeof logicalName !== 'string' || logicalName.length === 0) {
|
|
62
|
+
throw new Error(`Binding ${bindingName} for service ${serviceName} has an invalid logical name`);
|
|
63
|
+
}
|
|
64
|
+
bindings[bindingName] = { [kind]: logicalName };
|
|
65
|
+
}
|
|
66
|
+
return bindings;
|
|
67
|
+
}
|
|
39
68
|
export function parseServiceFilter(value) {
|
|
40
69
|
if (!value)
|
|
41
70
|
return [];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
export async function runShellSession(input) {
|
|
3
|
+
const stdin = input.stdin ?? process.stdin;
|
|
4
|
+
const stdout = input.stdout ?? process.stdout;
|
|
5
|
+
const stderr = input.stderr ?? process.stderr;
|
|
6
|
+
const url = new URL(input.path, input.apiUrl);
|
|
7
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
8
|
+
const createWebSocket = input.createWebSocket ?? ((address, options) => new WebSocket(address, options));
|
|
9
|
+
const socket = createWebSocket(url.toString(), {
|
|
10
|
+
headers: { Authorization: `Bearer ${input.token}` },
|
|
11
|
+
});
|
|
12
|
+
const ttyInput = stdin;
|
|
13
|
+
const ttyOutput = stdout;
|
|
14
|
+
const wasRaw = ttyInput.isRaw;
|
|
15
|
+
let exitCode = 1;
|
|
16
|
+
const sendResize = () => {
|
|
17
|
+
if (socket.readyState === WebSocket.OPEN && ttyOutput.rows && ttyOutput.columns) {
|
|
18
|
+
socket.send(JSON.stringify({ stream: 'resize', rows: ttyOutput.rows, cols: ttyOutput.columns }));
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const onInput = (data) => {
|
|
22
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
23
|
+
socket.send(JSON.stringify({ stream: 'stdin', data: Buffer.from(data).toString('base64') }));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const restore = () => {
|
|
27
|
+
stdin.off('data', onInput);
|
|
28
|
+
ttyOutput.off?.('resize', sendResize);
|
|
29
|
+
if (ttyInput.isTTY && typeof ttyInput.setRawMode === 'function')
|
|
30
|
+
ttyInput.setRawMode(Boolean(wasRaw));
|
|
31
|
+
stdin.pause();
|
|
32
|
+
};
|
|
33
|
+
return await new Promise((resolve, reject) => {
|
|
34
|
+
socket.once('open', () => {
|
|
35
|
+
if (ttyInput.isTTY && typeof ttyInput.setRawMode === 'function')
|
|
36
|
+
ttyInput.setRawMode(true);
|
|
37
|
+
stdin.resume();
|
|
38
|
+
stdin.on('data', onInput);
|
|
39
|
+
ttyOutput.on?.('resize', sendResize);
|
|
40
|
+
sendResize();
|
|
41
|
+
});
|
|
42
|
+
socket.on('message', (raw) => {
|
|
43
|
+
try {
|
|
44
|
+
const frame = JSON.parse(raw.toString());
|
|
45
|
+
if ((frame.stream === 'stdout' || frame.stream === 'stderr') && typeof frame.data === 'string') {
|
|
46
|
+
const target = frame.stream === 'stderr' ? stderr : stdout;
|
|
47
|
+
target.write(Buffer.from(frame.data, 'base64'));
|
|
48
|
+
}
|
|
49
|
+
else if (frame.stream === 'exit') {
|
|
50
|
+
const code = frame.code ?? frame.exit_code ?? 0;
|
|
51
|
+
if (Number.isSafeInteger(code))
|
|
52
|
+
exitCode = code;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
socket.once('error', (error) => {
|
|
59
|
+
restore();
|
|
60
|
+
reject(error);
|
|
61
|
+
});
|
|
62
|
+
socket.once('close', (code, reason) => {
|
|
63
|
+
restore();
|
|
64
|
+
if (code >= 4400) {
|
|
65
|
+
reject(new Error(reason.toString() || `${input.label} rejected (${code})`));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
resolve(exitCode);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function runEnvironmentDatabaseShell(input) {
|
|
74
|
+
return runShellSession({
|
|
75
|
+
...input,
|
|
76
|
+
path: `/environment-databases/${encodeURIComponent(input.databaseId)}/shell`,
|
|
77
|
+
label: 'Database shell',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function runSandboxShell(input) {
|
|
81
|
+
return runShellSession({
|
|
82
|
+
...input,
|
|
83
|
+
path: `/run-cloud/sandboxes/${encodeURIComponent(input.sandboxId)}/exec/stream`,
|
|
84
|
+
label: 'Sandbox shell',
|
|
85
|
+
});
|
|
86
|
+
}
|
package/dist/terminal.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
2
|
+
function supportsColor(stream) {
|
|
3
|
+
return stream.isTTY === true && process.env.NO_COLOR === undefined;
|
|
4
|
+
}
|
|
5
|
+
function paint(code, value, stream = process.stdout) {
|
|
6
|
+
return supportsColor(stream) ? `\u001b[${code}m${value}\u001b[0m` : value;
|
|
7
|
+
}
|
|
8
|
+
export const style = {
|
|
9
|
+
bold: (value, stream) => paint(1, value, stream),
|
|
10
|
+
dim: (value, stream) => paint(2, value, stream),
|
|
11
|
+
red: (value, stream) => paint(31, value, stream),
|
|
12
|
+
green: (value, stream) => paint(32, value, stream),
|
|
13
|
+
yellow: (value, stream) => paint(33, value, stream),
|
|
14
|
+
blue: (value, stream) => paint(34, value, stream),
|
|
15
|
+
cyan: (value, stream) => paint(36, value, stream),
|
|
16
|
+
};
|
|
17
|
+
function visibleLength(value) {
|
|
18
|
+
return value.replace(ANSI_PATTERN, '').length;
|
|
19
|
+
}
|
|
20
|
+
function truncate(value, maxWidth) {
|
|
21
|
+
if (!maxWidth || visibleLength(value) <= maxWidth)
|
|
22
|
+
return value;
|
|
23
|
+
if (maxWidth <= 1)
|
|
24
|
+
return '…';
|
|
25
|
+
return `${value.slice(0, maxWidth - 1)}…`;
|
|
26
|
+
}
|
|
27
|
+
function pad(value, width) {
|
|
28
|
+
return `${value}${' '.repeat(Math.max(0, width - visibleLength(value)))}`;
|
|
29
|
+
}
|
|
30
|
+
export function renderTable(title, columns, rows) {
|
|
31
|
+
if (rows.length === 0)
|
|
32
|
+
return title;
|
|
33
|
+
const cells = rows.map((row) => Object.fromEntries(columns.map((column) => [
|
|
34
|
+
column.key,
|
|
35
|
+
truncate(row[column.key] === undefined || row[column.key] === null ? '—' : String(row[column.key]), column.maxWidth),
|
|
36
|
+
])));
|
|
37
|
+
const widths = Object.fromEntries(columns.map((column) => [
|
|
38
|
+
column.key,
|
|
39
|
+
Math.max(visibleLength(column.label), ...cells.map((row) => visibleLength(row[column.key]))),
|
|
40
|
+
]));
|
|
41
|
+
const line = (row) => columns
|
|
42
|
+
.map((column, index) => {
|
|
43
|
+
const value = row[column.key];
|
|
44
|
+
return index === columns.length - 1 ? value : pad(value, widths[column.key]);
|
|
45
|
+
})
|
|
46
|
+
.join(' ')
|
|
47
|
+
.trimEnd();
|
|
48
|
+
const header = Object.fromEntries(columns.map((column) => [column.key, style.dim(column.label)]));
|
|
49
|
+
return `${style.bold(title)}\n\n${line(header)}\n${cells.map((row) => line(row)).join('\n')}`;
|
|
50
|
+
}
|
|
51
|
+
export function renderDetails(title, fields, footer) {
|
|
52
|
+
const visibleFields = fields.filter(({ value }) => value !== undefined && value !== null && value !== '');
|
|
53
|
+
const labelWidth = Math.max(...visibleFields.map(({ label }) => label.length), 0);
|
|
54
|
+
const lines = visibleFields.map(({ label, value }) => ` ${style.dim(pad(label, labelWidth))} ${String(value)}`);
|
|
55
|
+
return [style.bold(title), ...lines, footer ? `\n${footer}` : undefined]
|
|
56
|
+
.filter((value) => value !== undefined)
|
|
57
|
+
.join('\n');
|
|
58
|
+
}
|
|
59
|
+
export function formatState(state) {
|
|
60
|
+
const value = typeof state === 'string' && state ? state : 'unknown';
|
|
61
|
+
if (value === 'running' || value === 'ready')
|
|
62
|
+
return style.green(value);
|
|
63
|
+
if (['building', 'building_image', 'pending', 'starting', 'destroying'].includes(value)) {
|
|
64
|
+
return style.yellow(value.replaceAll('_', ' '));
|
|
65
|
+
}
|
|
66
|
+
if (value === 'paused' || value === 'archived' || value === 'stopped') {
|
|
67
|
+
return style.blue(value);
|
|
68
|
+
}
|
|
69
|
+
if (value === 'failed' || value === 'interrupted' || value === 'destroyed') {
|
|
70
|
+
return style.red(value);
|
|
71
|
+
}
|
|
72
|
+
return value.replaceAll('_', ' ');
|
|
73
|
+
}
|
|
74
|
+
function rounded(value, fractionDigits = 1) {
|
|
75
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(fractionDigits).replace(/\.0$/, '');
|
|
76
|
+
}
|
|
77
|
+
export function formatCpu(milliCpu) {
|
|
78
|
+
if (typeof milliCpu !== 'number' || !Number.isFinite(milliCpu))
|
|
79
|
+
return undefined;
|
|
80
|
+
return `${rounded(milliCpu / 1000, 3)} CPU`;
|
|
81
|
+
}
|
|
82
|
+
export function formatMib(mebibytes) {
|
|
83
|
+
if (typeof mebibytes !== 'number' || !Number.isFinite(mebibytes))
|
|
84
|
+
return undefined;
|
|
85
|
+
if (mebibytes >= 1024)
|
|
86
|
+
return `${rounded(mebibytes / 1024)} GiB`;
|
|
87
|
+
return `${rounded(mebibytes)} MiB`;
|
|
88
|
+
}
|
|
89
|
+
export function formatSeconds(seconds) {
|
|
90
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds))
|
|
91
|
+
return undefined;
|
|
92
|
+
if (seconds === 0)
|
|
93
|
+
return 'never';
|
|
94
|
+
if (seconds < 60)
|
|
95
|
+
return `${rounded(seconds)}s`;
|
|
96
|
+
if (seconds < 3600)
|
|
97
|
+
return `${rounded(seconds / 60)}m`;
|
|
98
|
+
if (seconds < 86_400)
|
|
99
|
+
return `${rounded(seconds / 3600)}h`;
|
|
100
|
+
return `${rounded(seconds / 86_400)}d`;
|
|
101
|
+
}
|
|
102
|
+
export function formatAge(value, now = Date.now()) {
|
|
103
|
+
if (typeof value !== 'string')
|
|
104
|
+
return undefined;
|
|
105
|
+
const timestamp = Date.parse(value);
|
|
106
|
+
if (!Number.isFinite(timestamp))
|
|
107
|
+
return value;
|
|
108
|
+
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
|
109
|
+
if (seconds < 5)
|
|
110
|
+
return 'just now';
|
|
111
|
+
return `${formatSeconds(seconds)} ago`;
|
|
112
|
+
}
|
|
113
|
+
export function shortDigest(value) {
|
|
114
|
+
if (typeof value !== 'string' || !value)
|
|
115
|
+
return undefined;
|
|
116
|
+
const digest = value.startsWith('sha256:') ? value.slice(7) : value;
|
|
117
|
+
return digest.slice(0, 12);
|
|
118
|
+
}
|
|
119
|
+
export function printJson(value) {
|
|
120
|
+
console.log(JSON.stringify(value, null, 2));
|
|
121
|
+
}
|
|
122
|
+
export class StatusLine {
|
|
123
|
+
lastMessage;
|
|
124
|
+
frame = 0;
|
|
125
|
+
update(message) {
|
|
126
|
+
if (process.stderr.isTTY) {
|
|
127
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
128
|
+
const prefix = style.cyan(frames[this.frame++ % frames.length], process.stderr);
|
|
129
|
+
process.stderr.write(`\r\u001b[2K${prefix} ${message}`);
|
|
130
|
+
this.lastMessage = message;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (message !== this.lastMessage) {
|
|
134
|
+
process.stderr.write(`→ ${message}\n`);
|
|
135
|
+
this.lastMessage = message;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
succeed(message) {
|
|
139
|
+
this.finish(`${style.green('✓', process.stderr)} ${message}`);
|
|
140
|
+
}
|
|
141
|
+
fail(message) {
|
|
142
|
+
this.finish(`${style.red('✗', process.stderr)} ${message}`);
|
|
143
|
+
}
|
|
144
|
+
clear() {
|
|
145
|
+
if (process.stderr.isTTY && this.lastMessage)
|
|
146
|
+
process.stderr.write('\r\u001b[2K');
|
|
147
|
+
this.lastMessage = undefined;
|
|
148
|
+
}
|
|
149
|
+
finish(message) {
|
|
150
|
+
if (process.stderr.isTTY && this.lastMessage) {
|
|
151
|
+
process.stderr.write(`\r\u001b[2K${message}\n`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
process.stderr.write(`${message}\n`);
|
|
155
|
+
}
|
|
156
|
+
this.lastMessage = undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CLI_VERSION = '0.1.27';
|