runcloud 0.1.16 → 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
  }
@@ -24,6 +24,54 @@ async function resolveBox(client, reference) {
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 = [
@@ -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);
@@ -4,6 +4,7 @@ import { writeFileSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import { ApiClient, ApiError, friendlyApiError } from '../api.js';
6
6
  import { requireCredentials } from '../config.js';
7
+ import { runSandboxCommand, shellCommand } from '../sandboxExec.js';
7
8
  import { runSandboxShell } from '../shellSession.js';
8
9
  import { printJson, StatusLine, style } from '../terminal.js';
9
10
  import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
@@ -15,6 +16,8 @@ const SANDBOX_POLL_INTERVAL_MS = 1_000;
15
16
  const SANDBOX_WAIT_TIMEOUT_MS = 20 * 60 * 1_000;
16
17
  const READY_STATES = new Set(['running']);
17
18
  const FAILED_STATES = new Set(['interrupted', 'stopped', 'destroyed']);
19
+ const METRIC_RANGES = new Set(['1h', '6h', '24h', '7d', '30d', 'all']);
20
+ const BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
18
21
  function runCloudApi() {
19
22
  const credentials = requireCredentials();
20
23
  return new ApiClient(credentials.apiUrl, credentials.token);
@@ -126,6 +129,20 @@ function parseIdlePauseSeconds(value) {
126
129
  }
127
130
  return n;
128
131
  }
132
+ function parseTimeoutSeconds(value) {
133
+ const timeout = Number(value);
134
+ if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 86_400) {
135
+ throw new InvalidArgumentError('timeout must be an integer from 0 to 86400 seconds');
136
+ }
137
+ return timeout;
138
+ }
139
+ function parseExecTimeoutSeconds(value) {
140
+ const timeout = parseTimeoutSeconds(value);
141
+ if (timeout === 0) {
142
+ throw new InvalidArgumentError('command timeout must be between 1 and 86400 seconds');
143
+ }
144
+ return timeout;
145
+ }
129
146
  function parsePort(value) {
130
147
  const n = Number(value);
131
148
  if (!Number.isInteger(n) || n < 1 || n > 65535) {
@@ -133,6 +150,66 @@ function parsePort(value) {
133
150
  }
134
151
  return n;
135
152
  }
153
+ function parseMetricRange(value) {
154
+ if (!METRIC_RANGES.has(value)) {
155
+ throw new InvalidArgumentError('range must be one of 1h, 6h, 24h, 7d, 30d, all');
156
+ }
157
+ return value;
158
+ }
159
+ function parseMetricInterval(value) {
160
+ const seconds = Number(value);
161
+ if (!Number.isFinite(seconds) || seconds < 1) {
162
+ throw new InvalidArgumentError('--interval must be at least 1 second');
163
+ }
164
+ return seconds;
165
+ }
166
+ function formatMetricBytes(value) {
167
+ if (!Number.isFinite(value) || value <= 0)
168
+ return '0 B';
169
+ const unit = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1);
170
+ const amount = value / 1024 ** unit;
171
+ return `${amount.toFixed(amount >= 10 || unit === 0 ? 0 : 1)} ${BYTE_UNITS[unit]}`;
172
+ }
173
+ function formatMetricCpu(millicores) {
174
+ return millicores >= 1000
175
+ ? `${(millicores / 1000).toFixed(2).replace(/\.?0+$/, '')} cores`
176
+ : `${Math.round(millicores)}m`;
177
+ }
178
+ function metricPercent(used, total) {
179
+ return total > 0 ? `${((used / total) * 100).toFixed(1)}%` : '—';
180
+ }
181
+ function metricSparkline(values) {
182
+ if (values.length === 0)
183
+ return '—';
184
+ const blocks = '▁▂▃▄▅▆▇█';
185
+ const max = Math.max(...values, 1);
186
+ const stride = Math.max(1, Math.ceil(values.length / 48));
187
+ return values
188
+ .filter((_, index) => index % stride === 0 || index === values.length - 1)
189
+ .map((value) => blocks[Math.min(blocks.length - 1, Math.floor((Math.max(0, value) / max) * (blocks.length - 1)))])
190
+ .join('');
191
+ }
192
+ export function renderSandboxMetrics(id, metrics) {
193
+ const point = metrics.current;
194
+ if (!point)
195
+ return `Sandbox metrics · ${id}\nNo samples recorded yet.`;
196
+ const history = metrics.history;
197
+ return [
198
+ `Sandbox metrics · ${id} · ${metrics.range}`,
199
+ `State ${point.state} · ${new Date(point.timestamp).toLocaleString()}`,
200
+ `CPU ${formatMetricCpu(point.cpu_millicores)}`,
201
+ `Memory ${formatMetricBytes(point.memory_bytes)}`,
202
+ `Network ↓ ${formatMetricBytes(point.network_rx_bytes_per_second)}/s · ↑ ${formatMetricBytes(point.network_tx_bytes_per_second)}/s`,
203
+ ` totals ↓ ${formatMetricBytes(point.network_rx_bytes)} · ↑ ${formatMetricBytes(point.network_tx_bytes)}`,
204
+ `Disk ${formatMetricBytes(point.disk_used_bytes)} / ${formatMetricBytes(point.disk_total_bytes)} (${metricPercent(point.disk_used_bytes, point.disk_total_bytes)})`,
205
+ `History ${history.length} samples`,
206
+ `CPU ${metricSparkline(history.map((sample) => sample.cpu_millicores))}`,
207
+ `Memory ${metricSparkline(history.map((sample) => sample.memory_bytes))}`,
208
+ `Network ↓ ${metricSparkline(history.map((sample) => sample.network_rx_bytes_per_second))}`,
209
+ `Network ↑ ${metricSparkline(history.map((sample) => sample.network_tx_bytes_per_second))}`,
210
+ `Disk ${metricSparkline(history.map((sample) => sample.disk_used_bytes))}`,
211
+ ].join('\n');
212
+ }
136
213
  function parseCoordinate(value) {
137
214
  const n = Number(value);
138
215
  if (!Number.isSafeInteger(n) || n < 0) {
@@ -158,6 +235,31 @@ async function findBoxForSandbox(api, sandboxId) {
158
235
  function attachHostname(api, sandboxId, name, port) {
159
236
  return api.post('/run-cloud/boxes', { sandboxId, name, port });
160
237
  }
238
+ function secretSelector(opts) {
239
+ const ordered = [...(opts.secretGroup ?? []), ...(opts.secret ?? [])];
240
+ const env = {};
241
+ for (const pair of opts.env ?? []) {
242
+ const eq = pair.indexOf('=');
243
+ if (eq <= 0)
244
+ throw new Error(`--env expects NAME=VALUE, got "${pair}"`);
245
+ env[pair.slice(0, eq)] = pair.slice(eq + 1);
246
+ }
247
+ if (opts.noSecrets) {
248
+ if (ordered.length > 0) {
249
+ throw new Error('--no-secrets cannot be combined with --secret-group or --secret');
250
+ }
251
+ return Object.keys(env).length > 0 ? { secrets: 'none', env } : { secrets: 'none' };
252
+ }
253
+ const out = {};
254
+ if (ordered.length > 0)
255
+ out.secrets = ordered;
256
+ if (Object.keys(env).length > 0)
257
+ out.env = env;
258
+ return out;
259
+ }
260
+ function collectRepeatable(value, previous) {
261
+ return [...previous, value];
262
+ }
161
263
  export function registerSandbox(program) {
162
264
  const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
163
265
  const defaultHelp = new Help();
@@ -181,8 +283,13 @@ export function registerSandbox(program) {
181
283
  .option('--memory <mb>', 'memory allocation in MiB', parseMemoryMb)
182
284
  .option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)
183
285
  .option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
286
+ .option('--timeout <seconds>', 'maximum sandbox lifetime; 0 disables the lifetime timer', parseTimeoutSeconds, 0)
184
287
  .option('--persistent', 'never pause when idle (same as --idle-pause 0)', false)
185
288
  .option('--expose <port>', 'publish a guest port at <name>-box.run.cloud; implies --persistent', parsePort)
289
+ .option('--secret-group <group>', 'attach a secret group; repeatable and ORDER MATTERS (a later group wins on a name collision)', collectRepeatable, [])
290
+ .option('--secret <group/name>', 'attach one secret out of a group; repeatable, same ordering', collectRepeatable, [])
291
+ .option('--env <NAME=VALUE>', 'literal value for this sandbox; applied last, so it wins; repeatable', collectRepeatable, [])
292
+ .option('--no-secrets', 'state explicitly that the sandbox holds no secrets')
186
293
  .option('--no-wait', 'return before the sandbox finishes building and starting')).action((opts) => run(async () => {
187
294
  const exposed = opts.expose !== undefined;
188
295
  if (exposed && !opts.name) {
@@ -208,10 +315,12 @@ export function registerSandbox(program) {
208
315
  body.memory = memory;
209
316
  if (opts.disk !== undefined)
210
317
  body.disk = opts.disk;
318
+ body.timeoutSeconds = opts.timeout;
211
319
  if (exposed || opts.persistent)
212
320
  body.idlePauseSeconds = 0;
213
321
  else if (opts.idlePause !== undefined)
214
322
  body.idlePauseSeconds = opts.idlePause;
323
+ Object.assign(body, secretSelector({ ...opts, noSecrets: opts.secrets === false }));
215
324
  const api = runCloudApi();
216
325
  let created = (await api.post('/run-cloud/sandboxes', body));
217
326
  if (typeof created.id !== 'string') {
@@ -281,6 +390,38 @@ export function registerSandbox(program) {
281
390
  else
282
391
  console.log(renderSandbox(sandboxRecord));
283
392
  }));
393
+ withOutput(sandbox
394
+ .command('metrics')
395
+ .description('Show current and historical CPU, memory, network, and disk usage')
396
+ .argument('<id>', 'sandbox id')
397
+ .option('--range <range>', 'history range: 1h, 6h, 24h, 7d, 30d, all', parseMetricRange, '24h')
398
+ .option('--watch', 'refresh continuously', false)
399
+ .option('--interval <seconds>', 'watch refresh interval', parseMetricInterval, 5)).action((id, opts) => run(async () => {
400
+ const api = runCloudApi();
401
+ for (;;) {
402
+ const metrics = (await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}/metrics?range=${encodeURIComponent(opts.range)}`));
403
+ if (opts.json) {
404
+ console.log(opts.watch ? JSON.stringify(metrics) : JSON.stringify(metrics, null, 2));
405
+ }
406
+ else {
407
+ console.log(renderSandboxMetrics(id, metrics));
408
+ }
409
+ if (!opts.watch)
410
+ return;
411
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, opts.interval * 1_000));
412
+ }
413
+ }));
414
+ withOutput(sandbox
415
+ .command('secrets')
416
+ .description('Replace the secrets a running sandbox holds (a full replacement, not a merge)')
417
+ .argument('<id>', 'sandbox id')
418
+ .option('--secret-group <group>', 'attach a secret group; repeatable and ORDER MATTERS (a later group wins on a name collision)', collectRepeatable, [])
419
+ .option('--secret <group/name>', 'attach one secret out of a group; repeatable, same ordering', collectRepeatable, [])
420
+ .option('--env <NAME=VALUE>', 'literal value; applied last, so it wins; repeatable', collectRepeatable, [])).action((id, opts) => run(async () => {
421
+ const body = secretSelector(opts);
422
+ await runCloudApi().put(`/run-cloud/sandboxes/${encodeURIComponent(id)}/secrets`, body);
423
+ console.log(`${id}: secrets replaced`);
424
+ }));
284
425
  withOutput(sandbox
285
426
  .command('expose')
286
427
  .description('Publish a sandbox guest port at a stable hostname, or change the published port')
@@ -320,19 +461,28 @@ export function registerSandbox(program) {
320
461
  .command('exec')
321
462
  .description('Run a command in a sandbox (via /bin/sh -c)')
322
463
  .argument('<id>', 'sandbox id')
323
- .argument('<cmd...>', 'command to run, e.g. npm run build')).action((id, cmdParts, opts) => run(async () => {
324
- const r = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/exec`, {
325
- cmd: ['/bin/sh', '-c', cmdParts.join(' ')],
464
+ .argument('<cmd...>', 'command to run, e.g. npm run build')
465
+ .option('--timeout <seconds>', 'command timeout', parseExecTimeoutSeconds)
466
+ .allowUnknownOption(true)).action((id, cmdParts, opts) => run(async () => {
467
+ const credentials = requireCredentials();
468
+ const r = await runSandboxCommand({
469
+ apiUrl: credentials.apiUrl,
470
+ token: credentials.token,
471
+ sandboxId: id,
472
+ request: {
473
+ cmd: ['/bin/sh', '-c', shellCommand(cmdParts)],
474
+ ...(opts.timeout === undefined ? {} : { timeout_seconds: opts.timeout }),
475
+ },
476
+ ...(opts.json
477
+ ? {}
478
+ : {
479
+ onStdout: (chunk) => process.stdout.write(chunk),
480
+ onStderr: (chunk) => process.stderr.write(chunk),
481
+ }),
326
482
  });
327
483
  if (opts.json) {
328
484
  console.log(JSON.stringify(r, null, 2));
329
485
  }
330
- else {
331
- if (r.stdout)
332
- process.stdout.write(r.stdout);
333
- if (r.stderr)
334
- process.stderr.write(r.stderr);
335
- }
336
486
  process.exitCode = r.exit_code ?? 0;
337
487
  }));
338
488
  sandbox
@@ -485,11 +635,25 @@ export function registerSandbox(program) {
485
635
  .command('create')
486
636
  .description('Snapshot a sandbox for later restore')
487
637
  .argument('<id>', 'sandbox id to snapshot')
488
- .option('--label <label>', 'human-readable label')).action((id, opts) => run(async () => {
638
+ .option('--label <label>', 'human-readable label')
639
+ .option('--timeout <seconds>', 'maximum time to wait for snapshot completion', parseExecTimeoutSeconds, 3600)).action((id, opts) => run(async () => {
489
640
  const body = {};
490
641
  if (opts.label)
491
642
  body.label = opts.label;
492
- print(await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/snapshots`, body), opts);
643
+ const api = runCloudApi();
644
+ let result = await api.post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/snapshots`, body);
645
+ const deadline = Date.now() + opts.timeout * 1000;
646
+ while (result.state === 'pending') {
647
+ if (!result.id)
648
+ throw new Error('snapshot job did not return an id');
649
+ if (Date.now() >= deadline)
650
+ throw new Error(`snapshot ${result.id} did not finish before the timeout`);
651
+ await new Promise((resolve) => setTimeout(resolve, 1000));
652
+ result = await api.get(`/run-cloud/snapshots/${encodeURIComponent(result.id)}`);
653
+ }
654
+ if (result.state === 'failed')
655
+ throw new Error(`snapshot ${result.id ?? ''} failed`);
656
+ print(result, opts);
493
657
  }));
494
658
  withOutput(snapshot
495
659
  .command('list')
@@ -509,15 +673,28 @@ export function registerSandbox(program) {
509
673
  .description('Fork a new sandbox from a snapshot (warm start)')
510
674
  .argument('<snapshot-id>', 'snapshot id to restore from')
511
675
  .option('--name <name>', 'human-readable name for the new sandbox')
676
+ .option('--size <size>', 'size preset for the restored sandbox')
677
+ .option('--cpu <cores>', 'CPU cores, including fractional values', parseCpuCores)
678
+ .option('--memory <mb>', 'memory in MiB', parseMemoryMb)
512
679
  .option('--disk <gb>', 'root disk capacity in GiB (may only grow)', parseDiskGb)
513
- .option('--region <region>', 'placement region')).action((snapshotId, opts) => run(async () => {
680
+ .option('--region <region>', 'placement region')
681
+ .option('--secret-group <group>', 'attach a secret group to the fork; a fork inherits NONE by default (secrets live in memory, so a snapshot cannot carry them)', collectRepeatable, [])
682
+ .option('--secret <group/name>', 'attach one secret out of a group; repeatable, order matters', collectRepeatable, [])
683
+ .option('--env <NAME=VALUE>', 'literal value for the fork; applied last; repeatable', collectRepeatable, [])).action((snapshotId, opts) => run(async () => {
514
684
  const body = {};
515
685
  if (opts.name)
516
686
  body.name = opts.name;
687
+ if (opts.size)
688
+ body.size = opts.size;
689
+ if (opts.cpu !== undefined)
690
+ body.cpu = opts.cpu;
691
+ if (opts.memory !== undefined)
692
+ body.memory = opts.memory;
517
693
  if (opts.disk !== undefined)
518
694
  body.disk = opts.disk;
519
695
  if (opts.region)
520
696
  body.region = opts.region;
697
+ Object.assign(body, secretSelector(opts));
521
698
  const restored = await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body);
522
699
  if (opts.json)
523
700
  printJson(restored);
@@ -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 {
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.1.16';
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.16",
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": [
@@ -0,0 +1,196 @@
1
+ ---
2
+ name: run-cloud-ios-simulator
3
+ description: Operate run.cloud iOS simulator and Android emulator sessions with the CLI or TypeScript SDK. Use for creating, installing, inspecting, embedding, smoke-testing, connecting local Metro, capturing iOS screenshots, injecting iOS media, or releasing remote mobile sessions.
4
+ ---
5
+
6
+ # Operate run.cloud Mobile Sessions
7
+
8
+ Use run.cloud through the `runcloud` CLI for terminal workflows and
9
+ `@run-cloud/sdk` for application, CI, or agent code.
10
+
11
+ ## Authenticate
12
+
13
+ - Install the CLI with `npm install -g runcloud`.
14
+ - Use `runcloud login` for an interactive browser handoff. Use
15
+ `runcloud login --manual` when a local callback cannot open.
16
+ - In CI, set `RUN_CLOUD_API_KEY`. `RUN_CLOUD_API_TOKEN` is an equivalent alias.
17
+ - Set `RUN_CLOUD_API_URL` only to override the production default
18
+ `https://api.run.cloud`.
19
+ - Never print, commit, or place credentials in a skill file.
20
+ - Treat signed simulator URLs and tunnel URLs as bearer secrets.
21
+ - Require Node.js 20 or newer for the CLI and TypeScript SDK.
22
+
23
+ Inspect account and organization credit before starting metered work:
24
+
25
+ ```bash
26
+ runcloud account --json
27
+ ```
28
+
29
+ Sessions require product access, organization credit below its ceiling, and
30
+ available fleet capacity. A create request may queue while capacity is full.
31
+
32
+ ## Choose the Interface
33
+
34
+ - Prefer CLI commands with `--json` for shell automation.
35
+ - Prefer `@run-cloud/sdk` for TypeScript applications and CI.
36
+ - Inspect `runcloud <command> --help` or installed SDK types before using a
37
+ method not documented here.
38
+
39
+ ## Use the CLI
40
+
41
+ Create an iOS session, capture its ID, open a deep link, and release it:
42
+
43
+ ```bash
44
+ SESSION_ID=$(runcloud ios create \
45
+ --install ./build/MyApp.tar.gz \
46
+ --inactivity-timeout 60s \
47
+ --hard-timeout 10m \
48
+ --json | jq -r '.id')
49
+
50
+ trap 'runcloud ios delete "$SESSION_ID" >/dev/null 2>&1 || true' EXIT
51
+
52
+ runcloud ios get "$SESSION_ID" --json
53
+ runcloud ios open-url myapp://settings --id "$SESSION_ID" --json
54
+ ```
55
+
56
+ Use the corresponding `runcloud android` commands for Android artifacts.
57
+
58
+ The shared mobile lifecycle is:
59
+
60
+ - `runcloud ios|android create`
61
+ - `runcloud ios|android list [--all]`
62
+ - `runcloud ios|android get <id>`
63
+ - `runcloud ios|android open-url <url> --id <id>`
64
+ - `runcloud ios|android delete <id>`
65
+
66
+ Create accepts `--model`, `--region`, `--display-name`, repeatable `--label`,
67
+ repeatable `--install`, repeatable `--install-asset`,
68
+ `--inactivity-timeout`, `--hard-timeout`, `--codec`, `--rm`, and `--json`.
69
+
70
+ Use assets and samples when no local artifact is ready:
71
+
72
+ ```bash
73
+ runcloud sample download ios
74
+ runcloud ios create --install ./run-cloud-sample-ios.app.tar.gz --json
75
+
76
+ runcloud asset push ./build/MyApp.tar.gz --name my-app --json
77
+ runcloud ios create --install-asset my-app --json
78
+ runcloud asset list --json
79
+ runcloud asset pull <asset-id> --output ./MyApp.tar.gz
80
+ runcloud asset delete <asset-id> --json
81
+ ```
82
+
83
+ iOS needs an Apple Silicon simulator-compatible `.app`, `.zip`, `.tar.gz`, or
84
+ `.ipa` artifact. A device-signed App Store IPA is not a substitute. Android
85
+ needs an emulator-compatible artifact such as an APK.
86
+
87
+ ## Connect Local Development
88
+
89
+ Connect a local Metro or mock server to an active iOS session:
90
+
91
+ ```bash
92
+ runcloud ios tunnel "$SESSION_ID" \
93
+ --local-port 8081 \
94
+ --service metro \
95
+ --json
96
+
97
+ runcloud ios tunnel-status --json
98
+ ```
99
+
100
+ Use the run.cloud sidecar flow. Do not install or expose an unauthenticated
101
+ third-party tunnel. If the sidecar is unavailable, report that requirement
102
+ instead of guessing a public URL.
103
+
104
+ ## Use the TypeScript SDK
105
+
106
+ Install the SDK:
107
+
108
+ ```bash
109
+ npm install @run-cloud/sdk
110
+ ```
111
+
112
+ Always release metered sessions in `finally`:
113
+
114
+ ```ts
115
+ import { writeFile } from "node:fs/promises";
116
+ import { Client } from "@run-cloud/sdk";
117
+
118
+ const cloud = new Client();
119
+ const session = await cloud.ios.create({
120
+ displayName: "Agent smoke",
121
+ labels: { owner: "agent" },
122
+ inactivityTimeout: "60s",
123
+ hardTimeout: "10m",
124
+ codec: "auto",
125
+ });
126
+
127
+ try {
128
+ await cloud.ios.openUrl(session.id, "https://run.cloud");
129
+ const screenshot = await cloud.ios.screenshot(session.id);
130
+ await writeFile("run-cloud.png", screenshot);
131
+ } finally {
132
+ await cloud.ios.delete(session.id);
133
+ }
134
+ ```
135
+
136
+ The mobile SDK surface is:
137
+
138
+ - `cloud.account()` and `cloud.usage({ orgId? })`
139
+ - `cloud.ios`: `create`, `list`, `get`, `openUrl`, `screenshot`,
140
+ `uploadVideo`, `uploadMicrophoneAudio`, `delete`
141
+ - `cloud.android`: `create`, `list`, `get`, `openUrl`, `delete`
142
+ - `cloud.simulators`: runtime-platform `create`, `list`, `get`, `openUrl`,
143
+ `delete`
144
+ - `cloud.assets`: `upload`, `list`, `delete`
145
+
146
+ Create options include `model`, `region`, `displayName`, `labels`,
147
+ `installAssets`, `inactivityTimeout`, `hardTimeout`, and `codec`.
148
+
149
+ Do not invent SDK methods for scripted taps, typing, recording, app lifecycle,
150
+ or Android screenshots. Browser-stream interaction and iframe commands are
151
+ separate from the public SDK.
152
+
153
+ ## Inject iOS Media
154
+
155
+ Use `cloud.ios.uploadVideo(id, video, options)` for MP4 or QuickTime video. It
156
+ stores a user-owned asset and imports it into Photos.
157
+
158
+ Use `cloud.ios.uploadMicrophoneAudio(id, audio, options)` for AAC, M4A, MP3,
159
+ MP4-audio, or WAV. Pass an optional `bundleId`; otherwise it targets the
160
+ foreground app. The operation relaunches the target app with microphone
161
+ permission and loops the decoded audio through `AVAudioEngine`.
162
+
163
+ Delete uploaded assets when they are no longer needed.
164
+
165
+ ## Embed a Session
166
+
167
+ - Add `embed=1` to the signed session URL for the clean iframe UI.
168
+ - Add `loadingGuard=1` when the iframe should block interaction until streaming
169
+ and app launch are ready.
170
+ - Verify `event.source` is the expected iframe before processing messages.
171
+ - Handle `ios-simulator:status`, `ios-simulator:auth-error`,
172
+ `ios-simulator:session-ended`, and
173
+ `ios-simulator:session-restart-requested`.
174
+ - Create a new session after a restart request; never reuse an ended URL.
175
+ - Use `ios-simulator:command` for `reload`, `home`, `rotate`, `screenshot`, and
176
+ `toggleAccessibility`.
177
+
178
+ ## Run Maintained Demos
179
+
180
+ ```bash
181
+ runcloud demo run eight-device-mosaic --open
182
+ runcloud demo run live-camera-relay --open
183
+ ```
184
+
185
+ The bundled demos release their sessions automatically.
186
+
187
+ ## Guardrails
188
+
189
+ - Release every session created during a task unless the user explicitly asks
190
+ to keep it open.
191
+ - Use inactivity and hard timeouts for unattended work.
192
+ - Verify platform compatibility before changing application code after an
193
+ install failure.
194
+ - Do not expose credentials, signed viewer URLs, tunnel URLs, or simulator
195
+ tokens in logs, screenshots, PR comments, or chat output.
196
+ - Do not claim that browser iframe controls are public SDK methods.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Run Cloud Mobile Sessions"
3
+ short_description: "Operate remote iOS and Android sessions"
4
+ default_prompt: "Use $run-cloud-ios-simulator to create, inspect, test, and clean up run.cloud mobile sessions."