@bobfrankston/npmglobalize 1.0.159 → 1.0.161

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.
package/README.md CHANGED
@@ -189,6 +189,31 @@ npmglobalize -np # --nopublish (formerly --apply)
189
189
  npmglobalize --cleanup # Restore original file: references
190
190
  ```
191
191
 
192
+ ### 📝 Release Notes via `.commitmsg`
193
+
194
+ For multi-line or reusable release notes, write them to a `.commitmsg` file in the package root instead of passing them on the command line:
195
+
196
+ ```bash
197
+ cat > .commitmsg <<'EOF'
198
+ Added foo feature
199
+ Fixed bar regression
200
+ EOF
201
+ npmglobalize
202
+ ```
203
+
204
+ Behavior:
205
+ - If `-m` / `-message` is **not** given and `.commitmsg` exists, its contents are used as the commit message (and force a release even if the working tree is otherwise clean).
206
+ - After a successful `npm publish`, npmglobalize:
207
+ 1. Appends the contents to `npmchanges.md` under a `## v<version> — <YYYY-MM-DD>` header (creating the file if needed)
208
+ 2. Deletes `.commitmsg`
209
+ 3. Commits both changes as `Log v<version> to npmchanges.md` and pushes
210
+
211
+ Notes:
212
+ - **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`).
213
+ - If both `-m` and `.commitmsg` are present, `-m` wins and `.commitmsg` is left alone (not consumed).
214
+ - If publish fails, `.commitmsg` is preserved for the next attempt.
215
+ - `.commitmsg` is auto-added to `.npmignore` (security pattern) so it never leaks into the tarball.
216
+
192
217
  ### 🔧 Git Integration & Error Recovery
193
218
 
194
219
  **Automatic tag conflict resolution**:
@@ -288,6 +313,8 @@ Publishing requires being on a branch so commits and tags can be properly tracke
288
313
  -nopublish, -np Just transform, don't publish (persisted to config)
289
314
  -cleanup Restore file: dependencies from .dependencies backup
290
315
  -m, -message <msg> Custom commit message (forces release even without changes)
316
+ If -m not given, a `.commitmsg` file (if present) is used instead.
317
+ See "Release Notes via .commitmsg" below.
291
318
  ```
292
319
 
293
320
  ### Dependency Options
package/cli.js CHANGED
@@ -27,6 +27,9 @@ Release Options:
27
27
  -nopublish, -np Just transform, don't publish (persisted to config)
28
28
  -cleanup Restore from .dependencies
29
29
  -m, -message <msg> Custom commit message (forces release even without changes)
30
+ If -m not given and a .commitmsg file exists in cwd, its
31
+ contents are used. After a successful publish it is appended
32
+ to npmchanges.md (with version header) and deleted.
30
33
 
31
34
  Dependency Options:
32
35
  -update-deps, -ud Update package.json to latest (minor/patch only, safe)
@@ -41,7 +41,8 @@
41
41
  ".env*",
42
42
  "token*",
43
43
  "certs/",
44
- "*cert*/"
44
+ "*cert*/",
45
+ ".commitmsg"
45
46
  ],
46
47
  // Prompted or auto-added with --conform
