mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
56b72c5cf7
* refactor(boundaries): move shared contracts below their consumers Acts on the depgraph findings: type-only edges are invisible to R5, so vocabulary that everything depends on had drifted above the zones that use it. - contracts/: the four platform-plugin facet tags (LogBackend, RecordingBackendTag, PerfMetricsSamplerTag, PlatformGatedProviderResolverKey) now live beside the plugin contract itself, which also moves out of core/; NetworkEntry moves next to the command surface that renders it; and the click-button, recording-export-quality, interactor-types and runner-lease-context vocabularies move down out of core/. - (root) drops from 29 files to 13: the internal *-contract/output/annotation modules move into contracts/, kernel/ (daemon-error, observability-redaction beside kernel/redaction), core/ (batch-policy, an ADR 0008 projection), commands/ (cli-command-aliases) and remote/ (upload-progress, upload-stream). What remains is entrypoints and the composition roots that R2 requires to sit outside the spine. - utils/ joins the ranked spine at rank 1 after its only two upward files move to the zones they were reaching for (cli/resolve-cli-options, cli-schema/cli-config), putting ~336 value edges under the gate. - Internal imports that routed types through the client-types re-export hub now name their real source. Type-only spine inversions drop from 61 to 35; the remainder is two clusters (client/client-types.ts and the ADR 0003 daemon facet). No behaviour change: 4470 unit tests and the layering gate pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * style: merge the duplicate contract imports the tag moves created Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * refactor(imports): name the declaring module, share find's argument rules Two follow-ups from re-measuring the graph after the boundary moves. 1. 89 type imports across 79 files routed through a re-export hub in another zone: `CliFlags` reached through commands/cli-grammar/flag-types.ts (52) when it is declared in contracts/cli-flags.ts, the replay suite result types reached through daemon/types.ts when they are declared in contracts/replay.ts, the doctor types through a daemon handler module, and so on. Each hop invented a cross-zone edge the architecture never asked for — including every apparent replay -> daemon and utils -> commands dependency. They now name the module that declares them. Within-zone hops are left alone; those are a local style choice, not a boundary claim. 2. `find`'s three positional/flag checks existed in both daemon entry points with hand-repeated messages, and the copy in dispatchFindReadOnlyViaRuntime was unreachable — its only caller validates first. Both now call checkFindArgs in selectors/find.ts, beside parseFindArgs and isReadOnlyFindAction, for the reason that module's own comment already gives: so the two paths cannot disagree. The refusal is returned rather than thrown, because the two mechanisms are not observationally identical in the session event log. Type-only spine inversions: 61 -> 35. 4470 unit tests and every gate pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * feat(layering): ratchet type-only spine inversions (R6) R5 ignores type-only edges by design — they cost nothing at runtime and do not affect cold start — so nothing was watching the direction they point. Ranking them the same way found 61 inversions, including contracts/ and utils/ declared in terms of rank-4 zones. 26 are fixed by the preceding commits; R6 pins the rest per zone pair so they can only shrink, and a new pair fails outright rather than being added to the baseline. The two remaining clusters each need their own change, and the baseline says so: the per-command Options/Result vocabulary declared inside the public Node-client surface, and the ADR 0003 daemon facet shape that core's descriptor registry composes. Both ratchet directions are covered: growth fails, and shrinking without lowering the number fails too, so the baseline cannot quietly stop describing the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * docs: record the import-graph findings behind this refactor A dated snapshot, not a normative document: when it disagrees with scripts/layering/, the gate wins. The graph tool that produced it lives on the claude/depgraph-viewer branch, deliberately out of this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * refactor(selectors): state the shared selector argument rules once R2 (commands-floor) forbids the daemon from importing commands/, and that is the right call: commands/ is the client-side surface — its only consumers are cli/, cli-schema/, mcp/, client/ and the composition roots — while the daemon is the executor on the other side of the wire. ADR 0008 protects exactly that seam. Relaxing R2 would let the executor depend on a client projection and pull CLI grammar and output formatting into the daemon's bundle. But the rule does force duplication: the daemon must validate independently because it accepts requests from any client, so 10 refusal messages existed in both zones. The only place a shared rule can live is below both, and selectors/ already held the parsers (splitIsSelectorArgs, splitSelectorFromArgs, isSupportedPredicate) and even the `is` predicate message — just not the checks that use them. Three drifts had already appeared in the `is` predicate rule alone: - commands/interaction/selectors.ts re-implemented the predicate list as an inlined seven-way `!==` chain while importing the message and hint from selectors/predicates.ts, so adding a predicate to the shared list would not have reached the CLI grammar. - That inlined chain compared the raw token, so the CLI rejected `is TEXT ...` while the daemon it hands the command to accepts it. The CLI now matches the executor; this is an intentional alignment, not an accident. - isCommand raised the same refusal without IS_PREDICATE_USAGE_HINT, so whether an agent got recovery guidance depended on which layer noticed first — the failure mode ADR 0010's audit calls out. checkIsPredicate, checkIsArgs, checkGetFormat, checkElementTargetArgs and checkWaitText now hold those rules, each beside the parser it wraps, and report a refusal rather than choosing how to raise it: the daemon returns a response, the command surface throws. Those mechanisms are not interchangeable — they write different session events — so the shared check stays out of that decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * feat(daemon): give ADR 0014's ref frame one transition, pin SessionState owners `SessionStore.get()` returns the live record out of a private Map and `set()` re-puts the same reference, so every `session.<field> = …` in the daemon is a durable write to store-owned state: 57 of them across 17 files, against 26 `set()` calls that are therefore ceremonial. Nothing at the store boundary can check what those writes are supposed to keep true. Measuring which module writes which field showed the problem is narrower than the raw count suggests — 16 of 27 fields already have exactly one writer. The sharp case is ADR 0014's ref frame: `refFrameState`, `refFrameScope`, `refFrameTree` and `refFrameGeneration` must move together or the frame is incoherent (an `active` state with a stale tree resolves refs against a namespace nobody authorized), yet complete issuance wrote them in ref-frame.ts and partial issuance wrote the same four in session-snapshot.ts. ref-frame.ts's own header claims to be "the single owner of the frame's transitions", and session-snapshot.ts documented itself as the exception. Both forms now go through `activateRefFrame`; they differ only in scope. `recordSession` deliberately moves alone in two paths (recording without arming a publication), so the save-script cluster gets no invented abstraction — it gets ownership instead. R7 records every field's owner and stops the set from growing quietly: a new SessionState field must declare one, a foreign write fails naming the owner to call, and an owner that stops writing must be removed so the table cannot drift into fiction. Field names are read out of the `SessionState` declaration, so a daemon module with an unrelated local named `session` — a provider or runner session — cannot trip it. 4475 unit tests and every gate pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * docs: record the reference semantics and refresh the findings SessionStore.get/set now document that the record is handed out live, since that is the fact behind R7. The findings snapshot picks up the resolved R2 question, the ref-frame consolidation and the two new gate scopes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * refactor(boundaries): rank every satellite zone, extract the provider port Second-order effect of the earlier rounds. With `utils` on the spine and `(root)` emptied of shared contracts, the eleven zones that were unranked "because ranking them would invent an order the architecture had not committed to" turned out to have a consistent rank already — the order was there, unasserted. Solving the constraint system showed one blocker: `utils/remote-config.ts` projected a remote-config profile into `CliFlags` while reaching up into `remote/`, and its only three consumers were in `cli/`. It moves there as `cli/remote-config-flags.ts`, and every satellite zone joins the spine. Ranked coverage goes from 730/895 files to 882/895. Only `(root)` stays out, and now for one stated reason: R2 forbids `daemon/` from importing `commands/`, so the files that wire them compose the spine from above. Ranking them exposed 22 type-only inversions R6 had never been able to see, and they were concentrated rather than scattered: - The device-provider port. `providers/` and `cloud-webdriver/` implement what the daemon calls, so both sides name `DeviceLease`, `LeaseLifecycleProvider`, `LeaseLifecycleContext` and `DeviceInventoryProvider` — now declared in contracts/device-provider.ts, below both. The adapters also imported the daemon's NARROWED `DaemonRequest` while only ever reading `req.flags`; they now name the public one from kernel/contracts. - `MetroPrepareKind` and the remote-config profile field groups move to contracts/ for the same reason: the command surface validates them and contracts/cli-flags.ts is composed from them. Two clusters remain, ratcheted with their reasons in TYPE_INVERSION_BASELINE: the client-types vocabulary, and `SessionAction`, which needs `CommandFlags` and `DaemonBatchStep` to move with it. Also fixes two things CI caught: the eight type re-exports my earlier import redirection orphaned (none published through any src/sdk/* entrypoint, so no public surface changes) and `isSupportedPredicate`, now module-private since `checkIsPredicate` is the admission API. `fallow-baselines/health.json` is keyed by path, so the moved cli-config entry moves with the file rather than being regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * fix(selectors): use the admitted predicate, not the raw option Review finding. `isCommand` called `checkIsPredicate` and then kept reading `options.predicate` for the capture policy, the `exists` branch, `evaluateIsPredicate`, the failure message and the returned result. Admission normalizes case, so an upper-case predicate was let past the gate and then evaluated against lower-case branches: `EXISTS` skipped its own branch and fell through to the generic path, and the result echoed the raw token. I widened admission at that surface without threading the normalized value through it — the CLI-grammar surface in the same change does use the admitted value. Every decision after admission now reads it. Two tests, both verified to fail without the fix: - a production-route regression driving `device.selectors.is` with `EXISTS`/`TEXT`, plus one pinning that an unknown predicate is still refused WITH the ADR 0010 usage hint; - a surface parity gate (selectors/__tests__/is-argument-surface-parity.test.ts) in the repo's existing parity style, asserting the daemon and CLI-grammar surfaces reach the same verdict and hand the same normalized predicate downstream across an input table. A helper-only test cannot catch a surface that admits correctly and then discards the result, which is what happened here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * docs: name the pre-push gate, and the formatter's path allowlist Both misses in this PR's review were process, not judgement, and the docs pointed the wrong way for both. AGENTS.md said "prefer the aggregate package.json scripts" without naming which aggregate, and CONTRIBUTING listed `pnpm test` and the targeted checks but never `pnpm check`. `check:tooling` looks like the gate and is a subset of it: it stops before the Fallow audit, so the dead exports this PR introduced passed a clean `check:tooling` and failed CI. Both files now name `pnpm check`, say what it covers, and say what it cannot (the device matrix). The same gap produced a second mistake twice: `oxfmt <path>` reformats whatever you point it at, while the repo's `format` script is an allowlist that excludes `scripts/` and every `.md`. One run reformatted 50 unrelated script files into a commit; the next nearly did it to AGENTS.md. AGENTS.md now says to run `pnpm format`, never `oxfmt <path>`. It also records the rule that cost a CI cycle: Fallow's baselines are keyed by path, so a renamed file needs its baseline entry moved, not the baselines regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * revert: undo stray formatter output across docs and scripts Three separate `oxfmt <path>` runs in this branch reformatted files the repo's `format` script deliberately excludes: 55 files under scripts/maestro-conformance plus scripts/perf, sync-mcp-metadata and the slow-test reporter, and 12 markdown files including six ADRs and docs/agents/. All of it was whitespace, quote style and markdown table padding — no content — but it inflated the diff a reviewer has to read and would have rewritten prose ownership across files this change has no business touching. All 70 are back to their origin/main content, so the diff outside src/ is now exactly this change's scope: three docs, scripts/layering, the Fallow baseline, and five provider integration tests. The rule this violated is now in AGENTS.md: run `pnpm format`, never `oxfmt <path>`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * style: reformat two provider tests with the repo's pinned oxfmt `pnpm format:check` failed in CI on the two files whose imports I merged by hand. The repo pins oxfmt 0.42.0 as a devDependency and both `format` scripts invoke `./node_modules/oxfmt/bin/oxfmt`; I had reformatted with `npx oxfmt`, which resolved 0.60.0, and the two versions disagree about wrapping a 100-column import. This is the rule AGENTS.md already states — run `pnpm format`, never oxfmt directly — so there is nothing to add to the docs, only to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * fix(ci): install deps for the layering guard, and gate the zero-dep contract The Layering Guard job failed with ERR_MODULE_NOT_FOUND on `oxc-parser`. The job ran with `install-deps: false` — no `pnpm install`, so no `node_modules` — and R7 had started parsing the daemon with oxc-parser instead of matching assignment operators with a regex. `pnpm check:layering` passed on every local run, because locally `node_modules` is always there. The job now installs dependencies. The alternative was to put R7 back on a regex, which cannot see `??=` or a computed `session[key] =` write, so it would trade a correct rule for a fast job. That leaves the interesting part: the zero-dep contract is real for the jobs that keep it, and it is invisible to every local run, which is the worst combination a constraint can have. R8 makes it checkable. It reads the zero-dep job list out of `.github/workflows/` rather than restating it — declaring a job zero-dep is what puts it under the rule — walks each job's entry scripts and their whole relative-import closure, and requires every specifier to be a Node builtin or another repo file. A zero-dep job whose entry scripts the scan cannot identify fails too, so the rule cannot be escaped by changing how the job invokes them. Specifiers come from oxc-parser's module record, not a line scan. The closures include `--test` files, and a test about imports naturally embeds import syntax in a fixture string; the line scanner reported two such phantom violations in model.test.ts before the switch, which is how a gate stops being trusted. Verified by re-running the real gate against three injected regressions: the layering job back on `install-deps: false` (reproduces the exact CI failure, pointing at session-state.ts:24), a package import added to the still-zero-dep affected-selector closure, and a zero-dep job whose run step names no script. Also corrects the CONTEXT.md spine paragraph, which still described the satellite zones as deliberately unranked after they had all joined the ranked spine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * fix(layering): make R7 exhaustive, and follow session records through aliases Review finding: `SESSION_STATE_FIELD_OWNERS` covered 27 of `SessionState`'s 42 fields and nothing asserted parity, so a new field could be added and pass the gate by being invisible to it. R7's advertised claim — "every SessionState write is inside its declared owner" — was broader than what it checked. Investigating that turned up a second, larger gap the finding did not name: the scan only recognized a binding literally named `session`. The daemon names these records by role, so `nextSession`, `provisionalSession`, `completedSession`, `preRunSession` and `preEntrySession` were all invisible — and three of those writes were genuine violations R7 existed to catch: src/daemon/snapshot-runtime.ts:256 nextSession.snapshotScopeSource src/daemon/snapshot-runtime.ts:265 nextSession.snapshotGeneration src/daemon/handlers/session-replay-runtime.ts:707 preEntrySession.pendingRecordAndHeal The first two are the #1076 versioned-ref invariant: the generation advances exactly when the stored tree is replaced. That rule lived in `setSessionSnapshot` and had acquired a second statement of itself in snapshot-runtime.ts, whose own comment admitted the bypass. It now goes through `setSnapshotLineage` in the owning module. The third clears a watermark stamped by session-replay-resume.ts; `clearPendingRecordAndHealWatermark` puts the clear beside the stamp. Gate changes: - Binding detection accepts aliases, paired with the existing declared-field filter so an unrelated `…Session` local only registers if it also writes a field SessionState owns — where the remedy is the same anyway. - `fieldClassificationDrift` asserts parity in all three directions: unclassified, in-both, and naming a field SessionState no longer declares. - `STORE_OWNED_SESSION_STATE_FIELDS` classifies the 11 fields the store establishes at construction. It is a positive claim, so a direct write to one fails and names both remedies. - Four fields the widened scan made visible (`lease`, `deviceClaim`, `appName`, `saveScriptComplete`) got real owners. `nextSnapshotGeneration` is now module-private: replacing its only external call site orphaned the export, which `pnpm check` caught via Fallow. Verified against three injected regressions: a new SessionState field with no direct write (the reviewer's exact scenario), a foreign write through an alias binding, and a direct write to a store-established field. All three rejected. `pnpm check` green, 4486 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * docs(daemon): correct the snapshot-lineage claim, and pin the real contract Device verification of the snapshot-lineage route found that a ref pinned before a `diff` keeps resolving with no pinned-ref warning. That is the designed ADR 0014 behaviour, not a regression — the comment describing it was wrong, and I propagated it. `main`'s comment in snapshot-runtime.ts said a diff "leaves client refs pinned to the previous generation, which is exactly what the pinned warning diagnoses". The counter and the authorization epoch are different clocks: - `diff` passes `issuesRefsToClient: false`, so it never reactivates the frame; - `resolveRefStalenessWarning` compares a pin against the frame EPOCH, not the observation counter, and its own comment says why — a capture that bumped the counter must not make a valid pin from the issuing frame look stale. So advancing the counter is not the same as invalidating client refs, and the observable the comment promised does not exist. I carried the sentence into `setSnapshotLineage`'s doc when the transition moved, and then into a hardware verification request, which cost a reviewer a device run against a false claim. `setSnapshotLineage` itself is unchanged and was a pure move: same expressions, same inputs as the inline assignments it replaced, so this route behaves exactly as it does on main. A comment that contradicts the code should be an assertion instead, so the contract is now pinned in session-snapshot.test.ts: the diff advances the counter, preserves the epoch, leaves the pre-diff pin resolving without a warning, and still warns for a pin from a different frame. Verified to fail when the epoch comparison is swapped for the counter. A second test covers the keep-current branch, which had no coverage. `pnpm check` green, 4488 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur --------- Co-authored-by: Claude <noreply@anthropic.com>
819 lines
27 KiB
TypeScript
819 lines
27 KiB
TypeScript
import { parseRawArgs, usage, usageForCommand } from './cli/parser/args.ts';
|
|
import { suggestCommandFor } from './cli/parser/command-suggestions.ts';
|
|
import { asAppError, AppError, normalizeError } from './kernel/errors.ts';
|
|
import { throwDaemonError } from './kernel/daemon-error.ts';
|
|
import { printHumanError, printJson } from './utils/output.ts';
|
|
import { readVersion } from './utils/version.ts';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { sendToDaemon } from './daemon/client/daemon-client.ts';
|
|
import fs from 'node:fs';
|
|
import type { BatchStep } from './client/client-types.ts';
|
|
import type { ReplayTestReporterRuntime } from './replay/test/reporting.ts';
|
|
import {
|
|
createAgentDeviceClient,
|
|
type AgentDeviceClientConfig,
|
|
type AgentDeviceDaemonTransport,
|
|
} from './agent-device-client.ts';
|
|
import { materializeRemoteConnectionForCommand } from './cli/commands/connection-runtime.ts';
|
|
import { tryRunClientBackedCommand } from './cli/commands/router.ts';
|
|
import { runAgentCdpCommand } from './cli/commands/agent-cdp.ts';
|
|
import { runReactDevtoolsCommand } from './cli/commands/react-devtools.ts';
|
|
import { runWebCommand } from './cli/commands/web.ts';
|
|
import { readCliBatchStepsJson } from './cli/batch-steps.ts';
|
|
import {
|
|
createRequestId,
|
|
emitDiagnostic,
|
|
flushDiagnosticsToSessionFile,
|
|
getDiagnosticsMeta,
|
|
withDiagnosticsScope,
|
|
} from './utils/diagnostics.ts';
|
|
import { resolveDaemonPaths } from './daemon/config.ts';
|
|
import { applyDefaultPlatformBinding, resolveBindingSettings } from './utils/session-binding.ts';
|
|
import { resolveCliOptions } from './cli/resolve-cli-options.ts';
|
|
import { maybeRunUpgradeNotifier } from './utils/update-check.ts';
|
|
import {
|
|
resolveRemoteConnectionDefaults,
|
|
type RemoteConnectionRequestMetadata,
|
|
} from './remote/remote-connection-state.ts';
|
|
import { resolveRemoteAuthForCli } from './cli/auth-session.ts';
|
|
import type { FlagKey } from './commands/cli-grammar/flag-types.ts';
|
|
import type { CliFlags } from './contracts/cli-flags.ts';
|
|
import type { SessionRuntimeHints } from './kernel/contracts.ts';
|
|
import { INTERNAL_COMMANDS, isKnownCliCommandName } from './command-catalog.ts';
|
|
|
|
type CliDeps = {
|
|
sendToDaemon: typeof sendToDaemon;
|
|
};
|
|
|
|
type CliDaemonTransport = typeof sendToDaemon;
|
|
type CliDaemonRequest = Parameters<CliDaemonTransport>[0];
|
|
type CliDaemonTransportOptions = Parameters<CliDaemonTransport>[1];
|
|
type ClientDaemonRequest = Parameters<AgentDeviceDaemonTransport>[0];
|
|
|
|
const DEFAULT_CLI_DEPS: CliDeps = {
|
|
sendToDaemon,
|
|
};
|
|
|
|
const METRO_RUNTIME_OVERRIDE_FLAG_KEYS = new Set<FlagKey>([
|
|
'launchUrl',
|
|
'kind',
|
|
'metroBearerToken',
|
|
'metroKind',
|
|
'metroListenHost',
|
|
'metroNoInstallDeps',
|
|
'metroNoReuseExisting',
|
|
'metroPreparePort',
|
|
'metroProbeTimeoutMs',
|
|
'metroProjectRoot',
|
|
'metroProxyBaseUrl',
|
|
'metroPublicBaseUrl',
|
|
'metroRuntimeFile',
|
|
'metroStartupTimeoutMs',
|
|
'metroStatusHost',
|
|
]);
|
|
|
|
const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([
|
|
'connect',
|
|
'connection',
|
|
'close',
|
|
'daemon',
|
|
'device',
|
|
'disconnect',
|
|
'metro',
|
|
'proxy',
|
|
'session',
|
|
]);
|
|
|
|
export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise<void> {
|
|
const requestId = createRequestId();
|
|
const version = readVersion();
|
|
const debugEnabled = isDebugRequested(argv);
|
|
const jsonRequested = argv.includes('--json');
|
|
// Best-effort session guess used only for pre-parse diagnostics scope.
|
|
// After parse succeeds, request dispatch uses parsed flags/session resolution.
|
|
const sessionGuess = guessSessionFromArgv(argv) ?? process.env.AGENT_DEVICE_SESSION ?? 'default';
|
|
|
|
await withDiagnosticsScope(
|
|
{
|
|
session: sessionGuess,
|
|
requestId,
|
|
command: argv[0],
|
|
debug: debugEnabled,
|
|
},
|
|
async () => {
|
|
const { parsed, command, positionals } = await parseCliInputOrExit(argv, {
|
|
version,
|
|
jsonRequested,
|
|
debugEnabled,
|
|
});
|
|
const debugOutputEnabled = isParsedDebugRequested(command, parsed.providedFlags);
|
|
const ctx = resolveRunContextOrExit(parsed, {
|
|
command,
|
|
positionals,
|
|
requestId,
|
|
debugOutputEnabled,
|
|
});
|
|
let logTailStopper: (() => void) | null = null;
|
|
try {
|
|
if (command === 'react-devtools') {
|
|
process.exit(await runReactDevtoolsCli(ctx, deps));
|
|
return;
|
|
}
|
|
if (command === 'web') {
|
|
process.exit(
|
|
await runWebCommand(positionals, {
|
|
flags: ctx.effectiveFlags,
|
|
stateDir: ctx.daemonPaths.baseDir,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
maybeRunUpgradeNotifier({
|
|
command,
|
|
currentVersion: version,
|
|
stateDir: ctx.daemonPaths.baseDir,
|
|
flags: ctx.effectiveFlags,
|
|
});
|
|
await resolveRemoteContext(ctx, deps);
|
|
if (command === 'cdp') {
|
|
process.exit(
|
|
await runAgentCdpCommand(positionals, {
|
|
flags: ctx.effectiveFlags,
|
|
runtime: ctx.resolvedRuntime,
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
logTailStopper = maybeStartDaemonLogTail(ctx);
|
|
const replayTestReporterRuntime = await createReplayReporterForTest(ctx);
|
|
const client = createAgentDeviceClient(buildClientConfig(ctx), {
|
|
transport: createCliDaemonTransport({
|
|
command,
|
|
flags: ctx.effectiveFlags,
|
|
replayTestReporterRuntime,
|
|
transport: deps.sendToDaemon,
|
|
}),
|
|
});
|
|
await dispatchCliCommand(ctx, client, replayTestReporterRuntime);
|
|
} catch (err) {
|
|
handleRunCliFailure(err, ctx, logTailStopper);
|
|
} finally {
|
|
if (logTailStopper) logTailStopper();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
type ParsedCliInput = {
|
|
parsed: ReturnType<typeof resolveCliOptions>;
|
|
command: string;
|
|
positionals: string[];
|
|
};
|
|
|
|
async function parseCliInputOrExit(
|
|
argv: string[],
|
|
options: { version: string; jsonRequested: boolean; debugEnabled: boolean },
|
|
): Promise<ParsedCliInput> {
|
|
let parsed: ReturnType<typeof resolveCliOptions>;
|
|
try {
|
|
parsed = resolveCliOptions(argv, { cwd: process.cwd(), env: process.env });
|
|
} catch (error) {
|
|
emitDiagnostic({
|
|
level: 'error',
|
|
phase: 'cli_parse_failed',
|
|
data: {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
});
|
|
const normalized = normalizeError(error, {
|
|
diagnosticId: getDiagnosticsMeta().diagnosticId,
|
|
logPath: flushDiagnosticsToSessionFile({ force: true }) ?? undefined,
|
|
});
|
|
if (options.jsonRequested) {
|
|
printJson({ success: false, error: normalized });
|
|
} else {
|
|
printHumanError(normalized, { showDetails: options.debugEnabled });
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
for (const warning of parsed.warnings) {
|
|
process.stderr.write(`Warning: ${warning}\n`);
|
|
}
|
|
|
|
if (parsed.flags.version) {
|
|
process.stdout.write(`${options.version}\n`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const isHelpAlias = parsed.command === 'help';
|
|
const isHelpFlag = parsed.flags.help;
|
|
if (isHelpAlias || isHelpFlag) {
|
|
if (isHelpAlias && parsed.positionals.length > 1) {
|
|
printHumanError(new AppError('INVALID_ARGS', 'help accepts at most one command.'));
|
|
process.exit(1);
|
|
}
|
|
const helpTarget = isHelpAlias ? parsed.positionals[0] : parsed.command;
|
|
if (!helpTarget) {
|
|
process.stdout.write(`${await usage()}\n`);
|
|
process.exit(0);
|
|
}
|
|
const commandHelp = await usageForCommand(helpTarget);
|
|
if (commandHelp) {
|
|
process.stdout.write(commandHelp);
|
|
process.exit(0);
|
|
}
|
|
printHumanError(new AppError('INVALID_ARGS', formatUnknownHelpTargetMessage(helpTarget)));
|
|
process.stdout.write(`${await usage()}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!parsed.command) {
|
|
process.stdout.write(`${await usage()}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
return { parsed, command: parsed.command, positionals: parsed.positionals };
|
|
}
|
|
|
|
type CliRunContext = {
|
|
command: string;
|
|
positionals: string[];
|
|
requestId: string;
|
|
debugOutputEnabled: boolean;
|
|
binding: ReturnType<typeof resolveBindingSettings>;
|
|
// Flags after platform binding but before connection-default merge; batch
|
|
// step inheritance keys off this pre-merge view.
|
|
flags: CliFlags;
|
|
daemonPaths: ReturnType<typeof resolveDaemonPaths>;
|
|
sessionName: string;
|
|
connectionDefaults: ReturnType<typeof resolveActiveConnectionDefaults>;
|
|
explicitFlagKeys: Set<FlagKey>;
|
|
// Mutated in place by resolveRemoteContext (auth, materialization) so the
|
|
// failure handler always sees the same state the throwing phase saw.
|
|
effectiveFlags: CliFlags;
|
|
resolvedRuntime: SessionRuntimeHints | undefined;
|
|
connectionMetadata: RemoteConnectionRequestMetadata | undefined;
|
|
parsedBatchSteps: BatchStep[] | undefined;
|
|
};
|
|
|
|
function resolveRunContextOrExit(
|
|
parsed: ReturnType<typeof resolveCliOptions>,
|
|
base: { command: string; positionals: string[]; requestId: string; debugOutputEnabled: boolean },
|
|
): CliRunContext {
|
|
const explicitFlagKeys = new Set(parsed.providedFlags.map((entry) => entry.key));
|
|
try {
|
|
const binding = resolveBindingSettings({
|
|
policyOverrides: parsed.flags,
|
|
configuredPlatform: parsed.flags.platform,
|
|
configuredSession: parsed.flags.session,
|
|
});
|
|
const flags = binding.lockPolicy
|
|
? { ...parsed.flags }
|
|
: applyDefaultPlatformBinding(parsed.flags, {
|
|
policyOverrides: parsed.flags,
|
|
configuredPlatform: parsed.flags.platform,
|
|
configuredSession: parsed.flags.session,
|
|
});
|
|
const daemonPaths = resolveDaemonPaths(flags.stateDir);
|
|
const sessionName = flags.session ?? 'default';
|
|
const connectionDefaults = resolveActiveConnectionDefaults({
|
|
command: base.command,
|
|
explicitFlagKeys,
|
|
stateDir: daemonPaths.baseDir,
|
|
session: sessionName,
|
|
remoteConfig: flags.remoteConfig,
|
|
hasResolvedSession: flags.session !== undefined,
|
|
});
|
|
const effectiveFlags = connectionDefaults
|
|
? mergeConnectionFlags(flags, connectionDefaults.flags, explicitFlagKeys)
|
|
: flags;
|
|
return {
|
|
...base,
|
|
binding,
|
|
flags,
|
|
daemonPaths,
|
|
sessionName,
|
|
connectionDefaults,
|
|
explicitFlagKeys,
|
|
effectiveFlags,
|
|
resolvedRuntime: connectionDefaults?.runtime,
|
|
connectionMetadata: connectionDefaults?.connection,
|
|
parsedBatchSteps: undefined,
|
|
};
|
|
} catch (err) {
|
|
const appErr = asAppError(err);
|
|
const normalized = normalizeError(appErr, {
|
|
diagnosticId: getDiagnosticsMeta().diagnosticId,
|
|
logPath: flushDiagnosticsToSessionFile({ force: true }) ?? undefined,
|
|
});
|
|
if (parsed.flags.json) {
|
|
printJson({ success: false, error: normalized });
|
|
} else {
|
|
printHumanError(normalized, { showDetails: base.debugOutputEnabled });
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function runReactDevtoolsCli(ctx: CliRunContext, deps: CliDeps): Promise<number> {
|
|
return await runReactDevtoolsCommand(ctx.positionals, {
|
|
flags: {
|
|
...ctx.effectiveFlags,
|
|
leaseProvider: ctx.connectionDefaults?.connection?.leaseProvider,
|
|
},
|
|
stateDir: ctx.daemonPaths.baseDir,
|
|
session: ctx.effectiveFlags.session ?? ctx.sessionName,
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
configureDirectPortReverse: async () => {
|
|
const response = await deps.sendToDaemon({
|
|
command: INTERNAL_COMMANDS.runtime,
|
|
positionals: ['port-reverse'],
|
|
flags: {
|
|
...ctx.effectiveFlags,
|
|
leaseProvider: ctx.connectionDefaults?.connection?.leaseProvider,
|
|
devicePort: 8097,
|
|
hostPort: 8097,
|
|
portReverseName: 'react-devtools',
|
|
},
|
|
session: ctx.effectiveFlags.session ?? ctx.sessionName,
|
|
});
|
|
if (!response.ok) throwDaemonError(response.error);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function resolveRemoteContext(ctx: CliRunContext, deps: CliDeps): Promise<void> {
|
|
if (ctx.command === 'batch') {
|
|
if (ctx.positionals.length > 0) {
|
|
throw new AppError('INVALID_ARGS', 'batch does not accept positional arguments.');
|
|
}
|
|
ctx.parsedBatchSteps = readBatchSteps(ctx.flags);
|
|
}
|
|
|
|
if (shouldResolveRemoteAuth(ctx.command)) {
|
|
const authResolution = await resolveRemoteAuthForCli({
|
|
command: ctx.command,
|
|
flags: ctx.effectiveFlags,
|
|
stateDir: ctx.daemonPaths.baseDir,
|
|
env: process.env,
|
|
});
|
|
ctx.effectiveFlags = authResolution.flags;
|
|
}
|
|
|
|
if (ctx.effectiveFlags.remoteConfig && shouldMaterializeRemoteConnection(ctx.command)) {
|
|
const materializationClient = createAgentDeviceClient(buildClientConfig(ctx), {
|
|
transport: createClientDaemonTransport(deps.sendToDaemon),
|
|
});
|
|
const materialized = await materializeRemoteConnectionForCommand({
|
|
command: ctx.command,
|
|
flags: ctx.effectiveFlags,
|
|
client: materializationClient,
|
|
runtime: ctx.resolvedRuntime,
|
|
positionals: ctx.positionals,
|
|
batchSteps: ctx.parsedBatchSteps,
|
|
forceRuntimePrepare: hasExplicitMetroRuntimeOverrides(ctx.explicitFlagKeys),
|
|
});
|
|
ctx.effectiveFlags = materialized.flags;
|
|
ctx.resolvedRuntime = materialized.runtime;
|
|
ctx.connectionMetadata = materialized.connection;
|
|
}
|
|
if (
|
|
shouldWarnOpenMayMissRemoteRuntime({
|
|
command: ctx.command,
|
|
flags: ctx.effectiveFlags,
|
|
runtime: ctx.resolvedRuntime,
|
|
explicitFlagKeys: ctx.explicitFlagKeys,
|
|
hadConnectionDefaults: Boolean(ctx.connectionDefaults),
|
|
})
|
|
) {
|
|
process.stderr.write(
|
|
'Warning: open is using explicit remote daemon or tenant flags without saved Metro runtime hints. React Native apps may launch without bundle/runtime hints; prefer connect --remote-config <path> first or pass --remote-config <path> on this command.\n',
|
|
);
|
|
}
|
|
}
|
|
|
|
function buildClientConfig(ctx: CliRunContext): AgentDeviceClientConfig {
|
|
const currentFlags = ctx.effectiveFlags;
|
|
const connection = ctx.connectionMetadata;
|
|
return {
|
|
session: currentFlags.session,
|
|
requestId: ctx.requestId,
|
|
stateDir: currentFlags.stateDir,
|
|
daemonBaseUrl: currentFlags.daemonBaseUrl,
|
|
daemonAuthToken: currentFlags.daemonAuthToken,
|
|
daemonTransport: currentFlags.daemonTransport,
|
|
daemonServerMode: currentFlags.daemonServerMode,
|
|
tenant: currentFlags.tenant,
|
|
sessionIsolation: currentFlags.sessionIsolation,
|
|
runId: currentFlags.runId,
|
|
leaseId: currentFlags.leaseId,
|
|
leaseBackend: currentFlags.leaseBackend,
|
|
leaseProvider: connection?.leaseProvider,
|
|
clientId: connection?.clientId,
|
|
deviceKey: connection?.deviceKey,
|
|
providerApp: currentFlags.providerApp,
|
|
providerOsVersion: currentFlags.providerOsVersion,
|
|
providerProject: currentFlags.providerProject,
|
|
providerBuild: currentFlags.providerBuild,
|
|
providerSessionName: currentFlags.providerSessionName,
|
|
awsProjectArn: currentFlags.awsProjectArn,
|
|
awsDeviceArn: currentFlags.awsDeviceArn,
|
|
awsAppArn: currentFlags.awsAppArn,
|
|
awsRegion: currentFlags.awsRegion,
|
|
awsInteractionMode: currentFlags.awsInteractionMode,
|
|
runtime: ctx.resolvedRuntime,
|
|
lockPolicy: ctx.binding.lockPolicy,
|
|
lockPlatform: ctx.binding.defaultPlatform,
|
|
cwd: process.cwd(),
|
|
debug: ctx.debugOutputEnabled,
|
|
cost: currentFlags.cost,
|
|
responseLevel: currentFlags.responseLevel,
|
|
};
|
|
}
|
|
|
|
function maybeStartDaemonLogTail(ctx: CliRunContext): (() => void) | null {
|
|
const remoteDaemonBaseUrl = ctx.effectiveFlags.daemonBaseUrl;
|
|
return ctx.debugOutputEnabled && !ctx.effectiveFlags.json && !remoteDaemonBaseUrl
|
|
? startDaemonLogTail(ctx.daemonPaths.logPath)
|
|
: null;
|
|
}
|
|
|
|
async function createReplayReporterForTest(
|
|
ctx: CliRunContext,
|
|
): Promise<ReplayTestReporterRuntime | undefined> {
|
|
if (ctx.command !== 'test') return undefined;
|
|
// Lazy: the replay test reporter is only needed by `test`, and its
|
|
// static import would put the reporting runtime on every command's path.
|
|
const { createReplayTestReporterRuntime } = await import('./replay/test/reporting.ts');
|
|
return createReplayTestReporterRuntime({
|
|
debug: ctx.debugOutputEnabled,
|
|
verbose: ctx.effectiveFlags.verbose,
|
|
json: ctx.effectiveFlags.json,
|
|
reporter: ctx.effectiveFlags.reporter,
|
|
reportJunit: ctx.effectiveFlags.reportJunit,
|
|
});
|
|
}
|
|
|
|
async function dispatchCliCommand(
|
|
ctx: CliRunContext,
|
|
client: ReturnType<typeof createAgentDeviceClient>,
|
|
replayTestReporterRuntime: ReplayTestReporterRuntime | undefined,
|
|
): Promise<void> {
|
|
const { command, positionals, effectiveFlags } = ctx;
|
|
if (command === 'batch') {
|
|
if (!ctx.parsedBatchSteps) {
|
|
throw new AppError('INVALID_ARGS', 'batch requires --steps or --steps-file.');
|
|
}
|
|
const batchSteps = ctx.parsedBatchSteps.map((step, _index) => ({
|
|
...step,
|
|
input:
|
|
ctx.binding.lockPolicy && ctx.flags.platform === undefined
|
|
? { ...step.input }
|
|
: applyDefaultPlatformBinding(step.input, {
|
|
policyOverrides: effectiveFlags,
|
|
configuredPlatform: effectiveFlags.platform,
|
|
configuredSession: effectiveFlags.session,
|
|
inheritedPlatform: effectiveFlags.platform,
|
|
}),
|
|
}));
|
|
if (
|
|
await tryRunClientBackedCommand({
|
|
command,
|
|
positionals,
|
|
flags: { ...effectiveFlags, batchSteps },
|
|
client,
|
|
debug: ctx.debugOutputEnabled,
|
|
replayTestReporterRuntime,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
} else if (command === 'runtime') {
|
|
throw new AppError(
|
|
'INVALID_ARGS',
|
|
'runtime command was removed. Use connect --remote-config <path> for remote runs, or metro prepare --remote-config <path> for inspection.',
|
|
);
|
|
} else if (
|
|
await tryRunClientBackedCommand({
|
|
command,
|
|
positionals,
|
|
flags: effectiveFlags,
|
|
client,
|
|
debug: ctx.debugOutputEnabled,
|
|
replayTestReporterRuntime,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new AppError('INVALID_ARGS', formatUnhandledCommandMessage(command));
|
|
}
|
|
|
|
function handleRunCliFailure(
|
|
err: unknown,
|
|
ctx: CliRunContext,
|
|
logTailStopper: (() => void) | null,
|
|
): void {
|
|
const appErr = asAppError(err);
|
|
const normalized = normalizeError(appErr, {
|
|
diagnosticId: getDiagnosticsMeta().diagnosticId,
|
|
logPath: flushDiagnosticsToSessionFile({ force: true }) ?? undefined,
|
|
});
|
|
if (ctx.command === 'close' && isDaemonStartupFailure(appErr)) {
|
|
if (ctx.effectiveFlags.json) {
|
|
printJson({ success: true, data: { closed: 'session', source: 'no-daemon' } });
|
|
}
|
|
return;
|
|
}
|
|
if (ctx.effectiveFlags.json) {
|
|
printJson({
|
|
success: false,
|
|
error: normalized,
|
|
});
|
|
} else {
|
|
printHumanError(normalized, { showDetails: ctx.debugOutputEnabled });
|
|
if (ctx.debugOutputEnabled) {
|
|
printDaemonLogTailOnError(ctx.daemonPaths.logPath);
|
|
}
|
|
}
|
|
if (logTailStopper) logTailStopper();
|
|
process.exit(1);
|
|
}
|
|
|
|
function printDaemonLogTailOnError(logPath: string): void {
|
|
try {
|
|
if (fs.existsSync(logPath)) {
|
|
const content = fs.readFileSync(logPath, 'utf8');
|
|
const lines = content.split('\n');
|
|
const tail = lines.slice(Math.max(0, lines.length - 200)).join('\n');
|
|
if (tail.trim().length > 0) {
|
|
process.stderr.write(`\n[daemon log]\n${tail}\n`);
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function isDebugRequested(argv: string[]): boolean {
|
|
try {
|
|
const parsed = parseRawArgs(argv);
|
|
return isParsedDebugRequested(parsed.command ?? '', parsed.providedFlags);
|
|
} catch {
|
|
return argv.includes('--debug') || argv.includes('-v') || argv.includes('--verbose');
|
|
}
|
|
}
|
|
|
|
function formatUnknownHelpTargetMessage(helpTarget: string): string {
|
|
const hint = suggestCommandFor(helpTarget);
|
|
return hint
|
|
? `Unknown command: ${helpTarget}. Did you mean ${hint}?`
|
|
: `Unknown command: ${helpTarget}`;
|
|
}
|
|
|
|
function formatUnhandledCommandMessage(command: string): string {
|
|
if (isKnownCliCommandName(command)) {
|
|
// Registered-but-unhandled means catalog/dispatch drift — make it visible
|
|
// in telemetry too, not just the thrown message (from #1055).
|
|
emitDiagnostic({
|
|
level: 'error',
|
|
phase: 'cli_known_command_unhandled',
|
|
data: { command },
|
|
});
|
|
return `Command is registered but no CLI handler accepted it: ${command}`;
|
|
}
|
|
return `Unknown command: ${command}`;
|
|
}
|
|
|
|
function isParsedDebugRequested(
|
|
command: string,
|
|
providedFlags: Array<{ key: FlagKey; token: string }>,
|
|
): boolean {
|
|
return providedFlags.some(
|
|
(entry) =>
|
|
entry.key === 'verbose' &&
|
|
(entry.token === '--debug' || entry.token === '-v' || command !== 'test'),
|
|
);
|
|
}
|
|
|
|
function readBatchSteps(flags: ReturnType<typeof resolveCliOptions>['flags']): BatchStep[] {
|
|
let raw = '';
|
|
if (flags.steps) {
|
|
raw = flags.steps;
|
|
} else if (flags.stepsFile) {
|
|
try {
|
|
raw = fs.readFileSync(flags.stepsFile, 'utf8');
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new AppError(
|
|
'INVALID_ARGS',
|
|
`Failed to read --steps-file ${flags.stepsFile}: ${message}`,
|
|
);
|
|
}
|
|
}
|
|
return readCliBatchStepsJson(raw);
|
|
}
|
|
|
|
function isDaemonStartupFailure(error: AppError): boolean {
|
|
if (error.code !== 'COMMAND_FAILED') return false;
|
|
if (error.details?.kind === 'daemon_startup_failed') return true;
|
|
if (!error.message.toLowerCase().includes('failed to start daemon')) return false;
|
|
return typeof error.details?.infoPath === 'string' || typeof error.details?.lockPath === 'string';
|
|
}
|
|
|
|
function resolveActiveConnectionDefaults(options: {
|
|
command: string;
|
|
explicitFlagKeys: Set<FlagKey>;
|
|
stateDir: string;
|
|
session: string;
|
|
remoteConfig?: string;
|
|
hasResolvedSession: boolean;
|
|
}): {
|
|
flags: Partial<CliFlags>;
|
|
runtime?: SessionRuntimeHints;
|
|
connection?: RemoteConnectionRequestMetadata;
|
|
} | null {
|
|
if (
|
|
options.command === 'connect' ||
|
|
options.command === 'connection' ||
|
|
options.command === 'daemon' ||
|
|
options.command === 'proxy'
|
|
) {
|
|
return null;
|
|
}
|
|
const defaults = resolveRemoteConnectionDefaults({
|
|
stateDir: options.stateDir,
|
|
session: options.session,
|
|
remoteConfig: options.remoteConfig,
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
allowActiveFallback:
|
|
!options.explicitFlagKeys.has('session') &&
|
|
(!options.remoteConfig || options.command === 'disconnect' || !options.hasResolvedSession),
|
|
validateRemoteConfigHash: options.command !== 'disconnect',
|
|
});
|
|
return defaults;
|
|
}
|
|
|
|
function shouldMaterializeRemoteConnection(command: string): boolean {
|
|
return !REMOTE_MATERIALIZATION_DEFERRED_COMMANDS.has(command);
|
|
}
|
|
|
|
function shouldResolveRemoteAuth(command: string): boolean {
|
|
return (
|
|
command !== 'auth' &&
|
|
command !== 'connection' &&
|
|
command !== 'daemon' &&
|
|
command !== 'device' &&
|
|
command !== 'proxy'
|
|
);
|
|
}
|
|
|
|
function shouldWarnOpenMayMissRemoteRuntime(options: {
|
|
command: string;
|
|
flags: CliFlags;
|
|
runtime?: SessionRuntimeHints;
|
|
explicitFlagKeys: Set<FlagKey>;
|
|
hadConnectionDefaults: boolean;
|
|
}): boolean {
|
|
if (options.command !== 'open') return false;
|
|
if (options.runtime) return false;
|
|
if (options.flags.bundleUrl || options.flags.metroHost || options.flags.metroPort) return false;
|
|
if (options.flags.remoteConfig) return false;
|
|
if (options.hadConnectionDefaults) return false;
|
|
return hasExplicitRemoteScopeFlags(options.explicitFlagKeys);
|
|
}
|
|
|
|
function hasExplicitRemoteScopeFlags(explicitFlagKeys: Set<FlagKey>): boolean {
|
|
return (
|
|
explicitFlagKeys.has('daemonBaseUrl') ||
|
|
explicitFlagKeys.has('daemonTransport') ||
|
|
explicitFlagKeys.has('tenant') ||
|
|
explicitFlagKeys.has('sessionIsolation') ||
|
|
explicitFlagKeys.has('runId') ||
|
|
explicitFlagKeys.has('leaseId') ||
|
|
explicitFlagKeys.has('leaseBackend')
|
|
);
|
|
}
|
|
|
|
function mergeConnectionFlags(
|
|
flags: CliFlags,
|
|
defaults: Partial<CliFlags>,
|
|
explicitFlagKeys: Set<FlagKey>,
|
|
): CliFlags {
|
|
const merged = { ...flags };
|
|
for (const [key, value] of Object.entries(defaults) as Array<[FlagKey, unknown]>) {
|
|
if (value === undefined) continue;
|
|
if (explicitFlagKeys.has(key)) continue;
|
|
(merged as Record<string, unknown>)[key] = value;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set<FlagKey>): boolean {
|
|
for (const key of METRO_RUNTIME_OVERRIDE_FLAG_KEYS) {
|
|
if (explicitFlagKeys.has(key)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function createCliDaemonTransport(options: {
|
|
command: string;
|
|
flags: CliFlags;
|
|
replayTestReporterRuntime?: ReplayTestReporterRuntime;
|
|
transport: CliDaemonTransport;
|
|
}): AgentDeviceDaemonTransport {
|
|
const { command, flags, replayTestReporterRuntime, transport } = options;
|
|
if (flags.json) return createClientDaemonTransport(transport);
|
|
return async (req) =>
|
|
await sendClientRequestToCliTransport(
|
|
transport,
|
|
{
|
|
...req,
|
|
meta: {
|
|
...req.meta,
|
|
requestProgress: command === 'test' ? 'replay-test' : 'command',
|
|
},
|
|
},
|
|
command === 'test' && replayTestReporterRuntime
|
|
? { onProgress: replayTestReporterRuntime.onProgress }
|
|
: undefined,
|
|
);
|
|
}
|
|
|
|
function createClientDaemonTransport(transport: CliDaemonTransport): AgentDeviceDaemonTransport {
|
|
return async (req) => await sendClientRequestToCliTransport(transport, req);
|
|
}
|
|
|
|
async function sendClientRequestToCliTransport(
|
|
transport: CliDaemonTransport,
|
|
req: ClientDaemonRequest,
|
|
options?: CliDaemonTransportOptions,
|
|
): ReturnType<CliDaemonTransport> {
|
|
return await transport(req as CliDaemonRequest, options);
|
|
}
|
|
|
|
function guessSessionFromArgv(argv: string[]): string | null {
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const token = argv[i]!;
|
|
if (token.startsWith('--session=')) {
|
|
const inline = token.slice('--session='.length).trim();
|
|
return inline.length > 0 ? inline : null;
|
|
}
|
|
if (token === '--session') {
|
|
const value = argv[i + 1]?.trim();
|
|
if (value && !value.startsWith('-')) return value;
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const isDirectRun = pathToFileURL(process.argv[1] ?? '').href === import.meta.url;
|
|
if (isDirectRun) {
|
|
runCli(process.argv.slice(2)).catch((err) => {
|
|
const appErr = asAppError(err);
|
|
printHumanError(normalizeError(appErr), { showDetails: true });
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
function startDaemonLogTail(logPath: string): (() => void) | null {
|
|
try {
|
|
let offset = fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
|
|
let stopped = false;
|
|
const interval = setInterval(() => {
|
|
if (stopped) return;
|
|
if (!fs.existsSync(logPath)) return;
|
|
try {
|
|
const stats = fs.statSync(logPath);
|
|
if (stats.size < offset) offset = 0;
|
|
if (stats.size <= offset) return;
|
|
const fd = fs.openSync(logPath, 'r');
|
|
try {
|
|
const buffer = Buffer.alloc(stats.size - offset);
|
|
fs.readSync(fd, buffer, 0, buffer.length, offset);
|
|
offset = stats.size;
|
|
if (buffer.length > 0) {
|
|
process.stdout.write(buffer.toString('utf8'));
|
|
}
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
} catch {
|
|
// Best-effort tailing should not crash CLI flow.
|
|
}
|
|
}, 200);
|
|
return () => {
|
|
stopped = true;
|
|
clearInterval(interval);
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|