Skip to content

Commit b94abf9

Browse files
author
Shubham Agarwal
committed
refactor(mobile-app): wire dev through Metro capture path
Manual `npm run dev` previously bypassed the wrapper. That made the PR unreliable: a user could start raw Expo on 8081, then /debug-app would see no state and start a second wrapped Metro on 8082, watching the wrong log while the actual app continued to emit errors elsewhere. /create-mobile-app now installs a project-local copy of metro-session.js and patches the created app's package.json so `npm run dev` uses wrapper foreground mode. The agent path still uses `start`, which runs detached so the skill can return after QR generation. `dev:expo` remains as the explicit raw Expo escape hatch for humans who want terminal-native Expo behavior and do not need debug log capture. The wrapper now has a `dev` command that runs foreground Metro through the same state/log pipeline as detached `start`, mirrors sanitized output back to the terminal, and preserves the same status/tail behavior for /debug-app. A regression test starts foreground dev, waits for wrapper state, verifies tail can read the captured log, then stops the session. This also trims the redaction implementation. Instead of a structured JSON/escaped-string parser, persisted logs now use a blunt line-level scrubber for auth/secrets/token/key/signed-query patterns. It over-redacts some diagnostics, but it is far smaller and fails safe: sensitive lines are dropped before hitting disk or model context. Docs and skill guidance now point actual app runs at `npm run dev`, with `dev:expo` documented only as the no-capture escape hatch. Validation: 49 mobile script tests pass; all repository validators pass.
1 parent 59a1699 commit b94abf9

10 files changed

Lines changed: 187 additions & 203 deletions

File tree

