runcloud 0.1.9 → 0.1.11

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 CHANGED
@@ -16,12 +16,15 @@ Installs the `runcloud` binary. Requires **Node ≥ 20**.
16
16
  ## Log in
17
17
 
18
18
  ```bash
19
- runcloud login # authenticate in the browser; stores a token in ~/.run-cloud/credentials
20
- runcloud login --manual # paste the credential instead of using the browser callback
21
- runcloud logout # delete the stored credential
19
+ runcloud login # sign in with email, Google, or GitHub; stores a token in ~/.run-cloud/credentials
20
+ runcloud logout # delete the stored credential
22
21
  runcloud account # show your entitlement and metered balance
23
22
  ```
24
23
 
24
+ Email signs you in without leaving the terminal. Google and GitHub open a browser and
25
+ show a short code to confirm, so login works over SSH and on headless machines too —
26
+ pick with `--email`, `--google`, or `--github` to skip the prompt.
27
+
25
28
  The token expires — re-run `runcloud login` when a command reports you're logged out.
26
29
 
27
30
  ## Remote simulators
@@ -83,6 +86,31 @@ runcloud sandbox key "$SANDBOX_ID" "ctrl+l"
83
86
  Treat a signed desktop URL as a secret. It grants temporary access to that
84
87
  desktop without asking the browser to send your CLI credential.
85
88
 
89
+ ### Persistence and a public hostname
90
+
91
+ A sandbox pauses when idle by default. Turn persistence on with `--persistent`,
92
+ and publish a guest port at a stable `<name>-box.run.cloud` hostname with
93
+ `--expose`:
94
+
95
+ ```bash
96
+ runcloud sandbox create --name my-project --persistent # never pauses
97
+ runcloud sandbox create --name my-project --expose 3000 # persistent + https://my-project-box.run.cloud
98
+ runcloud sandbox expose <id> --port 8080 # expose later, or move the published port
99
+ runcloud sandbox get <id> # inspect, including the hostname
100
+ ```
101
+
102
+ Exposed sandboxes support SSH, file transfer, VS Code, and custom domains:
103
+
104
+ ```bash
105
+ runcloud sandbox ssh my-project # shell over authenticated SSH (no public port 22)
106
+ runcloud sandbox cp my-project ./f :/workspace/f # scp; prefix the remote path with :
107
+ runcloud sandbox code my-project # VS Code Remote SSH
108
+ runcloud sandbox domain add my-project app.example.com
109
+ ```
110
+
111
+ `runcloud box …` from earlier releases keeps working as a deprecated alias for
112
+ these commands.
113
+
86
114
  ### Snapshot warm-start
87
115
 
88
116
  Freeze a "repo + deps ready" sandbox, then fork a new one from it in a fraction of a second instead of re-cloning and re-installing:
@@ -5,6 +5,9 @@ import { join } from 'node:path';
5
5
  import WebSocket, { createWebSocketStream } from 'ws';
6
6
  import { ApiClient, friendlyApiError } from '../api.js';
7
7
  import { requireCredentials } from '../config.js';
