@smoothbricks/cli 0.10.7 → 0.10.9

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 (58) 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 -4
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +220 -34
  6. package/dist/monorepo/ci-workflow.js +16 -6
  7. package/dist/monorepo/managed-files.d.ts.map +1 -1
  8. package/dist/monorepo/managed-files.js +19 -1
  9. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts +5 -0
  10. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts.map +1 -0
  11. package/dist/monorepo/pr-preview-cleanup-workflow.js +38 -0
  12. package/dist/monorepo/publish-workflow.js +3 -3
  13. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  14. package/dist/monorepo/tool-validation.js +85 -5
  15. package/dist/playwright/index.d.ts +22 -0
  16. package/dist/playwright/index.d.ts.map +1 -0
  17. package/dist/playwright/index.js +44 -0
  18. package/dist/release/bootstrap-npm-packages.d.ts +3 -0
  19. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -1
  20. package/dist/release/bootstrap-npm-packages.js +21 -0
  21. package/dist/release/index.d.ts +1 -0
  22. package/dist/release/index.d.ts.map +1 -1
  23. package/dist/release/index.js +31 -6
  24. package/dist/wrangler/cloudflare.d.ts +87 -0
  25. package/dist/wrangler/cloudflare.d.ts.map +1 -0
  26. package/dist/wrangler/cloudflare.js +238 -0
  27. package/dist/wrangler/deploy-environment.d.ts +48 -0
  28. package/dist/wrangler/deploy-environment.d.ts.map +1 -0
  29. package/dist/wrangler/deploy-environment.js +383 -0
  30. package/dist/wrangler/environment.d.ts +58 -0
  31. package/dist/wrangler/environment.d.ts.map +1 -0
  32. package/dist/wrangler/environment.js +297 -0
  33. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
  34. package/package.json +9 -2
  35. package/src/cli.ts +27 -3
  36. package/src/github-ci/index.test.ts +175 -2
  37. package/src/github-ci/index.ts +274 -31
  38. package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
  39. package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
  40. package/src/monorepo/__tests__/publish-workflow.test.ts +7 -5
  41. package/src/monorepo/ci-workflow.ts +16 -6
  42. package/src/monorepo/managed-files.test.ts +56 -1
  43. package/src/monorepo/managed-files.ts +20 -1
  44. package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
  45. package/src/monorepo/publish-workflow.ts +8 -5
  46. package/src/monorepo/tool-validation.test.ts +85 -0
  47. package/src/monorepo/tool-validation.ts +94 -5
  48. package/src/playwright/index.test.ts +90 -0
  49. package/src/playwright/index.ts +73 -0
  50. package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
  51. package/src/release/bootstrap-npm-packages.ts +34 -0
  52. package/src/release/index.ts +30 -4
  53. package/src/wrangler/cloudflare.test.ts +76 -0
  54. package/src/wrangler/cloudflare.ts +292 -0
  55. package/src/wrangler/deploy-environment.test.ts +354 -0
  56. package/src/wrangler/deploy-environment.ts +445 -0
  57. package/src/wrangler/environment.test.ts +173 -0
  58. package/src/wrangler/environment.ts +366 -0
@@ -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,6 +1,7 @@
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';
@@ -215,6 +216,21 @@ describe('nx graph project helpers', () => {
215
216
  });
216
217
  expect(deployTargetInfoFromProjects(sampleProjects, 'preview')).toEqual({ exists: false });
217
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
+ });
218
234
  });
219
235
 
220
236
  describe('managed raw files', () => {
@@ -228,6 +244,45 @@ describe('managed raw files', () => {
228
244
 
229
245
  expect(generated).toBe(source);
230
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
+ }
285
+ });
231
286
  });
232
287
 
