diff --git a/.github/workflows/installer-smoke-matrix.yml b/.github/workflows/installer-smoke-matrix.yml new file mode 100644 index 0000000000..4ca560a46d --- /dev/null +++ b/.github/workflows/installer-smoke-matrix.yml @@ -0,0 +1,260 @@ +# Installer Smoke Matrix +# +# Validates the Pro installer pipeline across the npm versions and ancestor +# directory topologies that cohort students hit in the wild. Without this +# matrix, fixes to the installer were landing as one-off patches per reported +# error (11 fixes to pro-setup.js in a 30-day window) instead of being +# guaranteed across the install surface. +# +# Scope: +# - Node 18, 20, 22 (current LTS spread mirrored from pro-integration.yml) +# - npm 8 (legacy), 10 (default for Node 20), 11 (default for Node 22) +# - Empty target dir + ancestor with workspaces + ancestor with plain +# package.json (regression scenarios — see pro-setup-target-install.test.js) +# +# Why this exists (root cause): +# npm 10+ walks the directory tree looking for the first ancestor with a +# package.json. Without `--prefix= --workspaces=false` the Pro +# tarball ends up in the wrong node_modules and the post-install integrity +# check fails with "Installed Pro artifact did not create node_modules/ +# @aiox-squads/pro" even though npm exited 0. + +name: Installer Smoke Matrix + +on: + push: + branches: [main] + paths: + - 'packages/installer/**' + - 'tests/installer/**' + - '.github/workflows/installer-smoke-matrix.yml' + + pull_request: + branches: [main] + paths: + - 'packages/installer/**' + - 'tests/installer/**' + - '.github/workflows/installer-smoke-matrix.yml' + + workflow_dispatch: + + schedule: + - cron: '0 7 * * 1' + +concurrency: + group: installer-smoke-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + installer-smoke: + name: Installer (Node ${{ matrix.node }} / npm ${{ matrix.npm }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + + # Matrix coverage notes: + # - npm 11 requires Node >= 20.17 (Node 18 is incompatible — excluded). + # - Node 22 already ships with npm 11, so re-pinning npm 11 conflicts; we + # leave Node 22 with its bundled npm (10 or 11 depending on minor). + # - npm 8 is too old to honor `--workspaces=false` reliably; excluded. + # - Windows is covered for the most common combo (Node 20 + npm 10). + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + node: '18' + npm: '10' + - os: ubuntu-latest + node: '20' + npm: '10' + - os: ubuntu-latest + node: '20' + npm: '11' + - os: ubuntu-latest + node: '22' + npm: 'bundled' + - os: macos-latest + node: '20' + npm: '11' + - os: macos-latest + node: '22' + npm: 'bundled' + - os: windows-latest + node: '20' + npm: '10' + - os: windows-latest + node: '22' + npm: 'bundled' + + steps: + - uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node.js ${{ matrix.node }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Pin npm to ${{ matrix.npm }} + if: matrix.npm != 'bundled' + shell: bash + run: | + npm install --global "npm@${{ matrix.npm }}" + echo "=== npm/node versions (pinned to ${{ matrix.npm }}) ===" + node --version + npm --version + + - name: Report bundled npm version + if: matrix.npm == 'bundled' + shell: bash + run: | + echo "=== npm/node versions (bundled with Node ${{ matrix.node }}) ===" + node --version + npm --version + + - name: Install core dependencies + run: npm ci + + - name: Run target-install regression tests + shell: bash + run: | + echo "=== Regression: installProArtifactIntoTarget across ancestor topologies ===" + npx jest tests/installer/pro-setup-target-install.test.js --no-coverage --verbose + + - name: Run pro-setup auth tests + shell: bash + run: | + echo "=== Backstop: pro-setup auth + invocation surface ===" + npx jest tests/installer/pro-setup-auth.test.js --no-coverage --verbose + + - name: Smoke install (empty target, no ancestors) + shell: bash + run: | + set -e + WORKDIR=$(mktemp -d) + cd "$WORKDIR" + echo "Smoke target: $WORKDIR" + node -e " + const proSetup = require('${{ github.workspace }}/packages/installer/src/wizard/pro-setup'); + (async () => { + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const { execFileSync } = require('child_process'); + + const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aiox-smoke-')); + fs.writeFileSync(path.join(fixtureDir, 'package.json'), + JSON.stringify({ name: '@aiox-squads/pro', version: '0.0.0-smoke', private: false })); + fs.mkdirSync(path.join(fixtureDir, 'squads')); + fs.writeFileSync(path.join(fixtureDir, 'squads', 'index.js'), 'module.exports = {};'); + const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const pack = JSON.parse(execFileSync(npmBin, ['pack', '--json'], { cwd: fixtureDir, encoding: 'utf8', shell: process.platform === 'win32' }))[0]; + const tarball = path.join(fixtureDir, pack.filename); + + const proSourceDir = await proSetup._testing.installProArtifactIntoTarget(tarball, process.cwd()); + if (!fs.existsSync(path.join(proSourceDir, 'package.json'))) { + throw new Error('smoke failed: ' + proSourceDir + ' missing'); + } + console.log('Smoke OK at:', proSourceDir); + })().catch((err) => { console.error(err); process.exit(1); }); + " + + - name: Smoke install (target nested inside an ancestor workspace) + shell: bash + run: | + set -e + WS_ROOT=$(mktemp -d) + mkdir -p "$WS_ROOT/projects/aiox-install" + cat > "$WS_ROOT/package.json" <<'JSON' + {"name":"smoke-workspace","private":true,"workspaces":["projects/*"]} + JSON + cd "$WS_ROOT/projects/aiox-install" + echo "Smoke target: $(pwd)" + node -e " + const proSetup = require('${{ github.workspace }}/packages/installer/src/wizard/pro-setup'); + (async () => { + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const { execFileSync } = require('child_process'); + + const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aiox-smoke-ws-')); + fs.writeFileSync(path.join(fixtureDir, 'package.json'), + JSON.stringify({ name: '@aiox-squads/pro', version: '0.0.0-smoke', private: false })); + fs.mkdirSync(path.join(fixtureDir, 'squads')); + fs.writeFileSync(path.join(fixtureDir, 'squads', 'index.js'), 'module.exports = {};'); + const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const pack = JSON.parse(execFileSync(npmBin, ['pack', '--json'], { cwd: fixtureDir, encoding: 'utf8', shell: process.platform === 'win32' }))[0]; + const tarball = path.join(fixtureDir, pack.filename); + + const proSourceDir = await proSetup._testing.installProArtifactIntoTarget(tarball, process.cwd()); + if (!fs.existsSync(path.join(proSourceDir, 'package.json'))) { + throw new Error('smoke failed: ' + proSourceDir + ' missing'); + } + + const wsRootHit = path.join(path.dirname(path.dirname(process.cwd())), 'node_modules', '@aiox-squads', 'pro', 'package.json'); + if (fs.existsSync(wsRootHit)) { + throw new Error('npm escaped to workspace root: ' + wsRootHit); + } + + console.log('Smoke OK at:', proSourceDir, '(workspace root clean)'); + })().catch((err) => { console.error(err); process.exit(1); }); + " + + - name: Smoke install (target nested under plain package.json ancestor) + shell: bash + run: | + set -e + PARENT_ROOT=$(mktemp -d) + mkdir -p "$PARENT_ROOT/subdir" + cat > "$PARENT_ROOT/package.json" <<'JSON' + {"name":"unrelated-parent","version":"1.0.0","dependencies":{"left-pad":"^1.0.0"}} + JSON + cd "$PARENT_ROOT/subdir" + echo "Smoke target: $(pwd)" + node -e " + const proSetup = require('${{ github.workspace }}/packages/installer/src/wizard/pro-setup'); + (async () => { + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const { execFileSync } = require('child_process'); + + const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aiox-smoke-parent-')); + fs.writeFileSync(path.join(fixtureDir, 'package.json'), + JSON.stringify({ name: '@aiox-squads/pro', version: '0.0.0-smoke', private: false })); + fs.mkdirSync(path.join(fixtureDir, 'squads')); + fs.writeFileSync(path.join(fixtureDir, 'squads', 'index.js'), 'module.exports = {};'); + const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const pack = JSON.parse(execFileSync(npmBin, ['pack', '--json'], { cwd: fixtureDir, encoding: 'utf8', shell: process.platform === 'win32' }))[0]; + const tarball = path.join(fixtureDir, pack.filename); + + const proSourceDir = await proSetup._testing.installProArtifactIntoTarget(tarball, process.cwd()); + if (!fs.existsSync(path.join(proSourceDir, 'package.json'))) { + throw new Error('smoke failed: ' + proSourceDir + ' missing'); + } + + const parentHit = path.join(path.dirname(process.cwd()), 'node_modules', '@aiox-squads', 'pro', 'package.json'); + if (fs.existsSync(parentHit)) { + throw new Error('npm escaped to parent: ' + parentHit); + } + + console.log('Smoke OK at:', proSourceDir, '(parent clean)'); + })().catch((err) => { console.error(err); process.exit(1); }); + " + + - name: Summary + if: always() + shell: bash + run: | + echo "=== Installer Smoke Matrix Summary ===" + echo "OS: ${{ matrix.os }}" + echo "Node: ${{ matrix.node }}" + echo "npm: ${{ matrix.npm }}" + echo "Status: ${{ job.status }}" diff --git a/packages/installer/src/wizard/pro-setup.js b/packages/installer/src/wizard/pro-setup.js index b371c9818e..5be2314f75 100644 --- a/packages/installer/src/wizard/pro-setup.js +++ b/packages/installer/src/wizard/pro-setup.js @@ -791,7 +791,19 @@ async function extractProArtifactToTemp(artifactPath, tempRoot) { try { await runNpm( - ['install', artifactPath, '--ignore-scripts', '--no-audit', '--no-fund', '--no-save', '--silent'], + [ + 'install', + artifactPath, + '--prefix', + installRoot, + '--workspaces=false', + '--include-workspace-root=false', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--no-save', + '--silent', + ], { cwd: installRoot, timeout: PRO_ARTIFACT_INSTALL_TIMEOUT_MS, @@ -814,11 +826,29 @@ async function extractProArtifactToTemp(artifactPath, tempRoot) { async function installProArtifactIntoTarget(artifactPath, targetDir) { await fs.ensureDir(targetDir); + // Ensure npm anchors the install at targetDir, even when an ancestor directory + // declares workspaces or a package.json. Without --prefix + --workspaces=false, + // npm 10+ walks up the directory tree and installs in the first ancestor it + // finds with a package.json, leaving targetDir/node_modules empty. The local + // anchor package.json gives npm a valid root regardless of ancestors. + const anchorPackageJsonPath = path.join(targetDir, 'package.json'); + const anchorCreated = !(await fs.pathExists(anchorPackageJsonPath)); + if (anchorCreated) { + await fs.writeJson(anchorPackageJsonPath, { + private: true, + dependencies: {}, + }); + } + try { await runNpm( [ 'install', artifactPath, + '--prefix', + targetDir, + '--workspaces=false', + '--include-workspace-root=false', '--ignore-scripts', '--no-audit', '--no-fund', @@ -833,18 +863,52 @@ async function installProArtifactIntoTarget(artifactPath, targetDir) { }, ); } catch (error) { + if (anchorCreated) { + await fs.remove(anchorPackageJsonPath).catch(() => {}); + } const details = error.stderr || error.stdout || error.message; throw new Error(`Failed to install Pro artifact into project: ${String(details).trim()}`); + } finally { + if (anchorCreated) { + await fs.remove(anchorPackageJsonPath).catch(() => {}); + } } const proSourceDir = path.join(targetDir, 'node_modules', '@aiox-squads', 'pro'); if (!(await fs.pathExists(path.join(proSourceDir, 'package.json')))) { - throw new Error('Installed Pro artifact did not create node_modules/@aiox-squads/pro.'); + const ancestorHit = await findAncestorNodeModulesPro(targetDir); + const diagnosis = ancestorHit + ? ` npm appears to have installed it at ${ancestorHit} instead (likely because an ancestor directory declares a package.json or workspaces).` + : ' Verify the target directory is writable and no parent directory hijacks npm install resolution.'; + const error = new Error( + `Installed Pro artifact did not create ${proSourceDir}.${diagnosis} Run \`aiox install\` from a fresh empty directory (e.g. \`mkdir ~/aiox-pro && cd ~/aiox-pro\`) to bypass this. See https://github.com/SynkraAI/aiox-core/issues for tracking.`, + ); + error.code = 'PRO_INSTALL_TARGET_HIJACKED'; + error.targetDir = targetDir; + error.expectedPath = proSourceDir; + error.ancestorHit = ancestorHit || null; + throw error; } return proSourceDir; } +async function findAncestorNodeModulesPro(startDir) { + let current = path.resolve(startDir); + const root = path.parse(current).root; + + // Walk up at most 8 levels to keep this bounded on deep trees. + for (let i = 0; i < 8 && current !== root; i += 1) { + current = path.dirname(current); + const candidate = path.join(current, 'node_modules', '@aiox-squads', 'pro', 'package.json'); + if (await fs.pathExists(candidate)) { + return path.dirname(candidate); + } + } + + return null; +} + async function acquireProArtifactSourceDir(targetDir, licenseResult, options = {}) { const accessToken = getLicenseResultAccessToken(licenseResult); if (!accessToken) { @@ -894,13 +958,27 @@ async function acquireProArtifactSourceDir(targetDir, licenseResult, options = { ? module.exports._testing.installProArtifactIntoTarget : installProArtifactIntoTarget; const extractedProSourceDir = await extractProArtifactToTemp(artifactPath, tempRoot); - const installedProSourceDir = await targetInstaller(artifactPath, targetDir); + + // The Pro content is already extracted and integrity-verified at this point. + // Installing into targetDir is a convenience so post-install Pro commands can + // resolve @aiox-squads/pro from the project's node_modules — it is NOT required + // for the scaffold step, which copies files directly from extractedProSourceDir. + // If the target install fails (e.g. PRO_INSTALL_TARGET_HIJACKED), warn but + // continue with the temp source so the user can still complete the install. + let installedProSourceDir = null; + let targetInstallWarning = null; + try { + installedProSourceDir = await targetInstaller(artifactPath, targetDir); + } catch (installError) { + targetInstallWarning = installError.message; + } return { success: true, proSourceDir: installedProSourceDir || extractedProSourceDir, installedProSourceDir: installedProSourceDir || null, tempRoot, + targetInstallWarning, artifact: { package: artifact.package, version: artifact.version, @@ -1938,6 +2016,13 @@ async function stepInstallScaffold(targetDir, options = {}) { resolvedProSourceDir = acquisition.proSourceDir; tempProSourceRoot = acquisition.tempRoot || null; installedArtifactProSourceDir = acquisition.installedProSourceDir || null; + + if (acquisition.targetInstallWarning) { + const expectedProDir = path.join(targetDir, 'node_modules', '@aiox-squads', 'pro'); + showWarning( + `Pro module could not be cached at ${expectedProDir}: ${acquisition.targetInstallWarning} This install will still complete using the verified Pro artifact from a temporary cache, but the cache is wiped at the end of this command — every future run of \`aiox install\` in this directory will re-download the Pro artifact until you run it from a fresh empty directory (e.g. \`mkdir ~/aiox-pro && cd ~/aiox-pro && npx -y -p @aiox-squads/core@latest aiox install\`).`, + ); + } } if (!resolvedProSourceDir) { @@ -2205,6 +2290,7 @@ module.exports = { downloadArtifactFile, extractProArtifactToTemp, installProArtifactIntoTarget, + findAncestorNodeModulesPro, getProArtifactVersion, getLicenseResultAccessToken, LICENSE_SERVER_URL, diff --git a/tests/installer/pro-setup-target-install.test.js b/tests/installer/pro-setup-target-install.test.js new file mode 100644 index 0000000000..f0b1193945 --- /dev/null +++ b/tests/installer/pro-setup-target-install.test.js @@ -0,0 +1,326 @@ +/** + * Regression tests for installProArtifactIntoTarget npm hijack scenarios. + * + * Background: + * `aiox install` (Pro flow) downloads an `@aiox-squads/pro` tarball and runs + * `npm install ` inside the user's chosen target directory. Without + * `--prefix` and `--workspaces=false`, npm 10+/11 walks up the directory tree + * looking for the first ancestor with a package.json — when it finds one, it + * installs node_modules there instead of in the target. The post-install + * integrity check (`node_modules/@aiox-squads/pro/package.json` exists at + * targetDir) then fails with the user-facing error + * + * Pro activation failed: Installed Pro artifact did not create + * node_modules/@aiox-squads/pro. + * + * even though npm exited 0 and a copy of @aiox-squads/pro now lives one + * directory up. + * + * These tests reproduce the four real-world install topologies students hit + * in cohorts and assert the fix prevents npm from escaping the target dir. + * + * Tests build a minimal `@aiox-squads/pro` tarball at runtime (via npm pack) + * so the fixture is self-contained and does not require the private Pro + * artifact bucket. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const proSetup = require('../../packages/installer/src/wizard/pro-setup'); + +const NPM_INSTALL_TIMEOUT_MS = 60 * 1000; + +function makeTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function removeDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } +} + +function writePackageJson(dir, body) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(body, null, 2)); +} + +/** + * Build a minimal `@aiox-squads/pro` tarball at runtime. Smaller than the + * private artifact and adequate to exercise the npm install codepath. Cached + * across tests in the suite via module-level memoization. + */ +let cachedFixtureTarball = null; +let cachedFixtureBuildDir = null; +function buildFixtureTarball() { + if (cachedFixtureTarball) { + return cachedFixtureTarball; + } + + const buildDir = makeTempDir('aiox-pro-fixture-'); + cachedFixtureBuildDir = buildDir; + + const pkg = { + name: '@aiox-squads/pro', + version: '0.0.0-test-fixture', + description: 'Minimal @aiox-squads/pro fixture for installer regression tests', + private: false, + files: ['squads/index.js', 'license/license-cache.js'], + }; + writePackageJson(buildDir, pkg); + fs.mkdirSync(path.join(buildDir, 'squads'), { recursive: true }); + fs.writeFileSync( + path.join(buildDir, 'squads', 'index.js'), + 'module.exports = { fixture: true };\n', + ); + fs.mkdirSync(path.join(buildDir, 'license'), { recursive: true }); + fs.writeFileSync( + path.join(buildDir, 'license', 'license-cache.js'), + 'module.exports = { writeLicenseCache: () => ({ success: true }) };\n', + ); + + const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const packOutput = execFileSync(npmBin, ['pack', '--json'], { + cwd: buildDir, + timeout: NPM_INSTALL_TIMEOUT_MS, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + + const packed = JSON.parse(packOutput)[0]; + cachedFixtureTarball = path.join(buildDir, packed.filename); + return cachedFixtureTarball; +} + +afterAll(() => { + if (cachedFixtureBuildDir) { + removeDir(cachedFixtureBuildDir); + cachedFixtureBuildDir = null; + cachedFixtureTarball = null; + } +}); + +describe('installProArtifactIntoTarget — npm hijack regression (Story PRO-13.6 hotfix)', () => { + let artifactPath; + + beforeAll(() => { + artifactPath = buildFixtureTarball(); + }, NPM_INSTALL_TIMEOUT_MS); + + test('installs into targetDir when no ancestor has package.json', async () => { + const root = makeTempDir('aiox-pro-target-empty-'); + try { + const proSourceDir = await proSetup._testing.installProArtifactIntoTarget( + artifactPath, + root, + ); + + expect(proSourceDir).toBe(path.join(root, 'node_modules', '@aiox-squads', 'pro')); + expect(fs.existsSync(path.join(proSourceDir, 'package.json'))).toBe(true); + } finally { + removeDir(root); + } + }, NPM_INSTALL_TIMEOUT_MS); + + test('installs into targetDir when an ancestor declares workspaces (the cohort bug)', async () => { + const wsRoot = makeTempDir('aiox-pro-target-ws-'); + const target = path.join(wsRoot, 'projects', 'aiox-install'); + fs.mkdirSync(target, { recursive: true }); + + writePackageJson(wsRoot, { + name: 'cohort-workspace-root', + private: true, + workspaces: ['projects/*'], + }); + + try { + const proSourceDir = await proSetup._testing.installProArtifactIntoTarget( + artifactPath, + target, + ); + + expect(proSourceDir).toBe(path.join(target, 'node_modules', '@aiox-squads', 'pro')); + expect(fs.existsSync(path.join(proSourceDir, 'package.json'))).toBe(true); + expect( + fs.existsSync(path.join(wsRoot, 'node_modules', '@aiox-squads', 'pro', 'package.json')), + ).toBe(false); + } finally { + removeDir(wsRoot); + } + }, NPM_INSTALL_TIMEOUT_MS); + + test('installs into targetDir when an ancestor has a plain package.json (no workspaces)', async () => { + const parentRoot = makeTempDir('aiox-pro-target-parent-'); + const target = path.join(parentRoot, 'subdir'); + fs.mkdirSync(target, { recursive: true }); + + writePackageJson(parentRoot, { + name: 'unrelated-parent-project', + version: '1.0.0', + dependencies: { 'left-pad': '^1.0.0' }, + }); + + try { + const proSourceDir = await proSetup._testing.installProArtifactIntoTarget( + artifactPath, + target, + ); + + expect(proSourceDir).toBe(path.join(target, 'node_modules', '@aiox-squads', 'pro')); + expect(fs.existsSync(path.join(proSourceDir, 'package.json'))).toBe(true); + expect( + fs.existsSync(path.join(parentRoot, 'node_modules', '@aiox-squads', 'pro', 'package.json')), + ).toBe(false); + } finally { + removeDir(parentRoot); + } + }, NPM_INSTALL_TIMEOUT_MS); + + test('installs into targetDir when targetDir itself has a package.json', async () => { + const target = makeTempDir('aiox-pro-target-local-'); + writePackageJson(target, { name: 'students-existing-project', version: '1.0.0' }); + + try { + const proSourceDir = await proSetup._testing.installProArtifactIntoTarget( + artifactPath, + target, + ); + + expect(proSourceDir).toBe(path.join(target, 'node_modules', '@aiox-squads', 'pro')); + expect(fs.existsSync(path.join(proSourceDir, 'package.json'))).toBe(true); + + const pkg = JSON.parse(fs.readFileSync(path.join(target, 'package.json'), 'utf8')); + expect(pkg.name).toBe('students-existing-project'); + } finally { + removeDir(target); + } + }, NPM_INSTALL_TIMEOUT_MS); + + test('does not leave the synthetic anchor package.json behind when targetDir starts empty', async () => { + const target = makeTempDir('aiox-pro-target-cleanup-'); + + try { + await proSetup._testing.installProArtifactIntoTarget(artifactPath, target); + expect(fs.existsSync(path.join(target, 'package.json'))).toBe(false); + } finally { + removeDir(target); + } + }, NPM_INSTALL_TIMEOUT_MS); +}); + +describe('findAncestorNodeModulesPro — diagnostic helper', () => { + test('returns the ancestor directory when an upstream node_modules holds @aiox-squads/pro', async () => { + const root = makeTempDir('aiox-pro-ancestor-'); + const proDir = path.join(root, 'node_modules', '@aiox-squads', 'pro'); + fs.mkdirSync(proDir, { recursive: true }); + fs.writeFileSync(path.join(proDir, 'package.json'), '{"name":"@aiox-squads/pro"}'); + + const target = path.join(root, 'projects', 'leaf'); + fs.mkdirSync(target, { recursive: true }); + + try { + const hit = await proSetup._testing.findAncestorNodeModulesPro(target); + expect(hit).toBe(proDir); + } finally { + removeDir(root); + } + }); + + test('returns null when no ancestor has @aiox-squads/pro installed', async () => { + const root = makeTempDir('aiox-pro-no-ancestor-'); + try { + const hit = await proSetup._testing.findAncestorNodeModulesPro(root); + expect(hit).toBeNull(); + } finally { + removeDir(root); + } + }); +}); + +describe('acquireProArtifactSourceDir — graceful fallback when target install rejects', () => { + let originalInstall; + let artifactPath; + + beforeAll(() => { + artifactPath = buildFixtureTarball(); + }, NPM_INSTALL_TIMEOUT_MS); + + beforeEach(() => { + originalInstall = proSetup._testing.installProArtifactIntoTarget; + }); + + afterEach(() => { + proSetup._testing.installProArtifactIntoTarget = originalInstall; + }); + + test('falls back to the verified temp source and surfaces a warning', async () => { + const target = makeTempDir('aiox-pro-acq-target-'); + const hijackError = new Error( + 'Installed Pro artifact did not create /fake/path. npm appears to have installed it at /elsewhere instead.', + ); + hijackError.code = 'PRO_INSTALL_TARGET_HIJACKED'; + + proSetup._testing.installProArtifactIntoTarget = jest + .fn() + .mockRejectedValue(hijackError); + + const tarballBuffer = require('fs').readFileSync(artifactPath); + const tarballSha256 = require('crypto') + .createHash('sha256') + .update(tarballBuffer) + .digest('hex'); + + const originalGetUrl = proSetup._testing.InlineLicenseClient.prototype.getProArtifactUrl; + proSetup._testing.InlineLicenseClient.prototype.getProArtifactUrl = jest + .fn() + .mockResolvedValue({ + package: '@aiox-squads/pro', + version: proSetup._testing.DEFAULT_PRO_ARTIFACT_VERSION, + artifactUrl: 'https://aiox-fixture.test.invalid/pro.tgz', + sha256: tarballSha256, + sizeBytes: tarballBuffer.length, + expiresAt: new Date(Date.now() + 60000).toISOString(), + }); + + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => + tarballBuffer.buffer.slice( + tarballBuffer.byteOffset, + tarballBuffer.byteOffset + tarballBuffer.byteLength, + ), + }); + + try { + const result = await proSetup._testing.acquireProArtifactSourceDir( + target, + { accessToken: 'fake-access-token', aioxCoreVersion: '5.2.5', machineId: 'a'.repeat(64) }, + { proArtifactVersion: proSetup._testing.DEFAULT_PRO_ARTIFACT_VERSION }, + ); + + expect(result.success).toBe(true); + expect(result.installedProSourceDir).toBeNull(); + expect(result.proSourceDir).toBeTruthy(); + expect(result.proSourceDir).toContain('node_modules'); + expect(result.proSourceDir).toContain('@aiox-squads'); + expect(result.targetInstallWarning).toBeTruthy(); + expect(result.targetInstallWarning).toContain('did not create'); + + const fs = require('fs'); + const path = require('path'); + expect(fs.existsSync(path.join(result.proSourceDir, 'package.json'))).toBe(true); + } finally { + proSetup._testing.InlineLicenseClient.prototype.getProArtifactUrl = originalGetUrl; + global.fetch = originalFetch; + removeDir(target); + } + }, NPM_INSTALL_TIMEOUT_MS); +});