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,444 @@
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() {
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 verifyCleanGitStatus(workspaces, dirtyMap, cwd) {
155
+ const changedFilesRaw = git(['diff', '--name-only', 'HEAD'], { cwd })
156
+ .stdout.trim()
157
+ .split('\n')
158
+ .filter(Boolean);
159
+ const stagedFilesRaw = git(['diff', '--cached', '--name-only'], { cwd })
160
+ .stdout.trim()
161
+ .split('\n')
162
+ .filter(Boolean);
163
+ const unstagedFilesRaw = git(['diff', '--name-only'], { cwd })
164
+ .stdout.trim()
165
+ .split('\n')
166
+ .filter(Boolean);
167
+ const status = git(['status', '--porcelain'], { cwd })
168
+ .stdout.trim()
169
+ .split('\n')
170
+ .filter(Boolean);
171
+ const untrackedFilesRaw = status
172
+ .filter((line) => line.startsWith('??'))
173
+ .map((line) => line.slice(3));
174
+ const workspaceIssues = new Map();
175
+ for (const ws of workspaces) {
176
+ workspaceIssues.set(ws.name, {
177
+ unstagedFiles: [],
178
+ stagedFiles: [],
179
+ untrackedFiles: [],
180
+ });
181
+ }
182
+ function findWorkspaceForFile(filePath) {
183
+ const absoluteFilePath = resolve(cwd, filePath).replace(/\\/g, '/');
184
+ for (const ws of workspaces) {
185
+ const wsPathNormalized = resolve(cwd, ws.path)
186
+ .replace(/\\/g, '/')
187
+ .replace(/\/$/, '');
188
+ if (absoluteFilePath === wsPathNormalized ||
189
+ absoluteFilePath.startsWith(wsPathNormalized + '/')) {
190
+ return ws;
191
+ }
192
+ }
193
+ return undefined;
194
+ }
195
+ const changedFiles = new Set(changedFilesRaw);
196
+ const stagedFiles = new Set(stagedFilesRaw);
197
+ const unstagedFiles = new Set(unstagedFilesRaw);
198
+ const untrackedFiles = new Set(untrackedFilesRaw);
199
+ for (const filePath of changedFiles) {
200
+ const ws = findWorkspaceForFile(filePath);
201
+ if (!ws)
202
+ continue;
203
+ const status = dirtyMap.get(ws.name);
204
+ if (status !== 'new' && status !== 'dirty')
205
+ continue;
206
+ const issues = workspaceIssues.get(ws.name);
207
+ if (unstagedFiles.has(filePath)) {
208
+ issues.unstagedFiles.push(filePath);
209
+ }
210
+ else if (stagedFiles.has(filePath)) {
211
+ issues.stagedFiles.push(filePath);
212
+ }
213
+ }
214
+ // lets think carefully about untracked files..... if we had a new workspace that something depended on then
215
+ // surely its untracked files must be commited however adding an untracked folder which was not a workspace would be fine
216
+ // we should allow untracked files within a workspace but the entire workspace if something depends on it should not be untracked....
217
+ // if its part of the workspaces it should not be enitrely untracked.... but a sub folder within it would be fine..... think hard on it.
218
+ for (const filePath of untrackedFilesRaw) {
219
+ const ws = findWorkspaceForFile(filePath);
220
+ if (!ws)
221
+ continue;
222
+ if (dirtyMap.get(ws.name) !== 'new' &&
223
+ dirtyMap.get(ws.name) !== 'dirty')
224
+ continue;
225
+ workspaceIssues.get(ws.name).untrackedFiles.push(filePath);
226
+ }
227
+ let hasIssues = false;
228
+ for (const [wsName, issues] of workspaceIssues.entries()) {
229
+ const { unstagedFiles, stagedFiles, untrackedFiles } = issues;
230
+ if (unstagedFiles.length === 0 &&
231
+ stagedFiles.length === 0 &&
232
+ untrackedFiles.length === 0 // but we should allow untracked files its more of a warning than an error isnt it....
233
+ ) {
234
+ continue;
235
+ }
236
+ hasIssues = true;
237
+ console.error(`āŒ Workspace '${wsName}' has issues preventing publish:`);
238
+ if (unstagedFiles.length > 0) {
239
+ console.error(' āš ļø Unstaged files:');
240
+ for (const f of unstagedFiles) {
241
+ console.error(` - ${f}`);
242
+ }
243
+ }
244
+ if (stagedFiles.length > 0) {
245
+ console.error(' āš ļø Staged but uncommitted files:');
246
+ for (const f of stagedFiles) {
247
+ console.error(` - ${f}`);
248
+ }
249
+ }
250
+ if (untrackedFiles.length > 0) {
251
+ console.error(' āš ļø Untracked files:');
252
+ for (const f of untrackedFiles) {
253
+ console.error(` - ${f}`);
254
+ }
255
+ }
256
+ }
257
+ const rootPackageJsonPath = 'package.json';
258
+ if (changedFiles.has(rootPackageJsonPath)) {
259
+ if (unstagedFiles.has(rootPackageJsonPath)) {
260
+ console.error(`āŒ Root package.json has unstaged changes. Please stage or discard them.`);
261
+ hasIssues = true;
262
+ }
263
+ if (stagedFiles.has(rootPackageJsonPath)) {
264
+ console.error(`āŒ Root package.json is staged but not committed. Please commit it.`);
265
+ hasIssues = true;
266
+ }
267
+ }
268
+ if (hasIssues) {
269
+ console.error('Please commit or stash the above changes before publishing.');
270
+ return false;
271
+ }
272
+ return true;
273
+ }
274
+ function validatePublish() {
275
+ const lastMonoRepoTag = getLastTag();
276
+ const workspaces = getWorkspaces(cwd);
277
+ const packageInfos = getPackageInfos(cwd);
278
+ const { dependencies } = createDependencyMap(packageInfos);
279
+ const inDegree = calculateWorkspaceInDegree(packageInfos, dependencies);
280
+ const releaseOrder = getReleaseOrderFromInDegree(inDegree, dependencies);
281
+ const dirtyMap = getDirtyMap(workspaces, lastMonoRepoTag);
282
+ const dirtyVersionChanges = getDirtyPackagesVersionChanges(workspaces, dirtyMap, lastMonoRepoTag, cwd);
283
+ let hasError = false;
284
+ for (const [pkgName, { oldVersion, newVersion, versionChanged, versionIncremented },] of dirtyVersionChanges.entries()) {
285
+ if (!semver.valid(newVersion)) {
286
+ console.error(`āŒ Package "${pkgName}" has invalid version: "${newVersion}"`);
287
+ hasError = true;
288
+ continue;
289
+ }
290
+ if (!versionChanged) {
291
+ console.error(`āŒ Package "${pkgName}" was modified but version unchanged (${newVersion})`);
292
+ hasError = true;
293
+ const ws = workspaces.find((w) => w.name === pkgName);
294
+ const changedFiles = lastMonoRepoTag
295
+ ? getChangesBetweenRefs(lastMonoRepoTag, 'HEAD', [], ws.path, cwd)
296
+ : [];
297
+ if (changedFiles.length > 0) {
298
+ console.error(` Changed files in "${pkgName}":`);
299
+ for (const file of changedFiles) {
300
+ console.error(` - ${relative(ws.path, file)}`);
301
+ }
302
+ }
303
+ continue;
304
+ }
305
+ if (oldVersion && !versionIncremented) {
306
+ console.error(`āŒ Package "${pkgName}" version not incremented: ${oldVersion} -> ${newVersion}`);
307
+ hasError = true;
308
+ }
309
+ }
310
+ if (hasError) {
311
+ console.error('\nFix the above issues before publishing.\n');
312
+ process.exit(1);
313
+ }
314
+ if (!verifyCleanGitStatus(workspaces, dirtyMap, cwd)) {
315
+ process.exit(1);
316
+ }
317
+ console.log('\nPublish summary:\n');
318
+ const packagesToRelease = [];
319
+ for (const pkgName of releaseOrder) {
320
+ const status = dirtyMap.get(pkgName);
321
+ const versionInfo = dirtyVersionChanges.get(pkgName);
322
+ if (status === 'new') {
323
+ packagesToRelease.push(pkgName);
324
+ const ws = workspaces.find((w) => w.name === pkgName);
325
+ const currentVersion = JSON.parse(readFileSync(resolve(cwd, ws.path, 'package.json'), 'utf8')).version;
326
+ console.log(`šŸ†• ${pkgName} @ ${currentVersion} (new)`);
327
+ }
328
+ else if (status === 'dirty' && versionInfo?.versionIncremented) {
329
+ packagesToRelease.push(pkgName);
330
+ console.log(`ā¬†ļø ${pkgName}: ${versionInfo.oldVersion} → ${versionInfo.newVersion}`);
331
+ }
332
+ else {
333
+ const ws = workspaces.find((w) => w.name === pkgName);
334
+ const currentVersion = JSON.parse(readFileSync(resolve(cwd, ws.path, 'package.json'), 'utf8')).version;
335
+ console.log(`āœ”ļø ${pkgName} @ ${currentVersion} (unchanged)`);
336
+ }
337
+ }
338
+ return { releaseOrder, packageInfos, packagesToRelease };
339
+ }
340
+ function replaceWorkspaceDepsWithVersions(packageInfos, packagesToUpdate) {
341
+ const originals = new Map();
342
+ for (const pkgName of packagesToUpdate) {
343
+ const pkgInfo = packageInfos[pkgName];
344
+ const packageJsonPath = pkgInfo.packageJsonPath;
345
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
346
+ originals.set(pkgName, JSON.stringify(packageJson));
347
+ let changed = false;
348
+ for (const depType of dependancyTypes) {
349
+ const deps = packageJson[depType];
350
+ if (!deps)
351
+ continue;
352
+ for (const [depName, currentSpec] of Object.entries(deps)) {
353
+ if (currentSpec === '*' && packageInfos[depName]) {
354
+ const newVersion = packageInfos[depName].packageJson.version;
355
+ deps[depName] = newVersion;
356
+ changed = true;
357
+ console.log(`Updated ${pkgName}'s ${depType} for ${depName} to ${newVersion}`);
358
+ }
359
+ }
360
+ }
361
+ if (changed) {
362
+ writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
363
+ }
364
+ }
365
+ return originals;
366
+ }
367
+ function restoreWorkspaceDepsToStar(packageInfos, packagesToRestore, originals) {
368
+ for (const pkgName of packagesToRestore) {
369
+ const original = originals.get(pkgName);
370
+ if (original) {
371
+ const pkgInfo = packageInfos[pkgName];
372
+ writeFileSync(pkgInfo.packageJsonPath, original);
373
+ console.log(`Restored dependencies for ${pkgName}`);
374
+ }
375
+ }
376
+ }
377
+ function runNpmPublishInReleaseOrder(releaseOrder, packageInfos, packagesToRelease) {
378
+ for (const packageName of releaseOrder) {
379
+ if (!packagesToRelease.includes(packageName))
380
+ continue;
381
+ const pkgInfo = packageInfos[packageName];
382
+ const pkgDir = dirname(pkgInfo.packageJsonPath);
383
+ console.log(`\nPublishing ${packageName} from ${pkgDir}...`);
384
+ try {
385
+ execSync('npm publish', {
386
+ cwd: pkgDir,
387
+ stdio: 'inherit',
388
+ env: { ...process.env, FORCE_COLOR: '1' }
389
+ });
390
+ console.log(`āœ… Successfully published ${packageName}`);
391
+ }
392
+ catch (error) {
393
+ console.error(`āŒ Failed to publish ${packageName}:`, error);
394
+ throw error;
395
+ }
396
+ }
397
+ }
398
+ program.name(pkg.name).description(pkg.description).version(pkg.version);
399
+ program
400
+ .command('publish')
401
+ .description('Publish packages')
402
+ .option('--dry-run', 'Run without making changes')
403
+ .action((options) => {
404
+ const { packageInfos, releaseOrder, packagesToRelease } = validatePublish();
405
+ const dryRun = options.dryRun;
406
+ if (packagesToRelease.length === 0) {
407
+ console.log('No packages to publish');
408
+ return;
409
+ }
410
+ let originalPackageJsons = null;
411
+ try {
412
+ if (!dryRun) {
413
+ console.log('\nUpdating workspace dependencies to exact versions...');
414
+ originalPackageJsons = replaceWorkspaceDepsWithVersions(packageInfos, packagesToRelease);
415
+ }
416
+ else {
417
+ console.log('\n[dry-run] Would update workspace dependencies to exact versions');
418
+ }
419
+ if (!dryRun) {
420
+ console.log('\nPublishing packages...');
421
+ runNpmPublishInReleaseOrder(releaseOrder, packageInfos, packagesToRelease);
422
+ console.log('\nšŸŽ‰ All packages published successfully!');
423
+ }
424
+ else {
425
+ console.log('\n[dry-run] Would publish packages in order:');
426
+ packagesToRelease.forEach(pkg => console.log(`- ${pkg}`));
427
+ }
428
+ }
429
+ catch (error) {
430
+ console.error('\nPublishing failed:', error);
431
+ process.exit(1);
432
+ }
433
+ finally {
434
+ if (!dryRun && originalPackageJsons) {
435
+ console.log('\nRestoring workspace dependencies...');
436
+ restoreWorkspaceDepsToStar(packageInfos, packagesToRelease, originalPackageJsons);
437
+ console.log('Restoration complete.');
438
+ }
439
+ else if (dryRun) {
440
+ console.log('\n[dry-run] Would restore workspace dependencies');
441
+ }
442
+ }
443
+ });
444
+ program.parse(process.argv);