@smoothbricks/cli 0.10.3 → 0.10.5

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 (115) hide show
  1. package/README.md +9 -13
  2. package/dist/bun/preload.d.ts +13 -0
  3. package/dist/bun/preload.d.ts.map +1 -0
  4. package/dist/bun/preload.js +12 -0
  5. package/dist/cli.js +13 -5
  6. package/dist/generate/index.d.ts.map +1 -1
  7. package/dist/generate/index.js +0 -6
  8. package/dist/github-ci/index.d.ts.map +1 -1
  9. package/dist/github-ci/index.js +52 -16
  10. package/dist/github-ci/outputs.d.ts.map +1 -1
  11. package/dist/github-ci/outputs.js +1 -2
  12. package/dist/lib/cli-package.js +2 -2
  13. package/dist/lib/json.d.ts +120 -11
  14. package/dist/lib/json.d.ts.map +1 -1
  15. package/dist/lib/json.js +319 -29
  16. package/dist/lib/workspace.d.ts +7 -14
  17. package/dist/lib/workspace.d.ts.map +1 -1
  18. package/dist/lib/workspace.js +76 -82
  19. package/dist/monorepo/ci-workflow.d.ts +2 -0
  20. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  21. package/dist/monorepo/ci-workflow.js +27 -1
  22. package/dist/monorepo/git-config.js +2 -2
  23. package/dist/monorepo/index.d.ts +4 -2
  24. package/dist/monorepo/index.d.ts.map +1 -1
  25. package/dist/monorepo/index.js +19 -3
  26. package/dist/monorepo/lockfile.d.ts +14 -1
  27. package/dist/monorepo/lockfile.d.ts.map +1 -1
  28. package/dist/monorepo/lockfile.js +33 -21
  29. package/dist/monorepo/managed-files.d.ts +11 -0
  30. package/dist/monorepo/managed-files.d.ts.map +1 -1
  31. package/dist/monorepo/managed-files.js +57 -32
  32. package/dist/monorepo/package-policy.d.ts.map +1 -1
  33. package/dist/monorepo/package-policy.js +52 -45
  34. package/dist/monorepo/packed-manifest.d.ts +5 -2
  35. package/dist/monorepo/packed-manifest.d.ts.map +1 -1
  36. package/dist/monorepo/packed-manifest.js +16 -47
  37. package/dist/monorepo/packed-package.d.ts.map +1 -1
  38. package/dist/monorepo/packed-package.js +28 -16
  39. package/dist/monorepo/packs/index.d.ts.map +1 -1
  40. package/dist/monorepo/packs/index.js +14 -5
  41. package/dist/monorepo/publish-workflow.js +3 -1
  42. package/dist/monorepo/runtime.d.ts +15 -0
  43. package/dist/monorepo/runtime.d.ts.map +1 -1
  44. package/dist/monorepo/runtime.js +66 -12
  45. package/dist/monorepo/tool-validation.d.ts +2 -2
  46. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  47. package/dist/monorepo/tool-validation.js +141 -38
  48. package/dist/monorepo/wrangler.d.ts.map +1 -1
  49. package/dist/monorepo/wrangler.js +13 -13
  50. package/dist/nx/index.d.ts +9 -7
  51. package/dist/nx/index.d.ts.map +1 -1
  52. package/dist/nx/index.js +48 -31
  53. package/dist/pr/index.d.ts.map +1 -1
  54. package/dist/pr/index.js +28 -32
  55. package/dist/release/candidates.d.ts +3 -2
  56. package/dist/release/candidates.d.ts.map +1 -1
  57. package/dist/release/candidates.js +4 -5
  58. package/dist/release/index.d.ts +1 -0
  59. package/dist/release/index.d.ts.map +1 -1
  60. package/dist/release/index.js +88 -59
  61. package/dist/wrangler/prepare-env.d.ts +0 -1
  62. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  63. package/dist/wrangler/prepare-env.js +100 -51
  64. package/dist/wrangler/scaffold.d.ts.map +1 -1
  65. package/dist/wrangler/scaffold.js +12 -12
  66. package/managed/raw/envrc +5 -3
  67. package/managed/raw/git-format-staged.yml +19 -1
  68. package/managed/raw/patches/ttsc@0.19.3.patch +67 -0
  69. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +2 -1
  70. package/managed/raw/tooling/direnv/merge-newer-pins.sh +3 -3
  71. package/managed/raw/tooling/direnv/setup-environment.ts +170 -19
  72. package/managed/raw/tooling/git-hooks/pre-commit.sh +0 -6
  73. package/managed/templates/github/actions/cache-node-modules/action.yml +5 -2
  74. package/managed/templates/github/actions/cache-ttsc-plugins/action.yml +19 -0
  75. package/managed/templates/github/actions/setup-devenv/action.yml +5 -0
  76. package/managed/templates/github/workflows/managed-files.yml +80 -0
  77. package/package.json +15 -4
  78. package/src/bun/preload.ts +12 -0
  79. package/src/cli.ts +20 -8
  80. package/src/generate/index.ts +0 -6
  81. package/src/github-ci/index.ts +49 -18
  82. package/src/github-ci/outputs.ts +1 -2
  83. package/src/lib/cli-package.ts +2 -2
  84. package/src/lib/json.ts +293 -32
  85. package/src/lib/workspace.ts +88 -100
  86. package/src/monorepo/__tests__/ci-workflow.test.ts +3 -3
  87. package/src/monorepo/ci-workflow.ts +27 -1
  88. package/src/monorepo/git-config.ts +2 -2
  89. package/src/monorepo/index.ts +19 -3
  90. package/src/monorepo/lockfile.test.ts +35 -36
  91. package/src/monorepo/lockfile.ts +49 -24
  92. package/src/monorepo/managed-files.test.ts +1 -0
  93. package/src/monorepo/managed-files.ts +62 -38
  94. package/src/monorepo/package-policy.test.ts +20 -50
  95. package/src/monorepo/package-policy.ts +76 -59
  96. package/src/monorepo/packed-manifest.ts +18 -52
  97. package/src/monorepo/packed-package.ts +43 -22
  98. package/src/monorepo/packs/index.ts +14 -5
  99. package/src/monorepo/publish-workflow.ts +3 -1
  100. package/src/monorepo/runtime.test.ts +42 -3
  101. package/src/monorepo/runtime.ts +90 -12
  102. package/src/monorepo/tool-validation.test.ts +38 -16
  103. package/src/monorepo/tool-validation.ts +149 -41
  104. package/src/monorepo/wrangler.test.ts +11 -15
  105. package/src/monorepo/wrangler.ts +15 -15
  106. package/src/nx/index.test.ts +9 -5
  107. package/src/nx/index.ts +69 -42
  108. package/src/pr/index.ts +42 -36
  109. package/src/release/__tests__/candidates.test.ts +5 -4
  110. package/src/release/__tests__/trust-publisher.test.ts +7 -0
  111. package/src/release/candidates.ts +10 -17
  112. package/src/release/index.ts +110 -73
  113. package/src/wrangler/prepare-env.ts +75 -54
  114. package/src/wrangler/scaffold.test.ts +9 -12
  115. package/src/wrangler/scaffold.ts +13 -13
