puls-dev 0.2.8 → 0.3.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 (66) hide show
  1. package/dist/core/checker.js +71 -0
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/core/config.js +12 -1
  4. package/dist/core/context.d.ts +14 -0
  5. package/dist/core/context.js +2 -0
  6. package/dist/core/decorators.d.ts +2 -0
  7. package/dist/core/decorators.js +8 -14
  8. package/dist/core/group.test.d.ts +1 -0
  9. package/dist/core/group.test.js +94 -0
  10. package/dist/core/parallel.test.d.ts +1 -0
  11. package/dist/core/parallel.test.js +215 -0
  12. package/dist/core/production.test.d.ts +1 -0
  13. package/dist/core/production.test.js +189 -0
  14. package/dist/core/provisioner.js +29 -11
  15. package/dist/core/resource.d.ts +8 -0
  16. package/dist/core/resource.js +45 -0
  17. package/dist/core/retry.d.ts +9 -0
  18. package/dist/core/retry.js +28 -0
  19. package/dist/core/retry.test.d.ts +1 -0
  20. package/dist/core/retry.test.js +66 -0
  21. package/dist/core/secret.d.ts +2 -1
  22. package/dist/core/secret.js +12 -2
  23. package/dist/core/stack.js +381 -75
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +1 -0
  26. package/dist/providers/aws/api.js +97 -17
  27. package/dist/providers/aws/ec2.d.ts +3 -0
  28. package/dist/providers/aws/ec2.js +37 -3
  29. package/dist/providers/aws/ec2.test.js +5 -3
  30. package/dist/providers/aws/index.d.ts +2 -0
  31. package/dist/providers/aws/index.js +2 -0
  32. package/dist/providers/aws/secrets.js +20 -3
  33. package/dist/providers/aws/template.d.ts +34 -0
  34. package/dist/providers/aws/template.js +252 -0
  35. package/dist/providers/aws/template.test.d.ts +1 -0
  36. package/dist/providers/aws/template.test.js +208 -0
  37. package/dist/providers/do/api.d.ts +2 -0
  38. package/dist/providers/do/api.js +124 -26
  39. package/dist/providers/do/droplet.js +14 -0
  40. package/dist/providers/firebase/api.js +92 -29
  41. package/dist/providers/firebase/list.d.ts +2 -0
  42. package/dist/providers/firebase/list.js +25 -0
  43. package/dist/providers/gcp/api.js +88 -14
  44. package/dist/providers/gcp/index.d.ts +3 -1
  45. package/dist/providers/gcp/index.js +3 -1
  46. package/dist/providers/gcp/list.d.ts +2 -0
  47. package/dist/providers/gcp/list.js +55 -0
  48. package/dist/providers/gcp/secrets.js +21 -4
  49. package/dist/providers/gcp/template.d.ts +32 -0
  50. package/dist/providers/gcp/template.js +252 -0
  51. package/dist/providers/gcp/template.test.d.ts +1 -0
  52. package/dist/providers/gcp/template.test.js +227 -0
  53. package/dist/providers/gcp/vm.d.ts +3 -0
  54. package/dist/providers/gcp/vm.js +46 -3
  55. package/dist/providers/proxmox/api.d.ts +1 -0
  56. package/dist/providers/proxmox/api.js +72 -16
  57. package/dist/providers/proxmox/index.d.ts +3 -1
  58. package/dist/providers/proxmox/index.js +14 -1
  59. package/dist/providers/proxmox/template.d.ts +44 -0
  60. package/dist/providers/proxmox/template.js +350 -0
  61. package/dist/providers/proxmox/template.test.d.ts +1 -0
  62. package/dist/providers/proxmox/template.test.js +215 -0
  63. package/dist/providers/proxmox/vm.d.ts +3 -0
  64. package/dist/providers/proxmox/vm.js +43 -11
  65. package/dist/types/inventory.d.ts +44 -1
  66. package/package.json +2 -2