8
+ export function noteDeprecated(oldCommand, replacement) {
9
+ process.stderr.write(`runcloud: '${oldCommand}' is deprecated — use '${replacement}'\n`);
10
+ }
8
11
  function api() {
9
12
  const credentials = requireCredentials();
10
13
  return new ApiClient(credentials.apiUrl, credentials.token);
@@ -13,10 +16,12 @@ async function resolveBox(client, reference) {
13
16
  const response = await client.get('/run-cloud/boxes');
14
17
  const boxes = (response?.items ?? []);
15
18
  const box = boxes.find((item) => item.id === reference ||
19
+ item.sandboxId === reference ||
16
20
  item.name === reference ||
17
21
  item.hostname === reference);
18
- if (!box)
19
- throw new Error(`Box not found: ${reference}`);
22
+ if (!box) {
23
+ throw new Error(`No exposed sandbox matches ${reference} — publish a hostname first: runcloud sandbox expose <id>`);
24
+ }
20
25
  return box;
21
26
  }
22
27
  function sshHome() {
@@ -75,7 +80,7 @@ function writeSshConfig(box, privateKey) {
75
80
  ' IdentitiesOnly yes',
76
81
  ' StrictHostKeyChecking accept-new',
77
82
  ` UserKnownHostsFile "${join(sshHome(), 'known_hosts')}"`,
78
- ` ProxyCommand runcloud box proxy ${box.id}`,
83
+ ` ProxyCommand runcloud sandbox proxy ${box.id}`,
79
84
  end,
80
85
  ].join('\n');
81
86
  const pattern = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n?`, 'g');
@@ -114,19 +119,27 @@ function openUrl(url) {
114
119
  return exec('cmd', ['/c', 'start', '', url]);
115
120
  return exec('xdg-open', [url]);
116
121
  }
117
- export function registerBoxAccessCommands(box) {
118
- box
122
+ const REFERENCE_HELP = 'sandbox id, box id, name, or hostname';
123
+ export function registerAccessCommands(parent, opts = {}) {
124
+ const note = (verb) => {
125
+ if (opts.deprecated)
126
+ noteDeprecated(`box ${verb}`, `sandbox ${verb}`);
127
+ };
128
+ parent
119
129
  .command('setup-ssh')
120
- .description('Install a dedicated key and write this box to ~/.ssh/config')
121
- .argument('<box>', 'box id, name, or hostname')
122
- .action((reference) => action(async () => {
123
- const configured = await setupSsh(reference);
124
- console.log(`SSH configured: ssh ${configured.alias}`);
125
- }));
126
- box
130
+ .description('Install a dedicated key and write this host to ~/.ssh/config')
131
+ .argument('<sandbox>', REFERENCE_HELP)
132
+ .action((reference) => {
133
+ note('setup-ssh');
134
+ return action(async () => {
135
+ const configured = await setupSsh(reference);
136
+ console.log(`SSH configured: ssh ${configured.alias}`);
137
+ });
138
+ });
139
+ parent
127
140
  .command('proxy', { hidden: true })
128
141
  .description('Authenticated WebSocket transport used by SSH ProxyCommand')
129
- .argument('<box>', 'box id, name, or hostname')
142
+ .argument('<sandbox>', REFERENCE_HELP)
130
143
  .action((reference) => action(async () => {
131
144
  const client = api();
132
145
  const resolved = await resolveBox(client, reference);
@@ -145,102 +158,127 @@ export function registerBoxAccessCommands(box) {
145
158
  socket.once('error', reject);
146
159
  });
147
160
  }));
148
- box
161
+ parent
149
162
  .command('ssh')
150
- .description('Open a shell in a box')
151
- .argument('<box>', 'box id, name, or hostname')
163
+ .description('Open a shell in an exposed sandbox')
164
+ .argument('<sandbox>', REFERENCE_HELP)
152
165
  .argument('[command...]', 'optional remote command')
153
- .action((reference, command) => action(async () => {
154
- const configured = await setupSsh(reference);
155
- const code = await exec('ssh', [configured.alias, ...command]);
156
- if (code !== 0)
157
- process.exitCode = code;
158
- }));
159
- box
166
+ .action((reference, command) => {
167
+ note('ssh');
168
+ return action(async () => {
169
+ const configured = await setupSsh(reference);
170
+ const code = await exec('ssh', [configured.alias, ...command]);
171
+ if (code !== 0)
172
+ process.exitCode = code;
173
+ });
174
+ });
175
+ parent
160
176
  .command('cp')
161
177
  .description('Copy files with scp; prefix the remote path with a colon')
162
- .argument('<box>', 'box id, name, or hostname')
178
+ .argument('<sandbox>', REFERENCE_HELP)
163
179
  .argument('<source>', 'local path or :/remote/path')
164
180
  .argument('<destination>', 'local path or :/remote/path')
165
181
  .option('-r, --recursive', 'copy directories recursively', false)
166
- .action((reference, source, destination, opts) => action(async () => {
167
- if (source.startsWith(':') === destination.startsWith(':')) {
168
- throw new Error('Exactly one path must be remote (prefix it with :)');
169
- }
170
- const configured = await setupSsh(reference);
171
- const remote = (path) => path.startsWith(':') ? `${configured.alias}:${path.slice(1)}` : path;
172
- const args = [
173
- ...(opts.recursive ? ['-r'] : []),
174
- '--',
175
- remote(source),
176
- remote(destination),
177
- ];
178
- const code = await exec('scp', args);
179
- if (code !== 0)
180
- process.exitCode = code;
181
- }));
182
- box
182
+ .action((reference, source, destination, cmdOpts) => {
183
+ note('cp');
184
+ return action(async () => {
185
+ if (source.startsWith(':') === destination.startsWith(':')) {
186
+ throw new Error('Exactly one path must be remote (prefix it with :)');
187
+ }
188
+ const configured = await setupSsh(reference);
189
+ const remote = (path) => path.startsWith(':') ? `${configured.alias}:${path.slice(1)}` : path;
190
+ const args = [
191
+ ...(cmdOpts.recursive ? ['-r'] : []),
192
+ '--',
193
+ remote(source),
194
+ remote(destination),
195
+ ];
196
+ const code = await exec('scp', args);
197
+ if (code !== 0)
198
+ process.exitCode = code;
199
+ });
200
+ });
201
+ parent
183
202
  .command('code')
184
- .description('Open a box in VS Code Remote SSH')
185
- .argument('<box>', 'box id, name, or hostname')
203
+ .description('Open an exposed sandbox in VS Code Remote SSH')
204
+ .argument('<sandbox>', REFERENCE_HELP)
186
205
  .argument('[path]', 'remote folder', '/workspace')
187
206
  .option('--no-open', 'print the VS Code URL without opening it')
188
- .action((reference, path, opts) => action(async () => {
189
- const configured = await setupSsh(reference);
190
- const remotePath = path.startsWith('/') ? path : `/${path}`;
191
- const url = `vscode://vscode-remote/ssh-remote+${encodeURIComponent(configured.alias)}` +
192
- remotePath.split('/').map(encodeURIComponent).join('/');
193
- if (opts.open === false)
194
- console.log(url);
195
- else
196
- await openUrl(url);
197
- }));
198
- const domain = box.command('domain').description('Manage custom domains');
207
+ .action((reference, path, cmdOpts) => {
208
+ note('code');
209
+ return action(async () => {
210
+ const configured = await setupSsh(reference);
211
+ const remotePath = path.startsWith('/') ? path : `/${path}`;
212
+ const url = `vscode://vscode-remote/ssh-remote+${encodeURIComponent(configured.alias)}` +
213
+ remotePath.split('/').map(encodeURIComponent).join('/');
214
+ if (cmdOpts.open === false)
215
+ console.log(url);
216
+ else
217
+ await openUrl(url);
218
+ });
219
+ });
220
+ const domain = parent.command('domain').description('Manage custom domains');
221
+ const domainNote = (verb) => {
222
+ if (opts.deprecated)
223
+ noteDeprecated(`box domain ${verb}`, `sandbox domain ${verb}`);
224
+ };
199
225
  domain
