fix: detect broken install and add Windows symlink fallback (#890)

Fixes #882 — npm install -g gsd-pi installing a broken version where
@gsd/pi-coding-agent cannot be resolved, causing ERR_MODULE_NOT_FOUND.

Root causes addressed:
1. On Windows without Developer Mode or admin rights, symlinkSync fails
   even for NTFS junctions, leaving node_modules/@gsd/ empty and causing
   a cryptic ERR_MODULE_NOT_FOUND instead of a usable error message.
2. If npm latest dist-tag is stale (pointing to an old version that
   predates the packages/ directory), users get the same failure.

Changes:
- src/loader.ts: after symlinking, validate @gsd/pi-coding-agent exists;
  emit a clear actionable error with reinstall instructions instead of
  letting Node throw ERR_MODULE_NOT_FOUND deep inside cli.js. Also adds
  cpSync fallback when symlinkSync fails (Windows without elevated perms).
- scripts/link-workspace-packages.cjs: same cpSync fallback — ensures
  postinstall succeeds on restricted Windows environments.
- scripts/validate-pack.js: verify @gsd/* packages are resolvable after
  the isolated install test, and run `gsd -v` to confirm end-to-end
  resolution before declaring the pack valid.
- .github/workflows/build-native.yml: add post-publish dist-tag
  verification step that confirms npm dist-tags.latest matches the
  published version for stable releases, catching stale-tag regressions
  in CI before users encounter them.
This commit is contained in:
Jeremy McSpadden 2026-03-17 10:35:57 -05:00 committed by GitHub
parent 01a6294b23
commit 23b89c64c9
4 changed files with 121 additions and 10 deletions

View file

@ -253,3 +253,20 @@ jobs:
done
echo "::error::Smoke test failed — gsd-pi@${VERSION} not installable"
exit 1
- name: Verify dist-tag after publish
if: steps.version-check.outputs.is_prerelease == 'false'
run: |
VERSION=$(node -p "require('./package.json').version")
echo "Verifying npm dist-tag 'latest' points to ${VERSION}..."
for attempt in $(seq 1 10); do
LATEST=$(npm view gsd-pi dist-tags.latest 2>/dev/null || echo "")
if [ "${LATEST}" = "${VERSION}" ]; then
echo " ✓ npm dist-tags.latest = ${VERSION}"
exit 0
fi
echo " Attempt ${attempt}/10: latest=${LATEST}, expected=${VERSION}, retrying in 15s..."
sleep 15
done
echo "::error::dist-tags.latest is '${LATEST}' but expected '${VERSION}' — run: npm dist-tag add gsd-pi@${VERSION} latest"
exit 1

View file

@ -10,8 +10,12 @@
* to resolve. This script bridges the gap.
*
* Runs as part of postinstall (before any ESM code that imports @gsd/*).
*
* On Windows without Developer Mode or administrator rights, creating symlinks
* (even NTFS junctions) can fail with EPERM. In that case we fall back to
* cpSync (directory copy) which works universally.
*/
const { existsSync, mkdirSync, symlinkSync, lstatSync, readlinkSync, unlinkSync, readdirSync } = require('fs')
const { existsSync, mkdirSync, symlinkSync, cpSync, lstatSync, readlinkSync, unlinkSync } = require('fs')
const { resolve, join } = require('path')
const root = resolve(__dirname, '..')
@ -33,6 +37,7 @@ if (!existsSync(nodeModulesGsd)) {
}
let linked = 0
let copied = 0
for (const [dir, name] of Object.entries(packageMap)) {
const source = join(packagesDir, dir)
const target = join(nodeModulesGsd, name)
@ -50,21 +55,32 @@ for (const [dir, name] of Object.entries(packageMap)) {
}
unlinkSync(target) // Wrong target, relink
} else {
continue // Real directory (e.g., from bundleDependencies), don't touch
continue // Real directory (e.g., copied or from bundleDependencies), don't touch
}
} catch {
continue
}
}
let symlinkOk = false
try {
symlinkSync(source, target, 'junction') // junction works on Windows too
symlinkOk = true
linked++
} catch {
// Non-fatal — may fail in read-only environments
// Symlink failed — common on Windows without Developer Mode or admin rights.
// Fall back to a directory copy so the package is still resolvable.
}
if (!symlinkOk) {
try {
cpSync(source, target, { recursive: true })
copied++
} catch {
// Non-fatal — loader.ts will emit a clearer error if resolution still fails
}
}
}
if (linked > 0) {
process.stderr.write(` Linked ${linked} workspace packages\n`)
}
if (linked > 0) process.stderr.write(` Linked ${linked} workspace package${linked !== 1 ? 's' : ''}\n`)
if (copied > 0) process.stderr.write(` Copied ${copied} workspace package${copied !== 1 ? 's' : ''} (symlinks unavailable)\n`)

View file

