@gachlab/devup 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,8 +17,9 @@ export interface CliArgs {
17
17
  onceTimeout: number;
18
18
  logFile: boolean;
19
19
  logDir?: string;
20
+ watchConfig: boolean;
20
21
  }
21
- export declare const USAGE = "devup \u2014 terminal UI dev stack runner\n\nUsage: devup [options]\n\nService selection:\n --only apis | webs Start only APIs or only webs\n --services a,b,c Start only the named services\n --profile <name> Start the services in a named profile (see ROADMAP)\n --skip a,b,c Start everything except these\n --config <path> Use a custom config file\n\nLazy mode:\n --lazy Enable lazy mode (default)\n --no-lazy Start every service immediately\n --timeout <minutes> Idle timeout for lazy services. Default: 10\n\nReverse proxy:\n --proxy Enable proxy config generation\n --proxy-host <host> Override the target host (Docker/local)\n --proxy-conf <path> Override the generated config file path\n --proxy-tls Enable TLS in the generated config (default)\n --no-proxy-tls Disable TLS\n --proxy-entrypoint <n> Override entrypoint name (Traefik only)\n\nCI / scripting:\n --dry-run Print the resolved boot plan and exit\n --once Boot, wait for readiness, exit 0/1 (no TUI)\n --once-timeout <s> Max seconds to wait in --once mode. Default: 90\n\nLog files:\n --no-log-file Disable persistent log files\n --log-dir <path> Override log root (default: ~/.devup/logs)\n\nOther:\n -h, --help Show this help and exit\n -v, --version Show version and exit\n\nSee https://github.com/gachlab/devup for the full documentation.";
22
+ export declare const USAGE = "devup \u2014 terminal UI dev stack runner\n\nUsage: devup [options]\n\nService selection:\n --only apis | webs Start only APIs or only webs\n --services a,b,c Start only the named services\n --profile <name> Start the services in a named profile (see ROADMAP)\n --skip a,b,c Start everything except these\n --config <path> Use a custom config file\n\nLazy mode:\n --lazy Enable lazy mode (default)\n --no-lazy Start every service immediately\n --timeout <minutes> Idle timeout for lazy services. Default: 10\n\nReverse proxy:\n --proxy Enable proxy config generation\n --proxy-host <host> Override the target host (Docker/local)\n --proxy-conf <path> Override the generated config file path\n --proxy-tls Enable TLS in the generated config (default)\n --no-proxy-tls Disable TLS\n --proxy-entrypoint <n> Override entrypoint name (Traefik only)\n\nCI / scripting:\n --dry-run Print the resolved boot plan and exit\n --once Boot, wait for readiness, exit 0/1 (no TUI)\n --once-timeout <s> Max seconds to wait in --once mode. Default: 90\n\nLog files:\n --no-log-file Disable persistent log files\n --log-dir <path> Override log root (default: ~/.devup/logs)\n\nHot reload:\n --watch-config Watch devup.config.* and apply add/remove/restart\n service changes without exiting the TUI\n\nOther:\n -h, --help Show this help and exit\n -v, --version Show version and exit\n\nSee https://github.com/gachlab/devup for the full documentation.";
22
23
  export declare function parseCliArgs(argv: string[]): CliArgs;
23
24
  export declare function filterServices(services: ServiceConfig[], args: CliArgs, config?: Pick<DevStackConfig, 'profiles'>): ServiceConfig[];
24
25
  //# sourceMappingURL=cli.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/config/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,OAAO;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAKD,eAAO,MAAM,KAAK,8gDAqC+C,CAAC;AAElE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CA0CpD;AAED,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,aAAa,EAAE,EACzB,IAAI,EAAE,OAAO,EACb,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,UAAU,CAAC,GACxC,aAAa,EAAE,CA6BjB"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/config/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,OAAO;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;CACtB;AAKD,eAAO,MAAM,KAAK,+qDAyC+C,CAAC;AAElE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CA4CpD;AAED,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,aAAa,EAAE,EACzB,IAAI,EAAE,OAAO,EACb,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,UAAU,CAAC,GACxC,aAAa,EAAE,CA6BjB"}
