@smoothbricks/cli 0.10.5 → 0.10.7

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 (65) hide show
  1. package/dist/cli.js +3 -2
  2. package/dist/github-ci/index.d.ts +1 -0
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +28 -5
  5. package/dist/lib/json.d.ts +3 -1
  6. package/dist/lib/json.d.ts.map +1 -1
  7. package/dist/lib/json.js +5 -20
  8. package/dist/lib/workspace.d.ts.map +1 -1
  9. package/dist/lib/workspace.js +1 -0
  10. package/dist/monorepo/ci-workflow.d.ts +2 -0
  11. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  12. package/dist/monorepo/ci-workflow.js +10 -4
  13. package/dist/monorepo/github-runs-on.d.ts +14 -0
  14. package/dist/monorepo/github-runs-on.d.ts.map +1 -0
  15. package/dist/monorepo/github-runs-on.js +43 -0
  16. package/dist/monorepo/index.d.ts +2 -2
  17. package/dist/monorepo/index.d.ts.map +1 -1
  18. package/dist/monorepo/index.js +7 -7
  19. package/dist/monorepo/managed-files.d.ts +14 -3
  20. package/dist/monorepo/managed-files.d.ts.map +1 -1
  21. package/dist/monorepo/managed-files.js +54 -74
  22. package/dist/monorepo/package-policy.js +2 -2
  23. package/dist/monorepo/packs/index.d.ts.map +1 -1
  24. package/dist/monorepo/packs/index.js +3 -1
  25. package/dist/monorepo/publish-workflow.d.ts +3 -0
  26. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  27. package/dist/monorepo/publish-workflow.js +17 -14
  28. package/dist/monorepo/runtime.d.ts +2 -2
  29. package/dist/monorepo/runtime.d.ts.map +1 -1
  30. package/dist/monorepo/runtime.js +11 -16
  31. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  32. package/dist/monorepo/tool-validation.js +20 -23
  33. package/dist/nx/index.d.ts +7 -4
  34. package/dist/nx/index.d.ts.map +1 -1
  35. package/dist/nx/index.js +117 -56
  36. package/managed/raw/tooling/devenv +71 -0
  37. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +24 -7
  38. package/managed/templates/github/actions/cache-nix-devenv/action.yml +13 -11
  39. package/managed/templates/github/actions/save-nix-devenv/action.yml +34 -3
  40. package/managed/templates/github/actions/setup-devenv/action.yml +44 -15
  41. package/managed/templates/github/workflows/managed-files.yml +64 -12
  42. package/package.json +4 -4
  43. package/src/cli.ts +10 -3
  44. package/src/github-ci/index.test.ts +53 -4
  45. package/src/github-ci/index.ts +37 -5
  46. package/src/lib/json.ts +3 -1
  47. package/src/lib/workspace.ts +1 -0
  48. package/src/monorepo/__tests__/ci-workflow.test.ts +37 -1
  49. package/src/monorepo/__tests__/publish-workflow.test.ts +39 -4
  50. package/src/monorepo/ci-workflow.ts +14 -4
  51. package/src/monorepo/github-runs-on.ts +45 -0
  52. package/src/monorepo/index.ts +7 -7
  53. package/src/monorepo/managed-files.test.ts +58 -0
  54. package/src/monorepo/managed-files.ts +59 -76
  55. package/src/monorepo/package-policy.test.ts +31 -0
  56. package/src/monorepo/package-policy.ts +2 -2
  57. package/src/monorepo/packs/index.ts +3 -1
  58. package/src/monorepo/publish-workflow.ts +28 -15
  59. package/src/monorepo/runtime.test.ts +23 -17
  60. package/src/monorepo/runtime.ts +12 -17
  61. package/src/monorepo/tool-validation.test.ts +15 -8
  62. package/src/monorepo/tool-validation.ts +20 -25
  63. package/src/nx/index.test.ts +45 -14
  64. package/src/nx/index.ts +133 -64
  65. package/managed/raw/patches/ttsc@0.19.3.patch +0 -67