@ -103,6 +103,54 @@ try {
process.exit(1);
}
// --- Verify @gsd/* packages resolved correctly post-install ---
// This catches the Windows-style failure where symlinkSync fails silently and
// node_modules/@gsd/ is never populated, causing ERR_MODULE_NOT_FOUND at runtime.
console.log('==> Verifying @gsd/* workspace package resolution...');
const installedRoot = join(installDir, 'node_modules', 'gsd-pi');
const criticalPkgs = ['pi-coding-agent'];
let resolutionFailed = false;
for (const pkg of criticalPkgs) {
const pkgPath = join(installedRoot, 'node_modules', '@gsd', pkg);
const fallbackPath = join(installedRoot, 'packages', pkg);
if (!existsSync(pkgPath)) {
if (existsSync(fallbackPath)) {
console.log(` MISSING symlink/copy: node_modules/@gsd/${pkg} (packages/${pkg} exists — postinstall may not have run)`);
} else {
console.log(` MISSING: node_modules/@gsd/${pkg} (packages/${pkg} also absent — package is broken)`);
}
resolutionFailed = true;
}
}
if (resolutionFailed) {
console.log('ERROR: @gsd/* packages are not resolvable after install.');
console.log(' This will cause ERR_MODULE_NOT_FOUND on first run (especially on Windows).');
process.exit(1);
}
console.log(' @gsd/* packages are resolvable.');
// --- Run the binary to confirm end-to-end resolution ---
console.log('==> Running installed binary (gsd -v)...');
const loaderPath = join(installedRoot, 'dist', 'loader.js');
try {
const versionOutput = execSync(`node "${loaderPath}" -v`, {
cwd: installDir,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000,
}).trim();
console.log(` gsd -v => ${versionOutput}`);
if (!versionOutput.match(/^\d+\.\d+\.\d+/)) {
console.log('ERROR: gsd -v returned unexpected output (expected a version string).');
process.exit(1);
}
} catch (err) {
console.log('ERROR: Running gsd -v failed after install.');
if (err.stdout) console.log(err.stdout);
if (err.stderr) console.log(err.stderr);
process.exit(1);
}
console.log('');
console.log('Package is installable. Safe to publish.');
process.exit(0);

View file

@ -3,7 +3,7 @@
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { fileURLToPath } from 'url'
import { dirname, resolve, join, delimiter } from 'path'
import { existsSync, readFileSync, readdirSync, mkdirSync, symlinkSync } from 'fs'
import { existsSync, readFileSync, readdirSync, mkdirSync, symlinkSync, cpSync } from 'fs'
// Fast-path: handle --version/-v and --help/-h before importing any heavy
// dependencies. This avoids loading the entire pi-coding-agent barrel import
@ -151,8 +151,12 @@ if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy
setGlobalDispatcher(new EnvHttpProxyAgent())
}
// Ensure workspace packages are linked before importing cli.js (which imports @gsd/*).
// Ensure workspace packages are linked (or copied on Windows) before importing
// cli.js (which imports @gsd/*).
// npm postinstall handles this normally, but npx --ignore-scripts skips postinstall.
// On Windows without Developer Mode or admin rights, symlinkSync will throw even for
// 'junction' type — so we fall back to cpSync (a full directory copy) which works
// everywhere without elevated permissions.
const gsdScopeDir = join(gsdNodeModules, '@gsd')
const packagesDir = join(gsdRoot, 'packages')
const wsPackages = ['native', 'pi-agent-core', 'pi-ai', 'pi-coding-agent', 'pi-tui']
@ -161,11 +165,37 @@ try {
for (const pkg of wsPackages) {
const target = join(gsdScopeDir, pkg)
const source = join(packagesDir, pkg)
if (existsSync(source) && !existsSync(target)) {
try { symlinkSync(source, target, 'junction') } catch { /* non-fatal */ }
if (!existsSync(source) || existsSync(target)) continue
try {
symlinkSync(source, target, 'junction')
} catch {
// Symlink failed (common on Windows without Developer Mode / admin).
// Fall back to a directory copy — slower on first run but universally works.
try { cpSync(source, target, { recursive: true }) } catch { /* non-fatal */ }
}
}
} catch { /* non-fatal */ }
// Validate critical workspace packages are resolvable. If still missing after the
// symlink+copy attempts, emit a clear diagnostic instead of a cryptic
// ERR_MODULE_NOT_FOUND from deep inside cli.js.
const criticalPackages = ['pi-coding-agent']
const missingPackages = criticalPackages.filter(pkg => !existsSync(join(gsdScopeDir, pkg)))
if (missingPackages.length > 0) {
const missing = missingPackages.map(p => `@gsd/${p}`).join(', ')
process.stderr.write(
`\nError: GSD installation is broken — missing packages: ${missing}\n\n` +
`This is usually caused by one of:\n` +
` • An outdated version installed from npm (run: npm install -g gsd-pi@latest)\n` +
` • The packages/ directory was excluded from the installed tarball\n` +
` • A filesystem error prevented linking or copying the workspace packages\n\n` +
`Fix it by reinstalling:\n\n` +
` npm install -g gsd-pi@latest\n\n` +
`If the issue persists, please open an issue at:\n` +
` https://github.com/gsd-build/gsd-2/issues\n`
)
process.exit(1)
}
// Dynamic import defers ESM evaluation — config.js will see PI_PACKAGE_DIR above
await import('./cli.js')