@smoothbricks/cli 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -67,9 +67,12 @@ function buildProgram() {
67
67
  const { validateCommitMessageFile } = await import('./monorepo/index.js');
68
68
  validateCommitMessageFile(commitMsgFile, options, await findRepoRoot());
69
69
  });
70
- monorepo.command('sync-bun-lockfile-versions').action(async () => {
70
+ monorepo
71
+ .command('sync-bun-lockfile-versions')
72
+ .option('--stage', 'stage bun.lock when versions were resynced (pre-commit self-heal); quiet when clean')
73
+ .action(async (options) => {
71
74
  const { syncBunLockfileVersions } = await import('./monorepo/index.js');
72
- syncBunLockfileVersions(await findRepoRoot());
75
+ syncBunLockfileVersions(await findRepoRoot(), options.stage ? { log: false, stage: true } : {});
73
76
  });
74
77
  monorepo
75
78
  .command('list-release-packages')
@@ -1,5 +1,7 @@
1
1
  export interface SyncBunLockfileVersionsOptions {
2
2
  log?: boolean;
3
+ /** `git add` bun.lock after rewriting it, for the pre-commit self-heal path. */
4
+ stage?: boolean;
3
5
  }
4
6
  export declare function syncBunLockfileVersions(root: string, options?: SyncBunLockfileVersionsOptions): number;
5
7
  export declare function validateBunLockfileVersions(root: string): number;
@@ -1 +1 @@
1
- {"version":3,"file":"lockfile.d.ts","sourceRoot":"","sources":["../../src/monorepo/lockfile.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,8BAA8B;IAC7C,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAWD,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC,GAAG,MAAM,CAkD1G;AAED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAiChE"}
1
+ {"version":3,"file":"lockfile.d.ts","sourceRoot":"","sources":["../../src/monorepo/lockfile.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,8BAA8B;IAC7C,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,gFAAgF;IAChF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAiBD,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC,GAAG,MAAM,CAuD1G;AAED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAiChE"}
@@ -11,6 +11,12 @@ import { escapeRegex, getWorkspacePackages } from '../lib/workspace.js';
11
11
  // - https://github.com/oven-sh/bun/issues/18906
12
12
  // - https://github.com/oven-sh/bun/issues/20477
13
13
  // - https://github.com/oven-sh/bun/issues/20829
14
+ //
15
+ // Any `bun install` rewrites the workspace versions from package.json, reverting
16
+ // this sync between releases. The pre-commit hook therefore runs
17
+ // `smoo monorepo sync-bun-lockfile-versions --stage` on every commit so drift is
18
+ // healed at the commit that would otherwise carry it — validation must never
19
+ // fail a committer for drift a routine `bun install` introduced.
14
20
  export function syncBunLockfileVersions(root, options = {}) {
15
21
  const log = options.log ?? true;
16
22
  const lockfilePath = join(root, 'bun.lock');
@@ -53,9 +59,14 @@ export function syncBunLockfileVersions(root, options = {}) {
53
59
  }
54
60
  if (updated > 0) {
55
61
  writeFileSync(lockfilePath, lockfile);
62
+ if (options.stage) {
63
+ execSync('git add bun.lock', { cwd: root, stdio: ['pipe', 'pipe', 'pipe'] });
64
+ }
56
65
  }
57
- if (log) {
58
- console.log(updated > 0 ? `Updated ${updated} workspace version(s) in bun.lock` : 'All workspace versions already in sync.');
66
+ if (log || updated > 0) {
67
+ console.log(updated > 0
68
+ ? `Updated ${updated} workspace version(s) in bun.lock${options.stage ? ' (staged)' : ''}`
69
+ : 'All workspace versions already in sync.');
59
70
  }
60
71
  return updated;
61
72
  }
@@ -9,6 +9,12 @@ set -e -o pipefail
9
9
  # Format exactly what is staged so the commit includes the formatter output.
10
10
  git-format-staged
11
11
 
12
+ # Any `bun install` rewrites bun.lock workspace versions from package.json,
13
+ # reverting the stable-tag versions the release flow syncs (see
14
+ # packages/cli/src/monorepo/lockfile.ts). Self-heal here instead of failing a
15
+ # later commit on drift it didn't cause.
16
+ smoo monorepo sync-bun-lockfile-versions --stage
17
+
12
18
  # Adding a workspace package is rare and easy to leave half-wired. Run the
13
19
  # full monorepo validator only for newly staged package manifests.
14
20
  smoo monorepo validate --fail-fast --only-if-new-workspace-package
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -52,7 +52,7 @@
52
52
  ],
53
53
  "dependencies": {
54
54
  "@arethetypeswrong/core": "^0.18.2",
55
- "@smoothbricks/nx-plugin": "0.2.5",
55
+ "@smoothbricks/nx-plugin": "0.3.0",
56
56
  "@smoothbricks/validation": "0.1.1",
57
57
  "commander": "^14.0.3",
58
58
  "publint": "^0.3.18",
package/src/cli.ts CHANGED
@@ -76,10 +76,13 @@ function buildProgram(): Command {
76
76
  const { validateCommitMessageFile } = await import('./monorepo/index.js');
77
77
  validateCommitMessageFile(commitMsgFile, options, await findRepoRoot());
78
78
  });
79
- monorepo.command('sync-bun-lockfile-versions').action(async () => {
80
- const { syncBunLockfileVersions } = await import('./monorepo/index.js');
81
- syncBunLockfileVersions(await findRepoRoot());
82
- });
79
+ monorepo
80
+ .command('sync-bun-lockfile-versions')
81
+ .option('--stage', 'stage bun.lock when versions were resynced (pre-commit self-heal); quiet when clean')
82
+ .action(async (options: { stage?: boolean }) => {
83
+ const { syncBunLockfileVersions } = await import('./monorepo/index.js');
84
+ syncBunLockfileVersions(await findRepoRoot(), options.stage ? { log: false, stage: true } : {});
85
+ });
83
86
  monorepo
84
87
  .command('list-release-packages')
85
88
  .option('--fail-empty', 'fail when no owned release packages are found')
@@ -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;
@@ -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);
@@ -82,9 +82,14 @@ export async function writeBuildablePackage(
82
82
  );
83
83
  }
84
84
 
85
- export async function git(root: string, args: string[], env?: Record<string, string>): Promise<void> {
86
- const result = await gitResult(root, args, env);
87
- if (result.exitCode !== 0) {
85
+ export async function git(
86
+ root: string,
87
+ args: string[],
88
+ env?: Record<string, string>,
89
+ timeoutMs = GIT_TIMEOUT_MS,
90
+ ): Promise<void> {
91
+ const result = await gitResult(root, args, env, timeoutMs);
92
+ if (result.timedOut || result.exitCode !== 0) {
88
93
  throw new Error(gitErrorMessage(root, args, result));
89
94
  }
90
95
  }
@@ -101,9 +106,15 @@ export async function gitSucceeds(root: string, args: string[]): Promise<boolean
101
106
  return (await gitResult(root, args)).exitCode === 0;
102
107
  }
103
108
 
104
- async function gitResult(root: string, args: string[], env?: Record<string, string>): Promise<GitResult> {
109
+ async function gitResult(
110
+ root: string,
111
+ args: string[],
112
+ env?: Record<string, string>,
113
+ timeoutMs = GIT_TIMEOUT_MS,
114
+ ): Promise<GitResult> {
105
115
  const proc = Bun.spawn(['git', ...args], {
106
116
  cwd: root,
117
+ detached: true,
107
118
  env: { ...process.env, ...GIT_FIXTURE_ENV, ...env },
108
119
  stdout: 'pipe',
109
120
  stderr: 'pipe',
@@ -111,15 +122,23 @@ async function gitResult(root: string, args: string[], env?: Record<string, stri
111
122
  let timedOut = false;
112
123
  const timeout = setTimeout(() => {
113
124
  timedOut = true;
114
- proc.kill();
115
- }, GIT_TIMEOUT_MS);
125
+ try {
126
+ // `git` may exit after spawning a transport or hook which still owns its
127
+ // pipes. Killing the detached process group closes every inherited pipe.
128
+ process.kill(-proc.pid, 'SIGKILL');
129
+ } catch (error) {
130
+ if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) {
131
+ proc.kill('SIGKILL');
132
+ }
133
+ }
134
+ }, timeoutMs);
116
135
  try {
117
136
  const [exitCode, stdout, stderr] = await Promise.all([
118
137
  proc.exited,
119
138
  streamBytes(proc.stdout),
120
139
  streamBytes(proc.stderr),
121
140
  ]);
122
- return { exitCode, stdout, stderr, timedOut };
141
+ return { exitCode, stdout, stderr, timedOut, timeoutMs };
123
142
  } finally {
124
143
  clearTimeout(timeout);
125
144
  }
@@ -151,12 +170,13 @@ interface GitResult {
151
170
  stdout: Uint8Array;
152
171
  stderr: Uint8Array;
153
172
  timedOut: boolean;
173
+ timeoutMs: number;
154
174
  }
155
175
 
156
176
  function gitErrorMessage(root: string, args: string[], result: GitResult): string {
157
177
  const stderr = new TextDecoder().decode(result.stderr).trim();
158
178
  const timeoutText = result.timedOut
159
- ? ` timed out after ${GIT_TIMEOUT_MS}ms`
179
+ ? ` timed out after ${result.timeoutMs}ms`
160
180
  : ` failed with exit code ${result.exitCode}`;
161
181
  return [`git ${args.join(' ')}${timeoutText}`, `cwd: ${root}`, stderr].filter(Boolean).join('\n');
162
182
  }