@@ -1,5 +1,7 @@
1
1
  /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are emitted literally. */
2
2
 
3
+ import { renderRunsOnLine } from './github-runs-on.js';
4
+
3
5
  export enum CiWorkflowStepKind {
4
6
  Checkout = 'checkout',
5
7
  SetupDevenv = 'setup-devenv',
@@ -26,6 +28,8 @@ export interface CiWorkflowDefinitionOptions {
26
28
  deploy: boolean;
27
29
  deployProvider?: 'cloudflare';
28
30
  pushBranches: string[];
31
+ /** Default ubuntu-latest when omitted. */
32
+ runsOn?: string | string[];
29
33
  }
30
34
 
31
35
  type CiWorkflowStepInput = Omit<CiWorkflowStep, 'number'>;
@@ -80,7 +84,7 @@ defaults:
80
84
  jobs:
81
85
  main:
82
86
  name: Validate
83
- runs-on: ubuntu-latest
87
+ ${renderRunsOnLine(options.runsOn)}
84
88
  timeout-minutes: 45
85
89
  env:
86
90
  NIX_STORE_NAR: ${githubExpression('github.workspace')}/nix-store.nar
@@ -150,7 +154,10 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
150
154
  ' fetch-depth: 0',
151
155
  ];
152
156
  case CiWorkflowStepKind.SetupDevenv:
157
+ // One step everywhere. setup-devenv detects host-nix (GARM) vs ephemeral
158
+ // and skips GH install/cache steps internally — same for ci/publish/managed.
153
159
  return [` - name: ${step.name}`, ' id: setup', ' uses: ./.github/actions/setup-devenv'];
160
+
154
161
  case CiWorkflowStepKind.SetNxShas:
