@smoothbricks/cli 0.1.1 → 0.3.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 (131) hide show
  1. package/README.md +161 -32
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +110 -10
  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/generate/index.d.ts +20 -0
  8. package/dist/generate/index.d.ts.map +1 -0
  9. package/dist/generate/index.js +47 -0
  10. package/dist/lib/cli-package.d.ts +4 -0
  11. package/dist/lib/cli-package.d.ts.map +1 -0
  12. package/dist/lib/cli-package.js +15 -0
  13. package/dist/lib/run.d.ts +6 -0
  14. package/dist/lib/run.d.ts.map +1 -1
  15. package/dist/lib/run.js +32 -0
  16. package/dist/lib/workspace.d.ts +25 -1
  17. package/dist/lib/workspace.d.ts.map +1 -1
  18. package/dist/lib/workspace.js +79 -17
  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 +19 -2
  23. package/dist/monorepo/index.d.ts.map +1 -1
  24. package/dist/monorepo/index.js +104 -16
  25. package/dist/monorepo/lockfile.d.ts.map +1 -1
  26. package/dist/monorepo/lockfile.js +9 -0
  27. package/dist/monorepo/managed-files.js +1 -1
  28. package/dist/monorepo/nx-sync.d.ts +2 -1
  29. package/dist/monorepo/nx-sync.d.ts.map +1 -1
  30. package/dist/monorepo/nx-sync.js +8 -2
  31. package/dist/monorepo/package-hygiene.d.ts +6 -2
  32. package/dist/monorepo/package-hygiene.d.ts.map +1 -1
  33. package/dist/monorepo/package-hygiene.js +12 -4
  34. package/dist/monorepo/package-policy.d.ts +13 -4
  35. package/dist/monorepo/package-policy.d.ts.map +1 -1
  36. package/dist/monorepo/package-policy.js +208 -113
  37. package/dist/monorepo/packed-package.d.ts +3 -0
  38. package/dist/monorepo/packed-package.d.ts.map +1 -1
  39. package/dist/monorepo/packed-package.js +68 -36
  40. package/dist/monorepo/packs/index.d.ts +20 -1
  41. package/dist/monorepo/packs/index.d.ts.map +1 -1
  42. package/dist/monorepo/packs/index.js +238 -19
  43. package/dist/monorepo/publish-workflow.d.ts +0 -4
  44. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  45. package/dist/monorepo/publish-workflow.js +7 -15
  46. package/dist/monorepo/tool-validation.d.ts +22 -0
  47. package/dist/monorepo/tool-validation.d.ts.map +1 -0
  48. package/dist/monorepo/tool-validation.js +399 -0
  49. package/dist/nx/index.d.ts +25 -0
  50. package/dist/nx/index.d.ts.map +1 -0
  51. package/dist/nx/index.js +112 -0
  52. package/dist/release/bootstrap-npm-packages.d.ts +19 -0
  53. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -0
  54. package/dist/release/bootstrap-npm-packages.js +58 -0
  55. package/dist/release/candidates.d.ts +11 -0
  56. package/dist/release/candidates.d.ts.map +1 -0
  57. package/dist/release/candidates.js +181 -0
  58. package/dist/release/core.d.ts +4 -1
  59. package/dist/release/core.d.ts.map +1 -1
  60. package/dist/release/core.js +76 -9
  61. package/dist/release/github-release.d.ts +1 -0
  62. package/dist/release/github-release.d.ts.map +1 -1
  63. package/dist/release/github-release.js +3 -3
  64. package/dist/release/index.d.ts +32 -1
  65. package/dist/release/index.d.ts.map +1 -1
  66. package/dist/release/index.js +488 -64
  67. package/dist/release/npm-auth.d.ts +0 -1
  68. package/dist/release/npm-auth.d.ts.map +1 -1
  69. package/dist/release/npm-auth.js +4 -16
  70. package/dist/release/orchestration.d.ts +11 -2
  71. package/dist/release/orchestration.d.ts.map +1 -1
  72. package/dist/release/orchestration.js +41 -3
  73. package/dist/release/publish-plan.js +1 -1
  74. package/managed/raw/git-format-staged.yml +54 -0
  75. package/managed/raw/tooling/git-hooks/commit-msg.sh +1 -1
  76. package/managed/templates/github/actions/setup-devenv/action.yml +1 -7
  77. package/managed/templates/github/workflows/ci.yml +4 -2
  78. package/package.json +15 -8
  79. package/src/cli.ts +153 -22
  80. package/src/devenv/index.test.ts +49 -0
  81. package/src/devenv/index.ts +38 -0
  82. package/src/generate/index.ts +92 -0
  83. package/src/lib/cli-package.ts +18 -0
  84. package/src/lib/run.ts +44 -0
  85. package/src/lib/workspace.ts +112 -17
  86. package/src/monorepo/__tests__/publish-workflow.test.ts +16 -9
  87. package/src/monorepo/commit-msg.test.ts +45 -0
  88. package/src/monorepo/commit-msg.ts +107 -7
  89. package/src/monorepo/index.ts +142 -16
  90. package/src/monorepo/lockfile.ts +9 -0
  91. package/src/monorepo/managed-files.ts +1 -1
  92. package/src/monorepo/nx-sync.ts +9 -2
  93. package/src/monorepo/package-hygiene.test.ts +34 -0
  94. package/src/monorepo/package-hygiene.ts +26 -4
  95. package/src/monorepo/package-policy.test.ts +1130 -0
  96. package/src/monorepo/package-policy.ts +237 -114
  97. package/src/monorepo/packed-package.ts +89 -37
  98. package/src/monorepo/packs/index.test.ts +189 -0
  99. package/src/monorepo/packs/index.ts +311 -20
  100. package/src/monorepo/publish-workflow.ts +10 -19
  101. package/src/monorepo/setup-test-tracing.test.ts +120 -0
  102. package/src/monorepo/tool-validation.test.ts +261 -0
  103. package/src/monorepo/tool-validation.ts +462 -0
  104. package/src/nx/index.test.ts +73 -0
  105. package/src/nx/index.ts +139 -0
  106. package/src/release/__tests__/bootstrap-npm-packages.test.ts +127 -0
  107. package/src/release/__tests__/candidates.test.ts +262 -0
  108. package/src/release/__tests__/core-properties.test.ts +6 -6
  109. package/src/release/__tests__/core-scenarios.test.ts +27 -20
  110. package/src/release/__tests__/core.test.ts +52 -5
  111. package/src/release/__tests__/fixture-repo.test.ts +90 -31
  112. package/src/release/__tests__/github-release.test.ts +22 -24
  113. package/src/release/__tests__/helpers/fixture-repo.ts +1 -0
  114. package/src/release/__tests__/npm-auth.test.ts +12 -21
  115. package/src/release/__tests__/orchestration.test.ts +142 -12
  116. package/src/release/__tests__/publish-plan.test.ts +9 -3
  117. package/src/release/__tests__/retag-unpublished.test.ts +10 -10
  118. package/src/release/__tests__/trust-publisher.test.ts +165 -0
  119. package/src/release/bootstrap-npm-packages.ts +88 -0
  120. package/src/release/candidates.ts +225 -0
  121. package/src/release/core.ts +96 -10
  122. package/src/release/github-release.ts +3 -3
  123. package/src/release/index.ts +658 -74
  124. package/src/release/npm-auth.ts +4 -33
  125. package/src/release/orchestration.ts +60 -5
  126. package/src/release/publish-plan.ts +1 -1
  127. package/dist/nx-version-actions.cjs +0 -25
  128. package/dist/nx-version-actions.d.cts +0 -6
  129. package/dist/nx-version-actions.d.cts.map +0 -1
  130. package/managed/raw/.git-format-staged.yml +0 -47
  131. package/src/nx-version-actions.cts +0 -36