plugins/mobile-apps/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Do not add preparation rewrites for `scheme`, `package`, `bundleIdentifier`, `sr
7373
- `DONE_WITH_CONCERNS` requires at least one concern. If none, use `DONE`.
7474
- Special early-return signals (`INDUSTRY_CONFIRM_REQUESTED:`, `DESIGN_VIBE_REQUESTED:`) pre-date this protocol and remain in effect — they are special-cased "ask the user one question and re-spawn me" handoffs, not terminal returns.
7575
- The canonical orchestrator handler lives in [`skills/create-mobile-app/SKILL.md`](./skills/create-mobile-app/SKILL.md) Step 3.0. Future skills that spawn agents should reference it rather than duplicating the switch.
76-
11. **Metro lifecycle is project-local**`/create-mobile-app` starts Metro through `scripts/metro-session.js`; `/debug-app` reads `.expo/metro-session/state.json` and the sanitized `metro.log` with a persisted byte cursor. Do not restore required `BashOutput`/terminal-ID behavior. Host terminal APIs may be optional conveniences only. Never write unsanitized Metro output to disk, and never signal a recorded PID unless the port probe proves the process is still ours.
76+
11. **Metro lifecycle is project-local**`/create-mobile-app` installs `scripts/metro-session.js` into the generated app and wires `npm run dev` through it; `/debug-app` reads `.expo/metro-session/state.json` and the sanitized `metro.log` with a persisted byte cursor. Do not restore required `BashOutput`/terminal-ID behavior. Host terminal APIs may be optional conveniences only. Never write unsanitized Metro output to disk, and never signal a recorded PID unless the port probe proves the process is still ours.
7777

7878
## Decisions made
7979

plugins/mobile-apps/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,16 @@ connector wiring.
8686
Copilot CLI, and Claude Code without asking for a terminal ID.
8787
8888
To start Metro manually instead, run the command below from the app directory.
89-
Manual sessions support normal Expo development, but `/debug-app` continuous
90-
monitoring requires a wrapper-owned session. If one is not running,
91-
`/debug-app` offers to start it.
89+
Created apps wire this command through the same project-local wrapper, so
90+
manual starts and `/debug-app` use the same captured log.
9291
9392
```bash
9493
npm run dev
9594
```
9695
96+
Use `npm run dev:expo` only when you explicitly want raw Expo terminal
97+
behavior and do not need `/debug-app` log capture.
98+
9799
The wrapper removes common credentials, tokens, keys, and signed-query
98100
values before writing `metro.log`. The complete `.expo/` folder is ignored
99101
by the template's `.gitignore`.

plugins/mobile-apps/scripts/metro-session.js

Lines changed: 76 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* and a *different* project's Metro now owns the port, making our log stale.
1717
*
1818
* Usage:
19+
* node metro-session.js dev [--project-root <dir>] [--clear]
1920
* node metro-session.js start [--project-root <dir>] [--clear] [--wait-ready-ms <n>]
2021
* node metro-session.js status [--project-root <dir>]
2122
* node metro-session.js tail [--project-root <dir>] [--cursor <bytes>] [--lines <n>]
@@ -303,155 +304,16 @@ function stripAnsi(value) {
303304
return String(value).replace(/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g, '');
304305
}
305306

306-
const SENSITIVE_JSON_KEYS = new Set([
307-
'authorization',
308-
'proxyauthorization',
309-
'clientsecret',
310-
'password',
311-
'token',
312-
'accesstoken',
313-
'refreshtoken',
314-
'idtoken',
315-
'apikey',
316-
'accountkey',
317-
'sharedaccesskey',
318-
]);
319-
320-
function normalizeSensitiveKey(value) {
321-
return String(value).replace(/[^a-z0-9]/gi, '').toLowerCase();
322-
}
323-
324-
function redactStructuredValue(value, depth = 0) {
325-
if (depth > 4) return { value, changed: false };
326-
327-
if (Array.isArray(value)) {
328-
let changed = false;
329-
const next = value.map((item) => {
330-
const result = redactStructuredValue(item, depth + 1);
331-
changed = changed || result.changed;
332-
return result.value;
333-
});
334-
return { value: changed ? next : value, changed };
335-
}
336-
337-
if (value && typeof value === 'object') {
338-
let changed = false;
339-
const next = {};
340-
for (const [key, item] of Object.entries(value)) {
341-
if (SENSITIVE_JSON_KEYS.has(normalizeSensitiveKey(key))) {
342-
next[key] = '[REDACTED]';
343-
changed = true;
344-
continue;
345-
}
346-
const result = redactStructuredValue(item, depth + 1);
347-
next[key] = result.value;
348-
changed = changed || result.changed;
349-
}
350-
return { value: changed ? next : value, changed };
351-
}
352-
353-
if (typeof value === 'string') {
354-
const trimmed = value.trim();
355-
if (!trimmed || !['{', '[', '"'].includes(trimmed[0])) {
356-
return { value, changed: false };
357-
}
358-
try {
359-
const parsed = JSON.parse(trimmed);
360-
const result = redactStructuredValue(parsed, depth + 1);
361-
return result.changed
362-
? { value: JSON.stringify(result.value), changed: true }
363-
: { value, changed: false };
364-
} catch {
365-
return { value, changed: false };
366-
}
367-
}
368-
369-
return { value, changed: false };
370-
}
307+
const SENSITIVE_LINE_PATTERN = /\b(?:authorization|bearer|client[_-]?secret|password|token|access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|accountkey|sharedaccesskey)\b|[?&](?:sig|se|sp|sv|token|access_token|code|client_secret)=|\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|sk-[A-Za-z0-9]{20,})\b/i;
371308

372-
function redactStructuredJsonLines(value) {
373-
return String(value).replace(/[^\r\n]+/g, (line) => {
374-
const leading = line.match(/^\s*/)[0];
375-
const payload = line.slice(leading.length);
376-
if (!payload) return line;
377-
try {
378-
const parsed = JSON.parse(payload);
379-
const result = redactStructuredValue(parsed);
380-
return result.changed ? `${leading}${JSON.stringify(result.value)}` : line;
381-
} catch {
382-
return line;
383-
}
384-
});
385-
}
386-
387-
function redactAuthorizationLines(value) {
388-
return String(value).replace(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g, (line) => {
389-
// Authorization values appear in plain headers, JSON, nested serialized
390-
// JSON, and arbitrary logger prefixes. Once a physical line contains an
391-
// Authorization key/value delimiter, preserving fragments is not worth the
392-
// credential-leak risk: replace the complete line and retain only a marker.
393-
const hasAuthorizationValue =
394-
/authorization/i.test(line) &&
395-
/authorization(?:\\*["'])*\s*[:=]/i.test(line);
396-
if (!hasAuthorizationValue) return line;
309+
function redactLogText(value) {
310+
return stripAnsi(value).replace(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g, (line) => {
311+
if (!SENSITIVE_LINE_PATTERN.test(line)) return line;
397312
const ending = line.endsWith('\r\n') ? '\r\n' : line.endsWith('\n') ? '\n' : line.endsWith('\r') ? '\r' : '';
398-
return `[metro-session] [REDACTED_AUTHORIZATION_LINE]${ending}`;
313+
return `[metro-session] [REDACTED_SENSITIVE_LINE]${ending}`;
399314
});
400315
}
401316

402-
function redactLogText(value) {
403-
let output = redactAuthorizationLines(redactStructuredJsonLines(stripAnsi(value)));
404-
405-
// Keep diagnostic labels while replacing only credential values. Examples:
406-
// Authorization: Bearer eyJ...
407-
// client_secret=abc...
408-
// https://host/path?sig=abc&other=value
409-
// Double-serialized forms first, e.g. {\"Authorization\":\"Basic ...\"}
410-
// or Authorization: \"Basic ...\". Replace the entire logical value so
411-
// escaped quotes inside credentials cannot terminate redaction early.
412-
output = output.replace(
413-
/\\(["'])(?:Proxy-)?Authorization\\\1\s*:\s*\\(["'])(?:(?:\\.)|[^\\\r\n])*?\\\2/gi,
414-
'Authorization: [REDACTED]'
415-
);
416-
output = output.replace(
417-
/\b(?:Proxy-)?Authorization\s*:\s*\\(["'])(?:(?:\\.)|[^\\\r\n])*?\\\1/gi,
418-
'Authorization: [REDACTED]'
419-
);
420-
output = output.replace(
421-
/(["'])((?:Proxy-)?Authorization)\1(\s*:\s*)(["'])([A-Za-z][A-Za-z0-9_-]*)(?:\s+)(?:(?:\\.)|[^\\\r\n])*?\4/gi,
422-
'$1$2$1$3$4$5 [REDACTED]$4'
423-
);
424-
output = output.replace(
425-
/(["'])((?:Proxy-)?Authorization)\1(\s*:\s*)(["'])(?:(?:\\.)|[^\\\r\n])*?\4/gi,
426-
'$1$2$1$3$4[REDACTED]$4'
427-
);
428-
output = output.replace(
429-
/\b((?:Proxy-)?Authorization)(\s*:\s*)(["'])(?:(?:\\.)|[^\\\r\n])*?\3/gi,
430-
'$1$2$3[REDACTED]$3'
431-
);
432-
output = output.replace(
433-
/\b((?:Proxy-)?Authorization)(\s*:\s*)([A-Za-z][A-Za-z0-9_-]*)(?:\s+)[^\r\n]+/gi,
434-
'$1$2$3 [REDACTED]'
435-
);
436-
output = output.replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]');
437-
output = output.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g, '[REDACTED_JWT]');
438-
output = output.replace(
439-
/(["']?)(client[_-]?secret|password|token|access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|accountkey|sharedaccesskey)\1(\s*[:=]\s*)(["'])(?:(?:\\.)|[^\\\r\n])*?\4/gi,
440-
'$1$2$1$3$4[REDACTED]$4'
441-
);
442-
output = output.replace(
443-
/(["']?)(client[_-]?secret|password|token|access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|accountkey|sharedaccesskey)\1(\s*[:=]\s*)[^\s,"';&]+/gi,
444-
'$1$2$1$3[REDACTED]'
445-
);
446-
output = output.replace(
447-
/([?&](?:sig|se|sp|sv|token|access_token|code|client_secret)=)[^&#\s]+/gi,
448-
'$1[REDACTED]'
449-
);
450-
output = output.replace(/\b(AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|sk-[A-Za-z0-9]{20,})\b/g, '[REDACTED_KEY]');
451-
452-
return output;
453-
}
454-
455317
function appendSanitized(paths, value, options = {}) {
456318
const sanitized = options.alreadySanitized ? String(value) : redactLogText(value);
457319
ensureSessionDir(paths);
@@ -614,6 +476,48 @@ function startSession(projectRoot, options = {}) {
614476
};
615477
}
616478

479+
function runDevSession(projectRoot, options = {}) {
480+
const paths = resolvePaths(projectRoot);
481+
ensureProject(paths.projectRoot);
482+
ensureSessionDir(paths);
483+
484+
return withStartLock(paths.startLockPath, () => {
485+
const existing = readState(paths);
486+
if (existing) {
487+
const liveness = resolveLiveness(existing, options);
488+
if (liveness.running) {
489+
process.stdout.write(
490+
`Metro is already running on port ${existing.port || 'unknown'}. ` +
491+
`Debug log: ${paths.logPath}\n`
492+
);
493+
return { ok: true, alreadyRunning: true, ...existing, ...liveness };
494+
}
495+
}
496+
497+
rotateLog(paths, options.maxLogBytes || DEFAULT_MAX_LOG_BYTES);
498+
writeState(paths, {
499+
schemaVersion: STATE_SCHEMA_VERSION,
500+
status: 'starting',
501+
runnerPid: process.pid,
502+
metroPid: null,
503+
port: null,
504+
metroUrl: null,
505+
updatedAt: new Date().toISOString(),
506+
});
507+
runMetroChild(paths, {
508+
clear: options.clear,
509+
mirrorOutput: true,
510+
stdin: 'inherit',
511+
_resolveExpoCli: options._resolveExpoCli,
512+
_spawn: options._spawn,
513+
});
514+
return { ok: true, alreadyRunning: false, ...(readState(paths) || {}) };
515+
}, {
516+
timeoutMs: options.startLockTimeoutMs,
517+
_sleepSync: options._sleepSync,
518+
});
519+
}
520+
617521
function startSessionLocked(paths, options = {}) {
618522
const existing = readState(paths);
619523
if (existing) {
@@ -891,24 +795,16 @@ function waitForOwnState(paths, timeoutMilliseconds = 5000, options = {}) {
891795
return null;
892796
}
893797

894-
function runWorker(projectRoot, options = {}) {
895-
const paths = resolvePaths(projectRoot);
896-
const state = (options._waitForOwnState || waitForOwnState)(paths);
897-
if (!state) {
898-
throw new Error('Metro session state was not initialized by the parent process.');
899-
}
900-
798+
function runMetroChild(paths, options = {}) {
901799
const cliPath = (options._resolveExpoCli || resolveExpoCli)(paths.projectRoot);
902800
appendSanitized(paths, `\n--- Metro session started ${new Date().toISOString()} ---\n`);
903801

904-
// `--clear` arrives on this runner's own argv, so it does not need to be
905-
// round-tripped through state.json.
906802
const child = (options._spawn || spawn)(
907803
process.execPath,
908804
[cliPath, 'start', ...(options.clear ? ['--clear'] : [])],
909805
{
910806
cwd: paths.projectRoot,
911-
stdio: ['ignore', 'pipe', 'pipe'],
807+
stdio: [options.stdin || 'ignore', 'pipe', 'pipe'],
912808
windowsHide: true,
913809
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
914810
}
@@ -919,9 +815,10 @@ function runWorker(projectRoot, options = {}) {
919815
let rollingText = '';
920816
const stdoutWriter = createSanitizedStreamWriter(paths);
921817
const stderrWriter = createSanitizedStreamWriter(paths);
922-
const consume = (writer) => (chunk) => {
818+
const consume = (writer, target) => (chunk) => {
923819
const sanitized = writer.write(chunk);
924820
if (!sanitized) return;
821+
if (options.mirrorOutput && target) target.write(sanitized);
925822
rollingText = `${rollingText}${sanitized}`.slice(-8192);
926823
const metroUrl = extractMetroUrl(rollingText);
927824
if (metroUrl) {
@@ -933,8 +830,8 @@ function runWorker(projectRoot, options = {}) {
933830
}
934831
rotateLog(paths);
935832
};
936-
child.stdout.on('data', consume(stdoutWriter));
937-
child.stderr.on('data', consume(stderrWriter));
833+
child.stdout.on('data', consume(stdoutWriter, process.stdout));
834+
child.stderr.on('data', consume(stderrWriter, process.stderr));
938835

939836
let shuttingDown = false;
940837
const shutdown = (signal) => {
@@ -970,11 +867,28 @@ function runWorker(projectRoot, options = {}) {
970867
}, process.pid);
971868
process.exitCode = code || 0;
972869
});
870+
871+
return child;
872+
}
873+
874+
function runWorker(projectRoot, options = {}) {
875+
const paths = resolvePaths(projectRoot);
876+
const state = (options._waitForOwnState || waitForOwnState)(paths);
877+
if (!state) {
878+
throw new Error('Metro session state was not initialized by the parent process.');
879+
}
880+
881+
runMetroChild(paths, {
882+
clear: options.clear,
883+
_resolveExpoCli: options._resolveExpoCli,
884+
_spawn: options._spawn,
885+
});
973886
}
974887

975888
function printHelp() {
976889
process.stdout.write(
977890
'Usage:\n' +
891+
' node metro-session.js dev [--project-root <dir>] [--clear]\n' +
978892
' node metro-session.js start [--project-root <dir>] [--clear] [--wait-ready-ms <n>]\n' +
979893
' node metro-session.js status [--project-root <dir>]\n' +
980894
' node metro-session.js tail [--project-root <dir>] [--cursor <bytes>] [--wait-ms <n>] [--lines <n>] [--max-bytes <n>]\n' +
@@ -1008,7 +922,9 @@ function main() {
1008922
return;
1009923
}
1010924

1011-
if (options.command === 'start') {
925+
if (options.command === 'dev') {
926+
runDevSession(options.projectRoot, { clear: options.clear });
927+
} else if (options.command === 'start') {
1012928
printJson(startSession(options.projectRoot, {
1013929
clear: options.clear,
1014930
waitReadyMs: options.waitReadyMs,
@@ -1050,6 +966,8 @@ module.exports = {
1050966
resolveLiveness,
1051967
resolvePaths,
1052968
rotateLog,
969+
runDevSession,
970+
runMetroChild,
1053971
runWorker,
1054972
startSession,
1055973
stopSession,

0 commit comments

Comments
 (0)