@smoothbricks/cli 0.10.6 → 0.10.8

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 (82) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +24 -1
  3. package/dist/github-ci/index.d.ts +44 -3
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +242 -36
  6. package/dist/lib/json.d.ts +1 -1
  7. package/dist/lib/json.d.ts.map +1 -1
  8. package/dist/lib/json.js +3 -18
  9. package/dist/lib/workspace.d.ts.map +1 -1
  10. package/dist/lib/workspace.js +1 -0
  11. package/dist/monorepo/ci-workflow.js +16 -6
  12. package/dist/monorepo/managed-files.d.ts +6 -22
  13. package/dist/monorepo/managed-files.d.ts.map +1 -1
  14. package/dist/monorepo/managed-files.js +31 -48
  15. package/dist/monorepo/package-policy.js +2 -2
  16. package/dist/monorepo/packs/index.d.ts.map +1 -1
  17. package/dist/monorepo/packs/index.js +2 -0
  18. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts +5 -0
  19. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts.map +1 -0
  20. package/dist/monorepo/pr-preview-cleanup-workflow.js +38 -0
  21. package/dist/monorepo/publish-workflow.js +3 -3
  22. package/dist/monorepo/runtime.d.ts +2 -2
  23. package/dist/monorepo/runtime.d.ts.map +1 -1
  24. package/dist/monorepo/runtime.js +11 -16
  25. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  26. package/dist/monorepo/tool-validation.js +105 -28
  27. package/dist/nx/index.d.ts +7 -4
  28. package/dist/nx/index.d.ts.map +1 -1
  29. package/dist/nx/index.js +117 -56
  30. package/dist/playwright/index.d.ts +22 -0
  31. package/dist/playwright/index.d.ts.map +1 -0
  32. package/dist/playwright/index.js +44 -0
  33. package/dist/release/bootstrap-npm-packages.d.ts +3 -0
  34. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -1
  35. package/dist/release/bootstrap-npm-packages.js +21 -0
  36. package/dist/release/index.d.ts.map +1 -1
  37. package/dist/release/index.js +17 -0
  38. package/dist/wrangler/cloudflare.d.ts +85 -0
  39. package/dist/wrangler/cloudflare.d.ts.map +1 -0
  40. package/dist/wrangler/cloudflare.js +235 -0
  41. package/dist/wrangler/deploy-environment.d.ts +48 -0
  42. package/dist/wrangler/deploy-environment.d.ts.map +1 -0
  43. package/dist/wrangler/deploy-environment.js +383 -0
  44. package/dist/wrangler/environment.d.ts +58 -0
  45. package/dist/wrangler/environment.d.ts.map +1 -0
  46. package/dist/wrangler/environment.js +297 -0
  47. package/managed/raw/tooling/devenv +71 -0
  48. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
  49. package/package.json +11 -4
  50. package/src/cli.ts +33 -3
  51. package/src/github-ci/index.test.ts +221 -1
  52. package/src/github-ci/index.ts +303 -32
  53. package/src/lib/json.ts +1 -1
  54. package/src/lib/workspace.ts +1 -0
  55. package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
  56. package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
  57. package/src/monorepo/__tests__/publish-workflow.test.ts +12 -4
  58. package/src/monorepo/ci-workflow.ts +16 -6
  59. package/src/monorepo/managed-files.test.ts +87 -23
  60. package/src/monorepo/managed-files.ts +35 -69
  61. package/src/monorepo/package-policy.test.ts +31 -0
  62. package/src/monorepo/package-policy.ts +2 -2
  63. package/src/monorepo/packs/index.ts +2 -0
  64. package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
  65. package/src/monorepo/publish-workflow.ts +7 -2
  66. package/src/monorepo/runtime.test.ts +23 -17
  67. package/src/monorepo/runtime.ts +12 -17
  68. package/src/monorepo/tool-validation.test.ts +100 -8
  69. package/src/monorepo/tool-validation.ts +115 -31
  70. package/src/nx/index.test.ts +45 -14
  71. package/src/nx/index.ts +133 -64
  72. package/src/playwright/index.test.ts +90 -0
  73. package/src/playwright/index.ts +73 -0
  74. package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
  75. package/src/release/bootstrap-npm-packages.ts +34 -0
  76. package/src/release/index.ts +18 -0
  77. package/src/wrangler/cloudflare.ts +289 -0
  78. package/src/wrangler/deploy-environment.test.ts +354 -0
  79. package/src/wrangler/deploy-environment.ts +445 -0
  80. package/src/wrangler/environment.test.ts +173 -0
  81. package/src/wrangler/environment.ts +366 -0
  82. package/managed/raw/patches/ttsc@0.19.3.patch +0 -67
