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:
parent
01a6294b23
commit
23b89c64c9
4 changed files with 121 additions and 10 deletions
17
.github/workflows/build-native.yml
vendored
17
.github/workflows/build-native.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue