npm-workspaces-publish-tool 0.0.1

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.
@@ -0,0 +1,548 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { readFileSync, writeFileSync } from 'fs';
4
+ import { createRequire } from 'module';
5
+ import { dirname, relative, resolve } from 'path';
6
+ import { cwd as getCwd } from 'process';
7
+ import semver from 'semver';
8
+ import { createDependencyMap, getChangesBetweenRefs, getPackageInfos, getWorkspaces, git, } from 'workspace-tools';
9
+ import { execSync } from 'child_process';
10
+ const require = createRequire(import.meta.url);
11
+ const pkg = require('../../package.json');
12
+ const cwd = getCwd();
13
+ const dependancyTypes = [
14
+ 'dependencies',
15
+ 'devDependencies',
16
+ 'peerDependencies',
17
+ 'optionalDependencies',
18
+ ];
19
+ function getLastTag(cwd) {
20
+ const result = git(['tag', '--list', 'v*', '--sort=-v:refname'], { cwd });
21
+ if (!result.success)
22
+ return null;
23
+ const tags = result.stdout.split('\n').filter(Boolean);
24
+ return tags[0] || null;
25
+ }
26
+ function calculateWorkspaceInDegree(packageInfos, dependencies) {
27
+ const inDegree = new Map();
28
+ for (const pkgName of Object.keys(packageInfos)) {
29
+ inDegree.set(pkgName, 0);
30
+ }
31
+ for (const [_, deps] of dependencies) {
32
+ for (const dep of deps) {
33
+ inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1);
34
+ }
35
+ }
36
+ return inDegree;
37
+ }
38
+ function getReleaseOrderFromInDegree(inDegree, dependencies) {
39
+ const queue = Array.from(inDegree.entries())
40
+ .filter(([_, count]) => count === 0)
41
+ .map(([pkg]) => pkg);
42
+ const order = [];
43
+ while (queue.length > 0) {
44
+ const current = queue.shift();
45
+ order.push(current);
46
+ if (dependencies.has(current)) {
47
+ for (const dependent of dependencies.get(current)) {
48
+ const newCount = inDegree.get(dependent) - 1;
49
+ inDegree.set(dependent, newCount);
50
+ if (newCount === 0) {
51
+ queue.push(dependent);
52
+ }
53
+ }
54
+ }
55
+ }
56
+ return order;
57
+ }
58
+ function getDirtyMap(workspaces, lastTag) {
59
+ const dirtyMap = new Map();
60
+ const changedFiles = lastTag
61
+ ? getChangesBetweenRefs(lastTag, 'HEAD', [], '', cwd)
62
+ : [];
63
+ for (const ws of workspaces) {
64
+ const isNew = !lastTag;
65
+ const isDirty = changedFiles.some((f) => f.startsWith(ws.path + '/') || f === ws.path);
66
+ if (isNew) {
67
+ dirtyMap.set(ws.name, 'new');
68
+ }
69
+ else if (isDirty) {
70
+ dirtyMap.set(ws.name, 'dirty');
71
+ }
72
+ else {
73
+ dirtyMap.set(ws.name, 'unchanged');
74
+ }
75
+ }
76
+ return dirtyMap;
77
+ }
78
+ function getDirtyPackagesVersionChanges(workspaces, dirtyMap, lastTag, cwd) {
79
+ const result = new Map();
80
+ if (!lastTag) {
81
+ for (const ws of workspaces) {
82
+ const status = dirtyMap.get(ws.name);
83
+ if (status !== 'dirty' && status !== 'new')
84
+ continue;
85
+ const pkgPath = resolve(cwd, ws.path, 'package.json');
86
+ let currentPkg;
87
+ try {
88
+ currentPkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
89
+ }
90
+ catch (err) {
91
+ throw new Error(`Failed to parse current package.json for package "${ws.name}" at path "${pkgPath}": ${err}`);
92
+ }
93
+ const newVersion = currentPkg.version;
94
+ result.set(ws.name, {
95
+ oldVersion: null,
96
+ newVersion,
97
+ versionChanged: true,
98
+ versionIncremented: true,
99
+ });
100
+ }
101
+ return result;
102
+ }
103
+ for (const ws of workspaces) {
104
+ const status = dirtyMap.get(ws.name);
105
+ if (status !== 'dirty' && status !== 'new')
106
+ continue;
107
+ const pkgPath = resolve(cwd, ws.path, 'package.json');
108
+ let currentPkg;
109
+ try {
110
+ currentPkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
111
+ }
112
+ catch (err) {
113
+ throw new Error(`Failed to parse current package.json for package "${ws.name}" at path "${pkgPath}": ${err}`);
114
+ }
115
+ const newVersion = currentPkg.version;
116
+ if (status === 'new') {
117
+ result.set(ws.name, {
118
+ oldVersion: null,
119
+ newVersion,
120
+ versionChanged: true,
121
+ versionIncremented: true,
122
+ });
123
+ continue;
124
+ }
125
+ const previousPkgRaw = git(['show', `${lastTag}:${ws.path}/package.json`], { cwd });
126
+ if (!previousPkgRaw.success) {
127
+ result.set(ws.name, {
128
+ oldVersion: null,
129
+ newVersion,
130
+ versionChanged: true,
131
+ versionIncremented: true,
132
+ });
133
+ continue;
134
+ }
135
+ let previousPkg;
136
+ try {
137
+ previousPkg = JSON.parse(previousPkgRaw.stdout);
138
+ }
139
+ catch (err) {
140
+ throw new Error(`Failed to parse package.json from git at tag "${lastTag}" for package "${ws.name}": ${err}`);
141
+ }
142
+ const oldVersion = previousPkg.version;
143
+ const versionChanged = newVersion !== oldVersion;
144
+ const versionIncremented = semver.gt(newVersion, oldVersion);
145
+ result.set(ws.name, {
146
+ oldVersion,
147
+ newVersion,
148
+ versionChanged,
149
+ versionIncremented,
150
+ });
151
+ }
152
+ return result;
153
+ }
154
+ function checkForUnpushedCommits(cwd) {
155
+ const branchResult = git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
156
+ if (!branchResult.success) {
157
+ console.error('ERROR: Could not determine current branch.');
158
+ return false;
159
+ }
160
+ const currentBranch = branchResult.stdout.trim();
161
+ if (currentBranch === 'HEAD') {
162
+ console.error('ERROR: You are in detached HEAD state. Cannot check for unpushed commits.');
163
+ return false;
164
+ }
165
+ const upstreamResult = git(['rev-parse', '--abbrev-ref', '@{u}'], { cwd });
166
+ if (!upstreamResult.success) {
167
+ console.error(`ERROR: Current branch '${currentBranch}' has no upstream set.`);
168
+ console.error('Please set upstream with: git branch --set-upstream-to=origin/<branch>');
169
+ return false;
170
+ }
171
+ const upstream = upstreamResult.stdout.trim();
172
+ const unpushed = git(['log', `${upstream}..HEAD`, '--oneline'], { cwd });
173
+ if (unpushed.stdout.trim() !== '') {
174
+ console.error('ERROR: You have committed but unpushed changes:');
175
+ console.error(unpushed.stdout);
176
+ console.error(`Please push your changes to '${upstream}' before publishing.`);
177
+ return false;
178
+ }
179
+ return true;
180
+ }
181
+ function verifyCleanGitStatus(workspaces, dirtyMap, cwd) {
182
+ const changedFilesRaw = git(['diff', '--name-only', 'HEAD'], { cwd })
183
+ .stdout.trim()
184
+ .split('\n')
185
+ .filter(Boolean);
186
+ const stagedFilesRaw = git(['diff', '--cached', '--name-only'], { cwd })
187
+ .stdout.trim()
188
+ .split('\n')
189
+ .filter(Boolean);
190
+ const unstagedFilesRaw = git(['diff', '--name-only'], { cwd })
191
+ .stdout.trim()
192
+ .split('\n')
193
+ .filter(Boolean);
194
+ const status = git(['status', '--porcelain'], { cwd })
195
+ .stdout.trim()
196
+ .split('\n')
197
+ .filter(Boolean);
198
+ const untrackedFilesRaw = status
199
+ .filter((line) => line.startsWith('??'))
200
+ .map((line) => line.slice(3));
201
+ const workspaceIssues = new Map();
202
+ for (const ws of workspaces) {
203
+ workspaceIssues.set(ws.name, {
204
+ unstagedFiles: [],
205
+ stagedFiles: [],
206
+ untrackedFiles: [],
207
+ });
208
+ }
209
+ function findWorkspaceForFile(filePath) {
210
+ const absoluteFilePath = resolve(cwd, filePath).replace(/\\/g, '/');
211
+ for (const ws of workspaces) {
212
+ const wsPathNormalized = resolve(cwd, ws.path)
213
+ .replace(/\\/g, '/')
214
+ .replace(/\/$/, '');
215
+ if (absoluteFilePath === wsPathNormalized ||
216
+ absoluteFilePath.startsWith(wsPathNormalized + '/')) {
217
+ return ws;
218
+ }
219
+ }
220
+ return undefined;
221
+ }
222
+ const changedFiles = new Set(changedFilesRaw);
223
+ const stagedFiles = new Set(stagedFilesRaw);
224
+ const unstagedFiles = new Set(unstagedFilesRaw);
225
+ for (const filePath of changedFiles) {
226
+ const ws = findWorkspaceForFile(filePath);
227
+ if (!ws)
228
+ continue;
229
+ const status = dirtyMap.get(ws.name);
230
+ if (status !== 'new' && status !== 'dirty')
231
+ continue;
232
+ const issues = workspaceIssues.get(ws.name);
233
+ if (unstagedFiles.has(filePath)) {
234
+ issues.unstagedFiles.push(filePath);
235
+ }
236
+ else if (stagedFiles.has(filePath)) {
237
+ issues.stagedFiles.push(filePath);
238
+ }
239
+ }
240
+ for (const filePath of untrackedFilesRaw) {
241
+ const ws = findWorkspaceForFile(filePath);
242
+ if (!ws)
243
+ continue;
244
+ if (dirtyMap.get(ws.name) !== 'new' &&
245
+ dirtyMap.get(ws.name) !== 'dirty')
246
+ continue;
247
+ workspaceIssues.get(ws.name).untrackedFiles.push(filePath);
248
+ }
249
+ let hasCriticalIssues = false;
250
+ for (const [wsName, issues] of workspaceIssues.entries()) {
251
+ const { unstagedFiles, stagedFiles, untrackedFiles } = issues;
252
+ const status = dirtyMap.get(wsName);
253
+ // Skip workspaces without issues
254
+ if (unstagedFiles.length === 0 &&
255
+ stagedFiles.length === 0 &&
256
+ untrackedFiles.length === 0) {
257
+ continue;
258
+ }
259
+ let workspaceHasCriticalIssues = false;
260
+ const workspaceWarnings = [];
261
+ if (unstagedFiles.length > 0) {
262
+ workspaceHasCriticalIssues = true;
263
+ }
264
+ if (stagedFiles.length > 0) {
265
+ workspaceHasCriticalIssues = true;
266
+ }
267
+ // Handle untracked files
268
+ if (untrackedFiles.length > 0) {
269
+ let hasCriticalUntracked = false;
270
+ for (const f of untrackedFiles) {
271
+ if (f.includes('package.json')) {
272
+ hasCriticalUntracked = true;
273
+ }
274
+ }
275
+ if (status === 'new') {
276
+ // All untracked files are critical for new workspaces
277
+ workspaceHasCriticalIssues = true;
278
+ }
279
+ else if (hasCriticalUntracked) {
280
+ // Untracked package.json is always critical
281
+ workspaceHasCriticalIssues = true;
282
+ }
283
+ else if (status === 'dirty') {
284
+ // Non-package.json untracked files are warnings
285
+ workspaceWarnings.push(...untrackedFiles);
286
+ }
287
+ }
288
+ if (workspaceHasCriticalIssues) {
289
+ hasCriticalIssues = true;
290
+ console.error(`Workspace '${wsName}' has critical issues preventing publish:`);
291
+ if (unstagedFiles.length > 0) {
292
+ console.error(' āŒ Unstaged files:');
293
+ for (const f of unstagedFiles) {
294
+ console.error(` - ${f}`);
295
+ }
296
+ }
297
+ if (stagedFiles.length > 0) {
298
+ console.error(' āŒ Staged but uncommitted files:');
299
+ for (const f of stagedFiles) {
300
+ console.error(` - ${f}`);
301
+ }
302
+ }
303
+ if (untrackedFiles.length > 0) {
304
+ console.error(' āŒ Critical untracked files:');
305
+ for (const f of untrackedFiles) {
306
+ // Only show package.json files as critical
307
+ if (f.includes('package.json')) {
308
+ console.error(` - ${f} (must be committed)`);
309
+ }
310
+ else if (status === 'new') {
311
+ console.error(` - ${f} (new workspace must commit all files)`);
312
+ }
313
+ }
314
+ }
315
+ }
316
+ // Show non-critical warnings
317
+ if (workspaceWarnings.length > 0) {
318
+ console.warn(`āš ļø Workspace '${wsName}' has non-critical issues:`);
319
+ console.warn(' Untracked files (allowed but should be in .gitignore):');
320
+ for (const f of workspaceWarnings) {
321
+ console.warn(` - ${f}`);
322
+ }
323
+ }
324
+ }
325
+ const rootPackageJsonPath = 'package.json';
326
+ if (changedFiles.has(rootPackageJsonPath)) {
327
+ if (unstagedFiles.has(rootPackageJsonPath)) {
328
+ console.error(`āŒ Root package.json has unstaged changes. Please stage or discard them.`);
329
+ hasCriticalIssues = true;
330
+ }
331
+ if (stagedFiles.has(rootPackageJsonPath)) {
332
+ console.error(`āŒ Root package.json is staged but not committed. Please commit it.`);
333
+ hasCriticalIssues = true;
334
+ }
335
+ }
336
+ if (hasCriticalIssues) {
337
+ console.error('Please commit or stash the above changes before publishing.');
338
+ return false;
339
+ }
340
+ if (!checkForUnpushedCommits(cwd)) {
341
+ return false;
342
+ }
343
+ return true;
344
+ }
345
+ function validatePublish() {
346
+ const lastMonoRepoTag = getLastTag(cwd);
347
+ const workspaces = getWorkspaces(cwd);
348
+ const packageInfos = getPackageInfos(cwd);
349
+ const { dependencies } = createDependencyMap(packageInfos);
350
+ const inDegree = calculateWorkspaceInDegree(packageInfos, dependencies);
351
+ const releaseOrder = getReleaseOrderFromInDegree(inDegree, dependencies);
352
+ const dirtyMap = getDirtyMap(workspaces, lastMonoRepoTag);
353
+ const dirtyVersionChanges = getDirtyPackagesVersionChanges(workspaces, dirtyMap, lastMonoRepoTag, cwd);
354
+ console.log('šŸ—ļø Building packages...');
355
+ for (const pkgName of releaseOrder) {
356
+ const pkgInfo = packageInfos[pkgName];
357
+ if (!pkgInfo.scripts?.build)
358
+ continue;
359
+ const pkgDir = dirname(pkgInfo.packageJsonPath);
360
+ console.log(`\nšŸ—ļø Building ${pkgName}...`);
361
+ try {
362
+ execSync('npm run build', {
363
+ cwd: pkgDir,
364
+ stdio: 'inherit',
365
+ env: { ...process.env, FORCE_COLOR: '1' },
366
+ });
367
+ }
368
+ catch (error) {
369
+ console.error(`āŒ Build failed for ${pkgName}:`, error);
370
+ process.exit(1);
371
+ }
372
+ }
373
+ console.log('#ļøāƒ£ Validating versions...\n');
374
+ let hasError = false;
375
+ for (const [pkgName, { oldVersion, newVersion, versionChanged, versionIncremented },] of dirtyVersionChanges.entries()) {
376
+ if (!semver.valid(newVersion)) {
377
+ console.error(`āŒ Package "${pkgName}" has invalid version: "${newVersion}"`);
378
+ hasError = true;
379
+ continue;
380
+ }
381
+ if (!versionChanged) {
382
+ console.error(`āŒ Package "${pkgName}" was modified but version unchanged (${newVersion})`);
383
+ hasError = true;
384
+ const ws = workspaces.find((w) => w.name === pkgName);
385
+ const changedFiles = lastMonoRepoTag
386
+ ? getChangesBetweenRefs(lastMonoRepoTag, 'HEAD', [], ws.path, cwd)
387
+ : [];
388
+ if (changedFiles.length > 0) {
389
+ console.error(` Changed files in "${pkgName}":`);
390
+ for (const file of changedFiles) {
391
+ console.error(` - ${relative(ws.path, file)}`);
392
+ }
393
+ }
394
+ continue;
395
+ }
396
+ if (oldVersion && !versionIncremented) {
397
+ console.error(`āŒ Package "${pkgName}" version not incremented: ${oldVersion} -> ${newVersion}`);
398
+ hasError = true;
399
+ }
400
+ }
401
+ if (hasError) {
402
+ console.error('\nFix the above issues before publishing.\n');
403
+ process.exit(1);
404
+ }
405
+ console.log('šŸ—ƒļø Validating git status...\n');
406
+ if (!verifyCleanGitStatus(workspaces, dirtyMap, cwd)) {
407
+ process.exit(1);
408
+ }
409
+ console.log('šŸ“ Publish summary:\n');
410
+ const packagesToRelease = [];
411
+ for (const pkgName of releaseOrder) {
412
+ const status = dirtyMap.get(pkgName);
413
+ const versionInfo = dirtyVersionChanges.get(pkgName);
414
+ if (status === 'new') {
415
+ packagesToRelease.push(pkgName);
416
+ const ws = workspaces.find((w) => w.name === pkgName);
417
+ const currentVersion = JSON.parse(readFileSync(resolve(cwd, ws.path, 'package.json'), 'utf8')).version;
418
+ console.log(`šŸ†• ${pkgName} @ ${currentVersion} (new)`);
419
+ }
420
+ else if (status === 'dirty' && versionInfo?.versionIncremented) {
421
+ packagesToRelease.push(pkgName);
422
+ console.log(`ā¬†ļø ${pkgName}: ${versionInfo.oldVersion} → ${versionInfo.newVersion}`);
423
+ }
424
+ else {
425
+ const ws = workspaces.find((w) => w.name === pkgName);
426
+ const currentVersion = JSON.parse(readFileSync(resolve(cwd, ws.path, 'package.json'), 'utf8')).version;
427
+ console.log(`āœ”ļø ${pkgName} @ ${currentVersion} (unchanged)`);
428
+ }
429
+ }
430
+ return { releaseOrder, packageInfos, packagesToRelease };
431
+ }
432
+ function replaceWorkspaceDepsWithVersions(packageInfos, packagesToUpdate) {
433
+ const originals = new Map();
434
+ for (const pkgName of packagesToUpdate) {
435
+ const pkgInfo = packageInfos[pkgName];
436
+ const packageJsonPath = pkgInfo.packageJsonPath;
437
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
438
+ originals.set(pkgName, JSON.stringify(packageJson));
439
+ let changed = false;
440
+ for (const depType of dependancyTypes) {
441
+ const deps = packageJson[depType];
442
+ if (!deps)
443
+ continue;
444
+ for (const [depName, currentSpec] of Object.entries(deps)) {
445
+ if (currentSpec === '*' && packageInfos[depName]) {
446
+ const newVersion = packageInfos[depName].packageJson.version;
447
+ deps[depName] = newVersion;
448
+ changed = true;
449
+ console.log(`šŸ†™ Updated ${pkgName}'s ${depType} for ${depName} to ${newVersion}`);
450
+ }
451
+ }
452
+ }
453
+ if (changed) {
454
+ writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
455
+ }
456
+ }
457
+ return originals;
458
+ }
459
+ function restoreWorkspaceDepsToStar(packageInfos, packagesToRestore, originals) {
460
+ for (const pkgName of packagesToRestore) {
461
+ const original = originals.get(pkgName);
462
+ if (original) {
463
+ const pkgInfo = packageInfos[pkgName];
464
+ writeFileSync(pkgInfo.packageJsonPath, original);
465
+ console.log(`ā™»ļø Restored dependencies for ${pkgName}`);
466
+ }
467
+ }
468
+ }
469
+ function runNpmPublishInReleaseOrder(releaseOrder, packageInfos, packagesToRelease) {
470
+ for (const packageName of releaseOrder) {
471
+ if (!packagesToRelease.includes(packageName))
472
+ continue;
473
+ const pkgInfo = packageInfos[packageName];
474
+ const pkgDir = dirname(pkgInfo.packageJsonPath);
475
+ console.log(`\nšŸ“¦ Publishing ${packageName} from ${pkgDir}...`);
476
+ try {
477
+ execSync('npm publish', {
478
+ cwd: pkgDir,
479
+ stdio: 'inherit',
480
+ env: { ...process.env, FORCE_COLOR: '1' },
481
+ });
482
+ console.log(`āœ… Successfully published ${packageName}`);
483
+ }
484
+ catch (error) {
485
+ console.error(`āŒ Failed to publish ${packageName}:`, error);
486
+ throw error;
487
+ }
488
+ }
489
+ }
490
+ function printHeader() {
491
+ console.log(`
492
+ _ _ _ _
493
+ ( ) ( )(_) ( )
494
+ ____ _ _ _ ___ ___ _ _ | |_ | | _ __ | |_
495
+ ( __ )( V V )(___)( o \\( U )( o \\( )( )(_' ( _ )
496
+ /_\\/_\\ \\_^_/ / __//___\\/___//_\\/_\\/__)/_\\||
497
+ |_|
498
+ `);
499
+ console.log(`šŸš€ ${pkg.name} v${pkg.version} - Npm Monorepo Publishing Suite\n`);
500
+ }
501
+ program.name(pkg.name).description(pkg.description).version(pkg.version);
502
+ program
503
+ .command('publish')
504
+ .description('Publish packages')
505
+ .option('--dry-run', 'Run without making changes')
506
+ .action((options) => {
507
+ printHeader();
508
+ const { packageInfos, releaseOrder, packagesToRelease } = validatePublish();
509
+ const dryRun = options.dryRun;
510
+ if (packagesToRelease.length === 0) {
511
+ console.log('  ̄\\_(惄)_/ ̄ No packages to publish');
512
+ return;
513
+ }
514
+ let originalPackageJsons = null;
515
+ try {
516
+ if (!dryRun) {
517
+ console.log('\nšŸ†™ Updating workspace dependencies to exact versions...');
518
+ originalPackageJsons = replaceWorkspaceDepsWithVersions(packageInfos, packagesToRelease);
519
+ }
520
+ else {
521
+ console.log('\n[dry-run] šŸ†™ Would update workspace dependencies to exact versions');
522
+ }
523
+ if (!dryRun) {
524
+ console.log('\nšŸ“¦ Publishing packages...');
525
+ runNpmPublishInReleaseOrder(releaseOrder, packageInfos, packagesToRelease);
526
+ console.log('\nšŸŽ‰ All packages published successfully!');
527
+ }
528
+ else {
529
+ console.log('\n[dry-run] šŸ“¦ Would publish packages in order:');
530
+ packagesToRelease.forEach((pkg) => console.log(`- ${pkg}`));
531
+ }
532
+ }
533
+ catch (error) {
534
+ console.error('\n āŒ Publishing failed:', error);
535
+ process.exit(1);
536
+ }
537
+ finally {
538
+ if (!dryRun && originalPackageJsons) {
539
+ console.log('\nā™»ļø Restoring workspace dependencies...');
540
+ restoreWorkspaceDepsToStar(packageInfos, packagesToRelease, originalPackageJsons);
541
+ console.log('ā™»ļø Restoration complete.');
542
+ }
543
+ else if (dryRun) {
544
+ console.log('\n[dry-run] ā™»ļø Would restore workspace dependencies');
545
+ }
546
+ }
547
+ });
548
+ program.parse(process.argv);