mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
33083b5a86
* refactor: remove dead code and unused exports
- Delete interaction-get.ts, interaction-is.ts, interaction-selector.ts (superseded by selector-runtime dispatchers)
- Remove handleWaitCommand and its private helpers from snapshot-wait.ts (replaced by dispatchWaitViaRuntime)
- Remove unused distanceFromSafeViewportBand/isRectWithinSafeViewportBand from rect-visibility
- Trim Linux platform barrel to only the re-export still in use (snapshotLinux)
* refactor: consolidate duplicated helpers
- Move sleep() to utils/timeouts.ts; remove 6 duplicate implementations
- Use existing isApplePlatform() helper for ios||macos checks (5 sites)
- Export trimRuntimeValue from runtime-hints; drop duplicate trimRuntimeString
- Merge normalizeTextSurfaceType/normalizeType into single text-surface helper
- Remove pointless isScrollableContainerType wrapper
* refactor: consolidate accidentally-duplicated type definitions
- SnapshotDiffLine/Summary: single definition in utils/snapshot-diff, re-exported from capture-snapshot
- FindLocator: single definition in utils/finders, re-exported from client-types
- JsonRpc envelope in http-server now uses JsonRpcRequestEnvelope/JsonRpcId from contracts
- Inline 'primary'|'secondary'|'middle' literals replaced with ClickButton (internal sites only)
* refactor: strengthen weak types and fix DEVICE_IN_USE downgrade bug
- Add toAppErrorCode() validator and DEVICE_IN_USE to AppErrorCode union
- Replace 'as any' casts on wire error codes with the validator (daemon-error, daemon-client, request-router, http-server)
- Type Metro worker payload as MetroTunnelResponseMessage
- Type xctestrun plist parsing with explicit partial schema
- Narrow 'details.stderr' via typeof checks on Android error paths
- Type runner-session parseRunnerResponse with RunnerResponsePayload
Bug fix: handler emitted 'DEVICE_IN_USE' but router cast silently downgraded to
'COMMAND_FAILED' because the code was missing from AppErrorCode. Clients can now
react to same-device contention.
* refactor: collapse redundant 'ignore' comments in empty catch blocks
- Replace 16 instances of 3-line catch { // ignore } with 1-line catch {}
- Remove one self-describing 'Re-export public API' comment
Specific 'ignore shutdown races' / 'ignore malformed pid files' style comments
that name concrete failure modes are kept.
* refactor: eliminate circular dependencies by extracting shared types to leaves
Resolves all 44 cycles reported by madge. Pattern throughout: extract shared
type into a leaf module; both producer and consumer import from the leaf;
original module re-exports for API stability.
New leaf type modules:
- src/runtime-contract.ts (AgentDeviceRuntime, CommandContext, ...)
- src/metro-types.ts (MetroRuntimeHints, MetroBridgeResult, ...)
- src/commands/runtime-types.ts (CommandResult, RuntimeCommand, ...)
- src/commands/diagnostics-types.ts
- src/cli/commands/router-types.ts (ClientCommandParams, ...)
- src/core/interactor-types.ts (Interactor, BackMode, ...)
- src/platforms/ios/runner-session-types.ts (RunnerSession)
- src/utils/screenshot-diff-region-types.ts (MutableDiffRegion)
- src/daemon/handlers/record-trace-types.ts
madge --circular now reports 0 cycles (was 44). No runtime behavior changes.
* fix: preserve wire error codes verbatim (addresses codex review)
The initial weak-types pass validated wire error codes against a closed
union, silently downgrading any unknown code to COMMAND_FAILED. This
dropped signals like AMBIGUOUS_MATCH that handlers emit and clients are
documented to handle (skills/agent-device/references/exploration.md).
- Widen AppErrorCode to 'KnownAppErrorCode | (string & {})' so autocomplete
of known codes is preserved while any wire code flows through
- toAppErrorCode now preserves any non-empty code; fallback only when
undefined or empty
- Add AMBIGUOUS_MATCH to KnownAppErrorCode (documented public code)
- Add test coverage for preservation and fallback behavior
* refactor: address review follow-ups
- Add DEVICE_IN_USE to the batch error taxonomy in exploration.md (now
observable by clients after earlier fix, needs agent-facing guidance)
- Delete one-line src/platforms/linux/index.ts barrel; both consumers
(core/dispatch, daemon/handlers/snapshot-capture) now import from
platforms/linux/snapshot directly
- Replace inline { tenantId; runId; leaseId } shapes with MetroBridgeScope
alias at client-types, metro, and cli/commands/connection-runtime
- Consolidate remaining inline setTimeout wrappers onto utils/timeouts.ts#sleep
(12 files, ~18 sites). Left test files and the runtime-clock aware helper
in commands/selector-read-utils alone. Also removes the local sleepMs
helper from daemon-client.ts.
* refactor: address low-priority review follow-ups
- daemon-client RPC error path: stringify any non-null data.code instead
of only forwarding strings. Preserves numeric codes from hypothetical
future proxies/servers; for first-party daemon today this is a no-op
since handlers already emit strings.
- errors.ts: expand AppErrorCode comment to call out the exhaustiveness
tradeoff of the '(string & {})' widening for SDK consumers.
514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
import { usage, usageForCommand } from './utils/args.ts';
|
|
import { asAppError, AppError, normalizeError } from './utils/errors.ts';
|
|
import { printHumanError, printJson } from './utils/output.ts';
|
|
import { readVersion } from './utils/version.ts';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { sendToDaemon } from './daemon-client.ts';
|
|
import fs from 'node:fs';
|
|
import type { BatchStep } from './core/dispatch.ts';
|
|
import { parseBatchStepsJson } from './core/batch.ts';
|
|
import {
|
|
createAgentDeviceClient,
|
|
type AgentDeviceClientConfig,
|
|
type AgentDeviceDaemonTransport,
|
|
} from './client.ts';
|
|
import { materializeRemoteConnectionForCommand } from './cli/commands/connection-runtime.ts';
|
|
import { tryRunClientBackedCommand } from './cli/commands/router.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 './utils/cli-options.ts';
|
|
import { maybeRunUpgradeNotifier } from './utils/update-check.ts';
|
|
import { resolveRemoteConnectionDefaults } from './remote-connection-state.ts';
|
|
import type { CliFlags, FlagKey } from './utils/command-schema.ts';
|
|
import type { SessionRuntimeHints } from './contracts.ts';
|
|
|
|
type CliDeps = {
|
|
sendToDaemon: typeof sendToDaemon;
|
|
};
|
|
|
|
const DEFAULT_CLI_DEPS: CliDeps = {
|
|
sendToDaemon,
|
|
};
|
|
|
|
const METRO_RUNTIME_OVERRIDE_FLAG_KEYS = new Set<FlagKey>([
|
|
'launchUrl',
|
|
'metroBearerToken',
|
|
'metroKind',
|
|
'metroListenHost',
|
|
'metroNoInstallDeps',
|
|
'metroNoReuseExisting',
|
|
'metroPreparePort',
|
|
'metroProbeTimeoutMs',
|
|
'metroProjectRoot',
|
|
'metroProxyBaseUrl',
|
|
'metroPublicBaseUrl',
|
|
'metroRuntimeFile',
|
|
'metroStartupTimeoutMs',
|
|
'metroStatusHost',
|
|
]);
|
|
|
|
const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([
|
|
'connect',
|
|
'connection',
|
|
'close',
|
|
'devices',
|
|
'disconnect',
|
|
'ensure-simulator',
|
|
'metro',
|
|
'session',
|
|
]);
|
|
|
|
export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise<void> {
|
|
const requestId = createRequestId();
|
|
const version = readVersion();
|
|
const debugEnabled =
|
|
argv.includes('--debug') || argv.includes('--verbose') || argv.includes('-v');
|
|
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 () => {
|
|
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 (jsonRequested) {
|
|
printJson({ success: false, error: normalized });
|
|
} else {
|
|
printHumanError(normalized, { showDetails: debugEnabled });
|
|
}
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
|
|
for (const warning of parsed.warnings) {
|
|
process.stderr.write(`Warning: ${warning}\n`);
|
|
}
|
|
|
|
if (parsed.flags.version) {
|
|
process.stdout.write(`${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(`${usage()}\n`);
|
|
process.exit(0);
|
|
}
|
|
const commandHelp = usageForCommand(helpTarget);
|
|
if (commandHelp) {
|
|
process.stdout.write(commandHelp);
|
|
process.exit(0);
|
|
}
|
|
printHumanError(new AppError('INVALID_ARGS', `Unknown command: ${helpTarget}`));
|
|
process.stdout.write(`${usage()}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!parsed.command) {
|
|
process.stdout.write(`${usage()}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const { command, positionals } = parsed;
|
|
let binding: ReturnType<typeof resolveBindingSettings>;
|
|
let flags: typeof parsed.flags;
|
|
let daemonPaths: ReturnType<typeof resolveDaemonPaths>;
|
|
let sessionName: string;
|
|
let connectionDefaults: ReturnType<typeof resolveActiveConnectionDefaults>;
|
|
let effectiveFlags: typeof parsed.flags;
|
|
const explicitFlagKeys = new Set(parsed.providedFlags.map((entry) => entry.key));
|
|
try {
|
|
binding = resolveBindingSettings({
|
|
policyOverrides: parsed.flags,
|
|
configuredPlatform: parsed.flags.platform,
|
|
configuredSession: parsed.flags.session,
|
|
});
|
|
flags = binding.lockPolicy
|
|
? { ...parsed.flags }
|
|
: applyDefaultPlatformBinding(parsed.flags, {
|
|
policyOverrides: parsed.flags,
|
|
configuredPlatform: parsed.flags.platform,
|
|
configuredSession: parsed.flags.session,
|
|
});
|
|
daemonPaths = resolveDaemonPaths(flags.stateDir);
|
|
sessionName = flags.session ?? 'default';
|
|
connectionDefaults = resolveActiveConnectionDefaults({
|
|
command,
|
|
explicitFlagKeys,
|
|
stateDir: daemonPaths.baseDir,
|
|
session: sessionName,
|
|
remoteConfig: flags.remoteConfig,
|
|
});
|
|
effectiveFlags = connectionDefaults
|
|
? mergeConnectionFlags(flags, connectionDefaults.flags, explicitFlagKeys)
|
|
: flags;
|
|
} 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: parsed.flags.verbose });
|
|
}
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
let logTailStopper: (() => void) | null = null;
|
|
try {
|
|
maybeRunUpgradeNotifier({
|
|
command,
|
|
currentVersion: version,
|
|
stateDir: daemonPaths.baseDir,
|
|
flags: effectiveFlags,
|
|
});
|
|
let resolvedRuntime = connectionDefaults?.runtime;
|
|
const buildClientConfig = (
|
|
currentFlags: CliFlags,
|
|
runtime: SessionRuntimeHints | undefined,
|
|
): AgentDeviceClientConfig => ({
|
|
session: currentFlags.session ?? sessionName,
|
|
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,
|
|
runtime,
|
|
lockPolicy: binding.lockPolicy,
|
|
lockPlatform: binding.defaultPlatform,
|
|
cwd: process.cwd(),
|
|
debug: Boolean(currentFlags.verbose),
|
|
});
|
|
let parsedBatchSteps: BatchStep[] | undefined;
|
|
if (command === 'batch') {
|
|
if (positionals.length > 0) {
|
|
throw new AppError('INVALID_ARGS', 'batch does not accept positional arguments.');
|
|
}
|
|
parsedBatchSteps = readBatchSteps(flags);
|
|
}
|
|
|
|
if (effectiveFlags.remoteConfig && shouldMaterializeRemoteConnection(command)) {
|
|
const materializationClient = createAgentDeviceClient(
|
|
buildClientConfig(effectiveFlags, resolvedRuntime),
|
|
{
|
|
transport: deps.sendToDaemon as AgentDeviceDaemonTransport,
|
|
},
|
|
);
|
|
const materialized = await materializeRemoteConnectionForCommand({
|
|
command,
|
|
flags: effectiveFlags,
|
|
client: materializationClient,
|
|
runtime: resolvedRuntime,
|
|
batchSteps: parsedBatchSteps,
|
|
forceRuntimePrepare: hasExplicitMetroRuntimeOverrides(explicitFlagKeys),
|
|
});
|
|
effectiveFlags = materialized.flags;
|
|
resolvedRuntime = materialized.runtime;
|
|
}
|
|
if (
|
|
shouldWarnOpenMayMissRemoteRuntime({
|
|
command,
|
|
flags: effectiveFlags,
|
|
runtime: resolvedRuntime,
|
|
explicitFlagKeys,
|
|
hadConnectionDefaults: Boolean(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',
|
|
);
|
|
}
|
|
const remoteDaemonBaseUrl = effectiveFlags.daemonBaseUrl;
|
|
logTailStopper =
|
|
effectiveFlags.verbose && !effectiveFlags.json && !remoteDaemonBaseUrl
|
|
? startDaemonLogTail(daemonPaths.logPath)
|
|
: null;
|
|
const client = createAgentDeviceClient(buildClientConfig(effectiveFlags, resolvedRuntime), {
|
|
transport: deps.sendToDaemon as AgentDeviceDaemonTransport,
|
|
});
|
|
if (command === 'batch') {
|
|
if (!parsedBatchSteps) {
|
|
throw new AppError('INVALID_ARGS', 'batch requires --steps or --steps-file.');
|
|
}
|
|
const batchSteps = parsedBatchSteps.map((step, _index) => ({
|
|
...step,
|
|
flags:
|
|
binding.lockPolicy && flags.platform === undefined
|
|
? { ...((step.flags ?? {}) as Partial<typeof flags>) }
|
|
: applyDefaultPlatformBinding((step.flags ?? {}) as Partial<typeof flags>, {
|
|
policyOverrides: effectiveFlags,
|
|
configuredPlatform: effectiveFlags.platform,
|
|
configuredSession: effectiveFlags.session,
|
|
inheritedPlatform: effectiveFlags.platform,
|
|
}),
|
|
}));
|
|
if (
|
|
await tryRunClientBackedCommand({
|
|
command,
|
|
positionals,
|
|
flags: { ...effectiveFlags, batchSteps },
|
|
client,
|
|
})
|
|
) {
|
|
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 })
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new AppError('INVALID_ARGS', `Unknown command: ${command}`);
|
|
} catch (err) {
|
|
const appErr = asAppError(err);
|
|
const normalized = normalizeError(appErr, {
|
|
diagnosticId: getDiagnosticsMeta().diagnosticId,
|
|
logPath: flushDiagnosticsToSessionFile({ force: true }) ?? undefined,
|
|
});
|
|
if (command === 'close' && isDaemonStartupFailure(appErr)) {
|
|
if (effectiveFlags.json) {
|
|
printJson({ success: true, data: { closed: 'session', source: 'no-daemon' } });
|
|
}
|
|
return;
|
|
}
|
|
if (effectiveFlags.json) {
|
|
printJson({
|
|
success: false,
|
|
error: normalized,
|
|
});
|
|
} else {
|
|
printHumanError(normalized, { showDetails: effectiveFlags.verbose });
|
|
if (effectiveFlags.verbose) {
|
|
try {
|
|
const logPath = daemonPaths.logPath;
|
|
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 {}
|
|
}
|
|
}
|
|
if (logTailStopper) logTailStopper();
|
|
process.exit(1);
|
|
} finally {
|
|
if (logTailStopper) logTailStopper();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
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 parseBatchStepsJson(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;
|
|
}): {
|
|
flags: Partial<CliFlags>;
|
|
runtime?: SessionRuntimeHints;
|
|
} | null {
|
|
if (options.command === 'connect' || options.command === 'connection') 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'),
|
|
validateRemoteConfigHash: options.command !== 'disconnect',
|
|
});
|
|
return defaults;
|
|
}
|
|
|
|
function shouldMaterializeRemoteConnection(command: string): boolean {
|
|
return !REMOTE_MATERIALIZATION_DEFERRED_COMMANDS.has(command);
|
|
}
|
|
|
|
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 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 = 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;
|
|
}
|
|
}
|