@smoothbricks/cli 0.11.12 → 0.11.13

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,12 +1,14 @@
1
1
  /**
2
- * Provider-neutral local secret resolution for smoo-managed repositories.
2
+ * Provider-neutral local secret resolution for smoo-managed repositories, and
3
+ * the developer-shell half of the declared Nx remote cache.
3
4
  *
4
- * Reads the root package.json `smoo.secrets` map the bootstrap twin of
5
- * `PackageSecretCommand` and `PackageSmooConfig.secrets` in
6
- * packages/cli/src/lib/json.ts. This file is a managed raw script: it runs
7
- * from `tooling/direnv/setup-environment.ts` BEFORE `bun install`, when no
8
- * workspace package and no Typia transform exist yet, so the map is
9
- * hand-validated here against the exact shape json.ts declares. Keep the two
5
+ * Reads the root package.json `smoo.secrets` map and `smoo.remoteCache` block
6
+ * — the bootstrap twins of `PackageSecretCommand`, `PackageSmooConfig.secrets`
7
+ * and `PackageRemoteCacheConfig` in packages/cli/src/lib/json.ts. This file is
8
+ * 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
10
12
  * aligned; smoo's Typia validation fails the manifest at generation time for
11
13
  * anything this file would reject at runtime.
12
14
  *
@@ -26,6 +28,23 @@
26
28
  * Failures aggregate so a single direnv reload surfaces every problem.
27
29
  * Error text names variables and exit codes only: secret values, provider
28
30
  * stdout/stderr, and command arguments are never echoed.
31
+ *
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.
29
48
  */
30
49
  import { existsSync, readFileSync } from 'node:fs';
31
50
  import { join } from 'node:path';
@@ -69,7 +88,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
69
88
  return typeof value === 'object' && value !== null && !Array.isArray(value);
70
89
  }
71
90
 
