@smoothbricks/cli 0.11.18 → 0.11.20

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 (96) hide show
  1. package/README.md +91 -11
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +28 -4
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +6 -20
  6. package/dist/lib/deploy-tags.d.ts +17 -3
  7. package/dist/lib/deploy-tags.d.ts.map +1 -1
  8. package/dist/lib/deploy-tags.js +23 -4
  9. package/dist/lib/json.d.ts +22 -1
  10. package/dist/lib/json.d.ts.map +1 -1
  11. package/dist/lib/json.js +15 -3
  12. package/dist/monorepo/cargo-policy.d.ts +17 -0
  13. package/dist/monorepo/cargo-policy.d.ts.map +1 -1
  14. package/dist/monorepo/cargo-policy.js +73 -1
  15. package/dist/monorepo/index.d.ts.map +1 -1
  16. package/dist/monorepo/index.js +4 -2
  17. package/dist/monorepo/managed-files.d.ts +5 -2
  18. package/dist/monorepo/managed-files.d.ts.map +1 -1
  19. package/dist/monorepo/managed-files.js +35 -21
  20. package/dist/monorepo/packs/index.d.ts.map +1 -1
  21. package/dist/monorepo/packs/index.js +6 -1
  22. package/dist/nx/index.d.ts +6 -0
  23. package/dist/nx/index.d.ts.map +1 -1
  24. package/dist/nx/index.js +14 -0
  25. package/dist/secrets/commands.d.ts +9 -5
  26. package/dist/secrets/commands.d.ts.map +1 -1
  27. package/dist/secrets/commands.js +122 -49
  28. package/dist/secrets/index.d.ts +42 -56
  29. package/dist/secrets/index.d.ts.map +1 -1
  30. package/dist/secrets/index.js +52 -79
  31. package/dist/secrets/resolver.d.ts +59 -0
  32. package/dist/secrets/resolver.d.ts.map +1 -0
  33. package/dist/secrets/resolver.js +100 -0
  34. package/dist/secrets/run.d.ts +23 -0
  35. package/dist/secrets/run.d.ts.map +1 -0
  36. package/dist/secrets/run.js +130 -0
  37. package/dist/secrets/status.d.ts +177 -0
  38. package/dist/secrets/status.d.ts.map +1 -0
  39. package/dist/secrets/status.js +724 -0
  40. package/dist/wrangler/deploy-stage.d.ts +1 -1
  41. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  42. package/dist/wrangler/deploy-stage.js +22 -20
  43. package/dist/wrangler/deployed-version.d.ts.map +1 -1
  44. package/dist/wrangler/deployed-version.js +7 -12
  45. package/dist/wrangler/flat-config.d.ts +1 -4
  46. package/dist/wrangler/flat-config.d.ts.map +1 -1
  47. package/dist/wrangler/flat-config.js +98 -44
  48. package/dist/wrangler/prepare-env.d.ts +4 -3
  49. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  50. package/dist/wrangler/prepare-env.js +7 -5
  51. package/dist/wrangler/source-config.d.ts +24 -0
  52. package/dist/wrangler/source-config.d.ts.map +1 -0
  53. package/dist/wrangler/source-config.js +110 -0
  54. package/dist/wrangler/stage.d.ts +55 -23
  55. package/dist/wrangler/stage.d.ts.map +1 -1
  56. package/dist/wrangler/stage.js +81 -162
  57. package/managed/raw/tooling/direnv/devenv.smoo.nix +19 -3
  58. package/managed/raw/tooling/direnv/secret-references.ts +280 -76
  59. package/managed/raw/tooling/direnv/setup-environment.ts +58 -2
  60. package/managed/raw/tsconfig.lib.json +28 -0
  61. package/package.json +7 -2
  62. package/src/cli.ts +34 -5
  63. package/src/github-ci/index.test.ts +6 -7
  64. package/src/github-ci/index.ts +11 -19
  65. package/src/lib/deploy-tags.ts +22 -4
  66. package/src/lib/json.ts +23 -1
  67. package/src/monorepo/cargo-policy.test.ts +91 -1
  68. package/src/monorepo/cargo-policy.ts +87 -1
  69. package/src/monorepo/index.ts +8 -2
  70. package/src/monorepo/managed-files.test.ts +69 -37
  71. package/src/monorepo/managed-files.ts +38 -21
  72. package/src/monorepo/packs/index.ts +10 -1
  73. package/src/monorepo/secret-references.test.ts +412 -6
  74. package/src/monorepo/setup-environment.test.ts +181 -0
  75. package/src/nx/index.test.ts +8 -0
  76. package/src/nx/index.ts +20 -0
  77. package/src/secrets/commands.test.ts +186 -4
  78. package/src/secrets/commands.ts +142 -51
  79. package/src/secrets/index.test.ts +4 -10
  80. package/src/secrets/index.ts +54 -115
  81. package/src/secrets/resolver.ts +130 -0
  82. package/src/secrets/run.test.ts +359 -0
  83. package/src/secrets/run.ts +148 -0
  84. package/src/secrets/status.test.ts +164 -0
  85. package/src/secrets/status.ts +297 -0
  86. package/src/wrangler/deploy-stage.test.ts +95 -2
  87. package/src/wrangler/deploy-stage.ts +26 -23
  88. package/src/wrangler/deployed-version.ts +7 -11
  89. package/src/wrangler/flat-config.test.ts +9 -4
  90. package/src/wrangler/flat-config.ts +1 -20
  91. package/src/wrangler/format-parity.test.ts +433 -0
  92. package/src/wrangler/prepare-env.ts +7 -5
  93. package/src/wrangler/source-config.test.ts +102 -0
  94. package/src/wrangler/source-config.ts +110 -0
  95. package/src/wrangler/stage.test.ts +61 -32
  96. package/src/wrangler/stage.ts +124 -173
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { decode, findRepoRoot, printCommandOutput } from './lib/run.js';
7
7
  import { ensureChromium } from './playwright/index.js';
