@brewnet/cli 0.0.1 → 0.0.3
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/dist/admin-server-UODBPGWR.js +16 -0
- package/dist/app-manager-FIHPVUP7.js +51 -0
- package/dist/{boilerplate-manager-P6QYUU7Q.js → boilerplate-manager-WEFTHL2O.js} +3 -3
- package/dist/{chunk-DH2VK3YI.js → chunk-54WFZCU6.js} +2 -2
- package/dist/{chunk-4TJMJZMO.js → chunk-AXSHZEB3.js} +63 -1
- package/dist/{chunk-4TJMJZMO.js.map → chunk-AXSHZEB3.js.map} +1 -1
- package/dist/chunk-FZZ3HP2G.js +1151 -0
- package/dist/chunk-FZZ3HP2G.js.map +1 -0
- package/dist/chunk-JIPAYMOA.js +58 -0
- package/dist/chunk-JIPAYMOA.js.map +1 -0
- package/dist/{chunk-SIXBB6JU.js → chunk-Q6UUZR2V.js} +238 -1171
- package/dist/chunk-Q6UUZR2V.js.map +1 -0
- package/dist/{chunk-2VWMDHGI.js → chunk-YAYXULLO.js} +9 -61
- package/dist/chunk-YAYXULLO.js.map +1 -0
- package/dist/{chunk-JFPHGZ6Z.js → chunk-YXFDB5YX.js} +17 -2
- package/dist/chunk-YXFDB5YX.js.map +1 -0
- package/dist/{cloudflare-client-TFT6VCXF.js → cloudflare-client-F2TGQXGS.js} +2 -2
- package/dist/{compose-generator-O7GSIJ2S.js → compose-generator-OFJ2YWMB.js} +4 -2
- package/dist/compose-generator-OFJ2YWMB.js.map +1 -0
- package/dist/index.js +122 -168
- package/dist/index.js.map +1 -1
- package/dist/services/admin-daemon.js +6 -4
- package/dist/services/admin-daemon.js.map +1 -1
- package/package.json +1 -1
- package/dist/admin-server-DQVIEHV3.js +0 -14
- package/dist/chunk-2VWMDHGI.js.map +0 -1
- package/dist/chunk-JFPHGZ6Z.js.map +0 -1
- package/dist/chunk-SIXBB6JU.js.map +0 -1
- /package/dist/{admin-server-DQVIEHV3.js.map → admin-server-UODBPGWR.js.map} +0 -0
- /package/dist/{boilerplate-manager-P6QYUU7Q.js.map → app-manager-FIHPVUP7.js.map} +0 -0
- /package/dist/{cloudflare-client-TFT6VCXF.js.map → boilerplate-manager-WEFTHL2O.js.map} +0 -0
- /package/dist/{chunk-DH2VK3YI.js.map → chunk-54WFZCU6.js.map} +0 -0
- /package/dist/{compose-generator-O7GSIJ2S.js.map → cloudflare-client-F2TGQXGS.js.map} +0 -0
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
createApp,
|
|
4
|
+
deployApp,
|
|
5
|
+
detectBasePath,
|
|
6
|
+
getAppBranches,
|
|
7
|
+
getAppDir,
|
|
8
|
+
getAppGitInfo,
|
|
9
|
+
getDeployHistory,
|
|
10
|
+
getDeploySettings,
|
|
11
|
+
getJobStatus,
|
|
12
|
+
listApps,
|
|
13
|
+
listGiteaRepos,
|
|
14
|
+
removeApp,
|
|
15
|
+
rollbackApp,
|
|
16
|
+
startApp,
|
|
17
|
+
stopApp,
|
|
18
|
+
updateDeploySettings
|
|
19
|
+
} from "./chunk-FZZ3HP2G.js";
|
|
2
20
|
import {
|
|
3
21
|
DomainManager,
|
|
4
|
-
addApp,
|
|
5
22
|
addService,
|
|
6
|
-
appendDeployHistory,
|
|
7
23
|
createBackup,
|
|
8
24
|
getLogStats,
|
|
9
25
|
listBackups,
|
|
10
26
|
queryLogs,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
removeApp,
|
|
14
|
-
removeService,
|
|
15
|
-
updateApp
|
|
16
|
-
} from "./chunk-2VWMDHGI.js";
|
|
27
|
+
removeService
|
|
28
|
+
} from "./chunk-YAYXULLO.js";
|
|
17
29
|
import {
|
|
18
30
|
verifyToken
|
|
19
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-YXFDB5YX.js";
|
|
20
32
|
import {
|
|
21
33
|
SERVICE_REGISTRY,
|
|
22
34
|
getServiceDefinition
|
|
23
|
-
} from "./chunk-
|
|
35
|
+
} from "./chunk-AXSHZEB3.js";
|
|
24
36
|
import {
|
|
25
37
|
getLastProject,
|
|
26
38
|
loadState,
|
|
@@ -33,1099 +45,13 @@ import {
|
|
|
33
45
|
// src/services/admin-server.ts
|
|
34
46
|
import { createServer } from "http";
|
|
35
47
|
import { createConnection } from "net";
|
|
36
|
-
import { join
|
|
37
|
-
import { existsSync
|
|
48
|
+
import { join, resolve, extname } from "path";
|
|
49
|
+
import { existsSync, readFileSync, writeFileSync, statSync, createReadStream } from "fs";
|
|
38
50
|
import { fileURLToPath } from "url";
|
|
39
|
-
import { homedir as homedir2 } from "os";
|
|
40
|
-
import Dockerode from "dockerode";
|
|
41
|
-
|
|
42
|
-
// src/services/app-manager.ts
|
|
43
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, readdirSync } from "fs";
|
|
44
|
-
import { join } from "path";
|
|
45
51
|
import { homedir } from "os";
|
|
46
|
-
import
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// src/services/gitea-client.ts
|
|
50
|
-
import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync, unlinkSync } from "fs";
|
|
51
|
-
import { dirname } from "path";
|
|
52
|
-
import { execSync } from "child_process";
|
|
53
|
-
var GiteaClient = class {
|
|
54
|
-
config;
|
|
55
|
-
constructor(config) {
|
|
56
|
-
this.config = config;
|
|
57
|
-
}
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
// Token management
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
/**
|
|
62
|
-
* Create a Gitea API token via Basic Auth.
|
|
63
|
-
* If the admin account has mustChangePassword=true (403), auto-fixes via docker exec and retries.
|
|
64
|
-
* Saves the token to tokenPath on success.
|
|
65
|
-
*/
|
|
66
|
-
async _createToken() {
|
|
67
|
-
const { tokenPath, baseUrl, username, password } = this.config;
|
|
68
|
-
const basic = Buffer.from(`${username}:${password}`).toString("base64");
|
|
69
|
-
const makeRequest = () => fetch(`${baseUrl}/api/v1/users/${username}/tokens`, {
|
|
70
|
-
method: "POST",
|
|
71
|
-
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/json" },
|
|
72
|
-
body: JSON.stringify({
|
|
73
|
-
name: `brewnet-${Date.now()}`,
|
|
74
|
-
scopes: ["write:repository", "read:repository", "write:user", "read:user"]
|
|
75
|
-
}),
|
|
76
|
-
signal: AbortSignal.timeout(8e3)
|
|
77
|
-
});
|
|
78
|
-
let res = await makeRequest();
|
|
79
|
-
let wasFixed = false;
|
|
80
|
-
if (!res.ok) {
|
|
81
|
-
const body = await res.text();
|
|
82
|
-
if (res.status === 403 && body.includes("must change")) {
|
|
83
|
-
try {
|
|
84
|
-
execSync(
|
|
85
|
-
`docker exec -u git brewnet-gitea gitea admin user change-password --username ${username} --password ${password} --must-change-password=false`,
|
|
86
|
-
{ stdio: "pipe" }
|
|
87
|
-
);
|
|
88
|
-
} catch (e) {
|
|
89
|
-
const stderr = e.stderr?.toString().trim() ?? String(e);
|
|
90
|
-
throw new Error(
|
|
91
|
-
`Gitea admin requires password change \u2014 auto-fix failed:
|
|
92
|
-
${stderr}
|
|
93
|
-
Manual fix: docker exec -u git brewnet-gitea gitea admin user change-password --username ${username} --password <password> --must-change-password=false`
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
wasFixed = true;
|
|
97
|
-
res = await makeRequest();
|
|
98
|
-
if (!res.ok) {
|
|
99
|
-
throw new Error(
|
|
100
|
-
`Gitea token creation failed after auto-fix: ${res.status} ${await res.text()}`
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
} else {
|
|
104
|
-
throw new Error(`Gitea token creation failed: ${res.status} ${body}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
const data = await res.json();
|
|
108
|
-
mkdirSync(dirname(tokenPath), { recursive: true });
|
|
109
|
-
writeFileSync(tokenPath, data.sha1, "utf-8");
|
|
110
|
-
chmodSync(tokenPath, 384);
|
|
111
|
-
return { wasFixed };
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Explicit setup step — call once before any API operations.
|
|
115
|
-
* Validates any cached token; deletes and re-creates if stale (401).
|
|
116
|
-
* Returns what happened so the caller can surface it in job step logs.
|
|
117
|
-
*/
|
|
118
|
-
async prepare() {
|
|
119
|
-
const { tokenPath, baseUrl } = this.config;
|
|
120
|
-
if (existsSync(tokenPath)) {
|
|
121
|
-
const token = readFileSync(tokenPath, "utf-8").trim();
|
|
122
|
-
try {
|
|
123
|
-
const check = await fetch(`${baseUrl}/api/v1/user`, {
|
|
124
|
-
headers: { Authorization: `token ${token}` },
|
|
125
|
-
signal: AbortSignal.timeout(8e3)
|
|
126
|
-
});
|
|
127
|
-
if (check.status !== 401) {
|
|
128
|
-
return { autoFixed: false, message: "token cached" };
|
|
129
|
-
}
|
|
130
|
-
unlinkSync(tokenPath);
|
|
131
|
-
} catch {
|
|
132
|
-
return { autoFixed: false, message: "token cached (network check skipped)" };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
const { wasFixed } = await this._createToken();
|
|
136
|
-
return {
|
|
137
|
-
autoFixed: wasFixed,
|
|
138
|
-
message: wasFixed ? "mustChangePassword was set \u2014 auto-fixed via docker exec; token created" : "token created"
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
async ensureToken() {
|
|
142
|
-
const { tokenPath } = this.config;
|
|
143
|
-
if (existsSync(tokenPath)) {
|
|
144
|
-
return readFileSync(tokenPath, "utf-8").trim();
|
|
145
|
-
}
|
|
146
|
-
await this._createToken();
|
|
147
|
-
return readFileSync(tokenPath, "utf-8").trim();
|
|
148
|
-
}
|
|
149
|
-
async authHeaders() {
|
|
150
|
-
return {
|
|
151
|
-
Authorization: `token ${await this.ensureToken()}`,
|
|
152
|
-
"Content-Type": "application/json"
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// Repository operations
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
async repoExists(name) {
|
|
159
|
-
const { baseUrl, username } = this.config;
|
|
160
|
-
const res = await fetch(
|
|
161
|
-
`${baseUrl}/api/v1/repos/${username}/${name}`,
|
|
162
|
-
{ headers: await this.authHeaders() }
|
|
163
|
-
);
|
|
164
|
-
return res.status === 200;
|
|
165
|
-
}
|
|
166
|
-
/** Returns true if the repo exists but has no commits (empty: true from Gitea API). */
|
|
167
|
-
async repoIsEmpty(name) {
|
|
168
|
-
const { baseUrl, username } = this.config;
|
|
169
|
-
const res = await fetch(
|
|
170
|
-
`${baseUrl}/api/v1/repos/${username}/${name}`,
|
|
171
|
-
{ headers: await this.authHeaders() }
|
|
172
|
-
);
|
|
173
|
-
if (res.status !== 200) return false;
|
|
174
|
-
const data = await res.json();
|
|
175
|
-
return data.empty === true;
|
|
176
|
-
}
|
|
177
|
-
/** Creates a private repo and returns the clone URL. */
|
|
178
|
-
async createRepo(name, description = "") {
|
|
179
|
-
const { baseUrl } = this.config;
|
|
180
|
-
const res = await fetch(`${baseUrl}/api/v1/user/repos`, {
|
|
181
|
-
method: "POST",
|
|
182
|
-
headers: await this.authHeaders(),
|
|
183
|
-
body: JSON.stringify({ name, description, private: false, auto_init: false })
|
|
184
|
-
});
|
|
185
|
-
if (!res.ok) {
|
|
186
|
-
const body = await res.text();
|
|
187
|
-
if (res.status === 409) {
|
|
188
|
-
const existing = await fetch(`${baseUrl}/api/v1/repos/${this.config.username}/${name}`, {
|
|
189
|
-
headers: await this.authHeaders()
|
|
190
|
-
});
|
|
191
|
-
if (existing.ok) {
|
|
192
|
-
const data2 = await existing.json();
|
|
193
|
-
return data2.clone_url;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
if (res.status === 500 && body.includes("files already exist")) {
|
|
197
|
-
await this.deleteRepo(name).catch(() => {
|
|
198
|
-
});
|
|
199
|
-
const retry = await fetch(`${baseUrl}/api/v1/user/repos`, {
|
|
200
|
-
method: "POST",
|
|
201
|
-
headers: await this.authHeaders(),
|
|
202
|
-
body: JSON.stringify({ name, description, private: false, auto_init: false })
|
|
203
|
-
});
|
|
204
|
-
if (!retry.ok) {
|
|
205
|
-
throw new Error(`Gitea createRepo retry failed: ${retry.status} ${await retry.text()}`);
|
|
206
|
-
}
|
|
207
|
-
const retryData = await retry.json();
|
|
208
|
-
return retryData.clone_url;
|
|
209
|
-
}
|
|
210
|
-
throw new Error(`Gitea createRepo failed: ${res.status} ${body}`);
|
|
211
|
-
}
|
|
212
|
-
const data = await res.json();
|
|
213
|
-
return data.clone_url;
|
|
214
|
-
}
|
|
215
|
-
/** Patch a repo from private to public visibility. No-op if already public. */
|
|
216
|
-
async makeRepoPublic(name) {
|
|
217
|
-
const { baseUrl, username } = this.config;
|
|
218
|
-
const res = await fetch(`${baseUrl}/api/v1/repos/${username}/${name}`, {
|
|
219
|
-
method: "PATCH",
|
|
220
|
-
headers: await this.authHeaders(),
|
|
221
|
-
body: JSON.stringify({ private: false })
|
|
222
|
-
});
|
|
223
|
-
if (!res.ok) throw new Error(`Gitea makeRepoPublic failed: ${res.status} ${await res.text()}`);
|
|
224
|
-
}
|
|
225
|
-
async deleteRepo(name) {
|
|
226
|
-
const { baseUrl, username } = this.config;
|
|
227
|
-
await fetch(`${baseUrl}/api/v1/repos/${username}/${name}`, {
|
|
228
|
-
method: "DELETE",
|
|
229
|
-
headers: await this.authHeaders()
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
/** Returns all repos accessible to the authenticated user. */
|
|
233
|
-
async listRepos() {
|
|
234
|
-
const { baseUrl } = this.config;
|
|
235
|
-
const res = await fetch(`${baseUrl}/api/v1/user/repos`, {
|
|
236
|
-
headers: await this.authHeaders(),
|
|
237
|
-
signal: AbortSignal.timeout(8e3)
|
|
238
|
-
});
|
|
239
|
-
if (!res.ok) {
|
|
240
|
-
throw new Error(`Gitea listRepos failed: ${res.status} ${await res.text()}`);
|
|
241
|
-
}
|
|
242
|
-
return await res.json();
|
|
243
|
-
}
|
|
244
|
-
/** Fetch a single repo's detail (includes default_branch, ssh_url). */
|
|
245
|
-
async getRepo(name) {
|
|
246
|
-
const { baseUrl, username } = this.config;
|
|
247
|
-
const res = await fetch(`${baseUrl}/api/v1/repos/${username}/${name}`, {
|
|
248
|
-
headers: await this.authHeaders()
|
|
249
|
-
});
|
|
250
|
-
if (!res.ok) throw new Error(`Gitea getRepo failed: ${res.status} ${await res.text()}`);
|
|
251
|
-
return res.json();
|
|
252
|
-
}
|
|
253
|
-
/** Get the latest commit on a branch. Returns null for empty repos. */
|
|
254
|
-
async getLatestCommit(repoName, branch) {
|
|
255
|
-
const { baseUrl, username } = this.config;
|
|
256
|
-
const res = await fetch(
|
|
257
|
-
`${baseUrl}/api/v1/repos/${username}/${repoName}/commits?sha=${encodeURIComponent(branch)}&limit=1`,
|
|
258
|
-
{ headers: await this.authHeaders() }
|
|
259
|
-
);
|
|
260
|
-
if (!res.ok) return null;
|
|
261
|
-
const commits = await res.json();
|
|
262
|
-
if (!commits.length) return null;
|
|
263
|
-
const c = commits[0];
|
|
264
|
-
return {
|
|
265
|
-
hash: c.sha,
|
|
266
|
-
shortHash: c.sha.slice(0, 7),
|
|
267
|
-
message: c.commit.message.split("\n")[0],
|
|
268
|
-
date: c.commit.committer.date
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
/** Register a push webhook on the repo. */
|
|
272
|
-
async createWebhook(repoName, webhookUrl, secret) {
|
|
273
|
-
const { baseUrl, username } = this.config;
|
|
274
|
-
const res = await fetch(`${baseUrl}/api/v1/repos/${username}/${repoName}/hooks`, {
|
|
275
|
-
method: "POST",
|
|
276
|
-
headers: await this.authHeaders(),
|
|
277
|
-
body: JSON.stringify({
|
|
278
|
-
type: "gitea",
|
|
279
|
-
config: { url: webhookUrl, content_type: "json", secret },
|
|
280
|
-
events: ["push"],
|
|
281
|
-
active: true
|
|
282
|
-
})
|
|
283
|
-
});
|
|
284
|
-
if (!res.ok) throw new Error(`Gitea createWebhook failed: ${res.status} ${await res.text()}`);
|
|
285
|
-
}
|
|
286
|
-
/** Returns branch names for a repo. Falls back to empty array on error. */
|
|
287
|
-
async listBranches(repoName) {
|
|
288
|
-
const { baseUrl, username } = this.config;
|
|
289
|
-
const res = await fetch(
|
|
290
|
-
`${baseUrl}/api/v1/repos/${username}/${repoName}/branches?limit=50`,
|
|
291
|
-
{ headers: await this.authHeaders(), signal: AbortSignal.timeout(8e3) }
|
|
292
|
-
);
|
|
293
|
-
if (!res.ok) return [];
|
|
294
|
-
const data = await res.json();
|
|
295
|
-
return data.map((b) => b.name);
|
|
296
|
-
}
|
|
297
|
-
/** URL suitable for git remote add — includes credentials in URL (stored in .git/config which is chmod 600). */
|
|
298
|
-
authedCloneUrl(cloneUrl) {
|
|
299
|
-
const { username, password } = this.config;
|
|
300
|
-
const encUser = encodeURIComponent(username);
|
|
301
|
-
const encPass = encodeURIComponent(password);
|
|
302
|
-
return cloneUrl.replace("http://", `http://${encUser}:${encPass}@`);
|
|
303
|
-
}
|
|
304
|
-
};
|
|
305
|
-
|
|
306
|
-
// src/services/app-manager.ts
|
|
307
|
-
var BREWNET_DIR = join(homedir(), ".brewnet");
|
|
308
|
-
var GITEA_TOKEN_PATH = join(BREWNET_DIR, "gitea-token");
|
|
309
|
-
var DEPLOY_HISTORY_PATH = join(BREWNET_DIR, "deploy-history.json");
|
|
310
|
-
var jobs = /* @__PURE__ */ new Map();
|
|
311
|
-
function resolveAppsJsonPath() {
|
|
312
|
-
return join(BREWNET_DIR, "apps.json");
|
|
313
|
-
}
|
|
314
|
-
function readDotEnvValue(envPath, key) {
|
|
315
|
-
if (!existsSync2(envPath)) return "";
|
|
316
|
-
const lines = readFileSync2(envPath, "utf-8").split("\n");
|
|
317
|
-
for (const line of lines) {
|
|
318
|
-
const trimmed = line.trim();
|
|
319
|
-
if (trimmed.startsWith(`${key}=`)) {
|
|
320
|
-
return trimmed.slice(key.length + 1).trim();
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return "";
|
|
324
|
-
}
|
|
325
|
-
var _boilerplateRegistered = false;
|
|
326
|
-
async function listApps() {
|
|
327
|
-
const appsJson = resolveAppsJsonPath();
|
|
328
|
-
const apps = readApps(appsJson);
|
|
329
|
-
if (_boilerplateRegistered) return apps;
|
|
330
|
-
_boilerplateRegistered = true;
|
|
331
|
-
try {
|
|
332
|
-
const ctx = resolveContext();
|
|
333
|
-
const bpPath = join(ctx.projectPath, ".brewnet-boilerplate.json");
|
|
334
|
-
if (existsSync2(bpPath)) {
|
|
335
|
-
const raw = JSON.parse(readFileSync2(bpPath, "utf-8"));
|
|
336
|
-
const bpMetas = Array.isArray(raw) ? raw : [raw];
|
|
337
|
-
let changed = false;
|
|
338
|
-
for (const bp of bpMetas) {
|
|
339
|
-
if (!bp.stackId || !bp.appDir) continue;
|
|
340
|
-
const exists = apps.some((a) => a.appDir === bp.appDir || a.stackId === bp.stackId);
|
|
341
|
-
if (!exists) {
|
|
342
|
-
const port = bp.backendUrl ? parseInt(new URL(bp.backendUrl).port || "8080", 10) : 8080;
|
|
343
|
-
const entry = {
|
|
344
|
-
name: bp.stackId,
|
|
345
|
-
mode: "boilerplate",
|
|
346
|
-
stackId: bp.stackId,
|
|
347
|
-
appDir: bp.appDir,
|
|
348
|
-
lang: bp.lang,
|
|
349
|
-
framework: bp.frameworkId,
|
|
350
|
-
port,
|
|
351
|
-
status: bp.status || "running",
|
|
352
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
353
|
-
};
|
|
354
|
-
apps.push(entry);
|
|
355
|
-
changed = true;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
if (changed) {
|
|
359
|
-
writeFileSync2(appsJson, JSON.stringify(apps, null, 2), "utf-8");
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
} catch {
|
|
363
|
-
}
|
|
364
|
-
return apps;
|
|
365
|
-
}
|
|
366
|
-
function getDeployHistory(appName) {
|
|
367
|
-
const entries = readDeployHistory(DEPLOY_HISTORY_PATH);
|
|
368
|
-
if (!appName) return entries;
|
|
369
|
-
return entries.filter((e) => e.appName === appName);
|
|
370
|
-
}
|
|
371
|
-
async function listGiteaRepos() {
|
|
372
|
-
const ctx = resolveContext();
|
|
373
|
-
const gitea = new GiteaClient({
|
|
374
|
-
baseUrl: ctx.giteaBaseUrl,
|
|
375
|
-
username: ctx.giteaUser,
|
|
376
|
-
password: ctx.giteaPassword,
|
|
377
|
-
tokenPath: GITEA_TOKEN_PATH
|
|
378
|
-
});
|
|
379
|
-
await gitea.prepare();
|
|
380
|
-
return gitea.listRepos();
|
|
381
|
-
}
|
|
382
|
-
function getDeploySettings(appName) {
|
|
383
|
-
const apps = readApps(resolveAppsJsonPath());
|
|
384
|
-
const app = apps.find((a) => a.name === appName);
|
|
385
|
-
const settings = app?.deploySettings;
|
|
386
|
-
return settings ?? { autoDeploy: false, deployBranch: "main" };
|
|
387
|
-
}
|
|
388
|
-
function updateDeploySettings(appName, settings) {
|
|
389
|
-
const appsJson = resolveAppsJsonPath();
|
|
390
|
-
const apps = readApps(appsJson);
|
|
391
|
-
const app = apps.find((a) => a.name === appName);
|
|
392
|
-
if (!app) throw new Error(`App "${appName}" not found`);
|
|
393
|
-
const existing = app.deploySettings ?? { autoDeploy: false, deployBranch: "main" };
|
|
394
|
-
app.deploySettings = { ...existing, ...settings };
|
|
395
|
-
updateApp(appsJson, appName, app);
|
|
396
|
-
}
|
|
397
|
-
async function getAppGitInfo(appName) {
|
|
398
|
-
const ctx = resolveContext();
|
|
399
|
-
const apps = readApps(resolveAppsJsonPath());
|
|
400
|
-
const app = apps.find((a) => a.name === appName);
|
|
401
|
-
if (!app) throw new Error(`App "${appName}" not found`);
|
|
402
|
-
const gitea = new GiteaClient({
|
|
403
|
-
baseUrl: ctx.giteaBaseUrl,
|
|
404
|
-
username: ctx.giteaUser,
|
|
405
|
-
password: ctx.giteaPassword,
|
|
406
|
-
tokenPath: GITEA_TOKEN_PATH
|
|
407
|
-
});
|
|
408
|
-
let branch = "main";
|
|
409
|
-
let latestCommit = null;
|
|
410
|
-
let cloneUrlSsh = `ssh://git@localhost:2222/${ctx.giteaUser}/${appName}.git`;
|
|
411
|
-
try {
|
|
412
|
-
const repo = await gitea.getRepo(appName);
|
|
413
|
-
branch = repo.default_branch || "main";
|
|
414
|
-
cloneUrlSsh = repo.ssh_url || cloneUrlSsh;
|
|
415
|
-
if (repo.private) {
|
|
416
|
-
await gitea.makeRepoPublic(appName).catch((e) => {
|
|
417
|
-
console.warn(`[app-manager] makeRepoPublic failed for ${appName}: ${e instanceof Error ? e.message : String(e)}`);
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
latestCommit = await gitea.getLatestCommit(appName, branch);
|
|
421
|
-
} catch (e) {
|
|
422
|
-
console.warn(`[app-manager] getAppGitInfo Gitea call failed (${appName}): ${e instanceof Error ? e.message : String(e)}`);
|
|
423
|
-
}
|
|
424
|
-
return {
|
|
425
|
-
giteaUrl: `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${appName}`,
|
|
426
|
-
cloneUrlHttp: `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${appName}.git`,
|
|
427
|
-
cloneUrlSsh,
|
|
428
|
-
localPath: app.appDir,
|
|
429
|
-
branch,
|
|
430
|
-
latestCommit
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
async function rollbackApp(appName, commitHash) {
|
|
434
|
-
const job = newJob(appName, ["Checkout", "Build & Start", "Health check"]);
|
|
435
|
-
jobs.set(job.jobId, job);
|
|
436
|
-
setImmediate(() => void _runRollback(job, appName, commitHash));
|
|
437
|
-
return job.jobId;
|
|
438
|
-
}
|
|
439
|
-
async function _runRollback(job, appName, commitHash) {
|
|
440
|
-
try {
|
|
441
|
-
const apps = readApps(resolveAppsJsonPath());
|
|
442
|
-
const app = apps.find((a) => a.name === appName);
|
|
443
|
-
if (!app) throw new Error(`App "${appName}" not found`);
|
|
444
|
-
const target = commitHash || "HEAD~1";
|
|
445
|
-
setStep(job, 0, "running", `git checkout ${target.slice(0, 7)}`);
|
|
446
|
-
await execa("git", ["checkout", target], { cwd: app.appDir });
|
|
447
|
-
setStep(job, 0, "done");
|
|
448
|
-
await _injectQuickTunnelIfNeeded(app.appDir, appName, app.port);
|
|
449
|
-
setStep(job, 1, "running", "docker compose up --build");
|
|
450
|
-
await _dockerComposeUp(app.appDir, job);
|
|
451
|
-
setStep(job, 1, "done", "containers started");
|
|
452
|
-
setStep(job, 2, "running");
|
|
453
|
-
const healthUrl = _buildHealthUrl(app.appDir, app.port);
|
|
454
|
-
setStep(job, 2, "running", `polling ${healthUrl}`);
|
|
455
|
-
await _pollHealth(healthUrl, 12e4, job);
|
|
456
|
-
setStep(job, 2, "done");
|
|
457
|
-
updateApp(resolveAppsJsonPath(), appName, { status: "running" });
|
|
458
|
-
appendDeployHistory(DEPLOY_HISTORY_PATH, {
|
|
459
|
-
appName,
|
|
460
|
-
commitHash,
|
|
461
|
-
commitMessage: `Rollback to ${commitHash.slice(0, 7)}`,
|
|
462
|
-
status: "success",
|
|
463
|
-
deployedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
464
|
-
});
|
|
465
|
-
job.status = "done";
|
|
466
|
-
} catch (err) {
|
|
467
|
-
job.status = "failed";
|
|
468
|
-
job.error = err instanceof Error ? err.message : String(err);
|
|
469
|
-
for (const step of job.steps) {
|
|
470
|
-
if (step.status === "running" || step.status === "pending") step.status = "failed";
|
|
471
|
-
}
|
|
472
|
-
appendDeployHistory(DEPLOY_HISTORY_PATH, {
|
|
473
|
-
appName,
|
|
474
|
-
commitHash,
|
|
475
|
-
commitMessage: `Rollback to ${commitHash.slice(0, 7)}`,
|
|
476
|
-
status: "failed",
|
|
477
|
-
deployedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
async function getAppBranches(appName) {
|
|
482
|
-
const ctx = resolveContext();
|
|
483
|
-
const gitea = new GiteaClient({
|
|
484
|
-
baseUrl: ctx.giteaBaseUrl,
|
|
485
|
-
username: ctx.giteaUser,
|
|
486
|
-
password: ctx.giteaPassword,
|
|
487
|
-
tokenPath: GITEA_TOKEN_PATH
|
|
488
|
-
});
|
|
489
|
-
return gitea.listBranches(appName);
|
|
490
|
-
}
|
|
491
|
-
async function setupWebhook(appName, webhookUrl) {
|
|
492
|
-
const ctx = resolveContext();
|
|
493
|
-
const settings = getDeploySettings(appName);
|
|
494
|
-
const secret = settings.webhookSecret ?? randomBytes(16).toString("hex");
|
|
495
|
-
const gitea = new GiteaClient({
|
|
496
|
-
baseUrl: ctx.giteaBaseUrl,
|
|
497
|
-
username: ctx.giteaUser,
|
|
498
|
-
password: ctx.giteaPassword,
|
|
499
|
-
tokenPath: GITEA_TOKEN_PATH
|
|
500
|
-
});
|
|
501
|
-
await gitea.createWebhook(appName, webhookUrl, secret);
|
|
502
|
-
updateDeploySettings(appName, { webhookSecret: secret });
|
|
503
|
-
}
|
|
504
|
-
async function deployApp(appName) {
|
|
505
|
-
const job = newJob(appName, ["Pull", "Build & Start", "Health check"]);
|
|
506
|
-
jobs.set(job.jobId, job);
|
|
507
|
-
setImmediate(() => void _runDeploy(job, appName));
|
|
508
|
-
return job.jobId;
|
|
509
|
-
}
|
|
510
|
-
async function _runDeploy(job, appName) {
|
|
511
|
-
const apps = readApps(resolveAppsJsonPath());
|
|
512
|
-
const app = apps.find((a) => a.name === appName);
|
|
513
|
-
if (!app) {
|
|
514
|
-
job.status = "failed";
|
|
515
|
-
job.error = `App "${appName}" not found`;
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
try {
|
|
519
|
-
const settings = getDeploySettings(appName);
|
|
520
|
-
setStep(job, 0, "running");
|
|
521
|
-
try {
|
|
522
|
-
const ctx = resolveContext();
|
|
523
|
-
const gitea = new GiteaClient({
|
|
524
|
-
baseUrl: ctx.giteaBaseUrl,
|
|
525
|
-
username: ctx.giteaUser,
|
|
526
|
-
password: ctx.giteaPassword,
|
|
527
|
-
tokenPath: GITEA_TOKEN_PATH
|
|
528
|
-
});
|
|
529
|
-
await gitea.prepare();
|
|
530
|
-
const repoExists = await gitea.repoExists(appName);
|
|
531
|
-
if (!repoExists) {
|
|
532
|
-
appendLog(job, "[pull] Gitea repo not found \u2014 recreating and pushing local code");
|
|
533
|
-
const cloneUrl = await gitea.createRepo(appName, `Brewnet app: ${appName}`);
|
|
534
|
-
const authedUrl = gitea.authedCloneUrl(cloneUrl);
|
|
535
|
-
await execa("git", ["remote", "add", "brewnet", authedUrl], { cwd: app.appDir }).catch(
|
|
536
|
-
() => execa("git", ["remote", "set-url", "brewnet", authedUrl], { cwd: app.appDir })
|
|
537
|
-
);
|
|
538
|
-
await execa("git", ["push", "brewnet", "HEAD:main", "--force"], { cwd: app.appDir });
|
|
539
|
-
appendLog(job, "[pull] Gitea repo recreated and code pushed \u2713");
|
|
540
|
-
} else if (!existsSync2(app.appDir)) {
|
|
541
|
-
appendLog(job, "[pull] appDir missing \u2014 cloning from Gitea");
|
|
542
|
-
const authedUrl = gitea.authedCloneUrl(`${ctx.giteaBaseUrl}/${ctx.giteaUser}/${appName}.git`);
|
|
543
|
-
await execa("git", ["clone", authedUrl, app.appDir]);
|
|
544
|
-
appendLog(job, "[pull] re-cloned from Gitea \u2713");
|
|
545
|
-
} else if (await gitea.repoIsEmpty(appName)) {
|
|
546
|
-
appendLog(job, "[pull] Gitea repo is empty \u2014 pushing local code");
|
|
547
|
-
const isShallow = await execa("git", ["rev-parse", "--is-shallow-repository"], { cwd: app.appDir }).then((r) => r.stdout.trim() === "true").catch(() => false);
|
|
548
|
-
if (isShallow) {
|
|
549
|
-
appendLog(job, "[pull] shallow clone detected \u2014 unshallowing");
|
|
550
|
-
await execa("git", ["fetch", "--unshallow", "origin"], { cwd: app.appDir }).catch(async () => {
|
|
551
|
-
const { reinitGit } = await import("./boilerplate-manager-P6QYUU7Q.js");
|
|
552
|
-
await reinitGit(app.appDir);
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
const authedUrl = gitea.authedCloneUrl(`${ctx.giteaBaseUrl}/${ctx.giteaUser}/${appName}.git`);
|
|
556
|
-
await execa("git", ["remote", "add", "brewnet", authedUrl], { cwd: app.appDir }).catch(
|
|
557
|
-
() => execa("git", ["remote", "set-url", "brewnet", authedUrl], { cwd: app.appDir })
|
|
558
|
-
);
|
|
559
|
-
await execa("git", ["push", "brewnet", "HEAD:main", "--force"], { cwd: app.appDir });
|
|
560
|
-
appendLog(job, "[pull] code pushed to Gitea \u2713");
|
|
561
|
-
} else {
|
|
562
|
-
await execa("git", ["pull", "brewnet", settings.deployBranch], { cwd: app.appDir }).catch((e) => {
|
|
563
|
-
appendLog(job, `[pull] git pull failed (non-critical): ${e instanceof Error ? e.message : String(e)}`);
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
} catch (e) {
|
|
567
|
-
appendLog(job, `[pull] Gitea sync failed (non-critical): ${e instanceof Error ? e.message : String(e)}`);
|
|
568
|
-
}
|
|
569
|
-
setStep(job, 0, "done");
|
|
570
|
-
const hasCompose = existsSync2(join(app.appDir, "docker-compose.yml")) || existsSync2(join(app.appDir, "compose.yml"));
|
|
571
|
-
if (!hasCompose) {
|
|
572
|
-
const projectType = _detectProjectType(app.appDir);
|
|
573
|
-
if (projectType) {
|
|
574
|
-
appendLog(job, `[scaffold] Detected ${projectType} project \u2014 generating Docker config`);
|
|
575
|
-
_scaffoldDockerConfig(app.appDir, appName, app.port, job, projectType);
|
|
576
|
-
} else {
|
|
577
|
-
throw new Error(
|
|
578
|
-
"This project has no docker-compose.yml or Dockerfile. Add a Dockerfile and docker-compose.yml to deploy, or use a Brewnet boilerplate."
|
|
579
|
-
);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
await _injectQuickTunnelIfNeeded(app.appDir, appName, app.port);
|
|
583
|
-
setStep(job, 1, "running", "docker compose up --build");
|
|
584
|
-
await _dockerComposeUp(app.appDir, job);
|
|
585
|
-
setStep(job, 1, "done", "containers started");
|
|
586
|
-
setStep(job, 2, "running");
|
|
587
|
-
const healthUrlDeploy = _buildHealthUrl(app.appDir, app.port);
|
|
588
|
-
setStep(job, 2, "running", `polling ${healthUrlDeploy}`);
|
|
589
|
-
await _pollHealth(healthUrlDeploy, 12e4, job);
|
|
590
|
-
setStep(job, 2, "done");
|
|
591
|
-
updateApp(resolveAppsJsonPath(), appName, { status: "running" });
|
|
592
|
-
const headHash = await execa("git", ["rev-parse", "HEAD"], { cwd: app.appDir }).then((r) => r.stdout.trim()).catch(() => "");
|
|
593
|
-
const headMsg = await execa("git", ["log", "-1", "--format=%s"], { cwd: app.appDir }).then((r) => r.stdout.trim()).catch(() => "Manual deploy");
|
|
594
|
-
appendDeployHistory(DEPLOY_HISTORY_PATH, {
|
|
595
|
-
appName,
|
|
596
|
-
commitHash: headHash,
|
|
597
|
-
commitMessage: headMsg,
|
|
598
|
-
status: "success",
|
|
599
|
-
deployedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
600
|
-
});
|
|
601
|
-
job.status = "done";
|
|
602
|
-
} catch (err) {
|
|
603
|
-
job.status = "failed";
|
|
604
|
-
job.error = err instanceof Error ? err.message : String(err);
|
|
605
|
-
for (const step of job.steps) {
|
|
606
|
-
if (step.status === "running" || step.status === "pending") step.status = "failed";
|
|
607
|
-
}
|
|
608
|
-
const headHashFail = await execa("git", ["rev-parse", "HEAD"], { cwd: app.appDir }).then((r) => r.stdout.trim()).catch(() => "");
|
|
609
|
-
appendDeployHistory(DEPLOY_HISTORY_PATH, {
|
|
610
|
-
appName,
|
|
611
|
-
commitHash: headHashFail,
|
|
612
|
-
commitMessage: "Manual deploy",
|
|
613
|
-
status: "failed",
|
|
614
|
-
deployedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
function getAppDir(appName) {
|
|
619
|
-
const apps = readApps(resolveAppsJsonPath());
|
|
620
|
-
return apps.find((a) => a.name === appName)?.appDir;
|
|
621
|
-
}
|
|
622
|
-
function getJobStatus(jobId) {
|
|
623
|
-
return jobs.get(jobId);
|
|
624
|
-
}
|
|
625
|
-
function newJob(appName, stepLabels) {
|
|
626
|
-
return {
|
|
627
|
-
jobId: randomBytes(8).toString("hex"),
|
|
628
|
-
appName,
|
|
629
|
-
status: "running",
|
|
630
|
-
steps: stepLabels.map((label) => ({ label, status: "pending" }))
|
|
631
|
-
};
|
|
632
|
-
}
|
|
633
|
-
function setStep(job, index, status, message) {
|
|
634
|
-
const step = job.steps[index];
|
|
635
|
-
if (step) {
|
|
636
|
-
step.status = status;
|
|
637
|
-
if (message) step.message = message;
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
function appendLog(job, line) {
|
|
641
|
-
if (!job.logs) job.logs = [];
|
|
642
|
-
job.logs.push(line);
|
|
643
|
-
if (job.logs.length > 200) job.logs.splice(0, job.logs.length - 200);
|
|
644
|
-
}
|
|
645
|
-
function readBoilerplateMeta(projectPath) {
|
|
646
|
-
const p = join(projectPath, ".brewnet-boilerplate.json");
|
|
647
|
-
if (!existsSync2(p)) return [];
|
|
648
|
-
try {
|
|
649
|
-
const raw = JSON.parse(readFileSync2(p, "utf-8"));
|
|
650
|
-
return Array.isArray(raw) ? raw : [raw];
|
|
651
|
-
} catch {
|
|
652
|
-
return [];
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
function resolveContext() {
|
|
656
|
-
const last = getLastProject();
|
|
657
|
-
const state = loadState(last ?? "");
|
|
658
|
-
const raw = state?.projectPath ?? process.cwd();
|
|
659
|
-
const projectPath = raw.startsWith("~") ? join(homedir(), raw.slice(1)) : raw;
|
|
660
|
-
const envPath = join(projectPath, ".env");
|
|
661
|
-
const giteaUser = readDotEnvValue(envPath, "GITEA_ADMIN_USER") || state?.admin?.username || "admin";
|
|
662
|
-
const secretsPath = join(projectPath, "secrets", "admin_password");
|
|
663
|
-
const secretsPassword = existsSync2(secretsPath) ? readFileSync2(secretsPath, "utf-8").trim() : "";
|
|
664
|
-
const giteaPassword = secretsPassword || readDotEnvValue(envPath, "GITEA_ADMIN_PASSWORD") || state?.admin?.password || "";
|
|
665
|
-
const tunnelMode = state?.domain?.cloudflare?.tunnelMode ?? "";
|
|
666
|
-
const gitPort = state?.servers?.gitServer?.port ?? 3e3;
|
|
667
|
-
const giteaBaseUrl = tunnelMode === "quick" ? "http://localhost/git" : `http://localhost:${gitPort}`;
|
|
668
|
-
return { projectPath, giteaBaseUrl, giteaUser, giteaPassword };
|
|
669
|
-
}
|
|
670
|
-
async function _injectQuickTunnelIfNeeded(appDir, appName, port) {
|
|
671
|
-
try {
|
|
672
|
-
const last = getLastProject();
|
|
673
|
-
const state = loadState(last ?? "");
|
|
674
|
-
if (state?.domain?.cloudflare?.tunnelMode !== "quick") return;
|
|
675
|
-
const { injectTraefikForQuickTunnel } = await import("./boilerplate-manager-P6QYUU7Q.js");
|
|
676
|
-
injectTraefikForQuickTunnel(appDir, appName, port);
|
|
677
|
-
} catch (err) {
|
|
678
|
-
console.error(`[Quick Tunnel] Failed to inject Traefik labels for ${appName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
function _detectProjectType(dir) {
|
|
682
|
-
try {
|
|
683
|
-
if (existsSync2(join(dir, "next.config.ts")) || existsSync2(join(dir, "next.config.mjs")) || existsSync2(join(dir, "next.config.js"))) return "nextjs";
|
|
684
|
-
if (existsSync2(join(dir, "package.json"))) return "nodejs";
|
|
685
|
-
if (existsSync2(join(dir, "requirements.txt")) || existsSync2(join(dir, "pyproject.toml"))) return "python";
|
|
686
|
-
if (existsSync2(join(dir, "go.mod"))) return "go";
|
|
687
|
-
if (existsSync2(join(dir, "Cargo.toml"))) return "rust";
|
|
688
|
-
if (existsSync2(join(dir, "pom.xml")) || existsSync2(join(dir, "build.gradle")) || existsSync2(join(dir, "build.gradle.kts"))) return "java";
|
|
689
|
-
if (existsSync2(join(dir, "index.html"))) return "static";
|
|
690
|
-
if (readdirSync(dir).some((f) => f.endsWith(".html"))) return "static";
|
|
691
|
-
} catch {
|
|
692
|
-
}
|
|
693
|
-
return null;
|
|
694
|
-
}
|
|
695
|
-
function _scaffoldDockerConfig(dir, _appName, port, job, detectedType) {
|
|
696
|
-
const type = detectedType || _detectProjectType(dir);
|
|
697
|
-
if (!type) throw new Error(`Cannot auto-detect project type in ${dir}. Add a Dockerfile and docker-compose.yml manually.`);
|
|
698
|
-
if (job && !detectedType) appendLog(job, `[scaffold] Detected ${type} project \u2014 generating Docker config`);
|
|
699
|
-
let dockerfile = "";
|
|
700
|
-
switch (type) {
|
|
701
|
-
case "nextjs":
|
|
702
|
-
dockerfile = [
|
|
703
|
-
"FROM node:22-alpine",
|
|
704
|
-
"WORKDIR /app",
|
|
705
|
-
"COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./",
|
|
706
|
-
"RUN npm install --legacy-peer-deps 2>/dev/null || yarn install 2>/dev/null || true",
|
|
707
|
-
"COPY . .",
|
|
708
|
-
"RUN npm run build",
|
|
709
|
-
"ENV PORT=3000 HOSTNAME=0.0.0.0",
|
|
710
|
-
"EXPOSE 3000",
|
|
711
|
-
'CMD ["npm", "start"]'
|
|
712
|
-
].join("\n");
|
|
713
|
-
break;
|
|
714
|
-
case "nodejs":
|
|
715
|
-
dockerfile = [
|
|
716
|
-
"FROM node:22-alpine",
|
|
717
|
-
"WORKDIR /app",
|
|
718
|
-
"COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./",
|
|
719
|
-
"RUN npm install --legacy-peer-deps || true",
|
|
720
|
-
"COPY . .",
|
|
721
|
-
"RUN npm run build 2>/dev/null || true",
|
|
722
|
-
"EXPOSE " + port,
|
|
723
|
-
'CMD ["npm", "start"]'
|
|
724
|
-
].join("\n");
|
|
725
|
-
break;
|
|
726
|
-
case "python":
|
|
727
|
-
dockerfile = [
|
|
728
|
-
"FROM python:3.13-slim",
|
|
729
|
-
"WORKDIR /app",
|
|
730
|
-
"COPY requirements.txt* pyproject.toml* ./",
|
|
731
|
-
"RUN pip install --no-cache-dir -r requirements.txt 2>/dev/null || pip install --no-cache-dir . 2>/dev/null || true",
|
|
732
|
-
"COPY . .",
|
|
733
|
-
"EXPOSE " + port,
|
|
734
|
-
'CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "' + port + '"]'
|
|
735
|
-
].join("\n");
|
|
736
|
-
break;
|
|
737
|
-
case "go":
|
|
738
|
-
dockerfile = [
|
|
739
|
-
"FROM golang:1.22-alpine AS builder",
|
|
740
|
-
"WORKDIR /app",
|
|
741
|
-
"COPY go.mod go.sum* ./",
|
|
742
|
-
"RUN go mod download",
|
|
743
|
-
"COPY . .",
|
|
744
|
-
"RUN CGO_ENABLED=0 go build -o server .",
|
|
745
|
-
"",
|
|
746
|
-
"FROM alpine",
|
|
747
|
-
"WORKDIR /app",
|
|
748
|
-
"COPY --from=builder /app/server .",
|
|
749
|
-
"EXPOSE " + port,
|
|
750
|
-
'CMD ["./server"]'
|
|
751
|
-
].join("\n");
|
|
752
|
-
break;
|
|
753
|
-
case "rust":
|
|
754
|
-
dockerfile = [
|
|
755
|
-
"FROM rust:1.88 AS builder",
|
|
756
|
-
"WORKDIR /app",
|
|
757
|
-
"COPY . .",
|
|
758
|
-
"RUN cargo build --release",
|
|
759
|
-
"",
|
|
760
|
-
"FROM debian:bookworm-slim",
|
|
761
|
-
"WORKDIR /app",
|
|
762
|
-
"COPY --from=builder /app/target/release/* /app/ 2>/dev/null || true",
|
|
763
|
-
"EXPOSE " + port,
|
|
764
|
-
'CMD ["./app"]'
|
|
765
|
-
].join("\n");
|
|
766
|
-
break;
|
|
767
|
-
case "java":
|
|
768
|
-
dockerfile = [
|
|
769
|
-
"FROM gradle:8.12-jdk21 AS builder",
|
|
770
|
-
"WORKDIR /app",
|
|
771
|
-
"COPY . .",
|
|
772
|
-
"RUN gradle build -x test 2>/dev/null || ./gradlew build -x test 2>/dev/null || mvn package -DskipTests 2>/dev/null || true",
|
|
773
|
-
"",
|
|
774
|
-
"FROM eclipse-temurin:21-jre-alpine",
|
|
775
|
-
"WORKDIR /app",
|
|
776
|
-
"COPY --from=builder /app/build/libs/*.jar app.jar 2>/dev/null || true",
|
|
777
|
-
"COPY --from=builder /app/target/*.jar app.jar 2>/dev/null || true",
|
|
778
|
-
"EXPOSE " + port,
|
|
779
|
-
'CMD ["java", "-jar", "app.jar"]'
|
|
780
|
-
].join("\n");
|
|
781
|
-
break;
|
|
782
|
-
case "static":
|
|
783
|
-
dockerfile = [
|
|
784
|
-
"FROM nginx:1.27-alpine",
|
|
785
|
-
"COPY . /usr/share/nginx/html/",
|
|
786
|
-
"EXPOSE 80"
|
|
787
|
-
].join("\n");
|
|
788
|
-
break;
|
|
789
|
-
}
|
|
790
|
-
const internalPort = type === "nextjs" ? 3e3 : type === "static" ? 80 : port;
|
|
791
|
-
const compose = [
|
|
792
|
-
"services:",
|
|
793
|
-
" backend:",
|
|
794
|
-
" build: .",
|
|
795
|
-
" ports:",
|
|
796
|
-
` - "${port}:${internalPort}"`,
|
|
797
|
-
" restart: unless-stopped",
|
|
798
|
-
" healthcheck:",
|
|
799
|
-
` test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:${internalPort}/"]`,
|
|
800
|
-
" interval: 10s",
|
|
801
|
-
" timeout: 5s",
|
|
802
|
-
" retries: 5"
|
|
803
|
-
].join("\n");
|
|
804
|
-
if (!existsSync2(join(dir, "Dockerfile"))) {
|
|
805
|
-
writeFileSync2(join(dir, "Dockerfile"), dockerfile, "utf-8");
|
|
806
|
-
if (job) appendLog(job, "[scaffold] Generated Dockerfile");
|
|
807
|
-
}
|
|
808
|
-
writeFileSync2(join(dir, "docker-compose.yml"), compose, "utf-8");
|
|
809
|
-
if (job) appendLog(job, "[scaffold] Generated docker-compose.yml");
|
|
810
|
-
if (!existsSync2(join(dir, ".dockerignore"))) {
|
|
811
|
-
writeFileSync2(join(dir, ".dockerignore"), "node_modules\n.next\n.git\n*.md\n", "utf-8");
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
function ensureComposeFile(dir, appName, port, job) {
|
|
815
|
-
if (existsSync2(join(dir, "docker-compose.yml")) || existsSync2(join(dir, "compose.yml"))) return;
|
|
816
|
-
_scaffoldDockerConfig(dir, appName, port, job);
|
|
817
|
-
}
|
|
818
|
-
function _resolveBackendPort(appDir, fallbackPort) {
|
|
819
|
-
const envPath = join(appDir, ".env");
|
|
820
|
-
const val = readDotEnvValue(envPath, "BACKEND_PORT");
|
|
821
|
-
const parsed = val ? parseInt(val, 10) : NaN;
|
|
822
|
-
return isNaN(parsed) ? fallbackPort : parsed;
|
|
823
|
-
}
|
|
824
|
-
function detectBasePath(appDir) {
|
|
825
|
-
for (const name of ["next.config.ts", "next.config.mjs", "next.config.js"]) {
|
|
826
|
-
const p = join(appDir, name);
|
|
827
|
-
if (existsSync2(p)) {
|
|
828
|
-
const content = readFileSync2(p, "utf-8");
|
|
829
|
-
const match = content.match(/basePath\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
830
|
-
if (match) return match[1];
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
return "";
|
|
834
|
-
}
|
|
835
|
-
function _buildHealthUrl(appDir, fallbackPort) {
|
|
836
|
-
const healthPort = _resolveBackendPort(appDir, fallbackPort);
|
|
837
|
-
const basePath = detectBasePath(appDir);
|
|
838
|
-
const isBoilerplate = existsSync2(join(appDir, ".env.example")) && readFileSync2(join(appDir, ".env.example"), "utf-8").includes("STACK_LANG");
|
|
839
|
-
const hasHealthRoute = existsSync2(join(appDir, "src", "app", "health")) || existsSync2(join(appDir, "backend", "src"));
|
|
840
|
-
const healthPath = isBoilerplate || hasHealthRoute ? "/health" : "/";
|
|
841
|
-
return `http://127.0.0.1:${healthPort}${basePath}${healthPath}`;
|
|
842
|
-
}
|
|
843
|
-
async function _pollHealth(url, maxMs = 12e4, job) {
|
|
844
|
-
const deadline = Date.now() + maxMs;
|
|
845
|
-
let attempt = 0;
|
|
846
|
-
while (Date.now() < deadline) {
|
|
847
|
-
attempt++;
|
|
848
|
-
try {
|
|
849
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
|
|
850
|
-
if (res.ok) {
|
|
851
|
-
if (job) appendLog(job, `[health] \u2713 ${url} \u2192 ${res.status} (attempt ${attempt})`);
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
if (job) appendLog(job, `[health] ${url} \u2192 ${res.status} (attempt ${attempt})`);
|
|
855
|
-
} catch {
|
|
856
|
-
if (job && attempt % 3 === 1) appendLog(job, `[health] waiting... ${url} (attempt ${attempt})`);
|
|
857
|
-
}
|
|
858
|
-
await new Promise((r) => setTimeout(r, 3e3));
|
|
859
|
-
}
|
|
860
|
-
if (job) appendLog(job, `[health] \u2717 timeout after ${maxMs / 1e3}s`);
|
|
861
|
-
throw new Error(`Health check timed out after ${maxMs / 1e3}s: ${url}`);
|
|
862
|
-
}
|
|
863
|
-
async function _dockerComposeUp(cwd, job) {
|
|
864
|
-
appendLog(job, `[docker] $ docker compose up -d --build`);
|
|
865
|
-
appendLog(job, `[docker] cwd: ${cwd}`);
|
|
866
|
-
const proc = execa("docker", ["compose", "up", "-d", "--build"], { cwd, reject: false });
|
|
867
|
-
proc.stdout?.on("data", (chunk) => {
|
|
868
|
-
for (const line of chunk.toString().split("\n").filter(Boolean)) {
|
|
869
|
-
appendLog(job, `[docker] ${line}`);
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
proc.stderr?.on("data", (chunk) => {
|
|
873
|
-
for (const line of chunk.toString().split("\n").filter(Boolean)) {
|
|
874
|
-
appendLog(job, `[docker] ${line}`);
|
|
875
|
-
}
|
|
876
|
-
});
|
|
877
|
-
const result = await proc;
|
|
878
|
-
if (result.exitCode !== 0) {
|
|
879
|
-
appendLog(job, `[docker] \u2717 exit code ${result.exitCode}`);
|
|
880
|
-
throw new Error(`Command failed with exit code ${result.exitCode}: docker compose up -d --build
|
|
881
|
-
${result.stderr}`);
|
|
882
|
-
}
|
|
883
|
-
appendLog(job, `[docker] \u2713 containers started`);
|
|
884
|
-
}
|
|
885
|
-
async function createApp(opts) {
|
|
886
|
-
const job = newJob(opts.appName, ["Validating", "Gitea setup", "Gitea repo", "Git push", "Docker up", "Health check"]);
|
|
887
|
-
jobs.set(job.jobId, job);
|
|
888
|
-
setImmediate(() => void _runCreateApp(job, opts));
|
|
889
|
-
return job.jobId;
|
|
890
|
-
}
|
|
891
|
-
async function _runCreateApp(job, opts) {
|
|
892
|
-
try {
|
|
893
|
-
const ctx = resolveContext();
|
|
894
|
-
const appsJson = resolveAppsJsonPath();
|
|
895
|
-
setStep(job, 0, "running");
|
|
896
|
-
if (opts.mode === "boilerplate") {
|
|
897
|
-
if (!opts.stackId) throw new Error("stackId is required for boilerplate mode");
|
|
898
|
-
const metas = readBoilerplateMeta(ctx.projectPath);
|
|
899
|
-
const meta = metas.find((m) => m.stackId === opts.stackId);
|
|
900
|
-
if (meta) {
|
|
901
|
-
opts._meta = meta;
|
|
902
|
-
} else {
|
|
903
|
-
appendLog(job, `[info] Stack "${opts.stackId}" not installed locally \u2014 cloning fresh from catalog`);
|
|
904
|
-
opts._resolvedStackId = opts.stackId;
|
|
905
|
-
}
|
|
906
|
-
} else if (opts.mode === "git-clone") {
|
|
907
|
-
if (!opts.gitUrl) throw new Error("gitUrl is required for Git Clone mode");
|
|
908
|
-
} else if (opts.mode === "new-project") {
|
|
909
|
-
const { resolveStackId } = await import("./frameworks-Z7VXDGP4.js");
|
|
910
|
-
const stackId = resolveStackId(opts.language ?? "nodejs", opts.frameworkId ?? "express");
|
|
911
|
-
if (!stackId) throw new Error(`Unknown stack: ${opts.language}/${opts.frameworkId}`);
|
|
912
|
-
opts._resolvedStackId = stackId;
|
|
913
|
-
}
|
|
914
|
-
setStep(job, 0, "done");
|
|
915
|
-
setStep(job, 1, "running");
|
|
916
|
-
const gitea = new GiteaClient({
|
|
917
|
-
baseUrl: ctx.giteaBaseUrl,
|
|
918
|
-
username: ctx.giteaUser,
|
|
919
|
-
password: ctx.giteaPassword,
|
|
920
|
-
tokenPath: GITEA_TOKEN_PATH
|
|
921
|
-
});
|
|
922
|
-
const giteaPrep = await gitea.prepare();
|
|
923
|
-
setStep(job, 1, "done", giteaPrep.message);
|
|
924
|
-
if (opts.mode === "boilerplate" && opts._meta) {
|
|
925
|
-
await _createModeA(job, opts, ctx, gitea, appsJson);
|
|
926
|
-
} else if (opts.mode === "git-clone") {
|
|
927
|
-
await _createModeB(job, opts, ctx, gitea, appsJson);
|
|
928
|
-
} else {
|
|
929
|
-
await _createModeC(job, opts, ctx, gitea, appsJson);
|
|
930
|
-
}
|
|
931
|
-
job.status = "done";
|
|
932
|
-
} catch (err) {
|
|
933
|
-
job.status = "failed";
|
|
934
|
-
job.error = err instanceof Error ? err.message : String(err);
|
|
935
|
-
for (const step of job.steps) {
|
|
936
|
-
if (step.status === "running" || step.status === "pending") step.status = "failed";
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
async function _createModeA(job, opts, ctx, gitea, appsJson) {
|
|
941
|
-
const meta = opts._meta;
|
|
942
|
-
const port = opts.port ?? meta.port ?? parseInt(meta.backendUrl.split(":").pop() ?? "8080", 10);
|
|
943
|
-
setStep(job, 2, "running", `checking ${ctx.giteaUser}/${opts.appName}`);
|
|
944
|
-
const alreadyExists = await gitea.repoExists(opts.appName);
|
|
945
|
-
let cloneUrl;
|
|
946
|
-
if (!alreadyExists) {
|
|
947
|
-
setStep(job, 2, "running", `creating ${ctx.giteaUser}/${opts.appName}`);
|
|
948
|
-
cloneUrl = await gitea.createRepo(opts.appName, `Brewnet app: ${opts.appName}`);
|
|
949
|
-
} else {
|
|
950
|
-
cloneUrl = `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${opts.appName}.git`;
|
|
951
|
-
}
|
|
952
|
-
setStep(job, 2, "done");
|
|
953
|
-
setStep(job, 3, "running", `pushing HEAD:main \u2192 ${ctx.giteaUser}/${opts.appName}`);
|
|
954
|
-
const shallowCheck = await execa("git", ["rev-parse", "--is-shallow-repository"], { cwd: meta.appDir }).catch(() => ({ stdout: "false" }));
|
|
955
|
-
if (shallowCheck.stdout.trim() === "true") {
|
|
956
|
-
await execa("git", ["fetch", "--unshallow", "origin"], { cwd: meta.appDir }).catch(async () => {
|
|
957
|
-
const { reinitGit } = await import("./boilerplate-manager-P6QYUU7Q.js");
|
|
958
|
-
await reinitGit(meta.appDir);
|
|
959
|
-
});
|
|
960
|
-
}
|
|
961
|
-
const authedUrl = gitea.authedCloneUrl(cloneUrl);
|
|
962
|
-
await execa("git", ["remote", "add", "brewnet", authedUrl], { cwd: meta.appDir }).catch(() => {
|
|
963
|
-
return execa("git", ["remote", "set-url", "brewnet", authedUrl], { cwd: meta.appDir });
|
|
964
|
-
});
|
|
965
|
-
await execa("git", ["push", "brewnet", "HEAD:main", "--force"], { cwd: meta.appDir });
|
|
966
|
-
setStep(job, 3, "done");
|
|
967
|
-
setStep(job, 4, "running", "docker compose up --build");
|
|
968
|
-
ensureComposeFile(meta.appDir, opts.appName, port, job);
|
|
969
|
-
await _injectQuickTunnelIfNeeded(meta.appDir, opts.appName, port);
|
|
970
|
-
await _dockerComposeUp(meta.appDir, job);
|
|
971
|
-
setStep(job, 4, "done", "containers started");
|
|
972
|
-
setStep(job, 5, "running");
|
|
973
|
-
const healthUrlA = _buildHealthUrl(meta.appDir, port);
|
|
974
|
-
setStep(job, 5, "running", `polling ${healthUrlA}`);
|
|
975
|
-
await _pollHealth(healthUrlA, 12e4, job);
|
|
976
|
-
setStep(job, 5, "done");
|
|
977
|
-
addApp(appsJson, {
|
|
978
|
-
name: opts.appName,
|
|
979
|
-
mode: "boilerplate",
|
|
980
|
-
stackId: opts.stackId,
|
|
981
|
-
appDir: meta.appDir,
|
|
982
|
-
lang: meta.lang,
|
|
983
|
-
framework: meta.frameworkId,
|
|
984
|
-
port,
|
|
985
|
-
giteaRepoUrl: `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${opts.appName}`,
|
|
986
|
-
status: "running",
|
|
987
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
988
|
-
});
|
|
989
|
-
await setupWebhook(opts.appName, "http://localhost:8088/api/deploy/hook").catch((e) => {
|
|
990
|
-
console.warn("[webhook] registration failed (non-critical):", e instanceof Error ? e.message : String(e));
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
async function _createModeB(job, opts, ctx, gitea, appsJson) {
|
|
994
|
-
const port = opts.port ?? 8080;
|
|
995
|
-
const appDir = join(ctx.projectPath, "apps", opts.appName);
|
|
996
|
-
setStep(job, 2, "running", "Cloning external repository...");
|
|
997
|
-
const { reinitGit: reinitGitB } = await import("./boilerplate-manager-P6QYUU7Q.js");
|
|
998
|
-
const { rmSync } = await import("fs");
|
|
999
|
-
if (existsSync2(appDir)) {
|
|
1000
|
-
rmSync(appDir, { recursive: true, force: true });
|
|
1001
|
-
}
|
|
1002
|
-
const cloneArgs = ["clone", "--depth", "1"];
|
|
1003
|
-
if (opts.branch) cloneArgs.push("-b", opts.branch);
|
|
1004
|
-
cloneArgs.push(opts.gitUrl, appDir);
|
|
1005
|
-
await execa("git", cloneArgs);
|
|
1006
|
-
const envExPath = join(appDir, ".env.example");
|
|
1007
|
-
const envPath = join(appDir, ".env");
|
|
1008
|
-
if (existsSync2(envExPath)) {
|
|
1009
|
-
const { findFreePort: findFreePortB } = await import("./boilerplate-manager-P6QYUU7Q.js");
|
|
1010
|
-
let envContent = readFileSync2(envExPath, "utf-8");
|
|
1011
|
-
envContent = envContent.replace(/^BACKEND_PORT=.*/m, `BACKEND_PORT=${port}`);
|
|
1012
|
-
const fePort = await findFreePortB(port + 1);
|
|
1013
|
-
envContent = envContent.replace(/^FRONTEND_PORT=.*/m, `FRONTEND_PORT=${fePort}`);
|
|
1014
|
-
writeFileSync2(envPath, envContent, "utf-8");
|
|
1015
|
-
} else if (existsSync2(join(appDir, ".env"))) {
|
|
1016
|
-
let envContent = readFileSync2(join(appDir, ".env"), "utf-8");
|
|
1017
|
-
envContent = envContent.replace(/^BACKEND_PORT=.*/m, `BACKEND_PORT=${port}`);
|
|
1018
|
-
writeFileSync2(join(appDir, ".env"), envContent, "utf-8");
|
|
1019
|
-
}
|
|
1020
|
-
await reinitGitB(appDir);
|
|
1021
|
-
const alreadyExists = await gitea.repoExists(opts.appName);
|
|
1022
|
-
const cloneUrl = alreadyExists ? `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${opts.appName}.git` : await gitea.createRepo(opts.appName, `Brewnet app: ${opts.appName}`);
|
|
1023
|
-
setStep(job, 2, "done");
|
|
1024
|
-
setStep(job, 3, "running");
|
|
1025
|
-
const authedUrl = gitea.authedCloneUrl(cloneUrl);
|
|
1026
|
-
await execa("git", ["remote", "add", "brewnet", authedUrl], { cwd: appDir });
|
|
1027
|
-
await execa("git", ["push", "brewnet", "HEAD:main", "--force"], { cwd: appDir });
|
|
1028
|
-
setStep(job, 3, "done");
|
|
1029
|
-
const hasCompose = existsSync2(join(appDir, "docker-compose.yml")) || existsSync2(join(appDir, "compose.yml"));
|
|
1030
|
-
if (hasCompose) {
|
|
1031
|
-
setStep(job, 4, "running", "docker compose up --build");
|
|
1032
|
-
await _injectQuickTunnelIfNeeded(appDir, opts.appName, port);
|
|
1033
|
-
await _dockerComposeUp(appDir, job);
|
|
1034
|
-
setStep(job, 4, "done", "containers started");
|
|
1035
|
-
setStep(job, 5, "running");
|
|
1036
|
-
const healthUrlB = _buildHealthUrl(appDir, port);
|
|
1037
|
-
setStep(job, 5, "running", `polling ${healthUrlB}`);
|
|
1038
|
-
await _pollHealth(healthUrlB, 12e4, job);
|
|
1039
|
-
setStep(job, 5, "done");
|
|
1040
|
-
} else {
|
|
1041
|
-
setStep(job, 4, "done", "skipped \u2014 no docker-compose.yml");
|
|
1042
|
-
setStep(job, 5, "done", "skipped \u2014 deploy separately");
|
|
1043
|
-
appendLog(job, "[clone] Gitea push completed \u2014 no docker-compose.yml, skipping Docker up");
|
|
1044
|
-
}
|
|
1045
|
-
addApp(appsJson, {
|
|
1046
|
-
name: opts.appName,
|
|
1047
|
-
mode: "git-clone",
|
|
1048
|
-
sourceUrl: opts.gitUrl,
|
|
1049
|
-
appDir,
|
|
1050
|
-
port,
|
|
1051
|
-
giteaRepoUrl: `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${opts.appName}`,
|
|
1052
|
-
status: hasCompose ? "running" : "stopped",
|
|
1053
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1054
|
-
});
|
|
1055
|
-
}
|
|
1056
|
-
async function _createModeC(job, opts, ctx, gitea, appsJson) {
|
|
1057
|
-
const { cloneStack, generateEnv, reinitGit, findFreePort } = await import("./boilerplate-manager-P6QYUU7Q.js");
|
|
1058
|
-
const { getStackById: getStackById2 } = await import("./stacks-M4FBTVO5.js");
|
|
1059
|
-
const stackId = opts._resolvedStackId;
|
|
1060
|
-
const requestedPort = opts.port ?? 8080;
|
|
1061
|
-
const appDir = join(ctx.projectPath, "apps", opts.appName);
|
|
1062
|
-
await cloneStack(stackId, appDir);
|
|
1063
|
-
const port = await findFreePort(requestedPort);
|
|
1064
|
-
const stackInfo = getStackById2(stackId);
|
|
1065
|
-
const frontendPort = stackInfo && !stackInfo.isUnified ? await findFreePort(port + 1) : void 0;
|
|
1066
|
-
generateEnv(appDir, stackId, "sqlite3", { hostPort: port, frontendPort });
|
|
1067
|
-
await reinitGit(appDir);
|
|
1068
|
-
setStep(job, 2, "running");
|
|
1069
|
-
const cloneUrl = await gitea.createRepo(opts.appName, `Brewnet app: ${opts.appName}`);
|
|
1070
|
-
setStep(job, 2, "done");
|
|
1071
|
-
setStep(job, 3, "running");
|
|
1072
|
-
const authedUrl = gitea.authedCloneUrl(cloneUrl);
|
|
1073
|
-
await execa("git", ["remote", "add", "brewnet", authedUrl], { cwd: appDir });
|
|
1074
|
-
await execa("git", ["push", "brewnet", "HEAD:main", "--force"], { cwd: appDir });
|
|
1075
|
-
setStep(job, 3, "done");
|
|
1076
|
-
setStep(job, 4, "running", "docker compose up --build");
|
|
1077
|
-
ensureComposeFile(appDir, opts.appName, port, job);
|
|
1078
|
-
await _injectQuickTunnelIfNeeded(appDir, opts.appName, port);
|
|
1079
|
-
await _dockerComposeUp(appDir, job);
|
|
1080
|
-
setStep(job, 4, "done", "containers started");
|
|
1081
|
-
setStep(job, 5, "running");
|
|
1082
|
-
const healthUrlC = _buildHealthUrl(appDir, port);
|
|
1083
|
-
setStep(job, 5, "running", `polling ${healthUrlC}`);
|
|
1084
|
-
await _pollHealth(healthUrlC, 12e4, job);
|
|
1085
|
-
setStep(job, 5, "done");
|
|
1086
|
-
addApp(appsJson, {
|
|
1087
|
-
name: opts.appName,
|
|
1088
|
-
mode: opts.mode === "boilerplate" ? "boilerplate" : "new-project",
|
|
1089
|
-
stackId,
|
|
1090
|
-
appDir,
|
|
1091
|
-
lang: opts.language ?? stackInfo?.language,
|
|
1092
|
-
framework: opts.frameworkId ?? stackInfo?.framework,
|
|
1093
|
-
port,
|
|
1094
|
-
giteaRepoUrl: `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${opts.appName}`,
|
|
1095
|
-
status: "running",
|
|
1096
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1097
|
-
});
|
|
1098
|
-
}
|
|
1099
|
-
async function startApp(appName) {
|
|
1100
|
-
const appsJson = resolveAppsJsonPath();
|
|
1101
|
-
const apps = readApps(appsJson);
|
|
1102
|
-
const app = apps.find((a) => a.name === appName);
|
|
1103
|
-
if (!app) throw new Error(`App "${appName}" not found`);
|
|
1104
|
-
await execa("docker", ["compose", "up", "-d"], { cwd: app.appDir });
|
|
1105
|
-
updateApp(appsJson, appName, { status: "running" });
|
|
1106
|
-
}
|
|
1107
|
-
async function stopApp(appName) {
|
|
1108
|
-
const appsJson = resolveAppsJsonPath();
|
|
1109
|
-
const apps = readApps(appsJson);
|
|
1110
|
-
const app = apps.find((a) => a.name === appName);
|
|
1111
|
-
if (!app) throw new Error(`App "${appName}" not found`);
|
|
1112
|
-
await execa("docker", ["compose", "down"], { cwd: app.appDir });
|
|
1113
|
-
updateApp(appsJson, appName, { status: "stopped" });
|
|
1114
|
-
}
|
|
1115
|
-
async function removeApp2(appName) {
|
|
1116
|
-
const appsJson = resolveAppsJsonPath();
|
|
1117
|
-
const apps = readApps(appsJson);
|
|
1118
|
-
const app = apps.find((a) => a.name === appName);
|
|
1119
|
-
if (!app) throw new Error(`App "${appName}" not found`);
|
|
1120
|
-
await execa("docker", ["compose", "down", "--volumes"], { cwd: app.appDir }).catch((e) => {
|
|
1121
|
-
console.warn("[removeApp] docker compose down failed:", e instanceof Error ? e.message : String(e));
|
|
1122
|
-
});
|
|
1123
|
-
removeApp(appsJson, appName);
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
// src/services/admin-server.ts
|
|
1127
|
-
var PKG_ROOT = join2(fileURLToPath(import.meta.url), "../../../..");
|
|
1128
|
-
var ADMIN_UI_DIST = join2(PKG_ROOT, "packages/admin-ui/dist");
|
|
52
|
+
import Dockerode from "dockerode";
|
|
53
|
+
var PKG_ROOT = join(fileURLToPath(import.meta.url), "../../../..");
|
|
54
|
+
var ADMIN_UI_DIST = join(PKG_ROOT, "packages/admin-ui/dist");
|
|
1129
55
|
var MIME_TYPES = {
|
|
1130
56
|
".html": "text/html; charset=utf-8",
|
|
1131
57
|
".js": "application/javascript; charset=utf-8",
|
|
@@ -1156,21 +82,21 @@ function serveStaticFile(filePath, res, statusCode = 200) {
|
|
|
1156
82
|
}
|
|
1157
83
|
var ICON_SVG = (() => {
|
|
1158
84
|
const candidates = [
|
|
1159
|
-
|
|
1160
|
-
|
|
85
|
+
join(PKG_ROOT, "public/images/icon.svg"),
|
|
86
|
+
join(PKG_ROOT, "../public/images/icon.svg")
|
|
1161
87
|
];
|
|
1162
88
|
for (const p of candidates) {
|
|
1163
|
-
if (
|
|
89
|
+
if (existsSync(p)) return readFileSync(p, "utf-8");
|
|
1164
90
|
}
|
|
1165
91
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="4 6 38 38" fill="none" stroke="#f5a623" stroke-linecap="round" stroke-linejoin="round"><path d="M8 26H32V34C32 36.8 29.8 39 27 39H13C10.2 39 8 36.8 8 34V26Z" stroke-width="3.5" fill="none"/><path d="M32 28.5C35.5 28.5 37 30.5 37 32.5C37 34.5 35.5 36.5 32 36.5" stroke-width="3.5" fill="none"/><circle cx="20" cy="30" r="2.2" fill="#f5a623" stroke="none"/><path d="M16.5 20a5 5 0 0 1 7 0" stroke-width="3.5" fill="none"/><path d="M13.5 15.5a10 10 0 0 1 13 0" stroke-width="3.5" fill="none"/><path d="M10.5 11a15 15 0 0 1 19 0" stroke-width="3.5" fill="none"/></svg>`;
|
|
1166
92
|
})();
|
|
1167
93
|
var FAVICON_ICO = (() => {
|
|
1168
94
|
const candidates = [
|
|
1169
|
-
|
|
1170
|
-
|
|
95
|
+
join(PKG_ROOT, "public/images/favicon.ico"),
|
|
96
|
+
join(PKG_ROOT, "../public/images/favicon.ico")
|
|
1171
97
|
];
|
|
1172
98
|
for (const p of candidates) {
|
|
1173
|
-
if (
|
|
99
|
+
if (existsSync(p)) return readFileSync(p);
|
|
1174
100
|
}
|
|
1175
101
|
return null;
|
|
1176
102
|
})();
|
|
@@ -1195,7 +121,8 @@ var SERVICE_DETAIL_MAP = {
|
|
|
1195
121
|
"Remove --api.insecure=true in production and add BasicAuth or Authelia",
|
|
1196
122
|
"Set exposedbydefault=false and explicitly enable each service with traefik.enable=true",
|
|
1197
123
|
"Add --certificatesresolvers.le.acme.email=YOUR_EMAIL for Let's Encrypt"
|
|
1198
|
-
]
|
|
124
|
+
],
|
|
125
|
+
securityNote: "\uBCF4\uC548\uC0C1 \uC678\uBD80 \uB3C4\uBA54\uC778\uC73C\uB85C \uB178\uCD9C\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uC11C\uBC84 \uB0B4\uBD80(localhost)\uC5D0\uC11C\uB9CC \uC811\uADFC \uAC00\uB2A5\uD569\uB2C8\uB2E4."
|
|
1199
126
|
},
|
|
1200
127
|
"Traefik Dashboard": {
|
|
1201
128
|
description: "Built-in Traefik web UI for monitoring routes, services, and middleware",
|
|
@@ -1273,6 +200,12 @@ var SERVICE_DETAIL_MAP = {
|
|
|
1273
200
|
summary: "Configured via POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB environment variables.",
|
|
1274
201
|
command: "docker exec -it brewnet-postgresql psql -U brewnet -d brewnet_db"
|
|
1275
202
|
},
|
|
203
|
+
connectionParams: [
|
|
204
|
+
{ label: "host", value: "localhost" },
|
|
205
|
+
{ label: "port", value: "5432" },
|
|
206
|
+
{ label: "user", value: "brewnet" },
|
|
207
|
+
{ label: "db", value: "brewnet_db" }
|
|
208
|
+
],
|
|
1276
209
|
tips: [
|
|
1277
210
|
"Internal network only (brewnet-internal) \u2014 no host port exposed",
|
|
1278
211
|
"Data persisted in named volume \u2014 safe across container restarts",
|
|
@@ -1294,6 +227,12 @@ var SERVICE_DETAIL_MAP = {
|
|
|
1294
227
|
summary: "Configured via MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD environment variables.",
|
|
1295
228
|
command: "docker exec -it brewnet-mysql mysql -u brewnet -p brewnet_db"
|
|
1296
229
|
},
|
|
230
|
+
connectionParams: [
|
|
231
|
+
{ label: "host", value: "localhost" },
|
|
232
|
+
{ label: "port", value: "3306" },
|
|
233
|
+
{ label: "user", value: "brewnet" },
|
|
234
|
+
{ label: "db", value: "brewnet_db" }
|
|
235
|
+
],
|
|
1297
236
|
tips: [
|
|
1298
237
|
"Internal network only (brewnet-internal) \u2014 no host port exposed",
|
|
1299
238
|
"Root password required at first startup",
|
|
@@ -1376,6 +315,12 @@ var SERVICE_DETAIL_MAP = {
|
|
|
1376
315
|
summary: "Uses admin username set in Pre-Step. Password auth enabled (PASSWORD_ACCESS=true); switch to key-only after setup.",
|
|
1377
316
|
command: "ssh -p 2222 USER@localhost"
|
|
1378
317
|
},
|
|
318
|
+
connectionParams: [
|
|
319
|
+
{ label: "host", value: "localhost" },
|
|
320
|
+
{ label: "port", value: "2222" },
|
|
321
|
+
{ label: "user", value: "<admin-username>" },
|
|
322
|
+
{ label: "protocol", value: "SSH / SFTP" }
|
|
323
|
+
],
|
|
1379
324
|
tips: [
|
|
1380
325
|
"Switch to key-only auth after initial setup: set PASSWORD_ACCESS=false",
|
|
1381
326
|
"Port 2222 avoids conflict with host SSH (port 22)",
|
|
@@ -1563,7 +508,15 @@ async function readBody(req) {
|
|
|
1563
508
|
req.on("end", () => resolve2(body));
|
|
1564
509
|
});
|
|
1565
510
|
}
|
|
1566
|
-
async function handleGetServices(_req, res, _parts, _body, _projectPath, urlMap = TRAEFIK_PATH_SERVICES, quickTunnelUrl = "", allowedDirs) {
|
|
511
|
+
async function handleGetServices(_req, res, _parts, _body, _projectPath, urlMap = TRAEFIK_PATH_SERVICES, quickTunnelUrl = "", allowedDirs, wizardState) {
|
|
512
|
+
const NAMED_SUBDOMAIN_MAP = {
|
|
513
|
+
gitea: "git",
|
|
514
|
+
nextcloud: "cloud",
|
|
515
|
+
jellyfin: "media",
|
|
516
|
+
filebrowser: "files",
|
|
517
|
+
pgadmin: "pgadmin",
|
|
518
|
+
minio: "minio"
|
|
519
|
+
};
|
|
1567
520
|
try {
|
|
1568
521
|
const allContainers = await docker.listContainers({ all: true });
|
|
1569
522
|
const services = [];
|
|
@@ -1597,7 +550,21 @@ async function handleGetServices(_req, res, _parts, _body, _projectPath, urlMap
|
|
|
1597
550
|
const localBasePath = traefikPath && !hasStripPrefix ? traefikPath : "";
|
|
1598
551
|
let externalUrl = null;
|
|
1599
552
|
const qtUrl = quickTunnelUrl;
|
|
1600
|
-
|
|
553
|
+
const tunnelMode = wizardState?.domain?.cloudflare?.tunnelMode ?? "none";
|
|
554
|
+
const namedDomain = wizardState?.domain?.cloudflare?.zoneName || wizardState?.domain?.name || "";
|
|
555
|
+
if (tunnelMode === "named" && namedDomain) {
|
|
556
|
+
const sub = NAMED_SUBDOMAIN_MAP[composeService];
|
|
557
|
+
if (sub) {
|
|
558
|
+
externalUrl = `https://${sub}.${namedDomain}`;
|
|
559
|
+
}
|
|
560
|
+
if (!externalUrl) {
|
|
561
|
+
const appNameVariants = [composeService, composeService.replace(/^brewnet-/, "")];
|
|
562
|
+
const conn = (wizardState?.domainConnections ?? []).find(
|
|
563
|
+
(c2) => appNameVariants.includes(c2.appName)
|
|
564
|
+
);
|
|
565
|
+
if (conn) externalUrl = `https://${conn.hostname}`;
|
|
566
|
+
}
|
|
567
|
+
} else if (qtUrl && traefikPath) {
|
|
1601
568
|
let extPath = traefikPath;
|
|
1602
569
|
const stackLabel = labels["com.brewnet.stack"] ?? "";
|
|
1603
570
|
if (stackLabel === "nodejs-nextjs" || composeService === "backend" && extPath.includes("nextjs-app")) {
|
|
@@ -1605,7 +572,7 @@ async function handleGetServices(_req, res, _parts, _body, _projectPath, urlMap
|
|
|
1605
572
|
}
|
|
1606
573
|
externalUrl = qtUrl.replace(/\/$/, "") + extPath;
|
|
1607
574
|
}
|
|
1608
|
-
if (!externalUrl && qtUrl) {
|
|
575
|
+
if (!externalUrl && qtUrl && tunnelMode !== "named") {
|
|
1609
576
|
const EXT_PATH_MAP = {
|
|
1610
577
|
traefik: "",
|
|
1611
578
|
gitea: "/git",
|
|
@@ -1762,7 +729,7 @@ async function handleGetCatalog(_req, res, _parts, _body, _projectPath) {
|
|
|
1762
729
|
}
|
|
1763
730
|
}
|
|
1764
731
|
async function handleBackup(req, res, _parts, _body, projectPath) {
|
|
1765
|
-
const backupsDir =
|
|
732
|
+
const backupsDir = join(homedir(), ".brewnet", "backups");
|
|
1766
733
|
if (req.method === "GET") {
|
|
1767
734
|
try {
|
|
1768
735
|
const backups = listBackups(backupsDir);
|
|
@@ -1803,7 +770,7 @@ function createAdminServer(options = {}) {
|
|
|
1803
770
|
}
|
|
1804
771
|
}
|
|
1805
772
|
if (projectPath.startsWith("~/") || projectPath === "~") {
|
|
1806
|
-
projectPath =
|
|
773
|
+
projectPath = join(homedir(), projectPath.slice(1));
|
|
1807
774
|
}
|
|
1808
775
|
const username = wizardState?.admin?.username ?? "";
|
|
1809
776
|
const password = wizardState?.admin?.password ?? "";
|
|
@@ -1812,17 +779,17 @@ function createAdminServer(options = {}) {
|
|
|
1812
779
|
const allowedWorkingDirs = /* @__PURE__ */ new Set();
|
|
1813
780
|
allowedWorkingDirs.add(projectPath);
|
|
1814
781
|
try {
|
|
1815
|
-
const bpMetaPath2 =
|
|
1816
|
-
if (
|
|
1817
|
-
const raw2 = JSON.parse(
|
|
782
|
+
const bpMetaPath2 = join(projectPath, ".brewnet-boilerplate.json");
|
|
783
|
+
if (existsSync(bpMetaPath2)) {
|
|
784
|
+
const raw2 = JSON.parse(readFileSync(bpMetaPath2, "utf-8"));
|
|
1818
785
|
const stacks2 = Array.isArray(raw2) ? raw2 : raw2.stackId ? [raw2] : [];
|
|
1819
786
|
for (const s of stacks2) {
|
|
1820
787
|
if (s.appDir) allowedWorkingDirs.add(s.appDir);
|
|
1821
788
|
}
|
|
1822
789
|
}
|
|
1823
|
-
const appsJsonPath =
|
|
1824
|
-
if (
|
|
1825
|
-
const apps = JSON.parse(
|
|
790
|
+
const appsJsonPath = join(homedir(), ".brewnet", "apps.json");
|
|
791
|
+
if (existsSync(appsJsonPath)) {
|
|
792
|
+
const apps = JSON.parse(readFileSync(appsJsonPath, "utf-8"));
|
|
1826
793
|
for (const app of apps) {
|
|
1827
794
|
if (app.appDir) allowedWorkingDirs.add(app.appDir);
|
|
1828
795
|
}
|
|
@@ -1926,7 +893,7 @@ function createAdminServer(options = {}) {
|
|
|
1926
893
|
res.end("Forbidden");
|
|
1927
894
|
return;
|
|
1928
895
|
}
|
|
1929
|
-
if (
|
|
896
|
+
if (existsSync(safePath) && statSync(safePath).isFile()) {
|
|
1930
897
|
serveStaticFile(safePath, res);
|
|
1931
898
|
return;
|
|
1932
899
|
}
|
|
@@ -1937,12 +904,12 @@ function createAdminServer(options = {}) {
|
|
|
1937
904
|
if (req.method === "GET" && !url.startsWith("/api/")) {
|
|
1938
905
|
const pathname = url.split("?")[0];
|
|
1939
906
|
const exactPath = resolve(ADMIN_UI_DIST, "." + (pathname === "/" ? "/index.html" : pathname));
|
|
1940
|
-
if (exactPath.startsWith(ADMIN_UI_DIST) &&
|
|
907
|
+
if (exactPath.startsWith(ADMIN_UI_DIST) && existsSync(exactPath) && statSync(exactPath).isFile()) {
|
|
1941
908
|
serveStaticFile(exactPath, res);
|
|
1942
909
|
return;
|
|
1943
910
|
}
|
|
1944
|
-
const indexPath =
|
|
1945
|
-
if (
|
|
911
|
+
const indexPath = join(ADMIN_UI_DIST, "index.html");
|
|
912
|
+
if (existsSync(indexPath)) {
|
|
1946
913
|
serveStaticFile(indexPath, res);
|
|
1947
914
|
return;
|
|
1948
915
|
}
|
|
@@ -1964,7 +931,21 @@ function createAdminServer(options = {}) {
|
|
|
1964
931
|
return;
|
|
1965
932
|
}
|
|
1966
933
|
if (parts[1] === "services" && parts[2] === "catalog" && req.method === "GET") {
|
|
1967
|
-
|
|
934
|
+
const adminUser = wizardState?.admin?.username ?? "USER";
|
|
935
|
+
const catalog = { ...SERVICE_DETAIL_MAP };
|
|
936
|
+
if (catalog["SSH Server"]) {
|
|
937
|
+
catalog["SSH Server"] = {
|
|
938
|
+
...catalog["SSH Server"],
|
|
939
|
+
credentials: {
|
|
940
|
+
...catalog["SSH Server"].credentials,
|
|
941
|
+
command: `ssh -p 2222 ${adminUser}@localhost`
|
|
942
|
+
},
|
|
943
|
+
connectionParams: catalog["SSH Server"].connectionParams?.map(
|
|
944
|
+
(p) => p.label === "user" ? { ...p, value: adminUser } : p
|
|
945
|
+
)
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
json(res, 200, { catalog, aliases: NAME_ALIASES });
|
|
1968
949
|
return;
|
|
1969
950
|
}
|
|
1970
951
|
if (parts[1] === "config" && req.method === "GET") {
|
|
@@ -1982,7 +963,7 @@ function createAdminServer(options = {}) {
|
|
|
1982
963
|
}
|
|
1983
964
|
if (parts[1] === "services") {
|
|
1984
965
|
if (req.method === "GET" && parts.length === 2) {
|
|
1985
|
-
await handleGetServices(req, res, parts, body, projectPath, runtimeUrlMap, dashConfig.quickTunnelUrl, allowedWorkingDirs);
|
|
966
|
+
await handleGetServices(req, res, parts, body, projectPath, runtimeUrlMap, dashConfig.quickTunnelUrl, allowedWorkingDirs, wizardState);
|
|
1986
967
|
return;
|
|
1987
968
|
}
|
|
1988
969
|
if (req.method === "POST" && parts[2] === "install") {
|
|
@@ -2047,11 +1028,11 @@ function createAdminServer(options = {}) {
|
|
|
2047
1028
|
for (const h of history) {
|
|
2048
1029
|
if (h.status === "success") historyByApp.set(h.appName, h);
|
|
2049
1030
|
}
|
|
2050
|
-
const bpMetaPath =
|
|
2051
|
-
|
|
2052
|
-
if (
|
|
1031
|
+
const bpMetaPath = join(projectPath, ".brewnet-boilerplate.json");
|
|
1032
|
+
const bpMetaMap = /* @__PURE__ */ new Map();
|
|
1033
|
+
if (existsSync(bpMetaPath)) {
|
|
2053
1034
|
try {
|
|
2054
|
-
const raw = JSON.parse(
|
|
1035
|
+
const raw = JSON.parse(readFileSync(bpMetaPath, "utf-8"));
|
|
2055
1036
|
const metas = Array.isArray(raw) ? raw : [raw];
|
|
2056
1037
|
for (const m of metas) bpMetaMap.set(m.stackId, m);
|
|
2057
1038
|
} catch {
|
|
@@ -2066,25 +1047,26 @@ function createAdminServer(options = {}) {
|
|
|
2066
1047
|
let externalUrl;
|
|
2067
1048
|
let backendLocalUrl = null;
|
|
2068
1049
|
let backendExternalUrl = null;
|
|
1050
|
+
const domainConn = (wizardState?.domainConnections ?? []).find((c) => c.appName === a.name);
|
|
2069
1051
|
if (isNonUnified) {
|
|
2070
1052
|
let frontendPort = 3e3;
|
|
2071
|
-
const feEnvPath =
|
|
2072
|
-
if (
|
|
2073
|
-
const feEnvContent =
|
|
1053
|
+
const feEnvPath = join(a.appDir, ".env");
|
|
1054
|
+
if (existsSync(feEnvPath)) {
|
|
1055
|
+
const feEnvContent = readFileSync(feEnvPath, "utf-8");
|
|
2074
1056
|
const fePortMatch = feEnvContent.match(/^FRONTEND_PORT=(\d+)/m);
|
|
2075
1057
|
if (fePortMatch) frontendPort = parseInt(fePortMatch[1], 10);
|
|
2076
1058
|
}
|
|
2077
1059
|
localUrl = `http://127.0.0.1:${frontendPort}`;
|
|
2078
|
-
externalUrl = qt ? `${qt.replace(/\/$/, "")}/apps/${a.name}-ui` : null;
|
|
1060
|
+
externalUrl = domainConn ? `https://${domainConn.hostname}` : qt ? `${qt.replace(/\/$/, "")}/apps/${a.name}-ui` : null;
|
|
2079
1061
|
backendLocalUrl = a.port ? `http://127.0.0.1:${a.port}` : null;
|
|
2080
|
-
backendExternalUrl = qt ? `${qt.replace(/\/$/, "")}/apps/${a.name}` : null;
|
|
1062
|
+
backendExternalUrl = domainConn ? `https://${domainConn.hostname}` : qt ? `${qt.replace(/\/$/, "")}/apps/${a.name}` : null;
|
|
2081
1063
|
} else {
|
|
2082
1064
|
localUrl = a.port ? `http://localhost:${a.port}` : null;
|
|
2083
1065
|
if (a.appDir) {
|
|
2084
1066
|
const bp = detectBasePath(a.appDir);
|
|
2085
1067
|
if (bp && localUrl) localUrl += bp;
|
|
2086
1068
|
}
|
|
2087
|
-
externalUrl = qt ? `${qt.replace(/\/$/, "")}/apps/${a.name}` : null;
|
|
1069
|
+
externalUrl = domainConn ? `https://${domainConn.hostname}` : qt ? `${qt.replace(/\/$/, "")}/apps/${a.name}` : null;
|
|
2088
1070
|
}
|
|
2089
1071
|
return { ...a, lastDeployedAt: lastDeploy?.deployedAt ?? null, localUrl, externalUrl, backendLocalUrl, backendExternalUrl };
|
|
2090
1072
|
});
|
|
@@ -2093,10 +1075,10 @@ function createAdminServer(options = {}) {
|
|
|
2093
1075
|
return;
|
|
2094
1076
|
}
|
|
2095
1077
|
if (req.method === "GET" && parts[2] === "boilerplates") {
|
|
2096
|
-
const bpPath =
|
|
1078
|
+
const bpPath = join(projectPath, ".brewnet-boilerplate.json");
|
|
2097
1079
|
const metas = [];
|
|
2098
|
-
if (
|
|
2099
|
-
const raw = JSON.parse(
|
|
1080
|
+
if (existsSync(bpPath)) {
|
|
1081
|
+
const raw = JSON.parse(readFileSync(bpPath, "utf-8"));
|
|
2100
1082
|
const wizardMetas = Array.isArray(raw) ? raw : [raw];
|
|
2101
1083
|
metas.push(...wizardMetas);
|
|
2102
1084
|
}
|
|
@@ -2104,13 +1086,13 @@ function createAdminServer(options = {}) {
|
|
|
2104
1086
|
for (const app of allApps) {
|
|
2105
1087
|
if (app.mode !== "boilerplate" || !app.stackId) continue;
|
|
2106
1088
|
if (metas.some((m) => m.appDir && app.appDir && m.appDir === app.appDir)) continue;
|
|
2107
|
-
const envPath =
|
|
1089
|
+
const envPath = join(app.appDir, ".env");
|
|
2108
1090
|
let frontendPort;
|
|
2109
1091
|
let dbDriver;
|
|
2110
1092
|
let dbUser;
|
|
2111
1093
|
let dbName;
|
|
2112
|
-
if (
|
|
2113
|
-
const envContent =
|
|
1094
|
+
if (existsSync(envPath)) {
|
|
1095
|
+
const envContent = readFileSync(envPath, "utf-8");
|
|
2114
1096
|
const fpMatch = envContent.match(/^FRONTEND_PORT=(\d+)/m);
|
|
2115
1097
|
if (fpMatch) frontendPort = parseInt(fpMatch[1], 10);
|
|
2116
1098
|
const ddMatch = envContent.match(/^DB_DRIVER=(.+)/m);
|
|
@@ -2141,12 +1123,12 @@ function createAdminServer(options = {}) {
|
|
|
2141
1123
|
if (req.method === "POST" && parts[2] === "boilerplates" && parts[3] && (parts[4] === "stop" || parts[4] === "start")) {
|
|
2142
1124
|
const stackId = decodeURIComponent(parts[3]);
|
|
2143
1125
|
const action = parts[4];
|
|
2144
|
-
const bpPath =
|
|
2145
|
-
if (!
|
|
1126
|
+
const bpPath = join(projectPath, ".brewnet-boilerplate.json");
|
|
1127
|
+
if (!existsSync(bpPath)) {
|
|
2146
1128
|
json(res, 404, { error: "No boilerplates found" });
|
|
2147
1129
|
return;
|
|
2148
1130
|
}
|
|
2149
|
-
const bpRaw = JSON.parse(
|
|
1131
|
+
const bpRaw = JSON.parse(readFileSync(bpPath, "utf-8"));
|
|
2150
1132
|
const bpMetas = Array.isArray(bpRaw) ? bpRaw : [bpRaw];
|
|
2151
1133
|
const meta = bpMetas.find((m) => m.stackId === stackId);
|
|
2152
1134
|
if (!meta) {
|
|
@@ -2161,8 +1143,8 @@ function createAdminServer(options = {}) {
|
|
|
2161
1143
|
await execaBp("docker", ["compose", "up", "-d"], { cwd: meta.appDir });
|
|
2162
1144
|
meta.status = "running";
|
|
2163
1145
|
}
|
|
2164
|
-
const { writeFileSync:
|
|
2165
|
-
|
|
1146
|
+
const { writeFileSync: writeFileSync2 } = await import("fs");
|
|
1147
|
+
writeFileSync2(bpPath, JSON.stringify(bpMetas, null, 2), "utf-8");
|
|
2166
1148
|
json(res, 200, { success: true });
|
|
2167
1149
|
return;
|
|
2168
1150
|
}
|
|
@@ -2192,7 +1174,7 @@ function createAdminServer(options = {}) {
|
|
|
2192
1174
|
return;
|
|
2193
1175
|
}
|
|
2194
1176
|
if (req.method === "DELETE" && parts[2]) {
|
|
2195
|
-
await
|
|
1177
|
+
await removeApp(parts[2]);
|
|
2196
1178
|
json(res, 200, { success: true });
|
|
2197
1179
|
return;
|
|
2198
1180
|
}
|
|
@@ -2207,10 +1189,10 @@ function createAdminServer(options = {}) {
|
|
|
2207
1189
|
const lastDeploy = history.filter((h) => h.appName === found.name && h.status === "success").pop() ?? null;
|
|
2208
1190
|
const qt = dashConfig.quickTunnelUrl;
|
|
2209
1191
|
const bpMetaSingle = found.mode === "boilerplate" && found.stackId ? (() => {
|
|
2210
|
-
const p =
|
|
2211
|
-
if (!
|
|
1192
|
+
const p = join(projectPath, ".brewnet-boilerplate.json");
|
|
1193
|
+
if (!existsSync(p)) return void 0;
|
|
2212
1194
|
try {
|
|
2213
|
-
const raw = JSON.parse(
|
|
1195
|
+
const raw = JSON.parse(readFileSync(p, "utf-8"));
|
|
2214
1196
|
const list = Array.isArray(raw) ? raw : [raw];
|
|
2215
1197
|
return list.find((m) => m.stackId === found.stackId);
|
|
2216
1198
|
} catch {
|
|
@@ -2222,24 +1204,25 @@ function createAdminServer(options = {}) {
|
|
|
2222
1204
|
let externalUrlSingle;
|
|
2223
1205
|
let backendLocalUrlSingle = null;
|
|
2224
1206
|
let backendExternalUrlSingle = null;
|
|
1207
|
+
const domainConnSingle = (wizardState?.domainConnections ?? []).find((c) => c.appName === found.name);
|
|
2225
1208
|
if (isNonUnified) {
|
|
2226
1209
|
let frontendPort = 3e3;
|
|
2227
|
-
const feEnvPath =
|
|
2228
|
-
if (
|
|
2229
|
-
const m =
|
|
1210
|
+
const feEnvPath = join(found.appDir, ".env");
|
|
1211
|
+
if (existsSync(feEnvPath)) {
|
|
1212
|
+
const m = readFileSync(feEnvPath, "utf-8").match(/^FRONTEND_PORT=(\d+)/m);
|
|
2230
1213
|
if (m) frontendPort = parseInt(m[1], 10);
|
|
2231
1214
|
}
|
|
2232
1215
|
localUrlSingle = `http://127.0.0.1:${frontendPort}`;
|
|
2233
|
-
externalUrlSingle = qt ? `${qt.replace(/\/$/, "")}/apps/${found.name}-ui` : null;
|
|
1216
|
+
externalUrlSingle = domainConnSingle ? `https://${domainConnSingle.hostname}` : qt ? `${qt.replace(/\/$/, "")}/apps/${found.name}-ui` : null;
|
|
2234
1217
|
backendLocalUrlSingle = found.port ? `http://127.0.0.1:${found.port}` : null;
|
|
2235
|
-
backendExternalUrlSingle = qt ? `${qt.replace(/\/$/, "")}/apps/${found.name}` : null;
|
|
1218
|
+
backendExternalUrlSingle = domainConnSingle ? `https://${domainConnSingle.hostname}` : qt ? `${qt.replace(/\/$/, "")}/apps/${found.name}` : null;
|
|
2236
1219
|
} else {
|
|
2237
1220
|
localUrlSingle = found.port ? `http://localhost:${found.port}` : null;
|
|
2238
1221
|
if (found.appDir) {
|
|
2239
1222
|
const bp = detectBasePath(found.appDir);
|
|
2240
1223
|
if (bp && localUrlSingle) localUrlSingle += bp;
|
|
2241
1224
|
}
|
|
2242
|
-
externalUrlSingle = qt ? `${qt.replace(/\/$/, "")}/apps/${found.name}` : null;
|
|
1225
|
+
externalUrlSingle = domainConnSingle ? `https://${domainConnSingle.hostname}` : qt ? `${qt.replace(/\/$/, "")}/apps/${found.name}` : null;
|
|
2243
1226
|
}
|
|
2244
1227
|
const app = { ...found, lastDeployedAt: lastDeploy?.deployedAt ?? null, localUrl: localUrlSingle, externalUrl: externalUrlSingle, backendLocalUrl: backendLocalUrlSingle, backendExternalUrl: backendExternalUrlSingle };
|
|
2245
1228
|
json(res, 200, { app });
|
|
@@ -2258,7 +1241,7 @@ function createAdminServer(options = {}) {
|
|
|
2258
1241
|
try {
|
|
2259
1242
|
const branches = await getAppBranches(decodeURIComponent(parts[2] ?? ""));
|
|
2260
1243
|
json(res, 200, { branches });
|
|
2261
|
-
} catch (
|
|
1244
|
+
} catch (_err) {
|
|
2262
1245
|
json(res, 200, { branches: [] });
|
|
2263
1246
|
}
|
|
2264
1247
|
return;
|
|
@@ -2446,8 +1429,8 @@ function createAdminServer(options = {}) {
|
|
|
2446
1429
|
return;
|
|
2447
1430
|
}
|
|
2448
1431
|
try {
|
|
2449
|
-
const appsPath =
|
|
2450
|
-
let existing =
|
|
1432
|
+
const appsPath = join(homedir(), ".brewnet", "apps.json");
|
|
1433
|
+
let existing = existsSync(appsPath) ? JSON.parse(readFileSync(appsPath, "utf-8")) : [];
|
|
2451
1434
|
const repos = await listGiteaRepos();
|
|
2452
1435
|
const repo = repos.find((r) => r.name === repoName);
|
|
2453
1436
|
if (!repo) {
|
|
@@ -2474,7 +1457,7 @@ function createAdminServer(options = {}) {
|
|
|
2474
1457
|
app = {
|
|
2475
1458
|
name: appName,
|
|
2476
1459
|
mode: "boilerplate",
|
|
2477
|
-
appDir:
|
|
1460
|
+
appDir: join(projectPath, repoName),
|
|
2478
1461
|
lang,
|
|
2479
1462
|
port: port2,
|
|
2480
1463
|
giteaRepoUrl: repoUrl,
|
|
@@ -2489,7 +1472,7 @@ function createAdminServer(options = {}) {
|
|
|
2489
1472
|
} else {
|
|
2490
1473
|
app.giteaRepoUrl = repoUrl;
|
|
2491
1474
|
}
|
|
2492
|
-
|
|
1475
|
+
writeFileSync(appsPath, JSON.stringify(existing, null, 2));
|
|
2493
1476
|
json(res, 200, { ok: true });
|
|
2494
1477
|
} catch (err) {
|
|
2495
1478
|
json(res, 500, { error: String(err) });
|
|
@@ -2631,7 +1614,7 @@ async function handleDomainList(res, state) {
|
|
|
2631
1614
|
const cf = state.domain.cloudflare;
|
|
2632
1615
|
if (cf.tunnelId && cf.apiToken && cf.accountId) {
|
|
2633
1616
|
try {
|
|
2634
|
-
const { getTunnelHealth } = await import("./cloudflare-client-
|
|
1617
|
+
const { getTunnelHealth } = await import("./cloudflare-client-F2TGQXGS.js");
|
|
2635
1618
|
const health = await getTunnelHealth(cf.apiToken, cf.accountId, cf.tunnelId);
|
|
2636
1619
|
tunnel = { ...health, tunnelName: cf.tunnelName, tunnelId: cf.tunnelId };
|
|
2637
1620
|
} catch {
|
|
@@ -2708,6 +1691,10 @@ async function handleDomainConnect(res, body, state) {
|
|
|
2708
1691
|
json(res, statusCode, { success: false, error: result.error, message: result.error, steps: result.steps });
|
|
2709
1692
|
return;
|
|
2710
1693
|
}
|
|
1694
|
+
const freshStateAfterConnect = loadState(state.projectName);
|
|
1695
|
+
if (freshStateAfterConnect?.domainConnections) {
|
|
1696
|
+
state.domainConnections = freshStateAfterConnect.domainConnections;
|
|
1697
|
+
}
|
|
2711
1698
|
json(res, 200, {
|
|
2712
1699
|
success: true,
|
|
2713
1700
|
hostname: result.hostname,
|
|
@@ -2731,6 +1718,10 @@ async function handleDomainDisconnect(res, appName, state) {
|
|
|
2731
1718
|
json(res, statusCode, { success: false, error: result.error?.split(":")[0], message: result.error });
|
|
2732
1719
|
return;
|
|
2733
1720
|
}
|
|
1721
|
+
const freshStateAfterDisconnect = loadState(state.projectName);
|
|
1722
|
+
if (freshStateAfterDisconnect) {
|
|
1723
|
+
state.domainConnections = freshStateAfterDisconnect.domainConnections ?? [];
|
|
1724
|
+
}
|
|
2734
1725
|
json(res, 200, {
|
|
2735
1726
|
success: true,
|
|
2736
1727
|
appName: result.appName,
|
|
@@ -2769,7 +1760,7 @@ async function handleCloudflareZones(res, state) {
|
|
|
2769
1760
|
return;
|
|
2770
1761
|
}
|
|
2771
1762
|
try {
|
|
2772
|
-
const { getZones } = await import("./cloudflare-client-
|
|
1763
|
+
const { getZones } = await import("./cloudflare-client-F2TGQXGS.js");
|
|
2773
1764
|
const zones = await getZones(apiToken);
|
|
2774
1765
|
if (!state.domain.cloudflare.accountId && zones.length > 0) {
|
|
2775
1766
|
const firstAccountId = zones[0]?.accountId;
|
|
@@ -2788,7 +1779,7 @@ async function handleCloudflareZones(res, state) {
|
|
|
2788
1779
|
return;
|
|
2789
1780
|
}
|
|
2790
1781
|
json(res, 200, { success: true, zones });
|
|
2791
|
-
} catch (
|
|
1782
|
+
} catch (_err) {
|
|
2792
1783
|
json(res, 400, {
|
|
2793
1784
|
success: false,
|
|
2794
1785
|
error: "TOKEN_INVALID",
|
|
@@ -2819,7 +1810,7 @@ async function handleCreateTunnel(res, body, state, projectPath) {
|
|
|
2819
1810
|
return;
|
|
2820
1811
|
}
|
|
2821
1812
|
try {
|
|
2822
|
-
const { createTunnel: cfCreateTunnel } = await import("./cloudflare-client-
|
|
1813
|
+
const { createTunnel: cfCreateTunnel } = await import("./cloudflare-client-F2TGQXGS.js");
|
|
2823
1814
|
const result = await cfCreateTunnel(cf.apiToken, cf.accountId, tunnelName.trim());
|
|
2824
1815
|
const { saveState: save } = await import("./state-2SI3P4JG.js");
|
|
2825
1816
|
state.domain.cloudflare.tunnelId = result.tunnelId;
|
|
@@ -2828,13 +1819,27 @@ async function handleCreateTunnel(res, body, state, projectPath) {
|
|
|
2828
1819
|
state.domain.cloudflare.tunnelMode = "named";
|
|
2829
1820
|
state.domain.cloudflare.enabled = true;
|
|
2830
1821
|
save(state);
|
|
1822
|
+
if (cf.zoneName) {
|
|
1823
|
+
state.domain.name = cf.zoneName;
|
|
1824
|
+
save(state);
|
|
1825
|
+
}
|
|
1826
|
+
const zoneName = cf.zoneName;
|
|
1827
|
+
try {
|
|
1828
|
+
const { saveGiteaConfig } = await import("./app-manager-FIHPVUP7.js");
|
|
1829
|
+
const adminUsername = state.admin?.username ?? "admin";
|
|
1830
|
+
saveGiteaConfig("http://localhost/git", adminUsername);
|
|
1831
|
+
logger.info("tunnel", `[${tunnelName}] gitea-config.json normalized \u2192 http://localhost/git`);
|
|
1832
|
+
} catch (e) {
|
|
1833
|
+
logger.warn("tunnel", `[${tunnelName}] gitea-config.json update failed: ${e instanceof Error ? e.message : e}`);
|
|
1834
|
+
}
|
|
1835
|
+
const steps = [];
|
|
2831
1836
|
let composeUpdated = false;
|
|
2832
1837
|
let containerRestarted = false;
|
|
2833
|
-
const composePath =
|
|
1838
|
+
const composePath = join(projectPath, "docker-compose.yml");
|
|
2834
1839
|
const { existsSync: fsExists } = await import("fs");
|
|
2835
1840
|
if (fsExists(composePath)) {
|
|
2836
1841
|
try {
|
|
2837
|
-
const { patchCloudflaredToNamedTunnel } = await import("./compose-generator-
|
|
1842
|
+
const { patchCloudflaredToNamedTunnel } = await import("./compose-generator-OFJ2YWMB.js");
|
|
2838
1843
|
composeUpdated = patchCloudflaredToNamedTunnel(composePath, result.tunnelToken);
|
|
2839
1844
|
logger.info("tunnel", `[${tunnelName}] compose patch: composeUpdated=${composeUpdated}`);
|
|
2840
1845
|
} catch (e) {
|
|
@@ -2858,6 +1863,67 @@ async function handleCreateTunnel(res, body, state, projectPath) {
|
|
|
2858
1863
|
logger.warn("tunnel", `[${tunnelName}] cloudflared recreate exception: ${e instanceof Error ? e.message : e}`);
|
|
2859
1864
|
}
|
|
2860
1865
|
}
|
|
1866
|
+
if (cf.apiToken && cf.accountId && cf.tunnelId && zoneName) {
|
|
1867
|
+
try {
|
|
1868
|
+
const { configureTunnelIngress, getActiveServiceRoutes } = await import("./cloudflare-client-F2TGQXGS.js");
|
|
1869
|
+
const routes = getActiveServiceRoutes(state).map((r) => ({ ...r, domain: zoneName }));
|
|
1870
|
+
await configureTunnelIngress(cf.apiToken, cf.accountId, cf.tunnelId, zoneName, routes);
|
|
1871
|
+
steps.push({ step: "ingress_configured", success: true, services: routes.map((r) => r.subdomain) });
|
|
1872
|
+
logger.info("tunnel", `[${tunnelName}] ingress configured for: ${routes.map((r) => r.subdomain).join(", ")}`);
|
|
1873
|
+
} catch (e) {
|
|
1874
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1875
|
+
steps.push({ step: "ingress_configured", success: false, detail: msg });
|
|
1876
|
+
logger.warn("tunnel", `[${tunnelName}] ingress configure failed (non-fatal): ${msg}`);
|
|
1877
|
+
}
|
|
1878
|
+
const { createDnsRecord, getActiveServiceRoutes: getRoutes } = await import("./cloudflare-client-F2TGQXGS.js");
|
|
1879
|
+
const dnsRoutes = getRoutes(state);
|
|
1880
|
+
const dnsResults = [];
|
|
1881
|
+
const dnsFailed = [];
|
|
1882
|
+
for (const route of dnsRoutes) {
|
|
1883
|
+
try {
|
|
1884
|
+
await createDnsRecord(cf.apiToken, cf.zoneId, cf.tunnelId, route.subdomain, zoneName);
|
|
1885
|
+
dnsResults.push(route.subdomain);
|
|
1886
|
+
} catch (e) {
|
|
1887
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1888
|
+
logger.warn("tunnel", `[${tunnelName}] DNS CNAME for ${route.subdomain} failed (non-fatal): ${msg}`);
|
|
1889
|
+
dnsFailed.push(route.subdomain);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
steps.push({ step: "dns_created", success: dnsFailed.length === 0, services: dnsResults, ...dnsFailed.length > 0 ? { detail: `skipped: ${dnsFailed.join(", ")}` } : {} });
|
|
1893
|
+
}
|
|
1894
|
+
if (zoneName) {
|
|
1895
|
+
try {
|
|
1896
|
+
const { patchBuiltinServicesForNamedTunnel } = await import("./compose-generator-OFJ2YWMB.js");
|
|
1897
|
+
const patchedServices = patchBuiltinServicesForNamedTunnel(composePath, zoneName);
|
|
1898
|
+
steps.push({ step: "services_env_patched", success: true, services: patchedServices });
|
|
1899
|
+
logger.info("tunnel", `[${tunnelName}] env patched for: ${patchedServices.join(", ") || "none"}`);
|
|
1900
|
+
if (patchedServices.length > 0) {
|
|
1901
|
+
try {
|
|
1902
|
+
const { execa: execaSvc } = await import("execa");
|
|
1903
|
+
const restart = await execaSvc(
|
|
1904
|
+
"docker",
|
|
1905
|
+
["compose", "-f", composePath, "up", "-d", "--force-recreate", ...patchedServices],
|
|
1906
|
+
{ cwd: projectPath, reject: false }
|
|
1907
|
+
);
|
|
1908
|
+
const restarted = restart.exitCode === 0;
|
|
1909
|
+
steps.push({ step: "services_restarted", success: restarted, services: patchedServices });
|
|
1910
|
+
if (!restarted) {
|
|
1911
|
+
logger.warn("tunnel", `[${tunnelName}] service restart failed (exit ${restart.exitCode}): ${restart.stderr}`);
|
|
1912
|
+
} else {
|
|
1913
|
+
logger.info("tunnel", `[${tunnelName}] restarted: ${patchedServices.join(", ")}`);
|
|
1914
|
+
}
|
|
1915
|
+
} catch (e) {
|
|
1916
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1917
|
+
steps.push({ step: "services_restarted", success: false, detail: msg });
|
|
1918
|
+
logger.warn("tunnel", `[${tunnelName}] service restart exception: ${msg}`);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
} catch (e) {
|
|
1922
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1923
|
+
steps.push({ step: "services_env_patched", success: false, detail: msg });
|
|
1924
|
+
logger.warn("tunnel", `[${tunnelName}] env patch failed (non-fatal): ${msg}`);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
2861
1927
|
} else {
|
|
2862
1928
|
logger.warn("tunnel", `[${tunnelName}] compose file not found at ${composePath} \u2014 cloudflared must be updated manually`);
|
|
2863
1929
|
}
|
|
@@ -2866,7 +1932,8 @@ async function handleCreateTunnel(res, body, state, projectPath) {
|
|
|
2866
1932
|
tunnelId: result.tunnelId,
|
|
2867
1933
|
tunnelName: tunnelName.trim(),
|
|
2868
1934
|
composeUpdated,
|
|
2869
|
-
containerRestarted
|
|
1935
|
+
containerRestarted,
|
|
1936
|
+
steps
|
|
2870
1937
|
});
|
|
2871
1938
|
} catch (err) {
|
|
2872
1939
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -2935,13 +2002,13 @@ async function handleSettingsCloudflarePut(res, body, state) {
|
|
|
2935
2002
|
}
|
|
2936
2003
|
const { saveState: save } = await import("./state-2SI3P4JG.js");
|
|
2937
2004
|
state.domain.cloudflare.apiToken = apiToken;
|
|
2938
|
-
let resolvedAccountId = accountId || state.domain.cloudflare.accountId || await (await import("./cloudflare-client-
|
|
2005
|
+
let resolvedAccountId = accountId || state.domain.cloudflare.accountId || await (await import("./cloudflare-client-F2TGQXGS.js")).getAccounts(apiToken).then((a) => a[0]?.id ?? "").catch(() => "");
|
|
2939
2006
|
if (zoneId) state.domain.cloudflare.zoneId = zoneId;
|
|
2940
2007
|
if (tunnelId) state.domain.cloudflare.tunnelId = tunnelId;
|
|
2941
2008
|
let zoneName = state.domain.cloudflare.zoneName;
|
|
2942
2009
|
if (zoneId) {
|
|
2943
2010
|
try {
|
|
2944
|
-
const { getZones } = await import("./cloudflare-client-
|
|
2011
|
+
const { getZones } = await import("./cloudflare-client-F2TGQXGS.js");
|
|
2945
2012
|
const zones = await getZones(apiToken);
|
|
2946
2013
|
const found = zones.find((z) => z.id === zoneId);
|
|
2947
2014
|
if (found) {
|
|
@@ -2970,4 +2037,4 @@ async function handleSettingsCloudflarePut(res, body, state) {
|
|
|
2970
2037
|
export {
|
|
2971
2038
|
createAdminServer
|
|
2972
2039
|
};
|
|
2973
|
-
//# sourceMappingURL=chunk-
|
|
2040
|
+
//# sourceMappingURL=chunk-Q6UUZR2V.js.map
|