@smoothbricks/cli 0.11.13 → 0.11.15

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.
@@ -1,16 +1,14 @@
1
1
  /**
2
- * Provider-neutral local secret resolution for smoo-managed repositories, and
3
- * the developer-shell half of the declared Nx remote cache.
2
+ * Provider-neutral local secret resolution for smoo-managed repositories.
4
3
  *
5
4
  * Reads the root package.json `smoo.secrets` map and `smoo.remoteCache` block
6
5
  * — the bootstrap twins of `PackageSecretCommand`, `PackageSmooConfig.secrets`
7
6
  * and `PackageRemoteCacheConfig` in packages/cli/src/lib/json.ts. This file is
8
7
  * a managed raw script: it runs from `tooling/direnv/setup-environment.ts`
9
- * BEFORE `bun install`, and from the managed devenv `enterShell` before that,
10
- * when no workspace package and no Typia transform exist yet, so both shapes
11
- * are hand-validated here against exactly what json.ts declares. Keep them
12
- * aligned; smoo's Typia validation fails the manifest at generation time for
13
- * anything this file would reject at runtime.
8
+ * BEFORE `bun install`, when no workspace package and no Typia transform exist
9
+ * yet, so both shapes are hand-validated here against exactly what json.ts
10
+ * declares. Keep them aligned; smoo's Typia validation fails the manifest at
11
+ * generation time for anything this file would reject at runtime.
14
12
  *
15
13
  * Routing per declared variable, in first-match order:
16
14
  *
@@ -28,23 +26,18 @@
28
26
  * Failures aggregate so a single direnv reload surfaces every problem.
29
27
  * Error text names variables and exit codes only: secret values, provider
30
28
  * stdout/stderr, and command arguments are never echoed.
29
+ * A resolved value still reaches the environment of the install its caller
30
+ * runs next, so `maskSecretValues` redacts those values out of any captured
31
+ * child output that caller replays.
31
32
  *
32
- * One declared variable is exempt from blocking an install: the one
33
- * `smoo.remoteCache.tokenSecret` names. A remote cache is an optimization, so
34
- * an unreachable secret provider must not keep dependencies from installing or
35
- * a shell from opening; that variable is resolved by the cache export below,
36
- * where failure costs a stderr line.
37
- *
38
- * Run as a program (`bun secret-references.ts [root]`), the script prints the
39
- * remote cache's `export` lines for the shell to `eval` and nothing else: Nx
40
- * runs in the developer's shell, not in the setup child, and the export is
41
- * limited to those two variables so `smoo.secrets` stays process-local. It
42
- * prints nothing when the repository declares no cache, when the shell already
43
- * carries a server (a CI job env is never overwritten), or when the declared
44
- * token has no value — Nx accepts only 200 or 404 from a cache server, so a
45
- * server it cannot authenticate to fails every task instead of missing. Why
46
- * the cache is off is said on stderr; the exit status stays 0, because a
47
- * missing cache credential must never keep a shell from opening.
33
+ * The variable `smoo.remoteCache.tokenSecret` names is never resolved here,
34
+ * nor anywhere at shell entry. A remote cache is an optimization, and shell
35
+ * entry happens on every direnv reload and every `devenv shell -- <command>`:
36
+ * a provider command run there is a credential prompt on each of them. Nx
37
+ * reads NX_SELF_HOSTED_REMOTE_CACHE_SERVER and _ACCESS_TOKEN from the
38
+ * environment it runs in — CI injects them, a developer exports the token
39
+ * once in the terminal that wants the cache and every nested shell inherits
40
+ * them. Absent, Nx keeps to its local cache.
48
41
  */
49
42
  import { existsSync, readFileSync } from 'node:fs';
50
43
  import { join } from 'node:path';
@@ -135,10 +128,6 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
135
128
  return { command: [first, ...rest] };
136
129
  }
137
130
 
