runcloud 0.1.104 → 0.1.106
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/README.md +6 -0
- package/dist/commands/run-cloud.js +74 -0
- package/dist/diagnose.js +56 -20
- package/dist/sessionDiagnostics.js +13 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/run-cloud-ios-simulator/SKILL.md +19 -3
package/README.md
CHANGED
|
@@ -64,6 +64,8 @@ runcloud ios create --model iphone --install ./MyApp.app # boot a simulator an
|
|
|
64
64
|
runcloud ios list # list active sessions
|
|
65
65
|
runcloud ios get <id> # inspect a session (viewer URL, status)
|
|
66
66
|
runcloud ios open-url myapp://path --id <id> # open a URL or deep link
|
|
67
|
+
runcloud ios logs <id> --tail 200 # read logs from this lease
|
|
68
|
+
runcloud ios logs <id> --follow # follow new log entries
|
|
67
69
|
runcloud ios delete <id> # release the session
|
|
68
70
|
|
|
69
71
|
# Android works the same under `runcloud android …`
|
|
@@ -74,6 +76,10 @@ Useful `create` flags: `--region`, `--display-name`, `--inactivity-timeout 3m`,
|
|
|
74
76
|
`--hard-timeout 1h`, `--install-asset <name-or-id>`, `--rm` (release when the
|
|
75
77
|
command exits), `--json`.
|
|
76
78
|
|
|
79
|
+
The same `logs` commands work for Android. Snapshots are limited to the active
|
|
80
|
+
lease and accept 1 to 1,000 lines. In follow mode, `--json` emits one JSON object
|
|
81
|
+
per line.
|
|
82
|
+
|
|
77
83
|
### Tunnel a local dev server into a simulator
|
|
78
84
|
|
|
79
85
|
```bash
|
|
@@ -65,6 +65,40 @@ function formatRecord(value) {
|
|
|
65
65
|
.map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
|
|
66
66
|
.join('\n');
|
|
67
67
|
}
|
|
68
|
+
export function consumeSimulatorLogSse(raw) {
|
|
69
|
+
const entries = [];
|
|
70
|
+
let offset = 0;
|
|
71
|
+
let separator = /\r?\n\r?\n/.exec(raw.slice(offset));
|
|
72
|
+
while (separator) {
|
|
73
|
+
const frameEnd = offset + separator.index;
|
|
74
|
+
const frame = raw.slice(offset, frameEnd);
|
|
75
|
+
offset = frameEnd + separator[0].length;
|
|
76
|
+
const data = frame
|
|
77
|
+
.split(/\r?\n/)
|
|
78
|
+
.filter((line) => line.startsWith('data:'))
|
|
79
|
+
.map((line) => line.slice('data:'.length).trimStart())
|
|
80
|
+
.join('\n');
|
|
81
|
+
if (data) {
|
|
82
|
+
let value;
|
|
83
|
+
try {
|
|
84
|
+
value = JSON.parse(data);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new Error('Simulator log stream returned invalid JSON');
|
|
88
|
+
}
|
|
89
|
+
const entry = value;
|
|
90
|
+
if (!entry || typeof entry.timestamp !== 'string' || typeof entry.message !== 'string') {
|
|
91
|
+
throw new Error('Simulator log stream returned an invalid entry');
|
|
92
|
+
}
|
|
93
|
+
entries.push({ timestamp: entry.timestamp, message: entry.message });
|
|
94
|
+
}
|
|
95
|
+
separator = /\r?\n\r?\n/.exec(raw.slice(offset));
|
|
96
|
+
}
|
|
97
|
+
return { entries, remainder: raw.slice(offset) };
|
|
98
|
+
}
|
|
99
|
+
function writeSimulatorLogEntry(entry, json) {
|
|
100
|
+
console.log(json ? JSON.stringify(entry) : entry.message);
|
|
101
|
+
}
|
|
68
102
|
function parseLabels(labels) {
|
|
69
103
|
const out = {};
|
|
70
104
|
for (const raw of labels ?? []) {
|
|
@@ -338,6 +372,46 @@ function registerSimulatorCommands(program, platform) {
|
|
|
338
372
|
.action((url, opts) => action(async () => {
|
|
339
373
|
print(await client().post(`/run-cloud/${platform}/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
|
|
340
374
|
}));
|
|
375
|
+
simulator
|
|
376
|
+
.command('logs')
|
|
377
|
+
.description(`Read or follow logs from ${article} active ${label} session`)
|
|
378
|
+
.argument('<id>')
|
|
379
|
+
.option('--tail <lines>', 'number of retained lines to return (1-1000)')
|
|
380
|
+
.option('-f, --follow', 'follow new log entries', false)
|
|
381
|
+
.option('--json', 'output JSON; follow mode emits one object per line', false)
|
|
382
|
+
.action((id, opts) => action(async () => {
|
|
383
|
+
if (opts.follow && opts.tail !== undefined) {
|
|
384
|
+
throw new Error('--tail cannot be combined with --follow');
|
|
385
|
+
}
|
|
386
|
+
const path = `/run-cloud/${platform}/${encodeURIComponent(id)}/logs`;
|
|
387
|
+
if (!opts.follow) {
|
|
388
|
+
const tail = opts.tail === undefined ? 200 : Number(opts.tail);
|
|
389
|
+
if (!Number.isInteger(tail) || tail < 1 || tail > 1_000) {
|
|
390
|
+
throw new Error('--tail must be an integer from 1 to 1000');
|
|
391
|
+
}
|
|
392
|
+
const snapshot = await client().get(`${path}?tail=${tail}`);
|
|
393
|
+
if (opts.json) {
|
|
394
|
+
print(snapshot, opts);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const entries = Array.isArray(snapshot.entries) ? snapshot.entries : [];
|
|
398
|
+
for (const entry of entries)
|
|
399
|
+
writeSimulatorLogEntry(entry, false);
|
|
400
|
+
if (entries.length === 0)
|
|
401
|
+
console.error('No simulator logs captured for this lease.');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
let pending = '';
|
|
405
|
+
for await (const chunk of client().stream(`${path}?follow=1`)) {
|
|
406
|
+
const parsed = consumeSimulatorLogSse(`${pending}${chunk}`);
|
|
407
|
+
pending = parsed.remainder;
|
|
408
|
+
if (pending.length > 1024 * 1024) {
|
|
409
|
+
throw new Error('Simulator log stream returned an oversized incomplete event');
|
|
410
|
+
}
|
|
411
|
+
for (const entry of parsed.entries)
|
|
412
|
+
writeSimulatorLogEntry(entry, opts.json === true);
|
|
413
|
+
}
|
|
414
|
+
}));
|
|
341
415
|
return simulator;
|
|
342
416
|
}
|
|
343
417
|
async function action(fn) {
|
package/dist/diagnose.js
CHANGED
|
@@ -3,6 +3,28 @@ import { promisify } from 'node:util';
|
|
|
3
3
|
import { GoogleAuth, Impersonated } from 'google-auth-library';
|
|
4
4
|
import { renderSessionDiagnosticTerminal, writeSessionDiagnosticBundle, } from './sessionDiagnostics.js';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
const AUTH_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
7
|
+
const AUTH_DISCOVERY_PROJECT_ID = 'newly-diagnostics';
|
|
8
|
+
async function withDeadline(label, operation) {
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
let timer;
|
|
11
|
+
try {
|
|
12
|
+
return await Promise.race([
|
|
13
|
+
operation(controller.signal),
|
|
14
|
+
new Promise((_, reject) => {
|
|
15
|
+
timer = setTimeout(() => {
|
|
16
|
+
controller.abort();
|
|
17
|
+
reject(new Error(`${label} timed out after ${AUTH_ATTEMPT_TIMEOUT_MS}ms`));
|
|
18
|
+
}, AUTH_ATTEMPT_TIMEOUT_MS);
|
|
19
|
+
timer.unref?.();
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
if (timer)
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
6
28
|
const DEFAULT_URLS = {
|
|
7
29
|
dev: {
|
|
8
30
|
apiOps: 'https://cp-api-ops-dev-97683904813.us-east4.run.app',
|
|
@@ -102,26 +124,36 @@ function exactAudienceAuthorization(authorization, audience) {
|
|
|
102
124
|
return authorization;
|
|
103
125
|
}
|
|
104
126
|
async function impersonatedIdentityToken(audience, serviceAccount) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
127
|
+
return withDeadline('service-account impersonation', async (signal) => {
|
|
128
|
+
const sourceClient = await new GoogleAuth({
|
|
129
|
+
projectId: AUTH_DISCOVERY_PROJECT_ID,
|
|
130
|
+
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
|
131
|
+
clientOptions: { transporterOptions: { signal, timeout: AUTH_ATTEMPT_TIMEOUT_MS } },
|
|
132
|
+
}).getClient();
|
|
133
|
+
const impersonated = new Impersonated({
|
|
134
|
+
sourceClient,
|
|
135
|
+
targetPrincipal: serviceAccount,
|
|
136
|
+
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
|
137
|
+
lifetime: 600,
|
|
138
|
+
transporterOptions: { signal, timeout: AUTH_ATTEMPT_TIMEOUT_MS },
|
|
139
|
+
});
|
|
140
|
+
const token = await impersonated.fetchIdToken(audience, { includeEmail: true });
|
|
141
|
+
return exactAudienceAuthorization(`Bearer ${token}`, audience);
|
|
113
142
|
});
|
|
114
|
-
const token = await impersonated.fetchIdToken(audience, { includeEmail: true });
|
|
115
|
-
return exactAudienceAuthorization(`Bearer ${token}`, audience);
|
|
116
143
|
}
|
|
117
144
|
export async function resolveOpsAuthHeader(env = process.env, requestedAudience, requestedServiceAccount) {
|
|
118
145
|
const audience = requestedAudience ?? env.DIAGNOSE_OIDC_AUDIENCE ?? 'newly-diagnose-ops';
|
|
119
146
|
const serviceAccount = env.DIAGNOSE_OPS_IMPERSONATE_SERVICE_ACCOUNT ?? requestedServiceAccount;
|
|
120
147
|
let adcError = 'unknown ADC error';
|
|
121
148
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
149
|
+
return await withDeadline('ADC ID-token resolution', async (signal) => {
|
|
150
|
+
const auth = new GoogleAuth({
|
|
151
|
+
projectId: AUTH_DISCOVERY_PROJECT_ID,
|
|
152
|
+
clientOptions: { transporterOptions: { signal, timeout: AUTH_ATTEMPT_TIMEOUT_MS } },
|
|
153
|
+
});
|
|
154
|
+
const client = await auth.getIdTokenClient(audience);
|
|
155
|
+
return exactAudienceAuthorization(extractAuthorization(await client.getRequestHeaders()), audience);
|
|
156
|
+
});
|
|
125
157
|
}
|
|
126
158
|
catch (error) {
|
|
127
159
|
adcError = error instanceof Error ? error.message : String(error);
|
|
@@ -251,6 +283,8 @@ export async function fetchSessionDiagnosticReport(input) {
|
|
|
251
283
|
params.set('session', input.session);
|
|
252
284
|
if (input.project)
|
|
253
285
|
params.set('project', projectIdFromInput(input.project));
|
|
286
|
+
if (input.user)
|
|
287
|
+
params.set('user', input.user);
|
|
254
288
|
if (input.at)
|
|
255
289
|
params.set('at', input.at);
|
|
256
290
|
const res = await fetch(`${input.target.url}/diagnose/session?${params}`, {
|
|
@@ -293,17 +327,18 @@ export function registerDiagnose(program) {
|
|
|
293
327
|
.option('--env <env>', 'dev, prod, or pr-<number>', 'dev')
|
|
294
328
|
.option('--session <id>', 'diagnose this exact session')
|
|
295
329
|
.option('--project <id-or-url>', 'diagnose the latest/nearest session for a project')
|
|
296
|
-
.option('--
|
|
330
|
+
.option('--user <email>', 'diagnose the latest/nearest session started by this user')
|
|
331
|
+
.option('--at <iso-time>', 'select the project/user session nearest this incident time')
|
|
297
332
|
.option('--out <dir>', 'write evidence, report, diagram, and agent repro/fix prompt')
|
|
298
333
|
.option('--json', 'emit the merged JSON document instead of the human rollup')
|
|
299
334
|
.action(async (opts) => {
|
|
300
335
|
try {
|
|
301
|
-
if (opts.session || opts.project) {
|
|
302
|
-
if (
|
|
303
|
-
throw new Error('Provide exactly one of --session or --
|
|
336
|
+
if (opts.session || opts.project || opts.user) {
|
|
337
|
+
if ([opts.session, opts.project, opts.user].filter(Boolean).length !== 1) {
|
|
338
|
+
throw new Error('Provide exactly one of --session, --project, or --user');
|
|
304
339
|
}
|
|
305
|
-
if (opts.at &&
|
|
306
|
-
throw new Error('--at requires --project because an exact --session needs no time selection');
|
|
340
|
+
if (opts.at && opts.session) {
|
|
341
|
+
throw new Error('--at requires --project or --user because an exact --session needs no time selection');
|
|
307
342
|
}
|
|
308
343
|
if (opts.at && Number.isNaN(Date.parse(opts.at))) {
|
|
309
344
|
throw new Error('--at must be an ISO timestamp');
|
|
@@ -315,6 +350,7 @@ export function registerDiagnose(program) {
|
|
|
315
350
|
authHeader,
|
|
316
351
|
...(opts.session ? { session: opts.session } : {}),
|
|
317
352
|
...(opts.project ? { project: opts.project } : {}),
|
|
353
|
+
...(opts.user ? { user: opts.user } : {}),
|
|
318
354
|
...(opts.at ? { at: opts.at } : {}),
|
|
319
355
|
});
|
|
320
356
|
if (opts.out) {
|
|
@@ -326,7 +362,7 @@ export function registerDiagnose(program) {
|
|
|
326
362
|
return;
|
|
327
363
|
}
|
|
328
364
|
if (opts.at || opts.out) {
|
|
329
|
-
throw new Error('--at and --out require --session or --
|
|
365
|
+
throw new Error('--at and --out require --session, --project, or --user');
|
|
330
366
|
}
|
|
331
367
|
if (opts.env !== 'dev' && opts.env !== 'prod') {
|
|
332
368
|
throw new Error('Global health diagnosis supports only --env dev or prod');
|
|
@@ -24,7 +24,16 @@ function nodeLabel(report, id) {
|
|
|
24
24
|
return `[${STATUS_TEXT[item.status]} ${item.label}]`;
|
|
25
25
|
}
|
|
26
26
|
export function renderAsciiDiagram(report) {
|
|
27
|
-
const main = [
|
|
27
|
+
const main = [
|
|
28
|
+
'session',
|
|
29
|
+
'venue',
|
|
30
|
+
'runner',
|
|
31
|
+
'command',
|
|
32
|
+
'metro',
|
|
33
|
+
'simulator_lease',
|
|
34
|
+
'simulator_app',
|
|
35
|
+
'preview',
|
|
36
|
+
];
|
|
28
37
|
const mainLine = main.map((id) => nodeLabel(report, id)).join(' -> ');
|
|
29
38
|
const runnerOffset = main.slice(0, 3).map((id) => nodeLabel(report, id)).join(' -> ').length;
|
|
30
39
|
const editor = nodeLabel(report, 'code_server');
|
|
@@ -42,7 +51,7 @@ function mermaidEscape(value) {
|
|
|
42
51
|
export function renderMermaidDiagram(report) {
|
|
43
52
|
const lines = [
|
|
44
53
|
'flowchart LR',
|
|
45
|
-
' session --> venue --> runner --> command --> metro --> preview',
|
|
54
|
+
' session --> venue --> runner --> command --> metro --> simulator_lease --> simulator_app --> preview',
|
|
46
55
|
' runner --> code_server',
|
|
47
56
|
];
|
|
48
57
|
for (const item of report.stages) {
|
|
@@ -172,6 +181,8 @@ Use the adjacent \`bundle.json\` as evidence, not as a conclusion to repeat blin
|
|
|
172
181
|
|
|
173
182
|
If the diagnosis is \`evidence_missing\`, repair evidence collection first: verify the \`session_diagnostic_events\` migration is applied, API/worker/launcher provenance matches, and the controlled PR preview smoke persists its full correlated timeline. Do not infer a product cause from absent telemetry.
|
|
174
183
|
|
|
184
|
+
For \`simulator_app_failed\`, inspect the persisted \`simulator.app.failed\` event first. In a live newly-desktop cloud session, call \`get_mobile_preview_logs\` to retrieve the retained native startup tail, Metro output, debugger status, and React Native log registry. Treat application exceptions as userland code to reproduce and fix in the project; do not relabel a successful \`simctl\`/\`adb\` launch as a healthy app.
|
|
185
|
+
|
|
175
186
|
1. Identify the first boundary whose recorded output does not match its required input.
|
|
176
187
|
2. Add or extend the smallest regression test that reproduces that exact failure. For cross-service delivery, use the normal PR environment smoke/e2e path instead of mocks alone.
|
|
177
188
|
3. Fix the production path durably in \`control-plane/\`, \`newly-desktop/\`, or \`newly-cli/\`. Do not add work to the frozen legacy Modal backend or Neon stack.
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = '0.1.
|
|
1
|
+
export const CLI_VERSION = '0.1.106';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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.
|
|
3
|
+
description: Operate run.cloud iOS simulator and Android emulator sessions with the CLI or TypeScript SDK. Use for creating, installing, inspecting, reading device logs, embedding, smoke-testing, connecting local Metro, capturing iOS screenshots, injecting iOS media, or releasing remote mobile sessions.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Operate run.cloud Mobile Sessions
|
|
@@ -61,6 +61,7 @@ The shared mobile lifecycle is:
|
|
|
61
61
|
- `runcloud ios|android list [--all]`
|
|
62
62
|
- `runcloud ios|android get <id>`
|
|
63
63
|
- `runcloud ios|android open-url <url> --id <id>`
|
|
64
|
+
- `runcloud ios|android logs <id> [--tail N|--follow]`
|
|
64
65
|
- `runcloud ios|android delete <id>`
|
|
65
66
|
|
|
66
67
|
Create accepts `--model`, `--region`, `--display-name`, repeatable `--label`,
|
|
@@ -84,6 +85,21 @@ iOS needs an Apple Silicon simulator-compatible `.app`, `.zip`, `.tar.gz`, or
|
|
|
84
85
|
`.ipa` artifact. A device-signed App Store IPA is not a substitute. Android
|
|
85
86
|
needs an emulator-compatible artifact such as an APK.
|
|
86
87
|
|
|
88
|
+
## Diagnose App Failures
|
|
89
|
+
|
|
90
|
+
Read up to 1,000 retained entries from the current lease:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
runcloud ios logs "$SESSION_ID" --tail 1000
|
|
94
|
+
runcloud android logs "$SESSION_ID" --tail 1000
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Use `--follow` while reproducing an issue, and add `--json` when another tool
|
|
98
|
+
will consume the entries. `--tail` and `--follow` are mutually exclusive.
|
|
99
|
+
Before releasing a failed session, always capture a bounded snapshot and keep
|
|
100
|
+
the relevant entries with the test evidence. A follow stream contains only new
|
|
101
|
+
entries and is not a substitute for the retained snapshot.
|
|
102
|
+
|
|
87
103
|
## Connect Local Development
|
|
88
104
|
|
|
89
105
|
Connect a local Metro or mock server to an active iOS session:
|
|
@@ -136,9 +152,9 @@ try {
|
|
|
136
152
|
The mobile SDK surface is:
|
|
137
153
|
|
|
138
154
|
- `cloud.account()` and `cloud.usage({ orgId? })`
|
|
139
|
-
- `cloud.ios`: `create`, `list`, `get`, `openUrl`, `screenshot`,
|
|
155
|
+
- `cloud.ios`: `create`, `list`, `get`, `openUrl`, `logs`, `followLogs`, `screenshot`,
|
|
140
156
|
`uploadVideo`, `uploadMicrophoneAudio`, `delete`
|
|
141
|
-
- `cloud.android`: `create`, `list`, `get`, `openUrl`, `delete`
|
|
157
|
+
- `cloud.android`: `create`, `list`, `get`, `openUrl`, `logs`, `followLogs`, `delete`
|
|
142
158
|
- `cloud.simulators`: runtime-platform `create`, `list`, `get`, `openUrl`,
|
|
143
159
|
`delete`
|
|
144
160
|
- `cloud.assets`: `upload`, `list`, `delete`
|