runcloud 0.1.2 → 0.1.4
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 +78 -37
- package/dist/commands/build.js +244 -13
- package/dist/commands/create.js +106 -30
- package/dist/commands/deploy.js +100 -129
- package/dist/commands/desktop.js +46 -0
- package/dist/commands/environment.js +290 -0
- package/dist/commands/login.js +4 -2
- package/dist/commands/run-cloud.js +297 -77
- package/dist/commands/sandbox.js +136 -0
- package/dist/config.js +50 -9
- package/dist/environmentShell.js +70 -0
- package/dist/index.js +4 -0
- package/dist/run-cloud.js +6 -2
- package/dist/services-manifest.js +30 -1
- package/examples/eight-device-mosaic/README.md +30 -0
- package/examples/eight-device-mosaic/demo.mjs +329 -0
- package/examples/eight-device-mosaic/package.json +13 -0
- package/examples/eight-device-mosaic/verify.mjs +115 -0
- package/examples/live-camera-relay/App.tsx +516 -0
- package/examples/live-camera-relay/README.md +57 -0
- package/examples/live-camera-relay/app.json +17 -0
- package/examples/live-camera-relay/demo.mjs +472 -0
- package/examples/live-camera-relay/index.ts +4 -0
- package/examples/live-camera-relay/package.json +31 -0
- package/examples/live-camera-relay/tsconfig.json +6 -0
- package/examples/live-camera-relay/verify.mjs +151 -0
- package/examples/parallel-simulators/README.md +33 -0
- package/examples/parallel-simulators/demo.mjs +185 -0
- package/examples/parallel-simulators/package.json +13 -0
- package/examples/parallel-simulators/verify.mjs +78 -0
- package/package.json +5 -3
|
@@ -1,46 +1,141 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Option } from 'commander';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
6
|
import { ApiClient, friendlyApiError } from '../api.js';
|
|
5
7
|
import { requireCredentials } from '../config.js';
|
|
6
|
-
const RUN_CLOUD_SKILL = `---
|
|
8
|
+
export const RUN_CLOUD_SKILL = `---
|
|
7
9
|
name: run-cloud-ios-simulator
|
|
8
|
-
description:
|
|
9
|
-
version: 0.
|
|
10
|
+
description: Use run.cloud SDK and CLI workflows for iOS simulator and Android emulator sessions.
|
|
11
|
+
version: 0.5.0
|
|
10
12
|
---
|
|
11
13
|
|
|
12
|
-
# run.cloud
|
|
14
|
+
# run.cloud Mobile Sessions
|
|
13
15
|
|
|
14
|
-
Use this skill when a user asks an agent to
|
|
16
|
+
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.
|
|
15
17
|
|
|
16
18
|
## Requirements
|
|
17
19
|
|
|
18
|
-
- The
|
|
19
|
-
-
|
|
20
|
-
-
|
|
20
|
+
- 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.newly.app\`.
|
|
21
|
+
- 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.
|
|
22
|
+
- The TypeScript SDK requires Node.js 20 or newer.
|
|
23
|
+
- The account must have run.cloud access, available capacity, and a positive run.cloud balance.
|
|
24
|
+
- App artifacts must match the target platform. iOS sessions need simulator-compatible builds; Android sessions need Android-compatible artifacts such as APKs.
|
|
21
25
|
|
|
22
|
-
##
|
|
26
|
+
## TypeScript SDK
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
Prefer \`@run-cloud/sdk\` for applications, CI, and agent code:
|
|
29
|
+
|
|
30
|
+
\`\`\`bash
|
|
31
|
+
npm install @run-cloud/sdk
|
|
32
|
+
\`\`\`
|
|
33
|
+
|
|
34
|
+
Use the platform client when the platform is known, and always release metered sessions in \`finally\`:
|
|
35
|
+
|
|
36
|
+
\`\`\`ts
|
|
37
|
+
import { Client } from "@run-cloud/sdk";
|
|
38
|
+
|
|
39
|
+
const cloud = new Client();
|
|
40
|
+
const session = await cloud.ios.create({
|
|
41
|
+
displayName: "Agent smoke",
|
|
42
|
+
labels: { owner: "agent" },
|
|
43
|
+
inactivityTimeout: "60s",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await cloud.ios.openUrl(session.id, "https://run.cloud");
|
|
48
|
+
console.log(session.url);
|
|
49
|
+
} finally {
|
|
50
|
+
await cloud.ios.delete(session.id);
|
|
51
|
+
}
|
|
52
|
+
\`\`\`
|
|
53
|
+
|
|
54
|
+
Use \`cloud.android\` for Android. When the platform is selected at runtime, use \`cloud.simulators\` and pass \`session.platform\` to \`get\`, \`openUrl\`, or \`delete\`.
|
|
55
|
+
|
|
56
|
+
The implemented SDK surface is:
|
|
57
|
+
|
|
58
|
+
- \`cloud.account()\`;
|
|
59
|
+
- \`cloud.ios\` and \`cloud.android\`: \`create\`, \`list\`, \`get\`, \`openUrl\`, \`delete\`;
|
|
60
|
+
- \`cloud.simulators\`: the same lifecycle with a runtime \`platform\` option;
|
|
61
|
+
- \`cloud.assets\`: \`upload\`, \`list\`, \`delete\`.
|
|
62
|
+
|
|
63
|
+
Do not invent screenshot, tap, typing, recording, app lifecycle, sandbox, build, or compatibility-adapter methods. Check the installed package types and https://run.cloud/cli/typescript-sdk before using a method not listed here.
|
|
64
|
+
|
|
65
|
+
## CLI Workflow
|
|
66
|
+
|
|
67
|
+
Use the CLI for interactive terminal work. Authenticate with a saved login:
|
|
68
|
+
|
|
69
|
+
\`\`\`bash
|
|
70
|
+
npm install -g runcloud
|
|
71
|
+
runcloud login
|
|
72
|
+
\`\`\`
|
|
73
|
+
|
|
74
|
+
Or authenticate non-interactively with both required environment variables:
|
|
75
|
+
|
|
76
|
+
\`\`\`bash
|
|
77
|
+
export RUN_CLOUD_API_KEY="rc_live_..."
|
|
78
|
+
export RUN_CLOUD_API_URL="https://api.newly.app"
|
|
79
|
+
\`\`\`
|
|
80
|
+
|
|
81
|
+
Then inspect the account:
|
|
82
|
+
|
|
83
|
+
\`\`\`bash
|
|
84
|
+
runcloud account --json
|
|
85
|
+
\`\`\`
|
|
86
|
+
|
|
87
|
+
Create, inspect, open a URL, and release an iOS session:
|
|
88
|
+
|
|
89
|
+
\`\`\`bash
|
|
90
|
+
runcloud ios create --install ./build/MyApp.tar.gz --json
|
|
91
|
+
runcloud ios get "$SESSION_ID" --json
|
|
92
|
+
runcloud ios open-url myapp://settings --id "$SESSION_ID"
|
|
93
|
+
runcloud ios delete "$SESSION_ID" --json
|
|
94
|
+
\`\`\`
|
|
95
|
+
|
|
96
|
+
Use the corresponding \`runcloud android\` commands with an Android artifact for Android emulator sessions.
|
|
97
|
+
|
|
98
|
+
## Runnable SDK Example
|
|
99
|
+
|
|
100
|
+
The maintained example checks account state, creates iOS and Android sessions, opens a URL on each, and releases both sessions:
|
|
101
|
+
|
|
102
|
+
\`\`\`bash
|
|
103
|
+
git clone --depth 1 https://github.com/newly-app/run-cloud-examples.git
|
|
104
|
+
cd run-cloud-examples/sdk-ios-android
|
|
105
|
+
npm install
|
|
106
|
+
npm run demo -- --platform both --open
|
|
107
|
+
\`\`\`
|
|
108
|
+
|
|
109
|
+
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\`.
|
|
110
|
+
|
|
111
|
+
## Bundled CLI Demos
|
|
112
|
+
|
|
113
|
+
These published demos exercise multi-simulator workflows:
|
|
114
|
+
|
|
115
|
+
\`\`\`bash
|
|
116
|
+
runcloud demo run parallel-simulators --open
|
|
117
|
+
runcloud demo run eight-device-mosaic --open
|
|
118
|
+
runcloud demo run live-camera-relay --open
|
|
119
|
+
\`\`\`
|
|
120
|
+
|
|
121
|
+
They use the same CLI authentication choices described above and release every session automatically.
|
|
29
122
|
|
|
30
123
|
## Embedded Iframes
|
|
31
124
|
|
|
32
|
-
- Use \`
|
|
33
|
-
- Omit
|
|
34
|
-
-
|
|
35
|
-
-
|
|
125
|
+
- Use \`inactivityTimeout: "60s"\` in the SDK, or \`--inactivity-timeout 60s\` in the CLI, when an embed should auto-close after user inactivity.
|
|
126
|
+
- Omit the option or pass \`null\`/\`none\` when the user needs a metered session without idle auto-close.
|
|
127
|
+
- Treat the returned signed session URL as a secret. Do not publish it in logs.
|
|
128
|
+
- Iframes post \`ios-simulator:status\`, \`ios-simulator:auth-error\`, \`ios-simulator:session-ended\`, and \`ios-simulator:session-restart-requested\` messages to the parent window.
|
|
129
|
+
- Verify \`event.source\` before acting on iframe messages.
|
|
130
|
+
- When \`ios-simulator:session-restart-requested\` arrives, create a fresh session; do not reuse the ended iframe URL.
|
|
36
131
|
|
|
37
132
|
## Rules
|
|
38
133
|
|
|
39
|
-
- Prefer \`--json\` for
|
|
134
|
+
- Prefer the SDK for code and \`--json\` CLI output for shell automation.
|
|
40
135
|
- Always release sessions you create unless the user asks to keep them open.
|
|
41
|
-
- If installation fails, verify that the artifact
|
|
136
|
+
- If installation fails, verify that the artifact matches the target platform before attempting code changes.
|
|
42
137
|
- Do not assume a local tunnel is installed on the user's machine.
|
|
43
|
-
- Do not expose
|
|
138
|
+
- Do not expose API keys, CLI tokens, signed simulator URLs, or simulator tokens in logs or screenshots.
|
|
44
139
|
`;
|
|
45
140
|
function client() {
|
|
46
141
|
const creds = requireCredentials();
|
|
@@ -123,6 +218,61 @@ function installSkill(opts) {
|
|
|
123
218
|
}
|
|
124
219
|
return results;
|
|
125
220
|
}
|
|
221
|
+
const RUN_CLOUD_DEMOS = [
|
|
222
|
+
'parallel-simulators',
|
|
223
|
+
'eight-device-mosaic',
|
|
224
|
+
'live-camera-relay',
|
|
225
|
+
];
|
|
226
|
+
function experimentalWebRtcEnabled() {
|
|
227
|
+
return ['1', 'true', 'yes', 'on'].includes((process.env.RUN_CLOUD_EXPERIMENTAL_WEBRTC ?? '').trim().toLowerCase());
|
|
228
|
+
}
|
|
229
|
+
function assertDevHostAllowed(devHost) {
|
|
230
|
+
if (devHost && process.env.RUN_CLOUD_DEV_FEATURES !== '1') {
|
|
231
|
+
throw new Error('--dev-host is dev-only; set RUN_CLOUD_DEV_FEATURES=1 to use it');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function requestedCodec(codec) {
|
|
235
|
+
const value = codec?.trim().toLowerCase() || 'auto';
|
|
236
|
+
if (value === 'webrtc' && !experimentalWebRtcEnabled()) {
|
|
237
|
+
throw new Error('--codec webrtc is experimental; set RUN_CLOUD_EXPERIMENTAL_WEBRTC=1 to use it');
|
|
238
|
+
}
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
function demoDirectory(name) {
|
|
242
|
+
if (!RUN_CLOUD_DEMOS.some((demoName) => demoName === name)) {
|
|
243
|
+
throw new Error(`Unknown demo: ${name}. Available demos: ${RUN_CLOUD_DEMOS.join(', ')}`);
|
|
244
|
+
}
|
|
245
|
+
const source = resolve(dirname(fileURLToPath(import.meta.url)), '../../examples', name);
|
|
246
|
+
if (!existsSync(source))
|
|
247
|
+
throw new Error(`The ${name} demo is missing from this runcloud package.`);
|
|
248
|
+
return source;
|
|
249
|
+
}
|
|
250
|
+
export function initRunCloudDemo(name = 'parallel-simulators', directory, opts = {}) {
|
|
251
|
+
const source = demoDirectory(name);
|
|
252
|
+
const target = resolve(directory ?? `run-cloud-${name}`);
|
|
253
|
+
if (existsSync(target)) {
|
|
254
|
+
if (!opts.force)
|
|
255
|
+
throw new Error(`${target} already exists. Pass --force to overwrite it.`);
|
|
256
|
+
rmSync(target, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
cpSync(source, target, { recursive: true });
|
|
259
|
+
return { example: name, path: target, status: 'created' };
|
|
260
|
+
}
|
|
261
|
+
export async function runCloudDemo(name = 'parallel-simulators', opts = {}, loadDemo = async (url) => (await import(url))) {
|
|
262
|
+
if (name === 'eight-device-mosaic' && opts.duration) {
|
|
263
|
+
throw new Error('The eight-device mosaic uses a fixed 10-minute lease and does not accept --duration.');
|
|
264
|
+
}
|
|
265
|
+
const args = [];
|
|
266
|
+
if (opts.count)
|
|
267
|
+
args.push('--count', opts.count);
|
|
268
|
+
if (opts.duration)
|
|
269
|
+
args.push('--duration', opts.duration);
|
|
270
|
+
if (opts.open)
|
|
271
|
+
args.push('--open');
|
|
272
|
+
const source = join(demoDirectory(name), 'demo.mjs');
|
|
273
|
+
const demo = await loadDemo(pathToFileURL(source).href);
|
|
274
|
+
await demo.runDemo(args);
|
|
275
|
+
}
|
|
126
276
|
async function pushAsset(path, opts = {}) {
|
|
127
277
|
const { blob, name } = fileBlob(path);
|
|
128
278
|
const form = new FormData();
|
|
@@ -131,92 +281,162 @@ async function pushAsset(path, opts = {}) {
|
|
|
131
281
|
form.set('name', opts.name);
|
|
132
282
|
return (await client().uploadForm('/run-cloud/assets', form));
|
|
133
283
|
}
|
|
134
|
-
async function
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
284
|
+
async function createSimulatorSession(platform, opts) {
|
|
285
|
+
assertDevHostAllowed(opts.devHost);
|
|
286
|
+
const uploadedAssets = [];
|
|
287
|
+
for (const file of opts.install ?? [])
|
|
288
|
+
uploadedAssets.push(await pushAsset(file));
|
|
289
|
+
const installAssets = [
|
|
290
|
+
...(opts.installAsset ?? []),
|
|
291
|
+
...uploadedAssets
|
|
292
|
+
.map((asset) => asset.id)
|
|
293
|
+
.filter((id) => typeof id === 'string' && id.length > 0),
|
|
294
|
+
];
|
|
295
|
+
return await client().post(`/run-cloud/${platform}`, {
|
|
296
|
+
model: opts.model,
|
|
297
|
+
region: opts.region,
|
|
298
|
+
displayName: opts.displayName,
|
|
299
|
+
labels: parseLabels(opts.label),
|
|
300
|
+
installAssets,
|
|
301
|
+
inactivityTimeout: opts.inactivityTimeout,
|
|
302
|
+
hardTimeout: opts.hardTimeout,
|
|
303
|
+
autoDelete: opts.rm,
|
|
304
|
+
codec: requestedCodec(opts.codec),
|
|
305
|
+
...(opts.devHost ? { devHost: opts.devHost } : {}),
|
|
306
|
+
});
|
|
142
307
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
.action((opts) => action(async () => {
|
|
149
|
-
print(await client().get('/run-cloud/account'), opts);
|
|
150
|
-
}));
|
|
151
|
-
const ios = program.command('ios').description('Create, list, inspect, and delete iOS simulator sessions');
|
|
152
|
-
ios
|
|
308
|
+
function registerSimulatorCommands(program, platform) {
|
|
309
|
+
const label = platform === 'android' ? 'Android emulator' : 'iOS simulator';
|
|
310
|
+
const article = platform === 'android' ? 'an' : 'an';
|
|
311
|
+
const simulator = program.command(platform).description(`Create, list, inspect, and delete ${label} sessions`);
|
|
312
|
+
simulator
|
|
153
313
|
.command('create')
|
|
154
|
-
.description(
|
|
155
|
-
.option('--model <
|
|
314
|
+
.description(`Create a remote ${label} session`)
|
|
315
|
+
.option('--model <model>', platform === 'ios' ? 'simulator model' : 'emulator model', platform === 'ios' ? 'iphone' : 'pixel')
|
|
156
316
|
.option('--region <region>', 'fleet region')
|
|
157
317
|
.option('--display-name <name>', 'human-readable name')
|
|
158
318
|
.option('--label <key=value>', 'label to attach to the session', (v, p) => [...p, v], [])
|
|
159
|
-
.option('--install <file>', 'local
|
|
319
|
+
.option('--install <file>', 'local app artifact to upload and install', (v, p) => [...p, v], [])
|
|
160
320
|
.option('--install-asset <name-or-id>', 'previously uploaded asset to install', (v, p) => [...p, v], [])
|
|
161
321
|
.option('--inactivity-timeout <duration>', 'auto-close idle timeout, e.g. 60s, 3m, or none')
|
|
162
322
|
.option('--hard-timeout <duration>', 'maximum lifetime, e.g. 10m or 1h')
|
|
323
|
+
.option('--codec <auto|mjpeg|webrtc>', 'force the simulator viewer stream codec', 'auto')
|
|
163
324
|
.option('--rm', 'release the session when this command exits', false)
|
|
164
325
|
.option('--json', 'output JSON', false)
|
|
326
|
+
.addOption(new Option('--dev-host <host>', 'dev-only: target a specific serve-sim fleet host').hideHelp())
|
|
165
327
|
.action((opts) => action(async () => {
|
|
166
|
-
|
|
167
|
-
for (const file of opts.install ?? [])
|
|
168
|
-
uploadedAssets.push(await pushAsset(file));
|
|
169
|
-
const installAssets = [
|
|
170
|
-
...(opts.installAsset ?? []),
|
|
171
|
-
...uploadedAssets
|
|
172
|
-
.map((asset) => asset.id)
|
|
173
|
-
.filter((id) => typeof id === 'string' && id.length > 0),
|
|
174
|
-
];
|
|
175
|
-
const session = await client().post('/run-cloud/ios', {
|
|
176
|
-
model: opts.model,
|
|
177
|
-
region: opts.region,
|
|
178
|
-
displayName: opts.displayName,
|
|
179
|
-
labels: parseLabels(opts.label),
|
|
180
|
-
installAssets,
|
|
181
|
-
inactivityTimeout: opts.inactivityTimeout,
|
|
182
|
-
hardTimeout: opts.hardTimeout,
|
|
183
|
-
autoDelete: opts.rm,
|
|
184
|
-
});
|
|
185
|
-
print(session, opts);
|
|
328
|
+
print(await createSimulatorSession(platform, opts), opts);
|
|
186
329
|
}));
|
|
187
|
-
|
|
330
|
+
simulator
|
|
188
331
|
.command('list')
|
|
189
|
-
.description(
|
|
332
|
+
.description(`List active ${label} sessions`)
|
|
190
333
|
.option('--all', 'include released sessions', false)
|
|
191
334
|
.option('--json', 'output JSON', false)
|
|
192
335
|
.action((opts) => action(async () => {
|
|
193
336
|
const qs = opts.all ? '?all=1' : '';
|
|
194
|
-
print(await client().get(`/run-cloud
|
|
337
|
+
print(await client().get(`/run-cloud/${platform}${qs}`), opts);
|
|
195
338
|
}));
|
|
196
|
-
|
|
339
|
+
simulator
|
|
197
340
|
.command('get')
|
|
198
|
-
.description(
|
|
341
|
+
.description(`Show ${article} ${label} session`)
|
|
199
342
|
.argument('<id>')
|
|
200
343
|
.option('--json', 'output JSON', false)
|
|
201
344
|
.action((id, opts) => action(async () => {
|
|
202
|
-
print(await client().get(`/run-cloud
|
|
345
|
+
print(await client().get(`/run-cloud/${platform}/${encodeURIComponent(id)}`), opts);
|
|
203
346
|
}));
|
|
204
|
-
|
|
347
|
+
simulator
|
|
205
348
|
.command('delete')
|
|
206
|
-
.description(
|
|
349
|
+
.description(`Release ${article} ${label} session`)
|
|
207
350
|
.argument('<id>')
|
|
208
351
|
.option('--json', 'output JSON', false)
|
|
209
352
|
.action((id, opts) => action(async () => {
|
|
210
|
-
print(await client().delete(`/run-cloud
|
|
353
|
+
print(await client().delete(`/run-cloud/${platform}/${encodeURIComponent(id)}`), opts);
|
|
211
354
|
}));
|
|
212
|
-
|
|
355
|
+
simulator
|
|
213
356
|
.command('open-url')
|
|
214
|
-
.description(
|
|
357
|
+
.description(`Open a URL or deep link in ${article} ${label} session`)
|
|
215
358
|
.argument('<url>')
|
|
216
359
|
.requiredOption('--id <id>', 'session id')
|
|
217
360
|
.option('--json', 'output JSON', false)
|
|
218
361
|
.action((url, opts) => action(async () => {
|
|
219
|
-
print(await client().post(`/run-cloud
|
|
362
|
+
print(await client().post(`/run-cloud/${platform}/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
|
|
363
|
+
}));
|
|
364
|
+
return simulator;
|
|
365
|
+
}
|
|
366
|
+
async function action(fn) {
|
|
367
|
+
try {
|
|
368
|
+
await fn();
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
console.error(friendlyApiError(err));
|
|
372
|
+
process.exitCode = 1;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
export function registerRunCloud(program) {
|
|
376
|
+
const demo = program.command('demo').description('Create and run run.cloud demo projects');
|
|
377
|
+
demo
|
|
378
|
+
.command('init')
|
|
379
|
+
.description('Scaffold a standalone demo project')
|
|
380
|
+
.argument('[name]', 'demo to create', 'parallel-simulators')
|
|
381
|
+
.argument('[directory]', 'output directory')
|
|
382
|
+
.option('--force', 'overwrite an existing output directory', false)
|
|
383
|
+
.option('--json', 'output JSON', false)
|
|
384
|
+
.action((name, directory, opts) => action(async () => {
|
|
385
|
+
print(initRunCloudDemo(name, directory, opts), opts);
|
|
386
|
+
}));
|
|
387
|
+
demo
|
|
388
|
+
.command('run')
|
|
389
|
+
.description('Run a bundled demo without scaffolding a project')
|
|
390
|
+
.argument('[name]', 'demo to run', 'parallel-simulators')
|
|
391
|
+
.option('--count <2|3>', 'number of sessions (parallel-simulators only)')
|
|
392
|
+
.option('--duration <seconds>', 'duration for parallel-simulators and live-camera-relay')
|
|
393
|
+
.option('--open', 'open the demo viewer or live sessions in the browser', false)
|
|
394
|
+
.action((name, opts) => action(async () => {
|
|
395
|
+
await runCloudDemo(name, opts);
|
|
396
|
+
}));
|
|
397
|
+
program
|
|
398
|
+
.command('account')
|
|
399
|
+
.description('Show run.cloud entitlement and metered balance')
|
|
400
|
+
.option('--json', 'output JSON', false)
|
|
401
|
+
.action((opts) => action(async () => {
|
|
402
|
+
print(await client().get('/run-cloud/account'), opts);
|
|
403
|
+
}));
|
|
404
|
+
const ios = registerSimulatorCommands(program, 'ios');
|
|
405
|
+
registerSimulatorCommands(program, 'android');
|
|
406
|
+
ios
|
|
407
|
+
.command('tunnel')
|
|
408
|
+
.description('Connect a local service to a remote iOS simulator session')
|
|
409
|
+
.argument('<id>')
|
|
410
|
+
.requiredOption('--local-port <port>', 'local service port, for example Metro on 8081')
|
|
411
|
+
.option('--service <name>', 'service name shown in status', 'metro')
|
|
412
|
+
.option('--local-host <host>', 'loopback service host', '127.0.0.1')
|
|
413
|
+
.option('--sidecar-url <url>', 'Newly sidecar URL', 'http://127.0.0.1:4500')
|
|
414
|
+
.option('--json', 'output JSON', false)
|
|
415
|
+
.action((id, opts) => action(async () => {
|
|
416
|
+
const localPort = Number(opts.localPort);
|
|
417
|
+
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)
|
|
418
|
+
throw new Error('--local-port must be a valid TCP port');
|
|
419
|
+
const allocation = await client().post(`/sessions/${encodeURIComponent(id)}/tunnel`, {
|
|
420
|
+
localService: opts.service, localHost: opts.localHost, localPort,
|
|
421
|
+
});
|
|
422
|
+
if (!allocation.tunnel)
|
|
423
|
+
throw new Error('Control plane did not return tunnel credentials');
|
|
424
|
+
const sidecarResponse = await fetch(`${opts.sidecarUrl.replace(/\/$/, '')}/run-cloud/tunnel`, {
|
|
425
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(allocation.tunnel),
|
|
426
|
+
});
|
|
427
|
+
const status = await sidecarResponse.json();
|
|
428
|
+
if (!sidecarResponse.ok)
|
|
429
|
+
throw new Error(`Local tunnel failed: ${status.error ?? sidecarResponse.statusText}`);
|
|
430
|
+
print(status, opts);
|
|
431
|
+
}));
|
|
432
|
+
ios
|
|
433
|
+
.command('tunnel-status')
|
|
434
|
+
.description('Show the local tunnel sidecar status')
|
|
435
|
+
.option('--sidecar-url <url>', 'Newly sidecar URL', 'http://127.0.0.1:4500')
|
|
436
|
+
.option('--json', 'output JSON', false)
|
|
437
|
+
.action((opts) => action(async () => {
|
|
438
|
+
const response = await fetch(`${opts.sidecarUrl.replace(/\/$/, '')}/run-cloud/tunnel`);
|
|
439
|
+
print(await response.json(), opts);
|
|
220
440
|
}));
|
|
221
441
|
const asset = program
|
|
222
442
|
.command('asset')
|
|
@@ -260,7 +480,7 @@ export function registerRunCloud(program) {
|
|
|
260
480
|
const skills = program.command('skills').description('Install run.cloud skills for AI coding agents');
|
|
261
481
|
skills
|
|
262
482
|
.command('install')
|
|
263
|
-
.description('Install the run.cloud
|
|
483
|
+
.description('Install the run.cloud agent runtime skill')
|
|
264
484
|
.option('--agents <agent>', 'agent to install for: claude, codex, cursor', (v, p) => [...p, v], [])
|
|
265
485
|
.option('--scope <project|global>', 'install scope', 'project')
|
|
266
486
|
.option('--force', 'overwrite existing skill', false)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { friendlyApiError } from '../api.js';
|
|
2
|
+
class CpClient {
|
|
3
|
+
baseUrl;
|
|
4
|
+
token;
|
|
5
|
+
constructor(baseUrl, token) {
|
|
6
|
+
this.baseUrl = baseUrl;
|
|
7
|
+
this.token = token;
|
|
8
|
+
}
|
|
9
|
+
async request(method, path, body) {
|
|
10
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
11
|
+
method,
|
|
12
|
+
headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
|
|
13
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
14
|
+
});
|
|
15
|
+
const text = await res.text();
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
let msg = text;
|
|
18
|
+
try {
|
|
19
|
+
msg = JSON.parse(text).error ?? text;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`control plane ${res.status}: ${msg}`);
|
|
24
|
+
}
|
|
25
|
+
return text ? JSON.parse(text) : null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function cp(opts) {
|
|
29
|
+
const baseUrl = (opts.cpUrl || process.env.SBX_API_URL || 'http://127.0.0.1:8080').replace(/\/+$/, '');
|
|
30
|
+
const token = opts.cpToken || process.env.SBX_TOKEN;
|
|
31
|
+
if (!token) {
|
|
32
|
+
throw new Error('No control-plane token: set SBX_TOKEN or pass --cp-token.');
|
|
33
|
+
}
|
|
34
|
+
return new CpClient(baseUrl, token);
|
|
35
|
+
}
|
|
36
|
+
function print(value, opts) {
|
|
37
|
+
if (opts.json) {
|
|
38
|
+
console.log(JSON.stringify(value, null, 2));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
for (const item of value)
|
|
43
|
+
console.log(formatRecord(item));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
console.log(formatRecord(value));
|
|
47
|
+
}
|
|
48
|
+
function formatRecord(value) {
|
|
49
|
+
if (!value || typeof value !== 'object')
|
|
50
|
+
return String(value);
|
|
51
|
+
return Object.entries(value)
|
|
52
|
+
.filter(([, v]) => v !== undefined && v !== null)
|
|
53
|
+
.map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
|
|
54
|
+
.join('\n');
|
|
55
|
+
}
|
|
56
|
+
async function run(fn) {
|
|
57
|
+
try {
|
|
58
|
+
await fn();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
console.error(friendlyApiError(err));
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function withCp(cmd) {
|
|
66
|
+
return cmd
|
|
67
|
+
.option('--cp-url <url>', 'control-plane base URL (default $SBX_API_URL or http://127.0.0.1:8080)')
|
|
68
|
+
.option('--cp-token <token>', 'control-plane org token (or $SBX_TOKEN; required)')
|
|
69
|
+
.option('--json', 'output JSON', false);
|
|
70
|
+
}
|
|
71
|
+
export function registerSandbox(program) {
|
|
72
|
+
const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
|
|
73
|
+
withCp(sandbox
|
|
74
|
+
.command('create')
|
|
75
|
+
.description('Spawn a microVM sandbox')
|
|
76
|
+
.option('--image <ref>', 'base image', 'newly/agent-base')
|
|
77
|
+
.option('--size <class>', 'size preset', 's-2')
|
|
78
|
+
.option('--region <region>', 'placement region')
|
|
79
|
+
.option('--name <name>', 'human-readable name')).action((opts) => run(async () => {
|
|
80
|
+
const body = { size_class: opts.size, image: opts.image };
|
|
81
|
+
if (opts.name)
|
|
82
|
+
body.name = opts.name;
|
|
83
|
+
if (opts.region)
|
|
84
|
+
body.region = opts.region;
|
|
85
|
+
print(await cp(opts).request('POST', '/v1/sandboxes', body), opts);
|
|
86
|
+
}));
|
|
87
|
+
withCp(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
|
|
88
|
+
const qs = opts.state ? `?state=${encodeURIComponent(opts.state)}` : '';
|
|
89
|
+
const data = await cp(opts).request('GET', `/v1/sandboxes${qs}`);
|
|
90
|
+
print(data?.items ?? [], opts);
|
|
91
|
+
}));
|
|
92
|
+
withCp(sandbox
|
|
93
|
+
.command('exec')
|
|
94
|
+
.description('Run a command in a sandbox (via /bin/sh -c)')
|
|
95
|
+
.argument('<id>', 'sandbox id')
|
|
96
|
+
.argument('<cmd...>', 'command to run, e.g. npm run build')).action((id, cmdParts, opts) => run(async () => {
|
|
97
|
+
const r = await cp(opts).request('POST', `/v1/sandboxes/${encodeURIComponent(id)}/exec`, {
|
|
98
|
+
cmd: ['/bin/sh', '-c', cmdParts.join(' ')],
|
|
99
|
+
});
|
|
100
|
+
if (opts.json) {
|
|
101
|
+
console.log(JSON.stringify(r, null, 2));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
if (r.stdout)
|
|
105
|
+
process.stdout.write(r.stdout);
|
|
106
|
+
if (r.stderr)
|
|
107
|
+
process.stderr.write(r.stderr);
|
|
108
|
+
}
|
|
109
|
+
process.exitCode = r.exit_code ?? 0;
|
|
110
|
+
}));
|
|
111
|
+
withCp(sandbox
|
|
112
|
+
.command('logs')
|
|
113
|
+
.description('Show recent sandbox logs (one-shot journalctl tail; streaming is a follow-up)')
|
|
114
|
+
.argument('<id>', 'sandbox id')
|
|
115
|
+
.option('--lines <n>', 'number of lines to tail', '200')).action((id, opts) => run(async () => {
|
|
116
|
+
const n = Number(opts.lines) || 200;
|
|
117
|
+
const r = await cp(opts).request('POST', `/v1/sandboxes/${encodeURIComponent(id)}/exec`, {
|
|
118
|
+
cmd: [
|
|
119
|
+
'/bin/sh',
|
|
120
|
+
'-c',
|
|
121
|
+
`j="$(journalctl -n ${n} --no-pager 2>/dev/null)"; if [ -n "$j" ]; then printf "%s\\n" "$j"; else tail -n ${n} /root/.natively/expo_server.log 2>&1 || echo '(no logs available)'; fi`,
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
if (opts.json) {
|
|
125
|
+
console.log(JSON.stringify(r, null, 2));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
process.stdout.write(r.stdout ?? '');
|
|
129
|
+
process.stderr.write(r.stderr ?? '');
|
|
130
|
+
}));
|
|
131
|
+
withCp(sandbox.command('rm').description('Destroy a sandbox').argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
|
|
132
|
+
await cp(opts).request('DELETE', `/v1/sandboxes/${encodeURIComponent(id)}`);
|
|
133
|
+
if (!opts.json)
|
|
134
|
+
console.log(`destroyed ${id}`);
|
|
135
|
+
}));
|
|
136
|
+
}
|