138
- /** The two variables Nx reads for its self-hosted HTTP cache. */
139
- const REMOTE_CACHE_SERVER = 'NX_SELF_HOSTED_REMOTE_CACHE_SERVER';
140
- const REMOTE_CACHE_ACCESS_TOKEN = 'NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN';
141
-
142
131
  /**
143
132
  * The declared remote cache as a local shell needs it. `internalServer` is
144
133
  * deliberately absent: it is the address a managed runner reaches inside its
@@ -325,6 +314,69 @@ const runSecretCommand: SecretCommandRunner = async (argv) => {
325
314
  return stdout;
326
315
  };
327
316
 
317
+ /** '*', the byte a redacted secret leaves behind. */
318
+ const MASK_BYTE = 0x2a;
319
+
320
+ /**
321
+ * A copy of captured child output with every resolved secret value replaced
322
+ * by the same number of `*` bytes.
323
+ *
324
+ * setup-environment.ts puts the values this file resolves into the
325
+ * environment of the install it then runs, and replays that child's captured
326
+ * stdout/stderr verbatim when it fails. Whatever the child echoes — a prepare
327
+ * script printing its environment, a registry URL carrying an inline
328
+ * credential — would otherwise walk straight past the suppression the rest of
329
+ * this file maintains. Redaction is byte-exact and same-length, so the
330
+ * failure being reported stays legible and every offset in it still lines up.
331
+ * Output with nothing to redact is returned as it came in, so the path that
332
+ * has no secrets to hide copies nothing.
333
+ */
334
+ export function maskSecretValues(output: Uint8Array, values: Iterable<string>): Uint8Array {
335
+ const encoder = new TextEncoder();
336
+ let masked: Uint8Array | undefined;
337
+ for (const value of values) {
338
+ const needle = encoder.encode(value);
339
+ if (needle.length === 0 || needle.length > output.length) {
340
+ continue;
341
+ }
342
+ for (let from = 0; from + needle.length <= output.length; ) {
343
+ const at = indexOfBytes(masked ?? output, needle, from);
344
+ if (at < 0) {
345
+ break;
346
+ }
347
+ // `new Uint8Array`, not `.slice()`: a captured child's output arrives as
348
+ // a node Buffer, whose `slice` is `subarray` — a view. Masking through
349
+ // one would rewrite the caller's own captured bytes.
350
+ masked ??= new Uint8Array(output);
351
+ masked.fill(MASK_BYTE, at, at + needle.length);
352
+ from = at + needle.length;
353
+ }
354
+ }
355
+ return masked ?? output;
356
+ }
357
+
358
+ /** First occurrence of `needle` in `haystack` at or after `from`, or -1. */
359
+ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): number {
360
+ const first = needle[0];
361
+ if (first === undefined) {
362
+ return -1;
363
+ }
364
+ const last = haystack.length - needle.length;
365
+ for (let at = haystack.indexOf(first, from); at >= 0 && at <= last; at = haystack.indexOf(first, at + 1)) {
366
+ let matches = true;
367
+ for (let offset = 1; offset < needle.length; offset += 1) {
368
+ if (haystack[at + offset] !== needle[offset]) {
369
+ matches = false;
370
+ break;
371
+ }
372
+ }
373
+ if (matches) {
374
+ return at;
375
+ }
376
+ }
377
+ return -1;
378
+ }
379
+
328
380
  /**
329
381
  * The declared secrets an install needs, resolved before it runs. The cache
330
382
  * token is deliberately not among them: a remote cache is an optimization, so
@@ -353,85 +405,6 @@ export async function resolveSecretEnvironment(
353
405
  });
354
406
  }
355
407
 
356
- /**
357
- * What the shell should do about the declared remote cache. Each case is a
358
- * value, not an exception: three of the four are ordinary, and only one is
359
- * worth saying out loud.
360
- */
361
- export type RemoteCacheOutcome =
362
- | { readonly kind: 'undeclared' }
363
- | { readonly kind: 'inherited' }
364
- | { readonly kind: 'unavailable'; readonly reason: string }
365
- | { readonly kind: 'exported'; readonly env: Readonly<Record<string, string>> };
366
-
367
- /**
368
- * The remote cache variables this shell should export, or why it exports none.
369
- * An inherited server always wins, so a CI job env — which carries the address
370
- * its own runners reach — is never overwritten by the public one. The token is
371
- * taken from the environment when it is there, and otherwise from the one
372
- * `smoo.secrets` entry that declares it — resolving that single variable, so
373
- * no other declared secret ever reaches the shell. A repository that declares
374
- * the cache token as a provider secret therefore pays that one command twice
375
- * per shell entry, once here and once in the setup child's own aggregate
376
- * resolution; an ambient token pays nothing.
377
- */
378
- export async function resolveRemoteCacheOutcome(options: SecretResolutionOptions): Promise<RemoteCacheOutcome> {
379
- const env = options.env ?? process.env;
380
- const packageJson = readPackageJson(options.root);
381
- const spec = parseSmooRemoteCache(packageJson);
382
- if (spec === null) {
383
- return { kind: 'undeclared' };
384
- }
385
- if (isNonemptyEnvValue(env[REMOTE_CACHE_SERVER])) {
386
- return { kind: 'inherited' };
387
- }
388
- const ambient = env[spec.tokenSecret];
389
- if (isNonemptyEnvValue(ambient)) {
390
- return {
391
- kind: 'exported',
392
- env: { [REMOTE_CACHE_SERVER]: spec.server, [REMOTE_CACHE_ACCESS_TOKEN]: ambient },
393
- };
394
- }
395
- const declared = parseSmooSecrets(packageJson)[spec.tokenSecret];
396
- if (declared === undefined) {
397
- return {
398
- kind: 'unavailable',
399
- reason: `${spec.tokenSecret} is unset and no smoo.secrets entry declares it, so ${spec.server} would be asked for cache entries with no credential`,
400
- };
401
- }
402
- // A cache token is not registry routing, so the cowshed `.npmrc` exclusion
403
- // cannot apply to it; every other rule in the header does.
404
- const outcome = await routeSecret(spec.tokenSecret, declared, {
405
- env,
406
- registryIntentEnvs: new Set(),
407
- run: options.runCommand ?? runSecretCommand,
408
- });
409
- switch (outcome.kind) {
410
- case 'resolved':
411
- return {
412
- kind: 'exported',
413
- env: { [REMOTE_CACHE_SERVER]: spec.server, [REMOTE_CACHE_ACCESS_TOKEN]: outcome.value },
414
- };
415
- case 'failed':
416
- return { kind: 'unavailable', reason: `${spec.tokenSecret}: ${outcome.guidance}` };
417
- case 'env-wins':
418
- // Only reachable if an ambient value appeared between the check above
419
- // and this call; the export happens on the next shell entry.
420
- return { kind: 'unavailable', reason: `${spec.tokenSecret} was set after it was read` };
421
- }
422
- }
423
-
424
- /**
425
- * POSIX `export` lines for a shell to `eval`. Single quotes are the only
426
- * quoting a value cannot escape from, so each value is single-quoted with its
427
- * own quotes spliced out — a token is opaque bytes, never assumed shell-safe.
428
- */
429
- export function shellExportLines(env: Readonly<Record<string, string>>): string {
430
- return Object.entries(env)
431
- .map(([name, value]) => `export ${name}='${value.replaceAll("'", "'\\''")}'\n`)
432
- .join('');
433
- }
434
-
435
408
  function readPackageJson(root: string): unknown {
436
409
  const packageJsonPath = join(root, 'package.json');
437
410
  let text: string;
@@ -452,27 +425,8 @@ function readPackageJson(root: string): unknown {
452
425
  }
453
426
  return parsed;
454
427
  }
