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 +6 -0
- package/dist/commands/boxAccess.js +60 -1
- package/dist/commands/run-cloud.js +36 -146
- package/dist/commands/sandbox.js +189 -12
- package/dist/commands/secrets.js +203 -0
- package/dist/prompt.js +44 -0
- package/dist/run-cloud.js +2 -0
- package/dist/sandboxExec.js +91 -0
- package/dist/shellSession.js +4 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/run-cloud-ios-simulator/SKILL.md +196 -0
- package/skills/run-cloud-ios-simulator/agents/openai.yaml +4 -0
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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);
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -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')
|
|
324
|
-
|
|
325
|
-
|
|
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')
|
|
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
|
-
|
|
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')
|
|
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
|
+
}
|
package/dist/shellSession.js
CHANGED
|
@@ -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'
|
|
50
|
-
|
|
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.
|
|
1
|
+
export const CLI_VERSION = '0.1.17';
|
package/package.json
CHANGED
|
@@ -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.
|