233
288
  describe('managed cache actions', () => {
@@ -6,6 +6,7 @@ import type { NxTargetConfig, PackageJson } from '../lib/json.js';
6
6
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
7
7
  import { loadNxProjects, type NxProjects, targetNamesFromProjects } from '../nx/index.js';
8
8
  import { renderCiWorkflowYaml } from './ci-workflow.js';
9
+ import { renderPrPreviewCleanupWorkflowYaml } from './pr-preview-cleanup-workflow.js';
9
10
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
10
11
 
11
12
  type ManagedKind = 'raw' | 'template' | 'generated';
@@ -24,6 +25,7 @@ interface ManagedFile {
24
25
  target: string;
25
26
  executable?: boolean;
26
27
  releasePackagesOnly?: boolean;
28
+ cloudflareDeployOnly?: boolean;
27
29
  }
28
30
 
29
31
  /** Split a managed target's content into the managed part and the repo-owned tail. */
@@ -211,6 +213,12 @@ const managedFiles: ManagedFile[] = [
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',
@@ -272,6 +280,9 @@ function applyManagedFile(
272
280
  if (file.releasePackagesOnly === true && !context.hasReleasePackages && !context.hasProductionDeployTargets) {
273
281
  return { target: file.target, action: 'skipped' };
274
282
  }
283
+ if (file.cloudflareDeployOnly === true && context.stagingDeployProvider !== 'cloudflare') {
284
+ return { target: file.target, action: 'skipped' };
285
+ }
275
286
  const target = resolve(root, file.target);
276
287
  const content = getManagedContent(file, context);
277
288
  if (existsSync(target)) {
@@ -322,6 +333,9 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
322
333
  runsOn: context.ciRunsOn,
323
334
  });
324
335
  }
336
+ if (file.source === 'pr-preview-cleanup-workflow') {
337
+ return renderPrPreviewCleanupWorkflowYaml({ runsOn: context.ciRunsOn });
338
+ }
325
339
  throw new Error(`Unknown generated managed file source ${file.source}`);
326
340
  }
327
341
  const sourceRoot = file.kind === 'raw' ? 'managed/raw' : 'managed/templates';
@@ -388,11 +402,16 @@ function deployTargetInfoFromTargets(targets: Record<string, NxTargetConfig>, co
388
402
  if (!deploy) {
389
403
  return { exists: false };
390
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
+ }
391
410
  const config = deploy.configurations?.[configuration];
392
411
  if (!config) {
393
412
  return { exists: false };
394
413
  }
395
- const commandValue = config.command ?? config.options?.command ?? deploy.options?.command ?? deploy.command;
414
+ const commandValue = config.command ?? config.options?.command ?? baseCommand;
396
415
  const command = typeof commandValue === 'string' ? commandValue : '';
397
416
  return { exists: true, provider: command.includes('wrangler ') ? 'cloudflare' : undefined };
398
417
  }
@@ -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
+ }
@@ -486,7 +486,7 @@ function deployProductionStep(step: PublishWorkflowStep, options: PublishWorkflo
486
486
  " ${{ steps.version.outputs.mode != 'none' && inputs.deploy_environment == 'production' && inputs.dry_run !=",
487
487
  " 'true' }}",
488
488
  ...deployEnvLines(options),
489
- ' run: smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
489
+ ' run: smoo github-ci nx-deploy --environment production --mode run-many --verify --name "Deploy Production"',
490
490
  ];
491
491
  }
492
492
 
@@ -778,16 +778,19 @@ function renderMacosPlatformSteps(options: PublishWorkflowDefinitionOptions): st
778
778
  '',
779
779
  ` # Step ${stepNumber++}`,
780
780
  ' - name: 🍎 Build selected macOS and iOS release outputs',
781
+ ' id: platform-outputs',
781
782
  ' run:',
782
783
  ` smoo release build-platform-outputs --bump "${githubExpression(
783
784
  'inputs.bump',
784
785
  )}" --ref "${githubExpression('github.sha')}" --targets "${MACOS_PLATFORM_TARGET_GLOBS.join(',')}" --output`,
785
- ` "${githubExpression('runner.temp')}/macos-platform-outputs"`,
786
+ ` "${githubExpression('runner.temp')}/macos-platform-outputs" --github-output "$GITHUB_OUTPUT"`,
786
787
  '',
787
788
  ` # Step ${stepNumber++}`,
788
- ' - name: 🧪 Unit test macOS and iOS packages',
789
+ ' - name: 🧪 Unit test selected macOS and iOS packages',
790
+ ` if: steps.platform-outputs.outputs.projects != ''`,
789
791
  ' run:',