47
48
  recommended: [
package/lib.d.ts CHANGED
@@ -243,12 +243,26 @@ export declare function parseVersionTag(tag: string): number[] | null;
243
243
  export declare function compareVersions(a: number[], b: number[]): number;
244
244
  /** Fix version/tag mismatches */
245
245
  export declare function fixVersionTagMismatch(cwd: string, pkg: any, verbose?: boolean): boolean;
246
+ /** Return declared deps (dependencies + devDependencies) that don't resolve from
247
+ * `pkgDir`. Skips `file:`/`workspace:`/`link:` specs — those are handled elsewhere.
248
+ * A declared dep that doesn't resolve means `package.json` and installed
249
+ * `node_modules/` are out of sync (e.g. dep added but `npm install` not re-run). */
250
+ export declare function missingDeps(pkgDir: string, pkg: any): string[];
246
251
  /** Walk `file:` deps transitively and run `npm install` in any target whose
247
- * package.json declares deps but has no `node_modules/`. Also covers `cwd`
248
- * itself on the first call, so a fresh clone with no `node_modules/` gets
249
- * installed before the upcoming build/pack tries to resolve imports.
252
+ * declared deps aren't all resolvable from its directory. Covers both
253
+ * "fresh clone, no `node_modules/`" and "dep added but `npm install` not
254
+ * re-run" (partial-sync) cases. Also covers `cwd` itself on the first call.
250
255
  * Cycle-safe via the shared `visited` set. */
251
256
  export declare function ensureFileDepModules(cwd: string, verbose?: boolean, visited?: Set<string>): void;
257
+ /** Ensure the workspace-root `node_modules/` is in sync with every member's
258
+ * declared deps. Workspaces hoist deps to the root, so a dep added to any
259
+ * member `package.json` without a follow-up `npm install` at the root leaves
260
+ * the root `node_modules/` stale — the case where `member/` dirs exist but
261
+ * the actual package (e.g. `marked`) is not hoisted. */
262
+ export declare function ensureWorkspaceDepModules(rootDir: string, members: Array<{
263
+ dir: string;
264
+ pkg: any;
265
+ }>, verbose?: boolean): void;
252
266
  /** Run a command and return success status */
253
267
  export declare function runCommand(cmd: string, args: string[], options?: {
254
268
  silent?: boolean;
package/lib.js CHANGED
@@ -1322,10 +1322,51 @@ function restoreNestedDepModules(stashed, verbose) {
1322
1322
  }
1323
1323
  }
1324
1324
  }
1325
+ /** Walk up the directory tree looking for `node_modules/<depName>/package.json`.
1326
+ * Matches Node's module resolution so hoisted workspace deps are found. */
1327
+ function depResolves(startDir, depName) {
1328
+ let dir = path.resolve(startDir);
1329
+ const segs = depName.split('/');
1330
+ while (true) {
1331
+ const candidate = path.join(dir, 'node_modules', ...segs, 'package.json');
1332
+ if (fs.existsSync(candidate))
1333
+ return true;
1334
+ const parent = path.dirname(dir);
1335
+ if (parent === dir)
1336
+ return false;
1337
+ dir = parent;
1338
+ }
1339
+ }
1340
+ /** Return declared deps (dependencies + devDependencies) that don't resolve from
1341
+ * `pkgDir`. Skips `file:`/`workspace:`/`link:` specs — those are handled elsewhere.
1342
+ * A declared dep that doesn't resolve means `package.json` and installed
1343
+ * `node_modules/` are out of sync (e.g. dep added but `npm install` not re-run). */
1344
+ export function missingDeps(pkgDir, pkg) {
1345
+ const missing = [];
1346
+ for (const key of ['dependencies', 'devDependencies']) {
1347
+ const deps = pkg?.[key];
1348
+ if (!deps || typeof deps !== 'object')
1349
+ continue;
1350
+ for (const [name, spec] of Object.entries(deps)) {
1351
+ if (typeof spec !== 'string')
1352
+ continue;
1353
+ if (spec.startsWith('file:') || spec.startsWith('workspace:') || spec.startsWith('link:'))
1354
+ continue;
1355
+ if (!depResolves(pkgDir, name))
1356
+ missing.push(name);
1357
+ }
1358
+ }
1359
+ return missing;
1360
+ }
1361
+ function formatMissingReason(missing) {
1362
+ if (missing.length === 1)
1363
+ return `missing dep: ${missing[0]}`;
1364
+ return `${missing.length} missing deps: ${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}`;
1365
+ }
1325
1366
  /** Walk `file:` deps transitively and run `npm install` in any target whose
1326
- * package.json declares deps but has no `node_modules/`. Also covers `cwd`
1327
- * itself on the first call, so a fresh clone with no `node_modules/` gets
1328
- * installed before the upcoming build/pack tries to resolve imports.
1367
+ * declared deps aren't all resolvable from its directory. Covers both
1368
+ * "fresh clone, no `node_modules/`" and "dep added but `npm install` not
1369
+ * re-run" (partial-sync) cases. Also covers `cwd` itself on the first call.
1329
1370
  * Cycle-safe via the shared `visited` set. */
1330
1371
  export function ensureFileDepModules(cwd, verbose = false, visited = new Set()) {
1331
1372
  const abs = path.resolve(cwd);
@@ -1339,11 +1380,13 @@ export function ensureFileDepModules(cwd, verbose = false, visited = new Set())
1339
1380
  catch {
1340
1381
  return;
1341
1382
  }
1342
- const cwdHasDeps = (pkg?.dependencies && Object.keys(pkg.dependencies).length > 0) ||
1343
- (pkg?.devDependencies && Object.keys(pkg.devDependencies).length > 0);
1344
- if (cwdHasDeps && !fs.existsSync(path.join(abs, 'node_modules'))) {
1383
+ const cwdMissing = missingDeps(abs, pkg);
1384
+ if (cwdMissing.length > 0) {
1385
+ // Only clear a stale lockfile if node_modules is entirely absent (fresh clone).
1386
+ // For partial-sync cases, keep the lockfile so pinned versions stay honored.
1387
+ const nm = path.join(abs, 'node_modules');
1345
1388
  const lock = path.join(abs, 'package-lock.json');
1346
- if (fs.existsSync(lock)) {
1389
+ if (!fs.existsSync(nm) && fs.existsSync(lock)) {
1347
1390
  if (verbose)
1348
1391
  console.log(colors.dim(` removing stale ${lock}`));
1349
1392
  try {
@@ -1351,7 +1394,7 @@ export function ensureFileDepModules(cwd, verbose = false, visited = new Set())
1351
1394
  }
1352
1395
  catch { /* best-effort */ }
1353
1396
  }
1354
- console.log(colors.yellow(`↻ installing node_modules in ${pkg?.name || abs}`));
1397
+ console.log(colors.yellow(`↻ installing node_modules in ${pkg?.name || abs} (${formatMissingReason(cwdMissing)})`));
1355
1398
  const r = runCommand('npm', ['install'], { cwd: abs, silent: !verbose });
1356
1399
  if (!r.success) {
1357
1400
  console.error(colors.red(` ✗ npm install failed in ${abs}`));
@@ -1376,12 +1419,11 @@ export function ensureFileDepModules(cwd, verbose = false, visited = new Set())
1376
1419
  catch {
1377
1420
  continue;
1378
1421
  }
1379
- const hasDeps = (targetPkg?.dependencies && Object.keys(targetPkg.dependencies).length > 0) ||
1380
- (targetPkg?.devDependencies && Object.keys(targetPkg.devDependencies).length > 0);
1381
- const nm = path.join(target, 'node_modules');
1382
- if (hasDeps && !fs.existsSync(nm)) {
1422
+ const targetMissing = missingDeps(target, targetPkg);
1423
+ if (targetMissing.length > 0) {
1424
+ const nm = path.join(target, 'node_modules');
1383
1425
  const lock = path.join(target, 'package-lock.json');
1384
- if (fs.existsSync(lock)) {
1426
+ if (!fs.existsSync(nm) && fs.existsSync(lock)) {
1385
1427
  if (verbose)
1386
1428
  console.log(colors.dim(` removing stale ${lock}`));
1387
1429
  try {
@@ -1389,7 +1431,7 @@ export function ensureFileDepModules(cwd, verbose = false, visited = new Set())
1389
1431
  }
1390
1432
  catch { /* best-effort */ }
1391
1433
  }
1392
- console.log(colors.yellow(`↻ restoring node_modules in ${name} (${target})`));
1434
+ console.log(colors.yellow(`↻ restoring node_modules in ${name} (${target}) (${formatMissingReason(targetMissing)})`));
1393
1435
  const r = runCommand('npm', ['install'], { cwd: target, silent: !verbose });
1394
1436
  if (!r.success) {
1395
1437
  console.error(colors.red(` ✗ npm install failed in ${target}`));
@@ -1401,6 +1443,38 @@ export function ensureFileDepModules(cwd, verbose = false, visited = new Set())
1401
1443
  }
1402
1444
  }
1403
1445
  }
1446
+ /** Ensure the workspace-root `node_modules/` is in sync with every member's
1447
+ * declared deps. Workspaces hoist deps to the root, so a dep added to any
1448
+ * member `package.json` without a follow-up `npm install` at the root leaves
1449
+ * the root `node_modules/` stale — the case where `member/` dirs exist but
1450
+ * the actual package (e.g. `marked`) is not hoisted. */
1451
+ export function ensureWorkspaceDepModules(rootDir, members, verbose = false) {
1452
+ const root = path.resolve(rootDir);
1453
+ let rootPkg;
1454
+ try {
1455
+ rootPkg = readPackageJson(root);
1456
+ }
1457
+ catch {
1458
+ return;
1459
+ }
1460
+ const allMissing = new Set();
1461
+ for (const name of missingDeps(root, rootPkg))
1462
+ allMissing.add(name);
1463
+ for (const m of members) {
1464
+ for (const name of missingDeps(m.dir, m.pkg))
1465
+ allMissing.add(name);
1466
+ }
1467
+ if (allMissing.size === 0)
1468
+ return;
1469
+ const list = [...allMissing];
1470
+ console.log(colors.yellow(`↻ installing workspace node_modules in ${rootPkg?.name || path.basename(root)} (${formatMissingReason(list)})`));
1471
+ const r = runCommand('npm', ['install'], { cwd: root, silent: !verbose });
1472
+ if (!r.success) {
1473
+ console.error(colors.red(` ✗ npm install failed in ${root}`));
1474
+ if (r.stderr)
1475
+ console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
1476
+ }
1477
+ }
1404
1478
  /** Run npm install -g with retries for registry propagation delay */
1405
1479
  function installGlobalWithRetry(pkgSpec, cwd, maxRetries = 3) {
1406
1480
  let result = runCommand('npm', ['install', '-g', pkgSpec], { cwd, silent: false, showCommand: true });
@@ -3845,11 +3919,29 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
3845
3919
  }
3846
3920
  // Re-check git status after all transformations and potential commits
3847
3921
  currentGitStatus = getGitStatus(cwd);
3922
+ // If no -m given, fall back to .commitmsg file contents (one-shot changelog entry).
3923
+ // Consumed (appended to npmchanges.md + deleted) only after a successful publish.
3924
+ const commitMsgPath = path.join(cwd, '.commitmsg');
3925
+ let commitMsgFromFile = null;
3926
+ let effectiveMessage = message;
3927
+ if (!effectiveMessage && fs.existsSync(commitMsgPath)) {
3928
+ try {
3929
+ const content = fs.readFileSync(commitMsgPath, 'utf-8').trim();
3930
+ if (content) {
3931
+ commitMsgFromFile = content;
3932
+ effectiveMessage = content;
3933
+ console.log(colors.cyan(` Using .commitmsg for commit message (${content.split('\n')[0].slice(0, 60)}${content.length > 60 ? '…' : ''})`));
3934
+ }
3935
+ }
3936
+ catch (err) {
3937
+ console.error(colors.yellow(` Warning: could not read .commitmsg: ${err.message}`));
3938
+ }
3939
+ }
3848
3940
  // Check if there are changes to commit or a custom message
3849
3941
  // Skip this check for first publish (currentAccess null) or just-initialized repos
3850
3942
  const isFirstPublish = !currentAccess;
3851
3943
  let skipVersionBump = false;
3852
- if (!currentGitStatus.hasUncommitted && !message && !justInitialized && !isFirstPublish) {
3944
+ if (!currentGitStatus.hasUncommitted && !effectiveMessage && !justInitialized && !isFirstPublish) {
3853
3945
  // Check if the current version is actually on npm (it might have failed to publish previously)
3854
3946
  const versionCheck = spawnSafe('npm', ['view', `${pkg.name}@${pkg.version}`, 'version'], {
3855
3947
  shell: process.platform === 'win32',
@@ -4000,7 +4092,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
4000
4092
  }
4001
4093
  // Git operations
4002
4094
  if (currentGitStatus.hasUncommitted) {
4003
- const commitMsg = message || 'Pre-release commit';
4095
+ const commitMsg = effectiveMessage || 'Pre-release commit';
4004
4096
  console.log(`${timestamp()} Committing changes: ${commitMsg}`);
4005
4097
  if (!dryRun) {
4006
4098
  // Remove 'nul' files that break git on Windows
@@ -4627,6 +4719,36 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
4627
4719
  const finalAccess = effectiveNpmVisibility || currentAccess || (isScoped ? 'restricted' : 'public');
4628
4720
  const accessLabel = (finalAccess === 'restricted' || finalAccess === 'private') ? 'PRIVATE' : 'PUBLIC';
4629
4721
  console.log(`${timestamp()} ${colors.green(`✓ Published to npm as ${accessLabel}`)}`);
4722
+ // Consume .commitmsg: append to npmchanges.md, delete the file, commit+push.
4723
+ // Only runs if .commitmsg was actually used as the commit message.
4724
+ if (commitMsgFromFile) {
4725
+ try {
4726
+ const publishedVersion = readPackageJson(cwd).version;
4727
+ const npmChangesPath = path.join(cwd, 'npmchanges.md');
4728
+ const date = new Date().toISOString().slice(0, 10);
4729
+ const header = `## v${publishedVersion} — ${date}\n\n`;
4730
+ const entry = header + commitMsgFromFile.trim() + '\n\n';
4731
+ const existing = fs.existsSync(npmChangesPath)
4732
+ ? fs.readFileSync(npmChangesPath, 'utf-8')
4733
+ : '# npm Publish Changes\n\n';
4734
+ const sep = existing.endsWith('\n') ? '' : '\n';
4735
+ fs.writeFileSync(npmChangesPath, existing + sep + entry);
4736
+ try {
4737
+ fs.unlinkSync(commitMsgPath);
4738
+ }
4739
+ catch { /* ignore */ }
4740
+ runCommand('git', ['add', 'npmchanges.md', '.commitmsg'], { cwd, silent: true });
4741
+ const logResult = gitCommit(`Log v${publishedVersion} to npmchanges.md`, cwd);
4742
+ if (logResult.success) {
4743
+ console.log(colors.green(` ✓ Appended to npmchanges.md and removed .commitmsg`));
4744
+ if (currentGitStatus.hasRemote)
4745
+ pushWithProtection(cwd, verbose);
4746
+ }
4747
+ }
4748
+ catch (err) {
4749
+ console.error(colors.yellow(` Warning: could not update npmchanges.md: ${err.message}`));
4750
+ }
4751
+ }
4630
4752
  }
4631
4753
  else {
4632
4754
  console.log(` [dry-run] Would run: npm publish ${quiet ? '--quiet' : ''}`);
@@ -4931,6 +5053,12 @@ export async function globalizeWorkspace(rootDir, options = {}, configOptions =
4931
5053
  }
4932
5054
  }
4933
5055
  }
5056
+ // Sync workspace-root node_modules with member package.json files. Catches
5057
+ // the case where a dep was added to a member package.json but `npm install`
5058
+ // wasn't re-run — the builds would otherwise fail resolving the new dep.
5059
+ if (!options.dryRun) {
5060
+ ensureWorkspaceDepModules(rootDir, packages.map(p => ({ dir: p.dir, pkg: p.pkg })), !!options.verbose);
5061
+ }
4934
5062
  // Prescan: decide which packages actually need processing so we don't waste
4935
5063
  // time rebuilding+republishing ones with no relevant changes.
4936
5064
  // A package is SKIPPED only if ALL of:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.159",
3
+ "version": "1.0.161",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",