@smoothbricks/cli 0.10.0 → 0.10.2

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 (43) hide show
  1. package/dist/cli.js +5 -2
  2. package/dist/github-ci/index.js +4 -3
  3. package/dist/lib/conflict-markers.d.ts.map +1 -1
  4. package/dist/lib/workspace.d.ts +1 -1
  5. package/dist/lib/workspace.d.ts.map +1 -1
  6. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  7. package/dist/monorepo/ci-workflow.js +1 -0
  8. package/dist/monorepo/lockfile.d.ts +2 -0
  9. package/dist/monorepo/lockfile.d.ts.map +1 -1
  10. package/dist/monorepo/lockfile.js +13 -2
  11. package/dist/monorepo/managed-files.d.ts +1 -0
  12. package/dist/monorepo/managed-files.d.ts.map +1 -1
  13. package/dist/monorepo/managed-files.js +12 -5
  14. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  15. package/dist/monorepo/publish-workflow.js +1 -0
  16. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  17. package/dist/monorepo/tool-validation.js +20 -7
  18. package/dist/release/github-release.d.ts +2 -2
  19. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  20. package/dist/wrangler/prepare-env.js +1 -1
  21. package/dist/wrangler/scaffold.d.ts.map +1 -1
  22. package/dist/wrangler/scaffold.js +1 -1
  23. package/managed/raw/tooling/direnv/setup-environment.ts +2 -8
  24. package/managed/raw/tooling/git-hooks/pre-commit.sh +6 -0
  25. package/package.json +3 -13
  26. package/src/cli.ts +7 -4
  27. package/src/github-ci/index.ts +5 -3
  28. package/src/monorepo/__tests__/ci-workflow.test.ts +2 -0
  29. package/src/monorepo/__tests__/publish-workflow.test.ts +12 -4
  30. package/src/monorepo/ci-workflow.ts +2 -0
  31. package/src/monorepo/lockfile.test.ts +157 -0
  32. package/src/monorepo/lockfile.ts +15 -2
  33. package/src/monorepo/managed-files.test.ts +13 -0
  34. package/src/monorepo/managed-files.ts +20 -5
  35. package/src/monorepo/package-policy.test.ts +7 -37
  36. package/src/monorepo/publish-workflow.ts +2 -0
  37. package/src/monorepo/tool-validation.test.ts +59 -24
  38. package/src/monorepo/tool-validation.ts +21 -7
  39. package/src/monorepo/wrangler.test.ts +9 -2
  40. package/src/release/__tests__/fixture-repo.test.ts +8 -0
  41. package/src/release/__tests__/helpers/fixture-repo.ts +28 -8
  42. package/src/wrangler/prepare-env.ts +14 -4
  43. package/src/wrangler/scaffold.ts +8 -3
