@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.
@@ -0,0 +1,70 @@
1
+ import { formatAge, formatCpu, formatMib, formatSeconds, formatState, renderDetails, renderTable, shortDigest, style, } from '../terminal.js';
2
+ function resources(record) {
3
+ const memory = formatMib(record.mem_mb);
4
+ const disk = formatMib(record.disk_mb);
5
+ return [
6
+ formatCpu(record.milli_cpu),
7
+ memory ? `${memory} memory` : undefined,
8
+ disk ? `${disk} disk` : undefined,
9
+ ]
10
+ .filter(Boolean)
11
+ .join(' · ') || undefined;
12
+ }
13
+ function hostname(record) {
14
+ const host = record.hostname ?? record.box?.hostname;
15
+ const port = record.exposedPort ?? record.box?.port;
16
+ return host && port ? `${host}:${port}` : host;
17
+ }
18
+ function title(record, verb) {
19
+ if (verb === 'details')
20
+ return 'Sandbox';
21
+ if (record.state === 'running')
22
+ return `✓ Sandbox ${verb === 'restored' ? 'restored' : 'ready'}`;
23
+ if (record.state === 'building_image')
24
+ return '◐ Sandbox is building its image';
25
+ if (record.state === 'paused' || record.state === 'archived')
26
+ return '○ Sandbox paused';
27
+ return verb === 'restored' ? 'Sandbox restored' : 'Sandbox created';
28
+ }
29
+ export function renderSandbox(record, verb = 'details') {
30
+ const next = verb === 'details'
31
+ ? undefined
32
+ : record.state === 'running'
33
+ ? `${style.dim('Next:')} runcloud sandbox shell ${record.id}`
34
+ : `${style.dim('Follow:')} runcloud sandbox get ${record.id}`;
35
+ return renderDetails(title(record, verb), [
36
+ { label: 'ID', value: record.id },
37
+ { label: 'Name', value: record.name },
38
+ { label: 'State', value: formatState(record.state) },
39
+ { label: 'Image', value: record.image },
40
+ { label: 'Resources', value: resources(record) },
41
+ { label: 'Idle pause', value: formatSeconds(record.idle_pause_seconds) },
42
+ { label: 'Timeout', value: formatSeconds(record.timeout_seconds) },
43
+ { label: 'Hostname', value: hostname(record) },
44
+ { label: 'Image digest', value: shortDigest(record.image_digest) },
45
+ { label: 'Created', value: formatAge(record.created_at) },
46
+ { label: 'Last active', value: verb === 'details' ? formatAge(record.last_active_at) : undefined },
47
+ ], next);
48
+ }
49
+ export function renderSandboxList(records) {
50
+ if (records.length === 0) {
51
+ return `${style.bold('Sandboxes')}\n\nNo sandboxes found.\n\n${style.dim('Create one:')} runcloud sandbox create`;
52
+ }
53
+ return renderTable(`Sandboxes (${records.length})`, [
54
+ { key: 'id', label: 'ID', maxWidth: 24 },
55
+ { key: 'name', label: 'NAME / HOSTNAME', maxWidth: 20 },
56
+ { key: 'state', label: 'STATE', maxWidth: 14 },
57
+ { key: 'image', label: 'IMAGE', maxWidth: 20 },
58
+ { key: 'resources', label: 'CPU / MEMORY', maxWidth: 18 },
59
+ { key: 'age', label: 'AGE' },
60
+ ], records.map((record) => ({
61
+ id: record.id,
62
+ name: hostname(record) ?? record.name ?? '—',
63
+ state: formatState(record.state),
64
+ image: record.image ?? '—',
65
+ resources: [formatCpu(record.milli_cpu)?.replace(' CPU', ''), formatMib(record.mem_mb)]
66
+ .filter(Boolean)
67
+ .join(' / ') || '—',
68
+ age: formatAge(record.created_at) ?? '—',
69
+ })));
70
+ }
@@ -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/config.js CHANGED
@@ -1,14 +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
+ 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
+ }
4
8
  function credentialsHome() {
5
- if (process.env.RUN_CLOUD_HOME)
6
- return process.env.RUN_CLOUD_HOME;
7
- if (process.env.NEWLY_HOME)
8
- return process.env.NEWLY_HOME;
9
- const invoked = basename(process.argv[1] ?? '');
10
- if (invoked === 'runcloud' || invoked === 'run-cloud.js')
11
- return join(homedir(), '.run-cloud');
9
+ if (isRunCloud())
10
+ return process.env.RUN_CLOUD_HOME ?? join(homedir(), '.run-cloud');
12
11
  return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
13
12
  }
