runcloud 0.1.2 → 0.1.3
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 +270 -68
- 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 +29 -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 +56 -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,25 +1,81 @@
|
|
|
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: Configure and use run.cloud for agent sandboxes, provider-compatible migrations, and mobile simulator sessions.
|
|
11
|
+
version: 0.4.0
|
|
10
12
|
---
|
|
11
13
|
|
|
12
|
-
# run.cloud
|
|
14
|
+
# run.cloud Agent Runtime
|
|
13
15
|
|
|
14
|
-
Use this skill when a user asks an agent to run, inspect, smoke test, or debug an iOS
|
|
16
|
+
Use this skill when a user asks an agent to adopt run.cloud, migrate an existing sandbox integration, or run, inspect, smoke test, or debug an iOS or Android app through run.cloud.
|
|
15
17
|
|
|
16
18
|
## Requirements
|
|
17
19
|
|
|
18
|
-
-
|
|
20
|
+
- Read the API credential from \`RUN_CLOUD_API_KEY\`. Never print it, commit it, or write it into a skill file.
|
|
21
|
+
- Use \`RUN_CLOUD_API_URL\` when set; otherwise use \`https://api.newly.app\`.
|
|
22
|
+
- CLI simulator commands can use \`runcloud login\`; SDK and API usage should use \`RUN_CLOUD_API_KEY\`.
|
|
19
23
|
- The account must have run.cloud access and a positive run.cloud balance.
|
|
20
|
-
- App artifacts
|
|
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
|
+
## Detect the Existing Sandbox Provider
|
|
27
|
+
|
|
28
|
+
Before changing dependencies or imports, inspect package manifests, lockfiles, source imports, and provider-specific environment variable names. Check for these signals:
|
|
29
|
+
|
|
30
|
+
| Provider | Common package/import signal | run.cloud compatibility entry point |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| Modal | \`modal\` | \`run_cloud.compat.modal\` |
|
|
33
|
+
| E2B | \`e2b\`, \`@e2b/code-interpreter\` | \`run_cloud.compat.e2b\` |
|
|
34
|
+
| Blaxel | \`blaxel\` | \`run_cloud.compat.blaxel\` |
|
|
35
|
+
| CodeSandbox | \`@codesandbox/sdk\` | \`@run-cloud/sdk/compat/codesandbox\` |
|
|
36
|
+
| Vercel Sandbox | \`@vercel/sandbox\` | \`@run-cloud/sdk/compat/vercel\` |
|
|
37
|
+
| Cloudflare Sandbox | \`@cloudflare/sandbox\` | \`@run-cloud/sdk/compat/cloudflare\` |
|
|
38
|
+
| Fly Machines | \`@flydotio/machines\` | \`@run-cloud/sdk/compat/fly\` |
|
|
39
|
+
| Daytona | \`daytona\` | \`run_cloud.compat.daytona\` |
|
|
40
|
+
|
|
41
|
+
When a provider is detected:
|
|
42
|
+
|
|
43
|
+
1. Preserve the existing provider-shaped API and change only the integration boundary.
|
|
44
|
+
2. Use the matching compatibility entry point only after confirming its package resolves through the project's package manager or configured registry.
|
|
45
|
+
3. Run the existing provider integration tests after the import change. Provider-specific images, networking, regions, and lifecycle options still require explicit validation.
|
|
46
|
+
4. If more than one provider is present, map each call site independently and report the result. Do not choose one globally.
|
|
47
|
+
|
|
48
|
+
When no provider is detected, prefer native \`runcloud\` CLI commands for supported operations. For TypeScript, \`@run-cloud/sdk\` is the native API-key SDK.
|
|
49
|
+
|
|
50
|
+
## TypeScript SDK
|
|
51
|
+
|
|
52
|
+
Install the SDK only when the project can resolve npm packages:
|
|
53
|
+
|
|
54
|
+
\`\`\`bash
|
|
55
|
+
npm install @run-cloud/sdk
|
|
56
|
+
\`\`\`
|
|
57
|
+
|
|
58
|
+
Use \`RUN_CLOUD_API_KEY\` for authentication:
|
|
59
|
+
|
|
60
|
+
\`\`\`ts
|
|
61
|
+
import { Client } from "@run-cloud/sdk";
|
|
62
|
+
|
|
63
|
+
const cloud = new Client();
|
|
64
|
+
const session = await cloud.simulators.create({
|
|
65
|
+
platform: "android",
|
|
66
|
+
displayName: "Agent smoke",
|
|
67
|
+
inactivityTimeout: "60s",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await cloud.simulators.openUrl(session.id, "myapp://settings", {
|
|
71
|
+
platform: session.platform,
|
|
72
|
+
});
|
|
73
|
+
await cloud.simulators.delete(session.id, {
|
|
74
|
+
platform: session.platform,
|
|
75
|
+
});
|
|
76
|
+
\`\`\`
|
|
77
|
+
|
|
78
|
+
## iOS Simulator Workflow
|
|
23
79
|
|
|
24
80
|
1. Check access with \`runcloud account --json\`.
|
|
25
81
|
2. Create a simulator with \`runcloud ios create --install ./build/MyApp.tar.gz --json\`.
|
|
@@ -27,20 +83,41 @@ Use this skill when a user asks an agent to run, inspect, smoke test, or debug a
|
|
|
27
83
|
4. Use \`runcloud ios open-url <url> --id <session-id>\` when a URL or deep link must be opened.
|
|
28
84
|
5. Release the session with \`runcloud ios delete <session-id>\`.
|
|
29
85
|
|
|
86
|
+
Use \`runcloud ios create --codec webrtc --json\` when the viewer must open directly on the WebRTC stream path. The response includes \`platform\`, \`codec\`, and \`stream\` fields. WebRTC is experimental; only use it when the environment enables experimental WebRTC.
|
|
87
|
+
|
|
88
|
+
## Android Emulator Workflow
|
|
89
|
+
|
|
90
|
+
1. Check access with \`runcloud account --json\`.
|
|
91
|
+
2. Create an emulator with \`runcloud android create --install ./build/app-debug.apk --json\`.
|
|
92
|
+
3. Save the returned session id and URL.
|
|
93
|
+
4. Use \`runcloud android open-url <url> --id <session-id>\` when a URL or deep link must be opened.
|
|
94
|
+
5. Release the session with \`runcloud android delete <session-id>\`.
|
|
95
|
+
|
|
96
|
+
## Simulator Demos
|
|
97
|
+
|
|
98
|
+
- Run \`runcloud demo run parallel-simulators --open\` to create three independent sessions.
|
|
99
|
+
- Run \`runcloud demo run eight-device-mosaic --open\` to coordinate eight sessions in one 4x2 animated display.
|
|
100
|
+
- Run \`runcloud demo run live-camera-relay --open\` to connect a webcam to one simulator camera and relay its WebRTC video to two receiver simulators.
|
|
101
|
+
|
|
102
|
+
All demos release every session automatically. Their source is public at \`https://github.com/newly-app/run-cloud-examples\`. They require the existing \`runcloud login\` simulator credential in addition to the API key.
|
|
103
|
+
|
|
30
104
|
## Embedded Iframes
|
|
31
105
|
|
|
32
106
|
- Use \`runcloud ios create --inactivity-timeout 60s --json\` when the embed should auto-close after user inactivity.
|
|
107
|
+
- Use \`runcloud android create --inactivity-timeout 60s --json\` for Android emulator embeds with the same inactivity policy.
|
|
33
108
|
- Omit \`--inactivity-timeout\` or pass \`none\` when the user needs a metered session without idle auto-close.
|
|
34
|
-
- Iframes post
|
|
35
|
-
- If
|
|
109
|
+
- Iframes post simulator status, auth error, session ended, and restart request messages to the parent window. Verify \`event.source\` before acting.
|
|
110
|
+
- If a session restart request arrives, create a fresh run.cloud session; do not reuse the ended iframe URL.
|
|
36
111
|
|
|
37
112
|
## Rules
|
|
38
113
|
|
|
39
114
|
- Prefer \`--json\` for parsed output.
|
|
115
|
+
- Detect existing provider usage before proposing SDK changes.
|
|
116
|
+
- Do not install an adapter until its package is resolvable.
|
|
40
117
|
- Always release sessions you create unless the user asks to keep them open.
|
|
41
|
-
- If installation fails, verify that the artifact
|
|
118
|
+
- If installation fails, verify that the artifact matches the target platform before attempting code changes.
|
|
42
119
|
- Do not assume a local tunnel is installed on the user's machine.
|
|
43
|
-
- Do not expose
|
|
120
|
+
- Do not expose simulator tokens in logs or screenshots.
|
|
44
121
|
`;
|
|
45
122
|
function client() {
|
|
46
123
|
const creds = requireCredentials();
|
|
@@ -123,6 +200,61 @@ function installSkill(opts) {
|
|
|
123
200
|
}
|
|
124
201
|
return results;
|
|
125
202
|
}
|
|
203
|
+
const RUN_CLOUD_DEMOS = [
|
|
204
|
+
'parallel-simulators',
|
|
205
|
+
'eight-device-mosaic',
|
|
206
|
+
'live-camera-relay',
|
|
207
|
+
];
|
|
208
|
+
function experimentalWebRtcEnabled() {
|
|
209
|
+
return ['1', 'true', 'yes', 'on'].includes((process.env.RUN_CLOUD_EXPERIMENTAL_WEBRTC ?? '').trim().toLowerCase());
|
|
210
|
+
}
|
|
211
|
+
function assertDevHostAllowed(devHost) {
|
|
212
|
+
if (devHost && process.env.RUN_CLOUD_DEV_FEATURES !== '1') {
|
|
213
|
+
throw new Error('--dev-host is dev-only; set RUN_CLOUD_DEV_FEATURES=1 to use it');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function requestedCodec(codec) {
|
|
217
|
+
const value = codec?.trim().toLowerCase() || 'auto';
|
|
218
|
+
if (value === 'webrtc' && !experimentalWebRtcEnabled()) {
|
|
219
|
+
throw new Error('--codec webrtc is experimental; set RUN_CLOUD_EXPERIMENTAL_WEBRTC=1 to use it');
|
|
220
|
+
}
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
function demoDirectory(name) {
|
|
224
|
+
if (!RUN_CLOUD_DEMOS.some((demoName) => demoName === name)) {
|
|
225
|
+
throw new Error(`Unknown demo: ${name}. Available demos: ${RUN_CLOUD_DEMOS.join(', ')}`);
|
|
226
|
+
}
|
|
227
|
+
const source = resolve(dirname(fileURLToPath(import.meta.url)), '../../examples', name);
|
|
228
|
+
if (!existsSync(source))
|
|
229
|
+
throw new Error(`The ${name} demo is missing from this runcloud package.`);
|
|
230
|
+
return source;
|
|
231
|
+
}
|
|
232
|
+
export function initRunCloudDemo(name = 'parallel-simulators', directory, opts = {}) {
|
|
233
|
+
const source = demoDirectory(name);
|
|
234
|
+
const target = resolve(directory ?? `run-cloud-${name}`);
|
|
235
|
+
if (existsSync(target)) {
|
|
236
|
+
if (!opts.force)
|
|
237
|
+
throw new Error(`${target} already exists. Pass --force to overwrite it.`);
|
|
238
|
+
rmSync(target, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
cpSync(source, target, { recursive: true });
|
|
241
|
+
return { example: name, path: target, status: 'created' };
|
|
242
|
+
}
|
|
243
|
+
export async function runCloudDemo(name = 'parallel-simulators', opts = {}, loadDemo = async (url) => (await import(url))) {
|
|
244
|
+
if (name === 'eight-device-mosaic' && opts.duration) {
|
|
245
|
+
throw new Error('The eight-device mosaic uses a fixed 10-minute lease and does not accept --duration.');
|
|
246
|
+
}
|
|
247
|
+
const args = [];
|
|
248
|
+
if (opts.count)
|
|
249
|
+
args.push('--count', opts.count);
|
|
250
|
+
if (opts.duration)
|
|
251
|
+
args.push('--duration', opts.duration);
|
|
252
|
+
if (opts.open)
|
|
253
|
+
args.push('--open');
|
|
254
|
+
const source = join(demoDirectory(name), 'demo.mjs');
|
|
255
|
+
const demo = await loadDemo(pathToFileURL(source).href);
|
|
256
|
+
await demo.runDemo(args);
|
|
257
|
+
}
|
|
126
258
|
async function pushAsset(path, opts = {}) {
|
|
127
259
|
const { blob, name } = fileBlob(path);
|
|
128
260
|
const form = new FormData();
|
|
@@ -131,92 +263,162 @@ async function pushAsset(path, opts = {}) {
|
|
|
131
263
|
form.set('name', opts.name);
|
|
132
264
|
return (await client().uploadForm('/run-cloud/assets', form));
|
|
133
265
|
}
|
|
134
|
-
async function
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
266
|
+
async function createSimulatorSession(platform, opts) {
|
|
267
|
+
assertDevHostAllowed(opts.devHost);
|
|
268
|
+
const uploadedAssets = [];
|
|
269
|
+
for (const file of opts.install ?? [])
|
|
270
|
+
uploadedAssets.push(await pushAsset(file));
|
|
271
|
+
const installAssets = [
|
|
272
|
+
...(opts.installAsset ?? []),
|
|
273
|
+
...uploadedAssets
|
|
274
|
+
.map((asset) => asset.id)
|
|
275
|
+
.filter((id) => typeof id === 'string' && id.length > 0),
|
|
276
|
+
];
|
|
277
|
+
return await client().post(`/run-cloud/${platform}`, {
|
|
278
|
+
model: opts.model,
|
|
279
|
+
region: opts.region,
|
|
280
|
+
displayName: opts.displayName,
|
|
281
|
+
labels: parseLabels(opts.label),
|
|
282
|
+
installAssets,
|
|
283
|
+
inactivityTimeout: opts.inactivityTimeout,
|
|
284
|
+
hardTimeout: opts.hardTimeout,
|
|
285
|
+
autoDelete: opts.rm,
|
|
286
|
+
codec: requestedCodec(opts.codec),
|
|
287
|
+
...(opts.devHost ? { devHost: opts.devHost } : {}),
|
|
288
|
+
});
|
|
142
289
|
}
|
|
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
|
|
290
|
+
function registerSimulatorCommands(program, platform) {
|
|
291
|
+
const label = platform === 'android' ? 'Android emulator' : 'iOS simulator';
|
|
292
|
+
const article = platform === 'android' ? 'an' : 'an';
|
|
293
|
+
const simulator = program.command(platform).description(`Create, list, inspect, and delete ${label} sessions`);
|
|
294
|
+
simulator
|
|
153
295
|
.command('create')
|
|
154
|
-
.description(
|
|
155
|
-
.option('--model <
|
|
296
|
+
.description(`Create a remote ${label} session`)
|
|
297
|
+
.option('--model <model>', platform === 'ios' ? 'simulator model' : 'emulator model', platform === 'ios' ? 'iphone' : 'pixel')
|
|
156
298
|
.option('--region <region>', 'fleet region')
|
|
157
299
|
.option('--display-name <name>', 'human-readable name')
|
|
158
300
|
.option('--label <key=value>', 'label to attach to the session', (v, p) => [...p, v], [])
|
|
159
|
-
.option('--install <file>', 'local
|
|
301
|
+
.option('--install <file>', 'local app artifact to upload and install', (v, p) => [...p, v], [])
|
|
160
302
|
.option('--install-asset <name-or-id>', 'previously uploaded asset to install', (v, p) => [...p, v], [])
|
|
161
303
|
.option('--inactivity-timeout <duration>', 'auto-close idle timeout, e.g. 60s, 3m, or none')
|
|
162
304
|
.option('--hard-timeout <duration>', 'maximum lifetime, e.g. 10m or 1h')
|
|
305
|
+
.option('--codec <auto|mjpeg|webrtc>', 'force the simulator viewer stream codec', 'auto')
|
|
163
306
|
.option('--rm', 'release the session when this command exits', false)
|
|
164
307
|
.option('--json', 'output JSON', false)
|
|
308
|
+
.addOption(new Option('--dev-host <host>', 'dev-only: target a specific serve-sim fleet host').hideHelp())
|
|
165
309
|
.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);
|
|
310
|
+
print(await createSimulatorSession(platform, opts), opts);
|
|
186
311
|
}));
|
|
187
|
-
|
|
312
|
+
simulator
|
|
188
313
|
.command('list')
|
|
189
|
-
.description(
|
|
314
|
+
.description(`List active ${label} sessions`)
|
|
190
315
|
.option('--all', 'include released sessions', false)
|
|
191
316
|
.option('--json', 'output JSON', false)
|
|
192
317
|
.action((opts) => action(async () => {
|
|
193
318
|
const qs = opts.all ? '?all=1' : '';
|
|
194
|
-
print(await client().get(`/run-cloud
|
|
319
|
+
print(await client().get(`/run-cloud/${platform}${qs}`), opts);
|
|
195
320
|
}));
|
|
196
|
-
|
|
321
|
+
simulator
|
|
197
322
|
.command('get')
|
|
198
|
-
.description(
|
|
323
|
+
.description(`Show ${article} ${label} session`)
|
|
199
324
|
.argument('<id>')
|
|
200
325
|
.option('--json', 'output JSON', false)
|
|
201
326
|
.action((id, opts) => action(async () => {
|
|
202
|
-
print(await client().get(`/run-cloud
|
|
327
|
+
print(await client().get(`/run-cloud/${platform}/${encodeURIComponent(id)}`), opts);
|
|
203
328
|
}));
|
|
204
|
-
|
|
329
|
+
simulator
|
|
205
330
|
.command('delete')
|
|
206
|
-
.description(
|
|
331
|
+
.description(`Release ${article} ${label} session`)
|
|
207
332
|
.argument('<id>')
|
|
208
333
|
.option('--json', 'output JSON', false)
|
|
209
334
|
.action((id, opts) => action(async () => {
|
|
210
|
-
print(await client().delete(`/run-cloud
|
|
335
|
+
print(await client().delete(`/run-cloud/${platform}/${encodeURIComponent(id)}`), opts);
|
|
211
336
|
}));
|
|
212
|
-
|
|
337
|
+
simulator
|
|
213
338
|
.command('open-url')
|
|
214
|
-
.description(
|
|
339
|
+
.description(`Open a URL or deep link in ${article} ${label} session`)
|
|
215
340
|
.argument('<url>')
|
|
216
341
|
.requiredOption('--id <id>', 'session id')
|
|
217
342
|
.option('--json', 'output JSON', false)
|
|
218
343
|
.action((url, opts) => action(async () => {
|
|
219
|
-
print(await client().post(`/run-cloud
|
|
344
|
+
print(await client().post(`/run-cloud/${platform}/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
|
|
345
|
+
}));
|
|
346
|
+
return simulator;
|
|
347
|
+
}
|
|
348
|
+
async function action(fn) {
|
|
349
|
+
try {
|
|
350
|
+
await fn();
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
console.error(friendlyApiError(err));
|
|
354
|
+
process.exitCode = 1;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
export function registerRunCloud(program) {
|
|
358
|
+
const demo = program.command('demo').description('Create and run run.cloud demo projects');
|
|
359
|
+
demo
|
|
360
|
+
.command('init')
|
|
361
|
+
.description('Scaffold a standalone demo project')
|
|
362
|
+
.argument('[name]', 'demo to create', 'parallel-simulators')
|
|
363
|
+
.argument('[directory]', 'output directory')
|
|
364
|
+
.option('--force', 'overwrite an existing output directory', false)
|
|
365
|
+
.option('--json', 'output JSON', false)
|
|
366
|
+
.action((name, directory, opts) => action(async () => {
|
|
367
|
+
print(initRunCloudDemo(name, directory, opts), opts);
|
|
368
|
+
}));
|
|
369
|
+
demo
|
|
370
|
+
.command('run')
|
|
371
|
+
.description('Run a bundled demo without scaffolding a project')
|
|
372
|
+
.argument('[name]', 'demo to run', 'parallel-simulators')
|
|
373
|
+
.option('--count <2|3>', 'number of sessions (parallel-simulators only)')
|
|
374
|
+
.option('--duration <seconds>', 'duration for parallel-simulators and live-camera-relay')
|
|
375
|
+
.option('--open', 'open the demo viewer or live sessions in the browser', false)
|
|
376
|
+
.action((name, opts) => action(async () => {
|
|
377
|
+
await runCloudDemo(name, opts);
|
|
378
|
+
}));
|
|
379
|
+
program
|
|
380
|
+
.command('account')
|
|
381
|
+
.description('Show run.cloud entitlement and metered balance')
|
|
382
|
+
.option('--json', 'output JSON', false)
|
|
383
|
+
.action((opts) => action(async () => {
|
|
384
|
+
print(await client().get('/run-cloud/account'), opts);
|
|
385
|
+
}));
|
|
386
|
+
const ios = registerSimulatorCommands(program, 'ios');
|
|
387
|
+
registerSimulatorCommands(program, 'android');
|
|
388
|
+
ios
|
|
389
|
+
.command('tunnel')
|
|
390
|
+
.description('Connect a local service to a remote iOS simulator session')
|
|
391
|
+
.argument('<id>')
|
|
392
|
+
.requiredOption('--local-port <port>', 'local service port, for example Metro on 8081')
|
|
393
|
+
.option('--service <name>', 'service name shown in status', 'metro')
|
|
394
|
+
.option('--local-host <host>', 'loopback service host', '127.0.0.1')
|
|
395
|
+
.option('--sidecar-url <url>', 'Newly sidecar URL', 'http://127.0.0.1:4500')
|
|
396
|
+
.option('--json', 'output JSON', false)
|
|
397
|
+
.action((id, opts) => action(async () => {
|
|
398
|
+
const localPort = Number(opts.localPort);
|
|
399
|
+
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)
|
|
400
|
+
throw new Error('--local-port must be a valid TCP port');
|
|
401
|
+
const allocation = await client().post(`/sessions/${encodeURIComponent(id)}/tunnel`, {
|
|
402
|
+
localService: opts.service, localHost: opts.localHost, localPort,
|
|
403
|
+
});
|
|
404
|
+
if (!allocation.tunnel)
|
|
405
|
+
throw new Error('Control plane did not return tunnel credentials');
|
|
406
|
+
const sidecarResponse = await fetch(`${opts.sidecarUrl.replace(/\/$/, '')}/run-cloud/tunnel`, {
|
|
407
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(allocation.tunnel),
|
|
408
|
+
});
|
|
409
|
+
const status = await sidecarResponse.json();
|
|
410
|
+
if (!sidecarResponse.ok)
|
|
411
|
+
throw new Error(`Local tunnel failed: ${status.error ?? sidecarResponse.statusText}`);
|
|
412
|
+
print(status, opts);
|
|
413
|
+
}));
|
|
414
|
+
ios
|
|
415
|
+
.command('tunnel-status')
|
|
416
|
+
.description('Show the local tunnel sidecar status')
|
|
417
|
+
.option('--sidecar-url <url>', 'Newly sidecar URL', 'http://127.0.0.1:4500')
|
|
418
|
+
.option('--json', 'output JSON', false)
|
|
419
|
+
.action((opts) => action(async () => {
|
|
420
|
+
const response = await fetch(`${opts.sidecarUrl.replace(/\/$/, '')}/run-cloud/tunnel`);
|
|
421
|
+
print(await response.json(), opts);
|
|
220
422
|
}));
|
|
221
423
|
const asset = program
|
|
222
424
|
.command('asset')
|
|
@@ -260,7 +462,7 @@ export function registerRunCloud(program) {
|
|
|
260
462
|
const skills = program.command('skills').description('Install run.cloud skills for AI coding agents');
|
|
261
463
|
skills
|
|
262
464
|
.command('install')
|
|
263
|
-
.description('Install the run.cloud
|
|
465
|
+
.description('Install the run.cloud agent runtime skill')
|
|
264
466
|
.option('--agents <agent>', 'agent to install for: claude, codex, cursor', (v, p) => [...p, v], [])
|
|
265
467
|
.option('--scope <project|global>', 'install scope', 'project')
|
|
266
468
|
.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
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
+
function isRunCloud() {
|
|
5
|
+
const invoked = basename(process.argv[1] ?? '');
|
|
6
|
+
return invoked === 'runcloud' || invoked === 'run-cloud.js';
|
|
7
|
+
}
|
|
4
8
|
function credentialsHome() {
|
|
5
9
|
if (process.env.RUN_CLOUD_HOME)
|
|
6
10
|
return process.env.RUN_CLOUD_HOME;
|
|
7
11
|
if (process.env.NEWLY_HOME)
|
|
8
12
|
return process.env.NEWLY_HOME;
|
|
9
|
-
|
|
10
|
-
if (invoked === 'runcloud' || invoked === 'run-cloud.js')
|
|
13
|
+
if (isRunCloud())
|
|
11
14
|
return join(homedir(), '.run-cloud');
|
|
12
15
|
return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
|
|
13
16
|
}
|
|
@@ -20,12 +23,10 @@ export function credentialsDisplayPath() {
|
|
|
20
23
|
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
21
24
|
}
|
|
22
25
|
function loginCommandName() {
|
|
23
|
-
|
|
24
|
-
return invoked === 'runcloud' || invoked === 'run-cloud.js' ? 'runcloud login' : 'newly login';
|
|
26
|
+
return isRunCloud() ? 'runcloud login' : 'newly login';
|
|
25
27
|
}
|
|
26
28
|
export function normalizeApiUrl(url) {
|
|
27
|
-
|
|
28
|
-
return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
|
|
29
|
+
return url.replace(/\/+$/, '');
|
|
29
30
|
}
|
|
30
31
|
export function saveCredentials(creds) {
|
|
31
32
|
mkdirSync(credentialsHome(), { recursive: true, mode: 0o700 });
|
|
@@ -34,13 +35,19 @@ export function saveCredentials(creds) {
|
|
|
34
35
|
chmodSync(credentialsPath(), 0o600);
|
|
35
36
|
}
|
|
36
37
|
export function loadCredentials() {
|
|
37
|
-
const envToken =
|
|
38
|
-
|
|
38
|
+
const envToken = isRunCloud()
|
|
39
|
+
? process.env.RUN_CLOUD_API_TOKEN ?? process.env.RUN_CLOUD_API_KEY
|
|
40
|
+
: process.env.NEWLY_API_TOKEN ?? process.env.NEWLY_CLI_TOKEN;
|
|
41
|
+
const envApiUrl = isRunCloud()
|
|
42
|
+
? process.env.RUN_CLOUD_API_URL
|
|
43
|
+
: process.env.NEWLY_API_URL;
|
|
39
44
|
if (envToken?.trim() && envApiUrl?.trim()) {
|
|
40
45
|
return {
|
|
41
46
|
token: envToken.trim(),
|
|
42
47
|
apiUrl: normalizeApiUrl(envApiUrl.trim()),
|
|
43
|
-
appUrl:
|
|
48
|
+
appUrl: isRunCloud()
|
|
49
|
+
? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
|
|
50
|
+
: process.env.NEWLY_APP_URL ?? 'https://newly.app',
|
|
44
51
|
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
45
52
|
};
|
|
46
53
|
}
|
|
@@ -56,6 +63,40 @@ export function loadCredentials() {
|
|
|
56
63
|
return null;
|
|
57
64
|
}
|
|
58
65
|
}
|
|
66
|
+
function environmentSelectionPath(dir) {
|
|
67
|
+
return join(dir, '.newly', 'environment.json');
|
|
68
|
+
}
|
|
69
|
+
export function writeEnvironmentSelection(dir, selection) {
|
|
70
|
+
const selectionDir = join(dir, '.newly');
|
|
71
|
+
mkdirSync(selectionDir, { recursive: true });
|
|
72
|
+
const gitignorePath = join(selectionDir, '.gitignore');
|
|
73
|
+
if (!existsSync(gitignorePath))
|
|
74
|
+
writeFileSync(gitignorePath, '*\n');
|
|
75
|
+
writeFileSync(environmentSelectionPath(dir), JSON.stringify(selection, null, 2) + '\n');
|
|
76
|
+
}
|
|
77
|
+
export function readEnvironmentSelection(dir) {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(readFileSync(environmentSelectionPath(dir), 'utf8'));
|
|
80
|
+
if (!parsed.id || !parsed.name || !parsed.projectId)
|
|
81
|
+
return null;
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function clearEnvironmentSelection(dir) {
|
|
89
|
+
rmSync(environmentSelectionPath(dir), { force: true });
|
|
90
|
+
}
|
|
91
|
+
export function requireEnvironmentSelection(dir, projectId) {
|
|
92
|
+
const selection = readEnvironmentSelection(dir);
|
|
93
|
+
if (!selection)
|
|
94
|
+
throw new Error('No environment selected. Run `newly env use <name>` first.');
|
|
95
|
+
if (selection.projectId !== projectId) {
|
|
96
|
+
throw new Error('The selected environment belongs to a different linked project. Run `newly env clear`.');
|
|
97
|
+
}
|
|
98
|
+
return selection;
|
|
99
|
+
}
|
|
59
100
|
export function clearCredentials() {
|
|
60
101
|
rmSync(credentialsPath(), { force: true });
|
|
61
102
|
}
|