@@ -0,0 +1,19 @@
1
+ import type { ServiceConfig } from './types.js';
2
+ export interface ServiceDiff {
3
+ added: ServiceConfig[];
4
+ removed: string[];
5
+ changed: Array<{
6
+ next: ServiceConfig;
7
+ prev: ServiceConfig;
8
+ }>;
9
+ unchanged: string[];
10
+ }
11
+ /** Computes the set-difference between two service lists by name.
12
+ * - added: in `next` but not in `prev`
13
+ * - removed: in `prev` but not in `next`
14
+ * - changed: in both but with a spawn-relevant field change
15
+ * - unchanged: in both with identical spawn-relevant fields */
16
+ export declare function diffServices(prev: ServiceConfig[], next: ServiceConfig[]): ServiceDiff;
17
+ /** Short human-readable summary for the TUI banner. */
18
+ export declare function summariseDiff(d: ServiceDiff): string;
19
+ //# sourceMappingURL=diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/config/diff.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,IAAI,EAAE,aAAa,CAAA;KAAE,CAAC,CAAC;IAC7D,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAeD;;;;gEAIgE;AAChE,wBAAgB,YAAY,CAAC,IAAI,EAAE,aAAa,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,GAAG,WAAW,CAmBtF;AAED,uDAAuD;AACvD,wBAAgB,aAAa,CAAC,CAAC,EAAE,WAAW,GAAG,MAAM,CAOpD"}
@@ -3,6 +3,15 @@ export interface ValidationError {
3
3
  field: string;
4
4
  message: string;
5
5
  }
6
+ export interface ValidationWarning {
7
+ field: string;
8
+ message: string;
9
+ }
10
+ /** Collects non-blocking warnings: things that look suspicious but don't justify
11
+ * refusing to start the stack. Run alongside `validateConfig` from the CLI entry
12
+ * point; print them and continue. */
13
+ export declare function collectWarnings(config: DevStackConfig): ValidationWarning[];
14
+ export declare function formatValidationWarnings(warnings: ValidationWarning[]): string;
6
15
  export declare function validateConfig(config: DevStackConfig, cwd: string): ValidationError[];
7
16
  export declare function formatValidationErrors(errors: ValidationError[]): string;
8
17
  //# sourceMappingURL=validator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../../src/config/validator.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,EAAE,CA0LrF;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,CAExE"}
1
+ {"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../../src/config/validator.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;sCAEsC;AACtC,wBAAgB,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,iBAAiB,EAAE,CAmB3E;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,iBAAiB,EAAE,GAAG,MAAM,CAE9E;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,EAAE,CA0LrF;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,CAExE"}
@@ -0,0 +1,31 @@
1
+ import { type Server } from 'node:net';
2
+ import type { ProcessState } from '../process/types.js';
3
+ /** Minimal JSON-RPC-like protocol over a local Unix socket.
4
+ * Request ─► { id?, method, params? } newline-terminated JSON
5
+ * Response ─► { id?, result | error } newline-terminated JSON
6
+ *
7
+ * Auth model: unix socket created with `chmod 0600`. Anyone with read access
8
+ * to the socket file already has the same uid as the devup process — no
9
+ * additional auth needed. Strictly local; TCP exposure is intentionally
10
+ * out of scope. */
11
+ export interface RpcContext {
12
+ /** State of every service (read-only snapshot). */
13
+ states(): Map<string, ProcessState>;
14
+ /** Restart a service by name. */
15
+ restart(name: string): Promise<void>;
16
+ /** Stop a service by name. */
17
+ stop(name: string): void;
18
+ /** Tail N most recent log lines for the given service (from the persistent log file). */
19
+ tailLogs(svcName: string, lines: number): Promise<string[]>;
20
+ }
21
+ export interface SocketServerHandle {
22
+ server: Server;
23
+ path: string;
24
+ close(): Promise<void>;
25
+ }
26
+ export declare function defaultSocketPath(projectName: string): string;
27
+ export declare function startSocketServer(projectName: string, ctx: RpcContext, opts?: {
28
+ path?: string;
29
+ onLog?: (msg: string) => void;
30
+ }): Promise<SocketServerHandle>;
31
+ //# sourceMappingURL=socket-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket-server.d.ts","sourceRoot":"","sources":["../../src/control-plane/socket-server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,MAAM,EAAe,MAAM,UAAU,CAAC;AAMlE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD;;;;;;;oBAOoB;AAEpB,MAAM,WAAW,UAAU;IACzB,mDAAmD;IACnD,MAAM,IAAI,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IACpC,iCAAiC;IACjC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,8BAA8B;IAC9B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,yFAAyF;IACzF,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAC7D;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAG7D;AAED,wBAAsB,iBAAiB,CACrC,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,UAAU,EACf,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAC1D,OAAO,CAAC,kBAAkB,CAAC,CAiC7B"}
package/dist/index.js CHANGED
@@ -4,9 +4,9 @@
4
4
  import React7 from "react";
