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.
package/dist/api.js CHANGED
@@ -21,6 +21,9 @@ function parsedErrorDetail(body) {
21
21
  }
22
22
  catch {
23
23
  }
24
+ if (/<(?:!doctype|html)\b/i.test(body)) {
25
+ return 'the API edge returned an HTML error page; retry the request or use the streaming/direct-transfer command';
26
+ }
24
27
  return body;
25
28
  }
26
29
  export class ApiClient {
@@ -69,6 +72,9 @@ export class ApiClient {
69
72
  const contentType = res.headers.get('content-type') ?? '';
70
73
  return contentType.includes('application/json') ? res.json() : res;
71
74
  }
75
+ put(path, body) {
76
+ return this.request('PUT', path, body);
77
+ }
72
78
  patch(path, body) {
73
79
  return this.request('PATCH', path, body);
74
80
  }
@@ -172,6 +178,14 @@ function headline(err) {
172
178
  if (err.status === 403)
173
179
  return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
174
180
  if (err.status === 404) {
181
+ const snapshotRestoreMatch = err.path?.match(/^\/run-cloud\/snapshots\/([^/]+)\/restore$/);
182
+ if (snapshotRestoreMatch) {
183
+ const id = decodeURIComponent(snapshotRestoreMatch[1]);
184
+ if (/^sb(?:x)?_/.test(id)) {
185
+ return `Snapshot ID not found: ${id}. \`sandbox restore\` expects a snapshot ID; to restart this sandbox, run \`runcloud sandbox resume ${id}\`.`;
186
+ }
187
+ return `Snapshot ID not found: ${id}.`;
188
+ }
175
189
  const sandboxMatch = err.path?.match(/^\/run-cloud\/sandboxes\/([^/]+)/);
176
190
  if (sandboxMatch) {
177
191
  return `Sandbox ID not found: ${decodeURIComponent(sandboxMatch[1])}.`;
@@ -196,5 +210,6 @@ function headline(err) {
196
210
  }
197
211
  export function friendlyApiError(err) {
198
212
  const message = headline(err);
199
- return err instanceof ApiError ? message : `${message}${detailSuffix(err)}`;
213
+ const debug = process.env.RUNCLOUD_DEBUG === '1' || process.env.NEWLY_DEBUG === '1';
214
+ return !debug || err instanceof ApiError ? message : `${message}${detailSuffix(err)}`;
200
215
  }
@@ -20,10 +20,58 @@ async function resolveBox(client, reference) {
20
20
  item.name === reference ||
21
21
  item.hostname === reference);
22
22
  if (!box) {
23
- throw new Error(`No exposed sandbox matches ${reference} — publish a hostname first: runcloud sandbox expose <id>`);
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
24
  }
25
25
  return box;
26
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
+ 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
+ }
51
+ }
52
+ async function readSandboxFile(client, sandboxId, path) {
53
+ const chunks = [];
54
+ let offset = 0;
55
+ for (;;) {
56
+ const response = await client.post(`/run-cloud/sandboxes/${encodeURIComponent(sandboxId)}/fs`, {
57
+ op: 'read',
58
+ path,
59
+ offset,
60
+ length: 512 * 1024,
61
+ });
62
+ if (response?.error)
63
+ throw new Error(String(response.error));
64
+ const chunk = Buffer.from(String(response?.content ?? ''), 'base64');
65
+ chunks.push(chunk);
66
+ const next = Number(response?.next_offset ?? offset + chunk.byteLength);
67
+ if (response?.eof === true || chunk.byteLength === 0)
68
+ break;
69
+ if (!Number.isSafeInteger(next) || next <= offset)
70
+ throw new Error('file read did not advance');
71
+ offset = next;
72
+ }
73
+ return Buffer.concat(chunks);
74
+ }
27
75
  function sshHome() {
28
76
  const base = process.env.RUN_CLOUD_HOME || join(homedir(), '.run-cloud');
29
77
  return join(base, 'ssh');
@@ -174,7 +222,7 @@ export function registerAccessCommands(parent, opts = {}) {
174
222
  });
175
223
  parent
176
224
  .command('cp')
177
- .description('Copy files with scp; prefix the remote path with a colon')
225
+ .description('Copy a file directly; prefix the remote path with a colon')
178
226
  .argument('<sandbox>', REFERENCE_HELP)
179
227
  .argument('<source>', 'local path or :/remote/path')
180
228
  .argument('<destination>', 'local path or :/remote/path')
@@ -185,6 +233,17 @@ export function registerAccessCommands(parent, opts = {}) {
185
233
  if (source.startsWith(':') === destination.startsWith(':')) {
186
234
  throw new Error('Exactly one path must be remote (prefix it with :)');
187
235
  }
236
+ if (!cmdOpts.recursive) {
237
+ const client = api();
238
+ const sandboxId = await resolveSandboxId(client, reference);
239
+ if (source.startsWith(':')) {
240
+ writeFileSync(destination, await readSandboxFile(client, sandboxId, source.slice(1)));
241
+ }
242
+ else {
243
+ await writeSandboxFile(client, sandboxId, destination.slice(1), readFileSync(source));
244
+ }
245
+ return;
246
+ }
188
247
  const configured = await setupSsh(reference);
189
248
  const remote = (path) => path.startsWith(':') ? `${configured.alias}:${path.slice(1)}` : path;
190
249
  const args = [
@@ -1,6 +1,7 @@
1
- import { InvalidArgumentError } from 'commander';
1
+ import { InvalidArgumentError, Option } from 'commander';
2
2
  import { ApiClient, friendlyApiError } from '../api.js';
3
3
  import { requireCredentials } from '../config.js';
4
+ import { formatAge, formatState, printJson, renderDetails, renderTable, shortDigest, StatusLine, style, } from '../terminal.js';
4
5
  const POLL_INTERVAL_MS = 3_000;
5
6
  const DEFAULT_WAIT_TIMEOUT_SECONDS = 1_200;
6
7
  function runCloudApi() {
@@ -9,23 +10,44 @@ function runCloudApi() {
9
10
  }
10
11
  function print(value, opts) {
11
12
  if (opts.json) {
12
- console.log(JSON.stringify(value, null, 2));
13
+ printJson(value);
13
14
  return;
14
15
  }
15
- if (Array.isArray(value)) {
16
- for (const item of value)
17
- console.log(formatRecord(item));
18
- return;
19
- }
20
- console.log(formatRecord(value));
16
+ console.log(renderImage(value));
21
17
  }
22
- function formatRecord(value) {
23
- if (!value || typeof value !== 'object')
24
- return String(value);
25
- return Object.entries(value)
26
- .filter(([, v]) => v !== undefined && v !== null)
27
- .map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
28
- .join('\n');
18
+ function renderImage(row, title) {
19
+ const heading = title ??
20
+ (row.state === 'ready'
21
+ ? '✓ Image ready'
22
+ : row.state === 'failed'
23
+ ? '✗ Image build failed'
24
+ : '◐ Image build started');
25
+ const footer = row.state === 'building'
26
+ ? `${style.dim('Follow:')} runcloud image list`
27
+ : undefined;
28
+ return renderDetails(heading, [
29
+ { label: 'Reference', value: row.ref ?? row.name },
30
+ { label: 'State', value: formatState(row.state) },
31
+ { label: 'Digest', value: shortDigest(row.digest) },
32
+ { label: 'Created', value: formatAge(row.createdAt) },
33
+ { label: 'Error', value: row.error },
34
+ ], footer);
35
+ }
36
+ function renderImageList(rows) {
37
+ if (rows.length === 0) {
38
+ return `${style.bold('Images')}\n\nNo images registered.\n\n${style.dim('Register one:')} runcloud image create --ref python:3.12-slim`;
39
+ }
40
+ return renderTable(`Images (${rows.length})`, [
41
+ { key: 'ref', label: 'REFERENCE', maxWidth: 44 },
42
+ { key: 'state', label: 'STATE', maxWidth: 12 },
43
+ { key: 'digest', label: 'DIGEST' },
44
+ { key: 'age', label: 'AGE' },
45
+ ], rows.map((row) => ({
46
+ ref: row.ref ?? row.name,
47
+ state: formatState(row.state),
48
+ digest: shortDigest(row.digest) ?? '—',
49
+ age: formatAge(row.createdAt) ?? '—',
50
+ })));
29
51
  }
30
52
  async function run(fn) {
31
53
  try {
@@ -55,6 +77,8 @@ export async function pollImageRegistration(api, ref, opts = {}) {
55
77
  const data = await api.get('/run-cloud/images');
56
78
  const items = (data?.items ?? []);
57
79
  const row = items.find((item) => item.ref === ref || item.name === ref);
80
+ if (row)
81
+ opts.onState?.(row.state);
58
82
  if (row && TERMINAL_STATES.has(row.state))
59
83
  return row;
60
84
  if (Date.now() > deadline) {
@@ -71,30 +95,51 @@ export function registerImage(program) {
71
95
  .command('create')
72
96
  .description('Register (build-once) an OCI image ref for run.cloud sandboxes')
73
97
  .requiredOption('--ref <ref>', 'OCI image ref to register, e.g. python:3.12-slim')
74
- .option('--wait', 'poll until the image is ready or failed', false)
75
- .option('--timeout <seconds>', `max time to wait with --wait, in seconds (default ${DEFAULT_WAIT_TIMEOUT_SECONDS})`, parseTimeoutSeconds, DEFAULT_WAIT_TIMEOUT_SECONDS)).action((opts) => run(async () => {
98
+ .option('--no-wait', 'return after scheduling instead of waiting for the image build')
99
+ .option('--timeout <seconds>', 'max time to wait in seconds', parseTimeoutSeconds, DEFAULT_WAIT_TIMEOUT_SECONDS)
100
+ .addOption(new Option('--wait').hideHelp())).action((opts) => run(async () => {
76
101
  const api = runCloudApi();
77
102
  const created = await api.post('/run-cloud/images', { ref: opts.ref });
78
- if (!opts.wait) {
103
+ if (!opts.wait || created?.state === 'ready') {
79
104
  print(created, opts);
80
105
  return;
81
106
  }
82
- const finalRow = await pollImageRegistration(api, opts.ref, { timeoutMs: opts.timeout * 1000 });
107
+ const status = opts.json ? undefined : new StatusLine();
108
+ status?.update(`Building ${opts.ref}`);
109
+ const finalRow = await pollImageRegistration(api, opts.ref, {
110
+ timeoutMs: opts.timeout * 1000,
111
+ onState: (state) => status?.update(state === 'building' ? `Building ${opts.ref}` : `${opts.ref} is ${state}`),
112
+ });
83
113
  if (finalRow.state === 'failed') {
114
+ status?.fail(`Image build failed`);
84
115
  throw new Error(finalRow.error
85
116
  ? `image ${opts.ref} failed to build: ${finalRow.error}`
86
117
  : `image ${opts.ref} failed to build`);
87
118
  }
119
+ status?.clear();
88
120
  print(finalRow, opts);
89
121
  }));
90
122
  withOutput(image.command('list').description('List images your organization has registered')).action((opts) => run(async () => {
91
123
  const data = await runCloudApi().get('/run-cloud/images');
92
- print(data?.items ?? [], opts);
124
+ const rows = (data?.items ?? []);
125
+ if (opts.json)
126
+ printJson(rows);
127
+ else
128
+ console.log(renderImageList(rows));
93
129
  }));
94
130
  withOutput(image
95
131
  .command('refresh')
96
132
  .description("Move a tag's pin to its current upstream digest")
97
133
  .argument('<ref>', 'OCI image ref to refresh')).action((ref, opts) => run(async () => {
98
- print(await runCloudApi().post('/run-cloud/images/refresh', { ref }), opts);
134
+ const refreshed = await runCloudApi().post('/run-cloud/images/refresh', { ref });
135
+ if (opts.json)
136
+ printJson(refreshed);
137
+ else {
138
+ console.log(renderDetails('✓ Image refreshed', [
139
+ { label: 'Reference', value: refreshed?.ref ?? ref },
140
+ { label: 'Previous', value: shortDigest(refreshed?.old_digest) },
141
+ { label: 'Digest', value: shortDigest(refreshed?.new_digest ?? refreshed?.digest) },
142
+ ]));
143
+ }
99
144
  }));
100
145
  }
@@ -6,148 +6,20 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { ApiClient, friendlyApiError } from '../api.js';
8
8
  import { requireCredentials } from '../config.js';
9
- export const RUN_CLOUD_SKILL = `---
10
- name: run-cloud-ios-simulator
11
- description: Use run.cloud SDK and CLI workflows for iOS simulator and Android emulator sessions.
12
- version: 0.5.1
13
- ---
14
-
15
- # run.cloud Mobile Sessions
16
-
17
- Use this skill when a user asks an agent to create, inspect, smoke test, debug, or release an iOS simulator or Android emulator through run.cloud.
18
-
19
- ## Requirements
20
-
21
- - Read SDK credentials from \`RUN_CLOUD_API_KEY\`. Never print it, commit it, or write it into a skill file. The SDK uses \`RUN_CLOUD_API_URL\` when set and otherwise defaults to \`https://api.run.cloud\`.
22
- - Authenticate the CLI with either a saved \`runcloud login\` credential or \`RUN_CLOUD_API_KEY\` together with \`RUN_CLOUD_API_URL\`. Do not require both a saved login and an API key.
23
- - The TypeScript SDK requires Node.js 20 or newer.
24
- - The account must have run.cloud access, available capacity, and organization credit.
25
- - App artifacts must match the target platform. iOS sessions need simulator-compatible builds; Android sessions need Android-compatible artifacts such as APKs.
26
-
27
- ## TypeScript SDK
28
-
29
- Prefer \`@run-cloud/sdk\` for applications, CI, and agent code:
30
-
31
- \`\`\`bash
32
- npm install @run-cloud/sdk
33
- \`\`\`
34
-
35
- Use the platform client when the platform is known, and always release metered sessions in \`finally\`:
36
-
37
- \`\`\`ts
38
- import { Client } from "@run-cloud/sdk";
39
-
40
- const cloud = new Client();
41
- const session = await cloud.ios.create({
42
- displayName: "Agent smoke",
43
- labels: { owner: "agent" },
44
- inactivityTimeout: "60s",
45
- });
46
-
47
- try {
48
- await cloud.ios.openUrl(session.id, "https://run.cloud");
49
- console.log(session.url);
50
- } finally {
51
- await cloud.ios.delete(session.id);
9
+ const RUN_CLOUD_SKILL_FILENAME = 'run-cloud-ios-simulator/SKILL.md';
10
+ function loadRunCloudSkill() {
11
+ const commandDirectory = dirname(fileURLToPath(import.meta.url));
12
+ const candidates = [
13
+ resolve(commandDirectory, '../../skills', RUN_CLOUD_SKILL_FILENAME),
14
+ resolve(commandDirectory, '../../../.claude/skills', RUN_CLOUD_SKILL_FILENAME),
15
+ ];
16
+ for (const candidate of candidates) {
17
+ if (existsSync(candidate))
18
+ return readFileSync(candidate, 'utf8');
19
+ }
20
+ throw new Error('run.cloud agent skill is missing from the package; reinstall runcloud or use npx skills add newly-app/run-cloud-examples --skill run-cloud-ios-simulator');
52
21
  }
53
- \`\`\`
54
-
55
- Use \`cloud.android\` for Android. When the platform is selected at runtime, use \`cloud.simulators\` and pass \`session.platform\` to \`get\`, \`openUrl\`, or \`delete\`.
56
-
57
- The implemented SDK surface is:
58
-
59
- - \`cloud.account()\`;
60
- - \`cloud.ios\` and \`cloud.android\`: \`create\`, \`list\`, \`get\`, \`openUrl\`, \`delete\`;
61
- - \`cloud.simulators\`: the same lifecycle with a runtime \`platform\` option;
62
- - \`cloud.assets\`: \`upload\`, \`list\`, \`delete\`.
63
-
64
- Do not invent screenshot, tap, typing, recording, app lifecycle, sandbox, build, or compatibility-adapter methods. Check the installed package types and https://docs.run.cloud/cli/typescript-sdk before using a method not listed here.
65
-
66
- ## CLI Workflow
67
-
68
- Use the CLI for interactive terminal work. Authenticate with a saved login:
69
-
70
- \`\`\`bash
71
- npm install -g runcloud
72
- runcloud login
73
- \`\`\`
74
-
75
- Or authenticate non-interactively with both required environment variables:
76
-
77
- \`\`\`bash
78
- export RUN_CLOUD_API_KEY="rc_live_..."
79
- export RUN_CLOUD_API_URL="https://api.run.cloud"
80
- \`\`\`
81
-
82
- Then inspect the account:
83
-
84
- \`\`\`bash
85
- runcloud account --json
86
- \`\`\`
87
-
88
- Create, inspect, open a URL, and release an iOS session:
89
-
90
- \`\`\`bash
91
- runcloud ios create --install ./build/MyApp.tar.gz --json
92
- runcloud ios get "$SESSION_ID" --json
93
- runcloud ios open-url myapp://settings --id "$SESSION_ID"
94
- runcloud ios delete "$SESSION_ID" --json
95
- \`\`\`
96
-
97
- Use the corresponding \`runcloud android\` commands with an Android artifact for Android emulator sessions.
98
-
99
- Download checksum-verified onboarding artifacts when no local build is
100
- available:
101
-
102
- \`\`\`bash
103
- runcloud sample download ios
104
- runcloud ios create --install ./run-cloud-sample-ios.app.tar.gz
105
-
106
- runcloud sample download android
107
- runcloud android create --install ./run-cloud-sample-android.apk
108
- \`\`\`
109
-
110
- ## Runnable SDK Example
111
-
112
- The maintained example checks account state, creates iOS and Android sessions, opens a URL on each, and releases both sessions:
113
-
114
- \`\`\`bash
115
- git clone --depth 1 https://github.com/newly-app/run-cloud-examples.git
116
- cd run-cloud-examples/sdk-ios-android
117
- npm install
118
- npm run demo -- --platform both --open
119
- \`\`\`
120
-
121
- Use \`--platform ios\` or \`--platform android\` for one platform. Use \`--json\` for machine-readable output. The example releases sessions on completion, failure, SIGINT, and SIGTERM unless the user explicitly passes \`--keep\`.
122
-
123
- ## Bundled CLI Demos
124
-
125
- These published demos exercise multi-simulator workflows:
126
-
127
- \`\`\`bash
128
- runcloud demo run eight-device-mosaic --open
129
- runcloud demo run live-camera-relay --open
130
- \`\`\`
131
-
132
- They use the same CLI authentication choices described above and release every session automatically.
133
-
134
- ## Embedded Iframes
135
-
136
- - Use \`inactivityTimeout: "60s"\` in the SDK, or \`--inactivity-timeout 60s\` in the CLI, when an embed should auto-close after user inactivity.
137
- - Omit the option or pass \`null\`/\`none\` when the user needs a metered session without idle auto-close.
138
- - Treat the returned signed session URL as a secret. Do not publish it in logs.
139
- - Iframes post \`ios-simulator:status\`, \`ios-simulator:auth-error\`, \`ios-simulator:session-ended\`, and \`ios-simulator:session-restart-requested\` messages to the parent window.
140
- - Verify \`event.source\` before acting on iframe messages.
141
- - When \`ios-simulator:session-restart-requested\` arrives, create a fresh session; do not reuse the ended iframe URL.
142
-
143
- ## Rules
144
-
145
- - Prefer the SDK for code and \`--json\` CLI output for shell automation.
146
- - Always release sessions you create unless the user asks to keep them open.
147
- - If installation fails, verify that the artifact matches the target platform before attempting code changes.
148
- - Do not assume a local tunnel is installed on the user's machine.
149
- - Do not expose API keys, CLI tokens, signed simulator URLs, or simulator tokens in logs or screenshots.
150
- `;
22
+ export const RUN_CLOUD_SKILL = loadRunCloudSkill();
151
23
  function client() {
152
24
  const creds = requireCredentials();
153
25
  return new ApiClient(creds.apiUrl, creds.token);
@@ -332,11 +204,29 @@ export async function runCloudDemo(name = 'eight-device-mosaic', opts = {}, load
332
204
  }
333
205
  async function pushAsset(path, opts = {}) {
334
206
  const { blob, name } = fileBlob(path);
335
- const form = new FormData();
336
- form.set('file', blob, name);
337
- if (opts.name)
338
- form.set('name', opts.name);
339
- return (await client().uploadForm('/run-cloud/assets', form));
207
+ const api = client();
208
+ const prepared = await api.post('/run-cloud/assets/uploads', {
209
+ filename: name,
210
+ ...(opts.name ? { name: opts.name } : {}),
211
+ contentType: blob.type || 'application/octet-stream',
212
+ byteSize: blob.size,
213
+ });
214
+ try {
215
+ const uploaded = await fetch(prepared.upload.url, {
216
+ method: 'PUT',
217
+ headers: prepared.upload.headers,
218
+ body: blob,
219
+ });
220
+ if (!uploaded.ok)
221
+ throw new Error(`asset storage upload failed with HTTP ${uploaded.status}`);
222
+ }
223
+ catch (error) {
224
+ if (prepared.asset.id) {
225
+ await api.delete(`/run-cloud/assets/${encodeURIComponent(prepared.asset.id)}`).catch(() => undefined);
226
+ }
227
+ throw error;
228
+ }
229
+ return prepared.asset;
340
230
  }
341
231
  async function createSimulatorSession(platform, opts) {
342
232
  assertDevHostAllowed(opts.devHost);