@@ -0,0 +1,157 @@
1
+ import { afterAll, describe, expect, it } from 'bun:test';
2
+ import { execSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import fc from 'fast-check';
7
+ import { syncBunLockfileVersions, validateBunLockfileVersions } from './lockfile.js';
8
+
9
+ const cleanupRoots: string[] = [];
10
+ afterAll(() => {
11
+ for (const root of cleanupRoots) {
12
+ rmSync(root, { recursive: true, force: true });
13
+ }
14
+ });
15
+
16
+ function git(root: string, args: string): string {
17
+ return execSync(`git ${args}`, {
18
+ cwd: root,
19
+ encoding: 'utf8',
20
+ env: {
21
+ ...process.env,
22
+ GIT_CONFIG_GLOBAL: '/dev/null',
23
+ GIT_CONFIG_SYSTEM: '/dev/null',
24
+ GIT_AUTHOR_NAME: 'fixture',
25
+ GIT_AUTHOR_EMAIL: 'fixture@invalid',
26
+ GIT_COMMITTER_NAME: 'fixture',
27
+ GIT_COMMITTER_EMAIL: 'fixture@invalid',
28
+ },
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ });
31
+ }
32
+
33
+ interface FixtureOptions {
34
+ packageVersion: string;
35
+ lockVersion: string;
36
+ stableTag?: string;
37
+ }
38
+
39
+ function makeFixtureRepo(options: FixtureOptions): string {
40
+ const root = mkdtempSync(join(tmpdir(), 'smoo-lockfile-'));
41
+ cleanupRoots.push(root);
42
+ git(root, 'init -q');
43
+ git(root, 'commit -q --allow-empty -m init');
44
+ writeFileSync(
45
+ join(root, 'package.json'),
46
+ JSON.stringify({ name: 'fixture-root', version: '0.0.0', private: true, workspaces: ['packages/*'] }),
47
+ );
48
+ mkdirSync(join(root, 'packages', 'foo'), { recursive: true });
49
+ writeFixturePackage(root, options.packageVersion);
50
+ writeFixtureLock(root, options.lockVersion);
51
+ if (options.stableTag) {
52
+ git(root, `tag ${options.stableTag}`);
53
+ }
54
+ return root;
55
+ }
56
+
57
+ function writeFixturePackage(root: string, version: string): void {
58
+ writeFileSync(
59
+ join(root, 'packages', 'foo', 'package.json'),
60
+ JSON.stringify({ name: '@fixture/foo', version, nx: { name: 'foo' } }),
61
+ );
62
+ }
63
+
64
+ function writeFixtureLock(root: string, version: string): void {
65
+ writeFileSync(
66
+ join(root, 'bun.lock'),
67
+ [
68
+ '{',
69
+ ' "lockfileVersion": 1,',
70
+ ' "workspaces": {',
71
+ ' "": { "name": "fixture-root" },',
72
+ ' "packages/foo": {',
73
+ ' "name": "@fixture/foo",',
74
+ ` "version": "${version}",`,
75
+ ' },',
76
+ ' },',
77
+ '}',
78
+ '',
79
+ ].join('\n'),
80
+ );
81
+ }
82
+
83
+ function lockVersionIn(root: string): string {
84
+ const match = readFileSync(join(root, 'bun.lock'), 'utf8').match(/"packages\/foo":\s*\{[^}]*"version":\s*"([^"]+)"/);
85
+ if (!match) throw new Error('fixture lockfile lost its workspace entry');
86
+ return match[1] as string;
87
+ }
88
+
89
+ describe('bun.lock workspace version sync', () => {
90
+ it('a bun-install revert of the release-synced version fails validation', () => {
91
+ // The b3298fc4c shape: package.json holds the next prerelease, a stable tag
92
+ // exists, and `bun install` rewrote the lockfile back to the prerelease.
93
+ const root = makeFixtureRepo({
94
+ packageVersion: '0.1.3-next.0',
95
+ lockVersion: '0.1.3-next.0',
96
+ stableTag: 'foo@0.1.2',
97
+ });
98
+ expect(validateBunLockfileVersions(root)).toBe(1);
99
+ });
100
+
101
+ it('sync restores the latest stable tag version and validation passes', () => {
102
+ const root = makeFixtureRepo({
103
+ packageVersion: '0.1.3-next.0',
104
+ lockVersion: '0.1.3-next.0',
105
+ stableTag: 'foo@0.1.2',
106
+ });
107
+ expect(syncBunLockfileVersions(root, { log: false })).toBe(1);
108
+ expect(lockVersionIn(root)).toBe('0.1.2');
109
+ expect(validateBunLockfileVersions(root)).toBe(0);
110
+ });
111
+
112
+ it('a never-published prerelease package falls back to its manifest version', () => {
113
+ // The new-workspace-package shape (e.g. cowshed): no stable tag exists yet.
114
+ const root = makeFixtureRepo({ packageVersion: '0.2.0-next.0', lockVersion: '0.2.0-next.0' });
115
+ expect(validateBunLockfileVersions(root)).toBe(0);
116
+ expect(syncBunLockfileVersions(root, { log: false })).toBe(0);
117
+ });
118
+
119
+ it('a stable manifest version is required verbatim in the lockfile', () => {
120
+ const root = makeFixtureRepo({ packageVersion: '0.1.0', lockVersion: '0.0.9' });
121
+ expect(validateBunLockfileVersions(root)).toBe(1);
122
+ expect(syncBunLockfileVersions(root, { log: false })).toBe(1);
123
+ expect(lockVersionIn(root)).toBe('0.1.0');
124
+ });
125
+
126
+ it('stage: true stages the healed bun.lock', () => {
127
+ const root = makeFixtureRepo({
128
+ packageVersion: '0.1.3-next.0',
129
+ lockVersion: '0.1.3-next.0',
130
+ stableTag: 'foo@0.1.2',
131
+ });
132
+ expect(syncBunLockfileVersions(root, { log: false, stage: true })).toBe(1);
133
+ expect(git(root, 'diff --cached --name-only')).toContain('bun.lock');
134
+ });
135
+
136
+ it('stage: true leaves a clean lockfile unstaged', () => {
137
+ const root = makeFixtureRepo({ packageVersion: '0.1.2', lockVersion: '0.1.2', stableTag: 'foo@0.1.2' });
138
+ expect(syncBunLockfileVersions(root, { log: false, stage: true })).toBe(0);
139
+ expect(git(root, 'diff --cached --name-only').trim()).toBe('');
140
+ });
141
+
142
+ it('property: after sync, validation always passes', () => {
143
+ const version = fc
144
+ .tuple(fc.nat({ max: 20 }), fc.nat({ max: 20 }), fc.nat({ max: 20 }), fc.option(fc.nat({ max: 5 })))
145
+ .map(([major, minor, patch, next]) => `${major}.${minor}.${patch}${next === null ? '' : `-next.${next}`}`);
146
+ const root = makeFixtureRepo({ packageVersion: '0.1.2', lockVersion: '0.1.2', stableTag: 'foo@0.1.2' });
147
+ fc.assert(
148
+ fc.property(version, version, (packageVersion, lockVersion) => {
149
+ writeFixturePackage(root, packageVersion);
150
+ writeFixtureLock(root, lockVersion);
151
+ syncBunLockfileVersions(root, { log: false });
152
+ return validateBunLockfileVersions(root) === 0;
153
+ }),
154
+ { numRuns: 25 },
155
+ );
156
+ });
157
+ });
@@ -5,6 +5,8 @@ import { escapeRegex, getWorkspacePackages } from '../lib/workspace.js';
5
5
 
