@smoothbricks/cli 0.9.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.
@@ -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
  }
@@ -4,10 +4,13 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import {
6
6
  assertToml,
7
+ blankD1Ids,
7
8
  blankEnvVars,
8
9
  blankKvIds,
9
10
  cloneEnvBlock,
10
11
  firstWranglerEnv,
12
+ getD1Id,
13
+ getD1Name,
11
14
  getKvId,
12
15
  getVar,
13
16
  hasEnvBlock,
@@ -16,6 +19,7 @@ import {
16
19
  parseDevVarsExample,
17
20
  readManifest,
18
21
  readPemFile,
22
+ setD1Id,
19
23
  setKvId,
20
24
  setVar,
21
25
  } from './prepare-env.js';
@@ -161,6 +165,81 @@ describe('blankEnvVars / blankKvIds', () => {
161
165
  });
162
166
  });
163
167
 
168
+ // D1 shell-outs (listD1Databases / ensureD1Database) invoke `bunx wrangler d1`
169
+ // against a live account and are integration-only — deliberately not exercised.
170
+
171
+ describe('getD1Id / getD1Name', () => {
172
+ const toml =
173
+ '[env.staging]\n\n' +
174
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_id = "abc123"\ndatabase_name = "app-staging-db"\n\n' +
175
+ '[env.production]\n\n' +
176
+ '[[env.production.d1_databases]]\nbinding = "DB"\ndatabase_id = ""\ndatabase_name = "app-db"\n';
177
+
178
+ it('reads the id for a matching binding and null for empty id, absent binding, and absent env', () => {
179
+ expect(getD1Id(toml, 'staging', 'DB')).toBe('abc123');
180
+ expect(getD1Id(toml, 'production', 'DB')).toBeNull(); // production id is ""
181
+ expect(getD1Id(toml, 'staging', 'MISSING')).toBeNull(); // absent binding
182
+ expect(getD1Id(toml, 'qa', 'DB')).toBeNull(); // absent env
183
+ });
184
+
185
+ it('reads the database_name for a matching binding and null when the binding or env is absent', () => {
186
+ expect(getD1Name(toml, 'staging', 'DB')).toBe('app-staging-db');
187
+ expect(getD1Name(toml, 'production', 'DB')).toBe('app-db'); // present even though its id is blank
188
+ expect(getD1Name(toml, 'staging', 'MISSING')).toBeNull();
189
+ expect(getD1Name(toml, 'qa', 'DB')).toBeNull();
190
+ });
191
+ });
192
+
193
+ describe('setD1Id', () => {
194
+ const toml =
195
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_id = ""\ndatabase_name = "app-staging-db"\n\n' +
196
+ '[[env.staging.d1_databases]]\nbinding = "ANALYTICS"\ndatabase_id = ""\ndatabase_name = "app-staging-analytics"\n\n' +
197
+ '[[env.production.d1_databases]]\nbinding = "DB"\ndatabase_id = "prod-existing"\ndatabase_name = "app-db"\n';
198
+
199
+ it('writes "<id>" # <name> against the matching env+binding, leaving siblings and other envs intact', () => {
200
+ const out = setD1Id(toml, 'staging', 'DB', 'new-staging-id', 'app-staging-db');
201
+ expect(out).toContain('"new-staging-id" # app-staging-db');
202
+ expect(getD1Id(out, 'staging', 'DB')).toBe('new-staging-id'); // round-trip
203
+ expect(getD1Id(out, 'staging', 'ANALYTICS')).toBeNull(); // sibling binding untouched
204
+ expect(getD1Id(out, 'production', 'DB')).toBe('prod-existing'); // other env untouched
205
+ });
206
+
207
+ it('tolerates database_name preceding database_id and preserves the name', () => {
208
+ const nameFirst =
209
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_name = "app-staging-db"\ndatabase_id = "old"\n';
210
+ const out = setD1Id(nameFirst, 'staging', 'DB', 'fresh', 'app-staging-db');
211
+ expect(getD1Id(out, 'staging', 'DB')).toBe('fresh');
212
+ expect(getD1Name(out, 'staging', 'DB')).toBe('app-staging-db'); // name line not clobbered
213
+ expect(() => assertToml(out)).not.toThrow();
214
+ });
215
+
216
+ it('throws when the binding or the env is absent', () => {
217
+ expect(() => setD1Id(toml, 'staging', 'MISSING', 'x', 'n')).toThrow();
218
+ expect(() => setD1Id(toml, 'qa', 'DB', 'x', 'n')).toThrow();
219
+ });
220
+ });
221
+
222
+ describe('blankD1Ids', () => {
223
+ const toml =
224
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_id = "staging-db-id"\ndatabase_name = "app-staging-db"\n\n' +
225
+ '[[env.staging.d1_databases]]\nbinding = "ANALYTICS"\ndatabase_id = "staging-an-id"\ndatabase_name = "app-staging-analytics"\n\n' +
226
+ '[[env.production.d1_databases]]\nbinding = "DB"\ndatabase_id = "prod-db-id"\ndatabase_name = "app-db"\n';
227
+
228
+ it('blanks every d1 id under the env while names, bindings, and the other env survive', () => {
229
+ const out = blankD1Ids(toml, 'staging');
230
+ expect(getD1Id(out, 'staging', 'DB')).toBeNull();
231
+ expect(getD1Id(out, 'staging', 'ANALYTICS')).toBeNull();
232
+ // names survive the blanking
233
+ expect(getD1Name(out, 'staging', 'DB')).toBe('app-staging-db');
234
+ expect(getD1Name(out, 'staging', 'ANALYTICS')).toBe('app-staging-analytics');
235
+ // other env's id is untouched
236
+ expect(getD1Id(out, 'production', 'DB')).toBe('prod-db-id');
237
+ // bindings survived: setD1Id still resolves them and re-fills an id
238
+ const refilled = setD1Id(out, 'staging', 'DB', 're-provisioned', 'app-staging-db');
239
+ expect(getD1Id(refilled, 'staging', 'DB')).toBe('re-provisioned');
240
+ });
241
+ });
242
+
164
243
  describe('assertToml', () => {
165
244
  it('returns for valid toml', () => {
166
245
  expect(() => assertToml('name = "svc"\n[env.production]\n')).not.toThrow();
@@ -192,6 +271,7 @@ describe('readManifest', () => {
192
271
  await writeFile(join(root, '.dev.vars.example'), '# secrets\nAPI_KEY=\nJWT_SECRET=""\n');
193
272
  expect(readManifest(root, 'staging')).toEqual({
194
273
  kvBindings: ['CACHE', 'SESSIONS'],
274
+ d1Bindings: [],
195
275
  vars: ['PUBLIC_URL', 'FEATURE_FLAG'],
196
276
  secrets: ['API_KEY', 'JWT_SECRET'],
197
277
  });
@@ -212,6 +292,27 @@ describe('readManifest', () => {
212
292
  await rm(root, { recursive: true, force: true });
213
293
  }
214
294
  });
295
+
296
+ it('reports d1 bindings alongside kv bindings and vars without dropping the existing fields', async () => {
297
+ const withD1 =
298
+ 'name = "svc"\n\n' +
299
+ '[env.staging.vars]\nPUBLIC_URL = "https://staging"\n\n' +
300
+ '[[env.staging.kv_namespaces]]\nbinding = "CACHE"\nid = "abc"\n\n' +
301
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_id = "id-1"\ndatabase_name = "app-staging-db"\n\n' +
302
+ '[[env.staging.d1_databases]]\nbinding = "ANALYTICS"\ndatabase_id = ""\ndatabase_name = "app-analytics-db"\n';
303
+ const root = await mkdtemp(join(tmpdir(), 'smoo-prepare-env-'));
304
+ try {
305
+ await writeFile(join(root, 'wrangler.toml'), withD1);
306
+ expect(readManifest(root, 'staging')).toEqual({
307
+ kvBindings: ['CACHE'],
308
+ d1Bindings: ['DB', 'ANALYTICS'],
309
+ vars: ['PUBLIC_URL'],
310
+ secrets: [],
311
+ });
312
+ } finally {
313
+ await rm(root, { recursive: true, force: true });
314
+ }
315
+ });
215
316
  });
216
317
 
217
318
  describe('isPem / readPemFile / looksLikeFileSecret', () => {