@gachlab/devup 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,10 +3,10 @@
3
3
  // src/index.ts
4
4
  import React7 from "react";
5
5
  import { render } from "ink";
6
- import { readFileSync as readFileSync3 } from "fs";
7
- import { dirname as dirname7, join as join9 } from "path";
6
+ import { readFileSync as readFileSync4 } from "fs";
7
+ import { dirname as dirname7, join as join10 } from "path";
8
8
  import { fileURLToPath as fileURLToPath2 } from "url";
9
- import { homedir as homedir4 } from "os";
9
+ import { homedir as homedir5 } from "os";
10
10
 
11
11
  // src/config/loader.ts
12
12
  import { existsSync } from "fs";
@@ -429,13 +429,13 @@ function filterServices(services, args, config) {
429
429
  }
430
430
 
431
431
  // src/orchestrator/subcommands.ts
432
- import { spawn } from "child_process";
433
- import { createReadStream, watchFile, unwatchFile, existsSync as existsSync5, statSync } from "fs";
432
+ import { spawn as spawn5 } from "child_process";
433
+ import { createReadStream as createReadStream2, watchFile as watchFile2, unwatchFile as unwatchFile2, existsSync as existsSync11, statSync as statSync2 } from "fs";
434
434
  import { readFile as readFile2 } from "fs/promises";
435
- import { join as join3, dirname } from "path";
435
+ import { join as join9, dirname as dirname3 } from "path";
436
436
  import { fileURLToPath } from "url";
437
- import { homedir } from "os";
438
- import { createInterface } from "readline";
437
+ import { homedir as homedir4 } from "os";
438
+ import { createInterface as createInterface4 } from "readline";
439
439
 
440
440
  // src/process/health.ts
441
441
  import net from "net";
@@ -459,6 +459,23 @@ function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
459
459
  socket.connect(port, host);
460
460
  });
461
461
  }
