contactsheet 0.1.4 → 0.1.6

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.
@@ -1187,3 +1187,34 @@ body:not([data-sidebar="off"]) #cs-hud { left: calc(var(--cs-sb-w) + 16px); }
1187
1187
  box-shadow: 0 0 0 3px var(--cs-accent), 0 0 0 6px var(--cs-accent-32);
1188
1188
  z-index: 4;
1189
1189
  }
1190
+
1191
+ /* ---------- dev server 僵死横幅(全局,盖在一切之上但不拦事件之外的区域) ---------- */
1192
+ .cs-health {
1193
+ position: fixed;
1194
+ top: 12px;
1195
+ left: 50%;
1196
+ transform: translateX(-50%);
1197
+ z-index: var(--cs-z-overlay);
1198
+ display: flex;
1199
+ align-items: baseline;
1200
+ gap: var(--cs-sp-4);
1201
+ max-width: min(720px, 86vw);
1202
+ padding: var(--cs-sp-4) var(--cs-sp-6);
1203
+ border: 1px solid var(--cs-danger);
1204
+ border-radius: var(--cs-r-3);
1205
+ background: color-mix(in srgb, var(--cs-danger) 12%, var(--cs-s1));
1206
+ color: var(--cs-fg);
1207
+ font: var(--cs-fs-sm)/1.5 var(--cs-ff-sans);
1208
+ box-shadow: var(--cs-e3);
1209
+ /* 入场:@starting-style 由 transition 接住,无 JS 时序(MOTION-013);进入用 ease-out(MOTION-006) */
1210
+ transition: opacity var(--cs-dur-3) var(--cs-ease-out), translate var(--cs-dur-3) var(--cs-ease-out);
1211
+ }
1212
+ @starting-style {
1213
+ .cs-health { opacity: 0; translate: 0 -8px; }
1214
+ }
1215
+ .cs-health b { color: var(--cs-danger); font-weight: var(--cs-fw-bold); white-space: nowrap; }
1216
+ .cs-health span { color: var(--cs-fg-2); }
1217
+ @media (prefers-reduced-motion: reduce) {
1218
+ .cs-health { transition: opacity var(--cs-dur-3) var(--cs-ease-out); }
1219
+ @starting-style { .cs-health { translate: none; } }
1220
+ }
package/dist/cli.js CHANGED
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';const require = createRequire(import.meta.url);
3
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
4
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
5
+ }) : x)(function(x) {
6
+ if (typeof require !== "undefined") return require.apply(this, arguments);
7
+ throw Error('Dynamic require of "' + x + '" is not supported');
8
+ });
3
9
 
4
10
  // src/cli.ts
5
11
  import { parseArgs } from "node:util";
@@ -29,7 +35,6 @@ function loadConfig(cwd, flags) {
29
35
  // 末尾斜杠去掉,后面一律靠 `${target}/xxx` 拼
30
36
  target: String(pick("target") ?? DEFAULT_TARGET).replace(/\/+$/, ""),
31
37
  port,
32
- portExplicit: flags.port !== void 0,
33
38
  appDir: pick("appDir") ?? detectAppDir(projectRoot),
34
39
  designDir: pick("designDir") ?? DEFAULT_DESIGN_DIR
35
40
  };
@@ -1147,6 +1152,56 @@ async function pushToSession(cfg, text2, pid) {
1147
1152
  return { ok: true, pid: target.pid, name: target.name };
1148
1153
  }
1149
1154
 
