agent-device 0.2.6 → 0.3.1

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.
@@ -0,0 +1,67 @@
1
+ import { asAppError } from '../utils/errors.ts';
2
+
3
+ export type BootFailureReason =
4
+ | 'BOOT_TIMEOUT'
5
+ | 'DEVICE_UNAVAILABLE'
6
+ | 'DEVICE_OFFLINE'
7
+ | 'PERMISSION_DENIED'
8
+ | 'TOOL_MISSING'
9
+ | 'BOOT_COMMAND_FAILED'
10
+ | 'UNKNOWN';
11
+
12
+ export function classifyBootFailure(input: {
13
+ error?: unknown;
14
+ message?: string;
15
+ stdout?: string;
16
+ stderr?: string;
17
+ }): BootFailureReason {
18
+ const appErr = input.error ? asAppError(input.error) : null;
19
+ if (appErr?.code === 'TOOL_MISSING') return 'TOOL_MISSING';
20
+ const details = (appErr?.details ?? {}) as Record<string, unknown>;
21
+ const detailMessage = typeof details.message === 'string' ? details.message : undefined;
22
+ const detailStdout = typeof details.stdout === 'string' ? details.stdout : undefined;
23
+ const detailStderr = typeof details.stderr === 'string' ? details.stderr : undefined;
24
+ const nestedBoot = details.boot && typeof details.boot === 'object'
25
+ ? (details.boot as Record<string, unknown>)
26
+ : null;
27
+ const nestedBootstatus = details.bootstatus && typeof details.bootstatus === 'object'
28
+ ? (details.bootstatus as Record<string, unknown>)
29
+ : null;
30
+
31
+ const haystack = [
32
+ input.message,
33
+ appErr?.message,
34
+ input.stdout,
35
+ input.stderr,
36
+ detailMessage,
37
+ detailStdout,
38
+ detailStderr,
39
+ typeof nestedBoot?.stdout === 'string' ? nestedBoot.stdout : undefined,
40
+ typeof nestedBoot?.stderr === 'string' ? nestedBoot.stderr : undefined,
41
+ typeof nestedBootstatus?.stdout === 'string' ? nestedBootstatus.stdout : undefined,
42
+ typeof nestedBootstatus?.stderr === 'string' ? nestedBootstatus.stderr : undefined,
43
+ ]
44
+ .filter(Boolean)
45
+ .join('\n')
46
+ .toLowerCase();
47
+
48
+ if (haystack.includes('timed out') || haystack.includes('timeout')) return 'BOOT_TIMEOUT';
49
+ if (
50
+ haystack.includes('device not found') ||
51
+ haystack.includes('no devices') ||
52
+ haystack.includes('unable to locate device') ||
53
+ haystack.includes('invalid device')
54
+ ) {
55
+ return 'DEVICE_UNAVAILABLE';
56
+ }
57
+ if (haystack.includes('offline')) return 'DEVICE_OFFLINE';
58
+ if (
59
+ haystack.includes('permission denied') ||
60
+ haystack.includes('not authorized') ||
61
+ haystack.includes('unauthorized')
62
+ ) {
63
+ return 'PERMISSION_DENIED';
64
+ }
65
+ if (appErr?.code === 'COMMAND_FAILED' || haystack.length > 0) return 'BOOT_COMMAND_FAILED';
66
+ return 'UNKNOWN';
67
+ }
@@ -1,11 +1,16 @@
1
1
  import { runCmd } from '../../utils/exec.ts';
2
+ import type { ExecResult } from '../../utils/exec.ts';
2
3
  import { AppError } from '../../utils/errors.ts';
3
4
  import type { DeviceInfo } from '../../utils/device.ts';
5
+ import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
6
+ import { classifyBootFailure } from '../boot-diagnostics.ts';
4
7
 
5
8
  const ALIASES: Record<string, string> = {
6
9
  settings: 'com.apple.Preferences',
7
10
  };
8
11
 
12
+ const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(process.env.AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS, 120_000, 5_000);
13
+
9
14
  export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
10
15
  const trimmed = app.trim();
11
16
  if (trimmed.includes('.')) return trimmed;
@@ -207,8 +212,88 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
207
212
  if (device.kind !== 'simulator') return;
208
213
  const state = await getSimulatorState(device.id);
209
214
  if (state === 'Booted') return;
