@smoothbricks/cli 0.10.7 → 0.10.9

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 (58) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +24 -1
  3. package/dist/github-ci/index.d.ts +44 -4
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +220 -34
  6. package/dist/monorepo/ci-workflow.js +16 -6
  7. package/dist/monorepo/managed-files.d.ts.map +1 -1
  8. package/dist/monorepo/managed-files.js +19 -1
  9. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts +5 -0
  10. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts.map +1 -0
  11. package/dist/monorepo/pr-preview-cleanup-workflow.js +38 -0
  12. package/dist/monorepo/publish-workflow.js +3 -3
  13. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  14. package/dist/monorepo/tool-validation.js +85 -5
  15. package/dist/playwright/index.d.ts +22 -0
  16. package/dist/playwright/index.d.ts.map +1 -0
  17. package/dist/playwright/index.js +44 -0
  18. package/dist/release/bootstrap-npm-packages.d.ts +3 -0
  19. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -1
  20. package/dist/release/bootstrap-npm-packages.js +21 -0
  21. package/dist/release/index.d.ts +1 -0
  22. package/dist/release/index.d.ts.map +1 -1
  23. package/dist/release/index.js +31 -6
  24. package/dist/wrangler/cloudflare.d.ts +87 -0
  25. package/dist/wrangler/cloudflare.d.ts.map +1 -0
  26. package/dist/wrangler/cloudflare.js +238 -0
  27. package/dist/wrangler/deploy-environment.d.ts +48 -0
  28. package/dist/wrangler/deploy-environment.d.ts.map +1 -0
  29. package/dist/wrangler/deploy-environment.js +383 -0
  30. package/dist/wrangler/environment.d.ts +58 -0
  31. package/dist/wrangler/environment.d.ts.map +1 -0
  32. package/dist/wrangler/environment.js +297 -0
  33. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
  34. package/package.json +9 -2
  35. package/src/cli.ts +27 -3
  36. package/src/github-ci/index.test.ts +175 -2
  37. package/src/github-ci/index.ts +274 -31
  38. package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
  39. package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
  40. package/src/monorepo/__tests__/publish-workflow.test.ts +7 -5
  41. package/src/monorepo/ci-workflow.ts +16 -6
  42. package/src/monorepo/managed-files.test.ts +56 -1
  43. package/src/monorepo/managed-files.ts +20 -1
  44. package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
  45. package/src/monorepo/publish-workflow.ts +8 -5
  46. package/src/monorepo/tool-validation.test.ts +85 -0
  47. package/src/monorepo/tool-validation.ts +94 -5
  48. package/src/playwright/index.test.ts +90 -0
  49. package/src/playwright/index.ts +73 -0
  50. package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
  51. package/src/release/bootstrap-npm-packages.ts +34 -0
  52. package/src/release/index.ts +30 -4
  53. package/src/wrangler/cloudflare.test.ts +76 -0
  54. package/src/wrangler/cloudflare.ts +292 -0
  55. package/src/wrangler/deploy-environment.test.ts +354 -0
  56. package/src/wrangler/deploy-environment.ts +445 -0
  57. package/src/wrangler/environment.test.ts +173 -0
  58. package/src/wrangler/environment.ts +366 -0
@@ -0,0 +1,445 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import typia from 'typia';
6
+ import { type CloudflareClient, CloudflareRestClient } from './cloudflare.js';
7
+ import {
8
+ type ConfiguredEnvironmentResourcePlan,
9
+ derivePullRequestWranglerConfig,
10
+ type EnvironmentToken,
11
+ environmentResourceName,
12
+ hasExactEnvironmentSegment,
13
+ isPullRequestEnvironment,
14
+ parseEnvironmentToken,
15
+ planConfiguredEnvironmentResources,
16
+ planPullRequestResources,
17
+ pullRequestEnvironment,
18
+ } from './environment.js';
19
+ import { parseDevVarsExample } from './prepare-env.js';
20
+
21
+ export interface ProcessResult {
22
+ exitCode: number;
23
+ stdout: string;
24
+ stderr: string;
25
+ }
26
+
27
+ export interface ProcessRunner {
28
+ run(command: string, args: string[], options: { cwd: string; env?: Record<string, string> }): Promise<ProcessResult>;
29
+ }
30
+
31
+ export class BunProcessRunner implements ProcessRunner {
32
+ async run(
33
+ command: string,
34
+ args: string[],
35
+ options: { cwd: string; env?: Record<string, string> },
36
+ ): Promise<ProcessResult> {
37
+ const child = Bun.spawn([command, ...args], {
38
+ cwd: options.cwd,
39
+ env: { ...process.env, ...options.env },
40
+ stdin: 'inherit',
41
+ stdout: 'pipe',
42
+ stderr: 'pipe',
43
+ });
44
+ const [exitCode, stdout, stderr] = await Promise.all([
45
+ child.exited,
46
+ new Response(child.stdout).text(),
47
+ new Response(child.stderr).text(),
48
+ ]);
49
+ return { exitCode, stdout, stderr };
50
+ }
51
+ }
52
+
53
+ export interface WranglerCommandDependencies {
54
+ runner?: ProcessRunner;
55
+ cloudflare?: CloudflareClient;
56
+ processEnv?: NodeJS.ProcessEnv;
57
+ }
58
+
59
+ export interface DeployEnvironmentResult {
60
+ environment: EnvironmentToken;
61
+ workerName: string;
62
+ action: 'deployed' | 'activated' | 'remote-cache-hit';
63
+ versionTag?: string;
64
+ }
65
+
66
+ const isUnknownRecord = typia.createIs<Record<string, unknown>>();
67
+
68
+ export async function deployEnvironment(
69
+ cwd: string,
70
+ environmentValue: string,
71
+ dependencies: WranglerCommandDependencies = {},
72
+ ): Promise<DeployEnvironmentResult> {
73
+ const environment = parseEnvironmentToken(environmentValue);
74
+ const processEnv = dependencies.processEnv ?? process.env;
75
+ const accountId = processEnv.CLOUDFLARE_ACCOUNT_ID;
76
+ const apiToken = processEnv.CLOUDFLARE_API_TOKEN;
77
+ if (!accountId) throw new Error('CLOUDFLARE_ACCOUNT_ID is required.');
78
+ const cloudflare =
79
+ dependencies.cloudflare ??
80
+ new CloudflareRestClient(accountId, requiredEnvironmentValue(apiToken, 'CLOUDFLARE_API_TOKEN'));
81
+ const runner = dependencies.runner ?? new BunProcessRunner();
82
+ const committedConfigPath = join(cwd, 'wrangler.toml');
83
+ const committedToml = await readFile(committedConfigPath, 'utf8');
84
+ const secretNames = readSecretNames(cwd);
85
+ const secretValues: Record<string, string> = {};
86
+ for (const name of secretNames) {
87
+ const value = processEnv[name];
88
+ if (value) secretValues[name] = value;
89
+ }
90
+ const missingSecrets = secretNames.filter((name) => !processEnv[name]);
91
+ let configPath = committedConfigPath;
92
+ let temporaryConfigPath: string | undefined;
93
+ let temporarySecretsPath: string | undefined;
94
+ try {
95
+ if (isPullRequestEnvironment(environment)) {
96
+ const staging = planConfiguredEnvironmentResources(committedToml, 'staging');
97
+ if (!staging.workerName.endsWith('-staging')) {
98
+ throw new Error('[env.staging].name must end with the exact suffix -staging.');
99
+ }
100
+ const workerName = environmentResourceName(staging.workerName.slice(0, -'-staging'.length), environment);
101
+ const firstDeployment = !(await cloudflare.listWorkerScripts()).some((script) => script.id === workerName);
102
+ if (firstDeployment && missingSecrets.length > 0) {
103
+ throw new Error(
104
+ `First deployment of ${workerName} requires process environment values for: ${missingSecrets.join(', ')}.`,
105
+ );
106
+ }
107
+ const liveNamespaces = await cloudflare.listKvNamespaces();
108
+ const plan = planPullRequestResources(committedToml, environment, liveNamespaces);
109
+ const derivedIds = new Map<string, string>();
110
+ const byTitle = new Map(liveNamespaces.map((namespace) => [namespace.title, namespace]));
111
+ for (const namespace of plan.kvNamespaces) {
112
+ let live = byTitle.get(namespace.title);
113
+ if (!live) {
114
+ try {
115
+ live = await cloudflare.createKvNamespace(namespace.title);
116
+ } catch (error) {
117
+ live = (await cloudflare.listKvNamespaces()).find((candidate) => candidate.title === namespace.title);
118
+ if (!live) throw error;
119
+ }
120
+ byTitle.set(live.title, live);
121
+ }
122
+ derivedIds.set(namespace.stagingId, live.id);
123
+ }
124
+ const derivedToml = derivePullRequestWranglerConfig(committedToml, {
125
+ environment,
126
+ accountId,
127
+ kvNamespaceIds: derivedIds,
128
+ });
129
+ temporaryConfigPath = join(cwd, `.wrangler.smoo-${process.pid}-${randomUUID()}.toml`);
130
+ await writeFile(temporaryConfigPath, derivedToml, { mode: 0o600 });
131
+ configPath = temporaryConfigPath;
132
+ }
133
+
134
+ const toml = configPath === committedConfigPath ? committedToml : await readFile(configPath, 'utf8');
135
+ const plan = planConfiguredEnvironmentResources(toml, environment);
136
+ const workerExists = await reconcileEnvironmentResources(plan, cloudflare);
137
+ const versionTag = nxTaskVersionTag(processEnv);
138
+ const commandContext = { cwd, configPath, environment, workerName: plan.workerName };
139
+
140
+ if (versionTag && workerExists) {
141
+ const versions = await wranglerJson(runner, ['versions', 'list', '--name', plan.workerName, '--json'], cwd);
142
+ const deployments = await wranglerJson(
143
+ runner,
144
+ ['deployments', 'status', '--name', plan.workerName, '--json'],
145
+ cwd,
146
+ );
147
+ const versionId = findVersionIdByTag(versions, versionTag);
148
+ if (versionId && isFullCurrentDeployment(deployments, versionId)) {
149
+ return { environment, workerName: plan.workerName, action: 'remote-cache-hit', versionTag };
150
+ }
151
+ if (versionId) {
152
+ await wrangler(
153
+ runner,
154
+ [
155
+ 'versions',
156
+ 'deploy',
157
+ '--version-tag',
158
+ versionTag,
159
+ '--name',
160
+ plan.workerName,
161
+ '--config',
162
+ configPath,
163
+ '--env',
164
+ environment,
165
+ '--yes',
166
+ ],
167
+ cwd,
168
+ );
169
+ return { environment, workerName: plan.workerName, action: 'activated', versionTag };
170
+ }
171
+ }
172
+
173
+ const deployArgs = ['deploy', '--config', commandContext.configPath, '--env', commandContext.environment];
174
+ if (versionTag) deployArgs.push('--tag', versionTag);
175
+ if (Object.keys(secretValues).length > 0) {
176
+ temporarySecretsPath = join(cwd, `.wrangler-secrets.smoo-${process.pid}-${randomUUID()}.json`);
177
+ await writeFile(temporarySecretsPath, `${JSON.stringify(secretValues)}\n`, { mode: 0o600 });
178
+ deployArgs.push('--secrets-file', temporarySecretsPath);
179
+ }
180
+ await wrangler(runner, deployArgs, cwd);
181
+ return {
182
+ environment,
183
+ workerName: commandContext.workerName,
184
+ action: 'deployed',
185
+ ...(versionTag ? { versionTag } : {}),
186
+ };
187
+ } finally {
188
+ if (temporaryConfigPath) {
189
+ await rm(temporaryConfigPath, { force: true });
190
+ }
191
+ if (temporarySecretsPath) {
192
+ await rm(temporarySecretsPath, { force: true });
193
+ }
194
+ }
195
+ }
196
+ export interface CleanupResult {
197
+ environment: `pr${number}`;
198
+ deleted: {
199
+ workers: number;
200
+ routes: number;
201
+ domains: number;
202
+ kvNamespaces: number;
203
+ r2Buckets: number;
204
+ r2Objects: number;
205
+ dnsRecords: number;
206
+ };
207
+ }
208
+
209
+ export async function cleanupPullRequest(
210
+ cwd: string,
211
+ prNumber: number,
212
+ dependencies: WranglerCommandDependencies = {},
213
+ ): Promise<CleanupResult> {
214
+ const environment = pullRequestEnvironment(prNumber);
215
+ const processEnv = dependencies.processEnv ?? process.env;
216
+ const cloudflare =
217
+ dependencies.cloudflare ??
218
+ new CloudflareRestClient(
219
+ requiredEnvironmentValue(processEnv.CLOUDFLARE_ACCOUNT_ID, 'CLOUDFLARE_ACCOUNT_ID'),
220
+ requiredEnvironmentValue(processEnv.CLOUDFLARE_API_TOKEN, 'CLOUDFLARE_API_TOKEN'),
221
+ );
222
+ void cwd;
223
+ const deleted = {
224
+ workers: 0,
225
+ routes: 0,
226
+ domains: 0,
227
+ kvNamespaces: 0,
228
+ r2Buckets: 0,
229
+ r2Objects: 0,
230
+ dnsRecords: 0,
231
+ };
232
+
233
+ for (const domain of await cloudflare.listWorkerDomains()) {
234
+ if (!hasExactEnvironmentSegment(domain.hostname, environment)) continue;
235
+ await cloudflare.deleteWorkerDomain(domain.id);
236
+ deleted.domains += 1;
237
+ }
238
+
239
+ for (const zone of await cloudflare.listZones()) {
240
+ for (const route of await cloudflare.listWorkerRoutes(zone.id)) {
241
+ if (!hasExactEnvironmentSegment(route.pattern, environment)) continue;
242
+ await cloudflare.deleteWorkerRoute(zone.id, route.id);
243
+ deleted.routes += 1;
244
+ }
245
+ for (const record of await cloudflare.listDnsRecords(zone.id)) {
246
+ if (!hasExactEnvironmentSegment(record.name, environment)) continue;
247
+ await cloudflare.deleteDnsRecord(zone.id, record.id);
248
+ deleted.dnsRecords += 1;
249
+ }
250
+ }
251
+
252
+ for (const script of await cloudflare.listWorkerScripts()) {
253
+ if (!hasExactEnvironmentSegment(script.id, environment)) continue;
254
+ await cloudflare.deleteWorkerScript(script.id);
255
+ deleted.workers += 1;
256
+ }
257
+ for (const namespace of await cloudflare.listKvNamespaces()) {
258
+ if (!hasExactEnvironmentSegment(namespace.title, environment)) continue;
259
+ await cloudflare.deleteKvNamespace(namespace.id);
260
+ deleted.kvNamespaces += 1;
261
+ }
262
+ for (const bucket of await cloudflare.listR2Buckets()) {
263
+ if (!hasExactEnvironmentSegment(bucket.name, environment)) continue;
264
+ for (const key of await cloudflare.listR2Objects(bucket.name)) {
265
+ await cloudflare.deleteR2Object(bucket.name, key);
266
+ deleted.r2Objects += 1;
267
+ }
268
+ await cloudflare.deleteR2Bucket(bucket.name);
269
+ deleted.r2Buckets += 1;
270
+ }
271
+ return { environment, deleted };
272
+ }
273
+
274
+ async function reconcileEnvironmentResources(
275
+ plan: ConfiguredEnvironmentResourcePlan,
276
+ cloudflare: CloudflareClient,
277
+ ): Promise<boolean> {
278
+ const namespaces = await cloudflare.listKvNamespaces();
279
+ const namespaceIds = new Set(namespaces.map((namespace) => namespace.id));
280
+ for (const binding of plan.kvNamespaces) {
281
+ if (!namespaceIds.has(binding.id)) {
282
+ throw new Error(`KV binding ${binding.binding} references missing namespace ${binding.id}.`);
283
+ }
284
+ }
285
+
286
+ const buckets = new Set((await cloudflare.listR2Buckets()).map((bucket) => bucket.name));
287
+ for (const binding of plan.r2Buckets) {
288
+ if (buckets.has(binding.bucketName)) continue;
289
+ try {
290
+ await cloudflare.createR2Bucket(binding.bucketName);
291
+ } catch (error) {
292
+ const exists = (await cloudflare.listR2Buckets()).some((bucket) => bucket.name === binding.bucketName);
293
+ if (!exists) throw error;
294
+ }
295
+ buckets.add(binding.bucketName);
296
+ }
297
+
298
+ const zones = await cloudflare.listZones();
299
+ const zoneByName = new Map(zones.map((zone) => [zone.name, zone]));
300
+ const dnsNamesByZone = new Map<string, Set<string>>();
301
+ for (const route of plan.routes) {
302
+ if (!route.pattern.startsWith('*.') || !route.zoneName) continue;
303
+ const zone = zoneByName.get(route.zoneName);
304
+ if (!zone) throw new Error(`Cloudflare zone ${route.zoneName} is not available to the deployment token.`);
305
+ const hostname = route.pattern.slice(0, route.pattern.indexOf('/')).replace(/^\*\./, '');
306
+ const wildcard = `*.${hostname}`;
307
+ let names = dnsNamesByZone.get(zone.id);
308
+ if (!names) {
309
+ names = new Set((await cloudflare.listDnsRecords(zone.id)).map((record) => record.name));
310
+ dnsNamesByZone.set(zone.id, names);
311
+ }
312
+ if (!names.has(wildcard)) {
313
+ try {
314
+ await cloudflare.createDnsRecord(zone.id, wildcard, hostname);
315
+ } catch (error) {
316
+ const exists = (await cloudflare.listDnsRecords(zone.id)).some((record) => record.name === wildcard);
317
+ if (!exists) throw error;
318
+ }
319
+ names.add(wildcard);
320
+ }
321
+ }
322
+
323
+ const scripts = await cloudflare.listWorkerScripts();
324
+ const workerExists = scripts.some((script) => script.id === plan.workerName);
325
+ if (!workerExists) return false;
326
+
327
+ const domains = await cloudflare.listWorkerDomains();
328
+ for (const route of plan.routes) {
329
+ if (!route.customDomain) continue;
330
+ const existing = domains.find((domain) => domain.hostname === route.pattern);
331
+ if (existing?.service === plan.workerName) continue;
332
+ if (existing) {
333
+ throw new Error(`Custom domain ${route.pattern} is already attached to ${existing.service ?? 'another Worker'}.`);
334
+ }
335
+ const zone = route.zoneName
336
+ ? zoneByName.get(route.zoneName)
337
+ : zones
338
+ .filter((candidate) => route.pattern === candidate.name || route.pattern.endsWith(`.${candidate.name}`))
339
+ .sort((left, right) => right.name.length - left.name.length)[0];
340
+ if (!zone) throw new Error(`No accessible Cloudflare zone contains custom domain ${route.pattern}.`);
341
+ await cloudflare.createWorkerDomain(route.pattern, plan.workerName, zone.id);
342
+ }
343
+
344
+ for (const zone of zones) {
345
+ const desired = plan.routes.filter((route) => !route.customDomain && route.zoneName === zone.name);
346
+ if (desired.length === 0) continue;
347
+ const routes = await cloudflare.listWorkerRoutes(zone.id);
348
+ for (const route of desired) {
349
+ const existing = routes.find((candidate) => candidate.pattern === route.pattern);
350
+ if (existing?.script === plan.workerName) continue;
351
+ if (existing) {
352
+ throw new Error(`Worker route ${route.pattern} is already attached to ${existing.script ?? 'another Worker'}.`);
353
+ }
354
+ await cloudflare.createWorkerRoute(zone.id, route.pattern, plan.workerName);
355
+ }
356
+ }
357
+ return true;
358
+ }
359
+
360
+ export function nxTaskVersionTag(environment: NodeJS.ProcessEnv): string | undefined {
361
+ const hash = environment.NX_TASK_HASH;
362
+ const underNx =
363
+ hash !== undefined ||
364
+ environment.NX_TASK_TARGET_PROJECT !== undefined ||
365
+ environment.NX_TASK_TARGET_TARGET !== undefined;
366
+ if (!underNx) return undefined;
367
+ if (!hash) throw new Error('NX_TASK_HASH is required when deploy-environment runs under Nx.');
368
+ if (/^(?:0|[1-9][0-9]*)$/.test(hash)) {
369
+ return `nx-${hash}`;
370
+ }
371
+ const normalized = hash.toLowerCase();
372
+ if (/^[0-9a-f]{32,}$/.test(normalized)) {
373
+ return `nx-${normalized.slice(0, 32)}`;
374
+ }
375
+ throw new Error('NX_TASK_HASH must be canonical decimal digits or at least 32 hexadecimal characters.');
376
+ }
377
+
378
+ export function findVersionIdByTag(value: unknown, tag: string): string | null {
379
+ if (Array.isArray(value)) {
380
+ for (const entry of value) {
381
+ const found = findVersionIdByTag(entry, tag);
382
+ if (found) return found;
383
+ }
384
+ return null;
385
+ }
386
+ if (!isUnknownRecord(value)) return null;
387
+ const annotations = isUnknownRecord(value.annotations) ? value.annotations : undefined;
388
+ const metadata = isUnknownRecord(value.metadata) ? value.metadata : undefined;
389
+ const annotationTag = annotations?.['workers/tag'];
390
+ const candidateTag =
391
+ typeof annotationTag === 'string' ? annotationTag : typeof value.tag === 'string' ? value.tag : metadata?.tag;
392
+ if (candidateTag === tag) {
393
+ if (typeof value.id === 'string') return value.id;
394
+ if (typeof value.version_id === 'string') return value.version_id;
395
+ }
396
+ for (const nested of Object.values(value)) {
397
+ const found = findVersionIdByTag(nested, tag);
398
+ if (found) return found;
399
+ }
400
+ return null;
401
+ }
402
+
403
+ export function isFullCurrentDeployment(value: unknown, versionId: string): boolean {
404
+ if (Array.isArray(value)) return value.some((entry) => isFullCurrentDeployment(entry, versionId));
405
+ if (!isUnknownRecord(value)) return false;
406
+ if (Array.isArray(value.versions)) {
407
+ return (
408
+ value.versions.length === 1 &&
409
+ value.versions.some(
410
+ (version) =>
411
+ isUnknownRecord(version) &&
412
+ (version.version_id === versionId || version.id === versionId) &&
413
+ Number(version.percentage) === 100,
414
+ )
415
+ );
416
+ }
417
+ return Object.values(value).some((entry) => isFullCurrentDeployment(entry, versionId));
418
+ }
419
+
420
+ async function wranglerJson(runner: ProcessRunner, args: string[], cwd: string): Promise<unknown> {
421
+ const result = await wrangler(runner, args, cwd);
422
+ try {
423
+ return JSON.parse(result.stdout);
424
+ } catch {
425
+ throw new Error(`wrangler ${args.join(' ')} returned invalid JSON.`);
426
+ }
427
+ }
428
+
429
+ async function wrangler(runner: ProcessRunner, args: string[], cwd: string): Promise<ProcessResult> {
430
+ const result = await runner.run('wrangler', args, { cwd });
431
+ if (result.exitCode !== 0) {
432
+ throw new Error(`wrangler ${args.join(' ')} failed with exit code ${result.exitCode}: ${result.stderr.trim()}`);
433
+ }
434
+ return result;
435
+ }
436
+
437
+ function requiredEnvironmentValue(value: string | undefined, name: string): string {
438
+ if (!value) throw new Error(`${name} is required.`);
439
+ return value;
440
+ }
441
+
442
+ function readSecretNames(cwd: string): string[] {
443
+ const path = join(cwd, '.dev.vars.example');
444
+ return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
445
+ }
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ derivePullRequestWranglerConfig,
4
+ environmentDomain,
5
+ environmentResourceName,
6
+ planPullRequestResources,
7
+ pullRequestEnvironment,
8
+ rateLimitNamespaceId,
9
+ } from './environment.js';
10
+
11
+ const APP_FIXTURE = `name = "conloca-app"
12
+ compatibility_date = "2026-05-06"
13
+
14
+ [env.staging]
15
+ name = "conloca-app-staging"
16
+ workers_dev = false
17
+
18
+ [env.staging.assets]
19
+ directory = "./dist"
20
+ not_found_handling = "single-page-application"
21
+
22
+ [[env.staging.routes]]
23
+ pattern = "*.staging.conloca.com/*"
24
+ zone_name = "conloca.com"
25
+
26
+ [[env.staging.routes]]
27
+ pattern = "staging.conloca.com"
28
+ custom_domain = true
29
+ `;
30
+
31
+ const BACKEND_FIXTURE = `name = "conloca-app-backend"
32
+ main = "dist/worker.js"
33
+
34
+ [[migrations]]
35
+ tag = "v1"
36
+ new_sqlite_classes = ["ConlocaAuthKeysDO", "SaasGitDO"]
37
+
38
+ [env.staging]
39
+ name = "conloca-app-backend-staging"
40
+ workers_dev = false
41
+
42
+ [[env.staging.routes]]
43
+ pattern = "*.staging.conloca.com/auth/*"
44
+ zone_name = "conloca.com"
45
+
46
+ [[env.staging.durable_objects.bindings]]
47
+ name = "AUTH_KEYS"
48
+ class_name = "ConlocaAuthKeysDO"
49
+
50
+ [[env.staging.kv_namespaces]]
51
+ binding = "ALIAS_INDEX"
52
+ id = "kv-alias-staging-id"
53
+
54
+ [[env.staging.kv_namespaces]]
55
+ binding = "ORG_PROFILES"
56
+ id = "kv-org-staging-id"
57
+
58
+ [[env.staging.kv_namespaces]]
59
+ binding = "MAIL_CAPTURE"
60
+ id = "kv-mail-staging-id"
61
+
62
+ [[env.staging.send_email]]
63
+ name = "EMAIL"
64
+ allowed_sender_addresses = ["login@mail.staging.conloca.com"]
65
+
66
+ [[env.staging.ratelimits]]
67
+ name = "MAGIC_LINK_EMAIL_RATE_LIMIT"
68
+ namespace_id = "2026071701"
69
+ simple = { limit = 5, period = 60 }
70
+
71
+ [[env.staging.ratelimits]]
72
+ name = "MAGIC_LINK_SOURCE_RATE_LIMIT"
73
+ namespace_id = "2026071702"
74
+ simple = { limit = 20, period = 60 }
75
+
76
+ [[env.staging.r2_buckets]]
77
+ binding = "MEDIA"
78
+ bucket_name = "conloca-media-staging"
79
+
80
+ [env.staging.vars]
81
+ ENVIRONMENT = "staging"
82
+ TLD_DOMAIN = "staging.conloca.com"
83
+ AUTH_TLD_DOMAIN = "staging.conloca.com"
84
+ GITHUB_WEBHOOK_INGRESS_URL = "https://staging.conloca.com/webhooks/github"
85
+ GITHUB_APP_ID = "4077531"
86
+ GITHUB_APP_SLUG = "conloca-staging"
87
+ GITHUB_CLIENT_ID = "Iv23liD6EDsBZ8kJGU3f"
88
+ AUTH_KEYS_INSTANCE_NAME = "staging-20260716-2"
89
+ MAIL_CAPTURE_RECIPIENTS = "login-test@staging.conloca.com,invite-test@staging.conloca.com"
90
+ EMAIL_FROM_ADDRESS = "login@mail.staging.conloca.com"
91
+ INVITATION_REDEEM_ORIGIN = "https://app.staging.conloca.com"
92
+ `;
93
+
94
+ const LIVE_NAMESPACES = [
95
+ { id: 'kv-alias-staging-id', title: 'alias-index-staging' },
96
+ { id: 'kv-org-staging-id', title: 'org-profiles-staging' },
97
+ { id: 'kv-mail-staging-id', title: 'conloca-mail-capture-staging' },
98
+ ];
99
+
100
+ const DERIVED_IDS = new Map([
101
+ ['kv-alias-staging-id', 'kv-alias-pr123-id'],
102
+ ['kv-org-staging-id', 'kv-org-pr123-id'],
103
+ ['kv-mail-staging-id', 'kv-mail-pr123-id'],
104
+ ]);
105
+
106
+ describe('Wrangler environment convention', () => {
107
+ it('validates pull-request numbers and derives generic names', () => {
108
+ expect(pullRequestEnvironment(123)).toBe('pr123');
109
+ expect(() => pullRequestEnvironment(0)).toThrow(/1 through 999999999/);
110
+ expect(() => pullRequestEnvironment(1.5)).toThrow(/integer/);
111
+ expect(() => pullRequestEnvironment(1_000_000_000)).toThrow(/1 through 999999999/);
112
+ expect(environmentDomain('pr123', 'conloca.com')).toBe('pr123.conloca.com');
113
+ expect(environmentDomain('production', 'conloca.com')).toBe('conloca.com');
114
+ expect(environmentResourceName('conloca-app', 'staging')).toBe('conloca-app-staging');
115
+ expect(environmentResourceName('conloca-app', 'production')).toBe('conloca-app');
116
+ });
117
+
118
+ it('derives the app staging block without changing inherited/static semantics', () => {
119
+ const derived = derivePullRequestWranglerConfig(APP_FIXTURE, {
120
+ environment: 'pr123',
121
+ accountId: 'account-1',
122
+ kvNamespaceIds: new Map<string, string>(),
123
+ });
124
+
125
+ expect(derived).toContain('[env.pr123]\nname = "conloca-app-pr123"');
126
+ expect(derived).toContain('pattern = "*.pr123.conloca.com/*"');
127
+ expect(derived).toContain('pattern = "pr123.conloca.com"');
128
+ expect(derived).toContain('[env.pr123.assets]\ndirectory = "./dist"');
129
+ expect(derived).toContain('compatibility_date = "2026-05-06"');
130
+ expect(derived).not.toContain('pr456');
131
+ });
132
+
133
+ it('derives backend resources from staging while preserving provider and DO identities', () => {
134
+ const plan = planPullRequestResources(BACKEND_FIXTURE, 'pr123', LIVE_NAMESPACES);
135
+ expect(plan.workerName).toBe('conloca-app-backend-pr123');
136
+ expect(plan.kvNamespaces.map(({ title }) => title)).toEqual([
137
+ 'alias-index-pr123',
138
+ 'org-profiles-pr123',
139
+ 'conloca-mail-capture-pr123',
140
+ ]);
141
+ expect(plan.r2Buckets).toEqual([{ binding: 'MEDIA', bucketName: 'conloca-media-pr123' }]);
142
+
143
+ const derived = derivePullRequestWranglerConfig(BACKEND_FIXTURE, {
144
+ environment: 'pr123',
145
+ accountId: 'account-1',
146
+ kvNamespaceIds: DERIVED_IDS,
147
+ });
148
+ const emailId = rateLimitNamespaceId('account-1', 'conloca-app-backend', 'pr123', 'MAGIC_LINK_EMAIL_RATE_LIMIT');
149
+ const sourceId = rateLimitNamespaceId('account-1', 'conloca-app-backend', 'pr123', 'MAGIC_LINK_SOURCE_RATE_LIMIT');
150
+
151
+ expect(Number(emailId)).toBeGreaterThan(0);
152
+ expect(Number(emailId)).toBeLessThanOrEqual(0x7fff_ffff);
153
+ expect(sourceId).not.toBe(emailId);
154
+ expect(derived).toContain(`namespace_id = "${emailId}"`);
155
+ expect(derived).toContain(`namespace_id = "${sourceId}"`);
156
+ expect(derived).toContain('id = "kv-alias-pr123-id"');
157
+ expect(derived).toContain('id = "kv-org-pr123-id"');
158
+ expect(derived).toContain('id = "kv-mail-pr123-id"');
159
+ expect(derived).toContain('bucket_name = "conloca-media-pr123"');
160
+ expect(derived).toContain('ENVIRONMENT = "pr123"');
161
+ expect(derived).toContain('TLD_DOMAIN = "pr123.conloca.com"');
162
+ expect(derived).toContain('AUTH_KEYS_INSTANCE_NAME = "pr123-20260716-2"');
163
+ expect(derived).toContain('allowed_sender_addresses = ["login@mail.pr123.conloca.com"]');
164
+ expect(derived).toContain('MAIL_CAPTURE_RECIPIENTS = "login-test@pr123.conloca.com,invite-test@pr123.conloca.com"');
165
+ expect(derived).toContain('INVITATION_REDEEM_ORIGIN = "https://app.pr123.conloca.com"');
166
+ expect(derived).toContain('GITHUB_APP_ID = "4077531"');
167
+ expect(derived).toContain('GITHUB_APP_SLUG = "conloca-staging"');
168
+ expect(derived).toContain('GITHUB_CLIENT_ID = "Iv23liD6EDsBZ8kJGU3f"');
169
+ expect(derived).toContain('class_name = "ConlocaAuthKeysDO"');
170
+ expect(derived).toContain('new_sqlite_classes = ["ConlocaAuthKeysDO", "SaasGitDO"]');
171
+ expect(derived).not.toContain('pr456');
172
+ });
173
+ });