1155
+ // src/server/health.ts
1156
+ var INTERVAL_MS = Number(process.env.CS_HEALTH_INTERVAL_MS ?? 2e4);
1157
+ var TIMEOUT_MS = Number(process.env.CS_HEALTH_TIMEOUT_MS ?? 1e4);
1158
+ var FAILS_TO_TRIP = 2;
1159
+ var current = { ok: true };
1160
+ function currentHealth() {
1161
+ return current;
1162
+ }
1163
+ function startHealthMonitor(cfg, broadcast2) {
1164
+ let fails = 0;
1165
+ let everOk = false;
1166
+ let timer = null;
1167
+ const probe = async () => {
1168
+ let failDetail = null;
1169
+ try {
1170
+ const r = await fetch(`${cfg.target}/__cs/registry`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
1171
+ if (r.ok || r.status === 404) {
1172
+ everOk = true;
1173
+ fails = 0;
1174
+ if (!current.ok) {
1175
+ current = { ok: true, lastOkAt: Date.now() };
1176
+ broadcast2({ type: "health", ok: true });
1177
+ } else {
1178
+ current = { ok: true, lastOkAt: Date.now() };
1179
+ }
1180
+ return;
1181
+ }
1182
+ failDetail = `SSR \u63A2\u6D4B\u8FD4\u56DE HTTP ${r.status}`;
1183
+ } catch (err) {
1184
+ const e = err;
1185
+ failDetail = e.name === "TimeoutError" ? `SSR \u63A2\u6D4B ${TIMEOUT_MS / 1e3}s \u65E0\u54CD\u5E94` : `\u8FDE\u4E0D\u4E0A(${e.cause?.code ?? "network"})`;
1186
+ }
1187
+ if (!everOk) return;
1188
+ fails++;
1189
+ if (fails >= FAILS_TO_TRIP && current.ok) {
1190
+ current = { ok: false, detail: failDetail ?? "\u672A\u77E5", lastOkAt: current.lastOkAt };
1191
+ broadcast2({ type: "health", ok: false, detail: current.detail });
1192
+ console.warn(`[contactsheet] \u26A0 \u76EE\u6807 ${cfg.target} \u7591\u4F3C\u50F5\u6B7B:${current.detail}(\u9759\u6001\u53EF\u80FD\u4ECD 200)\u3002\u753B\u5E03\u5DF2\u6302\u6A2A\u5E45\u3002`);
1193
+ }
1194
+ };
1195
+ timer = setInterval(() => void probe(), INTERVAL_MS);
1196
+ timer.unref();
1197
+ void probe();
1198
+ return {
1199
+ close() {
1200
+ if (timer) clearInterval(timer);
1201
+ }
1202
+ };
1203
+ }
1204
+
1150
1205
  // src/server/sse.ts
1151
1206
  var clients = /* @__PURE__ */ new Set();
1152
1207
  var heartbeat = null;
@@ -1383,6 +1438,10 @@ async function handleApi(cfg, req, res, pathname) {
1383
1438
  const rel = await saveRef(cfg, body.name || "ref.png", body.dataBase64);
1384
1439
  return sendJson(res, 200, { path: rel });
1385
1440
  }
1441
+ if (pathname === "/__cs/api/health") {
1442
+ if (method !== "GET") return sendText(res, 405, "method not allowed");
1443
+ return sendJson(res, 200, currentHealth());
1444
+ }
1386
1445
  if (pathname === "/__cs/api/context") {
1387
1446
  if (method !== "GET") return sendText(res, 405, "method not allowed");
1388
1447
  try {
@@ -1559,6 +1618,11 @@ async function startServer(cfg) {
1559
1618
  res.destroy();
1560
1619
  }
1561
1620
  });
1621
+ proxy.on("proxyReq", (proxyReq, _req, res) => {
1622
+ res.on("close", () => {
1623
+ if (!res.writableEnded) proxyReq.destroy();
1624
+ });
1625
+ });
1562
1626
  proxy.on("proxyRes", () => proxyLog.alive());
1563
1627
  proxy.on("open", () => proxyLog.alive());
1564
1628
  let mcp = null;
@@ -1594,9 +1658,11 @@ async function startServer(cfg) {
1594
1658
  warn(`\u76D1\u542C\u5728 ${host}:${port} \u2014\u2014 \u753B\u5E03\u65E0\u9274\u6743,\u540C\u7F51\u7EDC\u7684\u4EFB\u4F55\u4EBA\u90FD\u80FD\u8BFB\u4F60\u7684\u6279\u6CE8\u3001\u5E76\u5411\u4F60\u7684 Claude Code \u4F1A\u8BDD\u63A8\u9001\u6D88\u606F\u3002\u53EA\u5728\u53EF\u4FE1\u7F51\u7EDC\u8FD9\u4E48\u505A\u3002`)
1595
1659
  );
1596
1660
  }
