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/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 2d38e86..08fdd97 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'; @@ -41,8 +42,8 @@ const _originalSpawn = childProcess.spawn; function _fakeSpawn(_cmd: string, _args: string[], _opts: any) { return { kill: () => {}, - on: (_ev: string, _cb: (data: unknown) => void) => {}, - } as { kill(): void; on(event: string, callback: (data: unknown) => void): void }; + on: (_ev: string, _cb: (...args: unknown[]) => void) => {}, + } as any; } let _originalExit: typeof process.exit; @@ -55,7 +56,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 @@ -87,17 +87,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); @@ -108,9 +112,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); @@ -155,15 +158,19 @@ 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, __spawnSync: mockSpawnSync, + __noExit: true, }, 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); @@ -172,7 +179,7 @@ describe('dev-proxy executor with mocked runExecutor', () => { expect(childKilled).toBe(true); const res = await resPromise; - expect(res?.success).toBe(true); + expect(res?.value?.success).toBe(true); // restore spawn and listeners childProcess.spawn = originalSpawn; @@ -197,12 +204,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)); @@ -211,7 +221,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..68cf786 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.info('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.info(`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.info(`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.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(); @@ -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/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; 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": [],