@rexezuge/tooling 0.0.0-stage → 1.0.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/dist/eslint-boundaries.d.ts +75 -0
  4. package/dist/eslint-boundaries.d.ts.map +1 -0
  5. package/dist/eslint-boundaries.js +138 -0
  6. package/dist/eslint-boundaries.js.map +1 -0
  7. package/dist/eslint-rules.d.ts +41 -0
  8. package/dist/eslint-rules.d.ts.map +1 -0
  9. package/dist/eslint-rules.js +273 -0
  10. package/dist/eslint-rules.js.map +1 -0
  11. package/dist/eslint.d.ts +96 -0
  12. package/dist/eslint.d.ts.map +1 -0
  13. package/dist/eslint.js +189 -0
  14. package/dist/eslint.js.map +1 -0
  15. package/dist/functions/pages-proxy.d.ts +113 -0
  16. package/dist/functions/pages-proxy.d.ts.map +1 -0
  17. package/dist/functions/pages-proxy.js +131 -0
  18. package/dist/functions/pages-proxy.js.map +1 -0
  19. package/dist/index.d.ts +31 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +28 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/scripts/backup/d1-target.d.ts +35 -0
  24. package/dist/scripts/backup/d1-target.d.ts.map +1 -0
  25. package/dist/scripts/backup/d1-target.js +31 -0
  26. package/dist/scripts/backup/d1-target.js.map +1 -0
  27. package/dist/scripts/backup/destination-config.d.ts +61 -0
  28. package/dist/scripts/backup/destination-config.d.ts.map +1 -0
  29. package/dist/scripts/backup/destination-config.js +57 -0
  30. package/dist/scripts/backup/destination-config.js.map +1 -0
  31. package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
  32. package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
  33. package/dist/scripts/backup/encrypt-backup.js +98 -0
  34. package/dist/scripts/backup/encrypt-backup.js.map +1 -0
  35. package/dist/scripts/backup/naming.d.ts +44 -0
  36. package/dist/scripts/backup/naming.d.ts.map +1 -0
  37. package/dist/scripts/backup/naming.js +58 -0
  38. package/dist/scripts/backup/naming.js.map +1 -0
  39. package/dist/scripts/check-god-files.d.ts +164 -0
  40. package/dist/scripts/check-god-files.d.ts.map +1 -0
  41. package/dist/scripts/check-god-files.js +271 -0
  42. package/dist/scripts/check-god-files.js.map +1 -0
  43. package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
  44. package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
  45. package/dist/scripts/ensure-spa-shell-stub.js +30 -0
  46. package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
  47. package/dist/scripts/init-secrets.d.ts +63 -0
  48. package/dist/scripts/init-secrets.d.ts.map +1 -0
  49. package/dist/scripts/init-secrets.js +240 -0
  50. package/dist/scripts/init-secrets.js.map +1 -0
  51. package/dist/scripts/lib/cli-args.d.ts +78 -0
  52. package/dist/scripts/lib/cli-args.d.ts.map +1 -0
  53. package/dist/scripts/lib/cli-args.js +116 -0
  54. package/dist/scripts/lib/cli-args.js.map +1 -0
  55. package/dist/scripts/lib/github-actions.d.ts +26 -0
  56. package/dist/scripts/lib/github-actions.d.ts.map +1 -0
  57. package/dist/scripts/lib/github-actions.js +38 -0
  58. package/dist/scripts/lib/github-actions.js.map +1 -0
  59. package/dist/scripts/lib/wrangler-table.d.ts +46 -0
  60. package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
  61. package/dist/scripts/lib/wrangler-table.js +99 -0
  62. package/dist/scripts/lib/wrangler-table.js.map +1 -0
  63. package/dist/scripts/migrations-lock.d.ts +3 -0
  64. package/dist/scripts/migrations-lock.d.ts.map +1 -0
  65. package/dist/scripts/migrations-lock.js +46 -0
  66. package/dist/scripts/migrations-lock.js.map +1 -0
  67. package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
  68. package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
  69. package/dist/scripts/prepare-wrangler-config.js +50 -0
  70. package/dist/scripts/prepare-wrangler-config.js.map +1 -0
  71. package/dist/scripts/spa-shell.d.ts +41 -0
  72. package/dist/scripts/spa-shell.d.ts.map +1 -0
  73. package/dist/scripts/spa-shell.js +155 -0
  74. package/dist/scripts/spa-shell.js.map +1 -0
  75. package/dist/scripts/validate-locales.d.ts +26 -0
  76. package/dist/scripts/validate-locales.d.ts.map +1 -0
  77. package/dist/scripts/validate-locales.js +350 -0
  78. package/dist/scripts/validate-locales.js.map +1 -0
  79. package/dist/scripts/verify-migrations.d.ts +62 -0
  80. package/dist/scripts/verify-migrations.d.ts.map +1 -0
  81. package/dist/scripts/verify-migrations.js +302 -0
  82. package/dist/scripts/verify-migrations.js.map +1 -0
  83. package/dist/scripts/verify-spa-shell.d.ts +3 -0
  84. package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
  85. package/dist/scripts/verify-spa-shell.js +53 -0
  86. package/dist/scripts/verify-spa-shell.js.map +1 -0
  87. package/dist/scripts/wrangler-config/cli.d.ts +22 -0
  88. package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
  89. package/dist/scripts/wrangler-config/cli.js +51 -0
  90. package/dist/scripts/wrangler-config/cli.js.map +1 -0
  91. package/dist/scripts/wrangler-config/patches.d.ts +51 -0
  92. package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
  93. package/dist/scripts/wrangler-config/patches.js +140 -0
  94. package/dist/scripts/wrangler-config/patches.js.map +1 -0
  95. package/dist/scripts/wrangler-config/resources.d.ts +70 -0
  96. package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
  97. package/dist/scripts/wrangler-config/resources.js +290 -0
  98. package/dist/scripts/wrangler-config/resources.js.map +1 -0
  99. package/dist/scripts/wrangler-config/types.d.ts +103 -0
  100. package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
  101. package/dist/scripts/wrangler-config/types.js +49 -0
  102. package/dist/scripts/wrangler-config/types.js.map +1 -0
  103. package/dist/test/integration-migrations.d.ts +167 -0
  104. package/dist/test/integration-migrations.d.ts.map +1 -0
  105. package/dist/test/integration-migrations.js +171 -0
  106. package/dist/test/integration-migrations.js.map +1 -0
  107. package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
  108. package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
  109. package/dist/test/mocks/cloudflare-workers.js +90 -0
  110. package/dist/test/mocks/cloudflare-workers.js.map +1 -0
  111. package/dist/vite.d.ts +117 -0
  112. package/dist/vite.d.ts.map +1 -0
  113. package/dist/vite.js +125 -0
  114. package/dist/vite.js.map +1 -0
  115. package/dist/vitest-web.d.ts +73 -0
  116. package/dist/vitest-web.d.ts.map +1 -0
  117. package/dist/vitest-web.js +72 -0
  118. package/dist/vitest-web.js.map +1 -0
  119. package/dist/vitest.d.ts +92 -0
  120. package/dist/vitest.d.ts.map +1 -0
  121. package/dist/vitest.js +128 -0
  122. package/dist/vitest.js.map +1 -0
  123. package/package.json +58 -3
  124. package/src/eslint-boundaries.ts +169 -0
  125. package/src/eslint-rules.ts +276 -0
  126. package/src/eslint.test.ts +175 -0
  127. package/src/eslint.ts +257 -0
  128. package/src/functions/pages-proxy.test.ts +72 -0
  129. package/src/functions/pages-proxy.ts +187 -0
  130. package/src/github/actions/retry-step/action.yml +39 -0
  131. package/src/github/actions/setup-env/action.yml +20 -0
  132. package/src/github/dependabot.yml +30 -0
  133. package/src/github/workflows/backup-main.yml +46 -0
  134. package/src/github/workflows/continuous-deployment.yml +188 -0
  135. package/src/github/workflows/continuous-integration.yml +259 -0
  136. package/src/github/workflows/scheduled-version-update.yml +38 -0
  137. package/src/github/workflows/upstream-sync.yml +56 -0
  138. package/src/index.ts +42 -0
  139. package/src/scripts/backup/backup-rules.test.ts +105 -0
  140. package/src/scripts/backup/d1-target.ts +54 -0
  141. package/src/scripts/backup/destination-config.ts +92 -0
  142. package/src/scripts/backup/encrypt-backup.ts +107 -0
  143. package/src/scripts/backup/naming.ts +62 -0
  144. package/src/scripts/check-god-files.test.ts +131 -0
  145. package/src/scripts/check-god-files.ts +327 -0
  146. package/src/scripts/ensure-spa-shell-stub.ts +34 -0
  147. package/src/scripts/init-secrets.ts +265 -0
  148. package/src/scripts/lib/cli-args.ts +154 -0
  149. package/src/scripts/lib/github-actions.ts +41 -0
  150. package/src/scripts/lib/wrangler-table.ts +105 -0
  151. package/src/scripts/migrations-lock.ts +52 -0
  152. package/src/scripts/prepare-wrangler-config.ts +51 -0
  153. package/src/scripts/spa-shell.test.ts +91 -0
  154. package/src/scripts/spa-shell.ts +179 -0
  155. package/src/scripts/validate-locales.test.ts +89 -0
  156. package/src/scripts/validate-locales.ts +380 -0
  157. package/src/scripts/verify-migrations.test.ts +71 -0
  158. package/src/scripts/verify-migrations.ts +364 -0
  159. package/src/scripts/verify-spa-shell.ts +56 -0
  160. package/src/scripts/wrangler-config/cli.ts +51 -0
  161. package/src/scripts/wrangler-config/patches.ts +157 -0
  162. package/src/scripts/wrangler-config/resources.ts +330 -0
  163. package/src/scripts/wrangler-config/types.ts +113 -0
  164. package/src/test/integration-migrations.test.ts +169 -0
  165. package/src/test/integration-migrations.ts +267 -0
  166. package/src/test/mocks/cloudflare-workers.ts +115 -0
  167. package/src/vite.test.ts +83 -0
  168. package/src/vite.ts +202 -0
  169. package/src/vitest-web.ts +109 -0
  170. package/src/vitest.ts +185 -0
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Create the Secrets Store entries declared in `wrangler.jsonc`, generating a fresh
4
+ * value for each one that does not exist yet.
5
+ *
6
+ * Provenance: Edge-Sonic's `scripts/deploy/init-secrets.ts` and Durable-DAV's
7
+ * `scripts/init-secrets.ts`, converged. Both record the same two defects that hid
8
+ * a broken deploy for a whole release, and both fixes are carried over.
9
+ *
10
+ * ### Why this script exists at all
11
+ *
12
+ * A `secrets_store_secrets` binding fails the deploy unless the named secret
13
+ * already exists inside the named store. Nothing creates it: Cloudflare will not
14
+ * generate a value, and the template cannot carry one. So this script is the only
15
+ * thing standing between a fresh account and a deploy that cannot succeed.
16
+ *
17
+ * ### The two defects that hid a broken deploy
18
+ *
19
+ * **The generator list was stale.** Durable-DAV's copy carried an allowlist of
20
+ * exact names from the repository it was inherited from, all deleted when the repo
21
+ * was renamed, so the very first iteration threw `Unknown secret` and never created
22
+ * anything. So the rule here is the secret's **name shape**, not a list of names:
23
+ * `*-encryption-key` gets a generated AES-GCM key and `*-signing-secret` gets 32
24
+ * random bytes. Adding a secret to the template is then a one-line change needing
25
+ * no matching edit here, which is the property the exact-name list did not have.
26
+ *
27
+ * **The throw was swallowed.** The original ended in `main().catch(console.error)`.
28
+ * `console.error` returns `undefined` and never sets an exit status, so the process
29
+ * exited **0** and CI marked the step green — the failure only surfaced one step
30
+ * later, as an unexplainable `wrangler deploy` failure. Hence: the whole plan is
31
+ * validated **before** any Cloudflare call, and a rejection reaches the runner as
32
+ * a non-zero exit with a workflow annotation.
33
+ *
34
+ * ### Create-if-absent is a correctness property, not laziness
35
+ *
36
+ * An existing secret is left untouched. A replication encryption key seals every
37
+ * remote target's credential; regenerating it on a redeploy would strand them all
38
+ * as undecryptable. Rotation has to be a deliberate act with a re-encrypt path,
39
+ * not a side effect of deploying.
40
+ *
41
+ * Requires a materialized `wrangler.jsonc` — run
42
+ * `scripts/prepare-wrangler-config.ts` first. Fails rather than skipping when the
43
+ * config is missing, because a step reporting "Secret initialization complete"
44
+ * having created nothing is worse than a named error.
45
+ */
46
+
47
+ import { execFileSync } from 'node:child_process';
48
+ import { existsSync, readFileSync } from 'node:fs';
49
+ import { parse } from 'jsonc-parser';
50
+ import { fail } from './lib/github-actions';
51
+ import { isEmptySecretsStoreListing, parseWranglerNames } from './lib/wrangler-table';
52
+ import { CONFIG_PATH, type WranglerConfig } from './wrangler-config/types';
53
+
54
+ /**
55
+ * The secret names the AES-GCM key generator produces, matched by **shape**.
56
+ *
57
+ * Keyed on the suffix because the alternative is the exact-name map that went
58
+ * stale. See the header: a template naming `*-encryption-key` is then a one-line
59
+ * change with no matching edit here, and a new secret breaks the deploy loudly
60
+ * instead of silently.
61
+ */
62
+ const ENCRYPTION_KEY_SUFFIX = '-encryption-key';
63
+
64
+ /** The secret names the random signing secret generator produces. */
65
+ const SIGNING_SECRET_SUFFIX = '-signing-secret';
66
+
67
+ /** A 256-bit AES-GCM key, base64-encoded — the shape the worker's crypto layer accepts. */
68
+ async function generateAESGCMKey(): Promise<string> {
69
+ const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
70
+ const exported = await crypto.subtle.exportKey('raw', key);
71
+ // Chunked rather than spread: `String.fromCharCode(...bytes)` passes 32 arguments,
72
+ // which is fine today and is a stack overflow the day the key is longer.
73
+ let binary = '';
74
+ for (const byte of new Uint8Array(exported)) {
75
+ binary += String.fromCodePoint(byte);
76
+ }
77
+ return btoa(binary);
78
+ }
79
+
80
+ /** A 256-bit random signing secret for internal self-calls, hex-encoded. */
81
+ function generateSigningSecret(): Promise<string> {
82
+ let hex = '';
83
+ for (const byte of crypto.getRandomValues(new Uint8Array(32))) {
84
+ hex += byte.toString(16).padStart(2, '0');
85
+ }
86
+ return Promise.resolve(hex);
87
+ }
88
+
89
+ /**
90
+ * The generator for a secret name, chosen by the name's shape.
91
+ *
92
+ * A name matching neither is still refused rather than given a guess: the generator
93
+ * is not the only thing that has to be right, and an unusable value that looks
94
+ * provisioned is worse than a failure.
95
+ */
96
+ function generatorFor(secretName: string): () => Promise<string> {
97
+ if (secretName.endsWith(ENCRYPTION_KEY_SUFFIX)) return generateAESGCMKey;
98
+ if (secretName.endsWith(SIGNING_SECRET_SUFFIX)) return generateSigningSecret;
99
+ throw new Error(
100
+ `Unknown secret: ${secretName}. Name it '*-encryption-key' for a generated 32-byte AES-GCM key, ` +
101
+ `or '*-signing-secret' for 32 random bytes, and teach this script the shape if neither fits.`,
102
+ );
103
+ }
104
+
105
+ /** Whether this script can produce a value for `secretName`. */
106
+ export function canProvisionSecret(secretName: string): boolean {
107
+ try {
108
+ generatorFor(secretName);
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Runs a wrangler subcommand, optionally feeding `input` on stdin.
117
+ *
118
+ * Arguments are passed as an argv array rather than interpolated into a shell
119
+ * string, so a value containing shell metacharacters cannot alter the command.
120
+ */
121
+ function wrangler(args: readonly string[], input?: string): string {
122
+ try {
123
+ // eslint-disable-next-line sonarjs/no-os-command-from-path
124
+ return execFileSync('pnpm', ['exec', 'wrangler', ...args], {
125
+ encoding: 'utf8',
126
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
127
+ ...(input !== undefined && { input }),
128
+ });
129
+ } catch (error: unknown) {
130
+ const maybeProcessError = error as { stdout?: string | Buffer; stderr?: string | Buffer; message?: string };
131
+ const stdout: string = maybeProcessError.stdout ? maybeProcessError.stdout.toString() : '';
132
+ const stderr: string = maybeProcessError.stderr ? maybeProcessError.stderr.toString() : '';
133
+ throw new Error(`Command failed: pnpm exec wrangler ${args.join(' ')}\n${stdout}${stderr || maybeProcessError.message || ''}`);
134
+ }
135
+ }
136
+
137
+ function parseWranglerConfig(): WranglerConfig {
138
+ if (!existsSync(CONFIG_PATH)) {
139
+ throw new Error(
140
+ `${CONFIG_PATH} not found. Run scripts/prepare-wrangler-config.ts first — it is what creates the file this script reads.`,
141
+ );
142
+ }
143
+ // `parse` is error-tolerant and returns a partial object, so an empty result means
144
+ // malformed JSON rather than "no secrets declared". Treating that as an empty list
145
+ // would report success having created nothing.
146
+ const config = parse(readFileSync(CONFIG_PATH, 'utf8')) as WranglerConfig;
147
+ if (config.secrets_store_secrets === undefined && Object.keys(config).length === 0) {
148
+ throw new Error(`${CONFIG_PATH} is empty or not valid JSONC. Run scripts/prepare-wrangler-config.ts to regenerate it.`);
149
+ }
150
+ return config;
151
+ }
152
+
153
+ /** One secret to create, as the plan names it. */
154
+ interface PlannedSecret {
155
+ storeId: string;
156
+ secretName: string;
157
+ }
158
+
159
+ /**
160
+ * Turn the config's bindings into a validated work list.
161
+ *
162
+ * Every entry is checked *before* the first Cloudflare call, so an
163
+ * unprovisionable secret fails the step in milliseconds instead of after some other
164
+ * secret has already been created.
165
+ */
166
+ export function planSecrets(config: WranglerConfig): PlannedSecret[] {
167
+ const plan: PlannedSecret[] = [];
168
+ for (const [index, secret] of (config.secrets_store_secrets ?? []).entries()) {
169
+ const label = secret.binding ?? `index ${index}`;
170
+ const storeId = secret.store_id;
171
+ const secretName = secret.secret_name;
172
+ if (storeId === undefined || storeId === '' || secretName === undefined || secretName === '') {
173
+ throw new Error(
174
+ `secrets_store_secrets[${index}] ("${label}") needs both store_id and secret_name; ` +
175
+ 'store_id is normally filled in by scripts/prepare-wrangler-config.ts.',
176
+ );
177
+ }
178
+ if (!canProvisionSecret(secretName)) {
179
+ throw new Error(`No generator for secret "${secretName}" (binding "${label}").`);
180
+ }
181
+ plan.push({ storeId, secretName });
182
+ }
183
+ return plan;
184
+ }
185
+
186
+ /**
187
+ * List the secret names present in a store.
188
+ *
189
+ * Returns `undefined` when the listing itself failed, which is deliberately
190
+ * distinct from an empty set: a transient CLI failure must not be read as "the
191
+ * secret is missing" and cause a duplicate insert over a live value. An empty store
192
+ * *is* a confirmed absence, so it maps to an empty set rather than `undefined`.
193
+ *
194
+ * The name is taken exactly. `output.includes(secretName)` is not a membership test:
195
+ * `my-replication-encryption-key-2` satisfies a search for
196
+ * `my-replication-encryption-key`, so a store holding only the suffixed entry would
197
+ * report the unsuffixed one as already present and it would never be created.
198
+ */
199
+ function listSecretNames(storeId: string): Set<string> | undefined {
200
+ let output: string;
201
+ try {
202
+ output = wrangler(['secrets-store', 'secret', 'list', storeId, '--remote']);
203
+ } catch (error: unknown) {
204
+ if (isEmptySecretsStoreListing(error)) {
205
+ console.log(`Secrets Store ${storeId} is empty`);
206
+ return new Set();
207
+ }
208
+ console.warn(`Unable to list secrets in store ${storeId}:`, error instanceof Error ? error.message : 'unknown error');
209
+ return undefined;
210
+ }
211
+ return parseWranglerNames(output);
212
+ }
213
+
214
+ function createSecret(storeId: string, secretName: string, secretValue: string): void {
215
+ console.log(`Creating secret: ${secretName}`);
216
+ // The value goes in on stdin, never as an argv entry: an argv is visible to every
217
+ // process on the runner through `ps`, and the previous
218
+ // `echo "${value}" | wrangler … ${name}` also broke on `"`, `$`, backticks and
219
+ // newlines.
220
+ wrangler(['secrets-store', 'secret', 'create', storeId, '--name', secretName, '--scopes', 'workers', '--remote'], secretValue);
221
+ }
222
+
223
+ async function main(): Promise<void> {
224
+ console.log('Initializing Cloudflare secrets...');
225
+ const config = parseWranglerConfig();
226
+ const plan = planSecrets(config);
227
+
228
+ if (plan.length === 0) {
229
+ throw new Error('wrangler.jsonc declares no secrets_store_secrets entries, so there is nothing to initialize.');
230
+ }
231
+
232
+ const listedByStore = new Map<string, Set<string> | undefined>();
233
+
234
+ for (const { storeId, secretName } of plan) {
235
+ if (!listedByStore.has(storeId)) {
236
+ listedByStore.set(storeId, listSecretNames(storeId));
237
+ }
238
+ const existing = listedByStore.get(storeId);
239
+ if (existing === undefined) {
240
+ // Refuse to guess: creating a secret we could not read back risks overwriting
241
+ // a live value with a freshly generated one.
242
+ throw new Error(
243
+ `Could not list secrets in store ${storeId}; refusing to create ${secretName} without confirming it is absent.`,
244
+ );
245
+ }
246
+ if (existing.has(secretName)) {
247
+ console.log(`Secret ${secretName} already exists; leaving it untouched.`);
248
+ continue;
249
+ }
250
+ createSecret(storeId, secretName, await generatorFor(secretName)());
251
+ existing.add(secretName);
252
+ }
253
+
254
+ console.log('Secret initialization complete');
255
+ }
256
+
257
+ // Top-level await rather than `.catch(console.error)`, so a rejected `main` surfaces
258
+ // as a stack trace with its cause intact and the process exits non-zero. Either way
259
+ // the exit status has to be right: this runs in the deploy pipeline, and swallowing
260
+ // the rejection reported a failed key bootstrap as a green step.
261
+ try {
262
+ await main();
263
+ } catch (error: unknown) {
264
+ fail(error instanceof Error ? `Secret initialization failed: ${error.message}` : String(error));
265
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Minimal flag parsing for the operator scripts.
3
+ *
4
+ * Provenance: ported from Edge-Sonic's `scripts/lib/cli-args.ts`, which is the
5
+ * only typed arg parser in the family — every other repo reads `process.argv`
6
+ * with `includes('--flag')` inline.
7
+ *
8
+ * The shape is generic: a declared set of value flags and boolean flags, so an
9
+ * unknown or valueless flag is rejected **by name** rather than silently ignored.
10
+ * A mistyped `--write` must not degrade into "ran with no flags" — which is what
11
+ * an inline `argv.includes()` does, and what turns a migration-lock invocation
12
+ * that misspelled the flag into a read-only run that reports success.
13
+ *
14
+ * Usage:
15
+ *
16
+ * ```ts
17
+ * const flags = parseFlags(process.argv.slice(2), {
18
+ * boolean: ['write', 'dry-run'],
19
+ * value: ['locales-dir', 'root'],
20
+ * });
21
+ * if (isSet(flags, 'write')) { ... }
22
+ * const dir = valueOf(flags, 'locales-dir');
23
+ * ```
24
+ */
25
+
26
+ /**
27
+ * Parsed flags: value flags map to their string, boolean flags to `true`.
28
+ */
29
+ export type ParsedFlags = Map<string, string | true>;
30
+
31
+ /**
32
+ * Declares which flags take a value and which are bare switches.
33
+ */
34
+ export interface FlagSpec {
35
+ /**
36
+ * `--name` takes a value, e.g. `--to new@example.com`.
37
+ */
38
+ value?: readonly string[];
39
+ /**
40
+ * `--dry-run` takes no value.
41
+ */
42
+ boolean?: readonly string[];
43
+ /**
44
+ * Short form → long form, e.g. `{ h: 'help' }`.
45
+ */
46
+ alias?: Readonly<Record<string, string>>;
47
+ }
48
+
49
+ /**
50
+ * Parses `--flag value` and `--boolean` pairs out of `argv`.
51
+ *
52
+ * @throws when a value flag is missing its value, when a flag is repeated, or
53
+ * when an undeclared flag is seen. Each case names the offending flag.
54
+ */
55
+ export function parseFlags(argv: readonly string[], spec: FlagSpec): ParsedFlags {
56
+ const valueFlags = new Set(spec.value);
57
+ const booleanFlags = new Set(spec.boolean);
58
+ const aliases = spec.alias ?? {};
59
+ const flags: ParsedFlags = new Map();
60
+
61
+ for (let index = 0; index < argv.length; index += 1) {
62
+ const arg = argv[index] as string;
63
+ if (!arg.startsWith('-')) {
64
+ throw new Error(`Unexpected argument: ${arg}`);
65
+ }
66
+ const raw = arg.replace(/^--?/, '');
67
+ const name = aliases[raw] ?? raw;
68
+
69
+ if (booleanFlags.has(name)) {
70
+ if (flags.has(name)) {
71
+ throw new Error(`--${name} was given more than once`);
72
+ }
73
+ flags.set(name, true);
74
+ continue;
75
+ }
76
+
77
+ if (!valueFlags.has(name)) {
78
+ throw new Error(`Unknown argument: ${arg}`);
79
+ }
80
+ if (flags.has(name)) {
81
+ throw new Error(`--${name} was given more than once`);
82
+ }
83
+
84
+ const value = argv[index + 1];
85
+ // A value that starts with `-` is treated as a missing value so that
86
+ // `--to --dry-run` reports the real problem instead of capturing the flag.
87
+ if (value === undefined || value.startsWith('-')) {
88
+ throw new Error(`--${name} requires a value`);
89
+ }
90
+ flags.set(name, value);
91
+ index += 1;
92
+ }
93
+
94
+ return flags;
95
+ }
96
+
97
+ /**
98
+ * Reads a value flag, or `undefined` when it was not supplied.
99
+ */
100
+ export function valueOf(flags: ParsedFlags, name: string): string | undefined {
101
+ const value = flags.get(name);
102
+ return typeof value === 'string' ? value : undefined;
103
+ }
104
+
105
+ /**
106
+ * Reads a boolean flag, defaulting to `false`.
107
+ */
108
+ export function isSet(flags: ParsedFlags, name: string): boolean {
109
+ return flags.get(name) === true;
110
+ }
111
+
112
+ /**
113
+ * Split `argv` into its positional arguments and the rest, so a caller can accept
114
+ * `tsx script.ts <dir>` alongside `--root <dir>`.
115
+ *
116
+ * A token that follows a value flag *is* that flag's value, not a positional: the
117
+ * naive `argv.filter((arg) => !arg.startsWith('-'))` takes `.` out of
118
+ * `--root . --fail-on-hard` and then `parseFlags` reports `--root requires a value`.
119
+ * The value flags are passed in rather than inferred, because only the caller knows
120
+ * which of its declared flags take one.
121
+ *
122
+ * @throws when more than `expected` positionals are given, naming them: a second
123
+ * directory is far more likely to be a mistyped flag than an intended argument.
124
+ */
125
+ export function splitPositional(
126
+ argv: readonly string[],
127
+ valueFlags: readonly string[],
128
+ expected = 1,
129
+ ): { positional: string[]; rest: string[] } {
130
+ const takes = new Set(valueFlags);
131
+ const positional: string[] = [];
132
+ const rest: string[] = [];
133
+
134
+ for (let index = 0; index < argv.length; index += 1) {
135
+ const arg = argv[index] as string;
136
+ if (arg.startsWith('-')) {
137
+ rest.push(arg);
138
+ continue;
139
+ }
140
+ const previous = argv[index - 1] as string | undefined;
141
+ const isFlagValue = previous !== undefined && previous.startsWith('-') && takes.has(previous.replace(/^--?/, ''));
142
+ if (isFlagValue) {
143
+ rest.push(arg);
144
+ continue;
145
+ }
146
+ positional.push(arg);
147
+ }
148
+
149
+ if (positional.length > expected) {
150
+ throw new Error(`Expected at most ${expected} positional argument(s), got: ${positional.join(', ')}`);
151
+ }
152
+ return { positional, rest };
153
+ }
154
+
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Minimal GitHub Actions step helpers shared by the CI scripts.
3
+ *
4
+ * Provenance: ported from Edge-Sonic's `scripts/lib/github-actions.ts`, the only
5
+ * repo that factored these out. The scripts deliberately avoid `@actions/*`
6
+ * dependencies so they stay runnable with plain `tsx` from a local shell, which
7
+ * is how they are tested.
8
+ */
9
+
10
+ import { appendFileSync } from 'node:fs';
11
+
12
+ /**
13
+ * Append `name=value` to the step's output file. No-op outside Actions.
14
+ */
15
+ export function setOutput(name: string, value: string): void {
16
+ const outputFile = process.env.GITHUB_OUTPUT;
17
+ if (!outputFile) {
18
+ return;
19
+ }
20
+ appendFileSync(outputFile, `${name}=${value}\n`);
21
+ }
22
+
23
+ /**
24
+ * Emit a workflow-command annotation so the failure is visible in the run log.
25
+ */
26
+ export function logError(message: string): void {
27
+ process.stderr.write(`::error::${message}\n`);
28
+ }
29
+
30
+ /**
31
+ * Report a fatal error and exit non-zero.
32
+ *
33
+ * The annotation is written *before* exiting so the reason survives in the run
34
+ * log. This is the whole point of the helper: the failure mode it exists to
35
+ * prevent is a script that ends in `.catch(console.error)`, which reports success
36
+ * while every side effect it was supposed to perform did not happen.
37
+ */
38
+ export function fail(message: string): never {
39
+ logError(message);
40
+ process.exit(1);
41
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Parsers for the human-readable tables `wrangler` prints.
3
+ *
4
+ * Provenance: ported from Edge-Sonic's `scripts/lib/wrangler-table.ts`, which was
5
+ * factored out of two scripts that had each grown their own copy.
6
+ *
7
+ * `wrangler secrets-store …` renders through `cli-table3`, which draws a
8
+ * box-drawing frame and separates cells with U+2502 (`│`) padded by exactly one
9
+ * space. Columns are therefore *not* space-aligned, and a parser written for
10
+ * aligned output silently matches nothing — which reads as "the resource is
11
+ * absent" and makes provisioning create a duplicate.
12
+ *
13
+ * ### Why membership is exact and never a substring
14
+ *
15
+ * Durable-DAV's `init-secrets.ts` used `output.includes(secretName)`, and that is
16
+ * the defect this port replaces: a store holding both
17
+ * `durable-dav-replication-encryption-key` and a `…-key-backup` would satisfy a
18
+ * substring search for the first, so the real secret was reported as already
19
+ * present and never created. A substring match is not a membership test.
20
+ */
21
+
22
+ /**
23
+ * Cell separator emitted by `cli-table3`.
24
+ */
25
+ const COLUMN_SEPARATOR = String.fromCodePoint(0x25_02);
26
+
27
+ /**
28
+ * Horizontal rule drawn by `cli-table3`; a row made of these is not data.
29
+ */
30
+ const RULE_CHARACTER = String.fromCodePoint(0x25_00);
31
+
32
+ /**
33
+ * Header row of every `wrangler … list` table.
34
+ */
35
+ const HEADER_FIRST_CELL = 'Name';
36
+
37
+ /**
38
+ * Extracts the data rows of a `wrangler` table, dropping the frame, the header,
39
+ * and any surrounding log lines.
40
+ *
41
+ * Rows are returned in print order with cells trimmed and empty cells removed,
42
+ * so a blank optional column (`Comment`, typically unset) does not shift the
43
+ * columns a caller indexes into.
44
+ */
45
+ export function parseWranglerTableRows(output: string): string[][] {
46
+ const rows: string[][] = [];
47
+ for (const line of output.split('\n')) {
48
+ // The frame rows use U+253C (`┼`) at the joints rather than U+2502, so they
49
+ // are already excluded here; the rule-character guard below additionally
50
+ // covers borders that happen to contain a separator.
51
+ if (!line.includes(COLUMN_SEPARATOR)) {
52
+ continue;
53
+ }
54
+
55
+ const cells: string[] = line
56
+ .split(COLUMN_SEPARATOR)
57
+ .map((cell) => cell.trim())
58
+ .filter(Boolean);
59
+ const firstCell = cells[0];
60
+ if (firstCell === undefined || cells.length < 2) {
61
+ continue;
62
+ }
63
+ // The header row and the horizontal rules are frame, not data.
64
+ if (firstCell === HEADER_FIRST_CELL || firstCell.includes(RULE_CHARACTER)) {
65
+ continue;
66
+ }
67
+ rows.push(cells);
68
+ }
69
+ return rows;
70
+ }
71
+
72
+ /**
73
+ * The exact names in the first column of a `wrangler secrets-store secret list`
74
+ * table.
75
+ *
76
+ * Exact values rather than a substring search — see the header.
77
+ */
78
+ export function parseWranglerNames(output: string): Set<string> {
79
+ const names = new Set<string>();
80
+ for (const row of parseWranglerTableRows(output)) {
81
+ const name = row[0];
82
+ if (name !== undefined) names.add(name);
83
+ }
84
+ return names;
85
+ }
86
+
87
+ /**
88
+ * wrangler exits non-zero when a Secrets Store holds no secrets
89
+ * (`secrets-store secret list` throws this `FatalError`), which is
90
+ * indistinguishable by exit code from a genuine failure.
91
+ */
92
+ const EMPTY_STORE_MESSAGE = 'List request returned no secrets';
93
+
94
+ /**
95
+ * True when a failed `secrets-store secret list` means the store is empty
96
+ * rather than the listing having failed.
97
+ *
98
+ * Callers must treat this as a confirmed absence; every other failure has to
99
+ * stay indistinguishable from "unknown", or a transient CLI error would be read
100
+ * as permission to create a secret that already holds a live value.
101
+ */
102
+ export function isEmptySecretsStoreListing(error: unknown): boolean {
103
+ const message: string = error instanceof Error ? error.message : String(error);
104
+ return message.includes(EMPTY_STORE_MESSAGE);
105
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Record newly-added migration files in `migrations/migrations.lock.json`.
4
+ *
5
+ * Provenance: Edge-Sonic's `scripts/migrations/verify-migrations.ts --write`,
6
+ * which is the only repo that splits the two operations into two commands. The
7
+ * other repos have one script with a flag; the split is kept here because the two
8
+ * calls have different audiences and different blast radii — `verify-migrations`
9
+ * runs in CI and changes nothing, while this one is an operator action that writes
10
+ * a file into version control.
11
+ *
12
+ * The rules live once, in `verify-migrations.ts`, so the two entrypoints cannot
13
+ * disagree. `--write` is add-only: it records unlocked files, refreshes the
14
+ * baseline's own entry, and drops entries the baseline absorbed. It never touches
15
+ * another existing entry, so an incremental migration that has drifted still
16
+ * fails afterwards — re-baselining one means deleting its entry by hand, which
17
+ * shows up in the diff.
18
+ *
19
+ * There is deliberately no `--force`, because a write that could bless a drift
20
+ * without reading the drift is the operator blessing their own edit, and they are
21
+ * by definition the one making it.
22
+ *
23
+ * Usage, from the repo root:
24
+ *
25
+ * ```bash
26
+ * pnpm exec tsx scripts/migrations-lock.ts # ./migrations
27
+ * pnpm exec tsx scripts/migrations-lock.ts path/to/migrations
28
+ * ```
29
+ */
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { LOCK_NAME, verifyMigrations } from './verify-migrations';
33
+
34
+ const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
35
+ const migrationsDir = process.argv[2] === undefined ? path.join(REPOSITORY_ROOT, 'migrations') : path.resolve(process.argv[2]);
36
+
37
+ const report = verifyMigrations(migrationsDir, { write: true });
38
+
39
+ for (const problem of report.problems) {
40
+ console.error(`FAIL: ${problem.problem}`);
41
+ }
42
+
43
+ console.log(
44
+ `${report.lock === null ? 'left' : 'wrote'} ${path.join(migrationsDir, LOCK_NAME)} ` +
45
+ `(${report.files.length} migration(s) recorded)`,
46
+ );
47
+
48
+ // The lock is re-checked by the caller: `verify-migrations.ts` re-reads from disk,
49
+ // so running it after this command reports the same verdict CI will. A `--write`
50
+ // that could not resolve everything still exits non-zero here, which is why an
51
+ // incremental `edited` finding does not vanish just because someone wrote.
52
+ process.exit(report.problems.length === 0 ? 0 : 1);
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Materialize `wrangler.jsonc` and provision what it refers to.
4
+ *
5
+ * Provenance: Durable-DAV's `scripts/prepare-wrangler-config.ts` and Edge-Sonic's
6
+ * `scripts/deploy/prepare-wrangler-config.ts`, converged. The five ordered calls
7
+ * live in `wrangler-config/`: read or copy the config, refuse one older than the
8
+ * template requires, apply the caller's patches, then create whatever resources it
9
+ * names and write their real ids over the placeholders. This file is only the
10
+ * order; it has nothing else to do.
11
+ *
12
+ * Run by the deploy job before `wrangler deploy`, and by the backup workflow
13
+ * before `wrangler d1 export` — so the database a backup covers is resolved from
14
+ * the same config the deploy targets, with no second `D1_DATABASE_ID` secret to
15
+ * keep in sync.
16
+ *
17
+ * The one thing it does beyond calling is publish what
18
+ * `provisionWranglerResources` created, which the backup workflow reads to refuse
19
+ * exporting a database that did not exist until this run.
20
+ *
21
+ * Usage, from the repo root:
22
+ *
23
+ * ```bash
24
+ * pnpm exec tsx scripts/prepare-wrangler-config.ts
25
+ * ```
26
+ *
27
+ * Environment:
28
+ *
29
+ * - `WRANGLER_JSONC` — the whole config, as a repository variable. When set it is
30
+ * written verbatim instead of copying the template.
31
+ * - `WRANGLER_PATCH_JSON` — top-level keys merged into the config.
32
+ * - `WRANGLER_VARS_PATCH_JSON` — `vars` entries merged into the config.
33
+ */
34
+ import { setOutput } from './lib/github-actions';
35
+ import { applyTopLevelPatch, applyVarsPatch, ensureMinimumConfigVersion, prepareConfigFile } from './wrangler-config/patches';
36
+ import { provisionWranglerResources } from './wrangler-config/resources';
37
+
38
+ prepareConfigFile();
39
+ ensureMinimumConfigVersion();
40
+ applyTopLevelPatch();
41
+ applyVarsPatch();
42
+ const created = provisionWranglerResources();
43
+
44
+ // Published as a step output rather than only printed. The backup workflow needs
45
+ // to know whether the D1 database *existed* or was created moments ago: a fresh one
46
+ // holds no user data, and uploading that empty dump every night is a false sense
47
+ // of safety. The alternative — re-reading the placeholder id out of
48
+ // `wrangler.jsonc` — is unreachable, because `provisionWranglerResources` patches
49
+ // the placeholder away before the reader ever runs.
50
+ setOutput('created', created.join(','));
51
+ console.log('Wrangler configuration is ready.');