@gachlab/devup 0.9.2 → 0.10.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to `@gachlab/devup` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.3] — 2026-05-22
9
+
10
+ Critical hotfix for `devup down` against the VS Code extension.
11
+
12
+ ### Fixed
13
+ - **`devup down` no longer gets SIGKILL'd because the control-plane socket hangs on streaming clients.** The control-plane server's `close()` awaited every client to disconnect on its own, but long-lived streaming subscriptions (`status.follow`, `logs.follow` — exactly what the VS Code extension uses) never close until the daemon tells them to. Result: `devup down` waited the full 10 s grace, then SIGKILL'd the daemon. SIGKILL skips the cleanup handler → all spawned services orphaned to init, ports left busy, next `devup up -d` hits EADDRINUSE on every port. Fix: track every active client socket and `destroy()` them before awaiting `server.close()`. Clean shutdowns now complete in milliseconds even with the extension connected.
14
+ - **Pre-boot port-conflict scan now covers web services too.** They were skipped on the assumption that dev servers handle retry themselves, but in daemon mode the user wants devup to own the configured ports — same as APIs. If a web port is held by a stray Vite/ng-serve from a previous run, the scan now flags it and offers to take it over.
15
+
16
+ ### Added
17
+ - New unit test asserts `socket.close()` completes in under 2 s with an active `logs.follow` subscription.
18
+
8
19
  ## [0.9.2] — 2026-05-22
9
20
 
10
21
  Critical hotfix. **All 0.9.x users should upgrade immediately.**
@@ -9,6 +9,25 @@ import type { ProcessState } from '../process/types.js';
9
9
  * to the socket file already has the same uid as the devup process — no
10
10
  * additional auth needed. Strictly local; TCP exposure is intentionally
11
11
  * out of scope. */
