@newly/cli 0.1.2 → 0.1.91

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.
@@ -0,0 +1,349 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
3
+ import { homedir, hostname as localHostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import WebSocket, { createWebSocketStream } from 'ws';
6
+ import { ApiClient, friendlyApiError } from '../api.js';
7
+ import { requireCredentials } from '../config.js';
8
+ export function noteDeprecated(oldCommand, replacement) {
9
+ process.stderr.write(`runcloud: '${oldCommand}' is deprecated — use '${replacement}'\n`);
10
+ }
11
+ function api() {
12
+ const credentials = requireCredentials();
13
+ return new ApiClient(credentials.apiUrl, credentials.token);
14
+ }
15
+ async function resolveBox(client, reference) {
16
+ const response = await client.get('/run-cloud/boxes');
17
+ const boxes = (response?.items ?? []);
18
+ const box = boxes.find((item) => item.id === reference ||
19
+ item.sandboxId === reference ||
20
+ item.name === reference ||
21
+ item.hostname === reference);
22
+ if (!box) {
23
+ throw new Error(`No exposed sandbox matches ${reference}. Publish it first with \`runcloud sandbox expose ${reference} --name <name>\`, or use \`runcloud sandbox shell ${reference}\` without SSH.`);
24
+ }
25
+ return box;
26
+ }
27
+ async function resolveSandboxId(client, reference) {
28
+ const response = await client.get('/run-cloud/sandboxes');
29
+ const sandboxes = (response?.items ?? []);
30
+ const sandbox = sandboxes.find((item) => item.id === reference || item.name === reference);
31
+ if (sandbox?.id)
32
+ return sandbox.id;
33
+ return (await resolveBox(client, reference)).sandboxId ?? reference;
34
+ }
35
+ export async function writeSandboxFile(client, sandboxId, path, content) {
36
+ const chunkSize = 512 * 1024;
37
+ const count = Math.max(1, Math.ceil(content.byteLength / chunkSize));
38
+ for (let index = 0; index < count; index += 1) {
39
+ const offset = index * chunkSize;
40
+ const chunk = content.subarray(offset, Math.min(content.byteLength, offset + chunkSize));
41
+ const response = await client.post(`/run-cloud/sandboxes/${encodeURIComponent(sandboxId)}/fs`, {
42
+ op: 'write',
43
+ path,
44
+ offset,
45
+ content: chunk.toString('base64'),
46
+ truncate: index === 0,
47
+ });
48
+ if (response?.error)
49
+ throw new Error(String(response.error));
50
+ const expectedNextOffset = offset + chunk.byteLength;
51
+ if (chunk.byteLength > 0
52
+ && (!Number.isSafeInteger(response?.next_offset)
53
+ || response.next_offset !== expectedNextOffset)) {
54
+ throw new Error('file write chunk was not acknowledged; the file may be incomplete');
55
+ }
56
+ }
57
+ }
58
+ async function readSandboxFile(client, sandboxId, path) {
59
+ const chunks = [];
60
+ let offset = 0;
61
+ for (;;) {
62
+ const response = await client.post(`/run-cloud/sandboxes/${encodeURIComponent(sandboxId)}/fs`, {
63
+ op: 'read',
64
+ path,
65
+ offset,
66
+ length: 512 * 1024,
67
+ });
68
+ if (response?.error)
69
+ throw new Error(String(response.error));
70
+ const chunk = Buffer.from(String(response?.content ?? ''), 'base64');
71
+ chunks.push(chunk);
72
+ const next = Number(response?.next_offset ?? offset + chunk.byteLength);
73
+ if (response?.eof === true || chunk.byteLength === 0)
74
+ break;
75
+ if (!Number.isSafeInteger(next) || next <= offset)
76
+ throw new Error('file read did not advance');
77
+ offset = next;
78
+ }
79
+ return Buffer.concat(chunks);
80
+ }
81
+ function sshHome() {
82
+ const base = process.env.RUN_CLOUD_HOME || join(homedir(), '.run-cloud');
83
+ return join(base, 'ssh');
84
+ }
85
+ function keyPath() {
86
+ return join(sshHome(), 'id_ed25519');
87
+ }
88
+ function exec(command, args) {
89
+ return new Promise((resolve, reject) => {
90
+ const child = spawn(command, args, { stdio: 'inherit' });
91
+ child.once('error', reject);
92
+ child.once('exit', (code, signal) => {
93
+ if (signal)
94
+ reject(new Error(`${command} exited on signal ${signal}`));
95
+ else
96
+ resolve(code ?? 1);
97
+ });
98
+ });
99
+ }
100
+ async function ensureSshKey(client, box) {
101
+ mkdirSync(sshHome(), { recursive: true, mode: 0o700 });
102
+ const privateKey = keyPath();
103
+ const publicKey = `${privateKey}.pub`;
104
+ if (!existsSync(privateKey) || !existsSync(publicKey)) {
105
+ const generated = spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', `runcloud@${localHostname()}`, '-f', privateKey], { stdio: 'inherit' });
106
+ if (generated.error)
107
+ throw generated.error;
108
+ if (generated.status !== 0)
109
+ throw new Error(`ssh-keygen failed (${generated.status})`);
110
+ }
111
+ chmodSync(privateKey, 0o600);
112
+ await client.post(`/run-cloud/boxes/${encodeURIComponent(box.id)}/ssh-keys`, {
113
+ publicKey: readFileSync(publicKey, 'utf8').trim(),
114
+ label: `${localHostname()} (Run Cloud CLI)`,
115
+ });
116
+ return privateKey;
117
+ }
118
+ function sshAlias(box) {
119
+ return `runcloud-${box.name}`;
120
+ }
121
+ function writeSshConfig(box, privateKey) {
122
+ const sshDir = join(homedir(), '.ssh');
123
+ const configPath = join(sshDir, 'config');
124
+ mkdirSync(sshDir, { recursive: true, mode: 0o700 });
125
+ const current = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
126
+ const start = `# BEGIN RUN CLOUD ${box.id}`;
127
+ const end = `# END RUN CLOUD ${box.id}`;
128
+ const block = [
129
+ start,
130
+ `Host ${sshAlias(box)}`,
131
+ ` HostName ${box.hostname}`,
132
+ ' User runcloud',
133
+ ` IdentityFile "${privateKey}"`,
134
+ ' IdentitiesOnly yes',
135
+ ' StrictHostKeyChecking accept-new',
136
+ ` UserKnownHostsFile "${join(sshHome(), 'known_hosts')}"`,
137
+ ` ProxyCommand runcloud sandbox proxy ${box.id}`,
138
+ end,
139
+ ].join('\n');
140
+ const pattern = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n?`, 'g');
141
+ const next = `${current.replace(pattern, '').trimEnd()}${current.trim() ? '\n\n' : ''}${block}\n`;
142
+ writeFileSync(configPath, next, { mode: 0o600 });
143
+ chmodSync(configPath, 0o600);
144
+ return sshAlias(box);
145
+ }
146
+ async function setupSsh(reference) {
147
+ const client = api();
148
+ const box = await resolveBox(client, reference);
149
+ const privateKey = await ensureSshKey(client, box);
150
+ return { box, alias: writeSshConfig(box, privateKey) };
151
+ }
152
+ function output(value, opts) {
153
+ if (opts.json)
154
+ console.log(JSON.stringify(value, null, 2));
155
+ else if (typeof value === 'string')
156
+ console.log(value);
157
+ else {
158
+ for (const [key, item] of Object.entries(value)) {
159
+ console.log(`${key}: ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`);
160
+ }
161
+ }
162
+ }
163
+ function action(fn) {
164
+ return fn().catch((error) => {
165
+ console.error(friendlyApiError(error));
166
+ process.exitCode = 1;
167
+ });
168
+ }
169
+ function openUrl(url) {
170
+ if (process.platform === 'darwin')
171
+ return exec('open', [url]);
172
+ if (process.platform === 'win32')
173
+ return exec('cmd', ['/c', 'start', '', url]);
174
+ return exec('xdg-open', [url]);
175
+ }
176
+ const REFERENCE_HELP = 'sandbox id, box id, name, or hostname';
177
+ export function registerAccessCommands(parent, opts = {}) {
178
+ const note = (verb) => {
179
+ if (opts.deprecated)
180
+ noteDeprecated(`box ${verb}`, `sandbox ${verb}`);
181
+ };
182
+ parent
183
+ .command('setup-ssh')
184
+ .description('Install a dedicated key and write this host to ~/.ssh/config')
185
+ .argument('<sandbox>', REFERENCE_HELP)
186
+ .action((reference) => {
187
+ note('setup-ssh');
188
+ return action(async () => {
189
+ const configured = await setupSsh(reference);
190
+ console.log(`SSH configured: ssh ${configured.alias}`);
191
+ });
192
+ });
193
+ parent
194
+ .command('proxy', { hidden: true })
195
+ .description('Authenticated WebSocket transport used by SSH ProxyCommand')
196
+ .argument('<sandbox>', REFERENCE_HELP)
197
+ .action((reference) => action(async () => {
198
+ const client = api();
199
+ const resolved = await resolveBox(client, reference);
200
+ const access = (await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/access-tickets`, { purpose: 'ssh' }));
201
+ if (!access.websocketUrl)
202
+ throw new Error('SSH ticket response has no WebSocket URL');
203
+ const socket = new WebSocket(access.websocketUrl, {
204
+ headers: { 'x-run-cloud-access-ticket': access.ticket },
205
+ });
206
+ const stream = createWebSocketStream(socket, { encoding: undefined });
207
+ process.stdin.pipe(stream);
208
+ stream.pipe(process.stdout);
209
+ await new Promise((resolve, reject) => {
210
+ stream.once('close', resolve);
211
+ stream.once('error', reject);
212
+ socket.once('error', reject);
213
+ });
214
+ }));
215
+ parent
216
+ .command('ssh')
217
+ .description('Open a shell in an exposed sandbox')
218
+ .argument('<sandbox>', REFERENCE_HELP)
219
+ .argument('[command...]', 'optional remote command')
220
+ .action((reference, command) => {
221
+ note('ssh');
222
+ return action(async () => {
223
+ const configured = await setupSsh(reference);
224
+ const code = await exec('ssh', [configured.alias, ...command]);
225
+ if (code !== 0)
226
+ process.exitCode = code;
227
+ });
228
+ });
229
+ parent
230
+ .command('cp')
231
+ .description('Copy a file directly; prefix the remote path with a colon')
232
+ .argument('<sandbox>', REFERENCE_HELP)
233
+ .argument('<source>', 'local path or :/remote/path')
234
+ .argument('<destination>', 'local path or :/remote/path')
235
+ .option('-r, --recursive', 'copy directories recursively', false)
236
+ .action((reference, source, destination, cmdOpts) => {
237
+ note('cp');
238
+ return action(async () => {
239
+ if (source.startsWith(':') === destination.startsWith(':')) {
240
+ throw new Error('Exactly one path must be remote (prefix it with :)');
241
+ }
242
+ if (!cmdOpts.recursive) {
243
+ const client = api();
244
+ const sandboxId = await resolveSandboxId(client, reference);
245
+ if (source.startsWith(':')) {
246
+ writeFileSync(destination, await readSandboxFile(client, sandboxId, source.slice(1)));
247
+ }
248
+ else {
249
+ await writeSandboxFile(client, sandboxId, destination.slice(1), readFileSync(source));
250
+ }
251
+ return;
252
+ }
253
+ const configured = await setupSsh(reference);
254
+ const remote = (path) => path.startsWith(':') ? `${configured.alias}:${path.slice(1)}` : path;
255
+ const args = [
256
+ ...(cmdOpts.recursive ? ['-r'] : []),
257
+ '--',
258
+ remote(source),
259
+ remote(destination),
260
+ ];
261
+ const code = await exec('scp', args);
262
+ if (code !== 0)
263
+ process.exitCode = code;
264
+ });
265
+ });
266
+ parent
267
+ .command('code')
268
+ .description('Open an exposed sandbox in VS Code Remote SSH')
269
+ .argument('<sandbox>', REFERENCE_HELP)
270
+ .argument('[path]', 'remote folder', '/workspace')
271
+ .option('--no-open', 'print the VS Code URL without opening it')
272
+ .action((reference, path, cmdOpts) => {
273
+ note('code');
274
+ return action(async () => {
275
+ const configured = await setupSsh(reference);
276
+ const remotePath = path.startsWith('/') ? path : `/${path}`;
277
+ const url = `vscode://vscode-remote/ssh-remote+${encodeURIComponent(configured.alias)}` +
278
+ remotePath.split('/').map(encodeURIComponent).join('/');
279
+ if (cmdOpts.open === false)
280
+ console.log(url);
281
+ else
282
+ await openUrl(url);
283
+ });
284
+ });
285
+ const domain = parent.command('domain').description('Manage custom domains');
286
+ const domainNote = (verb) => {
287
+ if (opts.deprecated)
288
+ noteDeprecated(`box domain ${verb}`, `sandbox domain ${verb}`);
289
+ };
290
+ domain
291
+ .command('add')
292
+ .argument('<sandbox>', REFERENCE_HELP)
293
+ .argument('<hostname>', 'custom hostname')
294
+ .option('--json', 'output JSON', false)
295
+ .action((reference, hostname, cmdOpts) => {
296
+ domainNote('add');
297
+ return action(async () => {
298
+ const client = api();
299
+ const resolved = await resolveBox(client, reference);
300
+ output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`, {
301
+ hostname,
302
+ }), cmdOpts);
303
+ });
304
+ });
305
+ domain
306
+ .command('list')
307
+ .argument('<sandbox>', REFERENCE_HELP)
308
+ .option('--json', 'output JSON', false)
309
+ .action((reference, cmdOpts) => {
310
+ domainNote('list');
311
+ return action(async () => {
312
+ const client = api();
313
+ const resolved = await resolveBox(client, reference);
314
+ const response = await client.get(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`);
315
+ const items = response?.items ?? [];
316
+ if (cmdOpts.json)
317
+ console.log(JSON.stringify(items, null, 2));
318
+ else
319
+ for (const item of items)
320
+ output(item, cmdOpts);
321
+ });
322
+ });
323
+ domain
324
+ .command('status')
325
+ .argument('<sandbox>', REFERENCE_HELP)
326
+ .argument('<domain-id>', 'domain id from `domain list`')
327
+ .option('--json', 'output JSON', false)
328
+ .action((reference, domainId, cmdOpts) => {
329
+ domainNote('status');
330
+ return action(async () => {
331
+ const client = api();
332
+ const resolved = await resolveBox(client, reference);
333
+ output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}/refresh`), cmdOpts);
334
+ });
335
+ });
336
+ domain
337
+ .command('rm')
338
+ .argument('<sandbox>', REFERENCE_HELP)
339
+ .argument('<domain-id>', 'domain id from `domain list`')
340
+ .action((reference, domainId) => {
341
+ domainNote('rm');
342
+ return action(async () => {
343
+ const client = api();
344
+ const resolved = await resolveBox(client, reference);
345
+ await client.delete(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}`);
346
+ console.log(`removed custom domain ${domainId}`);
347
+ });
348
+ });
349
+ }