14
13
  function credentialsPath() {
@@ -19,13 +18,11 @@ export function credentialsDisplayPath() {
19
18
  const home = homedir();
20
19
  return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
21
20
  }
22
- function loginCommandName() {
23
- const invoked = basename(process.argv[1] ?? '');
24
- return invoked === 'runcloud' || invoked === 'run-cloud.js' ? 'runcloud login' : 'newly login';
21
+ export function loginCommandName() {
22
+ return isRunCloud() ? 'runcloud login' : 'newly login';
25
23
  }
26
24
  export function normalizeApiUrl(url) {
27
- const trimmed = url.replace(/\/+$/, '');
28
- return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
25
+ return url.replace(/\/+$/, '');
29
26
  }
30
27
  export function saveCredentials(creds) {
31
28
  mkdirSync(credentialsHome(), { recursive: true, mode: 0o700 });
@@ -34,13 +31,21 @@ export function saveCredentials(creds) {
34
31
  chmodSync(credentialsPath(), 0o600);
35
32
  }
36
33
  export function loadCredentials() {
37
- const envToken = process.env.NEWLY_API_TOKEN ?? process.env.NEWLY_CLI_TOKEN;
38
- const envApiUrl = process.env.NEWLY_API_URL ?? process.env.NEWLY_API_HOST ?? process.env.NEXT_PUBLIC_API_HOST;
39
- if (envToken?.trim() && envApiUrl?.trim()) {
34
+ const envToken = isRunCloud()
35
+ ? process.env.RUN_CLOUD_API_TOKEN ??
36
+ process.env.RUN_CLOUD_API_KEY ??
37
+ process.env.RUN_CLOUD_CI_ENVIRONMENT_TOKEN
38
+ : process.env.NEWLY_API_TOKEN ?? process.env.NEWLY_CLI_TOKEN;
39
+ const envApiUrl = isRunCloud()
40
+ ? process.env.RUN_CLOUD_API_URL
41
+ : process.env.NEWLY_API_URL;
42
+ if (envToken?.trim() && (isRunCloud() || envApiUrl?.trim())) {
40
43
  return {
41
44
  token: envToken.trim(),
42
- apiUrl: normalizeApiUrl(envApiUrl.trim()),
43
- appUrl: process.env.NEWLY_APP_URL ?? 'https://newly.dev',
45
+ apiUrl: normalizeApiUrl(envApiUrl?.trim() || 'https://api.run.cloud'),
46
+ appUrl: isRunCloud()
47
+ ? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
48
+ : process.env.NEWLY_APP_URL ?? 'https://newly.app',
44
49
  expiresAt: Number.MAX_SAFE_INTEGER,
45
50
  };
46
51
  }
@@ -56,6 +61,40 @@ export function loadCredentials() {
56
61
  return null;
57
62
  }
58
63
  }
64
+ function environmentSelectionPath(dir) {
65
+ return join(dir, '.newly', 'environment.json');
66
+ }
67
+ export function writeEnvironmentSelection(dir, selection) {
68
+ const selectionDir = join(dir, '.newly');
69
+ mkdirSync(selectionDir, { recursive: true });
70
+ const gitignorePath = join(selectionDir, '.gitignore');
71
+ if (!existsSync(gitignorePath))
72
+ writeFileSync(gitignorePath, '*\n');
73
+ writeFileSync(environmentSelectionPath(dir), JSON.stringify(selection, null, 2) + '\n');
74
+ }
75
+ export function readEnvironmentSelection(dir) {
76
+ try {
77
+ const parsed = JSON.parse(readFileSync(environmentSelectionPath(dir), 'utf8'));
78
+ if (!parsed.id || !parsed.name || !parsed.projectId)
79
+ return null;
80
+ return parsed;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ export function clearEnvironmentSelection(dir) {
87
+ rmSync(environmentSelectionPath(dir), { force: true });
88
+ }
89
+ export function requireEnvironmentSelection(dir, projectId) {
90
+ const selection = readEnvironmentSelection(dir);
91
+ if (!selection)
92
+ throw new Error('No environment selected. Run `newly env use <name>` first.');
93
+ if (selection.projectId !== projectId) {
94
+ throw new Error('The selected environment belongs to a different linked project. Run `newly env clear`.');
95
+ }
96
+ return selection;
97
+ }
59
98
  export function clearCredentials() {
60
99
  rmSync(credentialsPath(), { force: true });
61
100
  }
@@ -0,0 +1,48 @@
1
+ import { normalizeApiUrl } from "./config.js";
2
+ export async function runDeviceLogin(deps) {
3
+ const fetchFn = deps.fetchFn ?? fetch;
4
+ const log = deps.log ?? ((m) => console.log(m));
5
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6
+ const now = deps.now ?? (() => Date.now());
7
+ const base = normalizeApiUrl(deps.apiUrl);
8
+ const startRes = await fetchFn(`${base}/run-cloud/cli-auth/device`, {
9
+ method: "POST",
10
+ headers: { "content-type": "application/json" },
11
+ body: "{}",
12
+ });
13
+ if (!startRes.ok)
14
+ throw new Error(`Could not start login (${startRes.status}).`);
15
+ const start = (await startRes.json());
16
+ log(`\nTo authorize this terminal, confirm the code below in your browser:\n\n ${start.user_code}\n`);
17
+ log(`Opening ${start.verification_uri} … (if it doesn't open, go there and enter the code)\n`);
18
+ const approvalUrl = new URL(start.verification_uri_complete);
19
+ if (deps.provider)
20
+ approvalUrl.searchParams.set("provider", deps.provider);
21
+ deps.openBrowser?.(approvalUrl.toString());
22
+ let intervalMs = Math.max(0, start.interval) * 1000;
23
+ const deadline = now() + start.expires_in * 1000;
24
+ for (;;) {
25
+ if (now() >= deadline)
26
+ throw new Error("Login timed out. Run `runcloud login` again.");
27
+ await sleep(intervalMs);
28
+ const res = await fetchFn(`${base}/run-cloud/cli-auth/token`, {
29
+ method: "POST",
30
+ headers: { "content-type": "application/json" },
31
+ body: JSON.stringify({ device_code: start.device_code }),
32
+ });
33
+ if (res.ok) {
34
+ const body = (await res.json());
35
+ return { token: body.token, apiUrl: body.apiUrl, expiresIn: body.expiresIn };
36
+ }
37
+ const { error } = (await res.json().catch(() => ({ error: "unknown" })));
38
+ if (error === "authorization_pending")
39
+ continue;
40
+ if (error === "slow_down") {
41
+ intervalMs += 5000;
42
+ continue;
43
+ }
44
+ if (error === "access_denied")
45
+ throw new Error("Authorization was denied. Run `runcloud login` again.");
46
+ throw new Error("The login code expired. Run `runcloud login` again.");
47
+ }
48
+ }
package/dist/index.js CHANGED
@@ -16,6 +16,9 @@ import { registerWebsite } from './commands/website.js';
16
16
  import { registerDomains } from './commands/domains.js';
17
17
  import { registerDns } from './commands/dns.js';
18
18
  import { registerDiagnose } from './diagnose.js';
19
+ import { registerDesktop } from './commands/desktop.js';
20
+ import { registerEnvironment } from './commands/environment.js';
21
+ import { registerAppStore } from './commands/appStore.js';
19
22
  const program = new Command('newly')
20
23
  .description('Develop, provision, and deploy Newly backends')
21
24
  .version('0.1.0');
@@ -35,4 +38,7 @@ registerWebsite(program);
35
38
  registerDomains(program);
36
39
  registerDns(program);
37
40
  registerDiagnose(program);
41
+ registerDesktop(program);
42
+ registerEnvironment(program);
43
+ registerAppStore(program);
38
44
  await program.parseAsync(process.argv);
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
+ }
package/dist/run-cloud.js CHANGED
@@ -1,10 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { registerImage } from './commands/image.js';
3
4
  import { registerLogin } from './commands/login.js';
4
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';
5
9
  const program = new Command('runcloud')
6
- .description('Create and control remote iOS simulator sessions')
7
- .version('0.1.0');
10
+ .description('Create and control remote mobile simulators and microVM sandboxes')
11
+ .version(CLI_VERSION);
8
12
  registerLogin(program);
9
13
  registerRunCloud(program);
14
+ registerSandbox(program);
15
+ registerSecrets(program);
16
+ registerImage(program);
10
17
  await program.parseAsync(process.argv);