@smoothbricks/cli 0.3.4 → 0.4.1

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 (42) hide show
  1. package/dist/cli.js +14 -0
  2. package/dist/github-ci/index.d.ts +12 -0
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +72 -4
  5. package/dist/lib/run.d.ts +1 -0
  6. package/dist/lib/run.d.ts.map +1 -1
  7. package/dist/lib/run.js +11 -0
  8. package/dist/monorepo/ci-workflow.d.ts +26 -0
  9. package/dist/monorepo/ci-workflow.d.ts.map +1 -0
  10. package/dist/monorepo/ci-workflow.js +201 -0
  11. package/dist/monorepo/managed-files.d.ts.map +1 -1
  12. package/dist/monorepo/managed-files.js +97 -5
  13. package/dist/monorepo/nx-sync.d.ts.map +1 -1
  14. package/dist/monorepo/nx-sync.js +18 -4
  15. package/dist/monorepo/package-hygiene.d.ts +5 -0
  16. package/dist/monorepo/package-hygiene.d.ts.map +1 -1
  17. package/dist/monorepo/package-hygiene.js +18 -4
  18. package/dist/monorepo/package-policy.d.ts.map +1 -1
  19. package/dist/monorepo/package-policy.js +4 -2
  20. package/dist/monorepo/packed-package.js +5 -4
  21. package/dist/monorepo/packs/index.js +6 -2
  22. package/dist/monorepo/publish-workflow.d.ts +7 -1
  23. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  24. package/dist/monorepo/publish-workflow.js +59 -15
  25. package/managed/raw/git-format-staged.yml +4 -0
  26. package/package.json +2 -2
  27. package/src/cli.ts +35 -5
  28. package/src/github-ci/index.ts +94 -8
  29. package/src/lib/run.ts +13 -0
  30. package/src/monorepo/__tests__/ci-workflow.test.ts +34 -0
  31. package/src/monorepo/__tests__/publish-workflow.test.ts +65 -2
  32. package/src/monorepo/ci-workflow.ts +229 -0
  33. package/src/monorepo/managed-files.ts +115 -5
  34. package/src/monorepo/nx-sync.ts +18 -4
  35. package/src/monorepo/package-hygiene.test.ts +54 -1
  36. package/src/monorepo/package-hygiene.ts +34 -4
  37. package/src/monorepo/package-policy.test.ts +16 -6
  38. package/src/monorepo/package-policy.ts +4 -2
  39. package/src/monorepo/packed-package.ts +5 -5
  40. package/src/monorepo/packs/index.ts +6 -2
  41. package/src/monorepo/publish-workflow.ts +73 -16
  42. package/managed/templates/github/workflows/ci.yml +0 -102
@@ -28,6 +28,61 @@ describe('publish workflow definition', () => {
28
28
  expect(rendered).toContain('packages must already exist on npm and use trusted publishing/OIDC');
29
29
  });
30
30
 
