@brewnet/cli 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/admin-server-UODBPGWR.js +16 -0
  2. package/dist/app-manager-FIHPVUP7.js +51 -0
  3. package/dist/{boilerplate-manager-P6QYUU7Q.js → boilerplate-manager-WEFTHL2O.js} +3 -3
  4. package/dist/{chunk-DH2VK3YI.js → chunk-54WFZCU6.js} +2 -2
  5. package/dist/{chunk-4TJMJZMO.js → chunk-AXSHZEB3.js} +63 -1
  6. package/dist/{chunk-4TJMJZMO.js.map → chunk-AXSHZEB3.js.map} +1 -1
  7. package/dist/chunk-FZZ3HP2G.js +1151 -0
  8. package/dist/chunk-FZZ3HP2G.js.map +1 -0
  9. package/dist/chunk-JIPAYMOA.js +58 -0
  10. package/dist/chunk-JIPAYMOA.js.map +1 -0
  11. package/dist/{chunk-SIXBB6JU.js → chunk-Q6UUZR2V.js} +238 -1171
  12. package/dist/chunk-Q6UUZR2V.js.map +1 -0
  13. package/dist/{chunk-2VWMDHGI.js → chunk-YAYXULLO.js} +9 -61
  14. package/dist/chunk-YAYXULLO.js.map +1 -0
  15. package/dist/{chunk-JFPHGZ6Z.js → chunk-YXFDB5YX.js} +17 -2
  16. package/dist/chunk-YXFDB5YX.js.map +1 -0
  17. package/dist/{cloudflare-client-TFT6VCXF.js → cloudflare-client-F2TGQXGS.js} +2 -2
  18. package/dist/{compose-generator-O7GSIJ2S.js → compose-generator-OFJ2YWMB.js} +4 -2
  19. package/dist/compose-generator-OFJ2YWMB.js.map +1 -0
  20. package/dist/index.js +122 -168
  21. package/dist/index.js.map +1 -1
  22. package/dist/services/admin-daemon.js +6 -4
  23. package/dist/services/admin-daemon.js.map +1 -1
  24. package/package.json +1 -1
  25. package/dist/admin-server-DQVIEHV3.js +0 -14
  26. package/dist/chunk-2VWMDHGI.js.map +0 -1
  27. package/dist/chunk-JFPHGZ6Z.js.map +0 -1
  28. package/dist/chunk-SIXBB6JU.js.map +0 -1
  29. /package/dist/{admin-server-DQVIEHV3.js.map → admin-server-UODBPGWR.js.map} +0 -0
  30. /package/dist/{boilerplate-manager-P6QYUU7Q.js.map → app-manager-FIHPVUP7.js.map} +0 -0
  31. /package/dist/{cloudflare-client-TFT6VCXF.js.map → boilerplate-manager-WEFTHL2O.js.map} +0 -0
  32. /package/dist/{chunk-DH2VK3YI.js.map → chunk-54WFZCU6.js.map} +0 -0
  33. /package/dist/{compose-generator-O7GSIJ2S.js.map → cloudflare-client-F2TGQXGS.js.map} +0 -0
