@bobfrankston/npmglobalize 1.0.220 → 1.0.222

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 +73 -21
  2. package/cli.js +24 -7
  3. package/lib.d.ts +29 -0
  4. package/lib.js +298 -32
  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.
@@ -262,7 +304,7 @@ Notes:
262
304
  - **Git/GitHub only.** npm publish does not consume git commit messages; `npmchanges.md` lives in the git repo and on GitHub but is excluded from the published npm tarball (the standard `*.md` rule keeps only `README.md`).
263
305
  - If both `-m` and `.commitmsg` are present, `-m` wins and `.commitmsg` is left alone (not consumed).
264
306
  - If publish fails, `.commitmsg` is preserved for the next attempt.
265
- - Under `-local` (no publish) the file is still consumed — appended as `## v<version> (local) — <date>` and deleted, with no git commit. See [Local Install](#local-install--local).
307
+ - Under `-local-install` (no publish) the file is still consumed — appended as `## v<version> (local) — <date>` and deleted, with no git commit. See [Local Install](#local-install--local-install).
266
308
  - `.commitmsg` is auto-added to `.npmignore` (security pattern) so it never leaks into the tarball.
267
309
 
268
310
  ### 🔧 Git Integration & Error Recovery
@@ -425,9 +467,11 @@ not what you meant to publish, so npmglobalize lists the conflicted files and st
425
467
  ```
426
468
  -install, -i Install globally after publish (from registry)
427
469
  -link Install globally via symlink (npm install -g .)
428
- -local Local install only — skip transform/publish, just npm install -g .
429
- (persisted; a .commitmsg is still logged to npmchanges.md as "(local)")
430
- -global Clear a persisted -local in .globalize.json5 back to the normal
470
+ -local-install Install from the local directory only — skip transform/publish, just
471
+ npm install -g . with file: deps as-is (also -localinstall; -local is
472
+ the deprecated old spelling). Persisted; a .commitmsg is still logged
473
+ to npmchanges.md as "(local)"
474
+ -global Clear a persisted -local-install in .globalize.json5 — back to the normal
431
475
  transform/publish flow (publishes to npm again)
432
476
  -wsl Also install in WSL
433
477
  -once Don't persist flags to .globalize.json5
@@ -730,6 +774,10 @@ Cycle-safe via a shared visited set; each project is built at most once per run.
730
774
 
731
775
  This complements the existing publish cascade (which ensures version refs are correct) by closing the build-freshness gap that `npm install` alone left open.
732
776
 
777
+ #### `bin` shebang
778
+
779
+ Every file named in `package.json` `bin` must start with `#!/usr/bin/env node`. npm's Windows `.cmd` shim doesn't care, but the symlink npm creates on Linux, WSL and macOS execs the file directly, and without the shebang `sh` tries to run it and fails with a syntax error. Before each project is built, `npmglobalize` checks each `bin` file and adds the line if it is missing — in the `.ts` source when there is one (tsc carries it through to the emitted `.js`, and the touched source makes the freshness check rebuild), otherwise in the `.js` itself. A leading BOM, which would also defeat the shebang, is removed at the same time. A `bin` entry whose file does not exist yet is reported as a warning.
780
+
733
781
  #### Import maps (`importgen`) as a build step
734
782
 
735
783
  Browser projects that use [`importgen`](https://www.npmjs.com/package/@bobfrankston/importgen) have historically regenerated their import map from `.vscode/tasks.json`, which only runs when VS Code opens the folder — so a command-line or CI build could publish a stale map. `npmglobalize` treats the import map as a build product and moves the step into the package's own `build` script, where every build path picks it up.
@@ -796,7 +844,7 @@ Both `-flag` and `--flag` are accepted. Single-dash is the primary convention:
796
844
  ```bash
797
845
  npmglobalize -patch # same as --patch
798
846
  npmglobalize -np # same as --nopublish
799
- npmglobalize -local # same as --local
847
+ npmglobalize -local-install # same as --local-install
800
848
  ```
801
849
 
802
850
  ### Persistent vs One-Shot Flags
@@ -804,7 +852,7 @@ npmglobalize -local # same as --local
804
852
  Some flags are **persisted** to `.globalize.json5` when set from the CLI:
805
853
  - `-install`, `-link`, `-wsl`, `-files`, `-fix` — install/build preferences
806
854
  - `-np` (noPublish) — once set, prevents accidental publishes
807
- - `-local` — remembers "this project is local-only" (clear with `-global`)
855
+ - `-local-install` — remembers "this project is local-only" (clear with `-global`)
808
856
  - `-git`/`-npm` visibility
809
857
 
810
858
  Other flags are **one-shot** (never persisted):
@@ -817,14 +865,14 @@ Use `-once` to prevent any flag from persisting on that run:
817
865
  npmglobalize -np -once # No-publish this run only, don't remember it
818
866
  ```
819
867
 
820
- ### Local Install (`-local`)
868
+ ### Local Install (`-local-install`)
821
869
 
822
870
  Skip all transform/publish logic and just run `npm install -g .` with `file:` deps as-is. Use this when you want to install a CLI tool locally for your own use without publishing anything:
823
871
 
824
872
  ```bash
825
- npmglobalize -local # Install globally from local directory
826
- npmglobalize -local -wsl # Also install in WSL
827
- npmglobalize -global # Back to the normal transform/publish flow (publishes to npm)
873
+ npmglobalize -local-install # Install globally from the local directory
874
+ npmglobalize -local-install -wsl # Also install in WSL
875
+ npmglobalize -global # Back to the normal transform/publish flow (publishes to npm)
828
876
  ```
829
877
 
830
878
  This is useful for:
@@ -832,7 +880,11 @@ This is useful for:
832
880
  - Testing a CLI before publishing
833
881
  - Projects with `file:` deps that should stay as-is
834
882
 
835
- `-local` is persisted to `.globalize.json5` (`"local": true`), so a bare
883
+ `-localinstall` is accepted as well. The old spelling `-local` still works but prints a
884
+ deprecation note; it was renamed (2026-09-07) because "local" read as "local mode" rather
885
+ than "install from the local directory".
886
+
887
+ `-local-install` is persisted to `.globalize.json5` (`"local": true`), so a bare
836
888
  `npmglobalize` keeps doing local installs until you run `-global` (or edit the
837
889
  file). Nothing is committed, bumped, or published, but a `.commitmsg` is still
838
890
  consumed: it is appended to `npmchanges.md` under a
@@ -879,7 +931,7 @@ npmglobalize -install -wsl
879
931
  npmglobalize -link -wsl
880
932
 
881
933
  # Install locally without publishing (file: deps stay as-is)
882
- npmglobalize -local
934
+ npmglobalize -local-install
883
935
 
884
936
  # Restore original file: references
885
937
  npmglobalize -cleanup
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';
@@ -70,9 +70,11 @@ Dependency Options:
70
70
  Install Options:
71
71
  -install, -i Global install after publish (from registry)
72
72
  -link Global install via symlink (npm install -g .)
73
- -local Local install only — skip transform/publish, just npm install -g .
74
- (persisted; a .commitmsg is still logged to npmchanges.md as "(local)")
75
- -global Clear a persisted -local in .globalize.json5 back to the normal
73
+ -local-install Install from the local directory only — skip transform/publish, just
74
+ npm install -g . with file: deps as-is (also -localinstall; -local is
75
+ the deprecated old spelling). Persisted; a .commitmsg is still logged
76
+ to npmchanges.md as "(local)"
77
+ -global Clear a persisted -local-install in .globalize.json5 — back to the normal
76
78
  transform/publish flow (publishes to npm again)
77
79
  -wsl Also install globally in WSL
78
80
  -freeze Freeze node_modules (replace symlinks with real copies for network shares)
@@ -157,8 +159,8 @@ Examples:
157
159
  npmglobalize -fix Fix security vulnerabilities
158
160
  npmglobalize -install -wsl Release + install on Windows and WSL (from registry)
159
161
  npmglobalize -link -wsl Release + link on Windows and WSL (symlink)
160
- npmglobalize -local Install locally with file: deps as-is (no publish)
161
- npmglobalize -local -wsl Local install on Windows and WSL
162
+ npmglobalize -local-install Install from the local directory, file: deps as-is (no publish)
163
+ npmglobalize -local-install -wsl Local install on Windows and WSL
162
164
  npmglobalize -global Leave local-only mode; publish to npm from now on
163
165
  npmglobalize -np Just transform, no publish (remembered in config)
164
166
  npmglobalize -cleanup Restore original dependencies
@@ -228,11 +230,21 @@ function parseArgs(args) {
228
230
  case '-cleanup':
229
231
  options.cleanup = true;
230
232
  break;
233
+ // 2026-09-07 — Claude Code (Fable 5.1), at Bob's direction: "-local is
234
+ // confusing" — it read like "local mode" / "local deps" rather than "install
235
+ // from the local directory". Renamed to -local-install (-localinstall also
236
+ // accepted). -local still works but says so, so muscle memory isn't broken
237
+ // and the old spelling is phased out rather than silently kept alive.
238
+ // The .globalize.json5 key stays "local" so existing configs keep working.
231
239
  case '-local':
240
+ console.log(colors.yellow('! -local is deprecated — use -local-install (same meaning: npm install -g . from the local directory, no publish)'));
241
+ // falls through
242
+ case '-local-install':
243
+ case '-localinstall':
232
244
  options.local = true;
233
245
  options.explicitKeys.add('local');
234
246
  break;
235
- // 2026-09-01 16:30 EDT — Claude Code (Opus 5), at Bob's direction. -local
247
+ // 2026-09-01 16:30 EDT — Claude Code (Opus 5), at Bob's direction. -local-install
236
248
  // persists to .globalize.json5 but had no counterpart, so leaving local-only
237
249
  // mode meant hand-editing the config. Same shape as -publish for -np.
238
250
  // 2026-09-01 15:00 EDT — Bob: "-global makes more sense, -nolocal is confusing";
@@ -609,6 +621,11 @@ export async function main() {
609
621
  }
610
622
  if (!modulesOk)
611
623
  console.log(colors.warn('Continuing with -force despite npm install failure...'));
624
+ // 2026-09-07 — Claude Code (Fable 5.1), at Bob's direction: keep package.json
625
+ // "allowScripts" current — own/file: deps approved, third-party ones asked about —
626
+ // so npm's install-script gate stops warning and the global install honors it.
627
+ if (modulesOk)
628
+ await ensureAllowScripts(cwd, { dryRun: !!cliOptions.dryRun, verbose: !!cliOptions.verbose });
612
629
  const depsOk = await buildFileDepsTopologically(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force, forceBuild: !!cliOptions.forceBuild });
613
630
  if (!depsOk && !cliOptions.force) {
614
631
  printBuildSummary();
package/lib.d.ts CHANGED
@@ -456,6 +456,8 @@ export declare function ensureBuildScript(cwd: string): Promise<{
456
456
  * was skipped because there was nothing to build / the user declined the
457
457
  * build-script prompt / output already up to date). Pass `forceBuild` to
458
458
  * skip the freshness check. */
459
+ /** Every `bin` entry gets a `#!/usr/bin/env node` first line, added to the source if missing. */
460
+ export declare function ensureBinShebang(cwd: string): void;
459
461
  export declare function buildProject(cwd: string, opts?: {
460
462
  verbose?: boolean;
461
463
  force?: boolean;
@@ -559,6 +561,33 @@ export declare function fixPuppeteerCorruptedCache(combined: string): {
559
561
  * Returns [] for an unscoped package: there's no way to tell an unscoped
560
562
  * package of ours from a third-party one by name alone. */
561
563
  export declare function ownScopePackages(dir: string): string[];
564
+ /** The project's recorded install-script policy, as registry names. Keys in
565
+ * `allowScripts` are `name`, `name@version`, or a `file:` / absolute path;
566
+ * path keys are mapped to the target's manifest name so an approval given
567
+ * to a linked dep carries over to the global install, where that same dep
568
+ * arrives from the registry under its published name. */
569
+ export declare function projectAllowScripts(dir: string): {
570
+ allowed: string[];
571
+ denied: string[];
572
+ };
573
+ /** Bring the project's package.json `allowScripts` up to date with what is
574
+ * installed, so `npm install` stops warning and — once npm starts enforcing
575
+ * the policy — keeps running the scripts this project depends on.
576
+ *
577
+ * Own-scope packages and `file:` deps are approved without asking: they are
578
+ * this user's source. Anything else is asked about, one package at a time,
579
+ * showing the script npm would run. "no" records a deny (npm's own
580
+ * mechanism, `npm deny-scripts`) so the question isn't repeated; "skip"
581
+ * leaves it pending for next time. Approvals are name-only
582
+ * (`--no-allow-scripts-pin`): a version pin would re-open the question on
583
+ * every bump, which is exactly the churn this exists to remove.
584
+ *
585
+ * Runs once per project root, not per cascade dep: npm reads the policy
586
+ * from the root package.json only. ~1.5s (arborist loads the actual tree). */
587
+ export declare function ensureAllowScripts(dir: string, opts?: {
588
+ dryRun?: boolean;
589
+ verbose?: boolean;
590
+ }): Promise<void>;
562
591
  /** Run a command and return success status */
563
592
  export declare function runCommand(cmd: string, args: string[], options?: {
564
593
  silent?: boolean;
package/lib.js CHANGED
@@ -332,7 +332,7 @@ const BOOL_COMMENTS = {
332
332
  quiet: { on: 'Suppress npm warnings', off: 'Show npm warnings' },
333
333
  verbose: { on: 'Show detailed output', off: 'Normal output detail' },
334
334
  fix: { on: 'Auto-run npm audit fix (default)', off: 'Do NOT run npm audit fix (audit report still runs)' },
335
- local: { on: 'Local install only (skip transform/publish)', off: 'Normal transform/publish flow' },
335
+ local: { on: 'Local install only via -local-install (skip transform/publish)', off: 'Normal transform/publish flow' },
336
336
  noPublish: { on: 'Transform but don\'t publish', off: 'Publish normally' },
337
337
  freeze: { on: 'Freeze node_modules (replace symlinks with real copies)', off: 'Leave node_modules symlinks as-is' },
338
338
  usePaths: { on: 'Resolve file: deps from sibling checkouts', off: 'Resolve file: deps from npm, not siblings (standalone package)' },
@@ -444,7 +444,7 @@ export function writeConfig(dir, config, explicitKeys) {
444
444
  ['"gitVisibility": "private"', 'Git repo: private or public'],
445
445
  ['"npmVisibility": "private"', 'npm package: private or public'],
446
446
  ['"fix": true', 'true = auto-run npm audit fix (never --force); false = report only'],
447
- ['"local": false', 'true = local install only (skip transform/publish); clear with -global'],
447
+ ['"local": false', 'true = install from the local directory only, set by -local-install (skip transform/publish); clear with -global'],
448
448
  ['"noPublish": false', 'true = transform but don\'t publish'],
449
449
  ['"freeze": false', 'true = freeze node_modules (replace symlinks with real copies)'],
450
450
  ['"usePaths": true', 'true = resolve file: deps from siblings; false = use latest npm version (standalone)'],
@@ -3536,7 +3536,48 @@ export async function ensureBuildScript(cwd) {
3536
3536
  * was skipped because there was nothing to build / the user declined the
3537
3537
  * build-script prompt / output already up to date). Pass `forceBuild` to
3538
3538
  * skip the freshness check. */
3539
+ // 2026-09-07 — Claude Code (Fable 5.1), at Bob's direction: "When a program is
3540
+ // installed, at least locally, it needs a shebang for node. Make sure the main
3541
+ // program has a shebang." npm's Windows .cmd shim doesn't care, but the symlink
3542
+ // npm creates on Linux/WSL/macOS execs the file directly, so a bin entry
3543
+ // without `#!/usr/bin/env node` fails there with "syntax error" from sh. The
3544
+ // shebang belongs in the SOURCE (.ts): tsc carries it through to the emitted
3545
+ // .js, and editing the .ts bumps its mtime so the freshness check rebuilds.
3546
+ // Runs ahead of the build for that reason, and for every package in the
3547
+ // cascade, since a file: dep can be a CLI too.
3548
+ /** Every `bin` entry gets a `#!/usr/bin/env node` first line, added to the source if missing. */
3549
+ export function ensureBinShebang(cwd) {
3550
+ let pkg;
3551
+ try {
3552
+ pkg = readPackageJson(cwd);
3553
+ }
3554
+ catch {
3555
+ return;
3556
+ } // no package.json: nothing to check, the build reports that
3557
+ if (!pkg?.bin)
3558
+ return;
3559
+ const targets = typeof pkg.bin === 'string' ? [pkg.bin]
3560
+ : (typeof pkg.bin === 'object' ? Object.values(pkg.bin).filter((v) => typeof v === 'string') : []);
3561
+ for (const rel of targets) {
3562
+ const emitted = path.join(cwd, rel);
3563
+ // Prefer the TypeScript source the bin file is emitted from.
3564
+ const tsSibling = emitted.replace(/\.(m|c)?js$/i, '.$1ts');
3565
+ const file = tsSibling !== emitted && fs.existsSync(tsSibling) ? tsSibling : emitted;
3566
+ if (!fs.existsSync(file)) {
3567
+ console.log(colors.yellow(`! bin "${rel}" in ${pkg.name}: file not found (needs a #!/usr/bin/env node shebang once it exists)`));
3568
+ continue;
3569
+ }
3570
+ const raw = fs.readFileSync(file, 'utf-8');
3571
+ const body = raw.startsWith('') ? raw.slice(1) : raw; // a BOM ahead of #! defeats the shebang
3572
+ if (body.startsWith('#!'))
3573
+ continue;
3574
+ fs.writeFileSync(file, `#!/usr/bin/env node
3575
+ ${body}`, 'utf-8');
3576
+ console.log(colors.green(`+ Added #!/usr/bin/env node to ${path.relative(cwd, file)} (bin "${rel}")`));
3577
+ }
3578
+ }
3539
3579
  export async function buildProject(cwd, opts = {}) {
3580
+ ensureBinShebang(cwd);
3540
3581
  // Fix removed-in-TS7 tsconfig settings up front rather than waiting for a
3541
3582
  // build to fail on them. A deprecated `moduleResolution` is only a *hard*
3542
3583
  // error under the tsc that flags it (TS6+, and the editor's bundled
@@ -4214,20 +4255,238 @@ export function ownScopePackages(dir) {
4214
4255
  walk(pkg, dir);
4215
4256
  return [...found];
4216
4257
  }
4217
- /** npm args allowing install scripts for our own packages only. Empty when
4218
- * npm predates the policy (it runs the scripts anyway) or the package is
4219
- * unscoped.
4220
- *
4221
- * Note: the CLI layer *replaces* the .npmrc layer rather than merging with
4222
- * it (first source with any config wins, per npm's resolve-allow-scripts),
4223
- * so passing this suppresses any allow-scripts entries in .npmrc for this
4224
- * install. Third-party approvals therefore belong on the command line too,
4225
- * not in .npmrc. */
4226
- function allowOwnScriptsArgs(dir) {
4258
+ // 2026-09-07 Claude Code (Fable 5.1), at Bob's direction ("I want you to handle
4259
+ // this", after `npm un itemgen` in whts warned about com-wrapper, msger and sharp).
4260
+ // The project side of the policy. npm REJECTS `--allow-scripts` on a project-scoped
4261
+ // install (EALLOWSCRIPTS), so the only place a project's approvals can live is the
4262
+ // `allowScripts` field of its package.json, maintained by `npm approve-scripts`.
4263
+ // npmglobalize now keeps that field current (ensureAllowScripts) and feeds it back
4264
+ // into the global install's `--allow-scripts` flag (allowScriptsArgs), so a package
4265
+ // is trusted once, in one place, for both kinds of install.
4266
+ /** Names of the project's `file:` dependencies as npm displays them in the
4267
+ * allowScripts pending list. A linked dep has no registry identity, so npm
4268
+ * falls back to the link target's directory name (`com-wrapper` for
4269
+ * `@bobfrankston/com-wrapper` at ../../projects/com/com-wrapper); the
4270
+ * manifest name is included too in case that fallback ever changes. */
4271
+ function localDepDisplayNames(dir) {
4272
+ let pkg;
4273
+ try {
4274
+ pkg = readPackageJson(dir);
4275
+ }
4276
+ catch {
4277
+ return [];
4278
+ }
4279
+ const names = [];
4280
+ for (const key of ['dependencies', 'devDependencies', 'optionalDependencies']) {
4281
+ const deps = pkg[key];
4282
+ if (!deps || typeof deps !== 'object')
4283
+ continue;
4284
+ for (const spec of Object.values(deps)) {
4285
+ if (typeof spec !== 'string' || !spec.startsWith('file:'))
4286
+ continue;
4287
+ const target = path.resolve(dir, spec.slice('file:'.length));
4288
+ names.push(path.basename(target));
4289
+ try {
4290
+ const name = readPackageJson(target).name;
4291
+ if (name)
4292
+ names.push(name);
4293
+ }
4294
+ catch { /* target missing or unreadable — the basename alone still identifies it */ }
4295
+ }
4296
+ }
4297
+ return names;
4298
+ }
4299
+ /** The project's recorded install-script policy, as registry names. Keys in
4300
+ * `allowScripts` are `name`, `name@version`, or a `file:` / absolute path;
4301
+ * path keys are mapped to the target's manifest name so an approval given
4302
+ * to a linked dep carries over to the global install, where that same dep
4303
+ * arrives from the registry under its published name. */
4304
+ export function projectAllowScripts(dir) {
4305
+ const allowed = [];
4306
+ const denied = [];
4307
+ let pkg;
4308
+ try {
4309
+ pkg = readPackageJson(dir);
4310
+ }
4311
+ catch {
4312
+ return { allowed, denied };
4313
+ }
4314
+ const policy = pkg.allowScripts;
4315
+ if (!policy || typeof policy !== 'object')
4316
+ return { allowed, denied };
4317
+ // A `file:` key written by `npm approve-scripts` is the link's saved spec,
4318
+ // which npm records relative to the LINK's parent directory — verified on
4319
+ // whts: package.json says `file:../../projects/com/com-wrapper`, the policy
4320
+ // key came out `file:../../../../projects/com/com-wrapper`, i.e. relative to
4321
+ // node_modules/@bobfrankston/. A hand-written key is relative to the
4322
+ // project. Try both bases.
4323
+ const bases = new Set([dir]);
4324
+ for (const key of ['dependencies', 'devDependencies', 'optionalDependencies']) {
4325
+ for (const [name, spec] of Object.entries(pkg[key] || {})) {
4326
+ if (typeof spec === 'string' && spec.startsWith('file:'))
4327
+ bases.add(path.dirname(path.join(dir, 'node_modules', name)));
4328
+ }
4329
+ }
4330
+ const manifestName = (rel) => {
4331
+ for (const base of bases) {
4332
+ try {
4333
+ return readPackageJson(path.resolve(base, rel)).name;
4334
+ }
4335
+ catch { /* not this base — try the next; the caller skips the key when none has it */ }
4336
+ }
4337
+ return null;
4338
+ };
4339
+ for (const [key, value] of Object.entries(policy)) {
4340
+ let name;
4341
+ if (key.startsWith('file:') || path.isAbsolute(key)) {
4342
+ name = manifestName(key.startsWith('file:') ? key.slice('file:'.length) : key);
4343
+ }
4344
+ else {
4345
+ // strip a trailing @version; a scoped name keeps its leading @
4346
+ name = key.replace(/(.)@[^@]*$/, '$1');
4347
+ }
4348
+ if (!name)
4349
+ continue;
4350
+ (value === false ? denied : allowed).push(name);
4351
+ }
4352
+ return { allowed, denied };
4353
+ }
4354
+ /** Names from the user's own `allow-scripts` npm config (.npmrc layers).
4355
+ * npm's resolver takes the FIRST layer with any config and ignores the
4356
+ * rest, so a CLI flag would silently suppress these — merging them into the
4357
+ * flag keeps `npm config set allow-scripts=… --location=user` meaningful. */
4358
+ function npmrcAllowScripts() {
4359
+ const r = runCommand('npm', ['config', 'get', 'allow-scripts'], { silent: true });
4360
+ if (!r.success || isUnknownConfig(r.output))
4361
+ return [];
4362
+ return r.output.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
4363
+ }
4364
+ /** npm args allowing install scripts on a GLOBAL install: our own packages,
4365
+ * whatever the project's package.json `allowScripts` approves, and the
4366
+ * user's .npmrc list — minus anything the project denies. Empty when npm
4367
+ * predates the policy (it runs the scripts anyway) or nothing is allowed. */
4368
+ function allowScriptsArgs(dir) {
4227
4369
  if (!npmSupportsAllowScripts())
4228
4370
  return [];
4229
- const own = ownScopePackages(dir);
4230
- return own.length ? ['--allow-scripts', own.join(',')] : [];
4371
+ const project = projectAllowScripts(dir);
4372
+ const names = [...new Set([...ownScopePackages(dir), ...project.allowed, ...npmrcAllowScripts()])]
4373
+ .filter(n => !project.denied.includes(n));
4374
+ return names.length ? ['--allow-scripts', names.join(',')] : [];
4375
+ }
4376
+ /** Parse the text listing of `npm approve-scripts --allow-scripts-pending`:
4377
+ * one indented `name@version (event: cmd; event: cmd)` line per node,
4378
+ * the same package repeated when several versions are installed. */
4379
+ function parsePendingScripts(output) {
4380
+ const byName = new Map();
4381
+ for (const line of output.split('\n')) {
4382
+ const m = line.match(/^ (\S+?)(?:@([^@\s]+))? \((.*)\)\s*$/);
4383
+ if (!m)
4384
+ continue;
4385
+ const [, name, version, scripts] = m;
4386
+ const entry = byName.get(name) ?? { name, versions: [], scripts };
4387
+ if (version)
4388
+ entry.versions.push(version);
4389
+ byName.set(name, entry);
4390
+ }
4391
+ return [...byName.values()];
4392
+ }
4393
+ /** Bring the project's package.json `allowScripts` up to date with what is
4394
+ * installed, so `npm install` stops warning and — once npm starts enforcing
4395
+ * the policy — keeps running the scripts this project depends on.
4396
+ *
4397
+ * Own-scope packages and `file:` deps are approved without asking: they are
4398
+ * this user's source. Anything else is asked about, one package at a time,
4399
+ * showing the script npm would run. "no" records a deny (npm's own
4400
+ * mechanism, `npm deny-scripts`) so the question isn't repeated; "skip"
4401
+ * leaves it pending for next time. Approvals are name-only
4402
+ * (`--no-allow-scripts-pin`): a version pin would re-open the question on
4403
+ * every bump, which is exactly the churn this exists to remove.
4404
+ *
4405
+ * Runs once per project root, not per cascade dep: npm reads the policy
4406
+ * from the root package.json only. ~1.5s (arborist loads the actual tree). */
4407
+ export async function ensureAllowScripts(dir, opts = {}) {
4408
+ if (!npmSupportsAllowScripts())
4409
+ return;
4410
+ let pkgName;
4411
+ try {
4412
+ pkgName = readPackageJson(dir).name;
4413
+ }
4414
+ catch {
4415
+ return;
4416
+ }
4417
+ const listing = await runCommandAsync('npm', ['approve-scripts', '--allow-scripts-pending'], { cwd: dir, silent: true });
4418
+ if (!listing.success) {
4419
+ if (opts.verbose)
4420
+ console.log(colors.dim(` (could not list pending install scripts in ${pkgName}: ${(listing.stderr || listing.output).trim().split('\n')[0]})`));
4421
+ return;
4422
+ }
4423
+ const pending = parsePendingScripts(listing.output);
4424
+ if (!pending.length)
4425
+ return;
4426
+ const trusted = new Set([...ownScopePackages(dir), ...localDepDisplayNames(dir)]);
4427
+ const auto = pending.filter(p => trusted.has(p.name));
4428
+ const ask = pending.filter(p => !trusted.has(p.name));
4429
+ const label = (p) => p.versions.length ? `${p.name}@${p.versions.join('|')}` : p.name;
4430
+ if (opts.dryRun) {
4431
+ console.log(colors.dim(` [dry-run] allowScripts in ${pkgName}: would approve ${auto.map(label).join(', ') || '(none)'}; would ask about ${ask.map(label).join(', ') || '(none)'}`));
4432
+ return;
4433
+ }
4434
+ const approve = auto.map(p => p.name);
4435
+ const deny = [];
4436
+ const skipped = [];
4437
+ if (ask.length) {
4438
+ if (!process.stdin.isTTY) {
4439
+ 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(', ')}`));
4440
+ console.log(colors.dim(` npm approve-scripts --no-allow-scripts-pin <pkg> or npm deny-scripts <pkg>`));
4441
+ skipped.push(...ask.map(label));
4442
+ }
4443
+ else {
4444
+ 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.`));
4445
+ let allRemaining = false;
4446
+ for (const p of ask) {
4447
+ const prebuilt = PREBUILT_SCRIPT_RUNNERS.test(p.scripts) ? ' — prebuilt-binary fetcher, the package ships binaries' : '';
4448
+ if (allRemaining) {
4449
+ approve.push(p.name);
4450
+ continue;
4451
+ }
4452
+ 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']);
4453
+ switch (answer) {
4454
+ case 'y':
4455
+ approve.push(p.name);
4456
+ break;
4457
+ case 'a':
4458
+ approve.push(p.name);
4459
+ allRemaining = true;
4460
+ break;
4461
+ case 'n':
4462
+ deny.push(p.name);
4463
+ break;
4464
+ default:
4465
+ skipped.push(label(p));
4466
+ break; // 's', or EOF ('' from promptChoice)
4467
+ }
4468
+ }
4469
+ }
4470
+ }
4471
+ // `npm approve-scripts <name>` matches installed nodes by the same display
4472
+ // name the pending listing used, and writes the right key for each kind
4473
+ // (registry name, or the file: path for a linked dep).
4474
+ if (approve.length) {
4475
+ const r = await runCommandAsync('npm', ['approve-scripts', '--no-allow-scripts-pin', ...approve], { cwd: dir, silent: true });
4476
+ if (r.success)
4477
+ console.log(colors.green(` ✓ allowScripts: approved ${approve.join(', ')}${auto.length ? ` (${auto.length} own/local approved automatically)` : ''}`));
4478
+ else
4479
+ console.log(colors.red(` ✗ npm approve-scripts failed: ${(r.stderr || r.output).trim().split('\n').find(l => /npm error/.test(l)) ?? 'see output'}`));
4480
+ }
4481
+ if (deny.length) {
4482
+ const r = await runCommandAsync('npm', ['deny-scripts', ...deny], { cwd: dir, silent: true });
4483
+ if (r.success)
4484
+ console.log(colors.yellow(` ✓ allowScripts: denied ${deny.join(', ')} (remove the entry from package.json allowScripts to reconsider)`));
4485
+ else
4486
+ console.log(colors.red(` ✗ npm deny-scripts failed: ${(r.stderr || r.output).trim().split('\n').find(l => /npm error/.test(l)) ?? 'see output'}`));
4487
+ }
4488
+ if (skipped.length)
4489
+ console.log(colors.dim(` · install scripts still unreviewed (asked again next run): ${skipped.join(', ')}`));
4231
4490
  }
4232
4491
  /** Install-script runners that fetch or select a prebuilt binary and only
4233
4492
  * compile when no prebuild matches the platform. A package using one of these
@@ -4301,9 +4560,10 @@ async function installGlobalWithRetry(pkgSpec, cwd, isNewPackage = false, maxRet
4301
4560
  let attempt = 0;
4302
4561
  let cacheFixRounds = 0;
4303
4562
  // Our own packages are trusted — we just built and published them from
4304
- // this very directory — so their install scripts run. Everything else
4305
- // stays behind npm's allowScripts gate.
4306
- const allowArgs = allowOwnScriptsArgs(cwd);
4563
+ // this very directory — so their install scripts run, along with whatever
4564
+ // the project's package.json allowScripts approved (ensureAllowScripts)
4565
+ // and the user's .npmrc list. Everything else stays behind npm's gate.
4566
+ const allowArgs = allowScriptsArgs(cwd);
4307
4567
  while (true) {
4308
4568
  // 2026-09-01 10:15 EDT — Claude Code (Opus 5), at Bob's direction.
4309
4569
  // `--prefer-online`: this install always targets a version published
@@ -4623,13 +4883,15 @@ async function verifyWslGlobalBins(cwd) {
4623
4883
  }
4624
4884
  }
4625
4885
  export async function installInWsl(wslArgs, opts = {}) {
4626
- // Same trust rule as the Windows installs: allow our own packages'
4627
- // install scripts, leave third-party ones gated. Probed against WSL's
4886
+ // Same trust rule as the Windows installs: our own packages plus the
4887
+ // project's package.json approvals, minus its denies. The Windows .npmrc
4888
+ // list is left out — WSL's npm has its own .npmrc. Probed against WSL's
4628
4889
  // npm, which is a separate install from the Windows one.
4629
4890
  if (opts.cwd && wslArgs.includes('install') && await wslNpmSupportsAllowScripts()) {
4630
- const own = ownScopePackages(opts.cwd);
4631
- if (own.length)
4632
- wslArgs = [...wslArgs, '--allow-scripts', own.join(',')];
4891
+ const project = projectAllowScripts(opts.cwd);
4892
+ const names = [...new Set([...ownScopePackages(opts.cwd), ...project.allowed])].filter(n => !project.denied.includes(n));
4893
+ if (names.length)
4894
+ wslArgs = [...wslArgs, '--allow-scripts', names.join(',')];
4633
4895
  }
4634
4896
  const runOnce = async () => {
4635
4897
  console.log(colors.cyan(`> wsl ${wslArgs.join(' ')}`));
@@ -6308,15 +6570,15 @@ export function getToolVersion() {
6308
6570
  }
6309
6571
  }
6310
6572
  // 2026-09-01 16:30 EDT — Claude Code (Opus 5), at Bob's direction.
6311
- // Under -local the run returned before any of the .commitmsg handling, so the
6573
+ // Under -local-install the run returned before any of the .commitmsg handling, so the
6312
6574
  // notes a local-only package accumulated in .commitmsg were never recorded
6313
6575
  // anywhere — they sat in the file, and the next edit overwrote them. A local
6314
6576
  // install is still a change worth a history line, so the same npmchanges.md
6315
6577
  // gets the entry, tagged "(local)" because nothing was published or bumped
6316
6578
  // (several local runs may legitimately share one version number). No git
6317
- // activity: -local is the path taken when there is often no repo at all, and
6579
+ // activity: -local-install is the path taken when there is often no repo at all, and
6318
6580
  // when there is one the next real publish commits npmchanges.md with the rest.
6319
- /** Consume .commitmsg for a -local run: append to npmchanges.md, delete the file. */
6581
+ /** Consume .commitmsg for a -local-install run: append to npmchanges.md, delete the file. */
6320
6582
  function recordLocalChange(cwd, version, dryRun) {
6321
6583
  const commitMsgPath = path.join(cwd, '.commitmsg');
6322
6584
  if (!fs.existsSync(commitMsgPath))
@@ -6351,8 +6613,8 @@ function recordLocalChange(cwd, version, dryRun) {
6351
6613
  console.error(colors.yellow(` Warning: could not update npmchanges.md: ${err.message}`));
6352
6614
  }
6353
6615
  }
6354
- /** Perform local-only install (npm install -g .) — the single -local path, reached
6355
- * from the -local flag / config and from the git-init prompts' "local only" choice. */
6616
+ /** Perform local-only install (npm install -g .) — the single -local-install path, reached
6617
+ * from the -local-install flag / config and from the git-init prompts' "local only" choice. */
6356
6618
  async function doLocalInstall(cwd, options) {
6357
6619
  const { dryRun = false, wsl = false } = options;
6358
6620
  const pkg = readPackageJson(cwd);
@@ -6472,7 +6734,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
6472
6734
  if (configOptions.show)
6473
6735
  settings.push('--show');
6474
6736
  if (configOptions.local)
6475
- settings.push('-local');
6737
+ settings.push('-local-install');
6476
6738
  if (configOptions.freeze)
6477
6739
  settings.push('-freeze');
6478
6740
  if (configOptions.usePaths === false)
@@ -6489,8 +6751,8 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
6489
6751
  }
6490
6752
  }
6491
6753
  console.log('');
6492
- // -local: skip all transform/publish, just install from local directory with file: deps intact.
6493
- // -local is testing-only and must NOT mutate package.json — packages are meant to be installed
6754
+ // -local-install: skip all transform/publish, just install from local directory with file: deps intact.
6755
+ // -local-install is testing-only and must NOT mutate package.json — packages are meant to be installed
6494
6756
  // as published public packages by end users; the bin field is whatever the published shape is.
6495
6757
  // Library members (no bin) are silently skipped: nothing for global install to expose.
6496
6758
  // 2026-09-01 16:30 EDT — Claude Code (Opus 5): this was a verbatim copy of
@@ -6922,6 +7184,10 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
6922
7184
  }
6923
7185
  if (!modulesOk)
6924
7186
  console.log(colors.yellow('Continuing with -force despite npm install failure...'));
7187
+ // 2026-09-07 — Claude Code (Fable 5.1): same allowScripts upkeep the CLI
7188
+ // entrypoint does; the global install below reads the result.
7189
+ if (modulesOk)
7190
+ await ensureAllowScripts(cwd, { verbose });
6925
7191
  }
6926
7192
  // Run build step if package.json has a build script (skip if CLI already built)
6927
7193
  if (pkg.scripts?.build && !options._fromCli) {
@@ -8792,7 +9058,7 @@ export async function globalizeWorkspace(rootDir, options = {}, configOptions =
8792
9058
  console.log(`Packages (${packages.length}): ${packages.map(p => p.name).join(', ')}`);
8793
9059
  console.log(`Publish order: ${publishOrder.join(' → ')}`);
8794
9060
  console.log('');
8795
- // Warn if -local / -install are present at the workspace root: those are
9061
+ // Warn if -local-install / -install are present at the workspace root: those are
8796
9062
  // component-level concerns. In workspace mode they only act on members
8797
9063
  // that have a `bin` field; libraries are silently skipped. Telling the
8798
9064
  // user up front avoids the surprise of "I asked for install but most
@@ -8800,7 +9066,7 @@ export async function globalizeWorkspace(rootDir, options = {}, configOptions =
8800
9066
  const installSources = [];
8801
9067
  if (options.local || configOptions.local) {
8802
9068
  const src = configOptions.local && !options.local ? '.globalize.json5' : 'CLI';
8803
- installSources.push(`-local (${src})`);
9069
+ installSources.push(`-local-install (${src})`);
8804
9070
  }
8805
9071
  if (options.install || configOptions.install) {
8806
9072
  const src = configOptions.install && !options.install ? '.globalize.json5' : 'CLI';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.220",
3
+ "version": "1.0.222",
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.43",
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.43",
64
64
  "@bobfrankston/themecolors": "^0.1.9",
65
65
  "@bobfrankston/userconfig": "^1.0.11",
66
66
  "@npmcli/package-json": "^7.0.4",