@smoothbricks/cli 0.11.18 → 0.11.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -11
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +28 -4
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +6 -20
- package/dist/lib/deploy-tags.d.ts +17 -3
- package/dist/lib/deploy-tags.d.ts.map +1 -1
- package/dist/lib/deploy-tags.js +23 -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/managed-files.d.ts +5 -2
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +35 -21
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +6 -1
- package/dist/nx/index.d.ts +6 -0
- package/dist/nx/index.d.ts.map +1 -1
- package/dist/nx/index.js +14 -0
- 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/github-ci/index.test.ts +6 -7
- package/src/github-ci/index.ts +11 -19
- package/src/lib/deploy-tags.ts +22 -4
- 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/managed-files.test.ts +69 -37
- package/src/monorepo/managed-files.ts +38 -21
- package/src/monorepo/packs/index.ts +10 -1
- 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 -0
- package/src/nx/index.ts +20 -0
- 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
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
cargoCrossTestTripleFromArchiveTarget,
|
|
7
7
|
} from '@smoothbricks/nx-plugin/cross-check-policy';
|
|
8
8
|
import { MACOS_PLATFORM_TARGET_GLOBS, PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
|
|
9
|
-
import {
|
|
9
|
+
import { PRODUCTION_PUSH_DEPLOY_TAG, stageDeploysProject } from '../lib/deploy-tags.js';
|
|
10
10
|
import {
|
|
11
11
|
ciPushBranches,
|
|
12
12
|
type NonEmptyArray,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
targetNamesFromProjects,
|
|
30
30
|
} from '../nx/index.js';
|
|
31
31
|
import { resolvePrivateNpmWorkflowConfig } from '../release/private-npm.js';
|
|
32
|
+
import type { DeploymentStage } from '../wrangler/stage.js';
|
|
32
33
|
import { type CiCrossTestArchive, privateNpmReadTokenJobEnv, renderCiWorkflowYaml } from './ci-workflow.js';
|
|
33
34
|
import { renderRunsOnLine } from './github-runs-on.js';
|
|
34
35
|
import { renderPrPreviewCleanupWorkflowYaml } from './pr-preview-cleanup-workflow.js';
|
|
@@ -342,6 +343,23 @@ export const managedFileTargetsForTest = managedFiles.map(({ target, executable
|
|
|
342
343
|
|
|
343
344
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
344
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Whether a repository of this shape has the file at all. A repository that owns
|
|
348
|
+
* no packages has nothing to publish, and one the stage flow does not deploy to
|
|
349
|
+
* Cloudflare has no preview stage to clean up.
|
|
350
|
+
*/
|
|
351
|
+
function managedFileApplies(file: ManagedFile, context: ManagedFileContext): boolean {
|
|
352
|
+
if (file.releasePackagesOnly === true && !context.hasReleasePackages && !context.hasProductionDeployTargets) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
return file.cloudflareDeployOnly !== true || context.stagingDeployProvider === 'cloudflare';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Test seam: the managed targets a repository of this shape gets, in file order. */
|
|
359
|
+
export function managedFileTargetsForContext(context: ManagedFileContext): string[] {
|
|
360
|
+
return managedFiles.filter((file) => managedFileApplies(file, context)).map((file) => file.target);
|
|
361
|
+
}
|
|
362
|
+
|
|
345
363
|
export async function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): Promise<FileResult[]> {
|
|
346
364
|
const context = await getManagedFileContext(root);
|
|
347
365
|
return managedFiles.map((file) => applyManagedFile(root, file, mode, context));
|
|
@@ -353,10 +371,7 @@ function applyManagedFile(
|
|
|
353
371
|
mode: 'update' | 'check' | 'diff',
|
|
354
372
|
context: ManagedFileContext,
|
|
355
373
|
): FileResult {
|
|
356
|
-
if (file
|
|
357
|
-
return { target: file.target, action: 'skipped' };
|
|
358
|
-
}
|
|
359
|
-
if (file.cloudflareDeployOnly === true && context.stagingDeployProvider !== 'cloudflare') {
|
|
374
|
+
if (!managedFileApplies(file, context)) {
|
|
360
375
|
return { target: file.target, action: 'skipped' };
|
|
361
376
|
}
|
|
362
377
|
const target = resolve(root, file.target);
|
|
@@ -612,12 +627,12 @@ export function macosPlatformArchitecturesForTest(targetNames: Iterable<string>)
|
|
|
612
627
|
return [...architectures].sort((left, right) => left.localeCompare(right));
|
|
613
628
|
}
|
|
614
629
|
|
|
615
|
-
/** Test seam:
|
|
616
|
-
export function deployTargetInfoFromProjects(projects: NxProjects,
|
|
630
|
+
/** Test seam: what the stage flow deploys, and with which provider, from graph nodes. */
|
|
631
|
+
export function deployTargetInfoFromProjects(projects: NxProjects, stage: DeploymentStage): DeployTargetInfo {
|
|
617
632
|
let exists = false;
|
|
618
633
|
let provider: DeployTargetInfo['provider'];
|
|
619
634
|
for (const project of Object.values(projects)) {
|
|
620
|
-
const info = deployTargetInfoFromProject(project,
|
|
635
|
+
const info = deployTargetInfoFromProject(project, stage);
|
|
621
636
|
if (!info.exists) {
|
|
622
637
|
continue;
|
|
623
638
|
}
|
|
@@ -632,22 +647,24 @@ export function anyProjectHasTagForTest(projects: NxProjects, tag: string): bool
|
|
|
632
647
|
return Object.values(projects).some((project) => project.tags?.includes(tag));
|
|
633
648
|
}
|
|
634
649
|
|
|
635
|
-
|
|
650
|
+
/**
|
|
651
|
+
* A generated deploy job exists for the projects CI would actually deploy, which
|
|
652
|
+
* is `stageDeploysProject` and nothing else — the same rule `smoo github-ci
|
|
653
|
+
* nx-deploy` selects by, so the job and its selection cannot disagree. Reading
|
|
654
|
+
* the target's presence instead would render a deploy job, with cloud
|
|
655
|
+
* credentials, into a repository whose only wrangler manifests document a
|
|
656
|
+
* Durable Object binding for a published library's consumers.
|
|
657
|
+
*
|
|
658
|
+
* The command is read for the PROVIDER only: which cloud CLI runs says which
|
|
659
|
+
* credentials the job needs, and says nothing about whether CI runs it.
|
|
660
|
+
*/
|
|
661
|
+
function deployTargetInfoFromProject(project: NxProjectJson, stage: DeploymentStage): DeployTargetInfo {
|
|
636
662
|
const deploy = project.targets?.deploy;
|
|
637
|
-
if (!deploy) {
|
|
638
|
-
return { exists: false };
|
|
639
|
-
}
|
|
640
|
-
const baseCommandValue = deploy.options?.command ?? deploy.command;
|
|
641
|
-
const baseCommand = typeof baseCommandValue === 'string' ? baseCommandValue : '';
|
|
642
|
-
if (isStageDerivedDeploy(project.tags, baseCommand)) {
|
|
643
|
-
return { exists: true, provider: deployProvider(baseCommand) };
|
|
644
|
-
}
|
|
645
|
-
const config = deploy.configurations?.[configuration];
|
|
646
|
-
if (!config) {
|
|
663
|
+
if (!deploy || !stageDeploysProject(project.tags, stage)) {
|
|
647
664
|
return { exists: false };
|
|
648
665
|
}
|
|
649
|
-
const
|
|
650
|
-
return { exists: true, provider: deployProvider(typeof
|
|
666
|
+
const command = deploy.options?.command ?? deploy.command;
|
|
667
|
+
return { exists: true, provider: deployProvider(typeof command === 'string' ? command : '') };
|
|
651
668
|
}
|
|
652
669
|
|
|
653
670
|
function deployProvider(command: string): DeployTargetInfo['provider'] {
|
|
@@ -2,7 +2,11 @@ import { chmodSync, existsSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { printCommandOutput, runResult, runStatus } from '../../lib/run.js';
|
|
4
4
|
import { type ProjectTargets, readProjectTargets } from '../../nx/index.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
applyCargoFeatureUnification,
|
|
7
|
+
validateCargoCachePolicy,
|
|
8
|
+
validateCargoToolchainInputs,
|
|
9
|
+
} from '../cargo-policy.js';
|
|
6
10
|
import { validateGoToolchainAgreement } from '../go-toolchain.js';
|
|
7
11
|
import { syncBunLockfileVersions, validateBunLockfileVersions } from '../lockfile.js';
|
|
8
12
|
import { validateDevenvModuleImport, warnOnManagedFileDrift } from '../managed-files.js';
|
|
@@ -140,6 +144,11 @@ const packs: MonorepoPack[] = [
|
|
|
140
144
|
validatePreBuild(ctx) {
|
|
141
145
|
return validateCargoCachePolicy(ctx.root);
|
|
142
146
|
},
|
|
147
|
+
// Post-build: the toolchain identity is read off the RESOLVED graph, which
|
|
148
|
+
// means running the inference plugin, which means its dist has to exist.
|
|
149
|
+
async validatePostBuild(ctx) {
|
|
150
|
+
return validateCargoToolchainInputs(ctx.root, await readProjectTargets(ctx.root));
|
|
151
|
+
},
|
|
143
152
|
},
|
|
144
153
|
{
|
|
145
154
|
name: 'publishing',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { join, resolve } from 'node:path';
|
|
@@ -32,7 +33,8 @@ const RESOLVE_SCRIPT = `
|
|
|
32
33
|
const resolver = await import(Bun.argv[1]);
|
|
33
34
|
const request = JSON.parse(Bun.argv[2]);
|
|
34
35
|
try {
|
|
35
|
-
|
|
36
|
+
const { values, deferred } = await resolver.resolveSecretEnvironment(request);
|
|
37
|
+
process.stdout.write(JSON.stringify({ resolved: values, deferred }));
|
|
36
38
|
} catch (error) {
|
|
37
39
|
process.stdout.write(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
38
40
|
}
|
|
@@ -54,16 +56,44 @@ const names = [...resolver.registryAuthEnvNames(npmrc)];
|
|
|
54
56
|
process.stdout.write(JSON.stringify({ names }));
|
|
55
57
|
`;
|
|
56
58
|
|
|
59
|
+
const GROUPS_SCRIPT = `
|
|
60
|
+
const resolver = await import(Bun.argv[1]);
|
|
61
|
+
const { root } = JSON.parse(Bun.argv[2]);
|
|
62
|
+
const groups = resolver.readSecretGroups(root).map(({ name, group, derivedGroup }) => ({ name, group, derivedGroup }));
|
|
63
|
+
process.stdout.write(JSON.stringify({ groups }));
|
|
64
|
+
`;
|
|
65
|
+
|
|
57
66
|
interface BootstrapOutcome {
|
|
58
67
|
readonly resolved?: Record<string, string>;
|
|
68
|
+
readonly deferred?: readonly { readonly name: string; readonly group: string; readonly guidance: string }[];
|
|
59
69
|
readonly parsed?: unknown;
|
|
60
70
|
readonly error?: string;
|
|
61
71
|
}
|
|
62
72
|
|
|
73
|
+
interface GroupListing {
|
|
74
|
+
readonly groups: { name: string; group: string; derivedGroup: string }[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isGroupListing(value: unknown): value is GroupListing {
|
|
78
|
+
if (typeof value !== 'object' || value === null || !('groups' in value) || !Array.isArray(value.groups)) return false;
|
|
79
|
+
return value.groups.every(
|
|
80
|
+
(entry: unknown) =>
|
|
81
|
+
typeof entry === 'object' &&
|
|
82
|
+
entry !== null &&
|
|
83
|
+
'name' in entry &&
|
|
84
|
+
typeof entry.name === 'string' &&
|
|
85
|
+
'group' in entry &&
|
|
86
|
+
typeof entry.group === 'string' &&
|
|
87
|
+
'derivedGroup' in entry &&
|
|
88
|
+
typeof entry.derivedGroup === 'string',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
/** Narrows a harness JSON envelope with `in` checks instead of assertions: unknown shapes fail the test. Field-level expectations below do the precise checking. */
|
|
64
93
|
function isBootstrapOutcome(value: unknown): value is BootstrapOutcome {
|
|
65
94
|
if (typeof value !== 'object' || value === null) return false;
|
|
66
95
|
if ('resolved' in value && (typeof value.resolved !== 'object' || value.resolved === null)) return false;
|
|
96
|
+
if ('deferred' in value && !Array.isArray(value.deferred)) return false;
|
|
67
97
|
if ('error' in value && typeof value.error !== 'string') return false;
|
|
68
98
|
return true;
|
|
69
99
|
}
|
|
@@ -96,6 +126,39 @@ const emit = (text: string): readonly [string, ...string[]] => [
|
|
|
96
126
|
'-e',
|
|
97
127
|
`process.stdout.write(${JSON.stringify(text)})`,
|
|
98
128
|
];
|
|
129
|
+
|
|
130
|
+
/** `emit`, plus one line appended to `ledger` every time the command actually runs. */
|
|
131
|
+
const emitAndRecord = (text: string, ledger: string): readonly [string, ...string[]] => [
|
|
132
|
+
process.execPath,
|
|
133
|
+
'-e',
|
|
134
|
+
`require('node:fs').appendFileSync(${JSON.stringify(ledger)}, '1\\n');process.stdout.write(${JSON.stringify(text)})`,
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A ledger a provider command appends to, and the count of what it recorded.
|
|
139
|
+
* Counting invocations is the only direct evidence that a path ran no
|
|
140
|
+
* provider command: an absent value is also what a silently failing provider
|
|
141
|
+
* leaves behind, and "no credential prompt" is the whole point of the rule.
|
|
142
|
+
*/
|
|
143
|
+
async function withProviderLedger(
|
|
144
|
+
run: (ledger: { readonly path: string; readonly invocations: () => number }) => Promise<void>,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const dir = await mkdtemp(join(tmpdir(), 'smoo-provider-ledger-'));
|
|
147
|
+
const path = join(dir, 'invocations');
|
|
148
|
+
try {
|
|
149
|
+
await run({
|
|
150
|
+
path,
|
|
151
|
+
invocations: () =>
|
|
152
|
+
existsSync(path)
|
|
153
|
+
? readFileSync(path, 'utf8')
|
|
154
|
+
.split('\n')
|
|
155
|
+
.filter((line) => line.length > 0).length
|
|
156
|
+
: 0,
|
|
157
|
+
});
|
|
158
|
+
} finally {
|
|
159
|
+
await rm(dir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
99
162
|
/**
|
|
100
163
|
* Runs one script against the raw module in a plain Bun child. The child env
|
|
101
164
|
* carries only PATH/HOME so parent suite state (CI, workspace tokens) cannot
|
|
@@ -124,16 +187,33 @@ async function runInBootstrap(
|
|
|
124
187
|
async function resolveInBootstrap(options: {
|
|
125
188
|
root: string;
|
|
126
189
|
env?: Record<string, string | undefined>;
|
|
190
|
+
/**
|
|
191
|
+
* Defaults to the group shell entry resolves, which is what a direnv
|
|
192
|
+
* reload performs. The resolver itself declares no default — resolving the
|
|
193
|
+
* wrong group is a credential prompt — so every case that is not shell
|
|
194
|
+
* entry names its group out loud.
|
|
195
|
+
*/
|
|
196
|
+
group?: string;
|
|
127
197
|
}): Promise<BootstrapOutcome> {
|
|
128
198
|
const { stdout, stderr, exitCode } = await runInBootstrap(
|
|
129
199
|
RESOLVE_SCRIPT,
|
|
130
|
-
JSON.stringify({ root: options.root, env: options.env ?? {} }),
|
|
200
|
+
JSON.stringify({ root: options.root, env: options.env ?? {}, group: options.group ?? 'shell' }),
|
|
131
201
|
);
|
|
132
202
|
expect(stderr).toBe('');
|
|
133
203
|
expect(exitCode).toBe(0);
|
|
134
204
|
return mustOutcome(stdout);
|
|
135
205
|
}
|
|
136
206
|
|
|
207
|
+
/** The groups this repository's own declarations settle on, per declared variable. */
|
|
208
|
+
async function groupsInBootstrap(root: string): Promise<{ name: string; group: string; derivedGroup: string }[]> {
|
|
209
|
+
const { stdout, stderr, exitCode } = await runInBootstrap(GROUPS_SCRIPT, JSON.stringify({ root }));
|
|
210
|
+
expect(stderr).toBe('');
|
|
211
|
+
expect(exitCode).toBe(0);
|
|
212
|
+
const value: unknown = JSON.parse(stdout);
|
|
213
|
+
if (!isGroupListing(value)) throw new Error(`bootstrap harness returned a malformed listing: ${stdout}`);
|
|
214
|
+
return value.groups;
|
|
215
|
+
}
|
|
216
|
+
|
|
137
217
|
/** Builds a fixture repository whose package.json and .npmrc are the resolver's real inputs. */
|
|
138
218
|
async function withFixture(
|
|
139
219
|
options: { secrets: Record<string, SecretSpec>; npmrc?: string; remoteCache?: unknown },
|
|
@@ -195,6 +275,19 @@ describe('smoo.secrets shape validation', () => {
|
|
|
195
275
|
expect((await parse({ smoo: { secrets: { 'a b': { command: ['op'] } } } })).error).toContain('a b');
|
|
196
276
|
expect((await parse({ smoo: { secrets: { '': { command: ['op'] } } } })).error).toContain('smoo.secrets');
|
|
197
277
|
});
|
|
278
|
+
|
|
279
|
+
it('accepts any group label the command line can name, and rejects one it cannot', async () => {
|
|
280
|
+
// The shape is validated, never the value: a repository naming a group
|
|
281
|
+
// of its own must not need a new smoo release to run it.
|
|
282
|
+
expect(
|
|
283
|
+
(await parse({ smoo: { secrets: { SMOO_TOKEN: { command: ['op'], group: 'deploy-eu' } } } })).parsed,
|
|
284
|
+
).toEqual({ SMOO_TOKEN: { command: ['op'], group: 'deploy-eu' } });
|
|
285
|
+
for (const malformed of ['', 'two words', 7]) {
|
|
286
|
+
const outcome = await parse({ smoo: { secrets: { SMOO_TOKEN: { command: ['op'], group: malformed } } } });
|
|
287
|
+
expect(outcome.error).toContain('SMOO_TOKEN');
|
|
288
|
+
expect(outcome.error).toContain('group');
|
|
289
|
+
}
|
|
290
|
+
});
|
|
198
291
|
});
|
|
199
292
|
|
|
200
293
|
describe('registryAuthEnvNames', () => {
|
|
@@ -215,6 +308,78 @@ describe('registryAuthEnvNames', () => {
|
|
|
215
308
|
});
|
|
216
309
|
});
|
|
217
310
|
|
|
311
|
+
/**
|
|
312
|
+
* The group is derived from declarations the repository already carries, so
|
|
313
|
+
* a manifest states a group only where those cannot: nobody restates a fact
|
|
314
|
+
* the repo already declares.
|
|
315
|
+
*/
|
|
316
|
+
describe('group derivation', () => {
|
|
317
|
+
const REGISTRY_NPMRC = '//npm.example.net/:_authToken=${SMOO_NPM_TOKEN}\n';
|
|
318
|
+
|
|
319
|
+
it('derives registry from a .npmrc reference, nx-cache from the cache token, and shell from neither', async () => {
|
|
320
|
+
await withFixture(
|
|
321
|
+
{
|
|
322
|
+
npmrc: REGISTRY_NPMRC,
|
|
323
|
+
secrets: {
|
|
324
|
+
SMOO_NPM_TOKEN: { command: emit('x') },
|
|
325
|
+
NX_REMOTE_CACHE_TOKEN: { command: emit('x') },
|
|
326
|
+
SMOO_TOKEN: { command: emit('x') },
|
|
327
|
+
},
|
|
328
|
+
remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
|
|
329
|
+
},
|
|
330
|
+
async (root) => {
|
|
331
|
+
expect(await groupsInBootstrap(root)).toEqual([
|
|
332
|
+
{ name: 'SMOO_NPM_TOKEN', group: 'registry', derivedGroup: 'registry' },
|
|
333
|
+
{ name: 'NX_REMOTE_CACHE_TOKEN', group: 'nx-cache', derivedGroup: 'nx-cache' },
|
|
334
|
+
{ name: 'SMOO_TOKEN', group: 'shell', derivedGroup: 'shell' },
|
|
335
|
+
]);
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('a declared group wins, and the derivation it overrode stays visible', async () => {
|
|
341
|
+
await withFixture(
|
|
342
|
+
{
|
|
343
|
+
npmrc: REGISTRY_NPMRC,
|
|
344
|
+
secrets: {
|
|
345
|
+
SMOO_NPM_TOKEN: { command: emit('x'), group: 'shell' },
|
|
346
|
+
SMOO_TOKEN: { command: emit('x'), group: 'deploy' },
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
async (root) => {
|
|
350
|
+
expect(await groupsInBootstrap(root)).toEqual([
|
|
351
|
+
{ name: 'SMOO_NPM_TOKEN', group: 'shell', derivedGroup: 'registry' },
|
|
352
|
+
{ name: 'SMOO_TOKEN', group: 'deploy', derivedGroup: 'shell' },
|
|
353
|
+
]);
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('the cache token stays nx-cache even when .npmrc also references it', async () => {
|
|
359
|
+
await withFixture(
|
|
360
|
+
{
|
|
361
|
+
npmrc: '//npm.example.net/:_authToken=${NX_REMOTE_CACHE_TOKEN}\n',
|
|
362
|
+
secrets: { NX_REMOTE_CACHE_TOKEN: { command: emit('x') } },
|
|
363
|
+
remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
|
|
364
|
+
},
|
|
365
|
+
async (root) => {
|
|
366
|
+
// The by-name exclusion this replaced was applied before the .npmrc
|
|
367
|
+
// reference was ever consulted; the derivation keeps that order, so
|
|
368
|
+
// one pathological manifest behaves exactly as it did.
|
|
369
|
+
expect(await groupsInBootstrap(root)).toEqual([
|
|
370
|
+
{ name: 'NX_REMOTE_CACHE_TOKEN', group: 'nx-cache', derivedGroup: 'nx-cache' },
|
|
371
|
+
]);
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('a repository declaring nothing has no groups', async () => {
|
|
377
|
+
await withFixture({ secrets: {} }, async (root) => {
|
|
378
|
+
expect(await groupsInBootstrap(root)).toEqual([]);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
218
383
|
describe('resolveSecretEnvironment', () => {
|
|
219
384
|
it('an existing nonempty environment value wins and no command runs', async () => {
|
|
220
385
|
await withFixture(
|
|
@@ -222,7 +387,10 @@ describe('resolveSecretEnvironment', () => {
|
|
|
222
387
|
async (root) => {
|
|
223
388
|
// The declared command cannot run at all, so a resolution attempt would
|
|
224
389
|
// fail loudly; an empty outcome proves the existing value preempted it.
|
|
225
|
-
expect(await resolveInBootstrap({ root, env: { SMOO_TOKEN: 'already-set' } })).toEqual({
|
|
390
|
+
expect(await resolveInBootstrap({ root, env: { SMOO_TOKEN: 'already-set' } })).toEqual({
|
|
391
|
+
resolved: {},
|
|
392
|
+
deferred: [],
|
|
393
|
+
});
|
|
226
394
|
},
|
|
227
395
|
);
|
|
228
396
|
});
|
|
@@ -231,16 +399,23 @@ describe('resolveSecretEnvironment', () => {
|
|
|
231
399
|
await withFixture({ secrets: { SMOO_TOKEN: { command: emit('tok-from-provider') } } }, async (root) => {
|
|
232
400
|
expect(await resolveInBootstrap({ root, env: { SMOO_TOKEN: '' } })).toEqual({
|
|
233
401
|
resolved: { SMOO_TOKEN: 'tok-from-provider' },
|
|
402
|
+
deferred: [],
|
|
234
403
|
});
|
|
235
404
|
});
|
|
236
405
|
});
|
|
237
406
|
|
|
238
407
|
it('trims one terminal newline, LF or CRLF, preserving interior content', async () => {
|
|
239
408
|
await withFixture({ secrets: { SMOO_TOKEN: { command: emit('tok-from-provider\n') } } }, async (root) => {
|
|
240
|
-
expect(await resolveInBootstrap({ root, env: {} })).toEqual({
|
|
409
|
+
expect(await resolveInBootstrap({ root, env: {} })).toEqual({
|
|
410
|
+
resolved: { SMOO_TOKEN: 'tok-from-provider' },
|
|
411
|
+
deferred: [],
|
|
412
|
+
});
|
|
241
413
|
});
|
|
242
414
|
await withFixture({ secrets: { SMOO_TOKEN: { command: emit('tok\r\nmid\r\n') } } }, async (root) => {
|
|
243
|
-
expect(await resolveInBootstrap({ root, env: {} })).toEqual({
|
|
415
|
+
expect(await resolveInBootstrap({ root, env: {} })).toEqual({
|
|
416
|
+
resolved: { SMOO_TOKEN: 'tok\r\nmid' },
|
|
417
|
+
deferred: [],
|
|
418
|
+
});
|
|
244
419
|
});
|
|
245
420
|
});
|
|
246
421
|
|
|
@@ -265,16 +440,89 @@ describe('resolveSecretEnvironment', () => {
|
|
|
265
440
|
async (root) => {
|
|
266
441
|
// A cache is an optimization: its provider being down installs
|
|
267
442
|
// dependencies anyway. Every other declared secret keeps its refusal.
|
|
443
|
+
// The cache token is not excluded by name any more — it is in group
|
|
444
|
+
// `nx-cache`, which shell entry defers, so its provider is never
|
|
445
|
+
// reached to be unreachable.
|
|
268
446
|
expect(await resolveInBootstrap({ root, env: {} })).toEqual({
|
|
269
447
|
resolved: { SMOO_TOKEN: 'tok-from-provider' },
|
|
448
|
+
deferred: [
|
|
449
|
+
{ name: 'NX_REMOTE_CACHE_TOKEN', group: 'nx-cache', guidance: expect.stringContaining('nx-cache') },
|
|
450
|
+
],
|
|
270
451
|
});
|
|
271
452
|
const withoutCacheDeclaration = await resolveInBootstrap({ root, env: { CI: 'true' } });
|
|
272
453
|
expect(withoutCacheDeclaration.error).toContain('SMOO_TOKEN');
|
|
454
|
+
// CI refuses what shell entry needs, and the cache token is not that:
|
|
455
|
+
// nothing shell entry runs reads `smoo.remoteCache`.
|
|
273
456
|
expect(withoutCacheDeclaration.error).not.toContain('NX_REMOTE_CACHE_TOKEN');
|
|
274
457
|
},
|
|
275
458
|
);
|
|
276
459
|
});
|
|
277
460
|
|
|
461
|
+
it('the declared cache token is deferred at shell entry and its provider never runs', async () => {
|
|
462
|
+
await withProviderLedger(async (ledger) => {
|
|
463
|
+
await withFixture(
|
|
464
|
+
{
|
|
465
|
+
secrets: { NX_REMOTE_CACHE_TOKEN: { command: emitAndRecord('cache-tok', ledger.path) } },
|
|
466
|
+
remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
|
|
467
|
+
},
|
|
468
|
+
async (root) => {
|
|
469
|
+
// Named in the result rather than silently omitted, which is what
|
|
470
|
+
// the by-name exclusion this replaces used to do.
|
|
471
|
+
expect(await resolveInBootstrap({ root, env: {} })).toEqual({
|
|
472
|
+
resolved: {},
|
|
473
|
+
deferred: [
|
|
474
|
+
{
|
|
475
|
+
name: 'NX_REMOTE_CACHE_TOKEN',
|
|
476
|
+
group: 'nx-cache',
|
|
477
|
+
guidance: expect.stringContaining('smoo secrets run nx-cache'),
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
});
|
|
481
|
+
expect(ledger.invocations()).toBe(0);
|
|
482
|
+
|
|
483
|
+
// And a cowshed workspace does not refuse it either: the gateway
|
|
484
|
+
// rule is about registry credentials, and this is not one.
|
|
485
|
+
expect(await resolveInBootstrap({ root, env: { COWSHED_WORKSPACE_TOKEN: 'gateway-session' } })).toEqual({
|
|
486
|
+
resolved: {},
|
|
487
|
+
deferred: [
|
|
488
|
+
{
|
|
489
|
+
name: 'NX_REMOTE_CACHE_TOKEN',
|
|
490
|
+
group: 'nx-cache',
|
|
491
|
+
guidance: expect.stringContaining('smoo secrets run nx-cache'),
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
});
|
|
495
|
+
expect(ledger.invocations()).toBe(0);
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('a deliberate nx-cache run resolves the cache token nothing else resolves', async () => {
|
|
502
|
+
await withProviderLedger(async (ledger) => {
|
|
503
|
+
await withFixture(
|
|
504
|
+
{
|
|
505
|
+
secrets: {
|
|
506
|
+
NX_REMOTE_CACHE_TOKEN: { command: emitAndRecord('cache-tok', ledger.path) },
|
|
507
|
+
SMOO_TOKEN: { command: emitAndRecord('shell-tok', ledger.path) },
|
|
508
|
+
},
|
|
509
|
+
remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
|
|
510
|
+
},
|
|
511
|
+
async (root) => {
|
|
512
|
+
const outcome = await resolveInBootstrap({ root, env: {}, group: 'nx-cache' });
|
|
513
|
+
|
|
514
|
+
expect(outcome.resolved).toEqual({ NX_REMOTE_CACHE_TOKEN: 'cache-tok' });
|
|
515
|
+
expect(outcome.deferred).toEqual([
|
|
516
|
+
{ name: 'SMOO_TOKEN', group: 'shell', guidance: expect.stringContaining('smoo secrets run shell') },
|
|
517
|
+
]);
|
|
518
|
+
// One group, one provider invocation: the shell secret's command
|
|
519
|
+
// did not run for a cache-token request.
|
|
520
|
+
expect(ledger.invocations()).toBe(1);
|
|
521
|
+
},
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
278
526
|
it('a present variable is not reported while another refuses in CI', async () => {
|
|
279
527
|
await withFixture(
|
|
280
528
|
{
|
|
@@ -291,7 +539,7 @@ describe('resolveSecretEnvironment', () => {
|
|
|
291
539
|
);
|
|
292
540
|
});
|
|
293
541
|
|
|
294
|
-
it('cowshed
|
|
542
|
+
it('a cowshed workspace refuses a registry credential rather than deferring it', async () => {
|
|
295
543
|
await withFixture(
|
|
296
544
|
{
|
|
297
545
|
npmrc: '//npm.example.net/:_authToken=${SMOO_NPM_TOKEN}\n',
|
|
@@ -315,11 +563,169 @@ describe('resolveSecretEnvironment', () => {
|
|
|
315
563
|
async (root) => {
|
|
316
564
|
expect(await resolveInBootstrap({ root, env: { COWSHED_WORKSPACE_TOKEN: 'gateway-session' } })).toEqual({
|
|
317
565
|
resolved: { SMOO_GENERIC: 'generic-tok' },
|
|
566
|
+
deferred: [],
|
|
318
567
|
});
|
|
319
568
|
},
|
|
320
569
|
);
|
|
321
570
|
});
|
|
322
571
|
|
|
572
|
+
// A registry credential is the second instance of the rule the resolver's
|
|
573
|
+
// header states: shell entry never runs a provider command for a credential
|
|
574
|
+
// that only a network operation needs. The reference is what makes it one.
|
|
575
|
+
const REGISTRY_NPMRC = '@acme:registry=https://npm.example.net\n//npm.example.net/:_authToken=${SMOO_NPM_TOKEN}\n';
|
|
576
|
+
|
|
577
|
+
it('shell entry defers a .npmrc-referenced variable and never runs its provider', async () => {
|
|
578
|
+
await withProviderLedger(async (ledger) => {
|
|
579
|
+
await withFixture(
|
|
580
|
+
{
|
|
581
|
+
npmrc: REGISTRY_NPMRC,
|
|
582
|
+
secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('SHELL-ENTRY-RESOLVED-VALUE', ledger.path) } },
|
|
583
|
+
},
|
|
584
|
+
async (root) => {
|
|
585
|
+
const outcome = await resolveInBootstrap({ root, env: {}, group: 'shell' });
|
|
586
|
+
|
|
587
|
+
expect(outcome.resolved).toEqual({});
|
|
588
|
+
expect(outcome.error).toBeUndefined();
|
|
589
|
+
expect(ledger.invocations()).toBe(0);
|
|
590
|
+
// A failing install prints `- <name>: <guidance>`, so both halves
|
|
591
|
+
// have to be here: which variable, and the one command that
|
|
592
|
+
// supplies it.
|
|
593
|
+
expect(outcome.deferred).toEqual([
|
|
594
|
+
{
|
|
595
|
+
name: 'SMOO_NPM_TOKEN',
|
|
596
|
+
group: 'registry',
|
|
597
|
+
guidance: expect.stringContaining('smoo secrets run registry <command>'),
|
|
598
|
+
},
|
|
599
|
+
]);
|
|
600
|
+
},
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('a declared variable .npmrc does not reference still resolves at shell entry', async () => {
|
|
606
|
+
await withProviderLedger(async (ledger) => {
|
|
607
|
+
await withFixture(
|
|
608
|
+
{ npmrc: REGISTRY_NPMRC, secrets: { SMOO_GENERIC: { command: emitAndRecord('generic-tok', ledger.path) } } },
|
|
609
|
+
async (root) => {
|
|
610
|
+
// The same .npmrc, a variable it does not name: the deferral keys on
|
|
611
|
+
// the reference, not on a registry existing somewhere in the repo.
|
|
612
|
+
expect(await resolveInBootstrap({ root, env: {}, group: 'shell' })).toEqual({
|
|
613
|
+
resolved: { SMOO_GENERIC: 'generic-tok' },
|
|
614
|
+
deferred: [],
|
|
615
|
+
});
|
|
616
|
+
expect(ledger.invocations()).toBe(1);
|
|
617
|
+
},
|
|
618
|
+
);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('a deliberate registry run resolves the variable shell entry deferred, and nothing else', async () => {
|
|
623
|
+
await withProviderLedger(async (ledger) => {
|
|
624
|
+
await withFixture(
|
|
625
|
+
{
|
|
626
|
+
npmrc: REGISTRY_NPMRC,
|
|
627
|
+
secrets: {
|
|
628
|
+
SMOO_NPM_TOKEN: { command: emitAndRecord('registry-tok', ledger.path) },
|
|
629
|
+
SMOO_TOKEN: { command: emitAndRecord('shell-tok', ledger.path) },
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
async (root) => {
|
|
633
|
+
const outcome = await resolveInBootstrap({ root, env: {}, group: 'registry' });
|
|
634
|
+
|
|
635
|
+
expect(outcome.resolved).toEqual({ SMOO_NPM_TOKEN: 'registry-tok' });
|
|
636
|
+
// The shell secret is deferred, not resolved: one group, one
|
|
637
|
+
// provider invocation. A wrapper that resolved every declared
|
|
638
|
+
// secret would have run two.
|
|
639
|
+
expect(outcome.deferred).toEqual([
|
|
640
|
+
{ name: 'SMOO_TOKEN', group: 'shell', guidance: expect.stringContaining('smoo secrets run shell') },
|
|
641
|
+
]);
|
|
642
|
+
expect(ledger.invocations()).toBe(1);
|
|
643
|
+
},
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('a declared group is what shell entry resolves, derivation notwithstanding', async () => {
|
|
649
|
+
await withProviderLedger(async (ledger) => {
|
|
650
|
+
await withFixture(
|
|
651
|
+
{
|
|
652
|
+
npmrc: REGISTRY_NPMRC,
|
|
653
|
+
secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('registry-tok', ledger.path), group: 'shell' } },
|
|
654
|
+
},
|
|
655
|
+
async (root) => {
|
|
656
|
+
// The repository says this credential belongs to the shell, and
|
|
657
|
+
// that is the whole of the rule: the override is not a hint.
|
|
658
|
+
expect(await resolveInBootstrap({ root, env: {}, group: 'shell' })).toEqual({
|
|
659
|
+
resolved: { SMOO_NPM_TOKEN: 'registry-tok' },
|
|
660
|
+
deferred: [],
|
|
661
|
+
});
|
|
662
|
+
expect(ledger.invocations()).toBe(1);
|
|
663
|
+
},
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it('an exported registry credential wins over the deferral', async () => {
|
|
669
|
+
await withProviderLedger(async (ledger) => {
|
|
670
|
+
await withFixture(
|
|
671
|
+
{ npmrc: REGISTRY_NPMRC, secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('x', ledger.path) } } },
|
|
672
|
+
async (root) => {
|
|
673
|
+
// Nothing to defer and nothing to run: the developer who exported it
|
|
674
|
+
// for this terminal already answered the question.
|
|
675
|
+
expect(await resolveInBootstrap({ root, env: { SMOO_NPM_TOKEN: 'exported' }, group: 'shell' })).toEqual({
|
|
676
|
+
resolved: {},
|
|
677
|
+
deferred: [],
|
|
678
|
+
});
|
|
679
|
+
expect(ledger.invocations()).toBe(0);
|
|
680
|
+
},
|
|
681
|
+
);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('CI refuses a missing registry credential instead of deferring it', async () => {
|
|
686
|
+
await withProviderLedger(async (ledger) => {
|
|
687
|
+
await withFixture(
|
|
688
|
+
{ npmrc: REGISTRY_NPMRC, secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('x', ledger.path) } } },
|
|
689
|
+
async (root) => {
|
|
690
|
+
// CI outranks the registry rule and is unchanged by it: a job that
|
|
691
|
+
// needs the credential says so at setup rather than discovering it
|
|
692
|
+
// at the first fetch, and there is no wrapper to run there.
|
|
693
|
+
const outcome = await resolveInBootstrap({ root, env: { CI: 'true' }, group: 'shell' });
|
|
694
|
+
|
|
695
|
+
expect(outcome.error).toContain('SMOO_NPM_TOKEN');
|
|
696
|
+
expect(outcome.error).toContain('inject');
|
|
697
|
+
expect(outcome.error).not.toContain('smoo secrets run');
|
|
698
|
+
expect(ledger.invocations()).toBe(0);
|
|
699
|
+
},
|
|
700
|
+
);
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('a cowshed workspace refuses a registry credential for a network operation too', async () => {
|
|
705
|
+
await withProviderLedger(async (ledger) => {
|
|
706
|
+
await withFixture(
|
|
707
|
+
{
|
|
708
|
+
npmrc: REGISTRY_NPMRC,
|
|
709
|
+
secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('LOCAL-RESOLVED-VALUE', ledger.path) } },
|
|
710
|
+
},
|
|
711
|
+
async (root) => {
|
|
712
|
+
// The gateway is the enrollment path there, so the wrapper does not
|
|
713
|
+
// become a way around it.
|
|
714
|
+
const outcome = await resolveInBootstrap({
|
|
715
|
+
root,
|
|
716
|
+
env: { COWSHED_WORKSPACE_TOKEN: 'gateway-session' },
|
|
717
|
+
group: 'registry',
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
expect(outcome.error).toContain('SMOO_NPM_TOKEN');
|
|
721
|
+
expect(outcome.error).toContain('cowshed gateway');
|
|
722
|
+
expect(outcome.error).not.toContain('LOCAL-RESOLVED-VALUE');
|
|
723
|
+
expect(ledger.invocations()).toBe(0);
|
|
724
|
+
},
|
|
725
|
+
);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
323
729
|
it('provider failures name the variable, the exit code, and nothing secret-shaped', async () => {
|
|
324
730
|
await withFixture(
|
|
325
731
|
{
|