8
8
  import { resolvePrConflicts } from './pr/index.js';
9
9
  import { secretsSet, secretsStatus, secretsSync } from './secrets/commands.js';
10
+ import { secretsRun } from './secrets/run.js';
10
11
  import { cleanupPullRequest, deployStage } from './wrangler/deploy-stage.js';
11
12
  import { deployedVersion } from './wrangler/deployed-version.js';
12
13
  import { scaffold } from './wrangler/scaffold.js';
@@ -74,6 +75,11 @@ function buildProgram(): Command {
74
75
  .description('SmoothBricks monorepo tooling')
75
76
  .version(cliPackageVersion, '-v, --version', 'print smoo version')
76
77
  .exitOverride()
78
+ // `smoo secrets run <group> <command...>` hands the rest of argv to a
79
+ // child verbatim, flags included. Commander only stops parsing after the
80
+ // first operand when positional options are enabled here, on the parent
81
+ // of the command that declares `passThroughOptions`.
82
+ .enablePositionalOptions()
77
83
  .showHelpAfterError();
78
84
 
79
85
  const monorepo = program.command('monorepo').description('Manage SmoothBricks-style monorepos');
@@ -529,11 +535,29 @@ function buildProgram(): Command {
529
535
 
530
536
  const secrets = program
531
537
  .command('secrets')
532
- .description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds');
538
+ .description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds')
539
+ .enablePositionalOptions();
540
+ secrets
541
+ .command('run [group] [command...]')
542
+ .description(
543
+ 'Run one command with one group of declared secrets in its environment. Shell entry resolves the ' +
544
+ '`shell` group only, so a registry credential or the Nx cache token is resolved here, by the command ' +
545
+ 'that needs it, instead of by every direnv reload. The group is required and positional: with none, ' +
546
+ 'this lists the groups this repository declares and the secrets in each',
547
+ )
548
+ // Everything after the group reaches the child verbatim, flags included:
549
+ // `smoo secrets run registry bun add -d @acme/x` must run `bun add -d
550
+ // @acme/x`, not lose `-d` to this command's own parser.
551
+ .passThroughOptions()
552
+ .action(async (group: string | undefined, command: string[]) => {
553
+ process.exitCode = await secretsRun(await findRepoRoot(), group, command);
554
+ });
533
555
  secrets
534
556
  .command('status')
535
557
  .description(
536
- 'Show every declared secret with the scopes holding it, and refuse when a workflow passes one none has',
558
+ 'Show every declared secret with the scopes holding it. Exits 0 when every secret a managed workflow ' +
559
+ 'passes has a value in a scope the job reads, and 1 when one does not or when the repository, its ' +
560
+ 'secrets or a stage declaration could not be read',
537
561
  )
538
562
  .option(
539
563
  '-R, --repo <owner/name|remote>',
@@ -543,8 +567,13 @@ function buildProgram(): Command {
543
567
  '--env <environment>',
544
568
  "also read this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
545
569
  )
546
- .action(async (options: { repo?: string; env?: string }) => {
547
- process.exitCode = secretsStatus(await findRepoRoot(), options);
570
+ .option(
571
+ '--json',
572
+ 'write one JSON document to stdout instead of the table, for another tool to read; the exit code is ' +
573
+ 'unchanged, so a status that refuses still refuses',
574
+ )
575
+ .action(async (options: { repo?: string; env?: string; json?: boolean }) => {
576
+ process.exitCode = await secretsStatus(await findRepoRoot(), options);
548
577
  });
549
578
  secrets
550
579
  .command('set [name]')
@@ -586,7 +615,7 @@ function buildProgram(): Command {
586
615
  wrangler
587
616
  .command('deploy-stage')
588
617
  .requiredOption('--stage <stage>', 'staging, production, or prN')
589
- .option('--config <path>', 'deploy a build-generated flat wrangler.json instead of ./wrangler.toml')
618
+ .option('--config <path>', "deploy a build-generated flat wrangler.json instead of the project's own config")
590
619
  .option('--version-endpoint <url>', 'URL served by this worker whose trimmed body is the running version tag')
591
620
  .action(async (options: { stage: string; config?: string; versionEndpoint?: string }) => {
592
621
  await deployStage(process.cwd(), {
@@ -847,8 +847,11 @@ describe('event-aware stage deployment', () => {
847
847
  ).toThrow(/same-repository/);
848
848
  });
849
849
 
850
- it('selects stage-derived deploy targets plus staging-only infrastructure on staging', async () => {
850
+ it('selects tagged stage projects plus staging-only infrastructure, and never an untagged one', async () => {
851
851
  const definitions: Record<string, unknown> = {
852
+ // A private wrangler project the plugin gave a deploy target and nobody tagged: deployable
853
+ // by hand, invisible to CI. Reading the command instead deployed whatever held a wrangler
854
+ // manifest, including a published library that ships one to document a binding.
852
855
  app: {
853
856
  targets: {
854
857
  deploy: { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } },
@@ -875,18 +878,15 @@ describe('event-aware stage deployment', () => {
875
878
  const loadProject = async (project: string) => definitions[project];
876
879
 
877
880
  await expect(selectStageDeployProjects(candidates, 'staging', undefined, loadProject)).resolves.toEqual([
878
- 'app',
879
881
  'app-backend',
880
882
  'e2e-mail-capture',
881
883
  'website',
882
884
  ]);
883
885
  await expect(selectStageDeployProjects(candidates, 'pr123', undefined, loadProject)).resolves.toEqual([
884
- 'app',
885
886
  'app-backend',
886
887
  'website',
887
888
  ]);
888
889
  await expect(selectStageDeployProjects(candidates, 'production', undefined, loadProject)).resolves.toEqual([
889
- 'app',
890
890
  'app-backend',
891
891
  'website',
892
892
  ]);
@@ -1303,15 +1303,14 @@ describe('selectStageDeployProjects with a required tag', () => {
1303
1303
  };
1304
1304
  const loadProject = async (project: string) => definitions[project];
1305
1305
 
1306
- it('keeps only projects carrying the tag, on top of the stage-derived rule', async () => {
1306
+ it('keeps only projects carrying the tag, on top of the stage rules', async () => {
1307
1307
  await expect(
1308
1308
  selectStageDeployProjects(Object.keys(definitions), 'production', 'production-push-deploy-target', loadProject),
1309
1309
  ).resolves.toEqual(['website']);
1310
1310
  });
1311
1311
 
1312
- it('selects the website on every stage once it is stage-derived', async () => {
1312
+ it('selects only the tagged project on every stage, required tag or not', async () => {
1313
1313
  await expect(selectStageDeployProjects(Object.keys(definitions), 'pr12', undefined, loadProject)).resolves.toEqual([
1314
- 'app',
1315
1314
  'website',
1316
1315
  ]);
1317
1316
  });
@@ -4,7 +4,7 @@ import { appendFile } from 'node:fs/promises';
4
4
  import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
5
5
  import { $ } from 'bun';
6
6
  import typia from 'typia';
7
- import { isStageDerivedDeploy, PERMANENT_DEPLOY_TAG, STAGING_DEPLOY_TAG } from '../lib/deploy-tags.js';
7
+ import { PERMANENT_DEPLOY_TAG, STAGING_DEPLOY_TAG, stageDeploysProject } from '../lib/deploy-tags.js';
8
8
  import {
9
9
  ciPushBranches,
10
10
  isNonEmpty,
@@ -35,16 +35,14 @@ export interface GithubActionsEventPayload {
35
35
  };
36
36
  }
37
37
 
38
+ /**
39
+ * What a deploy candidate is READ for: its tags. `nx show projects --withTarget
40
+ * deploy` already established the target, and the tags decide the rest, so a
41
+ * project whose `tags` is not a string array fails the parse instead of being
42
+ * silently untagged.
43
+ */
38
44
  interface NxProjectDeployTarget {
39
- tags?: unknown;
40
- targets?: {
41
- deploy?: {
42
- command?: unknown;
43
- options?: {
44
- command?: unknown;
45
- };
46
- };
47
- };
45
+ tags?: string[];
48
46
  }
49
47
 
50
48
  const parseGithubActionsEvent = typia.json.createIsParse<GithubActionsEventPayload>();
@@ -570,16 +568,10 @@ export async function selectStageDeployProjects(
570
568
  for (const project of candidates) {
571
569
  const definition = await loadProject(project);
572
570
  if (!isNxProjectDeployTarget(definition)) continue;
573
- const deploy = definition.targets?.deploy;
574
- const commandValue = deploy?.options?.command ?? deploy?.command;
575
- const tags = Array.isArray(definition.tags)
576
- ? definition.tags.filter((tag): tag is string => typeof tag === 'string')
577
- : [];
578
- const isStageDerived = isStageDerivedDeploy(tags, typeof commandValue === 'string' ? commandValue : undefined);
579
- const isStagingOnly = tags.includes(STAGING_DEPLOY_TAG);
571
+ const tags = definition.tags;
580
572
  // A required tag narrows the stage rules; it never selects a project they exclude.
581
- if (requireTag && !tags.includes(requireTag)) continue;
582
- if (isStageDerived || (stage === 'staging' && isStagingOnly)) {
573
+ if (requireTag && !tags?.includes(requireTag)) continue;
574
+ if (stageDeploysProject(tags, stage)) {
583
575
  selected.push(project);
584
576
  }
585
577
  }
@@ -1,6 +1,8 @@
1
1
  // The Nx tags that drive stage deploys. CI selection (github-ci), workflow generation (monorepo)
2
2
  // and the graph readers must agree on these, so they live here rather than with any one of them.
3
3
 
4
+ import type { DeploymentStage } from '../wrangler/stage.js';
5
+
4
6
  /** Deployed on every stage: one command that deploys whichever stage it is given. */
5
7
  const STAGE_DEPLOY_TAG = 'stage-deploy-target';
6
8
 
@@ -14,9 +16,25 @@ export const PERMANENT_DEPLOY_TAG = 'permanent-deploy-target';
14
16
  export const PRODUCTION_PUSH_DEPLOY_TAG = 'production-push-deploy-target';
15
17
 
16
18
  /**
17
- * Whether a deploy target is stage-derived (the tag, or the `smoo wrangler deploy-stage` command) rather than a set of
18
- * per-stage configurations; CI selection and workflow generation must agree on this.
19
+ * Whether the stage flow deploys a project, from its TAGS alone the one rule
20
+ * CI selection and workflow generation both read, so a generated deploy job and
21
+ * the projects that job deploys cannot disagree.
22
+ *
23
+ * Having a `deploy` target is not that answer. A deploy target says a project
24
+ * CAN be deployed; only a tag says CI does it. A published library ships a
25
+ * wrangler manifest to document a Durable Object binding for its consumers, and
26
+ * that manifest is structurally identical to a deployable worker's — so the
27
+ * plugin infers `deploy` from one, and a repository that deploys nothing from CI
28
+ * would otherwise generate a deploy job, with cloud credentials, for it.
29
+ *
30
+ * The three tags compose as precedence, not as a set: `permanent` excludes a
31
+ * project from every stage, `staging` narrows it to one, `stage` generalizes it
32
+ * to all. `production-push` selects WITHIN this answer (see `--select-tag`) and
33
+ * never widens it.
19
34
  */
20
- export function isStageDerivedDeploy(tags: string[] | undefined, command: string | undefined): boolean {
21
- return tags?.includes(STAGE_DEPLOY_TAG) === true || command?.includes('smoo wrangler deploy-stage') === true;
35
+ export function stageDeploysProject(tags: string[] | undefined, stage: DeploymentStage): boolean {
36
+ if (tags === undefined) return false;
37
+ if (tags.includes(PERMANENT_DEPLOY_TAG)) return false;
38
+ if (tags.includes(STAGING_DEPLOY_TAG)) return stage === 'staging';
39
+ return tags.includes(STAGE_DEPLOY_TAG);
22
40
  }
package/src/lib/json.ts CHANGED
@@ -212,10 +212,32 @@ export interface PackageRemoteCacheConfig {
212
212
  tokenSecret: RepositorySecretName;
213
213
  }
214
214
 
215
- /** Local bootstrap fallback; existing environment values always take precedence. */
215
+ /**
216
+ * A `smoo.secrets` group: which operation resolves the credential. One argv
217
+ * word of `smoo secrets run <group> <command...>`, so whitespace would make
218
+ * that command unwritable. Only the shape is validated — the values are the
219
+ * repository's, and a new group must not need a new smoo release.
220
+ */
221
+ export type SecretGroupName = string & typia.tags.MinLength<1> & typia.tags.Pattern<'^\\S+$'>;
222
+
223
+ /**
224
+ * Local bootstrap fallback; existing environment values always take
225
+ * precedence, and CI injects these variables instead of running commands.
226
+ *
227
+ * Shell entry resolves the `shell` group and nothing else; every other group
228
+ * is resolved by `smoo secrets run <group> <command...>`, the one command
229
+ * that needs it. The group is DERIVED from what this manifest already
230
+ * declares — a variable `.npmrc` interpolates as `${VAR}` is `registry`, the
231
+ * variable `smoo.remoteCache.tokenSecret` names is `nx-cache`, everything
232
+ * else is `shell` — so `group` below is only for what those declarations
233
+ * cannot say. The routing lives in tooling/direnv/secret-references.ts,
234
+ * whose header states the rule and the derivation.
235
+ */
216
236
  export interface PackageSecretCommand {
217
237
  /** Executed directly, without a shell; stdout supplies the secret value. */
218
238
  command: NonEmptyArray<string>;
239
+ /** Overrides the derived group; absent derives one from this manifest and `.npmrc`. */
240
+ group?: SecretGroupName;
219
241
  }
220
242
 
221
243
  export interface PackageSmooConfig {
@@ -3,7 +3,13 @@ import { readFileSync } from 'node:fs';
3
3
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
- import { applyCargoFeatureUnification, type CargoHakariShell, validateCargoCachePolicy } from './cargo-policy.js';
6
+ import type { ProjectTargets } from '../nx/index.js';
7
+ import {
8
+ applyCargoFeatureUnification,
9
+ type CargoHakariShell,
10
+ validateCargoCachePolicy,
11
+ validateCargoToolchainInputs,
12
+ } from './cargo-policy.js';
7
13
 
8
14
  async function withFixture<T>(files: Record<string, string>, callback: (root: string) => T): Promise<T> {
9
15
  const root = await mkdtemp(join(tmpdir(), 'smoo-cargo-policy-'));
@@ -33,6 +39,20 @@ async function check(
33
39
  });
34
40
  }
35
41
 
42
+ async function checkToolchain(
43
+ files: Record<string, string>,
44
+ projects: ProjectTargets[],
45
+ ): Promise<{ failures: number; messages: string[] }> {
46
+ return withFixture(files, (root) => {
47
+ const captured = captureErrors();
48
+ try {
49
+ return { failures: validateCargoToolchainInputs(root, projects), messages: captured.messages };
50
+ } finally {
51
+ captured.restore();
52
+ }
53
+ });
54
+ }
55
+
36
56
  /** Records what update would run, and answers `verify` however the test needs. */
37
57
  function recordingHakari(
38
58
  verify: { code: number; output?: string; missing?: boolean } = { code: 0 },
@@ -389,3 +409,73 @@ describe('Cargo feature unification update', () => {
389
409
  );
390
410
  });
391
411
  });
412
+
413
+ describe('cargo toolchain identity policy', () => {
414
+ const cargoLint = (inputs: string[]): ProjectTargets => ({
415
+ project: 'codebase',
416
+ root: '.',
417
+ targets: ['cargo-lint'],
418
+ targetCache: new Map([['cargo-lint', true]]),
419
+ targetInputs: new Map([['cargo-lint', inputs]]),
420
+ targetOptions: new Map([['cargo-lint', { command: 'cargo --frozen clippy --workspace -- -D warnings' }]]),
421
+ });
422
+
423
+ // The failure this policy exists for: a hand-written input list replaces the
424
+ // inferred one, so the target stops hashing the pin and a toolchain bump
425
+ // leaves its cached verdict standing.
426
+ it('refuses a cached cargo target whose declared inputs drop the pin', async () => {
427
+ const { failures, messages } = await checkToolchain({ 'nx.json': '{}\n' }, [cargoLint(['rustWorkspace'])]);
428
+ expect(failures).toBe(1);
429
+ expect(messages).toEqual([
430
+ 'codebase:cargo-lint: cached cargo target hashes no toolchain pin, so a toolchain bump cannot invalidate it. ' +
431
+ 'Add "cargoToolchain" to its inputs, or to the named input it uses.',
432
+ ]);
433
+ });
434
+
435
+ it('accepts the pin reached directly, through a named input, or by the name inference defines', async () => {
436
+ const viaNamedInput = JSON.stringify({
437
+ namedInputs: { rustWorkspace: ['{workspaceRoot}/Cargo.toml', '{workspaceRoot}/tooling/direnv/devenv.lock'] },
438
+ });
439
+ expect(await checkToolchain({ 'nx.json': viaNamedInput }, [cargoLint(['rustWorkspace'])])).toMatchObject({
440
+ failures: 0,
441
+ });
442
+ expect(
443
+ await checkToolchain({ 'nx.json': '{}\n' }, [cargoLint(['{workspaceRoot}/tooling/direnv/devenv.lock'])]),
444
+ ).toMatchObject({ failures: 0 });
445
+ expect(await checkToolchain({ 'nx.json': '{}\n' }, [cargoLint(['cargoToolchain'])])).toMatchObject({
446
+ failures: 0,
447
+ });
448
+ });
449
+
450
+ // A negated fileset REMOVES a path from the hash. Reading one as the pin
451
+ // would accept the exact declaration that guarantees the bug.
452
+ it('does not accept an excluded lock as the pin', async () => {
453
+ expect(
454
+ await checkToolchain({ 'nx.json': '{}\n' }, [cargoLint(['!{workspaceRoot}/tooling/direnv/devenv.lock'])]),
455
+ ).toMatchObject({ failures: 1 });
456
+ });
457
+
458
+ // Only what Nx can restore, and only what runs cargo: an uncached warm-up or
459
+ // a TypeScript build has no stale artifact for a toolchain bump to strand.
460
+ it('governs cached cargo targets only', async () => {
461
+ const uncached: ProjectTargets = {
462
+ ...cargoLint([]),
463
+ targetCache: new Map([['cargo-lint', false]]),
464
+ };
465
+ const notCargo: ProjectTargets = {
466
+ ...cargoLint([]),
467
+ targets: ['tsc-js'],
468
+ targetCache: new Map([['tsc-js', true]]),
469
+ targetInputs: new Map([['tsc-js', ['default']]]),
470
+ targetOptions: new Map([['tsc-js', { command: 'ttsc --build' }]]),
471
+ };
472
+ expect(await checkToolchain({ 'nx.json': '{}\n' }, [uncached, notCargo])).toMatchObject({ failures: 0 });
473
+ });
474
+
475
+ // A cyclic named input is a repository mistake, not a reason to hang the
476
+ // whole validation run before it can report anything at all.
477
+ it('terminates on a named input that references itself', async () => {
478
+ const cyclic = JSON.stringify({ namedInputs: { rustWorkspace: ['rustWorkspace'] } });
479
+ expect(await checkToolchain({ 'nx.json': cyclic }, [cargoLint(['rustWorkspace'])])).toMatchObject({ failures: 1 });
480
+ });
481
+ });
@@ -1,8 +1,10 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { type Dirent, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { CARGO_TOOLCHAIN_NAMED_INPUT, isCargoToolchainInput } from '@smoothbricks/nx-plugin/cargo-toolchain-policy';
4
5
  import typia from 'typia';
5
- import { parsePackageJsonText } from '../lib/json.js';
6
+ import { type NxJson, type NxTargetOptions, parseNxJsonText, parsePackageJsonText } from '../lib/json.js';
7
+ import type { ProjectTargets } from '../nx/index.js';
6
8
 
7
9
  interface CargoProfile {
8
10
  inherits?: string;
@@ -869,6 +871,90 @@ export function validateCargoCachePolicy(root: string, options: CargoPolicyOptio
869
871
  return failures;
870
872
  }
871
873
 
874
+ /** A command that compiles or checks Rust, whichever driver spells it. */
875
+ const RUNS_CARGO = /(?:^|[\s;&|])cargo[\s-]|\bnapi build\b/;
876
+
877
+ const CARGO_TOOLCHAIN_FIX = `Add "${CARGO_TOOLCHAIN_NAMED_INPUT}" to its inputs, or to the named input it uses`;
878
+
879
+ function commandsOf(options: NxTargetOptions | undefined): string {
880
+ const command: unknown = options?.command;
881
+ const commands: unknown = options?.commands;
882
+ return [
883
+ typeof command === 'string' ? command : '',
884
+ ...(Array.isArray(commands) ? commands.filter((entry) => typeof entry === 'string') : []),
885
+ ].join('\n');
886
+ }
887
+
888
+ /**
889
+ * Every fileset one declared input list reaches, with nx.json's named inputs
890
+ * expanded. The plugin's own `cargoToolchain` is defined per PROJECT and wins
891
+ * over nx.json, so naming it counts without an entry here; a repository that
892
+ * redefines it workspace-wide is expanded and judged on what it resolves to.
893
+ */
894
+ function resolveInputFilesets(
895
+ declared: readonly string[],
896
+ namedInputs: NonNullable<NxJson['namedInputs']>,
897
+ seen: Set<string> = new Set(),
898
+ ): string[] {
899
+ const filesets: string[] = [];
900
+ for (const entry of declared) {
901
+ const name = entry.startsWith('^') ? entry.slice(1) : entry;
902
+ const definition = namedInputs[name];
903
+ if (definition === undefined) {
904
+ filesets.push(entry);
905
+ continue;
906
+ }
907
+ if (seen.has(name)) continue;
908
+ seen.add(name);
909
+ const expanded = typeof definition === 'string' ? [definition] : definition;
910
+ filesets.push(
911
+ ...resolveInputFilesets(
912
+ expanded.filter((value): value is string => typeof value === 'string'),
913
+ namedInputs,
914
+ seen,
915
+ ),
916
+ );
917
+ }
918
+ return filesets;
919
+ }
920
+
921
+ /**
922
+ * A cached cargo target must hash the toolchain that compiled it.
923
+ *
924
+ * Inference attaches the pin to every cargo target it infers, but a repository
925
+ * that DECLARES `inputs` for one replaces that list wholesale and silently
926
+ * drops it — the target then survives a toolchain bump with a stale verdict,
927
+ * and a pre-push cross-compile probe consults a cache entry that cannot
928
+ * express the change it is probing for. Measured before this policy existed:
929
+ * a one-byte bump to the declared pin left one repository's `cargo-lint-cross`
930
+ * hash unchanged.
931
+ *
932
+ * This is a generated safety property removed by a local override, which no
933
+ * amount of care at the override site will catch on its own, so it is checked
934
+ * against the RESOLVED graph rather than against any one file.
935
+ */
936
+ export function validateCargoToolchainInputs(root: string, projects: readonly ProjectTargets[]): number {
937
+ const nxJsonPath = join(resolve(root), 'nx.json');
938
+ const namedInputs = existsSync(nxJsonPath)
939
+ ? (parseNxJsonText(readFileSync(nxJsonPath, 'utf8'))?.namedInputs ?? {})
940
+ : {};
941
+ let failures = 0;
942
+ for (const project of projects) {
943
+ for (const target of [...project.targets].sort()) {
944
+ if (project.targetCache?.get(target) !== true) continue;
945
+ if (!RUNS_CARGO.test(commandsOf(project.targetOptions?.get(target)))) continue;
946
+ const declared = project.targetInputs?.get(target) ?? [];
947
+ if (declared.includes(CARGO_TOOLCHAIN_NAMED_INPUT)) continue;
948
+ if (resolveInputFilesets(declared, namedInputs).some(isCargoToolchainInput)) continue;
949
+ failures += report(
950
+ `${project.project}:${target}`,
951
+ `cached cargo target hashes no toolchain pin, so a toolchain bump cannot invalidate it. ${CARGO_TOOLCHAIN_FIX}.`,
952
+ );
953
+ }
954
+ }
955
+ return failures;
956
+ }
957
+
872
958
  /**
873
959
  * The fix `smoo monorepo update` applies for the policy above: write the
874
960
  * nightly resolver configuration, or generate and wire the workspace-hack a
@@ -2,7 +2,11 @@ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { printCommandOutput, run, runResult } from '../lib/run.js';
3
3
  import { escapeRegex, getWorkspacePackages, getWorkspacePatterns, listReleasePackages } from '../lib/workspace.js';
4
4
  import { readProjectTargets } from '../nx/index.js';
5
- import { applyCargoFeatureUnification, validateCargoCachePolicy } from './cargo-policy.js';
5
+ import {
6
+ applyCargoFeatureUnification,
7
+ validateCargoCachePolicy,
8
+ validateCargoToolchainInputs,
9
+ } from './cargo-policy.js';
6
10
  import {
7
11
  formatCommitMessage,
8
12
  stagedDeletedPublicPackages,
@@ -133,7 +137,8 @@ export async function checkManagedFiles(root: string, options: { warn?: boolean
133
137
  }
134
138
  const results = await applyManagedFiles(root, 'check');
135
139
  printResults(results);
136
- const resolvedTargets = resolvedTargetsByProject(await readProjectTargets(root));
140
+ const projectTargets = await readProjectTargets(root);
141
+ const resolvedTargets = resolvedTargetsByProject(projectTargets);
137
142
  const packageFailures =
138
143
  validateRootPackagePolicy(root) +
139
144
  validateNxProjectNames(root) +
@@ -145,6 +150,7 @@ export async function checkManagedFiles(root: string, options: { warn?: boolean
145
150
  validateWorkspaceDependencies(root, { resolvedTargetsByProject: resolvedTargets }) +
146
151
  validateDevenvModuleImport(root) +
147
152
  validateCargoCachePolicy(root) +
153
+ validateCargoToolchainInputs(root, projectTargets) +
148
154
  validateSccachePatches(root);
149
155
  if (results.some((result) => result.action === 'drifted') || packageFailures > 0) {
150
156
  throw new Error('Managed monorepo files or package conventions are out of date. Run: smoo monorepo update');
@@ -18,6 +18,7 @@ import {
18
18
  LOCAL_SECTION_MARKER,
19
19
  type ManagedFileContext,
20
20
  macosPlatformArchitecturesForTest,
21
+ managedFileTargetsForContext,
21
22
  managedFileTargetsForTest,
22
23
  platformTargetGlobsForTest,
23
24
  reinsertInlineLocalBlocksForTest,
@@ -263,14 +264,10 @@ describe('nx graph project helpers', () => {
263
264
  },
264
265
  },
265
266
  app: {
267
+ // What the plugin infers for a private wrangler project, and what a published library
268
+ // holding a wrangler manifest used to get: a deploy target no tag enrols in CI.
266
269
  targets: {
267
- deploy: {
268
- options: { command: 'echo no' },
269
- configurations: {
270
- staging: { command: 'wrangler deploy --env staging' },
271
- production: { options: { command: 'wrangler deploy --env production' } },
272
- },
273
- },
270
+ deploy: { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } },
274
271
  'package-macos': {},
275
272
  },
276
273
  },
@@ -296,43 +293,40 @@ describe('nx graph project helpers', () => {
296
293
  expect(hasExactTargetForTest(nearMisses, 'e2e-deployment')).toBe(false);
297
294
  });
298
295
 
299
- it('detects deploy configurations and cloudflare provider from graph nodes', () => {
300
- expect(deployTargetInfoFromProjects(sampleProjects, 'staging')).toEqual({
301
- exists: true,
302
- provider: 'cloudflare',
303
- });
304
- expect(deployTargetInfoFromProjects(sampleProjects, 'production')).toEqual({
305
- exists: true,
306
- provider: 'cloudflare',
307
- });
308
- expect(deployTargetInfoFromProjects(sampleProjects, 'preview')).toEqual({ exists: false });
296
+ it('deploys nothing for a wrangler deploy target no tag names', () => {
297
+ expect(deployTargetInfoFromProjects(sampleProjects, 'staging')).toEqual({ exists: false });
298
+ expect(deployTargetInfoFromProjects(sampleProjects, 'production')).toEqual({ exists: false });
309
299
  });
310
300
 
311
- it('recognizes convention-driven deploy-stage targets without per-stage configurations', () => {
312
- const projects: NxProjects = {
313
- app: {
314
- targets: {
315
- deploy: {
316
- options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' },
317
- },
318
- },
319
- },
320
- };
301
+ it('reads the tags as precedence: permanent excludes, staging narrows, stage generalizes', () => {
302
+ const deploy = { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } };
303
+ const stageFor = (tags: string[]) => ({
304
+ staging: deployTargetInfoFromProjects({ site: { tags, targets: { deploy } } }, 'staging').exists,
305
+ production: deployTargetInfoFromProjects({ site: { tags, targets: { deploy } } }, 'production').exists,
306
+ });
321
307
 
322
- expect(deployTargetInfoFromProjects(projects, 'staging')).toEqual({ exists: true, provider: 'cloudflare' });
323
- expect(deployTargetInfoFromProjects(projects, 'production')).toEqual({ exists: true, provider: 'cloudflare' });
308
+ expect(stageFor(['stage-deploy-target'])).toEqual({ staging: true, production: true });
309
+ expect(stageFor(['staging-deploy-target'])).toEqual({ staging: true, production: false });
310
+ expect(stageFor(['permanent-deploy-target'])).toEqual({ staging: false, production: false });
311
+ // Both spellings on one project is a contradiction; the exclusion wins, which is what
312
+ // `nx show projects --exclude=tag:permanent-deploy-target` does to it in CI too.
313
+ expect(stageFor(['permanent-deploy-target', 'stage-deploy-target'])).toEqual({
314
+ staging: false,
315
+ production: false,
316
+ });
317
+ expect(stageFor(['npm:private'])).toEqual({ staging: false, production: false });
324
318
  });
325
319
 
326
- it('treats a stage-deploy-target tag as a deploy target for every stage, like the deploy-stage command', () => {
327
- const projects: NxProjects = {
328
- site: {
329
- tags: ['stage-deploy-target'],
330
- targets: { deploy: { command: 'wrangler deploy --config dist/wrangler.json' } },
331
- },
320
+ it('reads the command for the provider only: which credentials the generated job needs', () => {
321
+ const cloudflare: NxProjects = {
322
+ site: { tags: ['stage-deploy-target'], targets: { deploy: { command: 'wrangler deploy --config dist/w.json' } } },
323
+ };
324
+ const elsewhere: NxProjects = {
325
+ site: { tags: ['stage-deploy-target'], targets: { deploy: { command: 'bun tooling/deploy-site.ts' } } },
332
326
  };
333
327
 
334
- expect(deployTargetInfoFromProjects(projects, 'staging')).toEqual({ exists: true, provider: 'cloudflare' });
335
- expect(deployTargetInfoFromProjects(projects, 'production')).toEqual({ exists: true, provider: 'cloudflare' });
328
+ expect(deployTargetInfoFromProjects(cloudflare, 'staging')).toEqual({ exists: true, provider: 'cloudflare' });
329
+ expect(deployTargetInfoFromProjects(elsewhere, 'staging')).toEqual({ exists: true, provider: undefined });
336
330
  });
337
331
 
338
332
  it('finds a tag carried by any project in the graph', () => {
@@ -582,6 +576,44 @@ describe('CI workflow rendering by repo shape', () => {
582
576
  expect(rendered).toContain(' deploy-production:');
583
577
  });
584
578
 
579
+ it('renders no deploy job at all for a repo whose wrangler projects carry no deploy tag', () => {
580
+ // The regression this exists for: published libraries ship a wrangler manifest to document a
581
+ // Durable Object binding for their consumers, the plugin inferred a deploy target from each,
582
+ // and a repository that deploys nothing from CI grew a Deploy Stage step holding Cloudflare
583
+ // credentials, a deployments permission, and a preview-cleanup workflow.
584
+ const deploy = { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } };
585
+ const projects: NxProjects = {
586
+ 'acme-cloudflare': { tags: ['npm:private'], targets: { deploy } },
587
+ 'acme-documents': { tags: ['npm:public'], targets: { deploy } },
588
+ // A private worker, deployed by hand from a developer's shell and never by CI.
589
+ 'acme-service': { targets: { deploy } },
590
+ };
591
+ const staging = deployTargetInfoFromProjects(projects, 'staging');
592
+ const production = deployTargetInfoFromProjects(projects, 'production');
593
+ const shape = context({
594
+ hasStagingDeployTargets: staging.exists,
595
+ stagingDeployProvider: staging.provider,
596
+ hasProductionDeployTargets: production.exists,
597
+ productionDeployProvider: production.provider,
598
+ hasE2eDeploymentTargets: true,
599
+ });
600
+ const ci = renderManagedWorkflowForTest('ci-workflow', shape);
601
+
602
+ expect(ci).not.toContain('Deploy Stage');
603
+ expect(ci).not.toContain('deployments: write');
604
+ expect(ci).not.toContain('CLOUDFLARE_API_TOKEN');
605
+ expect(ci).not.toContain('e2e-deployment');
606
+ expect(renderManagedWorkflowForTest('publish-workflow', shape)).not.toContain('Deploy production');
607
+ expect(managedFileTargetsForContext(shape)).not.toContain('.github/workflows/pr-preview-cleanup.yml');
608
+ });
609
+
610
+ it('adds the preview cleanup workflow only where a cloudflare stage deploy makes preview stages', () => {
611
+ expect(managedFileTargetsForContext(deploying)).toContain('.github/workflows/pr-preview-cleanup.yml');
612
+ expect(managedFileTargetsForContext({ ...deploying, stagingDeployProvider: undefined })).not.toContain(
613
+ '.github/workflows/pr-preview-cleanup.yml',
614
+ );
615
+ });
616
+
585
617
  it('threads environments and secrets from the context into the workflow', () => {
586
618
  const rendered = renderManagedWorkflowForTest('ci-workflow', {
587
619
  ...deploying,