@@ -0,0 +1,462 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { cliPackageVersion, isSmoothBricksCodebasePackageName } from '../lib/cli-package.js';
4
+ import { getOrCreateRecord, readJsonObject, recordProperty, setStringProperty, writeJsonObject } from '../lib/json.js';
5
+ import { type PackageJson, readPackageJsonObject } from '../lib/workspace.js';
6
+
7
+ interface RequiredDependency {
8
+ name: string;
9
+ fallbackVersion: string;
10
+ minimumVersion?: string;
11
+ prefix?: string;
12
+ useWorkspaceRangeInCodebase?: boolean;
13
+ }
14
+
15
+ export interface ToolPolicy {
16
+ isSmoothBricksCodebase: boolean;
17
+ toolingPackageName: string;
18
+ cliDependencyRange: string;
19
+ }
20
+
21
+ export interface ToolContext {
22
+ rootPackage: PackageJson | null;
23
+ policy: ToolPolicy;
24
+ }
25
+
26
+ const rootDevDependencies: RequiredDependency[] = [
27
+ { name: '@biomejs/biome', fallbackVersion: '^2.3.5', minimumVersion: '2.3.0', prefix: '^' },
28
+ { name: '@nx/js', fallbackVersion: '22.0.3', minimumVersion: '22.0.0' },
29
+ {
30
+ name: '@smoothbricks/nx-plugin',
31
+ fallbackVersion: '^0.0.1',
32
+ minimumVersion: '0.0.1',
33
+ prefix: '^',
34
+ useWorkspaceRangeInCodebase: true,
35
+ },
36
+ { name: 'eslint', fallbackVersion: '^9.39.1', minimumVersion: '9.39.0', prefix: '^' },
37
+ {
38
+ name: 'eslint-stdout',
39
+ fallbackVersion: '^1.1.1',
40
+ minimumVersion: '1.1.1',
41
+ prefix: '^',
42
+ useWorkspaceRangeInCodebase: true,
43
+ },
44
+ { name: 'nx', fallbackVersion: '22.5.4', minimumVersion: '22.5.0' },
45
+ { name: 'prettier', fallbackVersion: '^3.6.1', minimumVersion: '3.6.0', prefix: '^' },
46
+ { name: 'typescript', fallbackVersion: '^5.9.3', minimumVersion: '5.9.0', prefix: '^' },
47
+ ];
48
+
49
+ const cliPackageName = '@smoothbricks/cli';
50
+
51
+ const requiredDevenvPackages = ['bun', 'git', 'git-format-staged', 'jq', 'alejandra', 'coreutils', 'gnutar'];
52
+ const allowedNodePackages = ['nodejs_24', 'nodejs_latest'];
53
+
54
+ export async function applyToolConfigDefaults(root: string): Promise<void> {
55
+ const context = readToolContext(root);
56
+ await applyRootPackageToolDefaults(root, context);
57
+ applyToolingPackageDefaults(root, context.policy);
58
+ applyDevenvPackageDefaults(root);
59
+ }
60
+
61
+ export function validateToolConfig(root: string): number {
62
+ const context = readToolContext(root);
63
+ return (
64
+ validateRootDevDependencies(context.policy, context.rootPackage) +
65
+ validateToolingPackage(root, context.policy) +
66
+ validateToolingWorkspace(context.rootPackage) +
67
+ validateDevenvPackages(root)
68
+ );
69
+ }
70
+
71
+ export async function applyRootDevDependencyDefaults(root: string, context: ToolContext): Promise<void> {
72
+ const pkg = context.rootPackage;
73
+ if (!pkg) {
74
+ return;
75
+ }
76
+ let changed = false;
77
+ const devDependencies = getOrCreateRecord(pkg, 'devDependencies');
78
+ for (const dependency of rootDevDependencies) {
79
+ const current = devDependencies[dependency.name];
80
+ if (typeof current !== 'string' || !satisfiesDependencyPolicy(context.policy, current, dependency)) {
81
+ const version = await resolveDependencyVersion(context.policy, dependency);
82
+ changed = setStringProperty(devDependencies, dependency.name, version) || changed;
83
+ }
84
+ }
85
+ if (delete devDependencies['@smoothbricks/cli']) {
86
+ changed = true;
87
+ }
88
+ if (changed) {
89
+ writeJsonObject(join(root, 'package.json'), pkg);
90
+ console.log('updated package.json workspace tool dependencies');
91
+ } else {
92
+ console.log('unchanged package.json workspace tool dependencies');
93
+ }
94
+ }
95
+
96
+ async function applyRootPackageToolDefaults(root: string, context: ToolContext): Promise<void> {
97
+ const pkg = context.rootPackage;
98
+ if (!pkg) {
99
+ return;
100
+ }
101
+ let dependencyChanged = false;
102
+ let workspaceChanged = false;
103
+ const devDependencies = getOrCreateRecord(pkg, 'devDependencies');
104
+ for (const dependency of rootDevDependencies) {
105
+ const current = devDependencies[dependency.name];
106
+ if (typeof current !== 'string' || !satisfiesDependencyPolicy(context.policy, current, dependency)) {
107
+ const version = await resolveDependencyVersion(context.policy, dependency);
108
+ dependencyChanged = setStringProperty(devDependencies, dependency.name, version) || dependencyChanged;
109
+ }
110
+ }
111
+ if (delete devDependencies[cliPackageName]) {
112
+ dependencyChanged = true;
113
+ }
114
+ workspaceChanged = addWorkspacePattern(pkg, 'tooling');
115
+ if (dependencyChanged || workspaceChanged) {
116
+ writeJsonObject(join(root, 'package.json'), pkg);
117
+ }
118
+ console.log(
119
+ dependencyChanged
120
+ ? 'updated package.json workspace tool dependencies'
121
+ : 'unchanged package.json workspace tool dependencies',
122
+ );
123
+ console.log(
124
+ workspaceChanged
125
+ ? 'updated package.json tooling workspace'
126
+ : 'unchanged package.json tooling workspace',
127
+ );
128
+ }
129
+
130
+ export function applyToolingPackageDefaults(root: string, policy: ToolPolicy): void {
131
+ const path = join(root, 'tooling', 'package.json');
132
+ const pkg = readJsonObject(path) ?? { name: policy.toolingPackageName, private: true, dependencies: {} };
133
+ let changed = false;
134
+ changed = setStringProperty(pkg, 'name', policy.toolingPackageName) || changed;
135
+ if (pkg.private !== true) {
136
+ pkg.private = true;
137
+ changed = true;
138
+ }
139
+ const dependencies = getOrCreateRecord(pkg, 'dependencies');
140
+ changed = setStringProperty(dependencies, cliPackageName, policy.cliDependencyRange) || changed;
141
+ if (changed || !existsSync(path)) {
142
+ mkdirSync(dirname(path), { recursive: true });
143
+ writeJsonObject(path, pkg);
144
+ console.log('updated tooling/package.json tooling dependencies');
145
+ } else {
146
+ console.log('unchanged tooling/package.json tooling dependencies');
147
+ }
148
+ }
149
+
150
+ export function applyToolingWorkspaceDefault(root: string): void {
151
+ const path = join(root, 'package.json');
152
+ const pkg = readJsonObject(path);
153
+ if (!pkg) {
154
+ return;
155
+ }
156
+ if (addWorkspacePattern(pkg, 'tooling')) {
157
+ writeJsonObject(path, pkg);
158
+ console.log('updated package.json tooling workspace');
159
+ } else {
160
+ console.log('unchanged package.json tooling workspace');
161
+ }
162
+ }
163
+
164
+ export function applyDevenvPackageDefaults(root: string): void {
165
+ const path = join(root, 'tooling', 'direnv', 'devenv.nix');
166
+ if (!existsSync(path)) {
167
+ return;
168
+ }
169
+ let content = readFileSync(path, 'utf8');
170
+ let changed = false;
171
+ if (!allowedNodePackages.some((name) => hasNixPackage(content, name))) {
172
+ const next = addNixPackage(content, 'nodejs_latest', '# Node.js for workspace tooling');
173
+ changed = next !== content || changed;
174
+ content = next;
175
+ }
176
+ for (const name of requiredDevenvPackages) {
177
+ if (hasNixPackage(content, name)) {
178
+ continue;
179
+ }
180
+ const next = addNixPackage(content, name, nixPackageComment(name));
181
+ changed = next !== content || changed;
182
+ content = next;
183
+ }
184
+ if (changed) {
185
+ writeFileSync(path, content);
186
+ console.log('updated tooling/direnv/devenv.nix packages');
187
+ } else {
188
+ console.log('unchanged tooling/direnv/devenv.nix packages');
189
+ }
190
+ }
191
+
192
+ export function validateRootDevDependencies(policy: ToolPolicy, rootPackage: PackageJson | null): number {
193
+ const pkg = rootPackage;
194
+ if (!pkg) {
195
+ console.error('package.json not found or invalid');
196
+ return 1;
197
+ }
198
+ const devDependencies = recordProperty(pkg, 'devDependencies');
199
+ let failures = 0;
200
+ for (const dependency of rootDevDependencies) {
201
+ const version = devDependencies?.[dependency.name];
202
+ if (typeof version !== 'string') {
203
+ console.error(`package.json devDependencies.${dependency.name} must be defined`);
204
+ failures++;
205
+ } else if (!satisfiesDependencyPolicy(policy, version, dependency)) {
206
+ console.error(
207
+ `package.json devDependencies.${dependency.name} must be ${formatExpectedDependency(policy, dependency)}; found ${version}`,
208
+ );
209
+ failures++;
210
+ }
211
+ }
212
+ if (typeof devDependencies?.[cliPackageName] === 'string') {
213
+ console.error(`package.json devDependencies.${cliPackageName} must move to tooling/package.json dependencies`);
214
+ failures++;
215
+ }
216
+ return failures;
217
+ }
218
+
219
+ export function validateToolingPackage(root: string, policy: ToolPolicy): number {
220
+ const path = join(root, 'tooling', 'package.json');
221
+ const pkg = readJsonObject(path);
222
+ if (!pkg) {
223
+ console.error('tooling/package.json not found or invalid');
224
+ return 1;
225
+ }
226
+ const dependencies = recordProperty(pkg, 'dependencies');
227
+ let failures = 0;
228
+ const actualName = typeof pkg.name === 'string' ? pkg.name : null;
229
+ if (actualName !== policy.toolingPackageName) {
230
+ console.error(`tooling/package.json name must be ${policy.toolingPackageName}`);
231
+ failures++;
232
+ }
233
+ const actualCliRange = dependencies?.[cliPackageName];
234
+ if (actualCliRange !== policy.cliDependencyRange) {
235
+ console.error(`tooling/package.json dependencies.${cliPackageName} must be ${policy.cliDependencyRange}`);
236
+ failures++;
237
+ }
238
+ return failures;
239
+ }
240
+
241
+ export function validateToolingWorkspace(rootPackage: PackageJson | null): number {
242
+ const pkg = rootPackage;
243
+ if (!pkg) {
244
+ console.error('package.json not found or invalid');
245
+ return 1;
246
+ }
247
+ if (!hasWorkspacePattern(pkg, 'tooling')) {
248
+ console.error('package.json workspaces must include tooling so tooling/package.json participates in installs');
249
+ return 1;
250
+ }
251
+ return 0;
252
+ }
253
+
254
+ export function validateDevenvPackages(root: string): number {
255
+ const path = join(root, 'tooling', 'direnv', 'devenv.nix');
256
+ if (!existsSync(path)) {
257
+ console.error('tooling/direnv/devenv.nix not found');
258
+ return 1;
259
+ }
260
+ const content = readFileSync(path, 'utf8');
261
+ let failures = 0;
262
+ if (!allowedNodePackages.some((name) => hasNixPackage(content, name))) {
263
+ console.error(`tooling/direnv/devenv.nix packages must include ${allowedNodePackages.join(' or ')}`);
264
+ failures++;
265
+ }
266
+ for (const name of requiredDevenvPackages) {
267
+ if (!hasNixPackage(content, name)) {
268
+ console.error(`tooling/direnv/devenv.nix packages must include ${name}`);
269
+ failures++;
270
+ }
271
+ }
272
+ return failures;
273
+ }
274
+
275
+ function addNixPackage(content: string, name: string, comment: string): string {
276
+ const packageLine = ` ${name}${comment ? ` ${comment}` : ''}\n`;
277
+ if (hasNixPackage(content, name)) {
278
+ return content;
279
+ }
280
+ const packagesStart = content.indexOf(' packages = with pkgs; [');
281
+ if (packagesStart === -1) {
282
+ return content;
283
+ }
284
+ const insertAt = content.indexOf('\n', packagesStart) + 1;
285
+ return `${content.slice(0, insertAt)}${packageLine}${content.slice(insertAt)}`;
286
+ }
287
+
288
+ function hasNixPackage(content: string, name: string): boolean {
289
+ return new RegExp(`(^|\\s)${escapeRegex(name)}(\\s|#|$)`, 'm').test(content);
290
+ }
291
+
292
+ function nixPackageComment(name: string): string {
293
+ if (name === 'coreutils') {
294
+ return '# Provides fmt for commit message wrapping';
295
+ }
296
+ if (name === 'gnutar') {
297
+ return '# Tarball inspection for package validation';
298
+ }
299
+ if (name === 'git') {
300
+ return '# Git hooks and repository inspection';
301
+ }
302
+ return '';
303
+ }
304
+
305
+ function escapeRegex(value: string): string {
306
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
307
+ }
308
+
309
+ function satisfiesDependencyPolicy(policy: ToolPolicy, version: string, dependency: RequiredDependency): boolean {
310
+ if (workspaceDependencyExpected(policy, dependency)) {
311
+ return version === 'workspace:*';
312
+ }
313
+ if (dependency.minimumVersion === undefined) {
314
+ return version === dependency.fallbackVersion;
315
+ }
316
+ const parsed = parseVersion(version);
317
+ const minimum = parseVersion(dependency.minimumVersion);
318
+ if (!parsed || !minimum) {
319
+ return false;
320
+ }
321
+ return compareVersions(parsed, minimum) >= 0;
322
+ }
323
+
324
+ function formatExpectedDependency(policy: ToolPolicy, dependency: RequiredDependency): string {
325
+ if (workspaceDependencyExpected(policy, dependency)) {
326
+ return 'workspace:*';
327
+ }
328
+ return dependency.minimumVersion ? `>= ${dependency.minimumVersion}` : dependency.fallbackVersion;
329
+ }
330
+
331
+ export function readToolContext(root: string): ToolContext {
332
+ const rootPackage = readPackageJsonObject(join(root, 'package.json'));
333
+ return { rootPackage, policy: toolPolicy(rootPackage) };
334
+ }
335
+
336
+ function toolPolicy(rootPackage: PackageJson | null): ToolPolicy {
337
+ const name = typeof rootPackage?.name === 'string' ? rootPackage.name : null;
338
+ const isCodebase = isSmoothBricksCodebasePackageName(name ?? undefined);
339
+ const toolingName = toolingPackageName(name);
340
+ return {
341
+ isSmoothBricksCodebase: isCodebase,
342
+ toolingPackageName: toolingName,
343
+ cliDependencyRange: isCodebase ? 'workspace:*' : `^${cliPackageVersion}`,
344
+ };
345
+ }
346
+
347
+ function toolingPackageName(rootName: string | null): string {
348
+ const name = rootName;
349
+ const scope = name?.match(/^(@[^/]+)\//)?.[1];
350
+ return scope ? `${scope}/tooling` : 'tooling';
351
+ }
352
+
353
+ function workspaceDependencyExpected(policy: ToolPolicy, dependency: RequiredDependency): boolean {
354
+ return dependency.useWorkspaceRangeInCodebase === true && policy.isSmoothBricksCodebase;
355
+ }
356
+
357
+ async function resolveDependencyVersion(policy: ToolPolicy, dependency: RequiredDependency): Promise<string> {
358
+ if (workspaceDependencyExpected(policy, dependency)) {
359
+ return 'workspace:*';
360
+ }
361
+ if (!dependency.minimumVersion) {
362
+ return dependency.fallbackVersion;
363
+ }
364
+ const latest = await fetchLatestPatchVersion(dependency.name, dependency.minimumVersion);
365
+ return `${dependency.prefix ?? ''}${latest ?? stripRangePrefix(dependency.fallbackVersion)}`;
366
+ }
367
+
368
+ async function fetchLatestPatchVersion(packageName: string, minimumVersion: string): Promise<string | null> {
369
+ const minorRange = sameMajorMinorRange(minimumVersion);
370
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName).replace('%40', '@')}`;
371
+ const response = await fetch(url, { headers: { accept: 'application/vnd.npm.install-v1+json' } });
372
+ if (!response.ok) {
373
+ return null;
374
+ }
375
+ const body: unknown = await response.json();
376
+ if (!isRegistryPackument(body)) {
377
+ return null;
378
+ }
379
+ return latestVersionInSameMajorMinor(Object.keys(body.versions), minorRange);
380
+ }
381
+
382
+ function sameMajorMinorRange(minimumVersion: string): Version | null {
383
+ return parseVersion(minimumVersion);
384
+ }
385
+
386
+ interface Version {
387
+ major: number;
388
+ minor: number;
389
+ patch: number;
390
+ }
391
+
392
+ function parseVersion(version: string): Version | null {
393
+ const match = /^[~^]?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
394
+ if (!match?.[1] || !match[2] || !match[3]) {
395
+ return null;
396
+ }
397
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
398
+ }
399
+
400
+ function latestVersionInSameMajorMinor(versions: string[], minimum: Version | null): string | null {
401
+ if (!minimum) {
402
+ return null;
403
+ }
404
+ let latest: Version | null = null;
405
+ for (const raw of versions) {
406
+ const version = parseVersion(raw);
407
+ if (!version || version.major !== minimum.major || version.minor !== minimum.minor) {
408
+ continue;
409
+ }
410
+ if (compareVersions(version, minimum) < 0) {
411
+ continue;
412
+ }
413
+ if (!latest || compareVersions(version, latest) > 0) {
414
+ latest = version;
415
+ }
416
+ }
417
+ return latest ? `${latest.major}.${latest.minor}.${latest.patch}` : null;
418
+ }
419
+
420
+ function compareVersions(left: Version, right: Version): number {
421
+ return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
422
+ }
423
+
424
+ function isRegistryPackument(value: unknown): value is { versions: Record<string, unknown> } {
425
+ return typeof value === 'object' && value !== null && 'versions' in value && isObjectRecord(value.versions);
426
+ }
427
+
428
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
429
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
430
+ }
431
+
432
+ function stripRangePrefix(version: string): string {
433
+ return version.replace(/^[~^]/, '');
434
+ }
435
+
436
+ function addWorkspacePattern(pkg: PackageJson, pattern: string): boolean {
437
+ const workspaces = pkg.workspaces;
438
+ if (Array.isArray(workspaces)) {
439
+ if (workspaces.includes(pattern)) {
440
+ return false;
441
+ }
442
+ workspaces.push(pattern);
443
+ return true;
444
+ }
445
+ if (isObjectRecord(workspaces) && Array.isArray(workspaces.packages)) {
446
+ if (workspaces.packages.includes(pattern)) {
447
+ return false;
448
+ }
449
+ workspaces.packages.push(pattern);
450
+ return true;
451
+ }
452
+ pkg.workspaces = ['packages/*', pattern];
453
+ return true;
454
+ }
455
+
456
+ function hasWorkspacePattern(pkg: PackageJson, pattern: string): boolean {
457
+ const workspaces = pkg.workspaces;
458
+ if (Array.isArray(workspaces)) {
459
+ return workspaces.includes(pattern);
460
+ }
461
+ return isObjectRecord(workspaces) && Array.isArray(workspaces.packages) && workspaces.packages.includes(pattern);
462
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { join } from 'node:path';
3
+ import {
4
+ formatProjectTargetLines,
5
+ nxCacheDirectories,
6
+ nxResetCommand,
7
+ nxShowProjectCommand,
8
+ projectNamesFromNxShowProjectsOutput,
9
+ projectNamesWithTarget,
10
+ targetNamesFromNxProjectJson,
11
+ } from './index.js';
12
+
13
+ describe('Nx helper command construction', () => {
14
+ it('builds explicit nx reset invocation', () => {
15
+ expect(nxResetCommand()).toEqual({ command: 'nx', args: ['reset'] });
16
+ });
17
+
18
+ it('builds explicit nx project metadata invocation', () => {
19
+ expect(nxShowProjectCommand('cli')).toEqual({ command: 'nx', args: ['show', 'project', 'cli', '--json'] });
20
+ });
21
+
22
+ it('selects only local Nx cache directories', () => {
23
+ expect(nxCacheDirectories('/repo')).toEqual([
24
+ join('/repo', '.nx/cache'),
25
+ join('/repo', '.nx/workspace-data'),
26
+ join('/repo', 'node_modules/.cache/nx'),
27
+ ]);
28
+ });
29
+ });
30
+
31
+ describe('Nx helper output formatting', () => {
32
+ it('formats project target pairs like the old root helper script', () => {
33
+ expect(
34
+ formatProjectTargetLines([
35
+ { project: 'web', targets: ['test', 'build'] },
36
+ { project: 'cli', targets: ['lint'] },
37
+ ]),
38
+ ).toBe(['cli:lint', 'web:build', 'web:test'].join('\n'));
39
+ });
40
+
41
+ it('lists projects that define the requested target', () => {
42
+ expect(
43
+ projectNamesWithTarget(
44
+ [
45
+ { project: 'web', targets: ['build', 'test'] },
46
+ { project: 'cli', targets: ['build', 'lint'] },
47
+ { project: 'docs', targets: ['serve'] },
48
+ ],
49
+ 'build',
50
+ ),
51
+ ).toEqual(['cli', 'web']);
52
+ });
53
+
54
+ it('parses JSON project lists from nx show projects', () => {
55
+ expect(projectNamesFromNxShowProjectsOutput('["web","cli"]\n')).toEqual(['cli', 'web']);
56
+ });
57
+
58
+ it('parses legacy newline project lists from nx show projects', () => {
59
+ expect(projectNamesFromNxShowProjectsOutput('web\ncli\n')).toEqual(['cli', 'web']);
60
+ });
61
+
62
+ it('extracts sorted target names from Nx project JSON', () => {
63
+ expect(targetNamesFromNxProjectJson({ targets: { test: {}, build: {}, lint: {} } })).toEqual([
64
+ 'build',
65
+ 'lint',
66
+ 'test',
67
+ ]);
68
+ });
69
+
70
+ it('treats missing target metadata as an empty project', () => {
71
+ expect(targetNamesFromNxProjectJson({ name: 'cli' })).toEqual([]);
72
+ });
73
+ });
@@ -0,0 +1,139 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { lstat, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { $ } from 'bun';
5
+ import { isRecord, recordProperty } from '../lib/json.js';
6
+ import { decode, run } from '../lib/run.js';
7
+
8
+ export interface ProjectTargets {
9
+ project: string;
10
+ targets: string[];
11
+ buildDependsOn?: string[];
12
+ }
13
+
14
+ export interface CommandInvocation {
15
+ command: string;
16
+ args: string[];
17
+ }
18
+
19
+ export function nxResetCommand(): CommandInvocation {
20
+ return { command: 'nx', args: ['reset'] };
21
+ }
22
+
23
+ export function nxShowProjectCommand(project: string): CommandInvocation {
24
+ return { command: 'nx', args: ['show', 'project', project, '--json'] };
25
+ }
26
+
27
+ export function nxCacheDirectories(root: string): string[] {
28
+ return [join(root, '.nx/cache'), join(root, '.nx/workspace-data'), join(root, 'node_modules/.cache/nx')];
29
+ }
30
+
31
+ export function targetNamesFromNxProjectJson(value: unknown): string[] {
32
+ const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
33
+ return targets ? Object.keys(targets).sort((a, b) => a.localeCompare(b)) : [];
34
+ }
35
+
36
+ export function buildDependsOnFromNxProjectJson(value: unknown): string[] | undefined {
37
+ const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
38
+ const build = targets ? recordProperty(targets, 'build') : null;
39
+ if (!Array.isArray(build?.dependsOn) || !build.dependsOn.every((entry) => typeof entry === 'string')) {
40
+ return undefined;
41
+ }
42
+ return build.dependsOn;
43
+ }
44
+
45
+ export function formatProjectTargetLines(projects: ProjectTargets[]): string {
46
+ return projects
47
+ .flatMap((project) => project.targets.map((target) => `${project.project}:${target}`))
48
+ .sort((a, b) => a.localeCompare(b))
49
+ .join('\n');
50
+ }
51
+
52
+ export function projectNamesWithTarget(projects: ProjectTargets[], target: string): string[] {
53
+ return projects
54
+ .filter((project) => project.targets.includes(target))
55
+ .map((project) => project.project)
56
+ .sort((a, b) => a.localeCompare(b));
57
+ }
58
+
59
+ export function projectNamesFromNxShowProjectsOutput(output: string): string[] {
60
+ const trimmed = output.trim();
61
+ if (!trimmed) {
62
+ return [];
63
+ }
64
+
65
+ if (trimmed.startsWith('[')) {
66
+ try {
67
+ const parsed: unknown = JSON.parse(trimmed);
68
+ if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
69
+ return parsed.sort((a, b) => a.localeCompare(b));
70
+ }
71
+ } catch {
72
+ // Fall through to legacy newline parsing for older Nx output shapes.
73
+ }
74
+ }
75
+
76
+ return trimmed
77
+ .split('\n')
78
+ .map((line) => line.trim())
79
+ .filter(Boolean)
80
+ .sort((a, b) => a.localeCompare(b));
81
+ }
82
+
83
+ export async function listTargets(root: string): Promise<void> {
84
+ const output = formatProjectTargetLines(await readProjectTargets(root));
85
+ if (output) {
86
+ console.log(output);
87
+ }
88
+ }
89
+
90
+ export async function listProjects(root: string, options: { withTarget?: string }): Promise<void> {
91
+ if (!options.withTarget) {
92
+ throw new Error('smoo nx list-projects requires --with-target <target>');
93
+ }
94
+ const projects = projectNamesWithTarget(await readProjectTargets(root), options.withTarget).join('\n');
95
+ if (projects) {
96
+ console.log(projects);
97
+ }
98
+ }
99
+
100
+ export async function resetCache(root: string): Promise<void> {
101
+ const command = nxResetCommand();
102
+ await run(command.command, command.args, root);
103
+ }
104
+
105
+ export async function cleanCache(root: string): Promise<void> {
106
+ for (const path of nxCacheDirectories(root)) {
107
+ if (!existsSync(path)) {
108
+ continue;
109
+ }
110
+ const stat = await lstat(path);
111
+ if (!stat.isDirectory() && !stat.isSymbolicLink()) {
112
+ console.warn(`Skipping non-directory Nx cache path: ${path}`);
113
+ continue;
114
+ }
115
+ await rm(path, { recursive: true, force: true });
116
+ console.log(`Removed ${path}`);
117
+ }
118
+ }
119
+
120
+ export async function readProjectTargets(root: string): Promise<ProjectTargets[]> {
121
+ const projects = await readNxProjectNames(root);
122
+ return Promise.all(projects.map((project) => readProjectTarget(root, project)));
123
+ }
124
+
125
+ async function readNxProjectNames(root: string): Promise<string[]> {
126
+ const result = await $`nx show projects`.cwd(root).quiet();
127
+ return projectNamesFromNxShowProjectsOutput(decode(result.stdout));
128
+ }
129
+
130
+ async function readProjectTarget(root: string, project: string): Promise<ProjectTargets> {
131
+ const command = nxShowProjectCommand(project);
132
+ const result = await $`${command.command} ${command.args}`.cwd(root).quiet();
133
+ const parsed: unknown = JSON.parse(decode(result.stdout));
134
+ return {
135
+ project,
136
+ targets: targetNamesFromNxProjectJson(parsed),
137
+ buildDependsOn: buildDependsOnFromNxProjectJson(parsed),
138
+ };
139
+ }