200
226
  .command('add')
201
- .argument('<box>', 'box id, name, or hostname')
227
+ .argument('<sandbox>', REFERENCE_HELP)
202
228
  .argument('<hostname>', 'custom hostname')
203
229
  .option('--json', 'output JSON', false)
204
- .action((reference, hostname, opts) => action(async () => {
205
- const client = api();
206
- const resolved = await resolveBox(client, reference);
207
- output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`, {
208
- hostname,
209
- }), opts);
210
- }));
230
+ .action((reference, hostname, cmdOpts) => {
231
+ domainNote('add');
232
+ return action(async () => {
233
+ const client = api();
234
+ const resolved = await resolveBox(client, reference);
235
+ output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`, {
236
+ hostname,
237
+ }), cmdOpts);
238
+ });
239
+ });
211
240
  domain
212
241
  .command('list')
213
- .argument('<box>', 'box id, name, or hostname')
242
+ .argument('<sandbox>', REFERENCE_HELP)
214
243
  .option('--json', 'output JSON', false)
215
- .action((reference, opts) => action(async () => {
216
- const client = api();
217
- const resolved = await resolveBox(client, reference);
218
- const response = await client.get(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`);
219
- const items = response?.items ?? [];
220
- if (opts.json)
221
- console.log(JSON.stringify(items, null, 2));
222
- else
223
- for (const item of items)
224
- output(item, opts);
225
- }));
244
+ .action((reference, cmdOpts) => {
245
+ domainNote('list');
246
+ return action(async () => {
247
+ const client = api();
248
+ const resolved = await resolveBox(client, reference);
249
+ const response = await client.get(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains`);
250
+ const items = response?.items ?? [];
251
+ if (cmdOpts.json)
252
+ console.log(JSON.stringify(items, null, 2));
253
+ else
254
+ for (const item of items)
255
+ output(item, cmdOpts);
256
+ });
257
+ });
226
258
  domain
227
259
  .command('status')
228
- .argument('<box>', 'box id, name, or hostname')
229
- .argument('<domain-id>', 'domain id from `box domain list`')
260
+ .argument('<sandbox>', REFERENCE_HELP)
261
+ .argument('<domain-id>', 'domain id from `domain list`')
230
262
  .option('--json', 'output JSON', false)
231
- .action((reference, domainId, opts) => action(async () => {
232
- const client = api();
233
- const resolved = await resolveBox(client, reference);
234
- output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}/refresh`), opts);
235
- }));
263
+ .action((reference, domainId, cmdOpts) => {
264
+ domainNote('status');
265
+ return action(async () => {
266
+ const client = api();
267
+ const resolved = await resolveBox(client, reference);
268
+ output(await client.post(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}/refresh`), cmdOpts);
269
+ });
270
+ });
236
271
  domain
237
272
  .command('rm')