@@ -1,7 +1,16 @@
1
1
  import { readdirSync, statSync } from 'node:fs';
2
2
  import { dirname, join, relative } from 'node:path';
3
3
  import { isSmoothBricksCodebasePackageName } from './cli-package.js';
4
- import { hasOwn, hasOwnString, isRecord, readJson, readJsonObject, stringProperty } from './json.js';
4
+ import {
5
+ isPackageJson,
6
+ type PackageJson,
7
+ type PackageNxConfig,
8
+ type PackageRepository,
9
+ readJson,
10
+ readJsonObject,
11
+ } from './json.js';
12
+
13
+ export type { PackageJson, PackageNxConfig, PackageRepository };
5
14
 
6
15
  export interface PackageInfo {
7
16
  name: string;
@@ -11,7 +20,7 @@ export interface PackageInfo {
11
20
  tags: string[];
12
21
  path: string;
13
22
  packageJsonPath: string;
14
- json: Record<string, unknown>;
23
+ json: PackageJson;
15
24
  }
16
25
 
17
26
  export interface WorkspacePackageManifest {
@@ -20,18 +29,7 @@ export interface WorkspacePackageManifest {
20
29
  private: boolean;
21
30
  path: string;
22
31
  packageJsonPath: string;
23
- json: Record<string, unknown>;
24
- }
25
-
26
- export interface PackageJson extends Record<string, unknown> {
27
- name?: string;
28
- version?: string;
29
- private?: boolean;
30
- workspaces?: unknown;
31
- devDependencies?: unknown;
32
- dependencies?: unknown;
33
- nx?: unknown;
34
- repository?: unknown;
32
+ json: PackageJson;
35
33
  }
36
34
 
37
35
  export interface RepositoryInfo {
@@ -46,6 +44,8 @@ export const workspaceDependencyFields = [
46
44
  'optionalDependencies',
47
45
  ] as const;
48
46
 
47
+ export type WorkspaceDependencyField = (typeof workspaceDependencyFields)[number];
48
+
49
49
  export function listPublicPackages(root: string): PackageInfo[] {
50
50
  return getWorkspacePackages(root).filter((pkg) => !pkg.private && pkg.tags.includes('npm:public'));
51
51
  }
@@ -54,26 +54,21 @@ export function listReleasePackages(
54
54
  root: string,
55
55
  rootPackage = readPackageJson(join(root, 'package.json')),
56
56
  ): PackageInfo[] {
57
- if (!rootPackage) {
58
- return [];
59
- }
60
- const rootRepository = repositoryInfo(rootPackage.json);
57
+ const rootRepository = rootPackage ? repositoryInfo(rootPackage.json) : null;
61
58
  if (!rootRepository) {
62
59
  return [];
63
60
  }
64
- return getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json)).filter(
65
- (pkg) => !pkg.private && pkg.tags.includes('npm:public') && isOwnedPackage(rootRepository, pkg),
66
- );
61
+ return listPublicPackages(root).filter((pkg) => isOwnedPackage(rootRepository, pkg));
67
62
  }
68
63
 
69
64
  export function rootRepositoryInfo(root: string): RepositoryInfo | null {
70
- const rootPackage = readJsonObject(join(root, 'package.json'));
71
- return rootPackage ? repositoryInfo(rootPackage) : null;
65
+ const rootPackage = readPackageJson(join(root, 'package.json'));
66
+ return rootPackage ? repositoryInfo(rootPackage.json) : null;
72
67
  }
73
68
 
74
69
  export function rootPackageName(root: string): string | null {
75
70
  const rootPackage = readPackageJsonObject(join(root, 'package.json'));
76
- return rootPackage ? stringProperty(rootPackage, 'name') : null;
71
+ return rootPackage?.name ?? null;
77
72
  }
78
73
 
79
74
  export function isSmoothBricksCodebase(root: string): boolean {
@@ -90,37 +85,23 @@ export function isOwnedPackage(rootRepository: RepositoryInfo, pkg: PackageInfo)
90
85
  }
91
86
 
92
87
  export function getWorkspacePackages(root: string): PackageInfo[] {
93
- const rootPackage = readPackageJson(join(root, 'package.json'));
94
- if (!rootPackage) {
95
- throw new Error('package.json not found or invalid');
96
- }
97
- return getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json));
88
+ return getWorkspacePackagesForPatterns(root, getWorkspacePatterns(root));
98
89
  }