462
+ async function isPortBindable(port) {
463
+ for (const host of ["0.0.0.0", "::"]) {
464
+ if (!await tryBind(port, host)) return false;
465
+ }
466
+ return true;
467
+ }
468
+ function tryBind(port, host) {
469
+ return new Promise((resolve4) => {
470
+ const server = net.createServer();
471
+ server.once("error", (err) => {
472
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") resolve4(false);
473
+ else resolve4(true);
474
+ });
475
+ server.once("listening", () => server.close(() => resolve4(true)));
476
+ server.listen(port, host);
477
+ });
478
+ }
462
479
  function checkHttp(port, opts = {}) {
463
480
  const path = opts.path ?? "/";
464
481
  const host = opts.host ?? "127.0.0.1";
@@ -668,386 +685,287 @@ var tagColors = [
668
685
  "white"
669
686
  ];
670
687
 
671
- // src/orchestrator/subcommands.ts
672
- var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help"]);
673
- function detectSubcommand(argv) {
674
- const first = argv[0];
675
- return first && KNOWN.has(first) ? first : null;
688
+ // src/control-plane/client.ts
689
+ import { createConnection } from "net";
690
+ import { createInterface as createInterface2 } from "readline";
691
+ import { existsSync as existsSync6 } from "fs";
692
+
693
+ // src/control-plane/socket-server.ts
694
+ import { createServer } from "net";
695
+ import { createInterface } from "readline";
696
+ import { existsSync as existsSync5, unlinkSync, chmodSync, mkdirSync, statSync } from "fs";
697
+ import { dirname } from "path";
698
+ import { join as join3 } from "path";
699
+ import { homedir } from "os";
700
+ function defaultSocketPath(projectName) {
701
+ const safe = projectName.replace(/[^a-zA-Z0-9._-]+/g, "_") || "devup";
702
+ return join3(homedir(), ".devup", `sock-${safe}.sock`);
676
703
  }
677
- function logRoot(config, override) {
678
- const root = override ?? join3(homedir(), ".devup", "logs");
679
- return join3(root, sanitize(config.name));
704
+ async function startSocketServer(projectName, ctx, opts = {}) {
705
+ const path = opts.path ?? defaultSocketPath(projectName);
706
+ mkdirSync(dirname(path), { recursive: true });
707
+ if (existsSync5(path)) {
708
+ try {
709
+ const st = statSync(path);
710
+ if (st.isSocket()) unlinkSync(path);
711
+ } catch {
712
+ }
713
+ }
714
+ const server = createServer((socket) => handleClient(socket, ctx));
715
+ await new Promise((resolve4, reject) => {
716
+ server.once("error", reject);
717
+ server.listen(path, () => {
718
+ server.off("error", reject);
719
+ try {
720
+ chmodSync(path, 384);
721
+ } catch {
722
+ }
723
+ opts.onLog?.(`\u{1F50C} control plane at ${path}`);
724
+ resolve4();
725
+ });
726
+ });
727
+ return {
728
+ server,
729
+ path,
730
+ async close() {
731
+ await new Promise((resolve4) => server.close(() => resolve4()));
732
+ if (existsSync5(path)) {
733
+ try {
734
+ unlinkSync(path);
735
+ } catch {
736
+ }
737
+ }
738
+ }
739
+ };
680
740
  }
681
- function sanitize(name) {
682
- return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
741
+ function handleClient(socket, ctx) {
742
+ const rl = createInterface({ input: socket });
743
+ const unsubs = /* @__PURE__ */ new Set();
744
+ socket.on("close", () => {
745
+ for (const unsub of unsubs) unsub();
746
+ unsubs.clear();
747
+ });
748
+ rl.on("line", async (line) => {
749
+ if (!line.trim()) return;
750
+ let req;
751
+ try {
752
+ req = JSON.parse(line);
753
+ } catch (e) {
754
+ respond(socket, { error: { code: -32700, message: `parse error: ${e.message}` } });
755
+ return;
756
+ }
757
+ if (typeof req.method !== "string") {
758
+ respond(socket, { id: req.id, error: { code: -32600, message: "method required" } });
759
+ return;
760
+ }
761
+ const params = req.params ?? {};
762
+ if (req.method === "logs.follow" || req.method === "status.follow") {
763
+ try {
764
+ await handleFollow(socket, req, params, ctx, unsubs);
765
+ } catch (e) {
766
+ respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
767
+ }
768
+ return;
769
+ }
770
+ try {
771
+ const result = await dispatch(req.method, params, ctx);
772
+ respond(socket, { id: req.id, result });
773
+ } catch (e) {
774
+ respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
775
+ }
776
+ });
777
+ socket.on("error", () => {
778
+ });
683
779
  }
684
- async function runLogs(argv, opts) {
685
- const out = opts.out ?? ((l) => console.log(l));
686
- const follow = argv.includes("--follow") || argv.includes("-f");
687
- const svcArg = argv.find((a) => !a.startsWith("-"));
688
- if (!svcArg) {
689
- out("usage: devup logs <service> [--follow]");
690
- return 1;
780
+ async function handleFollow(socket, req, params, ctx, unsubs) {
781
+ if (req.method === "logs.follow") {
782
+ const rawSvc = params["svc"] ?? params["service"];
783
+ const svcName = rawSvc != null ? stringOrThrow(rawSvc, "svc") : null;
784
+ const tail = Math.max(0, Math.min(1e3, Number(params["tail"] ?? 50)));
785
+ respond(socket, { id: req.id, result: { ok: true } });
786
+ if (svcName) {
787
+ const lines = await ctx.tailLogs(svcName, tail);
788
+ for (const l of lines) {
789
+ respond(socket, { id: req.id, event: "log", data: l });
790
+ }
791
+ }
792
+ const unsub = ctx.watchLogs(svcName, (svc, line) => {
793
+ respond(socket, { id: req.id, event: "log", data: line, svc });
794
+ });
795
+ unsubs.add(unsub);
796
+ } else {
797
+ respond(socket, { id: req.id, result: { ok: true } });
798
+ const snapshot = [];
799
+ for (const [name, st] of ctx.states()) {
800
+ snapshot.push(serializeState(name, st));
801
+ }
802
+ if (snapshot.length) {
803
+ respond(socket, { id: req.id, event: "status", data: snapshot });
804
+ }
805
+ const unsub = ctx.watchStatus((name, state) => {
806
+ respond(socket, { id: req.id, event: "status", data: [serializeState(name, state)] });
807
+ });
808
+ unsubs.add(unsub);
691
809
  }
692
- const knownSvcs = opts.config.services.map((s) => s.name);
693
- if (!knownSvcs.includes(svcArg)) {
694
- out(`Unknown service "${svcArg}". Known: ${knownSvcs.join(", ")}`);
695
- return 1;
810
+ }
811
+ function serializeState(name, st) {
812
+ return {
813
+ name,
814
+ status: st.status,
815
+ health: st.health,
816
+ port: st.svc.port,
817
+ type: st.svc.type,
818
+ errors: st.errors,
819
+ restarts: st.restarts,
820
+ pid: st.pid,
821
+ startedAt: st.startedAt
822
+ };
823
+ }
824
+ function respond(socket, payload) {
825
+ if (socket.writable) socket.write(JSON.stringify(payload) + "\n");
826
+ }
827
+ async function dispatch(method, params, ctx) {
828
+ switch (method) {
829
+ case "status": {
830
+ const out = [];
831
+ for (const [name, st] of ctx.states()) {
832
+ out.push(serializeState(name, st));
833
+ }
834
+ return { services: out };
835
+ }
836
+ case "restart": {
837
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
838
+ await ctx.restart(svc);
839
+ return { ok: true };
840
+ }
841
+ case "stop": {
842
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
843
+ ctx.stop(svc);
844
+ return { ok: true };
845
+ }
846
+ case "logs.tail": {
847
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
848
+ const lines = Math.max(1, Math.min(1e4, Number(params["lines"] ?? 100)));
849
+ return { lines: await ctx.tailLogs(svc, lines) };
850
+ }
851
+ case "ping":
852
+ return { ok: true, ts: Date.now() };
853
+ default:
854
+ throw new Error(`unknown method: ${method}`);
696
855
  }
697
- const file = join3(logRoot(opts.config, opts.logDir), `${sanitize(svcArg)}.log`);
698
- if (!existsSync5(file)) {
699
- out(`No log file yet for "${svcArg}" (${file})`);
700
- return follow ? await followFile(file, out) : 1;
856
+ }
857
+ function stringOrThrow(v, paramName) {
858
+ if (typeof v !== "string" || !v.trim()) {
859
+ throw new Error(`param "${paramName}" must be a non-empty string`);
701
860
  }
702
- await streamFile(file, out);
703
- if (!follow) return 0;
704
- return await followFile(file, out, statSync(file).size);
861
+ return v;
705
862
  }
706
- async function streamFile(file, out) {
707
- return new Promise((resolve4, reject) => {
708
- const rl = createInterface({ input: createReadStream(file, { encoding: "utf8" }) });
709
- rl.on("line", (l) => out(l));
710
- rl.on("close", () => resolve4());
711
- rl.on("error", reject);
712
- });
863
+
864
+ // src/control-plane/client.ts
865
+ function resolveSocket(projectName, overridePath) {
866
+ return overridePath ?? defaultSocketPath(projectName);
713
867
  }
714
- async function followFile(file, out, startAt = 0) {
715
- let pos = startAt;
716
- while (!existsSync5(file)) await new Promise((r) => setTimeout(r, 500));
717
- return new Promise((resolve4) => {
718
- const tick = async () => {
719
- const size = statSync(file).size;
720
- if (size > pos) {
721
- await new Promise((res) => {
722
- const rl = createInterface({ input: createReadStream(file, { encoding: "utf8", start: pos, end: size - 1 }) });
723
- rl.on("line", (l) => out(l));
724
- rl.on("close", () => {
725
- pos = size;
726
- res();
727
- });
728
- });
729
- } else if (size < pos) {
730
- pos = 0;
868
+ function assertSocketExists(socketPath, projectName) {
869
+ if (!existsSync6(socketPath)) {
870
+ throw new Error(
871
+ `devup is not running for project "${projectName}".
872
+ Start it with \`devup\` first.`
873
+ );
874
+ }
875
+ }
876
+ function sendRpc(socketPath, method, params = {}) {
877
+ return new Promise((resolve4, reject) => {
878
+ const c = createConnection(socketPath);
879
+ c.on("error", reject);
880
+ const rl = createInterface2({ input: c });
881
+ rl.once("line", (l) => {
882
+ c.end();
883
+ try {
884
+ const msg = JSON.parse(l);
885
+ if (msg.error) reject(new Error(msg.error.message ?? String(msg.error)));
886
+ else resolve4(msg.result);
887
+ } catch (e) {
888
+ reject(e);
731
889
  }
732
- };
733
- watchFile(file, { interval: 500 }, () => {
734
- void tick();
735
- });
736
- process.once("SIGINT", () => {
737
- unwatchFile(file);
738
- resolve4(0);
739
890
  });
891
+ c.write(JSON.stringify({ id: 1, method, params }) + "\n");
740
892
  });
741
893
  }
742
- async function runInstall(opts) {
743
- const out = opts.out ?? ((l) => console.log(l));
744
- const concurrency = opts.concurrency ?? 4;
745
- const items = opts.config.services.map((s) => ({ name: s.name, cwd: join3(opts.baseCwd, s.cwd) }));
746
- const queue = [...items];
747
- const failed = [];
748
- let inFlight = 0;
749
- await new Promise((resolve4) => {
750
- const pump = () => {
751
- while (inFlight < concurrency && queue.length) {
752
- const item = queue.shift();
753
- inFlight++;
754
- installOne(item.cwd, opts.env).then((ok) => {
755
- inFlight--;
756
- if (ok) out(`\u2713 ${item.name}`);
757
- else {
758
- failed.push(item.name);
759
- out(`\u2717 ${item.name}`);
760
- }
761
- if (queue.length === 0 && inFlight === 0) resolve4();
762
- else pump();
763
- });
894
+ function openStream(socketPath, method, params, onFrame, onError) {
895
+ const c = createConnection(socketPath);
896
+ const rl = createInterface2({ input: c });
897
+ let ackDone = false;
898
+ c.on("error", (err) => onError?.(err));
899
+ c.write(JSON.stringify({ id: 1, method, params }) + "\n");
900
+ rl.on("line", (l) => {
901
+ try {
902
+ const msg = JSON.parse(l);
903
+ if (!ackDone) {
904
+ ackDone = true;
905
+ if (msg.error) {
906
+ onError?.(new Error(msg.error.message ?? String(msg.error)));
907
+ c.destroy();
908
+ }
909
+ return;
764
910
  }
765
- };
766
- pump();
911
+ if (msg.event) onFrame(msg);
912
+ } catch {
913
+ }
767
914
  });
768
- if (failed.length) {
769
- out(`
770
- failed: ${failed.join(", ")}`);
771
- return 1;
772
- }
773
- out(`
774
- ${items.length} services up to date`);
775
- return 0;
915
+ return () => c.destroy();
776
916
  }
777
- function installOne(cwd, env) {
778
- if (!existsSync5(cwd)) return Promise.resolve(false);
779
- if (!needsInstall(cwd)) return Promise.resolve(true);
917
+
918
+ // src/orchestrator/daemon.ts
919
+ import { spawn as spawn4 } from "child_process";
920
+ import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, existsSync as existsSync10, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, createReadStream } from "fs";
921
+ import { join as join8 } from "path";
922
+ import { homedir as homedir3 } from "os";
923
+ import { setTimeout as sleep } from "timers/promises";
924
+ import { createInterface as createInterface3 } from "readline";
925
+
926
+ // src/process/manager.ts
927
+ import { join as join5 } from "path";
928
+
929
+ // src/process/installer.ts
930
+ import { spawn } from "child_process";
931
+ import { existsSync as existsSync7 } from "fs";
932
+ function installService(cwd, env, onLog) {
933
+ if (!existsSync7(cwd)) {
934
+ onLog?.(`\u26A0 directory not found: ${cwd}`);
935
+ return Promise.resolve(false);
936
+ }
937
+ if (!needsInstall(cwd)) {
938
+ onLog?.("\u2705 dependencies up to date");
939
+ return Promise.resolve(true);
940
+ }
941
+ onLog?.("\u{1F4E6} npm install...");
780
942
  return new Promise((resolve4) => {
781
943
  const command = process.platform === "win32" ? "npm.cmd" : "npm";
782
944
  const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
945
+ let stderr = "";
946
+ proc.stderr?.on("data", (d) => {
947
+ stderr += d.toString();
948
+ });
783
949
  proc.on("close", (code) => {
784
- if (code === 0) {
950
+ if (code !== 0) {
951
+ onLog?.(`\u26A0 npm install failed: ${stderr.split("\n")[0]}`);
952
+ resolve4(false);
953
+ } else {
785
954
  writeInstallStamp(cwd);
955
+ onLog?.("\u2705 dependencies ready");
786
956
  resolve4(true);
787
- } else resolve4(false);
788
- });
789
- proc.on("error", () => resolve4(false));
790
- });
791
- }
792
- async function runStatus(opts) {
793
- const out = opts.out ?? ((l) => console.log(l));
794
- out(`${opts.config.icon ?? "\u{1F4E6}"} ${opts.config.name} \u2014 ${opts.config.services.length} services`);
795
- out("");
796
- const maxLen = Math.max(...opts.config.services.map((s) => s.name.length), 12);
797
- out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
798
- out("-".repeat(maxLen + 24));
799
- for (const svc of opts.config.services) {
800
- const up = await checkHealth(svc.port, svc.healthCheck);
801
- const health = up ? "\u2713 up" : "\u2717 down";
802
- out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
803
- }
804
- return 0;
805
- }
806
- function runHelp(argv, opts = {}) {
807
- const out = opts.out ?? ((l) => console.log(l));
808
- const sub = argv[0];
809
- if (sub === "logs") {
810
- out("Usage: devup logs <service> [--follow|-f]");
811
- out(" Print the persisted log file for a service (works without devup running).");
812
- out(" --follow tails new lines as they are appended.");
813
- return 0;
814
- }
815
- if (sub === "install") {
816
- out("Usage: devup install");
817
- out(" Run `npm install` across every service.cwd in parallel (max 4 at a time).");
818
- out(" Skips services whose .install-stamp matches package.json hash.");
819
- return 0;
820
- }
821
- if (sub === "status") {
822
- out("Usage: devup status");
823
- out(" For each service, probes its health-check endpoint and prints up/down.");
824
- return 0;
825
- }
826
- out("Subcommands:");
827
- out(" devup logs <service> [--follow] Read the persisted log file");
828
- out(" devup install Concurrent npm install across services");
829
- out(" devup status Health check every service in config");
830
- out(" devup help [<subcommand>] Show detailed help for a subcommand");
831
- out("");
832
- out("No subcommand \u2192 launch the interactive TUI.");
833
- return 0;
834
- }
835
-
836
- // src/platform/detect.ts
837
- async function detectPlatform() {
838
- switch (process.platform) {
839
- case "linux": {
840
- const { LinuxPlatform } = await import("./linux-OQ3Q4Z2Z.js");
841
- return new LinuxPlatform();
842
- }
843
- case "darwin": {
844
- const { DarwinPlatform } = await import("./darwin-3KJ3IEXN.js");
845
- return new DarwinPlatform();
846
- }
847
- case "win32": {
848
- const { Win32Platform } = await import("./win32-3X2OLSI6.js");
849
- return new Win32Platform();
850
- }
851
- default:
852
- throw new Error(`Unsupported platform: ${process.platform}`);
853
- }
854
- }
855
-
856
- // src/proxy-config/traefik.ts
857
- import { existsSync as existsSync6, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
858
- import { dirname as dirname2 } from "path";
859
- var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
860
- var TraefikProvider = class {
861
- name = "traefik";
862
- generate(services, opts) {
863
- const routers = [];
864
- const svcs = [];
865
- for (const [name, st] of services) {
866
- if (st.health !== "up") continue;
867
- const sub = opts.routes[name];
868
- if (sub === void 0) continue;
869
- const rule = sub ? `Host(\`${sub}.${opts.domain}\`)` : `Host(\`${opts.domain}\`)`;
870
- const safe = name.replace(/[^a-z0-9-]/g, "-");
871
- const port = st.realPort ?? st.port;
872
- let router = ` ${safe}:
873
- rule: "${rule}"
874
- service: ${safe}
875
- entryPoints:
876
- - ${opts.entrypoint}`;
877
- if (opts.tls) router += `
878
- tls:
879
- certResolver: le`;
880
- routers.push(router);
881
- svcs.push(` ${safe}:
882
- loadBalancer:
883
- servers:
884
- - url: "http://${opts.host}:${port}"`);
885
- }
886
- if (!routers.length) return EMPTY_CONFIG;
887
- return `http:
888
- routers:
889
- ${routers.join("\n")}
890
- services:
891
- ${svcs.join("\n")}
892
- `;
893
- }
894
- write(content, opts) {
895
- const dir = dirname2(opts.confPath);
896
- if (!existsSync6(dir)) mkdirSync(dir, { recursive: true });
897
- writeFileSync2(opts.confPath, content);
898
- }
899
- clear(opts) {
900
- this.write(EMPTY_CONFIG, opts);
901
- }
902
- };
903
-
904
- // src/proxy-config/nginx.ts
905
- import { existsSync as existsSync7, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
906
- import { dirname as dirname3 } from "path";
907
- var EMPTY_CONFIG2 = "# devup: no healthy services\n";
908
- var NginxProvider = class {
909
- name = "nginx";
910
- generate(services, opts) {
911
- const blocks = [];
912
- for (const [name, st] of services) {
913
- if (st.health !== "up") continue;
914
- const sub = opts.routes[name];
915
- if (sub === void 0) continue;
916
- const serverName = sub ? `${sub}.${opts.domain}` : opts.domain;
917
- const port = st.realPort ?? st.port;
918
- const listen = opts.tls ? "443 ssl" : "80";
919
- const tlsBlock = opts.tls ? ` ssl_certificate /etc/nginx/certs/${serverName}.crt;
920
- ssl_certificate_key /etc/nginx/certs/${serverName}.key;
921
- ` : "";
922
- blocks.push(
923
- `server {
924
- listen ${listen};
925
- server_name ${serverName};
926
- ` + tlsBlock + ` location / {
927
- proxy_pass http://${opts.host}:${port};
928
- proxy_set_header Host $host;
929
- proxy_set_header X-Real-IP $remote_addr;
930
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
931
- proxy_set_header X-Forwarded-Proto $scheme;
932
- proxy_http_version 1.1;
933
- proxy_set_header Upgrade $http_upgrade;
934
- proxy_set_header Connection "upgrade";
935
- }
936
- }`
937
- );
938
- }
939
- if (!blocks.length) return EMPTY_CONFIG2;
940
- return blocks.join("\n\n") + "\n";
941
- }
942
- write(content, opts) {
943
- const dir = dirname3(opts.confPath);
944
- if (!existsSync7(dir)) mkdirSync2(dir, { recursive: true });
945
- writeFileSync3(opts.confPath, content);
946
- }
947
- clear(opts) {
948
- this.write(EMPTY_CONFIG2, opts);
949
- }
950
- };
951
-
952
- // src/proxy-config/caddy.ts
953
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
954
- import { dirname as dirname4 } from "path";
955
- var EMPTY_CONFIG3 = "# devup: no healthy services\n";
956
- var CaddyProvider = class {
957
- name = "caddy";
958
- generate(services, opts) {
959
- const blocks = [];
960
- for (const [name, st] of services) {
961
- if (st.health !== "up") continue;
962
- const sub = opts.routes[name];
963
- if (sub === void 0) continue;
964
- const host = sub ? `${sub}.${opts.domain}` : opts.domain;
965
- const port = st.realPort ?? st.port;
966
- const siteAddr = opts.tls ? host : `http://${host}`;
967
- blocks.push(
968
- `${siteAddr} {
969
- reverse_proxy ${opts.host}:${port}
970
- }`
971
- );
972
- }
973
- if (!blocks.length) return EMPTY_CONFIG3;
974
- return blocks.join("\n\n") + "\n";
975
- }
976
- write(content, opts) {
977
- const dir = dirname4(opts.confPath);
978
- if (!existsSync8(dir)) mkdirSync3(dir, { recursive: true });
979
- writeFileSync4(opts.confPath, content);
980
- }
981
- clear(opts) {
982
- this.write(EMPTY_CONFIG3, opts);
983
- }
984
- };
985
-
986
- // src/proxy-config/detect.ts
987
- var providers = {
988
- traefik: () => new TraefikProvider(),
989
- nginx: () => new NginxProvider(),
990
- caddy: () => new CaddyProvider()
991
- };
992
- function detectProxyProvider(name) {
993
- const factory = providers[name];
994
- if (!factory) {
995
- const available = Object.keys(providers).join(", ");
996
- throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
997
- }
998
- return factory();
999
- }
1000
-
1001
- // src/tui/App.tsx
1002
- import { useCallback as useCallback3, useRef as useRef5 } from "react";
1003
- import { Box as Box6, Text as Text6 } from "ink";
1004
-
1005
- // src/tui/hooks/useProcessManager.ts
1006
- import { useState, useEffect, useRef, useCallback } from "react";
1007
-
1008
- // src/process/manager.ts
1009
- import { join as join5 } from "path";
1010
-
1011
- // src/process/installer.ts
1012
- import { spawn as spawn2 } from "child_process";
1013
- import { existsSync as existsSync9 } from "fs";
1014
- function installService(cwd, env, onLog) {
1015
- if (!existsSync9(cwd)) {
1016
- onLog?.(`\u26A0 directory not found: ${cwd}`);
1017
- return Promise.resolve(false);
1018
- }
1019
- if (!needsInstall(cwd)) {
1020
- onLog?.("\u2705 dependencies up to date");
1021
- return Promise.resolve(true);
1022
- }
1023
- onLog?.("\u{1F4E6} npm install...");
1024
- return new Promise((resolve4) => {
1025
- const command = process.platform === "win32" ? "npm.cmd" : "npm";
1026
- const proc = spawn2(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
1027
- let stderr = "";
1028
- proc.stderr?.on("data", (d) => {
1029
- stderr += d.toString();
1030
- });
1031
- proc.on("close", (code) => {
1032
- if (code !== 0) {
1033
- onLog?.(`\u26A0 npm install failed: ${stderr.split("\n")[0]}`);
1034
- resolve4(false);
1035
- } else {
1036
- writeInstallStamp(cwd);
1037
- onLog?.("\u2705 dependencies ready");
1038
- resolve4(true);
1039
- }
1040
- });
1041
- proc.on("error", (err) => {
1042
- onLog?.(`\u26A0 spawn error: ${err.message}`);
1043
- resolve4(false);
957
+ }
958
+ });
959
+ proc.on("error", (err) => {
960
+ onLog?.(`\u26A0 spawn error: ${err.message}`);
961
+ resolve4(false);
1044
962
  });
1045
963
  });
1046
964
  }
1047
965
 
1048
966
  // src/process/spawner.ts
1049
- import { spawn as spawn3 } from "child_process";
1050
- import { existsSync as existsSync10 } from "fs";
967
+ import { spawn as spawn2 } from "child_process";
968
+ import { existsSync as existsSync8 } from "fs";
1051
969
  import { join as join4, resolve as resolve3 } from "path";
1052
970
 
1053
971
  // src/process/internals.ts
@@ -1125,9 +1043,10 @@ var Spawner = class {
1125
1043
  async start(svc, colorIdx, isRestart = false) {
1126
1044
  const cwd = join4(this.baseCwd, svc.cwd);
1127
1045
  if (svc.type === "api") {
1128
- const occupied = await checkPort(svc.port);
1129
- if (occupied && !isRestart) {
1046
+ const bindable = await isPortBindable(svc.port);
1047
+ if (!bindable && !isRestart) {
1130
1048
  this.log(svc.name, `\u26A0 port ${svc.port} already in use \u2014 skipping`, colorIdx);
1049
+ this.recordCrashedState(svc, colorIdx);
1131
1050
  return;
1132
1051
  }
1133
1052
  }
@@ -1139,14 +1058,14 @@ var Spawner = class {
1139
1058
  }
1140
1059
  }
1141
1060
  const args = buildProcessArgs(svc);
1142
- const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync10(resolve3(cwd, p)));
1061
+ const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync8(resolve3(cwd, p)));
1143
1062
  if (missingWatchPaths.length) {
1144
1063
  this.log(svc.name, `\u26A0 missing watch paths: ${missingWatchPaths.join(", ")}`, colorIdx);
1145
1064
  this.recordCrashedState(svc, colorIdx);
1146
1065
  return;
1147
1066
  }
1148
1067
  const env = buildProcessEnv(svc, this.env);
1149
- const proc = spawn3(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
1068
+ const proc = spawn2(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
1150
1069
  const prev = this.state.get(svc.name);
1151
1070
  const state = {
1152
1071
  svc,
@@ -1224,7 +1143,7 @@ var Spawner = class {
1224
1143
  const shell = isWin ? "cmd.exe" : "sh";
1225
1144
  const shellFlag = isWin ? "/c" : "-c";
1226
1145
  const env = buildProcessEnv(svc, this.env);
1227
- const child = spawn3(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1146
+ const child = spawn2(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1228
1147
  const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
1229
1148
  const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
1230
1149
  child.stdout?.on("data", (d) => outBuf.push(d));
@@ -1251,7 +1170,7 @@ var Spawner = class {
1251
1170
  const isWin = process.platform === "win32";
1252
1171
  const shell = isWin ? "cmd.exe" : "sh";
1253
1172
  const shellFlag = isWin ? "/c" : "-c";
1254
- const child = spawn3(shell, [shellFlag, svc.watchBuild], {
1173
+ const child = spawn2(shell, [shellFlag, svc.watchBuild], {
1255
1174
  cwd,
1256
1175
  env,
1257
1176
  detached: true,
@@ -1487,25 +1406,1317 @@ var ProcessManager = class {
1487
1406
  }
1488
1407
  };
1489
1408
 
1490
- // src/tui/hooks/useProcessManager.ts
1491
- function useProcessManager(platform, baseCwd, env, logSink = null) {
1492
- const [states, setStates] = useState(/* @__PURE__ */ new Map());
1493
- const [logs, setLogs] = useState([]);
1494
- const [stats, setStats] = useState(/* @__PURE__ */ new Map());
1495
- const mgrRef = useRef(null);
1496
- const prevCpu = useRef(/* @__PURE__ */ new Map());
1497
- const pausedRef = useRef(false);
1498
- const pendingLogsRef = useRef([]);
1499
- const sinkRef = useRef(logSink);
1500
- sinkRef.current = logSink;
1501
- useEffect(() => {
1502
- const mgr2 = new ProcessManager({
1503
- baseCwd,
1504
- env,
1505
- platform,
1506
- events: {
1409
+ // src/process/log-sink.ts
1410
+ import { existsSync as existsSync9, mkdirSync as mkdirSync2, renameSync, createWriteStream } from "fs";
1411
+ import { join as join6, dirname as dirname2 } from "path";
1412
+ import { homedir as homedir2 } from "os";
1413
+ var LogSink = class {
1414
+ dir;
1415
+ rotateOnStart;
1416
+ streams = /* @__PURE__ */ new Map();
1417
+ seen = /* @__PURE__ */ new Set();
1418
+ constructor(opts) {
1419
+ const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
1420
+ this.dir = join6(root, sanitize(opts.projectName));
1421
+ this.rotateOnStart = opts.rotateOnStart ?? true;
1422
+ mkdirSync2(this.dir, { recursive: true });
1423
+ }
1424
+ /** Returns the file path for a service log (useful for tests / UI). */
1425
+ pathFor(svcName) {
1426
+ return join6(this.dir, `${sanitize(svcName)}.log`);
1427
+ }
1428
+ write(svcName, line) {
1429
+ const stream = this.streamFor(svcName);
1430
+ stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1431
+ `);
1432
+ }
1433
+ async close() {
1434
+ const closes = [...this.streams.values()].map(
1435
+ (s) => new Promise((r) => s.end(() => r()))
1436
+ );
1437
+ this.streams.clear();
1438
+ this.seen.clear();
1439
+ await Promise.all(closes);
1440
+ }
1441
+ streamFor(svcName) {
1442
+ let s = this.streams.get(svcName);
1443
+ if (s) return s;
1444
+ const file = this.pathFor(svcName);
1445
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync9(file)) {
1446
+ try {
1447
+ mkdirSync2(dirname2(file), { recursive: true });
1448
+ renameSync(file, file + ".prev");
1449
+ } catch {
1450
+ }
1451
+ }
1452
+ this.seen.add(svcName);
1453
+ s = createWriteStream(file, { flags: "a" });
1454
+ s.on("error", () => {
1455
+ });
1456
+ this.streams.set(svcName, s);
1457
+ return s;
1458
+ }
1459
+ };
1460
+ function sanitize(name) {
1461
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
1462
+ }
1463
+
1464
+ // src/utils/broadcaster.ts
1465
+ var Broadcaster = class {
1466
+ subs = /* @__PURE__ */ new Set();
1467
+ subscribe(fn) {
1468
+ this.subs.add(fn);
1469
+ return () => this.subs.delete(fn);
1470
+ }
1471
+ emit(v) {
1472
+ for (const fn of this.subs) {
1473
+ try {
1474
+ fn(v);
1475
+ } catch {
1476
+ }
1477
+ }
1478
+ }
1479
+ };
1480
+
1481
+ // src/process/external.ts
1482
+ import { spawn as spawn3 } from "child_process";
1483
+ import { join as join7 } from "path";
1484
+ var DEFAULT_START_TIMEOUT_S = 60;
1485
+ async function startExternals(externals, opts) {
1486
+ const procs = [];
1487
+ const failed = [];
1488
+ for (const svc of externals) {
1489
+ const proc = spawnExternal(svc, opts);
1490
+ procs.push({ svc, proc, pid: proc.pid ?? null });
1491
+ if (!svc.healthCheck) {
1492
+ opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
1493
+ continue;
1494
+ }
1495
+ if (svc.healthCheck.type === "tcp" && !svc.port) {
1496
+ opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
1497
+ continue;
1498
+ }
1499
+ const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
1500
+ const ok = await waitHealthy(svc, timeoutMs);
1501
+ if (ok) {
1502
+ opts.onLog?.(svc.name, "\u2705 healthy");
1503
+ } else {
1504
+ opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
1505
+ failed.push(svc.name);
1506
+ }
1507
+ }
1508
+ return { procs, allHealthy: failed.length === 0, failed };
1509
+ }
1510
+ async function stopExternals(procs, platform, opts = {}) {
1511
+ for (const { svc, proc, pid } of procs) {
1512
+ try {
1513
+ if (pid) platform.killTree(pid);
1514
+ if (svc.stopCmd) {
1515
+ opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
1516
+ await new Promise((resolve4) => {
1517
+ const isWin = process.platform === "win32";
1518
+ const shell = isWin ? "cmd.exe" : "sh";
1519
+ const flag = isWin ? "/c" : "-c";
1520
+ const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
1521
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1522
+ const child = spawn3(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
1523
+ child.on("close", () => resolve4());
1524
+ child.on("error", () => resolve4());
1525
+ setTimeout(() => resolve4(), 1e4);
1526
+ });
1527
+ }
1528
+ } catch {
1529
+ }
1530
+ void proc;
1531
+ }
1532
+ }
1533
+ function spawnExternal(svc, opts) {
1534
+ const isWin = process.platform === "win32";
1535
+ const shell = isWin ? "cmd.exe" : "sh";
1536
+ const flag = isWin ? "/c" : "-c";
1537
+ const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
1538
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1539
+ opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
1540
+ const child = spawn3(shell, [flag, svc.cmd], {
1541
+ cwd,
1542
+ env,
1543
+ detached: true,
1544
+ stdio: ["ignore", "pipe", "pipe"]
1545
+ });
1546
+ child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
1547
+ child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
1548
+ child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
1549
+ return child;
1550
+ }
1551
+ async function waitHealthy(svc, timeoutMs) {
1552
+ const deadline = Date.now() + timeoutMs;
1553
+ const port = svc.port;
1554
+ while (Date.now() < deadline) {
1555
+ if (await checkHealth(port, svc.healthCheck)) return true;
1556
+ await new Promise((r) => setTimeout(r, 500));
1557
+ }
1558
+ return false;
1559
+ }
1560
+
1561
+ // src/lazy/proxy.ts
1562
+ import net2 from "net";
1563
+ function createLazyProxy(opts) {
1564
+ const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
1565
+ let idleTimer = null;
1566
+ let lastActivity = Date.now();
1567
+ let starting = false;
1568
+ let serviceReady = false;
1569
+ let pendingConns = [];
1570
+ const activeConns = /* @__PURE__ */ new Set();
1571
+ function bumpActivity() {
1572
+ lastActivity = Date.now();
1573
+ }
1574
+ function scheduleIdleCheck() {
1575
+ if (idleTimer) clearTimeout(idleTimer);
1576
+ if (timeoutMin <= 0) return;
1577
+ const periodMs = timeoutMin * 6e4;
1578
+ idleTimer = setTimeout(() => {
1579
+ const elapsed = Date.now() - lastActivity;
1580
+ if (activeConns.size > 0 || elapsed < periodMs) {
1581
+ scheduleIdleCheck();
1582
+ return;
1583
+ }
1584
+ serviceReady = false;
1585
+ onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
1586
+ onIdleStop();
1587
+ }, periodMs);
1588
+ }
1589
+ function pipeToTarget(client) {
1590
+ const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
1591
+ activeConns.add(client);
1592
+ const cleanup = () => {
1593
+ activeConns.delete(client);
1594
+ bumpActivity();
1595
+ };
1596
+ target.on("error", () => {
1597
+ client.destroy();
1598
+ cleanup();
1599
+ });
1600
+ client.on("error", () => {
1601
+ target.destroy();
1602
+ cleanup();
1603
+ });
1604
+ client.on("close", cleanup);
1605
+ target.on("close", cleanup);
1606
+ target.on("connect", () => {
1607
+ target.on("data", (chunk) => {
1608
+ bumpActivity();
1609
+ if (!client.destroyed) client.write(chunk);
1610
+ });
1611
+ client.on("data", (chunk) => {
1612
+ bumpActivity();
1613
+ if (!target.destroyed) target.write(chunk);
1614
+ });
1615
+ target.on("end", () => {
1616
+ if (!client.destroyed) client.end();
1617
+ });
1618
+ client.on("end", () => {
1619
+ if (!target.destroyed) target.end();
1620
+ });
1621
+ });
1622
+ }
1623
+ async function handleConnection(client) {
1624
+ bumpActivity();
1625
+ client.on("error", () => {
1626
+ });
1627
+ if (serviceReady && isAlive()) {
1628
+ pipeToTarget(client);
1629
+ return;
1630
+ }
1631
+ pendingConns.push(client);
1632
+ client.on("close", () => {
1633
+ pendingConns = pendingConns.filter((s) => s !== client);
1634
+ });
1635
+ if (starting) return;
1636
+ starting = true;
1637
+ onLog?.("\u26A1 on-demand start");
1638
+ let ok = false;
1639
+ try {
1640
+ await onDemandStart();
1641
+ ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
1642
+ if (ok) serviceReady = true;
1643
+ else onLog?.("\u26A0 timeout waiting for service");
1644
+ } catch (e) {
1645
+ onLog?.(`\u274C start failed: ${e.message}`);
1646
+ }
1647
+ starting = false;
1648
+ const conns = pendingConns.splice(0);
1649
+ if (!ok) {
1650
+ for (const conn of conns) {
1651
+ if (!conn.destroyed) conn.destroy();
1652
+ }
1653
+ return;
1654
+ }
1655
+ for (const conn of conns) {
1656
+ if (!conn.destroyed) pipeToTarget(conn);
1657
+ }
1658
+ }
1659
+ const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
1660
+ server.listen(listenPort, "0.0.0.0");
1661
+ scheduleIdleCheck();
1662
+ return {
1663
+ server,
1664
+ resetTimer: bumpActivity,
1665
+ destroy: () => {
1666
+ if (idleTimer) clearTimeout(idleTimer);
1667
+ pendingConns.forEach((s) => s.destroy());
1668
+ activeConns.forEach((s) => s.destroy());
1669
+ server.close();
1670
+ }
1671
+ };
1672
+ }
1673
+
1674
+ // src/orchestrator/config-watcher.ts
1675
+ import { watchFile, unwatchFile } from "fs";
1676
+
1677
+ // src/config/diff.ts
1678
+ var SPAWN_RELEVANT = [
1679
+ "cwd",
1680
+ "cmd",
1681
+ "args",
1682
+ "port",
1683
+ "phase",
1684
+ "maxMem",
1685
+ "preBuild",
1686
+ "watchBuild",
1687
+ "nodeArgs",
1688
+ "extraEnv",
1689
+ "healthCheck",
1690
+ "readyPattern",
1691
+ "errorPattern",
1692
+ "type"
1693
+ ];
1694
+ function hasSpawnRelevantChange(prev, next) {
1695
+ for (const k of SPAWN_RELEVANT) {
1696
+ if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return true;
1697
+ }
1698
+ return false;
1699
+ }
1700
+ function diffServices(prev, next) {
1701
+ const prevByName = new Map(prev.map((s) => [s.name, s]));
1702
+ const nextByName = new Map(next.map((s) => [s.name, s]));
1703
+ const added = [];
1704
+ const removed = [];
1705
+ const changed = [];
1706
+ const unchanged = [];
1707
+ for (const [name, p] of prevByName) {
1708
+ if (!nextByName.has(name)) {
1709
+ removed.push(name);
1710
+ continue;
1711
+ }
1712
+ const n = nextByName.get(name);
1713
+ if (hasSpawnRelevantChange(p, n)) changed.push({ prev: p, next: n });
1714
+ else unchanged.push(name);
1715
+ }
1716
+ for (const [name, n] of nextByName) {
1717
+ if (!prevByName.has(name)) added.push(n);
1718
+ }
1719
+ return { added, removed, changed, unchanged };
1720
+ }
1721
+ function summariseDiff(d) {
1722
+ const parts = [];
1723
+ if (d.added.length) parts.push(`+${d.added.length} added`);
1724
+ if (d.removed.length) parts.push(`-${d.removed.length} removed`);
1725
+ if (d.changed.length) parts.push(`~${d.changed.length} changed`);
1726
+ if (!parts.length) parts.push("no changes");
1727
+ return parts.join(", ");
1728
+ }
1729
+
1730
+ // src/orchestrator/config-watcher.ts
1731
+ async function applyConfigChange(opts) {
1732
+ const { configPath, baseCwd, manager, log } = opts;
1733
+ try {
1734
+ const nextCfg = await loadConfig(configPath);
1735
+ const errs = validateConfig(nextCfg, baseCwd);
1736
+ if (errs.length) {
1737
+ log(`\u26A0 config reload failed:
1738
+ ${formatValidationErrors(errs)}`);
1739
+ return;
1740
+ }
1741
+ const currentSvcs = [...manager.state.values()].map((s) => s.svc);
1742
+ const diff = diffServices(currentSvcs, nextCfg.services);
1743
+ if (!diff.added.length && !diff.removed.length && !diff.changed.length) return;
1744
+ for (const name of diff.removed) {
1745
+ manager.stop(name);
1746
+ manager.state.delete(name);
1747
+ }
1748
+ let colorIdx = currentSvcs.length;
1749
+ for (const { next } of diff.changed) {
1750
+ const prev = manager.state.get(next.name);
1751
+ const ci = prev?.colorIdx ?? colorIdx++;
1752
+ manager.stop(next.name);
1753
+ await new Promise((r) => setTimeout(r, 800));
1754
+ await manager.install(next, ci);
1755
+ await manager.start(next, ci, true);
1756
+ }
1757
+ for (const next of diff.added) {
1758
+ const ci = colorIdx++;
1759
+ await manager.install(next, ci);
1760
+ await manager.start(next, ci);
1761
+ }
1762
+ log(`\u{1F501} config reloaded: ${summariseDiff(diff)}`);
1763
+ } catch (e) {
1764
+ log(`\u26A0 config reload error: ${e.message}`);
1765
+ }
1766
+ }
1767
+ function watchConfig(opts) {
1768
+ let debounceTimer = null;
1769
+ let reloadInFlight = false;
1770
+ let reloadAgain = false;
1771
+ const trigger = async () => {
1772
+ if (reloadInFlight) {
1773
+ reloadAgain = true;
1774
+ return;
1775
+ }
1776
+ reloadInFlight = true;
1777
+ try {
1778
+ await applyConfigChange(opts);
1779
+ } finally {
1780
+ reloadInFlight = false;
1781
+ if (reloadAgain) {
1782
+ reloadAgain = false;
1783
+ void trigger();
1784
+ }
1785
+ }
1786
+ };
1787
+ const listener = (curr, prev) => {
1788
+ if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
1789
+ if (debounceTimer) clearTimeout(debounceTimer);
1790
+ debounceTimer = setTimeout(() => void trigger(), 250);
1791
+ };
1792
+ watchFile(opts.configPath, { interval: 500 }, listener);
1793
+ return () => {
1794
+ if (debounceTimer) clearTimeout(debounceTimer);
1795
+ unwatchFile(opts.configPath, listener);
1796
+ };
1797
+ }
1798
+
1799
+ // src/orchestrator/daemon.ts
1800
+ var SAFE = /[^a-zA-Z0-9._-]+/g;
1801
+ var sanitize2 = (n) => n.replace(SAFE, "_").replace(/^_+|_+$/g, "") || "devup";
1802
+ var devupDir = () => join8(homedir3(), ".devup");
1803
+ function pidPathFor(projectName) {
1804
+ return join8(devupDir(), `${sanitize2(projectName)}.pid`);
1805
+ }
1806
+ function bootErrorPathFor(projectName) {
1807
+ return join8(devupDir(), `${sanitize2(projectName)}.boot-error`);
1808
+ }
1809
+ function pidAlive(pid) {
1810
+ try {
1811
+ process.kill(pid, 0);
1812
+ return true;
1813
+ } catch {
1814
+ return false;
1815
+ }
1816
+ }
1817
+ function isDaemonRunning(projectName) {
1818
+ const path = pidPathFor(projectName);
1819
+ if (!existsSync10(path)) return { pid: null, stale: false };
1820
+ let pid;
1821
+ try {
1822
+ pid = Number(readFileSync3(path, "utf8").trim());
1823
+ if (!pid || !Number.isFinite(pid)) return { pid: null, stale: true };
1824
+ } catch {
1825
+ return { pid: null, stale: true };
1826
+ }
1827
+ return pidAlive(pid) ? { pid, stale: false } : { pid, stale: true };
1828
+ }
1829
+ async function daemonBody(opts) {
1830
+ const { config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts } = opts;
1831
+ const projectName = config.name;
1832
+ const errPath = bootErrorPathFor(projectName);
1833
+ const pidPath = pidPathFor(projectName);
1834
+ mkdirSync3(devupDir(), { recursive: true });
1835
+ if (existsSync10(errPath)) {
1836
+ try {
1837
+ unlinkSync2(errPath);
1838
+ } catch {
1839
+ }
1840
+ }
1841
+ const logSink = new LogSink({ projectName, rootDir: cliArgs.logDir });
1842
+ const logBus = new Broadcaster();
1843
+ const stateBus = new Broadcaster();
1844
+ const lazyProxies = /* @__PURE__ */ new Map();
1845
+ let externals = [];
1846
+ let socket = null;
1847
+ let healthTimer = null;
1848
+ let proxyTimer = null;
1849
+ let stopConfigWatcher = null;
1850
+ const writeDevupLog = (text) => {
1851
+ logSink.write("devup", text);
1852
+ logBus.emit({ svc: "devup", text });
1853
+ };
1854
+ const mgr = new ProcessManager({
1855
+ baseCwd,
1856
+ env,
1857
+ platform,
1858
+ events: {
1859
+ onLog: (svcName, text) => {
1860
+ logSink.write(svcName, text);
1861
+ logBus.emit({ svc: svcName, text });
1862
+ },
1863
+ onStateChange: (name, state) => stateBus.emit({ name, state })
1864
+ }
1865
+ });
1866
+ const cleanup = async () => {
1867
+ if (healthTimer) clearInterval(healthTimer);
1868
+ if (proxyTimer) clearInterval(proxyTimer);
1869
+ if (stopConfigWatcher) {
1870
+ try {
1871
+ stopConfigWatcher();
1872
+ } catch {
1873
+ }
1874
+ }
1875
+ for (const p of lazyProxies.values()) p.destroy();
1876
+ if (socket) await socket.close().catch(() => {
1877
+ });
1878
+ await mgr.cleanup().catch(() => {
1879
+ });
1880
+ if (externals.length) {
1881
+ await stopExternals(externals, platform, {
1882
+ baseCwd,
1883
+ env,
1884
+ onLog: (svc, msg) => logSink.write(`ext:${svc}`, msg)
1885
+ }).catch(() => {
1886
+ });
1887
+ }
1888
+ if (proxyProvider && proxyOpts && cliArgs.proxy) {
1889
+ try {
1890
+ proxyProvider.clear(proxyOpts);
1891
+ } catch {
1892
+ }
1893
+ }
1894
+ await logSink.close().catch(() => {
1895
+ });
1896
+ if (existsSync10(pidPath)) {
1897
+ try {
1898
+ unlinkSync2(pidPath);
1899
+ } catch {
1900
+ }
1901
+ }
1902
+ };
1903
+ let shuttingDown = false;
1904
+ const onSignal = () => {
1905
+ if (shuttingDown) return;
1906
+ shuttingDown = true;
1907
+ cleanup().then(() => process.exit(0), () => process.exit(1));
1908
+ };
1909
+ process.on("SIGTERM", onSignal);
1910
+ process.on("SIGINT", onSignal);
1911
+ try {
1912
+ if (config.external?.length) {
1913
+ writeDevupLog(`\u25B6 externals (${config.external.length})`);
1914
+ const result = await startExternals(config.external, {
1915
+ baseCwd,
1916
+ env,
1917
+ platform,
1918
+ onLog: (svc, msg) => {
1919
+ logSink.write(`ext:${svc}`, msg);
1920
+ logBus.emit({ svc: `ext:${svc}`, text: msg });
1921
+ }
1922
+ });
1923
+ externals = result.procs;
1924
+ if (!result.allHealthy) {
1925
+ throw new Error(`externals failed: ${result.failed.join(", ")}`);
1926
+ }
1927
+ }
1928
+ if (cliArgs.lazy && config.lazy) {
1929
+ await bootLazy(mgr, services, config.lazy, cliArgs.lazyTimeout, lazyProxies);
1930
+ } else {
1931
+ await bootNormal(mgr, services);
1932
+ }
1933
+ socket = await startSocketServer(projectName, {
1934
+ states: () => mgr.state,
1935
+ restart: (n) => mgr.restart(n),
1936
+ stop: (n) => mgr.stop(n),
1937
+ tailLogs: async (svcName, lines) => {
1938
+ const file = logSink.pathFor(svcName);
1939
+ if (!existsSync10(file)) return [];
1940
+ return new Promise((resolve4, reject) => {
1941
+ const buf = [];
1942
+ const rl = createInterface3({ input: createReadStream(file, { encoding: "utf8" }) });
1943
+ rl.on("line", (l) => {
1944
+ buf.push(l);
1945
+ if (buf.length > lines) buf.shift();
1946
+ });
1947
+ rl.on("close", () => resolve4(buf));
1948
+ rl.on("error", reject);
1949
+ });
1950
+ },
1951
+ watchLogs: (svcName, onLine) => logBus.subscribe(({ svc, text }) => {
1952
+ if (svcName === null || svc === svcName) onLine(svc, text);
1953
+ }),
1954
+ watchStatus: (onUpdate) => stateBus.subscribe(({ name, state }) => onUpdate(name, state))
1955
+ }, { onLog: (msg) => writeDevupLog(msg) });
1956
+ healthTimer = setInterval(() => {
1957
+ void mgr.checkAllHealth();
1958
+ }, 3e3);
1959
+ if (proxyProvider && proxyOpts && cliArgs.proxy) {
1960
+ let lastContent = null;
1961
+ const sync = () => {
1962
+ const svcStates = /* @__PURE__ */ new Map();
1963
+ for (const [n, st] of mgr.state) {
1964
+ svcStates.set(n, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
1965
+ }
1966
+ const content = proxyProvider.generate(svcStates, proxyOpts);
1967
+ if (content === lastContent) return;
1968
+ lastContent = content;
1969
+ try {
1970
+ proxyProvider.write(content, proxyOpts);
1971
+ } catch {
1972
+ }
1973
+ };
1974
+ sync();
1975
+ proxyTimer = setInterval(sync, 3e3);
1976
+ }
1977
+ if (cliArgs.watchConfig) {
1978
+ try {
1979
+ const configPath = findConfigFile(baseCwd, cliArgs.configPath);
1980
+ writeDevupLog(`\u{1F440} watching ${configPath}`);
1981
+ stopConfigWatcher = watchConfig({
1982
+ configPath,
1983
+ baseCwd,
1984
+ manager: mgr,
1985
+ log: (msg) => writeDevupLog(msg)
1986
+ });
1987
+ } catch (e) {
1988
+ writeDevupLog(`\u26A0 watch-config disabled: ${e.message ?? String(e)}`);
1989
+ }
1990
+ }
1991
+ writeFileSync2(pidPath, String(process.pid));
1992
+ writeDevupLog(`\u2713 daemon ready (pid=${process.pid})`);
1993
+ } catch (e) {
1994
+ try {
1995
+ writeFileSync2(errPath, e.message ?? String(e));
1996
+ } catch {
1997
+ }
1998
+ try {
1999
+ writeDevupLog(`\u274C boot failed: ${e.message ?? String(e)}`);
2000
+ } catch {
2001
+ }
2002
+ await cleanup().catch(() => {
2003
+ });
2004
+ process.exit(1);
2005
+ }
2006
+ }
2007
+ async function bootNormal(mgr, services) {
2008
+ const phases = groupByPhase(services);
2009
+ let colorIdx = 0;
2010
+ for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
2011
+ for (const svc of phases[num]) {
2012
+ const ci = colorIdx++;
2013
+ await mgr.install(svc, ci);
2014
+ await mgr.start(svc, ci);
2015
+ }
2016
+ const apis = phases[num].filter((s) => s.type === "api");
2017
+ if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
2018
+ phases[num].filter((s) => s.type === "web").forEach((s) => {
2019
+ const st = mgr.state.get(s.name);
2020
+ if (st) st.status = "running";
2021
+ });
2022
+ }
2023
+ }
2024
+ async function bootLazy(mgr, services, lazyCfg, lazyTimeout, lazyProxies) {
2025
+ const { alwaysOn, lazy } = classifyServices(services, lazyCfg);
2026
+ const phases = groupByPhase(alwaysOn);
2027
+ let colorIdx = 0;
2028
+ for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
2029
+ for (const svc of phases[num]) {
2030
+ const ci = colorIdx++;
2031
+ await mgr.install(svc, ci);
2032
+ await mgr.start(svc, ci);
2033
+ }
2034
+ const apis = phases[num].filter((s) => s.type === "api");
2035
+ if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
2036
+ phases[num].filter((s) => s.type === "web").forEach((s) => {
2037
+ const st = mgr.state.get(s.name);
2038
+ if (st) st.status = "running";
2039
+ });
2040
+ }
2041
+ for (const svc of lazy) {
2042
+ const ci = colorIdx++;
2043
+ const rewritten = rewriteServicePort(svc);
2044
+ mgr.state.set(svc.name, {
2045
+ svc: rewritten,
2046
+ proc: null,
2047
+ pid: null,
2048
+ status: "idle",
2049
+ health: "idle",
2050
+ errors: 0,
2051
+ restarts: 0,
2052
+ startedAt: null,
2053
+ intentionalStop: false,
2054
+ colorIdx: ci
2055
+ });
2056
+ const proxy = createLazyProxy({
2057
+ listenPort: svc.port,
2058
+ targetPort: rewritten.realPort,
2059
+ timeoutMin: lazyTimeout,
2060
+ onDemandStart: async () => {
2061
+ await mgr.install(rewritten, ci);
2062
+ await mgr.start(rewritten, ci);
2063
+ const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
2064
+ const st = mgr.state.get(svc.name);
2065
+ if (st) {
2066
+ st.status = ok ? "running" : "timeout";
2067
+ if (ok) st.health = "up";
2068
+ }
2069
+ },
2070
+ onIdleStop: () => {
2071
+ mgr.stop(svc.name);
2072
+ const st = mgr.state.get(svc.name);
2073
+ if (st) {
2074
+ st.status = "idle";
2075
+ st.health = "idle";
2076
+ st.pid = null;
2077
+ st.proc = null;
2078
+ st.startedAt = null;
2079
+ }
2080
+ },
2081
+ isAlive: () => {
2082
+ const st = mgr.state.get(svc.name);
2083
+ return !!st && !!st.proc && !st.proc.killed && st.health === "up";
2084
+ }
2085
+ });
2086
+ lazyProxies.set(svc.name, proxy);
2087
+ }
2088
+ }
2089
+ async function runDetached(opts) {
2090
+ const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2091
+ const projectName = opts.config.name;
2092
+ if (process.platform === "win32") {
2093
+ out("\u274C daemon mode (devup up -d) is not yet supported on Windows. Run `devup` to use the TUI instead.");
2094
+ return 1;
2095
+ }
2096
+ const existing = isDaemonRunning(projectName);
2097
+ if (existing.pid && !existing.stale) {
2098
+ out(`\u274C daemon already running for "${projectName}" (pid=${existing.pid}). Run \`devup down\` to stop it.`);
2099
+ return 1;
2100
+ }
2101
+ if (existing.stale) {
2102
+ out(`\u2139 removing stale pid file for "${projectName}"`);
2103
+ try {
2104
+ unlinkSync2(pidPathFor(projectName));
2105
+ } catch {
2106
+ }
2107
+ }
2108
+ mkdirSync3(devupDir(), { recursive: true });
2109
+ const errPath = bootErrorPathFor(projectName);
2110
+ const pidPath = pidPathFor(projectName);
2111
+ if (existsSync10(errPath)) {
2112
+ try {
2113
+ unlinkSync2(errPath);
2114
+ } catch {
2115
+ }
2116
+ }
2117
+ const filteredArgs = process.argv.slice(2).filter((arg, i) => {
2118
+ if (i === 0 && arg === "up") return false;
2119
+ if (arg === "-d" || arg === "--detach") return false;
2120
+ return true;
2121
+ });
2122
+ out(`\u23F3 starting devup in detached mode for "${projectName}"...`);
2123
+ const child = spawn4(process.execPath, [...process.execArgv, process.argv[1], ...filteredArgs], {
2124
+ detached: true,
2125
+ stdio: "ignore",
2126
+ env: { ...process.env, DEVUP_DAEMON_CHILD: "1" },
2127
+ cwd: opts.baseCwd
2128
+ });
2129
+ child.unref();
2130
+ const deadline = Date.now() + 9e4;
2131
+ while (Date.now() < deadline) {
2132
+ if (existsSync10(pidPath)) {
2133
+ const pid = Number(readFileSync3(pidPath, "utf8").trim());
2134
+ out("");
2135
+ out(`\u{1F680} devup detached (PID ${pid})`);
2136
+ out(" inspect: devup ctl status");
2137
+ out(" logs: devup ctl logs <svc> --follow");
2138
+ out(" stop: devup down");
2139
+ return 0;
2140
+ }
2141
+ if (existsSync10(errPath)) {
2142
+ const msg = readFileSync3(errPath, "utf8").trim();
2143
+ out(`\u274C daemon boot failed: ${msg}`);
2144
+ try {
2145
+ unlinkSync2(errPath);
2146
+ } catch {
2147
+ }
2148
+ return 1;
2149
+ }
2150
+ await sleep(200);
2151
+ }
2152
+ out(`\u274C daemon did not become ready within 90s. Killing child (pid=${child.pid}).`);
2153
+ if (child.pid) {
2154
+ try {
2155
+ process.kill(child.pid, "SIGTERM");
2156
+ } catch {
2157
+ }
2158
+ }
2159
+ return 1;
2160
+ }
2161
+ async function stopDaemon(projectName, opts = {}) {
2162
+ const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2163
+ const grace = opts.gracePeriodMs ?? 1e4;
2164
+ const status = isDaemonRunning(projectName);
2165
+ if (!status.pid) {
2166
+ out(`\u2139 no daemon running for "${projectName}".`);
2167
+ return 1;
2168
+ }
2169
+ if (status.stale) {
2170
+ out(`\u2139 stale pid file for "${projectName}" (pid=${status.pid} not alive). Removing.`);
2171
+ try {
2172
+ unlinkSync2(pidPathFor(projectName));
2173
+ } catch {
2174
+ }
2175
+ return 1;
2176
+ }
2177
+ out(`\u23F3 stopping daemon (pid=${status.pid})...`);
2178
+ try {
2179
+ process.kill(status.pid, "SIGTERM");
2180
+ } catch (e) {
2181
+ out(`\u274C cannot signal pid=${status.pid}: ${e.message}`);
2182
+ return 1;
2183
+ }
2184
+ const deadline = Date.now() + grace;
2185
+ while (Date.now() < deadline) {
2186
+ if (!pidAlive(status.pid)) {
2187
+ out(`\u2713 stopped daemon (pid=${status.pid})`);
2188
+ const p2 = pidPathFor(projectName);
2189
+ if (existsSync10(p2)) {
2190
+ try {
2191
+ unlinkSync2(p2);
2192
+ } catch {
2193
+ }
2194
+ }
2195
+ return 0;
2196
+ }
2197
+ await sleep(200);
2198
+ }
2199
+ out(`\u26A0 daemon did not exit within ${(grace / 1e3).toFixed(0)}s; sending SIGKILL.`);
2200
+ try {
2201
+ process.kill(status.pid, "SIGKILL");
2202
+ } catch {
2203
+ }
2204
+ const p = pidPathFor(projectName);
2205
+ if (existsSync10(p)) {
2206
+ try {
2207
+ unlinkSync2(p);
2208
+ } catch {
2209
+ }
2210
+ }
2211
+ return 0;
2212
+ }
2213
+
2214
+ // src/orchestrator/subcommands.ts
2215
+ var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help", "ctl", "up", "down"]);
2216
+ function detectSubcommand(argv) {
2217
+ const first = argv[0];
2218
+ return first && KNOWN.has(first) ? first : null;
2219
+ }
2220
+ function logRoot(config, override) {
2221
+ const root = override ?? join9(homedir4(), ".devup", "logs");
2222
+ return join9(root, sanitize3(config.name));
2223
+ }
2224
+ function sanitize3(name) {
2225
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
2226
+ }
2227
+ async function runLogs(argv, opts) {
2228
+ const out = opts.out ?? ((l) => console.log(l));
2229
+ const follow = argv.includes("--follow") || argv.includes("-f");
2230
+ const svcArg = argv.find((a) => !a.startsWith("-"));
2231
+ if (!svcArg) {
2232
+ out("usage: devup logs <service> [--follow]");
2233
+ return 1;
2234
+ }
2235
+ const knownSvcs = opts.config.services.map((s) => s.name);
2236
+ if (!knownSvcs.includes(svcArg)) {
2237
+ out(`Unknown service "${svcArg}". Known: ${knownSvcs.join(", ")}`);
2238
+ return 1;
2239
+ }
2240
+ const file = join9(logRoot(opts.config, opts.logDir), `${sanitize3(svcArg)}.log`);
2241
+ if (!existsSync11(file)) {
2242
+ out(`No log file yet for "${svcArg}" (${file})`);
2243
+ return follow ? await followFile(file, out) : 1;
2244
+ }
2245
+ await streamFile(file, out);
2246
+ if (!follow) return 0;
2247
+ return await followFile(file, out, statSync2(file).size);
2248
+ }
2249
+ async function streamFile(file, out) {
2250
+ return new Promise((resolve4, reject) => {
2251
+ const rl = createInterface4({ input: createReadStream2(file, { encoding: "utf8" }) });
2252
+ rl.on("line", (l) => out(l));
2253
+ rl.on("close", () => resolve4());
2254
+ rl.on("error", reject);
2255
+ });
2256
+ }
2257
+ async function followFile(file, out, startAt = 0) {
2258
+ let pos = startAt;
2259
+ while (!existsSync11(file)) await new Promise((r) => setTimeout(r, 500));
2260
+ return new Promise((resolve4) => {
2261
+ const tick = async () => {
2262
+ const size = statSync2(file).size;
2263
+ if (size > pos) {
2264
+ await new Promise((res) => {
2265
+ const rl = createInterface4({ input: createReadStream2(file, { encoding: "utf8", start: pos, end: size - 1 }) });
2266
+ rl.on("line", (l) => out(l));
2267
+ rl.on("close", () => {
2268
+ pos = size;
2269
+ res();
2270
+ });
2271
+ });
2272
+ } else if (size < pos) {
2273
+ pos = 0;
2274
+ }
2275
+ };
2276
+ watchFile2(file, { interval: 500 }, () => {
2277
+ void tick();
2278
+ });
2279
+ process.once("SIGINT", () => {
2280
+ unwatchFile2(file);
2281
+ resolve4(0);
2282
+ });
2283
+ });
2284
+ }
2285
+ async function runInstall(opts) {
2286
+ const out = opts.out ?? ((l) => console.log(l));
2287
+ const concurrency = opts.concurrency ?? 4;
2288
+ const items = opts.config.services.map((s) => ({ name: s.name, cwd: join9(opts.baseCwd, s.cwd) }));
2289
+ const queue = [...items];
2290
+ const failed = [];
2291
+ let inFlight = 0;
2292
+ await new Promise((resolve4) => {
2293
+ const pump = () => {
2294
+ while (inFlight < concurrency && queue.length) {
2295
+ const item = queue.shift();
2296
+ inFlight++;
2297
+ installOne(item.cwd, opts.env).then((ok) => {
2298
+ inFlight--;
2299
+ if (ok) out(`\u2713 ${item.name}`);
2300
+ else {
2301
+ failed.push(item.name);
2302
+ out(`\u2717 ${item.name}`);
2303
+ }
2304
+ if (queue.length === 0 && inFlight === 0) resolve4();
2305
+ else pump();
2306
+ });
2307
+ }
2308
+ };
2309
+ pump();
2310
+ });
2311
+ if (failed.length) {
2312
+ out(`
2313
+ failed: ${failed.join(", ")}`);
2314
+ return 1;
2315
+ }
2316
+ out(`
2317
+ ${items.length} services up to date`);
2318
+ return 0;
2319
+ }
2320
+ function installOne(cwd, env) {
2321
+ if (!existsSync11(cwd)) return Promise.resolve(false);
2322
+ if (!needsInstall(cwd)) return Promise.resolve(true);
2323
+ return new Promise((resolve4) => {
2324
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
2325
+ const proc = spawn5(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
2326
+ proc.on("close", (code) => {
2327
+ if (code === 0) {
2328
+ writeInstallStamp(cwd);
2329
+ resolve4(true);
2330
+ } else resolve4(false);
2331
+ });
2332
+ proc.on("error", () => resolve4(false));
2333
+ });
2334
+ }
2335
+ async function runStatus(opts) {
2336
+ const out = opts.out ?? ((l) => console.log(l));
2337
+ out(`${opts.config.icon ?? "\u{1F4E6}"} ${opts.config.name} \u2014 ${opts.config.services.length} services`);
2338
+ out("");
2339
+ const maxLen = Math.max(...opts.config.services.map((s) => s.name.length), 12);
2340
+ out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
2341
+ out("-".repeat(maxLen + 24));
2342
+ for (const svc of opts.config.services) {
2343
+ const up = await checkHealth(svc.port, svc.healthCheck);
2344
+ const health = up ? "\u2713 up" : "\u2717 down";
2345
+ out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
2346
+ }
2347
+ return 0;
2348
+ }
2349
+ function fmtStatus(rows, out) {
2350
+ const maxLen = Math.max(...rows.map((r) => r.name.length), 8);
2351
+ for (const r of rows) {
2352
+ const pid = r.pid != null ? `pid=${r.pid}` : " ";
2353
+ const name = r.name.padEnd(maxLen);
2354
+ const port = `:${r.port}`.padStart(6);
2355
+ const status = r.status.padEnd(8);
2356
+ const health = r.health.padEnd(4);
2357
+ out(`${name} ${port} ${status} ${health} ${pid} errors=${r.errors} restarts=${r.restarts}`);
2358
+ }
2359
+ }
2360
+ async function runCtl(argv, opts) {
2361
+ const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2362
+ const method = argv[0];
2363
+ const follow = argv.includes("--follow") || argv.includes("-f");
2364
+ const socketPath = resolveSocket(opts.config.name, opts.socketPath);
2365
+ if (!method || method === "help") {
2366
+ out("Usage: devup ctl <method> [args] [--follow]");
2367
+ out(" ping Check if devup is running");
2368
+ out(" status [--follow] Service snapshot, or live updates");
2369
+ out(" logs <svc> [--follow] Tail logs (last 100), or follow live stream");
2370
+ out(" restart <svc> Restart a service");
2371
+ out(" stop <svc> Stop a service");
2372
+ return 0;
2373
+ }
2374
+ try {
2375
+ assertSocketExists(socketPath, opts.config.name);
2376
+ } catch (e) {
2377
+ out(e.message);
2378
+ return 1;
2379
+ }
2380
+ try {
2381
+ if (method === "ping") {
2382
+ const res = await sendRpc(socketPath, "ping");
2383
+ out(`pong ts=${res.ts}`);
2384
+ return 0;
2385
+ }
2386
+ if (method === "status" && !follow) {
2387
+ const res = await sendRpc(socketPath, "status");
2388
+ if (!res.services.length) {
2389
+ out("(no services)");
2390
+ return 0;
2391
+ }
2392
+ fmtStatus(res.services, out);
2393
+ return 0;
2394
+ }
2395
+ if (method === "status" && follow) {
2396
+ return await new Promise((resolve4) => {
2397
+ const abort = openStream(socketPath, "status.follow", {}, (frame) => {
2398
+ const rows = frame.data;
2399
+ const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
2400
+ for (const r of rows) {
2401
+ out(`[${ts}] ${r.name.padEnd(24)} ${r.status}/${r.health}`);
2402
+ }
2403
+ }, (err) => {
2404
+ out(`error: ${err.message}`);
2405
+ resolve4(1);
2406
+ });
2407
+ process.once("SIGINT", () => {
2408
+ abort();
2409
+ resolve4(0);
2410
+ });
2411
+ });
2412
+ }
2413
+ if (method === "logs") {
2414
+ const svc = argv.find((a, i) => i > 0 && !a.startsWith("-"));
2415
+ if (!svc) {
2416
+ out("usage: devup ctl logs <service> [--follow]");
2417
+ return 1;
2418
+ }
2419
+ if (!follow) {
2420
+ const res = await sendRpc(socketPath, "logs.tail", { svc, lines: 100 });
2421
+ for (const l of res.lines) out(l);
2422
+ return 0;
2423
+ }
2424
+ return await new Promise((resolve4) => {
2425
+ const abort = openStream(socketPath, "logs.follow", { svc, tail: 100 }, (frame) => {
2426
+ out(frame.data);
2427
+ }, (err) => {
2428
+ out(`error: ${err.message}`);
2429
+ resolve4(1);
2430
+ });
2431
+ process.once("SIGINT", () => {
2432
+ abort();
2433
+ resolve4(0);
2434
+ });
2435
+ });
2436
+ }
2437
+ if (method === "restart") {
2438
+ const svc = argv[1];
2439
+ if (!svc) {
2440
+ out("usage: devup ctl restart <service>");
2441
+ return 1;
2442
+ }
2443
+ await sendRpc(socketPath, "restart", { svc });
2444
+ out(`\u2713 restart sent to ${svc}`);
2445
+ return 0;
2446
+ }
2447
+ if (method === "stop") {
2448
+ const svc = argv[1];
2449
+ if (!svc) {
2450
+ out("usage: devup ctl stop <service>");
2451
+ return 1;
2452
+ }
2453
+ await sendRpc(socketPath, "stop", { svc });
2454
+ out(`\u2713 stop sent to ${svc}`);
2455
+ return 0;
2456
+ }
2457
+ out(`unknown ctl method: ${method}. Run \`devup ctl help\` for usage.`);
2458
+ return 1;
2459
+ } catch (e) {
2460
+ out(`error: ${e.message}`);
2461
+ return 1;
2462
+ }
2463
+ }
2464
+ async function runDown(opts) {
2465
+ const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2466
+ return stopDaemon(opts.config.name, { out });
2467
+ }
2468
+ function runHelp(argv, opts = {}) {
2469
+ const out = opts.out ?? ((l) => console.log(l));
2470
+ const sub = argv[0];
2471
+ if (sub === "logs") {
2472
+ out("Usage: devup logs <service> [--follow|-f]");
2473
+ out(" Print the persisted log file for a service (works without devup running).");
2474
+ out(" --follow tails new lines as they are appended.");
2475
+ return 0;
2476
+ }
2477
+ if (sub === "install") {
2478
+ out("Usage: devup install");
2479
+ out(" Run `npm install` across every service.cwd in parallel (max 4 at a time).");
2480
+ out(" Skips services whose .install-stamp matches package.json hash.");
2481
+ return 0;
2482
+ }
2483
+ if (sub === "status") {
2484
+ out("Usage: devup status");
2485
+ out(" For each service, probes its health-check endpoint and prints up/down.");
2486
+ return 0;
2487
+ }
2488
+ if (sub === "ctl") {
2489
+ out("Usage: devup ctl <method> [args] [--follow]");
2490
+ out(" Send commands to a running devup process via the control plane socket.");
2491
+ out("");
2492
+ out(" ping Check if devup is running");
2493
+ out(" status [--follow] Service snapshot, or live state-change stream");
2494
+ out(" logs <svc> [--follow] Tail last 100 lines, or follow the live stream");
2495
+ out(" restart <svc> Restart the named service");
2496
+ out(" stop <svc> Stop the named service");
2497
+ out("");
2498
+ out(" devup must be running in the same project directory.");
2499
+ return 0;
2500
+ }
2501
+ if (sub === "up") {
2502
+ out("Usage: devup up -d");
2503
+ out(" Boot the stack in detached/daemon mode (like `docker compose up -d`).");
2504
+ out(" Returns immediately once the stack is healthy; services keep running.");
2505
+ out(" Use `devup ctl status`, `devup ctl logs`, or `devup down` to interact.");
2506
+ out(" Not supported on Windows yet \u2014 use `devup` (TUI) instead.");
2507
+ return 0;
2508
+ }
2509
+ if (sub === "down") {
2510
+ out("Usage: devup down");
2511
+ out(" Stop the daemon for the current project. SIGTERM with 10s grace,");
2512
+ out(" then SIGKILL. Removes the PID file and the control-plane socket.");
2513
+ return 0;
2514
+ }
2515
+ out("Subcommands:");
2516
+ out(" devup logs <service> [--follow] Read the persisted log file");
2517
+ out(" devup install Concurrent npm install across services");
2518
+ out(" devup status Health check every service in config");
2519
+ out(" devup up -d Boot the stack in detached/daemon mode");
2520
+ out(" devup down Stop the running daemon");
2521
+ out(" devup ctl <method> [args] Control a running devup (restart/stop/logs/...)");
2522
+ out(" devup help [<subcommand>] Show detailed help for a subcommand");
2523
+ out("");
2524
+ out("No subcommand \u2192 launch the interactive TUI.");
2525
+ return 0;
2526
+ }
2527
+
2528
+ // src/platform/detect.ts
2529
+ async function detectPlatform() {
2530
+ switch (process.platform) {
2531
+ case "linux": {
2532
+ const { LinuxPlatform } = await import("./linux-OQ3Q4Z2Z.js");
2533
+ return new LinuxPlatform();
2534
+ }
2535
+ case "darwin": {
2536
+ const { DarwinPlatform } = await import("./darwin-3KJ3IEXN.js");
2537
+ return new DarwinPlatform();
2538
+ }
2539
+ case "win32": {
2540
+ const { Win32Platform } = await import("./win32-3X2OLSI6.js");
2541
+ return new Win32Platform();
2542
+ }
2543
+ default:
2544
+ throw new Error(`Unsupported platform: ${process.platform}`);
2545
+ }
2546
+ }
2547
+
2548
+ // src/proxy-config/traefik.ts
2549
+ import { existsSync as existsSync12, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "fs";
2550
+ import { dirname as dirname4 } from "path";
2551
+ var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
2552
+ var TraefikProvider = class {
2553
+ name = "traefik";
2554
+ generate(services, opts) {
2555
+ const routers = [];
2556
+ const svcs = [];
2557
+ for (const [name, st] of services) {
2558
+ if (st.health !== "up") continue;
2559
+ const sub = opts.routes[name];
2560
+ if (sub === void 0) continue;
2561
+ const rule = sub ? `Host(\`${sub}.${opts.domain}\`)` : `Host(\`${opts.domain}\`)`;
2562
+ const safe = name.replace(/[^a-z0-9-]/g, "-");
2563
+ const port = st.realPort ?? st.port;
2564
+ let router = ` ${safe}:
2565
+ rule: "${rule}"
2566
+ service: ${safe}
2567
+ entryPoints:
2568
+ - ${opts.entrypoint}`;
2569
+ if (opts.tls) router += `
2570
+ tls:
2571
+ certResolver: le`;
2572
+ routers.push(router);
2573
+ svcs.push(` ${safe}:
2574
+ loadBalancer:
2575
+ servers:
2576
+ - url: "http://${opts.host}:${port}"`);
2577
+ }
2578
+ if (!routers.length) return EMPTY_CONFIG;
2579
+ return `http:
2580
+ routers:
2581
+ ${routers.join("\n")}
2582
+ services:
2583
+ ${svcs.join("\n")}
2584
+ `;
2585
+ }
2586
+ write(content, opts) {
2587
+ const dir = dirname4(opts.confPath);
2588
+ if (!existsSync12(dir)) mkdirSync4(dir, { recursive: true });
2589
+ writeFileSync3(opts.confPath, content);
2590
+ }
2591
+ clear(opts) {
2592
+ this.write(EMPTY_CONFIG, opts);
2593
+ }
2594
+ };
2595
+
2596
+ // src/proxy-config/nginx.ts
2597
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2598
+ import { dirname as dirname5 } from "path";
2599
+ var EMPTY_CONFIG2 = "# devup: no healthy services\n";
2600
+ var NginxProvider = class {
2601
+ name = "nginx";
2602
+ generate(services, opts) {
2603
+ const blocks = [];
2604
+ for (const [name, st] of services) {
2605
+ if (st.health !== "up") continue;
2606
+ const sub = opts.routes[name];
2607
+ if (sub === void 0) continue;
2608
+ const serverName = sub ? `${sub}.${opts.domain}` : opts.domain;
2609
+ const port = st.realPort ?? st.port;
2610
+ const listen = opts.tls ? "443 ssl" : "80";
2611
+ const tlsBlock = opts.tls ? ` ssl_certificate /etc/nginx/certs/${serverName}.crt;
2612
+ ssl_certificate_key /etc/nginx/certs/${serverName}.key;
2613
+ ` : "";
2614
+ blocks.push(
2615
+ `server {
2616
+ listen ${listen};
2617
+ server_name ${serverName};
2618
+ ` + tlsBlock + ` location / {
2619
+ proxy_pass http://${opts.host}:${port};
2620
+ proxy_set_header Host $host;
2621
+ proxy_set_header X-Real-IP $remote_addr;
2622
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
2623
+ proxy_set_header X-Forwarded-Proto $scheme;
2624
+ proxy_http_version 1.1;
2625
+ proxy_set_header Upgrade $http_upgrade;
2626
+ proxy_set_header Connection "upgrade";
2627
+ }
2628
+ }`
2629
+ );
2630
+ }
2631
+ if (!blocks.length) return EMPTY_CONFIG2;
2632
+ return blocks.join("\n\n") + "\n";
2633
+ }
2634
+ write(content, opts) {
2635
+ const dir = dirname5(opts.confPath);
2636
+ if (!existsSync13(dir)) mkdirSync5(dir, { recursive: true });
2637
+ writeFileSync4(opts.confPath, content);
2638
+ }
2639
+ clear(opts) {
2640
+ this.write(EMPTY_CONFIG2, opts);
2641
+ }
2642
+ };
2643
+
2644
+ // src/proxy-config/caddy.ts
2645
+ import { existsSync as existsSync14, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
2646
+ import { dirname as dirname6 } from "path";
2647
+ var EMPTY_CONFIG3 = "# devup: no healthy services\n";
2648
+ var CaddyProvider = class {
2649
+ name = "caddy";
2650
+ generate(services, opts) {
2651
+ const blocks = [];
2652
+ for (const [name, st] of services) {
2653
+ if (st.health !== "up") continue;
2654
+ const sub = opts.routes[name];
2655
+ if (sub === void 0) continue;
2656
+ const host = sub ? `${sub}.${opts.domain}` : opts.domain;
2657
+ const port = st.realPort ?? st.port;
2658
+ const siteAddr = opts.tls ? host : `http://${host}`;
2659
+ blocks.push(
2660
+ `${siteAddr} {
2661
+ reverse_proxy ${opts.host}:${port}
2662
+ }`
2663
+ );
2664
+ }
2665
+ if (!blocks.length) return EMPTY_CONFIG3;
2666
+ return blocks.join("\n\n") + "\n";
2667
+ }
2668
+ write(content, opts) {
2669
+ const dir = dirname6(opts.confPath);
2670
+ if (!existsSync14(dir)) mkdirSync6(dir, { recursive: true });
2671
+ writeFileSync5(opts.confPath, content);
2672
+ }
2673
+ clear(opts) {
2674
+ this.write(EMPTY_CONFIG3, opts);
2675
+ }
2676
+ };
2677
+
2678
+ // src/proxy-config/detect.ts
2679
+ var providers = {
2680
+ traefik: () => new TraefikProvider(),
2681
+ nginx: () => new NginxProvider(),
2682
+ caddy: () => new CaddyProvider()
2683
+ };
2684
+ function detectProxyProvider(name) {
2685
+ const factory = providers[name];
2686
+ if (!factory) {
2687
+ const available = Object.keys(providers).join(", ");
2688
+ throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
2689
+ }
2690
+ return factory();
2691
+ }
2692
+
2693
+ // src/tui/App.tsx
2694
+ import { useCallback as useCallback3, useRef as useRef5 } from "react";
2695
+ import { Box as Box6, Text as Text6 } from "ink";
2696
+
2697
+ // src/tui/hooks/useProcessManager.ts
2698
+ import { useState, useEffect, useRef, useCallback } from "react";
2699
+ function useProcessManager(platform, baseCwd, env, logSink = null) {
2700
+ const [states, setStates] = useState(/* @__PURE__ */ new Map());
2701
+ const [logs, setLogs] = useState([]);
2702
+ const [stats, setStats] = useState(/* @__PURE__ */ new Map());
2703
+ const mgrRef = useRef(null);
2704
+ const prevCpu = useRef(/* @__PURE__ */ new Map());
2705
+ const pausedRef = useRef(false);
2706
+ const pendingLogsRef = useRef([]);
2707
+ const sinkRef = useRef(logSink);
2708
+ sinkRef.current = logSink;
2709
+ const logBus = useRef(new Broadcaster());
2710
+ const stateBus = useRef(new Broadcaster());
2711
+ useEffect(() => {
2712
+ const mgr2 = new ProcessManager({
2713
+ baseCwd,
2714
+ env,
2715
+ platform,
2716
+ events: {
1507
2717
  onLog: (svcName, text, colorIdx) => {
1508
2718
  sinkRef.current?.write(svcName, text);
2719
+ logBus.current.emit({ svc: svcName, text });
1509
2720
  const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
1510
2721
  if (pausedRef.current) {
1511
2722
  pendingLogsRef.current.push(entry);
@@ -1519,7 +2730,10 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
1519
2730
  return next.length > 5e3 ? next.slice(-5e3) : next;
1520
2731
  });
1521
2732
  },
1522
- onStateChange: () => setStates(new Map(mgr2.state))
2733
+ onStateChange: (name, state) => {
2734
+ stateBus.current.emit({ name, state });
2735
+ setStates(new Map(mgr2.state));
2736
+ }
1523
2737
  }
1524
2738
  });
1525
2739
  mgrRef.current = mgr2;
@@ -1564,6 +2778,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
1564
2778
  }, []);
1565
2779
  const pushLog = useCallback((svcName, text, colorIdx = 0) => {
1566
2780
  sinkRef.current?.write(svcName, text);
2781
+ logBus.current.emit({ svc: svcName, text });
1567
2782
  const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
1568
2783
  if (pausedRef.current) {
1569
2784
  pendingLogsRef.current.push(entry);
@@ -1600,7 +2815,9 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
1600
2815
  clearLogs,
1601
2816
  setPaused,
1602
2817
  pushLog,
1603
- manager: mgr
2818
+ manager: mgr,
2819
+ logBus: logBus.current,
2820
+ stateBus: stateBus.current
1604
2821
  };
1605
2822
  }
1606
2823
 
@@ -1739,134 +2956,9 @@ function useLogsPause(setPaused, logsPaused, logsScrollOffset) {
1739
2956
 
1740
2957
  // src/tui/hooks/useControlPlane.ts
1741
2958
  import { useEffect as useEffect5, useRef as useRef3 } from "react";
1742
- import { createInterface as createInterface3 } from "readline";
1743
- import { createReadStream as createReadStream2, existsSync as existsSync12 } from "fs";
1744
-
1745
- // src/control-plane/socket-server.ts
1746
- import { createServer } from "net";
1747
- import { createInterface as createInterface2 } from "readline";
1748
- import { existsSync as existsSync11, unlinkSync, chmodSync, mkdirSync as mkdirSync4, statSync as statSync2 } from "fs";
1749
- import { dirname as dirname5 } from "path";
1750
- import { join as join6 } from "path";
1751
- import { homedir as homedir2 } from "os";
1752
- function defaultSocketPath(projectName) {
1753
- const safe = projectName.replace(/[^a-zA-Z0-9._-]+/g, "_") || "devup";
1754
- return join6(homedir2(), ".devup", `sock-${safe}.sock`);
1755
- }
1756
- async function startSocketServer(projectName, ctx, opts = {}) {
1757
- const path = opts.path ?? defaultSocketPath(projectName);
1758
- mkdirSync4(dirname5(path), { recursive: true });
1759
- if (existsSync11(path)) {
1760
- try {
1761
- const st = statSync2(path);
1762
- if (st.isSocket()) unlinkSync(path);
1763
- } catch {
1764
- }
1765
- }
1766
- const server = createServer((socket) => handleClient(socket, ctx));
1767
- await new Promise((resolve4, reject) => {
1768
- server.once("error", reject);
1769
- server.listen(path, () => {
1770
- server.off("error", reject);
1771
- try {
1772
- chmodSync(path, 384);
1773
- } catch {
1774
- }
1775
- opts.onLog?.(`\u{1F50C} control plane at ${path}`);
1776
- resolve4();
1777
- });
1778
- });
1779
- return {
1780
- server,
1781
- path,
1782
- async close() {
1783
- await new Promise((resolve4) => server.close(() => resolve4()));
1784
- if (existsSync11(path)) {
1785
- try {
1786
- unlinkSync(path);
1787
- } catch {
1788
- }
1789
- }
1790
- }
1791
- };
1792
- }
1793
- function handleClient(socket, ctx) {
1794
- const rl = createInterface2({ input: socket });
1795
- rl.on("line", async (line) => {
1796
- if (!line.trim()) return;
1797
- let req;
1798
- try {
1799
- req = JSON.parse(line);
1800
- } catch (e) {
1801
- respond(socket, { error: { code: -32700, message: `parse error: ${e.message}` } });
1802
- return;
1803
- }
1804
- if (typeof req.method !== "string") {
1805
- respond(socket, { id: req.id, error: { code: -32600, message: "method required" } });
1806
- return;
1807
- }
1808
- try {
1809
- const result = await dispatch(req.method, req.params ?? {}, ctx);
1810
- respond(socket, { id: req.id, result });
1811
- } catch (e) {
1812
- respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
1813
- }
1814
- });
1815
- socket.on("error", () => {
1816
- });
1817
- }
1818
- function respond(socket, payload) {
1819
- if (socket.writable) socket.write(JSON.stringify(payload) + "\n");
1820
- }
1821
- async function dispatch(method, params, ctx) {
1822
- switch (method) {
1823
- case "status": {
1824
- const out = [];
1825
- for (const [name, st] of ctx.states()) {
1826
- out.push({
1827
- name,
1828
- status: st.status,
1829
- health: st.health,
1830
- port: st.svc.port,
1831
- type: st.svc.type,
1832
- errors: st.errors,
1833
- restarts: st.restarts,
1834
- pid: st.pid,
1835
- startedAt: st.startedAt
1836
- });
1837
- }
1838
- return { services: out };
1839
- }
1840
- case "restart": {
1841
- const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
1842
- await ctx.restart(svc);
1843
- return { ok: true };
1844
- }
1845
- case "stop": {
1846
- const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
1847
- ctx.stop(svc);
1848
- return { ok: true };
1849
- }
1850
- case "logs.tail": {
1851
- const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
1852
- const lines = Math.max(1, Math.min(1e4, Number(params["lines"] ?? 100)));
1853
- return { lines: await ctx.tailLogs(svc, lines) };
1854
- }
1855
- case "ping":
1856
- return { ok: true, ts: Date.now() };
1857
- default:
1858
- throw new Error(`unknown method: ${method}`);
1859
- }
1860
- }
1861
- function stringOrThrow(v, paramName) {
1862
- if (typeof v !== "string" || !v.trim()) {
1863
- throw new Error(`param "${paramName}" must be a non-empty string`);
1864
- }
1865
- return v;
1866
- }
1867
-
1868
- // src/tui/hooks/useControlPlane.ts
1869
- function useControlPlane(manager, projectName, logSink, pushLog) {
2959
+ import { createInterface as createInterface5 } from "readline";
2960
+ import { createReadStream as createReadStream3, existsSync as existsSync15 } from "fs";
2961
+ function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBus) {
1870
2962
  const handleRef = useRef3(null);
1871
2963
  useEffect5(() => {
1872
2964
  if (!manager) return;
@@ -1880,10 +2972,10 @@ function useControlPlane(manager, projectName, logSink, pushLog) {
1880
2972
  tailLogs: async (svcName, lines) => {
1881
2973
  if (!logSink) return [];
1882
2974
  const file = logSink.pathFor(svcName);
1883
- if (!existsSync12(file)) return [];
2975
+ if (!existsSync15(file)) return [];
1884
2976
  return new Promise((resolve4, reject) => {
1885
2977
  const buf = [];
1886
- const rl = createInterface3({ input: createReadStream2(file, { encoding: "utf8" }) });
2978
+ const rl = createInterface5({ input: createReadStream3(file, { encoding: "utf8" }) });
1887
2979
  rl.on("line", (l) => {
1888
2980
  buf.push(l);
1889
2981
  if (buf.length > lines) buf.shift();
@@ -1891,6 +2983,14 @@ function useControlPlane(manager, projectName, logSink, pushLog) {
1891
2983
  rl.on("close", () => resolve4(buf));
1892
2984
  rl.on("error", reject);
1893
2985
  });
2986
+ },
2987
+ watchLogs: (svcName, onLine) => {
2988
+ return logBus.subscribe(({ svc, text }) => {
2989
+ if (svcName === null || svc === svcName) onLine(svc, text);
2990
+ });
2991
+ },
2992
+ watchStatus: (onUpdate) => {
2993
+ return stateBus.subscribe(({ name, state }) => onUpdate(name, state));
1894
2994
  }
1895
2995
  }, { onLog: (msg) => pushLog("devup", msg, 12) });
1896
2996
  handleRef.current = handle;
@@ -1902,72 +3002,15 @@ function useControlPlane(manager, projectName, logSink, pushLog) {
1902
3002
  void handle?.close();
1903
3003
  handleRef.current = null;
1904
3004
  };
1905
- }, [manager, projectName, logSink, pushLog]);
3005
+ }, [manager, projectName, logSink, pushLog, logBus, stateBus]);
1906
3006
  return handleRef;
1907
3007
  }
1908
3008
 
1909
3009
  // src/tui/hooks/useHotReload.ts
1910
3010
  import { useEffect as useEffect6 } from "react";
1911
- import { watch as fsWatch } from "fs";
1912
-
1913
- // src/config/diff.ts
1914
- var SPAWN_RELEVANT = [
1915
- "cwd",
1916
- "cmd",
1917
- "args",
1918
- "port",
1919
- "phase",
1920
- "maxMem",
1921
- "preBuild",
1922
- "watchBuild",
1923
- "nodeArgs",
1924
- "extraEnv",
1925
- "healthCheck",
1926
- "readyPattern",
1927
- "errorPattern",
1928
- "type"
1929
- ];
1930
- function hasSpawnRelevantChange(prev, next) {
1931
- for (const k of SPAWN_RELEVANT) {
1932
- if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return true;
1933
- }
1934
- return false;
1935
- }
1936
- function diffServices(prev, next) {
1937
- const prevByName = new Map(prev.map((s) => [s.name, s]));
1938
- const nextByName = new Map(next.map((s) => [s.name, s]));
1939
- const added = [];
1940
- const removed = [];
1941
- const changed = [];
1942
- const unchanged = [];
1943
- for (const [name, p] of prevByName) {
1944
- if (!nextByName.has(name)) {
1945
- removed.push(name);
1946
- continue;
1947
- }
1948
- const n = nextByName.get(name);
1949
- if (hasSpawnRelevantChange(p, n)) changed.push({ prev: p, next: n });
1950
- else unchanged.push(name);
1951
- }
1952
- for (const [name, n] of nextByName) {
1953
- if (!prevByName.has(name)) added.push(n);
1954
- }
1955
- return { added, removed, changed, unchanged };
1956
- }
1957
- function summariseDiff(d) {
1958
- const parts = [];
1959
- if (d.added.length) parts.push(`+${d.added.length} added`);
1960
- if (d.removed.length) parts.push(`-${d.removed.length} removed`);
1961
- if (d.changed.length) parts.push(`~${d.changed.length} changed`);
1962
- if (!parts.length) parts.push("no changes");
1963
- return parts.join(", ");
1964
- }
1965
-
1966
- // src/tui/hooks/useHotReload.ts
1967
3011
  function useHotReload(manager, cliArgs, baseCwd, pushLog) {
1968
3012
  useEffect6(() => {
1969
3013
  if (!cliArgs.watchConfig || !manager) return;
1970
- let watcher = null;
1971
3014
  let configPath;
1972
3015
  try {
1973
3016
  configPath = findConfigFile(baseCwd, cliArgs.configPath);
@@ -1975,64 +3018,13 @@ function useHotReload(manager, cliArgs, baseCwd, pushLog) {
1975
3018
  pushLog("devup", `\u26A0 watch-config disabled: ${e.message}`, 5);
1976
3019
  return;
1977
3020
  }
1978
- pushLog("devup", `\u{1F440} watching ${configPath}`, 12);
1979
- let reloadInFlight = false;
1980
- let reloadAgain = false;
1981
- const reload = async () => {
1982
- if (reloadInFlight) {
1983
- reloadAgain = true;
1984
- return;
1985
- }
1986
- reloadInFlight = true;
1987
- try {
1988
- const nextCfg = await loadConfig(configPath);
1989
- const errs = validateConfig(nextCfg, baseCwd);
1990
- if (errs.length) {
1991
- pushLog("devup", `\u26A0 config reload failed:
1992
- ${formatValidationErrors(errs)}`, 5);
1993
- return;
1994
- }
1995
- const currentSvcs = [...manager.state.values()].map((s) => s.svc);
1996
- const diff = diffServices(currentSvcs, nextCfg.services);
1997
- if (!diff.added.length && !diff.removed.length && !diff.changed.length) return;
1998
- for (const name of diff.removed) {
1999
- manager.stop(name);
2000
- manager.state.delete(name);
2001
- }
2002
- let colorIdx = currentSvcs.length;
2003
- for (const { next } of diff.changed) {
2004
- const prev = manager.state.get(next.name);
2005
- const ci = prev?.colorIdx ?? colorIdx++;
2006
- manager.stop(next.name);
2007
- await new Promise((r) => setTimeout(r, 800));
2008
- await manager.install(next, ci);
2009
- await manager.start(next, ci, true);
2010
- }
2011
- for (const next of diff.added) {
2012
- const ci = colorIdx++;
2013
- await manager.install(next, ci);
2014
- await manager.start(next, ci);
2015
- }
2016
- pushLog("devup", `\u{1F501} config reloaded: ${summariseDiff(diff)}`, 12);
2017
- } catch (e) {
2018
- pushLog("devup", `\u26A0 config reload error: ${e.message}`, 5);
2019
- } finally {
2020
- reloadInFlight = false;
2021
- if (reloadAgain) {
2022
- reloadAgain = false;
2023
- void reload();
2024
- }
2025
- }
2026
- };
2027
- let debounceTimer = null;
2028
- watcher = fsWatch(configPath, () => {
2029
- if (debounceTimer) clearTimeout(debounceTimer);
2030
- debounceTimer = setTimeout(() => void reload(), 250);
3021
+ pushLog("devup", `\u{1F440} watching ${configPath}`, 12);
3022
+ return watchConfig({
3023
+ configPath,
3024
+ baseCwd,
3025
+ manager,
3026
+ log: (msg) => pushLog("devup", msg, msg.startsWith("\u26A0") ? 5 : 12)
2031
3027
  });
2032
- return () => {
2033
- if (debounceTimer) clearTimeout(debounceTimer);
2034
- watcher?.close();
2035
- };
2036
3028
  }, [cliArgs.watchConfig, cliArgs.configPath, baseCwd, manager, pushLog]);
2037
3029
  }
2038
3030
 
@@ -2289,201 +3281,6 @@ function useContextualTips(totalLogs, hasSearch, hasFilter, states) {
2289
3281
 
2290
3282
  // src/tui/hooks/useBootSequence.ts
2291
3283
  import { useEffect as useEffect9, useState as useState6 } from "react";
2292
-
2293
- // src/lazy/proxy.ts
2294
- import net2 from "net";
2295
- function createLazyProxy(opts) {
2296
- const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
2297
- let idleTimer = null;
2298
- let lastActivity = Date.now();
2299
- let starting = false;
2300
- let serviceReady = false;
2301
- let pendingConns = [];
2302
- const activeConns = /* @__PURE__ */ new Set();
2303
- function bumpActivity() {
2304
- lastActivity = Date.now();
2305
- }
2306
- function scheduleIdleCheck() {
2307
- if (idleTimer) clearTimeout(idleTimer);
2308
- if (timeoutMin <= 0) return;
2309
- const periodMs = timeoutMin * 6e4;
2310
- idleTimer = setTimeout(() => {
2311
- const elapsed = Date.now() - lastActivity;
2312
- if (activeConns.size > 0 || elapsed < periodMs) {
2313
- scheduleIdleCheck();
2314
- return;
2315
- }
2316
- serviceReady = false;
2317
- onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
2318
- onIdleStop();
2319
- }, periodMs);
2320
- }
2321
- function pipeToTarget(client) {
2322
- const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
2323
- activeConns.add(client);
2324
- const cleanup = () => {
2325
- activeConns.delete(client);
2326
- bumpActivity();
2327
- };
2328
- target.on("error", () => {
2329
- client.destroy();
2330
- cleanup();
2331
- });
2332
- client.on("error", () => {
2333
- target.destroy();
2334
- cleanup();
2335
- });
2336
- client.on("close", cleanup);
2337
- target.on("close", cleanup);
2338
- target.on("connect", () => {
2339
- target.on("data", (chunk) => {
2340
- bumpActivity();
2341
- if (!client.destroyed) client.write(chunk);
2342
- });
2343
- client.on("data", (chunk) => {
2344
- bumpActivity();
2345
- if (!target.destroyed) target.write(chunk);
2346
- });
2347
- target.on("end", () => {
2348
- if (!client.destroyed) client.end();
2349
- });
2350
- client.on("end", () => {
2351
- if (!target.destroyed) target.end();
2352
- });
2353
- });
2354
- }
2355
- async function handleConnection(client) {
2356
- bumpActivity();
2357
- client.on("error", () => {
2358
- });
2359
- if (serviceReady && isAlive()) {
2360
- pipeToTarget(client);
2361
- return;
2362
- }
2363
- pendingConns.push(client);
2364
- client.on("close", () => {
2365
- pendingConns = pendingConns.filter((s) => s !== client);
2366
- });
2367
- if (starting) return;
2368
- starting = true;
2369
- onLog?.("\u26A1 on-demand start");
2370
- let ok = false;
2371
- try {
2372
- await onDemandStart();
2373
- ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
2374
- if (ok) serviceReady = true;
2375
- else onLog?.("\u26A0 timeout waiting for service");
2376
- } catch (e) {
2377
- onLog?.(`\u274C start failed: ${e.message}`);
2378
- }
2379
- starting = false;
2380
- const conns = pendingConns.splice(0);
2381
- if (!ok) {
2382
- for (const conn of conns) {
2383
- if (!conn.destroyed) conn.destroy();
2384
- }
2385
- return;
2386
- }
2387
- for (const conn of conns) {
2388
- if (!conn.destroyed) pipeToTarget(conn);
2389
- }
2390
- }
2391
- const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
2392
- server.listen(listenPort, "0.0.0.0");
2393
- scheduleIdleCheck();
2394
- return {
2395
- server,
2396
- resetTimer: bumpActivity,
2397
- destroy: () => {
2398
- if (idleTimer) clearTimeout(idleTimer);
2399
- pendingConns.forEach((s) => s.destroy());
2400
- activeConns.forEach((s) => s.destroy());
2401
- server.close();
2402
- }
2403
- };
2404
- }
2405
-
2406
- // src/process/external.ts
2407
- import { spawn as spawn4 } from "child_process";
2408
- import { join as join7 } from "path";
2409
- var DEFAULT_START_TIMEOUT_S = 60;
2410
- async function startExternals(externals, opts) {
2411
- const procs = [];
2412
- const failed = [];
2413
- for (const svc of externals) {
2414
- const proc = spawnExternal(svc, opts);
2415
- procs.push({ svc, proc, pid: proc.pid ?? null });
2416
- if (!svc.healthCheck) {
2417
- opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
2418
- continue;
2419
- }
2420
- if (svc.healthCheck.type === "tcp" && !svc.port) {
2421
- opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
2422
- continue;
2423
- }
2424
- const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
2425
- const ok = await waitHealthy(svc, timeoutMs);
2426
- if (ok) {
2427
- opts.onLog?.(svc.name, "\u2705 healthy");
2428
- } else {
2429
- opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
2430
- failed.push(svc.name);
2431
- }
2432
- }
2433
- return { procs, allHealthy: failed.length === 0, failed };
2434
- }
2435
- async function stopExternals(procs, platform, opts = {}) {
2436
- for (const { svc, proc, pid } of procs) {
2437
- try {
2438
- if (pid) platform.killTree(pid);
2439
- if (svc.stopCmd) {
2440
- opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
2441
- await new Promise((resolve4) => {
2442
- const isWin = process.platform === "win32";
2443
- const shell = isWin ? "cmd.exe" : "sh";
2444
- const flag = isWin ? "/c" : "-c";
2445
- const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
2446
- const env = { ...opts.env, ...svc.extraEnv ?? {} };
2447
- const child = spawn4(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
2448
- child.on("close", () => resolve4());
2449
- child.on("error", () => resolve4());
2450
- setTimeout(() => resolve4(), 1e4);
2451
- });
2452
- }
2453
- } catch {
2454
- }
2455
- void proc;
2456
- }
2457
- }
2458
- function spawnExternal(svc, opts) {
2459
- const isWin = process.platform === "win32";
2460
- const shell = isWin ? "cmd.exe" : "sh";
2461
- const flag = isWin ? "/c" : "-c";
2462
- const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
2463
- const env = { ...opts.env, ...svc.extraEnv ?? {} };
2464
- opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
2465
- const child = spawn4(shell, [flag, svc.cmd], {
2466
- cwd,
2467
- env,
2468
- detached: true,
2469
- stdio: ["ignore", "pipe", "pipe"]
2470
- });
2471
- child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2472
- child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2473
- child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
2474
- return child;
2475
- }
2476
- async function waitHealthy(svc, timeoutMs) {
2477
- const deadline = Date.now() + timeoutMs;
2478
- const port = svc.port;
2479
- while (Date.now() < deadline) {
2480
- if (await checkHealth(port, svc.healthCheck)) return true;
2481
- await new Promise((r) => setTimeout(r, 500));
2482
- }
2483
- return false;
2484
- }
2485
-
2486
- // src/tui/hooks/useBootSequence.ts
2487
3284
  function useBootSequence(manager, config, services, cliArgs, platform, env, baseCwd, refs, pushLog) {
2488
3285
  const [booted, setBooted] = useState6(false);
2489
3286
  useEffect9(() => {
@@ -2813,7 +3610,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2813
3610
  onToggleProxy: () => {
2814
3611
  }
2815
3612
  });
2816
- const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog);
3613
+ const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog, pm.logBus, pm.stateBus);
2817
3614
  const shutdown = useCallback3(async () => {
2818
3615
  lazyProxies.current.forEach((p) => p.destroy());
2819
3616
  await socketServer.current?.close();
@@ -2916,61 +3713,6 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2916
3713
  ] });
2917
3714
  }
2918
3715
 
2919
- // src/process/log-sink.ts
2920
- import { existsSync as existsSync13, mkdirSync as mkdirSync5, renameSync, createWriteStream } from "fs";
2921
- import { join as join8, dirname as dirname6 } from "path";
2922
- import { homedir as homedir3 } from "os";
2923
- var LogSink = class {
2924
- dir;
2925
- rotateOnStart;
2926
- streams = /* @__PURE__ */ new Map();
2927
- seen = /* @__PURE__ */ new Set();
2928
- constructor(opts) {
2929
- const root = opts.rootDir ?? join8(homedir3(), ".devup", "logs");
2930
- this.dir = join8(root, sanitize2(opts.projectName));
2931
- this.rotateOnStart = opts.rotateOnStart ?? true;
2932
- mkdirSync5(this.dir, { recursive: true });
2933
- }
2934
- /** Returns the file path for a service log (useful for tests / UI). */
2935
- pathFor(svcName) {
2936
- return join8(this.dir, `${sanitize2(svcName)}.log`);
2937
- }
2938
- write(svcName, line) {
2939
- const stream = this.streamFor(svcName);
2940
- stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
2941
- `);
2942
- }
2943
- async close() {
2944
- const closes = [...this.streams.values()].map(
2945
- (s) => new Promise((r) => s.end(() => r()))
2946
- );
2947
- this.streams.clear();
2948
- this.seen.clear();
2949
- await Promise.all(closes);
2950
- }
2951
- streamFor(svcName) {
2952
- let s = this.streams.get(svcName);
2953
- if (s) return s;
2954
- const file = this.pathFor(svcName);
2955
- if (this.rotateOnStart && !this.seen.has(svcName) && existsSync13(file)) {
2956
- try {
2957
- mkdirSync5(dirname6(file), { recursive: true });
2958
- renameSync(file, file + ".prev");
2959
- } catch {
2960
- }
2961
- }
2962
- this.seen.add(svcName);
2963
- s = createWriteStream(file, { flags: "a" });
2964
- s.on("error", () => {
2965
- });
2966
- this.streams.set(svcName, s);
2967
- return s;
2968
- }
2969
- };
2970
- function sanitize2(name) {
2971
- return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
2972
- }
2973
-
2974
3716
  // src/orchestrator/dry-run.ts
2975
3717
  function renderDryRun(opts) {
2976
3718
  const { config, services, cliArgs, env, proxyProvider, proxyOpts } = opts;
@@ -3141,8 +3883,8 @@ function defineConfig(config) {
3141
3883
  function readVersion() {
3142
3884
  try {
3143
3885
  const here = dirname7(fileURLToPath2(import.meta.url));
3144
- const pkgPath = join9(here, "..", "package.json");
3145
- return JSON.parse(readFileSync3(pkgPath, "utf8")).version ?? "unknown";
3886
+ const pkgPath = join10(here, "..", "package.json");
3887
+ return JSON.parse(readFileSync4(pkgPath, "utf8")).version ?? "unknown";
3146
3888
  } catch {
3147
3889
  return "unknown";
3148
3890
  }
@@ -3177,6 +3919,8 @@ async function main() {
3177
3919
  if (subcmd === "logs") process.exit(await runLogs(subArgs, subOpts));
3178
3920
  if (subcmd === "install") process.exit(await runInstall(subOpts));
3179
3921
  if (subcmd === "status") process.exit(await runStatus(subOpts));
3922
+ if (subcmd === "ctl") process.exit(await runCtl(subArgs, subOpts));
3923
+ if (subcmd === "down") process.exit(await runDown(subOpts));
3180
3924
  }
3181
3925
  let configPath;
3182
3926
  try {
@@ -3209,7 +3953,7 @@ ${formatValidationWarnings(warnings)}`);
3209
3953
  process.exit(1);
3210
3954
  }
3211
3955
  const platform = await detectPlatform();
3212
- const envFile = config.envFile ? join9(cwd, config.envFile) : join9(cwd, ".env");
3956
+ const envFile = config.envFile ? join10(cwd, config.envFile) : join10(cwd, ".env");
3213
3957
  const env = parseEnvFile(envFile, process.env);
3214
3958
  if (config.env) {
3215
3959
  for (const [k, v] of Object.entries(config.env)) {
@@ -3226,7 +3970,7 @@ ${formatValidationWarnings(warnings)}`);
3226
3970
  routes: config.proxy.routes,
3227
3971
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
3228
3972
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
3229
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join9(homedir4(), ".traefik", "traefik_conf.yaml")
3973
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join10(homedir5(), ".traefik", "traefik_conf.yaml")
3230
3974
  };
3231
3975
  }
3232
3976
  if (cliArgs.dryRun) {
@@ -3250,6 +3994,36 @@ ${formatValidationWarnings(warnings)}`);
3250
3994
  await logSink?.close();
3251
3995
  process.exit(code);
3252
3996
  }
3997
+ if (process.env.DEVUP_DAEMON_CHILD === "1") {
3998
+ await daemonBody({ config, services, cliArgs, platform, env, baseCwd: cwd, proxyProvider, proxyOpts });
3999
+ return;
4000
+ }
4001
+ if (subcmd === "up") {
4002
+ if (!raw.includes("-d") && !raw.includes("--detach")) {
4003
+ console.error("usage: devup up -d (use plain `devup` for the TUI)");
4004
+ process.exit(1);
4005
+ }
4006
+ process.exit(await runDetached({
4007
+ config,
4008
+ services,
4009
+ cliArgs,
4010
+ platform,
4011
+ env,
4012
+ baseCwd: cwd,
4013
+ proxyProvider,
4014
+ proxyOpts
4015
+ }));
4016
+ }
4017
+ const daemonStatus = isDaemonRunning(config.name);
4018
+ if (daemonStatus.pid && !daemonStatus.stale) {
4019
+ console.error(`\u274C A devup daemon is already running for "${config.name}" (pid=${daemonStatus.pid}).`);
4020
+ console.error("");
4021
+ console.error("Stop it first with `devup down`, or interact via the control plane:");
4022
+ console.error(" devup ctl status");
4023
+ console.error(" devup ctl logs <svc> --follow");
4024
+ console.error(" devup ctl restart <svc>");
4025
+ process.exit(1);
4026
+ }
3253
4027
  const isInteractive = process.stdin.isTTY ?? false;
3254
4028
  const { waitUntilExit } = render(
3255
4029
  React7.createElement(App, {