210
- await runCmd('xcrun', ['simctl', 'boot', device.id], { allowFailure: true });
211
- await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], { allowFailure: true });
215
+ const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
216
+ let bootResult: ExecResult | undefined;
217
+ let bootStatusResult: ExecResult | undefined;
218
+ try {
219
+ await retryWithPolicy(
220
+ async () => {
221
+ const currentState = await getSimulatorState(device.id);
222
+ if (currentState === 'Booted') return;
223
+ bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], { allowFailure: true });
224
+ const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase();
225
+ const bootAlreadyDone =
226
+ bootOutput.includes('already booted') || bootOutput.includes('current state: booted');
227
+ if (bootResult.exitCode !== 0 && !bootAlreadyDone) {
228
+ throw new AppError('COMMAND_FAILED', 'simctl boot failed', {
229
+ stdout: bootResult.stdout,
230
+ stderr: bootResult.stderr,
231
+ exitCode: bootResult.exitCode,
232
+ });
233
+ }
234
+ bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], {
235
+ allowFailure: true,
236
+ });
237
+ if (bootStatusResult.exitCode !== 0) {
238
+ throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', {
239
+ stdout: bootStatusResult.stdout,
240
+ stderr: bootStatusResult.stderr,
241
+ exitCode: bootStatusResult.exitCode,
242
+ });
243
+ }
244
+ const nextState = await getSimulatorState(device.id);
245
+ if (nextState !== 'Booted') {
246
+ throw new AppError('COMMAND_FAILED', 'Simulator is still booting', {
247
+ state: nextState,
248
+ });
249
+ }
250
+ },
251
+ {
252
+ maxAttempts: 3,
253
+ baseDelayMs: 500,
254
+ maxDelayMs: 2000,
255
+ jitter: 0.2,
256
+ shouldRetry: (error) => {
257
+ const reason = classifyBootFailure({
258
+ error,
259
+ stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
260
+ stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
261
+ });
262
+ return reason !== 'PERMISSION_DENIED' && reason !== 'TOOL_MISSING';
263
+ },
264
+ },
265
+ { deadline },
266
+ );
267
+ } catch (error) {
268
+ const bootStdout = bootResult?.stdout;
269
+ const bootStderr = bootResult?.stderr;
270
+ const bootExitCode = bootResult?.exitCode;
271
+ const bootstatusStdout = bootStatusResult?.stdout;
272
+ const bootstatusStderr = bootStatusResult?.stderr;
273
+ const bootstatusExitCode = bootStatusResult?.exitCode;
274
+ const reason = classifyBootFailure({
275
+ error,
276
+ stdout: bootstatusStdout ?? bootStdout,
277
+ stderr: bootstatusStderr ?? bootStderr,
278
+ });
279
+ throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', {
280
+ platform: 'ios',
281
+ deviceId: device.id,
282
+ timeoutMs: IOS_BOOT_TIMEOUT_MS,
283
+ elapsedMs: deadline.elapsedMs(),
284
+ reason,
285
+ boot: bootResult
286
+ ? { exitCode: bootExitCode, stdout: bootStdout, stderr: bootStderr }
287
+ : undefined,
288
+ bootstatus: bootStatusResult
289
+ ? {
290
+ exitCode: bootstatusExitCode,
291
+ stdout: bootstatusStdout,
292
+ stderr: bootstatusStderr,
293
+ }
294
+ : undefined,
295
+ });
296
+ }
212
297
  }
213
298
 
214
299
  async function getSimulatorState(udid: string): Promise<string | null> {
@@ -229,3 +314,10 @@ async function getSimulatorState(udid: string): Promise<string | null> {
229
314
  }
230
315
  return null;
231
316
  }
317
+
318
+ function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
319
+ if (!raw) return fallback;
320
+ const parsed = Number(raw);
321
+ if (!Number.isFinite(parsed)) return fallback;
322
+ return Math.max(min, Math.floor(parsed));
323
+ }
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { AppError } from '../../utils/errors.ts';
6
- import { runCmd, runCmdStreaming, type ExecResult } from '../../utils/exec.ts';
6
+ import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
7
7
  import { withRetry } from '../../utils/retry.ts';
8
8
  import type { DeviceInfo } from '../../utils/device.ts';
9
9
  import net from 'node:net';
