@bobfrankston/npmglobalize 1.0.206 → 1.0.208

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib.d.ts +20 -0
  2. package/lib.js +197 -4
  3. package/package.json +3 -3
package/lib.d.ts CHANGED
@@ -287,6 +287,26 @@ export type UnpublishedDep = {
287
287
  path: string;
288
288
  reason: 'new' | 'update';
289
289
  };
290
+ /** Vet the casing of every `file:` dependency path, in BOTH places it is recorded:
291
+ * the spec in package.json and the junction npm created in node_modules.
292
+ *
293
+ * npm resolves a `file:` spec by string concatenation against the cwd and writes
294
+ * the result as the link target — it never canonicalizes case, and never checks
295
+ * that the target exists. On Windows that rots invisibly: `projects/nodejs/x`
296
+ * resolves fine when the directory is really `projects/NodeJS/x`, so nothing
297
+ * complains until the path reaches somewhere case matters. Two places it does:
298
+ * WSL and Linux deploys can't resolve it at all, and npm's own arborist keys
299
+ * nodes by path string, so one directory reached under two spellings becomes two
300
+ * nodes and tree loading dies with "Cannot read properties of null".
301
+ *
302
+ * Both records must be repaired together. Fixing only the manifest leaves the
303
+ * stale junction in place (npm will not recreate a link it thinks is satisfied);
304
+ * fixing only the junction lets the next `npm install` write the bad case back. */
305
+ export declare function verifyDepPathCase(cwd: string, pkg: any): {
306
+ specs: string[];
307
+ links: string[];
308
+ missing: string[];
309
+ };
290
310
  export declare function transformDeps(pkg: any, baseDir: string, verbose?: boolean, forcePublish?: boolean): {
291
311
  transformed: boolean;
292
312
  unpublished: UnpublishedDep[];
package/lib.js CHANGED
@@ -1320,6 +1320,125 @@ function hasLocalChanges(packageName, version, targetPath, verbose) {
1320
1320
  return false;
1321
1321
  }
1322
1322
  }
