runcloud 0.1.15 → 0.1.17

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,203 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { requireCredentials } from '../config.js';
4
+ import { promptHidden } from '../prompt.js';
5
+ function api() {
6
+ const credentials = requireCredentials();
7
+ return new ApiClient(credentials.apiUrl, credentials.token);
8
+ }
9
+ async function run(fn) {
10
+ try {
11
+ await fn();
12
+ }
13
+ catch (err) {
14
+ console.error(friendlyApiError(err));
15
+ process.exitCode = 1;
16
+ }
17
+ }
18
+ function orgQuery(org, extra = {}) {
19
+ const params = new URLSearchParams(extra);
20
+ if (org)
21
+ params.set('orgId', org);
22
+ const qs = params.toString();
23
+ return qs ? `?${qs}` : '';
24
+ }
25
+ export function parseDotenv(text) {
26
+ const entries = {};
27
+ for (const raw of text.split('\n')) {
28
+ const line = raw.trim();
29
+ if (!line || line.startsWith('#'))
30
+ continue;
31
+ const withoutExport = line.startsWith('export ') ? line.slice('export '.length).trim() : line;
32
+ const eq = withoutExport.indexOf('=');
33
+ if (eq <= 0)
34
+ continue;
35
+ const name = withoutExport.slice(0, eq).trim();
36
+ let value = withoutExport.slice(eq + 1).trim();
37
+ if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
38
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
39
+ value = value.slice(1, -1);
40
+ }
41
+ entries[name] = value;
42
+ }
43
+ return entries;
44
+ }
45
+ export function parseJsonEntries(text) {
46
+ const parsed = JSON.parse(text);
47
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
48
+ throw new Error('--from-json needs a JSON object of name → value');
49
+ }
50
+ const entries = {};
51
+ for (const [name, value] of Object.entries(parsed)) {
52
+ if (typeof value !== 'string') {
53
+ throw new Error(`--from-json: "${name}" is not a string; secrets are strings`);
54
+ }
55
+ entries[name] = value;
56
+ }
57
+ return entries;
58
+ }
59
+ async function readStdin() {
60
+ const chunks = [];
61
+ for await (const chunk of process.stdin)
62
+ chunks.push(Buffer.from(chunk));
63
+ return Buffer.concat(chunks).toString('utf8');
64
+ }
65
+ export function registerSecrets(program) {
66
+ const groups = program
67
+ .command('secret-group')
68
+ .description('Create and manage secret groups a sandbox can attach');
69
+ groups
70
+ .command('create')
71
+ .description('Create a group together with its contents')
72
+ .argument('<group>', 'group name, e.g. aws')
73
+ .argument('[unsupported...]', 'not accepted: a value cannot be passed as an argument')
74
+ .option('--from-dotenv <path>', 'read KEY=VALUE lines from a .env file')
75
+ .option('--from-json <path>', 'read a JSON object of name → value')
76
+ .option('--key <name>', 'prompt for this value (hidden); repeatable', collect, [])
77
+ .option('--force', 'replace the group if it already exists', false)
78
+ .option('--org <id>', 'organization to store it in')
79
+ .action((group, unsupported, opts) => run(async () => {
80
+ if (unsupported.length > 0) {
81
+ throw new Error(`values cannot be passed as arguments (got "${unsupported[0]?.split('=')[0]}=…"). ` +
82
+ 'An argument lands in your shell history, in `ps` output, and in CI logs. ' +
83
+ 'Use --from-dotenv <path>, --from-json <path>, or --key <name> for a hidden prompt.');
84
+ }
85
+ const entries = {};
86
+ if (opts.fromDotenv)
87
+ Object.assign(entries, parseDotenv(readFileSync(opts.fromDotenv, 'utf8')));
88
+ if (opts.fromJson)
89
+ Object.assign(entries, parseJsonEntries(readFileSync(opts.fromJson, 'utf8')));
90
+ for (const name of opts.key) {
91
+ entries[name] = await promptHidden(`${name}: `);
92
+ }
93
+ if (Object.keys(entries).length === 0) {
94
+ throw new Error('nothing to store: pass --from-dotenv <path>, --from-json <path>, or --key <name>. ' +
95
+ 'A value cannot be given as an argument — it would land in your shell history.');
96
+ }
97
+ const body = { entries };
98
+ if (opts.force)
99
+ body.force = true;
100
+ await api().put(`/run-cloud/secret-groups/${encodeURIComponent(group)}${orgQuery(opts.org)}`, body);
101
+ console.log(`secret group ${group}: ${Object.keys(entries).length} secret(s) stored`);
102
+ }));
103
+ groups
104
+ .command('list')
105
+ .description('List secret groups')
106
+ .option('--json', 'output JSON', false)
107
+ .option('--org <id>', 'organization to read from')
108
+ .action((opts) => run(async () => {
109
+ const data = await api().get(`/run-cloud/secret-groups${orgQuery(opts.org)}`);
110
+ const rows = (data?.groups ?? []);
111
+ if (opts.json) {
112
+ console.log(JSON.stringify(rows, null, 2));
113
+ return;
114
+ }
115
+ if (rows.length === 0) {
116
+ console.log('no secret groups yet — runcloud secret-group create <name> --from-dotenv <path>');
117
+ return;
118
+ }
119
+ for (const row of rows)
120
+ console.log(`${row.group}\t${row.secretCount} secret(s)\t${row.updatedAt}`);
121
+ }));
122
+ groups
123
+ .command('show')
124
+ .description('List the names in a group (never values)')
125
+ .argument('<group>', 'group name')
126
+ .option('--json', 'output JSON', false)
127
+ .option('--org <id>', 'organization to read from')
128
+ .action((group, opts) => run(async () => {
129
+ const data = await api().get(`/run-cloud/secrets${orgQuery(opts.org)}`);
130
+ const rows = (data?.secrets ?? []).filter((row) => row.group === group);
131
+ if (opts.json) {
132
+ console.log(JSON.stringify(rows, null, 2));
133
+ return;
134
+ }
135
+ if (rows.length === 0) {
136
+ console.log(`no secret group ${group}`);
137
+ return;
138
+ }
139
+ for (const row of rows) {
140
+ console.log(row.kind === 'file' ? `${row.name}\tfile → ${row.filePath}` : `${row.name}\tenv`);
141
+ }
142
+ }));
143
+ groups
144
+ .command('rm')
145
+ .description('Delete a group and every secret in it')
146
+ .argument('<group>', 'group name')
147
+ .option('--org <id>', 'organization to delete from')
148
+ .action((group, opts) => run(async () => {
149
+ await api().delete(`/run-cloud/secret-groups/${encodeURIComponent(group)}${orgQuery(opts.org)}`);
150
+ console.log(`secret group ${group} deleted`);
151
+ }));
152
+ const secrets = program
153
+ .command('secrets')
154
+ .description('Set and remove individual secrets inside a group');
155
+ secrets
156
+ .command('set')
157
+ .description('Store one environment-variable secret, reading the value from stdin')
158
+ .argument('<name>', 'secret name, e.g. AWS_SESSION_TOKEN')
159
+ .requiredOption('--group <group>', 'group to store it in')
160
+ .option('--stdin', 'read the value from stdin', false)
161
+ .option('--org <id>', 'organization to store it in')
162
+ .action((name, opts) => run(async () => {
163
+ const value = opts.stdin ? (await readStdin()).replace(/\n$/, '') : await promptHidden(`${name}: `);
164
+ if (!value)
165
+ throw new Error(`no value read for ${name}`);
166
+ await api().put(`/run-cloud/secrets/${encodeURIComponent(name)}${orgQuery(opts.org)}`, {
167
+ group: opts.group,
168
+ value,
169
+ kind: 'env',
170
+ });
171
+ console.log(`${opts.group}/${name} stored`);
172
+ }));
173
+ secrets
174
+ .command('set-file')
175
+ .description('Store a file secret, written into the sandbox at --path')
176
+ .argument('<name>', 'secret name, e.g. sa.json')
177
+ .requiredOption('--group <group>', 'group to store it in')
178
+ .requiredOption('--path <path>', 'path inside the sandbox, relative to its home')
179
+ .requiredOption('--from-file <path>', 'local file to read the contents from')
180
+ .option('--org <id>', 'organization to store it in')
181
+ .action((name, opts) => run(async () => {
182
+ await api().put(`/run-cloud/secrets/${encodeURIComponent(name)}${orgQuery(opts.org)}`, {
183
+ group: opts.group,
184
+ value: readFileSync(opts.fromFile, 'utf8'),
185
+ kind: 'file',
186
+ filePath: opts.path,
187
+ });
188
+ console.log(`${opts.group}/${name} stored → ${opts.path}`);
189
+ }));
190
+ secrets
191
+ .command('rm')
192
+ .description('Remove one secret from a group')
193
+ .argument('<name>', 'secret name')
194
+ .requiredOption('--group <group>', 'group it lives in')
195
+ .option('--org <id>', 'organization to delete from')
196
+ .action((name, opts) => run(async () => {
197
+ await api().delete(`/run-cloud/secrets/${encodeURIComponent(name)}${orgQuery(opts.org, { group: opts.group })}`);
198
+ console.log(`${opts.group}/${name} deleted`);
199
+ }));
200
+ }
201
+ function collect(value, previous) {
202
+ return [...previous, value];
203
+ }
package/dist/prompt.js CHANGED
@@ -150,3 +150,47 @@ export async function ask(question, options = {}) {
150
150
  rl.close();
151
151
  }
