@smoothbricks/cli 0.11.19 → 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 +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 -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/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/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
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value model behind `smoo secrets`: one row per secret name, reconciled
|
|
3
|
+
* from every source that has an opinion about it, plus the machine-readable
|
|
4
|
+
* document `smoo secrets status --json` emits.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is pure and depends on nothing but typia, which is what lets
|
|
7
|
+
* `@smoothbricks/cli/secrets` be imported for the types alone — a consumer
|
|
8
|
+
* driving a UI or a repository's own operations CLI gets the document type and
|
|
9
|
+
* a validator without pulling in `gh`, the filesystem readers, or commander.
|
|
10
|
+
* The readers that produce these facts live in ./index.ts; the operator
|
|
11
|
+
* commands that print them live in ./commands.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import typia from 'typia';
|
|
15
|
+
import type { SecretStageMap, SecretStageScope } from '../wrangler/stage-secrets.js';
|
|
16
|
+
|
|
17
|
+
export type { SecretStageScope };
|
|
18
|
+
|
|
19
|
+
/** Where an env name is described, which repository secret carries it, and whether that exists. */
|
|
20
|
+
export interface SecretRow {
|
|
21
|
+
name: string;
|
|
22
|
+
/** The repository secret this env name reads from, by convention or declaration. */
|
|
23
|
+
repositorySecret: string;
|
|
24
|
+
/** Wrangler projects whose `.dev.vars.example` declares it. */
|
|
25
|
+
declaredByWorkers: string[];
|
|
26
|
+
/** True when a managed workflow renders `secrets.<name>` into a job. */
|
|
27
|
+
suppliedByWorkflow: boolean;
|
|
28
|
+
/** True when `smoo.secrets` can fetch it for a developer shell; equivalently, `localGroup` is set. */
|
|
29
|
+
fetchableLocally: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* How `smoo.secrets` resolves it locally: the group that resolves it, and
|
|
32
|
+
* the group this repository's own declarations derive. They differ exactly
|
|
33
|
+
* when the entry overrides the derivation, which is the one thing a reader
|
|
34
|
+
* must never have to infer. Absent when nothing fetches the name locally.
|
|
35
|
+
*/
|
|
36
|
+
localGroup?: { resolves: string; derived: string };
|
|
37
|
+
/** True when the repository holds a secret of this name. */
|
|
38
|
+
onRepository: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* GitHub Environments holding their own value for this name. A job bound to
|
|
41
|
+
* an environment reads that value in preference to the repository's, which
|
|
42
|
+
* is how one name carries test credentials on a preview stage and live ones
|
|
43
|
+
* in production.
|
|
44
|
+
*/
|
|
45
|
+
heldByEnvironment: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One `smoo.secrets` entry as the reconciliation needs it: the name, the
|
|
50
|
+
* group that resolves it, and the group this repository's declarations
|
|
51
|
+
* derive. ../secrets/resolver.ts produces these from the same file shell
|
|
52
|
+
* entry routes with.
|
|
53
|
+
*/
|
|
54
|
+
export interface LocalSecret {
|
|
55
|
+
name: string;
|
|
56
|
+
group: string;
|
|
57
|
+
derivedGroup: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SecretSources {
|
|
61
|
+
/** Worker label -> env names it declares. */
|
|
62
|
+
workerSecrets: Record<string, readonly string[]>;
|
|
63
|
+
/** Env names a managed workflow passes into a job. */
|
|
64
|
+
workflowSecrets: readonly string[];
|
|
65
|
+
/** Env name -> repository secret, by convention with declared exceptions. */
|
|
66
|
+
secretNames: Readonly<Record<string, string>>;
|
|
67
|
+
/** Every `smoo.secrets` entry with the group that resolves it. */
|
|
68
|
+
localSecrets: readonly LocalSecret[];
|
|
69
|
+
/** Repository secret names GitHub currently holds. */
|
|
70
|
+
repositorySecrets: readonly string[];
|
|
71
|
+
/** Environment name -> secret names that environment holds. */
|
|
72
|
+
environmentSecrets?: Readonly<Record<string, readonly string[]>>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One row per name known to any source, sorted, so a reader sees the whole
|
|
77
|
+
* picture rather than one source's view of it.
|
|
78
|
+
*/
|
|
79
|
+
export function reconcileSecrets(sources: SecretSources): SecretRow[] {
|
|
80
|
+
const declaredByAnyWorker: string[] = Object.values(sources.workerSecrets).flatMap((names) => [...names]);
|
|
81
|
+
const localSecrets = new Map(sources.localSecrets.map((secret) => [secret.name, secret]));
|
|
82
|
+
const names = new Set<string>([...declaredByAnyWorker, ...sources.workflowSecrets, ...localSecrets.keys()]);
|
|
83
|
+
// A repository secret that already carries a known env name is that name's
|
|
84
|
+
// row, not a row of its own: listing ACME_GITHUB_CLIENT_SECRET beside
|
|
85
|
+
// GITHUB_CLIENT_SECRET would report one value as two secrets, one of them
|
|
86
|
+
// permanently "declared by nothing".
|
|
87
|
+
const carriesKnownEnvName = new Set(
|
|
88
|
+
names.size > 0 ? [...names].map((name) => sources.secretNames[name] ?? name) : [],
|
|
89
|
+
);
|
|
90
|
+
// A value only an environment holds is still a value: leaving it out of the
|
|
91
|
+
// rows is how an operator ends up hunting for a secret that is already set.
|
|
92
|
+
const addUndeclared = (secret: string): void => {
|
|
93
|
+
if (!carriesKnownEnvName.has(secret)) names.add(secret);
|
|
94
|
+
};
|
|
95
|
+
for (const secret of sources.repositorySecrets) addUndeclared(secret);
|
|
96
|
+
for (const held of Object.values(sources.environmentSecrets ?? {})) {
|
|
97
|
+
for (const secret of held) addUndeclared(secret);
|
|
98
|
+
}
|
|
99
|
+
return [...names]
|
|
100
|
+
.sort((left, right) => left.localeCompare(right))
|
|
101
|
+
.map((name) => {
|
|
102
|
+
const local = localSecrets.get(name);
|
|
103
|
+
return {
|
|
104
|
+
name,
|
|
105
|
+
repositorySecret: sources.secretNames[name] ?? name,
|
|
106
|
+
declaredByWorkers: Object.entries(sources.workerSecrets)
|
|
107
|
+
.filter(([, declared]) => declared.includes(name))
|
|
108
|
+
.map(([label]) => label)
|
|
109
|
+
.sort((left, right) => left.localeCompare(right)),
|
|
110
|
+
suppliedByWorkflow: sources.workflowSecrets.includes(name),
|
|
111
|
+
fetchableLocally: local !== undefined,
|
|
112
|
+
...(local === undefined ? {} : { localGroup: { resolves: local.group, derived: local.derivedGroup } }),
|
|
113
|
+
onRepository: sources.repositorySecrets.includes(sources.secretNames[name] ?? name),
|
|
114
|
+
heldByEnvironment: Object.entries(sources.environmentSecrets ?? {})
|
|
115
|
+
.filter(([, held]) => held.includes(sources.secretNames[name] ?? name))
|
|
116
|
+
.map(([environment]) => environment)
|
|
117
|
+
.sort((left, right) => left.localeCompare(right)),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The rows a CI deploy cannot satisfy: a Worker declares the name, a workflow
|
|
124
|
+
* promises to pass it, and the repository has no value to pass. Reported
|
|
125
|
+
* separately from "declared but not wired into any workflow", because the
|
|
126
|
+
* remedies differ — set a secret, versus declare it in `smoo.github`.
|
|
127
|
+
*/
|
|
128
|
+
export function unsatisfiedSecrets(rows: readonly SecretRow[], boundEnvironments: readonly string[] = []): SecretRow[] {
|
|
129
|
+
return rows.filter((row) => {
|
|
130
|
+
if (!row.suppliedByWorkflow) return false;
|
|
131
|
+
if (row.onRepository) return false;
|
|
132
|
+
// With no environment bound, the repository is the only scope a job reads.
|
|
133
|
+
// With environments bound, each one can carry the value instead - so the
|
|
134
|
+
// name is satisfied only when every bound environment holds it.
|
|
135
|
+
if (boundEnvironments.length === 0) return true;
|
|
136
|
+
return !boundEnvironments.every((environment) => row.heldByEnvironment.includes(environment));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Bound environments that lack their own value and cannot fall back to the repository. */
|
|
141
|
+
export function environmentsMissing(row: SecretRow, boundEnvironments: readonly string[]): string[] {
|
|
142
|
+
if (row.onRepository) return [];
|
|
143
|
+
return boundEnvironments.filter((environment) => !row.heldByEnvironment.includes(environment));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
|
|
147
|
+
export function unwiredSecrets(rows: readonly SecretRow[]): SecretRow[] {
|
|
148
|
+
return rows.filter((row) => row.declaredByWorkers.length > 0 && !row.suppliedByWorkflow);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every stage a scope can name, in the order the document lists them. A
|
|
153
|
+
* declared secret absent from `smoo.wrangler.secretStages` belongs to all of
|
|
154
|
+
* them, which is the rule this constant spells out exactly once.
|
|
155
|
+
*/
|
|
156
|
+
const EVERY_STAGE: readonly SecretStageScope[] = ['preview', 'production', 'staging'];
|
|
157
|
+
|
|
158
|
+
/** The repository the status was read from, and how that repository was chosen. */
|
|
159
|
+
export interface SecretsStatusRepository {
|
|
160
|
+
/** `owner/name`, the form `gh --repo` takes. */
|
|
161
|
+
repo: string;
|
|
162
|
+
/** Why this repository — the checkout fact that decided it, verbatim from the resolver. */
|
|
163
|
+
source: string;
|
|
164
|
+
/** How many secrets it holds at repository scope. */
|
|
165
|
+
secretCount: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One GitHub Environment the status looked at. Discriminated on `readable`
|
|
170
|
+
* because an environment `gh` refused to list is not an empty one: reporting
|
|
171
|
+
* it as empty would call values missing that may well be set, and the reason
|
|
172
|
+
* is the only thing that lets an operator tell the two apart.
|
|
173
|
+
*/
|
|
174
|
+
export type SecretsStatusEnvironment =
|
|
175
|
+
| {
|
|
176
|
+
name: string;
|
|
177
|
+
/** True when a managed workflow binds a job to it, so a job reads it before the repository. */
|
|
178
|
+
bound: boolean;
|
|
179
|
+
readable: true;
|
|
180
|
+
secretCount: number;
|
|
181
|
+
}
|
|
182
|
+
| {
|
|
183
|
+
name: string;
|
|
184
|
+
bound: boolean;
|
|
185
|
+
readable: false;
|
|
186
|
+
/** What `gh` said, so nothing below claims what this environment holds. */
|
|
187
|
+
reason: string;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** A reconciled row, plus which deployment stages require the name to have a value. */
|
|
191
|
+
export interface SecretsStatusRow extends SecretRow {
|
|
192
|
+
/**
|
|
193
|
+
* Stages that refuse to deploy without a value, from `smoo.wrangler.secretStages`
|
|
194
|
+
* unioned over every wrangler project declaring the name: absent from the map
|
|
195
|
+
* means every stage, mapped to `[]` means none. A name no wrangler project
|
|
196
|
+
* declares is `[]` too — `declaredByWorkers` tells those two apart.
|
|
197
|
+
*/
|
|
198
|
+
requiredByStages: SecretStageScope[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A name a workflow passes that no scope the job reads can supply: this is what fails a deploy. */
|
|
202
|
+
export interface UnsatisfiedSecret {
|
|
203
|
+
name: string;
|
|
204
|
+
repositorySecret: string;
|
|
205
|
+
/**
|
|
206
|
+
* Bound environments holding no value for it. Empty means no environment is
|
|
207
|
+
* bound and the repository itself is the scope to set it in.
|
|
208
|
+
*/
|
|
209
|
+
missingIn: string[];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Everything `smoo secrets status` learned, in one document.
|
|
214
|
+
*
|
|
215
|
+
* `unsatisfied` is the exit code's reason and not a convenience: the rule that
|
|
216
|
+
* decides whether a value exists in a scope a bound job reads is smoo's, and a
|
|
217
|
+
* consumer re-deriving it from `secrets` would be maintaining a second copy of
|
|
218
|
+
* it.
|
|
219
|
+
*/
|
|
220
|
+
export interface SecretsStatusDocument {
|
|
221
|
+
/** Document shape, so a consumer refuses a document it does not understand instead of misreading it. */
|
|
222
|
+
version: 1;
|
|
223
|
+
repository: SecretsStatusRepository;
|
|
224
|
+
/** Every environment looked at: those the workflows bind, then any asked for with `--env`. */
|
|
225
|
+
environments: SecretsStatusEnvironment[];
|
|
226
|
+
/** One row per name any source knows, sorted by name. */
|
|
227
|
+
secrets: SecretsStatusRow[];
|
|
228
|
+
/** Empty exactly when the command exits 0. */
|
|
229
|
+
unsatisfied: UnsatisfiedSecret[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The facts a document is projected from, all gathered before anything is printed. */
|
|
233
|
+
export interface SecretsStatusFacts {
|
|
234
|
+
repository: SecretsStatusRepository;
|
|
235
|
+
environments: readonly SecretsStatusEnvironment[];
|
|
236
|
+
rows: readonly SecretRow[];
|
|
237
|
+
/** Worker label -> its `smoo.wrangler.secretStages` declaration; a worker with no block is absent. */
|
|
238
|
+
stageScopes: Readonly<Record<string, SecretStageMap>>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The stages that require a value for one name: the union over every worker
|
|
243
|
+
* declaring it, since a name two projects share must exist wherever either of
|
|
244
|
+
* them deploys.
|
|
245
|
+
*/
|
|
246
|
+
function requiredByStages(
|
|
247
|
+
name: string,
|
|
248
|
+
workers: readonly string[],
|
|
249
|
+
stageScopes: Readonly<Record<string, SecretStageMap>>,
|
|
250
|
+
): SecretStageScope[] {
|
|
251
|
+
const required = new Set<SecretStageScope>();
|
|
252
|
+
for (const worker of workers) {
|
|
253
|
+
const scoped = stageScopes[worker]?.[name];
|
|
254
|
+
// No scope declared for a name the worker declares is "every stage": the
|
|
255
|
+
// map is an exception list, not a registry.
|
|
256
|
+
for (const stage of scoped ?? EVERY_STAGE) required.add(stage);
|
|
257
|
+
}
|
|
258
|
+
return EVERY_STAGE.filter((stage) => required.has(stage));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The document, from facts. Pure: every read happened before this was called. */
|
|
262
|
+
export function projectSecretsStatus(facts: SecretsStatusFacts): SecretsStatusDocument {
|
|
263
|
+
const secrets: SecretsStatusRow[] = facts.rows.map((row) => ({
|
|
264
|
+
...row,
|
|
265
|
+
requiredByStages: requiredByStages(row.name, row.declaredByWorkers, facts.stageScopes),
|
|
266
|
+
}));
|
|
267
|
+
// Only an environment a workflow binds *and* `gh` could list is a scope a job
|
|
268
|
+
// is known to read: an unreadable one is unknown, not empty.
|
|
269
|
+
const bound = facts.environments
|
|
270
|
+
.filter((environment) => environment.bound && environment.readable)
|
|
271
|
+
.map((environment) => environment.name);
|
|
272
|
+
return {
|
|
273
|
+
version: 1,
|
|
274
|
+
repository: facts.repository,
|
|
275
|
+
environments: [...facts.environments],
|
|
276
|
+
secrets,
|
|
277
|
+
unsatisfied: unsatisfiedSecrets(secrets, bound).map((row) => ({
|
|
278
|
+
name: row.name,
|
|
279
|
+
repositorySecret: row.repositorySecret,
|
|
280
|
+
missingIn: environmentsMissing(row, bound),
|
|
281
|
+
})),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The consumer's boundary: text off a `smoo secrets status --json` run into a
|
|
287
|
+
* validated document, or typia's own account of every field that disagreed.
|
|
288
|
+
*/
|
|
289
|
+
export const parseSecretsStatusDocument = typia.json.createValidateParse<SecretsStatusDocument>();
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The emitter's boundary: the projection is checked against its declared type
|
|
293
|
+
* before it becomes text. A shape bug then fails here, naming the field, rather
|
|
294
|
+
* than reaching a consumer as a document its validator rejects with no idea who
|
|
295
|
+
* produced it.
|
|
296
|
+
*/
|
|
297
|
+
export const stringifySecretsStatusDocument = typia.json.createAssertStringify<SecretsStatusDocument>();
|
|
@@ -1120,6 +1120,99 @@ describe('writeTemporaryConfig', () => {
|
|
|
1120
1120
|
});
|
|
1121
1121
|
});
|
|
1122
1122
|
|
|
1123
|
+
// The same project as FIXTURE/ROUTED_FIXTURE, in the format Cloudflare now recommends. The
|
|
1124
|
+
// deploy has to reach the same worker, the same routes and the same derived stage from it.
|
|
1125
|
+
const JSONC_FIXTURE = `{
|
|
1126
|
+
// A worker whose stages are derived, written as JSONC.
|
|
1127
|
+
"env": {
|
|
1128
|
+
"staging": {
|
|
1129
|
+
"name": "fixture-worker-staging",
|
|
1130
|
+
"workers_dev": false,
|
|
1131
|
+
"routes": [{ "pattern": "*.staging.example.test/*", "zone_name": "example.test" }],
|
|
1132
|
+
"vars": { "ENVIRONMENT": "staging" },
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
}
|
|
1136
|
+
`;
|
|
1137
|
+
|
|
1138
|
+
describe('deployStage with a JSONC source config', () => {
|
|
1139
|
+
it('deploys staging from wrangler.jsonc itself, with --env and no temporary file', async () => {
|
|
1140
|
+
const root = await fixtureRoot(JSONC_FIXTURE, 'wrangler.jsonc');
|
|
1141
|
+
const runner = new FakeRunner();
|
|
1142
|
+
const cloudflare = new FakeCloudflare();
|
|
1143
|
+
cloudflare.scripts = [{ id: 'fixture-worker-staging' }];
|
|
1144
|
+
cloudflare.zones = [{ id: 'zone-1', name: 'example.test' }];
|
|
1145
|
+
|
|
1146
|
+
const result = await deployStage(root, { stage: 'staging' }, dependencies(runner, cloudflare));
|
|
1147
|
+
|
|
1148
|
+
expect(result).toMatchObject({ workerName: 'fixture-worker-staging', action: 'deployed' });
|
|
1149
|
+
const deploy = requiredTestValue(
|
|
1150
|
+
runner.calls.find((call) => call.args[0] === 'deploy'),
|
|
1151
|
+
'deploy call',
|
|
1152
|
+
);
|
|
1153
|
+
expect(deploy.args.slice(0, 5)).toEqual(['deploy', '--config', join(root, 'wrangler.jsonc'), '--env', 'staging']);
|
|
1154
|
+
expect(cloudflare.mutations).toEqual([
|
|
1155
|
+
'create-dns:zone-1:*.staging.example.test:staging.example.test',
|
|
1156
|
+
'create-route:zone-1:*.staging.example.test/*:fixture-worker-staging',
|
|
1157
|
+
]);
|
|
1158
|
+
expect((await readdir(root)).filter((name) => name.startsWith('.wrangler.smoo-'))).toEqual([]);
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it('derives a pull-request stage into a temporary JSON config beside the source', async () => {
|
|
1162
|
+
const root = await fixtureRoot(JSONC_FIXTURE, 'wrangler.jsonc');
|
|
1163
|
+
const runner = new FakeRunner();
|
|
1164
|
+
const cloudflare = new FakeCloudflare();
|
|
1165
|
+
cloudflare.zones = [{ id: 'zone-1', name: 'example.test' }];
|
|
1166
|
+
let derivedConfig: Record<string, unknown> | undefined;
|
|
1167
|
+
let derivedPath: string | undefined;
|
|
1168
|
+
runner.onCall = async (args) => {
|
|
1169
|
+
if (args[0] !== 'deploy') return;
|
|
1170
|
+
derivedPath = args[args.indexOf('--config') + 1];
|
|
1171
|
+
derivedConfig = JSON.parse(await readFile(derivedPath ?? '', 'utf8'));
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, cloudflare));
|
|
1175
|
+
|
|
1176
|
+
expect(result).toMatchObject({ workerName: 'fixture-worker-pr123', action: 'deployed' });
|
|
1177
|
+
// Wrangler picks its parser by extension, so the derived config is JSON whatever the source was.
|
|
1178
|
+
expect(derivedPath?.startsWith(join(root, '.wrangler.smoo-'))).toBe(true);
|
|
1179
|
+
expect(derivedPath?.endsWith('.json')).toBe(true);
|
|
1180
|
+
expect(derivedConfig?.env).toEqual({
|
|
1181
|
+
staging: {
|
|
1182
|
+
name: 'fixture-worker-staging',
|
|
1183
|
+
workers_dev: false,
|
|
1184
|
+
routes: [{ pattern: '*.staging.example.test/*', zone_name: 'example.test' }],
|
|
1185
|
+
vars: { ENVIRONMENT: 'staging' },
|
|
1186
|
+
},
|
|
1187
|
+
pr123: {
|
|
1188
|
+
name: 'fixture-worker-pr123',
|
|
1189
|
+
workers_dev: false,
|
|
1190
|
+
routes: [{ pattern: '*.pr123.example.test/*', zone_name: 'example.test' }],
|
|
1191
|
+
vars: { ENVIRONMENT: 'pr123' },
|
|
1192
|
+
},
|
|
1193
|
+
});
|
|
1194
|
+
const deploy = requiredTestValue(
|
|
1195
|
+
runner.calls.find((call) => call.args[0] === 'deploy'),
|
|
1196
|
+
'deploy call',
|
|
1197
|
+
);
|
|
1198
|
+
expect(deploy.args.slice(3, 5)).toEqual(['--env', 'pr123']);
|
|
1199
|
+
// The committed source is never rewritten, and the derived copy does not outlive the deploy.
|
|
1200
|
+
expect(await readFile(join(root, 'wrangler.jsonc'), 'utf8')).toBe(JSONC_FIXTURE);
|
|
1201
|
+
expect((await readdir(root)).filter((name) => name.startsWith('.wrangler.smoo-'))).toEqual([]);
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it('refuses a project carrying two source configs before it reads either', async () => {
|
|
1205
|
+
const root = await fixtureRoot(JSONC_FIXTURE, 'wrangler.jsonc');
|
|
1206
|
+
await writeFile(join(root, 'wrangler.toml'), FIXTURE);
|
|
1207
|
+
const cloudflare = new FakeCloudflare();
|
|
1208
|
+
|
|
1209
|
+
await expect(deployStage(root, { stage: 'staging' }, dependencies(new FakeRunner(), cloudflare))).rejects.toThrow(
|
|
1210
|
+
`${root} declares more than one Wrangler configuration: wrangler.jsonc, wrangler.toml.`,
|
|
1211
|
+
);
|
|
1212
|
+
expect(cloudflare.mutations).toEqual([]);
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1123
1216
|
/** Runs `action` with the variables set on this process, restoring the previous values afterwards. */
|
|
1124
1217
|
async function withProcessEnv<T>(variables: Record<string, string>, action: () => Promise<T>): Promise<T> {
|
|
1125
1218
|
const previous = Object.fromEntries(Object.keys(variables).map((name) => [name, process.env[name]]));
|
|
@@ -1134,10 +1227,10 @@ async function withProcessEnv<T>(variables: Record<string, string>, action: () =
|
|
|
1134
1227
|
}
|
|
1135
1228
|
}
|
|
1136
1229
|
|
|
1137
|
-
async function fixtureRoot(
|
|
1230
|
+
async function fixtureRoot(config = FIXTURE, file = 'wrangler.toml'): Promise<string> {
|
|
1138
1231
|
const root = await mkdtemp(join(tmpdir(), 'smoo-wrangler-test-'));
|
|
1139
1232
|
roots.push(root);
|
|
1140
|
-
await writeFile(join(root,
|
|
1233
|
+
await writeFile(join(root, file), config);
|
|
1141
1234
|
return root;
|
|
1142
1235
|
}
|
|
1143
1236
|
|
|
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { parseJsonFileText } from '../lib/json.js';
|
|
5
5
|
import { mergeEnv, printCommandOutput } from '../lib/run.js';
|
|
6
6
|
import { type CloudflareClient, CloudflareRestClient, type D1DatabaseRecord } from './cloudflare.js';
|
|
7
|
-
import { type FlatWranglerConfig, parseFlatWranglerConfig
|
|
7
|
+
import { type FlatWranglerConfig, parseFlatWranglerConfig } from './flat-config.js';
|
|
8
8
|
import {
|
|
9
9
|
awaitLiveVersion,
|
|
10
10
|
awaitVersionEndpoint,
|
|
@@ -15,11 +15,12 @@ import {
|
|
|
15
15
|
type VersionEndpointWaitOptions,
|
|
16
16
|
writeCachedLiveVersion,
|
|
17
17
|
} from './live-version.js';
|
|
18
|
+
import { readWranglerSourceConfig } from './source-config.js';
|
|
18
19
|
import {
|
|
19
20
|
type ConfiguredStageResourcePlan,
|
|
20
21
|
type DeploymentStage,
|
|
22
|
+
derivePullRequestDocument,
|
|
21
23
|
derivePullRequestStageConfig,
|
|
22
|
-
derivePullRequestWranglerConfig,
|
|
23
24
|
hasExactStageSegment,
|
|
24
25
|
isPullRequestStage,
|
|
25
26
|
type LiveKvNamespace,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
planConfiguredStageResources,
|
|
29
30
|
planPullRequestBindings,
|
|
30
31
|
planPullRequestResources,
|
|
32
|
+
planStageResources,
|
|
31
33
|
pullRequestStage,
|
|
32
34
|
} from './stage.js';
|
|
33
35
|
import {
|
|
@@ -99,7 +101,7 @@ export interface DeployStageResult {
|
|
|
99
101
|
export interface DeployStageOptions {
|
|
100
102
|
/** `staging`, `production`, or `prN`. */
|
|
101
103
|
stage: string;
|
|
102
|
-
/** A build-generated flat wrangler.json to deploy instead of
|
|
104
|
+
/** A build-generated flat wrangler.json to deploy instead of the project's own config (see `prepareFlatConfig`). */
|
|
103
105
|
config?: string;
|
|
104
106
|
/**
|
|
105
107
|
* A URL, served by this worker, whose trimmed body is the running version tag. When given, the
|
|
@@ -137,7 +139,7 @@ export async function deployStage(
|
|
|
137
139
|
try {
|
|
138
140
|
const prepared = options.config
|
|
139
141
|
? await prepareFlatConfig(options.config, stage, accountId, gate, cloudflare)
|
|
140
|
-
: await
|
|
142
|
+
: await prepareSourceConfig(cwd, stage, accountId, gate, cloudflare);
|
|
141
143
|
temporaryConfigPath = prepared.temporaryConfigPath;
|
|
142
144
|
// The pull-request path already ran this before provisioning; the gate answers once per Worker.
|
|
143
145
|
// Everything below here writes to Cloudflare.
|
|
@@ -234,44 +236,45 @@ interface PreparedConfig {
|
|
|
234
236
|
deployConfigPath: string;
|
|
235
237
|
temporaryConfigPath?: string;
|
|
236
238
|
plan: ConfiguredStageResourcePlan;
|
|
237
|
-
/** D1 bindings whose migrations run before the deploy (flat configs only;
|
|
239
|
+
/** D1 bindings whose migrations run before the deploy (flat configs only; source configs keep migrations out of scope). */
|
|
238
240
|
d1MigrationBindings: string[];
|
|
239
|
-
/** `--env <stage>` for
|
|
241
|
+
/** `--env <stage>` for a source config with env blocks; a flat config is already resolved, so no flag. */
|
|
240
242
|
envFlag?: DeploymentStage;
|
|
241
243
|
}
|
|
242
244
|
|
|
243
|
-
|
|
245
|
+
/**
|
|
246
|
+
* The project's own `wrangler.jsonc`/`wrangler.json`/`wrangler.toml`, whichever it declares.
|
|
247
|
+
* Format stops mattering at `readWranglerSourceConfig`: everything below derives from the model.
|
|
248
|
+
*/
|
|
249
|
+
async function prepareSourceConfig(
|
|
244
250
|
cwd: string,
|
|
245
251
|
stage: DeploymentStage,
|
|
246
252
|
accountId: string,
|
|
247
253
|
gate: SecretGate,
|
|
248
254
|
cloudflare: CloudflareClient,
|
|
249
255
|
): Promise<PreparedConfig> {
|
|
250
|
-
const
|
|
251
|
-
const committedToml = await readFile(committedConfigPath, 'utf8');
|
|
256
|
+
const source = readWranglerSourceConfig(cwd);
|
|
252
257
|
if (!isPullRequestStage(stage)) {
|
|
253
258
|
return {
|
|
254
|
-
deployConfigPath:
|
|
255
|
-
plan: planConfiguredStageResources(
|
|
259
|
+
deployConfigPath: source.path,
|
|
260
|
+
plan: planConfiguredStageResources(source.document, stage),
|
|
256
261
|
d1MigrationBindings: [],
|
|
257
262
|
envFlag: stage,
|
|
258
263
|
};
|
|
259
264
|
}
|
|
260
265
|
const liveNamespaces = await cloudflare.listKvNamespaces();
|
|
261
|
-
const plan = planPullRequestResources(
|
|
266
|
+
const plan = planPullRequestResources(source.document, stage, liveNamespaces);
|
|
262
267
|
const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
});
|
|
269
|
-
const temporaryConfigPath = join(cwd, `.wrangler.smoo-${process.pid}-${randomUUID()}.toml`);
|
|
270
|
-
await writeTemporaryConfig(temporaryConfigPath, derivedToml);
|
|
268
|
+
const derived = derivePullRequestDocument(source.document, { stage, accountId, kvNamespaceIds, d1DatabaseIds });
|
|
269
|
+
// JSON whatever the source format was: wrangler picks its parser by extension, and a file
|
|
270
|
+
// written to be read once and deleted has no reader for the comments a TOML rewrite preserved.
|
|
271
|
+
// Beside the original, because its main/assets/migrations paths are relative to the file.
|
|
272
|
+
const temporaryConfigPath = join(dirname(source.path), `.wrangler.smoo-${process.pid}-${randomUUID()}.json`);
|
|
273
|
+
await writeTemporaryConfig(temporaryConfigPath, `${JSON.stringify(derived, null, 2)}\n`);
|
|
271
274
|
return {
|
|
272
275
|
deployConfigPath: temporaryConfigPath,
|
|
273
276
|
temporaryConfigPath,
|
|
274
|
-
plan: planConfiguredStageResources(
|
|
277
|
+
plan: planConfiguredStageResources(derived, stage),
|
|
275
278
|
d1MigrationBindings: [],
|
|
276
279
|
envFlag: stage,
|
|
277
280
|
};
|
|
@@ -288,7 +291,7 @@ async function prepareFlatConfig(
|
|
|
288
291
|
if (!isPullRequestStage(stage)) {
|
|
289
292
|
return {
|
|
290
293
|
deployConfigPath: configPath,
|
|
291
|
-
plan:
|
|
294
|
+
plan: planStageResources(flat, stage),
|
|
292
295
|
d1MigrationBindings: migrationBindings(flat),
|
|
293
296
|
};
|
|
294
297
|
}
|
|
@@ -302,7 +305,7 @@ async function prepareFlatConfig(
|
|
|
302
305
|
return {
|
|
303
306
|
deployConfigPath: temporaryConfigPath,
|
|
304
307
|
temporaryConfigPath,
|
|
305
|
-
plan:
|
|
308
|
+
plan: planStageResources(derived, stage),
|
|
306
309
|
d1MigrationBindings: migrationBindings(derived),
|
|
307
310
|
};
|
|
308
311
|
}
|
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
// Cloudflare. It never provisions, never derives a pull-request stage's resources, and never
|
|
5
5
|
// writes anything except its own cache entry.
|
|
6
6
|
|
|
7
|
-
import { existsSync } from 'node:fs';
|
|
8
7
|
import { readFile } from 'node:fs/promises';
|
|
9
|
-
import { join } from 'node:path';
|
|
10
8
|
import { parseJsonFileText } from '../lib/json.js';
|
|
11
9
|
import { BunProcessRunner, type ProcessRunner, type ProcessRunOptions, wranglerJson } from './deploy-stage.js';
|
|
12
|
-
import { parseFlatWranglerConfig
|
|
10
|
+
import { parseFlatWranglerConfig } from './flat-config.js';
|
|
13
11
|
import {
|
|
14
12
|
LIVE_VERSION_CACHE_TTL_MS,
|
|
15
13
|
type LiveVersionProbe,
|
|
@@ -18,11 +16,13 @@ import {
|
|
|
18
16
|
readLiveVersion,
|
|
19
17
|
writeCachedLiveVersion,
|
|
20
18
|
} from './live-version.js';
|
|
19
|
+
import { readWranglerSourceConfig } from './source-config.js';
|
|
21
20
|
import {
|
|
22
21
|
type DeploymentStage,
|
|
23
22
|
isPullRequestStage,
|
|
24
23
|
parseDeploymentStage,
|
|
25
24
|
planConfiguredStageResources,
|
|
25
|
+
planStageResources,
|
|
26
26
|
stageResourceName,
|
|
27
27
|
stagingWorkerBaseName,
|
|
28
28
|
} from './stage.js';
|
|
@@ -64,16 +64,12 @@ export async function stageWorkerName(cwd: string, stage: DeploymentStage, confi
|
|
|
64
64
|
const flat = parseJsonFileText(configPath, await readFile(configPath, 'utf8'), parseFlatWranglerConfig);
|
|
65
65
|
return isPullRequestStage(stage)
|
|
66
66
|
? stageResourceName(stagingWorkerBaseName(flat.name), stage)
|
|
67
|
-
:
|
|
67
|
+
: planStageResources(flat, stage).workerName;
|
|
68
68
|
}
|
|
69
|
-
const
|
|
70
|
-
if (!existsSync(tomlPath)) {
|
|
71
|
-
throw new Error(`${tomlPath} does not exist; pass --config for a build-generated flat configuration.`);
|
|
72
|
-
}
|
|
73
|
-
const toml = await readFile(tomlPath, 'utf8');
|
|
69
|
+
const { document } = readWranglerSourceConfig(cwd);
|
|
74
70
|
return isPullRequestStage(stage)
|
|
75
|
-
? stageResourceName(stagingWorkerBaseName(planConfiguredStageResources(
|
|
76
|
-
: planConfiguredStageResources(
|
|
71
|
+
? stageResourceName(stagingWorkerBaseName(planConfiguredStageResources(document, 'staging').workerName), stage)
|
|
72
|
+
: planConfiguredStageResources(document, stage).workerName;
|
|
77
73
|
}
|
|
78
74
|
|
|
79
75
|
/**
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
|
-
import { type FlatWranglerConfig, parseFlatWranglerConfig
|
|
3
|
-
import {
|
|
2
|
+
import { type FlatWranglerConfig, parseFlatWranglerConfig } from './flat-config.js';
|
|
3
|
+
import {
|
|
4
|
+
type DerivePullRequestConfigOptions,
|
|
5
|
+
derivePullRequestStageConfig,
|
|
6
|
+
planPullRequestBindings,
|
|
7
|
+
planStageResources,
|
|
8
|
+
} from './stage.js';
|
|
4
9
|
|
|
5
10
|
// The shape the Cloudflare Vite adapter emits for a site built with
|
|
6
11
|
// CLOUDFLARE_ENV=staging: one flat document, ids already resolved.
|
|
@@ -181,9 +186,9 @@ describe('derivePullRequestStageConfig for a flat config', () => {
|
|
|
181
186
|
});
|
|
182
187
|
});
|
|
183
188
|
|
|
184
|
-
describe('
|
|
189
|
+
describe('planStageResources', () => {
|
|
185
190
|
it('reads the bindings a reconcile needs from a flat config', () => {
|
|
186
|
-
const plan =
|
|
191
|
+
const plan = planStageResources(CONFIG, 'staging');
|
|
187
192
|
expect(plan.workerName).toBe('site-preview-staging');
|
|
188
193
|
expect(plan.kvNamespaces).toEqual([{ binding: 'SESSION', id: 'kv-staging' }]);
|
|
189
194
|
expect(plan.r2Buckets).toEqual([{ binding: 'MEDIA', bucketName: 'site-media-staging' }]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import typia from 'typia';
|
|
2
2
|
import { formatValidationErrors } from '../lib/json.js';
|
|
3
|
-
import type {
|
|
3
|
+
import type { StageConfigFields } from './stage.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* A Wrangler configuration with no `env` blocks: the shape build tools emit
|
|
@@ -11,7 +11,6 @@ import type { ConfiguredStageResourcePlan, DeploymentStage, StageConfigFields }
|
|
|
11
11
|
*/
|
|
12
12
|
export interface FlatWranglerConfig extends StageConfigFields {
|
|
13
13
|
env?: unknown;
|
|
14
|
-
[key: string]: unknown;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
const parseFlatWranglerConfigText = typia.json.createValidateParse<FlatWranglerConfig>();
|
|
@@ -27,21 +26,3 @@ export function parseFlatWranglerConfig(json: string): FlatWranglerConfig {
|
|
|
27
26
|
}
|
|
28
27
|
return result.data;
|
|
29
28
|
}
|
|
30
|
-
|
|
31
|
-
/** The reconcile plan (`reconcileStageResources`) read off a flat config for any stage. */
|
|
32
|
-
export function planFlatStageResources(
|
|
33
|
-
config: FlatWranglerConfig,
|
|
34
|
-
stage: DeploymentStage,
|
|
35
|
-
): ConfiguredStageResourcePlan {
|
|
36
|
-
return {
|
|
37
|
-
stage,
|
|
38
|
-
workerName: config.name,
|
|
39
|
-
kvNamespaces: (config.kv_namespaces ?? []).map(({ binding, id }) => ({ binding, id })),
|
|
40
|
-
r2Buckets: (config.r2_buckets ?? []).map((bucket) => ({ binding: bucket.binding, bucketName: bucket.bucket_name })),
|
|
41
|
-
routes: (config.routes ?? []).map((route) => ({
|
|
42
|
-
pattern: route.pattern,
|
|
43
|
-
...(typeof route.zone_name === 'string' ? { zoneName: route.zone_name } : {}),
|
|
44
|
-
customDomain: route.custom_domain === true,
|
|
45
|
-
})),
|
|
46
|
-
};
|
|
47
|
-
}
|