puls-dev 0.1.8 → 0.2.0

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 (48) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +0 -7
  3. package/dist/index.js +0 -7
  4. package/dist/providers/aws/index.d.ts +1 -0
  5. package/dist/providers/aws/index.js +1 -0
  6. package/dist/providers/aws/lambda.js +6 -6
  7. package/dist/providers/aws/lambda.test.d.ts +1 -0
  8. package/dist/providers/aws/lambda.test.js +189 -0
  9. package/dist/providers/aws/route53.d.ts +1 -1
  10. package/dist/providers/aws/route53.js +20 -12
  11. package/dist/providers/aws/route53.test.d.ts +1 -0
  12. package/dist/providers/aws/route53.test.js +229 -0
  13. package/dist/providers/aws/s3.d.ts +3 -0
  14. package/dist/providers/aws/s3.js +65 -3
  15. package/dist/providers/aws/s3.test.d.ts +1 -0
  16. package/dist/providers/aws/s3.test.js +172 -0
  17. package/dist/providers/do/api.js +5 -1
  18. package/dist/providers/do/certificate.test.d.ts +1 -0
  19. package/dist/providers/do/certificate.test.js +133 -0
  20. package/dist/providers/do/domain.d.ts +12 -1
  21. package/dist/providers/do/domain.js +129 -13
  22. package/dist/providers/do/domain.test.d.ts +1 -0
  23. package/dist/providers/do/domain.test.js +200 -0
  24. package/dist/providers/do/droplet.js +2 -2
  25. package/dist/providers/do/droplet.test.d.ts +1 -0
  26. package/dist/providers/do/droplet.test.js +265 -0
  27. package/dist/providers/do/firewall.test.d.ts +1 -0
  28. package/dist/providers/do/firewall.test.js +176 -0
  29. package/dist/providers/do/index.d.ts +1 -0
  30. package/dist/providers/do/index.js +1 -0
  31. package/dist/providers/do/load_balancer.d.ts +39 -5
  32. package/dist/providers/do/load_balancer.js +272 -30
  33. package/dist/providers/do/load_balancer.test.d.ts +1 -0
  34. package/dist/providers/do/load_balancer.test.js +269 -0
  35. package/dist/providers/firebase/api.js +2 -2
  36. package/dist/providers/firebase/functions.d.ts +1 -0
  37. package/dist/providers/firebase/functions.js +24 -10
  38. package/dist/providers/firebase/functions.test.d.ts +1 -0
  39. package/dist/providers/firebase/functions.test.js +297 -0
  40. package/dist/providers/firebase/hosting.d.ts +2 -0
  41. package/dist/providers/firebase/hosting.js +15 -9
  42. package/dist/providers/firebase/hosting.test.d.ts +1 -0
  43. package/dist/providers/firebase/hosting.test.js +181 -0
  44. package/dist/providers/proxmox/index.d.ts +1 -0
  45. package/dist/providers/proxmox/index.js +1 -0
  46. package/dist/providers/proxmox/vm.d.ts +0 -1
  47. package/dist/providers/proxmox/vm.js +4 -50
  48. package/package.json +78 -5
@@ -1,5 +1,5 @@
1
- import { execSync } from "node:child_process";
2
- import { mkdtempSync, readFileSync } from "node:fs";
1
+ import cp from "node:child_process";
2
+ import fs from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { BaseBuilder } from "../../core/resource.js";
@@ -26,8 +26,22 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
26
26
  _env = {};
27
27
  constructor(functionName) {
28
28
  super(functionName);
29
- // Discovery runs lazily in deploy() because we need _region, which may not be set at construction time
30
- this.discoveryPromise = Promise.resolve(null);
29
+ this.discoveryPromise = Promise.resolve().then(() => this.discoverFunction());
30
+ }
31
+ async discoverFunction() {
32
+ try {
33
+ return await this.getExisting();
34
+ }
35
+ catch (e) {
36
+ if (e.message?.includes("403") ||
37
+ e.message?.includes("404") ||
38
+ e.message?.includes("Firebase not configured") ||
39
+ e.message?.includes("private_key") ||
40
+ e.code === "MISSING_CREDENTIALS") {
41
+ return null;
42
+ }
43
+ throw e;
44
+ }
31
45
  }