@@ -46,9 +46,30 @@ export type RunnerSession = {
46
46
  xctestrunPath: string;
47
47
  jsonPath: string;
48
48
  testPromise: Promise<ExecResult>;
49
+ child: ExecBackgroundResult['child'];
50
+ ready: boolean;
49
51
  };
50
52
 
51
53
  const runnerSessions = new Map<string, RunnerSession>();
54
+ const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
55
+ process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
56
+ 120_000,
57
+ 5_000,
58
+ );
59
+ const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
60
+ process.env.AGENT_DEVICE_RUNNER_COMMAND_TIMEOUT_MS,
61
+ 15_000,
62
+ 1_000,
63
+ );
64
+ const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
65
+ const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
66
+
67
+ function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
68
+ if (!raw) return fallback;
69
+ const parsed = Number(raw);
70
+ if (!Number.isFinite(parsed)) return fallback;
71
+ return Math.max(min, Math.floor(parsed));
72
+ }
52
73
 
53
74
  export type RunnerSnapshotNode = {
54
75
  index: number;
@@ -87,29 +108,14 @@ async function executeRunnerCommand(
87
108
 
88
109
  try {
89
110
  const session = await ensureRunnerSession(device, options);
90
- const response = await waitForRunner(device, session.port, command, options.logPath);
91
- const text = await response.text();
92
-
93
- let json: any = {};
94
- try {
95
- json = JSON.parse(text);
96
- } catch {
97
- throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
98
- }
99
-
100
- if (!json.ok) {
101
- throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
102
- runner: json,
103
- xcodebuild: {
104
- exitCode: 1,
105
- stdout: '',
106
- stderr: '',
107
- },
108
- logPath: options.logPath,
109
- });
110
- }
111
-
112
- return json.data ?? {};
111
+ const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS;
112
+ return await executeRunnerCommandWithSession(
113
+ device,
114
+ session,
115
+ command,
116
+ options.logPath,
117
+ timeoutMs,
118
+ );
113
119
  } catch (err) {
114
120
  const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
115
121
  if (
@@ -119,46 +125,79 @@ async function executeRunnerCommand(
119
125
  ) {
120
126
  await stopIosRunnerSession(device.id);
121
127
  const session = await ensureRunnerSession(device, options);
122
- const response = await waitForRunner(device, session.port, command, options.logPath);
123
- const text = await response.text();
124
- let json: any = {};
125
- try {
126
- json = JSON.parse(text);
127
- } catch {
128
- throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
129
- }
130
- if (!json.ok) {
131
- throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
132
- runner: json,
133
- xcodebuild: {
134
- exitCode: 1,
135
- stdout: '',
136
- stderr: '',
137
- },
138
- logPath: options.logPath,
139
- });
140
- }
141
- return json.data ?? {};
128
+ const response = await waitForRunner(
129
+ session.device,
130
+ session.port,
131
+ command,
132
+ options.logPath,
133
+ RUNNER_STARTUP_TIMEOUT_MS,
134
+ );
135
+ return await parseRunnerResponse(response, session, options.logPath);
142
136
  }
143
137
  throw err;
144
138
  }
145
139
  }
146
140
 
141
+ async function executeRunnerCommandWithSession(
142
+ device: DeviceInfo,
143
+ session: RunnerSession,
144
+ command: RunnerCommand,
145
+ logPath: string | undefined,
146
+ timeoutMs: number,
147
+ ): Promise<Record<string, unknown>> {
148
+ const response = await waitForRunner(device, session.port, command, logPath, timeoutMs);
149
+ return await parseRunnerResponse(response, session, logPath);
150
+ }
151
+
152
+ async function parseRunnerResponse(
153
+ response: Response,
154
+ session: RunnerSession,
155
+ logPath?: string,
156
+ ): Promise<Record<string, unknown>> {
157
+ const text = await response.text();
158
+ let json: any = {};
159
+ try {
160
+ json = JSON.parse(text);
161
+ } catch {
162
+ throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
163
+ }
164
+ if (!json.ok) {
165
+ throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
166
+ runner: json,
167
+ xcodebuild: {
168
+ exitCode: 1,
169
+ stdout: '',
170
+ stderr: '',
171
+ },
172
+ logPath,
173
+ });
174
+ }
175
+ session.ready = true;
176
+ return json.data ?? {};
177
+ }
178
+
147
179
  export async function stopIosRunnerSession(deviceId: string): Promise<void> {
148
180
  const session = runnerSessions.get(deviceId);
149
181
  if (!session) return;
150
182
  try {
151
183
  await waitForRunner(session.device, session.port, {
152
184
  command: 'shutdown',
153
- } as RunnerCommand);
185
+ } as RunnerCommand, undefined, RUNNER_SHUTDOWN_TIMEOUT_MS);
154
186
  } catch {
155
- // ignore
187
+ // Runner not responsive — send SIGTERM so we don't hang on testPromise
188
+ await killRunnerProcessTree(session.child.pid, 'SIGTERM');
156
189
  }