@@ -0,0 +1,1151 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ addApp,
4
+ appendDeployHistory,
5
+ readApps,
6
+ readDeployHistory,
7
+ removeApp,
8
+ updateApp
9
+ } from "./chunk-JIPAYMOA.js";
10
+ import {
11
+ getLastProject,
12
+ loadState
13
+ } from "./chunk-ZKMWE5AH.js";
14
+
15
+ // src/services/app-manager.ts
16
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, readdirSync } from "fs";
17
+ import { join } from "path";
18
+ import { homedir } from "os";
19
+ import { randomBytes } from "crypto";
20
+ import { execa } from "execa";
21
+
22
+ // src/services/gitea-client.ts
23
+ import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync, unlinkSync } from "fs";
24
+ import { dirname } from "path";
25
+ import { execSync } from "child_process";
26
+ var GiteaClient = class {
27
+ config;
28
+ constructor(config) {
29
+ this.config = config;
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // Token management
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * Create a Gitea API token via Basic Auth.
36
+ * If the admin account has mustChangePassword=true (403), auto-fixes via docker exec and retries.
37
+ * Saves the token to tokenPath on success.
38
+ */
39
+ async _createToken() {
40
+ const { tokenPath, baseUrl, username, password } = this.config;
41
+ const basic = Buffer.from(`${username}:${password}`).toString("base64");
42
+ const makeRequest = () => fetch(`${baseUrl}/api/v1/users/${username}/tokens`, {
43
+ method: "POST",
44
+ headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/json" },
45
+ body: JSON.stringify({
46
+ name: `brewnet-${Date.now()}`,
47
+ scopes: ["write:repository", "read:repository", "write:user", "read:user"]
48
+ }),
49
+ signal: AbortSignal.timeout(8e3)
50
+ });
51
+ let res = await makeRequest();
52
+ let wasFixed = false;
53
+ if (!res.ok) {
54
+ const body = await res.text();
55
+ if (res.status === 403 && body.includes("must change")) {
56
+ try {
57
+ execSync(
58
+ `docker exec -u git brewnet-gitea gitea admin user change-password --username ${username} --password ${password} --must-change-password=false`,
59
+ { stdio: "pipe" }
60
+ );
61
+ } catch (e) {
62
+ const stderr = e.stderr?.toString().trim() ?? String(e);
63
+ throw new Error(
64
+ `Gitea admin requires password change \u2014 auto-fix failed:
65
+ ${stderr}
66
+ Manual fix: docker exec -u git brewnet-gitea gitea admin user change-password --username ${username} --password <password> --must-change-password=false`
67
+ );
68
+ }
69
+ wasFixed = true;
70
+ res = await makeRequest();
71
+ if (!res.ok) {
72
+ throw new Error(
73
+ `Gitea token creation failed after auto-fix: ${res.status} ${await res.text()}`
74
+ );
75
+ }
76
+ } else {
77
+ throw new Error(`Gitea token creation failed: ${res.status} ${body}`);
78
+ }
79
+ }
80
+ const data = await res.json();
81
+ mkdirSync(dirname(tokenPath), { recursive: true });
82
+ writeFileSync(tokenPath, data.sha1, "utf-8");
83
+ chmodSync(tokenPath, 384);
84
+ return { wasFixed };
85
+ }
86
+ /**
87
+ * Explicit setup step — call once before any API operations.
88
+ * Validates any cached token; deletes and re-creates if stale (401).
89
+ * Returns what happened so the caller can surface it in job step logs.
90
+ */
91
+ async prepare() {
92
+ const { tokenPath, baseUrl } = this.config;
93
+ if (existsSync(tokenPath)) {
94
+ const token = readFileSync(tokenPath, "utf-8").trim();
95
+ try {
96
+ const check = await fetch(`${baseUrl}/api/v1/user`, {
97
+ headers: { Authorization: `token ${token}` },
98
+ signal: AbortSignal.timeout(8e3)
99
+ });
100
+ if (check.status !== 401) {
101
+ return { autoFixed: false, message: "token cached" };
102
+ }
103
+ unlinkSync(tokenPath);
104
+ } catch {
105
+ return { autoFixed: false, message: "token cached (network check skipped)" };
106
+ }
107
+ }
108
+ const { wasFixed } = await this._createToken();
109
+ return {
110
+ autoFixed: wasFixed,
111
+ message: wasFixed ? "mustChangePassword was set \u2014 auto-fixed via docker exec; token created" : "token created"
112
+ };
113
+ }
114
+ async ensureToken() {
115
+ const { tokenPath } = this.config;
116
+ if (existsSync(tokenPath)) {
117
+ return readFileSync(tokenPath, "utf-8").trim();
118
+ }
119
+ await this._createToken();
120
+ return readFileSync(tokenPath, "utf-8").trim();
121
+ }
122
+ async authHeaders() {
123
+ return {
124
+ Authorization: `token ${await this.ensureToken()}`,
125
+ "Content-Type": "application/json"
126
+ };
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Repository operations
130
+ // ---------------------------------------------------------------------------
131
+ async repoExists(name) {
132
+ const { baseUrl, username } = this.config;
133
+ const res = await fetch(
134
+ `${baseUrl}/api/v1/repos/${username}/${name}`,
135
+ { headers: await this.authHeaders() }
136
+ );
137
+ return res.status === 200;
138
+ }
139
+ /** Returns true if the repo exists but has no commits (empty: true from Gitea API). */
140
+ async repoIsEmpty(name) {
141
+ const { baseUrl, username } = this.config;
142
+ const res = await fetch(
143
+ `${baseUrl}/api/v1/repos/${username}/${name}`,
144
+ { headers: await this.authHeaders() }
145
+ );
146
+ if (res.status !== 200) return false;
147
+ const data = await res.json();
148
+ return data.empty === true;
149
+ }
150
+ /** Creates a private repo and returns the clone URL. */
151
+ async createRepo(name, description = "") {
152
+ const { baseUrl } = this.config;
153
+ const res = await fetch(`${baseUrl}/api/v1/user/repos`, {
154
+ method: "POST",
155
+ headers: await this.authHeaders(),
156
+ body: JSON.stringify({ name, description, private: false, auto_init: false })
157
+ });
158
+ if (!res.ok) {
159
+ const body = await res.text();
160
+ if (res.status === 409) {
161
+ const existing = await fetch(`${baseUrl}/api/v1/repos/${this.config.username}/${name}`, {
162
+ headers: await this.authHeaders()
163
+ });
164
+ if (existing.ok) {
165
+ const data2 = await existing.json();
166
+ return data2.clone_url;
167
+ }
168
+ }
169
+ if (res.status === 500 && body.includes("files already exist")) {
170
+ await this.deleteRepo(name).catch(() => {
171
+ });
172
+ const retry = await fetch(`${baseUrl}/api/v1/user/repos`, {
173
+ method: "POST",
174
+ headers: await this.authHeaders(),
175
+ body: JSON.stringify({ name, description, private: false, auto_init: false })
176
+ });
177
+ if (!retry.ok) {
178
+ throw new Error(`Gitea createRepo retry failed: ${retry.status} ${await retry.text()}`);
179
+ }
180
+ const retryData = await retry.json();
181
+ return retryData.clone_url;
182
+ }
183
+ throw new Error(`Gitea createRepo failed: ${res.status} ${body}`);
184
+ }
185
+ const data = await res.json();
186
+ return data.clone_url;
187
+ }
188
+ /** Patch a repo from private to public visibility. No-op if already public. */
189
+ async makeRepoPublic(name) {
190
+ const { baseUrl, username } = this.config;
191
+ const res = await fetch(`${baseUrl}/api/v1/repos/${username}/${name}`, {
192
+ method: "PATCH",
193
+ headers: await this.authHeaders(),
194
+ body: JSON.stringify({ private: false })
195
+ });
196
+ if (!res.ok) throw new Error(`Gitea makeRepoPublic failed: ${res.status} ${await res.text()}`);
197
+ }
198
+ async deleteRepo(name) {
199
+ const { baseUrl, username } = this.config;
200
+ await fetch(`${baseUrl}/api/v1/repos/${username}/${name}`, {
201
+ method: "DELETE",
202
+ headers: await this.authHeaders()
203
+ });
204
+ }
205
+ /** Returns all repos accessible to the authenticated user. */
206
+ async listRepos() {
207
+ const { baseUrl } = this.config;
208
+ const res = await fetch(`${baseUrl}/api/v1/user/repos`, {
209
+ headers: await this.authHeaders(),
210
+ signal: AbortSignal.timeout(8e3)
211
+ });
212
+ if (!res.ok) {
213
+ throw new Error(`Gitea listRepos failed: ${res.status} ${await res.text()}`);
214
+ }
215
+ return await res.json();
216
+ }
217
+ /** Fetch a single repo's detail (includes default_branch, ssh_url). */
218
+ async getRepo(name) {
219
+ const { baseUrl, username } = this.config;
220
+ const res = await fetch(`${baseUrl}/api/v1/repos/${username}/${name}`, {
221
+ headers: await this.authHeaders()
222
+ });
223
+ if (!res.ok) throw new Error(`Gitea getRepo failed: ${res.status} ${await res.text()}`);
224
+ return res.json();
225
+ }
226
+ /** Get the latest commit on a branch. Returns null for empty repos. */
227
+ async getLatestCommit(repoName, branch) {
228
+ const { baseUrl, username } = this.config;
229
+ const res = await fetch(
230
+ `${baseUrl}/api/v1/repos/${username}/${repoName}/commits?sha=${encodeURIComponent(branch)}&limit=1`,
231
+ { headers: await this.authHeaders() }
232
+ );
233
+ if (!res.ok) return null;
234
+ const commits = await res.json();
235
+ if (!commits.length) return null;
236
+ const c = commits[0];
237
+ return {
238
+ hash: c.sha,
239
+ shortHash: c.sha.slice(0, 7),
240
+ message: c.commit.message.split("\n")[0],
241
+ date: c.commit.committer.date
242
+ };
243
+ }
244
+ /** Register a push webhook on the repo. */
245
+ async createWebhook(repoName, webhookUrl, secret) {
246
+ const { baseUrl, username } = this.config;
247
+ const res = await fetch(`${baseUrl}/api/v1/repos/${username}/${repoName}/hooks`, {
248
+ method: "POST",
249
+ headers: await this.authHeaders(),
250
+ body: JSON.stringify({
251
+ type: "gitea",
252
+ config: { url: webhookUrl, content_type: "json", secret },
253
+ events: ["push"],
254
+ active: true
255
+ })
256
+ });
257
+ if (!res.ok) throw new Error(`Gitea createWebhook failed: ${res.status} ${await res.text()}`);
258
+ }
259
+ /** Returns branch names for a repo. Falls back to empty array on error. */
260
+ async listBranches(repoName) {
261
+ const { baseUrl, username } = this.config;
262
+ const res = await fetch(
263
+ `${baseUrl}/api/v1/repos/${username}/${repoName}/branches?limit=50`,
264
+ { headers: await this.authHeaders(), signal: AbortSignal.timeout(8e3) }
265
+ );
266
+ if (!res.ok) return [];
267
+ const data = await res.json();
268
+ return data.map((b) => b.name);
269
+ }
270
+ /** URL suitable for git remote add — includes credentials in URL (stored in .git/config which is chmod 600). */
271
+ authedCloneUrl(cloneUrl) {
272
+ const { username, password, tokenPath, baseUrl } = this.config;
273
+ const credential = existsSync(tokenPath) ? readFileSync(tokenPath, "utf-8").trim() : password;
274
+ const encUser = encodeURIComponent(username);
275
+ const encCred = encodeURIComponent(credential);
276
+ const repoMatch = cloneUrl.match(/\/([^/]+\/[^/]+\.git)$/);
277
+ const normalizedUrl = repoMatch ? `${baseUrl.replace(/\/$/, "")}/${repoMatch[1]}` : cloneUrl;
278
+ return normalizedUrl.replace("http://", `http://${encUser}:${encCred}@`);
279
+ }
280
+ };
281
+
282
+ // src/services/app-manager.ts
283
+ var BREWNET_DIR = join(homedir(), ".brewnet");
284
+ var GITEA_TOKEN_PATH = join(BREWNET_DIR, "gitea-token");
285
+ var GITEA_CONFIG_PATH = join(BREWNET_DIR, "gitea-config.json");
286
+ var DEPLOY_HISTORY_PATH = join(BREWNET_DIR, "deploy-history.json");
287
+ var jobs = /* @__PURE__ */ new Map();
288
+ function resolveAppsJsonPath() {
289
+ return join(BREWNET_DIR, "apps.json");
290
+ }
291
+ function loadGiteaConfig() {
292
+ if (!existsSync2(GITEA_CONFIG_PATH)) return null;
293
+ try {
294
+ return JSON.parse(readFileSync2(GITEA_CONFIG_PATH, "utf-8"));
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+ function saveGiteaConfig(baseUrl, username) {
300
+ try {
301
+ const config = { baseUrl, username, writtenAt: (/* @__PURE__ */ new Date()).toISOString() };
302
+ writeFileSync2(GITEA_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
303
+ } catch (e) {
304
+ console.warn("[app-manager] gitea-config.json \uC800\uC7A5 \uC2E4\uD328:", e instanceof Error ? e.message : e);
305
+ }
306
+ }
307
+ function readDotEnvValue(envPath, key) {
308
+ if (!existsSync2(envPath)) return "";
309
+ const lines = readFileSync2(envPath, "utf-8").split("\n");
310
+ for (const line of lines) {
311
+ const trimmed = line.trim();
312
+ if (trimmed.startsWith(`${key}=`)) {
313
+ return trimmed.slice(key.length + 1).trim();
314
+ }
315
+ }
316
+ return "";
317
+ }
318
+ var _boilerplateRegistered = false;
319
+ async function listApps() {
320
+ const appsJson = resolveAppsJsonPath();
321
+ const apps = readApps(appsJson);
322
+ if (_boilerplateRegistered) return apps;
323
+ _boilerplateRegistered = true;
324
+ try {
325
+ const ctx = resolveContext();
326
+ const bpPath = join(ctx.projectPath, ".brewnet-boilerplate.json");
327
+ if (existsSync2(bpPath)) {
328
+ const raw = JSON.parse(readFileSync2(bpPath, "utf-8"));
329
+ const bpMetas = Array.isArray(raw) ? raw : [raw];
330
+ let changed = false;
331
+ for (const bp of bpMetas) {
332
+ if (!bp.stackId || !bp.appDir) continue;
333
+ const exists = apps.some((a) => a.appDir === bp.appDir || a.stackId === bp.stackId);
334
+ if (!exists) {
335
+ const port = bp.backendUrl ? parseInt(new URL(bp.backendUrl).port || "8080", 10) : 8080;
336
+ const entry = {
337
+ name: bp.stackId,
338
+ mode: "boilerplate",
339
+ stackId: bp.stackId,
340
+ appDir: bp.appDir,
341
+ lang: bp.lang,
342
+ framework: bp.frameworkId,
343
+ port,
344
+ status: bp.status || "running",
345
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
346
+ };
347
+ apps.push(entry);
348
+ changed = true;
349
+ }
350
+ }
351
+ if (changed) {
352
+ writeFileSync2(appsJson, JSON.stringify(apps, null, 2), "utf-8");
353
+ }
354
+ }
355
+ } catch {
356
+ }
357
+ return apps;
358
+ }
359
+ function getDeployHistory(appName) {
360
+ const entries = readDeployHistory(DEPLOY_HISTORY_PATH);
361
+ if (!appName) return entries;
362
+ return entries.filter((e) => e.appName === appName);
363
+ }
364
+ async function listGiteaRepos() {
365
+ const ctx = resolveContext();
366
+ const gitea = new GiteaClient({
367
+ baseUrl: ctx.giteaBaseUrl,
368
+ username: ctx.giteaUser,
369
+ password: ctx.giteaPassword,
370
+ tokenPath: GITEA_TOKEN_PATH
371
+ });
372
+ await gitea.prepare();
373
+ return gitea.listRepos();
374
+ }
375
+ function getDeploySettings(appName) {
376
+ const apps = readApps(resolveAppsJsonPath());
377
+ const app = apps.find((a) => a.name === appName);
378
+ const settings = app?.deploySettings;
379
+ return settings ?? { autoDeploy: false, deployBranch: "main" };
380
+ }
381
+ function updateDeploySettings(appName, settings) {
382
+ const appsJson = resolveAppsJsonPath();
383
+ const apps = readApps(appsJson);
384
+ const app = apps.find((a) => a.name === appName);
385
+ if (!app) throw new Error(`App "${appName}" not found`);
386
+ const existing = app.deploySettings ?? { autoDeploy: false, deployBranch: "main" };
387
+ app.deploySettings = { ...existing, ...settings };
388
+ updateApp(appsJson, appName, app);
389
+ }
390
+ async function getAppGitInfo(appName) {
391
+ const ctx = resolveContext();
392
+ const apps = readApps(resolveAppsJsonPath());
393
+ const app = apps.find((a) => a.name === appName);
394
+ if (!app) throw new Error(`App "${appName}" not found`);
395
+ const gitea = new GiteaClient({
396
+ baseUrl: ctx.giteaBaseUrl,
397
+ username: ctx.giteaUser,
398
+ password: ctx.giteaPassword,
399
+ tokenPath: GITEA_TOKEN_PATH
400
+ });
401
+ let branch = "main";
402
+ let latestCommit = null;
403
+ let cloneUrlSsh = `ssh://git@localhost:2222/${ctx.giteaUser}/${appName}.git`;
404
+ try {
405
+ const repo = await gitea.getRepo(appName);
406
+ branch = repo.default_branch || "main";
407
+ cloneUrlSsh = repo.ssh_url || cloneUrlSsh;
408
+ if (repo.private) {
409
+ await gitea.makeRepoPublic(appName).catch((e) => {
410
+ console.warn(`[app-manager] makeRepoPublic failed for ${appName}: ${e instanceof Error ? e.message : String(e)}`);
411
+ });
412
+ }
413
+ latestCommit = await gitea.getLatestCommit(appName, branch);
414
+ } catch (e) {
415
+ console.warn(`[app-manager] getAppGitInfo Gitea call failed (${appName}): ${e instanceof Error ? e.message : String(e)}`);
416
+ }
417
+ return {
418
+ giteaUrl: `${ctx.giteaDisplayUrl}/${ctx.giteaUser}/${appName}`,
419
+ cloneUrlHttp: `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${appName}.git`,
420
+ cloneUrlSsh,
421
+ localPath: app.appDir,
422
+ branch,
423
+ latestCommit
424
+ };
425
+ }
426
+ async function rollbackApp(appName, commitHash) {
427
+ const job = newJob(appName, ["Checkout", "Build & Start", "Health check"]);
428
+ jobs.set(job.jobId, job);
429
+ setImmediate(() => void _runRollback(job, appName, commitHash));
430
+ return job.jobId;
431
+ }
432
+ async function _runRollback(job, appName, commitHash) {
433
+ try {
434
+ const apps = readApps(resolveAppsJsonPath());
435
+ const app = apps.find((a) => a.name === appName);
436
+ if (!app) throw new Error(`App "${appName}" not found`);
437
+ const target = commitHash || "HEAD~1";
438
+ setStep(job, 0, "running", `git checkout ${target.slice(0, 7)}`);
439
+ await execa("git", ["checkout", target], { cwd: app.appDir });
440
+ setStep(job, 0, "done");
441
+ await _injectQuickTunnelIfNeeded(app.appDir, appName, app.port);
442
+ setStep(job, 1, "running", "docker compose up --build");
443
+ await _dockerComposeUp(app.appDir, job);
444
+ setStep(job, 1, "done", "containers started");
445
+ setStep(job, 2, "running");
446
+ const healthUrl = _buildHealthUrl(app.appDir, app.port);
447
+ setStep(job, 2, "running", `polling ${healthUrl}`);
448
+ await _pollHealth(healthUrl, 12e4, job);
449
+ setStep(job, 2, "done");
450
+ updateApp(resolveAppsJsonPath(), appName, { status: "running" });
451
+ appendDeployHistory(DEPLOY_HISTORY_PATH, {
452
+ appName,
453
+ commitHash,
454
+ commitMessage: `Rollback to ${commitHash.slice(0, 7)}`,
455
+ status: "success",
456
+ deployedAt: (/* @__PURE__ */ new Date()).toISOString()
457
+ });
458
+ job.status = "done";
459
+ } catch (err) {
460
+ job.status = "failed";
461
+ job.error = err instanceof Error ? err.message : String(err);
462
+ for (const step of job.steps) {
463
+ if (step.status === "running" || step.status === "pending") step.status = "failed";
464
+ }
465
+ appendDeployHistory(DEPLOY_HISTORY_PATH, {
466
+ appName,
467
+ commitHash,
468
+ commitMessage: `Rollback to ${commitHash.slice(0, 7)}`,
469
+ status: "failed",
470
+ deployedAt: (/* @__PURE__ */ new Date()).toISOString()
471
+ });
472
+ }
473
+ }
474
+ async function getAppBranches(appName) {
475
+ const ctx = resolveContext();
476
+ const gitea = new GiteaClient({
477
+ baseUrl: ctx.giteaBaseUrl,
478
+ username: ctx.giteaUser,
479
+ password: ctx.giteaPassword,
480
+ tokenPath: GITEA_TOKEN_PATH
481
+ });
482
+ return gitea.listBranches(appName);
483
+ }
484
+ async function setupWebhook(appName, webhookUrl) {
485
+ const ctx = resolveContext();
486
+ const settings = getDeploySettings(appName);
487
+ const secret = settings.webhookSecret ?? randomBytes(16).toString("hex");
488
+ const gitea = new GiteaClient({
489
+ baseUrl: ctx.giteaBaseUrl,
490
+ username: ctx.giteaUser,
491
+ password: ctx.giteaPassword,
492
+ tokenPath: GITEA_TOKEN_PATH
493
+ });
494
+ await gitea.createWebhook(appName, webhookUrl, secret);
495
+ updateDeploySettings(appName, { webhookSecret: secret });
496
+ }
497
+ async function deployApp(appName) {
498
+ const job = newJob(appName, ["Pull", "Build & Start", "Health check"]);
499
+ jobs.set(job.jobId, job);
500
+ setImmediate(() => void _runDeploy(job, appName));
501
+ return job.jobId;
502
+ }
503
+ async function _runDeploy(job, appName) {
504
+ const apps = readApps(resolveAppsJsonPath());
505
+ const app = apps.find((a) => a.name === appName);
506
+ if (!app) {
507
+ job.status = "failed";
508
+ job.error = `App "${appName}" not found`;
509
+ return;
510
+ }
511
+ try {
512
+ const settings = getDeploySettings(appName);
513
+ setStep(job, 0, "running");
514
+ try {
515
+ const ctx = resolveContext();
516
+ const gitea = new GiteaClient({
517
+ baseUrl: ctx.giteaBaseUrl,
518
+ username: ctx.giteaUser,
519
+ password: ctx.giteaPassword,
520
+ tokenPath: GITEA_TOKEN_PATH
521
+ });
522
+ await gitea.prepare();
523
+ const repoExists = await gitea.repoExists(appName);
524
+ if (!repoExists) {
525
+ appendLog(job, "[pull] Gitea repo not found \u2014 recreating and pushing local code");
526
+ const cloneUrl = await gitea.createRepo(appName, `Brewnet app: ${appName}`);
527
+ const authedUrl = gitea.authedCloneUrl(cloneUrl);
528
+ await execa("git", ["remote", "add", "brewnet", authedUrl], { cwd: app.appDir }).catch(
529
+ () => execa("git", ["remote", "set-url", "brewnet", authedUrl], { cwd: app.appDir })
530
+ );
531
+ await execa("git", ["push", "brewnet", "HEAD:main", "--force"], { cwd: app.appDir });
532
+ appendLog(job, "[pull] Gitea repo recreated and code pushed \u2713");
533
+ } else if (!existsSync2(app.appDir)) {
534
+ appendLog(job, "[pull] appDir missing \u2014 cloning from Gitea");
535
+ const authedUrl = gitea.authedCloneUrl(`${ctx.giteaBaseUrl}/${ctx.giteaUser}/${appName}.git`);
536
+ await execa("git", ["clone", authedUrl, app.appDir]);
537
+ appendLog(job, "[pull] re-cloned from Gitea \u2713");
538
+ } else if (await gitea.repoIsEmpty(appName)) {
539
+ appendLog(job, "[pull] Gitea repo is empty \u2014 pushing local code");
540
+ const isShallow = await execa("git", ["rev-parse", "--is-shallow-repository"], { cwd: app.appDir }).then((r) => r.stdout.trim() === "true").catch(() => false);
541
+ if (isShallow) {
542
+ appendLog(job, "[pull] shallow clone detected \u2014 unshallowing");
543
+ await execa("git", ["fetch", "--unshallow", "origin"], { cwd: app.appDir }).catch(async () => {
544
+ const { reinitGit } = await import("./boilerplate-manager-WEFTHL2O.js");
545
+ await reinitGit(app.appDir);
546
+ });
547
+ }
548
+ const authedUrl = gitea.authedCloneUrl(`${ctx.giteaBaseUrl}/${ctx.giteaUser}/${appName}.git`);
549
+ await execa("git", ["remote", "add", "brewnet", authedUrl], { cwd: app.appDir }).catch(
550
+ () => execa("git", ["remote", "set-url", "brewnet", authedUrl], { cwd: app.appDir })
551
+ );
552
+ await execa("git", ["push", "brewnet", "HEAD:main", "--force"], { cwd: app.appDir });
553
+ appendLog(job, "[pull] code pushed to Gitea \u2713");
554
+ } else {
555
+ await execa("git", ["pull", "brewnet", settings.deployBranch], { cwd: app.appDir }).catch((e) => {
556
+ appendLog(job, `[pull] git pull failed (non-critical): ${e instanceof Error ? e.message : String(e)}`);
557
+ });
558
+ }
559
+ } catch (e) {
560
+ const msg = e instanceof Error ? e.message : String(e);
561
+ appendLog(job, `[pull] Gitea sync failed (non-critical): ${msg}`);
562
+ console.warn(`[app-manager] Gitea sync failed for "${appName}": ${msg}`);
563
+ }
564
+ setStep(job, 0, "done");
565
+ const hasCompose = existsSync2(join(app.appDir, "docker-compose.yml")) || existsSync2(join(app.appDir, "compose.yml"));
566
+ if (!hasCompose) {
567
+ const projectType = _detectProjectType(app.appDir);
568
+ if (projectType) {
569
+ appendLog(job, `[scaffold] Detected ${projectType} project \u2014 generating Docker config`);
570
+ _scaffoldDockerConfig(app.appDir, appName, app.port, job, projectType);
571
+ } else {
572
+ throw new Error(
573
+ "This project has no docker-compose.yml or Dockerfile. Add a Dockerfile and docker-compose.yml to deploy, or use a Brewnet boilerplate."
574
+ );
575
+ }
576
+ }
577
+ await _injectQuickTunnelIfNeeded(app.appDir, appName, app.port);
578
+ setStep(job, 1, "running", "docker compose up --build");
579
+ await _dockerComposeUp(app.appDir, job);
580
+ setStep(job, 1, "done", "containers started");
581
+ setStep(job, 2, "running");
582
+ const healthUrlDeploy = _buildHealthUrl(app.appDir, app.port);
583
+ setStep(job, 2, "running", `polling ${healthUrlDeploy}`);
584
+ await _pollHealth(healthUrlDeploy, 12e4, job);
585
+ setStep(job, 2, "done");
586
+ updateApp(resolveAppsJsonPath(), appName, { status: "running" });
587
+ const headHash = await execa("git", ["rev-parse", "HEAD"], { cwd: app.appDir }).then((r) => r.stdout.trim()).catch(() => "");
588
+ const headMsg = await execa("git", ["log", "-1", "--format=%s"], { cwd: app.appDir }).then((r) => r.stdout.trim()).catch(() => "Manual deploy");
589
+ appendDeployHistory(DEPLOY_HISTORY_PATH, {
590
+ appName,
591
+ commitHash: headHash,
592
+ commitMessage: headMsg,
593
+ status: "success",
594
+ deployedAt: (/* @__PURE__ */ new Date()).toISOString()
595
+ });
596
+ job.status = "done";
597
+ } catch (err) {
598
+ job.status = "failed";
599
+ job.error = err instanceof Error ? err.message : String(err);
600
+ for (const step of job.steps) {
601
+ if (step.status === "running" || step.status === "pending") step.status = "failed";
602
+ }
603
+ const headHashFail = await execa("git", ["rev-parse", "HEAD"], { cwd: app.appDir }).then((r) => r.stdout.trim()).catch(() => "");
604
+ appendDeployHistory(DEPLOY_HISTORY_PATH, {
605
+ appName,
606
+ commitHash: headHashFail,
607
+ commitMessage: "Manual deploy",
608
+ status: "failed",
609
+ deployedAt: (/* @__PURE__ */ new Date()).toISOString()
610
+ });
611
+ }
612
+ }
613
+ function getAppDir(appName) {
614
+ const apps = readApps(resolveAppsJsonPath());
615
+ return apps.find((a) => a.name === appName)?.appDir;
616
+ }
617
+ function getJobStatus(jobId) {
618
+ return jobs.get(jobId);
619
+ }
620
+ function newJob(appName, stepLabels) {
621
+ return {
622
+ jobId: randomBytes(8).toString("hex"),
623
+ appName,
624
+ status: "running",
625
+ steps: stepLabels.map((label) => ({ label, status: "pending" }))
626
+ };
627
+ }
628
+ function setStep(job, index, status, message) {
629
+ const step = job.steps[index];
630
+ if (step) {
631
+ step.status = status;
632
+ if (message) step.message = message;
633
+ }
634
+ }
635
+ function appendLog(job, line) {
636
+ if (!job.logs) job.logs = [];
637
+ job.logs.push(line);
638
+ if (job.logs.length > 200) job.logs.splice(0, job.logs.length - 200);
639
+ }
640
+ function readBoilerplateMeta(projectPath) {
641
+ const p = join(projectPath, ".brewnet-boilerplate.json");
642
+ if (!existsSync2(p)) return [];
643
+ try {
644
+ const raw = JSON.parse(readFileSync2(p, "utf-8"));
645
+ return Array.isArray(raw) ? raw : [raw];
646
+ } catch {
647
+ return [];
648
+ }
649
+ }
650
+ function resolveContext() {
651
+ const cached = loadGiteaConfig();
652
+ const last = getLastProject();
653
+ const state = loadState(last ?? "");
654
+ const raw = state?.projectPath ?? process.cwd();
655
+ const projectPath = raw.startsWith("~") ? join(homedir(), raw.slice(1)) : raw;
656
+ const envPath = join(projectPath, ".env");
657
+ const giteaUser = cached?.username ?? (readDotEnvValue(envPath, "GITEA_ADMIN_USER") || state?.admin?.username || "admin");
658
+ const secretsPath = join(projectPath, "secrets", "admin_password");
659
+ const secretsPassword = existsSync2(secretsPath) ? readFileSync2(secretsPath, "utf-8").trim() : "";
660
+ const giteaPassword = secretsPassword || readDotEnvValue(envPath, "GITEA_ADMIN_PASSWORD") || state?.admin?.password || "";
661
+ const giteaBaseUrl = "http://localhost/git";
662
+ const tunnelMode = state?.domain?.cloudflare?.tunnelMode ?? "";
663
+ const zoneName = state?.domain?.cloudflare?.zoneName ?? "";
664
+ const giteaDisplayUrl = tunnelMode === "named" && zoneName ? `https://git.${zoneName}` : giteaBaseUrl;
665
+ return { projectPath, giteaBaseUrl, giteaDisplayUrl, giteaUser, giteaPassword };
666
+ }
667
+ async function _injectQuickTunnelIfNeeded(appDir, appName, port) {
668
+ try {
669
+ const last = getLastProject();
670
+ const state = loadState(last ?? "");
671
+ if (state?.domain?.cloudflare?.tunnelMode !== "quick") return;
672
+ const { injectTraefikForQuickTunnel } = await import("./boilerplate-manager-WEFTHL2O.js");
673
+ injectTraefikForQuickTunnel(appDir, appName, port);
674
+ } catch (err) {
675
+ console.error(`[Quick Tunnel] Failed to inject Traefik labels for ${appName}: ${err instanceof Error ? err.message : String(err)}`);
676
+ }
677
+ }
678
+ function _detectProjectType(dir) {
679
+ try {
680
+ if (existsSync2(join(dir, "next.config.ts")) || existsSync2(join(dir, "next.config.mjs")) || existsSync2(join(dir, "next.config.js"))) return "nextjs";
681
+ if (existsSync2(join(dir, "package.json"))) return "nodejs";
682
+ if (existsSync2(join(dir, "requirements.txt")) || existsSync2(join(dir, "pyproject.toml"))) return "python";
683
+ if (existsSync2(join(dir, "go.mod"))) return "go";
684
+ if (existsSync2(join(dir, "Cargo.toml"))) return "rust";
685
+ if (existsSync2(join(dir, "pom.xml")) || existsSync2(join(dir, "build.gradle")) || existsSync2(join(dir, "build.gradle.kts"))) return "java";
686
+ if (existsSync2(join(dir, "index.html"))) return "static";
687
+ if (readdirSync(dir).some((f) => f.endsWith(".html"))) return "static";
688
+ } catch {
689
+ }
690
+ return null;
691
+ }
692
+ function _scaffoldDockerConfig(dir, _appName, port, job, detectedType) {
693
+ const type = detectedType || _detectProjectType(dir);
694
+ if (!type) throw new Error(`Cannot auto-detect project type in ${dir}. Add a Dockerfile and docker-compose.yml manually.`);
695
+ if (job && !detectedType) appendLog(job, `[scaffold] Detected ${type} project \u2014 generating Docker config`);
696
+ let dockerfile = "";
697
+ switch (type) {
698
+ case "nextjs":
699
+ dockerfile = [
700
+ "FROM node:22-alpine",
701
+ "WORKDIR /app",
702
+ "COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./",
703
+ "RUN npm install --legacy-peer-deps 2>/dev/null || yarn install 2>/dev/null || true",
704
+ "COPY . .",
705
+ "RUN npm run build",
706
+ "ENV PORT=3000 HOSTNAME=0.0.0.0",
707
+ "EXPOSE 3000",
708
+ 'CMD ["npm", "start"]'
709
+ ].join("\n");
710
+ break;
711
+ case "nodejs":
712
+ dockerfile = [
713
+ "FROM node:22-alpine",
714
+ "WORKDIR /app",
715
+ "COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./",
716
+ "RUN npm install --legacy-peer-deps || true",
717
+ "COPY . .",
718
+ "RUN npm run build 2>/dev/null || true",
719
+ "EXPOSE " + port,
720
+ 'CMD ["npm", "start"]'
721
+ ].join("\n");
722
+ break;
723
+ case "python":
724
+ dockerfile = [
725
+ "FROM python:3.13-slim",
726
+ "WORKDIR /app",
727
+ "COPY requirements.txt* pyproject.toml* ./",
728
+ "RUN pip install --no-cache-dir -r requirements.txt 2>/dev/null || pip install --no-cache-dir . 2>/dev/null || true",
729
+ "COPY . .",
730
+ "EXPOSE " + port,
731
+ 'CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "' + port + '"]'
732
+ ].join("\n");
733
+ break;
734
+ case "go":
735
+ dockerfile = [
736
+ "FROM golang:1.22-alpine AS builder",
737
+ "WORKDIR /app",
738
+ "COPY go.mod go.sum* ./",
739
+ "RUN go mod download",
740
+ "COPY . .",
741
+ "RUN CGO_ENABLED=0 go build -o server .",
742
+ "",
743
+ "FROM alpine",
744
+ "WORKDIR /app",
745
+ "COPY --from=builder /app/server .",
746
+ "EXPOSE " + port,
747
+ 'CMD ["./server"]'
748
+ ].join("\n");
749
+ break;
750
+ case "rust":
751
+ dockerfile = [
752
+ "FROM rust:1.88 AS builder",
753
+ "WORKDIR /app",
754
+ "COPY . .",
755
+ "RUN cargo build --release",
756
+ "",
757
+ "FROM debian:bookworm-slim",
758
+ "WORKDIR /app",
759
+ "COPY --from=builder /app/target/release/* /app/ 2>/dev/null || true",
760
+ "EXPOSE " + port,
761
+ 'CMD ["./app"]'
762
+ ].join("\n");
763
+ break;
764
+ case "java":
765
+ dockerfile = [
766
+ "FROM gradle:8.12-jdk21 AS builder",
767
+ "WORKDIR /app",
768
+ "COPY . .",
769
+ "RUN gradle build -x test 2>/dev/null || ./gradlew build -x test 2>/dev/null || mvn package -DskipTests 2>/dev/null || true",
770
+ "",
771
+ "FROM eclipse-temurin:21-jre-alpine",
772
+ "WORKDIR /app",
773
+ "COPY --from=builder /app/build/libs/*.jar app.jar 2>/dev/null || true",
774
+ "COPY --from=builder /app/target/*.jar app.jar 2>/dev/null || true",
775
+ "EXPOSE " + port,
776
+ 'CMD ["java", "-jar", "app.jar"]'
777
+ ].join("\n");
778
+ break;
779
+ case "static":
780
+ dockerfile = [
781
+ "FROM nginx:1.27-alpine",
782
+ "COPY . /usr/share/nginx/html/",
783
+ "EXPOSE 80"
784
+ ].join("\n");
785
+ break;
786
+ }
787
+ const internalPort = type === "nextjs" ? 3e3 : type === "static" ? 80 : port;
788
+ const compose = [
789
+ "services:",
790
+ " backend:",
791
+ " build: .",
792
+ " ports:",
793
+ ` - "${port}:${internalPort}"`,
794
+ " restart: unless-stopped",
795
+ " healthcheck:",
796
+ ` test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:${internalPort}/"]`,
797
+ " interval: 10s",
798
+ " timeout: 5s",
799
+ " retries: 5"
800
+ ].join("\n");
801
+ if (!existsSync2(join(dir, "Dockerfile"))) {
802
+ writeFileSync2(join(dir, "Dockerfile"), dockerfile, "utf-8");
803
+ if (job) appendLog(job, "[scaffold] Generated Dockerfile");
804
+ }
805
+ writeFileSync2(join(dir, "docker-compose.yml"), compose, "utf-8");
806
+ if (job) appendLog(job, "[scaffold] Generated docker-compose.yml");
807
+ if (!existsSync2(join(dir, ".dockerignore"))) {
808
+ writeFileSync2(join(dir, ".dockerignore"), "node_modules\n.next\n.git\n*.md\n", "utf-8");
809
+ }
810
+ }
811
+ function ensureComposeFile(dir, appName, port, job) {
812
+ if (existsSync2(join(dir, "docker-compose.yml")) || existsSync2(join(dir, "compose.yml"))) return;
813
+ _scaffoldDockerConfig(dir, appName, port, job);
814
+ }
815
+ function _resolveBackendPort(appDir, fallbackPort) {
816
+ const envPath = join(appDir, ".env");
817
+ const val = readDotEnvValue(envPath, "BACKEND_PORT");
818
+ const parsed = val ? parseInt(val, 10) : NaN;
819
+ return isNaN(parsed) ? fallbackPort : parsed;
820
+ }
821
+ function detectBasePath(appDir) {
822
+ for (const name of ["next.config.ts", "next.config.mjs", "next.config.js"]) {
823
+ const p = join(appDir, name);
824
+ if (existsSync2(p)) {
825
+ const content = readFileSync2(p, "utf-8");
826
+ const match = content.match(/basePath\s*:\s*['"`]([^'"`]+)['"`]/);
827
+ if (match) return match[1];
828
+ }
829
+ }
830
+ return "";
831
+ }
832
+ function _buildHealthUrl(appDir, fallbackPort) {
833
+ const healthPort = _resolveBackendPort(appDir, fallbackPort);
834
+ const basePath = detectBasePath(appDir);
835
+ const isBoilerplate = existsSync2(join(appDir, ".env.example")) && readFileSync2(join(appDir, ".env.example"), "utf-8").includes("STACK_LANG");
836
+ const hasHealthRoute = existsSync2(join(appDir, "src", "app", "health")) || existsSync2(join(appDir, "backend", "src"));
837
+ const healthPath = isBoilerplate || hasHealthRoute ? "/health" : "/";
838
+ return `http://127.0.0.1:${healthPort}${basePath}${healthPath}`;
839
+ }
840
+ async function _pollHealth(url, maxMs = 12e4, job) {
841
+ const deadline = Date.now() + maxMs;
842
+ let attempt = 0;
843
+ while (Date.now() < deadline) {
844
+ attempt++;
845
+ try {
846
+ const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
847
+ if (res.ok) {
848
+ if (job) appendLog(job, `[health] \u2713 ${url} \u2192 ${res.status} (attempt ${attempt})`);
849
+ return;
850
+ }
851
+ if (job) appendLog(job, `[health] ${url} \u2192 ${res.status} (attempt ${attempt})`);
852
+ } catch {
853
+ if (job && attempt % 3 === 1) appendLog(job, `[health] waiting... ${url} (attempt ${attempt})`);
854
+ }
855
+ await new Promise((r) => setTimeout(r, 3e3));
856
+ }
857
+ if (job) appendLog(job, `[health] \u2717 timeout after ${maxMs / 1e3}s`);
858
+ throw new Error(`Health check timed out after ${maxMs / 1e3}s: ${url}`);
859
+ }
860
+ async function _dockerComposeUp(cwd, job) {
861
+ appendLog(job, `[docker] $ docker compose up -d --build`);
862
+ appendLog(job, `[docker] cwd: ${cwd}`);
863
+ const proc = execa("docker", ["compose", "up", "-d", "--build"], { cwd, reject: false });
864
+ proc.stdout?.on("data", (chunk) => {
865
+ for (const line of chunk.toString().split("\n").filter(Boolean)) {
866
+ appendLog(job, `[docker] ${line}`);
867
+ }
868
+ });
869
+ proc.stderr?.on("data", (chunk) => {
870
+ for (const line of chunk.toString().split("\n").filter(Boolean)) {
871
+ appendLog(job, `[docker] ${line}`);
872
+ }
873
+ });
874
+ const result = await proc;
875
+ if (result.exitCode !== 0) {
876
+ appendLog(job, `[docker] \u2717 exit code ${result.exitCode}`);
877
+ throw new Error(`Command failed with exit code ${result.exitCode}: docker compose up -d --build
878
+ ${result.stderr}`);
879
+ }
880
+ appendLog(job, `[docker] \u2713 containers started`);
881
+ }
882
+ async function createApp(opts) {
883
+ const job = newJob(opts.appName, ["Validating", "Gitea setup", "Gitea repo", "Git push", "Docker up", "Health check"]);
884
+ jobs.set(job.jobId, job);
885
+ setImmediate(() => void _runCreateApp(job, opts));
886
+ return job.jobId;
887
+ }
888
+ async function _runCreateApp(job, opts) {
889
+ try {
890
+ const ctx = resolveContext();
891
+ const appsJson = resolveAppsJsonPath();
892
+ setStep(job, 0, "running");
893
+ if (opts.mode === "boilerplate") {
894
+ if (!opts.stackId) throw new Error("stackId is required for boilerplate mode");
895
+ const metas = readBoilerplateMeta(ctx.projectPath);
896
+ const meta = metas.find((m) => m.stackId === opts.stackId);
897
+ if (meta) {
898
+ opts._meta = meta;
899
+ } else {
900
+ appendLog(job, `[info] Stack "${opts.stackId}" not installed locally \u2014 cloning fresh from catalog`);
901
+ opts._resolvedStackId = opts.stackId;
902
+ }
903
+ } else if (opts.mode === "git-clone") {
904
+ if (!opts.gitUrl) throw new Error("gitUrl is required for Git Clone mode");
905
+ } else if (opts.mode === "new-project") {
906
+ const { resolveStackId } = await import("./frameworks-Z7VXDGP4.js");
907
+ const stackId = resolveStackId(opts.language ?? "nodejs", opts.frameworkId ?? "express");
908
+ if (!stackId) throw new Error(`Unknown stack: ${opts.language}/${opts.frameworkId}`);
909
+ opts._resolvedStackId = stackId;
910
+ }
911
+ setStep(job, 0, "done");
912
+ setStep(job, 1, "running");
913
+ const gitea = new GiteaClient({
914
+ baseUrl: ctx.giteaBaseUrl,
915
+ username: ctx.giteaUser,
916
+ password: ctx.giteaPassword,
917
+ tokenPath: GITEA_TOKEN_PATH
918
+ });
919
+ const giteaPrep = await gitea.prepare();
920
+ setStep(job, 1, "done", giteaPrep.message);
921
+ if (opts.mode === "boilerplate" && opts._meta) {
922
+ await _createModeA(job, opts, ctx, gitea, appsJson);
923
+ } else if (opts.mode === "git-clone") {
924
+ await _createModeB(job, opts, ctx, gitea, appsJson);
925
+ } else {
926
+ await _createModeC(job, opts, ctx, gitea, appsJson);
927
+ }
928
+ job.status = "done";
929
+ } catch (err) {
930
+ job.status = "failed";
931
+ job.error = err instanceof Error ? err.message : String(err);
932
+ for (const step of job.steps) {
933
+ if (step.status === "running" || step.status === "pending") step.status = "failed";
934
+ }
935
+ }
936
+ }
937
+ async function _createModeA(job, opts, ctx, gitea, appsJson) {
938
+ const meta = opts._meta;
939
+ const port = opts.port ?? meta.port ?? parseInt(meta.backendUrl.split(":").pop() ?? "8080", 10);
940
+ setStep(job, 2, "running", `checking ${ctx.giteaUser}/${opts.appName}`);
941
+ const alreadyExists = await gitea.repoExists(opts.appName);
942
+ let cloneUrl;
943
+ if (!alreadyExists) {
944
+ setStep(job, 2, "running", `creating ${ctx.giteaUser}/${opts.appName}`);
945
+ cloneUrl = await gitea.createRepo(opts.appName, `Brewnet app: ${opts.appName}`);
946
+ } else {
947
+ cloneUrl = `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${opts.appName}.git`;
948
+ }
949
+ setStep(job, 2, "done");
950
+ setStep(job, 3, "running", `pushing HEAD:main \u2192 ${ctx.giteaUser}/${opts.appName}`);
951
+ const shallowCheck = await execa("git", ["rev-parse", "--is-shallow-repository"], { cwd: meta.appDir }).catch(() => ({ stdout: "false" }));
952
+ if (shallowCheck.stdout.trim() === "true") {
953
+ await execa("git", ["fetch", "--unshallow", "origin"], { cwd: meta.appDir }).catch(async () => {
954
+ const { reinitGit } = await import("./boilerplate-manager-WEFTHL2O.js");
955
+ await reinitGit(meta.appDir);
956
+ });
957
+ }
958
+ const authedUrl = gitea.authedCloneUrl(cloneUrl);
959
+ await execa("git", ["remote", "add", "brewnet", authedUrl], { cwd: meta.appDir }).catch(() => {
960
+ return execa("git", ["remote", "set-url", "brewnet", authedUrl], { cwd: meta.appDir });
961
+ });
962
+ await execa("git", ["push", "brewnet", "HEAD:main", "--force"], { cwd: meta.appDir });
963
+ setStep(job, 3, "done");
964
+ setStep(job, 4, "running", "docker compose up --build");
965
+ ensureComposeFile(meta.appDir, opts.appName, port, job);
966
+ await _injectQuickTunnelIfNeeded(meta.appDir, opts.appName, port);
967
+ await _dockerComposeUp(meta.appDir, job);
968
+ setStep(job, 4, "done", "containers started");
969
+ setStep(job, 5, "running");
970
+ const healthUrlA = _buildHealthUrl(meta.appDir, port);
971
+ setStep(job, 5, "running", `polling ${healthUrlA}`);
972
+ await _pollHealth(healthUrlA, 12e4, job);
973
+ setStep(job, 5, "done");
974
+ addApp(appsJson, {
975
+ name: opts.appName,
976
+ mode: "boilerplate",
977
+ stackId: opts.stackId,
978
+ appDir: meta.appDir,
979
+ lang: meta.lang,
980
+ framework: meta.frameworkId,
981
+ port,
982
+ giteaRepoUrl: `${ctx.giteaDisplayUrl}/${ctx.giteaUser}/${opts.appName}`,
983
+ status: "running",
984
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
985
+ });
986
+ await setupWebhook(opts.appName, "http://localhost:8088/api/deploy/hook").catch((e) => {
987
+ console.warn("[webhook] registration failed (non-critical):", e instanceof Error ? e.message : String(e));
988
+ });
989
+ }
990
+ async function _createModeB(job, opts, ctx, gitea, appsJson) {
991
+ const port = opts.port ?? 8080;
992
+ const appDir = join(ctx.projectPath, "apps", opts.appName);
993
+ setStep(job, 2, "running", "Cloning external repository...");
994
+ const { reinitGit: reinitGitB } = await import("./boilerplate-manager-WEFTHL2O.js");
995
+ const { rmSync } = await import("fs");
996
+ if (existsSync2(appDir)) {
997
+ rmSync(appDir, { recursive: true, force: true });
998
+ }
999
+ const cloneArgs = ["clone", "--depth", "1"];
1000
+ if (opts.branch) cloneArgs.push("-b", opts.branch);
1001
+ cloneArgs.push(opts.gitUrl, appDir);
1002
+ await execa("git", cloneArgs);
1003
+ appendLog(job, `[clone] ${opts.gitUrl} \u2192 ${opts.appName} \u2713`);
1004
+ const envExPath = join(appDir, ".env.example");
1005
+ const envPath = join(appDir, ".env");
1006
+ if (existsSync2(envExPath)) {
1007
+ const { findFreePort: findFreePortB } = await import("./boilerplate-manager-WEFTHL2O.js");
1008
+ let envContent = readFileSync2(envExPath, "utf-8");
1009
+ envContent = envContent.replace(/^BACKEND_PORT=.*/m, `BACKEND_PORT=${port}`);
1010
+ const fePort = await findFreePortB(port + 1);
1011
+ envContent = envContent.replace(/^FRONTEND_PORT=.*/m, `FRONTEND_PORT=${fePort}`);
1012
+ writeFileSync2(envPath, envContent, "utf-8");
1013
+ } else if (existsSync2(join(appDir, ".env"))) {
1014
+ let envContent = readFileSync2(join(appDir, ".env"), "utf-8");
1015
+ envContent = envContent.replace(/^BACKEND_PORT=.*/m, `BACKEND_PORT=${port}`);
1016
+ writeFileSync2(join(appDir, ".env"), envContent, "utf-8");
1017
+ }
1018
+ await reinitGitB(appDir);
1019
+ setStep(job, 2, "running", `Creating Gitea repo ${ctx.giteaUser}/${opts.appName}\u2026`);
1020
+ const alreadyExists = await gitea.repoExists(opts.appName);
1021
+ const cloneUrl = alreadyExists ? `${ctx.giteaBaseUrl}/${ctx.giteaUser}/${opts.appName}.git` : await gitea.createRepo(opts.appName, `Brewnet app: ${opts.appName}`);
1022
+ setStep(job, 2, "done");
1023
+ setStep(job, 3, "running");
1024
+ const authedUrl = gitea.authedCloneUrl(cloneUrl);
1025
+ await execa("git", ["remote", "add", "brewnet", authedUrl], { cwd: appDir });
1026
+ await execa("git", ["push", "brewnet", "HEAD:main", "--force"], { cwd: appDir });
1027
+ setStep(job, 3, "done");
1028
+ ensureComposeFile(appDir, opts.appName, port, job);
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.giteaDisplayUrl}/${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-WEFTHL2O.js");
1058
+ const { getStackById } = 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 = getStackById(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.giteaDisplayUrl}/${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 }).catch((e) => {
1113
+ console.warn("[stopApp] docker compose down failed:", e instanceof Error ? e.message : String(e));
1114
+ });
1115
+ updateApp(appsJson, appName, { status: "stopped" });
1116
+ }
1117
+ async function removeApp2(appName) {
1118
+ const appsJson = resolveAppsJsonPath();
1119
+ const apps = readApps(appsJson);
1120
+ const app = apps.find((a) => a.name === appName);
1121
+ if (!app) throw new Error(`App "${appName}" not found`);
1122
+ await execa("docker", ["compose", "down", "--volumes"], { cwd: app.appDir }).catch((e) => {
1123
+ console.warn("[removeApp] docker compose down failed:", e instanceof Error ? e.message : String(e));
1124
+ });
1125
+ removeApp(appsJson, appName);
1126
+ }
1127
+
1128
+ export {
1129
+ resolveAppsJsonPath,
1130
+ loadGiteaConfig,
1131
+ saveGiteaConfig,
1132
+ readDotEnvValue,
1133
+ listApps,
1134
+ getDeployHistory,
1135
+ listGiteaRepos,
1136
+ getDeploySettings,
1137
+ updateDeploySettings,
1138
+ getAppGitInfo,
1139
+ rollbackApp,
1140
+ getAppBranches,
1141
+ setupWebhook,
1142
+ deployApp,
1143
+ getAppDir,
1144
+ getJobStatus,
1145
+ detectBasePath,
1146
+ createApp,
1147
+ startApp,
1148
+ stopApp,
1149
+ removeApp2 as removeApp
1150
+ };
1151
+ //# sourceMappingURL=chunk-FZZ3HP2G.js.map