455
-
456
428
  function readNpmrcText(root: string): string | null {
457
429
  const npmrcPath = join(root, '.npmrc');
458
430
  if (!existsSync(npmrcPath)) return null;
459
431
  return readFileSync(npmrcPath, 'utf8');
460
432
  }
461
-
462
- // Program mode, run by the managed devenv shell as
463
- // `eval "$(bun "$DEVENV_ROOT/secret-references.ts" "$PWD")"`. stdout is
464
- // therefore shell text and carries nothing else; everything a human should
465
- // read goes to stderr, and the status stays 0 so a shell always opens.
466
- if (import.meta.main) {
467
- const root = Bun.argv[2] ?? process.cwd();
468
- try {
469
- const outcome = await resolveRemoteCacheOutcome({ root });
470
- if (outcome.kind === 'exported') {
471
- process.stdout.write(shellExportLines(outcome.env));
472
- } else if (outcome.kind === 'unavailable') {
473
- console.error(`smoo: Nx remote cache off — ${outcome.reason}`);
474
- }
475
- } catch (error) {
476
- console.error(`smoo: Nx remote cache off — ${error instanceof Error ? error.message : String(error)}`);
477
- }
478
- }
@@ -4,7 +4,7 @@ import { mkdir, rmdir, stat } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { $ } from 'bun';
7
- import { resolveSecretEnvironment } from './secret-references.ts';
7
+ import { maskSecretValues, resolveSecretEnvironment } from './secret-references.ts';
8
8
 