1323
+ /** The canonical on-disk spelling of `target`, or null if it does not exist.
1324
+ * Windows answers path queries case-insensitively, so the only way to learn how
1325
+ * a directory is really spelled is to ask its parent to list it — a segment at a
1326
+ * time, from the root down. */
1327
+ function canonicalCase(target) {
1328
+ const full = path.resolve(target);
1329
+ if (!fs.existsSync(full))
1330
+ return null;
1331
+ const parsed = path.parse(full);
1332
+ const parts = full.slice(parsed.root.length).split(/[\\/]/).filter(Boolean);
1333
+ let cur = parsed.root.toUpperCase();
1334
+ for (const part of parts) {
1335
+ let entries;
1336
+ try {
1337
+ entries = fs.readdirSync(cur);
1338
+ }
1339
+ catch {
1340
+ return null;
1341
+ }
1342
+ const real = entries.find(e => e.toLowerCase() === part.toLowerCase());
1343
+ if (!real)
1344
+ return null;
1345
+ cur = path.join(cur, real);
1346
+ }
1347
+ return cur;
1348
+ }
1349
+ /** True when two paths name the same place but are spelled with different case
1350
+ * in a directory name. A bare drive-letter difference (`y:` vs `Y:`) doesn't
1351
+ * count — it resolves identically everywhere, including WSL's /mnt/y. */
1352
+ function caseDiffers(a, b) {
1353
+ const strip = (p) => p.replace(/[\\/]+$/, '').slice(path.parse(p).root.length);
1354
+ return strip(a) !== strip(b);
1355
+ }
1356
+ /** Vet the casing of every `file:` dependency path, in BOTH places it is recorded:
1357
+ * the spec in package.json and the junction npm created in node_modules.
1358
+ *
1359
+ * npm resolves a `file:` spec by string concatenation against the cwd and writes
1360
+ * the result as the link target — it never canonicalizes case, and never checks
1361
+ * that the target exists. On Windows that rots invisibly: `projects/nodejs/x`
1362
+ * resolves fine when the directory is really `projects/NodeJS/x`, so nothing
1363
+ * complains until the path reaches somewhere case matters. Two places it does:
1364
+ * WSL and Linux deploys can't resolve it at all, and npm's own arborist keys
1365
+ * nodes by path string, so one directory reached under two spellings becomes two
1366
+ * nodes and tree loading dies with "Cannot read properties of null".
1367
+ *
1368
+ * Both records must be repaired together. Fixing only the manifest leaves the
1369
+ * stale junction in place (npm will not recreate a link it thinks is satisfied);
1370
+ * fixing only the junction lets the next `npm install` write the bad case back. */
1371
+ export function verifyDepPathCase(cwd, pkg) {
1372
+ const specs = [];
1373
+ const links = [];
1374
+ const missing = [];
1375
+ for (const bucket of ['dependencies', 'devDependencies', '.dependencies']) {
1376
+ const deps = pkg?.[bucket];
1377
+ if (!deps || typeof deps !== 'object')
1378
+ continue;
1379
+ for (const [name, spec] of Object.entries(deps)) {
1380
+ if (typeof spec !== 'string' || !spec.startsWith('file:'))
1381
+ continue;
1382
+ const abs = path.resolve(cwd, spec.slice('file:'.length));
1383
+ const canonical = canonicalCase(abs);
1384
+ if (!canonical) {
1385
+ missing.push(`${name} -> ${spec} (no such directory)`);
1386
+ continue;
1387
+ }
1388
+ if (!caseDiffers(abs, canonical))
1389
+ continue;
1390
+ // Keep the spec relative, the way it was written.
1391
+ const rel = path.relative(cwd, canonical).split(path.sep).join('/');
1392
+ deps[name] = `file:${rel}`;
1393
+ specs.push(`${name}: ${spec} -> file:${rel}`);
1394
+ }
1395
+ }
1396
+ // The junction npm already made from the old spelling.
1397
+ for (const bucket of ['dependencies', 'devDependencies', '.dependencies']) {
1398
+ const deps = pkg?.[bucket];
1399
+ if (!deps || typeof deps !== 'object')
1400
+ continue;
1401
+ for (const [name, spec] of Object.entries(deps)) {
1402
+ if (typeof spec !== 'string' || !spec.startsWith('file:'))
1403
+ continue;
1404
+ const linkPath = path.join(cwd, 'node_modules', ...name.split('/'));
1405
+ let stat;
1406
+ try {
1407
+ stat = fs.lstatSync(linkPath);
1408
+ }
1409
+ catch {
1410
+ continue;
1411
+ }
1412
+ if (!stat.isSymbolicLink())
1413
+ continue;
1414
+ let current;
1415
+ try {
1416
+ current = fs.readlinkSync(linkPath).replace(/[\\/]+$/, '');
1417
+ }
1418
+ catch {
1419
+ continue;
1420
+ }
1421
+ const canonical = canonicalCase(current);
1422
+ if (!canonical || !caseDiffers(current, canonical))
1423
+ continue;
1424
+ try {
1425
+ // On Windows a directory junction is removed with rmdir, not unlink,
1426
+ // and neither follows the link — which matters, because deleting
1427
+ // through it would take the real package tree with it.
1428
+ if (process.platform === 'win32')
1429
+ fs.rmdirSync(linkPath);
1430
+ else
1431
+ fs.unlinkSync(linkPath);
1432
+ fs.symlinkSync(canonical, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
1433
+ links.push(`${name}: ${current} -> ${canonical}`);
1434
+ }
1435
+ catch (error) {
1436
+ missing.push(`${name}: could not repair link (${error.message})`);
1437
+ }
1438
+ }
1439
+ }
1440
+ return { specs, links, missing };
1441
+ }
1323
1442
  export function transformDeps(pkg, baseDir, verbose = false, forcePublish = false) {
1324
1443
  let transformed = false;
1325
1444
  const unpublished = [];
@@ -4180,6 +4299,56 @@ async function waitForNpmVersionInWsl(spec, maxWaitMs = 180000) {
4180
4299
  process.stdout.write(' timed out\n');
4181
4300
  return false;
4182
4301
  }
4302
+ /** After a global WSL install, confirm a NON-interactive shell actually resolves
4303
+ * the binaries we just installed, rather than older copies of them.
4304
+ *
4305
+ * WSL's npm prefix is per-user (~/.npm-global), and that directory reaches PATH
4306
+ * only via ~/.bashrc — which returns immediately when the shell is not
4307
+ * interactive. Every `wsl <cmd>` is non-interactive, so it searches the default
4308
+ * PATH from /etc/environment instead, where a stale /usr/local/bin entry left by
4309
+ * an earlier root-prefix install keeps winning. The install genuinely succeeds
4310
+ * and the version reported by `wsl <tool>` is genuinely stale, which is exactly
4311
+ * why this went unnoticed for four months. Repair with a symlink rather than a
4312
+ * copy, so it tracks later installs on its own. */
4313
+ async function verifyWslGlobalBins(cwd) {
4314
+ let names = [];
4315
+ try {
4316
+ const pkg = readPackageJson(cwd);
4317
+ if (typeof pkg?.bin === 'string')
4318
+ names = [String(pkg.name || '').split('/').pop()].filter(Boolean);
4319
+ else if (pkg?.bin && typeof pkg.bin === 'object')
4320
+ names = Object.keys(pkg.bin);
4321
+ }
4322
+ catch {
4323
+ return;
4324
+ }
4325
+ if (!names.length)
4326
+ return;
4327
+ const prefixResult = await runCommandAsync('wsl', ['npm', 'config', 'get', 'prefix'], { silent: true });
4328
+ const prefix = (prefixResult.output || '').trim();
4329
+ if (!prefixResult.success || !prefix.startsWith('/'))
4330
+ return;
4331
+ for (const name of names) {
4332
+ const expected = `${prefix}/bin/${name}`;
4333
+ // `bash -c` is non-interactive — the same shape as a bare `wsl <cmd>`,
4334
+ // which is the case that breaks. A login shell would mask the problem.
4335
+ const lookup = await runCommandAsync('wsl', ['bash', '-c', `command -v ${name} || true`], { silent: true });
4336
+ const found = (lookup.output || '').trim();
4337
+ if (!found || found === expected)
4338
+ continue;
4339
+ console.log(colors.yellow(` WSL resolves ${name} to ${found}, not the copy just installed at ${expected}`));
4340
+ if (!found.startsWith('/usr/local/bin/')) {
4341
+ recordBuildIssue(name, 'warning', `WSL resolves ${name} to ${found} instead of ${expected}, so scripts run a different version than an interactive shell does. Left alone: only /usr/local/bin shadows are repaired automatically.`);
4342
+ continue;
4343
+ }
4344
+ const repair = await runCommandAsync('wsl', ['bash', '-c', `sudo -n rm -f ${found} && sudo -n ln -s ${expected} ${found}`], { silent: true });
4345
+ if (repair.success) {
4346
+ console.log(colors.green(` ✓ Repaired WSL shadow: ${found} -> ${expected}`));
4347
+ continue;
4348
+ }
4349
+ recordBuildIssue(name, 'warning', `WSL resolves ${name} to a stale ${found}. Repair with: wsl sudo rm ${found} && wsl sudo ln -s ${expected} ${found}`);
4350
+ }
4351
+ }
4183
4352
  export async function installInWsl(wslArgs, opts = {}) {
4184
4353
  // Same trust rule as the Windows installs: allow our own packages'
4185
4354
  // install scripts, leave third-party ones gated. Probed against WSL's
@@ -4198,9 +4367,16 @@ export async function installInWsl(wslArgs, opts = {}) {
4198
4367
  process.stderr.write(r.stderr);
4199
4368
  return r;
4200
4369
  };
4370
+ // Every success path leaves through here, so the shadow check cannot be
4371
+ // skipped by whichever retry happened to be the one that worked.
4372
+ const succeed = async (fixed) => {
4373
+ if (opts.cwd && wslArgs.includes('-g'))
4374
+ await verifyWslGlobalBins(opts.cwd);
4375
+ return { success: true, fixed };
4376
+ };
4201
4377
  let result = await runOnce();
4202
4378
  if (result.success)
4203
- return { success: true, fixed: false };
4379
+ return await succeed(false);
4204
4380
  let combined = (result.output || '') + '\n' + (result.stderr || '');
4205
4381
  // EACCES on a root-owned npm prefix → switch to a user prefix and retry.
4206
4382
  if (/EACCES/.test(combined) && /\/usr\/(?:local\/)?lib\/node_modules/.test(combined)) {
@@ -4216,7 +4392,7 @@ export async function installInWsl(wslArgs, opts = {}) {
4216
4392
  console.log(colors.green('✓ WSL npm prefix set to ~/.npm-global; PATH appended to ~/.bashrc'));
4217
4393
  result = await runOnce();
4218
4394
  if (result.success)
4219
- return { success: true, fixed: true };
4395
+ return await succeed(true);
4220
4396
  combined = (result.output || '') + '\n' + (result.stderr || '');
4221
4397
  }
4222
4398
  // E404 on a scoped package has TWO causes, and npm gives the same error for
@@ -4233,7 +4409,7 @@ export async function installInWsl(wslArgs, opts = {}) {
4233
4409
  console.log(colors.green('✓ Synced npm token to WSL'));
4234
4410
  result = await runOnce();
4235
4411
  if (result.success)
4236
- return { success: true, fixed: true };
4412
+ return await succeed(true);
4237
4413
  }
4238
4414
  else {
4239
4415
  console.error(colors.yellow(' Could not authenticate WSL npm. Run `wsl npm login` (or sync your ~/.npmrc token) and retry.'));
@@ -4245,7 +4421,7 @@ export async function installInWsl(wslArgs, opts = {}) {
4245
4421
  if (await waitForNpmVersionInWsl(spec)) {
4246
4422
  result = await runOnce();
4247
4423
  if (result.success)
4248
- return { success: true, fixed: true };
4424
+ return await succeed(true);
4249
4425
  }
4250
4426
  else {
4251
4427
  console.error(colors.yellow(` ${spec} still not visible to WSL's npm after waiting — try the WSL install again shortly.`));
@@ -6601,6 +6777,23 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
6601
6777
  }
6602
6778
  }
6603
6779
  }
6780
+ // Vet file: path casing before transformDeps swaps the specs out — this is the
6781
+ // last moment the real paths are still in hand. Repairs the manifest and the
6782
+ // node_modules junction together; fixing either alone is undone by the other.
6783
+ const caseCheck = verifyDepPathCase(cwd, pkg);
6784
+ if (caseCheck.specs.length || caseCheck.links.length) {
6785
+ for (const fix of caseCheck.specs)
6786
+ console.log(colors.green(` ✓ Corrected file: path case in package.json — ${fix}`));
6787
+ for (const fix of caseCheck.links)
6788
+ console.log(colors.green(` ✓ Corrected node_modules link case — ${fix}`));
6789
+ if (caseCheck.specs.length)
6790
+ writePackageJson(cwd, pkg);
6791
+ recordBuildIssue(pkg.name || path.basename(cwd), 'warning', `Corrected ${caseCheck.specs.length} file: path spec(s) and ${caseCheck.links.length} node_modules link(s) whose letter case did not match the directory on disk. npm does not canonicalize these, and the wrong case breaks WSL, Linux deploys, and npm's own tree loading.`);
6792
+ }
6793
+ for (const gone of caseCheck.missing) {
6794
+ console.error(colors.red(` ✗ file: dependency path does not exist — ${gone}`));
6795
+ recordBuildIssue(pkg.name || path.basename(cwd), 'error', `file: dependency path does not exist — ${gone}`);
6796
+ }
6604
6797
  // Transform dependencies
6605
6798
  if (verbose) {
6606
6799
  console.log('Transforming file: dependencies...');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.206",
3
+ "version": "1.0.208",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@bobfrankston/freezepak": "^0.1.9",
35
35
  "@bobfrankston/importgen": "^0.1.40",
36
- "@bobfrankston/themecolors": "^0.1.8",
36
+ "@bobfrankston/themecolors": "^0.1.9",
37
37
  "@bobfrankston/userconfig": "^1.0.11",
38
38
  "@npmcli/package-json": "^7.0.4",
39
39
  "json5": "^2.2.3",
@@ -61,7 +61,7 @@
61
61
  "dependencies": {
62
62
  "@bobfrankston/freezepak": "^0.1.9",
63
63
  "@bobfrankston/importgen": "^0.1.40",
64
- "@bobfrankston/themecolors": "^0.1.8",
64
+ "@bobfrankston/themecolors": "^0.1.9",
65
65
  "@bobfrankston/userconfig": "^1.0.11",
66
66
  "@npmcli/package-json": "^7.0.4",
67
67
  "json5": "^2.2.3",