@netrunner714/pegas 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -2
  3. package/dist/index.js +2885 -0
  4. package/package.json +48 -4
package/dist/index.js ADDED
@@ -0,0 +1,2885 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import fs12 from "node:fs";
5
+ import path11 from "node:path";
6
+ import child_process5 from "node:child_process";
7
+ import { parseArgs } from "node:util";
8
+
9
+ // src/commands/init.ts
10
+ import fs4 from "node:fs/promises";
11
+ import path4 from "node:path";
12
+ import os2 from "node:os";
13
+
14
+ // src/docker.ts
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ var execFileAsync = promisify(execFile);
18
+ async function runCommand(cmd, args, cwd) {
19
+ try {
20
+ const { stdout, stderr } = await execFileAsync(cmd, args, { cwd });
21
+ return { success: true, stdout: stdout.trim(), stderr: stderr.trim() };
22
+ } catch (err) {
23
+ return {
24
+ success: false,
25
+ stdout: (err.stdout || "").toString().trim(),
26
+ stderr: (err.stderr || err.message || "").toString().trim()
27
+ };
28
+ }
29
+ }
30
+ async function checkDocker() {
31
+ const res = await runCommand("docker", ["info", "--format", "{{.ServerVersion}}"]);
32
+ return res.success;
33
+ }
34
+ async function checkDockerCompose() {
35
+ const res = await runCommand("docker", ["compose", "version"]);
36
+ return res.success;
37
+ }
38
+ async function ensureDockerNetwork(networkName = "pegas-network") {
39
+ const inspect = await runCommand("docker", ["network", "inspect", networkName]);
40
+ if (inspect.success) {
41
+ return true;
42
+ }
43
+ const create = await runCommand("docker", ["network", "create", networkName]);
44
+ return create.success;
45
+ }
46
+ async function validateCaddyConfig(gatewayDir) {
47
+ return await runCommand("docker", ["compose", "exec", "-T", "caddy", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"], gatewayDir);
48
+ }
49
+ async function reloadCaddyConfig(gatewayDir) {
50
+ return await runCommand("docker", ["compose", "exec", "-T", "caddy", "caddy", "reload", "--config", "/etc/caddy/Caddyfile"], gatewayDir);
51
+ }
52
+ async function startGatewayContainer(gatewayDir) {
53
+ return await runCommand("docker", ["compose", "up", "-d"], gatewayDir);
54
+ }
55
+ async function getGatewayContainerStatus(gatewayDir) {
56
+ return await runCommand("docker", ["compose", "ps", "--format", "json"], gatewayDir);
57
+ }
58
+
59
+ // src/gateway.ts
60
+ import fs2 from "node:fs/promises";
61
+ import path2 from "node:path";
62
+ import os from "node:os";
63
+
64
+ // src/contract.ts
65
+ import fs from "node:fs/promises";
66
+ import path from "node:path";
67
+ var CONTRACT_FILENAME = "pegas.json";
68
+ function normalizeDomain(raw) {
69
+ let domain = raw.trim().toLowerCase();
70
+ domain = domain.replace(/^https?:\/\//, "");
71
+ domain = domain.replace(/^.*@/, "");
72
+ domain = domain.replace(/[/?#].*$/, "");
73
+ domain = domain.replace(/:\d+$/, "");
74
+ domain = domain.replace(/\.+$/, "");
75
+ return domain;
76
+ }
77
+ function toAsciiDomain(domain) {
78
+ try {
79
+ return new URL(`http://${domain}`).hostname;
80
+ } catch {
81
+ return domain;
82
+ }
83
+ }
84
+ function slugify(name) {
85
+ const unscope = name.replace(/^@[^/]+\//, "");
86
+ const slug = unscope.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
87
+ return slug || "app";
88
+ }
89
+ async function detectProjectSlug(projectDir) {
90
+ const pkgPath = path.join(projectDir, "package.json");
91
+ try {
92
+ const raw = await fs.readFile(pkgPath, "utf-8");
93
+ const pkg = JSON.parse(raw);
94
+ if (pkg.name && typeof pkg.name === "string" && pkg.name.trim()) {
95
+ return slugify(pkg.name.trim());
96
+ }
97
+ } catch {}
98
+ const dirName = path.basename(path.resolve(projectDir));
99
+ return slugify(dirName);
100
+ }
101
+ async function detectComposeWebService(projectDir) {
102
+ const composeFiles = [
103
+ "docker-compose.yml",
104
+ "docker-compose.yaml",
105
+ "compose.yml",
106
+ "compose.yaml"
107
+ ];
108
+ for (const f of composeFiles) {
109
+ try {
110
+ const content = await fs.readFile(path.join(projectDir, f), "utf-8");
111
+ const lines = content.replace(/\r\n/g, `
112
+ `).split(`
113
+ `);
114
+ let inServices = false;
115
+ let serviceIndent = null;
116
+ let currentService = null;
117
+ let currentPorts = [];
118
+ for (const line of lines) {
119
+ const trimmed = line.trim();
120
+ const indent = line.search(/\S/);
121
+ if (indent === 0 && /^services:\s*$/.test(line)) {
122
+ inServices = true;
123
+ continue;
124
+ }
125
+ if (indent === 0 && inServices && /^[a-zA-Z0-9_-]+:\s*$/.test(line)) {
126
+ break;
127
+ }
128
+ if (inServices) {
129
+ if (serviceIndent === null && indent > 0 && /^[a-zA-Z0-9._-]+:\s*$/.test(trimmed)) {
130
+ serviceIndent = indent;
131
+ }
132
+ if (serviceIndent !== null && indent === serviceIndent && /^[a-zA-Z0-9._-]+:\s*$/.test(trimmed)) {
133
+ if (currentService && currentPorts.length > 0) {
134
+ const clean = currentPorts[0].replace(/['"]/g, "").trim();
135
+ const portMatch = clean.match(/:(\d+)$/) || clean.match(/(\d+)$/);
136
+ return { name: currentService, port: portMatch ? portMatch[1] : "3000" };
137
+ }
138
+ currentService = trimmed.replace(/:\s*$/, "");
139
+ currentPorts = [];
140
+ } else if (currentService && /^\s*-\s*["']?\d+/.test(line)) {
141
+ currentPorts.push(line.trim());
142
+ }
143
+ }
144
+ }
145
+ if (currentService && currentPorts.length > 0) {
146
+ const clean = currentPorts[0].replace(/['"]/g, "").trim();
147
+ const portMatch = clean.match(/:(\d+)$/) || clean.match(/(\d+)$/);
148
+ return { name: currentService, port: portMatch ? portMatch[1] : "3000" };
149
+ }
150
+ } catch {}
151
+ }
152
+ return null;
153
+ }
154
+ function createDefaultContract(slug, serviceName = "web", port = "3000") {
155
+ return {
156
+ projectName: slug,
157
+ services: {
158
+ [serviceName]: {
159
+ upstream: `${serviceName}:${port}`,
160
+ domains: {
161
+ dev: [`${slug}.localhost`],
162
+ prod: [`${slug}.com`]
163
+ }
164
+ }
165
+ }
166
+ };
167
+ }
168
+ var DEFAULT_CONTRACT = createDefaultContract("app", "web", "3000");
169
+ async function setupProjectContract(projectDir) {
170
+ const contractPath = path.join(projectDir, CONTRACT_FILENAME);
171
+ const slug = await detectProjectSlug(projectDir);
172
+ try {
173
+ await fs.access(contractPath);
174
+ return { created: false, file: CONTRACT_FILENAME, slug };
175
+ } catch {
176
+ const detected = await detectComposeWebService(projectDir);
177
+ const serviceName = detected?.name || "web";
178
+ const port = detected?.port || "3000";
179
+ const defaultContract = createDefaultContract(slug, serviceName, port);
180
+ await fs.writeFile(contractPath, JSON.stringify(defaultContract, null, 2) + `
181
+ `, "utf-8");
182
+ return { created: true, file: CONTRACT_FILENAME, slug };
183
+ }
184
+ }
185
+ async function updatePackageJson(projectDir) {
186
+ const actions = [];
187
+ const pkgPath = path.join(projectDir, "package.json");
188
+ try {
189
+ const raw = await fs.readFile(pkgPath, "utf-8");
190
+ const pkg = JSON.parse(raw);
191
+ if (!pkg.scripts) {
192
+ pkg.scripts = {};
193
+ }
194
+ if (pkg.scripts.dev) {
195
+ if (!pkg.scripts.dev.includes("pegas dev")) {
196
+ pkg.scripts.dev = `pegas dev -- ${pkg.scripts.dev}`;
197
+ actions.push("Обновлён скрипт 'dev' в package.json: обёрнут в 'pegas dev --'");
198
+ }
199
+ } else {
200
+ pkg.scripts.dev = "pegas dev";
201
+ actions.push("Добавлен скрипт 'dev': 'pegas dev' в package.json");
202
+ }
203
+ if (!pkg.scripts.deploy || !pkg.scripts.deploy.includes("pegas deploy")) {
204
+ pkg.scripts.deploy = "pegas deploy";
205
+ actions.push("Добавлен скрипт 'deploy': 'pegas deploy' в package.json");
206
+ }
207
+ if (!pkg.devDependencies) {
208
+ pkg.devDependencies = {};
209
+ }
210
+ if (!pkg.devDependencies["@netrunner714/pegas"] && !pkg.devDependencies.pegas) {
211
+ pkg.devDependencies["@netrunner714/pegas"] = "^1.0.0";
212
+ actions.push("Добавлен '@netrunner714/pegas' в devDependencies в package.json");
213
+ }
214
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + `
215
+ `, "utf-8");
216
+ } catch {}
217
+ return actions;
218
+ }
219
+
220
+ // src/gateway.ts
221
+ async function findProjectRoot(startDir = process.cwd()) {
222
+ let currentDir = path2.resolve(startDir);
223
+ let packageJsonDir = null;
224
+ while (true) {
225
+ try {
226
+ await fs2.access(path2.join(currentDir, ".git"));
227
+ return currentDir;
228
+ } catch {}
229
+ if (!packageJsonDir) {
230
+ try {
231
+ await fs2.access(path2.join(currentDir, "package.json"));
232
+ packageJsonDir = currentDir;
233
+ } catch {}
234
+ }
235
+ const parentDir = path2.dirname(currentDir);
236
+ if (parentDir === currentDir) {
237
+ break;
238
+ }
239
+ currentDir = parentDir;
240
+ }
241
+ return packageJsonDir;
242
+ }
243
+ async function findGatewayDir(explicitDir) {
244
+ if (explicitDir) {
245
+ return path2.resolve(explicitDir);
246
+ }
247
+ if (process.env.PEGAS_DIR) {
248
+ return path2.resolve(process.env.PEGAS_DIR);
249
+ }
250
+ const cwd = process.cwd();
251
+ try {
252
+ await fs2.access(path2.join(cwd, "Caddyfile"));
253
+ await fs2.access(path2.join(cwd, "conf.d"));
254
+ return cwd;
255
+ } catch {}
256
+ const homeGateway = path2.join(os.homedir(), "gateway");
257
+ try {
258
+ await fs2.access(path2.join(homeGateway, "Caddyfile"));
259
+ return homeGateway;
260
+ } catch {}
261
+ const homePegas = path2.join(os.homedir(), "pegas-server");
262
+ try {
263
+ await fs2.access(path2.join(homePegas, "Caddyfile"));
264
+ return homePegas;
265
+ } catch {}
266
+ return homeGateway;
267
+ }
268
+ function getConfDir(gatewayDir) {
269
+ return path2.join(gatewayDir, "conf.d");
270
+ }
271
+ function getRouteFilePath(gatewayDir, domain) {
272
+ const ascii = toAsciiDomain(domain);
273
+ const sanitized = ascii.toLowerCase().replace(/[^a-z0-9.-]/g, "_");
274
+ return path2.join(getConfDir(gatewayDir), `${sanitized}.caddy`);
275
+ }
276
+ function getProjectRouteFilePath(gatewayDir, projectName) {
277
+ const sanitized = projectName.toLowerCase().replace(/[^a-z0-9._-]/g, "_");
278
+ return path2.join(getConfDir(gatewayDir), `${sanitized}.caddy`);
279
+ }
280
+ async function listRoutes(gatewayDir) {
281
+ const confDir = getConfDir(gatewayDir);
282
+ const routes = [];
283
+ try {
284
+ const files = await fs2.readdir(confDir);
285
+ for (const file of files) {
286
+ if (!file.endsWith(".caddy"))
287
+ continue;
288
+ const filePath = path2.join(confDir, file);
289
+ const content = await fs2.readFile(filePath, "utf-8");
290
+ const domain = file.replace(/\.caddy$/, "");
291
+ const isSpa = content.includes("handle /api/*");
292
+ const hasWwwRedirect = content.includes(`www.${domain}`);
293
+ const proxyMatches = [...content.matchAll(/reverse_proxy\s+([^\s\n]+)/g)];
294
+ const upstreams = proxyMatches.map((m) => m[1]);
295
+ let upstream = upstreams[0] || "unknown";
296
+ let apiUpstream = undefined;
297
+ if (isSpa && upstreams.length >= 2) {
298
+ apiUpstream = upstreams[0];
299
+ upstream = upstreams[1];
300
+ }
301
+ routes.push({
302
+ domain,
303
+ upstream,
304
+ apiUpstream,
305
+ filePath,
306
+ hasWwwRedirect,
307
+ isSpa
308
+ });
309
+ }
310
+ } catch (err) {
311
+ if (err.code !== "ENOENT") {
312
+ throw err;
313
+ }
314
+ }
315
+ return routes;
316
+ }
317
+
318
+ // src/compose.ts
319
+ import fs3 from "node:fs/promises";
320
+ import path3 from "node:path";
321
+ function createDefaultCompose(projectName = "app") {
322
+ return `services:
323
+ web:
324
+ build: .
325
+ restart: unless-stopped
326
+ expose:
327
+ - "3000"
328
+ logging:
329
+ driver: "json-file"
330
+ options:
331
+ max-size: "50m"
332
+ max-file: "3"
333
+ networks:
334
+ pegas-network:
335
+ aliases:
336
+ - ${projectName}-web
337
+
338
+ networks:
339
+ pegas-network:
340
+ external: true
341
+ `;
342
+ }
343
+ var DEFAULT_COMPOSE_CONTENT = createDefaultCompose("app");
344
+ var INFRA_PORTS = new Set([
345
+ 22,
346
+ 2222,
347
+ 3306,
348
+ 4222,
349
+ 5432,
350
+ 5672,
351
+ 6379,
352
+ 6380,
353
+ 8123,
354
+ 9000,
355
+ 9092,
356
+ 9200,
357
+ 9300,
358
+ 11211,
359
+ 15672,
360
+ 27017,
361
+ 27018
362
+ ]);
363
+ var INFRA_SERVICE_NAMES = new Set([
364
+ "db",
365
+ "database",
366
+ "postgres",
367
+ "postgresql",
368
+ "redis",
369
+ "valkey",
370
+ "mysql",
371
+ "mariadb",
372
+ "mongo",
373
+ "mongodb",
374
+ "rabbitmq",
375
+ "queue",
376
+ "broker",
377
+ "elastic",
378
+ "elasticsearch",
379
+ "kafka",
380
+ "memcached"
381
+ ]);
382
+ function parsePortEntry(raw) {
383
+ const clean = raw.trim().replace(/^-\s*/, "").replace(/^['"]|['"]$/g, "");
384
+ const protoIndex = clean.indexOf("/");
385
+ const noProto = protoIndex !== -1 ? clean.slice(0, protoIndex) : clean;
386
+ const parts = noProto.split(":");
387
+ const containerStr = parts[parts.length - 1];
388
+ const containerPort = parseInt(containerStr, 10);
389
+ if (isNaN(containerPort))
390
+ return null;
391
+ const hostPort = parts.length > 1 ? parts[parts.length - 2] : undefined;
392
+ return { host: hostPort, container: containerPort, raw };
393
+ }
394
+ function isInfraService(serviceName, serviceLines) {
395
+ const lowerName = serviceName.toLowerCase();
396
+ if (INFRA_SERVICE_NAMES.has(lowerName))
397
+ return true;
398
+ for (const name of INFRA_SERVICE_NAMES) {
399
+ if (lowerName.includes(name))
400
+ return true;
401
+ }
402
+ for (const line of serviceLines) {
403
+ const match = line.match(/^\s*image:\s*([^\s]+)/);
404
+ if (match) {
405
+ const img = match[1].toLowerCase();
406
+ for (const name of INFRA_SERVICE_NAMES) {
407
+ if (img.includes(name))
408
+ return true;
409
+ }
410
+ }
411
+ }
412
+ return false;
413
+ }
414
+ function detectInfraServices(content) {
415
+ const normalized = content.replace(/\r\n/g, `
416
+ `);
417
+ const lines = normalized.split(`
418
+ `);
419
+ const services = [];
420
+ let inServices = false;
421
+ let serviceIndent = null;
422
+ let currentService = null;
423
+ for (const line of lines) {
424
+ const trimmed = line.trim();
425
+ if (!trimmed || trimmed.startsWith("#"))
426
+ continue;
427
+ const indent = line.search(/\S/);
428
+ if (indent === 0) {
429
+ if (trimmed.startsWith("services:")) {
430
+ inServices = true;
431
+ serviceIndent = null;
432
+ currentService = null;
433
+ continue;
434
+ } else {
435
+ inServices = false;
436
+ if (currentService) {
437
+ services.push(currentService);
438
+ currentService = null;
439
+ }
440
+ continue;
441
+ }
442
+ }
443
+ if (inServices) {
444
+ if (serviceIndent === null) {
445
+ serviceIndent = indent;
446
+ const match = trimmed.match(/^([a-zA-Z0-9._-]+):/);
447
+ if (match) {
448
+ currentService = { name: match[1], lines: [] };
449
+ }
450
+ } else if (indent === serviceIndent) {
451
+ if (currentService) {
452
+ services.push(currentService);
453
+ }
454
+ const match = trimmed.match(/^([a-zA-Z0-9._-]+):/);
455
+ if (match) {
456
+ currentService = { name: match[1], lines: [] };
457
+ } else {
458
+ currentService = null;
459
+ }
460
+ } else if (indent > serviceIndent && currentService) {
461
+ currentService.lines.push(line);
462
+ }
463
+ }
464
+ }
465
+ if (currentService) {
466
+ services.push(currentService);
467
+ }
468
+ const infraServices = [];
469
+ for (const s of services) {
470
+ if (isInfraService(s.name, s.lines)) {
471
+ let hostPort = undefined;
472
+ let containerPort = undefined;
473
+ let inPorts = false;
474
+ let portsIndent = null;
475
+ for (const line of s.lines) {
476
+ const trimmed = line.trim();
477
+ const indent = line.search(/\S/);
478
+ if (trimmed.startsWith("ports:")) {
479
+ inPorts = true;
480
+ portsIndent = indent;
481
+ continue;
482
+ }
483
+ if (inPorts) {
484
+ if (indent > portsIndent && trimmed.startsWith("-")) {
485
+ const parsed = parsePortEntry(trimmed);
486
+ if (parsed) {
487
+ hostPort = parsed.host || String(parsed.container);
488
+ containerPort = parsed.container;
489
+ break;
490
+ }
491
+ } else if (indent <= portsIndent) {
492
+ inPorts = false;
493
+ }
494
+ }
495
+ }
496
+ infraServices.push({
497
+ name: s.name,
498
+ hostPort,
499
+ containerPort
500
+ });
501
+ }
502
+ }
503
+ return infraServices;
504
+ }
505
+ function patchExistingCompose(content, projectName = "app") {
506
+ const normalized = content.replace(/\r\n/g, `
507
+ `);
508
+ const lines = normalized.split(`
509
+ `);
510
+ const outputLines = [];
511
+ let inServices = false;
512
+ let serviceNameIndent = null;
513
+ let currentServiceName = null;
514
+ let currentServiceHeader = null;
515
+ let currentServiceLines = [];
516
+ let inNetworksSection = false;
517
+ function processService(name, header, sLines) {
518
+ const isInfra = isInfraService(name, sLines);
519
+ const hasPorts = sLines.some((l) => /^\s*ports:\s*$/.test(l));
520
+ const hasExpose = sLines.some((l) => /^\s*expose:\s*$/.test(l));
521
+ const hasPegasNetworkAlready = sLines.some((l) => l.includes("pegas-network"));
522
+ const isWeb = !isInfra && (hasPorts || hasExpose || hasPegasNetworkAlready);
523
+ if (!isWeb) {
524
+ return [header, ...sLines];
525
+ }
526
+ const remainingLines = [];
527
+ const exposedPorts = [];
528
+ let hasNetworks = false;
529
+ let hasPegasNetwork = false;
530
+ let propIndent = 4;
531
+ for (const l of sLines) {
532
+ const ind = l.search(/\S/);
533
+ if (ind > (serviceNameIndent ?? 2)) {
534
+ propIndent = ind;
535
+ break;
536
+ }
537
+ }
538
+ const propIndentStr = " ".repeat(propIndent);
539
+ const itemIndentStr = " ".repeat(propIndent + 2);
540
+ let i = 0;
541
+ while (i < sLines.length) {
542
+ const line = sLines[i];
543
+ const lineIndent = line.search(/\S/);
544
+ const portsMatch = line.match(/^(\s*)ports:\s*$/);
545
+ if (portsMatch && lineIndent === propIndent) {
546
+ i++;
547
+ const preservedPortLines = [];
548
+ while (i < sLines.length) {
549
+ const pLine = sLines[i];
550
+ const pTrimmed = pLine.trim();
551
+ const pIndent = pLine.search(/\S/);
552
+ if (pTrimmed === "" || pTrimmed.startsWith("#")) {
553
+ i++;
554
+ continue;
555
+ }
556
+ if (pIndent > propIndent) {
557
+ const parsed = parsePortEntry(pTrimmed);
558
+ if (parsed) {
559
+ if (isWeb) {
560
+ exposedPorts.push(parsed.container.toString());
561
+ } else {
562
+ preservedPortLines.push(pLine);
563
+ }
564
+ } else {
565
+ preservedPortLines.push(pLine);
566
+ }
567
+ i++;
568
+ } else {
569
+ break;
570
+ }
571
+ }
572
+ if (preservedPortLines.length > 0) {
573
+ remainingLines.push(line);
574
+ remainingLines.push(...preservedPortLines);
575
+ }
576
+ continue;
577
+ }
578
+ const exposeMatch = line.match(/^(\s*)expose:\s*$/);
579
+ if (exposeMatch && lineIndent === propIndent) {
580
+ remainingLines.push(line);
581
+ i++;
582
+ while (i < sLines.length) {
583
+ const eLine = sLines[i];
584
+ const eTrimmed = eLine.trim();
585
+ const eIndent = eLine.search(/\S/);
586
+ if (eTrimmed === "" || eTrimmed.startsWith("#")) {
587
+ remainingLines.push(eLine);
588
+ i++;
589
+ continue;
590
+ }
591
+ if (eIndent > propIndent) {
592
+ const clean = eTrimmed.replace(/^-\s*/, "").replace(/^['"]|['"]$/g, "");
593
+ exposedPorts.push(clean);
594
+ remainingLines.push(eLine);
595
+ i++;
596
+ } else {
597
+ break;
598
+ }
599
+ }
600
+ continue;
601
+ }
602
+ const networksMatch = line.match(/^(\s*)networks:\s*$/);
603
+ if (networksMatch && lineIndent === propIndent) {
604
+ hasNetworks = true;
605
+ remainingLines.push(line);
606
+ i++;
607
+ while (i < sLines.length) {
608
+ const nextLine = sLines[i];
609
+ const nextTrimmed = nextLine.trim();
610
+ const nextIndent = nextLine.search(/\S/);
611
+ if (nextTrimmed === "" || nextTrimmed.startsWith("#")) {
612
+ remainingLines.push(nextLine);
613
+ i++;
614
+ continue;
615
+ }
616
+ if (nextIndent > propIndent) {
617
+ if (nextTrimmed.startsWith("-")) {
618
+ const netName = nextTrimmed.replace(/^-\s*/, "").trim();
619
+ if (netName === "pegas-network") {
620
+ hasPegasNetwork = true;
621
+ }
622
+ } else if (nextTrimmed.startsWith("pegas-network:")) {
623
+ hasPegasNetwork = true;
624
+ }
625
+ remainingLines.push(nextLine);
626
+ i++;
627
+ } else {
628
+ break;
629
+ }
630
+ }
631
+ continue;
632
+ }
633
+ remainingLines.push(line);
634
+ i++;
635
+ }
636
+ if (isWeb) {
637
+ const hasExposeSection = remainingLines.some((l) => {
638
+ const ind = l.search(/\S/);
639
+ return ind === propIndent && /^\s*expose:\s*$/.test(l);
640
+ });
641
+ if (!hasExposeSection) {
642
+ let insertIdx = remainingLines.length;
643
+ const netIdx = remainingLines.findIndex((l) => {
644
+ const ind = l.search(/\S/);
645
+ return ind === propIndent && /^\s*networks:\s*$/.test(l);
646
+ });
647
+ if (netIdx !== -1) {
648
+ insertIdx = netIdx;
649
+ }
650
+ if (exposedPorts.length > 0) {
651
+ const unique = [...new Set(exposedPorts)];
652
+ const exposeLines = [
653
+ `${propIndentStr}expose:`,
654
+ ...unique.map((p) => `${itemIndentStr}- "${p}"`)
655
+ ];
656
+ remainingLines.splice(insertIdx, 0, ...exposeLines);
657
+ }
658
+ }
659
+ const scopedAlias = `${projectName}-${name}`;
660
+ const netIndentStr = " ".repeat(propIndent + 2);
661
+ const aliasIndentStr = " ".repeat(propIndent + 4);
662
+ const itemAliasIndentStr = " ".repeat(propIndent + 6);
663
+ const aliasSnippet = [
664
+ `${netIndentStr}pegas-network:`,
665
+ `${aliasIndentStr}aliases:`,
666
+ `${itemAliasIndentStr}- ${scopedAlias}`
667
+ ];
668
+ if (!hasNetworks) {
669
+ remainingLines.push(`${propIndentStr}networks:`);
670
+ remainingLines.push(...aliasSnippet);
671
+ } else {
672
+ const netIdx = remainingLines.findIndex((l) => {
673
+ const ind = l.search(/\S/);
674
+ return ind === propIndent && /^\s*networks:\s*$/.test(l);
675
+ });
676
+ if (netIdx !== -1) {
677
+ let existingPegasNetworkIdx = -1;
678
+ let hasScopedAlias = false;
679
+ let listPegasNetworkIdx = -1;
680
+ for (let k = netIdx + 1;k < remainingLines.length; k++) {
681
+ const nl = remainingLines[k];
682
+ const nind = nl.search(/\S/);
683
+ if (nind <= propIndent && nl.trim() !== "" && !nl.trim().startsWith("#")) {
684
+ break;
685
+ }
686
+ if (nl.trim() === "- pegas-network") {
687
+ listPegasNetworkIdx = k;
688
+ } else if (nl.trim() === "pegas-network:") {
689
+ existingPegasNetworkIdx = k;
690
+ }
691
+ if (nl.trim() === `- ${scopedAlias}`) {
692
+ hasScopedAlias = true;
693
+ }
694
+ }
695
+ if (listPegasNetworkIdx !== -1) {
696
+ remainingLines.splice(listPegasNetworkIdx, 1, ...aliasSnippet);
697
+ } else if (existingPegasNetworkIdx !== -1) {
698
+ if (!hasScopedAlias) {
699
+ let aliasHeaderIdx = -1;
700
+ for (let k = existingPegasNetworkIdx + 1;k < remainingLines.length; k++) {
701
+ const nl = remainingLines[k];
702
+ const nind = nl.search(/\S/);
703
+ if (nind <= propIndent + 2 && nl.trim() !== "" && !nl.trim().startsWith("#")) {
704
+ break;
705
+ }
706
+ if (nl.trim() === "aliases:") {
707
+ aliasHeaderIdx = k;
708
+ break;
709
+ }
710
+ }
711
+ if (aliasHeaderIdx !== -1) {
712
+ remainingLines.splice(aliasHeaderIdx + 1, 0, `${itemAliasIndentStr}- ${scopedAlias}`);
713
+ } else {
714
+ remainingLines.splice(existingPegasNetworkIdx + 1, 0, `${aliasIndentStr}aliases:`, `${itemAliasIndentStr}- ${scopedAlias}`);
715
+ }
716
+ }
717
+ } else {
718
+ remainingLines.splice(netIdx + 1, 0, ...aliasSnippet);
719
+ }
720
+ }
721
+ }
722
+ const hasLogging = sLines.some((l) => /^\s*logging:\s*$/.test(l));
723
+ if (!hasLogging) {
724
+ const logIndentStr = propIndentStr;
725
+ const logSubIndentStr = " ".repeat(propIndent + 2);
726
+ const logOptIndentStr = " ".repeat(propIndent + 4);
727
+ const loggingSnippet = [
728
+ `${logIndentStr}logging:`,
729
+ `${logSubIndentStr}driver: "json-file"`,
730
+ `${logSubIndentStr}options:`,
731
+ `${logOptIndentStr}max-size: "50m"`,
732
+ `${logOptIndentStr}max-file: "3"`
733
+ ];
734
+ const netIdx = remainingLines.findIndex((l) => {
735
+ const ind = l.search(/\S/);
736
+ return ind === propIndent && /^\s*networks:\s*$/.test(l);
737
+ });
738
+ if (netIdx !== -1) {
739
+ remainingLines.splice(netIdx, 0, ...loggingSnippet);
740
+ } else {
741
+ remainingLines.push(...loggingSnippet);
742
+ }
743
+ }
744
+ }
745
+ return [header, ...remainingLines];
746
+ }
747
+ for (let idx = 0;idx < lines.length; idx++) {
748
+ const line = lines[idx];
749
+ const trimmed = line.trim();
750
+ const lineIndent = line.search(/\S/);
751
+ if (lineIndent === 0 && /^[a-zA-Z0-9_-]+:\s*$/.test(line)) {
752
+ if (currentServiceName && currentServiceHeader) {
753
+ const processed = processService(currentServiceName, currentServiceHeader, currentServiceLines);
754
+ outputLines.push(...processed);
755
+ currentServiceName = null;
756
+ currentServiceHeader = null;
757
+ currentServiceLines = [];
758
+ }
759
+ if (/^services:\s*$/.test(line)) {
760
+ inServices = true;
761
+ serviceNameIndent = null;
762
+ inNetworksSection = false;
763
+ } else {
764
+ inServices = false;
765
+ inNetworksSection = /^networks:\s*$/.test(line);
766
+ }
767
+ outputLines.push(line);
768
+ continue;
769
+ }
770
+ if (inServices) {
771
+ if (serviceNameIndent === null && lineIndent > 0 && /^[a-zA-Z0-9._-]+:\s*$/.test(trimmed)) {
772
+ serviceNameIndent = lineIndent;
773
+ }
774
+ if (serviceNameIndent !== null && lineIndent === serviceNameIndent && /^[a-zA-Z0-9._-]+:\s*$/.test(trimmed)) {
775
+ if (currentServiceName && currentServiceHeader) {
776
+ const processed = processService(currentServiceName, currentServiceHeader, currentServiceLines);
777
+ outputLines.push(...processed);
778
+ currentServiceLines = [];
779
+ }
780
+ currentServiceName = trimmed.replace(/:\s*$/, "");
781
+ currentServiceHeader = line;
782
+ continue;
783
+ }
784
+ if (currentServiceName) {
785
+ currentServiceLines.push(line);
786
+ continue;
787
+ }
788
+ }
789
+ outputLines.push(line);
790
+ }
791
+ if (currentServiceName && currentServiceHeader) {
792
+ const processed = processService(currentServiceName, currentServiceHeader, currentServiceLines);
793
+ outputLines.push(...processed);
794
+ }
795
+ let finalContent = outputLines.join(`
796
+ `);
797
+ if (!/pegas-network:\s*\n\s*external:\s*true/m.test(finalContent)) {
798
+ const rootNetIndex = finalContent.search(/^networks:\s*$/m);
799
+ if (rootNetIndex !== -1) {
800
+ const afterNet = finalContent.indexOf(`
801
+ `, rootNetIndex) + 1;
802
+ finalContent = finalContent.slice(0, afterNet) + ` pegas-network:
803
+ external: true
804
+ ` + finalContent.slice(afterNet);
805
+ } else {
806
+ finalContent = finalContent.trimEnd() + `
807
+
808
+ networks:
809
+ pegas-network:
810
+ external: true
811
+ `;
812
+ }
813
+ }
814
+ return finalContent;
815
+ }
816
+ async function findRootCompose(dir) {
817
+ const candidates = [
818
+ "docker-compose.yml",
819
+ "docker-compose.yaml",
820
+ "compose.yml",
821
+ "compose.yaml"
822
+ ];
823
+ for (const file of candidates) {
824
+ try {
825
+ await fs3.access(path3.join(dir, file));
826
+ return file;
827
+ } catch {}
828
+ }
829
+ return null;
830
+ }
831
+ async function setupProjectCompose(projectDir, projectName) {
832
+ const slug = projectName || await detectProjectSlug(projectDir);
833
+ const existingFile = await findRootCompose(projectDir);
834
+ if (!existingFile) {
835
+ const targetFile = "docker-compose.yml";
836
+ await fs3.writeFile(path3.join(projectDir, targetFile), createDefaultCompose(slug), "utf-8");
837
+ return { created: true, updated: false, file: targetFile };
838
+ }
839
+ const fullPath = path3.join(projectDir, existingFile);
840
+ const original = await fs3.readFile(fullPath, "utf-8");
841
+ const patched = patchExistingCompose(original, slug);
842
+ if (patched !== original) {
843
+ await fs3.writeFile(fullPath, patched, "utf-8");
844
+ return { created: false, updated: true, file: existingFile };
845
+ }
846
+ return { created: false, updated: false, file: existingFile };
847
+ }
848
+
849
+ // src/commands/init.ts
850
+ var DOCS_PEGAS_MD = `# Pegas
851
+
852
+ Этот проект настроен для работы со стандартизированным L7-шлюзом **Pegas**.
853
+
854
+ ## Контракт сервисов
855
+ Конфигурация роутинга объявлена в файле \`pegas.json\`:
856
+ - \`upstream\`: адрес контейнера во внутренней сети \`pegas-network\` (например: \`app:3000\`)
857
+ - \`domains.dev\`: локальные домены разработки (например: \`app.localhost\`)
858
+ - \`domains.prod\`: боевые домены с автоматическим SSL Let's Encrypt
859
+
860
+ ## Команды
861
+ * Локальная разработка: \`npm/pnpm/bun run dev\` (запускает \`pegas dev\`)
862
+ * Деплой на продакшен: \`npm/pnpm/bun run deploy\` (запускает \`pegas deploy\`)
863
+ * Остановка проекта: \`pegas down\`
864
+
865
+ Документация: https://netrunner714.ru/pegas
866
+ `;
867
+ async function setupGitignore(projectDir) {
868
+ const gitignorePath = path4.join(projectDir, ".gitignore");
869
+ try {
870
+ const content = await fs4.readFile(gitignorePath, "utf-8");
871
+ const lines = content.split(`
872
+ `).map((line) => line.trim());
873
+ if (lines.includes(".pegas") || lines.includes(".pegas/")) {
874
+ return null;
875
+ }
876
+ const updated = content.endsWith(`
877
+ `) ? `${content}.pegas
878
+ ` : `${content}
879
+ .pegas
880
+ `;
881
+ await fs4.writeFile(gitignorePath, updated, "utf-8");
882
+ return "Добавлена запись .pegas в .gitignore";
883
+ } catch {
884
+ await fs4.writeFile(gitignorePath, `.pegas
885
+ `, "utf-8");
886
+ return "Создан .gitignore с записью .pegas";
887
+ }
888
+ }
889
+ async function handleProjectInit(cwd = process.cwd(), isJson = false) {
890
+ const actions = [];
891
+ if (cwd !== process.cwd()) {
892
+ actions.push(`Корень репозитория: ${cwd}`);
893
+ }
894
+ const contractRes = await setupProjectContract(cwd);
895
+ if (contractRes.created) {
896
+ actions.push(`Создан контракт ${contractRes.file} для проекта '${contractRes.slug}'`);
897
+ } else {
898
+ actions.push(`Контракт ${contractRes.file} уже существует`);
899
+ }
900
+ const pkgActions = await updatePackageJson(cwd);
901
+ actions.push(...pkgActions);
902
+ const gitignoreAction = await setupGitignore(cwd);
903
+ if (gitignoreAction) {
904
+ actions.push(gitignoreAction);
905
+ }
906
+ const docsDir = path4.join(cwd, "docs");
907
+ await fs4.mkdir(docsDir, { recursive: true });
908
+ const docsPath = path4.join(docsDir, "pegas.md");
909
+ await fs4.writeFile(docsPath, DOCS_PEGAS_MD, "utf-8");
910
+ actions.push("Сгенерирован docs/pegas.md");
911
+ const composeRes = await setupProjectCompose(cwd, contractRes.slug);
912
+ if (composeRes.created) {
913
+ actions.push(`Создан ${composeRes.file} с expose и сетью pegas-network`);
914
+ } else if (composeRes.updated) {
915
+ actions.push(`Выполнена трансформация портов и подключение pegas-network в ${composeRes.file}`);
916
+ } else {
917
+ actions.push(`${composeRes.file}: порты и сеть pegas-network уже оптимизированы`);
918
+ }
919
+ if (isJson) {
920
+ console.log(JSON.stringify({ status: "success", actions, docs: docsPath }));
921
+ return;
922
+ }
923
+ console.log(`
924
+ ✨ Project initialized with Pegas!
925
+ `);
926
+ for (const act of actions) {
927
+ console.log(` ✓ ${act}`);
928
+ }
929
+ console.log(`
930
+ Next steps:`);
931
+ console.log(" 1. Check pegas.json and set your domains.");
932
+ console.log(` 2. Start local dev with: npm run dev
933
+ `);
934
+ }
935
+ async function handleServerInit(options) {
936
+ const isJson = options.json ?? false;
937
+ const hasDocker = await checkDocker();
938
+ if (!hasDocker) {
939
+ if (isJson) {
940
+ console.log(JSON.stringify({ error: "Docker daemon is not running or not installed." }));
941
+ } else {
942
+ console.error("❌ Error: Docker daemon is not running or not installed.");
943
+ }
944
+ process.exit(1);
945
+ }
946
+ const hasCompose = await checkDockerCompose();
947
+ if (!hasCompose) {
948
+ if (isJson) {
949
+ console.log(JSON.stringify({ error: "Docker Compose is not available." }));
950
+ } else {
951
+ console.error("❌ Error: Docker Compose is not available.");
952
+ }
953
+ process.exit(1);
954
+ }
955
+ const gatewayDir = options.gatewayDir ? path4.resolve(options.gatewayDir) : path4.join(os2.homedir(), "gateway");
956
+ await fs4.mkdir(gatewayDir, { recursive: true });
957
+ const confDir = path4.join(gatewayDir, "conf.d");
958
+ await fs4.mkdir(confDir, { recursive: true });
959
+ const networkName = options.networkName || "pegas-network";
960
+ await ensureDockerNetwork(networkName);
961
+ const caddyfilePath = path4.join(gatewayDir, "Caddyfile");
962
+ try {
963
+ await fs4.access(caddyfilePath);
964
+ } catch {
965
+ const globalOptions = options.email ? `{
966
+ email ${options.email}
967
+ }` : `{
968
+ # Global options
969
+ }`;
970
+ const defaultCaddyfile = `${globalOptions}
971
+
972
+ import /etc/caddy/conf.d/*.caddy
973
+ `;
974
+ await fs4.writeFile(caddyfilePath, defaultCaddyfile, "utf-8");
975
+ }
976
+ const composePath = path4.join(gatewayDir, "docker-compose.yml");
977
+ try {
978
+ await fs4.access(composePath);
979
+ } catch {
980
+ const defaultCompose = `services:
981
+ caddy:
982
+ image: caddy:2-alpine
983
+ container_name: gateway-caddy
984
+ restart: unless-stopped
985
+ logging:
986
+ driver: "json-file"
987
+ options:
988
+ max-size: "50m"
989
+ max-file: "3"
990
+ ports:
991
+ - "80:80"
992
+ - "443:443"
993
+ - "443:443/udp"
994
+ volumes:
995
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
996
+ - ./conf.d:/etc/caddy/conf.d:ro
997
+ - caddy_data:/data
998
+ - caddy_config:/config
999
+ networks:
1000
+ - ${networkName}
1001
+
1002
+ networks:
1003
+ ${networkName}:
1004
+ external: true
1005
+
1006
+ volumes:
1007
+ caddy_data:
1008
+ caddy_config:
1009
+ `;
1010
+ await fs4.writeFile(composePath, defaultCompose, "utf-8");
1011
+ }
1012
+ const startRes = await startGatewayContainer(gatewayDir);
1013
+ if (!startRes.success) {
1014
+ if (isJson) {
1015
+ console.log(JSON.stringify({ error: "Failed to start Caddy container", details: startRes.stderr }));
1016
+ } else {
1017
+ console.error("❌ Failed to start Caddy container:", startRes.stderr);
1018
+ }
1019
+ process.exit(1);
1020
+ }
1021
+ const validateRes = await validateCaddyConfig(gatewayDir);
1022
+ if (isJson) {
1023
+ console.log(JSON.stringify({
1024
+ status: "success",
1025
+ gatewayDir,
1026
+ network: networkName,
1027
+ validated: validateRes.success
1028
+ }));
1029
+ } else {
1030
+ console.log(`
1031
+ \uD83D\uDE80 Pegas server gateway initialized successfully!`);
1032
+ console.log(` Gateway directory: ${gatewayDir}`);
1033
+ console.log(` Docker network: ${networkName}`);
1034
+ console.log(` Caddy container: Running on :80 and :443`);
1035
+ console.log("\nNext step: run `pegas add <domain> --upstream <service:port>` to route your first site.\n");
1036
+ }
1037
+ }
1038
+ async function handleInit(options) {
1039
+ const isJson = options.json ?? false;
1040
+ if (options.server) {
1041
+ await handleServerInit(options);
1042
+ return;
1043
+ }
1044
+ if (options.project) {
1045
+ await handleProjectInit(process.cwd(), isJson);
1046
+ return;
1047
+ }
1048
+ const projectRoot = await findProjectRoot(process.cwd());
1049
+ if (projectRoot) {
1050
+ await handleProjectInit(projectRoot, isJson);
1051
+ } else {
1052
+ await handleServerInit(options);
1053
+ }
1054
+ }
1055
+
1056
+ // src/commands/deploy.ts
1057
+ import fs6 from "node:fs/promises";
1058
+ import path6 from "node:path";
1059
+ import child_process from "node:child_process";
1060
+
1061
+ // src/generator.ts
1062
+ function isSubdomainOrIp(domain) {
1063
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(domain) || domain.includes(":")) {
1064
+ return true;
1065
+ }
1066
+ if (domain.startsWith("www.") || domain === "localhost") {
1067
+ return true;
1068
+ }
1069
+ const parts = domain.split(".");
1070
+ return parts.length > 2;
1071
+ }
1072
+ function generateCaddyfileSnippet(options) {
1073
+ const {
1074
+ domain,
1075
+ upstream,
1076
+ spa = false,
1077
+ apiUpstream,
1078
+ apiPrefix = "/api/*",
1079
+ noWww = false,
1080
+ security = true
1081
+ } = options;
1082
+ const lines = [];
1083
+ if (!noWww && !isSubdomainOrIp(domain)) {
1084
+ lines.push(`www.${domain} {`);
1085
+ lines.push(` redir https://${domain}{uri} permanent`);
1086
+ lines.push(`}`);
1087
+ lines.push(``);
1088
+ }
1089
+ lines.push(`${domain} {`);
1090
+ if (spa && apiUpstream) {
1091
+ lines.push(` encode gzip zstd`);
1092
+ lines.push(``);
1093
+ lines.push(` handle ${apiPrefix} {`);
1094
+ lines.push(` reverse_proxy ${apiUpstream}`);
1095
+ lines.push(` }`);
1096
+ lines.push(``);
1097
+ lines.push(` handle {`);
1098
+ lines.push(` reverse_proxy ${upstream || "app:80"}`);
1099
+ lines.push(` }`);
1100
+ } else {
1101
+ lines.push(` reverse_proxy ${upstream || "app:3000"}`);
1102
+ lines.push(``);
1103
+ lines.push(` encode gzip zstd`);
1104
+ }
1105
+ if (security) {
1106
+ lines.push(``);
1107
+ lines.push(` header {`);
1108
+ lines.push(` Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"`);
1109
+ lines.push(` X-Content-Type-Options nosniff`);
1110
+ lines.push(` Referrer-Policy strict-origin-when-cross-origin`);
1111
+ lines.push(` }`);
1112
+ }
1113
+ lines.push(`}`);
1114
+ lines.push(``);
1115
+ return lines.join(`
1116
+ `);
1117
+ }
1118
+
1119
+ // src/commands/reload.ts
1120
+ async function handleReload(options) {
1121
+ const isJson = options.json ?? false;
1122
+ const exitOnError = options.exitOnError ?? true;
1123
+ const gatewayDir = await findGatewayDir(options.gatewayDir);
1124
+ const validateRes = await validateCaddyConfig(gatewayDir);
1125
+ if (!validateRes.success) {
1126
+ if (isJson) {
1127
+ console.log(JSON.stringify({ error: "Validation failed", details: validateRes.stderr || validateRes.stdout }));
1128
+ } else {
1129
+ console.error("❌ Validation failed:");
1130
+ console.error(validateRes.stderr || validateRes.stdout);
1131
+ }
1132
+ if (exitOnError) {
1133
+ process.exit(1);
1134
+ }
1135
+ return false;
1136
+ }
1137
+ const reloadRes = await reloadCaddyConfig(gatewayDir);
1138
+ if (!reloadRes.success) {
1139
+ if (isJson) {
1140
+ console.log(JSON.stringify({ error: "Reload failed", details: reloadRes.stderr }));
1141
+ } else {
1142
+ console.error("❌ Reload failed:", reloadRes.stderr);
1143
+ }
1144
+ if (exitOnError) {
1145
+ process.exit(1);
1146
+ }
1147
+ return false;
1148
+ }
1149
+ if (!options.silent) {
1150
+ if (isJson) {
1151
+ console.log(JSON.stringify({ status: "success", message: "Caddy configuration reloaded" }));
1152
+ } else {
1153
+ console.log(`
1154
+ \uD83D\uDD04 Caddy configuration reloaded with zero downtime.
1155
+ `);
1156
+ }
1157
+ }
1158
+ return true;
1159
+ }
1160
+
1161
+ // src/commands/list.ts
1162
+ import fs5 from "node:fs/promises";
1163
+ import path5 from "node:path";
1164
+
1165
+ // src/table.ts
1166
+ function stripAnsi(str) {
1167
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
1168
+ }
1169
+ function stringVisualWidth(str) {
1170
+ const clean = stripAnsi(str);
1171
+ let width = 0;
1172
+ for (const char of clean) {
1173
+ const cp = char.codePointAt(0) || 0;
1174
+ if (cp >= 127744 && cp <= 129791 || cp >= 9728 && cp <= 10175) {
1175
+ width += 2;
1176
+ } else if (cp >= 32 && cp <= 126) {
1177
+ width += 1;
1178
+ } else {
1179
+ width += 1;
1180
+ }
1181
+ }
1182
+ return width;
1183
+ }
1184
+ function padEndVisual(str, targetWidth) {
1185
+ const current = stringVisualWidth(str);
1186
+ const diff = targetWidth - current;
1187
+ return diff > 0 ? str + " ".repeat(diff) : str;
1188
+ }
1189
+ function renderTable({ headers, rows }) {
1190
+ if (headers.length === 0)
1191
+ return "";
1192
+ const colWidths = headers.map((header, colIdx) => {
1193
+ let max = stringVisualWidth(header);
1194
+ for (const row of rows) {
1195
+ const cell = row[colIdx] || "";
1196
+ const w = stringVisualWidth(cell);
1197
+ if (w > max)
1198
+ max = w;
1199
+ }
1200
+ return max;
1201
+ });
1202
+ const topBorder = "┌─" + colWidths.map((w) => "─".repeat(w)).join("─┬─") + "─┐";
1203
+ const midBorder = "├─" + colWidths.map((w) => "─".repeat(w)).join("─┼─") + "─┤";
1204
+ const botBorder = "└─" + colWidths.map((w) => "─".repeat(w)).join("─┴─") + "─┘";
1205
+ const headerLine = "│ " + headers.map((h, i) => padEndVisual(`\x1B[1m${h}\x1B[0m`, colWidths[i])).join(" │ ") + " │";
1206
+ const dataLines = rows.map((row) => {
1207
+ return "│ " + colWidths.map((w, colIdx) => padEndVisual(row[colIdx] || "", w)).join(" │ ") + " │";
1208
+ });
1209
+ return [topBorder, headerLine, midBorder, ...dataLines, botBorder].join(`
1210
+ `);
1211
+ }
1212
+
1213
+ // src/commands/list.ts
1214
+ function parseCaddyProjectFile(content, filename) {
1215
+ let projectName = path5.basename(filename, ".caddy");
1216
+ const projectCommentMatch = content.match(/#\s*Auto-generated by Pegas for project:\s*([^\n\r]+)/i);
1217
+ if (projectCommentMatch && projectCommentMatch[1]) {
1218
+ projectName = projectCommentMatch[1].trim();
1219
+ }
1220
+ const routes = [];
1221
+ const wwwRedirects = new Set;
1222
+ const redirMatches = content.matchAll(/^([^{\n]+)\s*\{[^{}]*?redir\s+https?:\/\/([^\s\n{/]+)/gm);
1223
+ for (const rm of redirMatches) {
1224
+ const fromDomain = rm[1].trim();
1225
+ if (fromDomain.startsWith("www.")) {
1226
+ wwwRedirects.add(fromDomain);
1227
+ }
1228
+ }
1229
+ const blockRegex = /^([^{\n]+)\s*\{([\s\S]*?)^\}/gm;
1230
+ let blockMatch;
1231
+ while ((blockMatch = blockRegex.exec(content)) !== null) {
1232
+ const header = blockMatch[1].trim();
1233
+ const body = blockMatch[2];
1234
+ if (!header || header.startsWith("#"))
1235
+ continue;
1236
+ const proxyMatches = [...body.matchAll(/reverse_proxy\s+([^\s\n{}]+)/g)];
1237
+ if (proxyMatches.length === 0) {
1238
+ continue;
1239
+ }
1240
+ const upstream = proxyMatches[0][1];
1241
+ const rawDomains = header.split(",").map((d) => d.trim()).filter((d) => d && !d.startsWith("#"));
1242
+ const domains = [];
1243
+ for (const d of rawDomains) {
1244
+ const clean = d.replace(/:\d+$/, "");
1245
+ if (!domains.includes(clean)) {
1246
+ domains.push(clean);
1247
+ }
1248
+ const wwwVariant = `www.${clean}`;
1249
+ if (wwwRedirects.has(wwwVariant) && !domains.includes(wwwVariant)) {
1250
+ domains.push(wwwVariant);
1251
+ }
1252
+ }
1253
+ if (domains.length > 0) {
1254
+ routes.push({
1255
+ projectName,
1256
+ domains,
1257
+ upstream,
1258
+ filePath: filename
1259
+ });
1260
+ }
1261
+ }
1262
+ if (routes.length === 0) {
1263
+ const proxyMatch = content.match(/reverse_proxy\s+([^\s\n{}]+)/);
1264
+ if (proxyMatch) {
1265
+ routes.push({
1266
+ projectName,
1267
+ domains: [projectName],
1268
+ upstream: proxyMatch[1],
1269
+ filePath: filename
1270
+ });
1271
+ }
1272
+ }
1273
+ return routes;
1274
+ }
1275
+ function matchContainerForRoute(upstream, projectName, containers) {
1276
+ const parts = upstream.split(":");
1277
+ const upstreamHost = parts[0];
1278
+ for (const c of containers) {
1279
+ const cleanName = c.name.replace(/^\//, "");
1280
+ if (cleanName === upstreamHost) {
1281
+ return c;
1282
+ }
1283
+ }
1284
+ for (const c of containers) {
1285
+ const cleanName = c.name.replace(/^\//, "");
1286
+ if (cleanName.startsWith(`${upstreamHost}-`) || cleanName.includes(upstreamHost)) {
1287
+ return c;
1288
+ }
1289
+ }
1290
+ let serviceName = upstreamHost;
1291
+ if (upstreamHost.startsWith(`${projectName}-`)) {
1292
+ serviceName = upstreamHost.slice(projectName.length + 1);
1293
+ }
1294
+ for (const c of containers) {
1295
+ if (c.composeProject === projectName && c.composeService === serviceName) {
1296
+ return c;
1297
+ }
1298
+ }
1299
+ for (const c of containers) {
1300
+ if (c.composeProject === projectName) {
1301
+ return c;
1302
+ }
1303
+ }
1304
+ for (const c of containers) {
1305
+ const cleanName = c.name.replace(/^\//, "");
1306
+ if (cleanName.startsWith(`${projectName}-`) || cleanName.startsWith(projectName)) {
1307
+ return c;
1308
+ }
1309
+ }
1310
+ return null;
1311
+ }
1312
+ function formatDockerStatus(container) {
1313
+ if (!container) {
1314
+ return {
1315
+ displayStatus: "\x1B[31m\uD83D\uDD34 Offline\x1B[0m",
1316
+ state: "offline",
1317
+ statusText: "Offline"
1318
+ };
1319
+ }
1320
+ const state = container.state.toLowerCase();
1321
+ const rawStatus = container.status || state;
1322
+ if (state === "running") {
1323
+ const text = rawStatus.startsWith("Up") ? rawStatus : "Up";
1324
+ return {
1325
+ displayStatus: `\x1B[32m\uD83D\uDFE2 ${text}\x1B[0m`,
1326
+ state: "running",
1327
+ statusText: text
1328
+ };
1329
+ }
1330
+ if (state === "restarting") {
1331
+ return {
1332
+ displayStatus: `\x1B[33m\uD83D\uDFE1 ${rawStatus}\x1B[0m`,
1333
+ state: "restarting",
1334
+ statusText: rawStatus
1335
+ };
1336
+ }
1337
+ return {
1338
+ displayStatus: `\x1B[31m\uD83D\uDD34 ${rawStatus || "Offline"}\x1B[0m`,
1339
+ state: state || "exited",
1340
+ statusText: rawStatus || "Offline"
1341
+ };
1342
+ }
1343
+ async function getDockerContainers() {
1344
+ try {
1345
+ const res = await runCommand("docker", ["ps", "-a", "--format", "{{json .}}"]);
1346
+ if (!res.success || !res.stdout.trim()) {
1347
+ return [];
1348
+ }
1349
+ const lines = res.stdout.trim().split(`
1350
+ `);
1351
+ const containers = [];
1352
+ for (const line of lines) {
1353
+ try {
1354
+ const item = JSON.parse(line.trim());
1355
+ const labels = item.Labels || "";
1356
+ const projectMatch = labels.match(/com\.docker\.compose\.project=([^,]+)/);
1357
+ const serviceMatch = labels.match(/com\.docker\.compose\.service=([^,]+)/);
1358
+ containers.push({
1359
+ id: item.ID || "",
1360
+ name: item.Names || "",
1361
+ image: item.Image || "",
1362
+ state: (item.State || "").toLowerCase(),
1363
+ status: item.Status || "",
1364
+ networks: (item.Networks || "").split(",").map((n) => n.trim()),
1365
+ composeProject: projectMatch ? projectMatch[1] : undefined,
1366
+ composeService: serviceMatch ? serviceMatch[1] : undefined
1367
+ });
1368
+ } catch {}
1369
+ }
1370
+ return containers;
1371
+ } catch {
1372
+ return [];
1373
+ }
1374
+ }
1375
+ async function handleList(options) {
1376
+ const isJson = options.json ?? false;
1377
+ const gatewayDir = await findGatewayDir(options.gatewayDir);
1378
+ const confDir = path5.join(gatewayDir, "conf.d");
1379
+ const routes = [];
1380
+ try {
1381
+ const files = await fs5.readdir(confDir);
1382
+ for (const file of files) {
1383
+ if (!file.endsWith(".caddy"))
1384
+ continue;
1385
+ const filePath = path5.join(confDir, file);
1386
+ try {
1387
+ const content = await fs5.readFile(filePath, "utf-8");
1388
+ const parsed = parseCaddyProjectFile(content, file);
1389
+ routes.push(...parsed);
1390
+ } catch {}
1391
+ }
1392
+ } catch (err) {
1393
+ if (err.code !== "ENOENT") {
1394
+ throw err;
1395
+ }
1396
+ }
1397
+ if (routes.length === 0) {
1398
+ if (isJson) {
1399
+ console.log(JSON.stringify([], null, 2));
1400
+ return;
1401
+ }
1402
+ console.log(`
1403
+ ℹ️ No projects currently deployed to Pegas gateway.`);
1404
+ console.log(` Run 'pegas deploy' inside a project directory to launch your first service.
1405
+ `);
1406
+ return;
1407
+ }
1408
+ const containers = await getDockerContainers();
1409
+ const items = routes.map((route) => {
1410
+ const matchedContainer = matchContainerForRoute(route.upstream, route.projectName, containers);
1411
+ const { displayStatus, state, statusText } = formatDockerStatus(matchedContainer);
1412
+ return {
1413
+ project: route.projectName,
1414
+ domains: route.domains,
1415
+ upstream: route.upstream,
1416
+ status: statusText,
1417
+ state,
1418
+ displayStatus,
1419
+ container: matchedContainer ? matchedContainer.name.replace(/^\//, "") : undefined,
1420
+ containerId: matchedContainer?.id
1421
+ };
1422
+ });
1423
+ items.sort((a, b) => a.project.localeCompare(b.project));
1424
+ if (isJson) {
1425
+ const cleanJson = items.map(({ displayStatus, ...rest }) => rest);
1426
+ console.log(JSON.stringify(cleanJson, null, 2));
1427
+ return;
1428
+ }
1429
+ console.log(`
1430
+ \uD83D\uDE80 \x1B[1mPegas Global Dashboard\x1B[0m — Active Projects in ${gatewayDir}:
1431
+ `);
1432
+ const headers = ["PROJECT", "DOMAINS", "UPSTREAM", "STATUS"];
1433
+ const rows = items.map((item) => [
1434
+ item.project,
1435
+ item.domains.join(", "),
1436
+ item.upstream,
1437
+ item.displayStatus
1438
+ ]);
1439
+ console.log(renderTable({ headers, rows }));
1440
+ console.log(`
1441
+ Total: ${items.length} active route${items.length === 1 ? "" : "s"}
1442
+ `);
1443
+ }
1444
+
1445
+ // src/dns.ts
1446
+ import dns from "node:dns/promises";
1447
+ import https from "node:https";
1448
+ function fetchIpFromUrl(url, timeoutMs) {
1449
+ return new Promise((resolve, reject) => {
1450
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
1451
+ if (res.statusCode !== 200) {
1452
+ reject(new Error(`HTTP ${res.statusCode}`));
1453
+ return;
1454
+ }
1455
+ let data = "";
1456
+ res.setEncoding("utf-8");
1457
+ res.on("data", (chunk) => data += chunk);
1458
+ res.on("end", () => resolve(data.trim()));
1459
+ });
1460
+ req.on("timeout", () => {
1461
+ req.destroy();
1462
+ reject(new Error("Timeout"));
1463
+ });
1464
+ req.on("error", reject);
1465
+ });
1466
+ }
1467
+ async function getPublicIp(timeoutMs = 2500) {
1468
+ if (process.env.PEGAS_SERVER_IP) {
1469
+ return process.env.PEGAS_SERVER_IP.trim();
1470
+ }
1471
+ try {
1472
+ return await fetchIpFromUrl("https://api.ipify.org", timeoutMs);
1473
+ } catch {
1474
+ return await fetchIpFromUrl("https://icanhazip.com", timeoutMs);
1475
+ }
1476
+ }
1477
+ function isPublicDomain(domain) {
1478
+ const clean = domain.trim().toLowerCase();
1479
+ if (!clean || clean === "yourdomain.com")
1480
+ return false;
1481
+ if (clean === "localhost" || clean.endsWith(".localhost"))
1482
+ return false;
1483
+ if (clean.endsWith(".local") || clean.endsWith(".test") || clean.endsWith(".internal"))
1484
+ return false;
1485
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(clean) || clean.includes(":"))
1486
+ return false;
1487
+ return clean.includes(".");
1488
+ }
1489
+ async function validateDomains(domains, options = {}) {
1490
+ if (options.skipDnsCheck || !domains || domains.length === 0) {
1491
+ return { valid: true, warnings: [] };
1492
+ }
1493
+ const targets = [...new Set(domains.map((d) => d.trim()))].filter(isPublicDomain);
1494
+ if (targets.length === 0) {
1495
+ return { valid: true, warnings: [] };
1496
+ }
1497
+ let serverIp = options.serverIp;
1498
+ if (!serverIp) {
1499
+ try {
1500
+ serverIp = await getPublicIp();
1501
+ } catch {
1502
+ const msg = "⚠️ Warning: Unable to determine server's public IP. Skipping DNS pre-flight verification.";
1503
+ if (!options.json) {
1504
+ console.warn(msg);
1505
+ }
1506
+ return { valid: true, warnings: [msg] };
1507
+ }
1508
+ }
1509
+ const warnings = [];
1510
+ let hasMismatches = false;
1511
+ for (const domain of targets) {
1512
+ try {
1513
+ const records = await dns.resolve4(domain);
1514
+ if (!records.includes(serverIp)) {
1515
+ const warn = `⚠️ DNS Warning: '${domain}' resolves to ${records.join(", ")}, but this server's public IP is ${serverIp}.
1516
+ Let's Encrypt SSL certificate issuance will fail until DNS propagates.`;
1517
+ warnings.push(warn);
1518
+ hasMismatches = true;
1519
+ if (!options.json) {
1520
+ console.warn(`
1521
+ ${warn}`);
1522
+ }
1523
+ }
1524
+ } catch (err) {
1525
+ if (err.code === "ENOTFOUND" || err.code === "ENODATA") {
1526
+ const warn = `⚠️ DNS Warning: '${domain}' does not resolve to any IPv4 address (ENOTFOUND).
1527
+ Let's Encrypt SSL certificate issuance will fail until DNS propagates.`;
1528
+ warnings.push(warn);
1529
+ hasMismatches = true;
1530
+ if (!options.json) {
1531
+ console.warn(`
1532
+ ${warn}`);
1533
+ }
1534
+ } else {
1535
+ const warn = `⚠️ DNS Warning: Failed to query DNS for '${domain}': ${err.message}`;
1536
+ warnings.push(warn);
1537
+ if (!options.json) {
1538
+ console.warn(`
1539
+ ${warn}`);
1540
+ }
1541
+ }
1542
+ }
1543
+ }
1544
+ if (hasMismatches && options.strictDns) {
1545
+ const errMsg = "❌ Error: Strict DNS check failed (--strict-dns). Deploy aborted to prevent Let's Encrypt rate limits.";
1546
+ if (!options.json) {
1547
+ console.error(`
1548
+ ${errMsg}`);
1549
+ }
1550
+ return {
1551
+ valid: false,
1552
+ warnings,
1553
+ serverIp
1554
+ };
1555
+ }
1556
+ return {
1557
+ valid: !hasMismatches,
1558
+ warnings,
1559
+ serverIp
1560
+ };
1561
+ }
1562
+
1563
+ // src/commands/deploy.ts
1564
+ async function waitForContainerReadiness(projectRoot, serviceName, options = {}) {
1565
+ const timeoutMs = options.timeoutMs ?? 30000;
1566
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
1567
+ const defaultGrace = process.env.PEGAS_DEPLOY_GRACE_MS !== undefined ? parseInt(process.env.PEGAS_DEPLOY_GRACE_MS, 10) : 3000;
1568
+ const gracePeriodMs = options.gracePeriodMs ?? defaultGrace;
1569
+ const deadline = Date.now() + timeoutMs;
1570
+ while (Date.now() < deadline) {
1571
+ let containerId = "";
1572
+ try {
1573
+ const psRes = child_process.execFileSync("docker", ["compose", "ps", "-q", serviceName], { cwd: projectRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 3000 });
1574
+ containerId = psRes.trim().split(`
1575
+ `)[0]?.trim();
1576
+ } catch {}
1577
+ if (!containerId) {
1578
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
1579
+ continue;
1580
+ }
1581
+ try {
1582
+ const inspectRes = child_process.execFileSync("docker", ["inspect", containerId], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
1583
+ const inspectData = JSON.parse(inspectRes);
1584
+ if (Array.isArray(inspectData) && inspectData.length > 0) {
1585
+ const state = inspectData[0].State;
1586
+ if (state) {
1587
+ const status = state.Status?.toLowerCase();
1588
+ if (status === "exited" || status === "dead") {
1589
+ return {
1590
+ ready: false,
1591
+ service: serviceName,
1592
+ containerId,
1593
+ reason: `Container exited unexpectedly (exit code ${state.ExitCode})`
1594
+ };
1595
+ }
1596
+ if (status === "running") {
1597
+ if (state.Health) {
1598
+ const healthStatus = state.Health.Status?.toLowerCase();
1599
+ if (healthStatus === "healthy") {
1600
+ return { ready: true, service: serviceName, containerId };
1601
+ }
1602
+ if (healthStatus === "unhealthy") {
1603
+ return {
1604
+ ready: false,
1605
+ service: serviceName,
1606
+ containerId,
1607
+ reason: `Container healthcheck failed: status is '${state.Health.Status}'`
1608
+ };
1609
+ }
1610
+ } else {
1611
+ if (gracePeriodMs > 0) {
1612
+ await new Promise((r) => setTimeout(r, gracePeriodMs));
1613
+ }
1614
+ return { ready: true, service: serviceName, containerId };
1615
+ }
1616
+ }
1617
+ }
1618
+ }
1619
+ } catch {}
1620
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
1621
+ }
1622
+ return {
1623
+ ready: false,
1624
+ service: serviceName,
1625
+ reason: `Timed out waiting for service '${serviceName}' to become ready after ${timeoutMs / 1000}s`
1626
+ };
1627
+ }
1628
+ function resolveScopedUpstream(upstream, projectName) {
1629
+ const parts = upstream.split(":");
1630
+ const host = parts[0];
1631
+ const port = parts[1] || "3000";
1632
+ if (!host)
1633
+ return upstream;
1634
+ if (host.startsWith(`${projectName}-`)) {
1635
+ return upstream;
1636
+ }
1637
+ if (host === "localhost" || host === "127.0.0.1" || /^(\d{1,3}\.){3}\d{1,3}$/.test(host)) {
1638
+ return upstream;
1639
+ }
1640
+ return `${projectName}-${host}:${port}`;
1641
+ }
1642
+ async function handleDeploy(options = {}) {
1643
+ const isJson = options.json ?? false;
1644
+ const cwd = options.cwd || process.cwd();
1645
+ const projectRoot = await findProjectRoot(cwd) || cwd;
1646
+ const contractPath = path6.join(projectRoot, CONTRACT_FILENAME);
1647
+ let rawContract;
1648
+ try {
1649
+ rawContract = await fs6.readFile(contractPath, "utf-8");
1650
+ } catch {
1651
+ if (isJson) {
1652
+ console.log(JSON.stringify({
1653
+ error: `Contract '${CONTRACT_FILENAME}' not found. Run 'pegas init' first.`
1654
+ }));
1655
+ } else {
1656
+ console.error(`❌ Error: Contract '${CONTRACT_FILENAME}' not found in project root (${projectRoot}).`);
1657
+ console.error(` Run 'pegas init' to generate it.`);
1658
+ }
1659
+ if (!options.noExit) {
1660
+ if (!options.noExit) {
1661
+ process.exit(1);
1662
+ }
1663
+ return;
1664
+ }
1665
+ return;
1666
+ }
1667
+ let contract;
1668
+ try {
1669
+ contract = JSON.parse(rawContract);
1670
+ } catch (err) {
1671
+ if (isJson) {
1672
+ console.log(JSON.stringify({
1673
+ error: `Failed to parse '${path6.basename(contractPath)}': Invalid JSON syntax.`,
1674
+ details: err.message
1675
+ }));
1676
+ } else {
1677
+ console.error(`❌ Error: Failed to parse '${path6.basename(contractPath)}': Invalid JSON syntax.`);
1678
+ console.error(` ${err.message}`);
1679
+ }
1680
+ if (!options.noExit) {
1681
+ if (!options.noExit) {
1682
+ process.exit(1);
1683
+ }
1684
+ return;
1685
+ }
1686
+ return;
1687
+ }
1688
+ const projectName = contract.projectName || path6.basename(projectRoot).toLowerCase().replace(/[^a-z0-9._-]/g, "_");
1689
+ const gatewayDir = await findGatewayDir(options.gatewayDir);
1690
+ const caddyfilePath = path6.join(gatewayDir, "Caddyfile");
1691
+ try {
1692
+ await fs6.access(caddyfilePath);
1693
+ } catch {
1694
+ if (!options.dryRun) {
1695
+ if (!isJson) {
1696
+ console.log("ℹ️ Server gateway not initialized yet. Auto-bootstrapping Pegas on the fly...");
1697
+ }
1698
+ await handleServerInit({ gatewayDir, json: options.json });
1699
+ }
1700
+ }
1701
+ const requestedDomains = [];
1702
+ for (const [, serviceConfig] of Object.entries(contract.services || {})) {
1703
+ for (const d of serviceConfig.domains?.prod || []) {
1704
+ const norm = normalizeDomain(d);
1705
+ if (norm && norm !== "yourdomain.com") {
1706
+ if (!requestedDomains.includes(norm)) {
1707
+ requestedDomains.push(norm);
1708
+ }
1709
+ const parts = norm.split(".");
1710
+ if (!norm.startsWith("www.") && parts.length === 2) {
1711
+ const wwwVar = `www.${norm}`;
1712
+ if (!requestedDomains.includes(wwwVar)) {
1713
+ requestedDomains.push(wwwVar);
1714
+ }
1715
+ }
1716
+ }
1717
+ }
1718
+ }
1719
+ const projectRouteFile = getProjectRouteFilePath(gatewayDir, projectName);
1720
+ const confDir = path6.join(gatewayDir, "conf.d");
1721
+ const currentProjectFileName = path6.basename(projectRouteFile);
1722
+ try {
1723
+ const existingFiles = await fs6.readdir(confDir);
1724
+ for (const file of existingFiles) {
1725
+ if (!file.endsWith(".caddy") || file === currentProjectFileName) {
1726
+ continue;
1727
+ }
1728
+ const otherFilePath = path6.join(confDir, file);
1729
+ const otherContent = await fs6.readFile(otherFilePath, "utf-8");
1730
+ const otherRoutes = parseCaddyProjectFile(otherContent, file);
1731
+ for (const route of otherRoutes) {
1732
+ for (const claimedDomain of route.domains) {
1733
+ if (requestedDomains.includes(claimedDomain)) {
1734
+ const conflictErr = `Domain '${claimedDomain}' is already owned by project '${route.projectName}' (in ${file}).`;
1735
+ if (isJson) {
1736
+ console.log(JSON.stringify({
1737
+ error: "Domain collision",
1738
+ domain: claimedDomain,
1739
+ ownerProject: route.projectName,
1740
+ ownerFile: file
1741
+ }));
1742
+ } else {
1743
+ console.error(`
1744
+ ❌ Error: ${conflictErr}`);
1745
+ console.error(` Deploy aborted to prevent route hijacking.`);
1746
+ }
1747
+ if (!options.noExit) {
1748
+ process.exit(1);
1749
+ }
1750
+ return;
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+ } catch (err) {
1756
+ if (err.code !== "ENOENT") {}
1757
+ }
1758
+ if (!options.skipDnsCheck && requestedDomains.length > 0) {
1759
+ const dnsResult = await validateDomains(requestedDomains, {
1760
+ skipDnsCheck: options.skipDnsCheck,
1761
+ strictDns: options.strictDns,
1762
+ serverIp: options.serverIp,
1763
+ json: isJson,
1764
+ noExit: options.noExit
1765
+ });
1766
+ if (!dnsResult.valid && options.strictDns) {
1767
+ if (isJson) {
1768
+ console.log(JSON.stringify({
1769
+ error: "Strict DNS validation failed",
1770
+ warnings: dnsResult.warnings,
1771
+ serverIp: dnsResult.serverIp
1772
+ }));
1773
+ }
1774
+ if (!options.noExit) {
1775
+ process.exit(1);
1776
+ }
1777
+ return;
1778
+ }
1779
+ }
1780
+ if (!options.dryRun && !options.skipUp) {
1781
+ if (!isJson) {
1782
+ console.log(`
1783
+ \uD83D\uDE80 [1/3] Starting project containers with Docker Compose...`);
1784
+ }
1785
+ try {
1786
+ child_process.execSync("docker compose up -d", {
1787
+ stdio: "inherit",
1788
+ cwd: projectRoot
1789
+ });
1790
+ } catch (err) {
1791
+ if (isJson) {
1792
+ console.log(JSON.stringify({
1793
+ error: "Failed to execute 'docker compose up -d'",
1794
+ details: err.message
1795
+ }));
1796
+ } else {
1797
+ console.error("❌ Error: Failed to execute 'docker compose up -d':", err.message);
1798
+ }
1799
+ if (!options.noExit) {
1800
+ process.exit(1);
1801
+ }
1802
+ return;
1803
+ }
1804
+ if (!options.skipReadiness) {
1805
+ const servicesToCheck = [];
1806
+ for (const [sName, sCfg] of Object.entries(contract.services || {})) {
1807
+ if (Array.isArray(sCfg.domains?.prod) && sCfg.domains.prod.length > 0) {
1808
+ servicesToCheck.push(sName);
1809
+ }
1810
+ }
1811
+ if (servicesToCheck.length > 0) {
1812
+ if (!isJson) {
1813
+ console.log(`
1814
+ ⏳ Checking container readiness for: ${servicesToCheck.join(", ")}...`);
1815
+ }
1816
+ for (const sName of servicesToCheck) {
1817
+ const checkRes = await waitForContainerReadiness(projectRoot, sName, {
1818
+ timeoutMs: options.readinessTimeoutMs,
1819
+ gracePeriodMs: options.gracePeriodMs
1820
+ });
1821
+ if (!checkRes.ready) {
1822
+ const errMsg = `Service '${sName}' failed readiness check: ${checkRes.reason}`;
1823
+ if (isJson) {
1824
+ console.log(JSON.stringify({ error: errMsg, service: sName, reason: checkRes.reason }));
1825
+ } else {
1826
+ console.error(`
1827
+ ❌ Error: ${errMsg}`);
1828
+ console.error(` Deployment aborted before updating Caddy routes.`);
1829
+ }
1830
+ if (!options.noExit) {
1831
+ if (!options.noExit) {
1832
+ process.exit(1);
1833
+ }
1834
+ return;
1835
+ }
1836
+ return;
1837
+ }
1838
+ if (!isJson) {
1839
+ console.log(` \uD83D\uDFE2 Service '${sName}' is ready to accept traffic`);
1840
+ }
1841
+ }
1842
+ }
1843
+ }
1844
+ }
1845
+ const snippets = [];
1846
+ const deployedRoutes = [];
1847
+ const services = contract.services || {};
1848
+ for (const [serviceName, serviceConfig] of Object.entries(services)) {
1849
+ const prodDomains = serviceConfig.domains?.prod;
1850
+ const upstream = serviceConfig.upstream;
1851
+ if (!Array.isArray(prodDomains) || prodDomains.length === 0) {
1852
+ continue;
1853
+ }
1854
+ if (!upstream) {
1855
+ if (!isJson) {
1856
+ console.warn(`⚠️ Warning: Service '${serviceName}' has prod domains but no upstream. Skipping.`);
1857
+ }
1858
+ continue;
1859
+ }
1860
+ const scopedUpstream = resolveScopedUpstream(upstream, projectName);
1861
+ for (const domain of prodDomains) {
1862
+ const normDomain = normalizeDomain(domain);
1863
+ if (!normDomain)
1864
+ continue;
1865
+ if (normDomain === "yourdomain.com") {
1866
+ if (!isJson) {
1867
+ console.warn(`⚠️ Warning: Service '${serviceName}' has placeholder domain 'yourdomain.com'. Skipping.`);
1868
+ }
1869
+ continue;
1870
+ }
1871
+ const snippet = generateCaddyfileSnippet({
1872
+ domain: normDomain,
1873
+ upstream: scopedUpstream
1874
+ });
1875
+ snippets.push(snippet);
1876
+ deployedRoutes.push({
1877
+ service: serviceName,
1878
+ domain: normDomain,
1879
+ upstream: scopedUpstream
1880
+ });
1881
+ }
1882
+ }
1883
+ if (options.dryRun) {
1884
+ if (!isJson) {
1885
+ console.log(`
1886
+ \uD83D\uDCCB [dry-run] Target project route file: ${projectRouteFile}`);
1887
+ for (const r of deployedRoutes) {
1888
+ console.log(` - Route: https://${r.domain} -> ${r.upstream}`);
1889
+ }
1890
+ if (snippets.length === 0) {
1891
+ console.log(" (No production domains to configure)");
1892
+ }
1893
+ } else {
1894
+ console.log(JSON.stringify({ status: "dry-run", project: projectName, file: projectRouteFile, routes: deployedRoutes }));
1895
+ }
1896
+ return;
1897
+ }
1898
+ let originalContent = null;
1899
+ try {
1900
+ originalContent = await fs6.readFile(projectRouteFile, "utf-8");
1901
+ } catch {}
1902
+ let fileModified = false;
1903
+ if (snippets.length > 0) {
1904
+ const combinedCaddyfile = `# Auto-generated by Pegas for project: ${projectName}
1905
+ # Do not edit manually; update pegas.json instead.
1906
+
1907
+ ` + snippets.join(`
1908
+
1909
+ `) + `
1910
+ `;
1911
+ await fs6.mkdir(path6.dirname(projectRouteFile), { recursive: true });
1912
+ await fs6.writeFile(projectRouteFile, combinedCaddyfile, "utf-8");
1913
+ await fs6.chmod(projectRouteFile, 420).catch(() => {});
1914
+ fileModified = true;
1915
+ if (!isJson) {
1916
+ console.log(`
1917
+ \uD83D\uDCC4 [2/3] Generated route config: ${projectRouteFile}`);
1918
+ for (const r of deployedRoutes) {
1919
+ console.log(` ✓ Route: https://${r.domain} -> ${r.upstream}`);
1920
+ }
1921
+ }
1922
+ } else if (originalContent !== null) {
1923
+ await fs6.unlink(projectRouteFile).catch(() => {});
1924
+ fileModified = true;
1925
+ if (!isJson) {
1926
+ console.log(`
1927
+ \uD83D\uDDD1️ [2/3] Removed old project route config: ${projectRouteFile}`);
1928
+ }
1929
+ } else {
1930
+ if (!isJson) {
1931
+ console.log(`
1932
+ ℹ️ [2/3] No active production domains found in 'pegas.json'.`);
1933
+ }
1934
+ }
1935
+ if (fileModified) {
1936
+ if (!isJson) {
1937
+ console.log(`
1938
+ \uD83D\uDD04 [3/3] Validating and hot-reloading Caddy gateway...`);
1939
+ }
1940
+ const reloaded = await handleReload({
1941
+ gatewayDir,
1942
+ json: options.json,
1943
+ silent: isJson,
1944
+ exitOnError: false
1945
+ });
1946
+ if (!reloaded) {
1947
+ if (!isJson) {
1948
+ console.error("⚠️ Hot reload failed. Rolling back route configuration...");
1949
+ }
1950
+ if (originalContent !== null) {
1951
+ await fs6.writeFile(projectRouteFile, originalContent, "utf-8");
1952
+ } else {
1953
+ await fs6.unlink(projectRouteFile).catch(() => {});
1954
+ }
1955
+ if (!options.noExit) {
1956
+ process.exit(1);
1957
+ }
1958
+ return;
1959
+ }
1960
+ }
1961
+ if (isJson) {
1962
+ console.log(JSON.stringify({
1963
+ status: "success",
1964
+ project: projectName,
1965
+ routeFile: projectRouteFile,
1966
+ routes: deployedRoutes
1967
+ }));
1968
+ return;
1969
+ }
1970
+ console.log(`
1971
+ \uD83C\uDF89 Project '${projectName}' successfully deployed to Pegas gateway!
1972
+ `);
1973
+ }
1974
+
1975
+ // src/commands/down.ts
1976
+ import fs7 from "node:fs/promises";
1977
+ import path7 from "node:path";
1978
+ import child_process2 from "node:child_process";
1979
+ async function handleDown(options = {}) {
1980
+ const isJson = options.json ?? false;
1981
+ const cwd = options.cwd || process.cwd();
1982
+ const projectRoot = await findProjectRoot(cwd) || cwd;
1983
+ let projectName = path7.basename(projectRoot).toLowerCase().replace(/[^a-z0-9._-]/g, "_");
1984
+ try {
1985
+ const rawContract = await fs7.readFile(path7.join(projectRoot, CONTRACT_FILENAME), "utf-8");
1986
+ const contract = JSON.parse(rawContract);
1987
+ if (contract.projectName) {
1988
+ projectName = contract.projectName;
1989
+ }
1990
+ } catch {}
1991
+ const gatewayDir = await findGatewayDir(options.gatewayDir);
1992
+ const routeFilePath = getProjectRouteFilePath(gatewayDir, projectName);
1993
+ let fileRemoved = false;
1994
+ try {
1995
+ await fs7.unlink(routeFilePath);
1996
+ fileRemoved = true;
1997
+ if (!isJson) {
1998
+ console.log(`
1999
+ \uD83D\uDDD1️ [1/2] Removed project route file: ${routeFilePath}`);
2000
+ }
2001
+ } catch {
2002
+ if (!isJson) {
2003
+ console.log(`
2004
+ ℹ️ [1/2] No route file found for project '${projectName}' at ${routeFilePath}`);
2005
+ }
2006
+ }
2007
+ if (fileRemoved) {
2008
+ if (!isJson) {
2009
+ console.log("\uD83D\uDD04 Reloading Caddy gateway...");
2010
+ }
2011
+ await handleReload({
2012
+ gatewayDir,
2013
+ json: options.json,
2014
+ silent: false,
2015
+ exitOnError: false
2016
+ });
2017
+ }
2018
+ if (!options.skipComposeDown) {
2019
+ if (!isJson) {
2020
+ console.log(`
2021
+ \uD83D\uDED1 [2/2] Stopping project containers with 'docker compose down'...
2022
+ `);
2023
+ }
2024
+ try {
2025
+ child_process2.execSync("docker compose down", {
2026
+ stdio: "inherit",
2027
+ cwd: projectRoot
2028
+ });
2029
+ } catch (err) {
2030
+ if (isJson) {
2031
+ console.log(JSON.stringify({
2032
+ error: "Failed to execute 'docker compose down'",
2033
+ details: err.message
2034
+ }));
2035
+ } else {
2036
+ console.error("❌ Error: Failed to execute 'docker compose down':", err.message);
2037
+ }
2038
+ process.exit(1);
2039
+ }
2040
+ }
2041
+ if (isJson) {
2042
+ console.log(JSON.stringify({
2043
+ status: "success",
2044
+ project: projectName,
2045
+ routeFile: routeFilePath,
2046
+ fileRemoved
2047
+ }));
2048
+ return;
2049
+ }
2050
+ console.log(`
2051
+ ✨ Project '${projectName}' taken down and routes cleaned up successfully.
2052
+ `);
2053
+ }
2054
+
2055
+ // src/commands/dev.ts
2056
+ import fs9 from "node:fs/promises";
2057
+ import path9 from "node:path";
2058
+ import child_process4 from "node:child_process";
2059
+
2060
+ // src/binary.ts
2061
+ import fs8 from "node:fs/promises";
2062
+ import path8 from "node:path";
2063
+ import os3 from "node:os";
2064
+ import child_process3 from "node:child_process";
2065
+ async function findOrDownloadCaddy(options = {}) {
2066
+ const isWin = process.platform === "win32";
2067
+ const binaryName = isWin ? "caddy.exe" : "caddy";
2068
+ if (process.env.CADDY_PATH) {
2069
+ try {
2070
+ await fs8.access(process.env.CADDY_PATH);
2071
+ return process.env.CADDY_PATH;
2072
+ } catch {}
2073
+ }
2074
+ try {
2075
+ const whichCmd = isWin ? `where ${binaryName}` : `which ${binaryName}`;
2076
+ const sysPath = child_process3.execSync(whichCmd, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim().split(`
2077
+ `)[0].trim();
2078
+ if (sysPath) {
2079
+ return sysPath;
2080
+ }
2081
+ } catch {}
2082
+ const cacheDir = path8.join(os3.homedir(), ".pegas", "bin");
2083
+ const cachedBinary = path8.join(cacheDir, binaryName);
2084
+ try {
2085
+ await fs8.access(cachedBinary);
2086
+ return cachedBinary;
2087
+ } catch {}
2088
+ if (!options.silent) {
2089
+ console.log("⬇️ Caddy binary not found in PATH or cache. Downloading official release...");
2090
+ }
2091
+ await fs8.mkdir(cacheDir, { recursive: true });
2092
+ const platformMap = {
2093
+ linux: "linux",
2094
+ darwin: "mac",
2095
+ win32: "windows"
2096
+ };
2097
+ const archMap = {
2098
+ x64: "amd64",
2099
+ arm64: "arm64",
2100
+ arm: "armv7"
2101
+ };
2102
+ const plat = platformMap[process.platform];
2103
+ const arch = archMap[process.arch];
2104
+ if (!plat || !arch) {
2105
+ throw new Error(`Unsupported platform or architecture for Caddy: ${process.platform}/${process.arch}`);
2106
+ }
2107
+ const version = "2.9.1";
2108
+ const ext = isWin ? "zip" : "tar.gz";
2109
+ const filename = `caddy_${version}_${plat}_${arch}.${ext}`;
2110
+ const url = `https://github.com/caddyserver/caddy/releases/download/v${version}/${filename}`;
2111
+ const archivePath = path8.join(cacheDir, filename);
2112
+ const res = await fetch(url, { redirect: "follow" });
2113
+ if (!res.ok) {
2114
+ throw new Error(`Failed to download Caddy from ${url}: ${res.status} ${res.statusText}`);
2115
+ }
2116
+ const buffer = Buffer.from(await res.arrayBuffer());
2117
+ await fs8.writeFile(archivePath, buffer);
2118
+ if (isWin) {
2119
+ child_process3.execSync(`tar -xf "${archivePath}" -C "${cacheDir}" "${binaryName}"`);
2120
+ } else {
2121
+ child_process3.execSync(`tar -xzf "${archivePath}" -C "${cacheDir}" "${binaryName}"`);
2122
+ await fs8.chmod(cachedBinary, 493);
2123
+ }
2124
+ await fs8.unlink(archivePath).catch(() => {});
2125
+ if (!options.silent) {
2126
+ console.log(`✓ Caddy installed to ${cachedBinary}`);
2127
+ }
2128
+ return cachedBinary;
2129
+ }
2130
+
2131
+ // src/ports.ts
2132
+ import net from "node:net";
2133
+ function isPortAvailable(port, host) {
2134
+ return new Promise((resolve) => {
2135
+ const server = net.createServer();
2136
+ server.unref();
2137
+ server.once("error", () => {
2138
+ resolve(false);
2139
+ });
2140
+ const options = { port };
2141
+ if (host) {
2142
+ options.host = host;
2143
+ }
2144
+ server.listen(options, () => {
2145
+ server.close(() => {
2146
+ resolve(true);
2147
+ });
2148
+ });
2149
+ });
2150
+ }
2151
+ async function findAvailablePort(startPort, exclude = []) {
2152
+ let port = startPort;
2153
+ while (port < 65535) {
2154
+ if (!exclude.includes(port)) {
2155
+ const free = await isPortAvailable(port);
2156
+ if (free) {
2157
+ return port;
2158
+ }
2159
+ }
2160
+ port++;
2161
+ }
2162
+ throw new Error(`No available port found starting from ${startPort}`);
2163
+ }
2164
+
2165
+ // src/commands/dev.ts
2166
+ function generateDevCaddyfile(contract, ports = { httpsPort: 8443, httpPort: 8080 }) {
2167
+ const globalBlock = `{
2168
+ admin off
2169
+ http_port ${ports.httpPort}
2170
+ https_port ${ports.httpsPort}
2171
+ skip_install_trust
2172
+ }`;
2173
+ const blocks = [globalBlock];
2174
+ const services = contract.services || {};
2175
+ for (const [, serviceConfig] of Object.entries(services)) {
2176
+ const devDomains = serviceConfig.domains?.dev;
2177
+ if (!Array.isArray(devDomains) || devDomains.length === 0) {
2178
+ continue;
2179
+ }
2180
+ let port = "3000";
2181
+ if (serviceConfig.upstream) {
2182
+ const match = serviceConfig.upstream.match(/:(\d+)$/);
2183
+ if (match) {
2184
+ port = match[1];
2185
+ }
2186
+ }
2187
+ const domainList = devDomains.map((d) => d.trim()).filter(Boolean).join(", ");
2188
+ if (!domainList)
2189
+ continue;
2190
+ blocks.push(`${domainList} {
2191
+ reverse_proxy localhost:${port}
2192
+ }`);
2193
+ }
2194
+ if (blocks.length === 1) {
2195
+ blocks.push(`app.localhost {
2196
+ reverse_proxy localhost:3000
2197
+ }`);
2198
+ }
2199
+ return `# Auto-generated by pegas dev. Do not edit manually.
2200
+
2201
+ ` + blocks.join(`
2202
+
2203
+ `) + `
2204
+ `;
2205
+ }
2206
+ async function handleDev(options = {}) {
2207
+ const cwd = options.cwd || process.cwd();
2208
+ const projectRoot = await findProjectRoot(cwd) || cwd;
2209
+ const contractPath = path9.join(projectRoot, CONTRACT_FILENAME);
2210
+ let contractRaw;
2211
+ try {
2212
+ contractRaw = await fs9.readFile(contractPath, "utf-8");
2213
+ } catch {
2214
+ console.error(`❌ Error: Contract '${CONTRACT_FILENAME}' not found in project root (${projectRoot}).`);
2215
+ console.error(` Run 'pegas init' first to generate the contract.`);
2216
+ if (!options.noExit) {
2217
+ process.exit(1);
2218
+ }
2219
+ return;
2220
+ }
2221
+ let contract;
2222
+ try {
2223
+ contract = JSON.parse(contractRaw);
2224
+ } catch (err) {
2225
+ console.error(`❌ Error: Failed to parse '${path9.basename(contractPath)}': Invalid JSON syntax.`);
2226
+ console.error(` ${err.message}`);
2227
+ if (!options.noExit) {
2228
+ process.exit(1);
2229
+ }
2230
+ return;
2231
+ }
2232
+ let shouldBoot = false;
2233
+ if (options.infra === true) {
2234
+ shouldBoot = true;
2235
+ } else if (options.infra === false || options.skipInfra === true) {
2236
+ shouldBoot = false;
2237
+ } else if (contract.bootInfra !== undefined) {
2238
+ shouldBoot = Boolean(contract.bootInfra);
2239
+ }
2240
+ let startedInfraServices = [];
2241
+ const composeFile = await findRootCompose(projectRoot);
2242
+ if (composeFile) {
2243
+ try {
2244
+ const composeContent = await fs9.readFile(path9.join(projectRoot, composeFile), "utf-8");
2245
+ const infraServices = detectInfraServices(composeContent);
2246
+ if (infraServices.length > 0) {
2247
+ if (shouldBoot) {
2248
+ console.log(`\uD83D\uDCE6 Booting project infrastructure with Docker Compose...`);
2249
+ const serviceNames = infraServices.map((s) => s.name);
2250
+ try {
2251
+ child_process4.execFileSync("docker", ["compose", "up", "-d", ...serviceNames], {
2252
+ cwd: projectRoot,
2253
+ stdio: "pipe"
2254
+ });
2255
+ startedInfraServices = serviceNames;
2256
+ for (const s of infraServices) {
2257
+ const port = s.hostPort || (s.containerPort ? String(s.containerPort) : "ready");
2258
+ console.log(`\uD83D\uDFE2 Infra [${s.name}] is ready on localhost:${port}`);
2259
+ }
2260
+ console.log("");
2261
+ } catch (err) {
2262
+ console.warn(`⚠️ Warning: Failed to auto-start infra services: ${err.message}
2263
+ `);
2264
+ }
2265
+ } else {
2266
+ const list = infraServices.map((s) => s.name).join(", ");
2267
+ console.log(`\uD83D\uDCA1 Tip: Detected infra services (${list}). Pass '--infra' or set '"bootInfra": true' in ${CONTRACT_FILENAME} to auto-boot.
2268
+ `);
2269
+ }
2270
+ }
2271
+ } catch {}
2272
+ }
2273
+ const httpsPort = await findAvailablePort(8443);
2274
+ const httpPort = await findAvailablePort(8080, [httpsPort]);
2275
+ const caddyPath = await findOrDownloadCaddy();
2276
+ const devCaddyDir = path9.join(projectRoot, ".pegas");
2277
+ await fs9.mkdir(devCaddyDir, { recursive: true });
2278
+ const devCaddyfilePath = path9.join(devCaddyDir, "Caddyfile.dev");
2279
+ const caddyfileContent = generateDevCaddyfile(contract, { httpsPort, httpPort });
2280
+ await fs9.writeFile(devCaddyfilePath, caddyfileContent, "utf-8");
2281
+ console.log("\uD83D\uDD12 Starting local Caddy HTTPS proxy...");
2282
+ const caddyProc = child_process4.spawn(caddyPath, ["run", "--config", devCaddyfilePath], {
2283
+ stdio: ["ignore", "pipe", "pipe"],
2284
+ cwd: projectRoot
2285
+ });
2286
+ let caddyStderr = "";
2287
+ caddyProc.stderr?.on("data", (chunk) => {
2288
+ const text = chunk.toString();
2289
+ caddyStderr += text;
2290
+ if (text.includes("bind: address already in use") || text.includes("permission denied")) {
2291
+ console.error(`
2292
+ ❌ Caddy error: ${text.trim()}`);
2293
+ }
2294
+ });
2295
+ await new Promise((resolve) => setTimeout(resolve, 300));
2296
+ if (caddyProc.exitCode !== null) {
2297
+ console.error("❌ Failed to start Caddy proxy:");
2298
+ console.error(caddyStderr);
2299
+ if (!options.noExit) {
2300
+ process.exit(1);
2301
+ }
2302
+ return;
2303
+ }
2304
+ const devUrls = [];
2305
+ for (const s of Object.values(contract.services || {})) {
2306
+ for (const d of s.domains?.dev || []) {
2307
+ devUrls.push(`https://${d}:${httpsPort}`);
2308
+ }
2309
+ }
2310
+ if (devUrls.length === 0) {
2311
+ devUrls.push(`https://app.localhost:${httpsPort}`);
2312
+ }
2313
+ for (const u of devUrls) {
2314
+ console.log(`\uD83D\uDE80 Pegas proxy active at: ${u}`);
2315
+ }
2316
+ let userProc = null;
2317
+ const userCmdArgs = options.command || [];
2318
+ const isWin = process.platform === "win32";
2319
+ let isCleaningUp = false;
2320
+ function cleanup(exitCode = 0) {
2321
+ if (isCleaningUp)
2322
+ return;
2323
+ isCleaningUp = true;
2324
+ if (caddyProc && !caddyProc.killed) {
2325
+ try {
2326
+ caddyProc.kill("SIGTERM");
2327
+ } catch {}
2328
+ }
2329
+ if (userProc && !userProc.killed && userProc.pid) {
2330
+ try {
2331
+ if (isWin) {
2332
+ userProc.kill("SIGTERM");
2333
+ } else {
2334
+ process.kill(-userProc.pid, "SIGTERM");
2335
+ }
2336
+ } catch {}
2337
+ }
2338
+ if (startedInfraServices.length > 0) {
2339
+ try {
2340
+ console.log(`
2341
+ \uD83D\uDED1 Stopping infrastructure services: ${startedInfraServices.join(", ")}...`);
2342
+ child_process4.execFileSync("docker", ["compose", "stop", ...startedInfraServices], {
2343
+ cwd: projectRoot,
2344
+ stdio: "pipe"
2345
+ });
2346
+ } catch {}
2347
+ }
2348
+ if (!options.noExit) {
2349
+ process.exit(exitCode);
2350
+ }
2351
+ }
2352
+ process.on("SIGINT", () => cleanup(0));
2353
+ process.on("SIGTERM", () => cleanup(0));
2354
+ caddyProc.on("exit", (code) => {
2355
+ if (!isCleaningUp) {
2356
+ console.log(`
2357
+ ⚠️ Caddy proxy exited with code ${code}`);
2358
+ cleanup(code || 0);
2359
+ }
2360
+ });
2361
+ if (userCmdArgs.length > 0) {
2362
+ const cmdBinary = userCmdArgs[0];
2363
+ const cmdArgs = userCmdArgs.slice(1);
2364
+ const cmdStr = userCmdArgs.join(" ");
2365
+ console.log(`\uD83D\uDCE6 Running command: ${cmdStr}
2366
+ `);
2367
+ await new Promise((resolve) => {
2368
+ userProc = child_process4.spawn(cmdBinary, cmdArgs, {
2369
+ shell: isWin,
2370
+ stdio: "inherit",
2371
+ cwd: projectRoot,
2372
+ detached: !isWin
2373
+ });
2374
+ userProc.on("exit", (code) => {
2375
+ cleanup(code || 0);
2376
+ resolve();
2377
+ });
2378
+ userProc.on("error", (err) => {
2379
+ console.error(`❌ Failed to start command '${cmdStr}':`, err.message);
2380
+ cleanup(1);
2381
+ resolve();
2382
+ });
2383
+ });
2384
+ } else {
2385
+ console.log("\uD83D\uDCA1 Caddy proxy running in foreground. Press Ctrl+C to stop.");
2386
+ await new Promise(() => {});
2387
+ }
2388
+ }
2389
+
2390
+ // src/commands/add.ts
2391
+ import fs10 from "node:fs/promises";
2392
+ import path10 from "node:path";
2393
+ async function createOrUpdateRouteFile(options) {
2394
+ const routeFilePath = getRouteFilePath(options.gatewayDir, options.domain);
2395
+ let alreadyExists = false;
2396
+ let originalContent = null;
2397
+ try {
2398
+ originalContent = await fs10.readFile(routeFilePath, "utf-8");
2399
+ alreadyExists = true;
2400
+ } catch {}
2401
+ const snippet = generateCaddyfileSnippet(options);
2402
+ await fs10.writeFile(routeFilePath, snippet, "utf-8");
2403
+ return { routeFilePath, snippet, alreadyExists, originalContent };
2404
+ }
2405
+ async function handleAdd(options) {
2406
+ const isJson = options.json ?? false;
2407
+ if (!options.domain) {
2408
+ if (isJson) {
2409
+ console.log(JSON.stringify({ error: "Missing domain argument. Usage: pegas add <domain> --upstream <service:port>" }));
2410
+ } else {
2411
+ console.error("❌ Error: Missing domain name. Usage: pegas add <domain> --upstream <service:port>");
2412
+ }
2413
+ process.exit(1);
2414
+ }
2415
+ if (!options.upstream) {
2416
+ if (isJson) {
2417
+ console.log(JSON.stringify({ error: "Missing --upstream argument. Usage: pegas add <domain> --upstream <service:port>" }));
2418
+ } else {
2419
+ console.error("❌ Error: Missing --upstream argument. Usage: pegas add <domain> --upstream <service:port>");
2420
+ }
2421
+ process.exit(1);
2422
+ }
2423
+ const gatewayDir = await findGatewayDir(options.gatewayDir);
2424
+ const caddyfilePath = path10.join(gatewayDir, "Caddyfile");
2425
+ try {
2426
+ await fs10.access(caddyfilePath);
2427
+ } catch {
2428
+ if (!options.dryRun) {
2429
+ if (!isJson) {
2430
+ console.log("ℹ️ Server gateway not initialized yet. Auto-bootstrapping Pegas on the fly...");
2431
+ }
2432
+ await handleServerInit({ gatewayDir, json: options.json });
2433
+ }
2434
+ }
2435
+ const routeFilePath = getRouteFilePath(gatewayDir, options.domain);
2436
+ let alreadyExists = false;
2437
+ try {
2438
+ await fs10.access(routeFilePath);
2439
+ alreadyExists = true;
2440
+ } catch {}
2441
+ if (alreadyExists && !options.force && !options.dryRun) {
2442
+ if (isJson) {
2443
+ console.log(JSON.stringify({ error: `Route for ${options.domain} already exists. Use --force to overwrite.` }));
2444
+ } else {
2445
+ console.error(`⚠️ Route for '${options.domain}' already exists at: ${routeFilePath}`);
2446
+ console.error(` Use '--force' flag to overwrite.`);
2447
+ }
2448
+ process.exit(1);
2449
+ }
2450
+ if (options.dryRun) {
2451
+ const snippet = generateCaddyfileSnippet(options);
2452
+ if (isJson) {
2453
+ console.log(JSON.stringify({ domain: options.domain, snippet }));
2454
+ } else {
2455
+ console.log(`
2456
+ --- Preview of ${routeFilePath} ---
2457
+ `);
2458
+ console.log(snippet);
2459
+ console.log(`--- End of preview (dry run) ---
2460
+ `);
2461
+ }
2462
+ return;
2463
+ }
2464
+ const { originalContent } = await createOrUpdateRouteFile({
2465
+ domain: options.domain,
2466
+ upstream: options.upstream,
2467
+ gatewayDir,
2468
+ spa: options.spa,
2469
+ apiUpstream: options.apiUpstream,
2470
+ apiPrefix: options.apiPrefix,
2471
+ noWww: options.noWww,
2472
+ security: options.security
2473
+ });
2474
+ const validateRes = await validateCaddyConfig(gatewayDir);
2475
+ if (!validateRes.success) {
2476
+ if (originalContent !== null) {
2477
+ await fs10.writeFile(routeFilePath, originalContent, "utf-8");
2478
+ } else {
2479
+ await fs10.unlink(routeFilePath).catch(() => {});
2480
+ }
2481
+ if (isJson) {
2482
+ console.log(JSON.stringify({
2483
+ error: "Caddy configuration validation failed. Changes were rolled back.",
2484
+ details: validateRes.stderr || validateRes.stdout
2485
+ }));
2486
+ } else {
2487
+ console.error("❌ Caddy configuration validation failed! Changes have been rolled back.");
2488
+ console.error(validateRes.stderr || validateRes.stdout);
2489
+ }
2490
+ process.exit(1);
2491
+ }
2492
+ const reloadRes = await reloadCaddyConfig(gatewayDir);
2493
+ if (!reloadRes.success) {
2494
+ if (isJson) {
2495
+ console.log(JSON.stringify({
2496
+ error: "Failed to reload Caddy.",
2497
+ details: reloadRes.stderr
2498
+ }));
2499
+ } else {
2500
+ console.error("❌ Failed to reload Caddy:", reloadRes.stderr);
2501
+ }
2502
+ process.exit(1);
2503
+ }
2504
+ const composePatch = `services:
2505
+ ${(options.upstream || "app:3000").split(":")[0]}:
2506
+ networks:
2507
+ pegas-network:
2508
+ aliases:
2509
+ - ${(options.upstream || "app:3000").split(":")[0]}
2510
+
2511
+ networks:
2512
+ pegas-network:
2513
+ external: true`;
2514
+ if (isJson) {
2515
+ console.log(JSON.stringify({
2516
+ status: "success",
2517
+ domain: options.domain,
2518
+ upstream: options.upstream || "app:3000",
2519
+ configFile: routeFilePath,
2520
+ composePatch
2521
+ }));
2522
+ } else {
2523
+ console.log(`
2524
+ ✅ Route for https://${options.domain} successfully configured and live!`);
2525
+ console.log(` Config: ${routeFilePath}`);
2526
+ console.log(` Upstream: ${options.upstream || "app:3000"}`);
2527
+ console.log(` SSL: Automatic Let's Encrypt (issued on first HTTP/HTTPS request)
2528
+ `);
2529
+ console.log(`\uD83D\uDCCB Add this to your project's docker-compose.yml:
2530
+ `);
2531
+ console.log(composePatch);
2532
+ console.log(`
2533
+ `);
2534
+ }
2535
+ }
2536
+
2537
+ // src/commands/remove.ts
2538
+ import fs11 from "node:fs/promises";
2539
+ async function handleRemove(options) {
2540
+ const isJson = options.json ?? false;
2541
+ if (!options.domain) {
2542
+ if (isJson) {
2543
+ console.log(JSON.stringify({ error: "Missing domain argument. Usage: pegas remove <domain>" }));
2544
+ } else {
2545
+ console.error("❌ Error: Missing domain argument. Usage: pegas remove <domain>");
2546
+ }
2547
+ process.exit(1);
2548
+ }
2549
+ const gatewayDir = await findGatewayDir(options.gatewayDir);
2550
+ const routeFilePath = getRouteFilePath(gatewayDir, options.domain);
2551
+ try {
2552
+ await fs11.unlink(routeFilePath);
2553
+ } catch (err) {
2554
+ if (isJson) {
2555
+ console.log(JSON.stringify({ error: `Route for ${options.domain} not found.` }));
2556
+ } else {
2557
+ console.error(`❌ Route for '${options.domain}' not found at: ${routeFilePath}`);
2558
+ }
2559
+ process.exit(1);
2560
+ }
2561
+ const reloadRes = await reloadCaddyConfig(gatewayDir);
2562
+ if (!reloadRes.success) {
2563
+ if (isJson) {
2564
+ console.log(JSON.stringify({ error: "Failed to reload Caddy.", details: reloadRes.stderr }));
2565
+ } else {
2566
+ console.error("❌ Failed to reload Caddy:", reloadRes.stderr);
2567
+ }
2568
+ process.exit(1);
2569
+ }
2570
+ if (isJson) {
2571
+ console.log(JSON.stringify({ status: "success", domain: options.domain, removed: routeFilePath }));
2572
+ } else {
2573
+ console.log(`
2574
+ \uD83D\uDDD1️ Route for ${options.domain} removed and Caddy reloaded successfully.
2575
+ `);
2576
+ }
2577
+ }
2578
+
2579
+ // src/commands/status.ts
2580
+ async function handleStatus(options) {
2581
+ const isJson = options.json ?? false;
2582
+ const dockerOk = await checkDocker();
2583
+ const composeOk = await checkDockerCompose();
2584
+ const gatewayDir = await findGatewayDir(options.gatewayDir);
2585
+ const netInspect = await runCommand("docker", ["network", "inspect", "pegas-network"]);
2586
+ const networkOk = netInspect.success;
2587
+ const containerStatus = await getGatewayContainerStatus(gatewayDir);
2588
+ const isRunning = containerStatus.success && containerStatus.stdout.includes("caddy");
2589
+ const routes = await listRoutes(gatewayDir);
2590
+ if (isJson) {
2591
+ console.log(JSON.stringify({
2592
+ docker: dockerOk,
2593
+ compose: composeOk,
2594
+ gatewayDir,
2595
+ network: { name: "pegas-network", exists: networkOk },
2596
+ container: { running: isRunning, details: containerStatus.stdout },
2597
+ activeRoutesCount: routes.length
2598
+ }, null, 2));
2599
+ return;
2600
+ }
2601
+ console.log(`
2602
+ \uD83D\uDCE1 Pegas Gateway Status:
2603
+ `);
2604
+ console.log(` Docker Engine: ${dockerOk ? "✓ Active" : "✗ Offline"}`);
2605
+ console.log(` Docker Compose: ${composeOk ? "✓ Available" : "✗ Missing"}`);
2606
+ console.log(` Docker Network: ${networkOk ? "✓ 'pegas-network' exists" : "✗ 'pegas-network' missing"}`);
2607
+ console.log(` Gateway Location: ${gatewayDir}`);
2608
+ console.log(` Caddy Container: ${isRunning ? "✓ Running" : "✗ Stopped or not found"}`);
2609
+ console.log(` Configured Routes: ${routes.length} domain(s)`);
2610
+ console.log(`
2611
+ `);
2612
+ }
2613
+
2614
+ // src/commands/llms.ts
2615
+ var LLMS_TXT_CONTENT = `# pegas
2616
+
2617
+ > Agent-native zero-config Caddy reverse proxy for Docker multi-app VPS setups.
2618
+
2619
+ ## Core Concepts
2620
+ - Manages single reverse proxy (Caddy 2) listening on :80 and :443.
2621
+ - All Docker projects attach to a shared Docker bridge network named \`pegas-network\`.
2622
+ - Never expose 80:80 or 443:443 in application compose files.
2623
+ - Each project gets an isolated declarative drop-in configuration in \`conf.d/<projectName>.caddy\`.
2624
+ - Hot-reloads Caddy via API with zero downtime and automatic validation rollback.
2625
+ - Auto-provisions Let's Encrypt SSL on first request.
2626
+
2627
+ ## CLI Usage for Agents
2628
+ - \`npx @netrunner714/pegas init\` — Initialize gateway and Docker network on a clean VPS, or initialize a project with \`pegas.json\`.
2629
+ - \`npx pegas dev -- <command>\` — Local HTTPS proxy for dev domains without Docker.
2630
+ - \`npx pegas deploy\` — Declaratively deploy project containers and route Caddy domains from \`pegas.json\`.
2631
+ - \`npx pegas down\` — Tear down project containers and unbind Caddy routes.
2632
+ - \`npx pegas add <domain> --upstream <service:port>\` — Route a new domain to a container service.
2633
+ - \`npx pegas add <domain> --spa --upstream <client:80> --api-upstream <api:8000>\` — Route SPA + API.
2634
+ - \`npx pegas list --json\` — List all active routes.
2635
+ - \`npx pegas remove <domain>\` — Remove route and reload Caddy.
2636
+ - \`npx pegas status\` — Check Docker, network, and Caddy health.
2637
+
2638
+ ## Example Docker Compose Patch for Any App
2639
+ \`\`\`yaml
2640
+ services:
2641
+ app:
2642
+ networks:
2643
+ pegas-network:
2644
+ aliases:
2645
+ - app
2646
+
2647
+ networks:
2648
+ pegas-network:
2649
+ external: true
2650
+ \`\`\`
2651
+ `;
2652
+ function handleLlms(options) {
2653
+ if (options.json) {
2654
+ console.log(JSON.stringify({ llms: LLMS_TXT_CONTENT }));
2655
+ } else {
2656
+ console.log(LLMS_TXT_CONTENT);
2657
+ }
2658
+ }
2659
+
2660
+ // src/index.ts
2661
+ var VERSION = "1.0.0";
2662
+ var HELP_TEXT = `pegas v${VERSION} — Agent-native Caddy gateway for multi-app VPS
2663
+
2664
+ USAGE:
2665
+ $ pegas <command> [options]
2666
+
2667
+ COMMANDS:
2668
+ init Init Pegas in current project (or --server for host)
2669
+ dev [-- <command>] Run local dev HTTPS proxy and start dev command (e.g. pegas dev -- next dev)
2670
+ deploy Declarative deploy: configure Caddy routes from pegas.json and start containers
2671
+ down Tear down project containers and remove Caddy routes
2672
+ add <domain> [options] Route a domain to an internal container upstream
2673
+ list, ls List all active domain routes
2674
+ remove, rm <domain> Delete route and reload Caddy
2675
+ reload Validate and hot-reload Caddy configuration
2676
+ status Check Docker and gateway health
2677
+ llms Print machine-readable LLM context
2678
+
2679
+ OPTIONS FOR 'add':
2680
+ -u, --upstream <target> Upstream container:port (e.g. web:3000)
2681
+ --spa SPA mode (separate frontend and API proxying)
2682
+ --api-upstream <tgt> Upstream for API backend (e.g. api:8000)
2683
+ --api-prefix <pfx> API path prefix (default: /api/*)
2684
+ --no-www Do not auto-generate www redirect
2685
+ --force Overwrite existing route file
2686
+ --dry-run Print generated config without applying
2687
+
2688
+ OPTIONS FOR 'dev':
2689
+ --infra Boot infrastructure services defined in docker-compose.yml
2690
+ --no-infra Do not boot infrastructure services even if enabled in pegas.json
2691
+
2692
+ OPTIONS FOR 'deploy':
2693
+ --strict-dns Abort deployment if DNS records do not match server IP
2694
+ --skip-dns-check Bypass DNS pre-flight verification
2695
+
2696
+ GLOBAL OPTIONS:
2697
+ --gateway-dir <path> Override path to gateway directory
2698
+ --json Output machine-readable JSON (ideal for AI agents)
2699
+ -h, --help Show this help message
2700
+ -v, --version Show version
2701
+
2702
+ EXAMPLES:
2703
+ $ pegas init
2704
+ $ pegas add myapp.ru --upstream web:3000
2705
+ $ pegas add api.myapp.ru --upstream api:8000
2706
+ $ pegas list
2707
+ `;
2708
+ function tryLocalDelegation() {
2709
+ if (process.env.PEGAS_DELEGATED) {
2710
+ return;
2711
+ }
2712
+ const scopedTarget = path11.join(process.cwd(), "node_modules", "@netrunner714", "pegas", "dist", "index.js");
2713
+ const unscopedTarget = path11.join(process.cwd(), "node_modules", "pegas", "dist", "index.js");
2714
+ const localPegas = fs12.existsSync(scopedTarget) ? scopedTarget : fs12.existsSync(unscopedTarget) ? unscopedTarget : null;
2715
+ if (!localPegas) {
2716
+ return;
2717
+ }
2718
+ try {
2719
+ const currentScript = fs12.realpathSync(process.argv[1] || "");
2720
+ const targetScript = fs12.realpathSync(localPegas);
2721
+ if (currentScript === targetScript) {
2722
+ return;
2723
+ }
2724
+ } catch {}
2725
+ const result = child_process5.spawnSync(process.execPath, [localPegas, ...process.argv.slice(2)], {
2726
+ stdio: "inherit",
2727
+ env: {
2728
+ ...process.env,
2729
+ PEGAS_DELEGATED: "1"
2730
+ }
2731
+ });
2732
+ process.exit(result.status ?? (result.signal ? 1 : 0));
2733
+ }
2734
+ async function main() {
2735
+ tryLocalDelegation();
2736
+ const rawArgs = process.argv.slice(2);
2737
+ const doubleDashIdx = rawArgs.indexOf("--");
2738
+ const pegasArgs = doubleDashIdx !== -1 ? rawArgs.slice(0, doubleDashIdx) : rawArgs;
2739
+ if (pegasArgs.length === 0 || pegasArgs.includes("-h") || pegasArgs.includes("--help")) {
2740
+ console.log(HELP_TEXT);
2741
+ return;
2742
+ }
2743
+ if (pegasArgs.includes("-v") || pegasArgs.includes("--version")) {
2744
+ console.log(`pegas v${VERSION}`);
2745
+ return;
2746
+ }
2747
+ const { values, positionals } = parseArgs({
2748
+ args: pegasArgs,
2749
+ options: {
2750
+ upstream: { type: "string", short: "u" },
2751
+ spa: { type: "boolean", default: false },
2752
+ "api-upstream": { type: "string" },
2753
+ "api-prefix": { type: "string", default: "/api/*" },
2754
+ "no-www": { type: "boolean", default: false },
2755
+ force: { type: "boolean", default: false },
2756
+ "dry-run": { type: "boolean", default: false },
2757
+ "skip-up": { type: "boolean", default: false },
2758
+ "skip-readiness": { type: "boolean", default: false },
2759
+ "strict-dns": { type: "boolean", default: false },
2760
+ "skip-dns-check": { type: "boolean", default: false },
2761
+ infra: { type: "boolean" },
2762
+ "no-infra": { type: "boolean" },
2763
+ "gateway-dir": { type: "string" },
2764
+ server: { type: "boolean", default: false },
2765
+ project: { type: "boolean", default: false },
2766
+ email: { type: "string" },
2767
+ network: { type: "string" },
2768
+ write: { type: "boolean", default: false },
2769
+ json: { type: "boolean", default: false },
2770
+ help: { type: "boolean", short: "h" },
2771
+ version: { type: "boolean", short: "v" }
2772
+ },
2773
+ allowPositionals: true,
2774
+ strict: false
2775
+ });
2776
+ const command = positionals[0]?.toLowerCase();
2777
+ switch (command) {
2778
+ case "init": {
2779
+ await handleInit({
2780
+ gatewayDir: values["gateway-dir"],
2781
+ server: values.server,
2782
+ project: values.project,
2783
+ email: values.email,
2784
+ networkName: values.network,
2785
+ json: values.json
2786
+ });
2787
+ break;
2788
+ }
2789
+ case "dev": {
2790
+ const userCmd = doubleDashIdx !== -1 ? rawArgs.slice(doubleDashIdx + 1) : positionals.slice(1);
2791
+ let infra = undefined;
2792
+ if (values["no-infra"]) {
2793
+ infra = false;
2794
+ } else if (values.infra) {
2795
+ infra = true;
2796
+ }
2797
+ await handleDev({
2798
+ command: userCmd,
2799
+ infra
2800
+ });
2801
+ break;
2802
+ }
2803
+ case "deploy": {
2804
+ await handleDeploy({
2805
+ gatewayDir: values["gateway-dir"],
2806
+ dryRun: values["dry-run"],
2807
+ skipUp: values["skip-up"],
2808
+ skipReadiness: values["skip-readiness"],
2809
+ strictDns: values["strict-dns"],
2810
+ skipDnsCheck: values["skip-dns-check"],
2811
+ json: values.json
2812
+ });
2813
+ break;
2814
+ }
2815
+ case "down": {
2816
+ await handleDown({
2817
+ gatewayDir: values["gateway-dir"],
2818
+ json: values.json
2819
+ });
2820
+ break;
2821
+ }
2822
+ case "add": {
2823
+ const domain = positionals[1];
2824
+ await handleAdd({
2825
+ domain: domain || "",
2826
+ upstream: values.upstream,
2827
+ spa: values.spa,
2828
+ apiUpstream: values["api-upstream"],
2829
+ apiPrefix: values["api-prefix"],
2830
+ noWww: values["no-www"],
2831
+ force: values.force,
2832
+ dryRun: values["dry-run"],
2833
+ gatewayDir: values["gateway-dir"],
2834
+ json: values.json
2835
+ });
2836
+ break;
2837
+ }
2838
+ case "list":
2839
+ case "ls": {
2840
+ await handleList({
2841
+ gatewayDir: values["gateway-dir"],
2842
+ json: values.json
2843
+ });
2844
+ break;
2845
+ }
2846
+ case "remove":
2847
+ case "rm": {
2848
+ const domain = positionals[1];
2849
+ await handleRemove({
2850
+ domain: domain || "",
2851
+ gatewayDir: values["gateway-dir"],
2852
+ json: values.json
2853
+ });
2854
+ break;
2855
+ }
2856
+ case "reload": {
2857
+ await handleReload({
2858
+ gatewayDir: values["gateway-dir"],
2859
+ json: values.json
2860
+ });
2861
+ break;
2862
+ }
2863
+ case "status": {
2864
+ await handleStatus({
2865
+ gatewayDir: values["gateway-dir"],
2866
+ json: values.json
2867
+ });
2868
+ break;
2869
+ }
2870
+ case "llms": {
2871
+ handleLlms({
2872
+ json: values.json
2873
+ });
2874
+ break;
2875
+ }
2876
+ default: {
2877
+ console.error(`Unknown command: '${command}'. Run 'pegas --help' for usage.`);
2878
+ process.exit(1);
2879
+ }
2880
+ }
2881
+ }
2882
+ main().catch((err) => {
2883
+ console.error("Fatal error:", err);
2884
+ process.exit(1);
2885
+ });