@smoothbricks/cli 0.11.19 → 0.11.21
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.
- package/README.md +68 -5
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +28 -4
- package/dist/lib/json.d.ts +22 -1
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +15 -3
- package/dist/monorepo/cargo-policy.d.ts +17 -0
- package/dist/monorepo/cargo-policy.d.ts.map +1 -1
- package/dist/monorepo/cargo-policy.js +73 -1
- package/dist/monorepo/index.d.ts.map +1 -1
- package/dist/monorepo/index.js +4 -2
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +6 -2
- package/dist/nx/index.d.ts +6 -1
- package/dist/nx/index.d.ts.map +1 -1
- package/dist/nx/index.js +14 -1
- package/dist/secrets/commands.d.ts +9 -5
- package/dist/secrets/commands.d.ts.map +1 -1
- package/dist/secrets/commands.js +122 -49
- package/dist/secrets/index.d.ts +42 -56
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +52 -79
- package/dist/secrets/resolver.d.ts +59 -0
- package/dist/secrets/resolver.d.ts.map +1 -0
- package/dist/secrets/resolver.js +100 -0
- package/dist/secrets/run.d.ts +23 -0
- package/dist/secrets/run.d.ts.map +1 -0
- package/dist/secrets/run.js +130 -0
- package/dist/secrets/status.d.ts +177 -0
- package/dist/secrets/status.d.ts.map +1 -0
- package/dist/secrets/status.js +724 -0
- package/dist/wrangler/deploy-stage.d.ts +1 -1
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +22 -20
- package/dist/wrangler/deployed-version.d.ts.map +1 -1
- package/dist/wrangler/deployed-version.js +7 -12
- package/dist/wrangler/flat-config.d.ts +1 -4
- package/dist/wrangler/flat-config.d.ts.map +1 -1
- package/dist/wrangler/flat-config.js +98 -44
- package/dist/wrangler/prepare-env.d.ts +4 -3
- package/dist/wrangler/prepare-env.d.ts.map +1 -1
- package/dist/wrangler/prepare-env.js +7 -5
- package/dist/wrangler/source-config.d.ts +24 -0
- package/dist/wrangler/source-config.d.ts.map +1 -0
- package/dist/wrangler/source-config.js +110 -0
- package/dist/wrangler/stage.d.ts +55 -23
- package/dist/wrangler/stage.d.ts.map +1 -1
- package/dist/wrangler/stage.js +81 -162
- package/managed/raw/tooling/direnv/devenv.smoo.nix +19 -3
- package/managed/raw/tooling/direnv/secret-references.ts +280 -76
- package/managed/raw/tooling/direnv/setup-environment.ts +58 -2
- package/managed/raw/tsconfig.lib.json +28 -0
- package/package.json +7 -2
- package/src/cli.ts +34 -5
- package/src/lib/json.ts +23 -1
- package/src/monorepo/cargo-policy.test.ts +91 -1
- package/src/monorepo/cargo-policy.ts +87 -1
- package/src/monorepo/index.ts +8 -2
- package/src/monorepo/package-policy.test.ts +50 -19
- package/src/monorepo/packs/index.ts +10 -2
- package/src/monorepo/secret-references.test.ts +412 -6
- package/src/monorepo/setup-environment.test.ts +181 -0
- package/src/nx/index.test.ts +8 -2
- package/src/nx/index.ts +20 -2
- package/src/secrets/commands.test.ts +186 -4
- package/src/secrets/commands.ts +142 -51
- package/src/secrets/index.test.ts +4 -10
- package/src/secrets/index.ts +54 -115
- package/src/secrets/resolver.ts +130 -0
- package/src/secrets/run.test.ts +359 -0
- package/src/secrets/run.ts +148 -0
- package/src/secrets/status.test.ts +164 -0
- package/src/secrets/status.ts +297 -0
- package/src/wrangler/deploy-stage.test.ts +95 -2
- package/src/wrangler/deploy-stage.ts +26 -23
- package/src/wrangler/deployed-version.ts +7 -11
- package/src/wrangler/flat-config.test.ts +9 -4
- package/src/wrangler/flat-config.ts +1 -20
- package/src/wrangler/format-parity.test.ts +433 -0
- package/src/wrangler/prepare-env.ts +7 -5
- package/src/wrangler/source-config.test.ts +102 -0
- package/src/wrangler/source-config.ts +110 -0
- package/src/wrangler/stage.test.ts +61 -32
- package/src/wrangler/stage.ts +124 -173
|
@@ -10,49 +10,142 @@
|
|
|
10
10
|
* declares. Keep them aligned; smoo's Typia validation fails the manifest at
|
|
11
11
|
* generation time for anything this file would reject at runtime.
|
|
12
12
|
*
|
|
13
|
+
* THE RULE: shell entry resolves the `shell` group and nothing else. Every
|
|
14
|
+
* other group belongs to the command that needs it:
|
|
15
|
+
* `smoo secrets run <group> <command...>`.
|
|
16
|
+
*
|
|
17
|
+
* Shell entry happens on every direnv reload and every `devenv shell --
|
|
18
|
+
* <command>`, and a provider authorises per requesting process lineage, which
|
|
19
|
+
* is new each time — so a provider command placed there is a credential
|
|
20
|
+
* prompt on every one of them: dozens an hour for a developer, one more for
|
|
21
|
+
* every scripted subprocess. A credential whose only consumer is a deliberate
|
|
22
|
+
* command therefore belongs to that command.
|
|
23
|
+
*
|
|
24
|
+
* A group is NOT a fourth thing to declare. It is derived from the
|
|
25
|
+
* declarations a repository already carries, and an explicit `group` on the
|
|
26
|
+
* entry is only for what those cannot say. Derivation, per variable:
|
|
27
|
+
*
|
|
28
|
+
* - `nx-cache` — the variable `smoo.remoteCache.tokenSecret` names. A remote
|
|
29
|
+
* cache is an optimization: Nx reads NX_SELF_HOSTED_REMOTE_CACHE_SERVER
|
|
30
|
+
* and _ACCESS_TOKEN from the environment it runs in — CI injects them, a
|
|
31
|
+
* developer exports the token once in the terminal that wants the cache —
|
|
32
|
+
* and every nested shell inherits them. Absent, Nx keeps to its local
|
|
33
|
+
* cache.
|
|
34
|
+
* - `registry` — a variable `.npmrc` interpolates as `${VAR}`. An installed
|
|
35
|
+
* checkout contacts no registry at all (measured: `bun install --dry-run`
|
|
36
|
+
* succeeds in 14ms with the token absent), while a request that does
|
|
37
|
+
* contact one answers 401 without it.
|
|
38
|
+
* - `shell` — everything else: what the shell itself is for, resolved at
|
|
39
|
+
* entry because that is when it is needed.
|
|
40
|
+
*
|
|
41
|
+
* An explicit `group` wins over the derivation, and no group is privileged in
|
|
42
|
+
* code: a repository may name one of its own and run it the same way. Only
|
|
43
|
+
* the shape is validated, so a new group needs no new smoo release.
|
|
44
|
+
*
|
|
13
45
|
* Routing per declared variable, in first-match order:
|
|
14
46
|
*
|
|
15
47
|
* 1. An existing nonempty environment value wins; no command runs.
|
|
16
|
-
* 2.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
48
|
+
* 2. For the requested group, and for a group the request depends on (see
|
|
49
|
+
* below), a context that cannot run a provider command at all decides
|
|
50
|
+
* before the group does:
|
|
51
|
+
* a. CI (`CI` set): variables must be injected from the CI secret store;
|
|
52
|
+
* provider commands never run there. Missing variables refuse with
|
|
53
|
+
* injected-secret guidance instead of a confusing auth failure later.
|
|
54
|
+
* b. A `registry` variable inside a cowshed workspace
|
|
55
|
+
* (`COWSHED_WORKSPACE_TOKEN` set) refuses with names-only
|
|
56
|
+
* gateway-enrollment guidance: such a workspace holds no provider
|
|
57
|
+
* session, and the gateway exists to be the thing that does.
|
|
58
|
+
* 3. A variable outside the requested group is deferred — named in the
|
|
59
|
+
* result, not resolved, and not a failure.
|
|
23
60
|
* 4. Otherwise the command argv executes directly, without a shell; stdout
|
|
24
61
|
* with one terminal newline trimmed becomes the value.
|
|
25
62
|
*
|
|
63
|
+
* A request DEPENDS ON a group when the operation being resolved for reads
|
|
64
|
+
* that group's own declaration. Shell entry runs `bun install`, and
|
|
65
|
+
* `bun install` reads `.npmrc` — so `registry` is an input to shell entry
|
|
66
|
+
* that shell entry deliberately does not resolve. That dependency is what
|
|
67
|
+
* keeps rule 2 honest in a context which cannot honour a deferral: CI has no
|
|
68
|
+
* later command to run and a cowshed workspace has no provider session, so
|
|
69
|
+
* both refuse by name rather than promising that `smoo secrets run` would
|
|
70
|
+
* fix it. Nothing shell entry runs reads `smoo.remoteCache`, so an
|
|
71
|
+
* `nx-cache` variable is deferred in every context and blocks nothing.
|
|
72
|
+
*
|
|
73
|
+
* A deferred variable is a value in the result rather than a silent omission,
|
|
74
|
+
* and there is no fallback that resolves it anyway: a fallback would
|
|
75
|
+
* reinstate the prompt this rule removes, at the least predictable moment.
|
|
76
|
+
* The caller names the variable and the command that supplies it when the
|
|
77
|
+
* operation that needed it fails.
|
|
78
|
+
*
|
|
26
79
|
* Failures aggregate so a single direnv reload surfaces every problem.
|
|
27
|
-
* Error text names variables and exit codes only: secret values,
|
|
28
|
-
* stdout/stderr, and command arguments are never echoed.
|
|
80
|
+
* Error text names variables, groups and exit codes only: secret values,
|
|
81
|
+
* provider stdout/stderr, and command arguments are never echoed.
|
|
29
82
|
* A resolved value still reaches the environment of the install its caller
|
|
30
83
|
* runs next, so `maskSecretValues` redacts those values out of any captured
|
|
31
84
|
* child output that caller replays.
|
|
32
85
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
86
|
+
* Zero prompts, as an option and not a recommendation: a declared `command`
|
|
87
|
+
* may point at the OS keychain instead of at the provider. On macOS, populate
|
|
88
|
+
* the item once from the 1Password item (`security add-generic-password -s
|
|
89
|
+
* <name> -a <account> -w`) and declare
|
|
90
|
+
* `["security", "find-generic-password", "-w", "-s", "<name>"]`. macOS
|
|
91
|
+
* authorises a keychain item per calling binary, so the first approval covers
|
|
92
|
+
* every later read by that binary: rule 4 then answers without prompting and
|
|
93
|
+
* the deferral above never has to be paid. 1Password stays the source of
|
|
94
|
+
* truth and the keychain is a session cache of it — but the trade-off is
|
|
95
|
+
* exactly that, a second copy of the credential now lives on disk, outside
|
|
96
|
+
* the provider's rotation and revocation, and nothing here refreshes it.
|
|
41
97
|
*/
|
|
42
98
|
import { existsSync, readFileSync } from 'node:fs';
|
|
43
99
|
import { join } from 'node:path';
|
|
44
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Which operation resolves a declared secret. Open by construction: the three
|
|
103
|
+
* below are the DERIVED defaults, not the permitted values, so a repository
|
|
104
|
+
* that declares `"group": "deploy"` needs no smoo release to run it.
|
|
105
|
+
*/
|
|
106
|
+
export type SecretGroup = string;
|
|
107
|
+
|
|
108
|
+
/** Resolved at shell entry, because that is the operation that needs it. */
|
|
109
|
+
export const SHELL_GROUP: SecretGroup = 'shell';
|
|
110
|
+
|
|
111
|
+
/** Derived for a variable `.npmrc` interpolates as `${VAR}`. */
|
|
112
|
+
export const REGISTRY_GROUP: SecretGroup = 'registry';
|
|
113
|
+
|
|
114
|
+
/** Derived for the variable `smoo.remoteCache.tokenSecret` names. */
|
|
115
|
+
export const NX_CACHE_GROUP: SecretGroup = 'nx-cache';
|
|
116
|
+
|
|
45
117
|
export interface SecretSpec {
|
|
46
118
|
/** Executed directly, without a shell; stdout supplies the secret value. */
|
|
47
119
|
readonly command: readonly [string, ...string[]];
|
|
120
|
+
/**
|
|
121
|
+
* Overrides the derived group. Declared only for what the repository's own
|
|
122
|
+
* declarations cannot say — a credential no `.npmrc` reference and no cache
|
|
123
|
+
* token identifies, or one whose derivation is deliberately overridden.
|
|
124
|
+
*/
|
|
125
|
+
readonly group?: SecretGroup;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A declared secret with its group settled: explicit if stated, derived otherwise. */
|
|
129
|
+
export interface GroupedSecret {
|
|
130
|
+
readonly name: string;
|
|
131
|
+
/** The group that resolves it: `spec.group` when declared, `derivedGroup` otherwise. */
|
|
132
|
+
readonly group: SecretGroup;
|
|
133
|
+
/**
|
|
134
|
+
* What this repository's own declarations imply, kept beside the effective
|
|
135
|
+
* group so an override is a visible fact rather than a silent one. Equal to
|
|
136
|
+
* `group` unless the entry declares a different one.
|
|
137
|
+
*/
|
|
138
|
+
readonly derivedGroup: SecretGroup;
|
|
139
|
+
readonly spec: SecretSpec;
|
|
48
140
|
}
|
|
49
141
|
|
|
50
142
|
export type SecretCommandRunner = (argv: readonly string[]) => Promise<string>;
|
|
51
143
|
|
|
52
144
|
export interface SecretResolutionRequest {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
145
|
+
/** Every declared secret of the repository, each with its group settled. */
|
|
146
|
+
readonly secrets: readonly GroupedSecret[];
|
|
147
|
+
/** The one group to resolve. No default: the wrong one is a credential prompt. */
|
|
148
|
+
readonly group: SecretGroup;
|
|
56
149
|
/** Environment consulted for existing values and mode flags; read, never written. */
|
|
57
150
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
58
151
|
/** Overrides the default direct-argv runner (test seam). */
|
|
@@ -62,21 +155,71 @@ export interface SecretResolutionRequest {
|
|
|
62
155
|
export interface SecretResolutionOptions {
|
|
63
156
|
/** Repository root holding package.json and .npmrc. */
|
|
64
157
|
readonly root: string;
|
|
158
|
+
/** The one group to resolve. No default: the wrong one is a credential prompt. */
|
|
159
|
+
readonly group: SecretGroup;
|
|
65
160
|
/** Environment to consult; defaults to process.env. Read, never written. */
|
|
66
161
|
readonly env?: Record<string, string | undefined>;
|
|
67
162
|
/** Overrides the default direct-argv runner (test seam). */
|
|
68
163
|
readonly runCommand?: SecretCommandRunner;
|
|
69
164
|
}
|
|
70
165
|
|
|
166
|
+
/** A declared secret this request declined to resolve, and how to supply it. */
|
|
167
|
+
export interface DeferredSecret {
|
|
168
|
+
readonly name: string;
|
|
169
|
+
readonly group: SecretGroup;
|
|
170
|
+
readonly guidance: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The resolution as a value: what belongs in a child's environment, and what
|
|
175
|
+
* was deliberately left out of it. Every declared secret appears in exactly
|
|
176
|
+
* one of `values`, `deferred`, or neither — the environment already carried
|
|
177
|
+
* it.
|
|
178
|
+
*/
|
|
179
|
+
export interface SecretResolution {
|
|
180
|
+
readonly values: Readonly<Record<string, string>>;
|
|
181
|
+
readonly deferred: readonly DeferredSecret[];
|
|
182
|
+
}
|
|
183
|
+
|
|
71
184
|
/**
|
|
72
185
|
* The env name pattern every shell accepts, so the same regex classifies
|
|
73
186
|
* declared names and `.npmrc` references without echoing values.
|
|
74
187
|
*/
|
|
75
188
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
76
189
|
|
|
190
|
+
/**
|
|
191
|
+
* A group label's shape, and only its shape. It is one argv word of
|
|
192
|
+
* `smoo secrets run <group> <command...>`, so whitespace would make the
|
|
193
|
+
* command unwritable; every other value is the repository's business.
|
|
194
|
+
*/
|
|
195
|
+
const GROUP_NAME = /^\S+$/;
|
|
196
|
+
|
|
77
197
|
/** The `.npmrc` interpolations Bun substitutes while reading auth and registry config. */
|
|
78
198
|
const NPMRC_ENV_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
79
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The command that resolves one group for exactly one child process. Every
|
|
202
|
+
* deferral names it, so what a developer reads is the command to run next
|
|
203
|
+
* rather than a description of one.
|
|
204
|
+
*/
|
|
205
|
+
const ON_DEMAND_COMMAND = 'smoo secrets run';
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Groups an operation depends on without resolving them, keyed by the group
|
|
209
|
+
* it does resolve. Shell entry runs `bun install`, which reads `.npmrc` —
|
|
210
|
+
* the declaration that derives `registry`. This is a dependency edge, not an
|
|
211
|
+
* exclusion: it makes CI and a cowshed workspace refuse a registry credential
|
|
212
|
+
* they cannot supply later, where a group nothing depends on stays silent.
|
|
213
|
+
*/
|
|
214
|
+
const DEPENDENT_GROUPS: Readonly<Record<string, readonly SecretGroup[]>> = {
|
|
215
|
+
[SHELL_GROUP]: [REGISTRY_GROUP],
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Groups a request for `group` needs supplied but does not resolve itself. */
|
|
219
|
+
export function dependentGroups(group: SecretGroup): readonly SecretGroup[] {
|
|
220
|
+
return DEPENDENT_GROUPS[group] ?? [];
|
|
221
|
+
}
|
|
222
|
+
|
|
80
223
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
81
224
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
82
225
|
}
|
|
@@ -125,7 +268,16 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
|
|
|
125
268
|
}
|
|
126
269
|
}
|
|
127
270
|
const [first, ...rest] = raw;
|
|
128
|
-
|
|
271
|
+
const group = spec.group;
|
|
272
|
+
if (group === undefined) {
|
|
273
|
+
return { command: [first, ...rest] };
|
|
274
|
+
}
|
|
275
|
+
if (typeof group !== 'string' || !GROUP_NAME.test(group)) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`smoo.secrets.${name}.group: must be a nonempty label without whitespace, so \`${ON_DEMAND_COMMAND} <group>\` can name it`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return { command: [first, ...rest], group };
|
|
129
281
|
}
|
|
130
282
|
|
|
131
283
|
/**
|
|
@@ -136,7 +288,7 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
|
|
|
136
288
|
*/
|
|
137
289
|
export interface RemoteCacheSpec {
|
|
138
290
|
readonly server: string;
|
|
139
|
-
/** Variable holding the cache token: an ambient value, or a `smoo.secrets` entry
|
|
291
|
+
/** Variable holding the cache token: an ambient value, or a `smoo.secrets` entry in group `nx-cache`. */
|
|
140
292
|
readonly tokenSecret: string;
|
|
141
293
|
}
|
|
142
294
|
|
|
@@ -181,88 +333,139 @@ export function registryAuthEnvNames(npmrcText: string | null): ReadonlySet<stri
|
|
|
181
333
|
return names;
|
|
182
334
|
}
|
|
183
335
|
|
|
336
|
+
/** What a repository's own declarations say about one variable, before any override. */
|
|
337
|
+
export interface GroupDerivationContext {
|
|
338
|
+
/** Env names referenced from `.npmrc`: which declared secrets a registry request needs. */
|
|
339
|
+
readonly registryIntentEnvs: ReadonlySet<string>;
|
|
340
|
+
/** The variable `smoo.remoteCache.tokenSecret` names, or null when no cache is declared. */
|
|
341
|
+
readonly cacheTokenEnv: string | null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The group a repository's existing declarations already imply for one
|
|
346
|
+
* variable. The cache token is checked first: a variable that is both the
|
|
347
|
+
* declared cache token and `.npmrc`-referenced was excluded from shell entry
|
|
348
|
+
* outright before groups existed, and `nx-cache` is the group that keeps that
|
|
349
|
+
* true.
|
|
350
|
+
*/
|
|
351
|
+
export function deriveSecretGroup(name: string, context: GroupDerivationContext): SecretGroup {
|
|
352
|
+
if (context.cacheTokenEnv === name) return NX_CACHE_GROUP;
|
|
353
|
+
if (context.registryIntentEnvs.has(name)) return REGISTRY_GROUP;
|
|
354
|
+
return SHELL_GROUP;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Every declared secret with its group settled, in declaration order. */
|
|
358
|
+
export function groupSecrets(
|
|
359
|
+
secrets: Readonly<Record<string, SecretSpec>>,
|
|
360
|
+
context: GroupDerivationContext,
|
|
361
|
+
): readonly GroupedSecret[] {
|
|
362
|
+
return Object.entries(secrets).map(([name, spec]) => {
|
|
363
|
+
const derivedGroup = deriveSecretGroup(name, context);
|
|
364
|
+
return { name, group: spec.group ?? derivedGroup, derivedGroup, spec };
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
184
368
|
type SecretOutcome =
|
|
185
|
-
| { readonly name: string; readonly kind: 'env-wins' }
|
|
186
|
-
| { readonly name: string; readonly kind: 'resolved'; readonly value: string }
|
|
187
|
-
| { readonly name: string; readonly kind: '
|
|
369
|
+
| { readonly name: string; readonly group: SecretGroup; readonly kind: 'env-wins' }
|
|
370
|
+
| { readonly name: string; readonly group: SecretGroup; readonly kind: 'resolved'; readonly value: string }
|
|
371
|
+
| { readonly name: string; readonly group: SecretGroup; readonly kind: 'deferred'; readonly guidance: string }
|
|
372
|
+
| { readonly name: string; readonly group: SecretGroup; readonly kind: 'failed'; readonly guidance: string };
|
|
188
373
|
|
|
189
374
|
/**
|
|
190
|
-
* One variable's routing, the four rules in the header applied in order. It
|
|
191
|
-
* a value rather than a throw so each caller presents
|
|
192
|
-
* terms: an install refuses, the remote cache turns itself off
|
|
375
|
+
* One variable's routing, the four rules in the header applied in order. It
|
|
376
|
+
* is a value rather than a throw so each caller presents the result in its
|
|
377
|
+
* own terms: an install refuses, the remote cache turns itself off, and a
|
|
378
|
+
* deferred credential is named only if something then needs it.
|
|
193
379
|
*/
|
|
194
380
|
async function routeSecret(
|
|
195
|
-
|
|
196
|
-
spec: SecretSpec,
|
|
381
|
+
secret: GroupedSecret,
|
|
197
382
|
context: {
|
|
198
383
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
199
|
-
readonly
|
|
384
|
+
readonly group: SecretGroup;
|
|
385
|
+
readonly dependsOn: readonly SecretGroup[];
|
|
200
386
|
readonly run: SecretCommandRunner;
|
|
201
387
|
},
|
|
202
388
|
): Promise<SecretOutcome> {
|
|
203
389
|
const { env } = context;
|
|
390
|
+
const { name, group } = secret;
|
|
204
391
|
if (isNonemptyEnvValue(env[name])) {
|
|
205
|
-
return { name, kind: 'env-wins' };
|
|
392
|
+
return { name, group, kind: 'env-wins' };
|
|
206
393
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
394
|
+
const requested = group === context.group;
|
|
395
|
+
if (requested || context.dependsOn.includes(group)) {
|
|
396
|
+
if (isNonemptyEnvValue(env.CI)) {
|
|
397
|
+
return {
|
|
398
|
+
name,
|
|
399
|
+
group,
|
|
400
|
+
kind: 'failed',
|
|
401
|
+
guidance:
|
|
402
|
+
'CI does not run secret provider commands — inject this variable into the job environment from the CI secret store',
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (group === REGISTRY_GROUP && isNonemptyEnvValue(env.COWSHED_WORKSPACE_TOKEN)) {
|
|
406
|
+
return {
|
|
407
|
+
name,
|
|
408
|
+
group,
|
|
409
|
+
kind: 'failed',
|
|
410
|
+
guidance:
|
|
411
|
+
'a registry credential absent in this cowshed workspace — enroll registry credentials through the cowshed gateway; gateway-managed workspaces never resolve a registry credential via a local provider command',
|
|
412
|
+
};
|
|
413
|
+
}
|
|
214
414
|
}
|
|
215
|
-
if (
|
|
415
|
+
if (!requested) {
|
|
216
416
|
return {
|
|
217
417
|
name,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
418
|
+
group,
|
|
419
|
+
kind: 'deferred',
|
|
420
|
+
guidance: `in group \`${group}\`, which a \`${context.group}\` request does not resolve — run \`${ON_DEMAND_COMMAND} ${group} <command>\` to supply it to that one command`,
|
|
221
421
|
};
|
|
222
422
|
}
|
|
223
423
|
try {
|
|
224
|
-
const value = (await context.run(spec.command)).replace(/\r?\n$/, '');
|
|
424
|
+
const value = (await context.run(secret.spec.command)).replace(/\r?\n$/, '');
|
|
225
425
|
if (value.length === 0) {
|
|
226
426
|
return {
|
|
227
427
|
name,
|
|
428
|
+
group,
|
|
228
429
|
kind: 'failed',
|
|
229
430
|
guidance:
|
|
230
431
|
'provider command produced no output — run it locally to see why (its arguments and output are never logged here)',
|
|
231
432
|
};
|
|
232
433
|
}
|
|
233
|
-
return { name, kind: 'resolved', value };
|
|
434
|
+
return { name, group, kind: 'resolved', value };
|
|
234
435
|
} catch (error) {
|
|
235
|
-
return { name, kind: 'failed', guidance: describeCommandFailure(error) };
|
|
436
|
+
return { name, group, kind: 'failed', guidance: describeCommandFailure(error) };
|
|
236
437
|
}
|
|
237
438
|
}
|
|
238
439
|
|
|
239
|
-
export async function resolveSecrets(request: SecretResolutionRequest): Promise<
|
|
440
|
+
export async function resolveSecrets(request: SecretResolutionRequest): Promise<SecretResolution> {
|
|
240
441
|
const context = {
|
|
241
442
|
env: request.env,
|
|
242
|
-
|
|
443
|
+
group: request.group,
|
|
444
|
+
dependsOn: dependentGroups(request.group),
|
|
243
445
|
run: request.runCommand ?? runSecretCommand,
|
|
244
446
|
};
|
|
245
|
-
const outcomes = await Promise.all(
|
|
246
|
-
Object.entries(request.secrets).map(async ([name, spec]) => routeSecret(name, spec, context)),
|
|
247
|
-
);
|
|
447
|
+
const outcomes = await Promise.all(request.secrets.map(async (secret) => routeSecret(secret, context)));
|
|
248
448
|
const failures = outcomes.filter(
|
|
249
449
|
(outcome): outcome is Extract<SecretOutcome, { kind: 'failed' }> => outcome.kind === 'failed',
|
|
250
450
|
);
|
|
251
451
|
if (failures.length > 0) {
|
|
252
452
|
throw new Error(
|
|
253
453
|
[
|
|
254
|
-
'smoo secret resolution failed
|
|
255
|
-
...failures.map((failure) => `- ${failure.name}: ${failure.guidance}`),
|
|
454
|
+
'smoo secret resolution failed:',
|
|
455
|
+
...failures.map((failure) => `- ${failure.name} (${failure.group}): ${failure.guidance}`),
|
|
256
456
|
].join('\n'),
|
|
257
457
|
);
|
|
258
458
|
}
|
|
259
|
-
const
|
|
459
|
+
const values: Record<string, string> = {};
|
|
460
|
+
const deferred: DeferredSecret[] = [];
|
|
260
461
|
for (const outcome of outcomes) {
|
|
261
462
|
if (outcome.kind === 'resolved') {
|
|
262
|
-
|
|
463
|
+
values[outcome.name] = outcome.value;
|
|
464
|
+
} else if (outcome.kind === 'deferred') {
|
|
465
|
+
deferred.push({ name: outcome.name, group: outcome.group, guidance: outcome.guidance });
|
|
263
466
|
}
|
|
264
467
|
}
|
|
265
|
-
return
|
|
468
|
+
return { values, deferred };
|
|
266
469
|
}
|
|
267
470
|
|
|
268
471
|
class SecretCommandExitError extends Error {
|
|
@@ -378,28 +581,29 @@ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): n
|
|
|
378
581
|
}
|
|
379
582
|
|
|
380
583
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* install actually depends on still refuses loudly here. Which variable that
|
|
386
|
-
* is comes from `smoo.remoteCache.tokenSecret` rather than a second flag, so
|
|
387
|
-
* the two declarations cannot disagree.
|
|
584
|
+
* Every declared secret of the repository at `root`, each with its group
|
|
585
|
+
* settled from that repository's own declarations. The listing
|
|
586
|
+
* `smoo secrets run` prints and the routing below read the same value, so the
|
|
587
|
+
* groups a developer is offered are exactly the groups that resolve.
|
|
388
588
|
*/
|
|
389
|
-
export
|
|
390
|
-
|
|
391
|
-
)
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
589
|
+
export function readSecretGroups(root: string): readonly GroupedSecret[] {
|
|
590
|
+
const packageJson = readPackageJson(root);
|
|
591
|
+
return groupSecrets(parseSmooSecrets(packageJson), {
|
|
592
|
+
registryIntentEnvs: registryAuthEnvNames(readNpmrcText(root)),
|
|
593
|
+
cacheTokenEnv: parseSmooRemoteCache(packageJson)?.tokenSecret ?? null,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* The declared secrets of `options.group`, resolved before the thing that
|
|
599
|
+
* needs them runs. Everything outside that group comes back deferred: named,
|
|
600
|
+
* unresolved, and not a failure — except where rule 2 applies, which is a
|
|
601
|
+
* context that could not honour the deferral anyway.
|
|
602
|
+
*/
|
|
603
|
+
export async function resolveSecretEnvironment(options: SecretResolutionOptions): Promise<SecretResolution> {
|
|
400
604
|
return resolveSecrets({
|
|
401
|
-
secrets:
|
|
402
|
-
|
|
605
|
+
secrets: readSecretGroups(options.root),
|
|
606
|
+
group: options.group,
|
|
403
607
|
env: options.env ?? process.env,
|
|
404
608
|
runCommand: options.runCommand,
|
|
405
609
|
});
|
|
@@ -4,7 +4,13 @@ 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 {
|
|
7
|
+
import {
|
|
8
|
+
type DeferredSecret,
|
|
9
|
+
dependentGroups,
|
|
10
|
+
maskSecretValues,
|
|
11
|
+
resolveSecretEnvironment,
|
|
12
|
+
SHELL_GROUP,
|
|
13
|
+
} from './secret-references.ts';
|
|
8
14
|
|
|
9
15
|
// DEVENV_ROOT is set by the devenv shell, which is how this script normally
|
|
10
16
|
// runs. CI jobs that install dependencies without building that shell (the
|
|
@@ -32,6 +38,15 @@ class CapturedCommandError extends Error {
|
|
|
32
38
|
// of the failure it was called to report.
|
|
33
39
|
const resolvedSecretValues: string[] = [];
|
|
34
40
|
|
|
41
|
+
// The declared secrets shell entry deliberately did NOT resolve — see THE
|
|
42
|
+
// RULE in secret-references.ts: shell entry resolves the `shell` group and
|
|
43
|
+
// nothing else. Nonempty only when this repository declares a secret in
|
|
44
|
+
// another group that is absent from this environment. An installed checkout
|
|
45
|
+
// contacts no registry and Nx needs no cache to run, so this stays silent;
|
|
46
|
+
// it is printed by reportDegradedSetup, where an install has already failed
|
|
47
|
+
// and a 401 is one of the things it might have been.
|
|
48
|
+
const deferredSecrets: DeferredSecret[] = [];
|
|
49
|
+
|
|
35
50
|
async function resolveProjectRoot(): Promise<string> {
|
|
36
51
|
if (devenvRoot) {
|
|
37
52
|
return path.resolve(`${devenvRoot}/../..`);
|
|
@@ -145,12 +160,22 @@ try {
|
|
|
145
160
|
* scripts it runs, and every later child of this script inherit them (for
|
|
146
161
|
* example .npmrc `${VAR}` auth); the direnv shell itself does not, which is
|
|
147
162
|
* the point: this script must never act as a global shell export.
|
|
163
|
+
*
|
|
164
|
+
* `shell` is the group, and it is what keeps a credential only a deliberate
|
|
165
|
+
* command needs — a registry token, the Nx cache token — from running its
|
|
166
|
+
* provider command on every direnv reload. Such a variable comes back
|
|
167
|
+
* deferred instead of resolved, and an install proceeds without it — which
|
|
168
|
+
* is the normal case, because an installed checkout contacts no registry.
|
|
169
|
+
* The install that does need one fails, and reportDegradedSetup then names
|
|
170
|
+
* the variable and the exact command that supplies it.
|
|
148
171
|
*/
|
|
149
172
|
async function resolveSecrets(): Promise<void> {
|
|
150
|
-
|
|
173
|
+
const resolution = await resolveSecretEnvironment({ root: projectRoot, group: SHELL_GROUP });
|
|
174
|
+
for (const [name, value] of Object.entries(resolution.values)) {
|
|
151
175
|
process.env[name] = value;
|
|
152
176
|
resolvedSecretValues.push(value);
|
|
153
177
|
}
|
|
178
|
+
deferredSecrets.push(...resolution.deferred);
|
|
154
179
|
}
|
|
155
180
|
|
|
156
181
|
async function installLocalDependencies(): Promise<unknown> {
|
|
@@ -416,6 +441,7 @@ function reportSetupFailure(error: unknown): never {
|
|
|
416
441
|
|
|
417
442
|
function reportDegradedSetup(error: unknown): void {
|
|
418
443
|
describeFailure('WARNING', error);
|
|
444
|
+
reportDeferredSecrets();
|
|
419
445
|
console.error(
|
|
420
446
|
'The shell is loaded WITHOUT installed dependencies so the tools to repair this stay available.\n' +
|
|
421
447
|
'Fix the cause above (missing registry credential, unpublished package, stale lockfile → `devenv update`),\n' +
|
|
@@ -424,6 +450,36 @@ function reportDegradedSetup(error: unknown): void {
|
|
|
424
450
|
console.error('---');
|
|
425
451
|
}
|
|
426
452
|
|
|
453
|
+
/**
|
|
454
|
+
* The declared secrets this shell entry deliberately did not resolve, named
|
|
455
|
+
* now that an install has actually failed. Printing them on a healthy shell
|
|
456
|
+
* entry would be noise on every reload — an installed checkout contacts no
|
|
457
|
+
* registry and needs none of them — while an install that failed may be
|
|
458
|
+
* exactly the 401 they explain, and then the useful output is the variable
|
|
459
|
+
* and the command that supplies it.
|
|
460
|
+
*/
|
|
461
|
+
function reportDeferredSecrets(): void {
|
|
462
|
+
if (deferredSecrets.length === 0) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
console.error('Secrets outside the `shell` group are not resolved at shell entry, by design:');
|
|
466
|
+
for (const secret of deferredSecrets) {
|
|
467
|
+
console.error(`- ${secret.name} (${secret.group}): ${secret.guidance}`);
|
|
468
|
+
}
|
|
469
|
+
// Only a group the install itself reads can explain this failure, and only
|
|
470
|
+
// for those is re-running the install the command worth printing. A group
|
|
471
|
+
// nothing here consumes — the Nx cache token — is listed above and left
|
|
472
|
+
// alone: it did not cause this and re-running the install with it would
|
|
473
|
+
// not fix it.
|
|
474
|
+
for (const group of dependentGroups(SHELL_GROUP)) {
|
|
475
|
+
if (!deferredSecrets.some((secret) => secret.group === group)) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
console.error(`The install reads group \`${group}\`; supply it to exactly that one command with:`);
|
|
479
|
+
console.error(` smoo secrets run ${group} bun install`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
427
483
|
function describeFailure(level: 'ERROR' | 'WARNING', error: unknown): void {
|
|
428
484
|
if (error instanceof CapturedCommandError) {
|
|
429
485
|
console.error(`--- ${level}: setup-environment.ts failed while running: ${error.command}`);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The managed raw scripts are their own TypeScript project, and must be:
|
|
3
|
+
// they run as plain Bun source during bootstrap — before any workspace
|
|
4
|
+
// package, before node_modules, before the Typia transform — so they can
|
|
5
|
+
// belong to no project that assumes those. This file is what says so.
|
|
6
|
+
//
|
|
7
|
+
// It is also load-bearing at runtime. Bun's ttsc loader (see
|
|
8
|
+
// packages/validation/src/bun/preload.ts) routes each file to the nearest
|
|
9
|
+
// `tsconfig.lib.json` above it; without this one, a raw script under
|
|
10
|
+
// managed/ routed to packages/cli/tsconfig.lib.json, whose `include` is
|
|
11
|
+
// `src/**/*.ts`, and the transform returned no output at all. `smoo secrets
|
|
12
|
+
// run` imports secret-references.ts from here, and a consuming repo does
|
|
13
|
+
// the same from inside node_modules, so this file ships with the package.
|
|
14
|
+
"compilerOptions": {
|
|
15
|
+
"target": "ESNext",
|
|
16
|
+
"lib": ["ESNext"],
|
|
17
|
+
"module": "Preserve",
|
|
18
|
+
"moduleResolution": "bundler",
|
|
19
|
+
"allowImportingTsExtensions": true,
|
|
20
|
+
"verbatimModuleSyntax": true,
|
|
21
|
+
"noEmit": true,
|
|
22
|
+
"strict": true,
|
|
23
|
+
"noUncheckedIndexedAccess": false,
|
|
24
|
+
"skipLibCheck": true,
|
|
25
|
+
"types": ["bun"]
|
|
26
|
+
},
|
|
27
|
+
"include": ["**/*.ts"]
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smoothbricks/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SmoothBricks monorepo automation CLI",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
"import": "./dist/release/pack.js",
|
|
40
40
|
"default": "./dist/release/pack.js"
|
|
41
41
|
},
|
|
42
|
+
"./secrets": {
|
|
43
|
+
"types": "./dist/secrets/status.d.ts",
|
|
44
|
+
"import": "./dist/secrets/status.js",
|
|
45
|
+
"default": "./dist/secrets/status.js"
|
|
46
|
+
},
|
|
42
47
|
"./wrangler/prepare-env": {
|
|
43
48
|
"types": "./dist/wrangler/prepare-env.d.ts",
|
|
44
49
|
"import": "./dist/wrangler/prepare-env.js",
|
|
@@ -64,7 +69,7 @@
|
|
|
64
69
|
],
|
|
65
70
|
"dependencies": {
|
|
66
71
|
"@arethetypeswrong/core": "^0.18.2",
|
|
67
|
-
"@smoothbricks/nx-plugin": "0.4.
|
|
72
|
+
"@smoothbricks/nx-plugin": "0.4.15",
|
|
68
73
|
"@smoothbricks/validation": "0.1.8",
|
|
69
74
|
"commander": "^14.0.3",
|
|
70
75
|
"make-synchronized": "^0.8.0",
|