5
5
  import { render } from "ink";
6
6
  import { readFileSync as readFileSync2 } from "fs";
7
- import { dirname as dirname6, join as join7 } from "path";
7
+ import { dirname as dirname7, join as join8 } from "path";
8
8
  import { fileURLToPath as fileURLToPath2 } from "url";
9
- import { homedir as homedir3 } from "os";
9
+ import { homedir as homedir4 } from "os";
10
10
 
11
11
  // src/config/loader.ts
12
12
  import { existsSync } from "fs";
@@ -74,6 +74,26 @@ function rewriteServicePort(svc) {
74
74
  }
75
75
 
76
76
  // src/config/validator.ts
77
+ function collectWarnings(config) {
78
+ const warnings = [];
79
+ if (!config.services?.length) return warnings;
80
+ for (const svc of config.services) {
81
+ const ep = svc.extraEnv?.["PORT"];
82
+ if (ep !== void 0) {
83
+ const expected = String(svc.port);
84
+ if (ep !== expected) {
85
+ warnings.push({
86
+ field: `services[${svc.name}].extraEnv.PORT`,
87
+ message: `extraEnv.PORT="${ep}" does not match port=${svc.port}. devup will health-check :${svc.port} but the service will probably bind to :${ep}.`
88
+ });
89
+ }
90
+ }
91
+ }
92
+ return warnings;
93
+ }
94
+ function formatValidationWarnings(warnings) {
95
+ return warnings.map((w) => ` \u26A0 ${w.field}: ${w.message}`).join("\n");
96
+ }
77
97
  function validateConfig(config, cwd) {
78
98
  const errors = [];
79
99
  if (!config.name?.trim()) {
@@ -272,6 +292,10 @@ Log files:
272
292
  --no-log-file Disable persistent log files
273
293
  --log-dir <path> Override log root (default: ~/.devup/logs)
274
294
 
295
+ Hot reload:
296
+ --watch-config Watch devup.config.* and apply add/remove/restart
297
+ service changes without exiting the TUI
298
+
275
299
  Other:
276
300
  -h, --help Show this help and exit
277
301
  -v, --version Show version and exit
@@ -288,7 +312,8 @@ function parseCliArgs(argv) {
288
312
  dryRun: false,
289
313
  once: false,
290
314
  onceTimeout: DEFAULT_ONCE_TIMEOUT,
291
- logFile: true
315
+ logFile: true,
316
+ watchConfig: false
292
317
  };
293
318
  for (let i = 0; i < argv.length; i++) {
294
319
  const arg = argv[i];
@@ -362,6 +387,9 @@ function parseCliArgs(argv) {
362
387
  args.logDir = next;
363
388
  i++;
364
389
  break;
390
+ case "--watch-config":
391
+ args.watchConfig = true;
392
+ break;
365
393
  }
366
394
  }
367
395
  return args;
@@ -1550,7 +1578,14 @@ function useProxySync(provider, opts, states, enabled) {
1550
1578
  import { useEffect as useEffect3, useMemo } from "react";
1551
1579
  import { Box, Text } from "ink";
1552
1580
  import { jsx, jsxs } from "react/jsx-runtime";
1553
- function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll, levelFilter = "all" }) {
1581
+ function resolveBorder(focused, filter, filteredColorIdx) {
1582
+ if (focused) return "cyan";
1583
+ if (filter && filteredColorIdx !== null && filteredColorIdx >= 0) {
1584
+ return tagColors[filteredColorIdx % tagColors.length];
1585
+ }
1586
+ return "gray";
1587
+ }
1588
+ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll, levelFilter = "all", filteredColorIdx = null }) {
1554
1589
  const byService = filter ? logs.filter((l) => l.svcName === filter) : logs;
1555
1590
  const filtered = levelFilter === "all" ? byService : levelFilter === "error" ? byService.filter((l) => l.level === "error") : byService.filter((l) => l.level === "error" || l.level === "warn");
1556
1591
  const contentHeight = Math.max(1, height - 2);
@@ -1576,7 +1611,7 @@ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLe
1576
1611
  `${filtered.length} lines`,
1577
1612
  focused && totalLines > 0 ? `(${startIndex + 1}-${endIndex}/${totalLines})` : ""
1578
1613
  ].filter(Boolean).join(" ");