31
+ it('omits production deploy controls when no production deploy target exists', () => {
32
+ const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
33
+
34
+ expect(rendered).not.toContain('deploy_environment');
35
+ expect(rendered).not.toContain('Deploy production');
36
+ expect(rendered).not.toContain('nx-deploy');
37
+ });
38
+
39
+ it('renders production deploy controls for repos with production deploy targets', () => {
40
+ const rendered = renderPublishWorkflowYaml({ deploy: true, repoName: '@smoothbricks/codebase' });
41
+
42
+ expect(rendered).toContain('deploy_environment:');
43
+ expect(rendered).toContain('- name: 🚀 Deploy production');
44
+ expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
45
+ expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
46
+ expect(rendered).toContain(
47
+ 'smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
48
+ );
49
+ });
50
+
51
+ it('adds Cloudflare credentials for Wrangler-backed production deploys', () => {
52
+ const rendered = renderPublishWorkflowYaml({
53
+ deploy: true,
54
+ deployProvider: 'cloudflare',
55
+ repoName: '@smoothbricks/codebase',
56
+ });
57
+
58
+ expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
59
+ expect(rendered).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
60
+ });
61
+
62
+ it('deploys production only after a real publish when requested', async () => {
63
+ const deployed = await publishWorkflowScenario({
64
+ deploy: true,
65
+ repairs: [],
66
+ current: [],
67
+ bump: 'auto',
68
+ deployEnvironment: 'production',
69
+ dryRun: false,
70
+ version: { mode: 'new', projects: ['app'] },
71
+ }).run();
72
+ const dryRun = await publishWorkflowScenario({
73
+ deploy: true,
74
+ repairs: [],
75
+ current: [],
76
+ bump: 'auto',
77
+ deployEnvironment: 'production',
78
+ dryRun: true,
79
+ version: { mode: 'new', projects: ['app'] },
80
+ }).run();
81
+
82
+ expect(deployed.productionDeployed).toBe(true);
83
+ expect(dryRun.productionDeployed).toBe(false);
84
+ });
85
+
31
86
  it('bootstraps the self-hosted CLI before release commands only for the SmoothBricks repo', async () => {
32
87
  const smoothbricks = await publishWorkflowScenario({
33
88
  repoName: '@smoothbricks/codebase',
@@ -143,10 +198,12 @@ interface ReleaseGap {
143
198
  }
144
199
 
145
200
  interface WorkflowScenarioConfig {
201
+ deploy?: boolean;
146
202
  repoName?: string;
147
203
  repairs: ReleaseGap[];
148
204
  current: ReleaseGap[];
149
205
  bump: PublishWorkflowBump;
206
+ deployEnvironment?: 'none' | 'production';
150
207
  dryRun: boolean;
151
208
  version: PublishWorkflowVersionOutputs;
152
209
  }
@@ -161,6 +218,7 @@ interface WorkflowOutcome {
161
218
  repairBuildArtifacts: string[];
162
219
  validation: { checks: number; builds: string[]; lints: string[]; tests: string[]; validates: number };
163
220
  publishRan: boolean;
221
+ productionDeployed: boolean;
164
222
  publishSawValidatedRelease: boolean;
165
223
  publishCompletedTags: string[];
166
224
  remainingDurableGaps: string[];
@@ -170,8 +228,8 @@ function publishWorkflowScenario(config: WorkflowScenarioConfig): { run(): Promi
170
228
  return {
171
229
  async run() {
172
230
  const state = new WorkflowScenarioState(config);
173
- await runPublishWorkflow(definePublishWorkflow({ repoName: config.repoName }), {
174
- inputs: { bump: config.bump, dryRun: config.dryRun },
231
+ await runPublishWorkflow(definePublishWorkflow({ deploy: config.deploy, repoName: config.repoName }), {
232
+ inputs: { bump: config.bump, deployEnvironment: config.deployEnvironment ?? 'none', dryRun: config.dryRun },
175
233
  callbacks: state.callbacks(),
176
234
  });
177
235
  return state.outcome();
@@ -186,6 +244,7 @@ class WorkflowScenarioState {
186
244
  private repairObservedSelfHostedCli = false;
187
245
  private versionObservedSelfHostedCli = false;
188
246
  private publishReleaseRan = false;
247
+ private productionDeployRan = false;
189
248
  private publishSawValidation = false;
190
249
  private readonly repaired = new Set<string>();
191
250
  private readonly repairBuilds = new Set<string>();
@@ -275,6 +334,9 @@ class WorkflowScenarioState {
275
334
  }
276
335
  }
277
336
  },
337
+ deployProduction: async () => {
338
+ this.productionDeployRan = true;
339
+ },
278
340
  saveNixDevenv: async () => {},
279
341
  };
280
342
  }
@@ -296,6 +358,7 @@ class WorkflowScenarioState {
296
358
  validates: this.validationState.validates,
297
359
  },
298
360
  publishRan: this.publishReleaseRan,
361
+ productionDeployed: this.productionDeployRan,
299
362
  publishSawValidatedRelease: this.publishSawValidation,
300
363
  publishCompletedTags: [...this.publishedCurrent].sort(),
301
364
  remainingDurableGaps: [...this.durableGaps].sort(),
@@ -0,0 +1,229 @@
1
+ export enum CiWorkflowStepKind {
2
+ Checkout = 'checkout',
3
+ SetupDevenv = 'setup-devenv',
4
+ SetNxShas = 'set-nx-shas',
5
+ RestoreNxCache = 'restore-nx-cache',
6
+ Build = 'build',
7
+ Lint = 'lint',
8
+ UnitTests = 'unit-tests',
9
+ Deploy = 'deploy',
10
+ SaveNxCache = 'save-nx-cache',
11
+ UploadTraceDbs = 'upload-trace-dbs',
12
+ SaveNixDevenv = 'save-nix-devenv',
13
+ }
14
+
15
+ export interface CiWorkflowStep {
16
+ kind: CiWorkflowStepKind;
17
+ name: string;
18
+ number: number;
19
+ }
20
+
21
+ export interface CiWorkflowDefinitionOptions {
22
+ deploy: boolean;
23
+ deployProvider?: 'cloudflare';
24
+ pushBranches: string[];
25
+ }
26
+
27
+ type CiWorkflowStepInput = Omit<CiWorkflowStep, 'number'>;
28
+
29
+ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkflowStep[] {
30
+ const steps: CiWorkflowStepInput[] = [
31
+ { kind: CiWorkflowStepKind.Checkout, name: '📥 Checkout' },
32
+ { kind: CiWorkflowStepKind.SetupDevenv, name: '🧱 Setup Nix/devenv' },
33
+ { kind: CiWorkflowStepKind.SetNxShas, name: '🧭 Set Nx SHAs' },
34
+ { kind: CiWorkflowStepKind.RestoreNxCache, name: '🧠 Restore Nx cache' },
35
+ { kind: CiWorkflowStepKind.Build, name: '🔨 Build' },
36
+ { kind: CiWorkflowStepKind.Lint, name: '🔍 Lint' },
37
+ { kind: CiWorkflowStepKind.UnitTests, name: '🧪 Unit Tests' },
38
+ ];
39
+ if (options.deploy) {
40
+ steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Staging' });
41
+ }
42
+ steps.push(
43
+ { kind: CiWorkflowStepKind.SaveNxCache, name: '💾 Save Nx cache' },
44
+ { kind: CiWorkflowStepKind.UploadTraceDbs, name: '📎 Upload trace DBs' },
45
+ { kind: CiWorkflowStepKind.SaveNixDevenv, name: '🧹 Cleanup and cache Nix/devenv' },
46
+ );
47
+ return steps.map((step, index) => ({ ...step, number: index + 2 }));
48
+ }
49
+
50
+ export function renderCiWorkflowYaml(options: CiWorkflowDefinitionOptions): string {
51
+ const steps = defineCiWorkflow(options);
52
+ return `${renderCiWorkflowHeader(options)}${renderCiWorkflowSteps(steps, options)}`;
53
+ }
54
+
55
+ function renderCiWorkflowHeader(options: CiWorkflowDefinitionOptions): string {
56
+ return `name: CI
57
+
58
+ on:
59
+ push:
60
+ branches:
61
+ ${renderYamlList(options.pushBranches, 6)}
62
+ pull_request:
63
+
64
+ permissions:
65
+ actions: read
66
+ contents: read
67
+ statuses: write
68
+
69
+ defaults:
70
+ run:
71
+ working-directory: tooling/direnv
72
+
73
+ jobs:
74
+ main:
75
+ name: Validate
76
+ runs-on: ubuntu-latest
77
+ timeout-minutes: 45
78
+ env:
79
+ NIX_STORE_NAR: ${githubExpression('github.workspace')}/nix-store.nar
80
+ GH_TOKEN: ${githubExpression('github.token')}
81
+ steps:
82
+ `;
83
+ }
84
+
85
+ function githubExpression(expression: string): string {
86
+ return `$${`{{ ${expression} }}`}`;
87
+ }
88
+
89
+ function renderCiWorkflowSteps(steps: CiWorkflowStep[], options: CiWorkflowDefinitionOptions): string {
90
+ const lines: string[] = [];
91
+ for (const step of steps) {
92
+ lines.push(...sectionLinesBefore(step));
93
+ lines.push(...commentLinesForStep(step));
94
+ lines.push(...yamlLinesForStep(step, options));
95
+ lines.push('');
96
+ }
97
+ return `${lines.join('\n').trimEnd()}\n`;
98
+ }
99
+
100
+ function sectionLinesBefore(step: CiWorkflowStep): string[] {
101
+ if (step.kind === CiWorkflowStepKind.SetNxShas) {
102
+ return [' # --- Nx -----------------------------------------------------------------', ''];
103
+ }
104
+ if (step.kind === CiWorkflowStepKind.SaveNixDevenv) {
105
+ return [' # --- Cleanup ------------------------------------------------------------', ''];
106
+ }
107
+ return [];
108
+ }
109
+
110
+ function commentLinesForStep(step: CiWorkflowStep): string[] {
111
+ if (step.kind === CiWorkflowStepKind.Checkout) {
112
+ return [' # Step 1: GitHub adds "Set up job" automatically', ' # Step 2'];
113
+ }
114
+ if (step.kind === CiWorkflowStepKind.SetupDevenv) {
115
+ return [
116
+ ' # Step 3. Composite action internals do not affect top-level job step',
117
+ ' # anchors; update the nx-smart --step values below if top-level steps move.',
118
+ ];
119
+ }
120
+ if (step.kind === CiWorkflowStepKind.SetNxShas) {
121
+ return [` # Step ${step.number}`, ' # Sets the base and head SHAs required for the nx affected commands'];
122
+ }
123
+ if (step.kind === CiWorkflowStepKind.SaveNxCache) {
124
+ return [
125
+ ` # Step ${step.number}`,
126
+ " # Nx's database cache needs artifact files and .nx/workspace-data DB",
127
+ ' # metadata restored together; GitHub Actions cache is only the archive',
128
+ ' # transport. Save runs only after prior required steps succeed on the default',
129
+ ' # branch, so PRs may restore shared cache but cannot publish it.',
130
+ ];
131
+ }
132
+ return [` # Step ${step.number}`];
133
+ }
134
+
135
+ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOptions): string[] {
136
+ switch (step.kind) {
137
+ case CiWorkflowStepKind.Checkout:
138
+ return [
139
+ ` - name: ${step.name}`,
140
+ ' uses: actions/checkout@v6.0.2',
141
+ ' with:',
142
+ ' filter: blob:none',
143
+ ' fetch-depth: 0',
144
+ ];
145
+ case CiWorkflowStepKind.SetupDevenv:
146
+ return [` - name: ${step.name}`, ' id: setup', ' uses: ./.github/actions/setup-devenv'];
147
+ case CiWorkflowStepKind.SetNxShas:
148
+ return [
149
+ ` - name: ${step.name}`,
150
+ ' uses: nrwl/nx-set-shas@v5.0.1',
151
+ ' with:',
152
+ ' workflow-id: ci.yml',
153
+ ];
154
+ case CiWorkflowStepKind.RestoreNxCache:
155
+ return [` - name: ${step.name}`, ' id: nx-cache', ' uses: ./.github/actions/cache-nx'];
156
+ case CiWorkflowStepKind.Build:
157
+ return nxSmartStep(step, 'build', 'Build');
158
+ case CiWorkflowStepKind.Lint:
159
+ return nxSmartStep(step, 'lint', 'Lint');
160
+ case CiWorkflowStepKind.UnitTests:
161
+ return nxSmartStep(step, 'test', 'Unit Tests');
162
+ case CiWorkflowStepKind.Deploy:
163
+ return [
164
+ ` - name: ${step.name}`,
165
+ ' if:',
166
+ " ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}',",
167
+ ' github.event.repository.default_branch) }}',
168
+ ...deployEnvLines(options),
169
+ ` run: smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step ${step.number}`,
170
+ ];
171
+ case CiWorkflowStepKind.SaveNxCache:
172
+ return [
173
+ ` - name: ${step.name}`,
174
+ ' if:',
175
+ " ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}',",
176
+ " github.event.repository.default_branch) && steps.nx-cache.outputs.cache-hit != 'true' }}",
177
+ ' uses: actions/cache/save@v5.0.5',
178
+ ' with:',
179
+ ' path: |',
180
+ ' .nx/cache',
181
+ ' .nx/workspace-data/*.db*',
182
+ ' key: ${{ runner.os }}-nx-db-v1-${{ github.sha }}',
183
+ ];
184
+ case CiWorkflowStepKind.UploadTraceDbs:
185
+ return [
186
+ ` - name: ${step.name}`,
187
+ ' if: ${{ always() }}',
188
+ ' uses: actions/upload-artifact@v7.0.1',
189
+ ' with:',
190
+ ' name: trace-results-${{ github.run_id }}',
191
+ ' path: packages/*/.trace-results.db',
192
+ ' if-no-files-found: ignore',
193
+ ' retention-days: 14',
194
+ ' include-hidden-files: true',
195
+ ];
196
+ case CiWorkflowStepKind.SaveNixDevenv:
197
+ return [
198
+ ` - name: ${step.name}`,
199
+ ' if: ${{ always() }}',
200
+ ' uses: ./.github/actions/save-nix-devenv',
201
+ ' with:',
202
+ ' nix-cache-hit: ${{ steps.setup.outputs.nix-cache-hit }}',
203
+ ' devenv-cache-hit: ${{ steps.setup.outputs.devenv-cache-hit }}',
204
+ ];
205
+ }
206
+ }
207
+
208
+ function deployEnvLines(options: CiWorkflowDefinitionOptions): string[] {
209
+ if (options.deployProvider !== 'cloudflare') {
210
+ return [];
211
+ }
212
+ return [
213
+ ' env:',
214
+ ' CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}',
215
+ ' CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
216
+ ];
217
+ }
218
+
219
+ function nxSmartStep(step: CiWorkflowStep, target: string, name: string): string[] {
220
+ return [
221
+ ` - name: ${step.name}`,
222
+ ` run: smoo github-ci nx-smart --target ${target} --name "${name}" --step ${step.number}`,
223
+ ];
224
+ }
225
+
226
+ function renderYamlList(values: string[], spaces: number): string {
227
+ const indent = ' '.repeat(spaces);
228
+ return values.map((value) => `${indent}- ${value}`).join('\n');
229
+ }
@@ -1,7 +1,9 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
3
  import { dirname, join, resolve } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
6
+ import { renderCiWorkflowYaml } from './ci-workflow.js';
5
7
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
6
8
 
7
9
  type ManagedKind = 'raw' | 'template' | 'generated';
@@ -21,10 +23,20 @@ export interface FileResult {
21
23
 
22
24
  interface ManagedFileContext {
23
25
  hasReleasePackages: boolean;
26
+ hasStagingDeployTargets: boolean;
27
+ hasProductionDeployTargets: boolean;
28
+ stagingDeployProvider?: 'cloudflare';
29
+ productionDeployProvider?: 'cloudflare';
30
+ ciPushBranches: string[];
24
31
  nodeModulesCacheKey: string;
25
32
  repoName: string;
26
33
  }
27
34
 
35
+ interface DeployTargetInfo {
36
+ exists: boolean;
37
+ provider?: 'cloudflare';
38
+ }
39
+
28
40
  const managedFiles: ManagedFile[] = [
29
41
  {
30
42
  kind: 'raw',
@@ -56,8 +68,8 @@ const managedFiles: ManagedFile[] = [
56
68
  target: '.git-format-staged.yml',
57
69
  },
58
70
  {
59
- kind: 'template',
60
- source: 'github/workflows/ci.yml',
71
+ kind: 'generated',
72
+ source: 'ci-workflow',
61
73
  target: '.github/workflows/ci.yml',
62
74
  },
63
75
  {
@@ -106,7 +118,7 @@ function applyManagedFile(
106
118
  mode: 'update' | 'check' | 'diff',
107
119
  context: ManagedFileContext,
108
120
  ): FileResult {
109
- if (file.releasePackagesOnly === true && !context.hasReleasePackages) {
121
+ if (file.releasePackagesOnly === true && !context.hasReleasePackages && !context.hasProductionDeployTargets) {
110
122
  return { target: file.target, action: 'skipped' };
111
123
  }
112
124
  const target = resolve(root, file.target);
@@ -138,8 +150,19 @@ function applyManagedFile(
138
150
 
139
151
  function getManagedContent(file: ManagedFile, context: ManagedFileContext): string {
140
152
  if (file.kind === 'generated') {
153
+ if (file.source === 'ci-workflow') {
154
+ return renderCiWorkflowYaml({
155
+ deploy: context.hasStagingDeployTargets,
156
+ deployProvider: context.stagingDeployProvider,
157
+ pushBranches: context.ciPushBranches,
158
+ });
159
+ }
141
160
  if (file.source === 'publish-workflow') {
142
- return renderPublishWorkflowYaml({ repoName: context.repoName });
161
+ return renderPublishWorkflowYaml({
162
+ deploy: context.hasProductionDeployTargets,
163
+ deployProvider: context.productionDeployProvider,
164
+ repoName: context.repoName,
165
+ });
143
166
  }
144
167
  throw new Error(`Unknown generated managed file source ${file.source}`);
145
168
  }
@@ -155,18 +178,105 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
155
178
  function getManagedFileContext(root: string): ManagedFileContext {
156
179
  const packageJson = readPackageJson(join(root, 'package.json'));
157
180
  const repoName = packageJson?.name ?? 'monorepo';
181
+ const ciPushBranches = getCiPushBranches(packageJson?.json);
182
+ const stagingDeploy = getDeployTargetInfo(root, 'staging');
183
+ const productionDeploy = getDeployTargetInfo(root, 'production');
158
184
  const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
159
185
  ? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
160
186
  : `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
161
- return { hasReleasePackages: listReleasePackages(root, packageJson).length > 0, nodeModulesCacheKey, repoName };
187
+ return {
188
+ hasReleasePackages: listReleasePackages(root, packageJson).length > 0,
189
+ hasStagingDeployTargets: stagingDeploy.exists,
190
+ hasProductionDeployTargets: productionDeploy.exists,
191
+ stagingDeployProvider: stagingDeploy.provider,
192
+ productionDeployProvider: productionDeploy.provider,
193
+ ciPushBranches,
194
+ nodeModulesCacheKey,
195
+ repoName,
196
+ };
162
197
  }
163
198
 
164
199
  function renderTemplate(context: ManagedFileContext, template: string): string {
165
200
  return template
166
201
  .replaceAll('{{REPO_NAME}}', context.repoName)
202
+ .replaceAll('__SMOO_CI_PUSH_BRANCHES__', renderYamlFlowList(context.ciPushBranches))
167
203
  .replaceAll('{{NODE_MODULES_CACHE_KEY}}', context.nodeModulesCacheKey);
168
204
  }
169
205
 
206
+ function getDeployTargetInfo(root: string, configuration: string): DeployTargetInfo {
207
+ const output = execFileSync('nx', ['show', 'projects', '--withTarget', 'deploy', '--json'], {
208
+ cwd: root,
209
+ encoding: 'utf8',
210
+ stdio: ['ignore', 'pipe', 'inherit'],
211
+ });
212
+ const projects: unknown = JSON.parse(output);
213
+ if (!Array.isArray(projects) || !projects.every((project) => typeof project === 'string')) {
214
+ return { exists: false };
215
+ }
216
+ let exists = false;
217
+ let provider: DeployTargetInfo['provider'];
218
+ for (const project of projects) {
219
+ const target = nxDeployTarget(root, project, configuration);
220
+ if (!target.exists) {
221
+ continue;
222
+ }
223
+ exists = true;
224
+ provider ??= target.provider;
225
+ }
226
+ return { exists, provider };
227
+ }
228
+
229
+ function nxDeployTarget(root: string, project: string, configuration: string): DeployTargetInfo {
230
+ const output = execFileSync('nx', ['show', 'project', project, '--json'], {
231
+ cwd: root,
232
+ encoding: 'utf8',
233
+ stdio: ['ignore', 'pipe', 'inherit'],
234
+ });
235
+ const parsed: unknown = JSON.parse(output);
236
+ const projectJson = recordValue(parsed);
237
+ const targets = recordValue(projectJson?.targets);
238
+ const deploy = recordValue(targets?.deploy);
239
+ const configurations = recordValue(deploy?.configurations);
240
+ const config = recordValue(configurations?.[configuration]);
241
+ if (!config) {
242
+ return { exists: false };
243
+ }
244
+ const command = deployCommand(deploy, config);
245
+ return { exists: true, provider: command.includes('wrangler ') ? 'cloudflare' : undefined };
246
+ }
247
+
248
+ function deployCommand(deploy: Record<string, unknown> | undefined, config: Record<string, unknown>): string {
249
+ const command = stringValue(config.command) ?? stringValue(recordValue(config.options)?.command);
250
+ return command ?? stringValue(recordValue(deploy?.options)?.command) ?? '';
251
+ }
252
+
253
+ function stringValue(value: unknown): string | undefined {
254
+ return typeof value === 'string' ? value : undefined;
255
+ }
256
+
257
+ function getCiPushBranches(packageJson: unknown): string[] {
258
+ const configured = readCiPushBranches(packageJson);
259
+ return configured.length > 0 ? configured : ['main'];
260
+ }
261
+
262
+ function readCiPushBranches(packageJson: unknown): string[] {
263
+ const rootPackage = recordValue(packageJson);
264
+ const smoo = recordValue(rootPackage?.smoo);
265
+ const github = recordValue(smoo?.github);
266
+ const branches = Array.isArray(github?.pushBranches) ? github.pushBranches : [];
267
+ return branches.filter((branch): branch is string => typeof branch === 'string' && branch.length > 0);
268
+ }
269
+
270
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
271
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
272
+ ? (value as Record<string, unknown>)
273
+ : undefined;
274
+ }
275
+
276
+ function renderYamlFlowList(values: string[]): string {
277
+ return JSON.stringify(values);
278
+ }
279
+
170
280
  function writeManagedFile(path: string, content: string, executable: boolean): void {
171
281
  mkdirSync(dirname(path), { recursive: true });
172
282
  writeFileSync(path, content, { mode: executable ? 0o755 : 0o644 });
@@ -1,13 +1,27 @@
1
- import { runStatus } from '../lib/run.js';
1
+ import { printCommandOutput, runResult, runStatus } from '../lib/run.js';
2
2
 
3
3
  export async function fixNxSync(root: string, verbose = false): Promise<void> {
4
- const status = await runStatus('nx', ['sync'], root, !verbose);
4
+ const result = verbose
5
+ ? { exitCode: await runStatus('nx', ['sync'], root, false), stdout: '', stderr: '' }
6
+ : await runResult('nx', ['sync'], root);
7
+ const status = result.exitCode;
5
8
  if (status !== 0) {
9
+ if (!verbose) {
10
+ printCommandOutput(result.stdout, result.stderr);
11
+ }
6
12
  throw new Error(`nx sync failed with exit code ${status}`);
7
13
  }
8
14
  }
9
15
 
10
16
  export async function validateNxSync(root: string, verbose = false): Promise<number> {
11
- const status = await runStatus('nx', ['sync:check'], root, !verbose);
12
- return status === 0 ? 0 : 1;
17
+ const result = verbose
18
+ ? { exitCode: await runStatus('nx', ['sync:check'], root, false), stdout: '', stderr: '' }
19
+ : await runResult('nx', ['sync:check'], root);
20
+ if (result.exitCode === 0) {
21
+ return 0;
22
+ }
23
+ if (!verbose) {
24
+ printCommandOutput(result.stdout, result.stderr);
25
+ }
26
+ return 1;
13
27
  }
@@ -1,7 +1,12 @@
1
- import { describe, expect, it } from 'bun:test';
1
+ import { afterEach, describe, expect, it, spyOn } from 'bun:test';
2
2
  import { fixPackageHygiene, type PackageHygieneShell, validatePackageHygiene } from './package-hygiene.js';
3
3
 
4
4
  describe('package hygiene', () => {
5
+ afterEach(() => {
6
+ console.log = originalConsoleLog;
7
+ console.error = originalConsoleError;
8
+ });
9
+
5
10
  it('runs sherif in autofix mode and selects highest dependency versions', async () => {
6
11
  const shell = new RecordingShell();
7
12
 
@@ -17,8 +22,39 @@ describe('package hygiene', () => {
17
22
 
18
23
  expect(shell.statuses).toEqual([{ command: 'sherif', args: ['--fail-on-warnings'], cwd: '/repo' }]);
19
24
  });
25
+
26
+ it('prints captured sherif output when quiet validation fails', async () => {
27
+ const shell = new FailingResultShell('sherif stdout', 'sherif stderr');
28
+ const logs = captureConsoleLogs();
29
+ const errors = captureConsoleErrors();
30
+
31
+ const failures = await validatePackageHygiene('/repo', shell);
32
+
33
+ expect(failures).toBe(1);
34
+ expect(logs).toEqual(['sherif stdout']);
35
+ expect(errors).toEqual(['sherif stderr', 'sherif package hygiene validation failed']);
36
+ });
20
37
  });
21
38
 
39
+ const originalConsoleLog = console.log;
40
+ const originalConsoleError = console.error;
41
+
42
+ function captureConsoleLogs(): string[] {
43
+ const logs: string[] = [];
44
+ spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
45
+ logs.push(args.join(' '));
46
+ });
47
+ return logs;
48
+ }
49
+
50
+ function captureConsoleErrors(): string[] {
51
+ const errors: string[] = [];
52
+ spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
53
+ errors.push(args.join(' '));
54
+ });
55
+ return errors;
56
+ }
57
+
22
58
  class RecordingShell implements PackageHygieneShell {
23
59
  readonly runs: { command: string; args: string[]; cwd: string }[] = [];
24
60
  readonly statuses: { command: string; args: string[]; cwd: string }[] = [];
@@ -32,3 +68,20 @@ class RecordingShell implements PackageHygieneShell {
32
68
  return 0;
33
69
  }
34
70
  }
71
+
72
+ class FailingResultShell implements PackageHygieneShell {
73
+ constructor(
74
+ private readonly stdout: string,
75
+ private readonly stderr: string,
76
+ ) {}
77
+
78
+ async run(): Promise<void> {}
79
+
80
+ async runResult(): Promise<{ exitCode: number; stdout: string; stderr: string }> {
81
+ return { exitCode: 1, stdout: this.stdout, stderr: this.stderr };
82
+ }
83
+
84
+ async runStatus(): Promise<number> {
85
+ return 1;
86
+ }
87
+ }