1661
+ const health = startHealthMonitor(cfg, broadcast);
1597
1662
  return {
1598
1663
  port,
1599
1664
  async close() {
1665
+ health.close();
1600
1666
  closeAll();
1601
1667
  proxyLog.close();
1602
1668
  proxy.close();
@@ -1673,15 +1739,27 @@ function downPage(target) {
1673
1739
  function escapeHtml(s) {
1674
1740
  return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
1675
1741
  }
1676
- async function occupiedBySameProject(cfg, port) {
1742
+ async function probeSameProject(cfg, port) {
1743
+ for (let i = 0; i < 2; i++) {
1744
+ try {
1745
+ const r = await fetch(`http://127.0.0.1:${port}/__cs/api/state`, {
1746
+ signal: AbortSignal.timeout(2500)
1747
+ });
1748
+ if (!r.ok) return null;
1749
+ const data = await r.json();
1750
+ return data?.projectRoot === cfg.projectRoot ? { version: data.version } : null;
1751
+ } catch {
1752
+ }
1753
+ }
1754
+ return null;
1755
+ }
1756
+ function occupierPid(port) {
1677
1757
  try {
1678
- const ctl = AbortSignal.timeout(800);
1679
- const r = await fetch(`http://127.0.0.1:${port}/__cs/api/state`, { signal: ctl });
1680
- if (!r.ok) return false;
1681
- const data = await r.json();
1682
- return data?.projectRoot === cfg.projectRoot;
1758
+ const { execSync } = __require("node:child_process");
1759
+ const out = execSync(`lsof -t -nP -iTCP:${port} -sTCP:LISTEN`, { timeout: 3e3 }).toString().trim();
1760
+ return out.split("\n")[0] || null;
1683
1761
  } catch {
1684
- return false;
1762
+ return null;
1685
1763
  }
1686
1764
  }
1687
1765
  async function listenWithFallback(server, cfg, host, onTwin) {
@@ -1713,27 +1791,27 @@ async function listenWithFallback(server, cfg, host, onTwin) {
1713
1791
  return false;
1714
1792
  };
1715
1793
  if (await tryListen(cfg.port)) return cfg.port;
1716
- if (await occupiedBySameProject(cfg, cfg.port)) {
1794
+ const running = await probeSameProject(cfg, cfg.port);
1795
+ if (running) {
1796
+ if (running.version && running.version !== pkgVersion()) {
1797
+ const pid2 = occupierPid(cfg.port);
1798
+ console.error(
1799
+ warn(`\u672C\u9879\u76EE\u5DF2\u6709\u4E00\u4E2A contactsheet \u5728 ${cfg.port} \u4E0A,\u4F46\u5B83\u662F v${running.version},\u5F53\u524D\u662F v${pkgVersion()}\u3002`) + `
1800
+ \u65E7\u5B9E\u4F8B\u4E0D\u4F1A\u81EA\u52A8\u5347\u7EA7 \u2014\u2014 \u5148\u505C\u6389\u5B83\u518D\u8D77:kill ${pid2 ?? "<lsof -t -iTCP:" + cfg.port + ">"}`
1801
+ );
1802
+ process.exit(1);
1803
+ }
1717
1804
  console.log(
1718
1805
  `${cyan("\u25CF")} \u672C\u9879\u76EE\u5DF2\u7ECF\u6709\u4E00\u4E2A contactsheet \u5728\u8DD1\u4E86,\u76F4\u63A5\u7528\u5B83:${link(`http://localhost:${cfg.port}/__cs`)}`
1719
1806
  );
1720
1807
  process.exit(0);
1721
1808
  }
1722
- if (cfg.portExplicit) {
1723
- throw new Error(
1724
- `\u7AEF\u53E3 ${cfg.port} \u88AB\u5176\u4ED6\u8FDB\u7A0B\u5360\u7528\u3002\u6362\u4E00\u4E2A --port,\u6216\u627E\u51FA\u5360\u7528\u8005:lsof -nP -iTCP:${cfg.port} -sTCP:LISTEN`
1725
- );
1726
- }
1727
- for (let p = cfg.port + 1; p <= cfg.port + 20; p++) {
1728
- if (await tryListen(p)) {
1729
- console.warn(
1730
- warn(`\u7AEF\u53E3 ${cfg.port} \u88AB\u5360,\u5DF2\u987A\u5EF6\u5230 ${p} \u2192 `) + link(`http://localhost:${p}/__cs`) + "\n" + dim(` \u6CE8\u610F:\u6362\u7AEF\u53E3 = \u6362\u6D4F\u89C8\u5668\u6E90\u3002\u753B\u677F\u4F4D\u7F6E/\u6298\u53E0/\u5E95\u8272\u8FD9\u4E9B\u672C\u673A\u8BB0\u5FC6\u6309\u6E90\u9694\u79BB,\u8FD9\u4E2A\u7AEF\u53E3\u4E0B\u662F\u5168\u65B0\u7684;
1731
- `) + dim(` .mcp.json \u4E0E hook \u4ECD\u6307\u5411 ${cfg.port}\u3002\u60F3\u8981\u7A33\u5B9A,\u8BF7\u91CA\u653E ${cfg.port},\u6216\u6539 config \u7684 port \u540E\u91CD\u8DD1 npx contactsheet init\u3002`)
1732
- );
1733
- return p;
1734
- }
1735
- }
1736
- throw new Error(`\u4ECE ${cfg.port} \u5F80\u4E0A\u8FDE\u8BD5 20 \u4E2A\u7AEF\u53E3\u90FD\u88AB\u5360,\u7528 --port \u6307\u5B9A\u4E00\u4E2A\u7A7A\u95F2\u7AEF\u53E3`);
1809
+ const pid = occupierPid(cfg.port);
1810
+ throw new Error(
1811
+ `\u7AEF\u53E3 ${cfg.port} \u88AB\u5176\u4ED6\u8FDB\u7A0B\u5360\u7528${pid ? `(PID ${pid})` : ""}\u3002.mcp.json/hook/config \u90FD\u6307\u5411 ${cfg.port},\u6362\u7AEF\u53E3\u5B83\u4EEC\u4F1A\u5168\u90E8\u5931\u8054,\u6240\u4EE5\u4E0D\u81EA\u52A8\u987A\u5EF6 \u2014\u2014
1812
+ \u91CA\u653E\u5B83:kill ${pid ?? `$(lsof -t -nP -iTCP:${cfg.port} -sTCP:LISTEN)`}
1813
+ \u6216\u6362\u7AEF\u53E3:\u6539 contactsheet.config.json \u7684 port \u540E\u91CD\u8DD1 npx contactsheet init(MCP/hook \u4F1A\u8DDF\u7740\u6362)`
1814
+ );
1737
1815
  }
1738
1816
 
1739
1817
  // src/watch/index.ts