157
190
  try {
158
- await session.testPromise;
191
+ // Bound the wait so we never hang if xcodebuild refuses to exit
192
+ await Promise.race([
193
+ session.testPromise,
194
+ new Promise<void>((resolve) => setTimeout(resolve, RUNNER_STOP_WAIT_TIMEOUT_MS)),
195
+ ]);
159
196
  } catch {
160
197
  // ignore
161
198
  }
199
+ // Force-kill if still alive (harmless if already exited)
200
+ await killRunnerProcessTree(session.child.pid, 'SIGKILL');
162
201
  cleanupTempFile(session.xctestrunPath);
163
202
  cleanupTempFile(session.jsonPath);
164
203
  runnerSessions.delete(deviceId);
@@ -183,7 +222,7 @@ async function ensureRunnerSession(
183
222
  { AGENT_DEVICE_RUNNER_PORT: String(port) },
184
223
  `session-${device.id}-${port}`,
185
224
  );
186
- const testPromise = runCmdStreaming(
225
+ const { child, wait: testPromise } = runCmdBackground(
187
226
  'xcodebuild',
188
227
  [
189
228
  'test-without-building',
@@ -201,16 +240,16 @@ async function ensureRunnerSession(
201
240
  `platform=iOS Simulator,id=${device.id}`,
202
241
  ],
203
242
  {
204
- onStdoutChunk: (chunk) => {
205
- logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
206
- },
207
- onStderrChunk: (chunk) => {
208
- logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
209
- },
210
243
  allowFailure: true,
211
244
  env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
212
245
  },
213
246
  );
247
+ child.stdout?.on('data', (chunk: string) => {
248
+ logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
249
+ });
250
+ child.stderr?.on('data', (chunk: string) => {
251
+ logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
252
+ });
214
253
 
215
254
  const session: RunnerSession = {
216
255
  device,
@@ -219,11 +258,31 @@ async function ensureRunnerSession(
219
258
  xctestrunPath,
220
259
  jsonPath,
221
260
  testPromise,
261
+ child,
262
+ ready: false,
222
263
  };
223
264
  runnerSessions.set(device.id, session);
224
265
  return session;
225
266
  }
226
267
 
268
+ async function killRunnerProcessTree(
269
+ pid: number | undefined,
270
+ signal: 'SIGTERM' | 'SIGKILL',
271
+ ): Promise<void> {
272
+ if (!pid || pid <= 0) return;
273
+ try {
274
+ process.kill(pid, signal);
275
+ } catch {
276
+ // ignore
277
+ }
278
+ const pkillSignal = signal === 'SIGTERM' ? 'TERM' : 'KILL';
279
+ try {
280
+ await runCmd('pkill', [`-${pkillSignal}`, '-P', String(pid)], { allowFailure: true });
281
+ } catch {
282
+ // ignore
283
+ }
284
+ }
285
+
227
286
 
228
287
  async function ensureXctestrun(
229
288
  udid: string,
@@ -364,10 +423,11 @@ async function waitForRunner(
364
423
  port: number,
365
424
  command: RunnerCommand,
366
425
  logPath?: string,
426
+ timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
367
427
  ): Promise<Response> {
368
428
  const start = Date.now();
369
429
  let lastError: unknown = null;
370
- while (Date.now() - start < 15000) {
430
+ while (Date.now() - start < timeoutMs) {
371
431
  try {
372
432
  const response = await fetch(`http://127.0.0.1:${port}/command`, {
373
433
  method: 'POST',
@@ -0,0 +1,27 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Deadline, retryWithPolicy } from '../retry.ts';
4
+
5
+ test('Deadline tracks remaining and expiration', async () => {
6
+ const deadline = Deadline.fromTimeoutMs(25);
7
+ assert.equal(deadline.isExpired(), false);
8
+ await new Promise((resolve) => setTimeout(resolve, 30));
9
+ assert.equal(deadline.isExpired(), true);
10
+ assert.equal(deadline.remainingMs(), 0);
11
+ });
12
+
13
+ test('retryWithPolicy retries until success', async () => {
14
+ let attempts = 0;
15
+ const result = await retryWithPolicy(
16
+ async () => {
17
+ attempts += 1;
18
+ if (attempts < 3) {
19
+ throw new Error('transient');
20
+ }
21
+ return 'ok';
22
+ },
23
+ { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 },
24
+ );
25
+ assert.equal(result, 'ok');
26
+ assert.equal(attempts, 3);
27
+ });
package/src/utils/args.ts CHANGED
@@ -25,11 +25,12 @@ export type ParsedArgs = {
25
25
  noRecord?: boolean;
26
26
  replayUpdate?: boolean;
27
27
  help: boolean;
28
+ version: boolean;
28
29
  };
29
30
  };
30
31
 
31
32
  export function parseArgs(argv: string[]): ParsedArgs {
32
- const flags: ParsedArgs['flags'] = { json: false, help: false };
33
+ const flags: ParsedArgs['flags'] = { json: false, help: false, version: false };
33
34
  const positionals: string[] = [];
34
35
 
35
36
  for (let i = 0; i < argv.length; i += 1) {
@@ -42,6 +43,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
42
43
  flags.help = true;
43
44
  continue;
44
45
  }
46
+ if (arg === '--version' || arg === '-V') {
47
+ flags.version = true;
48
+ continue;
49
+ }
45
50
  if (arg === '--verbose' || arg === '-v') {
46
51
  flags.verbose = true;
47
52
  continue;
@@ -229,5 +234,6 @@ Flags:
229
234
  --update, -u Replay: update selectors and rewrite replay file in place
230
235
  --user-installed Apps: list user-installed packages (Android only)
231
236
  --all Apps: list all packages (Android only)
237
+ --version, -V Print version and exit
232
238
  `;
233
239
  }
@@ -8,6 +8,20 @@ type RetryOptions = {
8
8
  shouldRetry?: (error: unknown, attempt: number) => boolean;
9
9
  };
10
10
 
11
+ export type RetryPolicy = {
12
+ maxAttempts: number;
13
+ baseDelayMs: number;
14
+ maxDelayMs: number;
15
+ jitter: number;
16
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
17
+ };
18
+
19
+ export type RetryAttemptContext = {
20
+ attempt: number;
21
+ maxAttempts: number;
22
+ deadline?: Deadline;
23
+ };
24
+
11
25
  const defaultOptions: Required<Pick<RetryOptions, 'attempts' | 'baseDelayMs' | 'maxDelayMs' | 'jitter'>> = {
12
26
  attempts: 3,
13
27
  baseDelayMs: 200,
@@ -15,30 +29,76 @@ const defaultOptions: Required<Pick<RetryOptions, 'attempts' | 'baseDelayMs' | '
15
29
  jitter: 0.2,
16
30
  };
17
31
 
18
- export async function withRetry<T>(
19
- fn: () => Promise<T>,
20
- options: RetryOptions = {},
32
+ export class Deadline {
33
+ private readonly startedAtMs: number;
34
+ private readonly expiresAtMs: number;
35
+
36
+ private constructor(startedAtMs: number, timeoutMs: number) {
37
+ this.startedAtMs = startedAtMs;
38
+ this.expiresAtMs = startedAtMs + Math.max(0, timeoutMs);
39
+ }
40
+
41
+ static fromTimeoutMs(timeoutMs: number, nowMs = Date.now()): Deadline {
42
+ return new Deadline(nowMs, timeoutMs);
43
+ }
44
+
45
+ remainingMs(nowMs = Date.now()): number {
46
+ return Math.max(0, this.expiresAtMs - nowMs);
47
+ }
48
+
49
+ elapsedMs(nowMs = Date.now()): number {
50
+ return Math.max(0, nowMs - this.startedAtMs);
51
+ }
52
+
53
+ isExpired(nowMs = Date.now()): boolean {
54
+ return this.remainingMs(nowMs) <= 0;
55
+ }
56
+ }
57
+
58
+ export async function retryWithPolicy<T>(
59
+ fn: (context: RetryAttemptContext) => Promise<T>,
60
+ policy: Partial<RetryPolicy> = {},
61
+ options: { deadline?: Deadline } = {},
21
62
  ): Promise<T> {
22
- const attempts = options.attempts ?? defaultOptions.attempts;
23
- const baseDelayMs = options.baseDelayMs ?? defaultOptions.baseDelayMs;
24
- const maxDelayMs = options.maxDelayMs ?? defaultOptions.maxDelayMs;
25
- const jitter = options.jitter ?? defaultOptions.jitter;
63
+ const merged: RetryPolicy = {
64
+ maxAttempts: policy.maxAttempts ?? defaultOptions.attempts,
65
+ baseDelayMs: policy.baseDelayMs ?? defaultOptions.baseDelayMs,
66
+ maxDelayMs: policy.maxDelayMs ?? defaultOptions.maxDelayMs,
67
+ jitter: policy.jitter ?? defaultOptions.jitter,
68
+ shouldRetry: policy.shouldRetry,
69
+ };
26
70
  let lastError: unknown;
27
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
71
+ for (let attempt = 1; attempt <= merged.maxAttempts; attempt += 1) {
72
+ if (options.deadline?.isExpired() && attempt > 1) break;
28
73
  try {
29
- return await fn();
74
+ return await fn({ attempt, maxAttempts: merged.maxAttempts, deadline: options.deadline });
30
75
  } catch (err) {
31
76
  lastError = err;
32
- if (attempt >= attempts) break;
33
- if (options.shouldRetry && !options.shouldRetry(err, attempt)) break;
34
- const delay = computeDelay(baseDelayMs, maxDelayMs, jitter, attempt);
35
- await sleep(delay);
77
+ if (attempt >= merged.maxAttempts) break;
78
+ if (merged.shouldRetry && !merged.shouldRetry(err, attempt)) break;
79
+ const delay = computeDelay(merged.baseDelayMs, merged.maxDelayMs, merged.jitter, attempt);
80
+ const boundedDelay = options.deadline ? Math.min(delay, options.deadline.remainingMs()) : delay;
81
+ if (boundedDelay <= 0) break;
82
+ await sleep(boundedDelay);
36
83
  }
37
84
  }
38
85
  if (lastError) throw lastError;
39
86
  throw new AppError('COMMAND_FAILED', 'retry failed');
40
87
  }
41
88
 
89
+ export async function withRetry<T>(
90
+ fn: () => Promise<T>,
91
+ options: RetryOptions = {},
92
+ ): Promise<T> {
93
+ return retryWithPolicy(() => fn(), {
94
+ maxAttempts: options.attempts,
95
+ baseDelayMs: options.baseDelayMs,
96
+ maxDelayMs: options.maxDelayMs,
97
+ jitter: options.jitter,
98
+ shouldRetry: options.shouldRetry,
99
+ });
100
+ }
101
+
42
102
  function computeDelay(base: number, max: number, jitter: number, attempt: number): number {
43
103
  const exp = Math.min(max, base * 2 ** (attempt - 1));
44
104
  const jitterAmount = exp * jitter;
@@ -0,0 +1,26 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export function readVersion(): string {
6
+ try {
7
+ const root = findProjectRoot();
8
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
9
+ version?: string;
10
+ };
11
+ return pkg.version ?? '0.0.0';
12
+ } catch {
13
+ return '0.0.0';
14
+ }
15
+ }
16
+
17
+ export function findProjectRoot(): string {
18
+ const start = path.dirname(fileURLToPath(import.meta.url));
19
+ let current = start;
20
+ for (let i = 0; i < 6; i += 1) {
21
+ const pkgPath = path.join(current, 'package.json');
22
+ if (fs.existsSync(pkgPath)) return current;
23
+ current = path.dirname(current);
24
+ }
25
+ return start;
26
+ }
package/dist/src/861.js DELETED
@@ -1 +0,0 @@
1
- import{spawn as e}from"node:child_process";function t(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}class n extends Error{constructor(e,n,o,r){super(n),t(this,"code",void 0),t(this,"details",void 0),t(this,"cause",void 0),this.code=e,this.details=o,this.cause=r}}function o(e){return e instanceof n?e:e instanceof Error?new n("UNKNOWN",e.message,void 0,e):new n("UNKNOWN","Unknown error",{err:e})}async function r(t,o,d={}){return new Promise((r,i)=>{let s=e(t,o,{cwd:d.cwd,env:d.env,stdio:["pipe","pipe","pipe"]}),u="",a=d.binaryStdout?Buffer.alloc(0):void 0,c="";d.binaryStdout||s.stdout.setEncoding("utf8"),s.stderr.setEncoding("utf8"),void 0!==d.stdin&&s.stdin.write(d.stdin),s.stdin.end(),s.stdout.on("data",e=>{d.binaryStdout?a=Buffer.concat([a??Buffer.alloc(0),Buffer.isBuffer(e)?e:Buffer.from(e)]):u+=e}),s.stderr.on("data",e=>{c+=e}),s.on("error",e=>{"ENOENT"===e.code?i(new n("TOOL_MISSING",`${t} not found in PATH`,{cmd:t},e)):i(new n("COMMAND_FAILED",`Failed to run ${t}`,{cmd:t,args:o},e))}),s.on("close",e=>{let s=e??1;0===s||d.allowFailure?r({stdout:u,stderr:c,exitCode:s,stdoutBuffer:a}):i(new n("COMMAND_FAILED",`${t} exited with code ${s}`,{cmd:t,args:o,stdout:u,stderr:c,exitCode:s}))})})}async function d(e){try{var t;let{shell:n,args:o}=(t=e,"win32"===process.platform?{shell:"cmd.exe",args:["/c","where",t]}:{shell:"bash",args:["-lc",`command -v ${t}`]}),d=await r(n,o,{allowFailure:!0});return 0===d.exitCode&&d.stdout.trim().length>0}catch{return!1}}function i(t,n,o={}){e(t,n,{cwd:o.cwd,env:o.env,stdio:"ignore",detached:!0}).unref()}async function s(t,o,r={}){return new Promise((d,i)=>{let s=e(t,o,{cwd:r.cwd,env:r.env,stdio:["pipe","pipe","pipe"]}),u="",a="",c=r.binaryStdout?Buffer.alloc(0):void 0;r.binaryStdout||s.stdout.setEncoding("utf8"),s.stderr.setEncoding("utf8"),void 0!==r.stdin&&s.stdin.write(r.stdin),s.stdin.end(),s.stdout.on("data",e=>{if(r.binaryStdout){c=Buffer.concat([c??Buffer.alloc(0),Buffer.isBuffer(e)?e:Buffer.from(e)]);return}let t=String(e);u+=t,r.onStdoutChunk?.(t)}),s.stderr.on("data",e=>{let t=String(e);a+=t,r.onStderrChunk?.(t)}),s.on("error",e=>{"ENOENT"===e.code?i(new n("TOOL_MISSING",`${t} not found in PATH`,{cmd:t},e)):i(new n("COMMAND_FAILED",`Failed to run ${t}`,{cmd:t,args:o},e))}),s.on("close",e=>{let s=e??1;0===s||r.allowFailure?d({stdout:u,stderr:a,exitCode:s,stdoutBuffer:c}):i(new n("COMMAND_FAILED",`${t} exited with code ${s}`,{cmd:t,args:o,stdout:u,stderr:a,exitCode:s}))})})}function u(t,o,r={}){let d=e(t,o,{cwd:r.cwd,env:r.env,stdio:["ignore","pipe","pipe"]}),i="",s="";d.stdout.setEncoding("utf8"),d.stderr.setEncoding("utf8"),d.stdout.on("data",e=>{i+=e}),d.stderr.on("data",e=>{s+=e});let a=new Promise((e,u)=>{d.on("error",e=>{"ENOENT"===e.code?u(new n("TOOL_MISSING",`${t} not found in PATH`,{cmd:t},e)):u(new n("COMMAND_FAILED",`Failed to run ${t}`,{cmd:t,args:o},e))}),d.on("close",d=>{let a=d??1;0===a||r.allowFailure?e({stdout:i,stderr:s,exitCode:a}):u(new n("COMMAND_FAILED",`${t} exited with code ${a}`,{cmd:t,args:o,stdout:i,stderr:s,exitCode:a}))})});return{child:d,wait:a}}export{fileURLToPath,pathToFileURL}from"node:url";export{default as node_net}from"node:net";export{default as node_fs,promises}from"node:fs";export{default as node_os}from"node:os";export{default as node_path}from"node:path";export{o as asAppError,n as errors_AppError,r as runCmd,u as runCmdBackground,i as runCmdDetached,s as runCmdStreaming,d as whichCmd};