9
9
  // DEVENV_ROOT is set by the devenv shell, which is how this script normally
10
10
  // runs. CI jobs that install dependencies without building that shell (the
@@ -24,6 +24,14 @@ class CapturedCommandError extends Error {
24
24
  }
25
25
  }
26
26
 
27
+ // Every value resolved out of smoo.secrets below. The install inherits them,
28
+ // so whatever a failing child echoed is redacted before its captured output
29
+ // is replayed. Declared above the first replay site (resolveProjectRoot's own
30
+ // failure) because module consts are not hoisted: a reference from there to a
31
+ // const declared further down would report a temporal-dead-zone error instead
32
+ // of the failure it was called to report.
33
+ const resolvedSecretValues: string[] = [];
34
+
27
35
  async function resolveProjectRoot(): Promise<string> {
28
36
  if (devenvRoot) {
29
37
  return path.resolve(`${devenvRoot}/../..`);
@@ -66,19 +74,11 @@ const TYPESCRIPT_API_VERSION = '6.0.3';
66
74
  process.chdir(projectRoot);
67
75
 
68
76
  try {
69
- // Provider-declared secrets (smoo.secrets) resolve before any install. The
70
- // values land in THIS process environment only — bun install, the prepare
71
- // scripts it runs, and every later child of this script inherit them (for
72
- // example .npmrc `${VAR}` auth); the direnv shell itself does not, which is
73
- // the point: this script must never act as a global shell export.
74
- for (const [name, value] of Object.entries(await resolveSecretEnvironment({ root: projectRoot }))) {
75
- process.env[name] = value;
76
- }
77
-
78
77
  // Bootstrap only: install deps + wire local git hooks/config.
79
78
  // Do not import workspace packages here — this script is what installs them,
80
79
  // and package resolution/Typia transforms are not available yet.
81
80
  if (process.env.CI) {
81
+ await resolveSecrets();
82
82
  // Concurrent devenv activations share one node_modules, so the CI
83
83
  // installs race exactly like local ones (EEXIST link failures under
84
84
  // parallel shells). Serialize them under the same setup lock. Nothing
@@ -118,24 +118,60 @@ try {
118
118
  process.exit(1);
119
119
  }
120
120
  } else {
121
- await installLocalDependencies();
121
+ // A local secret-resolution or install failure (a provider that is not
122
+ // signed in, an unpublished private package, a missing registry
123
+ // credential, a lockfile that needs `devenv update`) must not take the
124
+ // shell down with it: direnv drops the whole environment on a non-zero
125
+ // exit, and then bun, nx, op and every other tool needed to repair the
126
+ // install are gone too. Report it, finish what does not depend on the
127
+ // install, and load the shell. CI above stays strict.
128
+ const installError = await installLocalDependencies();
129
+ if (installError === undefined) {
130
+ // Pin unscoped typescript → API 6 for root and Bun's shared .bun hoist (Nx).
131
+ ensureTypeScriptApiPackage(projectRoot);
132
+ }
133
+ await applyWorkspaceGitConfig(projectRoot);
134
+ if (installError !== undefined) {
135
+ reportDegradedSetup(installError);
136
+ }
122
137
  }
123
-
124
- // Pin unscoped typescript → API 6 for root and Bun's shared .bun hoist (Nx).
125
- ensureTypeScriptApiPackage(projectRoot);
126
-
127
- await applyWorkspaceGitConfig(projectRoot);
128
138
  } catch (error) {
129
139
  reportSetupFailure(error);
130
140
  }
131
141
 
132
- async function installLocalDependencies(): Promise<void> {
142
+ /**
143
+ * Provider-declared secrets (smoo.secrets) resolve before any install. The
144
+ * values land in THIS process environment only — bun install, the prepare
145
+ * scripts it runs, and every later child of this script inherit them (for
146
+ * example .npmrc `${VAR}` auth); the direnv shell itself does not, which is
147
+ * the point: this script must never act as a global shell export.
148
+ */
149
+ async function resolveSecrets(): Promise<void> {
150
+ for (const [name, value] of Object.entries(await resolveSecretEnvironment({ root: projectRoot }))) {
151
+ process.env[name] = value;
152
+ resolvedSecretValues.push(value);
153
+ }
154
+ }
155
+
156
+ async function installLocalDependencies(): Promise<unknown> {
157
+ try {
158
+ await resolveSecrets();
159
+ } catch (error) {
160
+ return error;
161
+ }
133
162
  // bun install runs the root prepare script. Multiple concurrent direnv
134
163
  // activations can otherwise race while mutating the same files under
135
- // node_modules.
164
+ // node_modules. Nothing that exits the process may run inside the callback
165
+ // (see the CI branch), so the failure is returned, not thrown.
166
+ let installError: unknown;
136
167
  await withSetupLock(async () => {
137
- await runSetupCommand('bun install --no-summary', $`bun install --no-summary`);
168
+ try {
169
+ await runSetupCommand('bun install --no-summary', $`bun install --no-summary`);
170
+ } catch (error) {
171
+ installError = error;
172
+ }
138
173
  });
174
+ return installError;
139
175
  }
140
176
 
141
177
  function ensureTypeScriptApiPackage(root: string): void {
@@ -374,25 +410,44 @@ function isFileExistsError(error: unknown): boolean {
374
410
  }
375
411
 
376
412
  function reportSetupFailure(error: unknown): never {
413
+ describeFailure('ERROR', error);
414
+ process.exit(1);
415
+ }
416
+
417
+ function reportDegradedSetup(error: unknown): void {
418
+ describeFailure('WARNING', error);
419
+ console.error(
420
+ 'The shell is loaded WITHOUT installed dependencies so the tools to repair this stay available.\n' +
421
+ 'Fix the cause above (missing registry credential, unpublished package, stale lockfile → `devenv update`),\n' +
422
+ 'then run `bun install` or `direnv reload`.',
423
+ );
424
+ console.error('---');
425
+ }
426
+
427
+ function describeFailure(level: 'ERROR' | 'WARNING', error: unknown): void {
377
428
  if (error instanceof CapturedCommandError) {
378
- console.error(`--- ERROR: setup-environment.ts failed while running: ${error.command}`);
429
+ console.error(`--- ${level}: setup-environment.ts failed while running: ${error.command}`);
379
430
  console.error(`exit code: ${error.exitCode}`);
380
431
  } else {
381
- console.error(`--- ERROR: setup-environment.ts failed: ${error}`);
432
+ console.error(`--- ${level}: setup-environment.ts failed: ${error}`);
382
433
  }
383
434
  replayCapturedOutput(error);
384
435
  console.error('\n---');
385
- process.exit(1);
386
436
  }
387
437
 
388
438
  function replayCapturedOutput(error: unknown): void {
389
439
  if (!(error instanceof CapturedCommandError)) {
390
440
  return;
391
441
  }
392
- if (error.stdout.length > 0) {
393
- process.stdout.write(error.stdout);
442
+ // The install ran with every resolved secret in its environment, so its
443
+ // output is redacted before replay. Masking is same-length, which keeps the
444
+ // failure this replay exists to show intact.
445
+ const stdout = maskSecretValues(error.stdout, resolvedSecretValues);
446
+ const stderr = maskSecretValues(error.stderr, resolvedSecretValues);
447
+ if (stdout.length > 0) {
448
+ process.stdout.write(stdout);
394
449
  }
395
- if (error.stderr.length > 0) {
396
- process.stderr.write(error.stderr);
450
+ if (stderr.length > 0) {
451
+ process.stderr.write(stderr);
397
452
  }
398
453
  }
@@ -109,6 +109,11 @@ runs:
109
109
  # stable path (a symlink would not do: Nix canonicalises) and devenv is
110
110
  # evaluated from there; every later step still runs in the checkout, which
111
111
  # is the same tree. DEVENV_WORKDIR names the path the devenv steps use.
112
+ # The mount target is the instance's own /work, not the shared bind: only
113
+ # the path's spelling must be stable, and a bind onto the idmapped cache
114
+ # dataset failed inside the job (move_mount: ENOENT) where a container-fs
115
+ # target does not. If the mount is refused the job goes on from the
116
+ # checkout and pays the evaluation: this is an optimisation, never a gate.
112
117
  - name: 📂 Locate devenv state on the shared bind
113
118
  if: steps.runner-kind.outputs.host == 'true'
114
119
  shell: bash
@@ -119,13 +124,19 @@ runs:
119
124
  run: |
120
125
  lane=$(printf '%s' "$DEVENV_STATE_LANE" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')
121
126
  root="/var/cache/ci/devenv/$DEVENV_STATE_REPO/${lane:-default}/$DEVENV_STATE_JOB"
122
- mkdir -p "$root/.devenv" "$root/.direnv" "$root/workspace"
123
- sudo mount --bind "$GITHUB_WORKSPACE" "$root/workspace"
127
+ mkdir -p "$root/.devenv" "$root/.direnv"
124
128
  rm -rf tooling/direnv/.devenv tooling/direnv/.direnv
125
129
  ln -s "$root/.devenv" tooling/direnv/.devenv
126
130
  ln -s "$root/.direnv" tooling/direnv/.direnv
127
- echo "DEVENV_WORKDIR=$root/workspace" >> "$GITHUB_ENV"
128
- echo "devenv state: $root (workspace bind-mounted at $root/workspace)"
131
+ stable="/work/$DEVENV_STATE_REPO/${lane:-default}/$DEVENV_STATE_JOB"
132
+ if mkdir -p "$stable" && sudo mount --bind "$GITHUB_WORKSPACE" "$stable"; then
133
+ echo "DEVENV_WORKDIR=$stable" >> "$GITHUB_ENV"
134
+ echo "devenv state: $root (workspace bind-mounted at $stable)"
135
+ else
136
+ echo "DEVENV_WORKDIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
137
+ echo "::warning::stable devenv path unavailable (bind mount refused); evaluating from the checkout"
138
+ echo "devenv state: $root (workspace at $GITHUB_WORKSPACE)"
139
+ fi
129
140
 
130
141
  - name: 📂 Devenv runs in the checkout
131
142
  if: steps.runner-kind.outputs.host != 'true'
@@ -189,8 +200,8 @@ runs:
189
200
  /nix
190
201
  ~/.cache/nix
191
202
  # prettier-ignore
192
- primary-key: ${{ runner.os }}-${{ runner.arch }}-nix-v5-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix', 'tooling/direnv/devenv.lock') }}
193
- restore-prefixes-first-match: ${{ runner.os }}-${{ runner.arch }}-nix-v5-
203
+ primary-key: ${{ runner.os }}-${{ runner.arch }}-nix-v6-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix', 'tooling/direnv/devenv.lock') }}
204
+ restore-prefixes-first-match: ${{ runner.os }}-${{ runner.arch }}-nix-v6-
194
205
  gc-max-store-size: 1G
195
206
 
196
207
  - name: ⚡ Enable Cachix
@@ -225,4 +236,6 @@ runs:
225
236
  - name: 🐚 Build devenv shell
226
237
  shell: bash
227
238
  working-directory: ${{ env.DEVENV_WORKDIR }}/tooling/direnv
239
+ env:
240
+ SMOO_HOST_RUNNER: ${{ steps.runner-kind.outputs.host }}
228
241
  run: ./github-actions-bootstrap.sh build-shell
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.11.13",
3
+ "version": "0.11.15",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -64,7 +64,7 @@
64
64
  ],
65
65
  "dependencies": {
66
66
  "@arethetypeswrong/core": "^0.18.2",
67
- "@smoothbricks/nx-plugin": "0.4.7",
67
+ "@smoothbricks/nx-plugin": "0.4.9",
68
68
  "@smoothbricks/validation": "0.1.8",
69
69
  "commander": "^14.0.3",
70
70
  "make-synchronized": "^0.8.0",
package/src/cli.ts CHANGED
@@ -89,15 +89,24 @@ function buildProgram(): Command {
89
89
  .option('--fail-fast', 'stop after the first failing validation pack')
90
90
  .option('--only-if-new-workspace-package', 'skip validation unless a new workspace package manifest is staged')
91
91
  .option('--verbose', 'print validation progress and successful checks')
92
+ .option(
93
+ '--projects <names>',
94
+ 'comma-separated Nx project names to build and pack-validate (a release selection); default: every project',
95
+ )
92
96
  .action(
93
97
  async (options: {
94
98
  fix?: boolean;
95
99
  failFast?: boolean;
96
100
  onlyIfNewWorkspacePackage?: boolean;
97
101
  verbose?: boolean;
102
+ projects?: string;
98
103
  }) => {
99
104
  const { validateMonorepo } = await import('./monorepo/index.js');
100
- await validateMonorepo(await findRepoRoot(), options);
105
+ const projects = options.projects
106
+ ?.split(',')
107
+ .map((name) => name.trim())
108
+ .filter((name) => name.length > 0);
109
+ await validateMonorepo(await findRepoRoot(), { ...options, projects });
101
110
  },
102
111
  );
103
112
  monorepo.command('update').action(async () => {
@@ -38,6 +38,9 @@ const codebaseWorkflowOptions: PublishWorkflowDefinitionOptions = {
38
38
  describe('publish workflow definition', () => {
39
39
  it('renders the checked-in local publish workflow copy', async () => {
40
40
  const rendered = renderPublishWorkflowYaml(codebaseWorkflowOptions);
41
+ // validate builds and pack-checks the release's candidates, not every project:
42
+ // unscoped it compiled binaries the release did not contain.
43
+ expect(rendered).toContain('smoo monorepo validate --projects "${{ steps.version.outputs.projects }}"');
41
44
  const packageRoot = join(import.meta.dir, '..', '..', '..');
42
45
  await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/publish.yml'), 'utf8')).resolves.toBe(
43
46
  rendered,
@@ -47,6 +47,8 @@ export interface ValidateOptions {
47
47
  onlyIfNewWorkspacePackage?: boolean;
48
48
  fix?: boolean;
49
49
  verbose?: boolean;
50
+ /** Nx project names to build and pack-validate; see MonorepoContext.projects. */
51
+ projects?: readonly string[];
50
52
  }
51
53
 
52
54
  export interface ValidateCommitMessageOptions {
@@ -85,7 +87,10 @@ export async function validateMonorepo(root: string, options: ValidateOptions =
85
87
  if (options.onlyIfNewWorkspacePackage && !(await hasNewWorkspacePackage(root))) {
86
88
  return;
87
89
  }
88
- const result = await runValidatePacks({ root, syncRuntime: false, verbose: options.verbose === true }, options);
90
+ const result = await runValidatePacks(
91
+ { root, syncRuntime: false, verbose: options.verbose === true, projects: options.projects },
92
+ options,
93
+ );
89
94
  if (result.failures > 0) {
90
95
  const checkNoun = result.failedChecks === 1 ? 'check' : 'checks';
91
96
  const problemNoun = result.failures === 1 ? 'problem' : 'problems';
@@ -21,25 +21,40 @@ export async function validatePackedPublishablePackages(root: string): Promise<n
21
21
  );
22
22
  }
23
23
 
24
- export async function validatePackedPublishablePackagePublint(root: string): Promise<number> {
24
+ /** The publishable packages, narrowed to `projects` (Nx names) when given. */
25
+ function selectedPublishablePackages(root: string, projects?: readonly string[]): PackageInfo[] {
26
+ const all = listPublishablePackages(root);
27
+ return projects?.length ? all.filter((pkg) => projects.includes(pkg.projectName)) : all;
28
+ }
29
+
30
+ export async function validatePackedPublishablePackagePublint(
31
+ root: string,
32
+ projects?: readonly string[],
33
+ ): Promise<number> {
25
34
  let failures = 0;
26
- for (const pkg of listPublishablePackages(root)) {
35
+ for (const pkg of selectedPublishablePackages(root, projects)) {
27
36
  failures += await validatePackedPublishablePackageTool(root, pkg, validatePublint);
28
37
  }
29
38
  return failures;
30
39
  }
31
40
 
32
- export async function validatePackedPublishablePackageManifest(root: string): Promise<number> {
41
+ export async function validatePackedPublishablePackageManifest(
42
+ root: string,
43
+ projects?: readonly string[],
44
+ ): Promise<number> {
33
45
  let failures = 0;
34
- for (const pkg of listPublishablePackages(root)) {
46
+ for (const pkg of selectedPublishablePackages(root, projects)) {
35
47
  failures += await validatePackedPublishablePackageTool(root, pkg, validatePackedManifest);
36
48
  }
37
49
  return failures;
38
50
  }
39
51
 
40
- export async function validatePackedPublishablePackageTypes(root: string): Promise<number> {
52
+ export async function validatePackedPublishablePackageTypes(
53
+ root: string,
54
+ projects?: readonly string[],
55
+ ): Promise<number> {
41
56
  let failures = 0;
42
- for (const pkg of listPublishablePackages(root)) {
57
+ for (const pkg of selectedPublishablePackages(root, projects)) {
43
58
  failures += await validatePackedPublishablePackageTool(root, pkg, validateAttw);
44
59
  }
45
60
  return failures;
@@ -36,6 +36,14 @@ export interface MonorepoContext {
36
36
  root: string;
37
37
  syncRuntime: boolean;
38
38
  verbose?: boolean;
39
+ /**
40
+ * Nx project names the validation is about, when a caller knows: the
41
+ * release's candidates. The build phase and the packed-package packs scope
42
+ * to them; manifest-level packs still see the whole repository, because a
43
+ * package's hygiene does not depend on which packages ship today. Absent,
44
+ * everything is validated and everything is built.
45
+ */
46
+ projects?: readonly string[];
39
47
  }
40
48
 
41
49
  export interface ValidatePackOptions {
@@ -174,19 +182,19 @@ const packs: MonorepoPack[] = [
174
182
  {
175
183
  name: 'packed-package-publint',
176
184
  validatePostBuild(ctx) {
177
- return validatePackedPublishablePackagePublint(ctx.root);
185
+ return validatePackedPublishablePackagePublint(ctx.root, ctx.projects);
178
186
  },
179
187
  },
180
188
  {
181
189
  name: 'packed-package-manifest',
182
190
  validatePostBuild(ctx) {
183
- return validatePackedPublishablePackageManifest(ctx.root);
191
+ return validatePackedPublishablePackageManifest(ctx.root, ctx.projects);
184
192
  },
185
193
  },
186
194
  {
187
195
  name: 'packed-package-types',
188
196
  validatePostBuild(ctx) {
189
- return validatePackedPublishablePackageTypes(ctx.root);
197
+ return validatePackedPublishablePackageTypes(ctx.root, ctx.projects);
190
198
  },
191
199
  },
192
200
  {
@@ -369,9 +377,10 @@ async function runBuild(ctx: MonorepoContext, options: ValidatePackOptions = {})
369
377
  if (options.verbose) {
370
378
  printCheckHeading('build', true);
371
379
  }
380
+ const args = ['run-many', '-t', 'build', ...(ctx.projects?.length ? ['-p', ctx.projects.join(',')] : [])];
372
381
  const result = options.verbose
373
- ? { exitCode: await runStatus('nx', ['run-many', '-t', 'build'], ctx.root, false), stdout: '', stderr: '' }
374
- : await runResult('nx', ['run-many', '-t', 'build'], ctx.root);
382
+ ? { exitCode: await runStatus('nx', args, ctx.root, false), stdout: '', stderr: '' }
383
+ : await runResult('nx', args, ctx.root);
375
384
  const status = result.exitCode;
376
385
  if (status !== 0) {
377
386
  if (!options.verbose) {
@@ -614,7 +614,14 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
614
614
  'failure()',
615
615
  );
616
616
  case PublishWorkflowStepKind.ValidateMonorepoConfig:
617
- return conditionalRunStep(step, 'smoo monorepo validate');
617
+ // Scoped to the release's candidates: the build phase and the packed-package
618
+ // checks cover what ships. Unscoped, validate compiled every project - on a
619
+ // release touching no Rust that was 3m30 of the cowshed CLI on the critical
620
+ // path, for a binary the release did not contain.
621
+ return conditionalRunStep(
622
+ step,
623
+ `smoo monorepo validate --projects "${githubExpression('steps.version.outputs.projects')}"`,
624
+ );
618
625
  case PublishWorkflowStepKind.TagRelease:
619
626
  return tagReleaseStepLines(step.name);
620
627
  case PublishWorkflowStepKind.PublishRelease: