@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.
Files changed (79) hide show
  1. package/README.md +65 -4
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +47 -0
  4. package/dist/github-ci/index.d.ts +1 -1
  5. package/dist/github-ci/index.d.ts.map +1 -1
  6. package/dist/github-ci/index.js +7 -10
  7. package/dist/lib/secret-names.d.ts +35 -0
  8. package/dist/lib/secret-names.d.ts.map +1 -0
  9. package/dist/lib/secret-names.js +61 -0
  10. package/dist/monorepo/ci-workflow.d.ts +56 -0
  11. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  12. package/dist/monorepo/ci-workflow.js +214 -8
  13. package/dist/monorepo/managed-files.d.ts +23 -0
  14. package/dist/monorepo/managed-files.d.ts.map +1 -1
  15. package/dist/monorepo/managed-files.js +39 -1
  16. package/dist/monorepo/publish-workflow.d.ts +10 -1
  17. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  18. package/dist/monorepo/publish-workflow.js +58 -20
  19. package/dist/release/github-release.d.ts +10 -0
  20. package/dist/release/github-release.d.ts.map +1 -1
  21. package/dist/release/github-release.js +11 -5
  22. package/dist/release/index.d.ts.map +1 -1
  23. package/dist/release/index.js +4 -5
  24. package/dist/secrets/commands.d.ts +84 -0
  25. package/dist/secrets/commands.d.ts.map +1 -0
  26. package/dist/secrets/commands.js +375 -0
  27. package/dist/secrets/index.d.ts +103 -0
  28. package/dist/secrets/index.d.ts.map +1 -0
  29. package/dist/secrets/index.js +255 -0
  30. package/dist/secrets/repository.d.ts +60 -0
  31. package/dist/secrets/repository.d.ts.map +1 -0
  32. package/dist/secrets/repository.js +145 -0
  33. package/dist/wrangler/cloudflare.d.ts +7 -0
  34. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  35. package/dist/wrangler/cloudflare.js +10 -0
  36. package/dist/wrangler/deploy-stage.d.ts +13 -2
  37. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  38. package/dist/wrangler/deploy-stage.js +89 -78
  39. package/dist/wrangler/deployed-version.d.ts +44 -0
  40. package/dist/wrangler/deployed-version.d.ts.map +1 -0
  41. package/dist/wrangler/deployed-version.js +87 -0
  42. package/dist/wrangler/live-version.d.ts +146 -0
  43. package/dist/wrangler/live-version.d.ts.map +1 -0
  44. package/dist/wrangler/live-version.js +326 -0
  45. package/dist/wrangler/stage-secrets.d.ts +57 -0
  46. package/dist/wrangler/stage-secrets.d.ts.map +1 -0
  47. package/dist/wrangler/stage-secrets.js +178 -0
  48. package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
  49. package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
  50. package/package.json +2 -2
  51. package/src/cli.ts +69 -1
  52. package/src/github-ci/index.test.ts +92 -8
  53. package/src/github-ci/index.ts +7 -10
  54. package/src/lib/secret-names.ts +70 -0
  55. package/src/monorepo/__tests__/ci-workflow.test.ts +188 -2
  56. package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
  57. package/src/monorepo/ci-workflow.ts +294 -8
  58. package/src/monorepo/managed-files.test.ts +28 -1
  59. package/src/monorepo/managed-files.ts +56 -2
  60. package/src/monorepo/package-policy.test.ts +1 -1
  61. package/src/monorepo/publish-workflow.ts +65 -19
  62. package/src/monorepo/secret-references.test.ts +1 -1
  63. package/src/release/__tests__/github-release.test.ts +8 -4
  64. package/src/release/__tests__/private-npm-status.test.ts +2 -2
  65. package/src/release/github-release.ts +13 -5
  66. package/src/release/index.ts +4 -4
  67. package/src/secrets/commands.test.ts +28 -0
  68. package/src/secrets/commands.ts +412 -0
  69. package/src/secrets/index.test.ts +209 -0
  70. package/src/secrets/index.ts +287 -0
  71. package/src/secrets/repository.test.ts +98 -0
  72. package/src/secrets/repository.ts +164 -0
  73. package/src/wrangler/cloudflare.ts +18 -0
  74. package/src/wrangler/deploy-stage.test.ts +448 -23
  75. package/src/wrangler/deploy-stage.ts +142 -83
  76. package/src/wrangler/deployed-version.test.ts +150 -0
  77. package/src/wrangler/deployed-version.ts +130 -0
  78. package/src/wrangler/live-version.ts +363 -0
  79. package/src/wrangler/stage-secrets.ts +146 -0
@@ -0,0 +1,326 @@
1
+ import * as _validateReport_1 from "typia/lib/internal/_validateReport";
2
+ // What is live right now, as opposed to what we last uploaded.
3
+ //
4
+ // `wrangler versions deploy` returns when Cloudflare ACCEPTS the traffic shift, not when the edge
5
+ // serves it. A deploy that returns is therefore not yet a deploy that answers: the site that signs
6
+ // in to its stage's backend measured the previous backend version's response about 20 s after the
7
+ // backend's deploy step had already gone green. Everything here exists to turn "we asked" into
8
+ // "we observed".
9
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
10
+ import { dirname, join } from 'node:path';
11
+ import typia from 'typia';
12
+ const isUnknownRecord = (() => {
13
+ const _io0 = input => Object.keys(input).every(key => {
14
+ const value = input[key];
15
+ if (undefined === value)
16
+ return true;
17
+ return true;
18
+ });
19
+ return input => "object" === typeof input && null !== input && false === Array.isArray(input) && _io0(input);
20
+ })();
21
+ /**
22
+ * Every `{ id -> tag }` pair the payload carries, in document order.
23
+ *
24
+ * Wrangler nests versions differently per subcommand and per version of itself, spells the tag
25
+ * three ways (`annotations['workers/tag']`, a bare `tag`, `metadata.tag`) and the id two (`id`,
26
+ * `version_id`). One walk collects the relation once; both directions of the lookup are then a Map
27
+ * read rather than a second recursive scan that could disagree with the first.
28
+ */
29
+ export function collectVersionTags(value) {
30
+ const pairs = new Map();
31
+ collectInto(value, pairs);
32
+ return pairs;
33
+ }
34
+ function collectInto(value, pairs) {
35
+ if (Array.isArray(value)) {
36
+ for (const entry of value)
37
+ collectInto(entry, pairs);
38
+ return;
39
+ }
40
+ if (!isUnknownRecord(value))
41
+ return;
42
+ const annotations = isUnknownRecord(value.annotations) ? value.annotations : undefined;
43
+ const metadata = isUnknownRecord(value.metadata) ? value.metadata : undefined;
44
+ const annotationTag = annotations?.['workers/tag'];
45
+ const candidateTag = typeof annotationTag === 'string' ? annotationTag : typeof value.tag === 'string' ? value.tag : metadata?.tag;
46
+ const candidateId = typeof value.id === 'string' ? value.id : typeof value.version_id === 'string' ? value.version_id : undefined;
47
+ if (typeof candidateTag === 'string' && candidateId !== undefined && !pairs.has(candidateId)) {
48
+ pairs.set(candidateId, candidateTag);
49
+ }
50
+ for (const nested of Object.values(value))
51
+ collectInto(nested, pairs);
52
+ }
53
+ /** The version id whose tag is `tag`, or null. */
54
+ export function findVersionIdByTag(value, tag) {
55
+ for (const [id, candidate] of collectVersionTags(value)) {
56
+ if (candidate === tag)
57
+ return id;
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * The current deployment's traffic split, or null when the payload names no deployment at all.
63
+ *
64
+ * "Nothing is deployed" and "two versions share the traffic" are different facts with different
65
+ * remedies, and a caller that collapses them cannot tell an operator which one it hit — so the
66
+ * split is returned whole and both questions are answered from it.
67
+ */
68
+ export function currentDeploymentVersions(value) {
69
+ if (Array.isArray(value)) {
70
+ for (const entry of value) {
71
+ const found = currentDeploymentVersions(entry);
72
+ if (found)
73
+ return found;
74
+ }
75
+ return null;
76
+ }
77
+ if (!isUnknownRecord(value))
78
+ return null;
79
+ if (Array.isArray(value.versions)) {
80
+ return value.versions.map((version) => {
81
+ if (!isUnknownRecord(version))
82
+ return { versionId: null, percentage: 0 };
83
+ const id = version.version_id ?? version.id;
84
+ return { versionId: typeof id === 'string' ? id : null, percentage: Number(version.percentage) };
85
+ });
86
+ }
87
+ for (const nested of Object.values(value)) {
88
+ const found = currentDeploymentVersions(nested);
89
+ if (found)
90
+ return found;
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * The one version serving 100% of traffic, or null when there is none or the deployment is split.
96
+ *
97
+ * A split is not "close enough": with two versions live, "is our version live" has no single
98
+ * answer, and answering it optimistically is how a deploy reports success while part of the
99
+ * traffic still reaches the old code.
100
+ */
101
+ export function currentDeploymentVersionId(value) {
102
+ const shares = currentDeploymentVersions(value);
103
+ if (shares?.length !== 1)
104
+ return null;
105
+ const [only] = shares;
106
+ return only.percentage === 100 ? only.versionId : null;
107
+ }
108
+ /** The version serving all traffic for this worker, with the tag it was uploaded under. */
109
+ export async function readLiveVersion(probe) {
110
+ const deployments = await probe.deployments();
111
+ const shares = currentDeploymentVersions(deployments);
112
+ const versionId = currentDeploymentVersionId(deployments);
113
+ if (!versionId) {
114
+ return shares && shares.length > 0
115
+ ? {
116
+ ok: false,
117
+ error: {
118
+ kind: 'split-traffic',
119
+ message: 'no single worker version is serving 100% of traffic; refusing to name one as live',
120
+ },
121
+ }
122
+ : { ok: false, error: { kind: 'never-deployed', message: 'this worker has no active deployment' } };
123
+ }
124
+ const tag = collectVersionTags(await probe.versions()).get(versionId) ?? null;
125
+ return { ok: true, value: { versionId, tag } };
126
+ }
127
+ /**
128
+ * How long a deploy waits for the version it just activated to become the one being served.
129
+ *
130
+ * The bound is the point: an unbounded loop turns a stuck propagation into a hung CI job nobody
131
+ * reads, and a fixed sleep turns it into a green job that lied. Both are worse than a red job
132
+ * naming what it expected and what it saw.
133
+ */
134
+ export const LIVE_VERSION_WAIT_BUDGET_MS = 120_000;
135
+ /** Cloudflare's control plane converges in seconds; polling faster than this only burns API quota. */
136
+ export const LIVE_VERSION_POLL_INTERVAL_MS = 2_000;
137
+ /**
138
+ * Runs `attempt` until it succeeds or the budget expires, and reports the LAST thing it saw.
139
+ *
140
+ * Both waits below need the same deadline arithmetic and the same "what did you see instead"
141
+ * record; sharing it keeps the two from drifting into disagreeing about when a wait is over.
142
+ */
143
+ async function pollWithinBudget(attempt, options) {
144
+ const budgetMs = options.budgetMs ?? LIVE_VERSION_WAIT_BUDGET_MS;
145
+ const intervalMs = options.intervalMs ?? LIVE_VERSION_POLL_INTERVAL_MS;
146
+ const now = options.now ?? Date.now;
147
+ const sleep = options.sleep ?? sleepFor;
148
+ const deadline = now() + budgetMs;
149
+ for (;;) {
150
+ const result = await attempt();
151
+ if (result.done)
152
+ return { ok: true, value: result.value };
153
+ if (now() >= deadline)
154
+ return { ok: false, error: { observed: result.observed, waitedMs: budgetMs } };
155
+ await sleep(intervalMs);
156
+ }
157
+ }
158
+ function sleepFor(ms) {
159
+ const { promise, resolve } = Promise.withResolvers();
160
+ setTimeout(resolve, ms);
161
+ return promise;
162
+ }
163
+ /**
164
+ * Polls until the worker's live version carries `expectedTag`, or the budget runs out.
165
+ *
166
+ * The tag is the match, not the version id: the tag IS the desired-state identity (`nx-<task
167
+ * hash>`), it is what both `wrangler deploy --tag` and `wrangler versions deploy --version-tag`
168
+ * were told to make live, and it is the one name the caller knows before the upload has produced
169
+ * a version id.
170
+ *
171
+ * Returns the failure rather than throwing it: a deploy that cannot be observed is an operational
172
+ * outcome the caller has to report, not a broken invariant.
173
+ */
174
+ export async function awaitLiveVersion(probe, expectedTag, options = {}) {
175
+ const polled = await pollWithinBudget(async () => {
176
+ const live = await readLiveVersion(probe);
177
+ if (!live.ok)
178
+ return { done: false, observed: live.error.message };
179
+ const { tag, versionId } = live.value;
180
+ if (tag === expectedTag)
181
+ return { done: true, value: live.value };
182
+ return { done: false, observed: tag ? `${tag} (${versionId})` : `an untagged version (${versionId})` };
183
+ }, options);
184
+ if (polled.ok)
185
+ return polled;
186
+ return {
187
+ ok: false,
188
+ error: {
189
+ kind: 'not-observed',
190
+ expected: expectedTag,
191
+ observed: polled.error.observed,
192
+ message: `deployed version ${expectedTag} was not serving traffic after ` +
193
+ `${Math.round(polled.error.waitedMs / 1000)}s; live is ${polled.error.observed}`,
194
+ },
195
+ };
196
+ }
197
+ /**
198
+ * Polls a worker-served endpoint until it answers with `expectedTag`.
199
+ *
200
+ * The control plane agreeing is necessary and not sufficient — that gap is exactly what this
201
+ * change's predecessor measured — so a project that can prove the edge serves the new code says so
202
+ * with an endpoint echoing its own version tag (Workers read it from the version-metadata
203
+ * binding). The contract is deliberately one shape: the trimmed response body IS the tag.
204
+ */
205
+ export async function awaitVersionEndpoint(url, expectedTag, options = {}) {
206
+ const request = options.fetch ?? fetch;
207
+ const polled = await pollWithinBudget(async () => {
208
+ const observed = await probeVersionEndpoint(request, url);
209
+ return observed === expectedTag ? { done: true, value: undefined } : { done: false, observed };
210
+ }, options);
211
+ if (polled.ok)
212
+ return polled;
213
+ return {
214
+ ok: false,
215
+ error: {
216
+ kind: 'not-observed',
217
+ expected: expectedTag,
218
+ observed: polled.error.observed,
219
+ message: `${url} did not report version ${expectedTag} after ` +
220
+ `${Math.round(polled.error.waitedMs / 1000)}s; it reported ${polled.error.observed}`,
221
+ },
222
+ };
223
+ }
224
+ async function probeVersionEndpoint(request, url) {
225
+ try {
226
+ const response = await request(url, { headers: { accept: 'text/plain' } });
227
+ const body = (await response.text()).trim();
228
+ return response.ok ? body : `HTTP ${response.status} ${body}`.trim();
229
+ }
230
+ catch (error) {
231
+ // A worker mid-rollout refuses connections; that is a poll result, not a reason to abandon the
232
+ // wait. It only becomes the failure when it is still the answer at the deadline.
233
+ return error instanceof Error ? error.message : String(error);
234
+ }
235
+ }
236
+ /**
237
+ * How long `smoo wrangler deployed-version` trusts its own last answer.
238
+ *
239
+ * This TTL is a convenience for operators and repeated local queries, NOT a correctness mechanism:
240
+ * nothing that decides whether to deploy may read it. The deploy's own liveness check always calls
241
+ * Cloudflare, because a cached "live already equals the desired tag" is precisely the belief a
242
+ * rollback falsifies, and acting on it would skip the deploy that repairs the rollback.
243
+ */
244
+ export const LIVE_VERSION_CACHE_TTL_MS = 45_000;
245
+ const parseCachedLiveVersion = (() => {
246
+ const _io0 = input => (null === input.versionTag || "string" === typeof input.versionTag) && "string" === typeof input.versionId && "number" === typeof input.fetchedAt;
247
+ const _vo0 = (input, _path, _exceptionable = true) => [null === input.versionTag || "string" === typeof input.versionTag || _report(_exceptionable, {
248
+ path: _path + ".versionTag",
249
+ expected: "(null | string)",
250
+ value: input.versionTag
251
+ }), "string" === typeof input.versionId || _report(_exceptionable, {
252
+ path: _path + ".versionId",
253
+ expected: "string",
254
+ value: input.versionId
255
+ }), "number" === typeof input.fetchedAt || _report(_exceptionable, {
256
+ path: _path + ".fetchedAt",
257
+ expected: "number",
258
+ value: input.fetchedAt
259
+ })].every(flag => flag);
260
+ const __is = input => "object" === typeof input && null !== input && _io0(input);
261
+ let errors;
262
+ let _report;
263
+ const __validate = input => {
264
+ if (false === __is(input)) {
265
+ errors = [];
266
+ _report = _validateReport_1._validateReport(errors);
267
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input || _report(true, {
268
+ path: _path + "",
269
+ expected: "CachedLiveVersion",
270
+ value: input
271
+ })) && _vo0(input, _path + "", true) || _report(true, {
272
+ path: _path + "",
273
+ expected: "CachedLiveVersion",
274
+ value: input
275
+ }))(input, "$input", true);
276
+ const success = 0 === errors.length;
277
+ return success ? {
278
+ success,
279
+ data: input
280
+ } : {
281
+ success,
282
+ errors,
283
+ data: input
284
+ };
285
+ }
286
+ return {
287
+ success: true,
288
+ data: input
289
+ };
290
+ };
291
+ return input => __validate(JSON.parse(input));
292
+ })();
293
+ /**
294
+ * One file per key, so two deploys running in parallel cannot clobber each other's answer; a
295
+ * shared map would need a lock to say the same thing.
296
+ */
297
+ export function liveVersionCachePath(cacheDirectory, key) {
298
+ const name = [key.accountId, key.workerName, key.stage].map(encodeURIComponent).join('_');
299
+ return join(cacheDirectory, 'smoo-live-version', `${name}.json`);
300
+ }
301
+ export async function readCachedLiveVersion(cacheDirectory, key, ttlMs, now = Date.now) {
302
+ let text;
303
+ try {
304
+ text = await readFile(liveVersionCachePath(cacheDirectory, key), 'utf8');
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ const parsed = parseCachedLiveVersion(text);
310
+ if (!parsed.success)
311
+ return null;
312
+ return now() - parsed.data.fetchedAt < ttlMs ? parsed.data : null;
313
+ }
314
+ export async function writeCachedLiveVersion(cacheDirectory, key, entry) {
315
+ const path = liveVersionCachePath(cacheDirectory, key);
316
+ await mkdir(dirname(path), { recursive: true });
317
+ await writeFile(path, `${JSON.stringify(entry)}\n`);
318
+ }
319
+ /**
320
+ * Where the cache lives. Nx's workspace data directory already exists, is already gitignored, and
321
+ * is already wiped when the workspace is reset — three properties a hand-rolled directory would
322
+ * each have to re-earn, and one ($HOME) that would leak one checkout's answers into another's.
323
+ */
324
+ export function liveVersionCacheDirectory(workspaceRoot, environment) {
325
+ return environment.NX_WORKSPACE_DATA_DIRECTORY ?? join(workspaceRoot, '.nx', 'workspace-data');
326
+ }
@@ -0,0 +1,57 @@
1
+ import { type DeploymentStage } from './stage.js';
2
+ /**
3
+ * How a declaration names a stage. The fixed stages go by their own name; every `prN` stage is
4
+ * `preview`, because a scope is written once and pull-request numbers are not knowable in advance.
5
+ */
6
+ export type SecretStageScope = 'staging' | 'production' | 'preview';
7
+ /**
8
+ * `smoo.wrangler.secretStages`: a declared secret name mapped to the stages it belongs to. The map
9
+ * answers two questions with one declaration, and both directions matter:
10
+ *
11
+ * - requirement — a stage the secret belongs to refuses to deploy without a value for it;
12
+ * - permission — a stage the secret does *not* belong to never receives it, however loudly the
13
+ * deploying shell exports it. A test-only capability exported by CI for preview stages must not
14
+ * ride along into production just because the variable happens to be set.
15
+ *
16
+ * A declared name absent from the map belongs to every stage. A name mapped to `[]` belongs to no
17
+ * stage, which is how a value that exists only for local development is declared.
18
+ */
19
+ export type SecretStageMap = Record<string, SecretStageScope[]>;
20
+ /** Secret NAMES the project declares. Values live on the Worker; the repo only ever holds the keys. */
21
+ export declare function readDeclaredSecretNames(cwd: string): string[];
22
+ /** `smoo.wrangler.secretStages` from the project's package.json; no file and no block scope nothing. */
23
+ export declare function readSecretStageMap(cwd: string): SecretStageMap;
24
+ /** Which of a project's declared secrets one stage may see, and which belong to other stages. */
25
+ export interface StageSecretPlan {
26
+ stage: DeploymentStage;
27
+ /** Declared names this stage requires — and the only ones a deploy of it may carry. */
28
+ required: string[];
29
+ /** Declared names scoped to other stages: withheld from this deploy even when a value is exported. */
30
+ withheld: string[];
31
+ /** The declaration itself, so a refusal can say why each name is where it is. */
32
+ scopes: SecretStageMap;
33
+ }
34
+ /**
35
+ * Splits the declared secrets by whether `stage` is in scope for each.
36
+ *
37
+ * A scope on an undeclared name is refused rather than ignored: it is almost always a typo of a
38
+ * real secret's name, and its effect is the dangerous direction — the misspelt entry scopes
39
+ * nothing while the real secret, still absent from the map, reaches every stage.
40
+ */
41
+ export declare function planStageSecrets(declared: string[], stage: DeploymentStage, scopes: SecretStageMap): StageSecretPlan;
42
+ /**
43
+ * Why this deploy must not proceed, or nothing when it may.
44
+ *
45
+ * Two refusals, reported together so one run names every problem:
46
+ *
47
+ * - a required secret with no value anywhere. `--secrets-file` applies additively, so a deploy
48
+ * that never mentions a secret leaves whatever the Worker already holds — silence that reads as
49
+ * success while a secret introduced after the first deploy never arrives, and the code that
50
+ * needs it fails at runtime instead of here.
51
+ * - a withheld secret the Worker already holds. Filtering it out of this deploy's payload cannot
52
+ * remove it, so the scope would be nominal rather than enforced until someone deletes it.
53
+ *
54
+ * A value is never read, never formatted, and never named beyond its key.
55
+ */
56
+ export declare function stageSecretRefusal(plan: StageSecretPlan, exported: ReadonlySet<string>, held: ReadonlySet<string>, workerName: string): string | undefined;
57
+ //# sourceMappingURL=stage-secrets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stage-secrets.d.ts","sourceRoot":"","sources":["../../src/wrangler/stage-secrets.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,eAAe,EAAsB,MAAM,YAAY,CAAC;AAEtE;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;AAahE,uGAAuG;AACvG,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAG7D;AAED,wGAAwG;AACxG,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAQ9D;AAED,iGAAiG;AACjG,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,eAAe,CAAC;IACvB,uFAAuF;IACvF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,sGAAsG;IACtG,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,iFAAiF;IACjF,MAAM,EAAE,cAAc,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,cAAc,GAAG,eAAe,CAsBpH;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,eAAe,EACrB,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,EAC7B,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EACzB,UAAU,EAAE,MAAM,GACjB,MAAM,GAAG,SAAS,CAsBpB"}
@@ -0,0 +1,178 @@
1
+ import * as _accessExpressionAsString_1 from "typia/lib/internal/_accessExpressionAsString";
2
+ const __typia_transform__accessExpressionAsString = _accessExpressionAsString_1._accessExpressionAsString;
3
+ import * as _validateReport_1 from "typia/lib/internal/_validateReport";
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import typia from 'typia';
7
+ import { formatValidationErrors, parseJsonFileText } from '../lib/json.js';
8
+ import { parseDevVarsExample } from './prepare-env.js';
9
+ import { isPullRequestStage } from './stage.js';
10
+ const validateWranglerManifest = (() => {
11
+ const _io0 = input => undefined === input.smoo || "object" === typeof input.smoo && null !== input.smoo && false === Array.isArray(input.smoo) && _io1(input.smoo);
12
+ const _io1 = input => undefined === input.wrangler || "object" === typeof input.wrangler && null !== input.wrangler && false === Array.isArray(input.wrangler) && _io2(input.wrangler);
13
+ const _io2 = input => undefined === input.secretStages || "object" === typeof input.secretStages && null !== input.secretStages && false === Array.isArray(input.secretStages) && _io3(input.secretStages);
14
+ const _io3 = input => Object.keys(input).every(key => {
15
+ const value = input[key];
16
+ if (undefined === value)
17
+ return true;
18
+ return Array.isArray(value) && value.every(elem => "preview" === elem || "production" === elem || "staging" === elem);
19
+ });
20
+ const _vo0 = (input, _path, _exceptionable = true) => [undefined === input.smoo || ("object" === typeof input.smoo && null !== input.smoo && false === Array.isArray(input.smoo) || _report(_exceptionable, {
21
+ path: _path + ".smoo",
22
+ expected: "(undefined | { wrangler?: { secretStages?: SecretStageMap | undefined; } | undefined; })",
23
+ value: input.smoo
24
+ })) && _vo1(input.smoo, _path + ".smoo", true && _exceptionable) || _report(_exceptionable, {
25
+ path: _path + ".smoo",
26
+ expected: "(undefined | { wrangler?: { secretStages?: SecretStageMap | undefined; } | undefined; })",
27
+ value: input.smoo
28
+ })].every(flag => flag);
29
+ const _vo1 = (input, _path, _exceptionable = true) => [undefined === input.wrangler || ("object" === typeof input.wrangler && null !== input.wrangler && false === Array.isArray(input.wrangler) || _report(_exceptionable, {
30
+ path: _path + ".wrangler",
31
+ expected: "(undefined | { secretStages?: SecretStageMap | undefined; })",
32
+ value: input.wrangler
33
+ })) && _vo2(input.wrangler, _path + ".wrangler", true && _exceptionable) || _report(_exceptionable, {
34
+ path: _path + ".wrangler",
35
+ expected: "(undefined | { secretStages?: SecretStageMap | undefined; })",
36
+ value: input.wrangler
37
+ })].every(flag => flag);
38
+ const _vo2 = (input, _path, _exceptionable = true) => [undefined === input.secretStages || ("object" === typeof input.secretStages && null !== input.secretStages && false === Array.isArray(input.secretStages) || _report(_exceptionable, {
39
+ path: _path + ".secretStages",
40
+ expected: "(SecretStageMap | undefined)",
41
+ value: input.secretStages
42
+ })) && _vo3(input.secretStages, _path + ".secretStages", true && _exceptionable) || _report(_exceptionable, {
43
+ path: _path + ".secretStages",
44
+ expected: "(SecretStageMap | undefined)",
45
+ value: input.secretStages
46
+ })].every(flag => flag);
47
+ const _vo3 = (input, _path, _exceptionable = true) => [false === _exceptionable || Object.keys(input).map(key => {
48
+ const value = input[key];
49
+ if (undefined === value)
50
+ return true;
51
+ return (Array.isArray(value) || _report(_exceptionable, {
52
+ path: _path + __typia_transform__accessExpressionAsString(key),
53
+ expected: "Array<SecretStageScope>",
54
+ value: value
55
+ })) && value.map((elem, _index2) => "preview" === elem || "production" === elem || "staging" === elem || _report(_exceptionable, {
56
+ path: _path + __typia_transform__accessExpressionAsString(key) + "[" + _index2 + "]",
57
+ expected: "(\"preview\" | \"production\" | \"staging\")",
58
+ value: elem
59
+ })).every(flag => flag) || _report(_exceptionable, {
60
+ path: _path + __typia_transform__accessExpressionAsString(key),
61
+ expected: "Array<SecretStageScope>",
62
+ value: value
63
+ });
64
+ }).every(flag => flag)].every(flag => flag);
65
+ const __is = input => "object" === typeof input && null !== input && false === Array.isArray(input) && _io0(input);
66
+ let errors;
67
+ let _report;
68
+ const __validate = input => {
69
+ if (false === __is(input)) {
70
+ errors = [];
71
+ _report = _validateReport_1._validateReport(errors);
72
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input && false === Array.isArray(input) || _report(true, {
73
+ path: _path + "",
74
+ expected: "WranglerPackageManifest",
75
+ value: input
76
+ })) && _vo0(input, _path + "", true) || _report(true, {
77
+ path: _path + "",
78
+ expected: "WranglerPackageManifest",
79
+ value: input
80
+ }))(input, "$input", true);
81
+ const success = 0 === errors.length;
82
+ return success ? {
83
+ success,
84
+ data: input
85
+ } : {
86
+ success,
87
+ errors,
88
+ data: input
89
+ };
90
+ }
91
+ return {
92
+ success: true,
93
+ data: input
94
+ };
95
+ };
96
+ return input => __validate(JSON.parse(input));
97
+ })();
98
+ /** Secret NAMES the project declares. Values live on the Worker; the repo only ever holds the keys. */
99
+ export function readDeclaredSecretNames(cwd) {
100
+ const path = join(cwd, '.dev.vars.example');
101
+ return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
102
+ }
103
+ /** `smoo.wrangler.secretStages` from the project's package.json; no file and no block scope nothing. */
104
+ export function readSecretStageMap(cwd) {
105
+ const path = join(cwd, 'package.json');
106
+ if (!existsSync(path))
107
+ return {};
108
+ const result = parseJsonFileText(path, readFileSync(path, 'utf8'), validateWranglerManifest);
109
+ if (!result.success) {
110
+ throw new Error(`${path} declares an invalid smoo.wrangler block: ${formatValidationErrors(result.errors)}`);
111
+ }
112
+ return result.data.smoo?.wrangler?.secretStages ?? {};
113
+ }
114
+ /**
115
+ * Splits the declared secrets by whether `stage` is in scope for each.
116
+ *
117
+ * A scope on an undeclared name is refused rather than ignored: it is almost always a typo of a
118
+ * real secret's name, and its effect is the dangerous direction — the misspelt entry scopes
119
+ * nothing while the real secret, still absent from the map, reaches every stage.
120
+ */
121
+ export function planStageSecrets(declared, stage, scopes) {
122
+ const declaredNames = new Set(declared);
123
+ const undeclared = Object.keys(scopes).filter((name) => !declaredNames.has(name));
124
+ if (undeclared.length > 0) {
125
+ throw new Error(`smoo.wrangler.secretStages scopes ${undeclared.join(', ')}, which .dev.vars.example does not declare. ` +
126
+ 'A scope on a name no secret has leaves the secret it was meant for unscoped, so that secret reaches every stage.');
127
+ }
128
+ // Every `prN` stage answers to one written token: a scope cannot name pull requests in advance.
129
+ const scope = isPullRequestStage(stage) ? 'preview' : stage;
130
+ const required = [];
131
+ const withheld = [];
132
+ for (const name of declared) {
133
+ const declaredScope = scopes[name];
134
+ if (declaredScope === undefined || declaredScope.includes(scope)) {
135
+ required.push(name);
136
+ }
137
+ else {
138
+ withheld.push(name);
139
+ }
140
+ }
141
+ return { stage, required, withheld, scopes };
142
+ }
143
+ /**
144
+ * Why this deploy must not proceed, or nothing when it may.
145
+ *
146
+ * Two refusals, reported together so one run names every problem:
147
+ *
148
+ * - a required secret with no value anywhere. `--secrets-file` applies additively, so a deploy
149
+ * that never mentions a secret leaves whatever the Worker already holds — silence that reads as
150
+ * success while a secret introduced after the first deploy never arrives, and the code that
151
+ * needs it fails at runtime instead of here.
152
+ * - a withheld secret the Worker already holds. Filtering it out of this deploy's payload cannot
153
+ * remove it, so the scope would be nominal rather than enforced until someone deletes it.
154
+ *
155
+ * A value is never read, never formatted, and never named beyond its key.
156
+ */
157
+ export function stageSecretRefusal(plan, exported, held, workerName) {
158
+ const unavailable = plan.required.filter((name) => !exported.has(name) && !held.has(name));
159
+ const installed = plan.withheld.filter((name) => held.has(name));
160
+ if (unavailable.length === 0 && installed.length === 0)
161
+ return undefined;
162
+ const lines = [`Refusing to deploy ${workerName} to ${plan.stage}.`];
163
+ if (unavailable.length > 0) {
164
+ lines.push(`Stage ${plan.stage} requires these secrets and no value exists for them, neither in this environment nor on the Worker:`, ...unavailable.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`), 'Export each one in the deploying shell before the deploy. Once the Worker exists,', `\`wrangler secret put <NAME> --name ${workerName}\` supplies it too.`);
165
+ }
166
+ if (installed.length > 0) {
167
+ lines.push(`The Worker holds these secrets, which smoo.wrangler.secretStages keeps out of ${plan.stage}:`, ...installed.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`), `Delete each one with \`wrangler secret delete <NAME> --name ${workerName}\`. A deploy of this stage`, 'never sends them, so leaving them installed would keep the scope nominal.');
168
+ }
169
+ return lines.join('\n');
170
+ }
171
+ function scopeDescription(name, scopes) {
172
+ const scope = scopes[name];
173
+ if (scope === undefined)
174
+ return 'unscoped, so every stage requires it';
175
+ if (scope.length === 0)
176
+ return 'scoped to no stage (local development only)';
177
+ return `scoped to ${scope.join(', ')}`;
178
+ }
@@ -354,7 +354,15 @@
354
354
  export GOFLAGS="''${GOFLAGS:--trimpath}"
355
355
  unset GOROOT
356
356
  bun "$DEVENV_ROOT/setup-environment.ts" || exit $?
357
- export NX_SOCKET_DIR="$DEVENV_RUNTIME/nx"
357
+ # One socket dir per Nx workspace. DEVENV_RUNTIME is keyed to the devenv
358
+ # ROOT, so every workspace sharing one devenv - a sibling repository, a
359
+ # copy-on-write clone, a scratch workspace created inside this shell -
360
+ # would land on one socket. The first daemon to claim it then refuses
361
+ # every message from the others ("received a message from a different
362
+ # workspace"), which reads as a hung Nx in a workspace that did nothing
363
+ # wrong. Nx's own diagnostic names this exact cause.
364
+ nx_workspace_root="$(cd "$DEVENV_ROOT/../.." >/dev/null 2>&1 && pwd || printf '%s' "$PWD")"
365
+ export NX_SOCKET_DIR="$DEVENV_RUNTIME/nx-$(printf '%s' "$nx_workspace_root" | cksum | cut -d' ' -f1)"
358
366
  mkdir -p "$NX_SOCKET_DIR"
359
367
  ${lib.optionalString pkgs.stdenv.isDarwin ''
360
368
  unset CC CXX
@@ -1,25 +1,25 @@
1
1
  #!/usr/bin/env bash
2
- # macOS-only Linux compile gate. Everything in tooling depends only on
3
- # devenv.nix packages, so this hook must also work without devenv on PATH
4
- # but it may not pretend a check ran that never did. Nx task hashes can
5
- # differ between the bare shell and the linux-cross profile, so a bare miss
6
- # proves nothing on its own. Policy, in order:
7
- # 1. Bare `nx run-many -t cargo-lint-cross`: a hit is a prior real
8
- # `cargo clippy --target x86_64-unknown-linux-gnu`; pass with no toolchain.
9
- # 2. If a Nix-built devenv is on PATH, `bun run check:linux` enters the
10
- # linux-cross profile properly and its result decides the push.
11
- # 3. Otherwise refuse with the recovery command instead of a toolchain error
12
-
13
- # A Nix-built devenv binary (under /nix/store), skipping the repo wrapper
14
- # scripts that also answer to this name but cannot enter a profile alone.
15
- has_nix_devenv() {
16
- while IFS= read -r candidate; do
17
- case "$(realpath "$candidate" 2>/dev/null)" in
18
- /nix/store/*) return 0 ;;
19
- esac
20
- done < <(which -a devenv 2>/dev/null)
21
- return 1
22
- }
2
+ # macOS-only Linux cross-compile gate. This hook is a cache PROBE and nothing
3
+ # else: it reads the nx cache and never compiles. A hit means a real
4
+ # `cargo clippy --target x86_64-unknown-linux-gnu` already passed for exactly
5
+ # this tree, so the push is safe with no toolchain present. Anything else
6
+ # refuses the push and names the one command that fixes it.
7
+ #
8
+ # Why probe-only. The cross-clippy needs the linux-cross C toolchain, which
9
+ # lives in a devenv profile. A hook that entered that profile spent minutes
10
+ # compiling on a push the user expected to take a second, and it did so in an
11
+ # environment they never asked for. Pushing is not the place to discover that
12
+ # the tree has not been compiled for Linux; `bun run check:linux` is.
13
+ #
14
+ # CC_x86_64_unknown_linux_gnu is unset for the probe deliberately. The target's
15
+ # own guard reads it to decide whether the toolchain is present, so leaving it
16
+ # set would let a push made from inside an already-entered linux-cross shell
17
+ # fall through into a real multi-minute compile. It is not a declared input of
18
+ # the target, so unsetting it cannot change the task hash — a warm entry still
19
+ # hits.
20
+ #
21
+ # Everything in tooling depends only on devenv.nix packages, so this hook also
22
+ # works with no devenv on PATH. It must never pretend a check ran that did not.
23
23
 
24
24
  cd "$(git rev-parse --show-toplevel)"
25
25
  TOOLING="$PWD/tooling"
@@ -31,17 +31,18 @@ case "$(uname -s)" in
31
31
  *) exit 0 ;;
32
32
  esac
33
33
 
34
- if nx run-many -t cargo-lint-cross; then
34
+ if env -u CC_x86_64_unknown_linux_gnu nx run-many -t cargo-lint-cross --output-style=static; then
35
35
  exit 0
36
36
  fi
37
37
 
38
- if has_nix_devenv; then
39
- exec bun run check:linux
40
- fi
41
-
42
38
  cat >&2 <<'EOF'
43
- pre-push: cargo-lint-cross is not cached for this tree and no Nix-built
44
- devenv is on PATH to enter the linux-cross profile. Run `bun run check:linux`
45
- from a shell with nix on PATH, wait for it to pass, then push again.
39
+
40
+ pre-push: the Linux cross-compile check is NOT cached for this tree, so this
41
+ push would ship code that has never been compiled for Linux. This hook only
42
+ reads the cache; it does not build. The "needs the linux-cross C toolchain"
43
+ line above is the probe refusing to compile, not a broken toolchain.
44
+
45
+ Run: bun run check:linux
46
+ Wait for it to pass, then push again.
46
47
  EOF
47
48
  exit 1