12
+ export interface ServiceStatEntry {
13
+ cpu: number;
14
+ memMB: number;
15
+ }
16
+ export interface StatsResult {
17
+ services: Record<string, ServiceStatEntry>;
18
+ system: {
19
+ totalMemMB: number;
20
+ freeMemMB: number;
21
+ cpuCores: number;
22
+ };
23
+ }
24
+ export interface ProxyInfo {
25
+ active: boolean;
26
+ provider: string;
27
+ domain: string;
28
+ tls: boolean;
29
+ routes: Record<string, string>;
30
+ }
12
31
  export interface RpcContext {
13
32
  /** State of every service (read-only snapshot). */
14
33
  states(): Map<string, ProcessState>;
@@ -23,6 +42,10 @@ export interface RpcContext {
23
42
  watchLogs(svcName: string | null, onLine: (svc: string, line: string) => void): () => void;
24
43
  /** Subscribe to service-state changes. Returns an unsubscribe function. */
25
44
  watchStatus(onUpdate: (name: string, state: ProcessState) => void): () => void;
45
+ /** Per-service CPU/mem stats + system totals. */
46
+ getStats(): Promise<StatsResult>;
47
+ /** Active proxy configuration, or null when no proxy is running. */
48
+ getProxyInfo(): ProxyInfo | null;
26
49
  }
27
50
  export interface SocketServerHandle {
28
51
  server: Server;
@@ -1 +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;;;;;;;;oBAQoB;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;IAC5D;2CACuC;IACvC,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAC3F,2EAA2E;IAC3E,WAAW,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CAChF;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"}
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;;;;;;;;oBAQoB;AAEpB,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC3C,MAAM,EAAE;QACN,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC;CACH;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,OAAO,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAChC;AAED,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;IAC5D;2CACuC;IACvC,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAC3F,2EAA2E;IAC3E,WAAW,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAC/E,iDAAiD;IACjD,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IACjC,oEAAoE;IACpE,YAAY,IAAI,SAAS,GAAG,IAAI,CAAC;CAClC;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,CA+C7B"}
package/dist/index.js CHANGED
@@ -720,7 +720,12 @@ async function startSocketServer(projectName, ctx, opts = {}) {
720
720
  } catch {
721
721
  }
722
722
  }
723
- const server = createServer((socket) => handleClient(socket, ctx));
723
+ const activeClients = /* @__PURE__ */ new Set();
724
+ const server = createServer((socket) => {
725
+ activeClients.add(socket);
726
+ socket.once("close", () => activeClients.delete(socket));
727
+ handleClient(socket, ctx);
728
+ });
724
729
  await new Promise((resolve4, reject) => {
725
730
  server.once("error", reject);
726
731
  server.listen(path, () => {
@@ -737,6 +742,8 @@ async function startSocketServer(projectName, ctx, opts = {}) {
737
742
  server,
738
743
  path,
739
744
  async close() {
745
+ for (const sock of activeClients) sock.destroy();
746
+ activeClients.clear();
740
747
  await new Promise((resolve4) => server.close(() => resolve4()));
741
748
  if (existsSync5(path)) {
742
749
  try {
@@ -840,8 +847,10 @@ async function dispatch(method, params, ctx) {
840
847
  for (const [name, st] of ctx.states()) {
841
848
  out.push(serializeState(name, st));
842
849
  }
843
- return { services: out };
850
+ return { services: out, proxy: ctx.getProxyInfo() };
844
851
  }
852
+ case "stats":
853
+ return await ctx.getStats();
845
854
  case "restart": {
846
855
  const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
847
856
  await ctx.restart(svc);
@@ -944,7 +953,7 @@ function openStream(socketPath, method, params, onFrame, onError) {
944
953
  import { spawn as spawn4 } from "child_process";
945
954
  import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, existsSync as existsSync10, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, createReadStream } from "fs";
946
955
  import { join as join8 } from "path";
947
- import { homedir as homedir3 } from "os";
956
+ import { homedir as homedir3, totalmem, freemem, cpus } from "os";
948
957
  import { setTimeout as sleep } from "timers/promises";
949
958
  import { createInterface as createInterface3 } from "readline";
950
959
 
@@ -1867,6 +1876,7 @@ async function daemonBody(opts) {
1867
1876
  const logBus = new Broadcaster();
1868
1877
  const stateBus = new Broadcaster();
1869
1878
  const lazyProxies = /* @__PURE__ */ new Map();
1879
+ const prevCpuMap = /* @__PURE__ */ new Map();
1870
1880
  let externals = [];
1871
1881
  let socket = null;
1872
1882
  let healthTimer = null;
@@ -1976,7 +1986,48 @@ async function daemonBody(opts) {
1976
1986
  watchLogs: (svcName, onLine) => logBus.subscribe(({ svc, text }) => {
1977
1987
  if (svcName === null || svc === svcName) onLine(svc, text);
1978
1988
  }),
1979
- watchStatus: (onUpdate) => stateBus.subscribe(({ name, state }) => onUpdate(name, state))
1989
+ watchStatus: (onUpdate) => stateBus.subscribe(({ name, state }) => onUpdate(name, state)),
1990
+ async getStats() {
1991
+ const pids = [];
1992
+ const pidToName = /* @__PURE__ */ new Map();
1993
+ for (const [name, st] of mgr.state) {
1994
+ if (st.pid) {
1995
+ pids.push(st.pid);
1996
+ pidToName.set(st.pid, name);
1997
+ }
1998
+ }
1999
+ const raw = pids.length ? await platform.getProcessStats(pids) : /* @__PURE__ */ new Map();
2000
+ const services2 = {};
2001
+ for (const [name] of mgr.state) {
2002
+ services2[name] = { cpu: 0, memMB: 0 };
2003
+ }
2004
+ for (const [pid, data] of raw) {
2005
+ const name = pidToName.get(pid);
2006
+ if (!name) continue;
2007
+ const prev = prevCpuMap.get(name) ?? { time: Date.now(), cpu: 0 };
2008
+ const cpu = calcCpuPercent(data.cpuSeconds, prev.cpu, prev.time);
2009
+ prevCpuMap.set(name, { time: Date.now(), cpu: data.cpuSeconds });
2010
+ services2[name] = { cpu: Math.round(cpu * 10) / 10, memMB: Math.round(data.rss / 1024 * 10) / 10 };
2011
+ }
2012
+ return {
2013
+ services: services2,
2014
+ system: {
2015
+ totalMemMB: Math.round(totalmem() / 1024 / 1024),
2016
+ freeMemMB: Math.round(freemem() / 1024 / 1024),
2017
+ cpuCores: cpus().length
2018
+ }
2019
+ };
2020
+ },
2021
+ getProxyInfo() {
2022
+ if (!proxyProvider || !proxyOpts || !cliArgs.proxy) return null;
2023
+ return {
2024
+ active: true,
2025
+ provider: proxyProvider.name,
2026
+ domain: proxyOpts.domain,
2027
+ tls: proxyOpts.tls,
2028
+ routes: proxyOpts.routes
2029
+ };
2030
+ }
1980
2031
  }, { onLog: (msg) => writeDevupLog(msg) });
1981
2032
  healthTimer = setInterval(() => {
1982
2033
  void mgr.checkAllHealth();
@@ -2583,9 +2634,8 @@ function parseLsof(stdout) {
2583
2634
  return null;
2584
2635
  }
2585
2636
  async function scanPortConflicts(services) {
2586
- const apis = services.filter((s) => s.type === "api");
2587
2637
  const conflicts = [];
2588
- for (const svc of apis) {
2638
+ for (const svc of services) {
2589
2639
  const bindable = await isPortBindable(svc.port);
2590
2640
  if (bindable) continue;
2591
2641
  const holder = await findPortHolder(svc.port);
@@ -3096,8 +3146,10 @@ function useLogsPause(setPaused, logsPaused, logsScrollOffset) {
3096
3146
  import { useEffect as useEffect5, useRef as useRef3 } from "react";
3097
3147
  import { createInterface as createInterface5 } from "readline";
3098
3148
  import { createReadStream as createReadStream3, existsSync as existsSync15 } from "fs";
3099
- function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBus) {
3149
+ import { totalmem as totalmem2, freemem as freemem2, cpus as cpus2 } from "os";
3150
+ function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBus, platform, proxy) {
3100
3151
  const handleRef = useRef3(null);
3152
+ const prevCpuMap = useRef3(/* @__PURE__ */ new Map());
3101
3153
  useEffect5(() => {
3102
3154
  if (!manager) return;
3103
3155
  let handle = null;
@@ -3129,6 +3181,47 @@ function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBu
3129
3181
  },
3130
3182
  watchStatus: (onUpdate) => {
3131
3183
  return stateBus.subscribe(({ name, state }) => onUpdate(name, state));
3184
+ },
3185
+ async getStats() {
3186
+ const pids = [];
3187
+ const pidToName = /* @__PURE__ */ new Map();
3188
+ for (const [name, st] of manager.state) {
3189
+ if (st.pid) {
3190
+ pids.push(st.pid);
3191
+ pidToName.set(st.pid, name);
3192
+ }
3193
+ }
3194
+ const raw = pids.length ? await platform.getProcessStats(pids) : /* @__PURE__ */ new Map();
3195
+ const services = {};
3196
+ for (const [name] of manager.state) {
3197
+ services[name] = { cpu: 0, memMB: 0 };
3198
+ }
3199
+ for (const [pid, data] of raw) {
3200
+ const name = pidToName.get(pid);
3201
+ if (!name) continue;
3202
+ const prev = prevCpuMap.current.get(name) ?? { time: Date.now(), cpu: 0 };
3203
+ const cpu = calcCpuPercent(data.cpuSeconds, prev.cpu, prev.time);
3204
+ prevCpuMap.current.set(name, { time: Date.now(), cpu: data.cpuSeconds });
3205
+ services[name] = { cpu: Math.round(cpu * 10) / 10, memMB: Math.round(data.rss / 1024 * 10) / 10 };
3206
+ }
3207
+ return {
3208
+ services,
3209
+ system: {
3210
+ totalMemMB: Math.round(totalmem2() / 1024 / 1024),
3211
+ freeMemMB: Math.round(freemem2() / 1024 / 1024),
3212
+ cpuCores: cpus2().length
3213
+ }
3214
+ };
3215
+ },
3216
+ getProxyInfo() {
3217
+ if (!proxy) return null;
3218
+ return {
3219
+ active: true,
3220
+ provider: proxy.provider.name,
3221
+ domain: proxy.opts.domain,
3222
+ tls: proxy.opts.tls,
3223
+ routes: proxy.opts.routes
3224
+ };
3132
3225
  }
3133
3226
  }, { onLog: (msg) => pushLog("devup", msg, 12) });
3134
3227
  handleRef.current = handle;
@@ -3140,7 +3233,7 @@ function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBu
3140
3233
  void handle?.close();
3141
3234
  handleRef.current = null;
3142
3235
  };
3143
- }, [manager, projectName, logSink, pushLog, logBus, stateBus]);
3236
+ }, [manager, projectName, logSink, pushLog, logBus, stateBus, platform, proxy]);
3144
3237
  return handleRef;
3145
3238
  }
3146
3239
 
@@ -3283,7 +3376,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
3283
3376
  const statsObj = Object.fromEntries([...stats].map(([k, v]) => [k, v]));
3284
3377
  const apis = sortServiceNames(names.filter((n) => states.get(n).svc.type === "api"), sortMode, statsObj, stObj);
3285
3378
  const webs = sortServiceNames(names.filter((n) => states.get(n).svc.type === "web"), sortMode, statsObj, stObj);
3286
- const cpus = os.cpus().length;
3379
+ const cpus3 = os.cpus().length;
3287
3380
  const totalGB = (os.totalmem() / 1024 / 1024 / 1024).toFixed(1);
3288
3381
  const usedGB = (parseFloat(totalGB) - os.freemem() / 1024 / 1024 / 1024).toFixed(1);
3289
3382
  const load = os.loadavg()[0].toFixed(2);
@@ -3336,7 +3429,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
3336
3429
  ] }),
3337
3430
  /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3338
3431
  " System: ",
3339
- cpus,
3432
+ cpus3,
3340
3433
  "c Load ",
3341
3434
  load,
3342
3435
  " RAM ",
@@ -3748,7 +3841,8 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
3748
3841
  onToggleProxy: () => {
3749
3842
  }
3750
3843
  });
3751
- const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog, pm.logBus, pm.stateBus);
3844
+ const proxyCtx = proxyProvider && proxyOpts ? { provider: proxyProvider, opts: proxyOpts } : null;
3845
+ const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog, pm.logBus, pm.stateBus, platform, proxyCtx);
3752
3846
  const shutdown = useCallback3(async () => {
3753
3847
  lazyProxies.current.forEach((p) => p.destroy());
3754
3848
  await socketServer.current?.close();