@bobfrankston/npmglobalize 1.0.219 → 1.0.221

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 (5) hide show
  1. package/README.md +52 -10
  2. package/cli.js +6 -1
  3. package/lib.d.ts +27 -10
  4. package/lib.js +312 -34
  5. package/package.json +3 -3
package/README.md CHANGED
@@ -172,15 +172,57 @@ exits 0 — e.g. `@bobfrankston/msger`'s postinstall copies its native binary
172
172
  into the per-user bin dir its launcher reads from, and skipping it leaves a
173
173
  stale exe behind a green checkmark.
174
174
 
175
- npmglobalize therefore allowlists **your own packages** on the global install
176
- it performs it just built and published them from your source, so they're
177
- trusted and leaves third-party packages gated:
178
-
179
- ```
180
- > npm install -g @bobfrankston/winpos@2.0.51 --allow-scripts @bobfrankston/winpos,@bobfrankston/msger,@bobfrankston/msgcommon
181
- ```
182
-
183
- Anything npm skips is reported rather than buried in the captured output:
175
+ A **project** install is the opposite case: npm *rejects* `--allow-scripts`
176
+ there and reads the policy from the `allowScripts` field of the root
177
+ `package.json` (maintained by `npm approve-scripts` / `npm deny-scripts`),
178
+ falling back to `allow-scripts` in `.npmrc` only when the field is absent.
179
+ Until that field covers everything installed, every `npm install` ends with a
180
+ `npm warn allow-scripts … not yet covered by allowScripts` block.
181
+
182
+ npmglobalize keeps that field current, once per run, right after the
183
+ node_modules check:
184
+
185
+ - **Own-scope packages and `file:` deps are approved without asking** — they
186
+ are your source.
187
+ - **Each third-party package is asked about**, showing the script npm would
188
+ run and whether it is a prebuilt-binary fetcher:
189
+
190
+ ```
191
+ npm gates install scripts for 2 third-party package(s) in @bobfrankston/whts. Approvals are recorded in package.json "allowScripts" (name-only) and honored on the global install too.
192
+ Allow install scripts for sharp@0.33.5 (install: node install/check)? [y]es / [n]o, deny / [a]ll remaining / [s]kip for now: y
193
+ Allow install scripts for koffi@2.14.1|2.16.1 (install: node src/cnoke/cnoke.js --prebuild) — prebuilt-binary fetcher, the package ships binaries? [y]es / [n]o, deny / [a]ll remaining / [s]kip for now: y
194
+ ✓ allowScripts: approved @bobfrankston/msger, com-wrapper, sharp, koffi (2 own/local approved automatically)
195
+ ```
196
+
197
+ `y` approves, `n` records a deny (`"pkg": false`, npm's own mechanism — the
198
+ question is not repeated; delete the entry to reconsider), `a` approves
199
+ everything remaining, `s` leaves it pending for next time. Approvals are
200
+ **name-only** (`--no-allow-scripts-pin`): a version pin would re-open the
201
+ question on every bump. Without a terminal (piped stdin) nothing is asked;
202
+ the pending packages are listed with the command to approve them.
203
+ - The field is written by npm itself, so a linked dep gets the key npm wants
204
+ for it — the `file:` spec, e.g. `"file:../../../../projects/com/com-wrapper": true`
205
+ (relative to the link's parent directory, not the project).
206
+
207
+ The **global** install then allowlists your own packages — it just built and
208
+ published them from your source, so they're trusted — plus whatever the
209
+ project's `allowScripts` approved (a `file:` key is mapped to that dep's
210
+ published name) and your user `.npmrc` `allow-scripts` list, minus anything
211
+ the project denied. Third-party packages you never approved stay gated:
212
+
213
+ ```
214
+ > npm install -g @bobfrankston/winpos@2.0.51 --allow-scripts @bobfrankston/winpos,@bobfrankston/msger,@bobfrankston/msgcommon,sharp
215
+ ```
216
+
217
+ npm takes the allowlist from the *first* source that has one rather than
218
+ merging — CLI flag, then `package.json`, then `.npmrc` — which is why the
219
+ `.npmrc` list is folded into the flag here, and why npm prints
220
+ `.npmrc allow-scripts setting is being ignored because package.json declares
221
+ its own allowScripts field` on project installs once the field exists. That
222
+ warning is npm's layering at work, not a problem.
223
+
224
+ Anything npm skips on the global install is reported rather than buried in
225
+ the captured output:
184
226
 
185
227
  ```
186
228
  · Skipped koffi@2.16.3 install script — prebuilt-binary fetcher, the package ships binaries; normally harmless.
@@ -756,7 +798,7 @@ As with import maps, these have historically been built only by a second `.vscod
756
798
  Before building, `npmglobalize` looks for sub-projects, in this order:
757
799
 
