puls-dev 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/dist/core/checker.d.ts +1 -1
- package/dist/core/checker.js +88 -56
- package/dist/core/decorators.js +8 -2
- package/dist/core/resource.js +2 -2
- package/dist/core/stack.d.ts +1 -1
- package/dist/core/stack.js +2 -2
- package/dist/providers/aws/acm.d.ts +1 -1
- package/dist/providers/aws/acm.js +27 -23
- package/dist/providers/aws/api.d.ts +14 -14
- package/dist/providers/aws/api.js +21 -21
- package/dist/providers/aws/apigateway.d.ts +2 -2
- package/dist/providers/aws/apigateway.js +33 -29
- package/dist/providers/aws/cloudfront.d.ts +3 -3
- package/dist/providers/aws/cloudfront.js +49 -34
- package/dist/providers/aws/fargate.d.ts +2 -2
- package/dist/providers/aws/fargate.js +99 -52
- package/dist/providers/aws/lambda.d.ts +2 -2
- package/dist/providers/aws/lambda.js +63 -32
- package/dist/providers/aws/rds.d.ts +1 -1
- package/dist/providers/aws/rds.js +77 -39
- package/dist/providers/aws/route53.d.ts +5 -5
- package/dist/providers/aws/route53.js +42 -35
- package/dist/providers/aws/s3.d.ts +2 -2
- package/dist/providers/aws/s3.js +40 -33
- package/dist/providers/aws/secrets.js +15 -7
- package/dist/providers/aws/sqs.d.ts +1 -1
- package/dist/providers/aws/sqs.js +47 -23
- package/dist/providers/do/domain.d.ts +4 -4
- package/dist/providers/do/domain.js +15 -11
- package/dist/providers/firebase/auth.d.ts +1 -1
- package/dist/providers/firebase/auth.js +65 -33
- package/dist/providers/firebase/firestore.d.ts +2 -2
- package/dist/providers/firebase/firestore.js +45 -28
- package/dist/providers/firebase/functions.d.ts +1 -1
- package/dist/providers/firebase/functions.js +75 -42
- package/dist/providers/firebase/hosting.d.ts +1 -1
- package/dist/providers/firebase/hosting.js +92 -52
- package/dist/providers/firebase/remoteconfig.d.ts +1 -1
- package/dist/providers/firebase/remoteconfig.js +42 -33
- package/dist/providers/firebase/storage.d.ts +1 -1
- package/dist/providers/firebase/storage.js +38 -24
- package/dist/providers/proxmox/vm.js +34 -22
- package/dist/types/aws.js +1 -1
- package/package.json +2 -1
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import { execSync } from
|
|
2
|
-
import { mkdtempSync, readFileSync } from
|
|
3
|
-
import { tmpdir } from
|
|
4
|
-
import { join } from
|
|
5
|
-
import { BaseBuilder } from
|
|
6
|
-
import { cloudFetch, getProjectId } from
|
|
7
|
-
const CF_BASE =
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
6
|
+
import { cloudFetch, getProjectId } from "./api.js";
|
|
7
|
+
const CF_BASE = "https://cloudfunctions.googleapis.com/v2";
|
|
8
8
|
export const FUNCTIONS_RUNTIME = {
|
|
9
|
-
NODEJS_22:
|
|
10
|
-
NODEJS_20:
|
|
11
|
-
NODEJS_18:
|
|
12
|
-
PYTHON_312:
|
|
13
|
-
PYTHON_311:
|
|
14
|
-
GO_122:
|
|
15
|
-
JAVA_21:
|
|
9
|
+
NODEJS_22: "nodejs22",
|
|
10
|
+
NODEJS_20: "nodejs20",
|
|
11
|
+
NODEJS_18: "nodejs18",
|
|
12
|
+
PYTHON_312: "python312",
|
|
13
|
+
PYTHON_311: "python311",
|
|
14
|
+
GO_122: "go122",
|
|
15
|
+
JAVA_21: "java21",
|
|
16
16
|
};
|
|
17
17
|
export class FirebaseFunctionsBuilder extends BaseBuilder {
|
|
18
18
|
_sourcePath;
|
|
19
|
-
_entryPoint =
|
|
19
|
+
_entryPoint = "handler";
|
|
20
20
|
_runtime = FUNCTIONS_RUNTIME.NODEJS_20;
|
|
21
|
-
_region =
|
|
22
|
-
_memory =
|
|
21
|
+
_region = "us-central1";
|
|
22
|
+
_memory = "256M";
|
|
23
23
|
_timeout = 60;
|
|
24
24
|
_maxInstances = 100;
|
|
25
25
|
_minInstances = 0;
|
|
@@ -29,15 +29,42 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
|
|
|
29
29
|
// Discovery runs lazily in deploy() because we need _region, which may not be set at construction time
|
|
30
30
|
this.discoveryPromise = Promise.resolve(null);
|
|
31
31
|
}
|
|
32
|
-
source(path) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
source(path) {
|
|
33
|
+
this._sourcePath = path;
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
entryPoint(e) {
|
|
37
|
+
this._entryPoint = e;
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
runtime(r) {
|
|
41
|
+
this._runtime = r;
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
region(r) {
|
|
45
|
+
this._region = r;
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
memory(m) {
|
|
49
|
+
this._memory = m;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
timeout(seconds) {
|
|
53
|
+
this._timeout = seconds;
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
maxInstances(n) {
|
|
57
|
+
this._maxInstances = n;
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
minInstances(n) {
|
|
61
|
+
this._minInstances = n;
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
env(vars) {
|
|
65
|
+
this._env = vars;
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
41
68
|
fnPath() {
|
|
42
69
|
return `/projects/${getProjectId()}/locations/${this._region}/functions/${this.name}`;
|
|
43
70
|
}
|
|
@@ -46,7 +73,7 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
|
|
|
46
73
|
return await cloudFetch(CF_BASE, this.fnPath());
|
|
47
74
|
}
|
|
48
75
|
catch (e) {
|
|
49
|
-
if (e.message?.includes(
|
|
76
|
+
if (e.message?.includes("404"))
|
|
50
77
|
return null;
|
|
51
78
|
throw e;
|
|
52
79
|
}
|
|
@@ -54,17 +81,19 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
|
|
|
54
81
|
zipSource() {
|
|
55
82
|
if (!this._sourcePath)
|
|
56
83
|
throw new Error(`[Firebase.Functions:${this.name}] .source() is required`);
|
|
57
|
-
const tmp = mkdtempSync(join(tmpdir(),
|
|
58
|
-
const zipPath = join(tmp,
|
|
59
|
-
execSync(`cd "${this._sourcePath}" && zip -r "${zipPath}" .`, {
|
|
84
|
+
const tmp = mkdtempSync(join(tmpdir(), "puls-fn-"));
|
|
85
|
+
const zipPath = join(tmp, "function.zip");
|
|
86
|
+
execSync(`cd "${this._sourcePath}" && zip -r "${zipPath}" .`, {
|
|
87
|
+
stdio: "pipe",
|
|
88
|
+
});
|
|
60
89
|
return readFileSync(zipPath);
|
|
61
90
|
}
|
|
62
91
|
async uploadSource() {
|
|
63
|
-
const { uploadUrl, storageSource } = await cloudFetch(CF_BASE, `/projects/${getProjectId()}/locations/${this._region}/functions:generateUploadUrl`, { method:
|
|
92
|
+
const { uploadUrl, storageSource } = await cloudFetch(CF_BASE, `/projects/${getProjectId()}/locations/${this._region}/functions:generateUploadUrl`, { method: "POST", body: "{}" });
|
|
64
93
|
const zipBuffer = this.zipSource();
|
|
65
94
|
const res = await fetch(uploadUrl, {
|
|
66
|
-
method:
|
|
67
|
-
headers: {
|
|
95
|
+
method: "PUT",
|
|
96
|
+
headers: { "Content-Type": "application/zip" },
|
|
68
97
|
body: new Uint8Array(zipBuffer),
|
|
69
98
|
});
|
|
70
99
|
if (!res.ok)
|
|
@@ -75,7 +104,7 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
|
|
|
75
104
|
const start = Date.now();
|
|
76
105
|
const timeout = 5 * 60 * 1000;
|
|
77
106
|
while (Date.now() - start < timeout) {
|
|
78
|
-
await new Promise(r => setTimeout(r, 4000));
|
|
107
|
+
await new Promise((r) => setTimeout(r, 4000));
|
|
79
108
|
const op = await cloudFetch(CF_BASE, `/${operationName}`);
|
|
80
109
|
if (op.done) {
|
|
81
110
|
if (op.error)
|
|
@@ -94,7 +123,7 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
|
|
|
94
123
|
if (dryRun) {
|
|
95
124
|
const existing = await this.getExisting();
|
|
96
125
|
if (existing) {
|
|
97
|
-
console.log(` ✅ Function "${this.name}" exists (${existing.state ??
|
|
126
|
+
console.log(` ✅ Function "${this.name}" exists (${existing.state ?? "ACTIVE"})`);
|
|
98
127
|
console.log(` 📝 [PLAN] Update → ${this._runtime}, ${this._memory}, ${this._timeout}s timeout`);
|
|
99
128
|
}
|
|
100
129
|
else {
|
|
@@ -123,23 +152,27 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
|
|
|
123
152
|
maxInstanceCount: this._maxInstances,
|
|
124
153
|
minInstanceCount: this._minInstances,
|
|
125
154
|
environmentVariables: this._env,
|
|
126
|
-
ingressSettings:
|
|
155
|
+
ingressSettings: "ALLOW_ALL",
|
|
127
156
|
},
|
|
128
157
|
};
|
|
129
158
|
const existing = await this.getExisting();
|
|
130
159
|
let op;
|
|
131
160
|
if (!existing) {
|
|
132
|
-
op = await cloudFetch(CF_BASE, `/projects/${project}/locations/${this._region}/functions?functionId=${this.name}`, { method:
|
|
161
|
+
op = await cloudFetch(CF_BASE, `/projects/${project}/locations/${this._region}/functions?functionId=${this.name}`, { method: "POST", body: JSON.stringify(body) });
|
|
133
162
|
console.log(` 🚀 Creating function...`);
|
|
134
163
|
}
|
|
135
164
|
else {
|
|
136
|
-
const mask =
|
|
137
|
-
op = await cloudFetch(CF_BASE, `${this.fnPath()}?updateMask=${mask}`, {
|
|
165
|
+
const mask = "buildConfig.source,buildConfig.runtime,buildConfig.entryPoint,buildConfig.environmentVariables,serviceConfig.availableMemory,serviceConfig.timeoutSeconds,serviceConfig.maxInstanceCount,serviceConfig.minInstanceCount,serviceConfig.environmentVariables";
|
|
166
|
+
op = await cloudFetch(CF_BASE, `${this.fnPath()}?updateMask=${mask}`, {
|
|
167
|
+
method: "PATCH",
|
|
168
|
+
body: JSON.stringify(body),
|
|
169
|
+
});
|
|
138
170
|
console.log(` 🔄 Updating function...`);
|
|
139
171
|
}
|
|
140
172
|
await this.pollOperation(op.name);
|
|
141
173
|
const fn = await this.getExisting();
|
|
142
|
-
const url = fn?.serviceConfig?.uri ??
|
|
174
|
+
const url = fn?.serviceConfig?.uri ??
|
|
175
|
+
`https://${this._region}-${project}.cloudfunctions.net/${this.name}`;
|
|
143
176
|
console.log(` ✅ Function live → ${url}`);
|
|
144
177
|
return { name: this.name, url, project, region: this._region };
|
|
145
178
|
}
|
|
@@ -148,14 +181,14 @@ export class FirebaseFunctionsBuilder extends BaseBuilder {
|
|
|
148
181
|
console.log(`\n🗑️ Destroying Firebase Function "${this.name}"...`);
|
|
149
182
|
const existing = await this.getExisting();
|
|
150
183
|
if (!existing) {
|
|
151
|
-
console.log(` ✅ Function "${this.name}" does not exist
|
|
184
|
+
console.log(` ✅ Function "${this.name}" does not exist - nothing to do`);
|
|
152
185
|
return { destroyed: this.name };
|
|
153
186
|
}
|
|
154
187
|
if (dryRun) {
|
|
155
188
|
console.log(` 📝 [PLAN] Delete function "${this.name}" in ${this._region}`);
|
|
156
189
|
return { destroyed: this.name };
|
|
157
190
|
}
|
|
158
|
-
const op = await cloudFetch(CF_BASE, this.fnPath(), { method:
|
|
191
|
+
const op = await cloudFetch(CF_BASE, this.fnPath(), { method: "DELETE" });
|
|
159
192
|
await this.pollOperation(op.name);
|
|
160
193
|
console.log(` ✅ Function "${this.name}" deleted`);
|
|
161
194
|
return { destroyed: this.name };
|
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import { createHash } from
|
|
2
|
-
import { readdirSync, statSync, readFileSync } from
|
|
3
|
-
import { join, relative
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { gzipSync } from "node:zlib";
|
|
5
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
6
|
+
import { getProjectId, hostingFetch, getFirebaseToken } from "./api.js";
|
|
6
7
|
const CONTENT_TYPES = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
8
|
+
".html": "text/html",
|
|
9
|
+
".css": "text/css",
|
|
10
|
+
".js": "application/javascript",
|
|
11
|
+
".json": "application/json",
|
|
12
|
+
".png": "image/png",
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".svg": "image/svg+xml",
|
|
15
|
+
".ico": "image/x-icon",
|
|
16
|
+
".woff2": "font/woff2",
|
|
17
|
+
".woff": "font/woff",
|
|
18
|
+
".ttf": "font/ttf",
|
|
19
|
+
".txt": "text/plain",
|
|
20
|
+
".xml": "application/xml",
|
|
20
21
|
};
|
|
21
22
|
function walkDir(dir) {
|
|
22
23
|
const results = [];
|
|
@@ -33,7 +34,7 @@ function walkDir(dir) {
|
|
|
33
34
|
}
|
|
34
35
|
function sha256(filePath) {
|
|
35
36
|
const content = readFileSync(filePath);
|
|
36
|
-
return createHash(
|
|
37
|
+
return createHash("sha256").update(content).digest("hex");
|
|
37
38
|
}
|
|
38
39
|
export class FirebaseHostingBuilder extends BaseBuilder {
|
|
39
40
|
_sourcePath;
|
|
@@ -42,17 +43,21 @@ export class FirebaseHostingBuilder extends BaseBuilder {
|
|
|
42
43
|
// Discovery: check if the site has any releases (confirms it exists and is active)
|
|
43
44
|
this.discoveryPromise = this.discoverSite(siteId);
|
|
44
45
|
}
|
|
45
|
-
source(path) {
|
|
46
|
+
source(path) {
|
|
47
|
+
this._sourcePath = path;
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
46
50
|
async discoverSite(siteId) {
|
|
47
51
|
try {
|
|
48
|
-
const
|
|
49
|
-
|
|
52
|
+
const projectId = getProjectId();
|
|
53
|
+
const result = await hostingFetch(`/projects/${projectId}/sites/${siteId}`);
|
|
54
|
+
return result;
|
|
50
55
|
}
|
|
51
56
|
catch (e) {
|
|
52
|
-
// Site doesn't exist yet or credentials not set yet
|
|
53
|
-
if (e.message?.includes(
|
|
57
|
+
// Site doesn't exist yet or credentials not set yet - not an error at construction time
|
|
58
|
+
if (e.message?.includes("403") || e.message?.includes("404"))
|
|
54
59
|
return null;
|
|
55
|
-
if (e.message?.includes(
|
|
60
|
+
if (e.message?.includes("Firebase not configured"))
|
|
56
61
|
return null;
|
|
57
62
|
throw e;
|
|
58
63
|
}
|
|
@@ -61,14 +66,18 @@ export class FirebaseHostingBuilder extends BaseBuilder {
|
|
|
61
66
|
const dryRun = this.isDryRunActive();
|
|
62
67
|
const projectId = getProjectId();
|
|
63
68
|
const siteId = this.name;
|
|
69
|
+
const parent = `projects/${projectId}/sites/${siteId}`;
|
|
64
70
|
console.log(`\n⚡ Finalizing Firebase Hosting "${siteId}"...`);
|
|
65
71
|
if (!this._sourcePath)
|
|
66
72
|
throw new Error(`[Firebase.Hosting:${siteId}] .source("./dist") is required`);
|
|
67
73
|
const files = walkDir(this._sourcePath);
|
|
68
74
|
if (files.length === 0)
|
|
69
75
|
throw new Error(`[Firebase.Hosting:${siteId}] No files found in "${this._sourcePath}"`);
|
|
76
|
+
const existing = await this.discoveryPromise;
|
|
70
77
|
if (dryRun) {
|
|
71
78
|
console.log(` 📝 [PLAN] Deploy ${files.length} file(s) to https://${siteId}.web.app`);
|
|
79
|
+
if (!existing)
|
|
80
|
+
console.log(` └─ Will create secondary site "${siteId}"`);
|
|
72
81
|
for (const f of files.slice(0, 5)) {
|
|
73
82
|
console.log(` └─ /${relative(this._sourcePath, f)}`);
|
|
74
83
|
}
|
|
@@ -76,52 +85,83 @@ export class FirebaseHostingBuilder extends BaseBuilder {
|
|
|
76
85
|
console.log(` └─ ... and ${files.length - 5} more`);
|
|
77
86
|
return { siteId, url: `https://${siteId}.web.app` };
|
|
78
87
|
}
|
|
88
|
+
// 0. Ensure site exists (for secondary sites)
|
|
89
|
+
if (!existing) {
|
|
90
|
+
console.log(` 🆕 Creating secondary site "${siteId}"...`);
|
|
91
|
+
try {
|
|
92
|
+
await hostingFetch(`/projects/${projectId}/sites?siteId=${siteId}`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
body: JSON.stringify({}), // Defaults to USER_SITE
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (!e.message.includes("409"))
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
79
102
|
// 1. Create a new version
|
|
80
|
-
const version = await hostingFetch(
|
|
81
|
-
method:
|
|
82
|
-
body: JSON.stringify({
|
|
103
|
+
const version = await hostingFetch(`/${parent}/versions`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
config: {
|
|
107
|
+
headers: [
|
|
108
|
+
{ glob: "**", headers: { "Cache-Control": "max-age=3600" } },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
83
112
|
});
|
|
84
|
-
const versionId = version.name.split(
|
|
113
|
+
const versionId = version.name.split("/").pop();
|
|
85
114
|
console.log(` 📦 Version created: ${versionId}`);
|
|
86
|
-
// 2. Build file hash map
|
|
115
|
+
// 2. Build file hash map - keys are URL paths, values are SHA256 hashes
|
|
116
|
+
// NOTE: Firebase Hosting requires gzipped content and the hash must match the gzipped bytes.
|
|
87
117
|
const fileMap = {};
|
|
88
|
-
const
|
|
118
|
+
const absoluteToCompressed = {};
|
|
119
|
+
const hashToUrl = {};
|
|
89
120
|
for (const absPath of files) {
|
|
90
|
-
const urlPath =
|
|
91
|
-
const
|
|
121
|
+
const urlPath = "/" + relative(this._sourcePath, absPath).replace(/\\/g, "/");
|
|
122
|
+
const content = readFileSync(absPath);
|
|
123
|
+
const compressed = gzipSync(content);
|
|
124
|
+
const hash = createHash("sha256").update(compressed).digest("hex");
|
|
92
125
|
fileMap[urlPath] = hash;
|
|
93
|
-
|
|
126
|
+
absoluteToCompressed[absPath] = compressed;
|
|
127
|
+
hashToUrl[hash] = absPath;
|
|
94
128
|
}
|
|
95
|
-
// 3. Populate files
|
|
96
|
-
const populate = await hostingFetch(
|
|
97
|
-
method:
|
|
129
|
+
// 3. Populate files - API tells us which hashes need uploading
|
|
130
|
+
const populate = await hostingFetch(`/${parent}/versions/${versionId}:populateFiles`, {
|
|
131
|
+
method: "POST",
|
|
98
132
|
body: JSON.stringify({ files: fileMap }),
|
|
99
133
|
});
|
|
100
134
|
const uploadUrl = populate.uploadUrl;
|
|
101
135
|
const required = populate.uploadRequiredHashes ?? [];
|
|
102
136
|
console.log(` 📤 Uploading ${required.length} file(s) (${files.length - required.length} cached)`);
|
|
103
137
|
// 4. Upload each required file
|
|
104
|
-
const token = await getFirebaseToken([
|
|
138
|
+
const token = await getFirebaseToken([
|
|
139
|
+
"https://www.googleapis.com/auth/firebase.hosting",
|
|
140
|
+
]);
|
|
105
141
|
for (const hash of required) {
|
|
106
|
-
const absPath =
|
|
107
|
-
const
|
|
108
|
-
const contentType = CONTENT_TYPES[extname(absPath).toLowerCase()] ?? 'application/octet-stream';
|
|
142
|
+
const absPath = hashToUrl[hash];
|
|
143
|
+
const compressed = absoluteToCompressed[absPath];
|
|
109
144
|
const res = await fetch(`${uploadUrl}/${hash}`, {
|
|
110
|
-
method:
|
|
111
|
-
headers: {
|
|
112
|
-
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: {
|
|
147
|
+
Authorization: `Bearer ${token}`,
|
|
148
|
+
"Content-Type": "application/octet-stream",
|
|
149
|
+
},
|
|
150
|
+
body: compressed,
|
|
113
151
|
});
|
|
114
|
-
if (!res.ok)
|
|
115
|
-
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
const errorBody = await res.text();
|
|
154
|
+
throw new Error(`Upload failed for ${absPath}: ${res.status} - ${errorBody}`);
|
|
155
|
+
}
|
|
116
156
|
}
|
|
117
157
|
// 5. Finalize the version
|
|
118
|
-
await hostingFetch(
|
|
119
|
-
method:
|
|
120
|
-
body: JSON.stringify({ status:
|
|
158
|
+
await hostingFetch(`/${parent}/versions/${versionId}`, {
|
|
159
|
+
method: "PATCH",
|
|
160
|
+
body: JSON.stringify({ status: "FINALIZED" }),
|
|
121
161
|
});
|
|
122
162
|
// 6. Create a release
|
|
123
|
-
await hostingFetch(
|
|
124
|
-
method:
|
|
163
|
+
await hostingFetch(`/${parent}/releases?versionName=${parent}/versions/${versionId}`, {
|
|
164
|
+
method: "POST",
|
|
125
165
|
body: JSON.stringify({}),
|
|
126
166
|
});
|
|
127
167
|
const url = `https://${siteId}.web.app`;
|
|
@@ -135,7 +175,7 @@ export class FirebaseHostingBuilder extends BaseBuilder {
|
|
|
135
175
|
console.log(` 📝 [PLAN] Delete all releases for site "${this.name}"`);
|
|
136
176
|
return { destroyed: this.name };
|
|
137
177
|
}
|
|
138
|
-
// Firebase doesn't support deleting the default site
|
|
178
|
+
// Firebase doesn't support deleting the default site - we can only roll back releases.
|
|
139
179
|
// For non-default sites, the Sites API supports deletion (requires Blaze plan).
|
|
140
180
|
console.log(` ℹ️ Firebase Hosting sites cannot be deleted via API (default site is permanent).`);
|
|
141
181
|
console.log(` To unpublish, go to the Firebase console and disable Hosting.`);
|
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
import { BaseBuilder } from
|
|
2
|
-
import { getFirebaseToken, getProjectId } from
|
|
3
|
-
const RC_BASE =
|
|
4
|
-
const RC_SCOPE =
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { getFirebaseToken, getProjectId } from "./api.js";
|
|
3
|
+
const RC_BASE = "https://firebaseremoteconfig.googleapis.com/v1";
|
|
4
|
+
const RC_SCOPE = "https://www.googleapis.com/auth/firebase.remoteconfig";
|
|
5
5
|
function inferType(v) {
|
|
6
|
-
if (typeof v ===
|
|
7
|
-
return
|
|
8
|
-
if (typeof v ===
|
|
9
|
-
return
|
|
10
|
-
if (typeof v ===
|
|
11
|
-
return
|
|
12
|
-
return
|
|
6
|
+
if (typeof v === "boolean")
|
|
7
|
+
return "BOOLEAN";
|
|
8
|
+
if (typeof v === "number")
|
|
9
|
+
return "NUMBER";
|
|
10
|
+
if (typeof v === "object")
|
|
11
|
+
return "JSON";
|
|
12
|
+
return "STRING";
|
|
13
13
|
}
|
|
14
14
|
function serialize(v) {
|
|
15
|
-
return typeof v ===
|
|
15
|
+
return typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
16
16
|
}
|
|
17
17
|
export class FirebaseRemoteConfigBuilder extends BaseBuilder {
|
|
18
18
|
_params = [];
|
|
19
19
|
_conditions = [];
|
|
20
20
|
constructor() {
|
|
21
|
-
super(
|
|
21
|
+
super("remoteconfig");
|
|
22
22
|
this.discoveryPromise = Promise.resolve(null);
|
|
23
23
|
}
|
|
24
24
|
string(key, value, description) {
|
|
25
|
-
this._params.push({ key, value, type:
|
|
25
|
+
this._params.push({ key, value, type: "STRING", description });
|
|
26
26
|
return this;
|
|
27
27
|
}
|
|
28
28
|
bool(key, value, description) {
|
|
29
|
-
this._params.push({ key, value, type:
|
|
29
|
+
this._params.push({ key, value, type: "BOOLEAN", description });
|
|
30
30
|
return this;
|
|
31
31
|
}
|
|
32
32
|
number(key, value, description) {
|
|
33
|
-
this._params.push({ key, value, type:
|
|
33
|
+
this._params.push({ key, value, type: "NUMBER", description });
|
|
34
34
|
return this;
|
|
35
35
|
}
|
|
36
36
|
json(key, value, description) {
|
|
37
|
-
this._params.push({ key, value, type:
|
|
37
|
+
this._params.push({ key, value, type: "JSON", description });
|
|
38
38
|
return this;
|
|
39
39
|
}
|
|
40
|
-
// Generic param
|
|
40
|
+
// Generic param - type inferred from value
|
|
41
41
|
param(key, value, description) {
|
|
42
42
|
this._params.push({ key, value, type: inferType(value), description });
|
|
43
43
|
return this;
|
|
@@ -48,9 +48,9 @@ export class FirebaseRemoteConfigBuilder extends BaseBuilder {
|
|
|
48
48
|
}
|
|
49
49
|
// Override a param's value for a specific condition
|
|
50
50
|
override(paramKey, conditionName, value) {
|
|
51
|
-
const p = this._params.find(p => p.key === paramKey);
|
|
51
|
+
const p = this._params.find((p) => p.key === paramKey);
|
|
52
52
|
if (!p)
|
|
53
|
-
throw new Error(`[RemoteConfig] param "${paramKey}" not defined
|
|
53
|
+
throw new Error(`[RemoteConfig] param "${paramKey}" not defined - call .param()/.bool()/etc. first`);
|
|
54
54
|
p.conditionalValues ??= {};
|
|
55
55
|
p.conditionalValues[conditionName] = value;
|
|
56
56
|
return this;
|
|
@@ -59,18 +59,21 @@ export class FirebaseRemoteConfigBuilder extends BaseBuilder {
|
|
|
59
59
|
async rcFetch(path, opts = {}) {
|
|
60
60
|
const token = await getFirebaseToken([RC_SCOPE]);
|
|
61
61
|
const headers = {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
Authorization: `Bearer ${token}`,
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
64
|
};
|
|
65
65
|
if (opts.etag)
|
|
66
|
-
headers[
|
|
66
|
+
headers["If-Match"] = opts.etag;
|
|
67
67
|
const res = await fetch(`${RC_BASE}${path}`, { ...opts, headers });
|
|
68
68
|
if (!res.ok) {
|
|
69
69
|
const body = await res.text();
|
|
70
|
-
throw new Error(`RemoteConfig API ${opts.method ??
|
|
70
|
+
throw new Error(`RemoteConfig API ${opts.method ?? "GET"} ${path} → ${res.status}: ${body}`);
|
|
71
71
|
}
|
|
72
72
|
const text = await res.text();
|
|
73
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
data: text ? JSON.parse(text) : null,
|
|
75
|
+
etag: res.headers.get("etag"),
|
|
76
|
+
};
|
|
74
77
|
}
|
|
75
78
|
// ── Deploy ────────────────────────────────────────────────────────────────
|
|
76
79
|
async deploy() {
|
|
@@ -80,7 +83,9 @@ export class FirebaseRemoteConfigBuilder extends BaseBuilder {
|
|
|
80
83
|
if (dryRun) {
|
|
81
84
|
console.log(` 📝 [PLAN] Set ${this._params.length} parameter(s):`);
|
|
82
85
|
for (const p of this._params) {
|
|
83
|
-
const overrides = p.conditionalValues
|
|
86
|
+
const overrides = p.conditionalValues
|
|
87
|
+
? ` + ${Object.keys(p.conditionalValues).length} override(s)`
|
|
88
|
+
: "";
|
|
84
89
|
console.log(` └─ ${p.key} (${p.type}): ${serialize(p.value)}${overrides}`);
|
|
85
90
|
}
|
|
86
91
|
if (this._conditions.length) {
|
|
@@ -92,7 +97,7 @@ export class FirebaseRemoteConfigBuilder extends BaseBuilder {
|
|
|
92
97
|
}
|
|
93
98
|
// Fetch current template + ETag
|
|
94
99
|
const { data: current, etag } = await this.rcFetch(`/projects/${project}/remoteConfig`);
|
|
95
|
-
// Merge our params into the existing template (non-destructive
|
|
100
|
+
// Merge our params into the existing template (non-destructive - preserves params we didn't define)
|
|
96
101
|
const parameters = { ...(current?.parameters ?? {}) };
|
|
97
102
|
for (const p of this._params) {
|
|
98
103
|
const entry = {
|
|
@@ -114,13 +119,17 @@ export class FirebaseRemoteConfigBuilder extends BaseBuilder {
|
|
|
114
119
|
const existingNames = new Set(existingConditions.map((c) => c.name));
|
|
115
120
|
const conditions = [
|
|
116
121
|
...existingConditions,
|
|
117
|
-
...this._conditions.filter(c => !existingNames.has(c.name)),
|
|
122
|
+
...this._conditions.filter((c) => !existingNames.has(c.name)),
|
|
118
123
|
];
|
|
119
|
-
const body = {
|
|
124
|
+
const body = {
|
|
125
|
+
parameters,
|
|
126
|
+
conditions,
|
|
127
|
+
parameterGroups: current?.parameterGroups ?? {},
|
|
128
|
+
};
|
|
120
129
|
await this.rcFetch(`/projects/${project}/remoteConfig`, {
|
|
121
|
-
method:
|
|
130
|
+
method: "PUT",
|
|
122
131
|
body: JSON.stringify(body),
|
|
123
|
-
etag: etag ??
|
|
132
|
+
etag: etag ?? "*",
|
|
124
133
|
});
|
|
125
134
|
console.log(` ✅ ${this._params.length} parameter(s) published`);
|
|
126
135
|
if (this._conditions.length)
|
|
@@ -129,7 +138,7 @@ export class FirebaseRemoteConfigBuilder extends BaseBuilder {
|
|
|
129
138
|
}
|
|
130
139
|
async destroy() {
|
|
131
140
|
console.log(`\n🗑️ Destroying Firebase RemoteConfig...`);
|
|
132
|
-
console.log(` ℹ️ RemoteConfig templates cannot be deleted via API
|
|
133
|
-
return { destroyed:
|
|
141
|
+
console.log(` ℹ️ RemoteConfig templates cannot be deleted via API - clear parameters in the Firebase console`);
|
|
142
|
+
return { destroyed: "remoteconfig" };
|
|
134
143
|
}
|
|
135
144
|
}
|