From bffbb6f302b6aa7debdfdd56d9a3e46aea3e9b0f Mon Sep 17 00:00:00 2001 From: thoroc Date: Sat, 31 Jan 2026 22:59:25 +0000 Subject: [PATCH 1/3] feat(dev): add development tools and custom executors - Add dev-proxy executor for plugin development workflow - Add check-mirror-exists executor for CI/CD validation - Independent infrastructure - no package dependencies - Enables development workflows across the monorepo These executors provide essential tooling for plugin development and CI/CD pipeline validation. --- .../executors/check-mirror-exists/executor.ts | 1 - .../check-mirror-exists/project.json | 9 ++++ tools/executors/dev-proxy/executor.test.ts | 35 ++++++++----- tools/executors/dev-proxy/executor.ts | 49 ++++++++++++------- tools/executors/dev-proxy/project.json | 9 ++++ tools/executors/dev-proxy/schema.json | 10 ++++ 6 files changed, 82 insertions(+), 31 deletions(-) diff --git a/tools/executors/check-mirror-exists/executor.ts b/tools/executors/check-mirror-exists/executor.ts index c77908a..52390dd 100644 --- a/tools/executors/check-mirror-exists/executor.ts +++ b/tools/executors/check-mirror-exists/executor.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Executor file with comprehensive error handling and documentation */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; diff --git a/tools/executors/check-mirror-exists/project.json b/tools/executors/check-mirror-exists/project.json index 416415c..73d0b38 100644 --- a/tools/executors/check-mirror-exists/project.json +++ b/tools/executors/check-mirror-exists/project.json @@ -4,6 +4,15 @@ "sourceRoot": "tools/executors/check-mirror-exists", "projectType": "library", "targets": { + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "biome check --write .", + "cwd": "tools/executors/check-mirror-exists" + }, + "cache": true, + "inputs": ["default", "{workspaceRoot}/biome.json"] + }, "type-check": { "executor": "nx:run-commands", "options": { diff --git a/tools/executors/dev-proxy/executor.test.ts b/tools/executors/dev-proxy/executor.test.ts index b3e6c2f..d92c8e9 100644 --- a/tools/executors/dev-proxy/executor.test.ts +++ b/tools/executors/dev-proxy/executor.test.ts @@ -1,3 +1,4 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import type { spawnSync } from 'node:child_process'; import type { ExecutorContext } from '@nx/devkit'; @@ -37,7 +38,7 @@ const _originalSpawn = childProcess.spawn; function _fakeSpawn(_cmd: string, _args: string[], _opts: any) { return { kill: () => {}, - on: (_ev: string, _cb: Function) => {}, + on: (_ev: string, _cb: (...args: unknown[]) => void) => {}, } as any; } @@ -51,7 +52,6 @@ beforeEach(() => { // stub process.exit so tests can simulate SIGINT without killing the test runner _originalExit = process.exit; _exitCalled = false; - // @ts-expect-error override for test process.exit = ((_code?: number) => { _exitCalled = true; // do not actually exit during tests @@ -83,17 +83,21 @@ describe('dev-proxy executor with mocked runExecutor', () => { // Snapshot existing SIGINT listeners const beforeListeners = process.listeners('SIGINT').slice(); - const resPromise = runExecutor( + const gen = runExecutor( { plugins: ['opencode-warcraft-notifications-plugin'], __runExecutor: mockRunExecutor, __spawnSync: mockSpawnSync, + __noExit: true, }, context, ); + // Start the generator to execute the executor body + const resPromise = gen.next(); + // Wait briefly to let executor start and attach iterator - await new Promise((r) => setTimeout(r, 50)); + await new Promise((r) => setTimeout(r, 100)); // Simulate SIGINT by sending the signal to the process process.emit('SIGINT' as any); @@ -104,9 +108,8 @@ describe('dev-proxy executor with mocked runExecutor', () => { expect(iterator._returned()).toBe(true); const res = await resPromise; - expect(res?.success).toBe(true); + expect(res?.value?.success).toBe(true); - // Restore SIGINT listeners to avoid side effects on other tests const afterListeners = process.listeners('SIGINT'); for (const l of afterListeners) { if (!beforeListeners.includes(l)) process.removeListener('SIGINT', l); @@ -124,13 +127,15 @@ describe('dev-proxy executor with mocked runExecutor', () => { const childProcess = require('node:child_process'); const originalSpawn = childProcess.spawn; - childProcess.spawn = (_cmd: string, _args: string[], _opts: any) => { + childProcess.spawn = (cmd: string, args: string[], _opts: any) => { _childSpawned = true; + console.log('[TEST] child_process.spawn called with:', cmd, args); // return a fake child with kill() return { kill: () => { childKilled = true; }, + // biome-ignore lint/complexity/noBannedTypes: Mock EventEmitter interface on: (_ev: string, _cb: Function) => {}, } as any; }; @@ -156,6 +161,7 @@ describe('dev-proxy executor with mocked runExecutor', () => { plugins: ['opencode-warcraft-notifications-plugin'], __runExecutor: mockRunExecutor, __spawnSync: mockSpawnSync, + __noExit: true, }, context, ); @@ -167,8 +173,8 @@ describe('dev-proxy executor with mocked runExecutor', () => { expect(childKilled).toBe(true); - const res = await resPromise; - expect(res?.success).toBe(true); + const res = await resPromise.next(); + expect(res?.value?.success).toBe(true); // restore spawn and listeners childProcess.spawn = originalSpawn; @@ -193,12 +199,15 @@ describe('dev-proxy executor with mocked runExecutor', () => { const beforeListeners = process.listeners('SIGINT').slice(); - const resPromise = runExecutor( - { plugins: ['pA', 'pB'], __runExecutor: mockRunExecutor, __spawnSync: mockSpawnSync }, + const gen = runExecutor( + { plugins: ['pA', 'pB'], __runExecutor: mockRunExecutor, __spawnSync: mockSpawnSync, __noExit: true }, context, ); - await new Promise((r) => setTimeout(r, 50)); + // Start the generator to execute the executor body + const resPromise = gen.next(); + + await new Promise((r) => setTimeout(r, 100)); process.emit('SIGINT' as any); await new Promise((r) => setTimeout(r, 50)); @@ -207,7 +216,7 @@ describe('dev-proxy executor with mocked runExecutor', () => { expect(iterB._returned()).toBe(true); const res = await resPromise; - expect(res?.success).toBe(true); + expect(res?.value?.success).toBe(true); const afterListeners = process.listeners('SIGINT'); for (const l of afterListeners) { diff --git a/tools/executors/dev-proxy/executor.ts b/tools/executors/dev-proxy/executor.ts index a80b79b..6005b52 100644 --- a/tools/executors/dev-proxy/executor.ts +++ b/tools/executors/dev-proxy/executor.ts @@ -8,6 +8,7 @@ interface DevProxyOptions { apply?: boolean; __runExecutor?: typeof nxRunExecutor; __spawnSync?: typeof spawnSync; + __noExit?: boolean; } interface ExecutorResult { @@ -24,10 +25,10 @@ interface ResolvedProject { * Nx executor for running dev proxy with build watchers * @param options - Executor options including plugin names and symlink configuration * @param context - Nx executor context - * @returns Executor result indicating success or failure + * @yields Executor results during execution */ // eslint-disable-next-line max-statements, complexity -const runExecutor = async (options: DevProxyOptions, context: ExecutorContext): Promise => { +async function* runExecutor(options: DevProxyOptions, context: ExecutorContext): AsyncGenerator { const workspaceRoot = context.root; const requestedPlugins = @@ -35,7 +36,8 @@ const runExecutor = async (options: DevProxyOptions, context: ExecutorContext): if (requestedPlugins.length === 0) { console.error('No project specified for dev-proxy (provide --plugins or run from a project context)'); - return { success: false }; + yield { success: false }; + return; } // Resolve projects - simplified to just use the names @@ -48,6 +50,8 @@ const runExecutor = async (options: DevProxyOptions, context: ExecutorContext): const runExecutorImpl = options.__runExecutor ?? nxRunExecutor; const spawnSyncImpl = options.__spawnSync ?? spawnSync; + console.log('dev-proxy: workspaceRoot=', workspaceRoot); + for (const r of resolved) { const projName = r.name; let started = false; @@ -60,23 +64,27 @@ const runExecutor = async (options: DevProxyOptions, context: ExecutorContext): context, ); - if (iterator && Symbol.asyncIterator in iterator) { - (async () => { - try { - for await (const out of iterator) { - if (!out || !out.success) console.error(`Build for ${projName} reported failure`); + if (iterator) { + const it = iterator as AsyncIterable<{ success: boolean }> & { return?: () => Promise }; + if (Symbol.asyncIterator in it) { + (async () => { + try { + for await (const out of it) { + if (!out || !out.success) console.error(`Build for ${projName} reported failure`); + } + } catch (err) { + console.error(`runExecutor iterator error for ${projName}:`, err); } - } catch (err) { - console.error(`runExecutor iterator error for ${projName}:`, err); - } - })(); + })(); + } stopFns.push(async () => { try { - if (typeof iterator.return === 'function') await iterator.return(); + if (typeof it.return === 'function') await it.return(); } catch { // Failed to stop iterator } }); + console.log(`Started build target for ${projName} via @nx/devkit.runExecutor`); started = true; } } catch (err) { @@ -86,6 +94,7 @@ const runExecutor = async (options: DevProxyOptions, context: ExecutorContext): if (!started) { try { + console.log(`Falling back to CLI watcher for ${projName}`); const child = spawn('bunx', ['nx', 'run', `${projName}:build`, '--watch'], { stdio: 'inherit', cwd: workspaceRoot, @@ -110,11 +119,14 @@ const runExecutor = async (options: DevProxyOptions, context: ExecutorContext): if (options.apply === false) args.push('--no-apply'); args.push(...requestedPlugins); + console.log('Running dev proxy runtime:', ['bunx', 'tsx', script, ...args].join(' ')); + // Ensure cleanup on SIGINT let exiting = false; const sigintHandler = async () => { if (exiting) return; exiting = true; + console.log('\nInterrupted. Stopping build watchers and exiting...'); for (const fn of stopFns) { try { await fn(); @@ -122,7 +134,9 @@ const runExecutor = async (options: DevProxyOptions, context: ExecutorContext): // Failed to stop watcher } } - process.exit(0); + if (!options.__noExit) { + process.exit(0); + } }; process.on('SIGINT', sigintHandler); @@ -140,9 +154,10 @@ const runExecutor = async (options: DevProxyOptions, context: ExecutorContext): if (res?.error) { console.error('Failed to run dev proxy runtime', res.error); - return { success: false }; + yield { success: false }; + return; } - return { success: res?.status === 0 }; -}; + yield { success: res?.status === 0 }; +} export default runExecutor; diff --git a/tools/executors/dev-proxy/project.json b/tools/executors/dev-proxy/project.json index c7fec15..ab9bdde 100644 --- a/tools/executors/dev-proxy/project.json +++ b/tools/executors/dev-proxy/project.json @@ -4,6 +4,15 @@ "sourceRoot": "tools/executors/dev-proxy", "projectType": "library", "targets": { + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "biome check --write .", + "cwd": "tools/executors/dev-proxy" + }, + "cache": true, + "inputs": ["default", "{workspaceRoot}/biome.json"] + }, "type-check": { "executor": "nx:run-commands", "options": { diff --git a/tools/executors/dev-proxy/schema.json b/tools/executors/dev-proxy/schema.json index 27f0e54..96549a8 100644 --- a/tools/executors/dev-proxy/schema.json +++ b/tools/executors/dev-proxy/schema.json @@ -16,6 +16,16 @@ "type": "boolean", "description": "Whether to apply filesystem symlink changes. Set to false for a dry-run.", "default": true + }, + "__runExecutor": { + "description": "Internal: mock implementation for testing (do not use)" + }, + "__spawnSync": { + "description": "Internal: mock implementation for testing (do not use)" + }, + "__noExit": { + "type": "boolean", + "description": "Internal: prevent process exit for testing (do not use)" } }, "required": [], From cc8469a83e11b021cb5ce003a3e754db038477c7 Mon Sep 17 00:00:00 2001 From: thoroc Date: Tue, 3 Feb 2026 10:10:01 +0000 Subject: [PATCH 2/3] fix(dev-proxy): fix skipped test and resolve Biome lint issues --- tools/executors/dev-proxy/executor.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tools/executors/dev-proxy/executor.test.ts b/tools/executors/dev-proxy/executor.test.ts index d92c8e9..7541bae 100644 --- a/tools/executors/dev-proxy/executor.test.ts +++ b/tools/executors/dev-proxy/executor.test.ts @@ -129,14 +129,14 @@ describe('dev-proxy executor with mocked runExecutor', () => { const originalSpawn = childProcess.spawn; childProcess.spawn = (cmd: string, args: string[], _opts: any) => { _childSpawned = true; + // biome-ignore lint/suspicious/noConsole: Debug logging for test console.log('[TEST] child_process.spawn called with:', cmd, args); // return a fake child with kill() return { kill: () => { childKilled = true; }, - // biome-ignore lint/complexity/noBannedTypes: Mock EventEmitter interface - on: (_ev: string, _cb: Function) => {}, + on: (_ev: string, _cb: (...args: unknown[]) => void) => {}, } as any; }; @@ -156,7 +156,7 @@ describe('dev-proxy executor with mocked runExecutor', () => { const beforeListeners = process.listeners('SIGINT').slice(); - const resPromise = runExecutor( + const gen = runExecutor( { plugins: ['opencode-warcraft-notifications-plugin'], __runExecutor: mockRunExecutor, @@ -166,6 +166,9 @@ describe('dev-proxy executor with mocked runExecutor', () => { context, ); + // Start the generator to execute the executor body + const resPromise = gen.next(); + await new Promise((r) => setTimeout(r, 100)); process.emit('SIGINT' as any); @@ -173,7 +176,7 @@ describe('dev-proxy executor with mocked runExecutor', () => { expect(childKilled).toBe(true); - const res = await resPromise.next(); + const res = await resPromise; expect(res?.value?.success).toBe(true); // restore spawn and listeners From 8d09cea5acce61cc0adddf425b7246297b27ea3b Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Tue, 3 Feb 2026 10:18:56 +0000 Subject: [PATCH 3/3] fix(dev-proxy): address PR comments - change console.log to console.info and remove debug logging --- .emdash.json | 10 ++ tools/executors/dev-proxy/executor.test.ts | 4 +- tools/executors/dev-proxy/executor.ts | 10 +- tools/executors/dev-proxy/executor.ts.bak | 163 ++++++++++++++++++++ tools/executors/dev-proxy/executor.ts.bak3 | 168 +++++++++++++++++++++ 5 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 .emdash.json create mode 100644 tools/executors/dev-proxy/executor.ts.bak create mode 100644 tools/executors/dev-proxy/executor.ts.bak3 diff --git a/.emdash.json b/.emdash.json new file mode 100644 index 0000000..4d9702d --- /dev/null +++ b/.emdash.json @@ -0,0 +1,10 @@ +{ + "preservePatterns": [ + ".env", + ".env.keys", + ".env.local", + ".env.*.local", + ".envrc", + "docker-compose.override.yml" + ] +} diff --git a/tools/executors/dev-proxy/executor.test.ts b/tools/executors/dev-proxy/executor.test.ts index 16ee306..08fdd97 100644 --- a/tools/executors/dev-proxy/executor.test.ts +++ b/tools/executors/dev-proxy/executor.test.ts @@ -131,10 +131,8 @@ describe('dev-proxy executor with mocked runExecutor', () => { const childProcess = require('node:child_process'); const originalSpawn = childProcess.spawn; - childProcess.spawn = (cmd: string, args: string[], _opts: any) => { + childProcess.spawn = (_cmd: string, _args: string[], _opts: any) => { _childSpawned = true; - // biome-ignore lint/suspicious/noConsole: Debug logging for test - console.log('[TEST] child_process.spawn called with:', cmd, args); // return a fake child with kill() return { kill: () => { diff --git a/tools/executors/dev-proxy/executor.ts b/tools/executors/dev-proxy/executor.ts index 6005b52..68cf786 100644 --- a/tools/executors/dev-proxy/executor.ts +++ b/tools/executors/dev-proxy/executor.ts @@ -50,7 +50,7 @@ async function* runExecutor(options: DevProxyOptions, context: ExecutorContext): const runExecutorImpl = options.__runExecutor ?? nxRunExecutor; const spawnSyncImpl = options.__spawnSync ?? spawnSync; - console.log('dev-proxy: workspaceRoot=', workspaceRoot); + console.info('dev-proxy: workspaceRoot=', workspaceRoot); for (const r of resolved) { const projName = r.name; @@ -84,7 +84,7 @@ async function* runExecutor(options: DevProxyOptions, context: ExecutorContext): // Failed to stop iterator } }); - console.log(`Started build target for ${projName} via @nx/devkit.runExecutor`); + console.info(`Started build target for ${projName} via @nx/devkit.runExecutor`); started = true; } } catch (err) { @@ -94,7 +94,7 @@ async function* runExecutor(options: DevProxyOptions, context: ExecutorContext): if (!started) { try { - console.log(`Falling back to CLI watcher for ${projName}`); + console.info(`Falling back to CLI watcher for ${projName}`); const child = spawn('bunx', ['nx', 'run', `${projName}:build`, '--watch'], { stdio: 'inherit', cwd: workspaceRoot, @@ -119,14 +119,14 @@ async function* runExecutor(options: DevProxyOptions, context: ExecutorContext): if (options.apply === false) args.push('--no-apply'); args.push(...requestedPlugins); - console.log('Running dev proxy runtime:', ['bunx', 'tsx', script, ...args].join(' ')); + console.info('Running dev proxy runtime:', ['bunx', 'tsx', script, ...args].join(' ')); // Ensure cleanup on SIGINT let exiting = false; const sigintHandler = async () => { if (exiting) return; exiting = true; - console.log('\nInterrupted. Stopping build watchers and exiting...'); + console.info('\nInterrupted. Stopping build watchers and exiting...'); for (const fn of stopFns) { try { await fn(); diff --git a/tools/executors/dev-proxy/executor.ts.bak b/tools/executors/dev-proxy/executor.ts.bak new file mode 100644 index 0000000..68cf786 --- /dev/null +++ b/tools/executors/dev-proxy/executor.ts.bak @@ -0,0 +1,163 @@ +import { spawn, spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { type ExecutorContext, runExecutor as nxRunExecutor, type ProjectConfiguration } from '@nx/devkit'; + +interface DevProxyOptions { + plugins?: string[]; + symlinkRoot?: string; + apply?: boolean; + __runExecutor?: typeof nxRunExecutor; + __spawnSync?: typeof spawnSync; + __noExit?: boolean; +} + +interface ExecutorResult { + success: boolean; +} + +interface ResolvedProject { + name: string; + root?: string; + config?: ProjectConfiguration; +} + +/** + * Nx executor for running dev proxy with build watchers + * @param options - Executor options including plugin names and symlink configuration + * @param context - Nx executor context + * @yields Executor results during execution + */ +// eslint-disable-next-line max-statements, complexity +async function* runExecutor(options: DevProxyOptions, context: ExecutorContext): AsyncGenerator { + const workspaceRoot = context.root; + + const requestedPlugins = + options.plugins && options.plugins.length > 0 ? options.plugins : context.projectName ? [context.projectName] : []; + + if (requestedPlugins.length === 0) { + console.error('No project specified for dev-proxy (provide --plugins or run from a project context)'); + yield { success: false }; + return; + } + + // Resolve projects - simplified to just use the names + const resolved: ResolvedProject[] = requestedPlugins.map((name) => ({ name })); + + // Start watchers + const stopFns: Array<() => Promise> = []; + + // Choose runExecutor implementation: allow injection for tests + const runExecutorImpl = options.__runExecutor ?? nxRunExecutor; + const spawnSyncImpl = options.__spawnSync ?? spawnSync; + + console.info('dev-proxy: workspaceRoot=', workspaceRoot); + + for (const r of resolved) { + const projName = r.name; + let started = false; + + if (runExecutorImpl) { + try { + const iterator = await runExecutorImpl( + { project: projName, target: 'build', configuration: undefined }, + { watch: true }, + context, + ); + + if (iterator) { + const it = iterator as AsyncIterable<{ success: boolean }> & { return?: () => Promise }; + if (Symbol.asyncIterator in it) { + (async () => { + try { + for await (const out of it) { + if (!out || !out.success) console.error(`Build for ${projName} reported failure`); + } + } catch (err) { + console.error(`runExecutor iterator error for ${projName}:`, err); + } + })(); + } + stopFns.push(async () => { + try { + if (typeof it.return === 'function') await it.return(); + } catch { + // Failed to stop iterator + } + }); + console.info(`Started build target for ${projName} via @nx/devkit.runExecutor`); + started = true; + } + } catch (err) { + console.warn(`runExecutor failed for ${projName}:`, String(err)); + } + } + + if (!started) { + try { + console.info(`Falling back to CLI watcher for ${projName}`); + const child = spawn('bunx', ['nx', 'run', `${projName}:build`, '--watch'], { + stdio: 'inherit', + cwd: workspaceRoot, + }); + stopFns.push(async () => { + try { + child.kill(); + } catch { + // Failed to kill process + } + }); + } catch (err) { + console.warn(`Failed to start CLI watcher for ${projName}:`, String(err)); + } + } + } + + // Spawn the runtime dev script + const script = path.join(workspaceRoot, 'tools', 'dev', 'opencode-dev.ts'); + const args: string[] = []; + if (options.symlinkRoot) args.push('--symlink-root', options.symlinkRoot); + if (options.apply === false) args.push('--no-apply'); + args.push(...requestedPlugins); + + console.info('Running dev proxy runtime:', ['bunx', 'tsx', script, ...args].join(' ')); + + // Ensure cleanup on SIGINT + let exiting = false; + const sigintHandler = async () => { + if (exiting) return; + exiting = true; + console.info('\nInterrupted. Stopping build watchers and exiting...'); + for (const fn of stopFns) { + try { + await fn(); + } catch { + // Failed to stop watcher + } + } + if (!options.__noExit) { + process.exit(0); + } + }; + process.on('SIGINT', sigintHandler); + + // Run runtime script synchronously + const res = spawnSyncImpl('bunx', ['tsx', script, ...args], { stdio: 'inherit', cwd: workspaceRoot }); + + // Ensure watchers are terminated when runtime exits + for (const fn of stopFns) { + try { + fn(); + } catch { + // Failed to terminate watcher + } + } + + if (res?.error) { + console.error('Failed to run dev proxy runtime', res.error); + yield { success: false }; + return; + } + yield { success: res?.status === 0 }; +} + +export default runExecutor; diff --git a/tools/executors/dev-proxy/executor.ts.bak3 b/tools/executors/dev-proxy/executor.ts.bak3 new file mode 100644 index 0000000..6a01910 --- /dev/null +++ b/tools/executors/dev-proxy/executor.ts.bak3 @@ -0,0 +1,168 @@ +import { spawn, spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { type ExecutorContext, runExecutor as nxRunExecutor, type ProjectConfiguration } from '@nx/devkit'; + +interface DevProxyOptions { + plugins?: string[]; + symlinkRoot?: string; + apply?: boolean; + __runExecutor?: typeof nxRunExecutor; + __spawnSync?: typeof spawnSync; + __noExit?: boolean; +} + +interface ExecutorResult { + success: boolean; +} + +interface ResolvedProject { + name: string; + root?: string; + config?: ProjectConfiguration; +} + +/** + * Nx executor for running dev proxy with build watchers + * @param options - Executor options including plugin names and symlink configuration + * @param context - Nx executor context + * @yields Executor results during execution + */ +// eslint-disable-next-line max-statements, complexity +async function* runExecutor(options: DevProxyOptions, context: ExecutorContext): AsyncGenerator { + const workspaceRoot = context.root; + + const requestedPlugins = + options.plugins && options.plugins.length > 0 ? options.plugins : context.projectName ? [context.projectName] : []; + + if (requestedPlugins.length === 0) { + console.error('No project specified for dev-proxy (provide --plugins or run from a project context)'); + yield { success: false }; + return; + } + + // Resolve projects - simplified to just use the names + const resolved: ResolvedProject[] = requestedPlugins.map((name) => ({ name })); + + // Start watchers + const stopFns: Array<() => Promise> = []; + + // Choose runExecutor implementation: allow injection for tests + const runExecutorImpl = options.__runExecutor ?? nxRunExecutor; + const spawnSyncImpl = options.__spawnSync ?? spawnSync; + + // biome-ignore lint/suspicious/noConsole: Intentional logging for dev-proxy executor + console.info('dev-proxy: workspaceRoot=', workspaceRoot); + + for (const r of resolved) { + const projName = r.name; + let started = false; + + if (runExecutorImpl) { + try { + const iterator = await runExecutorImpl( + { project: projName, target: 'build', configuration: undefined }, + { watch: true }, + context, + ); + + if (iterator) { + const it = iterator as AsyncIterable<{ success: boolean }> & { return?: () => Promise }; + if (Symbol.asyncIterator in it) { + (async () => { + try { + for await (const out of it) { + if (!out || !out.success) console.error(`Build for ${projName} reported failure`); + } + } catch (err) { + console.error(`runExecutor iterator error for ${projName}:`, err); + } + })(); + } + stopFns.push(async () => { + try { + if (typeof it.return === 'function') await it.return(); + } catch { + // Failed to stop iterator + } + }); + // biome-ignore lint/suspicious/noConsole: Intentional logging for dev-proxy executor + console.info(`Started build target for ${projName} via @nx/devkit.runExecutor`); + started = true; + } + } catch (err) { + console.warn(`runExecutor failed for ${projName}:`, String(err)); + } + } + + if (!started) { + try { + // biome-ignore lint/suspicious/noConsole: Intentional logging for dev-proxy executor + console.info(`Falling back to CLI watcher for ${projName}`); + const child = spawn('bunx', ['nx', 'run', `${projName}:build`, '--watch'], { + stdio: 'inherit', + cwd: workspaceRoot, + }); + stopFns.push(async () => { + try { + child.kill(); + } catch { + // Failed to kill process + } + }); + } catch (err) { + console.warn(`Failed to start CLI watcher for ${projName}:`, String(err)); + } + } + } + + // Spawn the runtime dev script + const script = path.join(workspaceRoot, 'tools', 'dev', 'opencode-dev.ts'); + const args: string[] = []; + if (options.symlinkRoot) args.push('--symlink-root', options.symlinkRoot); + if (options.apply === false) args.push('--no-apply'); + args.push(...requestedPlugins); + + // biome-ignore lint/suspicious/noConsole: Intentional logging for dev-proxy executor + console.info('Running dev proxy runtime:', ['bunx', 'tsx', script, ...args].join(' ')); + + // Ensure cleanup on SIGINT + let exiting = false; + const sigintHandler = async () => { + if (exiting) return; + exiting = true; + // biome-ignore lint/suspicious/noConsole: Intentional logging for dev-proxy executor + console.info('\nInterrupted. Stopping build watchers and exiting...'); + for (const fn of stopFns) { + try { + await fn(); + } catch { + // Failed to stop watcher + } + } + if (!options.__noExit) { + process.exit(0); + } + }; + process.on('SIGINT', sigintHandler); + + // Run runtime script synchronously + const res = spawnSyncImpl('bunx', ['tsx', script, ...args], { stdio: 'inherit', cwd: workspaceRoot }); + + // Ensure watchers are terminated when runtime exits + for (const fn of stopFns) { + try { + fn(); + } catch { + // Failed to terminate watcher + } + } + + if (res?.error) { + console.error('Failed to run dev proxy runtime', res.error); + yield { success: false }; + return; + } + yield { success: res?.status === 0 }; +} + +export default runExecutor;