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.
Files changed (47) hide show
  1. package/README.md +11 -11
  2. package/dist/bin/install-shell.js +5 -6
  3. package/dist/bin/puls.js +10 -3
  4. package/dist/core/config.d.ts +4 -0
  5. package/dist/core/decorators.d.ts +4 -0
  6. package/dist/core/decorators.js +2 -0
  7. package/dist/core/parallel.test.js +4 -3
  8. package/dist/core/resource.d.ts +2 -1
  9. package/dist/core/resource.js +4 -2
  10. package/dist/core/stack.d.ts +4 -0
  11. package/dist/core/stack.js +8 -8
  12. package/dist/providers/aws/acm.test.d.ts +1 -0
  13. package/dist/providers/aws/acm.test.js +167 -0
  14. package/dist/providers/aws/cloudfront.test.d.ts +1 -0
  15. package/dist/providers/aws/cloudfront.test.js +170 -0
  16. package/dist/providers/aws/fargate.test.d.ts +1 -0
  17. package/dist/providers/aws/fargate.test.js +244 -0
  18. package/dist/providers/aws/rds.test.d.ts +1 -0
  19. package/dist/providers/aws/rds.test.js +219 -0
  20. package/dist/providers/aws/sqs.test.d.ts +1 -0
  21. package/dist/providers/aws/sqs.test.js +181 -0
  22. package/dist/providers/cloudflare/api.d.ts +15 -0
  23. package/dist/providers/cloudflare/api.js +199 -0
  24. package/dist/providers/cloudflare/index.d.ts +14 -0
  25. package/dist/providers/cloudflare/index.js +19 -0
  26. package/dist/providers/cloudflare/kv.d.ts +20 -0
  27. package/dist/providers/cloudflare/kv.js +69 -0
  28. package/dist/providers/cloudflare/kv.test.d.ts +1 -0
  29. package/dist/providers/cloudflare/kv.test.js +134 -0
  30. package/dist/providers/cloudflare/r2.d.ts +14 -0
  31. package/dist/providers/cloudflare/r2.js +57 -0
  32. package/dist/providers/cloudflare/r2.test.d.ts +1 -0
  33. package/dist/providers/cloudflare/r2.test.js +132 -0
  34. package/dist/providers/cloudflare/worker.d.ts +28 -0
  35. package/dist/providers/cloudflare/worker.js +172 -0
  36. package/dist/providers/cloudflare/worker.test.d.ts +1 -0
  37. package/dist/providers/cloudflare/worker.test.js +220 -0
  38. package/dist/providers/cloudflare/zone.d.ts +42 -0
  39. package/dist/providers/cloudflare/zone.js +280 -0
  40. package/dist/providers/cloudflare/zone.test.d.ts +1 -0
  41. package/dist/providers/cloudflare/zone.test.js +284 -0
  42. package/dist/providers/firebase/auth.test.d.ts +1 -0
  43. package/dist/providers/firebase/auth.test.js +145 -0
  44. package/dist/providers/firebase/hosting.test.js +7 -6
  45. package/dist/providers/firebase/storage.test.d.ts +1 -0
  46. package/dist/providers/firebase/storage.test.js +148 -0
  47. package/package.json +6 -2