32
46
  source(path) {
33
47
  this._sourcePath = path;
@@ -81,12 +95,12 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
81
95
  zipSource() {
82
96
  if (!this._sourcePath)
83
97
  throw new Error(`[Firebase.Functions:${this.name}] .source() is required`);
84
- const tmp = mkdtempSync(join(tmpdir(), "puls-fn-"));
98
+ const tmp = fs.mkdtempSync(join(tmpdir(), "puls-fn-"));
85
99
  const zipPath = join(tmp, "function.zip");
86
- execSync(`cd "${this._sourcePath}" && zip -r "${zipPath}" .`, {
100
+ cp.execSync(`cd "${this._sourcePath}" && zip -r "${zipPath}" .`, {
87
101
  stdio: "pipe",
88
102
  });
89
- return readFileSync(zipPath);
103
+ return fs.readFileSync(zipPath);
90
104
  }
91
105
  async uploadSource() {
92
106
  const { uploadUrl, storageSource } = await cloudFetch(CF_BASE, `/projects/${getProjectId()}/locations/${this._region}/functions:generateUploadUrl`, { method: "POST", body: "{}" });
@@ -121,7 +135,7 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
121
135
  if (!this._sourcePath)
122
136
  throw new Error(`[Firebase.Functions:${this.name}] .source() is required`);
123
137
  if (dryRun) {
124
- const existing = await this.getExisting();
138
+ const existing = await this.discoveryPromise;
125
139
  if (existing) {
126
140
  console.log(` ✅ Function "${this.name}" exists (${existing.state ?? "ACTIVE"})`);
127
141
  console.log(` 📝 [PLAN] Update → ${this._runtime}, ${this._memory}, ${this._timeout}s timeout`);
@@ -155,7 +169,7 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
155
169
  ingressSettings: "ALLOW_ALL",
156
170
  },
157
171
  };
158
- const existing = await this.getExisting();
172
+ const existing = await this.discoveryPromise;
159
173
  let op;
160
174
  if (!existing) {
161
175
  op = await cloudFetch(CF_BASE, `/projects/${project}/locations/${this._region}/functions?functionId=${this.name}`, { method: "POST", body: JSON.stringify(body) });
@@ -179,7 +193,7 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
179
193
  async destroy() {
180
194
  const dryRun = this.isDryRunActive();
181
195
  console.log(`\n🗑️ Destroying Firebase Function "${this.name}"...`);
182
- const existing = await this.getExisting();
196
+ const existing = await this.discoveryPromise;
183
197
  if (!existing) {
184
198
  console.log(` ✅ Function "${this.name}" does not exist - nothing to do`);
185
199
  return { destroyed: this.name };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,297 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import cp from 'node:child_process';
5
+ import { GoogleAuth } from 'google-auth-library';
6
+ import { FirebaseFunctionsBuilder } from './functions.js';
7
+ import { Config } from '../../core/config.js';
8
+ describe('FirebaseFunctionsBuilder Unit Tests', () => {
9
+ let originalFetch;
10
+ let fetchCalls = [];
11
+ let mockResponses = {};
12
+ beforeEach(() => {
13
+ Config.set({
14
+ dryRun: false,
15
+ providers: {
16
+ firebase: {
17
+ projectId: 'my-project',
18
+ serviceAccountPath: '/fake/sa.json'
19
+ }
20
+ }
21
+ });
22
+ originalFetch = globalThis.fetch;
23
+ fetchCalls = [];
24
+ mockResponses = {};
25
+ globalThis.fetch = async (input, init) => {
26
+ const url = String(input);
27
+ const method = init?.method ?? 'GET';
28
+ let body;
29
+ if (init?.body) {
30
+ if (typeof init.body === 'string') {
31
+ try {
32
+ body = JSON.parse(init.body);
33
+ }
34
+ catch {
35
+ body = init.body;
36
+ }
37
+ }
38
+ else {
39
+ body = '[Binary/Buffer Body]';
40
+ }
41
+ }
42
+ const headers = init?.headers;
43
+ fetchCalls.push({ url, method, body, headers });
44
+ const matchKey = Object.keys(mockResponses)
45
+ .filter(key => {
46
+ const [mMethod, mPath] = key.split(' ');
47
+ return method === mMethod && url.includes(mPath);
48
+ })
49
+ .sort((a, b) => b.split(' ')[1].length - a.split(' ')[1].length)[0];
50
+ if (matchKey) {
51
+ let resp = mockResponses[matchKey];
52
+ if (typeof resp === 'function') {
53
+ resp = resp();
54
+ }
55
+ const status = resp.status;
56
+ const body = resp.body;
57
+ return {
58
+ ok: status >= 200 && status < 300,
59
+ status,
60
+ json: async () => body,
61
+ text: async () => JSON.stringify(body),
62
+ };
63
+ }
64
+ return {
65
+ ok: false,
66
+ status: 404,
67
+ json: async () => ({ error: { message: `Endpoint not mocked: ${method} ${url}` } }),
68
+ text: async () => `Endpoint not mocked: ${method} ${url}`,
69
+ };
70
+ };
71
+ // 1. Mock GoogleAuth prototype to completely bypass filesystem SA key loading and OAuth
72
+ mock.method(GoogleAuth.prototype, 'getClient', async () => {
73
+ return {
74
+ getAccessToken: async () => ({ token: 'fake-access-token' })
75
+ };
76
+ });
77
+ // 2. Mock filesystem / subprocesses to prevent real zip generation
78
+ mock.method(fs, 'mkdtempSync', () => '/fake/tmp/puls-fn-123');
79
+ mock.method(fs, 'readFileSync', () => Buffer.from('mock-zip-bytes'));
80
+ mock.method(cp, 'execSync', () => Buffer.from('mock-zip-stdout'));
81
+ // 3. Fast-forward setTimeout to bypass poll timers
82
+ mock.method(global, 'setTimeout', (fn) => fn());
83
+ });
84
+ afterEach(() => {
85
+ globalThis.fetch = originalFetch;
86
+ mock.restoreAll();
87
+ });
88
+ test('gracefully handles discovery when function does not exist', async () => {
89
+ mockResponses['GET /projects/my-project/locations/us-central1/functions/my-fn'] = {
90
+ status: 404,
91
+ body: { error: { message: 'Function not found' } }
92
+ };
93
+ const builder = new FirebaseFunctionsBuilder('my-fn');
94
+ const discoveryResult = await builder.discoveryPromise;
95
+ assert.strictEqual(discoveryResult, null);
96
+ const apiCall = fetchCalls.find(c => c.url.includes('/functions/my-fn'));
97
+ assert.ok(apiCall);
98
+ assert.strictEqual(apiCall.method, 'GET');
99
+ });
100
+ test('discovers function successfully when it exists', async () => {
101
+ mockResponses['GET /projects/my-project/locations/us-central1/functions/my-fn'] = {
102
+ status: 200,
103
+ body: {
104
+ name: 'projects/my-project/locations/us-central1/functions/my-fn',
105
+ state: 'ACTIVE',
106
+ serviceConfig: { uri: 'https://my-fn-live-url' }
107
+ }
108
+ };
109
+ const builder = new FirebaseFunctionsBuilder('my-fn');
110
+ const discoveryResult = await builder.discoveryPromise;
111
+ assert.ok(discoveryResult);
112
+ assert.strictEqual(discoveryResult.name, 'projects/my-project/locations/us-central1/functions/my-fn');
113
+ assert.strictEqual(discoveryResult.state, 'ACTIVE');
114
+ });
115
+ test('performs clean dry-run planning without making write requests', async () => {
116
+ Config.set({
117
+ dryRun: true,
118
+ providers: {
119
+ firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' }
120
+ }
121
+ });
122
+ mockResponses['GET /projects/my-project/locations/us-central1/functions/my-fn'] = {
123
+ status: 404,
124
+ body: { error: { message: 'Function not found' } }
125
+ };
126
+ const builder = new FirebaseFunctionsBuilder('my-fn');
127
+ builder
128
+ .source('./src')
129
+ .entryPoint('main')
130
+ .runtime('nodejs22')
131
+ .region('us-central1')
132
+ .memory('512M')
133
+ .timeout(120)
134
+ .maxInstances(50)
135
+ .minInstances(2)
136
+ .env({ MY_VAR: 'value' });
137
+ const result = await builder.deploy();
138
+ assert.ok(result);
139
+ assert.strictEqual(result.name, 'my-fn');
140
+ assert.strictEqual(result.project, 'my-project');
141
+ assert.strictEqual(result.region, 'us-central1');
142
+ // No write calls (POST, PUT, PATCH, DELETE) should be made
143
+ const writeCalls = fetchCalls.filter(c => c.method !== 'GET');
144
+ assert.strictEqual(writeCalls.length, 0);
145
+ });
146
+ test('deploys new function when missing, performing upload and polling', async () => {
147
+ let getCallCount = 0;
148
+ mockResponses['GET /projects/my-project/locations/us-central1/functions/my-fn'] = (() => {
149
+ getCallCount++;
150
+ if (getCallCount === 1) {
151
+ return {
152
+ status: 404,
153
+ body: { error: { message: 'Function not found' } }
154
+ };
155
+ }
156
+ return {
157
+ status: 200,
158
+ body: {
159
+ name: 'projects/my-project/locations/us-central1/functions/my-fn',
160
+ serviceConfig: { uri: 'https://live-uri.net' }
161
+ }
162
+ };
163
+ });
164
+ mockResponses['POST /functions:generateUploadUrl'] = {
165
+ status: 200,
166
+ body: {
167
+ uploadUrl: 'https://upload-gcs.googleapis.com/upload-my-zip',
168
+ storageSource: { bucket: 'my-bucket', object: 'my-zip' }
169
+ }
170
+ };
171
+ mockResponses['PUT /upload-my-zip'] = {
172
+ status: 200,
173
+ body: {}
174
+ };
175
+ mockResponses['POST /functions?functionId=my-fn'] = {
176
+ status: 200,
177
+ body: { name: 'projects/my-project/locations/us-central1/operations/op-create-123' }
178
+ };
179
+ mockResponses['GET /operations/op-create-123'] = {
180
+ status: 200,
181
+ body: { done: true }
182
+ };
183
+ const builder = new FirebaseFunctionsBuilder('my-fn');
184
+ builder
185
+ .source('./src')
186
+ .entryPoint('main')
187
+ .env({ API_KEY: 'secret' });
188
+ const result = await builder.deploy();
189
+ assert.ok(result);
190
+ assert.strictEqual(result.name, 'my-fn');
191
+ assert.strictEqual(result.url, 'https://live-uri.net');
192
+ assert.strictEqual(result.project, 'my-project');
193
+ assert.strictEqual(result.region, 'us-central1');
194
+ // Assert generateUploadUrl was called
195
+ const uploadUrlCall = fetchCalls.find(c => c.method === 'POST' && c.url.endsWith('/functions:generateUploadUrl'));
196
+ assert.ok(uploadUrlCall);
197
+ // Assert zip upload call was sent via PUT to the generated upload URL
198
+ const uploadCall = fetchCalls.find(c => c.method === 'PUT' && c.url.includes('/upload-my-zip'));
199
+ assert.ok(uploadCall);
200
+ assert.strictEqual(uploadCall.body, '[Binary/Buffer Body]');
201
+ // Assert POST create function was called with configuration
202
+ const createCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/functions?functionId=my-fn'));
203
+ assert.ok(createCall);
204
+ assert.strictEqual(createCall.body.buildConfig.entryPoint, 'main');
205
+ assert.deepStrictEqual(createCall.body.buildConfig.environmentVariables, { API_KEY: 'secret' });
206
+ assert.strictEqual(createCall.body.serviceConfig.availableMemory, '256M');
207
+ // Assert polling call was sent
208
+ const pollCall = fetchCalls.find(c => c.method === 'GET' && c.url.endsWith('/operations/op-create-123'));
209
+ assert.ok(pollCall);
210
+ });
211
+ test('updates existing function code and configurations correctly', async () => {
212
+ mockResponses['GET /projects/my-project/locations/us-central1/functions/my-fn'] = {
213
+ status: 200,
214
+ body: {
215
+ name: 'projects/my-project/locations/us-central1/functions/my-fn',
216
+ state: 'ACTIVE',
217
+ serviceConfig: { uri: 'https://my-fn-live-url' }
218
+ }
219
+ };
220
+ mockResponses['POST /functions:generateUploadUrl'] = {
221
+ status: 200,
222
+ body: {
223
+ uploadUrl: 'https://upload-gcs.googleapis.com/upload-my-zip',
224
+ storageSource: { bucket: 'my-bucket', object: 'my-zip' }
225
+ }
226
+ };
227
+ mockResponses['PUT /upload-my-zip'] = {
228
+ status: 200,
229
+ body: {}
230
+ };
231
+ mockResponses['PATCH /projects/my-project/locations/us-central1/functions/my-fn'] = {
232
+ status: 200,
233
+ body: { name: 'projects/my-project/locations/us-central1/operations/op-update-456' }
234
+ };
235
+ mockResponses['GET /operations/op-update-456'] = {
236
+ status: 200,
237
+ body: { done: true }
238
+ };
239
+ const builder = new FirebaseFunctionsBuilder('my-fn');
240
+ builder
241
+ .source('./src')
242
+ .entryPoint('updatedMain')
243
+ .runtime('nodejs22')
244
+ .memory('512M');
245
+ const result = await builder.deploy();
246
+ assert.ok(result);
247
+ assert.strictEqual(result.name, 'my-fn');
248
+ // Assert PATCH call was made
249
+ const patchCall = fetchCalls.find(c => c.method === 'PATCH' && c.url.includes('/functions/my-fn'));
250
+ assert.ok(patchCall);
251
+ assert.ok(patchCall.url.includes('updateMask='));
252
+ assert.strictEqual(patchCall.body.buildConfig.entryPoint, 'updatedMain');
253
+ assert.strictEqual(patchCall.body.buildConfig.runtime, 'nodejs22');
254
+ assert.strictEqual(patchCall.body.serviceConfig.availableMemory, '512M');
255
+ // Assert polling occurred
256
+ const pollCall = fetchCalls.find(c => c.method === 'GET' && c.url.endsWith('/operations/op-update-456'));
257
+ assert.ok(pollCall);
258
+ });
259
+ test('destroys existing function successfully', async () => {
260
+ mockResponses['GET /projects/my-project/locations/us-central1/functions/my-fn'] = {
261
+ status: 200,
262
+ body: {
263
+ name: 'projects/my-project/locations/us-central1/functions/my-fn',
264
+ state: 'ACTIVE'
265
+ }
266
+ };
267
+ mockResponses['DELETE /projects/my-project/locations/us-central1/functions/my-fn'] = {
268
+ status: 200,
269
+ body: { name: 'projects/my-project/locations/us-central1/operations/op-delete-789' }
270
+ };
271
+ mockResponses['GET /operations/op-delete-789'] = {
272
+ status: 200,
273
+ body: { done: true }
274
+ };
275
+ const builder = new FirebaseFunctionsBuilder('my-fn');
276
+ const result = await builder.destroy();
277
+ assert.deepStrictEqual(result, { destroyed: 'my-fn' });
278
+ // Assert DELETE was called
279
+ const deleteCall = fetchCalls.find(c => c.method === 'DELETE' && c.url.includes('/functions/my-fn'));
280
+ assert.ok(deleteCall);
281
+ // Assert polling occurred
282
+ const pollCall = fetchCalls.find(c => c.method === 'GET' && c.url.endsWith('/operations/op-delete-789'));
283
+ assert.ok(pollCall);
284
+ });
285
+ test('destroys does nothing when function does not exist', async () => {
286
+ mockResponses['GET /projects/my-project/locations/us-central1/functions/my-fn'] = {
287
+ status: 404,
288
+ body: { error: { message: 'Function not found' } }
289
+ };
290
+ const builder = new FirebaseFunctionsBuilder('my-fn');
291
+ const result = await builder.destroy();
292
+ assert.deepStrictEqual(result, { destroyed: 'my-fn' });
293
+ // Verify no DELETE call was placed
294
+ const deleteCall = fetchCalls.find(c => c.method === 'DELETE');
295
+ assert.strictEqual(deleteCall, undefined);
296
+ });
297
+ });
@@ -1,8 +1,10 @@
1
1
  import { BaseBuilder } from "../../core/resource.js";
2
2
  export declare class FirebaseHostingBuilder extends BaseBuilder {
3
3
  private _sourcePath?;
4
+ private _domain?;
4
5
  constructor(siteId: string);
5
6
  source(path: string): this;
7
+ domain(name: string): this;
6
8
  private discoverSite;
7
9
  deploy(): Promise<{
8
10
  siteId: string;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readdirSync, statSync, readFileSync } from "node:fs";
2
+ import fs from "node:fs";
3
3
  import { join, relative } from "node:path";
4
4
  import { gzipSync } from "node:zlib";
5
5
  import { BaseBuilder } from "../../core/resource.js";
@@ -21,9 +21,9 @@ const CONTENT_TYPES = {
21
21
  };
22
22
  function walkDir(dir) {
23
23
  const results = [];
24
- for (const entry of readdirSync(dir)) {
24
+ for (const entry of fs.readdirSync(dir)) {
25
25
  const full = join(dir, entry);
26
- if (statSync(full).isDirectory()) {
26
+ if (fs.statSync(full).isDirectory()) {
27
27
  results.push(...walkDir(full));
28
28
  }
29
29
  else {
@@ -33,20 +33,25 @@ function walkDir(dir) {
33
33
  return results;
34
34
  }
35
35
  function sha256(filePath) {
36
- const content = readFileSync(filePath);
36
+ const content = fs.readFileSync(filePath);
37
37
  return createHash("sha256").update(content).digest("hex");
38
38
  }
39
39
  export class FirebaseHostingBuilder extends BaseBuilder {
40
40
  _sourcePath;
41
+ _domain;
41
42
  constructor(siteId) {
42
43
  super(siteId);
43
- // Discovery: check if the site has any releases (confirms it exists and is active)
44
+ // Discovery: check if the site exists
44
45
  this.discoveryPromise = this.discoverSite(siteId);
45
46
  }
46
47
  source(path) {
47
48
  this._sourcePath = path;
48
49
  return this;
49
50
  }
51
+ domain(name) {
52
+ this._domain = name;
53
+ return this;
54
+ }
50
55
  async discoverSite(siteId) {
51
56
  try {
52
57
  const projectId = getProjectId();
@@ -75,7 +80,8 @@ export class FirebaseHostingBuilder extends BaseBuilder {
75
80
  throw new Error(`[Firebase.Hosting:${siteId}] No files found in "${this._sourcePath}"`);
76
81
  const existing = await this.discoveryPromise;
77
82
  if (dryRun) {
78
- console.log(` 📝 [PLAN] Deploy ${files.length} file(s) to https://${siteId}.web.app`);
83
+ const displayUrl = this._domain ? `https://${this._domain}` : `https://${siteId}.web.app`;
84
+ console.log(` 📝 [PLAN] Deploy ${files.length} file(s) to ${displayUrl}`);
79
85
  if (!existing)
80
86
  console.log(` └─ Will create secondary site "${siteId}"`);
81
87
  for (const f of files.slice(0, 5)) {
@@ -83,7 +89,7 @@ export class FirebaseHostingBuilder extends BaseBuilder {
83
89
  }
84
90
  if (files.length > 5)
85
91
  console.log(` └─ ... and ${files.length - 5} more`);
86
- return { siteId, url: `https://${siteId}.web.app` };
92
+ return { siteId, url: displayUrl };
87
93
  }
88
94
  // 0. Ensure site exists (for secondary sites)
89
95
  if (!existing) {
@@ -119,7 +125,7 @@ export class FirebaseHostingBuilder extends BaseBuilder {
119
125
  const hashToUrl = {};
120
126
  for (const absPath of files) {
121
127
  const urlPath = "/" + relative(this._sourcePath, absPath).replace(/\\/g, "/");
122
- const content = readFileSync(absPath);
128
+ const content = fs.readFileSync(absPath);
123
129
  const compressed = gzipSync(content);
124
130
  const hash = createHash("sha256").update(compressed).digest("hex");
125
131
  fileMap[urlPath] = hash;
@@ -164,7 +170,7 @@ export class FirebaseHostingBuilder extends BaseBuilder {
164
170
  method: "POST",
165
171
  body: JSON.stringify({}),
166
172
  });
167
- const url = `https://${siteId}.web.app`;
173
+ const url = this._domain ? `https://${this._domain}` : `https://${siteId}.web.app`;
168
174
  console.log(`🚀 Deployed ${files.length} file(s) → ${url}`);
169
175
  return { siteId, url };
170
176
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,181 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import { GoogleAuth } from 'google-auth-library';
5
+ import { FirebaseHostingBuilder } from './hosting.js';
6
+ import { Config } from '../../core/config.js';
7
+ describe('FirebaseHostingBuilder Unit Tests', () => {
8
+ let originalFetch;
9
+ let fetchCalls = [];
10
+ let mockResponses = {};
11
+ beforeEach(() => {
12
+ // Configure Firebase provider with fake credentials
13
+ Config.set({
14
+ dryRun: false,
15
+ providers: {
16
+ firebase: {
17
+ projectId: 'my-project',
18
+ serviceAccountPath: '/fake/sa.json'
19
+ }
20
+ }
21
+ });
22
+ originalFetch = globalThis.fetch;
23
+ fetchCalls = [];
24
+ mockResponses = {};
25
+ globalThis.fetch = async (input, init) => {
26
+ const url = String(input);
27
+ const method = init?.method ?? 'GET';
28
+ let body;
29
+ if (init?.body) {
30
+ if (typeof init.body === 'string') {
31
+ try {
32
+ body = JSON.parse(init.body);
33
+ }
34
+ catch {
35
+ body = init.body;
36
+ }
37
+ }
38
+ else {
39
+ body = '[Binary/Buffer Body]';
40
+ }
41
+ }
42
+ const headers = init?.headers;
43
+ fetchCalls.push({ url, method, body, headers });
44
+ const matchKey = Object.keys(mockResponses)
45
+ .filter(key => {
46
+ const [mMethod, mPath] = key.split(' ');
47
+ return method === mMethod && url.includes(mPath);
48
+ })
49
+ .sort((a, b) => b.split(' ')[1].length - a.split(' ')[1].length)[0];
50
+ if (matchKey) {
51
+ const resp = mockResponses[matchKey];
52
+ return {
53
+ ok: resp.status >= 200 && resp.status < 300,
54
+ status: resp.status,
55
+ json: async () => resp.body,
56
+ text: async () => JSON.stringify(resp.body),
57
+ };
58
+ }
59
+ return {
60
+ ok: false,
61
+ status: 404,
62
+ json: async () => ({ message: `Endpoint not mocked: ${method} ${url}` }),
63
+ text: async () => `Endpoint not mocked: ${method} ${url}`,
64
+ };
65
+ };
66
+ // 1. Mock GoogleAuth prototype to completely bypass filesystem SA key loading and OAuth
67
+ mock.method(GoogleAuth.prototype, 'getClient', async () => {
68
+ return {
69
+ getAccessToken: async () => ({ token: 'fake-access-token' })
70
+ };
71
+ });
72
+ // 2. Mock filesystem calls specifically for the test source folder ./dist
73
+ mock.method(fs, 'readdirSync', () => {
74
+ return ['index.html'];
75
+ });
76
+ mock.method(fs, 'statSync', () => {
77
+ return {
78
+ isDirectory: () => false
79
+ };
80
+ });
81
+ mock.method(fs, 'readFileSync', () => {
82
+ return Buffer.from('hello from index.html');
83
+ });
84
+ });
85
+ afterEach(() => {
86
+ globalThis.fetch = originalFetch;
87
+ mock.restoreAll();
88
+ });
89
+ test('gracefully handles discovery when site does not exist', async () => {
90
+ mockResponses['GET /projects/my-project/sites/my-site'] = {
91
+ status: 404,
92
+ body: { error: { message: 'Site not found' } }
93
+ };
94
+ const builder = new FirebaseHostingBuilder('my-site');
95
+ const discoveryResult = await builder.discoveryPromise;
96
+ assert.strictEqual(discoveryResult, null);
97
+ const apiCall = fetchCalls.find(c => c.url.includes('/sites/my-site'));
98
+ assert.ok(apiCall);
99
+ assert.strictEqual(apiCall.method, 'GET');
100
+ });
101
+ test('discovers site successfully when it exists', async () => {
102
+ mockResponses['GET /projects/my-project/sites/my-site'] = {
103
+ status: 200,
104
+ body: { name: 'projects/my-project/sites/my-site', type: 'USER_SITE' }
105
+ };
106
+ const builder = new FirebaseHostingBuilder('my-site');
107
+ const discoveryResult = await builder.discoveryPromise;
108
+ assert.ok(discoveryResult);
109
+ assert.strictEqual(discoveryResult.name, 'projects/my-project/sites/my-site');
110
+ });
111
+ test('performs clean dry-run planning without making write requests', async () => {
112
+ Config.set({
113
+ dryRun: true,
114
+ providers: {
115
+ firebase: { projectId: 'my-project', serviceAccountPath: '/fake/sa.json' }
116
+ }
117
+ });
118
+ mockResponses['GET /projects/my-project/sites/my-site'] = {
119
+ status: 200,
120
+ body: { name: 'projects/my-project/sites/my-site' }
121
+ };
122
+ const builder = new FirebaseHostingBuilder('my-site');
123
+ builder.source('./dist').domain('my-domain.com');
124
+ const result = await builder.deploy();
125
+ assert.ok(result);
126
+ assert.strictEqual(result.siteId, 'my-site');
127
+ assert.strictEqual(result.url, 'https://my-domain.com');
128
+ const writeCalls = fetchCalls.filter(c => c.method !== 'GET');
129
+ assert.strictEqual(writeCalls.length, 0);
130
+ });
131
+ test('deploys new version and creates release when site exists', async () => {
132
+ mockResponses['GET /projects/my-project/sites/my-site'] = {
133
+ status: 200,
134
+ body: { name: 'projects/my-project/sites/my-site' }
135
+ };
136
+ mockResponses['POST /versions'] = {
137
+ status: 200,
138
+ body: { name: 'projects/my-project/sites/my-site/versions/v111' }
139
+ };
140
+ mockResponses['POST /versions/v111:populateFiles'] = {
141
+ status: 200,
142
+ body: {
143
+ uploadUrl: 'https://upload-firebasehosting.googleapis.com/upload/v111',
144
+ uploadRequiredHashes: ['mock-hash-index']
145
+ }
146
+ };
147
+ mockResponses['POST /upload/v111/mock-hash-index'] = {
148
+ status: 200,
149
+ body: {}
150
+ };
151
+ mockResponses['PATCH /versions/v111'] = {
152
+ status: 200,
153
+ body: { status: 'FINALIZED' }
154
+ };
155
+ mockResponses['POST /releases'] = {
156
+ status: 200,
157
+ body: { name: 'projects/my-project/sites/my-site/releases/r222' }
158
+ };
159
+ const builder = new FirebaseHostingBuilder('my-site');
160
+ builder.source('./dist');
161
+ const result = await builder.deploy();
162
+ assert.ok(result);
163
+ assert.strictEqual(result.siteId, 'my-site');
164
+ assert.strictEqual(result.url, 'https://my-site.web.app');
165
+ const createVersionCall = fetchCalls.find(c => c.method === 'POST' && c.url.endsWith('/versions'));
166
+ assert.ok(createVersionCall);
167
+ const populateCall = fetchCalls.find(c => c.method === 'POST' && c.url.endsWith('/versions/v111:populateFiles'));
168
+ 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'));
173
+ assert.ok(uploadCall);
174
+ assert.strictEqual(uploadCall.headers?.['Authorization'], 'Bearer fake-access-token');
175
+ const patchVersionCall = fetchCalls.find(c => c.method === 'PATCH' && c.url.endsWith('/versions/v111'));
176
+ assert.ok(patchVersionCall);
177
+ assert.deepStrictEqual(patchVersionCall.body, { status: 'FINALIZED' });
178
+ const releaseCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/releases'));
179
+ assert.ok(releaseCall);
180
+ });
181
+ });
@@ -13,3 +13,4 @@ export declare const Proxmox: {
13
13
  }) => void;
14
14
  VM: (name: string) => VMBuilder;
15
15
  };
16
+ export * from "../../types/proxmox.js";
@@ -8,3 +8,4 @@ export const Proxmox = {
8
8
  },
9
9
  VM: (name) => new VMBuilder(name),
10
10
  };
11
+ export * from "../../types/proxmox.js";
@@ -55,7 +55,6 @@ export declare class VMBuilder extends BaseBuilder {
55
55
  private checkPort;
56
56
  private sshKeyPath;
57
57
  private runProvisioner;
58
- private runShellScript;
59
58
  private runPuppet;
60
59
  private runAnsible;
61
60
  }