@@ -0,0 +1,208 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from "node:test";
2
+ import assert from "node:assert";
3
+ import { EC2Client } from "@aws-sdk/client-ec2";
4
+ import { EC2TemplateBuilder } from "./template.js";
5
+ import { EC2VMBuilder } from "./ec2.js";
6
+ import { Config } from "../../core/config.js";
7
+ import { getFileHash } from "../proxmox/hash.js";
8
+ import { Stack } from "../../core/stack.js";
9
+ describe("AWS EC2TemplateBuilder Unit Tests", () => {
10
+ let originalSend;
11
+ let clientCalls = [];
12
+ let mockResponses = {};
13
+ beforeEach(() => {
14
+ Config.set({
15
+ dryRun: false,
16
+ providers: {
17
+ aws: { region: "us-east-1" },
18
+ },
19
+ });
20
+ clientCalls = [];
21
+ mockResponses = {};
22
+ originalSend = EC2Client.prototype.send;
23
+ EC2Client.prototype.send = async function (command) {
24
+ const name = command.constructor.name;
25
+ clientCalls.push({ method: name, input: command.input });
26
+ if (mockResponses[name] !== undefined) {
27
+ const handler = mockResponses[name];
28
+ if (typeof handler === "function")
29
+ return handler(command.input);
30
+ return handler;
31
+ }
32
+ if (name === "DescribeImagesCommand") {
33
+ return { Images: [] };
34
+ }
35
+ if (name === "DescribeInstancesCommand") {
36
+ return { Reservations: [] };
37
+ }
38
+ if (name === "RunInstancesCommand") {
39
+ return { Instances: [{ InstanceId: "i-temp123" }] };
40
+ }
41
+ if (name === "CreateImageCommand") {
42
+ return { ImageId: "ami-custom456" };
43
+ }
44
+ return {};
45
+ };
46
+ });
47
+ afterEach(() => {
48
+ EC2Client.prototype.send = originalSend;
49
+ });
50
+ test("gracefully handles discovery when Template does not exist", async () => {
51
+ const template = new EC2TemplateBuilder("my-golden-image");
52
+ const existing = await template.discoveryPromise;
53
+ assert.strictEqual(existing, null);
54
+ });
55
+ test("discovers existing Template and skips deployment if hashes match (Idempotence)", async () => {
56
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
57
+ mockResponses["DescribeImagesCommand"] = {
58
+ Images: [
59
+ {
60
+ ImageId: "ami-golden123",
61
+ State: "available",
62
+ Tags: [
63
+ { Key: "Name", Value: "my-docker-base" },
64
+ { Key: "puls-provision", Value: `nginx-yaml=${nginxHash}` },
65
+ ],
66
+ },
67
+ ],
68
+ };
69
+ const template = new EC2TemplateBuilder("my-docker-base")
70
+ .provision("playbooks/nginx.yaml");
71
+ const result = await template.deploy();
72
+ assert.strictEqual(result.amiId, "ami-golden123");
73
+ // Ensure no RunInstances or CreateImage calls were made
74
+ const writes = clientCalls.filter(c => c.method === "RunInstancesCommand" || c.method === "CreateImageCommand");
75
+ assert.strictEqual(writes.length, 0);
76
+ });
77
+ test("purges and rebuilds template if playbooks differ", async () => {
78
+ mockResponses["DescribeImagesCommand"] = (input) => {
79
+ // If querying the target name "my-docker-base"
80
+ if (input.Filters?.some((f) => f.Name === "name" && f.Values?.includes("my-docker-base"))) {
81
+ return {
82
+ Images: [
83
+ {
84
+ ImageId: "ami-old555",
85
+ State: "available",
86
+ Tags: [
87
+ { Key: "Name", Value: "my-docker-base" },
88
+ { Key: "puls-provision", Value: "nginx-yaml=outdated" },
89
+ ],
90
+ BlockDeviceMappings: [
91
+ { Ebs: { SnapshotId: "snap-old999" } }
92
+ ],
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ // If querying the baked template status "ami-custom456"
98
+ if (input.ImageIds?.includes("ami-custom456")) {
99
+ return {
100
+ Images: [{ ImageId: "ami-custom456", State: "available" }]
101
+ };
102
+ }
103
+ return { Images: [] };
104
+ };
105
+ let describeInstanceCount = 0;
106
+ mockResponses["DescribeInstancesCommand"] = () => {
107
+ describeInstanceCount++;
108
+ return {
109
+ Reservations: [
110
+ {
111
+ Instances: [
112
+ {
113
+ InstanceId: "i-temp123",
114
+ State: { Name: describeInstanceCount > 1 ? "stopped" : "running" },
115
+ PublicIpAddress: "34.20.10.99",
116
+ }
117
+ ]
118
+ }
119
+ ]
120
+ };
121
+ };
122
+ const template = new EC2TemplateBuilder("my-docker-base")
123
+ .provision("playbooks/nginx.yaml");
124
+ template.waitFor = async (label, condition) => {
125
+ return await condition();
126
+ };
127
+ template.checkPort = async () => true;
128
+ const provisionSpy = mock.method(template, "runProvisioner", async () => { });
129
+ const result = await template.deploy();
130
+ assert.strictEqual(result.amiId, "ami-custom456");
131
+ // Verify Deregister and Snapshot delete called
132
+ const deregisterCall = clientCalls.find(c => c.method === "DeregisterImageCommand");
133
+ assert.ok(deregisterCall);
134
+ assert.strictEqual(deregisterCall.input.ImageId, "ami-old555");
135
+ const deleteSnapCall = clientCalls.find(c => c.method === "DeleteSnapshotCommand");
136
+ assert.ok(deleteSnapCall);
137
+ assert.strictEqual(deleteSnapCall.input.SnapshotId, "snap-old999");
138
+ // Verify temp instance was created, stopped, image created, and terminated
139
+ assert.ok(clientCalls.some(c => c.method === "RunInstancesCommand"));
140
+ assert.ok(clientCalls.some(c => c.method === "StopInstancesCommand"));
141
+ assert.ok(clientCalls.some(c => c.method === "CreateImageCommand"));
142
+ assert.ok(clientCalls.some(c => c.method === "TerminateInstancesCommand"));
143
+ // Verify provision script ran on resolved temporary instance IP
144
+ assert.strictEqual(provisionSpy.mock.callCount(), 1);
145
+ assert.strictEqual(provisionSpy.mock.calls[0].arguments[0], "34.20.10.99");
146
+ assert.strictEqual(provisionSpy.mock.calls[0].arguments[1], "playbooks/nginx.yaml");
147
+ });
148
+ test("EC2 instance clones from custom baked template successfully", async () => {
149
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
150
+ mockResponses["DescribeImagesCommand"] = (input) => {
151
+ if (input.Filters?.some((f) => f.Name === "name" && f.Values?.includes("my-golden-ami"))) {
152
+ return {
153
+ Images: [
154
+ {
155
+ ImageId: "ami-custom777",
156
+ State: "available",
157
+ Tags: [
158
+ { Key: "Name", Value: "my-golden-ami" },
159
+ { Key: "puls-provision", Value: `nginx-yaml=${nginxHash}` },
160
+ ],
161
+ },
162
+ ],
163
+ };
164
+ }
165
+ return { Images: [] };
166
+ };
167
+ let describeCount = 0;
168
+ mockResponses["DescribeInstancesCommand"] = () => {
169
+ describeCount++;
170
+ if (describeCount === 1)
171
+ return { Reservations: [] }; // VM doesn't exist initially
172
+ return {
173
+ Reservations: [
174
+ {
175
+ Instances: [
176
+ {
177
+ InstanceId: "i-prod123",
178
+ State: { Name: "running" },
179
+ PublicIpAddress: "34.200.5.5",
180
+ }
181
+ ]
182
+ }
183
+ ]
184
+ };
185
+ };
186
+ mockResponses["RunInstancesCommand"] = {
187
+ Instances: [{ InstanceId: "i-prod123" }],
188
+ };
189
+ class AWSStack extends Stack {
190
+ amiTemplate = new EC2TemplateBuilder("my-golden-ami")
191
+ .provision("playbooks/nginx.yaml");
192
+ server = new EC2VMBuilder("prod-server-01")
193
+ .fromTemplate(this.amiTemplate)
194
+ .instanceType("t3.small");
195
+ }
196
+ const stack = new AWSStack();
197
+ stack.server.waitFor = async () => true;
198
+ stack.server.checkPort = async () => true;
199
+ const result = await stack.deploy();
200
+ // Verify Stack outputs
201
+ assert.strictEqual(result.amiTemplate.amiId, "ami-custom777");
202
+ assert.strictEqual(result.server.id, "i-prod123");
203
+ // Verify VM cloned from dynamic template AMI
204
+ const runInstanceCall = clientCalls.find(c => c.method === "RunInstancesCommand" && c.input?.ImageId === "ami-custom777");
205
+ assert.ok(runInstanceCall);
206
+ assert.strictEqual(runInstanceCall.input.InstanceType, "t3.small");
207
+ });
208
+ });
@@ -3,6 +3,8 @@ export declare class DoApiClient {
3
3
  private static readonly BASE;
4
4
  constructor(token: string);
5
5
  private get authHeaders();
6
+ private createDoOfflineMock;
7
+ private request;
6
8
  get<T>(path: string): Promise<T>;
7
9
  post<T>(path: string, body: unknown): Promise<T>;
8
10
  put<T>(path: string, body: unknown): Promise<T>;
@@ -1,4 +1,6 @@
1
1
  import { Config } from '../../core/config.js';
2
+ import { withRetry } from '../../core/retry.js';
3
+ import { resourceContextStorage } from '../../core/context.js';
2
4
  export class DoApiClient {
3
5
  token;
4
6
  static BASE = 'https://api.digitalocean.com/v2';
@@ -12,46 +14,142 @@ export class DoApiClient {
12
14
  'Accept-Encoding': 'identity'
13
15
  };
14
16
  }
17
+ createDoOfflineMock(method, path, body) {
18
+ if (path.includes("/droplets")) {
19
+ return {
20
+ droplet: {
21
+ id: 1234567,
22
+ name: body?.name ?? "mock-droplet",
23
+ networks: {
24
+ v4: [
25
+ { ip_address: "159.203.12.34", type: "public" },
26
+ { ip_address: "10.132.0.3", type: "private" }
27
+ ]
28
+ }
29
+ },
30
+ droplets: [
31
+ {
32
+ id: 1234567,
33
+ name: body?.name ?? "mock-droplet",
34
+ networks: {
35
+ v4: [
36
+ { ip_address: "159.203.12.34", type: "public" },
37
+ { ip_address: "10.132.0.3", type: "private" }
38
+ ]
39
+ }
40
+ }
41
+ ]
42
+ };
43
+ }
44
+ if (path.includes("/domains")) {
45
+ return { domain: { name: "mock-domain.com", ttl: 1800 } };
46
+ }
47
+ if (path.includes("/ssh_keys")) {
48
+ return { ssh_key: { id: 12345, name: "mock-key", public_key: "ssh-rsa mock" } };
49
+ }
50
+ return new Proxy({}, {
51
+ get(target, prop) {
52
+ if (prop === "then")
53
+ return undefined;
54
+ if (prop === "id")
55
+ return 123456;
56
+ if (prop === "name")
57
+ return "mock-do-name";
58
+ if (prop === "status")
59
+ return "active";
60
+ if (prop.endsWith("s"))
61
+ return [];
62
+ return `mock-do-${prop.toLowerCase()}`;
63
+ }
64
+ });
65
+ }
66
+ async request(fn) {
67
+ return withRetry(fn, {
68
+ retryable: (err) => {
69
+ const match = err.message.match(/: (\d+)/);
70
+ const status = match ? parseInt(match[1], 10) : null;
71
+ return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
72
+ }
73
+ });
74
+ }
15
75
  async get(path) {
16
- const res = await fetch(`${DoApiClient.BASE}${path}`, { headers: this.authHeaders });
17
- if (!res.ok)
18
- throw new Error(`DO API GET ${path}: ${res.status} ${await res.text()}`);
19
- return res.json();
76
+ const context = resourceContextStorage.getStore();
77
+ const abortSignal = context?.abortSignal;
78
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
79
+ return Promise.resolve(this.createDoOfflineMock('GET', path));
80
+ }
81
+ return this.request(async () => {
82
+ const res = await fetch(`${DoApiClient.BASE}${path}`, {
83
+ headers: this.authHeaders,
84
+ ...(abortSignal && { signal: abortSignal })
85
+ });
86
+ if (!res.ok)
87
+ throw new Error(`DO API GET ${path}: ${res.status} ${await res.text()}`);
88
+ return res.json();
89
+ });
20
90
  }
21
91
  async post(path, body) {
22
- const res = await fetch(`${DoApiClient.BASE}${path}`, {
23
- method: 'POST',
24
- headers: this.authHeaders,
25
- body: JSON.stringify(body),
92
+ const context = resourceContextStorage.getStore();
93
+ const abortSignal = context?.abortSignal;
94
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
95
+ return Promise.resolve(this.createDoOfflineMock('POST', path, body));
96
+ }
97
+ return this.request(async () => {
98
+ const res = await fetch(`${DoApiClient.BASE}${path}`, {
99
+ method: 'POST',
100
+ headers: this.authHeaders,
101
+ body: JSON.stringify(body),
102
+ ...(abortSignal && { signal: abortSignal })
103
+ });
104
+ if (!res.ok)
105
+ throw new Error(`DO API POST ${path}: ${res.status} ${await res.text()}`);
106
+ return res.json();
26
107
  });
27
- if (!res.ok)
28
- throw new Error(`DO API POST ${path}: ${res.status} ${await res.text()}`);
29
- return res.json();
30
108
  }
31
109
  async put(path, body) {
32
- const res = await fetch(`${DoApiClient.BASE}${path}`, {
33
- method: 'PUT',
34
- headers: this.authHeaders,
35
- body: JSON.stringify(body),
110
+ const context = resourceContextStorage.getStore();
111
+ const abortSignal = context?.abortSignal;
112
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
113
+ return Promise.resolve(this.createDoOfflineMock('PUT', path, body));
114
+ }
115
+ return this.request(async () => {
116
+ const res = await fetch(`${DoApiClient.BASE}${path}`, {
117
+ method: 'PUT',
118
+ headers: this.authHeaders,
119
+ body: JSON.stringify(body),
120
+ ...(abortSignal && { signal: abortSignal })
121
+ });
122
+ if (!res.ok)
123
+ throw new Error(`DO API PUT ${path}: ${res.status} ${await res.text()}`);
124
+ return res.json();
36
125
  });
37
- if (!res.ok)
38
- throw new Error(`DO API PUT ${path}: ${res.status} ${await res.text()}`);
39
- return res.json();
40
126
  }
41
127
  async delete(path, body) {
42
- const res = await fetch(`${DoApiClient.BASE}${path}`, {
43
- method: 'DELETE',
44
- headers: this.authHeaders,
45
- ...(body !== undefined && { body: JSON.stringify(body) }),
46
- });
47
- if (!res.ok && res.status !== 404) {
48
- throw new Error(`DO API DELETE ${path}: ${res.status} ${await res.text()}`);
128
+ const context = resourceContextStorage.getStore();
129
+ const abortSignal = context?.abortSignal;
130
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
131
+ return Promise.resolve();
49
132
  }
133
+ return this.request(async () => {
134
+ const res = await fetch(`${DoApiClient.BASE}${path}`, {
135
+ method: 'DELETE',
136
+ headers: this.authHeaders,
137
+ ...(body !== undefined && { body: JSON.stringify(body) }),
138
+ ...(abortSignal && { signal: abortSignal })
139
+ });
140
+ if (!res.ok && res.status !== 404) {
141
+ throw new Error(`DO API DELETE ${path}: ${res.status} ${await res.text()}`);
142
+ }
143
+ });
50
144
  }
51
145
  }
52
146
  export function getDoApi() {
53
147
  const token = Config.get().providers.do?.token;
54
- if (!token)
148
+ if (!token) {
149
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
150
+ return new DoApiClient("mock-do-token");
151
+ }
55
152
  throw new Error('DO token not configured. Call DO.init({ token: "..." })');
153
+ }
56
154
  return new DoApiClient(token);
57
155
  }
@@ -10,6 +10,7 @@ import { LoadBalancerBuilder } from './load_balancer.js';
10
10
  import { getDoApi } from './api.js';
11
11
  import { checkPort, runProvisioner } from '../../core/provisioner.js';
12
12
  import { getFileHash } from '../proxmox/hash.js';
13
+ import { resourceContextStorage } from '../../core/context.js';
13
14
  export class DropletBuilder extends BaseBuilder {
14
15
  out = {
15
16
  ip: new Output(),
@@ -250,6 +251,19 @@ export class DropletBuilder extends BaseBuilder {
250
251
  console.log(`✅ ${this.name} is up to date.`);
251
252
  }
252
253
  }
254
+ const context = resourceContextStorage.getStore();
255
+ if (context && context.hosts) {
256
+ const activeIp = this.resolvedIp ?? "0.0.0.0";
257
+ if (!context.hosts.some(h => h.name === this.name)) {
258
+ context.hosts.push({
259
+ name: this.name,
260
+ ip: activeIp,
261
+ user: "root",
262
+ sshKey: this.sshKeyPath,
263
+ provider: "do"
264
+ });
265
+ }
266
+ }
253
267
  for (const sidecar of this.sidecars)
254
268
  await sidecar.deploy();
255
269
  return this.config;
@@ -1,7 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import { GoogleAuth } from 'google-auth-library';
3
3
  import { Config } from '../../core/config.js';
4
+ import { withRetry } from '../../core/retry.js';
5
+ import { resourceContextStorage } from '../../core/context.js';
4
6
  function resolveFirebaseConfig() {
7
+ const isOffline = Config.isOfflineMode() || Config.isGlobalDryRun();
5
8
  const cfg = Config.get().providers.firebase;
6
9
  if (cfg?.serviceAccountPath)
7
10
  return cfg;
@@ -11,12 +14,18 @@ function resolveFirebaseConfig() {
11
14
  const sa = JSON.parse(fs.readFileSync(saPath, 'utf8'));
12
15
  return { projectId: sa.project_id, serviceAccountPath: saPath };
13
16
  }
17
+ if (isOffline) {
18
+ return { projectId: "mock-firebase-project", serviceAccountPath: "/mock/sa.json" };
19
+ }
14
20
  throw new Error('Firebase not configured. Set FIREBASE_SA=/path/to/sa.json or use @Deploy({ firebase: "..." })');
15
21
  }
16
22
  export function getProjectId() {
17
23
  return resolveFirebaseConfig().projectId;
18
24
  }
19
25
  export async function getFirebaseToken(scopes) {
26
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
27
+ return "mock-firebase-token";
28
+ }
20
29
  const { serviceAccountPath } = resolveFirebaseConfig();
21
30
  const auth = new GoogleAuth({ keyFile: serviceAccountPath, scopes });
22
31
  const client = await auth.getClient();
@@ -25,38 +34,92 @@ export async function getFirebaseToken(scopes) {
25
34
  }
26
35
  const HOSTING_SCOPE = 'https://www.googleapis.com/auth/firebase.hosting';
27
36
  const CLOUD_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
28
- export async function hostingFetch(path, opts = {}) {
29
- const token = await getFirebaseToken([HOSTING_SCOPE]);
30
- const base = 'https://firebasehosting.googleapis.com/v1beta1';
31
- const res = await fetch(`${base}${path}`, {
32
- ...opts,
33
- headers: {
34
- 'Authorization': `Bearer ${token}`,
35
- 'Content-Type': 'application/json',
36
- ...(opts.headers ?? {}),
37
- },
37
+ function createFirebaseOfflineMock(path, opts) {
38
+ if (path.includes("/versions")) {
39
+ return { name: `${path}/versions/mock-version-id`, status: "FINALIZED" };
40
+ }
41
+ if (path.includes("/releases")) {
42
+ return { name: `${path}/releases/mock-release-id` };
43
+ }
44
+ if (path.includes("/sites/")) {
45
+ return { name: "mock-site-name", defaultUrl: "https://mock-project.web.app" };
46
+ }
47
+ return new Proxy({}, {
48
+ get(target, prop) {
49
+ if (prop === "then")
50
+ return undefined;
51
+ if (prop === "name")
52
+ return "mock-firebase-name";
53
+ if (prop === "status")
54
+ return "FINALIZED";
55
+ if (prop === "id")
56
+ return "mock-firebase-id";
57
+ if (prop.endsWith("s"))
58
+ return [];
59
+ return `mock-fb-${prop.toLowerCase()}`;
60
+ }
38
61
  });
39
- if (!res.ok) {
40
- const body = await res.text();
41
- throw new Error(`Firebase Hosting API ${opts.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
62
+ }
63
+ export async function hostingFetch(path, opts = {}) {
64
+ const context = resourceContextStorage.getStore();
65
+ const abortSignal = context?.abortSignal;
66
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
67
+ return Promise.resolve(createFirebaseOfflineMock(path, opts));
42
68
  }
43
- const text = await res.text();
44
- return text ? JSON.parse(text) : null;
69
+ const fetchOpts = abortSignal ? { ...opts, signal: abortSignal } : opts;
70
+ return withRetry(async () => {
71
+ const token = await getFirebaseToken([HOSTING_SCOPE]);
72
+ const base = 'https://firebasehosting.googleapis.com/v1beta1';
73
+ const res = await fetch(`${base}${path}`, {
74
+ ...fetchOpts,
75
+ headers: {
76
+ 'Authorization': `Bearer ${token}`,
77
+ 'Content-Type': 'application/json',
78
+ ...(fetchOpts.headers ?? {}),
79
+ },
80
+ });
81
+ if (!res.ok) {
82
+ const body = await res.text();
83
+ throw new Error(`Firebase Hosting API ${fetchOpts.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
84
+ }
85
+ const text = await res.text();
86
+ return text ? JSON.parse(text) : null;
87
+ }, {
88
+ retryable: (err) => {
89
+ const match = err.message.match(/→ (\d+):/);
90
+ const status = match ? parseInt(match[1], 10) : null;
91
+ return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
92
+ }
93
+ });
45
94
  }
46
95
  export async function cloudFetch(base, path, opts = {}) {
47
- const token = await getFirebaseToken([CLOUD_SCOPE]);
48
- const res = await fetch(`${base}${path}`, {
49
- ...opts,
50
- headers: {
51
- 'Authorization': `Bearer ${token}`,
52
- 'Content-Type': 'application/json',
53
- ...(opts.headers ?? {}),
54
- },
55
- });
56
- if (!res.ok) {
57
- const body = await res.text();
58
- throw new Error(`GCP API ${opts.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
96
+ const context = resourceContextStorage.getStore();
97
+ const abortSignal = context?.abortSignal;
98
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
99
+ return Promise.resolve(createFirebaseOfflineMock(path, opts));
59
100
  }
60
- const text = await res.text();
61
- return text ? JSON.parse(text) : null;
101
+ const fetchOpts = abortSignal ? { ...opts, signal: abortSignal } : opts;
102
+ return withRetry(async () => {
103
+ const token = await getFirebaseToken([CLOUD_SCOPE]);
104
+ const res = await fetch(`${base}${path}`, {
105
+ ...fetchOpts,
106
+ headers: {
107
+ 'Authorization': `Bearer ${token}`,
108
+ 'Content-Type': 'application/json',
109
+ ...(fetchOpts.headers ?? {}),
110
+ },
111
+ });
112
+ if (!res.ok) {
113
+ const body = await res.text();
114
+ throw new Error(`GCP API ${fetchOpts.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
115
+ }
116
+ const text = await res.text();
117
+ return text ? JSON.parse(text) : null;
118
+ }, {
119
+ retryable: (err) => {
120
+ const match = err.message.match(/→ (\d+):/);
121
+ const status = match ? parseInt(match[1], 10) : null;
122
+ return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
123
+ }
124
+ });
62
125
  }
@@ -0,0 +1,2 @@
1
+ import type { FirebaseInventory } from "../../types/inventory.js";
2
+ export declare function listFirebaseResources(): Promise<FirebaseInventory>;
@@ -0,0 +1,25 @@
1
+ import { getProjectId, hostingFetch, cloudFetch } from "./api.js";
2
+ export async function listFirebaseResources() {
3
+ const project = getProjectId();
4
+ const [hostRes, fnRes] = await Promise.all([
5
+ hostingFetch(`/projects/${project}/sites`).catch(() => ({})),
6
+ cloudFetch("https://cloudfunctions.googleapis.com/v2", `/projects/${project}/locations/-/functions`).catch(() => ({})),
7
+ ]);
8
+ // 1. Map Hosting Sites
9
+ const hostingSites = (hostRes.sites ?? []).map((s) => ({
10
+ site: s.name.split("/").pop() ?? "unknown",
11
+ }));
12
+ // 2. Map Cloud Functions
13
+ const functions = (fnRes.functions ?? []).map((f) => {
14
+ const parts = f.name.split("/");
15
+ const name = parts.pop() ?? "unknown";
16
+ const region = parts[parts.indexOf("locations") + 1] ?? "unknown";
17
+ return {
18
+ name,
19
+ region,
20
+ entryPoint: f.buildConfig?.entryPoint ?? "unknown",
21
+ runtime: f.buildConfig?.runtime ?? "unknown",
22
+ };
23
+ });
24
+ return { hostingSites, functions };
25
+ }