@@ -0,0 +1,145 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { GoogleAuth } from 'google-auth-library';
4
+ import { FirebaseAuthBuilder } from './auth.js';
5
+ import { Config } from '../../core/config.js';
6
+ describe('FirebaseAuthBuilder Unit Tests', () => {
7
+ let originalFetch;
8
+ let fetchCalls = [];
9
+ let mockResponses = {};
10
+ function matchResponse(method, url) {
11
+ const key = Object.keys(mockResponses)
12
+ .filter((k) => {
13
+ const [m, path] = k.split(' ');
14
+ return method === m && url.includes(path);
15
+ })
16
+ .sort((a, b) => b.split(' ')[1].length - a.split(' ')[1].length)[0];
17
+ return key ? mockResponses[key] : null;
18
+ }
19
+ beforeEach(() => {
20
+ Config.set({
21
+ dryRun: false,
22
+ providers: { firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' } },
23
+ });
24
+ originalFetch = globalThis.fetch;
25
+ fetchCalls = [];
26
+ mockResponses = {};
27
+ globalThis.fetch = async (input, init) => {
28
+ const url = String(input);
29
+ const method = init?.method ?? 'GET';
30
+ let body;
31
+ if (init?.body && typeof init.body === 'string') {
32
+ try {
33
+ body = JSON.parse(init.body);
34
+ }
35
+ catch {
36
+ body = init.body;
37
+ }
38
+ }
39
+ fetchCalls.push({ url, method, body });
40
+ const resp = matchResponse(method, url);
41
+ if (resp) {
42
+ return {
43
+ ok: resp.status >= 200 && resp.status < 300,
44
+ status: resp.status,
45
+ json: async () => resp.body,
46
+ text: async () => JSON.stringify(resp.body),
47
+ };
48
+ }
49
+ return {
50
+ ok: true,
51
+ status: 200,
52
+ json: async () => ({}),
53
+ text: async () => '{}',
54
+ };
55
+ };
56
+ mock.method(GoogleAuth.prototype, 'getClient', async () => ({
57
+ getAccessToken: async () => ({ token: 'fake-token' }),
58
+ }));
59
+ });
60
+ afterEach(() => {
61
+ globalThis.fetch = originalFetch;
62
+ mock.restoreAll();
63
+ });
64
+ test('performs dry-run without any API write calls', async () => {
65
+ Config.set({
66
+ dryRun: true,
67
+ providers: { firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' } },
68
+ });
69
+ const builder = new FirebaseAuthBuilder();
70
+ builder.emailPassword().anonymous().phone();
71
+ const result = await builder.deploy();
72
+ assert.ok(result);
73
+ assert.strictEqual(result.project, 'my-project');
74
+ const writeCalls = fetchCalls.filter((c) => c.method !== 'GET');
75
+ assert.strictEqual(writeCalls.length, 0);
76
+ });
77
+ test('configures email/password and anonymous sign-in via PATCH', async () => {
78
+ const builder = new FirebaseAuthBuilder();
79
+ builder.emailPassword({ passwordRequired: false }).anonymous();
80
+ await builder.deploy();
81
+ const patchCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/config'));
82
+ assert.ok(patchCall);
83
+ assert.ok(patchCall.url.includes('signIn.email'));
84
+ assert.ok(patchCall.url.includes('signIn.anonymous'));
85
+ assert.strictEqual(patchCall.body.signIn.email.enabled, true);
86
+ assert.strictEqual(patchCall.body.signIn.email.passwordRequired, false);
87
+ assert.strictEqual(patchCall.body.signIn.anonymous.enabled, true);
88
+ });
89
+ test('enables phone sign-in via PATCH', async () => {
90
+ const builder = new FirebaseAuthBuilder();
91
+ builder.phone();
92
+ await builder.deploy();
93
+ const patchCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/config'));
94
+ assert.ok(patchCall);
95
+ assert.ok(patchCall.url.includes('signIn.phoneNumber'));
96
+ assert.strictEqual(patchCall.body.signIn.phoneNumber.enabled, true);
97
+ });
98
+ test('configures OAuth provider via POST when IDP does not exist', async () => {
99
+ // getIdp returns 404 (not configured yet)
100
+ mockResponses['GET /defaultSupportedIdpConfigs/google.com'] = {
101
+ status: 404,
102
+ body: { error: { message: 'not found' } },
103
+ };
104
+ const builder = new FirebaseAuthBuilder();
105
+ builder.google({ clientId: 'client-id-123', clientSecret: 'secret-456' });
106
+ await builder.deploy();
107
+ const postCall = fetchCalls.find((c) => c.method === 'POST' && c.url.includes('defaultSupportedIdpConfigs'));
108
+ assert.ok(postCall);
109
+ assert.ok(postCall.url.includes('idpId=google.com'));
110
+ assert.strictEqual(postCall.body.clientId, 'client-id-123');
111
+ assert.strictEqual(postCall.body.clientSecret, 'secret-456');
112
+ assert.strictEqual(postCall.body.enabled, true);
113
+ });
114
+ test('updates OAuth provider via PATCH when IDP already exists', async () => {
115
+ mockResponses['GET /defaultSupportedIdpConfigs/github.com'] = {
116
+ status: 200,
117
+ body: { name: 'projects/my-project/defaultSupportedIdpConfigs/github.com', enabled: true },
118
+ };
119
+ const builder = new FirebaseAuthBuilder();
120
+ builder.github({ clientId: 'gh-id', clientSecret: 'gh-secret' });
121
+ await builder.deploy();
122
+ const patchCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('defaultSupportedIdpConfigs/github.com'));
123
+ assert.ok(patchCall);
124
+ assert.strictEqual(patchCall.body.clientId, 'gh-id');
125
+ });
126
+ test('sets authorized domains via PATCH', async () => {
127
+ const builder = new FirebaseAuthBuilder();
128
+ builder.authorizedDomains(['example.com', 'app.example.com', 'localhost']);
129
+ await builder.deploy();
130
+ const patchCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/config'));
131
+ assert.ok(patchCall);
132
+ assert.ok(patchCall.url.includes('authorizedDomains'));
133
+ assert.deepStrictEqual(patchCall.body.authorizedDomains, [
134
+ 'example.com',
135
+ 'app.example.com',
136
+ 'localhost',
137
+ ]);
138
+ });
139
+ test('destroy returns without making API calls', async () => {
140
+ const builder = new FirebaseAuthBuilder();
141
+ const result = await builder.destroy();
142
+ assert.deepStrictEqual(result, { destroyed: 'auth' });
143
+ assert.strictEqual(fetchCalls.filter((c) => c.method !== 'GET').length, 0);
144
+ });
145
+ });
@@ -1,6 +1,8 @@
1
1
  import { test, describe, beforeEach, afterEach, mock } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import fs from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { gzipSync } from 'node:zlib';
4
6
  import { GoogleAuth } from 'google-auth-library';
5
7
  import { FirebaseHostingBuilder } from './hosting.js';
6
8
  import { Config } from '../../core/config.js';
@@ -129,6 +131,7 @@ describe('FirebaseHostingBuilder Unit Tests', () => {
129
131
  assert.strictEqual(writeCalls.length, 0);
130
132
  });
131
133
  test('deploys new version and creates release when site exists', async () => {
134
+ const expectedHash = createHash('sha256').update(gzipSync(Buffer.from('hello from index.html'))).digest('hex');
132
135
  mockResponses['GET /projects/my-project/sites/my-site'] = {
133
136
  status: 200,
134
137
  body: { name: 'projects/my-project/sites/my-site' }
@@ -141,10 +144,10 @@ describe('FirebaseHostingBuilder Unit Tests', () => {
141
144
  status: 200,
142
145
  body: {
143
146
  uploadUrl: 'https://upload-firebasehosting.googleapis.com/upload/v111',
144
- uploadRequiredHashes: ['mock-hash-index']
147
+ uploadRequiredHashes: [expectedHash]
145
148
  }
146
149
  };
147
- mockResponses['POST /upload/v111/mock-hash-index'] = {
150
+ mockResponses[`POST /upload/v111/${expectedHash}`] = {
148
151
  status: 200,
149
152
  body: {}
150
153
  };
@@ -166,10 +169,8 @@ describe('FirebaseHostingBuilder Unit Tests', () => {
166
169
  assert.ok(createVersionCall);
167
170
  const populateCall = fetchCalls.find(c => c.method === 'POST' && c.url.endsWith('/versions/v111:populateFiles'));
168
171
  assert.ok(populateCall);
169
- assert.deepStrictEqual(populateCall.body.files, {
170
- '/index.html': '8bde9ea78d892f46c76f95e789dfe2000427a3fb3abff9afd8f6b31456703c1d'
171
- });
172
- const uploadCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/upload/v111/mock-hash-index'));
172
+ assert.deepStrictEqual(populateCall.body.files, { '/index.html': expectedHash });
173
+ const uploadCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes(`/upload/v111/${expectedHash}`));
173
174
  assert.ok(uploadCall);
174
175
  assert.strictEqual(uploadCall.headers?.['Authorization'], 'Bearer fake-access-token');
175
176
  const patchVersionCall = fetchCalls.find(c => c.method === 'PATCH' && c.url.endsWith('/versions/v111'));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,148 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { GoogleAuth } from 'google-auth-library';
7
+ import { FirebaseStorageBuilder } from './storage.js';
8
+ import { Config } from '../../core/config.js';
9
+ describe('FirebaseStorageBuilder Unit Tests', () => {
10
+ let originalFetch;
11
+ let fetchCalls = [];
12
+ let mockResponses = {};
13
+ // Real temp file: storage.ts uses a named ESM import that bypasses mock.method on the fs object
14
+ const tmpRulesFile = path.join(os.tmpdir(), 'puls-storage-test.rules');
15
+ function matchResponse(method, url) {
16
+ const key = Object.keys(mockResponses)
17
+ .filter((k) => {
18
+ const [m, path] = k.split(' ');
19
+ return method === m && url.includes(path);
20
+ })
21
+ .sort((a, b) => b.split(' ')[1].length - a.split(' ')[1].length)[0];
22
+ return key ? mockResponses[key] : null;
23
+ }
24
+ beforeEach(() => {
25
+ Config.set({
26
+ dryRun: false,
27
+ providers: { firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' } },
28
+ });
29
+ originalFetch = globalThis.fetch;
30
+ fetchCalls = [];
31
+ mockResponses = {};
32
+ globalThis.fetch = async (input, init) => {
33
+ const url = String(input);
34
+ const method = init?.method ?? 'GET';
35
+ let body;
36
+ if (init?.body && typeof init.body === 'string') {
37
+ try {
38
+ body = JSON.parse(init.body);
39
+ }
40
+ catch {
41
+ body = init.body;
42
+ }
43
+ }
44
+ fetchCalls.push({ url, method, body });
45
+ const resp = matchResponse(method, url);
46
+ if (resp) {
47
+ return {
48
+ ok: resp.status >= 200 && resp.status < 300,
49
+ status: resp.status,
50
+ json: async () => resp.body,
51
+ text: async () => JSON.stringify(resp.body),
52
+ };
53
+ }
54
+ return {
55
+ ok: true, status: 200, json: async () => ({}), text: async () => '{}',
56
+ };
57
+ };
58
+ mock.method(GoogleAuth.prototype, 'getClient', async () => ({
59
+ getAccessToken: async () => ({ token: 'fake-token' }),
60
+ }));
61
+ fs.writeFileSync(tmpRulesFile, 'service firebase.storage { match /b/{b}/o/{a=**} { allow read; } }');
62
+ });
63
+ afterEach(() => {
64
+ globalThis.fetch = originalFetch;
65
+ mock.restoreAll();
66
+ try {
67
+ fs.unlinkSync(tmpRulesFile);
68
+ }
69
+ catch { /* ignore */ }
70
+ });
71
+ test('deploys without writing anything when no rules, cors, or lifecycle configured', async () => {
72
+ const builder = new FirebaseStorageBuilder('my-project.appspot.com');
73
+ const result = await builder.deploy();
74
+ assert.ok(result);
75
+ assert.strictEqual(result.bucket, 'my-project.appspot.com');
76
+ assert.strictEqual(result.project, 'my-project');
77
+ const writeCalls = fetchCalls.filter((c) => c.method !== 'GET');
78
+ assert.strictEqual(writeCalls.length, 0);
79
+ });
80
+ test('deploys storage rules by creating a ruleset and updating the release', async () => {
81
+ mockResponses['POST /rulesets'] = {
82
+ status: 200,
83
+ body: { name: 'projects/my-project/rulesets/ruleset-abc' },
84
+ };
85
+ mockResponses['PUT /releases/firebase.storage/my-project.appspot.com'] = {
86
+ status: 200,
87
+ body: {},
88
+ };
89
+ const builder = new FirebaseStorageBuilder('my-project.appspot.com');
90
+ builder.rules(tmpRulesFile);
91
+ await builder.deploy();
92
+ const rulesetCall = fetchCalls.find((c) => c.method === 'POST' && c.url.includes('/rulesets'));
93
+ assert.ok(rulesetCall);
94
+ assert.ok(rulesetCall.body.source.files[0].name === 'storage.rules');
95
+ const releaseCall = fetchCalls.find((c) => c.method === 'PUT' && c.url.includes('/releases/'));
96
+ assert.ok(releaseCall);
97
+ assert.ok(releaseCall.body.rulesetName.includes('ruleset-abc'));
98
+ });
99
+ test('deploys CORS configuration via PATCH to GCS API', async () => {
100
+ const builder = new FirebaseStorageBuilder('my-bucket');
101
+ builder.cors([
102
+ { origin: ['https://example.com'], method: ['GET', 'PUT'], maxAge: 7200 },
103
+ ]);
104
+ await builder.deploy();
105
+ const corsCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/b/my-bucket'));
106
+ assert.ok(corsCall);
107
+ assert.ok(Array.isArray(corsCall.body.cors));
108
+ assert.strictEqual(corsCall.body.cors[0].maxAgeSeconds, 7200);
109
+ assert.deepStrictEqual(corsCall.body.cors[0].origin, ['https://example.com']);
110
+ });
111
+ test('deploys lifecycle policy via PATCH to GCS API', async () => {
112
+ const builder = new FirebaseStorageBuilder('my-bucket');
113
+ builder.lifecycle({ deleteAfterDays: 30, matchesPrefix: ['tmp/', 'cache/'] });
114
+ await builder.deploy();
115
+ const lcCall = fetchCalls.find((c) => c.method === 'PATCH' && c.url.includes('/b/my-bucket'));
116
+ assert.ok(lcCall);
117
+ const rule = lcCall.body.lifecycle.rule[0];
118
+ assert.strictEqual(rule.action.type, 'Delete');
119
+ assert.strictEqual(rule.condition.age, 30);
120
+ assert.deepStrictEqual(rule.condition.matchesPrefix, ['tmp/', 'cache/']);
121
+ });
122
+ test('performs dry-run without any write API calls', async () => {
123
+ Config.set({
124
+ dryRun: true,
125
+ providers: { firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' } },
126
+ });
127
+ const builder = new FirebaseStorageBuilder('my-project.appspot.com');
128
+ builder
129
+ .cors([{ origin: ['*'], method: ['GET'] }])
130
+ .lifecycle({ deleteAfterDays: 90 });
131
+ await builder.deploy();
132
+ const writeCalls = fetchCalls.filter((c) => c.method !== 'GET');
133
+ assert.strictEqual(writeCalls.length, 0);
134
+ });
135
+ test('uses project-derived default bucket name when none provided', async () => {
136
+ const builder = new FirebaseStorageBuilder();
137
+ const result = await builder.deploy();
138
+ // Default bucket is projectId.appspot.com
139
+ assert.strictEqual(result.bucket, 'my-project.appspot.com');
140
+ });
141
+ test('destroy returns without making write API calls', async () => {
142
+ const builder = new FirebaseStorageBuilder('my-project.appspot.com');
143
+ const result = await builder.destroy();
144
+ assert.ok(result.destroyed.includes('appspot.com'));
145
+ const writeCalls = fetchCalls.filter((c) => c.method !== 'GET');
146
+ assert.strictEqual(writeCalls.length, 0);
147
+ });
148
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puls-dev",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "Intent-driven infrastructure-as-code with eager discovery and no state files.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,6 +30,10 @@
30
30
  "./gcp": {
31
31
  "types": "./dist/providers/gcp/index.d.ts",
32
32
  "default": "./dist/providers/gcp/index.js"
33
+ },
34
+ "./cloudflare": {
35
+ "types": "./dist/providers/cloudflare/index.d.ts",
36
+ "default": "./dist/providers/cloudflare/index.js"
33
37
  }
34
38
  },
35
39
  "files": [
@@ -44,7 +48,7 @@
44
48
  "build": "tsc",
45
49
  "postbuild": "node -e \"const fs=require('fs'),f='dist/bin/puls.js',c=fs.readFileSync(f,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);\" && chmod +x dist/bin/puls.js",
46
50
  "prepublishOnly": "npm run build",
47
- "test": "tsx --test \"src/**/*.test.ts\""
51
+ "test": "find src -name '*.test.ts' | xargs tsx --test --test-concurrency=1"
48
52
  },
49
53
  "keywords": [
50
54
  "iac",