puls-dev 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/dist/bin/install-shell.js +5 -6
- package/dist/bin/puls.js +10 -3
- package/dist/core/config.d.ts +4 -0
- package/dist/core/decorators.d.ts +4 -0
- package/dist/core/decorators.js +2 -0
- package/dist/core/parallel.test.js +4 -3
- package/dist/core/resource.d.ts +2 -1
- package/dist/core/resource.js +4 -2
- package/dist/core/stack.d.ts +4 -0
- package/dist/core/stack.js +8 -8
- package/dist/providers/aws/acm.test.d.ts +1 -0
- package/dist/providers/aws/acm.test.js +167 -0
- package/dist/providers/aws/cloudfront.test.d.ts +1 -0
- package/dist/providers/aws/cloudfront.test.js +170 -0
- package/dist/providers/aws/fargate.test.d.ts +1 -0
- package/dist/providers/aws/fargate.test.js +244 -0
- package/dist/providers/aws/rds.test.d.ts +1 -0
- package/dist/providers/aws/rds.test.js +219 -0
- package/dist/providers/aws/sqs.test.d.ts +1 -0
- package/dist/providers/aws/sqs.test.js +181 -0
- package/dist/providers/cloudflare/api.d.ts +15 -0
- package/dist/providers/cloudflare/api.js +199 -0
- package/dist/providers/cloudflare/index.d.ts +14 -0
- package/dist/providers/cloudflare/index.js +19 -0
- package/dist/providers/cloudflare/kv.d.ts +20 -0
- package/dist/providers/cloudflare/kv.js +69 -0
- package/dist/providers/cloudflare/kv.test.d.ts +1 -0
- package/dist/providers/cloudflare/kv.test.js +134 -0
- package/dist/providers/cloudflare/r2.d.ts +14 -0
- package/dist/providers/cloudflare/r2.js +57 -0
- package/dist/providers/cloudflare/r2.test.d.ts +1 -0
- package/dist/providers/cloudflare/r2.test.js +132 -0
- package/dist/providers/cloudflare/worker.d.ts +28 -0
- package/dist/providers/cloudflare/worker.js +172 -0
- package/dist/providers/cloudflare/worker.test.d.ts +1 -0
- package/dist/providers/cloudflare/worker.test.js +220 -0
- package/dist/providers/cloudflare/zone.d.ts +42 -0
- package/dist/providers/cloudflare/zone.js +280 -0
- package/dist/providers/cloudflare/zone.test.d.ts +1 -0
- package/dist/providers/cloudflare/zone.test.js +284 -0
- package/dist/providers/firebase/auth.test.d.ts +1 -0
- package/dist/providers/firebase/auth.test.js +145 -0
- package/dist/providers/firebase/hosting.test.js +7 -6
- package/dist/providers/firebase/storage.test.d.ts +1 -0
- package/dist/providers/firebase/storage.test.js +148 -0
- package/package.json +6 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { SQSClient } from '@aws-sdk/client-sqs';
|
|
4
|
+
import { SQSBuilder } from './sqs.js';
|
|
5
|
+
import { Config } from '../../core/config.js';
|
|
6
|
+
describe('SQSBuilder Unit Tests', () => {
|
|
7
|
+
let originalSend;
|
|
8
|
+
let calls = [];
|
|
9
|
+
let mockResponses = {};
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
Config.set({ dryRun: false, providers: { aws: { region: 'us-east-1' } } });
|
|
12
|
+
calls = [];
|
|
13
|
+
mockResponses = {};
|
|
14
|
+
originalSend = SQSClient.prototype.send;
|
|
15
|
+
SQSClient.prototype.send = async function (command) {
|
|
16
|
+
const commandName = command.constructor.name;
|
|
17
|
+
calls.push({ commandName, input: command.input });
|
|
18
|
+
const handler = mockResponses[commandName];
|
|
19
|
+
if (handler) {
|
|
20
|
+
if (typeof handler === 'function')
|
|
21
|
+
return handler(command.input);
|
|
22
|
+
if (handler instanceof Error)
|
|
23
|
+
throw handler;
|
|
24
|
+
return handler;
|
|
25
|
+
}
|
|
26
|
+
return {};
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
SQSClient.prototype.send = originalSend;
|
|
31
|
+
});
|
|
32
|
+
test('returns null when queue does not exist', async () => {
|
|
33
|
+
const err = new Error('no queue');
|
|
34
|
+
err.name = 'QueueDoesNotExist';
|
|
35
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
36
|
+
const builder = new SQSBuilder('my-queue');
|
|
37
|
+
const result = await builder.discoveryPromise;
|
|
38
|
+
assert.strictEqual(result, null);
|
|
39
|
+
assert.strictEqual(calls[0].commandName, 'GetQueueUrlCommand');
|
|
40
|
+
});
|
|
41
|
+
test('discovers existing queue and populates resolvedUrl and resolvedArn', async () => {
|
|
42
|
+
mockResponses['GetQueueUrlCommand'] = {
|
|
43
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue',
|
|
44
|
+
};
|
|
45
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
46
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue' },
|
|
47
|
+
};
|
|
48
|
+
const builder = new SQSBuilder('my-queue');
|
|
49
|
+
const result = await builder.discoveryPromise;
|
|
50
|
+
assert.ok(result);
|
|
51
|
+
assert.strictEqual(builder.resolvedUrl, 'https://sqs.us-east-1.amazonaws.com/123/my-queue');
|
|
52
|
+
assert.strictEqual(builder.resolvedArn, 'arn:aws:sqs:us-east-1:123:my-queue');
|
|
53
|
+
});
|
|
54
|
+
test('performs dry-run without creating the queue', async () => {
|
|
55
|
+
Config.set({ dryRun: true, providers: { aws: { region: 'us-east-1' } } });
|
|
56
|
+
const err = new Error('no queue');
|
|
57
|
+
err.name = 'QueueDoesNotExist';
|
|
58
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
59
|
+
const builder = new SQSBuilder('my-queue');
|
|
60
|
+
builder.retention(7).timeout(60);
|
|
61
|
+
const result = await builder.deploy();
|
|
62
|
+
assert.ok(result);
|
|
63
|
+
assert.ok(result.url.includes('DRYRUN'));
|
|
64
|
+
assert.ok(result.arn.includes('DRYRUN'));
|
|
65
|
+
const writeCalls = calls.filter((c) => c.commandName === 'CreateQueueCommand');
|
|
66
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
67
|
+
});
|
|
68
|
+
test('creates new standard queue with configured attributes', async () => {
|
|
69
|
+
const err = new Error('no queue');
|
|
70
|
+
err.name = 'QueueDoesNotExist';
|
|
71
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
72
|
+
mockResponses['CreateQueueCommand'] = {
|
|
73
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue',
|
|
74
|
+
};
|
|
75
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
76
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue' },
|
|
77
|
+
};
|
|
78
|
+
const builder = new SQSBuilder('my-queue');
|
|
79
|
+
builder.retention(7).timeout(45).delay(5);
|
|
80
|
+
const result = await builder.deploy();
|
|
81
|
+
assert.ok(result);
|
|
82
|
+
assert.strictEqual(result.name, 'my-queue');
|
|
83
|
+
assert.strictEqual(result.url, 'https://sqs.us-east-1.amazonaws.com/123/my-queue');
|
|
84
|
+
const createCall = calls.find((c) => c.commandName === 'CreateQueueCommand');
|
|
85
|
+
assert.ok(createCall);
|
|
86
|
+
assert.strictEqual(createCall.input.Attributes.VisibilityTimeout, '45');
|
|
87
|
+
assert.strictEqual(createCall.input.Attributes.MessageRetentionPeriod, String(7 * 86400));
|
|
88
|
+
assert.strictEqual(createCall.input.Attributes.DelaySeconds, '5');
|
|
89
|
+
});
|
|
90
|
+
test('creates FIFO queue with .fifo suffix appended automatically', async () => {
|
|
91
|
+
const err = new Error('no queue');
|
|
92
|
+
err.name = 'QueueDoesNotExist';
|
|
93
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
94
|
+
mockResponses['CreateQueueCommand'] = {
|
|
95
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue.fifo',
|
|
96
|
+
};
|
|
97
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
98
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue.fifo' },
|
|
99
|
+
};
|
|
100
|
+
const builder = new SQSBuilder('my-queue');
|
|
101
|
+
builder.fifo().deduplication();
|
|
102
|
+
const result = await builder.deploy();
|
|
103
|
+
assert.ok(result);
|
|
104
|
+
assert.strictEqual(result.name, 'my-queue.fifo');
|
|
105
|
+
const createCall = calls.find((c) => c.commandName === 'CreateQueueCommand');
|
|
106
|
+
assert.ok(createCall);
|
|
107
|
+
assert.strictEqual(createCall.input.QueueName, 'my-queue.fifo');
|
|
108
|
+
assert.strictEqual(createCall.input.Attributes.FifoQueue, 'true');
|
|
109
|
+
assert.strictEqual(createCall.input.Attributes.ContentBasedDeduplication, 'true');
|
|
110
|
+
});
|
|
111
|
+
test('creates DLQ before main queue and wires redrive policy', async () => {
|
|
112
|
+
const err = new Error('no queue');
|
|
113
|
+
err.name = 'QueueDoesNotExist';
|
|
114
|
+
let getUrlCallCount = 0;
|
|
115
|
+
mockResponses['GetQueueUrlCommand'] = (input) => {
|
|
116
|
+
getUrlCallCount++;
|
|
117
|
+
if (getUrlCallCount === 1)
|
|
118
|
+
throw err; // discovery: queue not found
|
|
119
|
+
// DLQ lookup in ensureQueue: also not found
|
|
120
|
+
if (input.QueueName === 'my-dlq')
|
|
121
|
+
throw err;
|
|
122
|
+
return { QueueUrl: `https://sqs.us-east-1.amazonaws.com/123/${input.QueueName}` };
|
|
123
|
+
};
|
|
124
|
+
mockResponses['CreateQueueCommand'] = (input) => ({
|
|
125
|
+
QueueUrl: `https://sqs.us-east-1.amazonaws.com/123/${input.QueueName}`,
|
|
126
|
+
});
|
|
127
|
+
mockResponses['GetQueueAttributesCommand'] = (input) => ({
|
|
128
|
+
Attributes: { QueueArn: `arn:aws:sqs:us-east-1:123:${input.QueueUrl?.split('/').pop()}` },
|
|
129
|
+
});
|
|
130
|
+
const builder = new SQSBuilder('my-queue');
|
|
131
|
+
builder.dlq('my-dlq', 5);
|
|
132
|
+
const result = await builder.deploy();
|
|
133
|
+
assert.ok(result);
|
|
134
|
+
const dlqCreate = calls.find((c) => c.commandName === 'CreateQueueCommand' && c.input.QueueName === 'my-dlq');
|
|
135
|
+
assert.ok(dlqCreate);
|
|
136
|
+
const mainCreate = calls.find((c) => c.commandName === 'CreateQueueCommand' && c.input.QueueName === 'my-queue');
|
|
137
|
+
assert.ok(mainCreate);
|
|
138
|
+
const redrivePolicy = JSON.parse(mainCreate.input.Attributes.RedrivePolicy);
|
|
139
|
+
assert.strictEqual(redrivePolicy.maxReceiveCount, 5);
|
|
140
|
+
assert.ok(redrivePolicy.deadLetterTargetArn.includes('my-dlq'));
|
|
141
|
+
});
|
|
142
|
+
test('updates mutable attributes on existing queue', async () => {
|
|
143
|
+
mockResponses['GetQueueUrlCommand'] = {
|
|
144
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue',
|
|
145
|
+
};
|
|
146
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
147
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue' },
|
|
148
|
+
};
|
|
149
|
+
const builder = new SQSBuilder('my-queue');
|
|
150
|
+
builder.retention(14).timeout(120);
|
|
151
|
+
await builder.deploy();
|
|
152
|
+
const setAttr = calls.find((c) => c.commandName === 'SetQueueAttributesCommand');
|
|
153
|
+
assert.ok(setAttr);
|
|
154
|
+
assert.strictEqual(setAttr.input.Attributes.VisibilityTimeout, '120');
|
|
155
|
+
assert.strictEqual(setAttr.input.Attributes.MessageRetentionPeriod, String(14 * 86400));
|
|
156
|
+
});
|
|
157
|
+
test('deletes queue on destroy', async () => {
|
|
158
|
+
mockResponses['GetQueueUrlCommand'] = {
|
|
159
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue',
|
|
160
|
+
};
|
|
161
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
162
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue' },
|
|
163
|
+
};
|
|
164
|
+
const builder = new SQSBuilder('my-queue');
|
|
165
|
+
await builder.discoveryPromise;
|
|
166
|
+
const result = await builder.destroy();
|
|
167
|
+
assert.deepStrictEqual(result, { destroyed: 'my-queue' });
|
|
168
|
+
const deleteCall = calls.find((c) => c.commandName === 'DeleteQueueCommand');
|
|
169
|
+
assert.ok(deleteCall);
|
|
170
|
+
assert.strictEqual(deleteCall.input.QueueUrl, 'https://sqs.us-east-1.amazonaws.com/123/my-queue');
|
|
171
|
+
});
|
|
172
|
+
test('skips destroy when queue does not exist', async () => {
|
|
173
|
+
const err = new Error('no queue');
|
|
174
|
+
err.name = 'QueueDoesNotExist';
|
|
175
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
176
|
+
const builder = new SQSBuilder('my-queue');
|
|
177
|
+
const result = await builder.destroy();
|
|
178
|
+
assert.deepStrictEqual(result, { destroyed: 'my-queue' });
|
|
179
|
+
assert.ok(!calls.some((c) => c.commandName === 'DeleteQueueCommand'));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare class CloudflareApiClient {
|
|
2
|
+
private token;
|
|
3
|
+
private static readonly BASE;
|
|
4
|
+
constructor(token: string);
|
|
5
|
+
private get authHeaders();
|
|
6
|
+
private createCfOfflineMock;
|
|
7
|
+
private request;
|
|
8
|
+
get<T>(path: string): Promise<T>;
|
|
9
|
+
post<T>(path: string, body: unknown): Promise<T>;
|
|
10
|
+
put<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T>;
|
|
11
|
+
patch<T>(path: string, body: unknown): Promise<T>;
|
|
12
|
+
delete(path: string, body?: unknown): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare function getCloudflareApi(): CloudflareApiClient;
|
|
15
|
+
export declare function getCloudflareAccountId(): string;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Config } from "../../core/config.js";
|
|
2
|
+
import { withRetry } from "../../core/retry.js";
|
|
3
|
+
import { resourceContextStorage } from "../../core/context.js";
|
|
4
|
+
export class CloudflareApiClient {
|
|
5
|
+
token;
|
|
6
|
+
static BASE = "https://api.cloudflare.com/client/v4";
|
|
7
|
+
constructor(token) {
|
|
8
|
+
this.token = token;
|
|
9
|
+
}
|
|
10
|
+
get authHeaders() {
|
|
11
|
+
return {
|
|
12
|
+
Authorization: `Bearer ${this.token}`,
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
createCfOfflineMock(method, path, body) {
|
|
17
|
+
if (path.includes("/zones") && !path.includes("/dns_records") && !path.includes("/routes")) {
|
|
18
|
+
if (method === "GET") {
|
|
19
|
+
return {
|
|
20
|
+
result: [
|
|
21
|
+
{ id: "mock-zone-id", name: "mock-domain.com", status: "active" }
|
|
22
|
+
]
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
result: { id: "mock-zone-id", name: "mock-domain.com", status: "active" }
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (path.includes("/dns_records")) {
|
|
30
|
+
if (method === "GET") {
|
|
31
|
+
return { result: [] };
|
|
32
|
+
}
|
|
33
|
+
return { result: { id: "mock-dns-record-id" } };
|
|
34
|
+
}
|
|
35
|
+
if (path.includes("/namespaces")) {
|
|
36
|
+
if (method === "GET") {
|
|
37
|
+
return {
|
|
38
|
+
result: [
|
|
39
|
+
{ id: "mock-kv-namespace-id", title: "mock-namespace" }
|
|
40
|
+
]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { result: { id: "mock-kv-namespace-id", title: "mock-namespace" } };
|
|
44
|
+
}
|
|
45
|
+
if (path.includes("/workers/scripts")) {
|
|
46
|
+
return { result: { id: "mock-worker-script-id" } };
|
|
47
|
+
}
|
|
48
|
+
if (path.includes("/routes")) {
|
|
49
|
+
if (method === "GET") {
|
|
50
|
+
return { result: [] };
|
|
51
|
+
}
|
|
52
|
+
return { result: { id: "mock-route-id" } };
|
|
53
|
+
}
|
|
54
|
+
if (path.includes("/r2/buckets")) {
|
|
55
|
+
if (method === "GET") {
|
|
56
|
+
return { result: { buckets: [] } };
|
|
57
|
+
}
|
|
58
|
+
return { result: {} };
|
|
59
|
+
}
|
|
60
|
+
return new Proxy({}, {
|
|
61
|
+
get(target, prop) {
|
|
62
|
+
if (prop === "then")
|
|
63
|
+
return undefined;
|
|
64
|
+
if (prop === "id")
|
|
65
|
+
return "mock-cf-id-123456";
|
|
66
|
+
if (prop === "name")
|
|
67
|
+
return "mock-cf-name";
|
|
68
|
+
if (prop === "status")
|
|
69
|
+
return "active";
|
|
70
|
+
if (prop.endsWith("s"))
|
|
71
|
+
return [];
|
|
72
|
+
return `mock-cf-${prop.toLowerCase()}`;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async request(fn) {
|
|
77
|
+
return withRetry(fn, {
|
|
78
|
+
retryable: (err) => {
|
|
79
|
+
const match = err.message.match(/: (\d+)/);
|
|
80
|
+
const status = match ? parseInt(match[1], 10) : null;
|
|
81
|
+
return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async get(path) {
|
|
86
|
+
const context = resourceContextStorage.getStore();
|
|
87
|
+
const abortSignal = context?.abortSignal;
|
|
88
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
89
|
+
return Promise.resolve(this.createCfOfflineMock("GET", path));
|
|
90
|
+
}
|
|
91
|
+
return this.request(async () => {
|
|
92
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
93
|
+
headers: this.authHeaders,
|
|
94
|
+
...(abortSignal && { signal: abortSignal })
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
throw new Error(`Cloudflare API GET ${path}: ${res.status} ${await res.text()}`);
|
|
98
|
+
return res.json();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async post(path, body) {
|
|
102
|
+
const context = resourceContextStorage.getStore();
|
|
103
|
+
const abortSignal = context?.abortSignal;
|
|
104
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
105
|
+
return Promise.resolve(this.createCfOfflineMock("POST", path, body));
|
|
106
|
+
}
|
|
107
|
+
return this.request(async () => {
|
|
108
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: this.authHeaders,
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
...(abortSignal && { signal: abortSignal })
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok)
|
|
115
|
+
throw new Error(`Cloudflare API POST ${path}: ${res.status} ${await res.text()}`);
|
|
116
|
+
return res.json();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async put(path, body, headers) {
|
|
120
|
+
const context = resourceContextStorage.getStore();
|
|
121
|
+
const abortSignal = context?.abortSignal;
|
|
122
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
123
|
+
return Promise.resolve(this.createCfOfflineMock("PUT", path, body));
|
|
124
|
+
}
|
|
125
|
+
return this.request(async () => {
|
|
126
|
+
const isMultipartOrRaw = body instanceof FormData || typeof body === "string" || body instanceof Buffer;
|
|
127
|
+
const customHeaders = headers ?? (isMultipartOrRaw ? {} : { "Content-Type": "application/json" });
|
|
128
|
+
const reqHeaders = {
|
|
129
|
+
Authorization: `Bearer ${this.token}`,
|
|
130
|
+
...customHeaders,
|
|
131
|
+
};
|
|
132
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
133
|
+
method: "PUT",
|
|
134
|
+
headers: reqHeaders,
|
|
135
|
+
body: isMultipartOrRaw ? body : JSON.stringify(body),
|
|
136
|
+
...(abortSignal && { signal: abortSignal })
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok)
|
|
139
|
+
throw new Error(`Cloudflare API PUT ${path}: ${res.status} ${await res.text()}`);
|
|
140
|
+
return res.json();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async patch(path, body) {
|
|
144
|
+
const context = resourceContextStorage.getStore();
|
|
145
|
+
const abortSignal = context?.abortSignal;
|
|
146
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
147
|
+
return Promise.resolve(this.createCfOfflineMock("PATCH", path, body));
|
|
148
|
+
}
|
|
149
|
+
return this.request(async () => {
|
|
150
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
151
|
+
method: "PATCH",
|
|
152
|
+
headers: this.authHeaders,
|
|
153
|
+
body: JSON.stringify(body),
|
|
154
|
+
...(abortSignal && { signal: abortSignal })
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok)
|
|
157
|
+
throw new Error(`Cloudflare API PATCH ${path}: ${res.status} ${await res.text()}`);
|
|
158
|
+
return res.json();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async delete(path, body) {
|
|
162
|
+
const context = resourceContextStorage.getStore();
|
|
163
|
+
const abortSignal = context?.abortSignal;
|
|
164
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
165
|
+
return Promise.resolve();
|
|
166
|
+
}
|
|
167
|
+
return this.request(async () => {
|
|
168
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
169
|
+
method: "DELETE",
|
|
170
|
+
headers: this.authHeaders,
|
|
171
|
+
...(body !== undefined && { body: JSON.stringify(body) }),
|
|
172
|
+
...(abortSignal && { signal: abortSignal })
|
|
173
|
+
});
|
|
174
|
+
if (!res.ok && res.status !== 404) {
|
|
175
|
+
throw new Error(`Cloudflare API DELETE ${path}: ${res.status} ${await res.text()}`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function getCloudflareApi() {
|
|
181
|
+
const token = Config.get().providers.cloudflare?.token;
|
|
182
|
+
if (!token) {
|
|
183
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
184
|
+
return new CloudflareApiClient("mock-cf-token");
|
|
185
|
+
}
|
|
186
|
+
throw new Error('Cloudflare token not configured. Call CF.init({ token: "..." })');
|
|
187
|
+
}
|
|
188
|
+
return new CloudflareApiClient(token);
|
|
189
|
+
}
|
|
190
|
+
export function getCloudflareAccountId() {
|
|
191
|
+
const accId = Config.get().providers.cloudflare?.accountId;
|
|
192
|
+
if (!accId) {
|
|
193
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
194
|
+
return "mock-cf-account-id";
|
|
195
|
+
}
|
|
196
|
+
throw new Error("Cloudflare account ID not configured. Account ID is required for Workers, KV namespaces, and R2 buckets.");
|
|
197
|
+
}
|
|
198
|
+
return accId;
|
|
199
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ZoneBuilder } from "./zone.js";
|
|
2
|
+
import { WorkerBuilder } from "./worker.js";
|
|
3
|
+
import { KVBuilder } from "./kv.js";
|
|
4
|
+
import { R2Builder } from "./r2.js";
|
|
5
|
+
export declare const CF: {
|
|
6
|
+
init: (opts: {
|
|
7
|
+
token: string;
|
|
8
|
+
accountId?: string;
|
|
9
|
+
}) => void;
|
|
10
|
+
Zone: (domainName: string) => ZoneBuilder;
|
|
11
|
+
Worker: (name: string) => WorkerBuilder;
|
|
12
|
+
KV: (title: string) => KVBuilder;
|
|
13
|
+
R2: (bucketName: string) => R2Builder;
|
|
14
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Config } from "../../core/config.js";
|
|
2
|
+
import { ZoneBuilder } from "./zone.js";
|
|
3
|
+
import { WorkerBuilder } from "./worker.js";
|
|
4
|
+
import { KVBuilder } from "./kv.js";
|
|
5
|
+
import { R2Builder } from "./r2.js";
|
|
6
|
+
export const CF = {
|
|
7
|
+
init: (opts) => {
|
|
8
|
+
Config.set({
|
|
9
|
+
providers: {
|
|
10
|
+
...Config.get().providers,
|
|
11
|
+
cloudflare: opts,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
Zone: (domainName) => new ZoneBuilder(domainName),
|
|
16
|
+
Worker: (name) => new WorkerBuilder(name),
|
|
17
|
+
KV: (title) => new KVBuilder(title),
|
|
18
|
+
R2: (bucketName) => new R2Builder(bucketName),
|
|
19
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
export declare class KVBuilder extends BaseBuilder {
|
|
4
|
+
title: string;
|
|
5
|
+
readonly out: {
|
|
6
|
+
id: Output<string>;
|
|
7
|
+
};
|
|
8
|
+
resolvedId: string | null;
|
|
9
|
+
constructor(title: string);
|
|
10
|
+
private discoverNamespace;
|
|
11
|
+
deploy(): Promise<{
|
|
12
|
+
title: string;
|
|
13
|
+
id: string | null;
|
|
14
|
+
}>;
|
|
15
|
+
destroy(): Promise<{
|
|
16
|
+
destroyed: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
destroyed: string;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
import { getCloudflareApi, getCloudflareAccountId } from "./api.js";
|
|
4
|
+
export class KVBuilder extends BaseBuilder {
|
|
5
|
+
title;
|
|
6
|
+
out = {
|
|
7
|
+
id: new Output(),
|
|
8
|
+
};
|
|
9
|
+
resolvedId = null;
|
|
10
|
+
constructor(title) {
|
|
11
|
+
super(title);
|
|
12
|
+
this.title = title;
|
|
13
|
+
this.discoveryPromise = this.discoverNamespace(title);
|
|
14
|
+
}
|
|
15
|
+
async discoverNamespace(title) {
|
|
16
|
+
try {
|
|
17
|
+
const api = getCloudflareApi();
|
|
18
|
+
const accountId = getCloudflareAccountId();
|
|
19
|
+
const res = await api.get(`/accounts/${accountId}/workers/namespaces?per_page=100`);
|
|
20
|
+
return (res.result ?? []).find((n) => n.title === title) ?? null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async deploy() {
|
|
27
|
+
const dryRun = this.isDryRunActive();
|
|
28
|
+
const existing = await this.discoveryPromise;
|
|
29
|
+
const api = getCloudflareApi();
|
|
30
|
+
const accountId = getCloudflareAccountId();
|
|
31
|
+
console.log(`\n📦 Finalizing Cloudflare KV Namespace "${this.title}"...`);
|
|
32
|
+
if (existing) {
|
|
33
|
+
this.resolvedId = existing.id;
|
|
34
|
+
this.out.id.resolve(existing.id);
|
|
35
|
+
console.log(` ✅ KV Namespace "${this.title}" already exists (id=${existing.id})`);
|
|
36
|
+
return { title: this.title, id: this.resolvedId };
|
|
37
|
+
}
|
|
38
|
+
if (dryRun) {
|
|
39
|
+
console.log(` 📝 [PLAN] Create KV Namespace "${this.title}"`);
|
|
40
|
+
this.out.id.resolve("PENDING");
|
|
41
|
+
return { title: this.title, id: "PENDING" };
|
|
42
|
+
}
|
|
43
|
+
const res = await api.post(`/accounts/${accountId}/workers/namespaces`, {
|
|
44
|
+
title: this.title,
|
|
45
|
+
});
|
|
46
|
+
this.resolvedId = res.result.id;
|
|
47
|
+
this.out.id.resolve(this.resolvedId);
|
|
48
|
+
console.log(`🚀 Created KV Namespace "${this.title}" (id=${this.resolvedId})`);
|
|
49
|
+
return { title: this.title, id: this.resolvedId };
|
|
50
|
+
}
|
|
51
|
+
async destroy() {
|
|
52
|
+
const dryRun = this.isDryRunActive();
|
|
53
|
+
const existing = await this.discoveryPromise;
|
|
54
|
+
const api = getCloudflareApi();
|
|
55
|
+
const accountId = getCloudflareAccountId();
|
|
56
|
+
console.log(`\n🗑️ Destroying Cloudflare KV Namespace "${this.title}"...`);
|
|
57
|
+
if (!existing) {
|
|
58
|
+
console.log(` ─ KV Namespace "${this.title}" not found`);
|
|
59
|
+
return { destroyed: false };
|
|
60
|
+
}
|
|
61
|
+
if (dryRun) {
|
|
62
|
+
console.log(` 📝 [PLAN] Delete KV Namespace "${this.title}" (id=${existing.id})`);
|
|
63
|
+
return { destroyed: this.title };
|
|
64
|
+
}
|
|
65
|
+
await api.delete(`/accounts/${accountId}/workers/namespaces/${existing.id}`);
|
|
66
|
+
console.log(` 🗑️ Removed KV Namespace "${this.title}" (id=${existing.id})`);
|
|
67
|
+
return { destroyed: this.title };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { KVBuilder } from "./kv.js";
|
|
4
|
+
import { Config } from "../../core/config.js";
|
|
5
|
+
describe("KVBuilder Unit Tests", () => {
|
|
6
|
+
let originalFetch;
|
|
7
|
+
let fetchCalls = [];
|
|
8
|
+
let mockResponses = {};
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
Config.set({
|
|
11
|
+
dryRun: false,
|
|
12
|
+
providers: {
|
|
13
|
+
cloudflare: { token: "fake-cf-token", accountId: "fake-cf-account" }
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
originalFetch = globalThis.fetch;
|
|
17
|
+
fetchCalls = [];
|
|
18
|
+
mockResponses = {};
|
|
19
|
+
globalThis.fetch = async (input, init) => {
|
|
20
|
+
const url = String(input);
|
|
21
|
+
const method = init?.method ?? "GET";
|
|
22
|
+
let body;
|
|
23
|
+
if (init?.body) {
|
|
24
|
+
if (typeof init.body === "string") {
|
|
25
|
+
try {
|
|
26
|
+
body = JSON.parse(init.body);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
body = init.body;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
body = init.body;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const headers = init?.headers;
|
|
37
|
+
fetchCalls.push({ url, method, body, headers });
|
|
38
|
+
const matchKey = Object.keys(mockResponses).find(key => {
|
|
39
|
+
const [mMethod, mPath] = key.split(" ");
|
|
40
|
+
return method === mMethod && url.endsWith(mPath);
|
|
41
|
+
});
|
|
42
|
+
if (matchKey) {
|
|
43
|
+
const resp = mockResponses[matchKey];
|
|
44
|
+
return {
|
|
45
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
46
|
+
status: resp.status,
|
|
47
|
+
json: async () => resp.body,
|
|
48
|
+
text: async () => JSON.stringify(resp.body),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
status: 404,
|
|
54
|
+
json: async () => ({ errors: [{ message: "Not found" }] }),
|
|
55
|
+
text: async () => "Not found",
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
globalThis.fetch = originalFetch;
|
|
61
|
+
});
|
|
62
|
+
test("discovers namespace successfully if it already exists", async () => {
|
|
63
|
+
mockResponses["GET /accounts/fake-cf-account/workers/namespaces?per_page=100"] = {
|
|
64
|
+
status: 200,
|
|
65
|
+
body: {
|
|
66
|
+
result: [
|
|
67
|
+
{ id: "kv-123", title: "my-namespace" }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const builder = new KVBuilder("my-namespace");
|
|
72
|
+
const result = await builder.deploy();
|
|
73
|
+
assert.strictEqual(result.id, "kv-123");
|
|
74
|
+
assert.strictEqual(builder.resolvedId, "kv-123");
|
|
75
|
+
// Deploying again shouldn't send post
|
|
76
|
+
const deployAgainResult = await builder.deploy();
|
|
77
|
+
assert.strictEqual(deployAgainResult.id, "kv-123");
|
|
78
|
+
const posts = fetchCalls.filter(c => c.method === "POST");
|
|
79
|
+
assert.strictEqual(posts.length, 0);
|
|
80
|
+
});
|
|
81
|
+
test("creates namespace if it does not exist", async () => {
|
|
82
|
+
mockResponses["GET /accounts/fake-cf-account/workers/namespaces?per_page=100"] = {
|
|
83
|
+
status: 200,
|
|
84
|
+
body: { result: [] }
|
|
85
|
+
};
|
|
86
|
+
mockResponses["POST /accounts/fake-cf-account/workers/namespaces"] = {
|
|
87
|
+
status: 200,
|
|
88
|
+
body: { result: { id: "kv-created-456" } }
|
|
89
|
+
};
|
|
90
|
+
const builder = new KVBuilder("my-namespace");
|
|
91
|
+
const result = await builder.deploy();
|
|
92
|
+
assert.strictEqual(result.id, "kv-created-456");
|
|
93
|
+
assert.strictEqual(builder.resolvedId, "kv-created-456");
|
|
94
|
+
const postCall = fetchCalls.find(c => c.method === "POST");
|
|
95
|
+
assert.ok(postCall);
|
|
96
|
+
assert.deepStrictEqual(postCall.body, { title: "my-namespace" });
|
|
97
|
+
});
|
|
98
|
+
test("does not call fetch during dryRun deploy", async () => {
|
|
99
|
+
Config.set({
|
|
100
|
+
dryRun: true,
|
|
101
|
+
providers: {
|
|
102
|
+
cloudflare: { token: "fake-cf-token", accountId: "fake-cf-account" }
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
mockResponses["GET /accounts/fake-cf-account/workers/namespaces?per_page=100"] = {
|
|
106
|
+
status: 200,
|
|
107
|
+
body: { result: [] }
|
|
108
|
+
};
|
|
109
|
+
const builder = new KVBuilder("my-namespace");
|
|
110
|
+
const result = await builder.deploy();
|
|
111
|
+
assert.strictEqual(result.id, "PENDING");
|
|
112
|
+
const posts = fetchCalls.filter(c => c.method === "POST");
|
|
113
|
+
assert.strictEqual(posts.length, 0);
|
|
114
|
+
});
|
|
115
|
+
test("destroys namespace successfully if exists", async () => {
|
|
116
|
+
mockResponses["GET /accounts/fake-cf-account/workers/namespaces?per_page=100"] = {
|
|
117
|
+
status: 200,
|
|
118
|
+
body: {
|
|
119
|
+
result: [
|
|
120
|
+
{ id: "kv-123", title: "my-namespace" }
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
mockResponses["DELETE /accounts/fake-cf-account/workers/namespaces/kv-123"] = {
|
|
125
|
+
status: 200,
|
|
126
|
+
body: { success: true }
|
|
127
|
+
};
|
|
128
|
+
const builder = new KVBuilder("my-namespace");
|
|
129
|
+
const result = await builder.destroy();
|
|
130
|
+
assert.deepStrictEqual(result, { destroyed: "my-namespace" });
|
|
131
|
+
const deleteCall = fetchCalls.find(c => c.method === "DELETE");
|
|
132
|
+
assert.ok(deleteCall);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
export declare class R2Builder extends BaseBuilder {
|
|
3
|
+
bucketName: string;
|
|
4
|
+
constructor(bucketName: string);
|
|
5
|
+
private discoverBucket;
|
|
6
|
+
deploy(): Promise<{
|
|
7
|
+
bucket: string;
|
|
8
|
+
}>;
|
|
9
|
+
destroy(): Promise<{
|
|
10
|
+
destroyed: boolean;
|
|
11
|
+
} | {
|
|
12
|
+
destroyed: string;
|
|
13
|
+
}>;
|
|
14
|
+
}
|