flow-walker-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runner.ts ADDED
@@ -0,0 +1,391 @@
1
+ // Flow executor: runs YAML flows via agent-flutter
2
+
3
+ import { join } from 'node:path';
4
+ import { writeFileSync } from 'node:fs';
5
+ import type { Flow, FlowStep, SnapshotElement } from './types.ts';
6
+ import { AgentBridge } from './agent-bridge.ts';
7
+ import { generateRunId, type RunResult, type StepResult } from './run-schema.ts';
8
+ import { screenshot as captureScreenshot, startRecording, stopRecording, startLogcat, stopLogcat, getDeviceName, ensureDir } from './capture.ts';
9
+
10
+ export interface RunOptions {
11
+ outputDir: string;
12
+ noVideo?: boolean;
13
+ noLogs?: boolean;
14
+ json?: boolean;
15
+ agentFlutterPath?: string;
16
+ }
17
+
18
+ /** Execute a flow and produce a RunResult */
19
+ export async function runFlow(flow: Flow, options: RunOptions): Promise<RunResult> {
20
+ const bridge = new AgentBridge(options.agentFlutterPath ?? 'agent-flutter');
21
+ const runId = generateRunId();
22
+
23
+ // Append run ID to output dir so multiple runs don't overwrite each other
24
+ const outputDir = join(options.outputDir, runId);
25
+ ensureDir(outputDir);
26
+
27
+ const device = getDeviceName();
28
+ const startedAt = new Date().toISOString();
29
+ const t0 = Date.now();
30
+ const steps: StepResult[] = [];
31
+
32
+ // Start video recording
33
+ let videoHandle: ReturnType<typeof startRecording> | null = null;
34
+ if (!options.noVideo) {
35
+ try {
36
+ videoHandle = startRecording();
37
+ } catch { /* warn but continue */ }
38
+ }
39
+
40
+ // Start logcat
41
+ let logHandle: ReturnType<typeof startLogcat> | null = null;
42
+ if (!options.noLogs) {
43
+ try {
44
+ logHandle = startLogcat();
45
+ } catch { /* warn but continue */ }
46
+ }
47
+
48
+ // Execute each step
49
+ for (let i = 0; i < flow.steps.length; i++) {
50
+ const step = flow.steps[i];
51
+ const stepStart = Date.now();
52
+ const timestamp = stepStart - t0;
53
+
54
+ try {
55
+ const result = await executeStep(step, bridge, outputDir, i + 1);
56
+ steps.push({
57
+ ...result,
58
+ index: i,
59
+ timestamp,
60
+ duration: Date.now() - stepStart,
61
+ });
62
+ } catch (err) {
63
+ // Step failed — mark and continue
64
+ const snapshot = await safeSnapshot(bridge);
65
+ steps.push({
66
+ index: i,
67
+ name: step.name,
68
+ action: getStepAction(step),
69
+ status: 'fail',
70
+ timestamp,
71
+ duration: Date.now() - stepStart,
72
+ elementCount: snapshot.length,
73
+ error: String(err),
74
+ });
75
+ }
76
+
77
+ if (!options.json) {
78
+ const s = steps[steps.length - 1];
79
+ const icon = s.status === 'pass' ? '✓' : s.status === 'fail' ? '✗' : '○';
80
+ console.log(` ${icon} Step ${i + 1}: ${s.name} [${s.status}] (${s.duration}ms, ${s.elementCount} elements)`);
81
+ }
82
+ }
83
+
84
+ const duration = Date.now() - t0;
85
+
86
+ // Stop video
87
+ let videoPath: string | undefined;
88
+ if (videoHandle) {
89
+ const localVideo = join(outputDir, 'recording.mp4');
90
+ if (stopRecording(videoHandle, localVideo)) {
91
+ videoPath = 'recording.mp4';
92
+ }
93
+ }
94
+
95
+ // Stop logcat
96
+ let logPath: string | undefined;
97
+ if (logHandle) {
98
+ const logLines = stopLogcat(logHandle);
99
+ if (logLines.length > 0) {
100
+ const localLog = join(outputDir, 'device.log');
101
+ writeFileSync(localLog, logLines.join('\n'));
102
+ logPath = 'device.log';
103
+ }
104
+ }
105
+
106
+ const overallResult = steps.every(s => s.status !== 'fail') ? 'pass' as const : 'fail' as const;
107
+
108
+ const runResult: RunResult = {
109
+ id: runId,
110
+ flow: flow.name,
111
+ ...(flow.app ? { app: flow.app } : {}),
112
+ ...(flow.appUrl ? { appUrl: flow.appUrl } : {}),
113
+ device,
114
+ startedAt,
115
+ duration,
116
+ result: overallResult,
117
+ steps,
118
+ video: videoPath,
119
+ log: logPath,
120
+ };
121
+
122
+ // Write run.json
123
+ const runJsonPath = join(outputDir, 'run.json');
124
+ writeFileSync(runJsonPath, JSON.stringify(runResult, null, 2));
125
+
126
+ return runResult;
127
+ }
128
+
129
+ /** Execute a single step */
130
+ async function executeStep(
131
+ step: FlowStep,
132
+ bridge: AgentBridge,
133
+ outputDir: string,
134
+ stepNum: number,
135
+ ): Promise<StepResult> {
136
+ const action = getStepAction(step);
137
+ let elements = await safeSnapshot(bridge);
138
+
139
+ // Execute the action
140
+ if (step.press) {
141
+ const target = resolvePress(step.press, elements);
142
+ if (target) {
143
+ await bridge.press(target.ref);
144
+ await delay(1500); // wait for transition
145
+ elements = await safeSnapshot(bridge);
146
+ } else {
147
+ return {
148
+ index: 0, // filled by caller
149
+ name: step.name,
150
+ action,
151
+ status: 'fail',
152
+ timestamp: 0,
153
+ duration: 0,
154
+ elementCount: elements.length,
155
+ error: `Could not resolve press target: ${JSON.stringify(step.press)}`,
156
+ };
157
+ }
158
+ } else if (step.scroll) {
159
+ await bridge.scroll(step.scroll);
160
+ await delay(1000);
161
+ elements = await safeSnapshot(bridge);
162
+ } else if (step.fill) {
163
+ const target = resolveFill(step.fill, elements);
164
+ if (target) {
165
+ await bridge.fill(target.ref, step.fill.value);
166
+ await delay(500);
167
+ elements = await safeSnapshot(bridge);
168
+ } else {
169
+ return {
170
+ index: 0, // filled by caller
171
+ name: step.name,
172
+ action,
173
+ status: 'fail',
174
+ timestamp: 0,
175
+ duration: 0,
176
+ elementCount: elements.length,
177
+ error: `Could not resolve fill target: ${JSON.stringify(step.fill)}`,
178
+ };
179
+ }
180
+ } else if (step.back) {
181
+ await bridge.back();
182
+ await delay(1500);
183
+ elements = await safeSnapshot(bridge);
184
+ }
185
+
186
+ // Take screenshot
187
+ let screenshotPath: string | undefined;
188
+ if (step.screenshot) {
189
+ const filename = `step-${stepNum}-${step.screenshot}.png`;
190
+ const fullPath = join(outputDir, filename);
191
+ if (captureScreenshot(fullPath)) {
192
+ screenshotPath = filename;
193
+ }
194
+ }
195
+
196
+ // Check assertions
197
+ let status: 'pass' | 'fail' = 'pass';
198
+ let assertion: StepResult['assertion'];
199
+
200
+ if (step.assert) {
201
+ assertion = {};
202
+ if (step.assert.interactive_count) {
203
+ const actual = elements.length;
204
+ const min = step.assert.interactive_count.min;
205
+ assertion.interactive_count = { min, actual };
206
+ if (actual < min) status = 'fail';
207
+ }
208
+ if (step.assert.bottom_nav_tabs) {
209
+ const navTabs = elements.filter(e =>
210
+ e.flutterType === 'InkWell' && e.bounds && e.bounds.y > 780
211
+ );
212
+ const actual = navTabs.length;
213
+ const min = step.assert.bottom_nav_tabs.min;
214
+ assertion.bottom_nav_tabs = { min, actual };
215
+ if (actual < min) status = 'fail';
216
+ }
217
+ if (step.assert.has_type) {
218
+ const searchType = step.assert.has_type.type.toLowerCase();
219
+ const matching = elements.filter(e =>
220
+ e.type === searchType || e.flutterType?.toLowerCase().includes(searchType)
221
+ );
222
+ const actual = matching.length;
223
+ const min = step.assert.has_type.min ?? 1;
224
+ assertion.has_type = { type: step.assert.has_type.type, min, actual };
225
+ if (actual < min) status = 'fail';
226
+ }
227
+ if (step.assert.text_visible || step.assert.text_not_visible) {
228
+ // Fetch all visible text from UIAutomator accessibility layer
229
+ const screenTexts = bridge.text();
230
+
231
+ if (step.assert.text_visible) {
232
+ const missing: string[] = [];
233
+ const found: string[] = [];
234
+ for (const query of step.assert.text_visible) {
235
+ const lowerQuery = query.toLowerCase();
236
+ const match = screenTexts.find(t => t.toLowerCase().includes(lowerQuery));
237
+ if (match) {
238
+ found.push(query);
239
+ } else {
240
+ missing.push(query);
241
+ status = 'fail';
242
+ }
243
+ }
244
+ assertion.text_visible = { expected: step.assert.text_visible, found, missing };
245
+ }
246
+
247
+ if (step.assert.text_not_visible) {
248
+ const unexpected: string[] = [];
249
+ const absent: string[] = [];
250
+ for (const query of step.assert.text_not_visible) {
251
+ const lowerQuery = query.toLowerCase();
252
+ const match = screenTexts.find(t => t.toLowerCase().includes(lowerQuery));
253
+ if (match) {
254
+ unexpected.push(query);
255
+ status = 'fail';
256
+ } else {
257
+ absent.push(query);
258
+ }
259
+ }
260
+ assertion.text_not_visible = { expected_absent: step.assert.text_not_visible, absent, unexpected };
261
+ }
262
+ }
263
+ }
264
+
265
+ return {
266
+ index: 0, // filled by caller
267
+ name: step.name,
268
+ action,
269
+ status,
270
+ timestamp: 0, // filled by caller
271
+ duration: 0, // filled by caller
272
+ elementCount: elements.length,
273
+ screenshot: screenshotPath,
274
+ assertion,
275
+ };
276
+ }
277
+
278
+ /** Resolve a press target from the snapshot */
279
+ export function resolvePress(
280
+ press: NonNullable<FlowStep['press']>,
281
+ elements: SnapshotElement[],
282
+ ): SnapshotElement | null {
283
+ if (press.ref) {
284
+ return elements.find(e => e.ref === press.ref) ?? null;
285
+ }
286
+
287
+ if (press.bottom_nav_tab !== undefined) {
288
+ const navItems = elements
289
+ .filter(e => e.flutterType === 'InkWell' && e.bounds && e.bounds.y > 780)
290
+ .sort((a, b) => (a.bounds?.x ?? 0) - (b.bounds?.x ?? 0));
291
+ return navItems[press.bottom_nav_tab] ?? null;
292
+ }
293
+
294
+ if (press.type) {
295
+ const typeMatches = elements.filter(e =>
296
+ e.type === press.type || e.flutterType?.toLowerCase().includes(press.type!.toLowerCase())
297
+ );
298
+
299
+ if (press.position === 'rightmost') {
300
+ return typeMatches.sort((a, b) => (b.bounds?.x ?? 0) - (a.bounds?.x ?? 0))[0] ?? null;
301
+ }
302
+ if (press.position === 'leftmost') {
303
+ return typeMatches.sort((a, b) => (a.bounds?.x ?? 0) - (b.bounds?.x ?? 0))[0] ?? null;
304
+ }
305
+
306
+ return typeMatches[0] ?? null;
307
+ }
308
+
309
+ return null;
310
+ }
311
+
312
+ /** Resolve a fill target from the snapshot */
313
+ export function resolveFill(
314
+ fill: NonNullable<FlowStep['fill']>,
315
+ elements: SnapshotElement[],
316
+ ): SnapshotElement | null {
317
+ if (fill.type) {
318
+ return elements.find(e =>
319
+ e.type === fill.type || e.type === 'textfield' || e.flutterType === 'TextField'
320
+ ) ?? null;
321
+ }
322
+ return elements.find(e => e.type === 'textfield' || e.flutterType === 'TextField') ?? null;
323
+ }
324
+
325
+ export function getStepAction(step: FlowStep): string {
326
+ if (step.press) return 'press';
327
+ if (step.scroll) return 'scroll';
328
+ if (step.fill) return 'fill';
329
+ if (step.back) return 'back';
330
+ if (step.assert) return 'assert';
331
+ if (step.screenshot) return 'screenshot';
332
+ return 'unknown';
333
+ }
334
+
335
+ /** Dry-run: parse flow and resolve step targets without executing */
336
+ export async function dryRunFlow(flow: Flow, agentFlutterPath: string = 'agent-flutter'): Promise<{
337
+ flow: string;
338
+ steps: { index: number; name: string; action: string; target: unknown; resolved: boolean; reason?: string }[];
339
+ dryRun: true;
340
+ }> {
341
+ const bridge = new AgentBridge(agentFlutterPath);
342
+ let elements: SnapshotElement[] = [];
343
+ try {
344
+ elements = await safeSnapshot(bridge);
345
+ } catch { /* no device = empty snapshot */ }
346
+
347
+ const hasDevice = elements.length > 0;
348
+
349
+ const steps = flow.steps.map((step, i) => {
350
+ const action = getStepAction(step);
351
+ let target: unknown = null;
352
+ let resolved = true;
353
+ let reason: string | undefined;
354
+
355
+ if (step.press) {
356
+ const el = resolvePress(step.press, elements);
357
+ target = el ? { ref: el.ref, type: el.type, text: el.text } : step.press;
358
+ resolved = el !== null;
359
+ if (!resolved) reason = hasDevice ? 'element not found in current snapshot' : 'no device connected for snapshot';
360
+ } else if (step.fill) {
361
+ const el = resolveFill(step.fill, elements);
362
+ target = el ? { ref: el.ref, type: el.type } : step.fill;
363
+ resolved = el !== null;
364
+ if (!resolved) reason = hasDevice ? 'textfield not found in current snapshot' : 'no device connected for snapshot';
365
+ } else if (step.scroll) {
366
+ target = { direction: step.scroll };
367
+ } else if (step.back) {
368
+ target = { back: true };
369
+ } else if (step.assert) {
370
+ target = step.assert;
371
+ // Assertions always "resolve" — they check conditions at runtime
372
+ }
373
+
374
+ return { index: i, name: step.name, action, target, resolved, ...(reason ? { reason } : {}) };
375
+ });
376
+
377
+ return { flow: flow.name, steps, dryRun: true };
378
+ }
379
+
380
+ async function safeSnapshot(bridge: AgentBridge): Promise<SnapshotElement[]> {
381
+ try {
382
+ const snapshot = await bridge.snapshot();
383
+ return snapshot.elements;
384
+ } catch {
385
+ return [];
386
+ }
387
+ }
388
+
389
+ function delay(ms: number): Promise<void> {
390
+ return new Promise(resolve => setTimeout(resolve, ms));
391
+ }
package/src/safety.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { SnapshotElement } from './types.ts';
2
+
3
+ const DEFAULT_BLOCKLIST = [
4
+ 'delete', 'sign out', 'remove', 'reset', 'unpair', 'logout', 'clear all',
5
+ 'delete account', 'factory reset', 'erase', 'uninstall',
6
+ ];
7
+
8
+ /**
9
+ * Check if an element is safe to press.
10
+ * Returns { safe: true } or { safe: false, reason: string }.
11
+ */
12
+ export function isSafe(
13
+ element: SnapshotElement,
14
+ nearbyElements: SnapshotElement[],
15
+ blocklist: string[] = DEFAULT_BLOCKLIST,
16
+ ): { safe: boolean; reason?: string } {
17
+ // Skip disabled elements
18
+ if (element.enabled === false) {
19
+ return { safe: false, reason: 'element is disabled' };
20
+ }
21
+
22
+ // Check element's own text against blocklist
23
+ const elementText = element.text.toLowerCase();
24
+ for (const keyword of blocklist) {
25
+ if (elementText.includes(keyword.toLowerCase())) {
26
+ return { safe: false, reason: `text matches blocklist: "${keyword}"` };
27
+ }
28
+ }
29
+
30
+ // Check nearby elements' text for context clues
31
+ // "nearby" = elements within a small vertical range
32
+ if (element.bounds) {
33
+ const nearbyTexts = nearbyElements
34
+ .filter(el => {
35
+ if (!el.bounds || el.ref === element.ref) return false;
36
+ const verticalDist = Math.abs(el.bounds.y - element.bounds!.y);
37
+ return verticalDist < 60; // within 60px vertically
38
+ })
39
+ .map(el => el.text.toLowerCase());
40
+
41
+ for (const text of nearbyTexts) {
42
+ for (const keyword of blocklist) {
43
+ if (text.includes(keyword.toLowerCase())) {
44
+ return { safe: false, reason: `nearby text matches blocklist: "${keyword}"` };
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ return { safe: true };
51
+ }
52
+
53
+ /**
54
+ * Filter a list of elements to only safe-to-press ones.
55
+ * Returns [safeElements, skippedElements].
56
+ */
57
+ export function filterSafe(
58
+ elements: SnapshotElement[],
59
+ blocklist: string[] = DEFAULT_BLOCKLIST,
60
+ ): [SnapshotElement[], Array<{ element: SnapshotElement; reason: string }>] {
61
+ const safe: SnapshotElement[] = [];
62
+ const skipped: Array<{ element: SnapshotElement; reason: string }> = [];
63
+
64
+ for (const el of elements) {
65
+ const result = isSafe(el, elements, blocklist);
66
+ if (result.safe) {
67
+ safe.push(el);
68
+ } else {
69
+ skipped.push({ element: el, reason: result.reason! });
70
+ }
71
+ }
72
+
73
+ return [safe, skipped];
74
+ }
package/src/types.ts ADDED
@@ -0,0 +1,82 @@
1
+ // Shared types for flow-walker
2
+
3
+ /** An interactive element from agent-flutter snapshot */
4
+ export interface SnapshotElement {
5
+ ref: string; // e.g. "@e1"
6
+ type: string; // e.g. "button", "textfield", "gesture"
7
+ text: string; // label/text content
8
+ flutterType?: string; // e.g. "ElevatedButton", "InkWell"
9
+ enabled?: boolean;
10
+ bounds?: { x: number; y: number; width: number; height: number };
11
+ }
12
+
13
+ /** A snapshot of a screen's interactive elements */
14
+ export interface ScreenSnapshot {
15
+ elements: SnapshotElement[];
16
+ raw?: string; // raw agent-flutter output
17
+ }
18
+
19
+ /** A node in the navigation graph */
20
+ export interface ScreenNode {
21
+ id: string; // fingerprint hash
22
+ name: string; // human-readable name derived from elements
23
+ elementTypes: string[]; // sorted type list used for fingerprint
24
+ elementCount: number;
25
+ firstSeen: number; // timestamp
26
+ visits: number;
27
+ }
28
+
29
+ /** An edge in the navigation graph */
30
+ export interface ScreenEdge {
31
+ source: string; // source screen fingerprint
32
+ target: string; // target screen fingerprint
33
+ element: {
34
+ ref: string;
35
+ type: string;
36
+ text: string;
37
+ };
38
+ }
39
+
40
+ /** A step in a YAML flow */
41
+ export interface FlowStep {
42
+ name: string;
43
+ press?: { type?: string; position?: string; hint?: string; bottom_nav_tab?: number; ref?: string };
44
+ scroll?: string;
45
+ fill?: { type?: string; value: string };
46
+ back?: boolean;
47
+ assert?: {
48
+ interactive_count?: { min: number; verified?: string };
49
+ bottom_nav_tabs?: { min: number };
50
+ has_type?: { type: string; min?: number };
51
+ text?: string;
52
+ text_visible?: string[];
53
+ text_not_visible?: string[];
54
+ };
55
+ screenshot?: string;
56
+ note?: string;
57
+ }
58
+
59
+ /** A complete YAML flow */
60
+ export interface Flow {
61
+ name: string;
62
+ description: string;
63
+ app?: string;
64
+ appUrl?: string;
65
+ covers?: string[];
66
+ prerequisites?: string[];
67
+ setup: string;
68
+ steps: FlowStep[];
69
+ }
70
+
71
+ /** Walker configuration */
72
+ export interface WalkerConfig {
73
+ appUri?: string;
74
+ bundleId?: string;
75
+ maxDepth: number;
76
+ outputDir: string;
77
+ blocklist: string[];
78
+ json: boolean;
79
+ dryRun: boolean;
80
+ agentFlutterPath: string;
81
+ skipConnect: boolean;
82
+ }
@@ -0,0 +1,115 @@
1
+ // Input validation for flow-walker
2
+ // "Agents hallucinate. Build like it." — validate all inputs before dispatch.
3
+
4
+ import { existsSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { FlowWalkerError, ErrorCodes } from './errors.ts';
7
+
8
+ /** Reject strings containing ASCII control characters (except \n and \t) */
9
+ export function rejectControlChars(str: string, fieldName: string): void {
10
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(str)) {
11
+ throw new FlowWalkerError(
12
+ ErrorCodes.INVALID_INPUT,
13
+ `${fieldName} contains invalid control characters`,
14
+ `Remove ASCII control characters (\\n and \\t are allowed)`,
15
+ );
16
+ }
17
+ }
18
+
19
+ /** Reject path traversal attempts */
20
+ function rejectPathTraversal(path: string, fieldName: string): void {
21
+ const normalized = resolve(path);
22
+ if (path.includes('..')) {
23
+ throw new FlowWalkerError(
24
+ ErrorCodes.INVALID_INPUT,
25
+ `${fieldName} contains path traversal (..)`,
26
+ `Use an absolute path or a path relative to cwd without ..`,
27
+ );
28
+ }
29
+ }
30
+
31
+ /** Validate a YAML flow file path */
32
+ export function validateFlowPath(path: string): void {
33
+ rejectControlChars(path, 'Flow path');
34
+ rejectPathTraversal(path, 'Flow path');
35
+
36
+ if (!path.endsWith('.yaml') && !path.endsWith('.yml')) {
37
+ throw new FlowWalkerError(
38
+ ErrorCodes.INVALID_INPUT,
39
+ `Flow path must end in .yaml or .yml: ${path}`,
40
+ `Provide a YAML flow file. Run: flow-walker schema run`,
41
+ );
42
+ }
43
+
44
+ if (!existsSync(path)) {
45
+ throw new FlowWalkerError(
46
+ ErrorCodes.FILE_NOT_FOUND,
47
+ `Flow file not found: ${path}`,
48
+ `Check the path and try again. Run: ls ${path}`,
49
+ );
50
+ }
51
+ }
52
+
53
+ /** Validate an output directory path */
54
+ export function validateOutputDir(dir: string): void {
55
+ rejectControlChars(dir, 'Output directory');
56
+ rejectPathTraversal(dir, 'Output directory');
57
+ }
58
+
59
+ /** Validate a VM Service WebSocket URI */
60
+ export function validateUri(uri: string): void {
61
+ rejectControlChars(uri, 'URI');
62
+
63
+ if (!uri.startsWith('ws://') && !uri.startsWith('wss://')) {
64
+ throw new FlowWalkerError(
65
+ ErrorCodes.INVALID_INPUT,
66
+ `URI must start with ws:// or wss://: ${uri}`,
67
+ `Example: ws://127.0.0.1:38047/abc=/ws`,
68
+ );
69
+ }
70
+
71
+ try {
72
+ new URL(uri);
73
+ } catch {
74
+ throw new FlowWalkerError(
75
+ ErrorCodes.INVALID_INPUT,
76
+ `Invalid URI format: ${uri}`,
77
+ `Provide a valid WebSocket URI. Example: ws://127.0.0.1:38047/abc=/ws`,
78
+ );
79
+ }
80
+ }
81
+
82
+ /** Validate a bundle ID (reverse-domain format) */
83
+ export function validateBundleId(id: string): void {
84
+ rejectControlChars(id, 'Bundle ID');
85
+
86
+ if (!/^[a-zA-Z][a-zA-Z0-9._-]*(\.[a-zA-Z][a-zA-Z0-9._-]*)+$/.test(id)) {
87
+ throw new FlowWalkerError(
88
+ ErrorCodes.INVALID_INPUT,
89
+ `Invalid bundle ID format: ${id}`,
90
+ `Bundle ID must be reverse-domain format. Example: com.example.app`,
91
+ );
92
+ }
93
+ }
94
+
95
+ /** Validate a run directory exists and contains run.json */
96
+ export function validateRunDir(dir: string): void {
97
+ rejectControlChars(dir, 'Run directory');
98
+ rejectPathTraversal(dir, 'Run directory');
99
+
100
+ if (!existsSync(dir)) {
101
+ throw new FlowWalkerError(
102
+ ErrorCodes.FILE_NOT_FOUND,
103
+ `Run directory not found: ${dir}`,
104
+ `Provide the output directory from a previous flow-walker run`,
105
+ );
106
+ }
107
+
108
+ if (!existsSync(`${dir}/run.json`)) {
109
+ throw new FlowWalkerError(
110
+ ErrorCodes.FILE_NOT_FOUND,
111
+ `run.json not found in ${dir}`,
112
+ `Run a flow first: flow-walker run <flow.yaml> --output-dir ${dir}`,
113
+ );
114
+ }
115
+ }