152
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
+ }
package/dist/run-cloud.js CHANGED
@@ -4,6 +4,7 @@ import { registerImage } from './commands/image.js';
4
4
  import { registerLogin } from './commands/login.js';
5
5
  import { registerRunCloud } from './commands/run-cloud.js';
6
6
  import { registerSandbox } from './commands/sandbox.js';
7
+ import { registerSecrets } from './commands/secrets.js';
7
8
  import { CLI_VERSION } from './version.js';
8
9
  const program = new Command('runcloud')
9
10
  .description('Create and control remote mobile simulators and microVM sandboxes')
@@ -11,5 +12,6 @@ const program = new Command('runcloud')
11
12
  registerLogin(program);
12
13
  registerRunCloud(program);
13
14
  registerSandbox(program);
15
+ registerSecrets(program);
14
16
  registerImage(program);
15
17
  await program.parseAsync(process.argv);
@@ -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
+ }
@@ -46,8 +46,10 @@ export async function runShellSession(input) {
46
46
  const target = frame.stream === 'stderr' ? stderr : stdout;
47
47
  target.write(Buffer.from(frame.data, 'base64'));
48
48
  }
49
- else if (frame.stream === 'exit' && typeof frame.exit_code === 'number') {
50
- exitCode = frame.exit_code;
49
+ else if (frame.stream === 'exit') {
50
+ const code = frame.code ?? frame.exit_code ?? 0;
51
+ if (Number.isSafeInteger(code))
52
+ exitCode = code;
51
53
  }
52
54
  }
53
55
  catch {
@@ -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 CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.1.15';
1
+ export const CLI_VERSION = '0.1.17';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [