@smoothbricks/cli 0.10.7 → 0.10.8

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 (56) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +23 -1
  3. package/dist/github-ci/index.d.ts +43 -3
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +219 -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 +2 -2
  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.map +1 -1
  22. package/dist/release/index.js +17 -0
  23. package/dist/wrangler/cloudflare.d.ts +85 -0
  24. package/dist/wrangler/cloudflare.d.ts.map +1 -0
  25. package/dist/wrangler/cloudflare.js +235 -0
  26. package/dist/wrangler/deploy-environment.d.ts +48 -0
  27. package/dist/wrangler/deploy-environment.d.ts.map +1 -0
  28. package/dist/wrangler/deploy-environment.js +383 -0
  29. package/dist/wrangler/environment.d.ts +58 -0
  30. package/dist/wrangler/environment.d.ts.map +1 -0
  31. package/dist/wrangler/environment.js +297 -0
  32. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
  33. package/package.json +8 -1
  34. package/src/cli.ts +25 -2
  35. package/src/github-ci/index.test.ts +171 -0
  36. package/src/github-ci/index.ts +272 -30
  37. package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
  38. package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
  39. package/src/monorepo/__tests__/publish-workflow.test.ts +1 -1
  40. package/src/monorepo/ci-workflow.ts +16 -6
  41. package/src/monorepo/managed-files.test.ts +56 -1
  42. package/src/monorepo/managed-files.ts +20 -1
  43. package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
  44. package/src/monorepo/publish-workflow.ts +2 -2
  45. package/src/monorepo/tool-validation.test.ts +85 -0
  46. package/src/monorepo/tool-validation.ts +94 -5
  47. package/src/playwright/index.test.ts +90 -0
  48. package/src/playwright/index.ts +73 -0
  49. package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
  50. package/src/release/bootstrap-npm-packages.ts +34 -0
  51. package/src/release/index.ts +18 -0
  52. package/src/wrangler/cloudflare.ts +289 -0
  53. package/src/wrangler/deploy-environment.test.ts +354 -0
  54. package/src/wrangler/deploy-environment.ts +445 -0
  55. package/src/wrangler/environment.test.ts +173 -0
  56. package/src/wrangler/environment.ts +366 -0
@@ -0,0 +1,289 @@
1
+ import typia from 'typia';
2
+ import type { LiveKvNamespace } from './environment.js';
3
+
4
+ export interface R2Bucket {
5
+ name: string;
6
+ }
7
+
8
+ export interface WorkerScript {
9
+ id: string;
10
+ }
11
+
12
+ export interface WorkerRoute {
13
+ id: string;
14
+ pattern: string;
15
+ script?: string;
16
+ }
17
+
18
+ export interface WorkerDomain {
19
+ id: string;
20
+ hostname: string;
21
+ service?: string;
22
+ }
23
+
24
+ export interface CloudflareZone {
25
+ id: string;
26
+ name: string;
27
+ }
28
+
29
+ export interface DnsRecord {
30
+ id: string;
31
+ name: string;
32
+ type: string;
33
+ content: string;
34
+ proxied?: boolean;
35
+ }
36
+
37
+ export interface CloudflareClient {
38
+ listKvNamespaces(): Promise<LiveKvNamespace[]>;
39
+ createKvNamespace(title: string): Promise<LiveKvNamespace>;
40
+ deleteKvNamespace(id: string): Promise<void>;
41
+ listR2Buckets(): Promise<R2Bucket[]>;
42
+ createR2Bucket(name: string): Promise<void>;
43
+ listR2Objects(bucket: string): Promise<string[]>;
44
+ deleteR2Object(bucket: string, key: string): Promise<void>;
45
+ deleteR2Bucket(name: string): Promise<void>;
46
+ listWorkerScripts(): Promise<WorkerScript[]>;
47
+ deleteWorkerScript(name: string): Promise<void>;
48
+ listWorkerDomains(): Promise<WorkerDomain[]>;
49
+ createWorkerDomain(hostname: string, workerName: string, zoneId: string): Promise<void>;
50
+ deleteWorkerDomain(id: string): Promise<void>;
51
+ listZones(): Promise<CloudflareZone[]>;
52
+ listWorkerRoutes(zoneId: string): Promise<WorkerRoute[]>;
53
+ createWorkerRoute(zoneId: string, pattern: string, workerName: string): Promise<void>;
54
+ deleteWorkerRoute(zoneId: string, routeId: string): Promise<void>;
55
+ listDnsRecords(zoneId: string): Promise<DnsRecord[]>;
56
+ createDnsRecord(zoneId: string, name: string, content: string): Promise<void>;
57
+ deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
58
+ }
59
+
60
+ interface CloudflareEnvelope {
61
+ success: boolean;
62
+ result?: unknown;
63
+ errors?: Array<{ code?: number; message?: string }>;
64
+ result_info?: {
65
+ page?: number;
66
+ total_pages?: number;
67
+ cursor?: string;
68
+ is_truncated?: boolean;
69
+ };
70
+ }
71
+
72
+ const isCloudflareEnvelope = typia.createIs<CloudflareEnvelope>();
73
+ const isKvNamespaces = typia.createIs<LiveKvNamespace[]>();
74
+ const isR2Buckets = typia.createIs<R2Bucket[]>();
75
+ const isWorkerScripts = typia.createIs<WorkerScript[]>();
76
+ const isWorkerDomains = typia.createIs<WorkerDomain[]>();
77
+ const isCloudflareZones = typia.createIs<CloudflareZone[]>();
78
+ const isWorkerRoutes = typia.createIs<WorkerRoute[]>();
79
+ const isDnsRecords = typia.createIs<DnsRecord[]>();
80
+ const isR2Objects = typia.createIs<Array<{ key: string }>>();
81
+ const isR2BucketPage = typia.createIs<{ buckets: R2Bucket[] }>();
82
+ const isR2ObjectPage = typia.createIs<{ objects: Array<{ key: string }> }>();
83
+ const isCreatedKvNamespace = typia.createIs<LiveKvNamespace>();
84
+
85
+ export class CloudflareApiError extends Error {
86
+ constructor(
87
+ message: string,
88
+ readonly status: number,
89
+ readonly codes: number[],
90
+ ) {
91
+ super(message);
92
+ }
93
+ }
94
+
95
+ export class CloudflareRestClient implements CloudflareClient {
96
+ private readonly accountPath: string;
97
+
98
+ constructor(
99
+ accountId: string,
100
+ private readonly apiToken: string,
101
+ private readonly fetcher: typeof fetch = fetch,
102
+ ) {
103
+ if (!accountId || !apiToken) {
104
+ throw new Error('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required.');
105
+ }
106
+ this.accountPath = `/accounts/${encodeURIComponent(accountId)}`;
107
+ }
108
+
109
+ listKvNamespaces(): Promise<LiveKvNamespace[]> {
110
+ return this.listPageItems(`${this.accountPath}/storage/kv/namespaces`, isKvNamespaces);
111
+ }
112
+
113
+ async createKvNamespace(title: string): Promise<LiveKvNamespace> {
114
+ const result = await this.result(`${this.accountPath}/storage/kv/namespaces`, {
115
+ method: 'POST',
116
+ body: JSON.stringify({ title }),
117
+ });
118
+ if (!isCreatedKvNamespace(result)) {
119
+ throw new Error(`Cloudflare returned an invalid KV namespace after creating ${title}.`);
120
+ }
121
+ return result;
122
+ }
123
+
124
+ async deleteKvNamespace(id: string): Promise<void> {
125
+ await this.result(`${this.accountPath}/storage/kv/namespaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
126
+ }
127
+
128
+ async listR2Buckets(): Promise<R2Bucket[]> {
129
+ const buckets: R2Bucket[] = [];
130
+ let page = 1;
131
+ for (;;) {
132
+ const envelope = await this.request(`${this.accountPath}/r2/buckets?per_page=1000&page=${page}`);
133
+ let rows: R2Bucket[];
134
+ if (isR2Buckets(envelope.result)) rows = envelope.result;
135
+ else if (isR2BucketPage(envelope.result)) rows = envelope.result.buckets;
136
+ else throw new Error('Cloudflare returned an invalid R2 bucket listing.');
137
+ buckets.push(...rows);
138
+ const totalPages = envelope.result_info?.total_pages ?? page;
139
+ if (page >= totalPages) break;
140
+ page += 1;
141
+ }
142
+ return buckets;
143
+ }
144
+
145
+ async createR2Bucket(name: string): Promise<void> {
146
+ await this.result(`${this.accountPath}/r2/buckets`, { method: 'POST', body: JSON.stringify({ name }) });
147
+ }
148
+
149
+ async listR2Objects(bucket: string): Promise<string[]> {
150
+ const keys: string[] = [];
151
+ let cursor: string | undefined;
152
+ do {
153
+ const query = new URLSearchParams({ per_page: '1000' });
154
+ if (cursor) query.set('cursor', cursor);
155
+ const envelope = await this.request(
156
+ `${this.accountPath}/r2/buckets/${encodeURIComponent(bucket)}/objects?${query.toString()}`,
157
+ );
158
+ let rows: Array<{ key: string }>;
159
+ if (isR2Objects(envelope.result)) rows = envelope.result;
160
+ else if (isR2ObjectPage(envelope.result)) rows = envelope.result.objects;
161
+ else throw new Error(`Cloudflare returned an invalid object listing for R2 bucket ${bucket}.`);
162
+ keys.push(...rows.map((row) => row.key));
163
+ cursor = envelope.result_info?.is_truncated === true ? envelope.result_info.cursor : undefined;
164
+ } while (cursor);
165
+ return keys;
166
+ }
167
+
168
+ async deleteR2Object(bucket: string, key: string): Promise<void> {
169
+ const objectPath = key.split('/').map(encodeURIComponent).join('/');
170
+ await this.result(`${this.accountPath}/r2/buckets/${encodeURIComponent(bucket)}/objects/${objectPath}`, {
171
+ method: 'DELETE',
172
+ });
173
+ }
174
+
175
+ async deleteR2Bucket(name: string): Promise<void> {
176
+ await this.result(`${this.accountPath}/r2/buckets/${encodeURIComponent(name)}`, { method: 'DELETE' });
177
+ }
178
+
179
+ listWorkerScripts(): Promise<WorkerScript[]> {
180
+ return this.listPageItems(`${this.accountPath}/workers/scripts`, isWorkerScripts);
181
+ }
182
+
183
+ async deleteWorkerScript(name: string): Promise<void> {
184
+ await this.result(`${this.accountPath}/workers/scripts/${encodeURIComponent(name)}`, { method: 'DELETE' });
185
+ }
186
+
187
+ listWorkerDomains(): Promise<WorkerDomain[]> {
188
+ return this.listPageItems(`${this.accountPath}/workers/domains`, isWorkerDomains);
189
+ }
190
+
191
+ async createWorkerDomain(hostname: string, workerName: string, zoneId: string): Promise<void> {
192
+ await this.result(`${this.accountPath}/workers/domains`, {
193
+ method: 'PUT',
194
+ body: JSON.stringify({ hostname, service: workerName, zone_id: zoneId }),
195
+ });
196
+ }
197
+
198
+ async deleteWorkerDomain(id: string): Promise<void> {
199
+ await this.result(`${this.accountPath}/workers/domains/${encodeURIComponent(id)}`, { method: 'DELETE' });
200
+ }
201
+
202
+ listZones(): Promise<CloudflareZone[]> {
203
+ return this.listPageItems('/zones', isCloudflareZones);
204
+ }
205
+
206
+ listWorkerRoutes(zoneId: string): Promise<WorkerRoute[]> {
207
+ return this.listPageItems(`/zones/${encodeURIComponent(zoneId)}/workers/routes`, isWorkerRoutes);
208
+ }
209
+
210
+ async createWorkerRoute(zoneId: string, pattern: string, workerName: string): Promise<void> {
211
+ await this.result(`/zones/${encodeURIComponent(zoneId)}/workers/routes`, {
212
+ method: 'POST',
213
+ body: JSON.stringify({ pattern, script: workerName }),
214
+ });
215
+ }
216
+
217
+ async deleteWorkerRoute(zoneId: string, routeId: string): Promise<void> {
218
+ await this.result(`/zones/${encodeURIComponent(zoneId)}/workers/routes/${encodeURIComponent(routeId)}`, {
219
+ method: 'DELETE',
220
+ });
221
+ }
222
+
223
+ listDnsRecords(zoneId: string): Promise<DnsRecord[]> {
224
+ return this.listPageItems(`/zones/${encodeURIComponent(zoneId)}/dns_records`, isDnsRecords);
225
+ }
226
+
227
+ async createDnsRecord(zoneId: string, name: string, content: string): Promise<void> {
228
+ await this.result(`/zones/${encodeURIComponent(zoneId)}/dns_records`, {
229
+ method: 'POST',
230
+ body: JSON.stringify({ type: 'CNAME', name, content, proxied: true }),
231
+ });
232
+ }
233
+
234
+ async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
235
+ await this.result(`/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, {
236
+ method: 'DELETE',
237
+ });
238
+ }
239
+
240
+ private async listPageItems<T>(path: string, isItems: (value: unknown) => value is T[]): Promise<T[]> {
241
+ const items: T[] = [];
242
+ let page = 1;
243
+ for (;;) {
244
+ const separator = path.includes('?') ? '&' : '?';
245
+ const envelope = await this.request(`${path}${separator}per_page=1000&page=${page}`);
246
+ if (!isItems(envelope.result)) {
247
+ throw new Error(`Cloudflare returned an invalid paginated result for ${path}.`);
248
+ }
249
+ items.push(...envelope.result);
250
+ const totalPages = envelope.result_info?.total_pages ?? page;
251
+ if (page >= totalPages) break;
252
+ page += 1;
253
+ }
254
+ return items;
255
+ }
256
+
257
+ private async result(path: string, init?: RequestInit): Promise<unknown> {
258
+ return (await this.request(path, init)).result;
259
+ }
260
+
261
+ private async request(path: string, init: RequestInit = {}): Promise<CloudflareEnvelope> {
262
+ const response = await this.fetcher(`https://api.cloudflare.com/client/v4${path}`, {
263
+ ...init,
264
+ headers: {
265
+ Authorization: `Bearer ${this.apiToken}`,
266
+ 'Content-Type': 'application/json',
267
+ ...init.headers,
268
+ },
269
+ });
270
+ const body: unknown = await response.json();
271
+ if (!isCloudflareEnvelope(body)) {
272
+ throw new CloudflareApiError(`Cloudflare returned a malformed response for ${path}.`, response.status, []);
273
+ }
274
+ if (!response.ok || !body.success) {
275
+ const errors = body.errors ?? [];
276
+ const message =
277
+ errors
278
+ .map((error) => error.message)
279
+ .filter(Boolean)
280
+ .join('; ') || `HTTP ${response.status}`;
281
+ throw new CloudflareApiError(
282
+ `Cloudflare API ${path} failed: ${message}`,
283
+ response.status,
284
+ errors.flatMap((error) => error.code ?? []),
285
+ );
286
+ }
287
+ return body;
288
+ }
289
+ }
@@ -0,0 +1,354 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import type {
7
+ CloudflareClient,
8
+ CloudflareZone,
9
+ DnsRecord,
10
+ R2Bucket,
11
+ WorkerDomain,
12
+ WorkerRoute,
13
+ WorkerScript,
14
+ } from './cloudflare.js';
15
+ import { cleanupPullRequest, deployEnvironment, type ProcessResult, type ProcessRunner } from './deploy-environment.js';
16
+ import type { LiveKvNamespace } from './environment.js';
17
+
18
+ const HASH = '16577780061662788004';
19
+ const FIXTURE = `[env.staging]
20
+ name = "fixture-worker-staging"
21
+ workers_dev = false
22
+
23
+ [env.staging.vars]
24
+ ENVIRONMENT = "staging"
25
+ `;
26
+
27
+ const ROUTED_FIXTURE = `${FIXTURE}
28
+ [[env.staging.routes]]
29
+ pattern = "*.staging.conloca.com/*"
30
+ zone_name = "conloca.com"
31
+ `;
32
+
33
+ const roots: string[] = [];
34
+
35
+ function requiredTestValue<T>(value: T | undefined, name: string): T {
36
+ if (value === undefined) throw new Error(`${name} was not captured.`);
37
+ return value;
38
+ }
39
+
40
+ afterEach(async () => {
41
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
42
+ });
43
+
44
+ class FakeRunner implements ProcessRunner {
45
+ readonly calls: string[][] = [];
46
+ configPathSeen: string | undefined;
47
+ secretsPathSeen: string | undefined;
48
+ secretsMode: number | undefined;
49
+ secretsJson: string | undefined;
50
+
51
+ constructor(
52
+ private readonly versions: unknown,
53
+ private readonly deployment: unknown,
54
+ ) {}
55
+
56
+ async run(_command: string, args: string[]): Promise<ProcessResult> {
57
+ this.calls.push(args);
58
+ if (args[0] === 'versions' && args[1] === 'list') {
59
+ return success(this.versions);
60
+ }
61
+ if (args[0] === 'deployments' && args[1] === 'status') {
62
+ return success(this.deployment);
63
+ }
64
+ const configIndex = args.indexOf('--config');
65
+ if (configIndex >= 0) {
66
+ this.configPathSeen = args[configIndex + 1];
67
+ expect(this.configPathSeen && existsSync(this.configPathSeen)).toBe(true);
68
+ }
69
+ const secretsIndex = args.indexOf('--secrets-file');
70
+ if (secretsIndex >= 0) {
71
+ this.secretsPathSeen = args[secretsIndex + 1];
72
+ const secretsPath = requiredTestValue(this.secretsPathSeen, 'secrets path');
73
+ this.secretsMode = statSync(secretsPath).mode & 0o777;
74
+ this.secretsJson = readFileSync(secretsPath, 'utf8');
75
+ }
76
+ return success({});
77
+ }
78
+ }
79
+
80
+ class FakeCloudflare implements CloudflareClient {
81
+ namespaces: LiveKvNamespace[] = [];
82
+ buckets: R2Bucket[] = [];
83
+ scripts: WorkerScript[] = [{ id: 'fixture-worker-pr123' }];
84
+ domains: WorkerDomain[] = [];
85
+ zones: CloudflareZone[] = [];
86
+ routes: Record<string, WorkerRoute[]> = {};
87
+ records: Record<string, DnsRecord[]> = {};
88
+ objects: Record<string, string[]> = {};
89
+ mutations: string[] = [];
90
+
91
+ async listKvNamespaces(): Promise<LiveKvNamespace[]> {
92
+ return this.namespaces;
93
+ }
94
+ async createKvNamespace(title: string): Promise<LiveKvNamespace> {
95
+ this.mutations.push(`create-kv:${title}`);
96
+ const namespace = { id: `id-${title}`, title };
97
+ this.namespaces.push(namespace);
98
+ return namespace;
99
+ }
100
+ async deleteKvNamespace(id: string): Promise<void> {
101
+ this.mutations.push(`delete-kv:${id}`);
102
+ }
103
+ async listR2Buckets(): Promise<R2Bucket[]> {
104
+ return this.buckets;
105
+ }
106
+ async createR2Bucket(name: string): Promise<void> {
107
+ this.mutations.push(`create-r2:${name}`);
108
+ this.buckets.push({ name });
109
+ }
110
+ async listR2Objects(bucket: string): Promise<string[]> {
111
+ return this.objects[bucket] ?? [];
112
+ }
113
+ async deleteR2Object(bucket: string, key: string): Promise<void> {
114
+ this.mutations.push(`delete-object:${bucket}:${key}`);
115
+ }
116
+ async deleteR2Bucket(name: string): Promise<void> {
117
+ this.mutations.push(`delete-r2:${name}`);
118
+ }
119
+ async listWorkerScripts(): Promise<WorkerScript[]> {
120
+ return this.scripts;
121
+ }
122
+ async deleteWorkerScript(name: string): Promise<void> {
123
+ this.mutations.push(`delete-worker:${name}`);
124
+ }
125
+ async listWorkerDomains(): Promise<WorkerDomain[]> {
126
+ return this.domains;
127
+ }
128
+ async createWorkerDomain(hostname: string, workerName: string): Promise<void> {
129
+ this.mutations.push(`create-domain:${hostname}:${workerName}`);
130
+ }
131
+ async deleteWorkerDomain(id: string): Promise<void> {
132
+ this.mutations.push(`delete-domain:${id}`);
133
+ }
134
+ async listZones(): Promise<CloudflareZone[]> {
135
+ return this.zones;
136
+ }
137
+ async listWorkerRoutes(zoneId: string): Promise<WorkerRoute[]> {
138
+ return this.routes[zoneId] ?? [];
139
+ }
140
+ async createWorkerRoute(zoneId: string, pattern: string, workerName: string): Promise<void> {
141
+ this.mutations.push(`create-route:${zoneId}:${pattern}:${workerName}`);
142
+ }
143
+ async deleteWorkerRoute(zoneId: string, routeId: string): Promise<void> {
144
+ this.mutations.push(`delete-route:${zoneId}:${routeId}`);
145
+ }
146
+ async listDnsRecords(zoneId: string): Promise<DnsRecord[]> {
147
+ return this.records[zoneId] ?? [];
148
+ }
149
+ async createDnsRecord(zoneId: string, name: string, content: string): Promise<void> {
150
+ this.mutations.push(`create-dns:${zoneId}:${name}:${content}`);
151
+ }
152
+ async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
153
+ this.mutations.push(`delete-dns:${zoneId}:${recordId}`);
154
+ }
155
+ }
156
+
157
+ describe('deploy-environment remote version fallback', () => {
158
+ it('returns a remote cache hit for the active tagged 100% version', async () => {
159
+ const root = await fixtureRoot();
160
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
161
+ versions: [{ version_id: 'version-1', percentage: 100 }],
162
+ });
163
+
164
+ const result = await deployEnvironment(root, 'pr123', dependencies(runner, new FakeCloudflare()));
165
+
166
+ expect(result.action).toBe('remote-cache-hit');
167
+ expect(runner.calls.map((args) => args.slice(0, 2))).toEqual([
168
+ ['versions', 'list'],
169
+ ['deployments', 'status'],
170
+ ]);
171
+ });
172
+
173
+ it('activates an existing tagged version that is not current', async () => {
174
+ const root = await fixtureRoot();
175
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
176
+ versions: [{ version_id: 'version-2', percentage: 100 }],
177
+ });
178
+
179
+ const result = await deployEnvironment(root, 'pr123', dependencies(runner, new FakeCloudflare()));
180
+
181
+ expect(result.action).toBe('activated');
182
+ expect(runner.calls.at(-1)?.slice(0, 3)).toEqual(['versions', 'deploy', '--version-tag']);
183
+ expect(runner.configPathSeen).toBeDefined();
184
+ expect(existsSync(requiredTestValue(runner.configPathSeen, 'config path'))).toBe(false);
185
+ });
186
+
187
+ it('uploads a missing tag with a temporary config and secure secrets file, then removes both', async () => {
188
+ const root = await fixtureRoot();
189
+ await writeFile(join(root, '.dev.vars.example'), 'OAUTH_STATE_SIGNING_KEY=""\nTOKEN_ENCRYPTION_KEY=""\n');
190
+ const runner = new FakeRunner([], { versions: [{ version_id: 'version-2', percentage: 100 }] });
191
+
192
+ const result = await deployEnvironment(root, 'pr123', {
193
+ ...dependencies(runner, new FakeCloudflare()),
194
+ processEnv: {
195
+ CLOUDFLARE_ACCOUNT_ID: 'account-1',
196
+ CLOUDFLARE_API_TOKEN: 'token',
197
+ NX_TASK_HASH: HASH,
198
+ NX_TASK_TARGET_PROJECT: 'fixture',
199
+ OAUTH_STATE_SIGNING_KEY: 'shared-secret',
200
+ TOKEN_ENCRYPTION_KEY: 'encryption-secret',
201
+ },
202
+ });
203
+
204
+ expect(result.action).toBe('deployed');
205
+ expect(runner.calls.at(-1)?.[0]).toBe('deploy');
206
+ expect(runner.calls.at(-1)).toContain('--tag');
207
+ expect(runner.calls.at(-1)).toContain(`nx-${HASH}`);
208
+ expect(runner.secretsMode).toBe(0o600);
209
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
210
+ OAUTH_STATE_SIGNING_KEY: 'shared-secret',
211
+ TOKEN_ENCRYPTION_KEY: 'encryption-secret',
212
+ });
213
+ expect(existsSync(requiredTestValue(runner.configPathSeen, 'config path'))).toBe(false);
214
+ expect(existsSync(requiredTestValue(runner.secretsPathSeen, 'secrets path'))).toBe(false);
215
+ });
216
+
217
+ it('passes only present manifest values for fixed environments and preserves absent remote secrets', async () => {
218
+ const root = await fixtureRoot();
219
+ await writeFile(join(root, '.dev.vars.example'), 'OAUTH_STATE_SIGNING_KEY=""\nTOKEN_ENCRYPTION_KEY=""\n');
220
+ const runner = new FakeRunner([], {});
221
+ const cloudflare = new FakeCloudflare();
222
+
223
+ const result = await deployEnvironment(root, 'staging', {
224
+ runner,
225
+ cloudflare,
226
+ processEnv: {
227
+ CLOUDFLARE_ACCOUNT_ID: 'account-1',
228
+ CLOUDFLARE_API_TOKEN: 'token',
229
+ OAUTH_STATE_SIGNING_KEY: 'shared-secret',
230
+ },
231
+ });
232
+
233
+ expect(result.action).toBe('deployed');
234
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
235
+ OAUTH_STATE_SIGNING_KEY: 'shared-secret',
236
+ });
237
+ expect(existsSync(requiredTestValue(runner.secretsPathSeen, 'secrets path'))).toBe(false);
238
+ });
239
+
240
+ it('rejects a first PR Worker with missing manifest secrets before mutating Cloudflare', async () => {
241
+ const root = await fixtureRoot();
242
+ await writeFile(join(root, '.dev.vars.example'), 'OAUTH_STATE_SIGNING_KEY=""\n');
243
+ const cloudflare = new FakeCloudflare();
244
+ cloudflare.scripts = [];
245
+
246
+ await expect(
247
+ deployEnvironment(root, 'pr123', {
248
+ ...dependencies(new FakeRunner([], {}), cloudflare),
249
+ processEnv: {
250
+ CLOUDFLARE_ACCOUNT_ID: 'account-1',
251
+ CLOUDFLARE_API_TOKEN: 'token',
252
+ NX_TASK_HASH: HASH,
253
+ NX_TASK_TARGET_PROJECT: 'fixture',
254
+ },
255
+ }),
256
+ ).rejects.toThrow(/OAUTH_STATE_SIGNING_KEY/);
257
+ expect(cloudflare.mutations).toEqual([]);
258
+ });
259
+
260
+ it('recovers when a parallel deployment creates the wildcard DNS record first', async () => {
261
+ const root = await fixtureRoot(ROUTED_FIXTURE);
262
+ const cloudflare = new FakeCloudflare();
263
+ cloudflare.zones = [{ id: 'zone', name: 'conloca.com' }];
264
+ let createAttempts = 0;
265
+ cloudflare.createDnsRecord = async (zoneId, name, content) => {
266
+ createAttempts += 1;
267
+ cloudflare.records[zoneId] = [{ id: 'raced', name, type: 'CNAME', content, proxied: true }];
268
+ throw new Error('record already exists');
269
+ };
270
+
271
+ const result = await deployEnvironment(root, 'pr123', dependencies(new FakeRunner([], {}), cloudflare));
272
+
273
+ expect(result.action).toBe('deployed');
274
+ expect(createAttempts).toBe(1);
275
+ expect(cloudflare.records.zone?.map((record) => record.name)).toEqual(['*.pr123.conloca.com']);
276
+ });
277
+ });
278
+
279
+ describe('cleanup-pr exact environment matching', () => {
280
+ it('rejects an invalid PR before touching the client', async () => {
281
+ const cloudflare = new FakeCloudflare();
282
+ let calls = 0;
283
+ cloudflare.listWorkerDomains = async () => {
284
+ calls += 1;
285
+ return [];
286
+ };
287
+
288
+ await expect(cleanupPullRequest('/unused', 0, { cloudflare })).rejects.toThrow(/1 through 999999999/);
289
+ expect(calls).toBe(0);
290
+ });
291
+
292
+ it('deletes only exact hyphen/dot-delimited pr123 resources and is idempotent for missing resources', async () => {
293
+ const cloudflare = new FakeCloudflare();
294
+ cloudflare.domains = [
295
+ { id: 'domain-123', hostname: 'app.pr123.conloca.com' },
296
+ { id: 'domain-1234', hostname: 'app.pr1234.conloca.com' },
297
+ ];
298
+ cloudflare.zones = [{ id: 'zone', name: 'conloca.com' }];
299
+ cloudflare.routes.zone = [
300
+ { id: 'route-123', pattern: '*.pr123.conloca.com/*' },
301
+ { id: 'route-1234', pattern: '*.pr1234.conloca.com/*' },
302
+ ];
303
+ cloudflare.records.zone = [
304
+ { id: 'dns-123', name: '*.pr123.conloca.com', type: 'CNAME', content: 'pr123.conloca.com' },
305
+ { id: 'dns-staging', name: '*.staging.conloca.com', type: 'CNAME', content: 'staging.conloca.com' },
306
+ ];
307
+ cloudflare.scripts = [{ id: 'conloca-app-pr123' }, { id: 'conloca-app-pr1234' }, { id: 'conloca-app-staging' }];
308
+ cloudflare.namespaces = [
309
+ { id: 'kv-123', title: 'org-profiles-pr123' },
310
+ { id: 'kv-1234', title: 'org-profiles-pr1234' },
311
+ ];
312
+ cloudflare.buckets = [{ name: 'conloca-media-pr123' }, { name: 'conloca-media-pr1234' }];
313
+ cloudflare.objects['conloca-media-pr123'] = ['one', 'nested/two'];
314
+
315
+ const result = await cleanupPullRequest('/unused', 123, { cloudflare });
316
+
317
+ expect(result.deleted).toEqual({
318
+ workers: 1,
319
+ routes: 1,
320
+ domains: 1,
321
+ kvNamespaces: 1,
322
+ r2Buckets: 1,
323
+ r2Objects: 2,
324
+ dnsRecords: 1,
325
+ });
326
+ expect(cloudflare.mutations.join('\n')).toContain('delete-worker:conloca-app-pr123');
327
+ expect(cloudflare.mutations.join('\n')).not.toContain('pr1234');
328
+ expect(cloudflare.mutations.join('\n')).not.toContain('staging');
329
+ });
330
+ });
331
+
332
+ async function fixtureRoot(toml = FIXTURE): Promise<string> {
333
+ const root = await mkdtemp(join(tmpdir(), 'smoo-wrangler-test-'));
334
+ roots.push(root);
335
+ await writeFile(join(root, 'wrangler.toml'), toml);
336
+ return root;
337
+ }
338
+
339
+ function dependencies(runner: ProcessRunner, cloudflare: CloudflareClient) {
340
+ return {
341
+ runner,
342
+ cloudflare,
343
+ processEnv: {
344
+ CLOUDFLARE_ACCOUNT_ID: 'account-1',
345
+ CLOUDFLARE_API_TOKEN: 'token',
346
+ NX_TASK_HASH: HASH,
347
+ NX_TASK_TARGET_PROJECT: 'fixture',
348
+ },
349
+ };
350
+ }
351
+
352
+ function success(value: unknown): ProcessResult {
353
+ return { exitCode: 0, stdout: JSON.stringify(value), stderr: '' };
354
+ }