790
- ` smoo github-ci nx-run-many --targets test --projects-with-targets "${MACOS_PLATFORM_TARGET_GLOBS.join(',')}"`,
792
+ ' smoo github-ci nx-run-many --targets test --projects',
793
+ ` "${githubExpression('steps.platform-outputs.outputs.projects')}"`,
791
794
  '',
792
795
  ` # Step ${stepNumber++}`,
793
796
  ' - name: 📤 Upload macOS platform outputs',
@@ -940,7 +943,7 @@ function renderFinalLinuxPublishSteps(options: PublishWorkflowDefinitionOptions)
940
943
  " ${{ needs.linux-release-candidate.outputs.mode != 'none' && inputs.deploy_environment == 'production' &&",
941
944
  " inputs.dry_run != 'true' }}",
942
945
  ...deployEnvLines(options),
943
- ' run: smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
946
+ ' run: smoo github-ci nx-deploy --environment production --mode run-many --verify --name "Deploy Production"',
944
947
  );
945
948
  }
946
949
  lines.push(
@@ -3,10 +3,12 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import {
6
+ applyDevenvPackageDefaults,
6
7
  applyRootDevDependencyDefaults,
7
8
  applyToolConfigDefaults,
8
9
  applyToolingPackageDefaults,
9
10
  readToolContext,
11
+ validateDevenvPackages,
10
12
  validateToolConfig,
11
13
  } from './tool-validation.js';
12
14
 
@@ -89,6 +91,88 @@ describe('tool configuration validation', () => {
89
91
  expect(devenv).toContain('nodejs_latest');
90
92
  expect(devenv).toContain('coreutils');
91
93
  expect(devenv).toContain('git-format-staged');
94
+ expect(devenv).toContain('go # Builds ttsc source plugins');
95
+ expect(devenv).not.toContain('sccache');
96
+ } finally {
97
+ await rm(root, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ it('requires Go for managed TypeScript source-plugin builds without forcing Rust tools', async () => {
102
+ const root = await mkdtemp(join(tmpdir(), 'smoo-tool-validation-'));
103
+ try {
104
+ const devenvPath = join(root, 'tooling/direnv/devenv.nix');
105
+ await mkdir(dirname(devenvPath), { recursive: true });
106
+ await writeFile(
107
+ devenvPath,
108
+ `{
109
+ pkgs,
110
+ ...
111
+ }: {
112
+ packages = with pkgs; [
113
+ nodejs_latest
114
+ bun
115
+ git
116
+ git-format-staged
117
+ jq
118
+ alejandra
119
+ coreutils
120
+ gnutar
121
+ ];
122
+ }
123
+ `,
124
+ );
125
+
126
+ expect(validateDevenvPackages(root)).toBe(1);
127
+ applyDevenvPackageDefaults(root);
128
+ expect(validateDevenvPackages(root)).toBe(0);
129
+ const devenv = await readFile(devenvPath, 'utf8');
130
+ expect(devenv).toContain('go # Builds ttsc source plugins');
131
+ expect(devenv).not.toContain('sccache');
132
+ } finally {
133
+ await rm(root, { recursive: true, force: true });
134
+ }
135
+ });
136
+
137
+ it('requires Rust compiler helpers only when the workspace contains Cargo manifests', async () => {
138
+ const root = await mkdtemp(join(tmpdir(), 'smoo-tool-validation-'));
139
+ try {
140
+ const crateRoot = join(root, 'packages/native');
141
+ await mkdir(crateRoot, { recursive: true });
142
+ await writeFile(join(crateRoot, 'Cargo.toml'), '[package]\nname = "native"\nversion = "0.0.0"\n');
143
+ const devenvPath = join(root, 'tooling/direnv/devenv.nix');
144
+ await mkdir(dirname(devenvPath), { recursive: true });
145
+ await writeFile(
146
+ devenvPath,
147
+ `{
148
+ pkgs,
149
+ lib,
150
+ ...
151
+ }: {
152
+ packages = with pkgs; [
153
+ nodejs_latest
154
+ bun
155
+ git
156
+ git-format-staged
157
+ jq
158
+ alejandra
159
+ coreutils
160
+ gnutar
161
+ go
162
+ stdenv.cc.cc.lib
163
+ ];
164
+ }
165
+ `,
166
+ );
167
+
168
+ expect(validateDevenvPackages(root)).toBe(2);
169
+ applyDevenvPackageDefaults(root);
170
+ expect(validateDevenvPackages(root)).toBe(0);
171
+ const devenv = await readFile(devenvPath, 'utf8');
172
+ expect(devenv).toContain('sccache # Rust compiler wrapper and cache');
173
+ expect(devenv).toContain('++ lib.optionals pkgs.stdenv.isLinux [');
174
+ expect(devenv).toContain('pkgs.stdenv.cc');
175
+ expect(devenv).toContain('stdenv.cc.cc.lib');
92
176
  } finally {
93
177
  await rm(root, { recursive: true, force: true });
94
178
  }
@@ -141,6 +225,7 @@ describe('tool configuration validation', () => {
141
225
  alejandra
142
226
  coreutils
143
227
  gnutar
228
+ go
144
229
  ];
145
230
  }
146
231
  `,
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import typia from 'typia';
4
4
  import { cliPackageVersion, isSmoothBricksCodebasePackageName } from '../lib/cli-package.js';
@@ -67,12 +67,24 @@ const rootDevDependencies: RequiredDependency[] = [
67
67
 
68
68
  const cliPackageName = '@smoothbricks/cli';
69
69
 
70
- const requiredDevenvPackages = ['bun', 'git', 'git-format-staged', 'jq', 'alejandra', 'coreutils', 'gnutar'];
70
+ const requiredDevenvPackages = ['bun', 'git', 'git-format-staged', 'jq', 'alejandra', 'coreutils', 'gnutar', 'go'];
71
71
  // Any explicit nodejs provider is fine — the pinned major is the repo's choice
72
72
  // (Lambda parity, org convergence, …). Whether the pins in package.json agree
73
73
  // with the runtime is validated against the live PATH (validateRootRuntimeVersions),
74
74
  // never against a version template here.
75
75
  const nodePackagePattern = /(^|\s)nodejs(_\d+|_latest)?(\s|#|$)/m;
76
+ const requiredRustDevenvPackages = ['sccache'];
77
+ const linuxCompilerPackage = 'pkgs.stdenv.cc';
78
+ const ignoredNativeManifestDirectories = new Set([
79
+ '.devenv',
80
+ '.direnv',
81
+ '.git',
82
+ '.nx',
83
+ 'dist',
84
+ 'node_modules',
85
+ 'target',
86
+ 'vendor',
87
+ ]);
76
88
 
77
89
  const obsoleteTtscPatchedDependencyKey = 'ttsc@0.19.3';
78
90
 
@@ -201,7 +213,9 @@ export function applyDevenvPackageDefaults(root: string): void {
201
213
  changed = next !== content || changed;
202
214
  content = next;
203
215
  }
204
- for (const name of requiredDevenvPackages) {
216
+ const compilesRust = workspaceCompilesRust(root);
217
+ const requiredPackages = [...requiredDevenvPackages, ...(compilesRust ? requiredRustDevenvPackages : [])];
218
+ for (const name of requiredPackages) {
205
219
  if (hasNixPackage(content, name)) {
206
220
  continue;
207
221
  }
@@ -209,6 +223,11 @@ export function applyDevenvPackageDefaults(root: string): void {
209
223
  changed = next !== content || changed;
210
224
  content = next;
211
225
  }
226
+ if (compilesRust && !hasLinuxCompilerPackage(content)) {
227
+ const next = addLinuxCompilerPackage(content);
228
+ changed = next !== content || changed;
229
+ content = next;
230
+ }
212
231
  if (changed) {
213
232
  writeFileSync(path, content);
214
233
  console.log('updated tooling/direnv/devenv.nix packages');
@@ -316,12 +335,20 @@ export function validateDevenvPackages(root: string): number {
316
335
  );
317
336
  failures++;
318
337
  }
319
- for (const name of requiredDevenvPackages) {
338
+ const compilesRust = workspaceCompilesRust(root);
339
+ const requiredPackages = [...requiredDevenvPackages, ...(compilesRust ? requiredRustDevenvPackages : [])];
340
+ for (const name of requiredPackages) {
320
341
  if (!hasNixPackage(content, name)) {
321
342
  console.error(`tooling/direnv/devenv.nix packages must include ${name}`);
322
343
  failures++;
323
344
  }
324
345
  }
346
+ if (compilesRust && !hasLinuxCompilerPackage(content)) {
347
+ console.error(
348
+ 'tooling/direnv/devenv.nix packages must include pkgs.stdenv.cc in a Linux-only package block for Rust builds',
349
+ );
350
+ failures++;
351
+ }
325
352
  return failures;
326
353
  }
327
354
 
@@ -330,7 +357,9 @@ function addNixPackage(content: string, name: string, comment: string): string {
330
357
  if (hasNixPackage(content, name)) {
331
358
  return content;
332
359
  }
333
- const packagesStart = content.indexOf(' packages = with pkgs; [');
360
+ const directPackagesStart = content.indexOf(' packages = with pkgs; [');
361
+ const groupedPackagesStart = content.indexOf(' (with pkgs; [');
362
+ const packagesStart = directPackagesStart === -1 ? groupedPackagesStart : directPackagesStart;
334
363
  if (packagesStart === -1) {
335
364
  return content;
336
365
  }
@@ -342,6 +371,60 @@ function hasNixPackage(content: string, name: string): boolean {
342
371
  return new RegExp(`(^|\\s)${escapeRegex(name)}(\\s|#|$)`, 'm').test(content);
343
372
  }
344
373
 
374
+ function workspaceCompilesRust(root: string): boolean {
375
+ return containsCargoManifest(root);
376
+ }
377
+
378
+ function containsCargoManifest(directory: string): boolean {
379
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
380
+ if (entry.isFile() && entry.name === 'Cargo.toml') {
381
+ return true;
382
+ }
383
+ if (
384
+ entry.isDirectory() &&
385
+ entry.name.startsWith('.') === false &&
386
+ ignoredNativeManifestDirectories.has(entry.name) === false &&
387
+ containsCargoManifest(join(directory, entry.name))
388
+ ) {
389
+ return true;
390
+ }
391
+ }
392
+ return false;
393
+ }
394
+
395
+ function hasLinuxCompilerPackage(content: string): boolean {
396
+ const linuxPackages = /\+\+\s+lib\.optionals\s+pkgs\.stdenv\.isLinux\s+\[([\s\S]*?)\]/g;
397
+ return [...content.matchAll(linuxPackages)].some((match) => hasNixPackage(match[1] ?? '', linuxCompilerPackage));
398
+ }
399
+
400
+ function addLinuxCompilerPackage(content: string): string {
401
+ if (hasLinuxCompilerPackage(content)) {
402
+ return content;
403
+ }
404
+ const directStart = content.indexOf(' packages = with pkgs; [');
405
+ if (directStart !== -1) {
406
+ const listEnd = content.indexOf('\n ];', directStart);
407
+ if (listEnd === -1) {
408
+ return content;
409
+ }
410
+ const listBody = content.slice(directStart + ' packages = with pkgs; ['.length, listEnd);
411
+ const replacement = ` packages =\n (with pkgs; [${listBody}\n ])\n ++ lib.optionals pkgs.stdenv.isLinux [\n ${linuxCompilerPackage}\n ];`;
412
+ return `${content.slice(0, directStart)}${replacement}${content.slice(listEnd + '\n ];'.length)}`;
413
+ }
414
+
415
+ const packagesStart = content.indexOf(' packages =');
416
+ const nextOption = content.indexOf('\n\n ', packagesStart);
417
+ if (packagesStart === -1 || nextOption === -1) {
418
+ return content;
419
+ }
420
+ const terminator = content.lastIndexOf(';', nextOption);
421
+ if (terminator < packagesStart) {
422
+ return content;
423
+ }
424
+ const linuxPackages = `\n ++ lib.optionals pkgs.stdenv.isLinux [\n ${linuxCompilerPackage}\n ]`;
425
+ return `${content.slice(0, terminator)}${linuxPackages}${content.slice(terminator)}`;
426
+ }
427
+
345
428
  function nixPackageComment(name: string): string {
346
429
  if (name === 'coreutils') {
347
430
  return '# Provides fmt for commit message wrapping';
@@ -352,6 +435,12 @@ function nixPackageComment(name: string): string {
352
435
  if (name === 'git') {
353
436
  return '# Git hooks and repository inspection';
354
437
  }
438
+ if (name === 'go') {
439
+ return '# Builds ttsc source plugins';
440
+ }
441
+ if (name === 'sccache') {
442
+ return '# Rust compiler wrapper and cache';
443
+ }
355
444
  return '';
356
445
  }
357
446
 
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { type ChromiumSetupDependencies, ensureChromium } from './index.js';
3
+
4
+ interface RunCall {
5
+ readonly command: string;
6
+ readonly args: string[];
7
+ readonly cwd: string;
8
+ readonly env?: Record<string, string>;
9
+ }
10
+
11
+ function setupDependencies(
12
+ env: ChromiumSetupDependencies['env'],
13
+ existingPaths: readonly string[],
14
+ calls: RunCall[],
15
+ ): ChromiumSetupDependencies {
16
+ const existing = new Set(existingPaths);
17
+ return {
18
+ env,
19
+ exists: (path) => existing.has(path),
20
+ run: async (command, args, cwd, runEnv) => {
21
+ calls.push({ command, args, cwd, ...(runEnv ? { env: runEnv } : {}) });
22
+ },
23
+ };
24
+ }
25
+
26
+ describe('ensureChromium', () => {
27
+ it('uses preinstalled Chrome without invoking Playwright install on an ephemeral GitHub runner', async () => {
28
+ const calls: RunCall[] = [];
29
+ const executablePath = '/fixture/google-chrome';
30
+
31
+ const result = await ensureChromium(
32
+ '/workspace/package',
33
+ setupDependencies(
34
+ { GITHUB_ACTIONS: 'true', PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: executablePath },
35
+ [executablePath],
36
+ calls,
37
+ ),
38
+ );
39
+
40
+ expect(result).toEqual({ mode: 'system', executablePath });
41
+ expect(calls).toEqual([]);
42
+ });
43
+
44
+ it('installs Chromium into the persistent host-runner cache', async () => {
45
+ const calls: RunCall[] = [];
46
+
47
+ const result = await ensureChromium(
48
+ '/workspace/package',
49
+ setupDependencies({ GITHUB_ACTIONS: 'true' }, ['/var/cache/ci'], calls),
50
+ );
51
+
52
+ expect(result).toEqual({ mode: 'persistent-cache', browserCachePath: '/var/cache/ci/ms-playwright' });
53
+ expect(calls).toEqual([
54
+ {
55
+ command: 'playwright',
56
+ args: ['install', 'chromium', '--only-shell'],
57
+ cwd: '/workspace/package',
58
+ env: { PLAYWRIGHT_BROWSERS_PATH: '/var/cache/ci/ms-playwright' },
59
+ },
60
+ ]);
61
+ });
62
+
63
+ it('fails rather than downloading when an ephemeral GitHub runner has no system Chrome', async () => {
64
+ const calls: RunCall[] = [];
65
+
66
+ await expect(
67
+ ensureChromium('/workspace/package', setupDependencies({ GITHUB_ACTIONS: 'true' }, [], calls)),
68
+ ).rejects.toThrow('Refusing to download');
69
+ expect(calls).toEqual([]);
70
+ });
71
+
72
+ it('honors an explicit developer cache outside GitHub Actions', async () => {
73
+ const calls: RunCall[] = [];
74
+
75
+ const result = await ensureChromium(
76
+ '/workspace/package',
77
+ setupDependencies({ PLAYWRIGHT_BROWSERS_PATH: '/tmp/browser-cache' }, [], calls),
78
+ );
79
+
80
+ expect(result).toEqual({ mode: 'developer-cache', browserCachePath: '/tmp/browser-cache' });
81
+ expect(calls).toEqual([
82
+ {
83
+ command: 'playwright',
84
+ args: ['install', 'chromium', '--only-shell'],
85
+ cwd: '/workspace/package',
86
+ env: { PLAYWRIGHT_BROWSERS_PATH: '/tmp/browser-cache' },
87
+ },
88
+ ]);
89
+ });
90
+ });