package/src/lib/json.ts CHANGED
@@ -80,7 +80,7 @@ export type NxDependsOn = string | NxDependsOnObject;
80
80
  /** Nx target options are executor-specific open bags. */
81
81
  export type NxTargetOptions = Record<string, unknown>;
82
82
 
83
- export type NxInput = string | Record<string, unknown>;
83
+ export type NxInput = string | object;
84
84
 
85
85
  export interface NxTargetConfig {
86
86
  executor?: string;
@@ -129,6 +129,7 @@ function listWorkspacePackageJsonPaths(root: string, workspacePatterns: string[]
129
129
  const paths: string[] = [];
130
130
  for (const pattern of workspacePatterns) {
131
131
  if (!pattern.endsWith('/*')) {
132
+ paths.push(join(root, pattern, 'package.json'));
132
133
  continue;
133
134
  }
134
135
  const parent = join(root, pattern.slice(0, -2));
@@ -24,12 +24,12 @@ describe('CI workflow definition', () => {
24
24
  const rendered = renderCiWorkflowYaml({ deploy: true, pushBranches: ['main'] });
25
25
 
26
26
  expect(steps.map((step) => [step.kind, step.number])).toContainEqual([CiWorkflowStepKind.Deploy, 11]);
27
- expect(rendered).toContain('- name: 🚀 Deploy Staging');
27
+ expect(rendered.match(/- name: 🚀 Deploy Environment/g)).toHaveLength(1);
28
28
  expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
29
29
  expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
30
- expect(rendered).toContain(
31
- 'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 11',
32
- );
30
+ expect(rendered).toContain('smoo github-ci nx-deploy --mode run-many --name "Deploy Environment" --step 11');
31
+ expect(rendered).toContain('github.event.pull_request.head.repo.full_name == github.repository');
32
+ expect(rendered).toContain("github.ref == 'refs/heads/private'");
33
33
  expect(rendered).toContain("# Step 12\n # Nx's database cache needs artifact files");
34
34
  expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
35
35
  expect(rendered).toContain('id: setup');
@@ -45,6 +45,12 @@ describe('CI workflow definition', () => {
45
45
 
46
46
  expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
47
47
  expect(rendered).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
48
+ expect(rendered).toContain('GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }}');
49
+ expect(rendered).toContain('GITHUB_APP_PRIVATE_KEY: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}');
50
+ expect(rendered).toContain('GITHUB_APP_PRIVATE_KEY_PEM: ${{ secrets.GITHUB_APP_PRIVATE_KEY_PEM }}');
51
+ expect(rendered).toContain('OAUTH_STATE_SIGNING_KEY: ${{ secrets.OAUTH_STATE_SIGNING_KEY }}');
52
+ expect(rendered).toContain('MAIL_CAPTURE_CONTROL_TOKEN: ${{ secrets.MAIL_CAPTURE_CONTROL_TOKEN }}');
53
+ expect(rendered).toContain('TOKEN_ENCRYPTION_KEY: ${{ secrets.TOKEN_ENCRYPTION_KEY }}');
48
54
  });
49
55
 
50
56
  it('uses the same architecture-scoped key to restore and save the Nx cache', async () => {
@@ -0,0 +1,23 @@
1
+ /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: Assertions verify emitted GitHub Actions expressions literally. */
2
+ import { describe, expect, it } from 'bun:test';
3
+ import { managedFileTargetsForTest } from '../managed-files.js';
4
+ import { renderPrPreviewCleanupWorkflowYaml } from '../pr-preview-cleanup-workflow.js';
5
+
6
+ describe('PR preview cleanup workflow', () => {
7
+ it('renders one close-only same-repository cleanup job from the PR number', () => {
8
+ const rendered = renderPrPreviewCleanupWorkflowYaml({ runsOn: ['nixos-latest-x64', 'self-hosted'] });
9
+ expect(managedFileTargetsForTest).toContainEqual({
10
+ target: '.github/workflows/pr-preview-cleanup.yml',
11
+ executable: undefined,
12
+ });
13
+
14
+ expect(rendered).toContain('pull_request:\n types: [closed]');
15
+ expect(rendered).not.toContain('opened');
16
+ expect(rendered).not.toContain('synchronize');
17
+ expect(rendered).toContain('if: github.event.pull_request.head.repo.full_name == github.repository');
18
+ expect(rendered.match(/smoo wrangler cleanup-pr --pr/g)).toHaveLength(1);
19
+ expect(rendered).toContain('smoo wrangler cleanup-pr --pr ${{ github.event.pull_request.number }}');
20
+ expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
21
+ expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
22
+ });
23
+ });
@@ -99,7 +99,7 @@ describe('publish workflow definition', () => {
99
99
 
100
100
  expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 15 }, (_, index) => index + 1));
101
101
  expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 19 }, (_, index) => index + 1));
102
- expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 7 }, (_, index) => index + 1));
102
+ expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 8 }, (_, index) => index + 1));
103
103
  expect(stepAnchorNumbers(finalJob)).toEqual(Array.from({ length: 14 }, (_, index) => index + 1));