238
- .argument('<box>', 'box id, name, or hostname')
239
- .argument('<domain-id>', 'domain id from `box domain list`')
240
- .action((reference, domainId) => action(async () => {
241
- const client = api();
242
- const resolved = await resolveBox(client, reference);
243
- await client.delete(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}`);
244
- console.log(`removed custom domain ${domainId}`);
245
- }));
273
+ .argument('<sandbox>', REFERENCE_HELP)
274
+ .argument('<domain-id>', 'domain id from `domain list`')
275
+ .action((reference, domainId) => {
276
+ domainNote('rm');
277
+ return action(async () => {
278
+ const client = api();
279
+ const resolved = await resolveBox(client, reference);
280
+ await client.delete(`/run-cloud/boxes/${encodeURIComponent(resolved.id)}/domains/${encodeURIComponent(domainId)}`);
281
+ console.log(`removed custom domain ${domainId}`);
282
+ });
283
+ });
246
284
  }
@@ -3,7 +3,7 @@ import crypto from 'node:crypto';
3
3
  import { hostname } from 'node:os';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { ApiClient, ApiError } from '../api.js';
6
- import { clearCredentials, credentialsDisplayPath, normalizeApiUrl, saveCredentials } from '../config.js';
6
+ import { clearCredentials, credentialsDisplayPath, loadCredentials, normalizeApiUrl, saveCredentials, } from '../config.js';
7
7
  import { acquirePromptIO, ask, PromptAbortedError, select, } from '../prompt.js';
8
8
  import { AuthError, createApiKey, fetchProviders, isValidEmail, isValidOtp, normalizeOtp, sendEmailCode, verifyEmailCode, } from '../runCloudAuth.js';
9
9
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
@@ -114,7 +114,7 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
114
114
  },
115
115
  };
116
116
  }
117
- function openBrowser(url) {
117
+ export function openBrowser(url) {
118
118
  const [cmd, args] = process.platform === 'darwin'
119
119
  ? ['open', [url]]
120
120
  : process.platform === 'win32'
@@ -201,6 +201,20 @@ export async function verifyRunCloudCredential(apiUrl, token) {
201
201
  `(${e instanceof Error ? e.message : String(e)}). Saving it anyway.`);
202
202
  }
203
203
  }
204
+ export async function revokeRunCloudCredential(apiUrl, token) {
205
+ try {
206
+ await new ApiClient(normalizeApiUrl(apiUrl), token).delete('/run-cloud/api-keys/self');
207
+ return 'Credential revoked.';
208
+ }
209
+ catch (e) {
210
+ if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
211
+ return 'Credential was already invalid.';
212
+ }
213
+ return (`Warning: could not reach ${apiUrl} to revoke this credential ` +
214
+ `(${e instanceof Error ? e.message : String(e)}). It may still be valid — ` +
215
+ 'revoke it from the run.cloud dashboard.');
216
+ }
217
+ }
204
218
  export function registerLogin(program) {
205
219
  const isRunCloud = program.name() === 'runcloud';
206
220
  const defaultAppUrl = isRunCloud
@@ -212,7 +226,7 @@ export function registerLogin(program) {
212
226
  ? 'Log in with your run.cloud account: email, Google, or GitHub'
213
227
  : 'Log in with your Newly account via the browser')
214
228
  .option('--app-url <url>', 'product web app URL', defaultAppUrl)
215
- .option('--manual', 'paste the credential from the browser instead of using the local callback', false)
229
+ .option('--manual', 'newly skin only: paste the credential from the browser instead of using the local callback', false)
216
230
  .option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
217
231
  .option('--token <token>', 'explicit credential token (skips browser; use with --api-url)')
218
232
  .option('--expires-in <seconds>', 'session lifetime when using --token (default 7 days)', (v) => Number(v), 604800)
@@ -251,19 +265,12 @@ export function registerLogin(program) {
251
265
  console.log(`Logged in headlessly. Credentials written for API ${apiUrl}.`);
252
266
  return;
253
267
  }
254
- if (opts.manual) {
255
- if (isRunCloud) {
256
- const manualUrl = `${opts.appUrl.replace(/\/$/, '')}/cli-auth?manual=1`;
257
- console.log(`Opening your browser to authorize the CLI…\n ${manualUrl}\n`);
258
- openBrowser(manualUrl);
259
- }
268
+ if (opts.manual && !isRunCloud) {
260
269
  const { createInterface } = await import('node:readline/promises');
261
270
  const rl = createInterface({ input: process.stdin, output: process.stdout });
262
271
  const blob = await rl.question('Paste the credential from the browser: ');
263
272
  rl.close();
264
273
  const parsed = parseManualCredential(blob);
265
- if (isRunCloud)
266
- await verifyRunCloudCredential(parsed.apiUrl, parsed.token);
267
274
  saveCredentials({
268
275
  token: parsed.token,
269
276
  apiUrl: parsed.apiUrl,
@@ -295,6 +302,7 @@ export function registerLogin(program) {
295
302
  apiUrl: authApiUrl,
296
303
  appUrl: opts.appUrl,
297
304
  expiresAt: Number.MAX_SAFE_INTEGER,
305
+ revocable: true,
298
306
  });
299
307
  console.log(`\n Signed in as ${email}`);
300
308
  console.log(` Credential saved to ${credentialsDisplayPath()}\n`);
@@ -310,6 +318,29 @@ export function registerLogin(program) {
310
318
  if (method === 'google' || method === 'github')
311
319
  socialProvider = method;
312
320
  }
321
+ if (isRunCloud) {
322
+ if (opts.manual) {
323
+ console.log('Note: --manual is no longer needed for run.cloud — `runcloud login` works over SSH and headless via device flow.');
324
+ }
325
+ const apiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
326
+ const { runDeviceLogin } = await import('../deviceLogin.js');
327
+ const payload = await runDeviceLogin({
328
+ apiUrl,
329
+ appUrl: opts.appUrl,
330
+ provider: socialProvider,
331
+ openBrowser,
332
+ });
333
+ await verifyRunCloudCredential(payload.apiUrl, payload.token);
334
+ saveCredentials({
335
+ token: payload.token,
336
+ apiUrl: payload.apiUrl,
337
+ appUrl: opts.appUrl,
338
+ expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
339
+ revocable: true,
340
+ });
341
+ console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
342
+ return;
343
+ }
313
344
  const state = crypto.randomBytes(16).toString('hex');
314
345
  const appOrigin = new URL(opts.appUrl).origin;
315
346
  const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
@@ -345,8 +376,14 @@ export function registerLogin(program) {
345
376
  });
346
377
  program
347
378
  .command('logout')
348
- .description('Delete the stored CLI credential')
349
- .action(() => {
379
+ .description(isRunCloud
380
+ ? 'Revoke this machine’s credential and delete it locally'
381
+ : 'Delete the stored CLI credential')
382
+ .action(async () => {
383
+ const creds = loadCredentials();
384
+ if (isRunCloud && creds?.revocable) {
385
+ console.log(await revokeRunCloudCredential(creds.apiUrl, creds.token));
386
+ }
350
387
  clearCredentials();
351
388
  console.log('Logged out.');
352
389
  });
@@ -2,10 +2,10 @@ import { InvalidArgumentError } from 'commander';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { writeFileSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
- import { ApiClient, friendlyApiError } from '../api.js';
5
+ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
6
6
  import { requireCredentials } from '../config.js';
7
7
  import { runSandboxShell } from '../shellSession.js';
8
- import { registerBoxAccessCommands } from './boxAccess.js';
8
+ import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
9
9
  const MAX_SANDBOX_DISK_GIB = 2048;
10
10
  const DEFAULT_BOX_CPU_CORES = 2;
11
11
  const DEFAULT_BOX_MEMORY_MIB = 24 * 1024;
@@ -98,40 +98,126 @@ export function openBrowserUrl(url, platform = process.platform) {
98
98
  }
99
99
  execFileSync('xdg-open', [url], { stdio: 'ignore' });
100
100
  }
101
+ async function findBoxForSandbox(api, sandboxId) {
102
+ const data = await api.get('/run-cloud/boxes');
103
+ return (data?.items ?? []).find((box) => box.sandboxId === sandboxId);
104
+ }
105
+ function attachHostname(api, sandboxId, name, port) {
106
+ return api.post('/run-cloud/boxes', { sandboxId, name, port });
107
+ }
101
108
  export function registerSandbox(program) {
102
109
  const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
103
110
  withOutput(sandbox
104
111
  .command('create')
105
112
  .description('Spawn a microVM sandbox')
106
- .option('--image <ref>', 'base image', 'runcloud/agent-base')
113
+ .option('--image <ref>', 'base image (default runcloud/agent-base, or runcloud/box with --expose)')
107
114
  .option('--region <region>', 'placement region')
108
- .option('--name <name>', 'human-readable name')
115
+ .option('--name <name>', 'human-readable name; with --expose, the hostname prefix <name>-box.run.cloud')
109
116
  .option('--org <id>', 'billing and ownership organization')
110
117
  .option('--cpu <cores>', 'fractional vCPU allocation, e.g. 0.5', parseCpuCores)
111
118
  .option('--memory <mb>', 'memory allocation in MiB', parseMemoryMb)
112
119
  .option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)
113
- .option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)).action((opts) => run(async () => {
114
- const body = { image: opts.image };
120
+ .option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
121
+ .option('--persistent', 'never pause when idle (same as --idle-pause 0)', false)
122
+ .option('--expose <port>', 'publish a guest port at <name>-box.run.cloud; implies --persistent', parsePort)).action((opts) => run(async () => {
123
+ const exposed = opts.expose !== undefined;
124
+ if (exposed && !opts.name) {
125
+ throw new Error('--expose needs --name <name>, the hostname prefix for <name>-box.run.cloud');
126
+ }
127
+ if ((exposed || opts.persistent) && opts.idlePause !== undefined && opts.idlePause !== 0) {
128
+ throw new Error('--persistent and --expose keep the sandbox running; drop the non-zero --idle-pause');
129
+ }
130
+ const body = {
131
+ image: opts.image ?? (exposed ? 'runcloud/box' : 'runcloud/agent-base'),
132
+ };
115
133
  if (opts.name)
116
134
  body.name = opts.name;
117
135
  if (opts.region)
118
136
  body.region = opts.region;
119
137
  if (opts.org)
120
138
  body.orgId = opts.org;
121
- if (opts.cpu !== undefined)
122
- body.cpu = opts.cpu;
123
- if (opts.memory !== undefined)
124
- body.memory = opts.memory;
139
+ const cpu = opts.cpu ?? (exposed ? DEFAULT_BOX_CPU_CORES : undefined);
140
+ const memory = opts.memory ?? (exposed ? DEFAULT_BOX_MEMORY_MIB : undefined);
141
+ if (cpu !== undefined)
142
+ body.cpu = cpu;
143
+ if (memory !== undefined)
144
+ body.memory = memory;
125
145
  if (opts.disk !== undefined)
126
146
  body.disk = opts.disk;
127
- if (opts.idlePause !== undefined)
147
+ if (exposed || opts.persistent)
148
+ body.idlePauseSeconds = 0;
149
+ else if (opts.idlePause !== undefined)
128
150
  body.idlePauseSeconds = opts.idlePause;
129
- print(await runCloudApi().post('/run-cloud/sandboxes', body), opts);
151
+ const api = runCloudApi();
152
+ const created = await api.post('/run-cloud/sandboxes', body);
153
+ if (!exposed) {
154
+ print(created, opts);
155
+ return;
156
+ }
157
+ if (typeof created?.id !== 'string')
158
+ throw new Error('sandbox create response did not include an id');
159
+ const box = await attachHostname(api, created.id, opts.name, opts.expose);
160
+ print({ ...created, box }, opts);
130
161
  }));
131
162
  withOutput(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
132
163
  const qs = opts.state ? `?state=${encodeURIComponent(opts.state)}` : '';
133
- const data = await runCloudApi().get(`/run-cloud/sandboxes${qs}`);
134
- print(data?.items ?? [], opts);
164
+ const api = runCloudApi();
165
+ const [data, boxes] = await Promise.all([
166
+ api.get(`/run-cloud/sandboxes${qs}`),
167
+ api.get('/run-cloud/boxes'),
168
+ ]);
169
+ const bySandbox = new Map((boxes?.items ?? []).map((box) => [box.sandboxId, box]));
170
+ const items = (data?.items ?? []).map((item) => {
171
+ const box = bySandbox.get(item.id);
172
+ return box ? { ...item, hostname: box.hostname, exposedPort: box.port } : item;
173
+ });
174
+ print(items, opts);
175
+ }));
176
+ withOutput(sandbox
177
+ .command('get')
178
+ .description('Inspect a sandbox, including its public hostname when exposed')
179
+ .argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
180
+ const api = runCloudApi();
181
+ const [record, box] = await Promise.all([
182
+ api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`),
183
+ findBoxForSandbox(api, id),
184
+ ]);
185
+ print(box ? { ...record, box } : record, opts);
186
+ }));
187
+ withOutput(sandbox
188
+ .command('expose')
189
+ .description('Publish a sandbox guest port at a stable hostname, or change the published port')
190
+ .argument('<id>', 'sandbox id')
191
+ .option('--port <port>', 'guest port to publish (default 3000)', parsePort)
192
+ .option('--name <name>', 'hostname prefix for a first exposure: <name>-box.run.cloud (defaults to the sandbox name)')
193
+ .option('--reconcile', 'retry or reconcile hostname routing', false)).action((id, opts) => run(async () => {
194
+ const api = runCloudApi();
195
+ const existing = await findBoxForSandbox(api, id);
196
+ if (!existing) {
197
+ if (opts.reconcile)
198
+ throw new Error(`sandbox ${id} has no hostname to reconcile`);
199
+ let name = opts.name;
200
+ if (!name) {
201
+ const record = await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
202
+ name = typeof record?.name === 'string' && record.name ? record.name : undefined;
203
+ }
204
+ if (!name)
205
+ throw new Error('this sandbox has no name; pass --name <name> to mint <name>-box.run.cloud');
206
+ print(await attachHostname(api, id, name, opts.port ?? 3000), opts);
207
+ return;
208
+ }
209
+ if (opts.name && opts.name !== existing.name) {
210
+ throw new Error(`hostname is fixed at ${existing.hostname}; run 'sandbox rm ${id}' and expose again to rename`);
211
+ }
212
+ if (opts.reconcile) {
213
+ print(await api.post(`/run-cloud/boxes/${encodeURIComponent(existing.id)}/route`), opts);
214
+ return;
215
+ }
216
+ if (opts.port !== undefined) {
217
+ print(await api.patch(`/run-cloud/boxes/${encodeURIComponent(existing.id)}`, { port: opts.port }), opts);
218
+ return;
219
+ }
220
+ print(existing, opts);
135
221
  }));