6
6
  export interface SyncBunLockfileVersionsOptions {
7
7
  log?: boolean;
8
+ /** `git add` bun.lock after rewriting it, for the pre-commit self-heal path. */
9
+ stage?: boolean;
8
10
  }
9
11
 
10
12
  // Temporary Bun workaround. Delete this sync function, validateBunLockfileVersions,
@@ -16,6 +18,12 @@ export interface SyncBunLockfileVersionsOptions {
16
18
  // - https://github.com/oven-sh/bun/issues/18906
17
19
  // - https://github.com/oven-sh/bun/issues/20477
18
20
  // - https://github.com/oven-sh/bun/issues/20829
21
+ //
22
+ // Any `bun install` rewrites the workspace versions from package.json, reverting
23
+ // this sync between releases. The pre-commit hook therefore runs
24
+ // `smoo monorepo sync-bun-lockfile-versions --stage` on every commit so drift is
25
+ // healed at the commit that would otherwise carry it — validation must never
26
+ // fail a committer for drift a routine `bun install` introduced.
19
27
  export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVersionsOptions = {}): number {
20
28
  const log = options.log ?? true;
21
29
  const lockfilePath = join(root, 'bun.lock');
@@ -59,10 +67,15 @@ export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVe
59
67
  }
60
68
  if (updated > 0) {
61
69
  writeFileSync(lockfilePath, lockfile);
70
+ if (options.stage) {
71
+ execSync('git add bun.lock', { cwd: root, stdio: ['pipe', 'pipe', 'pipe'] });
72
+ }
62
73
  }
63
- if (log) {
74
+ if (log || updated > 0) {
64
75
  console.log(
65
- updated > 0 ? `Updated ${updated} workspace version(s) in bun.lock` : 'All workspace versions already in sync.',
76
+ updated > 0
77
+ ? `Updated ${updated} workspace version(s) in bun.lock${options.stage ? ' (staged)' : ''}`
78
+ : 'All workspace versions already in sync.',
66
79
  );
67
80
  }
68
81
  return updated;
@@ -93,6 +93,19 @@ describe('managed-file inline local blocks', () => {
93
93
  );
94
94
  });
95
95
 
