@smoothbricks/cli 0.3.2 → 0.3.4

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.
@@ -1 +1 @@
1
- {"version":3,"file":"lockfile.d.ts","sourceRoot":"","sources":["../../src/monorepo/lockfile.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,8BAA8B;IAC7C,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAWD,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC,GAAG,MAAM,CA0C1G;AAED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA6BhE"}
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,3 +1,4 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { escapeRegex, getWorkspacePackages } from '../lib/workspace.js';
@@ -20,6 +21,12 @@ export function syncBunLockfileVersions(root, options = {}) {
20
21
  let lockfile = readFileSync(lockfilePath, 'utf8');
21
22
  let updated = 0;
22
23
  for (const pkg of packages) {
24
+ // If the package.json version is a prerelease (e.g. 0.2.2-next.0) it may
25
+ // never have been published. Use the latest stable git tag version so
26
+ // that `bun pm pack` writes an installable dependency range for consumers.
27
+ const targetVersion = pkg.version.includes('-')
28
+ ? (latestStableTagVersion(root, pkg.projectName) ?? pkg.version)
29
+ : pkg.version;
23
30
  const relativePath = pkg.path.replaceAll('\\', '/');
24
31
  const escaped = escapeRegex(relativePath);
25
32
  const pattern = new RegExp(`("${escaped}":\\s*\\{[^}]*"version":\\s*")([^"]+)(")`);
@@ -31,15 +38,16 @@ export function syncBunLockfileVersions(root, options = {}) {
31
38
  continue;
32
39
  }
33
40
  const lockVersion = match[2];
34
- if (lockVersion === pkg.version) {
41
+ if (lockVersion === targetVersion) {
35
42
  if (log) {
36
- console.log(`ok: ${relativePath} = ${pkg.version}`);
43
+ console.log(`ok: ${relativePath} = ${targetVersion}`);
37
44
  }
38
45
  continue;
39
46
  }
40
- lockfile = lockfile.replace(pattern, `$1${pkg.version}$3`);
47
+ lockfile = lockfile.replace(pattern, `$1${targetVersion}$3`);
41
48
  if (log) {
42
- console.log(`fix: ${relativePath}: ${lockVersion} -> ${pkg.version}`);
49
+ const suffix = targetVersion !== pkg.version ? ` (latest stable tag; package.json has ${pkg.version})` : '';
50
+ console.log(`fix: ${relativePath}: ${lockVersion} -> ${targetVersion}${suffix}`);
43
51
  }
44
52
  updated++;
45
53
  }
@@ -61,6 +69,9 @@ export function validateBunLockfileVersions(root) {
61
69
  const lockfile = readFileSync(lockfilePath, 'utf8');
62
70
  let failures = 0;
63
71
  for (const pkg of packages) {
72
+ const targetVersion = pkg.version.includes('-')
73
+ ? (latestStableTagVersion(root, pkg.projectName) ?? pkg.version)
74
+ : pkg.version;
64
75
  const relativePath = pkg.path.replaceAll('\\', '/');
65
76
  const escaped = escapeRegex(relativePath);
66
77
  const pattern = new RegExp(`("${escaped}":\\s*\\{[^}]*"version":\\s*")([^"]+)(")`);
@@ -71,8 +82,8 @@ export function validateBunLockfileVersions(root) {
71
82
  continue;
72
83
  }
73
84
  const lockVersion = match[2];
74
- if (lockVersion !== pkg.version) {
75
- console.error(`${relativePath}: bun.lock workspace version must be ${pkg.version}, got ${lockVersion}`);
85
+ if (lockVersion !== targetVersion) {
86
+ console.error(`${relativePath}: bun.lock workspace version must be ${targetVersion}, got ${lockVersion}`);
76
87
  failures++;
77
88
  }
78
89
  }
@@ -81,3 +92,26 @@ export function validateBunLockfileVersions(root) {
81
92
  }
82
93
  return failures;
83
94
  }
95
+ function latestStableTagVersion(root, projectName) {
96
+ try {
97
+ const output = execSync(`git tag --list '${projectName}@*' --sort=-v:refname`, {
98
+ cwd: root,
99
+ encoding: 'utf8',
100
+ stdio: ['pipe', 'pipe', 'pipe'],
101
+ });
102
+ const prefix = `${projectName}@`;
103
+ for (const line of output.split('\n')) {
104
+ const tag = line.trim();
105
+ if (!tag.startsWith(prefix))
106
+ continue;
107
+ const version = tag.slice(prefix.length);
108
+ if (version && !version.includes('-')) {
109
+ return version;
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"packed-manifest.d.ts","sourceRoot":"","sources":["../../src/monorepo/packed-manifest.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGvD,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAUlC;AAED,wBAAgB,mCAAmC,CACjD,IAAI,EAAE,MAAM,EACZ,aAAa,EAAE,WAAW,EAC1B,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACrC,MAAM,EAAE,CAoCV"}
1
+ {"version":3,"file":"packed-manifest.d.ts","sourceRoot":"","sources":["../../src/monorepo/packed-manifest.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGvD,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAUlC;AAED,wBAAgB,mCAAmC,CACjD,IAAI,EAAE,MAAM,EACZ,aAAa,EAAE,WAAW,EAC1B,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACrC,MAAM,EAAE,CA8CV"}
@@ -1,3 +1,4 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import { $ } from 'bun';
2
3
  import { isRecord, recordProperty } from '../lib/json.js';
3
4
  import { decode } from '../lib/run.js';
@@ -14,7 +15,9 @@ export async function readPackedPackageJson(root, tarball, packageName) {
14
15
  return parsed;
15
16
  }
16
17
  export function validatePackedWorkspaceDependencies(root, sourcePackage, packedPackage) {
17
- const workspaceVersions = new Map(getWorkspacePackages(root).map((pkg) => [pkg.name, pkg.version]));
18
+ const workspacePackages = getWorkspacePackages(root);
19
+ const workspaceVersions = new Map(workspacePackages.map((pkg) => [pkg.name, pkg.version]));
20
+ const projectNameByPackage = new Map(workspacePackages.map((pkg) => [pkg.name, pkg.projectName]));
18
21
  const failures = [];
19
22
  for (const field of workspaceDependencyFields) {
20
23
  const sourceDependencies = recordProperty(sourcePackage.json, field);
@@ -39,8 +42,15 @@ export function validatePackedWorkspaceDependencies(root, sourcePackage, packedP
39
42
  if (sourceRange !== 'workspace:*') {
40
43
  failures.push(`${sourcePackage.path}: source ${field}.${name} must use workspace:*`);
41
44
  }
42
- if (packedRange !== workspaceVersion) {
43
- failures.push(`${sourcePackage.path}: packed ${field}.${name} must be ${workspaceVersion}, got ${formatRange(packedRange)}`);
45
+ // When the workspace dep is at a prerelease version that was never
46
+ // published, the lockfile sync rewrites it to the latest stable tag
47
+ // version. Accept that version as valid.
48
+ const projectName = projectNameByPackage.get(name);
49
+ const expectedVersion = workspaceVersion.includes('-') && projectName
50
+ ? (latestStableTagVersion(root, projectName) ?? workspaceVersion)
51
+ : workspaceVersion;
52
+ if (packedRange !== expectedVersion) {
53
+ failures.push(`${sourcePackage.path}: packed ${field}.${name} must be ${expectedVersion}, got ${formatRange(packedRange)}`);
44
54
  }
45
55
  }
46
56
  }
@@ -49,3 +59,26 @@ export function validatePackedWorkspaceDependencies(root, sourcePackage, packedP
49
59
  function formatRange(value) {
50
60
  return typeof value === 'string' ? value : '<missing>';
51
61
  }
62
+ function latestStableTagVersion(root, projectName) {
63
+ try {
64
+ const output = execSync(`git tag --list '${projectName}@*' --sort=-v:refname`, {
65
+ cwd: root,
66
+ encoding: 'utf8',
67
+ stdio: ['pipe', 'pipe', 'pipe'],
68
+ });
69
+ const prefix = `${projectName}@`;
70
+ for (const line of output.split('\n')) {
71
+ const tag = line.trim();
72
+ if (!tag.startsWith(prefix))
73
+ continue;
74
+ const version = tag.slice(prefix.length);
75
+ if (version && !version.includes('-')) {
76
+ return version;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
@@ -1,2 +1,5 @@
1
1
  export declare function syncRootRuntimeVersions(root: string): Promise<void>;
2
+ type RuntimeTypesPinMode = 'major' | 'exact';
3
+ export declare function runtimeTypesRangeForPublishedVersions(packageName: string, runtimeVersion: string, pinMode: RuntimeTypesPinMode, versions: readonly string[]): string;
4
+ export {};
2
5
  //# sourceMappingURL=runtime.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/monorepo/runtime.ts"],"names":[],"mappings":"AAKA,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BzE"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/monorepo/runtime.ts"],"names":[],"mappings":"AAKA,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqCzE;AAED,KAAK,mBAAmB,GAAG,OAAO,GAAG,OAAO,CAAC;AAgB7C,wBAAgB,qCAAqC,CACnD,WAAW,EAAE,MAAM,EACnB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,MAAM,CAsBR"}
@@ -21,13 +21,77 @@ export async function syncRootRuntimeVersions(root) {
21
21
  changed = setStringProperty(engines, 'node', `>=${nodeMajor}.0.0`) || changed;
22
22
  changed = setStringProperty(packageJson, 'packageManager', `bun@${bunVersion}`) || changed;
23
23
  const devDependencies = getOrCreateRecord(packageJson, 'devDependencies');
24
- changed = setStringProperty(devDependencies, '@types/node', `~${nodeMajor}.0.0`) || changed;
25
- changed = setStringProperty(devDependencies, '@types/bun', bunVersion) || changed;
24
+ changed =
25
+ setStringProperty(devDependencies, '@types/node', await runtimeTypesRange(root, '@types/node', nodeVersion, 'major')) || changed;
26
+ changed =
27
+ setStringProperty(devDependencies, '@types/bun', await runtimeTypesRange(root, '@types/bun', bunVersion, 'exact')) || changed;
26
28
  if (changed) {
27
29
  writeJsonObject(packageJsonPath, packageJson);
28
30
  console.log('updated package.json runtime versions');
29
31
  }
30
32
  }
33
+ async function runtimeTypesRange(root, packageName, runtimeVersion, pinMode) {
34
+ const versionsText = await $ `bun pm view ${packageName} versions --json`.cwd(root).text();
35
+ const versions = JSON.parse(versionsText);
36
+ if (!Array.isArray(versions) || !versions.every((version) => typeof version === 'string')) {
37
+ throw new Error(`Unable to read published ${packageName} versions`);
38
+ }
39
+ return runtimeTypesRangeForPublishedVersions(packageName, runtimeVersion, pinMode, versions);
40
+ }
41
+ export function runtimeTypesRangeForPublishedVersions(packageName, runtimeVersion, pinMode, versions) {
42
+ const parsedRuntimeVersion = parseVersion(runtimeVersion);
43
+ if (!parsedRuntimeVersion) {
44
+ throw new Error(`Unable to parse runtime version ${runtimeVersion}`);
45
+ }
46
+ if (pinMode === 'major') {
47
+ const runtimeMajor = parsedRuntimeVersion[0].toString();
48
+ if (latestVersion(versions.filter((version) => versionMajor(version) === runtimeMajor))) {
49
+ return `~${runtimeMajor}.0.0`;
50
+ }
51
+ }
52
+ else if (versions.includes(runtimeVersion)) {
53
+ return runtimeVersion;
54
+ }
55
+ const latest = latestVersion(versions);
56
+ if (!latest) {
57
+ throw new Error(`Unable to find any published ${packageName} versions`);
58
+ }
59
+ console.warn(`${packageName} has no published types for runtime ${runtimeVersion}; using ${latest}`);
60
+ return pinMode === 'major' ? `~${latest}` : latest;
61
+ }
62
+ function latestVersion(versions) {
63
+ let latest = null;
64
+ for (const version of versions) {
65
+ if (!parseVersion(version)) {
66
+ continue;
67
+ }
68
+ if (!latest || compareVersions(version, latest) > 0) {
69
+ latest = version;
70
+ }
71
+ }
72
+ return latest;
73
+ }
74
+ function compareVersions(left, right) {
75
+ const leftParts = parseVersion(left);
76
+ const rightParts = parseVersion(right);
77
+ if (!leftParts || !rightParts) {
78
+ return 0;
79
+ }
80
+ for (let index = 0; index < leftParts.length; index++) {
81
+ const diff = leftParts[index] - rightParts[index];
82
+ if (diff !== 0) {
83
+ return diff;
84
+ }
85
+ }
86
+ return 0;
87
+ }
88
+ function versionMajor(version) {
89
+ return parseVersion(version)?.[0].toString() ?? null;
90
+ }
91
+ function parseVersion(version) {
92
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
93
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
94
+ }
31
95
  function runtimeCommand(root, name) {
32
96
  const candidates = [
33
97
  join(root, 'tooling', 'direnv', '.devenv', 'profile', 'bin', name),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -45,7 +45,7 @@
45
45
  ],
46
46
  "dependencies": {
47
47
  "@arethetypeswrong/cli": "^0.18.2",
48
- "@smoothbricks/nx-plugin": "0.2.2-next.0",
48
+ "@smoothbricks/nx-plugin": "0.2.3",
49
49
  "@smoothbricks/validation": "0.1.1",
50
50
  "commander": "^14.0.3",
51
51
  "publint": "^0.3.18",
@@ -72,7 +72,7 @@
72
72
  "test": {
73
73
  "executor": "@smoothbricks/nx-plugin:bounded-exec",
74
74
  "options": {
75
- "command": "bun test",
75
+ "command": "bun test --timeout=30000",
76
76
  "cwd": "{projectRoot}",
77
77
  "timeoutMs": 120000,
78
78
  "killAfterMs": 1e4
@@ -1,3 +1,4 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { escapeRegex, getWorkspacePackages } from '../lib/workspace.js';
@@ -25,6 +26,13 @@ export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVe
25
26
  let lockfile = readFileSync(lockfilePath, 'utf8');
26
27
  let updated = 0;
27
28
  for (const pkg of packages) {
29
+ // If the package.json version is a prerelease (e.g. 0.2.2-next.0) it may
30
+ // never have been published. Use the latest stable git tag version so
31
+ // that `bun pm pack` writes an installable dependency range for consumers.
32
+ const targetVersion = pkg.version.includes('-')
33
+ ? (latestStableTagVersion(root, pkg.projectName) ?? pkg.version)
34
+ : pkg.version;
35
+
28
36
  const relativePath = pkg.path.replaceAll('\\', '/');
29
37
  const escaped = escapeRegex(relativePath);
30
38
  const pattern = new RegExp(`("${escaped}":\\s*\\{[^}]*"version":\\s*")([^"]+)(")`);
@@ -36,15 +44,16 @@ export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVe
36
44
  continue;
37
45
  }
38
46
  const lockVersion = match[2];
39
- if (lockVersion === pkg.version) {
47
+ if (lockVersion === targetVersion) {
40
48
  if (log) {
41
- console.log(`ok: ${relativePath} = ${pkg.version}`);
49
+ console.log(`ok: ${relativePath} = ${targetVersion}`);
42
50
  }
43
51
  continue;
44
52
  }
45
- lockfile = lockfile.replace(pattern, `$1${pkg.version}$3`);
53
+ lockfile = lockfile.replace(pattern, `$1${targetVersion}$3`);
46
54
  if (log) {
47
- console.log(`fix: ${relativePath}: ${lockVersion} -> ${pkg.version}`);
55
+ const suffix = targetVersion !== pkg.version ? ` (latest stable tag; package.json has ${pkg.version})` : '';
56
+ console.log(`fix: ${relativePath}: ${lockVersion} -> ${targetVersion}${suffix}`);
48
57
  }
49
58
  updated++;
50
59
  }
@@ -69,6 +78,10 @@ export function validateBunLockfileVersions(root: string): number {
69
78
  const lockfile = readFileSync(lockfilePath, 'utf8');
70
79
  let failures = 0;
71
80
  for (const pkg of packages) {
81
+ const targetVersion = pkg.version.includes('-')
82
+ ? (latestStableTagVersion(root, pkg.projectName) ?? pkg.version)
83
+ : pkg.version;
84
+
72
85
  const relativePath = pkg.path.replaceAll('\\', '/');
73
86
  const escaped = escapeRegex(relativePath);
74
87
  const pattern = new RegExp(`("${escaped}":\\s*\\{[^}]*"version":\\s*")([^"]+)(")`);
@@ -79,8 +92,8 @@ export function validateBunLockfileVersions(root: string): number {
79
92
  continue;
80
93
  }
81
94
  const lockVersion = match[2];
82
- if (lockVersion !== pkg.version) {
83
- console.error(`${relativePath}: bun.lock workspace version must be ${pkg.version}, got ${lockVersion}`);
95
+ if (lockVersion !== targetVersion) {
96
+ console.error(`${relativePath}: bun.lock workspace version must be ${targetVersion}, got ${lockVersion}`);
84
97
  failures++;
85
98
  }
86
99
  }
@@ -89,3 +102,25 @@ export function validateBunLockfileVersions(root: string): number {
89
102
  }
90
103
  return failures;
91
104
  }
105
+
106
+ function latestStableTagVersion(root: string, projectName: string): string | null {
107
+ try {
108
+ const output = execSync(`git tag --list '${projectName}@*' --sort=-v:refname`, {
109
+ cwd: root,
110
+ encoding: 'utf8',
111
+ stdio: ['pipe', 'pipe', 'pipe'],
112
+ });
113
+ const prefix = `${projectName}@`;
114
+ for (const line of output.split('\n')) {
115
+ const tag = line.trim();
116
+ if (!tag.startsWith(prefix)) continue;
117
+ const version = tag.slice(prefix.length);
118
+ if (version && !version.includes('-')) {
119
+ return version;
120
+ }
121
+ }
122
+ return null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
@@ -5,8 +5,12 @@ import { dirname, join } from 'node:path';
5
5
  import {
6
6
  BOUNDED_TEST_EXECUTOR,
7
7
  BOUNDED_TEST_KILL_AFTER_MS,
8
+ BOUNDED_TEST_PER_TEST_TIMEOUT_MS,
8
9
  BOUNDED_TEST_TIMEOUT_MS,
9
10
  } from '@smoothbricks/nx-plugin/bounded-test-policy';
11
+
12
+ const TIMEOUT_FLAG = `--timeout=${BOUNDED_TEST_PER_TEST_TIMEOUT_MS}`;
13
+
10
14
  import {
11
15
  applyFixableMonorepoDefaults,
12
16
  applyNxPluginDefaults,
@@ -595,7 +599,7 @@ describe('workspace package script policy', () => {
595
599
  test: {
596
600
  executor: BOUNDED_TEST_EXECUTOR,
597
601
  options: {
598
- command: 'bun test',
602
+ command: `bun test ${TIMEOUT_FLAG}`,
599
603
  cwd: '{projectRoot}',
600
604
  timeoutMs: BOUNDED_TEST_TIMEOUT_MS,
601
605
  killAfterMs: BOUNDED_TEST_KILL_AFTER_MS,
@@ -825,7 +829,7 @@ describe('workspace package script policy', () => {
825
829
  test: {
826
830
  executor: BOUNDED_TEST_EXECUTOR,
827
831
  options: {
828
- command: 'bun test --pass-with-no-tests',
832
+ command: `bun test ${TIMEOUT_FLAG} --pass-with-no-tests`,
829
833
  cwd: '{projectRoot}',
830
834
  timeoutMs: BOUNDED_TEST_TIMEOUT_MS,
831
835
  killAfterMs: BOUNDED_TEST_KILL_AFTER_MS,
@@ -970,7 +974,7 @@ describe('workspace package script policy', () => {
970
974
  test: {
971
975
  executor: BOUNDED_TEST_EXECUTOR,
972
976
  options: {
973
- command: 'bun test --pass-with-no-tests',
977
+ command: `bun test ${TIMEOUT_FLAG} --pass-with-no-tests`,
974
978
  cwd: '{projectRoot}',
975
979
  timeoutMs: BOUNDED_TEST_TIMEOUT_MS,
976
980
  killAfterMs: BOUNDED_TEST_KILL_AFTER_MS,
@@ -1076,6 +1080,7 @@ function validNxJson(): Record<string, unknown> {
1076
1080
  },
1077
1081
  releaseTag: { pattern: '{projectName}@{version}' },
1078
1082
  changelog: {
1083
+ automaticFromRef: true,
1079
1084
  workspaceChangelog: false,
1080
1085
  projectChangelogs: {
1081
1086
  createRelease: false,
@@ -1,3 +1,4 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import { $ } from 'bun';
2
3
  import { isRecord, recordProperty } from '../lib/json.js';
3
4
  import { decode } from '../lib/run.js';
@@ -25,7 +26,9 @@ export function validatePackedWorkspaceDependencies(
25
26
  sourcePackage: PackageInfo,
26
27
  packedPackage: Record<string, unknown>,
27
28
  ): string[] {
28
- const workspaceVersions = new Map(getWorkspacePackages(root).map((pkg) => [pkg.name, pkg.version]));
29
+ const workspacePackages = getWorkspacePackages(root);
30
+ const workspaceVersions = new Map(workspacePackages.map((pkg) => [pkg.name, pkg.version]));
31
+ const projectNameByPackage = new Map(workspacePackages.map((pkg) => [pkg.name, pkg.projectName]));
29
32
  const failures: string[] = [];
30
33
  for (const field of workspaceDependencyFields) {
31
34
  const sourceDependencies = recordProperty(sourcePackage.json, field);
@@ -52,9 +55,17 @@ export function validatePackedWorkspaceDependencies(
52
55
  if (sourceRange !== 'workspace:*') {
53
56
  failures.push(`${sourcePackage.path}: source ${field}.${name} must use workspace:*`);
54
57
  }
55
- if (packedRange !== workspaceVersion) {
58
+ // When the workspace dep is at a prerelease version that was never
59
+ // published, the lockfile sync rewrites it to the latest stable tag
60
+ // version. Accept that version as valid.
61
+ const projectName = projectNameByPackage.get(name);
62
+ const expectedVersion =
63
+ workspaceVersion.includes('-') && projectName
64
+ ? (latestStableTagVersion(root, projectName) ?? workspaceVersion)
65
+ : workspaceVersion;
66
+ if (packedRange !== expectedVersion) {
56
67
  failures.push(
57
- `${sourcePackage.path}: packed ${field}.${name} must be ${workspaceVersion}, got ${formatRange(packedRange)}`,
68
+ `${sourcePackage.path}: packed ${field}.${name} must be ${expectedVersion}, got ${formatRange(packedRange)}`,
58
69
  );
59
70
  }
60
71
  }
@@ -65,3 +76,25 @@ export function validatePackedWorkspaceDependencies(
65
76
  function formatRange(value: unknown): string {
66
77
  return typeof value === 'string' ? value : '<missing>';
67
78
  }
79
+
80
+ function latestStableTagVersion(root: string, projectName: string): string | null {
81
+ try {
82
+ const output = execSync(`git tag --list '${projectName}@*' --sort=-v:refname`, {
83
+ cwd: root,
84
+ encoding: 'utf8',
85
+ stdio: ['pipe', 'pipe', 'pipe'],
86
+ });
87
+ const prefix = `${projectName}@`;
88
+ for (const line of output.split('\n')) {
89
+ const tag = line.trim();
90
+ if (!tag.startsWith(prefix)) continue;
91
+ const version = tag.slice(prefix.length);
92
+ if (version && !version.includes('-')) {
93
+ return version;
94
+ }
95
+ }
96
+ return null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { runtimeTypesRangeForPublishedVersions } from './runtime.js';
3
+
4
+ describe('runtimeTypesRangeForPublishedVersions', () => {
5
+ test('uses the installed Node major when @types/node has published it', () => {
6
+ expect(
7
+ runtimeTypesRangeForPublishedVersions('@types/node', '24.12.0', 'major', ['24.0.0', '24.12.4', '25.9.1']),
8
+ ).toBe('~24.0.0');
9
+ });
10
+
11
+ test('falls back to latest published @types/node when the Node major is unpublished', () => {
12
+ expect(
13
+ runtimeTypesRangeForPublishedVersions('@types/node', '26.0.0', 'major', ['24.12.4', '25.9.0', '25.9.1']),
14
+ ).toBe('~25.9.1');
15
+ });
16
+
17
+ test('uses the installed Bun version when @types/bun has published it', () => {
18
+ expect(runtimeTypesRangeForPublishedVersions('@types/bun', '1.3.14', 'exact', ['1.3.13', '1.3.14'])).toBe('1.3.14');
19
+ });
20
+
21
+ test('falls back to latest published @types/bun when the Bun version is unpublished', () => {
22
+ expect(runtimeTypesRangeForPublishedVersions('@types/bun', '1.3.15', 'exact', ['1.3.13', '1.3.14'])).toBe('1.3.14');
23
+ });
24
+
25
+ test('ignores non-stable version strings when choosing the fallback', () => {
26
+ expect(runtimeTypesRangeForPublishedVersions('@types/node', '26.0.0', 'major', ['25.9.1', '26.0.0-beta.1'])).toBe(
27
+ '~25.9.1',
28
+ );
29
+ });
30
+ });
@@ -23,8 +23,18 @@ export async function syncRootRuntimeVersions(root: string): Promise<void> {
23
23
  changed = setStringProperty(engines, 'node', `>=${nodeMajor}.0.0`) || changed;
24
24
  changed = setStringProperty(packageJson, 'packageManager', `bun@${bunVersion}`) || changed;
25
25
  const devDependencies = getOrCreateRecord(packageJson, 'devDependencies');
26
- changed = setStringProperty(devDependencies, '@types/node', `~${nodeMajor}.0.0`) || changed;
27
- changed = setStringProperty(devDependencies, '@types/bun', bunVersion) || changed;
26
+ changed =
27
+ setStringProperty(
28
+ devDependencies,
29
+ '@types/node',
30
+ await runtimeTypesRange(root, '@types/node', nodeVersion, 'major'),
31
+ ) || changed;
32
+ changed =
33
+ setStringProperty(
34
+ devDependencies,
35
+ '@types/bun',
36
+ await runtimeTypesRange(root, '@types/bun', bunVersion, 'exact'),
37
+ ) || changed;
28
38
 
29
39
  if (changed) {
30
40
  writeJsonObject(packageJsonPath, packageJson);
@@ -32,6 +42,88 @@ export async function syncRootRuntimeVersions(root: string): Promise<void> {
32
42
  }
33
43
  }
34
44
 
45
+ type RuntimeTypesPinMode = 'major' | 'exact';
46
+
47
+ async function runtimeTypesRange(
48
+ root: string,
49
+ packageName: string,
50
+ runtimeVersion: string,
51
+ pinMode: RuntimeTypesPinMode,
52
+ ): Promise<string> {
53
+ const versionsText = await $`bun pm view ${packageName} versions --json`.cwd(root).text();
54
+ const versions = JSON.parse(versionsText) as unknown;
55
+ if (!Array.isArray(versions) || !versions.every((version) => typeof version === 'string')) {
56
+ throw new Error(`Unable to read published ${packageName} versions`);
57
+ }
58
+ return runtimeTypesRangeForPublishedVersions(packageName, runtimeVersion, pinMode, versions);
59
+ }
60
+
61
+ export function runtimeTypesRangeForPublishedVersions(
62
+ packageName: string,
63
+ runtimeVersion: string,
64
+ pinMode: RuntimeTypesPinMode,
65
+ versions: readonly string[],
66
+ ): string {
67
+ const parsedRuntimeVersion = parseVersion(runtimeVersion);
68
+ if (!parsedRuntimeVersion) {
69
+ throw new Error(`Unable to parse runtime version ${runtimeVersion}`);
70
+ }
71
+
72
+ if (pinMode === 'major') {
73
+ const runtimeMajor = parsedRuntimeVersion[0].toString();
74
+ if (latestVersion(versions.filter((version) => versionMajor(version) === runtimeMajor))) {
75
+ return `~${runtimeMajor}.0.0`;
76
+ }
77
+ } else if (versions.includes(runtimeVersion)) {
78
+ return runtimeVersion;
79
+ }
80
+
81
+ const latest = latestVersion(versions);
82
+ if (!latest) {
83
+ throw new Error(`Unable to find any published ${packageName} versions`);
84
+ }
85
+
86
+ console.warn(`${packageName} has no published types for runtime ${runtimeVersion}; using ${latest}`);
87
+ return pinMode === 'major' ? `~${latest}` : latest;
88
+ }
89
+
90
+ function latestVersion(versions: readonly string[]): string | null {
91
+ let latest: string | null = null;
92
+ for (const version of versions) {
93
+ if (!parseVersion(version)) {
94
+ continue;
95
+ }
96
+ if (!latest || compareVersions(version, latest) > 0) {
97
+ latest = version;
98
+ }
99
+ }
100
+ return latest;
101
+ }
102
+
103
+ function compareVersions(left: string, right: string): number {
104
+ const leftParts = parseVersion(left);
105
+ const rightParts = parseVersion(right);
106
+ if (!leftParts || !rightParts) {
107
+ return 0;
108
+ }
109
+ for (let index = 0; index < leftParts.length; index++) {
110
+ const diff = leftParts[index] - rightParts[index];
111
+ if (diff !== 0) {
112
+ return diff;
113
+ }
114
+ }
115
+ return 0;
116
+ }
117
+
118
+ function versionMajor(version: string): string | null {
119
+ return parseVersion(version)?.[0].toString() ?? null;
120
+ }
121
+
122
+ function parseVersion(version: string): [number, number, number] | null {
123
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
124
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
125
+ }
126
+
35
127
  function runtimeCommand(root: string, name: 'bun' | 'node'): string {
36
128
  const candidates = [
37
129
  join(root, 'tooling', 'direnv', '.devenv', 'profile', 'bin', name),
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { $ } from 'bun';
5
4
  import { collectOwnedReleaseTagRecords, pendingReleaseTargets, type ReleasePackageInfo, releaseTag } from '../core.js';
6
5
  import { completeReleaseAtHead, type ReleaseRepairShell, repairPendingTargets } from '../orchestration.js';
7
6
  import {
@@ -9,7 +8,9 @@ import {
9
8
  gitIsAncestor,
10
9
  gitOutput,
11
10
  gitReleaseTagsByCreatorDate,
11
+ gitSucceeds,
12
12
  packageVersionAtRef,
13
+ runFixtureNx,
13
14
  tag,
14
15
  withFixtureRepo,
15
16
  writeBuildablePackage,
@@ -78,7 +79,7 @@ describe('release planning with fixture git repositories', () => {
78
79
  await git(root, ['init', '--bare', 'remote.git']);
79
80
  await git(root, ['remote', 'add', 'origin', join(root, 'remote.git')]);
80
81
  await git(root, ['push', 'origin', 'main', '--tags']);
81
- await $`git clone --branch main ${join(root, 'remote.git')} checkout`.cwd(root).quiet();
82
+ await git(root, ['clone', '--branch', 'main', join(root, 'remote.git'), 'checkout']);
82
83
  const checkout = join(root, 'checkout');
83
84
  await git(checkout, ['fetch', '--tags', 'origin', 'main']);
84
85
  const head = await gitOutput(checkout, ['rev-parse', 'HEAD']);
@@ -105,7 +106,7 @@ describe('release planning with fixture git repositories', () => {
105
106
  await writeBuildablePackage(root, '@scope/a', 'packages/a');
106
107
  await writeBuildablePackage(root, '@scope/b', 'packages/b');
107
108
 
108
- await $`nx run-many -t build --projects=${'a,b'}`.cwd(root).quiet();
109
+ await runFixtureNx(root, ['run-many', '-t', 'build', '--projects=a,b']);
109
110
 
110
111
  await expect(readFile(join(root, 'packages/a/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
111
112
  await expect(readFile(join(root, 'packages/b/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
@@ -143,7 +144,7 @@ describe('release planning with fixture git repositories', () => {
143
144
  await git(author, ['init', '--bare', 'remote.git']);
144
145
  await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
145
146
  await git(author, ['push', 'origin', 'main', '--tags']);
146
- await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
147
+ await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'runner']);
147
148
  const runner = join(author, 'runner');
148
149
  await git(runner, ['config', 'user.name', 'Test User']);
149
150
  await git(runner, ['config', 'user.email', 'test@example.com']);
@@ -197,7 +198,7 @@ describe('release planning with fixture git repositories', () => {
197
198
  await git(author, ['init', '--bare', 'remote.git']);
198
199
  await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
199
200
  await git(author, ['push', 'origin', 'main']);
200
- await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
201
+ await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'runner']);
201
202
  const runner = join(author, 'runner');
202
203
  await git(runner, ['config', 'user.name', 'Test User']);
203
204
  await git(runner, ['config', 'user.email', 'test@example.com']);
@@ -213,7 +214,7 @@ describe('release planning with fixture git repositories', () => {
213
214
 
214
215
  expect(summary.pushed).toBe(true);
215
216
  expect(shell.pushes).toEqual([['pushed@1.0.0']]);
216
- await $`git clone --branch main ${join(author, 'remote.git')} auditor`.cwd(author).quiet();
217
+ await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'auditor']);
217
218
  const auditor = join(author, 'auditor');
218
219
  await git(auditor, ['fetch', '--tags', 'origin', 'main']);
219
220
  await expect(gitOutput(auditor, ['rev-parse', 'refs/tags/pushed@1.0.0^{}'])).resolves.toBe(
@@ -237,7 +238,7 @@ describe('release planning with fixture git repositories', () => {
237
238
  await git(author, ['init', '--bare', 'remote.git']);
238
239
  await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
239
240
  await git(author, ['push', 'origin', 'main', '--tags']);
240
- await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
241
+ await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'runner']);
241
242
  const runner = join(author, 'runner');
242
243
  await git(runner, ['config', 'user.name', 'Test User']);
243
244
  await git(runner, ['config', 'user.email', 'test@example.com']);
@@ -323,7 +324,12 @@ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
323
324
 
324
325
  async buildReleaseCandidate(packages: ReleasePackageInfo[]): Promise<void> {
325
326
  this.builds.push(packages.map((pkg) => pkg.name));
326
- await $`nx run-many -t build --projects=${packages.map((pkg) => pkg.projectName).join(',')}`.cwd(this.root).quiet();
327
+ await runFixtureNx(this.root, [
328
+ 'run-many',
329
+ '-t',
330
+ 'build',
331
+ `--projects=${packages.map((pkg) => pkg.projectName).join(',')}`,
332
+ ]);
327
333
  }
328
334
 
329
335
  async publishPackage(pkg: ReleasePackageInfo, distTag: string, dryRun: boolean): Promise<void> {
@@ -357,8 +363,3 @@ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
357
363
  await git(this.root, ['tag', '-a', tagName, '-m', tagName, 'HEAD']);
358
364
  }
359
365
  }
360
-
361
- async function gitSucceeds(root: string, args: string[]): Promise<boolean> {
362
- const result = await $`git ${args}`.cwd(root).quiet().nothrow();
363
- return result.exitCode === 0;
364
- }
@@ -4,6 +4,8 @@ import { join } from 'node:path';
4
4
  import { $ } from 'bun';
5
5
  import type { GitReleaseTagInfo } from '../../core.js';
6
6
 
7
+ const GIT_TIMEOUT_MS = 10_000;
8
+
7
9
  export async function withFixtureRepo(fn: (root: string) => Promise<void>): Promise<void> {
8
10
  const root = await mkdtemp(join(tmpdir(), 'smoo-release-test-'));
9
11
  try {
@@ -63,26 +65,98 @@ export async function writeBuildablePackage(
63
65
  }
64
66
 
65
67
  export async function git(root: string, args: string[], env?: Record<string, string>): Promise<void> {
66
- await $`git ${args}`
67
- .cwd(root)
68
- .env(env ?? {})
69
- .quiet();
68
+ const result = await gitResult(root, args, env);
69
+ if (result.exitCode !== 0) {
70
+ throw new Error(gitErrorMessage(root, args, result));
71
+ }
70
72
  }
71
73
 
72
74
  export async function gitOutput(root: string, args: string[]): Promise<string> {
73
- const result = await $`git ${args}`.cwd(root).quiet();
75
+ const result = await gitResult(root, args);
76
+ if (result.exitCode !== 0) {
77
+ throw new Error(gitErrorMessage(root, args, result));
78
+ }
74
79
  return new TextDecoder().decode(result.stdout).trim();
75
80
  }
76
81
 
82
+ export async function gitSucceeds(root: string, args: string[]): Promise<boolean> {
83
+ return (await gitResult(root, args)).exitCode === 0;
84
+ }
85
+
86
+ async function gitResult(root: string, args: string[], env?: Record<string, string>): Promise<GitResult> {
87
+ const proc = Bun.spawn(['git', ...args], {
88
+ cwd: root,
89
+ env: { ...process.env, ...env },
90
+ stdout: 'pipe',
91
+ stderr: 'pipe',
92
+ });
93
+ let timedOut = false;
94
+ const timeout = setTimeout(() => {
95
+ timedOut = true;
96
+ proc.kill();
97
+ }, GIT_TIMEOUT_MS);
98
+ try {
99
+ const [exitCode, stdout, stderr] = await Promise.all([
100
+ proc.exited,
101
+ streamBytes(proc.stdout),
102
+ streamBytes(proc.stderr),
103
+ ]);
104
+ return { exitCode, stdout, stderr, timedOut };
105
+ } finally {
106
+ clearTimeout(timeout);
107
+ }
108
+ }
109
+
110
+ async function streamBytes(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
111
+ return new Uint8Array(await new Response(stream).arrayBuffer());
112
+ }
113
+
114
+ export async function runFixtureNx(root: string, args: string[]): Promise<void> {
115
+ await $`nx ${args}`
116
+ .cwd(root)
117
+ .env({ ...definedProcessEnv(), NX_DAEMON: 'false' })
118
+ .quiet();
119
+ }
120
+
121
+ function definedProcessEnv(): Record<string, string> {
122
+ const env: Record<string, string> = {};
123
+ for (const [key, value] of Object.entries(process.env)) {
124
+ if (value !== undefined) {
125
+ env[key] = value;
126
+ }
127
+ }
128
+ return env;
129
+ }
130
+
131
+ interface GitResult {
132
+ exitCode: number;
133
+ stdout: Uint8Array;
134
+ stderr: Uint8Array;
135
+ timedOut: boolean;
136
+ }
137
+
138
+ function gitErrorMessage(root: string, args: string[], result: GitResult): string {
139
+ const stderr = new TextDecoder().decode(result.stderr).trim();
140
+ const timeoutText = result.timedOut
141
+ ? ` timed out after ${GIT_TIMEOUT_MS}ms`
142
+ : ` failed with exit code ${result.exitCode}`;
143
+ return [`git ${args.join(' ')}${timeoutText}`, `cwd: ${root}`, stderr].filter(Boolean).join('\n');
144
+ }
145
+
77
146
  export async function tag(root: string, tagName: string, date: string): Promise<void> {
78
147
  await git(root, ['tag', '-a', tagName, '-m', tagName], { GIT_COMMITTER_DATE: date });
79
148
  }
80
149
 
81
150
  export async function gitReleaseTagsByCreatorDate(root: string): Promise<GitReleaseTagInfo[]> {
82
- const result =
83
- await $`git for-each-ref --sort=-creatordate --format=${'%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)'} refs/tags`
84
- .cwd(root)
85
- .quiet();
151
+ const result = await gitResult(root, [
152
+ 'for-each-ref',
153
+ '--sort=-creatordate',
154
+ '--format=%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)',
155
+ 'refs/tags',
156
+ ]);
157
+ if (result.exitCode !== 0) {
158
+ throw new Error(gitErrorMessage(root, ['for-each-ref', 'refs/tags'], result));
159
+ }
86
160
  return new TextDecoder()
87
161
  .decode(result.stdout)
88
162
  .split('\n')
@@ -100,12 +174,11 @@ export async function gitReleaseTagsByCreatorDate(root: string): Promise<GitRele
100
174
  }
101
175
 
102
176
  export async function gitIsAncestor(root: string, ancestor: string, descendant: string): Promise<boolean> {
103
- const result = await $`git merge-base --is-ancestor ${ancestor} ${descendant}`.cwd(root).quiet().nothrow();
104
- return result.exitCode === 0;
177
+ return gitSucceeds(root, ['merge-base', '--is-ancestor', ancestor, descendant]);
105
178
  }
106
179
 
107
180
  export async function packageVersionAtRef(root: string, packagePath: string, ref: string): Promise<string | null> {
108
- const result = await $`git show ${`${ref}:${packagePath}/package.json`}`.cwd(root).quiet().nothrow();
181
+ const result = await gitResult(root, ['show', `${ref}:${packagePath}/package.json`]);
109
182
  if (result.exitCode !== 0) {
110
183
  return null;
111
184
  }