99
90
 
100
91
  export function getWorkspacePackageManifests(root: string): WorkspacePackageManifest[] {
101
- const rootPackage = readPackageJsonObject(join(root, 'package.json'));
102
- if (!rootPackage) {
103
- throw new Error('package.json not found or invalid');
104
- }
105
- return getWorkspacePackageManifestsForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage));
92
+ return getWorkspacePackageManifestsForPatterns(root, getWorkspacePatterns(root));
106
93
  }
107
94
 
108
95
  function getWorkspacePackagesForPatterns(root: string, workspacePatterns: string[]): PackageInfo[] {
109
96
  const packages: PackageInfo[] = [];
110
- for (const pkgPath of listWorkspacePackageJsonPaths(root, workspacePatterns)) {
111
- const pkg = readPackageJson(pkgPath);
112
- if (!pkg?.name || !pkg.version) {
97
+ for (const path of listWorkspacePackageJsonPaths(root, workspacePatterns)) {
98
+ const pkg = readPackageJson(path);
99
+ if (!pkg) {
113
100
  continue;
114
101
  }
115
102
  packages.push({
116
- name: pkg.name,
117
- projectName: pkg.projectName,
118
- version: pkg.version,
119
- private: pkg.private,
120
- tags: pkg.tags,
121
- path: relative(root, dirname(pkgPath)),
122
- packageJsonPath: pkg.packageJsonPath,
123
- json: pkg.json,
103
+ ...pkg,
104
+ path: relative(root, dirname(path)),
124
105
  });
125
106
  }
126
107
  return packages.sort((a, b) => a.name.localeCompare(b.name));
@@ -130,30 +111,42 @@ function getWorkspacePackageManifestsForPatterns(
130
111
  root: string,
131
112
  workspacePatterns: string[],
132
113
  ): WorkspacePackageManifest[] {
133
- const manifests: WorkspacePackageManifest[] = [];
134
- for (const pkgPath of listWorkspacePackageJsonPaths(root, workspacePatterns)) {
135
- const pkg = readWorkspacePackageManifest(pkgPath);
136
- if (pkg) {
137
- manifests.push(pkg);
114
+ const packages: WorkspacePackageManifest[] = [];
115
+ for (const path of listWorkspacePackageJsonPaths(root, workspacePatterns)) {
116
+ const pkg = readWorkspacePackageManifest(path);
117
+ if (!pkg) {
118
+ continue;
138
119
  }
120
+ packages.push({
121
+ ...pkg,
122
+ path: relative(root, dirname(path)),
123
+ });
139
124
  }
140
- return manifests.sort((a, b) => a.name.localeCompare(b.name));
125
+ return packages.sort((a, b) => a.name.localeCompare(b.name));
141
126
  }
142
127
 
143
128
  function listWorkspacePackageJsonPaths(root: string, workspacePatterns: string[]): string[] {
144
129
  const paths: string[] = [];
145
130
  for (const pattern of workspacePatterns) {
146
131
  if (!pattern.endsWith('/*')) {
147
- paths.push(join(root, pattern, 'package.json'));
148
132
  continue;
149
133
  }
150
-
151
134
  const parent = join(root, pattern.slice(0, -2));
152
- if (!statSync(parent, { throwIfNoEntry: false })?.isDirectory()) {
135
+ let entries: string[];
136
+ try {
137
+ entries = readdirSync(parent);
138
+ } catch {
153
139
  continue;
154
140
  }
155
- for (const entry of readdirSync(parent)) {
156
- paths.push(join(parent, entry, 'package.json'));
141
+ for (const entry of entries) {
142
+ const packageJsonPath = join(parent, entry, 'package.json');
143
+ try {
144
+ if (statSync(packageJsonPath).isFile()) {
145
+ paths.push(packageJsonPath);
146
+ }
147
+ } catch {
148
+ // skip missing package.json
149
+ }
157
150
  }
158
151
  }
159
152
  return paths;
@@ -162,7 +155,7 @@ function listWorkspacePackageJsonPaths(root: string, workspacePatterns: string[]
162
155
  export function listPackageJsonRecords(root: string): PackageInfo[] {
163
156
  const rootPackage = readPackageJson(join(root, 'package.json'));
164
157
  if (!rootPackage) {
165
- throw new Error('package.json not found or invalid');
158
+ return getWorkspacePackages(root);
166
159
  }
167
160
  return [
168
161
  { ...rootPackage, path: '.' },
@@ -172,18 +165,16 @@ export function listPackageJsonRecords(root: string): PackageInfo[] {
172
165
 
173
166
  export function getWorkspacePatterns(root: string): string[] {
174
167
  const raw = readJson(join(root, 'package.json'));
175
- return isRecord(raw) ? getWorkspacePatternsFromPackageJson(raw) : ['packages/*'];
168
+ const pkg = isPackageJson(raw) ? raw : null;
169
+ return pkg ? getWorkspacePatternsFromPackageJson(pkg) : ['packages/*'];
176
170
  }
177
171
 
178
- function getWorkspacePatternsFromPackageJson(pkg: Record<string, unknown>): string[] {
179
- if (!hasOwn(pkg, 'workspaces')) {
180
- return ['packages/*'];
181
- }
172
+ function getWorkspacePatternsFromPackageJson(pkg: PackageJson): string[] {
182
173
  const workspaces = pkg.workspaces;
183
174
  if (Array.isArray(workspaces)) {
184
175
  return workspaces.filter((entry): entry is string => typeof entry === 'string');
185
176
  }
186
- if (isRecord(workspaces) && hasOwn(workspaces, 'packages') && Array.isArray(workspaces.packages)) {
177
+ if (workspaces && typeof workspaces === 'object' && Array.isArray(workspaces.packages)) {
187
178
  return workspaces.packages.filter((entry): entry is string => typeof entry === 'string');
188
179
  }
189
180
  return ['packages/*'];
@@ -191,17 +182,15 @@ function getWorkspacePatternsFromPackageJson(pkg: Record<string, unknown>): stri
191
182
 
192
183
  export function readPackageJson(path: string): PackageInfo | null {
193
184
  const parsed = readPackageJsonObject(path);
194
- if (!isRecord(parsed) || !hasOwnString(parsed, 'name') || !hasOwnString(parsed, 'version')) {
185
+ if (!parsed?.name || !parsed.version) {
195
186
  return null;
196
187
  }
197
- const privateValue = hasOwn(parsed, 'private') && typeof parsed.private === 'boolean' ? parsed.private : false;
198
- const tags = getNxTags(parsed);
199
188
  return {
200
189
  name: parsed.name,
201
190
  projectName: packageNxProjectName(parsed),
202
191
  version: parsed.version,
203
- private: privateValue,
204
- tags,
192
+ private: parsed.private === true,
193
+ tags: getNxTags(parsed),
205
194
  path: dirname(path),
206
195
  packageJsonPath: path,
207
196
  json: parsed,
@@ -210,50 +199,48 @@ export function readPackageJson(path: string): PackageInfo | null {
210
199
 
211
200
  export function readWorkspacePackageManifest(path: string): WorkspacePackageManifest | null {
212
201
  const parsed = readPackageJsonObject(path);
213
- if (!isRecord(parsed) || !hasOwnString(parsed, 'name')) {
202
+ if (!parsed?.name) {
214
203
  return null;
215
204
  }
216
- const privateValue = hasOwn(parsed, 'private') && typeof parsed.private === 'boolean' ? parsed.private : false;
217
205
  return {
218
206
  name: parsed.name,
219
207
  projectName: packageNxProjectName(parsed),
220
- private: privateValue,
208
+ private: parsed.private === true,
221
209
  path: dirname(path),
222
210
  packageJsonPath: path,
223
211
  json: parsed,
224
212
  };
225
213
  }
226
214
 
227
- function packageNxProjectName(pkg: Record<string, unknown>): string {
228
- const nx = isRecord(pkg.nx) ? pkg.nx : null;
229
- return (nx ? stringProperty(nx, 'name') : null) ?? stringProperty(pkg, 'name') ?? '';
215
+ function packageNxProjectName(pkg: PackageJson): string {
216
+ return pkg.nx?.name ?? pkg.name ?? '';
230
217
  }
231
218
 
232
219
  export function readPackageJsonObject(path: string): PackageJson | null {
233
- const parsed = readJsonObject(path);
234
- return parsed;
220
+ return readJsonObject(path);
235
221
  }
236
222
 
237
- export function getNxTags(pkg: Record<string, unknown>): string[] {
238
- if (!hasOwn(pkg, 'nx') || !isRecord(pkg.nx) || !hasOwn(pkg.nx, 'tags') || !Array.isArray(pkg.nx.tags)) {
223
+ export function getNxTags(pkg: PackageJson): string[] {
224
+ const tags = pkg.nx?.tags;
225
+ if (!Array.isArray(tags)) {
239
226
  return [];
240
227
  }
241
- return pkg.nx.tags.filter((tag): tag is string => typeof tag === 'string');
228
+ return tags.filter((tag): tag is string => typeof tag === 'string');
242
229
  }
243
230
 
244
- export function repositoryInfo(pkg: Record<string, unknown>): RepositoryInfo | null {
231
+ export function repositoryInfo(pkg: PackageJson): RepositoryInfo | null {
245
232
  const repository = pkg.repository;
246
233
  if (typeof repository === 'string') {
247
234
  return { type: 'git', url: repository };
248
235
  }
249
- if (!isRecord(repository)) {
236
+ if (!repository || typeof repository !== 'object') {
250
237
  return null;
251
238
  }
252
- const url = stringProperty(repository, 'url');
239
+ const url = repository.url;
253
240
  if (!url) {
254
241
  return null;
255
242
  }
256
- return { type: stringProperty(repository, 'type') ?? 'git', url };
243
+ return { type: repository.type ?? 'git', url };
257
244
  }
258
245
 
259
246
  export function sameRepositoryAfterNormalization(left: string, right: string): boolean {
@@ -261,25 +248,26 @@ export function sameRepositoryAfterNormalization(left: string, right: string): b
261
248
  }
262
249
 
263
250
  function normalizedRepositoryUrl(url: string): string {
264
- const trimmed = url
265
- .trim()
266
- .replace(/^git\+/, '')
267
- .replace(/#.*$/, '')
268
- .replace(/\/$/, '')
269
- .replace(/\.git$/, '');
270
- const https = /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/i.exec(trimmed);
271
- if (https?.[1] && https[2]) {
272
- return githubRepositoryKey(https[1], https[2]);
273
- }
274
- const ssh = /^git@github\.com:([^/]+)\/([^/]+)$/i.exec(trimmed);
275
- if (ssh?.[1] && ssh[2]) {
276
- return githubRepositoryKey(ssh[1], ssh[2]);
251
+ const trimmed = url.trim().replace(/\.git$/i, '');
252
+ const ssh = /^git@github\.com:(.+)$/i.exec(trimmed);
253
+ if (ssh?.[1]) {
254
+ const [owner, repo] = ssh[1].split('/');
255
+ if (owner && repo) {
256
+ return githubRepositoryKey(owner, repo);
257
+ }
277
258
  }
278
- const shorthand = /^github:([^/]+)\/([^/]+)$/i.exec(trimmed);
279
- if (shorthand?.[1] && shorthand[2]) {
280
- return githubRepositoryKey(shorthand[1], shorthand[2]);
259
+ try {
260
+ const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
261
+ if (parsed.hostname.replace(/^www\./i, '').toLowerCase() === 'github.com') {
262
+ const [owner, repo] = parsed.pathname.replace(/^\//, '').split('/');
263
+ if (owner && repo) {
264
+ return githubRepositoryKey(owner, repo);
265
+ }
266
+ }
267
+ } catch {
268
+ // fall through
281
269
  }
282
- return trimmed;
270
+ return trimmed.toLowerCase();
283
271
  }
284
272
 
285
273
  function githubRepositoryKey(owner: string, repo: string): string {
@@ -17,14 +17,14 @@ describe('CI workflow definition', () => {
17
17
  const steps = defineCiWorkflow({ deploy: true, pushBranches: ['main'] });
18
18
  const rendered = renderCiWorkflowYaml({ deploy: true, pushBranches: ['main'] });
19
19
 
20
- expect(steps.map((step) => [step.kind, step.number])).toContainEqual([CiWorkflowStepKind.Deploy, 9]);
20
+ expect(steps.map((step) => [step.kind, step.number])).toContainEqual([CiWorkflowStepKind.Deploy, 11]);
21
21
  expect(rendered).toContain('- name: 🚀 Deploy Staging');
22
22
  expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
23
23
  expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
24
24
  expect(rendered).toContain(
25
- 'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 9',
25
+ 'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 11',
26
26
  );
27
- expect(rendered).toContain("# Step 10\n # Nx's database cache needs artifact files");
27
+ expect(rendered).toContain("# Step 12\n # Nx's database cache needs artifact files");
28
28
  });
29
29
 
30
30
  it('adds Cloudflare credentials for Wrangler-backed deploys', () => {
@@ -8,6 +8,8 @@ export enum CiWorkflowStepKind {
8
8
  Build = 'build',
9
9
  Lint = 'lint',
10
10
  UnitTests = 'unit-tests',
11
+ ManagedFilesCheck = 'managed-files-check',
12
+ ManagedFilesDispatch = 'managed-files-dispatch',
11
13
  Deploy = 'deploy',
12
14
  SaveNxCache = 'save-nx-cache',
13
15
  UploadTraceDbs = 'upload-trace-dbs',
@@ -37,6 +39,8 @@ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkfl
37
39
  { kind: CiWorkflowStepKind.Build, name: '🔨 Build' },
38
40
  { kind: CiWorkflowStepKind.Lint, name: '🔍 Lint' },
39
41
  { kind: CiWorkflowStepKind.UnitTests, name: '🧪 Unit Tests' },
42
+ { kind: CiWorkflowStepKind.ManagedFilesCheck, name: '🩺 Check managed-file drift' },
43
+ { kind: CiWorkflowStepKind.ManagedFilesDispatch, name: '🔁 Dispatch managed-file drift healing' },
40
44
  ];
41
45
  if (options.deploy) {
42
46
  steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Staging' });
@@ -64,7 +68,8 @@ ${renderYamlList(options.pushBranches, 6)}
64
68
  pull_request:
65
69
 
66
70
  permissions:
67
- actions: read
71
+ # actions:write lets the drift step dispatch the managed-files workflow.
72
+ actions: write
68
73
  contents: read
69
74
  statuses: write
70
75
 
@@ -161,6 +166,27 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
161
166
  return nxSmartStep(step, 'lint', 'Lint');
162
167
  case CiWorkflowStepKind.UnitTests:
163
168
  return nxSmartStep(step, 'test', 'Unit Tests');
169
+ case CiWorkflowStepKind.ManagedFilesCheck:
170
+ // The authoritative drift check is nearly free here (devenv is already
171
+ // up). --warn never fails the job and publishes the step output
172
+ // `drifted=<count>` for the dispatch step below.
173
+ return [
174
+ ` - name: ${step.name}`,
175
+ ' id: managed-drift',
176
+ ' if:',
177
+ " ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}',",
178
+ ' github.event.repository.default_branch) }}',
179
+ ' run: smoo monorepo check --warn',
180
+ ];
181
+ case CiWorkflowStepKind.ManagedFilesDispatch:
182
+ // Dispatches the managed-files workflow, which maintains the persistent
183
+ // review PR. workflow_dispatch is exempt from GitHub's GITHUB_TOKEN
184
+ // recursion guard, so the default token suffices.
185
+ return [
186
+ ` - name: ${step.name}`,
187
+ " if: ${{ steps.managed-drift.outputs.drifted != '' && steps.managed-drift.outputs.drifted != '0' }}",
188
+ ' run: gh workflow run managed-files.yml --ref "$GITHUB_REF_NAME"',
189
+ ];
164
190
  case CiWorkflowStepKind.Deploy:
165
191
  return [
166
192
  ` - name: ${step.name}`,
@@ -17,8 +17,8 @@ export async function applyWorkspaceGitConfig(root: string): Promise<void> {
17
17
  // Keep the newer runtime version pins on any merge (nvfetcher overlay +
18
18
  // devenv.lock) so a mirror sync's `git am --3way` never stalls on a version
19
19
  // conflict. Mapped by the managed .gitattributes (merge=smoo-newer-pins);
20
- // implemented in tooling/direnv/merge-newer-pins.sh. The next `devenv shell`
21
- // regenerates package.json fields from the resulting runtime.
20
+ // implemented in tooling/direnv/merge-newer-pins.sh. Runtime package.json
21
+ // pin repair is explicit via `smoo monorepo init --runtime-only`.
22
22
  await $`git config --local merge.smoo-newer-pins.name ${'keep the newer devenv/nvfetcher runtime pins'}`.cwd(root);
23
23
  await $`git config --local merge.smoo-newer-pins.driver ${'bash tooling/direnv/merge-newer-pins.sh %O %A %B %P'}`.cwd(
24
24
  root,
@@ -5,10 +5,11 @@ import { escapeRegex, getWorkspacePackages, getWorkspacePatterns, listReleasePac
5
5
  import { formatCommitMessage, validateCommitMessage } from './commit-msg.js';
6
6
  import { applyWorkspaceGitConfig } from './git-config.js';
7
7
  import { syncBunLockfileVersions } from './lockfile.js';
8
- import { applyManagedFiles, printResults } from './managed-files.js';
8
+ import { applyManagedFiles, printResults, warnOnManagedFileDrift } from './managed-files.js';
9
9
  import { listValidCommitScopes, validatePublicTags } from './package-policy.js';
10
10
  import { runInitPacks, runValidatePacks } from './packs/index.js';
11
11
  import { syncRootRuntimeVersions } from './runtime.js';
12
+ import { applyToolConfigDefaults } from './tool-validation.js';
12
13
 
13
14
  export interface InitOptions {
14
15
  runtimeOnly?: boolean;
@@ -74,11 +75,26 @@ export async function validateMonorepo(root: string, options: ValidateOptions =
74
75
  }
75
76
  }
76
77
 
77
- export function updateManagedFiles(root: string): void {
78
+ export async function updateManagedFiles(root: string): Promise<void> {
78
79
  printResults(applyManagedFiles(root, 'update'));
80
+ // Tool dependency policy (typescript API 6, @typescript/native for ttsc, nx, …)
81
+ // lives next to managed templates — update must install them, not only rewrite files.
82
+ await applyToolConfigDefaults(root);
83
+ syncBunLockfileVersions(root, { mode: 'install' });
84
+ console.log('installing workspace dependencies (bun install)');
85
+ const install = await $`bun install --no-summary`.cwd(root).nothrow();
86
+ if (install.exitCode !== 0) {
87
+ const stderr = decode(install.stderr).trim();
88
+ const stdout = decode(install.stdout).trim();
89
+ throw new Error(`bun install failed after monorepo update${stderr || stdout ? `:\n${stderr || stdout}` : ''}`);
90
+ }
79
91
  }
80
92
 
81
- export function checkManagedFiles(root: string): void {
93
+ export function checkManagedFiles(root: string, options: { warn?: boolean } = {}): void {
94
+ if (options.warn === true) {
95
+ warnOnManagedFileDrift(root);
96
+ return;
97
+ }
82
98
  const results = applyManagedFiles(root, 'check');
83
99
  printResults(results);
84
100
  if (results.some((result) => result.action === 'drifted')) {
@@ -17,15 +17,6 @@ function git(root: string, args: string): string {
17
17
  return execSync(`git ${args}`, {
18
18
  cwd: root,
19
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
20
  stdio: ['pipe', 'pipe', 'pipe'],
30
21
  });
31
22
  }
@@ -40,6 +31,9 @@ function makeFixtureRepo(options: FixtureOptions): string {
40
31
  const root = mkdtempSync(join(tmpdir(), 'smoo-lockfile-'));
41
32
  cleanupRoots.push(root);
42
33
  git(root, 'init -q');
34
+ // Local identity only — CI runners have no global git user.
35
+ git(root, 'config user.name Test');
36
+ git(root, 'config user.email test@example.com');
43
37
  git(root, 'commit -q --allow-empty -m init');
44
38
  writeFileSync(
45
39
  join(root, 'package.json'),
@@ -56,8 +50,8 @@ function makeFixtureRepo(options: FixtureOptions): string {
56
50
 
57
51
  function writeFixturePackage(root: string, version: string): void {
58
52
  writeFileSync(
59
- join(root, 'packages', 'foo', 'package.json'),
60
- JSON.stringify({ name: '@fixture/foo', version, nx: { name: 'foo' } }),
53
+ join(root, 'packages/foo/package.json'),
54
+ `${JSON.stringify({ name: '@fixture/foo', version, private: false, nx: { name: 'foo' } }, null, 2)}\n`,
61
55
  );
62
56
  }
63
57
 
@@ -71,9 +65,9 @@ function writeFixtureLock(root: string, version: string): void {
71
65
  ' "": { "name": "fixture-root" },',
72
66
  ' "packages/foo": {',
73
67
  ' "name": "@fixture/foo",',
74
- ` "version": "${version}",`,
75
- ' },',
76
- ' },',
68
+ ` "version": "${version}"`,
69
+ ' }',
70
+ ' }',
77
71
  '}',
78
72
  '',
79
73
  ].join('\n'),
@@ -87,59 +81,64 @@ function lockVersionIn(root: string): string {
87
81
  }
88
82
 
89
83
  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.
84
+ it('install validate requires lockfile package.json including -next', () => {
93
85
  const root = makeFixtureRepo({
94
86
  packageVersion: '0.1.3-next.0',
95
- lockVersion: '0.1.3-next.0',
87
+ lockVersion: '0.1.2',
96
88
  stableTag: 'foo@0.1.2',
97
89
  });
98
90
  expect(validateBunLockfileVersions(root)).toBe(1);
99
91
  });
100
92
 
101
- it('sync restores the latest stable tag version and validation passes', () => {
93
+ it('install mode sync restores package.json -next for frozen CI', () => {
94
+ const root = makeFixtureRepo({
95
+ packageVersion: '0.1.3-next.0',
96
+ lockVersion: '0.1.2',
97
+ stableTag: 'foo@0.1.2',
98
+ });
99
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'install' })).toBe(1);
100
+ expect(lockVersionIn(root)).toBe('0.1.3-next.0');
101
+ expect(validateBunLockfileVersions(root)).toBe(0);
102
+ });
103
+
104
+ it('publish mode rewrites unpublished -next to last stable tag for pack', () => {
102
105
  const root = makeFixtureRepo({
103
106
  packageVersion: '0.1.3-next.0',
104
107
  lockVersion: '0.1.3-next.0',
105
108
  stableTag: 'foo@0.1.2',
106
109
  });
107
- expect(syncBunLockfileVersions(root, { log: false })).toBe(1);
108
- expect(lockVersionIn(root)).toBe('0.1.2');
110
+ // Install/CI is happy with -next matching package.json.
109
111
  expect(validateBunLockfileVersions(root)).toBe(0);
112
+ // Pre-publish rewrite embeds last stable for bun pm pack.
113
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'publish' })).toBe(1);
114
+ expect(lockVersionIn(root)).toBe('0.1.2');
110
115
  });
111
116
 
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.
117
+ it('never-published prerelease keeps package.json version in both modes', () => {
114
118
  const root = makeFixtureRepo({ packageVersion: '0.2.0-next.0', lockVersion: '0.2.0-next.0' });
115
119
  expect(validateBunLockfileVersions(root)).toBe(0);
116
- expect(syncBunLockfileVersions(root, { log: false })).toBe(0);
120
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'install' })).toBe(0);
121
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'publish' })).toBe(0);
117
122
  });
118
123
 
119
- it('a stable manifest version is required verbatim in the lockfile', () => {
124
+ it('stable manifest version is required verbatim', () => {
120
125
  const root = makeFixtureRepo({ packageVersion: '0.1.0', lockVersion: '0.0.9' });
121
126
  expect(validateBunLockfileVersions(root)).toBe(1);
122
- expect(syncBunLockfileVersions(root, { log: false })).toBe(1);
127
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'install' })).toBe(1);
123
128
  expect(lockVersionIn(root)).toBe('0.1.0');
124
129
  });
125
130
 
126
131
  it('stage: true stages the healed bun.lock', () => {
127
132
  const root = makeFixtureRepo({
128
133
  packageVersion: '0.1.3-next.0',
129
- lockVersion: '0.1.3-next.0',
134
+ lockVersion: '0.1.2',
130
135
  stableTag: 'foo@0.1.2',
131
136
  });
132
- expect(syncBunLockfileVersions(root, { log: false, stage: true })).toBe(1);
137
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'install', stage: true })).toBe(1);
133
138
  expect(git(root, 'diff --cached --name-only')).toContain('bun.lock');
134
139
  });
135
140
 
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', () => {
141
+ it('property: after install-mode sync, validation always passes', () => {
143
142
  const version = fc
144
143
  .tuple(fc.nat({ max: 20 }), fc.nat({ max: 20 }), fc.nat({ max: 20 }), fc.option(fc.nat({ max: 5 })))
145
144
  .map(([major, minor, patch, next]) => `${major}.${minor}.${patch}${next === null ? '' : `-next.${next}`}`);
@@ -148,7 +147,7 @@ describe('bun.lock workspace version sync', () => {
148
147
  fc.property(version, version, (packageVersion, lockVersion) => {
149
148
  writeFixturePackage(root, packageVersion);
150
149
  writeFixtureLock(root, lockVersion);
151
- syncBunLockfileVersions(root, { log: false });
150
+ syncBunLockfileVersions(root, { log: false, mode: 'install' });
152
151
  return validateBunLockfileVersions(root) === 0;
153
152
  }),
154
153
  { numRuns: 25 },