From c8741413bdfa8bde65bcb724fc7b46a36afeb478 Mon Sep 17 00:00:00 2001 From: Morax Date: Tue, 11 Aug 2026 15:20:59 +0200 Subject: [PATCH] fix(cli): resolve Nx hooks from package directories --- cli/src/common.ts | 31 ++++++++---- cli/test/common.spec.ts | 101 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 cli/test/common.spec.ts diff --git a/cli/src/common.ts b/cli/src/common.ts index 1da9d1b21c..2533ae0ae9 100644 --- a/cli/src/common.ts +++ b/cli/src/common.ts @@ -153,31 +153,42 @@ export async function runHooks(config: Config, platformName: string, dir: string const allPlugins = await getPlugins(config, platformName); for (const p of allPlugins) { - await runPlatformHook(config, platformName, p.rootPath, hook); + await runPlatformHook(config, platformName, p.rootPath, hook, { allowNXRootFallback: false }); } } +interface RunPlatformHookOptions { + allowNXRootFallback?: boolean; +} + export async function runPlatformHook( config: Config, platformName: string, platformDir: string, hook: string, + options: RunPlatformHookOptions = {}, ): Promise { const { spawn } = await import('child_process'); - let pkg; - if (isNXMonorepo(platformDir)) { - pkg = await readJSON(join(findNXMonorepoRoot(platformDir), 'package.json')); - } else { - pkg = await readJSON(join(platformDir, 'package.json')); + const platformPackagePath = join(platformDir, 'package.json'); + let cmd: string | undefined; + + if (await pathExists(platformPackagePath)) { + const pkg = await readJSON(platformPackagePath); + cmd = pkg.scripts?.[hook]; + } + + if (!cmd && options.allowNXRootFallback !== false && isNXMonorepo(platformDir)) { + const pkg = await readJSON(join(findNXMonorepoRoot(platformDir), 'package.json')); + cmd = pkg.scripts?.[hook]; } - const cmd = pkg.scripts?.[hook]; if (!cmd) { return; } + const hookCommand = cmd; return new Promise((resolve, reject) => { - const p = spawn(cmd, { + const p = spawn(hookCommand, { stdio: 'inherit', shell: true, cwd: platformDir, @@ -195,7 +206,9 @@ export async function runPlatformHook( resolve(); } else { reject( - new Error(`${hook} hook on ${platformName} failed with error code: ${code} while running command: ${cmd}`), + new Error( + `${hook} hook on ${platformName} failed with error code: ${code} while running command: ${hookCommand}`, + ), ); } }); diff --git a/cli/test/common.spec.ts b/cli/test/common.spec.ts new file mode 100644 index 0000000000..f23cecc67f --- /dev/null +++ b/cli/test/common.spec.ts @@ -0,0 +1,101 @@ +import { mkdirp, pathExists, remove, writeJSON } from 'fs-extra'; +import { join } from 'path'; +import tmp from 'tmp'; + +import { runHooks } from '../src/common'; +import type { Config } from '../src/definitions'; +import { getPlugins } from '../src/plugin'; + +jest.mock('../src/plugin', () => ({ + getPlugins: jest.fn(), +})); + +const HOOK = 'capacitor:sync:before'; + +describe('runHooks', () => { + let cleanupCallback: () => void; + let workspaceDir: string; + let appDir: string; + let pluginDir: string; + let config: Config; + + beforeEach(async () => { + const tmpDir = tmp.dirSync({ unsafeCleanup: true }); + cleanupCallback = tmpDir.removeCallback; + workspaceDir = tmpDir.name; + appDir = join(workspaceDir, 'apps', 'mobile'); + pluginDir = join(workspaceDir, 'node_modules', 'test-plugin'); + + await mkdirp(appDir); + await mkdirp(pluginDir); + await writeJSON(join(workspaceDir, 'nx.json'), {}); + await writeJSON(join(workspaceDir, 'package.json'), { + scripts: { + [HOOK]: markerCommand('root-hook-ran'), + }, + }); + await writeJSON(join(appDir, 'package.json'), { + scripts: { + [HOOK]: markerCommand('app-hook-ran'), + }, + }); + await writeJSON(join(pluginDir, 'package.json'), { + scripts: { + [HOOK]: markerCommand('plugin-hook-ran'), + }, + }); + + config = { + app: { + rootDir: appDir, + webDirAbs: join(appDir, 'www'), + extConfig: {}, + }, + } as Config; + + jest.mocked(getPlugins).mockResolvedValue([ + { + id: 'test-plugin', + name: 'test-plugin', + version: '1.0.0', + rootPath: pluginDir, + }, + ]); + }); + + afterEach(() => { + cleanupCallback(); + jest.resetAllMocks(); + }); + + it('runs app and plugin hooks from their own packages in an Nx workspace', async () => { + await runHooks(config, 'android', appDir, HOOK); + + expect(await pathExists(join(appDir, 'app-hook-ran'))).toBe(true); + expect(await pathExists(join(pluginDir, 'plugin-hook-ran'))).toBe(true); + expect(await pathExists(join(appDir, 'root-hook-ran'))).toBe(false); + expect(await pathExists(join(pluginDir, 'root-hook-ran'))).toBe(false); + }); + + it('falls back to the Nx root hook when the app has no package', async () => { + await remove(join(appDir, 'package.json')); + jest.mocked(getPlugins).mockResolvedValue([]); + + await runHooks(config, 'android', appDir, HOOK); + + expect(await pathExists(join(appDir, 'root-hook-ran'))).toBe(true); + }); + + it('does not fall back to the Nx root hook for plugins', async () => { + await writeJSON(join(pluginDir, 'package.json'), {}); + + await runHooks(config, 'android', appDir, HOOK); + + expect(await pathExists(join(appDir, 'app-hook-ran'))).toBe(true); + expect(await pathExists(join(pluginDir, 'root-hook-ran'))).toBe(false); + }); +}); + +function markerCommand(marker: string): string { + return `node -e "require('fs').writeFileSync('${marker}', '')"`; +}