@smoothbricks/cli 0.11.16 → 0.11.18
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 +65 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +47 -0
- package/dist/github-ci/index.d.ts +1 -1
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +7 -10
- package/dist/lib/secret-names.d.ts +35 -0
- package/dist/lib/secret-names.d.ts.map +1 -0
- package/dist/lib/secret-names.js +61 -0
- package/dist/monorepo/ci-workflow.d.ts +56 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +214 -8
- package/dist/monorepo/managed-files.d.ts +23 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +39 -1
- package/dist/monorepo/publish-workflow.d.ts +10 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +58 -20
- package/dist/release/github-release.d.ts +10 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +11 -5
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +4 -5
- package/dist/secrets/commands.d.ts +84 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +375 -0
- package/dist/secrets/index.d.ts +103 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +255 -0
- package/dist/secrets/repository.d.ts +60 -0
- package/dist/secrets/repository.d.ts.map +1 -0
- package/dist/secrets/repository.js +145 -0
- package/dist/wrangler/cloudflare.d.ts +7 -0
- package/dist/wrangler/cloudflare.d.ts.map +1 -1
- package/dist/wrangler/cloudflare.js +10 -0
- package/dist/wrangler/deploy-stage.d.ts +13 -2
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +89 -78
- package/dist/wrangler/deployed-version.d.ts +44 -0
- package/dist/wrangler/deployed-version.d.ts.map +1 -0
- package/dist/wrangler/deployed-version.js +87 -0
- package/dist/wrangler/live-version.d.ts +146 -0
- package/dist/wrangler/live-version.d.ts.map +1 -0
- package/dist/wrangler/live-version.js +326 -0
- package/dist/wrangler/stage-secrets.d.ts +57 -0
- package/dist/wrangler/stage-secrets.d.ts.map +1 -0
- package/dist/wrangler/stage-secrets.js +178 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
- package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
- package/package.json +2 -2
- package/src/cli.ts +69 -1
- package/src/github-ci/index.test.ts +92 -8
- package/src/github-ci/index.ts +7 -10
- package/src/lib/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +188 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
- package/src/monorepo/ci-workflow.ts +294 -8
- package/src/monorepo/managed-files.test.ts +28 -1
- package/src/monorepo/managed-files.ts +56 -2
- package/src/monorepo/package-policy.test.ts +1 -1
- package/src/monorepo/publish-workflow.ts +65 -19
- package/src/monorepo/secret-references.test.ts +1 -1
- package/src/release/__tests__/github-release.test.ts +8 -4
- package/src/release/__tests__/private-npm-status.test.ts +2 -2
- package/src/release/github-release.ts +13 -5
- package/src/release/index.ts +4 -4
- package/src/secrets/commands.test.ts +28 -0
- package/src/secrets/commands.ts +412 -0
- package/src/secrets/index.test.ts +209 -0
- package/src/secrets/index.ts +287 -0
- package/src/secrets/repository.test.ts +98 -0
- package/src/secrets/repository.ts +164 -0
- package/src/wrangler/cloudflare.ts +18 -0
- package/src/wrangler/deploy-stage.test.ts +448 -23
- package/src/wrangler/deploy-stage.ts +142 -83
- package/src/wrangler/deployed-version.test.ts +150 -0
- package/src/wrangler/deployed-version.ts +130 -0
- package/src/wrangler/live-version.ts +363 -0
- package/src/wrangler/stage-secrets.ts +146 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// What is live right now, as opposed to what we last uploaded.
|
|
2
|
+
//
|
|
3
|
+
// `wrangler versions deploy` returns when Cloudflare ACCEPTS the traffic shift, not when the edge
|
|
4
|
+
// serves it. A deploy that returns is therefore not yet a deploy that answers: the site that signs
|
|
5
|
+
// in to its stage's backend measured the previous backend version's response about 20 s after the
|
|
6
|
+
// backend's deploy step had already gone green. Everything here exists to turn "we asked" into
|
|
7
|
+
// "we observed".
|
|
8
|
+
|
|
9
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import typia from 'typia';
|
|
12
|
+
|
|
13
|
+
/** Same shape as the release tooling's Result: a known operational failure is a value, not a throw. */
|
|
14
|
+
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
|
15
|
+
|
|
16
|
+
/** The version Cloudflare is serving to 100% of traffic for one worker. */
|
|
17
|
+
export interface LiveVersion {
|
|
18
|
+
versionId: string;
|
|
19
|
+
/** `wrangler versions deploy --version-tag` matches on this; a version deployed outside Nx has none. */
|
|
20
|
+
tag: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type LiveVersionFailure =
|
|
24
|
+
| { kind: 'never-deployed'; message: string }
|
|
25
|
+
| { kind: 'split-traffic'; message: string };
|
|
26
|
+
|
|
27
|
+
/** Reads the two `wrangler` JSON payloads the live version is derived from. */
|
|
28
|
+
export interface LiveVersionProbe {
|
|
29
|
+
/** `wrangler deployments status --name <worker> --json` */
|
|
30
|
+
deployments(): Promise<unknown>;
|
|
31
|
+
/** `wrangler versions list --name <worker> --json` */
|
|
32
|
+
versions(): Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isUnknownRecord = typia.createIs<Record<string, unknown>>();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every `{ id -> tag }` pair the payload carries, in document order.
|
|
39
|
+
*
|
|
40
|
+
* Wrangler nests versions differently per subcommand and per version of itself, spells the tag
|
|
41
|
+
* three ways (`annotations['workers/tag']`, a bare `tag`, `metadata.tag`) and the id two (`id`,
|
|
42
|
+
* `version_id`). One walk collects the relation once; both directions of the lookup are then a Map
|
|
43
|
+
* read rather than a second recursive scan that could disagree with the first.
|
|
44
|
+
*/
|
|
45
|
+
export function collectVersionTags(value: unknown): Map<string, string> {
|
|
46
|
+
const pairs = new Map<string, string>();
|
|
47
|
+
collectInto(value, pairs);
|
|
48
|
+
return pairs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collectInto(value: unknown, pairs: Map<string, string>): void {
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
for (const entry of value) collectInto(entry, pairs);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!isUnknownRecord(value)) return;
|
|
57
|
+
const annotations = isUnknownRecord(value.annotations) ? value.annotations : undefined;
|
|
58
|
+
const metadata = isUnknownRecord(value.metadata) ? value.metadata : undefined;
|
|
59
|
+
const annotationTag = annotations?.['workers/tag'];
|
|
60
|
+
const candidateTag =
|
|
61
|
+
typeof annotationTag === 'string' ? annotationTag : typeof value.tag === 'string' ? value.tag : metadata?.tag;
|
|
62
|
+
const candidateId =
|
|
63
|
+
typeof value.id === 'string' ? value.id : typeof value.version_id === 'string' ? value.version_id : undefined;
|
|
64
|
+
if (typeof candidateTag === 'string' && candidateId !== undefined && !pairs.has(candidateId)) {
|
|
65
|
+
pairs.set(candidateId, candidateTag);
|
|
66
|
+
}
|
|
67
|
+
for (const nested of Object.values(value)) collectInto(nested, pairs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The version id whose tag is `tag`, or null. */
|
|
71
|
+
export function findVersionIdByTag(value: unknown, tag: string): string | null {
|
|
72
|
+
for (const [id, candidate] of collectVersionTags(value)) {
|
|
73
|
+
if (candidate === tag) return id;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One version's share of the current deployment's traffic. */
|
|
79
|
+
export interface DeployedVersionShare {
|
|
80
|
+
versionId: string | null;
|
|
81
|
+
percentage: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The current deployment's traffic split, or null when the payload names no deployment at all.
|
|
86
|
+
*
|
|
87
|
+
* "Nothing is deployed" and "two versions share the traffic" are different facts with different
|
|
88
|
+
* remedies, and a caller that collapses them cannot tell an operator which one it hit — so the
|
|
89
|
+
* split is returned whole and both questions are answered from it.
|
|
90
|
+
*/
|
|
91
|
+
export function currentDeploymentVersions(value: unknown): DeployedVersionShare[] | null {
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
for (const entry of value) {
|
|
94
|
+
const found = currentDeploymentVersions(entry);
|
|
95
|
+
if (found) return found;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (!isUnknownRecord(value)) return null;
|
|
100
|
+
if (Array.isArray(value.versions)) {
|
|
101
|
+
return value.versions.map((version) => {
|
|
102
|
+
if (!isUnknownRecord(version)) return { versionId: null, percentage: 0 };
|
|
103
|
+
const id = version.version_id ?? version.id;
|
|
104
|
+
return { versionId: typeof id === 'string' ? id : null, percentage: Number(version.percentage) };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const nested of Object.values(value)) {
|
|
108
|
+
const found = currentDeploymentVersions(nested);
|
|
109
|
+
if (found) return found;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The one version serving 100% of traffic, or null when there is none or the deployment is split.
|
|
116
|
+
*
|
|
117
|
+
* A split is not "close enough": with two versions live, "is our version live" has no single
|
|
118
|
+
* answer, and answering it optimistically is how a deploy reports success while part of the
|
|
119
|
+
* traffic still reaches the old code.
|
|
120
|
+
*/
|
|
121
|
+
export function currentDeploymentVersionId(value: unknown): string | null {
|
|
122
|
+
const shares = currentDeploymentVersions(value);
|
|
123
|
+
if (shares?.length !== 1) return null;
|
|
124
|
+
const [only] = shares;
|
|
125
|
+
return only.percentage === 100 ? only.versionId : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The version serving all traffic for this worker, with the tag it was uploaded under. */
|
|
129
|
+
export async function readLiveVersion(probe: LiveVersionProbe): Promise<Result<LiveVersion, LiveVersionFailure>> {
|
|
130
|
+
const deployments = await probe.deployments();
|
|
131
|
+
const shares = currentDeploymentVersions(deployments);
|
|
132
|
+
const versionId = currentDeploymentVersionId(deployments);
|
|
133
|
+
if (!versionId) {
|
|
134
|
+
return shares && shares.length > 0
|
|
135
|
+
? {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: {
|
|
138
|
+
kind: 'split-traffic',
|
|
139
|
+
message: 'no single worker version is serving 100% of traffic; refusing to name one as live',
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
: { ok: false, error: { kind: 'never-deployed', message: 'this worker has no active deployment' } };
|
|
143
|
+
}
|
|
144
|
+
const tag = collectVersionTags(await probe.versions()).get(versionId) ?? null;
|
|
145
|
+
return { ok: true, value: { versionId, tag } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* How long a deploy waits for the version it just activated to become the one being served.
|
|
150
|
+
*
|
|
151
|
+
* The bound is the point: an unbounded loop turns a stuck propagation into a hung CI job nobody
|
|
152
|
+
* reads, and a fixed sleep turns it into a green job that lied. Both are worse than a red job
|
|
153
|
+
* naming what it expected and what it saw.
|
|
154
|
+
*/
|
|
155
|
+
export const LIVE_VERSION_WAIT_BUDGET_MS = 120_000;
|
|
156
|
+
/** Cloudflare's control plane converges in seconds; polling faster than this only burns API quota. */
|
|
157
|
+
export const LIVE_VERSION_POLL_INTERVAL_MS = 2_000;
|
|
158
|
+
|
|
159
|
+
export interface LiveVersionWaitOptions {
|
|
160
|
+
budgetMs?: number;
|
|
161
|
+
intervalMs?: number;
|
|
162
|
+
now?: () => number;
|
|
163
|
+
sleep?: (ms: number) => Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface LiveVersionNotObserved {
|
|
167
|
+
kind: 'not-observed';
|
|
168
|
+
message: string;
|
|
169
|
+
expected: string;
|
|
170
|
+
observed: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One poll's verdict: the value that ends the wait, or how the world looked this time round. */
|
|
174
|
+
type Attempt<T> = { done: true; value: T } | { done: false; observed: string };
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Runs `attempt` until it succeeds or the budget expires, and reports the LAST thing it saw.
|
|
178
|
+
*
|
|
179
|
+
* Both waits below need the same deadline arithmetic and the same "what did you see instead"
|
|
180
|
+
* record; sharing it keeps the two from drifting into disagreeing about when a wait is over.
|
|
181
|
+
*/
|
|
182
|
+
async function pollWithinBudget<T>(
|
|
183
|
+
attempt: () => Promise<Attempt<T>>,
|
|
184
|
+
options: LiveVersionWaitOptions,
|
|
185
|
+
): Promise<Result<T, { observed: string; waitedMs: number }>> {
|
|
186
|
+
const budgetMs = options.budgetMs ?? LIVE_VERSION_WAIT_BUDGET_MS;
|
|
187
|
+
const intervalMs = options.intervalMs ?? LIVE_VERSION_POLL_INTERVAL_MS;
|
|
188
|
+
const now = options.now ?? Date.now;
|
|
189
|
+
const sleep = options.sleep ?? sleepFor;
|
|
190
|
+
const deadline = now() + budgetMs;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const result = await attempt();
|
|
193
|
+
if (result.done) return { ok: true, value: result.value };
|
|
194
|
+
if (now() >= deadline) return { ok: false, error: { observed: result.observed, waitedMs: budgetMs } };
|
|
195
|
+
await sleep(intervalMs);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sleepFor(ms: number): Promise<void> {
|
|
200
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
201
|
+
setTimeout(resolve, ms);
|
|
202
|
+
return promise;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Polls until the worker's live version carries `expectedTag`, or the budget runs out.
|
|
207
|
+
*
|
|
208
|
+
* The tag is the match, not the version id: the tag IS the desired-state identity (`nx-<task
|
|
209
|
+
* hash>`), it is what both `wrangler deploy --tag` and `wrangler versions deploy --version-tag`
|
|
210
|
+
* were told to make live, and it is the one name the caller knows before the upload has produced
|
|
211
|
+
* a version id.
|
|
212
|
+
*
|
|
213
|
+
* Returns the failure rather than throwing it: a deploy that cannot be observed is an operational
|
|
214
|
+
* outcome the caller has to report, not a broken invariant.
|
|
215
|
+
*/
|
|
216
|
+
export async function awaitLiveVersion(
|
|
217
|
+
probe: LiveVersionProbe,
|
|
218
|
+
expectedTag: string,
|
|
219
|
+
options: LiveVersionWaitOptions = {},
|
|
220
|
+
): Promise<Result<LiveVersion, LiveVersionNotObserved>> {
|
|
221
|
+
const polled = await pollWithinBudget<LiveVersion>(async () => {
|
|
222
|
+
const live = await readLiveVersion(probe);
|
|
223
|
+
if (!live.ok) return { done: false, observed: live.error.message };
|
|
224
|
+
const { tag, versionId } = live.value;
|
|
225
|
+
if (tag === expectedTag) return { done: true, value: live.value };
|
|
226
|
+
return { done: false, observed: tag ? `${tag} (${versionId})` : `an untagged version (${versionId})` };
|
|
227
|
+
}, options);
|
|
228
|
+
if (polled.ok) return polled;
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
error: {
|
|
232
|
+
kind: 'not-observed',
|
|
233
|
+
expected: expectedTag,
|
|
234
|
+
observed: polled.error.observed,
|
|
235
|
+
message:
|
|
236
|
+
`deployed version ${expectedTag} was not serving traffic after ` +
|
|
237
|
+
`${Math.round(polled.error.waitedMs / 1000)}s; live is ${polled.error.observed}`,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Exactly the call the wait makes. Demanding all of `typeof fetch` would force every caller — the
|
|
244
|
+
* tests included — to also supply `preconnect`, which nothing here uses.
|
|
245
|
+
*/
|
|
246
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
247
|
+
|
|
248
|
+
export interface VersionEndpointWaitOptions extends LiveVersionWaitOptions {
|
|
249
|
+
fetch?: FetchLike;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Polls a worker-served endpoint until it answers with `expectedTag`.
|
|
254
|
+
*
|
|
255
|
+
* The control plane agreeing is necessary and not sufficient — that gap is exactly what this
|
|
256
|
+
* change's predecessor measured — so a project that can prove the edge serves the new code says so
|
|
257
|
+
* with an endpoint echoing its own version tag (Workers read it from the version-metadata
|
|
258
|
+
* binding). The contract is deliberately one shape: the trimmed response body IS the tag.
|
|
259
|
+
*/
|
|
260
|
+
export async function awaitVersionEndpoint(
|
|
261
|
+
url: string,
|
|
262
|
+
expectedTag: string,
|
|
263
|
+
options: VersionEndpointWaitOptions = {},
|
|
264
|
+
): Promise<Result<void, LiveVersionNotObserved>> {
|
|
265
|
+
const request = options.fetch ?? fetch;
|
|
266
|
+
const polled = await pollWithinBudget<void>(async () => {
|
|
267
|
+
const observed = await probeVersionEndpoint(request, url);
|
|
268
|
+
return observed === expectedTag ? { done: true, value: undefined } : { done: false, observed };
|
|
269
|
+
}, options);
|
|
270
|
+
if (polled.ok) return polled;
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
error: {
|
|
274
|
+
kind: 'not-observed',
|
|
275
|
+
expected: expectedTag,
|
|
276
|
+
observed: polled.error.observed,
|
|
277
|
+
message:
|
|
278
|
+
`${url} did not report version ${expectedTag} after ` +
|
|
279
|
+
`${Math.round(polled.error.waitedMs / 1000)}s; it reported ${polled.error.observed}`,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function probeVersionEndpoint(request: FetchLike, url: string): Promise<string> {
|
|
285
|
+
try {
|
|
286
|
+
const response = await request(url, { headers: { accept: 'text/plain' } });
|
|
287
|
+
const body = (await response.text()).trim();
|
|
288
|
+
return response.ok ? body : `HTTP ${response.status} ${body}`.trim();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
// A worker mid-rollout refuses connections; that is a poll result, not a reason to abandon the
|
|
291
|
+
// wait. It only becomes the failure when it is still the answer at the deadline.
|
|
292
|
+
return error instanceof Error ? error.message : String(error);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* How long `smoo wrangler deployed-version` trusts its own last answer.
|
|
298
|
+
*
|
|
299
|
+
* This TTL is a convenience for operators and repeated local queries, NOT a correctness mechanism:
|
|
300
|
+
* nothing that decides whether to deploy may read it. The deploy's own liveness check always calls
|
|
301
|
+
* Cloudflare, because a cached "live already equals the desired tag" is precisely the belief a
|
|
302
|
+
* rollback falsifies, and acting on it would skip the deploy that repairs the rollback.
|
|
303
|
+
*/
|
|
304
|
+
export const LIVE_VERSION_CACHE_TTL_MS = 45_000;
|
|
305
|
+
|
|
306
|
+
export interface LiveVersionCacheKey {
|
|
307
|
+
accountId: string;
|
|
308
|
+
workerName: string;
|
|
309
|
+
stage: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface CachedLiveVersion {
|
|
313
|
+
versionTag: string | null;
|
|
314
|
+
versionId: string;
|
|
315
|
+
fetchedAt: number;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const parseCachedLiveVersion = typia.json.createValidateParse<CachedLiveVersion>();
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* One file per key, so two deploys running in parallel cannot clobber each other's answer; a
|
|
322
|
+
* shared map would need a lock to say the same thing.
|
|
323
|
+
*/
|
|
324
|
+
export function liveVersionCachePath(cacheDirectory: string, key: LiveVersionCacheKey): string {
|
|
325
|
+
const name = [key.accountId, key.workerName, key.stage].map(encodeURIComponent).join('_');
|
|
326
|
+
return join(cacheDirectory, 'smoo-live-version', `${name}.json`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function readCachedLiveVersion(
|
|
330
|
+
cacheDirectory: string,
|
|
331
|
+
key: LiveVersionCacheKey,
|
|
332
|
+
ttlMs: number,
|
|
333
|
+
now: () => number = Date.now,
|
|
334
|
+
): Promise<CachedLiveVersion | null> {
|
|
335
|
+
let text: string;
|
|
336
|
+
try {
|
|
337
|
+
text = await readFile(liveVersionCachePath(cacheDirectory, key), 'utf8');
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const parsed = parseCachedLiveVersion(text);
|
|
342
|
+
if (!parsed.success) return null;
|
|
343
|
+
return now() - parsed.data.fetchedAt < ttlMs ? parsed.data : null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function writeCachedLiveVersion(
|
|
347
|
+
cacheDirectory: string,
|
|
348
|
+
key: LiveVersionCacheKey,
|
|
349
|
+
entry: CachedLiveVersion,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
const path = liveVersionCachePath(cacheDirectory, key);
|
|
352
|
+
await mkdir(dirname(path), { recursive: true });
|
|
353
|
+
await writeFile(path, `${JSON.stringify(entry)}\n`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Where the cache lives. Nx's workspace data directory already exists, is already gitignored, and
|
|
358
|
+
* is already wiped when the workspace is reset — three properties a hand-rolled directory would
|
|
359
|
+
* each have to re-earn, and one ($HOME) that would leak one checkout's answers into another's.
|
|
360
|
+
*/
|
|
361
|
+
export function liveVersionCacheDirectory(workspaceRoot: string, environment: NodeJS.ProcessEnv): string {
|
|
362
|
+
return environment.NX_WORKSPACE_DATA_DIRECTORY ?? join(workspaceRoot, '.nx', 'workspace-data');
|
|
363
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import typia from 'typia';
|
|
4
|
+
import { formatValidationErrors, parseJsonFileText } from '../lib/json.js';
|
|
5
|
+
import { parseDevVarsExample } from './prepare-env.js';
|
|
6
|
+
import { type DeploymentStage, isPullRequestStage } from './stage.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How a declaration names a stage. The fixed stages go by their own name; every `prN` stage is
|
|
10
|
+
* `preview`, because a scope is written once and pull-request numbers are not knowable in advance.
|
|
11
|
+
*/
|
|
12
|
+
export type SecretStageScope = 'staging' | 'production' | 'preview';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `smoo.wrangler.secretStages`: a declared secret name mapped to the stages it belongs to. The map
|
|
16
|
+
* answers two questions with one declaration, and both directions matter:
|
|
17
|
+
*
|
|
18
|
+
* - requirement — a stage the secret belongs to refuses to deploy without a value for it;
|
|
19
|
+
* - permission — a stage the secret does *not* belong to never receives it, however loudly the
|
|
20
|
+
* deploying shell exports it. A test-only capability exported by CI for preview stages must not
|
|
21
|
+
* ride along into production just because the variable happens to be set.
|
|
22
|
+
*
|
|
23
|
+
* A declared name absent from the map belongs to every stage. A name mapped to `[]` belongs to no
|
|
24
|
+
* stage, which is how a value that exists only for local development is declared.
|
|
25
|
+
*/
|
|
26
|
+
export type SecretStageMap = Record<string, SecretStageScope[]>;
|
|
27
|
+
|
|
28
|
+
/** The one block of a project's package.json this reader needs; every other field is ignored. */
|
|
29
|
+
interface WranglerPackageManifest {
|
|
30
|
+
smoo?: {
|
|
31
|
+
wrangler?: {
|
|
32
|
+
secretStages?: SecretStageMap;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const validateWranglerManifest = typia.json.createValidateParse<WranglerPackageManifest>();
|
|
38
|
+
|
|
39
|
+
/** Secret NAMES the project declares. Values live on the Worker; the repo only ever holds the keys. */
|
|
40
|
+
export function readDeclaredSecretNames(cwd: string): string[] {
|
|
41
|
+
const path = join(cwd, '.dev.vars.example');
|
|
42
|
+
return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `smoo.wrangler.secretStages` from the project's package.json; no file and no block scope nothing. */
|
|
46
|
+
export function readSecretStageMap(cwd: string): SecretStageMap {
|
|
47
|
+
const path = join(cwd, 'package.json');
|
|
48
|
+
if (!existsSync(path)) return {};
|
|
49
|
+
const result = parseJsonFileText(path, readFileSync(path, 'utf8'), validateWranglerManifest);
|
|
50
|
+
if (!result.success) {
|
|
51
|
+
throw new Error(`${path} declares an invalid smoo.wrangler block: ${formatValidationErrors(result.errors)}`);
|
|
52
|
+
}
|
|
53
|
+
return result.data.smoo?.wrangler?.secretStages ?? {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Which of a project's declared secrets one stage may see, and which belong to other stages. */
|
|
57
|
+
export interface StageSecretPlan {
|
|
58
|
+
stage: DeploymentStage;
|
|
59
|
+
/** Declared names this stage requires — and the only ones a deploy of it may carry. */
|
|
60
|
+
required: string[];
|
|
61
|
+
/** Declared names scoped to other stages: withheld from this deploy even when a value is exported. */
|
|
62
|
+
withheld: string[];
|
|
63
|
+
/** The declaration itself, so a refusal can say why each name is where it is. */
|
|
64
|
+
scopes: SecretStageMap;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Splits the declared secrets by whether `stage` is in scope for each.
|
|
69
|
+
*
|
|
70
|
+
* A scope on an undeclared name is refused rather than ignored: it is almost always a typo of a
|
|
71
|
+
* real secret's name, and its effect is the dangerous direction — the misspelt entry scopes
|
|
72
|
+
* nothing while the real secret, still absent from the map, reaches every stage.
|
|
73
|
+
*/
|
|
74
|
+
export function planStageSecrets(declared: string[], stage: DeploymentStage, scopes: SecretStageMap): StageSecretPlan {
|
|
75
|
+
const declaredNames = new Set(declared);
|
|
76
|
+
const undeclared = Object.keys(scopes).filter((name) => !declaredNames.has(name));
|
|
77
|
+
if (undeclared.length > 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`smoo.wrangler.secretStages scopes ${undeclared.join(', ')}, which .dev.vars.example does not declare. ` +
|
|
80
|
+
'A scope on a name no secret has leaves the secret it was meant for unscoped, so that secret reaches every stage.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
// Every `prN` stage answers to one written token: a scope cannot name pull requests in advance.
|
|
84
|
+
const scope: SecretStageScope = isPullRequestStage(stage) ? 'preview' : stage;
|
|
85
|
+
const required: string[] = [];
|
|
86
|
+
const withheld: string[] = [];
|
|
87
|
+
for (const name of declared) {
|
|
88
|
+
const declaredScope = scopes[name];
|
|
89
|
+
if (declaredScope === undefined || declaredScope.includes(scope)) {
|
|
90
|
+
required.push(name);
|
|
91
|
+
} else {
|
|
92
|
+
withheld.push(name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { stage, required, withheld, scopes };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Why this deploy must not proceed, or nothing when it may.
|
|
100
|
+
*
|
|
101
|
+
* Two refusals, reported together so one run names every problem:
|
|
102
|
+
*
|
|
103
|
+
* - a required secret with no value anywhere. `--secrets-file` applies additively, so a deploy
|
|
104
|
+
* that never mentions a secret leaves whatever the Worker already holds — silence that reads as
|
|
105
|
+
* success while a secret introduced after the first deploy never arrives, and the code that
|
|
106
|
+
* needs it fails at runtime instead of here.
|
|
107
|
+
* - a withheld secret the Worker already holds. Filtering it out of this deploy's payload cannot
|
|
108
|
+
* remove it, so the scope would be nominal rather than enforced until someone deletes it.
|
|
109
|
+
*
|
|
110
|
+
* A value is never read, never formatted, and never named beyond its key.
|
|
111
|
+
*/
|
|
112
|
+
export function stageSecretRefusal(
|
|
113
|
+
plan: StageSecretPlan,
|
|
114
|
+
exported: ReadonlySet<string>,
|
|
115
|
+
held: ReadonlySet<string>,
|
|
116
|
+
workerName: string,
|
|
117
|
+
): string | undefined {
|
|
118
|
+
const unavailable = plan.required.filter((name) => !exported.has(name) && !held.has(name));
|
|
119
|
+
const installed = plan.withheld.filter((name) => held.has(name));
|
|
120
|
+
if (unavailable.length === 0 && installed.length === 0) return undefined;
|
|
121
|
+
const lines = [`Refusing to deploy ${workerName} to ${plan.stage}.`];
|
|
122
|
+
if (unavailable.length > 0) {
|
|
123
|
+
lines.push(
|
|
124
|
+
`Stage ${plan.stage} requires these secrets and no value exists for them, neither in this environment nor on the Worker:`,
|
|
125
|
+
...unavailable.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`),
|
|
126
|
+
'Export each one in the deploying shell before the deploy. Once the Worker exists,',
|
|
127
|
+
`\`wrangler secret put <NAME> --name ${workerName}\` supplies it too.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (installed.length > 0) {
|
|
131
|
+
lines.push(
|
|
132
|
+
`The Worker holds these secrets, which smoo.wrangler.secretStages keeps out of ${plan.stage}:`,
|
|
133
|
+
...installed.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`),
|
|
134
|
+
`Delete each one with \`wrangler secret delete <NAME> --name ${workerName}\`. A deploy of this stage`,
|
|
135
|
+
'never sends them, so leaving them installed would keep the scope nominal.',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function scopeDescription(name: string, scopes: SecretStageMap): string {
|
|
142
|
+
const scope = scopes[name];
|
|
143
|
+
if (scope === undefined) return 'unscoped, so every stage requires it';
|
|
144
|
+
if (scope.length === 0) return 'scoped to no stage (local development only)';
|
|
145
|
+
return `scoped to ${scope.join(', ')}`;
|
|
146
|
+
}
|