136
222
  withOutput(sandbox
137
223
  .command('exec')
@@ -249,8 +335,22 @@ export function registerSandbox(program) {
249
335
  const result = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/computer`, { action: 'key', keys });
250
336
  print(result ?? { ok: true }, opts);
251
337
  }));
252
- withOutput(sandbox.command('rm').description('Destroy a sandbox').argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
253
- await runCloudApi().delete(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
338
+ withOutput(sandbox
339
+ .command('rm')
340
+ .description('Destroy a sandbox, along with its public hostname when exposed')
341
+ .argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
342
+ const api = runCloudApi();
343
+ try {
344
+ await api.delete(`/run-cloud/sandboxes/${encodeURIComponent(id)}`);
345
+ }
346
+ catch (err) {
347
+ if (!(err instanceof ApiError) || err.status !== 409)
348
+ throw err;
349
+ const box = await findBoxForSandbox(api, id);
350
+ if (!box)
351
+ throw err;
352
+ await api.delete(`/run-cloud/boxes/${encodeURIComponent(box.id)}`);
353
+ }
254
354
  if (!opts.json)
255
355
  console.log(`destroyed ${id}`);
256
356
  }));
@@ -294,10 +394,13 @@ export function registerSandbox(program) {
294
394
  body.region = opts.region;
295
395
  print(await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body), opts);
296
396
  }));
297
- const box = program.command('box').description('Run a persistent sandbox at a stable public URL');
397
+ registerAccessCommands(sandbox);
398
+ const box = program
399
+ .command('box')
400
+ .description('Deprecated: a box is a sandbox with --persistent and --expose — use `runcloud sandbox`');
298
401
  withOutput(box
299
402
  .command('create')
300
- .description('Create a persistent box or attach a hostname to an existing sandbox')
403
+ .description('Deprecated: use `sandbox create --expose <port> --name <name>`')
301
404
  .argument('<name>', 'hostname prefix: <name>-box.run.cloud')
302
405
  .option('--sandbox <id>', 'attach an existing sandbox created with --idle-pause 0')
303
406
  .option('--port <port>', 'guest port to publish', parsePort, 3000)
@@ -306,61 +409,77 @@ export function registerSandbox(program) {
306
409
  .option('--org <id>', 'billing and ownership organization for a new sandbox')
307
410
  .option('--cpu <cores>', 'vCPU allocation for a new box', parseCpuCores, DEFAULT_BOX_CPU_CORES)
308
411
  .option('--memory <mb>', 'memory allocation in MiB for a new box', parseMemoryMb, DEFAULT_BOX_MEMORY_MIB)
309
- .option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)).action((name, opts) => run(async () => {
310
- const api = runCloudApi();
311
- let sandboxId = opts.sandbox;
312
- if (!sandboxId) {
313
- const sandboxBody = {
314
- image: opts.image,
315
- name,
316
- idlePauseSeconds: 0,
317
- };
318
- if (opts.region)
319
- sandboxBody.region = opts.region;
320
- if (opts.org)
321
- sandboxBody.orgId = opts.org;
322
- sandboxBody.cpu = opts.cpu;
323
- sandboxBody.memory = opts.memory;
324
- if (opts.disk !== undefined)
325
- sandboxBody.disk = opts.disk;
326
- const created = await api.post('/run-cloud/sandboxes', sandboxBody);
327
- sandboxId = typeof created?.id === 'string' ? created.id : undefined;
328
- if (!sandboxId)
329
- throw new Error('sandbox create response did not include an id');
330
- }
331
- print(await api.post('/run-cloud/boxes', {
332
- sandboxId,
333
- name,
334
- port: opts.port,
335
- }), opts);
336
- }));
337
- withOutput(box.command('list').description('List persistent boxes')).action((opts) => run(async () => {
338
- const data = await runCloudApi().get('/run-cloud/boxes');
339
- print(data?.items ?? [], opts);
340
- }));
341
- withOutput(box.command('get').description('Inspect a persistent box').argument('<id>', 'box id')).action((id, opts) => run(async () => {
342
- print(await runCloudApi().get(`/run-cloud/boxes/${encodeURIComponent(id)}`), opts);
343
- }));
412
+ .option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)).action((name, opts) => {
413
+ noteDeprecated('box create', opts.sandbox
414
+ ? `sandbox expose ${opts.sandbox} --name ${name}`
415
+ : `sandbox create --expose ${opts.port} --name ${name}`);
416
+ return run(async () => {
417
+ const api = runCloudApi();
418
+ let sandboxId = opts.sandbox;
419
+ if (!sandboxId) {
420
+ const sandboxBody = {
421
+ image: opts.image,
422
+ name,
423
+ idlePauseSeconds: 0,
424
+ };
425
+ if (opts.region)
426
+ sandboxBody.region = opts.region;
427
+ if (opts.org)
428
+ sandboxBody.orgId = opts.org;
429
+ sandboxBody.cpu = opts.cpu;
430
+ sandboxBody.memory = opts.memory;
431
+ if (opts.disk !== undefined)
432
+ sandboxBody.disk = opts.disk;
433
+ const created = await api.post('/run-cloud/sandboxes', sandboxBody);
434
+ sandboxId = typeof created?.id === 'string' ? created.id : undefined;
435
+ if (!sandboxId)
436
+ throw new Error('sandbox create response did not include an id');
437
+ }
438
+ print(await attachHostname(api, sandboxId, name, opts.port), opts);
439
+ });
440
+ });
441
+ withOutput(box.command('list').description('Deprecated: use `sandbox list`')).action((opts) => {
442
+ noteDeprecated('box list', 'sandbox list');
443
+ return run(async () => {
444
+ const data = await runCloudApi().get('/run-cloud/boxes');
445
+ print(data?.items ?? [], opts);
446
+ });
447
+ });
448
+ withOutput(box.command('get').description('Deprecated: use `sandbox get <sandbox-id>`').argument('<id>', 'box id')).action((id, opts) => {
449
+ noteDeprecated('box get', 'sandbox get <sandbox-id>');
450
+ return run(async () => {
451
+ print(await runCloudApi().get(`/run-cloud/boxes/${encodeURIComponent(id)}`), opts);
452
+ });
453
+ });
344
454
  withOutput(box
345
455
  .command('port')
346
- .description('Move a box hostname to another guest port')
456
+ .description('Deprecated: use `sandbox expose <sandbox-id> --port <port>`')
347
457
  .argument('<id>', 'box id')
348
- .argument('<port>', 'guest port', parsePort)).action((id, port, opts) => run(async () => {
349
- print(await runCloudApi().patch(`/run-cloud/boxes/${encodeURIComponent(id)}`, { port }), opts);
350
- }));
458
+ .argument('<port>', 'guest port', parsePort)).action((id, port, opts) => {
459
+ noteDeprecated('box port', `sandbox expose <sandbox-id> --port ${port}`);
460
+ return run(async () => {
461
+ print(await runCloudApi().patch(`/run-cloud/boxes/${encodeURIComponent(id)}`, { port }), opts);
462
+ });
463
+ });
351
464
  withOutput(box
352
465
  .command('route')
353
- .description('Retry or reconcile a box hostname')
354
- .argument('<id>', 'box id')).action((id, opts) => run(async () => {
355
- print(await runCloudApi().post(`/run-cloud/boxes/${encodeURIComponent(id)}/route`), opts);
356
- }));
466
+ .description('Deprecated: use `sandbox expose <sandbox-id> --reconcile`')
467
+ .argument('<id>', 'box id')).action((id, opts) => {
468
+ noteDeprecated('box route', 'sandbox expose <sandbox-id> --reconcile');
469
+ return run(async () => {
470
+ print(await runCloudApi().post(`/run-cloud/boxes/${encodeURIComponent(id)}/route`), opts);
471
+ });
472
+ });
357
473
  withOutput(box
358
474
  .command('rm')
359
- .description('Remove a box hostname and destroy its sandbox')
360
- .argument('<id>', 'box id')).action((id, opts) => run(async () => {
361
- await runCloudApi().delete(`/run-cloud/boxes/${encodeURIComponent(id)}`);
362
- if (!opts.json)
363
- console.log(`destroyed box ${id}`);
364
- }));
365
- registerBoxAccessCommands(box);
475
+ .description('Deprecated: use `sandbox rm <sandbox-id>`')
476
+ .argument('<id>', 'box id')).action((id, opts) => {
477
+ noteDeprecated('box rm', 'sandbox rm <sandbox-id>');
478
+ return run(async () => {
479
+ await runCloudApi().delete(`/run-cloud/boxes/${encodeURIComponent(id)}`);
480
+ if (!opts.json)
481
+ console.log(`destroyed box ${id}`);
482
+ });
483
+ });
484
+ registerAccessCommands(box, { deprecated: true });
366
485
  }
@@ -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/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.1.9';
1
+ export const CLI_VERSION = '0.1.11';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",