@smoothbricks/cli 0.1.0 → 0.2.0

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 (148) hide show
  1. package/README.md +338 -88
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +136 -22
  4. package/dist/devenv/index.d.ts +10 -0
  5. package/dist/devenv/index.d.ts.map +1 -0
  6. package/dist/devenv/index.js +27 -0
  7. package/dist/lib/cli-package.d.ts +4 -0
  8. package/dist/lib/cli-package.d.ts.map +1 -0
  9. package/dist/lib/cli-package.js +15 -0
  10. package/dist/lib/devenv.d.ts +6 -0
  11. package/dist/lib/devenv.d.ts.map +1 -0
  12. package/dist/lib/devenv.js +82 -0
  13. package/dist/lib/run.d.ts +5 -0
  14. package/dist/lib/run.d.ts.map +1 -1
  15. package/dist/lib/run.js +13 -0
  16. package/dist/lib/workspace.d.ts +20 -1
  17. package/dist/lib/workspace.d.ts.map +1 -1
  18. package/dist/lib/workspace.js +87 -7
  19. package/dist/monorepo/commit-msg.d.ts +8 -1
  20. package/dist/monorepo/commit-msg.d.ts.map +1 -1
  21. package/dist/monorepo/commit-msg.js +85 -7
  22. package/dist/monorepo/index.d.ts +24 -2
  23. package/dist/monorepo/index.d.ts.map +1 -1
  24. package/dist/monorepo/index.js +136 -15
  25. package/dist/monorepo/lockfile.d.ts +5 -1
  26. package/dist/monorepo/lockfile.d.ts.map +1 -1
  27. package/dist/monorepo/lockfile.js +45 -5
  28. package/dist/monorepo/managed-files.d.ts +1 -1
  29. package/dist/monorepo/managed-files.d.ts.map +1 -1
  30. package/dist/monorepo/managed-files.js +28 -11
  31. package/dist/monorepo/nx-sync.d.ts +2 -1
  32. package/dist/monorepo/nx-sync.d.ts.map +1 -1
  33. package/dist/monorepo/nx-sync.js +8 -2
  34. package/dist/monorepo/package-hygiene.d.ts +6 -2
  35. package/dist/monorepo/package-hygiene.d.ts.map +1 -1
  36. package/dist/monorepo/package-hygiene.js +12 -4
  37. package/dist/monorepo/package-policy.d.ts +25 -2
  38. package/dist/monorepo/package-policy.d.ts.map +1 -1
  39. package/dist/monorepo/package-policy.js +1289 -27
  40. package/dist/monorepo/packed-manifest.d.ts +4 -0
  41. package/dist/monorepo/packed-manifest.d.ts.map +1 -0
  42. package/dist/monorepo/packed-manifest.js +51 -0
  43. package/dist/monorepo/packed-package.d.ts +3 -0
  44. package/dist/monorepo/packed-package.d.ts.map +1 -1
  45. package/dist/monorepo/packed-package.js +69 -31
  46. package/dist/monorepo/packs/index.d.ts +20 -1
  47. package/dist/monorepo/packs/index.d.ts.map +1 -1
  48. package/dist/monorepo/packs/index.js +240 -18
  49. package/dist/monorepo/publish-workflow.d.ts +82 -0
  50. package/dist/monorepo/publish-workflow.d.ts.map +1 -0
  51. package/dist/monorepo/publish-workflow.js +336 -0
  52. package/dist/monorepo/tool-validation.d.ts +22 -0
  53. package/dist/monorepo/tool-validation.d.ts.map +1 -0
  54. package/dist/monorepo/tool-validation.js +399 -0
  55. package/dist/nx/index.d.ts +24 -0
  56. package/dist/nx/index.d.ts.map +1 -0
  57. package/dist/nx/index.js +94 -0
  58. package/dist/nx-version-actions.cjs +25 -0
  59. package/dist/nx-version-actions.d.cts +6 -0
  60. package/dist/nx-version-actions.d.cts.map +1 -0
  61. package/dist/release/bootstrap-npm-packages.d.ts +19 -0
  62. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -0
  63. package/dist/release/bootstrap-npm-packages.js +58 -0
  64. package/dist/release/candidates.d.ts +11 -0
  65. package/dist/release/candidates.d.ts.map +1 -0
  66. package/dist/release/candidates.js +181 -0
  67. package/dist/release/core.d.ts +51 -0
  68. package/dist/release/core.d.ts.map +1 -0
  69. package/dist/release/core.js +97 -0
  70. package/dist/release/github-release.d.ts +46 -0
  71. package/dist/release/github-release.d.ts.map +1 -0
  72. package/dist/release/github-release.js +97 -0
  73. package/dist/release/index.d.ts +39 -5
  74. package/dist/release/index.d.ts.map +1 -1
  75. package/dist/release/index.js +763 -130
  76. package/dist/release/npm-auth.d.ts +16 -0
  77. package/dist/release/npm-auth.d.ts.map +1 -0
  78. package/dist/release/npm-auth.js +39 -0
  79. package/dist/release/orchestration.d.ts +49 -0
  80. package/dist/release/orchestration.d.ts.map +1 -0
  81. package/dist/release/orchestration.js +113 -0
  82. package/dist/release/publish-plan.d.ts +17 -0
  83. package/dist/release/publish-plan.d.ts.map +1 -0
  84. package/dist/release/publish-plan.js +15 -0
  85. package/dist/release/retag-unpublished.d.ts +34 -0
  86. package/dist/release/retag-unpublished.d.ts.map +1 -0
  87. package/dist/release/retag-unpublished.js +77 -0
  88. package/managed/raw/git-format-staged.yml +54 -0
  89. package/managed/raw/tooling/git-hooks/commit-msg.sh +1 -1
  90. package/managed/raw/tooling/git-hooks/pre-commit.sh +2 -7
  91. package/managed/templates/github/actions/setup-devenv/action.yml +1 -7
  92. package/managed/templates/github/workflows/ci.yml +3 -0
  93. package/package.json +37 -4
  94. package/src/cli.ts +193 -40
  95. package/src/devenv/index.test.ts +49 -0
  96. package/src/devenv/index.ts +38 -0
  97. package/src/lib/cli-package.ts +18 -0
  98. package/src/lib/devenv.test.ts +28 -0
  99. package/src/lib/devenv.ts +89 -0
  100. package/src/lib/run.ts +19 -0
  101. package/src/lib/workspace.ts +117 -7
  102. package/src/monorepo/__tests__/nx-version-actions.test.ts +75 -0
  103. package/src/monorepo/__tests__/publish-workflow.test.ts +322 -0
  104. package/src/monorepo/commit-msg.test.ts +45 -0
  105. package/src/monorepo/commit-msg.ts +107 -7
  106. package/src/monorepo/index.ts +181 -15
  107. package/src/monorepo/lockfile.ts +52 -7
  108. package/src/monorepo/managed-files.ts +43 -13
  109. package/src/monorepo/nx-sync.ts +9 -2
  110. package/src/monorepo/package-hygiene.test.ts +34 -0
  111. package/src/monorepo/package-hygiene.ts +26 -4
  112. package/src/monorepo/package-policy.test.ts +902 -0
  113. package/src/monorepo/package-policy.ts +1527 -25
  114. package/src/monorepo/packed-manifest.ts +67 -0
  115. package/src/monorepo/packed-package.ts +90 -31
  116. package/src/monorepo/packs/index.test.ts +189 -0
  117. package/src/monorepo/packs/index.ts +314 -19
  118. package/src/monorepo/publish-workflow.ts +422 -0
  119. package/src/monorepo/setup-test-tracing.test.ts +120 -0
  120. package/src/monorepo/tool-validation.test.ts +261 -0
  121. package/src/monorepo/tool-validation.ts +462 -0
  122. package/src/nx/index.test.ts +64 -0
  123. package/src/nx/index.ts +119 -0
  124. package/src/nx-version-actions.cts +36 -0
  125. package/src/release/__tests__/bootstrap-npm-packages.test.ts +127 -0
  126. package/src/release/__tests__/candidates.test.ts +217 -0
  127. package/src/release/__tests__/core-properties.test.ts +149 -0
  128. package/src/release/__tests__/core-scenarios.test.ts +187 -0
  129. package/src/release/__tests__/core.test.ts +73 -0
  130. package/src/release/__tests__/fixture-repo.test.ts +314 -0
  131. package/src/release/__tests__/github-release.test.ts +149 -0
  132. package/src/release/__tests__/helpers/fixture-repo.ts +114 -0
  133. package/src/release/__tests__/npm-auth.test.ts +120 -0
  134. package/src/release/__tests__/orchestration.test.ts +246 -0
  135. package/src/release/__tests__/publish-plan.test.ts +67 -0
  136. package/src/release/__tests__/retag-unpublished.test.ts +160 -0
  137. package/src/release/__tests__/trust-publisher.test.ts +133 -0
  138. package/src/release/bootstrap-npm-packages.ts +88 -0
  139. package/src/release/candidates.ts +225 -0
  140. package/src/release/core.ts +171 -0
  141. package/src/release/github-release.ts +134 -0
  142. package/src/release/index.ts +974 -138
  143. package/src/release/npm-auth.ts +93 -0
  144. package/src/release/orchestration.ts +196 -0
  145. package/src/release/publish-plan.ts +37 -0
  146. package/src/release/retag-unpublished.ts +122 -0
  147. package/managed/raw/.git-format-staged.yml +0 -47
  148. package/managed/templates/github/workflows/publish.yml +0 -136