155
162
  return [
156
163
  ` - name: ${step.name}`,
@@ -184,7 +191,7 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
184
191
  // recursion guard, so the default token suffices.
185
192
  return [
186
193
  ` - name: ${step.name}`,
187
- " if: ${{ steps.managed-drift.outputs.drifted != '' && steps.managed-drift.outputs.drifted != '0' }}",
194
+ " if: steps.managed-drift.outputs.drifted != '' && steps.managed-drift.outputs.drifted != '0'",
188
195
  ' run: gh workflow run managed-files.yml --ref "$GITHUB_REF_NAME"',
189
196
  ];
190
197
  case CiWorkflowStepKind.Deploy:
@@ -212,7 +219,8 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
212
219
  case CiWorkflowStepKind.UploadTraceDbs:
213
220
  return [
214
221
  ` - name: ${step.name}`,
215
- ' if: ${{ always() }}',
222
+ ' # success() is the default; always() keeps traces after a red build/test.',
223
+ ' if: always()',
216
224
  ' uses: actions/upload-artifact@v7.0.1',
217
225
  ' with:',
218
226
  ' name: trace-results-${{ github.run_id }}',
@@ -224,7 +232,9 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
224
232
  case CiWorkflowStepKind.SaveNixDevenv:
225
233
  return [
226
234
  ` - name: ${step.name}`,
227
- ' if: ${{ always() }}',
235
+ ' # always() still saves after a red job. Nix NAR is ephemeral-only;',
236
+ ' # devenv eval-cache also saves on host-nix when setup missed.',
237
+ ' if: always()',
228
238
  ' uses: ./.github/actions/save-nix-devenv',
229
239
  ' with:',
230
240
  ' nix-cache-hit: ${{ steps.setup.outputs.nix-cache-hit }}',
@@ -0,0 +1,45 @@
1
+ /** Shared `runs-on:` emission for generated GitHub workflow YAML. */
2
+
3
+ export type WorkflowRunsOn = string | string[] | undefined;
4
+
5
+ export function isNixosRunner(runsOn: WorkflowRunsOn): boolean {
6
+ const labels = runsOn === undefined ? [] : typeof runsOn === 'string' ? [runsOn] : runsOn;
7
+ return labels.some((label) => label === 'nixos' || label.startsWith('nixos-'));
8
+ }
9
+
10
+ /**
11
+ * Trusted jobs use configured nixos self-hosted labels; fork PRs stay on
12
+ * ubuntu-latest (no access to the private runner fleet).
13
+ */
14
+ export function githubUsesNixosRunnerExpr(): string {
15
+ return "(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)";
16
+ }
17
+
18
+ /**
19
+ * Full ` runs-on: …` line(s), indented for a job under `jobs.<id>:`.
20
+ * Nixos labels emit the multiline prettier-stable fork-gate expression.
21
+ */
22
+ export function renderRunsOnLine(runsOn: WorkflowRunsOn): string {
23
+ if (!isNixosRunner(runsOn)) {
24
+ let value: string;
25
+ if (runsOn === undefined) {
26
+ value = 'ubuntu-latest';
27
+ } else if (typeof runsOn === 'string') {
28
+ value = runsOn.length > 0 ? runsOn : 'ubuntu-latest';
29
+ } else if (runsOn.length === 0) {
30
+ value = 'ubuntu-latest';
31
+ } else if (runsOn.length === 1) {
32
+ value = runsOn[0] ?? 'ubuntu-latest';
33
+ } else {
34
+ value = `[${runsOn.map((label) => `'${label}'`).join(', ')}]`;
35
+ }
36
+ return ` runs-on: ${value}`;
37
+ }
38
+
39
+ const labels = typeof runsOn === 'string' ? [runsOn] : runsOn;
40
+ // Multiline matches prettier so checked-in workflows stay byte-identical.
41
+ const nixosJson = JSON.stringify(labels);
42
+ return ` runs-on:
43
+ \${{ ${githubUsesNixosRunnerExpr()} &&
44
+ fromJSON('${nixosJson}') || 'ubuntu-latest' }}`;
45
+ }
@@ -51,7 +51,7 @@ export async function initMonorepo(root: string, options: InitOptions): Promise<
51
51
  return;
52
52
  }
53
53
 
54
- printResults(applyManagedFiles(root, 'update'));
54
+ printResults(await applyManagedFiles(root, 'update'));
55
55
  await runInitPacks({ root, syncRuntime: process.env.DEVENV_ROOT !== undefined || options.syncRuntime === true });
56
56
  }
57
57
 
@@ -76,7 +76,7 @@ export async function validateMonorepo(root: string, options: ValidateOptions =
76
76
  }
77
77
 
78
78
  export async function updateManagedFiles(root: string): Promise<void> {
79
- printResults(applyManagedFiles(root, 'update'));
79
+ printResults(await applyManagedFiles(root, 'update'));
80
80
  // Tool dependency policy (typescript API 6, @typescript/native for ttsc, nx, …)
81
81
  // lives next to managed templates — update must install them, not only rewrite files.
82
82
  await applyToolConfigDefaults(root);
@@ -90,20 +90,20 @@ export async function updateManagedFiles(root: string): Promise<void> {
90
90
  }
91
91
  }
92
92
 
93
- export function checkManagedFiles(root: string, options: { warn?: boolean } = {}): void {
93
+ export async function checkManagedFiles(root: string, options: { warn?: boolean } = {}): Promise<void> {
94
94
  if (options.warn === true) {
95
- warnOnManagedFileDrift(root);
95
+ await warnOnManagedFileDrift(root);
96
96
  return;
97
97
  }
98
- const results = applyManagedFiles(root, 'check');
98
+ const results = await applyManagedFiles(root, 'check');
99
99
  printResults(results);
100
100
  if (results.some((result) => result.action === 'drifted')) {
101
101
  throw new Error('Managed monorepo files are out of date. Run: smoo monorepo update');
102
102
  }
103
103
  }
104
104
 
105
- export function diffManagedFiles(root: string): void {
106
- printResults(applyManagedFiles(root, 'diff'));
105
+ export async function diffManagedFiles(root: string): Promise<void> {
106
+ printResults(await applyManagedFiles(root, 'diff'));
107
107
  }
108
108
 
109
109
  export function validateCommitMessageFile(
@@ -4,11 +4,14 @@ import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { LINUX_PLATFORM_TARGET_GLOBS, PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
6
6
  import fc from 'fast-check';
7
+ import { type NxProjects, targetNamesFromProjects } from '../nx/index.js';
7
8
  import {
9
+ deployTargetInfoFromProjects,
8
10
  extractInlineLocalBlocksForTest,
9
11
  INLINE_LOCAL_BEGIN,
10
12
  INLINE_LOCAL_END,
11
13
  LOCAL_SECTION_MARKER,
14
+ managedFileTargetsForTest,
12
15
  platformTargetGlobsForTest,
13
16
  reinsertInlineLocalBlocksForTest,
14
17
  splitLocalSectionForTest,
@@ -172,6 +175,61 @@ describe('managed publish platform discovery', () => {
172
175
  });
173
176
  });
174
177
 
178
+ describe('nx graph project helpers', () => {
179
+ const sampleProjects: NxProjects = {
180
+ lib: {
181
+ targets: {
182
+ build: {},
183
+ 'bundle-linux': {},
184
+ lint: {},
185
+ },
186
+ },
187
+ app: {
188
+ targets: {
189
+ deploy: {
190
+ options: { command: 'echo no' },
191
+ configurations: {
192
+ staging: { command: 'wrangler deploy --env staging' },
193
+ production: { options: { command: 'wrangler deploy --env production' } },
194
+ },
195
+ },
196
+ 'package-macos': {},
197
+ },
198
+ },
199
+ };
200
+
201
+ it('collects target names from graph nodes without project names', () => {
202
+ expect(targetNamesFromProjects(sampleProjects).sort()).toEqual(
203
+ ['build', 'bundle-linux', 'deploy', 'lint', 'package-macos'].sort(),
204
+ );
205
+ });
206
+
207
+ it('detects deploy configurations and cloudflare provider from graph nodes', () => {
208
+ expect(deployTargetInfoFromProjects(sampleProjects, 'staging')).toEqual({
209
+ exists: true,
210
+ provider: 'cloudflare',
211
+ });
212
+ expect(deployTargetInfoFromProjects(sampleProjects, 'production')).toEqual({
213
+ exists: true,
214
+ provider: 'cloudflare',
215
+ });
216
+ expect(deployTargetInfoFromProjects(sampleProjects, 'preview')).toEqual({ exists: false });
217
+ });
218
+ });
219
+
220
+ describe('managed raw files', () => {
221
+ it('manages the devenv wrapper as an executable byte-exact copy', async () => {
222
+ expect(managedFileTargetsForTest).toContainEqual({ target: 'tooling/devenv', executable: true });
223
+
224
+ const [source, generated] = await Promise.all([
225
+ readFile(join(REPO_ROOT, 'packages', 'cli', 'managed', 'raw', 'tooling', 'devenv'), 'utf8'),
226
+ readFile(join(REPO_ROOT, 'tooling', 'devenv'), 'utf8'),
227
+ ]);
228
+
229
+ expect(generated).toBe(source);
230
+ });
231
+ });
232
+
175
233
  describe('managed cache actions', () => {
176
234
  it('renders the checked-in action copies from their managed templates', async () => {
177
235
  for (const action of CACHE_ACTIONS) {
@@ -1,10 +1,10 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
2
  import { dirname, join, resolve } from 'node:path';
4
3
  import { fileURLToPath } from 'node:url';
5
4
  import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
6
- import { type PackageJson, parseNxProjectJsonText, parseStringArrayText } from '../lib/json.js';
5
+ import type { NxTargetConfig, PackageJson } from '../lib/json.js';
7
6
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
7
+ import { loadNxProjects, type NxProjects, targetNamesFromProjects } from '../nx/index.js';
8
8
  import { renderCiWorkflowYaml } from './ci-workflow.js';
9
9
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
10
10
 
@@ -137,6 +137,7 @@ interface ManagedFileContext {
137
137
  stagingDeployProvider?: 'cloudflare';
138
138
  productionDeployProvider?: 'cloudflare';
139
139
  ciPushBranches: string[];
140
+ ciRunsOn: string | string[];
140
141
  nodeModulesCacheKey: string;
141
142
  repoName: string;
142
143
  platformTargetGlobs: string[];
@@ -153,6 +154,12 @@ const managedFiles: ManagedFile[] = [
153
154
  source: 'envrc',
154
155
  target: '.envrc',
155
156
  },
157
+ {
158
+ kind: 'raw',
159
+ source: 'tooling/devenv',
160
+ target: 'tooling/devenv',
161
+ executable: true,
162
+ },
156
163
  {
157
164
  kind: 'raw',
158
165
  source: 'tooling/direnv/repo-path',
@@ -199,11 +206,6 @@ const managedFiles: ManagedFile[] = [
199
206
  target: 'tooling/direnv/merge-newer-pins.sh',
200
207
  executable: true,
201
208
  },
202
- {
203
- kind: 'raw',
204
- source: 'patches/ttsc@0.19.3.patch',
205
- target: 'patches/ttsc@0.19.3.patch',
206
- },
207
209
  {
208
210
  kind: 'generated',
209
211
  source: 'ci-workflow',
@@ -252,10 +254,12 @@ const managedFiles: ManagedFile[] = [
252
254
  },
253
255
  ];
254
256
 
257
+ export const managedFileTargetsForTest = managedFiles.map(({ target, executable }) => ({ target, executable }));
258
+
255
259
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
256
260
 
257
- export function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): FileResult[] {
258
- const context = getManagedFileContext(root);
261
+ export async function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): Promise<FileResult[]> {
262
+ const context = await getManagedFileContext(root);
259
263
  return managedFiles.map((file) => applyManagedFile(root, file, mode, context));
260
264
  }
261
265
 
@@ -306,6 +310,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
306
310
  deploy: context.hasStagingDeployTargets,
307
311
  deployProvider: context.stagingDeployProvider,
308
312
  pushBranches: context.ciPushBranches,
313
+ runsOn: context.ciRunsOn,
309
314
  });
310
315
  }
311
316
  if (file.source === 'publish-workflow') {
@@ -314,6 +319,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
314
319
  deployProvider: context.productionDeployProvider,
315
320
  repoName: context.repoName,
316
321
  platformTargetGlobs: context.platformTargetGlobs,
322
+ runsOn: context.ciRunsOn,
317
323
  });
318
324
  }
319
325
  throw new Error(`Unknown generated managed file source ${file.source}`);
@@ -327,13 +333,16 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
327
333
  return renderTemplate(context, content);
328
334
  }
329
335
 
330
- function getManagedFileContext(root: string): ManagedFileContext {
336
+ async function getManagedFileContext(root: string): Promise<ManagedFileContext> {
331
337
  const packageJson = readPackageJson(join(root, 'package.json'));
332
338
  const repoName = packageJson?.name ?? 'monorepo';
333
339
  const ciPushBranches = getCiPushBranches(packageJson?.json);
334
- const stagingDeploy = getDeployTargetInfo(root, 'staging');
335
- const productionDeploy = getDeployTargetInfo(root, 'production');
336
- const platformTargetGlobs = platformTargetGlobsForTest(readResolvedNxTargetNames(root));
340
+ const ciRunsOn = getCiRunsOn(packageJson?.json);
341
+ // In-process Nx API → daemon socket (no second Node/`nx` CLI process).
342
+ const nxProjects = await loadNxProjects(root);
343
+ const stagingDeploy = deployTargetInfoFromProjects(nxProjects, 'staging');
344
+ const productionDeploy = deployTargetInfoFromProjects(nxProjects, 'production');
345
+ const platformTargetGlobs = platformTargetGlobsForTest(targetNamesFromProjects(nxProjects));
337
346
  const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
338
347
  ? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
339
348
  : `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
@@ -344,6 +353,7 @@ function getManagedFileContext(root: string): ManagedFileContext {
344
353
  stagingDeployProvider: stagingDeploy.provider,
345
354
  productionDeployProvider: productionDeploy.provider,
346
355
  ciPushBranches,
356
+ ciRunsOn,
347
357
  nodeModulesCacheKey,
348
358
  repoName,
349
359
  platformTargetGlobs,
@@ -358,81 +368,54 @@ export function platformTargetGlobsForTest(targetNames: Iterable<string>): strin
358
368
  });
359
369
  }
360
370
 
361
- function readResolvedNxTargetNames(root: string): string[] {
362
- const output = execFileSync('nx', ['show', 'projects', '--json'], {
363
- cwd: root,
364
- encoding: 'utf8',
365
- stdio: ['ignore', 'pipe', 'inherit'],
366
- });
367
- const projects = parseStringArrayText(output);
368
- if (!projects) {
369
- return [];
370
- }
371
- const targetNames = new Set<string>();
372
- for (const project of projects) {
373
- const projectOutput = execFileSync('nx', ['show', 'project', project, '--json'], {
374
- cwd: root,
375
- encoding: 'utf8',
376
- stdio: ['ignore', 'pipe', 'inherit'],
377
- });
378
- const projectJson = parseNxProjectJsonText(projectOutput);
379
- const targets = projectJson?.targets;
380
- if (targets) {
381
- for (const targetName of Object.keys(targets)) {
382
- targetNames.add(targetName);
383
- }
384
- }
385
- }
386
- return [...targetNames];
387
- }
388
-
389
- function renderTemplate(context: ManagedFileContext, template: string): string {
390
- return template
391
- .replaceAll('{{REPO_NAME}}', context.repoName)
392
- .replaceAll('__SMOO_CI_PUSH_BRANCHES__', renderYamlFlowList(context.ciPushBranches))
393
- .replaceAll('{{NODE_MODULES_CACHE_KEY}}', context.nodeModulesCacheKey);
394
- }
395
-
396
- function getDeployTargetInfo(root: string, configuration: string): DeployTargetInfo {
397
- const output = execFileSync('nx', ['show', 'projects', '--withTarget', 'deploy', '--json'], {
398
- cwd: root,
399
- encoding: 'utf8',
400
- stdio: ['ignore', 'pipe', 'inherit'],
401
- });
402
- const projects = parseStringArrayText(output);
403
- if (!projects) {
404
- return { exists: false };
405
- }
371
+ /** Test seam: deploy configuration presence/provider from graph nodes. */
372
+ export function deployTargetInfoFromProjects(projects: NxProjects, configuration: string): DeployTargetInfo {
406
373
  let exists = false;
407
374
  let provider: DeployTargetInfo['provider'];
408
- for (const project of projects) {
409
- const target = nxDeployTarget(root, project, configuration);
410
- if (!target.exists) {
375
+ for (const project of Object.values(projects)) {
376
+ const info = deployTargetInfoFromTargets(project.targets ?? {}, configuration);
377
+ if (!info.exists) {
411
378
  continue;
412
379
  }
413
380
  exists = true;
414
- provider ??= target.provider;
381
+ provider ??= info.provider;
415
382
  }
416
383
  return { exists, provider };
417
384
  }
418
385
 
419
- function nxDeployTarget(root: string, project: string, configuration: string): DeployTargetInfo {
420
- const output = execFileSync('nx', ['show', 'project', project, '--json'], {
421
- cwd: root,
422
- encoding: 'utf8',
423
- stdio: ['ignore', 'pipe', 'inherit'],
424
- });
425
- const projectJson = parseNxProjectJsonText(output);
426
- const deploy = projectJson?.targets?.deploy;
427
- const config = deploy?.configurations?.[configuration];
386
+ function deployTargetInfoFromTargets(targets: Record<string, NxTargetConfig>, configuration: string): DeployTargetInfo {
387
+ const deploy = targets.deploy;
388
+ if (!deploy) {
389
+ return { exists: false };
390
+ }
391
+ const config = deploy.configurations?.[configuration];
428
392
  if (!config) {
429
393
  return { exists: false };
430
394
  }
431
- const commandValue = config.command ?? config.options?.command ?? deploy?.options?.command;
395
+ const commandValue = config.command ?? config.options?.command ?? deploy.options?.command ?? deploy.command;
432
396
  const command = typeof commandValue === 'string' ? commandValue : '';
433
397
  return { exists: true, provider: command.includes('wrangler ') ? 'cloudflare' : undefined };
434
398
  }
435
399
 
400
+ function renderTemplate(context: ManagedFileContext, template: string): string {
401
+ return template
402
+ .replaceAll('{{REPO_NAME}}', context.repoName)
403
+ .replaceAll('__SMOO_CI_PUSH_BRANCHES__', renderYamlFlowList(context.ciPushBranches))
404
+ .replaceAll('{{NODE_MODULES_CACHE_KEY}}', context.nodeModulesCacheKey);
405
+ }
406
+
407
+ function getCiRunsOn(packageJson: PackageJson | null | undefined): string | string[] {
408
+ const configured = packageJson?.smoo?.github?.runsOn;
409
+ if (configured === undefined) {
410
+ return 'ubuntu-latest';
411
+ }
412
+ if (typeof configured === 'string') {
413
+ return configured.length > 0 ? configured : 'ubuntu-latest';
414
+ }
415
+ const labels = configured.filter((label) => label.length > 0);
416
+ return labels.length > 0 ? labels : 'ubuntu-latest';
417
+ }
418
+
436
419
  function getCiPushBranches(packageJson: PackageJson | null | undefined): string[] {
437
420
  const configured = readCiPushBranches(packageJson);
438
421
  return configured.length > 0 ? configured : ['main'];
@@ -458,8 +441,8 @@ export function printResults(results: FileResult[]): void {
458
441
  }
459
442
  }
460
443
 
461
- export function validateManagedFiles(root: string): number {
462
- const results = applyManagedFiles(root, 'check');
444
+ export async function validateManagedFiles(root: string): Promise<number> {
445
+ const results = await applyManagedFiles(root, 'check');
463
446
  printResults(results);
464
447
  const failures = results.filter((result) => result.action === 'drifted').length;
465
448
  if (failures > 0) {
@@ -478,8 +461,8 @@ export function validateManagedFiles(root: string): number {
478
461
  * Under GitHub Actions the drifted-file count is published as the step output
479
462
  * `drifted`, so downstream steps gate declaratively instead of parsing logs.
480
463
  */
481
- export function warnOnManagedFileDrift(root: string): void {
482
- const results = applyManagedFiles(root, 'check');
464
+ export async function warnOnManagedFileDrift(root: string): Promise<void> {
465
+ const results = await applyManagedFiles(root, 'check');
483
466
  printResults(results);
484
467
  const drifted = results.filter((result) => result.action === 'drifted');
485
468
  if (process.env.GITHUB_OUTPUT) {
@@ -11,6 +11,8 @@ import {
11
11
 
12
12
  const TIMEOUT_FLAG = `--timeout=${BOUNDED_TEST_PER_TEST_TIMEOUT_MS}`;
13
13
 
14
+ import { validateCommitMessage } from './commit-msg.js';
15
+
14
16
  import {
15
17
  applyFixableMonorepoDefaults,
16
18
  applyNxPluginDefaults,
@@ -245,6 +247,35 @@ describe('Nx project name policy', () => {
245
247
  await rm(root, { recursive: true, force: true });
246
248
  }
247
249
  });
250
+
251
+ it('uses nx.name from an exact workspace package as the commit scope', async () => {
252
+ const root = await mkdtemp(join(tmpdir(), 'smoo-package-policy-'));
253
+ try {
254
+ await writeJson(join(root, 'package.json'), {
255
+ name: '@smoothbricks/codebase',
256
+ version: '0.0.0',
257
+ private: true,
258
+ workspaces: ['tooling'],
259
+ });
260
+ await writeJson(join(root, 'tooling/package.json'), {
261
+ name: '@smoothbricks/internal-tooling',
262
+ private: true,
263
+ nx: { name: 'tooling' },
264
+ });
265
+
266
+ const validScopes = listValidCommitScopes(root);
267
+ expect(validScopes).toEqual(new Set(['release', 'tooling']));
268
+ expect(validateCommitMessage('fix(tooling): repair tooling\n', { validScopes })).toBeNull();
269
+ expect(validateCommitMessage('fix(internal-tooling): repair tooling\n', { validScopes })).toContain(
270
+ 'Invalid conventional commit scope',
271
+ );
272
+ expect(validateCommitMessage('fix(arbitrary): repair tooling\n', { validScopes })).toContain(
273
+ 'Invalid conventional commit scope',
274
+ );
275
+ } finally {
276
+ await rm(root, { recursive: true, force: true });
277
+ }
278
+ });
248
279
  });
249
280
 
250
281
  describe('workspace package script policy', () => {
@@ -165,7 +165,7 @@ export function applyWorkspaceDependencyDefaults(root: string, options: PackageT
165
165
  } else {
166
166
  console.log('unchanged tsconfig.test.json policy');
167
167
  }
168
- if (applyWorkspaceBoundedTestTargetPolicy(root)) {
168
+ if (applyWorkspaceBoundedTestTargetPolicy(root, options)) {
169
169
  console.log('updated package test targets bounded execution policy');
170
170
  } else {
171
171
  console.log('unchanged package test targets bounded execution policy');
@@ -409,7 +409,7 @@ export function validateWorkspaceDependencies(root: string, options: PackageTarg
409
409
  console.error(`${issue.path}: ${issue.message}`);
410
410
  failures++;
411
411
  }
412
- for (const issue of checkWorkspaceBoundedTestTargetPolicy(root)) {
412
+ for (const issue of checkWorkspaceBoundedTestTargetPolicy(root, options)) {
413
413
  console.error(`${issue.path}: ${issue.message}`);
414
414
  failures++;
415
415
  }
@@ -92,7 +92,7 @@ const packs: MonorepoPack[] = [
92
92
  // Managed-file drift is derived state (CLI template x pinned version) with
93
93
  // its own remediation flow; it warns instead of failing so validation only
94
94
  // blocks on actual package issues. See warnOnManagedFileDrift.
95
- warnOnManagedFileDrift(ctx.root);
95
+ await warnOnManagedFileDrift(ctx.root);
96
96
  return (
97
97
  runtimeFailures +
98
98
  validateRootPackagePolicy(ctx.root) +
@@ -414,10 +414,12 @@ export function resolvedTargetsByProject(projects: ProjectTargets[]): Map<string
414
414
  projects.map((project) => [
415
415
  project.project,
416
416
  {
417
+ ...(project.root ? { root: project.root } : {}),
417
418
  targets: new Set(project.targets),
418
419
  ...(project.buildDependsOn ? { buildDependsOn: project.buildDependsOn } : {}),
419
420
  ...(project.targetDependencies ? { targetDependencies: project.targetDependencies } : {}),
420
421
  ...(project.targetExecutors ? { targetExecutors: project.targetExecutors } : {}),
422
+ ...(project.targetOptions ? { targetOptions: project.targetOptions } : {}),
421
423
  ...(project.targetScripts ? { targetScripts: project.targetScripts } : {}),
422
424
  },
423
425
  ]),