@smoothbricks/cli 0.10.5 → 0.10.6

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 (41) hide show
  1. package/dist/cli.js +2 -2
  2. package/dist/github-ci/index.d.ts.map +1 -1
  3. package/dist/github-ci/index.js +5 -3
  4. package/dist/lib/json.d.ts +2 -0
  5. package/dist/lib/json.d.ts.map +1 -1
  6. package/dist/lib/json.js +2 -2
  7. package/dist/monorepo/ci-workflow.d.ts +2 -0
  8. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  9. package/dist/monorepo/ci-workflow.js +10 -4
  10. package/dist/monorepo/github-runs-on.d.ts +14 -0
  11. package/dist/monorepo/github-runs-on.d.ts.map +1 -0
  12. package/dist/monorepo/github-runs-on.js +43 -0
  13. package/dist/monorepo/index.d.ts +2 -2
  14. package/dist/monorepo/index.d.ts.map +1 -1
  15. package/dist/monorepo/index.js +7 -7
  16. package/dist/monorepo/managed-files.d.ts +30 -3
  17. package/dist/monorepo/managed-files.d.ts.map +1 -1
  18. package/dist/monorepo/managed-files.js +78 -63
  19. package/dist/monorepo/packs/index.js +1 -1
  20. package/dist/monorepo/publish-workflow.d.ts +3 -0
  21. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  22. package/dist/monorepo/publish-workflow.js +17 -14
  23. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +24 -7
  24. package/managed/templates/github/actions/cache-nix-devenv/action.yml +13 -11
  25. package/managed/templates/github/actions/save-nix-devenv/action.yml +34 -3
  26. package/managed/templates/github/actions/setup-devenv/action.yml +44 -15
  27. package/managed/templates/github/workflows/managed-files.yml +64 -12
  28. package/package.json +1 -1
  29. package/src/cli.ts +2 -2
  30. package/src/github-ci/index.test.ts +3 -3
  31. package/src/github-ci/index.ts +6 -3
  32. package/src/lib/json.ts +2 -0
  33. package/src/monorepo/__tests__/ci-workflow.test.ts +37 -1
  34. package/src/monorepo/__tests__/publish-workflow.test.ts +28 -1
  35. package/src/monorepo/ci-workflow.ts +14 -4
  36. package/src/monorepo/github-runs-on.ts +45 -0
  37. package/src/monorepo/index.ts +7 -7
  38. package/src/monorepo/managed-files.test.ts +49 -0
  39. package/src/monorepo/managed-files.ts +100 -64
  40. package/src/monorepo/packs/index.ts +1 -1
  41. package/src/monorepo/publish-workflow.ts +23 -15
package/src/lib/json.ts CHANGED
@@ -23,6 +23,8 @@ export interface PackagePublishConfig {
23
23
 
24
24
  export interface PackageSmooGithub {
25
25
  pushBranches?: string[];
26
+ /** GitHub Actions runs-on for managed CI (string or label list). Default: ubuntu-latest. */
27
+ runsOn?: string | string[];
26
28
  }
27
29
 
28
30
  export interface PackageSmooConfig {
@@ -5,9 +5,15 @@ import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { CiWorkflowStepKind, defineCiWorkflow, renderCiWorkflowYaml } from '../ci-workflow.js';
7
7
 
8
+ const nixosRunsOn = ['nixos-latest-x64', 'self-hosted'] as const;
9
+
8
10
  describe('CI workflow definition', () => {
9
11
  it('renders the checked-in local CI workflow copy', async () => {
10
- const rendered = renderCiWorkflowYaml({ deploy: false, pushBranches: ['main'] });
12
+ const rendered = renderCiWorkflowYaml({
13
+ deploy: false,
14
+ pushBranches: ['main'],
15
+ runsOn: [...nixosRunsOn],
16
+ });
11
17
  const packageRoot = join(import.meta.dir, '..', '..', '..');
12
18
 
13
19
  await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/ci.yml'), 'utf8')).resolves.toBe(rendered);
@@ -25,6 +31,13 @@ describe('CI workflow definition', () => {
25
31
  'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 11',
26
32
  );
27
33
  expect(rendered).toContain("# Step 12\n # Nx's database cache needs artifact files");
34
+ expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
35
+ expect(rendered).toContain('id: setup');
36
+ expect(rendered).not.toContain('Setup Nix/devenv (fork)');
37
+ expect(rendered).not.toContain('Setup Nix/devenv (nixos)');
38
+ expect(rendered).not.toContain('github-actions-bootstrap.sh');
39
+ expect(rendered).toContain('uses: ./.github/actions/save-nix-devenv');
40
+ expect(rendered).toContain('runs-on: ubuntu-latest');
28
41
  });
29
42
 
30
43
  it('adds Cloudflare credentials for Wrangler-backed deploys', () => {
@@ -44,4 +57,27 @@ describe('CI workflow definition', () => {
44
57
  expect(restoreKey).toBe('${{ runner.os }}-${{ runner.arch }}-nx-db-v1-${{ github.sha }}');
45
58
  expect(saveKey).toBe(restoreKey);
46
59
  });
60
+
61
+ it('nixos config: fork PRs on ubuntu; one setup-devenv (composite owns host path)', () => {
62
+ const rendered = renderCiWorkflowYaml({
63
+ deploy: false,
64
+ pushBranches: ['main'],
65
+ runsOn: [...nixosRunsOn],
66
+ });
67
+
68
+ expect(rendered).toContain(
69
+ "runs-on:\n ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&\n fromJSON('[\"nixos-latest-x64\",\"self-hosted\"]') || 'ubuntu-latest' }}",
70
+ );
71
+ expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
72
+ expect(rendered).toContain('id: setup');
73
+ expect(rendered).not.toContain('Setup Nix/devenv (fork)');
74
+ expect(rendered).not.toContain('Setup Nix/devenv (nixos)');
75
+ expect(rendered).not.toContain('github-actions-bootstrap.sh');
76
+ expect(rendered).not.toContain('determinate-nix-action');
77
+ expect(rendered).toContain('if: always()');
78
+ expect(rendered).toContain('uses: ./.github/actions/save-nix-devenv');
79
+ // workflow yaml does not embed composite internals
80
+ expect(rendered).not.toContain('cache-node-modules');
81
+ expect(rendered).not.toContain('cache-ttsc-plugins');
82
+ });
47
83
  });
@@ -19,11 +19,14 @@ import {
19
19
  runPublishWorkflow,
20
20
  } from '../publish-workflow.js';
21
21
 
22
+ const nixosRunsOn = ['nixos-latest-x64', 'self-hosted'] as const;
23
+
22
24
  describe('publish workflow definition', () => {
23
25
  it('renders the checked-in local publish workflow copy', async () => {
24
26
  const rendered = renderPublishWorkflowYaml({
25
27
  repoName: '@smoothbricks/codebase',
26
28
  platformTargetGlobs: PLATFORM_TARGET_GLOBS,
29
+ runsOn: [...nixosRunsOn],
27
30
  });
28
31
  const packageRoot = join(import.meta.dir, '..', '..', '..');
29
32
  await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/publish.yml'), 'utf8')).resolves.toBe(
@@ -35,6 +38,7 @@ describe('publish workflow definition', () => {
35
38
  const rendered = renderPublishWorkflowYaml({
36
39
  repoName: '@smoothbricks/codebase',
37
40
  platformTargetGlobs: PLATFORM_TARGET_GLOBS,
41
+ runsOn: [...nixosRunsOn],
38
42
  });
39
43
 
40
44
  await expect(
@@ -125,6 +129,7 @@ describe('publish workflow definition', () => {
125
129
  const native = renderPublishWorkflowYaml({
126
130
  repoName: '@smoothbricks/codebase',
127
131
  platformTargetGlobs: PLATFORM_TARGET_GLOBS,
132
+ runsOn: [...nixosRunsOn],
128
133
  });
129
134
  const linuxCandidate = native.slice(
130
135
  native.indexOf(' linux-release-candidate:'),
@@ -137,8 +142,9 @@ describe('publish workflow definition', () => {
137
142
  expect(linuxOnly).toContain(
138
143
  `smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects`,
139
144
  );
140
- expect(native).toContain(' linux-release-candidate:\n runs-on: ubuntu-latest');
145
+ expect(native).toContain(' linux-release-candidate:');
141
146
  expect(native).toContain(' macos-platform:\n runs-on: macos-latest');
147
+ expect(native).toContain(' publish-on-linux:');
142
148
  expect(native).toContain(' publish-on-linux:\n needs: [linux-release-candidate, macos-platform]');
143
149
  expect(linuxCandidate).not.toContain('needs:');
144
150
  expect(macosPlatform).not.toContain('needs:');
@@ -612,3 +618,24 @@ function packageNameFromTag(tag: string): string {
612
618
  }
613
619
  return tag.slice(0, versionSeparator);
614
620
  }
621
+
622
+ it('linux publish jobs use smoo.github.runsOn; macOS stays macos-latest', () => {
623
+ const rendered = renderPublishWorkflowYaml({
624
+ repoName: '@smoothbricks/codebase',
625
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
626
+ runsOn: [...nixosRunsOn],
627
+ });
628
+ expect(rendered).toContain(
629
+ `runs-on:
630
+ \${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
631
+ fromJSON('["nixos-latest-x64","self-hosted"]') || 'ubuntu-latest' }}`,
632
+ );
633
+ expect(rendered).toContain(' macos-platform:\n runs-on: macos-latest');
634
+ // Only the pre-publish linux job runs on self-hosted
635
+ expect(rendered.split('fromJSON(\'["nixos-latest-x64","self-hosted"]\')').length - 1).toBe(1);
636
+ expect(rendered).not.toContain(' linux-release-candidate:\n runs-on: ubuntu-latest');
637
+ // NPM publishing happens from GitHub runner
638
+ expect(rendered).toContain(
639
+ ' publish-on-linux:\n needs: [linux-release-candidate, macos-platform]\n runs-on: ubuntu-latest',
640
+ );
641
+ });
@@ -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(
@@ -5,13 +5,16 @@ 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
7
  import {
8
+ deployTargetInfoFromProjects,
8
9
  extractInlineLocalBlocksForTest,
9
10
  INLINE_LOCAL_BEGIN,
10
11
  INLINE_LOCAL_END,
11
12
  LOCAL_SECTION_MARKER,
13
+ type NxGraphProjectNode,
12
14
  platformTargetGlobsForTest,
13
15
  reinsertInlineLocalBlocksForTest,
14
16
  splitLocalSectionForTest,
17
+ targetNamesFromProjects,
15
18
  } from './managed-files.js';
16
19
 
17
20
  const MANAGED = '# managed content\npath merge=driver\n';
@@ -172,6 +175,52 @@ describe('managed publish platform discovery', () => {
172
175
  });
173
176
  });
174
177
 
178
+ describe('nx graph project helpers', () => {
179
+ const sampleNodes: Record<string, NxGraphProjectNode> = {
180
+ lib: {
181
+ data: {
182
+ targets: {
183
+ build: {},
184
+ 'bundle-linux': {},
185
+ lint: {},
186
+ },
187
+ },
188
+ },
189
+ app: {
190
+ data: {
191
+ targets: {
192
+ deploy: {
193
+ options: { command: 'echo no' },
194
+ configurations: {
195
+ staging: { command: 'wrangler deploy --env staging' },
196
+ production: { options: { command: 'wrangler deploy --env production' } },
197
+ },
198
+ },
199
+ 'package-macos': {},
200
+ },
201
+ },
202
+ },
203
+ };
204
+
205
+ it('collects target names from graph nodes without project names', () => {
206
+ expect(targetNamesFromProjects(sampleNodes).sort()).toEqual(
207
+ ['build', 'bundle-linux', 'deploy', 'lint', 'package-macos'].sort(),
208
+ );
209
+ });
210
+
211
+ it('detects deploy configurations and cloudflare provider from graph nodes', () => {
212
+ expect(deployTargetInfoFromProjects(sampleNodes, 'staging')).toEqual({
213
+ exists: true,
214
+ provider: 'cloudflare',
215
+ });
216
+ expect(deployTargetInfoFromProjects(sampleNodes, 'production')).toEqual({
217
+ exists: true,
218
+ provider: 'cloudflare',
219
+ });
220
+ expect(deployTargetInfoFromProjects(sampleNodes, 'preview')).toEqual({ exists: false });
221
+ });
222
+ });
223
+
175
224
  describe('managed cache actions', () => {
176
225
  it('renders the checked-in action copies from their managed templates', async () => {
177
226
  for (const action of CACHE_ACTIONS) {
@@ -1,9 +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
+ // Nx package exports are CLI-oriented; this is the module the CLI uses for graph + daemon IPC.
6
+ import { createProjectGraphAsync } from 'nx/src/project-graph/project-graph.js';
7
+ import type { PackageJson } from '../lib/json.js';
7
8
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
8
9
  import { renderCiWorkflowYaml } from './ci-workflow.js';
9
10
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
@@ -137,6 +138,7 @@ interface ManagedFileContext {
137
138
  stagingDeployProvider?: 'cloudflare';
138
139
  productionDeployProvider?: 'cloudflare';
139
140
  ciPushBranches: string[];
141
+ ciRunsOn: string | string[];
140
142
  nodeModulesCacheKey: string;
141
143
  repoName: string;
142
144
  platformTargetGlobs: string[];
@@ -254,8 +256,8 @@ const managedFiles: ManagedFile[] = [
254
256
 
255
257
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
256
258
 
257
- export function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): FileResult[] {
258
- const context = getManagedFileContext(root);
259
+ export async function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): Promise<FileResult[]> {
260
+ const context = await getManagedFileContext(root);
259
261
  return managedFiles.map((file) => applyManagedFile(root, file, mode, context));
260
262
  }
261
263
 
@@ -306,6 +308,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
306
308
  deploy: context.hasStagingDeployTargets,
307
309
  deployProvider: context.stagingDeployProvider,
308
310
  pushBranches: context.ciPushBranches,
311
+ runsOn: context.ciRunsOn,
309
312
  });
310
313
  }
311
314
  if (file.source === 'publish-workflow') {
@@ -314,6 +317,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
314
317
  deployProvider: context.productionDeployProvider,
315
318
  repoName: context.repoName,
316
319
  platformTargetGlobs: context.platformTargetGlobs,
320
+ runsOn: context.ciRunsOn,
317
321
  });
318
322
  }
319
323
  throw new Error(`Unknown generated managed file source ${file.source}`);
@@ -327,13 +331,16 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
327
331
  return renderTemplate(context, content);
328
332
  }
329
333
 
330
- function getManagedFileContext(root: string): ManagedFileContext {
334
+ async function getManagedFileContext(root: string): Promise<ManagedFileContext> {
331
335
  const packageJson = readPackageJson(join(root, 'package.json'));
332
336
  const repoName = packageJson?.name ?? 'monorepo';
333
337
  const ciPushBranches = getCiPushBranches(packageJson?.json);
334
- const stagingDeploy = getDeployTargetInfo(root, 'staging');
335
- const productionDeploy = getDeployTargetInfo(root, 'production');
336
- const platformTargetGlobs = platformTargetGlobsForTest(readResolvedNxTargetNames(root));
338
+ const ciRunsOn = getCiRunsOn(packageJson?.json);
339
+ // In-process Nx API → daemon socket (no second Node/`nx` CLI process).
340
+ const nxProjects = await loadNxGraphProjects(root);
341
+ const stagingDeploy = deployTargetInfoFromProjects(nxProjects, 'staging');
342
+ const productionDeploy = deployTargetInfoFromProjects(nxProjects, 'production');
343
+ const platformTargetGlobs = platformTargetGlobsForTest(targetNamesFromProjects(nxProjects));
337
344
  const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
338
345
  ? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
339
346
  : `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
@@ -344,6 +351,7 @@ function getManagedFileContext(root: string): ManagedFileContext {
344
351
  stagingDeployProvider: stagingDeploy.provider,
345
352
  productionDeployProvider: productionDeploy.provider,
346
353
  ciPushBranches,
354
+ ciRunsOn,
347
355
  nodeModulesCacheKey,
348
356
  repoName,
349
357
  platformTargetGlobs,
@@ -358,81 +366,109 @@ export function platformTargetGlobsForTest(targetNames: Iterable<string>): strin
358
366
  });
359
367
  }
360
368
 
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
- }
369
+ /** Resolved Nx project node as returned under `graph.nodes` / project-graph.json. */
370
+ export type NxGraphProjectNode = {
371
+ data?: { targets?: Record<string, NxGraphTarget> };
372
+ targets?: Record<string, NxGraphTarget>;
373
+ };
374
+
375
+ type NxGraphTarget = {
376
+ command?: string;
377
+ options?: { command?: string };
378
+ configurations?: Record<string, { command?: string; options?: { command?: string } }>;
379
+ };
380
+
381
+ /**
382
+ * Load resolved project nodes via Nx's programmatic API.
383
+ *
384
+ * `createProjectGraphAsync` is what the CLI uses: with the daemon up it speaks the
385
+ * daemon unix socket instead of rebuilding the graph in a fresh Node process.
386
+ * Spawning `nx graph` / `nx show` only added CLI boot + plugin-worker overhead on
387
+ * top of that. smoo runs under Bun; Bun can import this CJS module directly.
388
+ */
389
+ async function loadNxGraphProjects(root: string): Promise<Record<string, NxGraphProjectNode>> {
390
+ const prevCwd = process.cwd();
391
+ process.chdir(root);
392
+ try {
393
+ const graph = (await createProjectGraphAsync()) as {
394
+ nodes: Record<string, { data?: { targets?: Record<string, NxGraphTarget> } }>;
395
+ };
396
+ const out: Record<string, NxGraphProjectNode> = {};
397
+ for (const [name, node] of Object.entries(graph.nodes)) {
398
+ out[name] = { data: { targets: node.data?.targets ?? {} } };
384
399
  }
400
+ return out;
401
+ } finally {
402
+ process.chdir(prevCwd);
385
403
  }
386
- return [...targetNames];
387
404
  }
388
405
 
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);
406
+ function targetsOfProject(node: NxGraphProjectNode): Record<string, NxGraphTarget> {
407
+ return node.data?.targets ?? node.targets ?? {};
394
408
  }
395
409
 
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 };
410
+ /** Test seam: collect target names from a graph.nodes map. */
411
+ export function targetNamesFromProjects(nodes: Record<string, NxGraphProjectNode>): string[] {
412
+ const targetNames = new Set<string>();
413
+ for (const node of Object.values(nodes)) {
414
+ for (const name of Object.keys(targetsOfProject(node))) {
415
+ targetNames.add(name);
416
+ }
405
417
  }
418
+ return [...targetNames];
419
+ }
420
+
421
+ /** Test seam: deploy configuration presence/provider from graph nodes. */
422
+ export function deployTargetInfoFromProjects(
423
+ nodes: Record<string, NxGraphProjectNode>,
424
+ configuration: string,
425
+ ): DeployTargetInfo {
406
426
  let exists = false;
407
427
  let provider: DeployTargetInfo['provider'];
408
- for (const project of projects) {
409
- const target = nxDeployTarget(root, project, configuration);
410
- if (!target.exists) {
428
+ for (const node of Object.values(nodes)) {
429
+ const info = deployTargetInfoFromTargets(targetsOfProject(node), configuration);
430
+ if (!info.exists) {
411
431
  continue;
412
432
  }
413
433
  exists = true;
414
- provider ??= target.provider;
434
+ provider ??= info.provider;
415
435
  }
416
436
  return { exists, provider };
417
437
  }
418
438
 
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];
439
+ function deployTargetInfoFromTargets(targets: Record<string, NxGraphTarget>, configuration: string): DeployTargetInfo {
440
+ const deploy = targets.deploy;
441
+ if (!deploy) {
442
+ return { exists: false };
443
+ }
444
+ const config = deploy.configurations?.[configuration];
428
445
  if (!config) {
429
446
  return { exists: false };
430
447
  }
431
- const commandValue = config.command ?? config.options?.command ?? deploy?.options?.command;
448
+ const commandValue = config.command ?? config.options?.command ?? deploy.options?.command ?? deploy.command;
432
449
  const command = typeof commandValue === 'string' ? commandValue : '';
433
450
  return { exists: true, provider: command.includes('wrangler ') ? 'cloudflare' : undefined };
434
451
  }
435
452
 
453
+ function renderTemplate(context: ManagedFileContext, template: string): string {
454
+ return template
455
+ .replaceAll('{{REPO_NAME}}', context.repoName)
456
+ .replaceAll('__SMOO_CI_PUSH_BRANCHES__', renderYamlFlowList(context.ciPushBranches))
457
+ .replaceAll('{{NODE_MODULES_CACHE_KEY}}', context.nodeModulesCacheKey);
458
+ }
459
+
460
+ function getCiRunsOn(packageJson: PackageJson | null | undefined): string | string[] {
461
+ const configured = packageJson?.smoo?.github?.runsOn;
462
+ if (configured === undefined) {
463
+ return 'ubuntu-latest';
464
+ }
465
+ if (typeof configured === 'string') {
466
+ return configured.length > 0 ? configured : 'ubuntu-latest';
467
+ }
468
+ const labels = configured.filter((label) => label.length > 0);
469
+ return labels.length > 0 ? labels : 'ubuntu-latest';
470
+ }
471
+
436
472
  function getCiPushBranches(packageJson: PackageJson | null | undefined): string[] {
437
473
  const configured = readCiPushBranches(packageJson);
438
474
  return configured.length > 0 ? configured : ['main'];
@@ -458,8 +494,8 @@ export function printResults(results: FileResult[]): void {
458
494
  }
459
495
  }
460
496
 
461
- export function validateManagedFiles(root: string): number {
462
- const results = applyManagedFiles(root, 'check');
497
+ export async function validateManagedFiles(root: string): Promise<number> {
498
+ const results = await applyManagedFiles(root, 'check');
463
499
  printResults(results);
464
500
  const failures = results.filter((result) => result.action === 'drifted').length;
465
501
  if (failures > 0) {
@@ -478,8 +514,8 @@ export function validateManagedFiles(root: string): number {
478
514
  * Under GitHub Actions the drifted-file count is published as the step output
479
515
  * `drifted`, so downstream steps gate declaratively instead of parsing logs.
480
516
  */
481
- export function warnOnManagedFileDrift(root: string): void {
482
- const results = applyManagedFiles(root, 'check');
517
+ export async function warnOnManagedFileDrift(root: string): Promise<void> {
518
+ const results = await applyManagedFiles(root, 'check');
483
519
  printResults(results);
484
520
  const drifted = results.filter((result) => result.action === 'drifted');
485
521
  if (process.env.GITHUB_OUTPUT) {
@@ -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) +