104
104
  expect(singleJob).toContain('# Step 14\n - name: 📦 Publish release (${{ steps.version.outputs.mode }})');
105
105
  expect(singleJob).toContain('# Step 15\n - name: 🧹 Cleanup and cache Nix/devenv');
@@ -109,7 +109,7 @@ describe('publish workflow definition', () => {
109
109
  );
110
110
  expect(linuxCandidate).toContain('# Step 19\n - name: 🧹 Cleanup and cache Nix/devenv');
111
111
  expect(macosPlatform).toContain('# Step 5\n - name: 🍎 Build selected macOS and iOS release outputs');
112
- expect(macosPlatform).toContain('# Step 7\n - name: 🧹 Cleanup and cache Nix/devenv');
112
+ expect(macosPlatform).toContain('# Step 8\n - name: 🧹 Cleanup and cache Nix/devenv');
113
113
  expect(finalJob).toContain('# Step 7\n - name: 🧯 Repair pending releases');
114
114
  expect(finalJob).toContain('# Step 8\n - name: ♻️ Restore validated release state');
115
115
  expect(finalJob).toContain('# Step 9\n - name: 📦 Apply verified Linux outputs');
@@ -156,12 +156,20 @@ describe('publish workflow definition', () => {
156
156
  expect(linuxCandidate).toContain(
157
157
  `smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects`,
158
158
  );
159
- expect(macosPlatform).not.toContain('smoo github-ci nx-run-many');
160
159
  expect(foldedRunCommand(macosPlatform, '🍎 Build selected macOS and iOS release outputs')).toBe(
161
160
  `smoo release build-platform-outputs --bump "\${{ inputs.bump }}" --ref "\${{ github.sha }}" --targets "${MACOS_PLATFORM_TARGET_GLOBS.join(
162
161
  ',',
163
162
  )}" --output "\${{ runner.temp }}/macos-platform-outputs"`,
164
163
  );
164
+ expect(macosPlatform).toContain(
165
+ `run: smoo github-ci nx-run-many --targets test --projects-with-targets "${MACOS_PLATFORM_TARGET_GLOBS.join(',')}"`,
166
+ );
167
+ expect(macosPlatform.indexOf('- name: 🍎 Build selected macOS and iOS release outputs')).toBeLessThan(
168
+ macosPlatform.indexOf('- name: 🧪 Unit test macOS and iOS packages'),
169
+ );
170
+ expect(macosPlatform.indexOf('- name: 🧪 Unit test macOS and iOS packages')).toBeLessThan(
171
+ macosPlatform.indexOf('- name: 📤 Upload macOS platform outputs'),
172
+ );
165
173
  expect(finalJob).not.toContain('smoo release version');
166
174
  expect(finalJob).not.toContain('nx-run-many');
167
175
  expect(foldedRunCommand(finalJob, '🧯 Repair pending releases')).toBe(
@@ -253,7 +261,7 @@ describe('publish workflow definition', () => {
253
261
  expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
254
262
  expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
255
263
  expect(rendered).toContain(
256
- 'smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
264
+ 'smoo github-ci nx-deploy --environment production --mode run-many --verify --name "Deploy Production"',
257
265
  );
258
266
  });
259
267
 
@@ -47,7 +47,7 @@ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkfl
47
47
  { kind: CiWorkflowStepKind.ManagedFilesDispatch, name: '🔁 Dispatch managed-file drift healing' },
48
48
  ];
49
49
  if (options.deploy) {
50
- steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Staging' });
50
+ steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Environment' });
51
51
  }
52
52
  steps.push(
53
53
  { kind: CiWorkflowStepKind.SaveNxCache, name: '💾 Save Nx cache' },
@@ -75,7 +75,7 @@ permissions:
75
75
  # actions:write lets the drift step dispatch the managed-files workflow.
76
76
  actions: write
77
77
  contents: read
78
- statuses: write
78
+ ${options.deploy ? ' deployments: write\n' : ''} statuses: write
79
79
 
80
80
  defaults:
81
81
  run:
@@ -197,11 +197,15 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
197
197
  case CiWorkflowStepKind.Deploy:
198
198
  return [
199
199
  ` - name: ${step.name}`,
200
- ' if:',
201
- " ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}',",
202
- ' github.event.repository.default_branch) }}',
200
+ ' if: >-',
201
+ ' ${{',
202
+ " (github.event_name == 'pull_request' &&",
203
+ ' contains(fromJSON(\'["opened","reopened","synchronize"]\'), github.event.action) &&',
204
+ ' github.event.pull_request.head.repo.full_name == github.repository) ||',
205
+ " (github.event_name == 'push' && github.ref == 'refs/heads/private')",
206
+ ' }}',
203
207
  ...deployEnvLines(options),
204
- ` run: smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step ${step.number}`,
208
+ ` run: smoo github-ci nx-deploy --mode run-many --name "Deploy Environment" --step ${step.number}`,
205
209
  ];
206
210
  case CiWorkflowStepKind.SaveNxCache:
207
211
  return [
@@ -251,6 +255,12 @@ function deployEnvLines(options: CiWorkflowDefinitionOptions): string[] {
251
255
  ' env:',
252
256
  ' CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}',
253
257
  ' CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
258
+ ' GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }}',
259
+ ' GITHUB_APP_PRIVATE_KEY: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}',
260
+ ' GITHUB_APP_PRIVATE_KEY_PEM: ${{ secrets.GITHUB_APP_PRIVATE_KEY_PEM }}',
261
+ ' OAUTH_STATE_SIGNING_KEY: ${{ secrets.OAUTH_STATE_SIGNING_KEY }}',
262
+ ' MAIL_CAPTURE_CONTROL_TOKEN: ${{ secrets.MAIL_CAPTURE_CONTROL_TOKEN }}',
263
+ ' TOKEN_ENCRYPTION_KEY: ${{ secrets.TOKEN_ENCRYPTION_KEY }}',
254
264
  ];
255
265
  }
256
266
 
@@ -1,20 +1,21 @@
1
1
  /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are asserted literally. */
2
2
  import { describe, expect, it } from 'bun:test';
3
- import { readFile } from 'node:fs/promises';
3
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
5
6
  import { LINUX_PLATFORM_TARGET_GLOBS, PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
6
7
  import fc from 'fast-check';
8
+ import { type NxProjects, targetNamesFromProjects } from '../nx/index.js';
7
9
  import {
8
10
  deployTargetInfoFromProjects,
9
11
  extractInlineLocalBlocksForTest,
10
12
  INLINE_LOCAL_BEGIN,
11
13
  INLINE_LOCAL_END,
12
14
  LOCAL_SECTION_MARKER,
13
- type NxGraphProjectNode,
15
+ managedFileTargetsForTest,
14
16
  platformTargetGlobsForTest,
15
17
  reinsertInlineLocalBlocksForTest,
16
18
  splitLocalSectionForTest,
17
- targetNamesFromProjects,
18
19
  } from './managed-files.js';
19
20
 
20
21
  const MANAGED = '# managed content\npath merge=driver\n';
@@ -176,48 +177,111 @@ describe('managed publish platform discovery', () => {
176
177
  });
177
178
 
178
179
  describe('nx graph project helpers', () => {
179
- const sampleNodes: Record<string, NxGraphProjectNode> = {
180
+ const sampleProjects: NxProjects = {
180
181
  lib: {
181
- data: {
182
- targets: {
183
- build: {},
184
- 'bundle-linux': {},
185
- lint: {},
186
- },
182
+ targets: {
183
+ build: {},
184
+ 'bundle-linux': {},
185
+ lint: {},
187
186
  },
188
187
  },
189
188
  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
- },
189
+ targets: {
190
+ deploy: {
191
+ options: { command: 'echo no' },
192
+ configurations: {
193
+ staging: { command: 'wrangler deploy --env staging' },
194
+ production: { options: { command: 'wrangler deploy --env production' } },
198
195
  },
199
- 'package-macos': {},
200
196
  },
197
+ 'package-macos': {},
201
198
  },
202
199
  },
203
200
  };
204
201
 
205
202
  it('collects target names from graph nodes without project names', () => {
206
- expect(targetNamesFromProjects(sampleNodes).sort()).toEqual(
203
+ expect(targetNamesFromProjects(sampleProjects).sort()).toEqual(
207
204
  ['build', 'bundle-linux', 'deploy', 'lint', 'package-macos'].sort(),
208
205
  );
209
206
  });
210
207
 
211
208
  it('detects deploy configurations and cloudflare provider from graph nodes', () => {
212
- expect(deployTargetInfoFromProjects(sampleNodes, 'staging')).toEqual({
209
+ expect(deployTargetInfoFromProjects(sampleProjects, 'staging')).toEqual({
213
210
  exists: true,
214
211
  provider: 'cloudflare',
215
212
  });
216
- expect(deployTargetInfoFromProjects(sampleNodes, 'production')).toEqual({
213
+ expect(deployTargetInfoFromProjects(sampleProjects, 'production')).toEqual({
217
214
  exists: true,
218
215
  provider: 'cloudflare',
219
216
  });
220
- expect(deployTargetInfoFromProjects(sampleNodes, 'preview')).toEqual({ exists: false });
217
+ expect(deployTargetInfoFromProjects(sampleProjects, 'preview')).toEqual({ exists: false });
218
+ });
219
+
220
+ it('recognizes convention-driven deploy-environment targets without per-environment configurations', () => {
221
+ const projects: NxProjects = {
222
+ app: {
223
+ targets: {
224
+ deploy: {
225
+ options: { command: 'smoo wrangler deploy-environment --environment {args.environment}' },
226
+ },
227
+ },
228
+ },
229
+ };
230
+
231
+ expect(deployTargetInfoFromProjects(projects, 'staging')).toEqual({ exists: true, provider: 'cloudflare' });
232
+ expect(deployTargetInfoFromProjects(projects, 'production')).toEqual({ exists: true, provider: 'cloudflare' });
233
+ });
234
+ });
235
+
236
+ describe('managed raw files', () => {
237
+ it('manages the devenv wrapper as an executable byte-exact copy', async () => {
238
+ expect(managedFileTargetsForTest).toContainEqual({ target: 'tooling/devenv', executable: true });
239
+
240
+ const [source, generated] = await Promise.all([
241
+ readFile(join(REPO_ROOT, 'packages', 'cli', 'managed', 'raw', 'tooling', 'devenv'), 'utf8'),
242
+ readFile(join(REPO_ROOT, 'tooling', 'devenv'), 'utf8'),
243
+ ]);
244
+
245
+ expect(generated).toBe(source);
246
+ });
247
+
248
+ it('exports the restored ttsc cache path while preserving host cache overrides', async () => {
249
+ const temp = await mkdtemp(join(tmpdir(), 'smoo-github-bootstrap-'));
250
+ const bin = join(temp, 'bin');
251
+ await mkdir(bin);
252
+ const devenv = join(bin, 'devenv');
253
+ await writeFile(devenv, '#!/bin/sh\nexit 0\n');
254
+ await chmod(devenv, 0o755);
255
+
256
+ try {
257
+ const cases = [
258
+ { input: '', expected: join(REPO_ROOT, '.cache', 'ttsc') },
259
+ { input: join(temp, 'host-ttsc'), expected: join(temp, 'host-ttsc') },
260
+ ];
261
+ for (const [index, cache] of cases.entries()) {
262
+ const githubEnv = join(temp, `github-env-${index}`);
263
+ const githubPath = join(temp, `github-path-${index}`);
264
+ const process = Bun.spawn(
265
+ [join(REPO_ROOT, 'tooling', 'direnv', 'github-actions-bootstrap.sh'), 'build-shell'],
266
+ {
267
+ cwd: join(REPO_ROOT, 'tooling', 'direnv'),
268
+ env: {
269
+ ...Bun.env,
270
+ GITHUB_ENV: githubEnv,
271
+ GITHUB_PATH: githubPath,
272
+ PATH: `${bin}:${Bun.env.PATH ?? ''}`,
273
+ TTSC_CACHE_DIR: cache.input,
274
+ },
275
+ stderr: 'pipe',
276
+ stdout: 'pipe',
277
+ },
278
+ );
279
+ expect(await process.exited).toBe(0);
280
+ expect(await readFile(githubEnv, 'utf8')).toContain(`TTSC_CACHE_DIR=${cache.expected}\n`);
281
+ }
282
+ } finally {
283
+ await rm(temp, { recursive: true, force: true });
284
+ }
221
285
  });
222
286
  });
223
287
 
@@ -2,11 +2,11 @@ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFi
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
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';
5
+ import type { NxTargetConfig, PackageJson } from '../lib/json.js';
8
6
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
7
+ import { loadNxProjects, type NxProjects, targetNamesFromProjects } from '../nx/index.js';
9
8
  import { renderCiWorkflowYaml } from './ci-workflow.js';
9
+ import { renderPrPreviewCleanupWorkflowYaml } from './pr-preview-cleanup-workflow.js';
10
10
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
11
11
 
12
12
  type ManagedKind = 'raw' | 'template' | 'generated';
@@ -25,6 +25,7 @@ interface ManagedFile {
25
25
  target: string;
26
26
  executable?: boolean;
27
27
  releasePackagesOnly?: boolean;
28
+ cloudflareDeployOnly?: boolean;
28
29
  }
29
30
 
30
31
  /** Split a managed target's content into the managed part and the repo-owned tail. */
@@ -155,6 +156,12 @@ const managedFiles: ManagedFile[] = [
155
156
  source: 'envrc',
156
157
  target: '.envrc',
157
158
  },
159
+ {
160
+ kind: 'raw',
161
+ source: 'tooling/devenv',
162
+ target: 'tooling/devenv',
163
+ executable: true,
164
+ },
158
165
  {
159
166
  kind: 'raw',
160
167
  source: 'tooling/direnv/repo-path',
@@ -201,16 +208,17 @@ const managedFiles: ManagedFile[] = [
201
208
  target: 'tooling/direnv/merge-newer-pins.sh',
202
209
  executable: true,
203
210
  },
204
- {
205
- kind: 'raw',
206
- source: 'patches/ttsc@0.19.3.patch',
207
- target: 'patches/ttsc@0.19.3.patch',
208
- },
209
211
  {
210
212
  kind: 'generated',
211
213
  source: 'ci-workflow',
212
214
  target: '.github/workflows/ci.yml',
213
215
  },
216
+ {
217
+ kind: 'generated',
218
+ source: 'pr-preview-cleanup-workflow',
219
+ target: '.github/workflows/pr-preview-cleanup.yml',
220
+ cloudflareDeployOnly: true,
221
+ },
214
222
  {
215
223
  kind: 'generated',
216
224
  source: 'publish-workflow',
@@ -254,6 +262,8 @@ const managedFiles: ManagedFile[] = [
254
262
  },
255
263
  ];
256
264
 
265
+ export const managedFileTargetsForTest = managedFiles.map(({ target, executable }) => ({ target, executable }));
266
+
257
267
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
258
268
 
259
269
  export async function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): Promise<FileResult[]> {
@@ -270,6 +280,9 @@ function applyManagedFile(
270
280
  if (file.releasePackagesOnly === true && !context.hasReleasePackages && !context.hasProductionDeployTargets) {
271
281
  return { target: file.target, action: 'skipped' };
272
282
  }
283
+ if (file.cloudflareDeployOnly === true && context.stagingDeployProvider !== 'cloudflare') {
284
+ return { target: file.target, action: 'skipped' };
285
+ }
273
286
  const target = resolve(root, file.target);
274
287
  const content = getManagedContent(file, context);
275
288
  if (existsSync(target)) {
@@ -320,6 +333,9 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
320
333
  runsOn: context.ciRunsOn,
321
334
  });
322
335
  }
336
+ if (file.source === 'pr-preview-cleanup-workflow') {
337
+ return renderPrPreviewCleanupWorkflowYaml({ runsOn: context.ciRunsOn });
338
+ }
323
339
  throw new Error(`Unknown generated managed file source ${file.source}`);
324
340
  }
325
341
  const sourceRoot = file.kind === 'raw' ? 'managed/raw' : 'managed/templates';
@@ -337,7 +353,7 @@ async function getManagedFileContext(root: string): Promise<ManagedFileContext>
337
353
  const ciPushBranches = getCiPushBranches(packageJson?.json);
338
354
  const ciRunsOn = getCiRunsOn(packageJson?.json);
339
355
  // In-process Nx API → daemon socket (no second Node/`nx` CLI process).
340
- const nxProjects = await loadNxGraphProjects(root);
356
+ const nxProjects = await loadNxProjects(root);
341
357
  const stagingDeploy = deployTargetInfoFromProjects(nxProjects, 'staging');
342
358
  const productionDeploy = deployTargetInfoFromProjects(nxProjects, 'production');
343
359
  const platformTargetGlobs = platformTargetGlobsForTest(targetNamesFromProjects(nxProjects));
@@ -366,67 +382,12 @@ export function platformTargetGlobsForTest(targetNames: Iterable<string>): strin
366
382
  });
367
383
  }
368
384
 
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 ?? {} } };
399
- }
400
- return out;
401
- } finally {
402
- process.chdir(prevCwd);
403
- }
404
- }
405
-
406
- function targetsOfProject(node: NxGraphProjectNode): Record<string, NxGraphTarget> {
407
- return node.data?.targets ?? node.targets ?? {};
408
- }
409
-
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
- }
417
- }
418
- return [...targetNames];
419
- }
420
-
421
385
  /** Test seam: deploy configuration presence/provider from graph nodes. */