@@ -1,3 +1,5 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
1
3
  const validCommitTypes = new Set([
2
4
  'build',
3
5
  'chore',
@@ -10,26 +12,124 @@ const validCommitTypes = new Set([
10
12
  'revert',
11
13
  'style',
12
14
  'test',
15
+ 'types',
13
16
  ]);
14
17
 
15
- export function validateCommitMessage(message: string): string | null {
18
+ export interface FormatCommitMessageOptions {
19
+ wrapBody?: (paragraph: string) => string;
20
+ }
21
+
22
+ export interface ValidateCommitMessageOptions {
23
+ validScopes?: ReadonlySet<string>;
24
+ }
25
+
26
+ export function validateCommitMessage(message: string, options: ValidateCommitMessageOptions = {}): string | null {
16
27
  const subject = message.split('\n', 1)[0]?.trim() ?? '';
17
28
  if (!subject) {
18
29
  return 'Commit message subject is empty.';
19
30
  }
20
- if (/^(Merge|Revert ")/.test(subject) || /^(fixup|squash)! /.test(subject)) {
31
+ if (isGitGeneratedSubject(subject)) {
21
32
  return null;
22
33
  }
23
- const match = /^(?<type>[a-z]+)(\([a-z0-9._/-]+\))?(?<breaking>!)?: .+$/.exec(subject);
34
+ const match = /^(?<type>[a-z]+)(\((?<scope>[a-z0-9._/@-]+(?:,[a-z0-9._/@-]+)*)\))?(?<breaking>!)?: .+$/.exec(subject);
24
35
  const type = match?.groups?.type;
25
- if (type && validCommitTypes.has(type)) {
26
- return null;
27
- }
28
- return `Invalid conventional commit subject: ${subject}
36
+ if (!type || !validCommitTypes.has(type)) {
37
+ return `Invalid conventional commit subject: ${subject}
29
38
 
30
39
  Expected examples:
31
40
  feat(statebus-core): add optimistic transactions
32
41
  fix(money): round negative amounts consistently
33
42
  chore(release): publish 1.2.3
34
43
  feat!: remove deprecated API`;
44
+ }
45
+ const scope = match.groups?.scope;
46
+ if (scope && options.validScopes) {
47
+ const invalidScopes = scope.split(',').filter((entry) => !options.validScopes?.has(entry));
48
+ if (invalidScopes.length > 0) {
49
+ return `Invalid conventional commit scope: ${invalidScopes.join(',')}
50
+
51
+ Use package.json nx.name values, for example:
52
+ feat(statebus-core): add optimistic transactions
53
+ fix(money): round negative amounts consistently`;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ export function formatCommitMessage(message: string, options: FormatCommitMessageOptions = {}): string {
60
+ const wrapBody = options.wrapBody ?? wrapBodyWithFmt;
61
+ const normalized = message.replace(/\r\n?/g, '\n');
62
+ const lines = normalized.split('\n');
63
+ const subject = lines.shift()?.trimEnd() ?? '';
64
+ const formatted = [subject];
65
+ let paragraph: string[] = [];
66
+ let inFence = false;
67
+
68
+ const flushParagraph = () => {
69
+ if (paragraph.length === 0) {
70
+ return;
71
+ }
72
+ formatted.push(wrapBody(paragraph.join('\n')).trimEnd());
73
+ paragraph = [];
74
+ };
75
+
76
+ for (const line of lines) {
77
+ const trimmedEnd = line.trimEnd();
78
+ if (trimmedEnd.startsWith('```') || trimmedEnd.startsWith('~~~')) {
79
+ flushParagraph();
80
+ inFence = !inFence;
81
+ formatted.push(trimmedEnd);
82
+ continue;
83
+ }
84
+ if (inFence || shouldPreserveLine(trimmedEnd)) {
85
+ flushParagraph();
86
+ formatted.push(trimmedEnd);
87
+ continue;
88
+ }
89
+ if (trimmedEnd === '') {
90
+ flushParagraph();
91
+ formatted.push('');
92
+ continue;
93
+ }
94
+ paragraph.push(trimmedEnd);
95
+ }
96
+ flushParagraph();
97
+
98
+ while (formatted.length > 1 && formatted.at(-1) === '') {
99
+ formatted.pop();
100
+ }
101
+ return `${formatted.join('\n')}\n`;
102
+ }
103
+
104
+ function wrapBodyWithFmt(paragraph: string): string {
105
+ const result = spawnSync('fmt', ['-w', '72'], { input: `${paragraph}\n`, encoding: 'utf8' });
106
+ if (result.error) {
107
+ throw new Error(
108
+ `fmt is required to format commit messages. Install it through devenv and retry.\n${result.error.message}`,
109
+ );
110
+ }
111
+ if (result.status !== 0) {
112
+ throw new Error(
113
+ [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join('\n') ||
114
+ 'fmt failed to wrap commit message body.',
115
+ );
116
+ }
117
+ return result.stdout;
118
+ }
119
+
120
+ function shouldPreserveLine(line: string): boolean {
121
+ return (
122
+ line.startsWith('#') ||
123
+ line.startsWith('>') ||
124
+ /^\s/.test(line) ||
125
+ /^[-*+]\s+/.test(line) ||
126
+ /^\d+\.\s+/.test(line) ||
127
+ /^https?:\/\//.test(line) ||
128
+ /^[A-Za-z][A-Za-z0-9-]*: /.test(line) ||
129
+ /^BREAKING CHANGE: /.test(line)
130
+ );
131
+ }
132
+
133
+ function isGitGeneratedSubject(subject: string): boolean {
134
+ return /^(Merge|Revert ")/.test(subject) || /^(fixup|squash)! /.test(subject);
35
135
  }
@@ -1,10 +1,12 @@
1
- import { readFileSync } from 'node:fs';
2
- import { listPublicPackages } from '../lib/workspace.js';
3
- import { validateCommitMessage } from './commit-msg.js';
1
+ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { $ } from 'bun';
3
+ import { decode, run } from '../lib/run.js';
4
+ import { escapeRegex, getWorkspacePackages, getWorkspacePatterns, listReleasePackages } from '../lib/workspace.js';
5
+ import { formatCommitMessage, validateCommitMessage } from './commit-msg.js';
4
6
  import { applyWorkspaceGitConfig } from './git-config.js';
5
7
  import { syncBunLockfileVersions } from './lockfile.js';
6
8
  import { applyManagedFiles, printResults } from './managed-files.js';
7
- import { validatePublicTags } from './package-policy.js';
9
+ import { listValidCommitScopes, validatePublicTags } from './package-policy.js';
8
10
  import { runInitPacks, runValidatePacks } from './packs/index.js';
9
11
  import { syncRootRuntimeVersions } from './runtime.js';
10
12
 
@@ -15,6 +17,31 @@ export interface InitOptions {
15
17
 
16
18
  export interface ValidateOptions {
17
19
  failFast?: boolean;
20
+ onlyIfNewWorkspacePackage?: boolean;
21
+ fix?: boolean;
22
+ verbose?: boolean;
23
+ }
24
+
25
+ export interface ValidateCommitMessageOptions {
26
+ fix?: boolean;
27
+ }
28
+
29
+ export interface ListReleasePackagesOptions {
30
+ failEmpty?: boolean;
31
+ githubOutput?: string;
32
+ }
33
+
34
+ export interface SetupTestTracingOptions {
35
+ all?: boolean;
36
+ projects?: string;
37
+ opContextExport?: string;
38
+ tracerModule?: string;
39
+ dryRun?: boolean;
40
+ }
41
+
42
+ export interface SetupTestTracingShell {
43
+ run(command: string, args: string[], cwd: string): Promise<void>;
44
+ log(message: string): void;
18
45
  }
19
46
 
20
47
  export async function initMonorepo(root: string, options: InitOptions): Promise<void> {
@@ -28,12 +55,23 @@ export async function initMonorepo(root: string, options: InitOptions): Promise<
28
55
  }
29
56
 
30
57
  export async function validateMonorepo(root: string, options: ValidateOptions = {}): Promise<void> {
31
- const failures = await runValidatePacks({ root, syncRuntime: false }, options);
32
- if (failures > 0) {
33
- throw new Error(`Monorepo validation failed with ${failures} problem(s).`);
58
+ if (options.onlyIfNewWorkspacePackage && !(await hasNewWorkspacePackage(root))) {
59
+ return;
60
+ }
61
+ const result = await runValidatePacks({ root, syncRuntime: false, verbose: options.verbose === true }, options);
62
+ if (result.failures > 0) {
63
+ const checkNoun = result.failedChecks === 1 ? 'check' : 'checks';
64
+ const problemNoun = result.failures === 1 ? 'problem' : 'problems';
65
+ throw new Error(
66
+ `\n🔴 Monorepo validation failed: ${result.failedChecks} ${checkNoun} failed with ${result.failures} ${problemNoun}.`,
67
+ );
68
+ }
69
+ if (options.verbose) {
70
+ console.log('\n== summary ==');
71
+ console.log('Monorepo configuration is valid.');
72
+ } else {
73
+ console.log('🟢 Monorepo configuration is valid.');
34
74
  }
35
- console.log('\n== summary ==');
36
- console.log('Monorepo configuration is valid.');
37
75
  }
38
76
 
39
77
  export function updateManagedFiles(root: string): void {
@@ -52,21 +90,39 @@ export function diffManagedFiles(root: string): void {
52
90
  printResults(applyManagedFiles(root, 'diff'));
53
91
  }
54
92
 
55
- export function validateCommitMessageFile(path: string | undefined): void {
93
+ export function validateCommitMessageFile(
94
+ path: string | undefined,
95
+ options: ValidateCommitMessageOptions = {},
96
+ root = process.cwd(),
97
+ ): void {
56
98
  if (!path) {
57
99
  throw new Error('Usage: smoo monorepo validate-commit-msg <commit-msg-file>');
58
100
  }
59
- const message = readFileSync(path, 'utf8');
60
- const error = validateCommitMessage(message);
101
+ let message = readFileSync(path, 'utf8');
102
+ if (options.fix) {
103
+ const formatted = formatCommitMessage(message);
104
+ if (formatted !== message) {
105
+ writeFileSync(path, formatted);
106
+ message = formatted;
107
+ }
108
+ }
109
+ const error = validateCommitMessage(message, { validScopes: listValidCommitScopes(root) });
61
110
  if (error) {
62
111
  throw new Error(error);
63
112
  }
64
113
  }
65
114
 
66
- export function listPublicProjects(root: string): string {
67
- return listPublicPackages(root)
68
- .map((pkg) => pkg.name)
115
+ export function listReleaseProjectNamesForNx(root: string, options: ListReleasePackagesOptions = {}): string {
116
+ const packages = listReleasePackages(root)
117
+ .map((pkg) => pkg.projectName)
69
118
  .join(',');
119
+ if (!packages && options.failEmpty) {
120
+ throw new Error('No owned release packages found.');
121
+ }
122
+ if (options.githubOutput) {
123
+ appendFileSync(options.githubOutput, `projects=${packages}\n`);
124
+ }
125
+ return packages;
70
126
  }
71
127
 
72
128
  export function validatePublicPackageTags(root: string): void {
@@ -75,4 +131,114 @@ export function validatePublicPackageTags(root: string): void {
75
131
  }
76
132
  }
77
133
 
134
+ export async function setupTestTracing(
135
+ root: string,
136
+ options: SetupTestTracingOptions,
137
+ shell: SetupTestTracingShell = defaultSetupTestTracingShell,
138
+ ): Promise<void> {
139
+ const selectedPackages = selectTestTracingPackages(root, options);
140
+ const opContextExport = options.opContextExport ?? 'opContext';
141
+ const tracerModule = options.tracerModule ?? '@smoothbricks/lmao/testing/bun';
142
+
143
+ if (selectedPackages.length === 0) {
144
+ throw new Error('No workspace packages matched LMAO test tracing setup selection.');
145
+ }
146
+
147
+ for (const pkg of selectedPackages) {
148
+ const args = [
149
+ 'g',
150
+ '@smoothbricks/nx-plugin:bun-test-tracing',
151
+ '--project',
152
+ pkg.projectName,
153
+ '--opContextModule',
154
+ pkg.name,
155
+ '--opContextExport',
156
+ opContextExport,
157
+ '--tracerModule',
158
+ tracerModule,
159
+ ];
160
+ const commandPreview = `nx ${args.join(' ')}`;
161
+ if (options.dryRun) {
162
+ shell.log(`would run ${commandPreview}`);
163
+ continue;
164
+ }
165
+ shell.log(`running ${commandPreview}`);
166
+ await shell.run('nx', args, root);
167
+ }
168
+ }
169
+
78
170
  export { applyWorkspaceGitConfig, syncBunLockfileVersions };
171
+
172
+ const defaultSetupTestTracingShell: SetupTestTracingShell = {
173
+ run,
174
+ log(message) {
175
+ console.log(message);
176
+ },
177
+ };
178
+
179
+ function selectTestTracingPackages(root: string, options: SetupTestTracingOptions) {
180
+ const packages = getWorkspacePackages(root);
181
+ const requested = splitCommaList(options.projects);
182
+
183
+ if (options.all && requested.length > 0) {
184
+ throw new Error('Use either --all or --projects, not both.');
185
+ }
186
+ if (!options.all && requested.length === 0) {
187
+ throw new Error('Pass --all or --projects <projects> to select packages for LMAO test tracing setup.');
188
+ }
189
+ if (options.all) {
190
+ return packages;
191
+ }
192
+
193
+ const bySelector = new Map<string, (typeof packages)[number]>();
194
+ for (const pkg of packages) {
195
+ bySelector.set(pkg.projectName, pkg);
196
+ bySelector.set(pkg.name, pkg);
197
+ bySelector.set(pkg.path, pkg);
198
+ }
199
+
200
+ const selected = [];
201
+ const missing = [];
202
+ for (const selector of requested) {
203
+ const pkg = bySelector.get(selector);
204
+ if (pkg) {
205
+ selected.push(pkg);
206
+ } else {
207
+ missing.push(selector);
208
+ }
209
+ }
210
+
211
+ if (missing.length > 0) {
212
+ throw new Error(`Unknown workspace package selection for LMAO test tracing setup: ${missing.join(', ')}`);
213
+ }
214
+
215
+ return selected;
216
+ }
217
+
218
+ function splitCommaList(value: string | undefined): string[] {
219
+ return (value ?? '')
220
+ .split(',')
221
+ .map((entry) => entry.trim())
222
+ .filter(Boolean);
223
+ }
224
+
225
+ async function hasNewWorkspacePackage(root: string): Promise<boolean> {
226
+ const result = await $`git diff --cached --name-only --diff-filter=A -- ${'*/package.json'}`
227
+ .cwd(root)
228
+ .quiet()
229
+ .nothrow();
230
+ if (result.exitCode !== 0) {
231
+ throw new Error('Unable to inspect staged package manifests.');
232
+ }
233
+ const manifests = decode(result.stdout)
234
+ .split('\n')
235
+ .map((path) => path.trim())
236
+ .filter(Boolean);
237
+ if (manifests.length === 0) {
238
+ return false;
239
+ }
240
+ const patterns = getWorkspacePatterns(root)
241
+ .filter((pattern) => pattern.endsWith('/*'))
242
+ .map((pattern) => new RegExp(`^${escapeRegex(pattern.slice(0, -2))}/[^/]+/package\\.json$`));
243
+ return manifests.some((manifest) => patterns.some((pattern) => pattern.test(manifest)));
244
+ }
@@ -2,7 +2,12 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { escapeRegex, getWorkspacePackages } from '../lib/workspace.js';
4
4
 
5
- export function syncBunLockfileVersions(root: string): void {
5
+ export interface SyncBunLockfileVersionsOptions {
6
+ log?: boolean;
7
+ }
8
+
9
+ export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVersionsOptions = {}): number {
10
+ const log = options.log ?? true;
6
11
  const lockfilePath = join(root, 'bun.lock');
7
12
  if (!existsSync(lockfilePath)) {
8
13
  throw new Error('bun.lock not found');
@@ -16,22 +21,62 @@ export function syncBunLockfileVersions(root: string): void {
16
21
  const pattern = new RegExp(`("${escaped}":\\s*\\{[^}]*"version":\\s*")([^"]+)(")`);
17
22
  const match = lockfile.match(pattern);
18
23
  if (!match) {
19
- console.log(`skip: ${relativePath} (not found in lockfile)`);
24
+ if (log) {
25
+ console.log(`skip: ${relativePath} (not found in lockfile)`);
26
+ }
20
27
  continue;
21
28
  }
22
29
  const lockVersion = match[2];
23
30
  if (lockVersion === pkg.version) {
24
- console.log(`ok: ${relativePath} = ${pkg.version}`);
31
+ if (log) {
32
+ console.log(`ok: ${relativePath} = ${pkg.version}`);
33
+ }
25
34
  continue;
26
35
  }
27
36
  lockfile = lockfile.replace(pattern, `$1${pkg.version}$3`);
28
- console.log(`fix: ${relativePath}: ${lockVersion} -> ${pkg.version}`);
37
+ if (log) {
38
+ console.log(`fix: ${relativePath}: ${lockVersion} -> ${pkg.version}`);
39
+ }
29
40
  updated++;
30
41
  }
31
42
  if (updated > 0) {
32
43
  writeFileSync(lockfilePath, lockfile);
33
44
  }
34
- console.log(
35
- updated > 0 ? `Updated ${updated} workspace version(s) in bun.lock` : 'All workspace versions already in sync.',
36
- );
45
+ if (log) {
46
+ console.log(
47
+ updated > 0 ? `Updated ${updated} workspace version(s) in bun.lock` : 'All workspace versions already in sync.',
48
+ );
49
+ }
50
+ return updated;
51
+ }
52
+
53
+ export function validateBunLockfileVersions(root: string): number {
54
+ const lockfilePath = join(root, 'bun.lock');
55
+ if (!existsSync(lockfilePath)) {
56
+ console.error('bun.lock not found');
57
+ return 1;
58
+ }
59
+ const packages = getWorkspacePackages(root);
60
+ const lockfile = readFileSync(lockfilePath, 'utf8');
61
+ let failures = 0;
62
+ for (const pkg of packages) {
63
+ const relativePath = pkg.path.replaceAll('\\', '/');
64
+ const escaped = escapeRegex(relativePath);
65
+ const pattern = new RegExp(`("${escaped}":\\s*\\{[^}]*"version":\\s*")([^"]+)(")`);
66
+ const match = lockfile.match(pattern);
67
+ if (!match) {
68
+ console.error(`bun.lock missing workspace entry for ${relativePath}`);
69
+ failures++;
70
+ continue;
71
+ }
72
+ const lockVersion = match[2];
73
+ if (lockVersion !== pkg.version) {
74
+ console.error(`${relativePath}: bun.lock workspace version must be ${pkg.version}, got ${lockVersion}`);
75
+ failures++;
76
+ }
77
+ }
78
+ if (failures === 0) {
79
+ console.log('bun.lock workspace versions are valid.');
80
+ }
81
+ return failures;
37
82
  }
@@ -1,20 +1,28 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { readPackageJson } from '../lib/workspace.js';
4
+ import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
5
+ import { renderPublishWorkflowYaml } from './publish-workflow.js';
5
6
 
6
- type ManagedKind = 'raw' | 'template';
7
+ type ManagedKind = 'raw' | 'template' | 'generated';
7
8
 
8
9
  interface ManagedFile {
9
10
  kind: ManagedKind;
10
11
  source: string;
11
12
  target: string;
12
13
  executable?: boolean;
14
+ releasePackagesOnly?: boolean;
13
15
  }
14
16
 
15
17
  export interface FileResult {
16
18
  target: string;
17
- action: 'created' | 'updated' | 'unchanged' | 'skipped-symlink' | 'drifted' | 'ok-symlink';
19
+ action: 'created' | 'updated' | 'unchanged' | 'skipped' | 'skipped-symlink' | 'drifted' | 'ok-symlink';
20
+ }
21
+
22
+ interface ManagedFileContext {
23
+ hasReleasePackages: boolean;
24
+ nodeModulesCacheKey: string;
25
+ repoName: string;
18
26
  }
19
27
 
20
28
  const managedFiles: ManagedFile[] = [
@@ -38,7 +46,7 @@ const managedFiles: ManagedFile[] = [
38
46
  },
39
47
  {
40
48
  kind: 'raw',
41
- source: '.git-format-staged.yml',
49
+ source: 'git-format-staged.yml',
42
50
  target: '.git-format-staged.yml',
43
51
  },
44
52
  {
@@ -47,9 +55,10 @@ const managedFiles: ManagedFile[] = [
47
55
  target: '.github/workflows/ci.yml',
48
56
  },
49
57
  {
50
- kind: 'template',
51
- source: 'github/workflows/publish.yml',
58
+ kind: 'generated',
59
+ source: 'publish-workflow',
52
60
  target: '.github/workflows/publish.yml',
61
+ releasePackagesOnly: true,
53
62
  },
54
63
  {
55
64
  kind: 'template',
@@ -81,12 +90,21 @@ const managedFiles: ManagedFile[] = [
81
90
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
82
91
 
83
92
  export function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): FileResult[] {
84
- return managedFiles.map((file) => applyManagedFile(root, file, mode));
93
+ const context = getManagedFileContext(root);
94
+ return managedFiles.map((file) => applyManagedFile(root, file, mode, context));
85
95
  }
86
96
 
87
- function applyManagedFile(root: string, file: ManagedFile, mode: 'update' | 'check' | 'diff'): FileResult {
97
+ function applyManagedFile(
98
+ root: string,
99
+ file: ManagedFile,
100
+ mode: 'update' | 'check' | 'diff',
101
+ context: ManagedFileContext,
102
+ ): FileResult {
103
+ if (file.releasePackagesOnly === true && !context.hasReleasePackages) {
104
+ return { target: file.target, action: 'skipped' };
105
+ }
88
106
  const target = resolve(root, file.target);
89
- const content = getManagedContent(root, file);
107
+ const content = getManagedContent(file, context);
90
108
  if (existsSync(target)) {
91
109
  const info = lstatSync(target);
92
110
  if (info.isSymbolicLink()) {
@@ -112,23 +130,35 @@ function applyManagedFile(root: string, file: ManagedFile, mode: 'update' | 'che
112
130
  return { target: file.target, action: 'created' };
113
131
  }
114
132
 
115
- function getManagedContent(root: string, file: ManagedFile): string {
133
+ function getManagedContent(file: ManagedFile, context: ManagedFileContext): string {
134
+ if (file.kind === 'generated') {
135
+ if (file.source === 'publish-workflow') {
136
+ return renderPublishWorkflowYaml({ repoName: context.repoName });
137
+ }
138
+ throw new Error(`Unknown generated managed file source ${file.source}`);
139
+ }
116
140
  const sourceRoot = file.kind === 'raw' ? 'managed/raw' : 'managed/templates';
117
141
  const sourcePath = join(packageRoot, sourceRoot, file.source);
118
142
  const content = readFileSync(sourcePath, 'utf8');
119
143
  if (file.kind === 'raw') {
120
144
  return content;
121
145
  }
122
- return renderTemplate(root, content);
146
+ return renderTemplate(context, content);
123
147
  }
124
148
 
125
- function renderTemplate(root: string, template: string): string {
149
+ function getManagedFileContext(root: string): ManagedFileContext {
126
150
  const packageJson = readPackageJson(join(root, 'package.json'));
127
151
  const repoName = packageJson?.name ?? 'monorepo';
128
152
  const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
129
153
  ? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
130
154
  : `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
131
- return template.replaceAll('{{REPO_NAME}}', repoName).replaceAll('{{NODE_MODULES_CACHE_KEY}}', nodeModulesCacheKey);
155
+ return { hasReleasePackages: listReleasePackages(root, packageJson).length > 0, nodeModulesCacheKey, repoName };
156
+ }
157
+
158
+ function renderTemplate(context: ManagedFileContext, template: string): string {
159
+ return template
160
+ .replaceAll('{{REPO_NAME}}', context.repoName)
161
+ .replaceAll('{{NODE_MODULES_CACHE_KEY}}', context.nodeModulesCacheKey);
132
162
  }
133
163
 
134
164
  function writeManagedFile(path: string, content: string, executable: boolean): void {
@@ -1,6 +1,13 @@
1
1
  import { runStatus } from '../lib/run.js';
2
2
 
3
- export async function validateNxSync(root: string): Promise<number> {
4
- const status = await runStatus('nx', ['sync:check'], root);
3
+ export async function fixNxSync(root: string, verbose = false): Promise<void> {
4
+ const status = await runStatus('nx', ['sync'], root, !verbose);
5
+ if (status !== 0) {
6
+ throw new Error(`nx sync failed with exit code ${status}`);
7
+ }
8
+ }
9
+
10
+ export async function validateNxSync(root: string, verbose = false): Promise<number> {
11
+ const status = await runStatus('nx', ['sync:check'], root, !verbose);
5
12
  return status === 0 ? 0 : 1;
6
13
  }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { fixPackageHygiene, type PackageHygieneShell, validatePackageHygiene } from './package-hygiene.js';
3
+
4
+ describe('package hygiene', () => {
5
+ it('runs sherif in autofix mode and selects highest dependency versions', async () => {
6
+ const shell = new RecordingShell();
7
+
8
+ await fixPackageHygiene('/repo', shell);
9
+
10
+ expect(shell.statuses).toEqual([{ command: 'sherif', args: ['-f', '--select', 'highest'], cwd: '/repo' }]);
11
+ });
12
+
13
+ it('validates sherif warnings as failures', async () => {
14
+ const shell = new RecordingShell();
15
+
16
+ await validatePackageHygiene('/repo', shell);
17
+
18
+ expect(shell.statuses).toEqual([{ command: 'sherif', args: ['--fail-on-warnings'], cwd: '/repo' }]);
19
+ });
20
+ });
21
+
22
+ class RecordingShell implements PackageHygieneShell {
23
+ readonly runs: { command: string; args: string[]; cwd: string }[] = [];
24
+ readonly statuses: { command: string; args: string[]; cwd: string }[] = [];
25
+
26
+ async run(command: string, args: string[], cwd: string): Promise<void> {
27
+ this.runs.push({ command, args, cwd });
28
+ }
29
+
30
+ async runStatus(command: string, args: string[], cwd: string): Promise<number> {
31
+ this.statuses.push({ command, args, cwd });
32
+ return 0;
33
+ }
34
+ }
@@ -1,11 +1,33 @@
1
1
  import { run, runStatus } from '../lib/run.js';
2
2
 
3
- export async function fixPackageHygiene(root: string): Promise<void> {
4
- await run('sherif', ['--fix', '--select', 'highest'], root);
3
+ export interface PackageHygieneShell {
4
+ run(command: string, args: string[], cwd: string): Promise<void>;
5
+ runStatus(command: string, args: string[], cwd: string, quiet?: boolean): Promise<number>;
5
6
  }
6
7
 
7
- export async function validatePackageHygiene(root: string): Promise<number> {
8
- const status = await runStatus('sherif', [], root);
8
+ const defaultShell: PackageHygieneShell = { run, runStatus };
9
+
10
+ export async function fixPackageHygiene(
11
+ root: string,
12
+ verboseOrShell: boolean | PackageHygieneShell = false,
13
+ maybeShell: PackageHygieneShell = defaultShell,
14
+ ): Promise<void> {
15
+ const verbose = typeof verboseOrShell === 'boolean' ? verboseOrShell : false;
16
+ const shell = typeof verboseOrShell === 'boolean' ? maybeShell : verboseOrShell;
17
+ const status = await shell.runStatus('sherif', ['-f', '--select', 'highest'], root, !verbose);
18
+ if (status !== 0) {
19
+ throw new Error(`sherif -f --select highest failed with exit code ${status}`);
20
+ }
21
+ }
22
+
23
+ export async function validatePackageHygiene(
24
+ root: string,
25
+ verboseOrShell: boolean | PackageHygieneShell = false,
26
+ maybeShell: PackageHygieneShell = defaultShell,
27
+ ): Promise<number> {
28
+ const verbose = typeof verboseOrShell === 'boolean' ? verboseOrShell : false;
29
+ const shell = typeof verboseOrShell === 'boolean' ? maybeShell : verboseOrShell;
30
+ const status = await shell.runStatus('sherif', ['--fail-on-warnings'], root, !verbose);
9
31
  if (status !== 0) {
10
32
  console.error('sherif package hygiene validation failed');
11
33
  return 1;