72
- function isNonemptyEnvValue(value: string | undefined): boolean {
91
+ function isNonemptyEnvValue(value: string | undefined): value is string {
73
92
  return value !== undefined && value.length > 0;
74
93
  }
75
94
 
@@ -80,9 +99,9 @@ function isNonemptyEnvValue(value: string | undefined): boolean {
80
99
  */
81
100
  export function parseSmooSecrets(packageJson: unknown): Readonly<Record<string, SecretSpec>> {
82
101
  if (!isRecord(packageJson)) return {};
83
- const smoo = packageJson['smoo'];
102
+ const smoo = packageJson.smoo;
84
103
  if (!isRecord(smoo)) return {};
85
- const secrets = smoo['secrets'];
104
+ const secrets = smoo.secrets;
86
105
  if (secrets === undefined) return {};
87
106
  if (!isRecord(secrets)) {
88
107
  throw new Error('smoo.secrets must map environment variable names to { command: [string, ...] }');
@@ -103,7 +122,7 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
103
122
  if (!isRecord(spec)) {
104
123
  throw new Error(`smoo.secrets.${name}: each entry must be an object with a command array`);
105
124
  }
106
- const raw = spec['command'];
125
+ const raw = spec.command;
107
126
  if (!Array.isArray(raw) || raw.length === 0) {
108
127
  throw new Error(`smoo.secrets.${name}.command: must be an array of at least one string`);
109
128
  }
@@ -116,6 +135,53 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
116
135
  return { command: [first, ...rest] };
117
136
  }
118
137
 
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
+ /**
143
+ * The declared remote cache as a local shell needs it. `internalServer` is
144
+ * deliberately absent: it is the address a managed runner reaches inside its
145
+ * own network, and a shell that loads this file is by definition outside it,
146
+ * so a developer machine always takes the public `server`.
147
+ */
148
+ export interface RemoteCacheSpec {
149
+ readonly server: string;
150
+ /** Variable holding the cache token: an ambient value, or a `smoo.secrets` entry resolved here. */
151
+ readonly tokenSecret: string;
152
+ }
153
+
154
+ /**
155
+ * Validates the `smoo.remoteCache` block out of an already-parsed
156
+ * package.json. Absent `smoo` or `smoo.remoteCache` is no cache; a malformed
157
+ * declaration throws naming the field.
158
+ */
159
+ export function parseSmooRemoteCache(packageJson: unknown): RemoteCacheSpec | null {
160
+ if (!isRecord(packageJson)) return null;
161
+ const smoo = packageJson.smoo;
162
+ if (!isRecord(smoo)) return null;
163
+ const remoteCache = smoo.remoteCache;
164
+ if (remoteCache === undefined) return null;
165
+ if (!isRecord(remoteCache)) {
166
+ throw new Error('smoo.remoteCache must be an object with { server, tokenSecret }');
167
+ }
168
+ const server = remoteCache.server;
169
+ const tokenSecret = remoteCache.tokenSecret;
170
+ // A trailing slash is refused rather than trimmed: Nx appends
171
+ // `/v1/cache/<hash>`, so the doubled slash is a route that answers 404
172
+ // forever, and silently repairing the manifest here would leave managed CI
173
+ // — which reads the same field — disagreeing with this shell.
174
+ if (typeof server !== 'string' || server.length === 0 || server.endsWith('/')) {
175
+ throw new Error(
176
+ 'smoo.remoteCache.server must be an origin with no trailing slash, e.g. https://nx-cache.example.net',
177
+ );
178
+ }
179
+ if (typeof tokenSecret !== 'string' || !ENV_NAME.test(tokenSecret)) {
180
+ throw new Error('smoo.remoteCache.tokenSecret must name the environment variable holding the cache token');
181
+ }
182
+ return { server, tokenSecret };
183
+ }
184
+
119
185
  /** Every `${VAR}` referenced from `.npmrc` text; an absent file contributes nothing. */
120
186
  export function registryAuthEnvNames(npmrcText: string | null): ReadonlySet<string> {
121
187
  const names = new Set<string>();
@@ -131,47 +197,64 @@ type SecretOutcome =
131
197
  | { readonly name: string; readonly kind: 'resolved'; readonly value: string }
132
198
  | { readonly name: string; readonly kind: 'failed'; readonly guidance: string };
133
199
 
200
+ /**
201
+ * One variable's routing, the four rules in the header applied in order. It is
202
+ * a value rather than a throw so each caller presents a failure in its own
203
+ * terms: an install refuses, the remote cache turns itself off.
204
+ */
205
+ async function routeSecret(
206
+ name: string,
207
+ spec: SecretSpec,
208
+ context: {
209
+ readonly env: Readonly<Record<string, string | undefined>>;
210
+ readonly registryIntentEnvs: ReadonlySet<string>;
211
+ readonly run: SecretCommandRunner;
212
+ },
213
+ ): Promise<SecretOutcome> {
214
+ const { env } = context;
215
+ if (isNonemptyEnvValue(env[name])) {
216
+ return { name, kind: 'env-wins' };
217
+ }
218
+ if (isNonemptyEnvValue(env.CI)) {
219
+ return {
220
+ name,
221
+ kind: 'failed',
222
+ guidance:
223
+ 'CI does not run secret provider commands — inject this variable into the job environment from the CI secret store',
224
+ };
225
+ }
226
+ if (isNonemptyEnvValue(env.COWSHED_WORKSPACE_TOKEN) && context.registryIntentEnvs.has(name)) {
227
+ return {
228
+ name,
229
+ kind: 'failed',
230
+ guidance:
231
+ 'referenced from .npmrc and absent in this cowshed workspace — enroll registry credentials through the cowshed gateway; gateway-managed workspaces never resolve .npmrc registry variables via local provider commands',
232
+ };
233
+ }
234
+ try {
235
+ const value = (await context.run(spec.command)).replace(/\r?\n$/, '');
236
+ if (value.length === 0) {
237
+ return {
238
+ name,
239
+ kind: 'failed',
240
+ guidance:
241
+ 'provider command produced no output — run it locally to see why (its arguments and output are never logged here)',
242
+ };
243
+ }
244
+ return { name, kind: 'resolved', value };
245
+ } catch (error) {
246
+ return { name, kind: 'failed', guidance: describeCommandFailure(error) };
247
+ }
248
+ }
249
+
134
250
  export async function resolveSecrets(request: SecretResolutionRequest): Promise<Readonly<Record<string, string>>> {
135
- const run = request.runCommand ?? runSecretCommand;
136
- const env = request.env;
137
- const ci = isNonemptyEnvValue(env['CI']);
138
- const cowshed = isNonemptyEnvValue(env['COWSHED_WORKSPACE_TOKEN']);
251
+ const context = {
252
+ env: request.env,
253
+ registryIntentEnvs: request.registryIntentEnvs,
254
+ run: request.runCommand ?? runSecretCommand,
255
+ };
139
256
  const outcomes = await Promise.all(
140
- Object.entries(request.secrets).map(async ([name, spec]): Promise<SecretOutcome> => {
141
- if (isNonemptyEnvValue(env[name])) {
142
- return { name, kind: 'env-wins' };
143
- }
144
- if (ci) {
145
- return {
146
- name,
147
- kind: 'failed',
148
- guidance:
149
- 'CI does not run secret provider commands — inject this variable into the job environment from the CI secret store',
150
- };
151
- }
152
- if (cowshed && request.registryIntentEnvs.has(name)) {
153
- return {
154
- name,
155
- kind: 'failed',
156
- guidance:
157
- 'referenced from .npmrc and absent in this cowshed workspace — enroll registry credentials through the cowshed gateway; gateway-managed workspaces never resolve .npmrc registry variables via local provider commands',
158
- };
159
- }
160
- try {
161
- const value = (await run(spec.command)).replace(/\r?\n$/, '');
162
- if (value.length === 0) {
163
- return {
164
- name,
165
- kind: 'failed',
166
- guidance:
167
- 'provider command produced no output — run it locally to see why (its arguments and output are never logged here)',
168
- };
169
- }
170
- return { name, kind: 'resolved', value };
171
- } catch (error) {
172
- return { name, kind: 'failed', guidance: describeCommandFailure(error) };
173
- }
174
- }),
257
+ Object.entries(request.secrets).map(async ([name, spec]) => routeSecret(name, spec, context)),
175
258
  );
176
259
  const failures = outcomes.filter(
177
260
  (outcome): outcome is Extract<SecretOutcome, { kind: 'failed' }> => outcome.kind === 'failed',
@@ -242,17 +325,113 @@ const runSecretCommand: SecretCommandRunner = async (argv) => {
242
325
  return stdout;
243
326
  };
244
327
 
328
+ /**
329
+ * The declared secrets an install needs, resolved before it runs. The cache
330
+ * token is deliberately not among them: a remote cache is an optimization, so
331
+ * its credential is resolved by the shell's cache export — where an
332
+ * unreachable secret provider costs a stderr line — while every secret an
333
+ * install actually depends on still refuses loudly here. Which variable that
334
+ * is comes from `smoo.remoteCache.tokenSecret` rather than a second flag, so
335
+ * the two declarations cannot disagree.
336
+ */
245
337
  export async function resolveSecretEnvironment(
246
338
  options: SecretResolutionOptions,
247
339
  ): Promise<Readonly<Record<string, string>>> {
340
+ const packageJson = readPackageJson(options.root);
341
+ const cacheToken = parseSmooRemoteCache(packageJson)?.tokenSecret;
342
+ const required: Record<string, SecretSpec> = {};
343
+ for (const [name, spec] of Object.entries(parseSmooSecrets(packageJson))) {
344
+ if (name !== cacheToken) {
345
+ required[name] = spec;
346
+ }
347
+ }
248
348
  return resolveSecrets({
249
- secrets: parseSmooSecrets(readPackageJson(options.root)),
349
+ secrets: required,
250
350
  registryIntentEnvs: registryAuthEnvNames(readNpmrcText(options.root)),
251
351
  env: options.env ?? process.env,
252
352
  runCommand: options.runCommand,
253
353
  });
254
354
  }
255
355
 
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
+
256
435
  function readPackageJson(root: string): unknown {
257
436
  const packageJsonPath = join(root, 'package.json');
258
437
  let text: string;
@@ -279,3 +458,21 @@ function readNpmrcText(root: string): string | null {
279
458
  if (!existsSync(npmrcPath)) return null;
280
459
  return readFileSync(npmrcPath, 'utf8');
281
460
  }
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
+ }
@@ -1,7 +1,7 @@
1
1
  name: Save Nix/devenv caches
2
2
  description: >
3
- Save the devenv/direnv eval-cache segment after the job, on every runner where setup missed and build-shell produced
4
- nix-eval-cache.db. The Nix store cache saves itself in setup-devenv's post phase (ephemeral runners only).
3
+ Save the devenv/direnv eval-cache segment after the job, on every ephemeral runner where setup missed and build-shell
4
+ produced nix-eval-cache.db. The Nix store cache saves itself in setup-devenv's post phase (ephemeral runners only).
5
5
 
6
6
  inputs:
7
7
  devenv-cache-hit:
@@ -8,7 +8,7 @@ description: >
8
8
  outputs:
9
9
  devenv-cache-hit:
10
10
  description: Whether the devenv/direnv cache restored from the exact primary key.
11
- value: ${{ steps.devenv-cache.outputs.cache-hit || 'false' }}
11
+ value: ${{ steps.runner-kind.outputs.host == 'true' || steps.devenv-cache.outputs.cache-hit || 'false' }}
12
12
  host-runner:
13
13
  description: Whether this job is on a host-nix runner with shared store/caches.
14
14
  value: ${{ steps.runner-kind.outputs.host }}
@@ -95,14 +95,52 @@ runs:
95
95
  } >> "$GITHUB_ENV"
96
96
  echo "nx cache : $NX_CACHE_ROOT"
97
97
 
98
+ # Host runners keep devenv's state on the shared bind, next to the Nx cache:
99
+ # .devenv (eval SQLite, gc roots, profile) and .direnv survive the job, so
100
+ # the next job on this host evaluates nothing and downloads nothing. The
101
+ # Actions cache segment below is the ephemeral runners' substitute for this.
102
+ # Scoped per job as well as lane: two jobs of one run are parallel, and one
103
+ # eval-cache.db has one writer.
104
+ #
105
+ # The eval cache keys on canonical absolute paths, and act's host executor
106
+ # checks out under <cache>/<random 8 chars>/hostexecutor - a new path every
107
+ # job by design (act/runner/run_context.go: MustRandName), so a restored
108
+ # cache could never match. The workspace is therefore bind-mounted at a
109
+ # stable path (a symlink would not do: Nix canonicalises) and devenv is
110
+ # evaluated from there; every later step still runs in the checkout, which
111
+ # is the same tree. DEVENV_WORKDIR names the path the devenv steps use.
112
+ - name: 📂 Locate devenv state on the shared bind
113
+ if: steps.runner-kind.outputs.host == 'true'
114
+ shell: bash
115
+ env:
116
+ DEVENV_STATE_REPO: ${{ github.repository }}
117
+ DEVENV_STATE_LANE: ${{ github.workflow }}
118
+ DEVENV_STATE_JOB: ${{ github.job }}
119
+ run: |
120
+ lane=$(printf '%s' "$DEVENV_STATE_LANE" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')
121
+ 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"
124
+ rm -rf tooling/direnv/.devenv tooling/direnv/.direnv
125
+ ln -s "$root/.devenv" tooling/direnv/.devenv
126
+ 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)"
129
+
130
+ - name: 📂 Devenv runs in the checkout
131
+ if: steps.runner-kind.outputs.host != 'true'
132
+ shell: bash
133
+ run: echo "DEVENV_WORKDIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
134
+
98
135
  # Devenv segment (own key): tooling/direnv/.devenv + .direnv — includes
99
- # nix-eval-cache.db. Independent of the store cache; restore on host and
100
- # ephemeral whether or not the store cache hit. Missing store paths are fine:
136
+ # nix-eval-cache.db. Independent of the store cache; restored on ephemeral
137
+ # runners whether or not the store cache hit. Missing store paths are fine:
101
138
  # devenv verifies shell drv/out exist on eval-cache hit and invalidates +
102
139
  # re-evals / realizes when GC removed them. Packages still come from host
103
140
  # store, the store cache, or Cachix — this blob only skips evaluation.
104
141
  - name: 🧊 Cache devenv and direnv
105
142
  id: devenv-cache
143
+ if: steps.runner-kind.outputs.host != 'true'
106
144
  uses: ./.github/actions/cache-nix-devenv
107
145
 
108
146
  # Single-user Nix unpacked into /nix in seconds: no installer volume, no
@@ -169,7 +207,7 @@ runs:
169
207
  # until the check existed.
170
208
  - name: 🧰 Install devenv
171
209
  shell: bash
172
- working-directory: tooling/direnv
210
+ working-directory: ${{ env.DEVENV_WORKDIR }}/tooling/direnv
173
211
  env:
174
212
  SMOO_HOST_RUNNER: ${{ steps.runner-kind.outputs.host }}
175
213
  run: ./github-actions-bootstrap.sh install-devenv
@@ -186,5 +224,5 @@ runs:
186
224
 
187
225
  - name: 🐚 Build devenv shell
188
226
  shell: bash
189
- working-directory: tooling/direnv
227
+ working-directory: ${{ env.DEVENV_WORKDIR }}/tooling/direnv
190
228
  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.12",
3
+ "version": "0.11.13",
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.6",
67
+ "@smoothbricks/nx-plugin": "0.4.7",
68
68
  "@smoothbricks/validation": "0.1.8",
69
69
  "commander": "^14.0.3",
70
70
  "make-synchronized": "^0.8.0",
package/src/lib/json.ts CHANGED
@@ -134,6 +134,19 @@ export interface PackageCargoGitOrigin {
134
134
  * origin is fetched as declared.
135
135
  */
136
136
  internalMirror?: string;
137
+ /**
138
+ * SSH spellings of the same forge that managed CI rewrites onto
139
+ * `internalMirror` as well, e.g. `ssh://forgejo@forge.example.net:2223/`
140
+ * as Cargo and uv pin a git dependency. A runner holds the read token the
141
+ * mirror accepts and no SSH key at all, so every spelling a lockfile can
142
+ * carry needs its own rewrite: git matches `insteadOf` values as literal
143
+ * URL prefixes and infers no spelling from another — not the SSH host from
144
+ * the HTTPS one, and not the userless form from the one carrying a user.
145
+ * Each entry is a credential-free `ssh://` origin without a path; the SSH
146
+ * user is part of the spelling and stays in it. Requires
147
+ * `internalMirror`, since the entry is a rewrite source and nothing else.
148
+ */
149
+ sshOrigins?: string[];
137
150
  }
138
151
 
139
152
  /**
@@ -153,6 +166,43 @@ export interface PackagePrivateNpmConfig {
153
166
  publishTokenEnv?: string;
154
167
  }
155
168
 
169
+ /**
170
+ * Declared self-hosted Nx remote cache (`smoo.remoteCache`). Nx enables its
171
+ * HTTP cache on a nonempty NX_SELF_HOSTED_REMOTE_CACHE_SERVER and reads the
172
+ * credential from NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN; this declaration
173
+ * is what puts that pair in a managed CI job's environment and in a developer
174
+ * shell (tooling/direnv/secret-references.ts). Absent means every workspace
175
+ * keeps its own local cache and nothing is shared.
176
+ *
177
+ * The pair is emitted whole or not at all, because Nx accepts only 200 or 404
178
+ * from a cache server: a server without a working credential fails every task
179
+ * with 401 rather than missing quietly.
180
+ */
181
+ export interface PackageRemoteCacheConfig {
182
+ /**
183
+ * Origin every runner and developer machine can reach, e.g.
184
+ * `https://nx-cache.example.net`. Credential-free, no path, and no trailing
185
+ * slash — Nx appends `/v1/cache/<hash>`, so a trailing slash requests
186
+ * `//v1/cache/<hash>`, which is a different route and answers 404 forever.
187
+ */
188
+ server: string;
189
+ /**
190
+ * Origin an internal runner reaches instead of `server`, e.g.
191
+ * `http://10.89.0.1:8765` across a container bridge. Same declaration as a
192
+ * git origin's `internalMirror`: declaring it says this repository's managed
193
+ * runners are inside that network, so managed CI uses it and shells outside
194
+ * keep `server`. Omitted means CI uses `server` too.
195
+ */
196
+ internalServer?: string;
197
+ /**
198
+ * Repository secret holding the cache token, and the same variable name a
199
+ * developer shell resolves locally (an ambient value or a `smoo.secrets`
200
+ * entry). A read-only token here keeps an untrusted context reading the
201
+ * cache without being able to publish into it.
202
+ */
203
+ tokenSecret: RepositorySecretName;
204
+ }
205
+
156
206
  /** Local bootstrap fallback; existing environment values always take precedence. */
157
207
  export interface PackageSecretCommand {
158
208
  /** Executed directly, without a shell; stdout supplies the secret value. */
@@ -162,6 +212,8 @@ export interface PackageSecretCommand {
162
212
  export interface PackageSmooConfig {
163
213
  github?: PackageSmooGithub;
164
214
  privateNpm?: PackagePrivateNpmConfig;
215
+ /** Declared self-hosted Nx remote cache; absent means local caching only. */
216
+ remoteCache?: PackageRemoteCacheConfig;
165
217
  /** Provider-neutral local secret commands. CI supplies these variables externally. */
166
218
  secrets?: Record<string, PackageSecretCommand>;
167
219
  }