758
800
  1. `.vscode/tasks.json` has a task that runs `tsc` or `importgen` with `"options": { "cwd": "${workspaceFolder}/client" }`, or runs `tsc` with `-p`/`--project` naming a sub-directory (authoritative — it's how the project is actually built today; the task's `label` is quoted back in the prompt).
759
- 2. An immediate sub-directory containing its own `package.json` or `tsconfig.json`. A sub-package without a build script gets one set up the same way the root does (importgen, `tsc`, its own sub-projects) before the root delegates to it. Build output and vendored trees (`node_modules`, `prev`, `dist`, `built`, `out`, `wwwroot`, `coverage`, `temp`, `preflight`, dot-directories) are never scanned.
801
+ 2. An immediate sub-directory that is a build of its own: a `package.json` with a `build` script or an import map to regenerate (a sub-package gets its build script set up the same way the root does importgen, `tsc`, its own sub-projects before the root delegates to it), or a `tsconfig.json` the root `tsconfig.json` does not already compile. A `package.json` that only holds dependencies for files the root `tsc` compiles (`whts/pdfToImg`) is not a sub-project. Build output and vendored trees (`node_modules`, `prev`, `dist`, `built`, `out`, `wwwroot`, `coverage`, `temp`, `preflight`, dot-directories) are never scanned.
760
802
 
761
803
  Nothing is detected when the root `tsconfig.json` uses project `references` — that build graph belongs to `tsc -b` and isn't second-guessed.
762
804
 
package/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * npmglobalize CLI - Transform file: dependencies to npm versions for publishing
4
4
  */
5
- import { globalize, globalizeWorkspace, installCleanupHandlers, readConfig, readPackageJson, readUserNpmConfig, writeConfig, writePackageJson, getBuildIssues, clearBuildIssues, ensureFileDepModules, buildProject, buildFileDepsTopologically, reportTs7Deprecations, fixTs7Deprecations, canonicalCase } from './lib.js';
5
+ import { globalize, globalizeWorkspace, installCleanupHandlers, readConfig, readPackageJson, readUserNpmConfig, writeConfig, writePackageJson, getBuildIssues, clearBuildIssues, ensureFileDepModules, ensureAllowScripts, buildProject, buildFileDepsTopologically, reportTs7Deprecations, fixTs7Deprecations, canonicalCase } from './lib.js';
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
8
  import { colors } from './colors.js';
@@ -609,6 +609,11 @@ export async function main() {
609
609
  }
610
610
  if (!modulesOk)
611
611
  console.log(colors.warn('Continuing with -force despite npm install failure...'));
612
+ // 2026-09-07 — Claude Code (Fable 5.1), at Bob's direction: keep package.json
613
+ // "allowScripts" current — own/file: deps approved, third-party ones asked about —
614
+ // so npm's install-script gate stops warning and the global install honors it.
615
+ if (modulesOk)
616
+ await ensureAllowScripts(cwd, { dryRun: !!cliOptions.dryRun, verbose: !!cliOptions.verbose });
612
617
  const depsOk = await buildFileDepsTopologically(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force, forceBuild: !!cliOptions.forceBuild });
613
618
  if (!depsOk && !cliOptions.force) {
614
619
  printBuildSummary();
package/lib.d.ts CHANGED
@@ -397,16 +397,6 @@ export declare function missingDeps(pkgDir: string, pkg: any): string[];
397
397
  * re-run" (partial-sync) cases. Also covers `cwd` itself on the first call.
398
398
  * Cycle-safe via the shared `visited` set. */
399
399
  export declare function ensureFileDepModules(cwd: string, verbose?: boolean, visited?: Set<string>): Promise<boolean>;
400
- /** Cheap freshness check so the build cascade can skip packages whose output is
401
- * already current. Returns true only when provably up to date:
402
- * - outDir projects: newest source (.ts/.tsx/.mts/.cts + tsconfig*.json) must
403
- * not be newer than the newest file under outDir.
404
- * - side-by-side projects: every source must have an emitted sibling
405
- * (.js/.jsx/.mjs/.cjs or declaration) at least as new.
406
- * Conservative: project references, allowJs, missing outputs, or unreadable
407
- * tsconfig all report stale (→ build). package.json mtime is deliberately
408
- * ignored — npmglobalize itself rewrites it around every publish, which would
409
- * otherwise force a rebuild on every run. */
410
400
  export declare function isBuildUpToDate(cwd: string): boolean;
411
401
  /** Detect whether a package is an importgen project — a browser app whose HTML
412
402
  * carries a generated `<script type="importmap">`. Signals, in order:
@@ -569,6 +559,33 @@ export declare function fixPuppeteerCorruptedCache(combined: string): {
569
559
  * Returns [] for an unscoped package: there's no way to tell an unscoped
570
560
  * package of ours from a third-party one by name alone. */
571
561
  export declare function ownScopePackages(dir: string): string[];
562
+ /** The project's recorded install-script policy, as registry names. Keys in
563
+ * `allowScripts` are `name`, `name@version`, or a `file:` / absolute path;
564
+ * path keys are mapped to the target's manifest name so an approval given
565
+ * to a linked dep carries over to the global install, where that same dep
566
+ * arrives from the registry under its published name. */
567
+ export declare function projectAllowScripts(dir: string): {
568
+ allowed: string[];
569
+ denied: string[];
570
+ };
571
+ /** Bring the project's package.json `allowScripts` up to date with what is
572
+ * installed, so `npm install` stops warning and — once npm starts enforcing
573
+ * the policy — keeps running the scripts this project depends on.
574
+ *
575
+ * Own-scope packages and `file:` deps are approved without asking: they are
576
+ * this user's source. Anything else is asked about, one package at a time,
577
+ * showing the script npm would run. "no" records a deny (npm's own
578
+ * mechanism, `npm deny-scripts`) so the question isn't repeated; "skip"
579
+ * leaves it pending for next time. Approvals are name-only
580
+ * (`--no-allow-scripts-pin`): a version pin would re-open the question on
581
+ * every bump, which is exactly the churn this exists to remove.
582
+ *
583
+ * Runs once per project root, not per cascade dep: npm reads the policy
584
+ * from the root package.json only. ~1.5s (arborist loads the actual tree). */
585
+ export declare function ensureAllowScripts(dir: string, opts?: {
586
+ dryRun?: boolean;
587
+ verbose?: boolean;
588
+ }): Promise<void>;
572
589
  /** Run a command and return success status */
573
590
  export declare function runCommand(cmd: string, args: string[], options?: {
574
591
  silent?: boolean;
package/lib.js CHANGED
@@ -2888,6 +2888,35 @@ function newestMtimeUnder(dir) {
2888
2888
  * tsconfig all report stale (→ build). package.json mtime is deliberately
2889
2889
  * ignored — npmglobalize itself rewrites it around every publish, which would
2890
2890
  * otherwise force a rebuild on every run. */
2891
+ /** The directories a parsed tsconfig's `exclude` keeps out of the compile, as
2892
+ * lower-cased relative paths. Matches on the literal path prefix (glob tails
2893
+ * like "tests/**" reduce to "tests"). */
2894
+ function tsconfigExcludedDirs(tsconfig) {
2895
+ return (Array.isArray(tsconfig?.exclude) ? tsconfig.exclude : [])
2896
+ .filter((x) => typeof x === 'string')
2897
+ .map((x) => x.replace(/^\.\//, '').replace(/[\\/]?\*.*$/, '').replace(/[\\/]+$/, '').toLowerCase())
2898
+ .filter((x) => x.length > 0 && !x.includes('*'));
2899
+ }
2900
+ /** True when the root tsconfig in `cwd` already compiles the sources under
2901
+ * `rel`: it exists, doesn't narrow the compile with `files`/`include`, and
2902
+ * doesn't `exclude` the directory. Conservative — a root that uses `include`
2903
+ * is treated as not covering the sub-directory. */
2904
+ function rootTsconfigCompiles(cwd, rel) {
2905
+ let tsconfig;
2906
+ try {
2907
+ tsconfig = JSON5.parse(fs.readFileSync(path.join(cwd, 'tsconfig.json'), 'utf-8'));
2908
+ }
2909
+ catch {
2910
+ return false;
2911
+ }
2912
+ if (Array.isArray(tsconfig.files) || Array.isArray(tsconfig.include))
2913
+ return false;
2914
+ const target = normalizeProjectPath(rel).toLowerCase();
2915
+ const parts = target.split('/');
2916
+ const excludes = tsconfigExcludedDirs(tsconfig);
2917
+ // Excluding a parent excludes everything under it.
2918
+ return !parts.some((_, i) => excludes.includes(parts.slice(0, i + 1).join('/')));
2919
+ }
2891
2920
  export function isBuildUpToDate(cwd) {
2892
2921
  let tsconfig;
2893
2922
  try {
@@ -2901,12 +2930,8 @@ export function isBuildUpToDate(cwd) {
2901
2930
  return false;
2902
2931
  const outDir = typeof co.outDir === 'string' ? path.resolve(cwd, co.outDir) : null;
2903
2932
  // tsconfig "exclude" dirs aren't compiled, so their .ts files never get
2904
- // outputs — don't count them as sources. Match on the literal path prefix
2905
- // (glob tails like "tests/**" reduce to "tests").
2906
- const excludes = (Array.isArray(tsconfig.exclude) ? tsconfig.exclude : [])
2907
- .filter((x) => typeof x === 'string')
2908
- .map((x) => x.replace(/^\.\//, '').replace(/[\\/]?\*.*$/, '').replace(/[\\/]+$/, '').toLowerCase())
2909
- .filter((x) => x.length > 0 && !x.includes('*'));
2933
+ // outputs — don't count them as sources.
2934
+ const excludes = tsconfigExcludedDirs(tsconfig);
2910
2935
  const sources = [];
2911
2936
  const collect = (dir, rel) => {
2912
2937
  let entries;
@@ -3198,13 +3223,31 @@ function resolveOnDiskCase(base, rel) {
3198
3223
  }
3199
3224
  return out.length ? out.join('/') : null;
3200
3225
  }
3201
- /** Classify `rel` under `cwd`: a package (its own package.json its build
3202
- * script is set up by `ensureBuildScript` if it lacks one), a plain tsconfig
3203
- * sub-project, or null when it's neither. */
3226
+ /** Classify `rel` under `cwd`: a package (its own package.json with a build
3227
+ * script, or an importgen project `ensureBuildScript` sets its script up),
3228
+ * a plain tsconfig sub-project the root tsc doesn't compile, or null when it
3229
+ * is neither.
3230
+ * 2026-09-06 — Claude Code (Fable 5.1), at Bob's direction. whts/pdfToImg has
3231
+ * a package.json that only holds puppeteer/sharp for a file the root tsc
3232
+ * already compiles; treating every package.json as a sub-package gave it a
3233
+ * `build: tsc` it never needed and a second compile under its stale tsconfig.
3234
+ * A package.json is a build of its own only when it has something the root
3235
+ * can't do — its own build script, or an import map to regenerate. */
3204
3236
  function subProjectKind(cwd, rel) {
3205
- if (fs.existsSync(path.join(cwd, rel, 'package.json')))
3206
- return 'package';
3207
- return fs.existsSync(path.join(cwd, rel, 'tsconfig.json')) ? 'tsconfig' : null;
3237
+ const dir = path.join(cwd, rel);
3238
+ if (fs.existsSync(path.join(dir, 'package.json'))) {
3239
+ let pkg = {};
3240
+ try {
3241
+ pkg = readPackageJson(dir);
3242
+ }
3243
+ catch { /* unreadable — treat as having no build script */ }
3244
+ const hasBuild = typeof pkg?.scripts?.build === 'string' && pkg.scripts.build.trim();
3245
+ if (hasBuild || detectImportgen(dir))
3246
+ return 'package';
3247
+ }
3248
+ if (!fs.existsSync(path.join(dir, 'tsconfig.json')))
3249
+ return null;
3250
+ return rootTsconfigCompiles(cwd, rel) ? null : 'tsconfig';
3208
3251
  }
3209
3252
  /** The command the root build script runs for a sub-project. */
3210
3253
  function subProjectBuildCommand(s) {
@@ -3505,7 +3548,7 @@ export async function buildProject(cwd, opts = {}) {
3505
3548
  // tsconfig.json, which makes the freshness check correctly read as stale.
3506
3549
  const tsconfigSnapshot = new Map();
3507
3550
  const issueMark = markBuildIssues();
3508
- const migratedUpFront = fs.existsSync(path.join(cwd, 'tsconfig.json')) && migrateTsconfigDeprecations(cwd, tsconfigSnapshot);
3551
+ let migratedUpFront = fs.existsSync(path.join(cwd, 'tsconfig.json')) && migrateTsconfigDeprecations(cwd, tsconfigSnapshot);
3509
3552
  const setup = await ensureBuildScript(cwd);
3510
3553
  if (!setup)
3511
3554
  return true;
@@ -3514,6 +3557,16 @@ export async function buildProject(cwd, opts = {}) {
3514
3557
  // freshness — one the user declined to wire in isn't this build's business.
3515
3558
  const finalBuild = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '';
3516
3559
  const wiredSubs = detectSubProjects(cwd).filter(s => scriptBuildsSubProject(finalBuild, s.dir));
3560
+ // 2026-09-06 — Claude Code (Fable 5.1), at Bob's direction.
3561
+ // The sub-projects this build runs get the same TS7 migration as the root.
3562
+ // whts: `tsc -p pdfToImg` failed on pdfToImg/tsconfig.json's
3563
+ // `moduleResolution: node10` (TS5107) and the retry below re-migrated only
3564
+ // the root chain, which had nothing left to fix — so no retry ever happened.
3565
+ const migrateSubProjects = () => wiredSubs
3566
+ .map(s => migrateTsconfigDeprecations(path.join(cwd, s.dir), tsconfigSnapshot))
3567
+ .some(Boolean);
3568
+ if (migrateSubProjects())
3569
+ migratedUpFront = true;
3517
3570
  // A migration just changed how modules resolve — never report that as fresh;
3518
3571
  // the rebuild is what validates it (and what the revert path above needs).
3519
3572
  if (!opts.forceBuild && !migratedUpFront && isBuildUpToDate(cwd) && areSubProjectsUpToDate(cwd, wiredSubs)
@@ -3529,7 +3582,7 @@ export async function buildProject(cwd, opts = {}) {
3529
3582
  // We don't silence with ignoreDeprecations — the retry may now surface real
3530
3583
  // resolution errors, which are genuine bugs to fix, not deprecations.
3531
3584
  const out = (buildResult.stderr || '') + (buildResult.output || '');
3532
- if (/error TS510[17]\b/.test(out) && migrateTsconfigDeprecations(cwd, tsconfigSnapshot)) {
3585
+ if (/error TS510[17]\b/.test(out) && [migrateTsconfigDeprecations(cwd, tsconfigSnapshot), migrateSubProjects()].some(Boolean)) {
3533
3586
  buildResult = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
3534
3587
  }
3535
3588
  }
@@ -4161,20 +4214,238 @@ export function ownScopePackages(dir) {
4161
4214
  walk(pkg, dir);
4162
4215
  return [...found];
4163
4216
  }
4164
- /** npm args allowing install scripts for our own packages only. Empty when
4165
- * npm predates the policy (it runs the scripts anyway) or the package is
4166
- * unscoped.
4167
- *
4168
- * Note: the CLI layer *replaces* the .npmrc layer rather than merging with
4169
- * it (first source with any config wins, per npm's resolve-allow-scripts),
4170
- * so passing this suppresses any allow-scripts entries in .npmrc for this
4171
- * install. Third-party approvals therefore belong on the command line too,
4172
- * not in .npmrc. */
4173
- function allowOwnScriptsArgs(dir) {
4217
+ // 2026-09-07 Claude Code (Fable 5.1), at Bob's direction ("I want you to handle
4218
+ // this", after `npm un itemgen` in whts warned about com-wrapper, msger and sharp).
4219
+ // The project side of the policy. npm REJECTS `--allow-scripts` on a project-scoped
4220
+ // install (EALLOWSCRIPTS), so the only place a project's approvals can live is the
4221
+ // `allowScripts` field of its package.json, maintained by `npm approve-scripts`.
4222
+ // npmglobalize now keeps that field current (ensureAllowScripts) and feeds it back
4223
+ // into the global install's `--allow-scripts` flag (allowScriptsArgs), so a package
4224
+ // is trusted once, in one place, for both kinds of install.
4225
+ /** Names of the project's `file:` dependencies as npm displays them in the
4226
+ * allowScripts pending list. A linked dep has no registry identity, so npm
4227
+ * falls back to the link target's directory name (`com-wrapper` for
4228
+ * `@bobfrankston/com-wrapper` at ../../projects/com/com-wrapper); the
4229
+ * manifest name is included too in case that fallback ever changes. */
4230
+ function localDepDisplayNames(dir) {
4231
+ let pkg;
4232
+ try {
4233
+ pkg = readPackageJson(dir);
4234
+ }
4235
+ catch {
4236
+ return [];
4237
+ }
4238
+ const names = [];
4239
+ for (const key of ['dependencies', 'devDependencies', 'optionalDependencies']) {
4240
+ const deps = pkg[key];
4241
+ if (!deps || typeof deps !== 'object')
4242
+ continue;
4243
+ for (const spec of Object.values(deps)) {
4244
+ if (typeof spec !== 'string' || !spec.startsWith('file:'))
4245
+ continue;
4246
+ const target = path.resolve(dir, spec.slice('file:'.length));
4247
+ names.push(path.basename(target));
4248
+ try {
4249
+ const name = readPackageJson(target).name;
4250
+ if (name)
4251
+ names.push(name);
4252
+ }
4253
+ catch { /* target missing or unreadable — the basename alone still identifies it */ }
4254
+ }
4255
+ }
4256
+ return names;
4257
+ }
4258
+ /** The project's recorded install-script policy, as registry names. Keys in
4259
+ * `allowScripts` are `name`, `name@version`, or a `file:` / absolute path;
4260
+ * path keys are mapped to the target's manifest name so an approval given
4261
+ * to a linked dep carries over to the global install, where that same dep
4262
+ * arrives from the registry under its published name. */
4263
+ export function projectAllowScripts(dir) {
4264
+ const allowed = [];
4265
+ const denied = [];
4266
+ let pkg;
4267
+ try {
4268
+ pkg = readPackageJson(dir);
4269
+ }
4270
+ catch {
4271
+ return { allowed, denied };
4272
+ }
4273
+ const policy = pkg.allowScripts;
4274
+ if (!policy || typeof policy !== 'object')
4275
+ return { allowed, denied };
4276
+ // A `file:` key written by `npm approve-scripts` is the link's saved spec,
4277
+ // which npm records relative to the LINK's parent directory — verified on
4278
+ // whts: package.json says `file:../../projects/com/com-wrapper`, the policy
4279
+ // key came out `file:../../../../projects/com/com-wrapper`, i.e. relative to
4280
+ // node_modules/@bobfrankston/. A hand-written key is relative to the
4281
+ // project. Try both bases.
4282
+ const bases = new Set([dir]);
4283
+ for (const key of ['dependencies', 'devDependencies', 'optionalDependencies']) {
4284
+ for (const [name, spec] of Object.entries(pkg[key] || {})) {
4285
+ if (typeof spec === 'string' && spec.startsWith('file:'))
4286
+ bases.add(path.dirname(path.join(dir, 'node_modules', name)));
4287
+ }
4288
+ }
4289
+ const manifestName = (rel) => {
4290
+ for (const base of bases) {
4291
+ try {
4292
+ return readPackageJson(path.resolve(base, rel)).name;
4293
+ }
4294
+ catch { /* not this base — try the next; the caller skips the key when none has it */ }
4295
+ }
4296
+ return null;
4297
+ };
4298
+ for (const [key, value] of Object.entries(policy)) {
4299
+ let name;
4300
+ if (key.startsWith('file:') || path.isAbsolute(key)) {
4301
+ name = manifestName(key.startsWith('file:') ? key.slice('file:'.length) : key);
4302
+ }
4303
+ else {
4304
+ // strip a trailing @version; a scoped name keeps its leading @
4305
+ name = key.replace(/(.)@[^@]*$/, '$1');
4306
+ }
4307
+ if (!name)
4308
+ continue;
4309
+ (value === false ? denied : allowed).push(name);
4310
+ }
4311
+ return { allowed, denied };
4312
+ }
4313
+ /** Names from the user's own `allow-scripts` npm config (.npmrc layers).
4314
+ * npm's resolver takes the FIRST layer with any config and ignores the
4315
+ * rest, so a CLI flag would silently suppress these — merging them into the
4316
+ * flag keeps `npm config set allow-scripts=… --location=user` meaningful. */
4317
+ function npmrcAllowScripts() {
4318
+ const r = runCommand('npm', ['config', 'get', 'allow-scripts'], { silent: true });
4319
+ if (!r.success || isUnknownConfig(r.output))
4320
+ return [];
4321
+ return r.output.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
4322
+ }
4323
+ /** npm args allowing install scripts on a GLOBAL install: our own packages,
4324
+ * whatever the project's package.json `allowScripts` approves, and the
4325
+ * user's .npmrc list — minus anything the project denies. Empty when npm
4326
+ * predates the policy (it runs the scripts anyway) or nothing is allowed. */
4327
+ function allowScriptsArgs(dir) {
4174
4328
  if (!npmSupportsAllowScripts())
4175
4329
  return [];
4176
- const own = ownScopePackages(dir);
4177
- return own.length ? ['--allow-scripts', own.join(',')] : [];
4330
+ const project = projectAllowScripts(dir);
4331
+ const names = [...new Set([...ownScopePackages(dir), ...project.allowed, ...npmrcAllowScripts()])]
4332
+ .filter(n => !project.denied.includes(n));
4333
+ return names.length ? ['--allow-scripts', names.join(',')] : [];
4334
+ }
4335
+ /** Parse the text listing of `npm approve-scripts --allow-scripts-pending`:
4336
+ * one indented `name@version (event: cmd; event: cmd)` line per node,
4337
+ * the same package repeated when several versions are installed. */
4338
+ function parsePendingScripts(output) {
4339
+ const byName = new Map();
4340
+ for (const line of output.split('\n')) {
4341
+ const m = line.match(/^ (\S+?)(?:@([^@\s]+))? \((.*)\)\s*$/);
4342
+ if (!m)
4343
+ continue;
4344
+ const [, name, version, scripts] = m;
4345
+ const entry = byName.get(name) ?? { name, versions: [], scripts };
4346
+ if (version)
4347
+ entry.versions.push(version);
4348
+ byName.set(name, entry);
4349
+ }
4350
+ return [...byName.values()];
4351
+ }
4352
+ /** Bring the project's package.json `allowScripts` up to date with what is
4353
+ * installed, so `npm install` stops warning and — once npm starts enforcing
4354
+ * the policy — keeps running the scripts this project depends on.
4355
+ *
4356
+ * Own-scope packages and `file:` deps are approved without asking: they are
4357
+ * this user's source. Anything else is asked about, one package at a time,
4358
+ * showing the script npm would run. "no" records a deny (npm's own
4359
+ * mechanism, `npm deny-scripts`) so the question isn't repeated; "skip"
4360
+ * leaves it pending for next time. Approvals are name-only
4361
+ * (`--no-allow-scripts-pin`): a version pin would re-open the question on
4362
+ * every bump, which is exactly the churn this exists to remove.
4363
+ *
4364
+ * Runs once per project root, not per cascade dep: npm reads the policy
4365
+ * from the root package.json only. ~1.5s (arborist loads the actual tree). */
4366
+ export async function ensureAllowScripts(dir, opts = {}) {
4367
+ if (!npmSupportsAllowScripts())
4368
+ return;
4369
+ let pkgName;
4370
+ try {
4371
+ pkgName = readPackageJson(dir).name;
4372
+ }
4373
+ catch {
4374
+ return;
4375
+ }
4376
+ const listing = await runCommandAsync('npm', ['approve-scripts', '--allow-scripts-pending'], { cwd: dir, silent: true });
4377
+ if (!listing.success) {
4378
+ if (opts.verbose)
4379
+ console.log(colors.dim(` (could not list pending install scripts in ${pkgName}: ${(listing.stderr || listing.output).trim().split('\n')[0]})`));
4380
+ return;
4381
+ }
4382
+ const pending = parsePendingScripts(listing.output);
4383
+ if (!pending.length)
4384
+ return;
4385
+ const trusted = new Set([...ownScopePackages(dir), ...localDepDisplayNames(dir)]);
4386
+ const auto = pending.filter(p => trusted.has(p.name));
4387
+ const ask = pending.filter(p => !trusted.has(p.name));
4388
+ const label = (p) => p.versions.length ? `${p.name}@${p.versions.join('|')}` : p.name;
4389
+ if (opts.dryRun) {
4390
+ console.log(colors.dim(` [dry-run] allowScripts in ${pkgName}: would approve ${auto.map(label).join(', ') || '(none)'}; would ask about ${ask.map(label).join(', ') || '(none)'}`));
4391
+ return;
4392
+ }
4393
+ const approve = auto.map(p => p.name);
4394
+ const deny = [];
4395
+ const skipped = [];
4396
+ if (ask.length) {
4397
+ if (!process.stdin.isTTY) {
4398
+ console.log(colors.yellow(` ⚠ ${ask.length} third-party package(s) in ${pkgName} have install scripts not yet covered by allowScripts (no terminal to ask): ${ask.map(label).join(', ')}`));
4399
+ console.log(colors.dim(` npm approve-scripts --no-allow-scripts-pin <pkg> or npm deny-scripts <pkg>`));
4400
+ skipped.push(...ask.map(label));
4401
+ }
4402
+ else {
4403
+ console.log(colors.cyan(`npm gates install scripts for ${ask.length} third-party package(s) in ${pkgName}. Approvals are recorded in package.json "allowScripts" (name-only) and honored on the global install too.`));
4404
+ let allRemaining = false;
4405
+ for (const p of ask) {
4406
+ const prebuilt = PREBUILT_SCRIPT_RUNNERS.test(p.scripts) ? ' — prebuilt-binary fetcher, the package ships binaries' : '';
4407
+ if (allRemaining) {
4408
+ approve.push(p.name);
4409
+ continue;
4410
+ }
4411
+ const answer = await promptChoice(` Allow install scripts for ${label(p)} (${p.scripts})${prebuilt}? [y]es / [n]o, deny / [a]ll remaining / [s]kip for now:`, ['y', 'n', 'a', 's']);
4412
+ switch (answer) {
4413
+ case 'y':
4414
+ approve.push(p.name);
4415
+ break;
4416
+ case 'a':
4417
+ approve.push(p.name);
4418
+ allRemaining = true;
4419
+ break;
4420
+ case 'n':
4421
+ deny.push(p.name);
4422
+ break;
4423
+ default:
4424
+ skipped.push(label(p));
4425
+ break; // 's', or EOF ('' from promptChoice)
4426
+ }
4427
+ }
4428
+ }
4429
+ }
4430
+ // `npm approve-scripts <name>` matches installed nodes by the same display
4431
+ // name the pending listing used, and writes the right key for each kind
4432
+ // (registry name, or the file: path for a linked dep).
4433
+ if (approve.length) {
4434
+ const r = await runCommandAsync('npm', ['approve-scripts', '--no-allow-scripts-pin', ...approve], { cwd: dir, silent: true });
4435
+ if (r.success)
4436
+ console.log(colors.green(` ✓ allowScripts: approved ${approve.join(', ')}${auto.length ? ` (${auto.length} own/local approved automatically)` : ''}`));
4437
+ else
4438
+ console.log(colors.red(` ✗ npm approve-scripts failed: ${(r.stderr || r.output).trim().split('\n').find(l => /npm error/.test(l)) ?? 'see output'}`));
4439
+ }
4440
+ if (deny.length) {
4441
+ const r = await runCommandAsync('npm', ['deny-scripts', ...deny], { cwd: dir, silent: true });
4442
+ if (r.success)
4443
+ console.log(colors.yellow(` ✓ allowScripts: denied ${deny.join(', ')} (remove the entry from package.json allowScripts to reconsider)`));
4444
+ else
4445
+ console.log(colors.red(` ✗ npm deny-scripts failed: ${(r.stderr || r.output).trim().split('\n').find(l => /npm error/.test(l)) ?? 'see output'}`));
4446
+ }
4447
+ if (skipped.length)
4448
+ console.log(colors.dim(` · install scripts still unreviewed (asked again next run): ${skipped.join(', ')}`));
4178
4449
  }
4179
4450
  /** Install-script runners that fetch or select a prebuilt binary and only
4180
4451
  * compile when no prebuild matches the platform. A package using one of these
@@ -4248,9 +4519,10 @@ async function installGlobalWithRetry(pkgSpec, cwd, isNewPackage = false, maxRet
4248
4519
  let attempt = 0;
4249
4520
  let cacheFixRounds = 0;
4250
4521
  // Our own packages are trusted — we just built and published them from
4251
- // this very directory — so their install scripts run. Everything else
4252
- // stays behind npm's allowScripts gate.
4253
- const allowArgs = allowOwnScriptsArgs(cwd);
4522
+ // this very directory — so their install scripts run, along with whatever
4523
+ // the project's package.json allowScripts approved (ensureAllowScripts)
4524
+ // and the user's .npmrc list. Everything else stays behind npm's gate.
4525
+ const allowArgs = allowScriptsArgs(cwd);
4254
4526
  while (true) {
4255
4527
  // 2026-09-01 10:15 EDT — Claude Code (Opus 5), at Bob's direction.
4256
4528
  // `--prefer-online`: this install always targets a version published
@@ -4570,13 +4842,15 @@ async function verifyWslGlobalBins(cwd) {
4570
4842
  }
4571
4843
  }
4572
4844
  export async function installInWsl(wslArgs, opts = {}) {
4573
- // Same trust rule as the Windows installs: allow our own packages'
4574
- // install scripts, leave third-party ones gated. Probed against WSL's
4845
+ // Same trust rule as the Windows installs: our own packages plus the
4846
+ // project's package.json approvals, minus its denies. The Windows .npmrc
4847
+ // list is left out — WSL's npm has its own .npmrc. Probed against WSL's
4575
4848
  // npm, which is a separate install from the Windows one.
4576
4849
  if (opts.cwd && wslArgs.includes('install') && await wslNpmSupportsAllowScripts()) {
4577
- const own = ownScopePackages(opts.cwd);
4578
- if (own.length)
4579
- wslArgs = [...wslArgs, '--allow-scripts', own.join(',')];
4850
+ const project = projectAllowScripts(opts.cwd);
4851
+ const names = [...new Set([...ownScopePackages(opts.cwd), ...project.allowed])].filter(n => !project.denied.includes(n));
4852
+ if (names.length)
4853
+ wslArgs = [...wslArgs, '--allow-scripts', names.join(',')];
4580
4854
  }
4581
4855
  const runOnce = async () => {
4582
4856
  console.log(colors.cyan(`> wsl ${wslArgs.join(' ')}`));
@@ -6869,6 +7143,10 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
6869
7143
  }
6870
7144
  if (!modulesOk)
6871
7145
  console.log(colors.yellow('Continuing with -force despite npm install failure...'));
7146
+ // 2026-09-07 — Claude Code (Fable 5.1): same allowScripts upkeep the CLI
7147
+ // entrypoint does; the global install below reads the result.
7148
+ if (modulesOk)
7149
+ await ensureAllowScripts(cwd, { verbose });
6872
7150
  }
6873
7151
  // Run build step if package.json has a build script (skip if CLI already built)
6874
7152
  if (pkg.scripts?.build && !options._fromCli) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.219",
3
+ "version": "1.0.221",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@bobfrankston/freezepak": "^0.1.9",
35
- "@bobfrankston/importgen": "^0.1.40",
35
+ "@bobfrankston/importgen": "^0.1.42",
36
36
  "@bobfrankston/themecolors": "^0.1.9",
37
37
  "@bobfrankston/userconfig": "^1.0.11",
38
38
  "@npmcli/package-json": "^7.0.4",
@@ -60,7 +60,7 @@
60
60
  ".transformedSnapshot": {
61
61
  "dependencies": {
62
62
  "@bobfrankston/freezepak": "^0.1.9",
63
- "@bobfrankston/importgen": "^0.1.40",
63
+ "@bobfrankston/importgen": "^0.1.42",
64
64
  "@bobfrankston/themecolors": "^0.1.9",
65
65
  "@bobfrankston/userconfig": "^1.0.11",
66
66
  "@npmcli/package-json": "^7.0.4",