1579
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "cyan" : "gray", height, children: [
1614
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: resolveBorder(focused, filter, filteredColorIdx), height, children: [
1580
1615
  /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
1581
1616
  " ",
1582
1617
  label,
@@ -2148,6 +2183,186 @@ function pickTip(state) {
2148
2183
  return null;
2149
2184
  }
2150
2185
 
2186
+ // src/control-plane/socket-server.ts
2187
+ import { createServer } from "net";
2188
+ import { createInterface as createInterface2 } from "readline";
2189
+ import { existsSync as existsSync10, unlinkSync, chmodSync, mkdirSync as mkdirSync4, statSync as statSync2 } from "fs";
2190
+ import { dirname as dirname5 } from "path";
2191
+ import { join as join6 } from "path";
2192
+ import { homedir as homedir2 } from "os";
2193
+ function defaultSocketPath(projectName) {
2194
+ const safe = projectName.replace(/[^a-zA-Z0-9._-]+/g, "_") || "devup";
2195
+ return join6(homedir2(), ".devup", `sock-${safe}.sock`);
2196
+ }
2197
+ async function startSocketServer(projectName, ctx, opts = {}) {
2198
+ const path = opts.path ?? defaultSocketPath(projectName);
2199
+ mkdirSync4(dirname5(path), { recursive: true });
2200
+ if (existsSync10(path)) {
2201
+ try {
2202
+ const st = statSync2(path);
2203
+ if (st.isSocket()) unlinkSync(path);
2204
+ } catch {
2205
+ }
2206
+ }
2207
+ const server = createServer((socket) => handleClient(socket, ctx));
2208
+ await new Promise((resolve4, reject) => {
2209
+ server.once("error", reject);
2210
+ server.listen(path, () => {
2211
+ server.off("error", reject);
2212
+ try {
2213
+ chmodSync(path, 384);
2214
+ } catch {
2215
+ }
2216
+ opts.onLog?.(`\u{1F50C} control plane at ${path}`);
2217
+ resolve4();
2218
+ });
2219
+ });
2220
+ return {
2221
+ server,
2222
+ path,
2223
+ async close() {
2224
+ await new Promise((resolve4) => server.close(() => resolve4()));
2225
+ if (existsSync10(path)) {
2226
+ try {
2227
+ unlinkSync(path);
2228
+ } catch {
2229
+ }
2230
+ }
2231
+ }
2232
+ };
2233
+ }
2234
+ function handleClient(socket, ctx) {
2235
+ const rl = createInterface2({ input: socket });
2236
+ rl.on("line", async (line) => {
2237
+ if (!line.trim()) return;
2238
+ let req;
2239
+ try {
2240
+ req = JSON.parse(line);
2241
+ } catch (e) {
2242
+ respond(socket, { error: { code: -32700, message: `parse error: ${e.message}` } });
2243
+ return;
2244
+ }
2245
+ if (typeof req.method !== "string") {
2246
+ respond(socket, { id: req.id, error: { code: -32600, message: "method required" } });
2247
+ return;
2248
+ }
2249
+ try {
2250
+ const result = await dispatch(req.method, req.params ?? {}, ctx);
2251
+ respond(socket, { id: req.id, result });
2252
+ } catch (e) {
2253
+ respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
2254
+ }
2255
+ });
2256
+ socket.on("error", () => {
2257
+ });
2258
+ }
2259
+ function respond(socket, payload) {
2260
+ if (socket.writable) socket.write(JSON.stringify(payload) + "\n");
2261
+ }
2262
+ async function dispatch(method, params, ctx) {
2263
+ switch (method) {
2264
+ case "status": {
2265
+ const out = [];
2266
+ for (const [name, st] of ctx.states()) {
2267
+ out.push({
2268
+ name,
2269
+ status: st.status,
2270
+ health: st.health,
2271
+ port: st.svc.port,
2272
+ type: st.svc.type,
2273
+ errors: st.errors,
2274
+ restarts: st.restarts,
2275
+ pid: st.pid,
2276
+ startedAt: st.startedAt
2277
+ });
2278
+ }
2279
+ return { services: out };
2280
+ }
2281
+ case "restart": {
2282
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
2283
+ await ctx.restart(svc);
2284
+ return { ok: true };
2285
+ }
2286
+ case "stop": {
2287
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
2288
+ ctx.stop(svc);
2289
+ return { ok: true };
2290
+ }
2291
+ case "logs.tail": {
2292
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
2293
+ const lines = Math.max(1, Math.min(1e4, Number(params["lines"] ?? 100)));
2294
+ return { lines: await ctx.tailLogs(svc, lines) };
2295
+ }
2296
+ case "ping":
2297
+ return { ok: true, ts: Date.now() };
2298
+ default:
2299
+ throw new Error(`unknown method: ${method}`);
2300
+ }
2301
+ }
2302
+ function stringOrThrow(v, paramName) {
2303
+ if (typeof v !== "string" || !v.trim()) {
2304
+ throw new Error(`param "${paramName}" must be a non-empty string`);
2305
+ }
2306
+ return v;
2307
+ }
2308
+
2309
+ // src/tui/App.tsx
2310
+ import { createInterface as createInterface3 } from "readline";
2311
+ import { createReadStream as createReadStream2, existsSync as existsSync11, watch as fsWatch } from "fs";
2312
+
2313
+ // src/config/diff.ts
2314
+ var SPAWN_RELEVANT = [
2315
+ "cwd",
2316
+ "cmd",
2317
+ "args",
2318
+ "port",
2319
+ "phase",
2320
+ "maxMem",
2321
+ "preBuild",
2322
+ "watchBuild",
2323
+ "nodeArgs",
2324
+ "extraEnv",
2325
+ "healthCheck",
2326
+ "readyPattern",
2327
+ "errorPattern",
2328
+ "type"
2329
+ ];
2330
+ function hasSpawnRelevantChange(prev, next) {
2331
+ for (const k of SPAWN_RELEVANT) {
2332
+ if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return true;
2333
+ }
2334
+ return false;
2335
+ }
2336
+ function diffServices(prev, next) {
2337
+ const prevByName = new Map(prev.map((s) => [s.name, s]));
2338
+ const nextByName = new Map(next.map((s) => [s.name, s]));
2339
+ const added = [];
2340
+ const removed = [];
2341
+ const changed = [];
2342
+ const unchanged = [];
2343
+ for (const [name, p] of prevByName) {
2344
+ if (!nextByName.has(name)) {
2345
+ removed.push(name);
2346
+ continue;
2347
+ }
2348
+ const n = nextByName.get(name);
2349
+ if (hasSpawnRelevantChange(p, n)) changed.push({ prev: p, next: n });
2350
+ else unchanged.push(name);
2351
+ }
2352
+ for (const [name, n] of nextByName) {
2353
+ if (!prevByName.has(name)) added.push(n);
2354
+ }
2355
+ return { added, removed, changed, unchanged };
2356
+ }
2357
+ function summariseDiff(d) {
2358
+ const parts = [];
2359
+ if (d.added.length) parts.push(`+${d.added.length} added`);
2360
+ if (d.removed.length) parts.push(`-${d.removed.length} removed`);
2361
+ if (d.changed.length) parts.push(`~${d.changed.length} changed`);
2362
+ if (!parts.length) parts.push("no changes");
2363
+ return parts.join(", ");
2364
+ }
2365
+
2151
2366
  // src/tui/App.tsx
2152
2367
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2153
2368
  function buildServiceUrl(name, port, proxyActive, proxyOpts) {
@@ -2179,6 +2394,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2179
2394
  const [booted, setBooted] = useState6(false);
2180
2395
  const lazyProxies = useRef3(/* @__PURE__ */ new Map());
2181
2396
  const externals = useRef3([]);
2397
+ const socketServer = useRef3(null);
2182
2398
  const shownTips = useRef3(/* @__PURE__ */ new Set());
2183
2399
  const [activeTip, setActiveTip] = useState6(null);
2184
2400
  const kb = useKeyBindings({
@@ -2191,6 +2407,8 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2191
2407
  });
2192
2408
  const shutdown = useCallback3(async () => {
2193
2409
  lazyProxies.current.forEach((p) => p.destroy());
2410
+ await socketServer.current?.close();
2411
+ socketServer.current = null;
2194
2412
  await pm.cleanup();
2195
2413
  if (externals.current.length) {
2196
2414
  await stopExternals(externals.current, platform, {
@@ -2203,6 +2421,110 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2203
2421
  await logSink?.close();
2204
2422
  process.exit(0);
2205
2423
  }, [pm, logSink, platform, baseCwd, env]);
2424
+ useEffect5(() => {
2425
+ if (!pm.manager) return;
2426
+ let handle = null;
2427
+ (async () => {
2428
+ try {
2429
+ handle = await startSocketServer(config.name, {
2430
+ states: () => pm.manager.state,
2431
+ restart: (name) => pm.manager.restart(name),
2432
+ stop: (name) => pm.manager.stop(name),
2433
+ tailLogs: async (svcName, lines) => {
2434
+ if (!logSink) return [];
2435
+ const file = logSink.pathFor(svcName);
2436
+ if (!existsSync11(file)) return [];
2437
+ return new Promise((resolve4, reject) => {
2438
+ const buf = [];
2439
+ const rl = createInterface3({ input: createReadStream2(file, { encoding: "utf8" }) });
2440
+ rl.on("line", (l) => {
2441
+ buf.push(l);
2442
+ if (buf.length > lines) buf.shift();
2443
+ });
2444
+ rl.on("close", () => resolve4(buf));
2445
+ rl.on("error", reject);
2446
+ });
2447
+ }
2448
+ }, { onLog: (msg) => pm.pushLog("devup", msg, 12) });
2449
+ socketServer.current = handle;
2450
+ } catch (e) {
2451
+ pm.pushLog("devup", `\u26A0 control plane disabled: ${e.message}`, 5);
2452
+ }
2453
+ })();
2454
+ return () => {
2455
+ void handle?.close();
2456
+ };
2457
+ }, [pm.manager, config.name, logSink]);
2458
+ useEffect5(() => {
2459
+ if (!cliArgs.watchConfig || !pm.manager) return;
2460
+ let watcher = null;
2461
+ let configPath;
2462
+ try {
2463
+ configPath = findConfigFile(baseCwd, cliArgs.configPath);
2464
+ } catch (e) {
2465
+ pm.pushLog("devup", `\u26A0 watch-config disabled: ${e.message}`, 5);
2466
+ return;
2467
+ }
2468
+ pm.pushLog("devup", `\u{1F440} watching ${configPath}`, 12);
2469
+ let reloadInFlight = false;
2470
+ let reloadAgain = false;
2471
+ const reload = async () => {
2472
+ if (reloadInFlight) {
2473
+ reloadAgain = true;
2474
+ return;
2475
+ }
2476
+ reloadInFlight = true;
2477
+ try {
2478
+ const nextCfg = await loadConfig(configPath);
2479
+ const errs = validateConfig(nextCfg, baseCwd);
2480
+ if (errs.length) {
2481
+ pm.pushLog("devup", `\u26A0 config reload failed:
2482
+ ${formatValidationErrors(errs)}`, 5);
2483
+ return;
2484
+ }
2485
+ const mgr = pm.manager;
2486
+ const currentSvcs = [...mgr.state.values()].map((s) => s.svc);
2487
+ const diff = diffServices(currentSvcs, nextCfg.services);
2488
+ if (!diff.added.length && !diff.removed.length && !diff.changed.length) return;
2489
+ for (const name of diff.removed) {
2490
+ mgr.stop(name);
2491
+ mgr.state.delete(name);
2492
+ }
2493
+ let colorIdx = currentSvcs.length;
2494
+ for (const { next } of diff.changed) {
2495
+ const prev = mgr.state.get(next.name);
2496
+ const ci = prev?.colorIdx ?? colorIdx++;
2497
+ mgr.stop(next.name);
2498
+ await new Promise((r) => setTimeout(r, 800));
2499
+ await mgr.install(next, ci);
2500
+ await mgr.start(next, ci, true);
2501
+ }
2502
+ for (const next of diff.added) {
2503
+ const ci = colorIdx++;
2504
+ await mgr.install(next, ci);
2505
+ await mgr.start(next, ci);
2506
+ }
2507
+ pm.pushLog("devup", `\u{1F501} config reloaded: ${summariseDiff(diff)}`, 12);
2508
+ } catch (e) {
2509
+ pm.pushLog("devup", `\u26A0 config reload error: ${e.message}`, 5);
2510
+ } finally {
2511
+ reloadInFlight = false;
2512
+ if (reloadAgain) {
2513
+ reloadAgain = false;
2514
+ void reload();
2515
+ }
2516
+ }
2517
+ };
2518
+ let debounceTimer = null;
2519
+ watcher = fsWatch(configPath, () => {
2520
+ if (debounceTimer) clearTimeout(debounceTimer);
2521
+ debounceTimer = setTimeout(() => void reload(), 250);
2522
+ });
2523
+ return () => {
2524
+ if (debounceTimer) clearTimeout(debounceTimer);
2525
+ watcher?.close();
2526
+ };
2527
+ }, [cliArgs.watchConfig, cliArgs.configPath, baseCwd, pm.manager, pm]);
2206
2528
  useEffect5(() => {
2207
2529
  pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
2208
2530
  }, [kb.logsPaused, kb.logsScrollOffset, pm]);
@@ -2374,7 +2696,8 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2374
2696
  focused: kb.panel === "logs",
2375
2697
  scrollOffset: kb.logsScrollOffset,
2376
2698
  resetScroll: kb.resetLogsScroll,
2377
- levelFilter: kb.levelFilter
2699
+ levelFilter: kb.levelFilter,
2700
+ filteredColorIdx: kb.logFilter ? pm.states.get(kb.logFilter)?.colorIdx ?? null : null
2378
2701
  }
2379
2702
  ),
2380
2703
  /* @__PURE__ */ jsx6(
@@ -2400,23 +2723,23 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2400
2723
  }
2401
2724
 
2402
2725
  // src/process/log-sink.ts
2403
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
2404
- import { join as join6, dirname as dirname5 } from "path";
2405
- import { homedir as homedir2 } from "os";
2726
+ import { existsSync as existsSync12, mkdirSync as mkdirSync5, renameSync, createWriteStream } from "fs";
2727
+ import { join as join7, dirname as dirname6 } from "path";
2728
+ import { homedir as homedir3 } from "os";
2406
2729
  var LogSink = class {
2407
2730
  dir;
2408
2731
  rotateOnStart;
2409
2732
  streams = /* @__PURE__ */ new Map();
2410
2733
  seen = /* @__PURE__ */ new Set();
2411
2734
  constructor(opts) {
2412
- const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
2413
- this.dir = join6(root, sanitize2(opts.projectName));
2735
+ const root = opts.rootDir ?? join7(homedir3(), ".devup", "logs");
2736
+ this.dir = join7(root, sanitize2(opts.projectName));
2414
2737
  this.rotateOnStart = opts.rotateOnStart ?? true;
2415
- mkdirSync4(this.dir, { recursive: true });
2738
+ mkdirSync5(this.dir, { recursive: true });
2416
2739
  }
2417
2740
  /** Returns the file path for a service log (useful for tests / UI). */
2418
2741
  pathFor(svcName) {
2419
- return join6(this.dir, `${sanitize2(svcName)}.log`);
2742
+ return join7(this.dir, `${sanitize2(svcName)}.log`);
2420
2743
  }
2421
2744
  write(svcName, line) {
2422
2745
  const stream = this.streamFor(svcName);
@@ -2435,9 +2758,9 @@ var LogSink = class {
2435
2758
  let s = this.streams.get(svcName);
2436
2759
  if (s) return s;
2437
2760
  const file = this.pathFor(svcName);
2438
- if (this.rotateOnStart && !this.seen.has(svcName) && existsSync10(file)) {
2761
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync12(file)) {
2439
2762
  try {
2440
- mkdirSync4(dirname5(file), { recursive: true });
2763
+ mkdirSync5(dirname6(file), { recursive: true });
2441
2764
  renameSync(file, file + ".prev");
2442
2765
  } catch {
2443
2766
  }
@@ -2623,8 +2946,8 @@ function defineConfig(config) {
2623
2946
  // src/index.ts
2624
2947
  function readVersion() {
2625
2948
  try {
2626
- const here = dirname6(fileURLToPath2(import.meta.url));
2627
- const pkgPath = join7(here, "..", "package.json");
2949
+ const here = dirname7(fileURLToPath2(import.meta.url));
2950
+ const pkgPath = join8(here, "..", "package.json");
2628
2951
  return JSON.parse(readFileSync2(pkgPath, "utf8")).version ?? "unknown";
2629
2952
  } catch {
2630
2953
  return "unknown";
@@ -2675,6 +2998,11 @@ async function main() {
2675
2998
  ${formatValidationErrors(errors)}`);
2676
2999
  process.exit(1);
2677
3000
  }
3001
+ const warnings = collectWarnings(config);
3002
+ if (warnings.length) {
3003
+ console.warn(`\u26A0 Config warnings:
3004
+ ${formatValidationWarnings(warnings)}`);
3005
+ }
2678
3006
  let services;
2679
3007
  try {
2680
3008
  services = filterServices(config.services, cliArgs, config);
@@ -2687,7 +3015,7 @@ ${formatValidationErrors(errors)}`);
2687
3015
  process.exit(1);
2688
3016
  }
2689
3017
  const platform = await detectPlatform();
2690
- const envFile = config.envFile ? join7(cwd, config.envFile) : join7(cwd, ".env");
3018
+ const envFile = config.envFile ? join8(cwd, config.envFile) : join8(cwd, ".env");
2691
3019
  const env = parseEnvFile(envFile, process.env);
2692
3020
  if (config.env) {
2693
3021
  for (const [k, v] of Object.entries(config.env)) {
@@ -2704,7 +3032,7 @@ ${formatValidationErrors(errors)}`);
2704
3032
  routes: config.proxy.routes,
2705
3033
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
2706
3034
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
2707
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join7(homedir3(), ".traefik", "traefik_conf.yaml")
3035
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join8(homedir4(), ".traefik", "traefik_conf.yaml")
2708
3036
  };
2709
3037
  }
2710
3038
  if (cliArgs.dryRun) {