422
- export function deployTargetInfoFromProjects(
423
- nodes: Record<string, NxGraphProjectNode>,
424
- configuration: string,
425
- ): DeployTargetInfo {
386
+ export function deployTargetInfoFromProjects(projects: NxProjects, configuration: string): DeployTargetInfo {
426
387
  let exists = false;
427
388
  let provider: DeployTargetInfo['provider'];
428
- for (const node of Object.values(nodes)) {
429
- const info = deployTargetInfoFromTargets(targetsOfProject(node), configuration);
389
+ for (const project of Object.values(projects)) {
390
+ const info = deployTargetInfoFromTargets(project.targets ?? {}, configuration);
430
391
  if (!info.exists) {
431
392
  continue;
432
393
  }
@@ -436,16 +397,21 @@ export function deployTargetInfoFromProjects(
436
397
  return { exists, provider };
437
398
  }
438
399
 
439
- function deployTargetInfoFromTargets(targets: Record<string, NxGraphTarget>, configuration: string): DeployTargetInfo {
400
+ function deployTargetInfoFromTargets(targets: Record<string, NxTargetConfig>, configuration: string): DeployTargetInfo {
440
401
  const deploy = targets.deploy;
441
402
  if (!deploy) {
442
403
  return { exists: false };
443
404
  }
405
+ const baseCommandValue = deploy.options?.command ?? deploy.command;
406
+ const baseCommand = typeof baseCommandValue === 'string' ? baseCommandValue : '';
407
+ if (baseCommand.includes('smoo wrangler deploy-environment')) {
408
+ return { exists: true, provider: 'cloudflare' };
409
+ }
444
410
  const config = deploy.configurations?.[configuration];
445
411
  if (!config) {
446
412
  return { exists: false };
447
413
  }
448
- const commandValue = config.command ?? config.options?.command ?? deploy.options?.command ?? deploy.command;
414
+ const commandValue = config.command ?? config.options?.command ?? baseCommand;
449
415
  const command = typeof commandValue === 'string' ? commandValue : '';
450
416
  return { exists: true, provider: command.includes('wrangler ') ? 'cloudflare' : undefined };
451
417
  }
@@ -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
  }
@@ -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
  ]),
@@ -0,0 +1,44 @@
1
+ /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are emitted literally. */
2
+
3
+ import { renderRunsOnLine } from './github-runs-on.js';
4
+
5
+ export interface PrPreviewCleanupWorkflowOptions {
6
+ runsOn?: string | string[];
7
+ }
8
+
9
+ export function renderPrPreviewCleanupWorkflowYaml(options: PrPreviewCleanupWorkflowOptions = {}): string {
10
+ return `name: PR Preview Cleanup
11
+
12
+ on:
13
+ pull_request:
14
+ types: [closed]
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ jobs:
20
+ cleanup:
21
+ name: Cleanup PR environment
22
+ if: github.event.pull_request.head.repo.full_name == github.repository
23
+ ${renderRunsOnLine(options.runsOn)}
24
+ timeout-minutes: 15
25
+ defaults:
26
+ run:
27
+ working-directory: tooling/direnv
28
+ steps:
29
+ - name: Checkout
30
+ uses: actions/checkout@v6.0.2
31
+ with:
32
+ filter: blob:none
33
+ fetch-depth: 1
34
+
35
+ - name: Setup Nix/devenv
36
+ uses: ./.github/actions/setup-devenv
37
+
38
+ - name: Cleanup PR environment
39
+ env:
40
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
41
+ CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
42
+ run: smoo wrangler cleanup-pr --pr \${{ github.event.pull_request.number }}
43
+ `;
44
+ }