96
+ it('preserves marker indentation when refreshing nested configuration', () => {
97
+ const markerIndent = ' ';
98
+ const current = [
99
+ 'patterns:',
100
+ " - '*.html'",
101
+ `${markerIndent}${INLINE_LOCAL_BEGIN}`,
102
+ " - '!**/templates/*.html'",
103
+ `${markerIndent}${INLINE_LOCAL_END}`,
104
+ ].join('\n');
105
+ const { withoutInline, blocks } = extractInlineLocalBlocksForTest(current);
106
+ expect(reinsertInlineLocalBlocksForTest(withoutInline, blocks)).toBe(current);
107
+ });
108
+
96
109
  it("reinserting refuses when the anchor no longer appears — never silently drops the repo's customization", () => {
97
110
  const fresh = ['a:', ' - one', 'b:'].join('\n'); // ' - two' is gone
98
111
  expect(() => reinsertInlineLocalBlocksForTest(fresh, [{ anchor: ' - two', lines: ' - repo-owned' }])).toThrow(
@@ -50,6 +50,7 @@ export const INLINE_LOCAL_END = '# smoo-local-end';
50
50
  interface InlineLocalBlock {
51
51
  anchor: string;
52
52
  lines: string;
53
+ markerIndent?: string;
53
54
  }
54
55
 
55
56
  /** Pull inline local blocks out of a managed section, returning the section
@@ -76,7 +77,12 @@ function extractInlineLocalBlocks(managed: string): { withoutInline: string; blo
76
77
  if (i >= lines.length) {
77
78
  throw new Error(`${INLINE_LOCAL_BEGIN} anchored on "${anchor}" has no matching ${INLINE_LOCAL_END}`);
78
79
  }
79
- blocks.push({ anchor, lines: blockLines.join('\n') });
80
+ const markerIndent = line.slice(0, line.length - line.trimStart().length);
81
+ blocks.push({
82
+ anchor,
83
+ lines: blockLines.join('\n'),
84
+ ...(markerIndent === '' ? {} : { markerIndent }),
85
+ });
80
86
  i += 1; // skip the END marker line itself
81
87
  continue;
82
88
  }
@@ -102,7 +108,14 @@ function reinsertInlineLocalBlocks(content: string, blocks: InlineLocalBlock[]):
102
108
  'template — reconcile the repo-owned block manually',
103
109
  );
104
110
  }
105
- lines.splice(index + 1, 0, INLINE_LOCAL_BEGIN, ...block.lines.split('\n'), INLINE_LOCAL_END);
111
+ const markerIndent = block.markerIndent ?? '';
112
+ lines.splice(
113
+ index + 1,
114
+ 0,
115
+ `${markerIndent}${INLINE_LOCAL_BEGIN}`,
116
+ ...block.lines.split('\n'),
117
+ `${markerIndent}${INLINE_LOCAL_END}`,
118
+ );
106
119
  }
107
120
  return lines.join('\n');
108
121
  }
@@ -388,9 +401,11 @@ function readCiPushBranches(packageJson: unknown): string[] {
388
401
  }
389
402
 
390
403
  function recordValue(value: unknown): Record<string, unknown> | undefined {
391
- return value !== null && typeof value === 'object' && !Array.isArray(value)
392
- ? (value as Record<string, unknown>)
393
- : undefined;
404
+ return isRecord(value) ? value : undefined;
405
+ }
406
+
407
+ function isRecord(value: unknown): value is Record<string, unknown> {
408
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
394
409
  }
395
410
 
396
411
  function renderYamlFlowList(values: string[]): string {
@@ -70,21 +70,7 @@ describe('root smoo monorepo policy', () => {
70
70
  expect(rootPackage).toMatchObject({ nx: { includedScripts: [] } });
71
71
  expect(nxJson.namedInputs).toEqual(validNamedInputs());
72
72
  expect(nxJson.targetDefaults).toEqual(validTargetDefaults());
73
- expect(nxJson.plugins).toEqual([
74
- {
75
- plugin: '@nx/js/typescript',
76
- options: {
77
- typecheck: { targetName: 'typecheck' },
78
- build: {
79
- targetName: 'tsc-js',
80
- configName: 'tsconfig.lib.json',
81
- buildDepsName: 'build-deps',
82
- watchDepsName: 'watch-deps',
83
- },
84
- },
85
- },
86
- '@smoothbricks/nx-plugin',
87
- ]);
73
+ expect(nxJson.plugins).toEqual(['@smoothbricks/nx-plugin']);
88
74
  expect(validateRootPackagePolicy(root)).toBe(0);
89
75
  expect(validateNxReleaseConfig(root)).toBe(0);
90
76
  } finally {
@@ -167,7 +153,7 @@ describe('root smoo monorepo policy', () => {
167
153
  }
168
154
  });
169
155
 
170
- it('explains Nx plugin conventions when nx.json is not configured', async () => {
156
+ it('explains SmoothBricks Nx plugin conventions when nx.json is not configured', async () => {
171
157
  const root = await mkdtemp(join(tmpdir(), 'smoo-package-policy-'));
172
158
  try {
173
159
  await writeJson(join(root, 'package.json'), validRootPackage());
@@ -177,10 +163,8 @@ describe('root smoo monorepo policy', () => {
177
163
  });
178
164
  const errors = captureConsoleErrors();
179
165
 
180
- expect(validateNxReleaseConfig(root)).toBe(2);
166
+ expect(validateNxReleaseConfig(root)).toBe(1);
181
167
 
182
- expect(errors.join('\n')).toContain('Official Nx owns TypeScript library inference');
183
- expect(errors.join('\n')).toContain('tsconfig.lib.json produces tsc-js');
184
168
  expect(errors.join('\n')).toContain('Smoo relies on this plugin to infer convention targets');
185
169
  expect(errors.join('\n')).not.toContain('Fix:');
186
170
  } finally {
@@ -188,7 +172,7 @@ describe('root smoo monorepo policy', () => {
188
172
  }
189
173
  });
190
174
 
191
- it('explains why TypeScript build targetName is tsc-js', async () => {
175
+ it('rejects the official Nx TypeScript plugin because it bypasses ttsc transforms', async () => {
192
176
  const root = await mkdtemp(join(tmpdir(), 'smoo-package-policy-'));
193
177
  try {
194
178
  await writeJson(join(root, 'package.json'), validRootPackage());
@@ -206,8 +190,8 @@ describe('root smoo monorepo policy', () => {
206
190
 
207
191
  expect(validateNxReleaseConfig(root)).toBe(1);
208
192
 
209
- expect(errors.join('\n')).toContain('build.targetName must be tsc-js');
210
- expect(errors.join('\n')).toContain('build is reserved for aggregate targets');
193
+ expect(errors.join('\n')).toContain('plugins must not configure @nx/js/typescript');
194
+ expect(errors.join('\n')).toContain('Smoo owns transformer-aware TypeScript targets');
211
195
  expect(errors.join('\n')).not.toContain('Fix:');
212
196
  } finally {
213
197
  await rm(root, { recursive: true, force: true });
@@ -1100,21 +1084,7 @@ function validConfiguredNxJson(): Record<string, unknown> {
1100
1084
  ...validNxJson(),
1101
1085
  namedInputs: validNamedInputs(),
1102
1086
  targetDefaults: validTargetDefaults(),
1103
- plugins: [
1104
- {
1105
- plugin: '@nx/js/typescript',
1106
- options: {
1107
- typecheck: { targetName: 'typecheck' },
1108
- build: {
1109
- targetName: 'tsc-js',
1110
- configName: 'tsconfig.lib.json',
1111
- buildDepsName: 'build-deps',
1112
- watchDepsName: 'watch-deps',
1113
- },
1114
- },
1115
- },
1116
- '@smoothbricks/nx-plugin',
1117
- ],
1087
+ plugins: ['@smoothbricks/nx-plugin'],
1118
1088
  };
1119
1089
  }
1120
1090
 
@@ -1,3 +1,5 @@
1
+ /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are emitted literally. */
2
+
1
3
  import { isSmoothBricksCodebasePackageName } from '../lib/cli-package.js';
2
4
 
3
5
  export type PublishWorkflowBump = 'auto' | 'patch' | 'minor' | 'major' | 'prerelease';
@@ -12,12 +12,13 @@ import {
12
12
 
13
13
  const registryVersions: Record<string, string[]> = {
14
14
  '@biomejs/biome': ['2.3.5'],
15
- '@nx/js': ['22.5.4'],
16
- '@smoothbricks/nx-plugin': ['0.0.1'],
15
+ '@nx/js': ['23.1.0'],
16
+ '@smoothbricks/nx-plugin': ['0.3.0'],
17
17
  eslint: ['9.39.1'],
18
18
  'eslint-stdout': ['1.1.1', '1.1.2'],
19
- nx: ['22.5.4'],
19
+ nx: ['23.1.0'],
20
20
  prettier: ['3.6.1'],
21
+ ttsc: ['0.18.4'],
21
22
  typescript: ['5.9.3'],
22
23
  };
23
24
 
@@ -25,7 +26,7 @@ const realFetch = globalThis.fetch;
25
26
 
26
27
  describe('tool configuration validation', () => {
27
28
  beforeEach(() => {
28
- globalThis.fetch = mockRegistryFetch as typeof fetch;
29
+ globalThis.fetch = mockRegistryFetch;
29
30
  });
30
31
 
31
32
  afterEach(() => {
@@ -67,7 +68,7 @@ describe('tool configuration validation', () => {
67
68
  expect(rootPackage.devDependencies['@smoothbricks/cli']).toBeUndefined();
68
69
  expect(rootPackage.devDependencies['@smoothbricks/nx-plugin']).toBe('workspace:*');
69
70
  expect(rootPackage.devDependencies['eslint-stdout']).toBe('workspace:*');
70
- expect(rootPackage.devDependencies.nx).toBe('22.5.4');
71
+ expect(rootPackage.devDependencies.nx).toBe('23.1.0');
71
72
  expect(rootPackage.workspaces).toContain('tooling');
72
73
  expect(toolingPackage.name).toBe('@smoothbricks/tooling');
73
74
  expect(toolingPackage.dependencies['@smoothbricks/cli']).toBe('workspace:*');
@@ -89,13 +90,14 @@ describe('tool configuration validation', () => {
89
90
  workspaces: ['packages/*', 'tooling'],
90
91
  devDependencies: {
91
92
  '@biomejs/biome': '^3.0.0',
92
- '@nx/js': '22.6.0',
93
- '@smoothbricks/nx-plugin': '^0.1.0',
93
+ '@nx/js': '23.2.0',
94
+ '@smoothbricks/nx-plugin': '^0.4.0',
94
95
  '@types/bun': '1.3.99',
95
96
  eslint: '^10.0.0',
96
97
  'eslint-stdout': await currentEslintStdoutRange(),
97
- nx: '23.0.0',
98
+ nx: '24.0.0',
98
99
  prettier: '^3.7.0',
100
+ ttsc: '^0.19.0',
99
101
  typescript: '^6.0.0',
100
102
  },
101
103
  });
@@ -132,6 +134,35 @@ describe('tool configuration validation', () => {
132
134
  }
133
135
  });
134
136
 
137
+ it('preserves an installable CLI range when the running CLI is a linked prerelease', async () => {
138
+ const root = await mkdtemp(join(tmpdir(), 'smoo-tool-validation-'));
139
+ try {
140
+ const configuredRange = '^0.10.1';
141
+ const runningRange = await currentCliRange();
142
+ const expectedRange = runningRange.includes('-') ? configuredRange : runningRange;
143
+ await writeJson(join(root, 'package.json'), {
144
+ name: '@fixture/app',
145
+ version: '0.0.0',
146
+ private: true,
147
+ workspaces: ['packages/*', 'tooling'],
148
+ });
149
+ await writeJson(join(root, 'tooling/package.json'), {
150
+ name: '@fixture/tooling',
151
+ private: true,
152
+ dependencies: { '@smoothbricks/cli': configuredRange },
153
+ });
154
+
155
+ const context = readToolContext(root);
156
+ expect(context.policy.cliDependencyRange).toBe(expectedRange);
157
+ applyToolingPackageDefaults(root, context.policy);
158
+
159
+ const toolingPackage = JSON.parse(await readFile(join(root, 'tooling/package.json'), 'utf8'));
160
+ expect(toolingPackage.dependencies['@smoothbricks/cli']).toBe(expectedRange);
161
+ } finally {
162
+ await rm(root, { recursive: true, force: true });
163
+ }
164
+ });
165
+
135
166
  it('uses workspace smoo in the SmoothBricks codebase repo', async () => {
136
167
  const root = await mkdtemp(join(tmpdir(), 'smoo-tool-validation-'));
137
168
  try {
@@ -161,10 +192,11 @@ describe('tool configuration validation', () => {
161
192
  workspaces: ['packages/*', 'tooling'],
162
193
  devDependencies: {
163
194
  '@biomejs/biome': '^3.0.0',
164
- '@nx/js': '22.6.0',
195
+ '@nx/js': '23.2.0',
165
196
  eslint: '^10.0.0',
166
- nx: '23.0.0',
197
+ nx: '24.0.0',
167
198
  prettier: '^3.7.0',
199
+ ttsc: '^0.19.0',
168
200
  typescript: '^6.0.0',
169
201
  },
170
202
  });
@@ -195,20 +227,23 @@ async function currentEslintStdoutRange(): Promise<string> {
195
227
  return `^${latestPatchVersion('eslint-stdout', pkg.version) ?? pkg.version}`;
196
228
  }
197
229
 
198
- function mockRegistryFetch(input: Parameters<typeof fetch>[0]): Promise<Response> {
199
- const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
200
- const packageName = decodeURIComponent(new URL(url).pathname.slice(1));
201
- const versions = registryVersions[packageName];
202
- if (!versions) {
203
- return Promise.resolve(new Response('{}', { status: 404 }));
204
- }
205
- return Promise.resolve(
206
- new Response(JSON.stringify({ versions: Object.fromEntries(versions.map((version) => [version, {}])) }), {
207
- status: 200,
208
- headers: { 'content-type': 'application/json' },
209
- }),
210
- );
211
- }
230
+ const mockRegistryFetch = Object.assign(
231
+ (input: string | URL | Request): Promise<Response> => {
232
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
233
+ const packageName = decodeURIComponent(new URL(url).pathname.slice(1));
234
+ const versions = registryVersions[packageName];
235
+ if (!versions) {
236
+ return Promise.resolve(new Response('{}', { status: 404 }));
237
+ }
238
+ return Promise.resolve(
239
+ new Response(JSON.stringify({ versions: Object.fromEntries(versions.map((version) => [version, {}])) }), {
240
+ status: 200,
241
+ headers: { 'content-type': 'application/json' },
242
+ }),
243
+ );
244
+ },
245
+ { preconnect: realFetch.preconnect },
246
+ ) satisfies typeof fetch;
212
247
 
213
248
  function latestPatchVersion(packageName: string, version: string): string | null {
214
249
  const [major, minor] = version.split('.');
@@ -25,11 +25,11 @@ export interface ToolContext {
25
25
 
26
26
  const rootDevDependencies: RequiredDependency[] = [
27
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' },
28
+ { name: '@nx/js', fallbackVersion: '23.1.0', minimumVersion: '23.1.0' },
29
29
  {
30
30
  name: '@smoothbricks/nx-plugin',
31
- fallbackVersion: '^0.0.1',
32
- minimumVersion: '0.0.1',
31
+ fallbackVersion: '^0.3.0',
32
+ minimumVersion: '0.3.0',
33
33
  prefix: '^',
34
34
  useWorkspaceRangeInCodebase: true,
35
35
  },
@@ -41,8 +41,11 @@ const rootDevDependencies: RequiredDependency[] = [
41
41
  prefix: '^',
42
42
  useWorkspaceRangeInCodebase: true,
43
43
  },
44
- { name: 'nx', fallbackVersion: '22.5.4', minimumVersion: '22.5.0' },
44
+ { name: 'nx', fallbackVersion: '23.1.0', minimumVersion: '23.1.0' },
45
45
  { name: 'prettier', fallbackVersion: '^3.6.1', minimumVersion: '3.6.0', prefix: '^' },
46
+ { name: 'ttsc', fallbackVersion: '^0.18.4', minimumVersion: '0.18.4', prefix: '^' },
47
+ // Nx and typescript-eslint still load the TypeScript 5.x JavaScript API.
48
+ // Compilation is exclusively delegated to ttsc by the Nx plugin targets.
46
49
  { name: 'typescript', fallbackVersion: '^5.9.3', minimumVersion: '5.9.0', prefix: '^' },
47
50
  ];
48
51
 
@@ -330,17 +333,28 @@ function formatExpectedDependency(policy: ToolPolicy, dependency: RequiredDepend
330
333
 
331
334
  export function readToolContext(root: string): ToolContext {
332
335
  const rootPackage = readPackageJsonObject(join(root, 'package.json'));
333
- return { rootPackage, policy: toolPolicy(rootPackage) };
336
+ const toolingPackage = readJsonObject(join(root, 'tooling', 'package.json'));
337
+ const configuredCliRange = recordProperty(toolingPackage ?? {}, 'dependencies')?.[cliPackageName];
338
+ return {
339
+ rootPackage,
340
+ policy: toolPolicy(rootPackage, typeof configuredCliRange === 'string' ? configuredCliRange : null),
341
+ };
334
342
  }
335
343
 
336
- function toolPolicy(rootPackage: PackageJson | null): ToolPolicy {
344
+ function toolPolicy(rootPackage: PackageJson | null, configuredCliRange: string | null): ToolPolicy {
337
345
  const name = typeof rootPackage?.name === 'string' ? rootPackage.name : null;
338
346
  const isCodebase = isSmoothBricksCodebasePackageName(name ?? undefined);
339
347
  const toolingName = toolingPackageName(name);
348
+ // A locally linked prerelease can be newer than every published CLI. Keep an existing
349
+ // installable range instead of rewriting the consumer manifest to an unpublished version.
350
+ const publishedCliRange =
351
+ cliPackageVersion.includes('-') && configuredCliRange && parseVersion(configuredCliRange)
352
+ ? configuredCliRange
353
+ : `^${cliPackageVersion}`;
340
354
  return {
341
355
  isSmoothBricksCodebase: isCodebase,
342
356
  toolingPackageName: toolingName,
343
- cliDependencyRange: isCodebase ? 'workspace:*' : `^${cliPackageVersion}`,
357
+ cliDependencyRange: isCodebase ? 'workspace:*' : publishedCliRange,
344
358
  };
345
359
  }
346
360
 
@@ -21,7 +21,12 @@ describe('firstWranglerEnv', () => {
21
21
  describe('applyWranglerDefaults', () => {
22
22
  it('injects the wrangler-types target and wires typecheck, idempotently', async () => {
23
23
  const root = await createWorkspace([
24
- { dir: 'api', name: '@acme/api', toml: '[env.production]\n', nx: { targets: { typecheck: { dependsOn: ['^build'] } } } },
24
+ {
25
+ dir: 'api',
26
+ name: '@acme/api',
27
+ toml: '[env.production]\n',
28
+ nx: { targets: { typecheck: { dependsOn: ['^build'] } } },
29
+ },
25
30
  ]);
26
31
  try {
27
32
  applyWranglerDefaults(root);
@@ -51,7 +56,9 @@ describe('applyWranglerDefaults', () => {
51
56
  });
52
57
 
53
58
  it('omits --env from the command when the wrangler.toml declares no env block', async () => {
54
- const root = await createWorkspace([{ dir: 'api', name: '@acme/api', toml: 'name = "svc"\nmain = "src/index.ts"\n' }]);
59
+ const root = await createWorkspace([
60
+ { dir: 'api', name: '@acme/api', toml: 'name = "svc"\nmain = "src/index.ts"\n' },
61
+ ]);
55
62
  captureConsoleLogs();
56
63
  try {
57
64
  applyWranglerDefaults(root);
@@ -226,6 +226,14 @@ describe('release planning with fixture git repositories', () => {
226
226
  });
227
227
  });
228
228
 
229
+ it('kills timed-out fixture Git process groups so descendants cannot hold output pipes open', async () => {
230
+ await withFixtureRepo(async (root) => {
231
+ await expect(
232
+ git(root, ['-c', "alias.hold-pipes=!sh -c 'sleep 30 & exit 0'", 'hold-pipes'], undefined, 100),
233
+ ).rejects.toThrow('timed out after 100ms');
234
+ });
235
+ }, 5_000);
236
+
229
237
  it('repairs a scoped package from its project-name release tag without creating a package-name tag', async () => {
230
238
  await withFixtureRepo(async (author) => {
231
239
  await writeWorkspace(author);