mellos-mapping 0.23.0 → 0.24.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/README.md CHANGED
@@ -661,6 +661,26 @@ by a test, so a graphical client (a web panel, an editor view) can parse state
661
661
  files and reuse the exact aggregation, zoom and glyph semantics the terminal
662
662
  pane draws with.
663
663
 
664
+ ## Optional GitHub Star reminder
665
+
666
+ npm/npx installations may show one small, dismissible reminder after at least
667
+ 7 days and successful map viewing on 3 different UTC dates. Web viewers show
668
+ it inline without taking focus; a native interactive terminal shows it only
669
+ after a successful viewing session exits. It is never added to MCP replies,
670
+ AI prompts, install logs, CI or piped output. Plugin editions and source
671
+ checkouts are excluded.
672
+
673
+ The reminder is claimed once per user, across projects, versions and installs.
674
+ The Star button opens GitHub in the system's default browser, keeping its
675
+ existing sign-in session. A failed launch leaves the notice open with a copy-address option.
676
+ Closing it, following its Star link, or simply leaving the session means it
677
+ will not appear again. To suppress it before it appears, set
678
+ `MELLOS_MAPPING_NO_STAR=1` in the environment of the viewer/MCP process.
679
+ Only the first-use time, last UTC day, a capped day count and the one-time flag
680
+ are stored in `~/.mellos/support/star-reminder.json`; nothing is uploaded and
681
+ GitHub is contacted only when the user follows the link. Unreadable settings
682
+ or a busy store silently skip the reminder.
683
+
664
684
  ## License
665
685
 
666
686
  MIT
package/README.zh-CN.md CHANGED
@@ -596,6 +596,22 @@ npm run verify
596
596
  守护——图形客户端(web 面板、编辑器视图)可以直接解析状态文件,并复用与
597
597
  终端面板完全一致的聚合、缩放与字形语义。
598
598
 
599
+ ## 可选的 GitHub Star 提醒
600
+
601
+ 仅 npm/npx 安装会在首次成功查看地图满 7 天、且已在至少 3 个不同 UTC 日期
602
+ 成功查看地图后,显示一次轻提示。网页中是一行可关闭的文字,不抢焦点;
603
+ 原生交互终端只在正常退出地图后提示。MCP 返回、AI 提示词、安装日志、CI
604
+ 及管道输出中都不会加入提醒,插件版和源码运行也不触发。
605
+
606
+ Star 按钮会调用系统默认浏览器打开 GitHub,方便沿用已有登录状态。
607
+ 若未能打开,会保留提示并提供复制地址按钮。
608
+ 提醒按用户跨项目、版本及安装去重,一生效就记为已提醒。关闭、点击 Star
609
+ 或直接离开后,都不会再次出现。想提前关闭,可在启动地图或 MCP 的环境中
610
+ 设置 `MELLOS_MAPPING_NO_STAR=1`。
611
+ 仅在 `~/.mellos/support/star-reminder.json` 本地记录首次使用时间、最后一个
612
+ UTC 日期、最多 3 天的计数和已提醒标记,不上传数据,不查询是否点过 Star;
613
+ 只有用户主动点击链接时才访问 GitHub。设置损坏、不可写或锁忙时静默跳过。
614
+
599
615
  ## 许可证
600
616
 
601
617
  MIT
package/dist/server.mjs CHANGED
@@ -24590,7 +24590,7 @@ function launcherViewerPid(run) {
24590
24590
 
24591
24591
  // src/server/server.ts
24592
24592
  var SERVER_NAME = "mellos-mapping";
24593
- var SERVER_VERSION = "0.23.0";
24593
+ var SERVER_VERSION = "0.24.0";
24594
24594
  function text(s, isError = false) {
24595
24595
  return { content: [{ type: "text", text: s }], ...isError ? { isError: true } : {} };
24596
24596
  }
@@ -2896,6 +2896,7 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
2896
2896
  lastHits = [];
2897
2897
  } else {
2898
2898
  const windowed = rendered.value;
2899
+ if (map.nodes.length > 0) io.onMapRendered?.();
2899
2900
  const maxX = Math.max(0, windowed.contentWidth - viewW);
2900
2901
  const maxY = Math.max(0, windowed.contentHeight - viewH);
2901
2902
  if (view.offsetX > maxX || view.offsetY > maxY || view.offsetX < 0 || view.offsetY < 0) {
package/dist/watch.mjs CHANGED
@@ -2735,12 +2735,12 @@ function viewerReportOf(pane, defaultFile) {
2735
2735
  const shown = pane.activeFile ?? pane.pendingFocusFile ?? defaultFile;
2736
2736
  return { page: pageIdOfFile(defaultFile, shown), follow: pane.follow };
2737
2737
  }
2738
- function runWatcher(cfg, io = nativeWatcherIO()) {
2738
+ function runWatcher(cfg, io2 = nativeWatcherIO()) {
2739
2739
  const renderScene = createWindowRenderer();
2740
2740
  if (migrateLegacyStore(cfg.file)) console.error("mellos-mapping: moved the legacy .claude map store to .mellos/ \u2014 commit the move.");
2741
2741
  sweepQuitRequest(cfg.file);
2742
2742
  sweepQuitRequest(cfg.file, process.pid);
2743
- const interactive = io.interactive;
2743
+ const interactive = io2.interactive;
2744
2744
  const mouseActive = interactive && cfg.mouse;
2745
2745
  let lastFrame = "";
2746
2746
  let spinnerFrame = 0;
@@ -2749,8 +2749,8 @@ function runWatcher(cfg, io = nativeWatcherIO()) {
2749
2749
  const standbyNotice = "waiting for the first mmap_declare ...";
2750
2750
  let map;
2751
2751
  let notice = standbyNotice;
2752
- let lastCols = io.output.columns ?? 0;
2753
- let lastRows = io.output.rows ?? 0;
2752
+ let lastCols = io2.output.columns ?? 0;
2753
+ let lastRows = io2.output.rows ?? 0;
2754
2754
  let pane = initialPaneState(
2755
2755
  cfg.follow,
2756
2756
  cfg.page === void 0 ? void 0 : pageFilePath(cfg.file, cfg.page)
@@ -2777,8 +2777,8 @@ function runWatcher(cfg, io = nativeWatcherIO()) {
2777
2777
  }
2778
2778
  return false;
2779
2779
  };
2780
- const viewWidth = () => usableColumns(io.output.columns ?? FALLBACK_COLUMNS);
2781
- const viewHeight = () => Math.max(1, (io.output.rows ?? FALLBACK_ROWS) - (1 + panelContentRows) - 1 - tabRows());
2780
+ const viewWidth = () => usableColumns(io2.output.columns ?? FALLBACK_COLUMNS);
2781
+ const viewHeight = () => Math.max(1, (io2.output.rows ?? FALLBACK_ROWS) - (1 + panelContentRows) - 1 - tabRows());
2782
2782
  const dividerY = () => tabRows() + viewHeight() + 1;
2783
2783
  const pageTabsOf = (files) => files.map((f) => {
2784
2784
  const entry = entryOf(pane, f);
@@ -2833,20 +2833,20 @@ function runWatcher(cfg, io = nativeWatcherIO()) {
2833
2833
  return lastHits.find((h) => cx >= h.x && cx < h.x + h.w && cy >= h.y && cy < h.y + h.h)?.id;
2834
2834
  };
2835
2835
  const publishPresence = () => {
2836
- io.report?.(viewerReportOf(pane, cfg.file));
2836
+ io2.report?.(viewerReportOf(pane, cfg.file));
2837
2837
  publishViewer(cfg.file, process.pid, {
2838
2838
  ...viewerReportOf(pane, cfg.file),
2839
2839
  ...cfg.owner === void 0 ? {} : { owner: cfg.owner }
2840
2840
  });
2841
2841
  };
2842
- if (interactive) io.input.setRawMode(true);
2843
- const terminal = openTerminalSession({ interactive, mouse: cfg.mouse, write: (text) => io.output.write(text) });
2842
+ if (interactive) io2.input.setRawMode(true);
2843
+ const terminal = openTerminalSession({ interactive, mouse: cfg.mouse, write: (text) => io2.output.write(text) });
2844
2844
  const frameOutput = interactive ? createFrameOutput({
2845
- write: (text) => io.output.write(text),
2845
+ write: (text) => io2.output.write(text),
2846
2846
  onDrain: (ready) => {
2847
- io.output.once("drain", ready);
2847
+ io2.output.once("drain", ready);
2848
2848
  return () => {
2849
- io.output.off("drain", ready);
2849
+ io2.output.off("drain", ready);
2850
2850
  };
2851
2851
  }
2852
2852
  }) : void 0;
@@ -2877,9 +2877,9 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
2877
2877
  const paint2 = () => {
2878
2878
  clearTimeout(paintTimer);
2879
2879
  paintTimer = void 0;
2880
- const cols = io.output.columns ?? FALLBACK_COLUMNS;
2880
+ const cols = io2.output.columns ?? FALLBACK_COLUMNS;
2881
2881
  const viewW = viewWidth();
2882
- panelContentRows = clampPanelRows(panelContentRows, io.output.rows ?? FALLBACK_ROWS, tabRows());
2882
+ panelContentRows = clampPanelRows(panelContentRows, io2.output.rows ?? FALLBACK_ROWS, tabRows());
2883
2883
  const viewH = viewHeight();
2884
2884
  const focus = view.hoverId ?? view.selectedId;
2885
2885
  let body;
@@ -2897,6 +2897,7 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
2897
2897
  lastHits = [];
2898
2898
  } else {
2899
2899
  const windowed = rendered.value;
2900
+ if (map.nodes.length > 0) io2.onMapRendered?.();
2900
2901
  const maxX = Math.max(0, windowed.contentWidth - viewW);
2901
2902
  const maxY = Math.max(0, windowed.contentHeight - viewH);
2902
2903
  if (view.offsetX > maxX || view.offsetY > maxY || view.offsetX < 0 || view.offsetY < 0) {
@@ -2990,7 +2991,7 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
2990
2991
  } else {
2991
2992
  const frame = HOME + rows.map((row) => row + ERASE_LINE_END).join("\n");
2992
2993
  if (frame !== lastFrame) {
2993
- io.output.write(frame);
2994
+ io2.output.write(frame);
2994
2995
  lastFrame = frame;
2995
2996
  }
2996
2997
  }
@@ -2999,16 +3000,16 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
2999
3000
  paintTimer ??= setTimeout(paint2, 16);
3000
3001
  };
3001
3002
  const handleResize = () => {
3002
- lastCols = io.output.columns ?? lastCols;
3003
- lastRows = io.output.rows ?? lastRows;
3003
+ lastCols = io2.output.columns ?? lastCols;
3004
+ lastRows = io2.output.rows ?? lastRows;
3004
3005
  lastFrame = "";
3005
3006
  frameOutput?.invalidate();
3006
- io.output.write(CLEAR_ALL);
3007
+ io2.output.write(CLEAR_ALL);
3007
3008
  paint2();
3008
3009
  };
3009
3010
  const tick = () => {
3010
3011
  if (takeQuitRequest(cfg.file, process.pid)) quit();
3011
- if ((io.output.columns ?? lastCols) !== lastCols || (io.output.rows ?? lastRows) !== lastRows) {
3012
+ if ((io2.output.columns ?? lastCols) !== lastCols || (io2.output.rows ?? lastRows) !== lastRows) {
3012
3013
  handleResize();
3013
3014
  }
3014
3015
  const discovered = listPageFiles(cfg.file);
@@ -3077,9 +3078,9 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
3077
3078
  tick();
3078
3079
  };
3079
3080
  if (interactive) {
3080
- io.input.resume();
3081
- io.input.setEncoding("utf8");
3082
- io.input.on("data", (chunk) => {
3081
+ io2.input.resume();
3082
+ io2.input.setEncoding("utf8");
3083
+ io2.input.on("data", (chunk) => {
3083
3084
  const parsed2 = parseInput(pendingInput + chunk);
3084
3085
  pendingInput = parsed2.rest;
3085
3086
  let dirty = false;
@@ -3152,7 +3153,7 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
3152
3153
  break;
3153
3154
  case "mouse-drag":
3154
3155
  if (view.dividerDrag) {
3155
- const next = panelRowsFromDividerY(event.y, io.output.rows ?? FALLBACK_ROWS, tabRows());
3156
+ const next = panelRowsFromDividerY(event.y, io2.output.rows ?? FALLBACK_ROWS, tabRows());
3156
3157
  if (next !== panelContentRows) {
3157
3158
  panelContentRows = next;
3158
3159
  dirty = true;
@@ -3248,7 +3249,7 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
3248
3249
  else paint2();
3249
3250
  }
3250
3251
  });
3251
- io.output.on("resize", handleResize);
3252
+ io2.output.on("resize", handleResize);
3252
3253
  }
3253
3254
  tick();
3254
3255
  setInterval(tick, cfg.intervalMs);
@@ -3263,6 +3264,73 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
3263
3264
  }
3264
3265
  }
3265
3266
 
3267
+ // src/watch/cli.ts
3268
+ import { homedir as homedir2 } from "node:os";
3269
+ import { fileURLToPath } from "node:url";
3270
+
3271
+ // src/support/star-reminder.ts
3272
+ import { existsSync as existsSync5, readFileSync as readFileSync5, realpathSync as realpathSync3 } from "node:fs";
3273
+ import { basename as basename2, dirname as dirname10, join as join9 } from "node:path";
3274
+ var STAR_URL = "https://github.com/GuangminJu/mellos-mapping";
3275
+ var DAY_MS = 864e5;
3276
+ function isNpmInstallation(entry) {
3277
+ try {
3278
+ const root = dirname10(dirname10(realpathSync3(entry)));
3279
+ if (basename2(dirname10(root)).toLowerCase() !== "node_modules" || existsSync5(join9(root, ".git")) || existsSync5(join9(root, ".codex-plugin")) || existsSync5(join9(root, ".claude-plugin"))) return false;
3280
+ const pkg = JSON.parse(readFileSync5(join9(root, "package.json"), "utf8"));
3281
+ return pkg.name === "mellos-mapping" && pkg.bin?.mmap === "dist/mmap.mjs";
3282
+ } catch {
3283
+ return false;
3284
+ }
3285
+ }
3286
+ function starReminderFile(userBase) {
3287
+ return join9(userBase, ".mellos", "support", "star-reminder.json");
3288
+ }
3289
+ function enabledFlag(value) {
3290
+ return !!value && value !== "0" && value.toLowerCase() !== "false";
3291
+ }
3292
+ function validState(value) {
3293
+ if (!value || typeof value !== "object") return false;
3294
+ const s = value;
3295
+ return s.version === 1 && Number.isFinite(s.firstSeen) && s.firstSeen >= 0 && Number.isSafeInteger(s.lastDay) && s.lastDay >= Math.floor(s.firstSeen / DAY_MS) && Number.isSafeInteger(s.days) && s.days >= 1 && s.days <= 3 && typeof s.notified === "boolean";
3296
+ }
3297
+ function createStarReminder(options) {
3298
+ const eligible = isNpmInstallation(options.entry) && !enabledFlag(options.env["CI"]) && !enabledFlag(options.env["MELLOS_MAPPING_NO_STAR"]);
3299
+ const file = starReminderFile(options.userBase);
3300
+ return {
3301
+ visit() {
3302
+ if (!eligible) return false;
3303
+ try {
3304
+ return withStoreLock(file, () => {
3305
+ const now = (options.now ?? Date.now)();
3306
+ if (!Number.isFinite(now) || now < 0) return false;
3307
+ const day = Math.floor(now / DAY_MS);
3308
+ let state;
3309
+ try {
3310
+ const saved2 = JSON.parse(readFileSync5(file, "utf8"));
3311
+ if (!validState(saved2)) return false;
3312
+ state = saved2;
3313
+ } catch (error) {
3314
+ if (error.code !== "ENOENT") return false;
3315
+ state = { version: 1, firstSeen: now, lastDay: day, days: 1, notified: false };
3316
+ }
3317
+ if (state.notified || now < state.firstSeen || day < state.lastDay) return false;
3318
+ if (day > state.lastDay) {
3319
+ state.days = Math.min(3, state.days + 1);
3320
+ state.lastDay = day;
3321
+ }
3322
+ const show = state.days >= 3 && now - state.firstSeen >= 7 * DAY_MS;
3323
+ if (show) state.notified = true;
3324
+ const saved = writeAtomic(file, JSON.stringify(state, null, 2) + "\n", 1);
3325
+ return saved.ok && show;
3326
+ });
3327
+ } catch {
3328
+ return false;
3329
+ }
3330
+ }
3331
+ };
3332
+ }
3333
+
3266
3334
  // src/watch/cli.ts
3267
3335
  var parsed = parseArgs(process.argv.slice(2), process.cwd());
3268
3336
  if (!parsed.ok) {
@@ -3270,4 +3338,17 @@ if (!parsed.ok) {
3270
3338
  ${USAGE}`);
3271
3339
  process.exit(1);
3272
3340
  }
3273
- runWatcher(parsed.value);
3341
+ var viewed = false;
3342
+ var io = nativeWatcherIO();
3343
+ var reminder = createStarReminder({ entry: fileURLToPath(import.meta.url), userBase: homedir2(), env: process.env });
3344
+ runWatcher(parsed.value, { ...io, onMapRendered: () => {
3345
+ viewed = true;
3346
+ } });
3347
+ process.on("exit", (code) => {
3348
+ if (code === 0 && viewed && io.interactive && process.stderr.isTTY && process.env["TERM"] !== "dumb" && reminder.visit()) {
3349
+ process.stderr.write(`
3350
+ If Mellos Mapping has been useful, a GitHub Star is welcome: ${STAR_URL}
3351
+ This optional reminder will not appear again.
3352
+ `);
3353
+ }
3354
+ });
package/dist/web/app.css CHANGED
@@ -724,7 +724,8 @@ h2 {
724
724
  margin-left: 20px;
725
725
  }
726
726
  footer {
727
- height: 29px;
727
+ min-height: 29px;
728
+ flex-wrap: wrap;
728
729
  flex-shrink: 0;
729
730
  border-top: 1px solid var(--line);
730
731
  display: flex;
@@ -919,7 +920,7 @@ dialog p {
919
920
  #app {
920
921
  display: grid;
921
922
  grid-template-columns: minmax(0, 1fr) 300px;
922
- grid-template-rows: 58px auto auto minmax(0, 1fr) 29px;
923
+ grid-template-rows: 58px auto auto minmax(0, 1fr) auto;
923
924
  }
924
925
  header {
925
926
  grid-column: 1 / -1;
@@ -965,3 +966,106 @@ dialog p {
965
966
  grid-column: 1 / -1;
966
967
  }
967
968
  }
969
+
970
+ #star-reminder {
971
+ --star-surface: linear-gradient(115deg, #15302e, #202341 48%, #36243b);
972
+ --star-text: #f2f5ff;
973
+ --star-muted: #c9d2e5;
974
+ width: 100%;
975
+ display: flex;
976
+ flex-wrap: wrap;
977
+ align-items: center;
978
+ gap: 10px 14px;
979
+ margin: 4px 0 8px;
980
+ padding: 12px 16px;
981
+ border: 1px solid transparent;
982
+ border-radius: 12px;
983
+ background: var(--star-surface) padding-box,
984
+ linear-gradient(110deg, #69ebc9, #8299ff, #d897ff, #ffc17d, #69ebc9) border-box;
985
+ background-size: 200% 100%;
986
+ color: var(--star-text);
987
+ font-size: 14px;
988
+ line-height: 1.5;
989
+ animation: star-color-flow 6s ease-in-out 2, star-glow 3s ease-in-out 4;
990
+ }
991
+ #star-reminder::before {
992
+ content: "★";
993
+ display: grid;
994
+ place-items: center;
995
+ flex: 0 0 36px;
996
+ height: 36px;
997
+ border: 1px solid #ffe49950;
998
+ border-radius: 10px;
999
+ background: #f9c76916;
1000
+ color: #ffe49b;
1001
+ font-size: 26px;
1002
+ line-height: 1;
1003
+ text-shadow: 0 0 14px #ffd47870;
1004
+ animation: star-beat 3s ease-in-out 4;
1005
+ }
1006
+ #star-reminder > span { flex: 1 1 230px; font-weight: 550; }
1007
+ #star-reminder a {
1008
+ display: inline-flex;
1009
+ align-items: center;
1010
+ justify-content: center;
1011
+ min-height: 36px;
1012
+ padding: 7px 16px;
1013
+ border: 1px solid #ffffff50;
1014
+ border-radius: 8px;
1015
+ background: linear-gradient(110deg, #8bf0d0, #babaff 55%, #ffd0aa);
1016
+ color: #17243a;
1017
+ font-size: 13px;
1018
+ font-weight: 750;
1019
+ line-height: 1.4;
1020
+ text-decoration: none;
1021
+ white-space: nowrap;
1022
+ box-shadow: 0 2px 12px #8b96ff25;
1023
+ }
1024
+ #star-reminder a:hover { filter: brightness(1.1); }
1025
+ #star-reminder a[aria-disabled=true] { cursor: progress; opacity: .8; }
1026
+ #star-reminder .star-open-error { flex: 1 1 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: var(--star-muted); }
1027
+ #star-reminder .star-open-error input { min-width: 0; max-width: 100%; width: 340px; padding: 6px; color: var(--star-text); background: transparent; border: 1px solid #b8c5e470; border-radius: 6px; }
1028
+ #star-reminder button {
1029
+ min-height: 34px;
1030
+ padding: 6px 10px;
1031
+ border: 1px solid #b8c5e42e;
1032
+ border-radius: 8px;
1033
+ background: transparent;
1034
+ color: var(--star-muted);
1035
+ font-size: 12px;
1036
+ line-height: 1.4;
1037
+ white-space: nowrap;
1038
+ }
1039
+ #star-reminder button:hover { background: #a0b5f014; border-color: #b8c5e470; }
1040
+ #star-reminder a:focus-visible, #star-reminder button:focus-visible {
1041
+ outline: 2px solid #b0b9ff;
1042
+ outline-offset: 3px;
1043
+ }
1044
+ #star-reminder:hover, #star-reminder:focus-within,
1045
+ #star-reminder:hover::before, #star-reminder:focus-within::before { animation-play-state: paused; }
1046
+ [data-theme=light] #star-reminder {
1047
+ --star-surface: linear-gradient(115deg, #e4fbf5, #eeedff 48%, #fff0e5);
1048
+ --star-text: #263451;
1049
+ --star-muted: #53607b;
1050
+ }
1051
+ [data-theme=light] #star-reminder::before { color: #a5660c; background: #ffe6aa80; border-color: #dbae6340; text-shadow: none; }
1052
+ @keyframes star-color-flow {
1053
+ 0%, 100% { background-position: 0% 50%; }
1054
+ 50% { background-position: 100% 50%; }
1055
+ }
1056
+ @keyframes star-glow {
1057
+ 0%, 100% { box-shadow: 0 0 0 #8299ff00; }
1058
+ 50% { box-shadow: 0 0 16px #8299ff30, inset 0 0 18px #b897ff0c; }
1059
+ }
1060
+ @keyframes star-beat {
1061
+ 0%, 100% { transform: scale(1) rotate(0deg); }
1062
+ 50% { transform: scale(1.1) rotate(-7deg); }
1063
+ }
1064
+ @media (max-width: 520px) {
1065
+ #star-reminder { padding: 12px; gap: 10px; }
1066
+ #star-reminder > span { flex-basis: calc(100% - 50px); font-size: 13px; }
1067
+ #star-reminder a { margin-left: 46px; }
1068
+ }
1069
+ @media (prefers-reduced-motion: reduce) {
1070
+ #star-reminder, #star-reminder::before { animation: none; }
1071
+ }
package/dist/web/app.js CHANGED
@@ -904,6 +904,82 @@ function zoomScene(state, factor, x, y) {
904
904
  };
905
905
  }
906
906
 
907
+ // src/web/star-reminder.ts
908
+ function createStarNotice(base2) {
909
+ let attempted = false;
910
+ const notice = document.getElementById("star-reminder");
911
+ const link = notice.querySelector("a");
912
+ let opening = false;
913
+ link.title = "\u5728\u7CFB\u7EDF\u9ED8\u8BA4\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00 GitHub";
914
+ const dismiss = () => {
915
+ notice.hidden = true;
916
+ };
917
+ notice.querySelector("button").addEventListener("click", dismiss);
918
+ const launch = async (event) => {
919
+ event.preventDefault();
920
+ if (opening) return;
921
+ opening = true;
922
+ link.setAttribute("aria-disabled", "true");
923
+ link.textContent = "\u6B63\u5728\u6253\u5F00\u2026";
924
+ notice.querySelector(".star-open-error")?.remove();
925
+ try {
926
+ const response = await fetch(new URL("api/star-reminder/open", base2), { method: "POST", signal: AbortSignal.timeout(8e3) });
927
+ const result = await response.json();
928
+ if (!response.ok || result.opened !== true) throw new Error("Not opened");
929
+ dismiss();
930
+ } catch {
931
+ const error = document.createElement("div");
932
+ error.className = "star-open-error";
933
+ error.setAttribute("role", "status");
934
+ const message = document.createElement("span");
935
+ message.textContent = "\u9ED8\u8BA4\u6D4F\u89C8\u5668\u672A\u80FD\u6253\u5F00\uFF0C\u53EF\u91CD\u8BD5\u6216\u590D\u5236\u5730\u5740\u3002";
936
+ const copy = document.createElement("button");
937
+ copy.type = "button";
938
+ copy.textContent = "\u590D\u5236 GitHub \u5730\u5740";
939
+ copy.addEventListener("click", () => {
940
+ void (async () => {
941
+ try {
942
+ await navigator.clipboard.writeText(link.href);
943
+ copy.textContent = "\u5DF2\u590D\u5236";
944
+ } catch {
945
+ const address = document.createElement("input");
946
+ address.readOnly = true;
947
+ address.value = link.href;
948
+ address.setAttribute("aria-label", "GitHub \u5730\u5740");
949
+ copy.replaceWith(address);
950
+ address.focus();
951
+ address.select();
952
+ }
953
+ })();
954
+ });
955
+ error.append(message, copy);
956
+ notice.append(error);
957
+ } finally {
958
+ opening = false;
959
+ link.removeAttribute("aria-disabled");
960
+ link.textContent = "\u53BB\u70B9 Star \u2197";
961
+ }
962
+ };
963
+ link.addEventListener("click", (event) => {
964
+ void launch(event);
965
+ });
966
+ link.addEventListener("auxclick", (event) => {
967
+ if (event.button === 1) void launch(event);
968
+ });
969
+ return (page) => {
970
+ if (attempted || document.visibilityState !== "visible") return;
971
+ attempted = true;
972
+ const url = new URL("api/star-reminder/visit", base2);
973
+ if (page) url.searchParams.set("page", page);
974
+ void fetch(url, { method: "POST", signal: AbortSignal.timeout(3e3) }).then(async (response) => {
975
+ if (!response.ok) return;
976
+ const result = await response.json();
977
+ if (result.show === true && document.visibilityState === "visible") notice.hidden = false;
978
+ }).catch(() => {
979
+ });
980
+ };
981
+ }
982
+
907
983
  // src/web/app.ts
908
984
  var element = (id) => document.getElementById(id);
909
985
  var viewport = element("viewport");
@@ -913,6 +989,7 @@ var pages = element("pages");
913
989
  var search = element("search");
914
990
  var dialog = element("dialog");
915
991
  var base = new URL(".", window.location.href);
992
+ var starNotice = createStarNotice(base);
916
993
  var kinds = { dev: "\u5F00\u53D1\u5730\u56FE", architecture: "\u67B6\u6784\u5730\u56FE", dataflow: "\u6570\u636E\u6D41", "behavior-tree": "\u884C\u4E3A\u6811", sequence: "\u65F6\u5E8F\u5730\u56FE" };
917
994
  var snapshot = { project: "", pages: [] };
918
995
  var revision = "";
@@ -1340,6 +1417,7 @@ async function poll() {
1340
1417
  }
1341
1418
  if (initial || JSON.stringify(priorPage) !== JSON.stringify(current())) render();
1342
1419
  else renderHeading();
1420
+ if ((current()?.map?.nodes.length ?? 0) > 0 && !current()?.error) starNotice(currentId);
1343
1421
  element("updated").textContent = `\u5DF2\u540C\u6B65 ${(/* @__PURE__ */ new Date()).toLocaleTimeString("zh-CN", { hour12: false })}`;
1344
1422
  }
1345
1423
  }
@@ -5,5 +5,5 @@
5
5
  <section class="map-heading"><div class="project-line"><span id="project">本地项目</span><span>/</span><span id="kind">开发地图</span></div><div class="title-row"><h1 id="title">正在读取地图…</h1><select id="pages" aria-label="切换地图页面"></select></div><div id="breadcrumbs"></div><div class="progress-row"><span id="summary"></span><div id="progress"><i></i></div><button id="follow" class="follow active" aria-pressed="true">● 自动跟随</button></div></section>
6
6
  <section class="toolbar"><label class="search"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8" cy="8" r="5.5"/><path d="m12 12 5 5"/></svg><input id="search" placeholder="搜索模块、说明…" aria-label="搜索模块"><kbd>/</kbd></label><button id="overview" class="tool-button" aria-pressed="false">分组概览</button><button id="export" class="icon-button" title="导出 SVG" aria-label="导出 SVG">↓</button><button id="delete" class="icon-button" title="删除当前地图" aria-label="删除当前地图">×</button></section>
7
7
  <section class="canvas-shell"><div id="viewport" tabindex="0" aria-label="交互地图画布"><div id="stage"></div><div id="empty" hidden></div></div><div id="filters" class="filters" aria-label="按状态筛选"><button data-status="all" class="active">全部</button><button data-status="planned"><i class="planned"></i>待开发</button><button data-status="in-progress"><i class="in-progress"></i>进行中</button><button data-status="done"><i class="done"></i>已验证</button><button data-status="regressed"><i class="regressed"></i>回归</button><button data-status="unverified"><i class="unverified"></i>待验证</button></div><div class="zoom-controls"><button id="zoom-out" aria-label="缩小">−</button><output id="zoom-label">100%</output><button id="zoom-in" aria-label="放大">+</button><span></span><button id="fit" title="适应窗口 · 0" aria-label="适应窗口">⛶</button></div><div id="minimap" title="点击移动视口"></div><div id="match-count" role="status"></div></section>
8
- <div id="divider" role="separator" aria-label="调整详情高度" aria-orientation="horizontal" aria-valuemin="100" aria-valuemax="500" aria-valuenow="210" tabindex="0"><i></i></div><aside id="inspector"></aside><footer><span>滚轮缩放 · 拖动画布 · 双击进入子地图</span><span id="updated">等待同步</span></footer>
8
+ <div id="divider" role="separator" aria-label="调整详情高度" aria-orientation="horizontal" aria-valuemin="100" aria-valuemax="500" aria-valuenow="210" tabindex="0"><i></i></div><aside id="inspector"></aside><footer><span>滚轮缩放 · 拖动画布 · 双击进入子地图</span><span id="updated">等待同步</span><aside id="star-reminder" aria-label="支持 Mellos Mapping" hidden><span>地图有帮到你?欢迎在 GitHub 点个 Star。</span><a href="https://github.com/GuangminJu/mellos-mapping" target="_blank" rel="noopener noreferrer">去点 Star ↗</a><button type="button">不再提醒</button></aside></footer>
9
9
  </main><div id="toast" role="status" hidden></div><dialog id="dialog"><h2 id="dialog-title"></h2><p id="dialog-body"></p><div class="dialog-actions"><button id="dialog-cancel">关闭</button><button id="dialog-confirm" class="danger" hidden>删除此地图</button></div></dialog></body></html>
@@ -7,3 +7,106 @@ header{display:flex;align-items:center;justify-content:space-between;gap:12px;pa
7
7
  footer{min-height:32px;display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:5px 14px;border-top:1px solid #283343;color:#8c9eb5;font-size:11px}#connection{overflow-wrap:anywhere}#connection[data-state="connected"]{color:#81dbae}#connection[data-state="error"]{color:#ffaf87}.hint{margin-left:auto}[hidden]{display:none!important}
8
8
  @media(max-width:440px){.brand span{margin-left:6px}.hint{margin-left:0;width:100%}header{padding:0 10px}#terminal .xterm{padding-left:4px;padding-right:4px}}
9
9
  dialog{max-width:min(420px,90vw);border:1px solid #3b4b60;border-radius:10px;background:#151c25;color:#d9e2ed;padding:24px}dialog::backdrop{background:#0009}dialog h2{font-size:18px;margin-top:0}dialog p{line-height:1.9;font-size:13px}dialog form{text-align:right}
10
+
11
+ #star-reminder {
12
+ --star-surface: linear-gradient(115deg, #15302e, #202341 48%, #36243b);
13
+ --star-text: #f2f5ff;
14
+ --star-muted: #c9d2e5;
15
+ width: 100%;
16
+ display: flex;
17
+ flex-wrap: wrap;
18
+ align-items: center;
19
+ gap: 10px 14px;
20
+ margin: 4px 0 8px;
21
+ padding: 12px 16px;
22
+ border: 1px solid transparent;
23
+ border-radius: 12px;
24
+ background: var(--star-surface) padding-box,
25
+ linear-gradient(110deg, #69ebc9, #8299ff, #d897ff, #ffc17d, #69ebc9) border-box;
26
+ background-size: 200% 100%;
27
+ color: var(--star-text);
28
+ font-size: 14px;
29
+ line-height: 1.5;
30
+ animation: star-color-flow 6s ease-in-out 2, star-glow 3s ease-in-out 4;
31
+ }
32
+ #star-reminder::before {
33
+ content: "★";
34
+ display: grid;
35
+ place-items: center;
36
+ flex: 0 0 36px;
37
+ height: 36px;
38
+ border: 1px solid #ffe49950;
39
+ border-radius: 10px;
40
+ background: #f9c76916;
41
+ color: #ffe49b;
42
+ font-size: 26px;
43
+ line-height: 1;
44
+ text-shadow: 0 0 14px #ffd47870;
45
+ animation: star-beat 3s ease-in-out 4;
46
+ }
47
+ #star-reminder > span { flex: 1 1 230px; font-weight: 550; }
48
+ #star-reminder a {
49
+ display: inline-flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ min-height: 36px;
53
+ padding: 7px 16px;
54
+ border: 1px solid #ffffff50;
55
+ border-radius: 8px;
56
+ background: linear-gradient(110deg, #8bf0d0, #babaff 55%, #ffd0aa);
57
+ color: #17243a;
58
+ font-size: 13px;
59
+ font-weight: 750;
60
+ line-height: 1.4;
61
+ text-decoration: none;
62
+ white-space: nowrap;
63
+ box-shadow: 0 2px 12px #8b96ff25;
64
+ }
65
+ #star-reminder a:hover { filter: brightness(1.1); }
66
+ #star-reminder a[aria-disabled=true] { cursor: progress; opacity: .8; }
67
+ #star-reminder .star-open-error { flex: 1 1 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: var(--star-muted); }
68
+ #star-reminder .star-open-error input { min-width: 0; max-width: 100%; width: 340px; padding: 6px; color: var(--star-text); background: transparent; border: 1px solid #b8c5e470; border-radius: 6px; }
69
+ #star-reminder button {
70
+ min-height: 34px;
71
+ padding: 6px 10px;
72
+ border: 1px solid #b8c5e42e;
73
+ border-radius: 8px;
74
+ background: transparent;
75
+ color: var(--star-muted);
76
+ font-size: 12px;
77
+ line-height: 1.4;
78
+ white-space: nowrap;
79
+ }
80
+ #star-reminder button:hover { background: #a0b5f014; border-color: #b8c5e470; }
81
+ #star-reminder a:focus-visible, #star-reminder button:focus-visible {
82
+ outline: 2px solid #b0b9ff;
83
+ outline-offset: 3px;
84
+ }
85
+ #star-reminder:hover, #star-reminder:focus-within,
86
+ #star-reminder:hover::before, #star-reminder:focus-within::before { animation-play-state: paused; }
87
+ [data-theme=light] #star-reminder {
88
+ --star-surface: linear-gradient(115deg, #e4fbf5, #eeedff 48%, #fff0e5);
89
+ --star-text: #263451;
90
+ --star-muted: #53607b;
91
+ }
92
+ [data-theme=light] #star-reminder::before { color: #a5660c; background: #ffe6aa80; border-color: #dbae6340; text-shadow: none; }
93
+ @keyframes star-color-flow {
94
+ 0%, 100% { background-position: 0% 50%; }
95
+ 50% { background-position: 100% 50%; }
96
+ }
97
+ @keyframes star-glow {
98
+ 0%, 100% { box-shadow: 0 0 0 #8299ff00; }
99
+ 50% { box-shadow: 0 0 16px #8299ff30, inset 0 0 18px #b897ff0c; }
100
+ }
101
+ @keyframes star-beat {
102
+ 0%, 100% { transform: scale(1) rotate(0deg); }
103
+ 50% { transform: scale(1.1) rotate(-7deg); }
104
+ }
105
+ @media (max-width: 520px) {
106
+ #star-reminder { padding: 12px; gap: 10px; }
107
+ #star-reminder > span { flex-basis: calc(100% - 50px); font-size: 13px; }
108
+ #star-reminder a { margin-left: 46px; }
109
+ }
110
+ @media (prefers-reduced-motion: reduce) {
111
+ #star-reminder, #star-reminder::before { animation: none; }
112
+ }
@@ -2,6 +2,6 @@
2
2
  <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Mellos · 终端地图</title><link rel="icon" href="data:,"><link rel="stylesheet" href="./xterm.css"><link rel="stylesheet" href="./terminal.css"><script type="module" src="./terminal.js"></script></head>
3
3
  <body><header><a class="brand" id="graphic" href="./" title="切换到图形地图">Mellos <span>图形地图 ↗</span></a><div class="tools"><label for="font-size">字号</label><select id="font-size" aria-label="终端字号"><option>10</option><option>11</option><option>12</option><option selected>13</option><option>14</option><option>15</option><option>16</option><option>18</option><option>20</option><option>24</option><option>28</option></select><button id="help" aria-label="操作帮助">?</button></div></header>
4
4
  <main id="terminal" aria-label="Mellos 终端地图"></main>
5
- <footer><span id="connection" role="status" aria-live="polite">正在连接…</span><button id="restart" hidden>重新连接</button><span class="hint">滚轮缩放 · 拖动平移 · 点击选择 · ? 帮助</span></footer>
5
+ <footer><span id="connection" role="status" aria-live="polite">正在连接…</span><button id="restart" hidden>重新连接</button><span class="hint">滚轮缩放 · 拖动平移 · 点击选择 · ? 帮助</span><aside id="star-reminder" aria-label="支持 Mellos Mapping" hidden><span>地图有帮到你?欢迎在 GitHub 点个 Star。</span><a href="https://github.com/GuangminJu/mellos-mapping" target="_blank" rel="noopener noreferrer">去点 Star ↗</a><button type="button">不再提醒</button></aside></footer>
6
6
  <dialog id="help-dialog"><h2>终端地图操作</h2><p>滚轮 / + / −:总览 → 模块 → 细节<br>拖动:平移 · 单击:固定详情<br>双击带 ⊞ 的节点:进入子地图<br>Backspace:返回父地图<br>Tab / 数字 1–9:切换页面<br>f:自动跟随 · 0:重置视图<br>x 按两次:确认删除当前页<br>q:关闭地图,可用「重新连接」再次打开</p><p>右上角字号只影响此网页终端。</p><form method="dialog"><button>关闭帮助</button></form></dialog>
7
7
  </body></html>
@@ -9137,6 +9137,82 @@ var o = class {
9137
9137
  }
9138
9138
  };
9139
9139
 
9140
+ // src/web/star-reminder.ts
9141
+ function createStarNotice(base2) {
9142
+ let attempted = false;
9143
+ const notice = document.getElementById("star-reminder");
9144
+ const link = notice.querySelector("a");
9145
+ let opening = false;
9146
+ link.title = "\u5728\u7CFB\u7EDF\u9ED8\u8BA4\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00 GitHub";
9147
+ const dismiss = () => {
9148
+ notice.hidden = true;
9149
+ };
9150
+ notice.querySelector("button").addEventListener("click", dismiss);
9151
+ const launch = async (event) => {
9152
+ event.preventDefault();
9153
+ if (opening) return;
9154
+ opening = true;
9155
+ link.setAttribute("aria-disabled", "true");
9156
+ link.textContent = "\u6B63\u5728\u6253\u5F00\u2026";
9157
+ notice.querySelector(".star-open-error")?.remove();
9158
+ try {
9159
+ const response = await fetch(new URL("api/star-reminder/open", base2), { method: "POST", signal: AbortSignal.timeout(8e3) });
9160
+ const result = await response.json();
9161
+ if (!response.ok || result.opened !== true) throw new Error("Not opened");
9162
+ dismiss();
9163
+ } catch {
9164
+ const error = document.createElement("div");
9165
+ error.className = "star-open-error";
9166
+ error.setAttribute("role", "status");
9167
+ const message = document.createElement("span");
9168
+ message.textContent = "\u9ED8\u8BA4\u6D4F\u89C8\u5668\u672A\u80FD\u6253\u5F00\uFF0C\u53EF\u91CD\u8BD5\u6216\u590D\u5236\u5730\u5740\u3002";
9169
+ const copy = document.createElement("button");
9170
+ copy.type = "button";
9171
+ copy.textContent = "\u590D\u5236 GitHub \u5730\u5740";
9172
+ copy.addEventListener("click", () => {
9173
+ void (async () => {
9174
+ try {
9175
+ await navigator.clipboard.writeText(link.href);
9176
+ copy.textContent = "\u5DF2\u590D\u5236";
9177
+ } catch {
9178
+ const address = document.createElement("input");
9179
+ address.readOnly = true;
9180
+ address.value = link.href;
9181
+ address.setAttribute("aria-label", "GitHub \u5730\u5740");
9182
+ copy.replaceWith(address);
9183
+ address.focus();
9184
+ address.select();
9185
+ }
9186
+ })();
9187
+ });
9188
+ error.append(message, copy);
9189
+ notice.append(error);
9190
+ } finally {
9191
+ opening = false;
9192
+ link.removeAttribute("aria-disabled");
9193
+ link.textContent = "\u53BB\u70B9 Star \u2197";
9194
+ }
9195
+ };
9196
+ link.addEventListener("click", (event) => {
9197
+ void launch(event);
9198
+ });
9199
+ link.addEventListener("auxclick", (event) => {
9200
+ if (event.button === 1) void launch(event);
9201
+ });
9202
+ return (page2) => {
9203
+ if (attempted || document.visibilityState !== "visible") return;
9204
+ attempted = true;
9205
+ const url = new URL("api/star-reminder/visit", base2);
9206
+ if (page2) url.searchParams.set("page", page2);
9207
+ void fetch(url, { method: "POST", signal: AbortSignal.timeout(3e3) }).then(async (response) => {
9208
+ if (!response.ok) return;
9209
+ const result = await response.json();
9210
+ if (result.show === true && document.visibilityState === "visible") notice.hidden = false;
9211
+ }).catch(() => {
9212
+ });
9213
+ };
9214
+ }
9215
+
9140
9216
  // src/web/terminal-app.ts
9141
9217
  var element = (id) => document.getElementById(id);
9142
9218
  var container = element("terminal");
@@ -9145,6 +9221,7 @@ var restart = element("restart");
9145
9221
  var font = element("font-size");
9146
9222
  var graphic = element("graphic");
9147
9223
  var base = new URL(".", location.href);
9224
+ var starNotice = createStarNotice(base);
9148
9225
  var page = new URL(location.href).searchParams.get("page") ?? void 0;
9149
9226
  var socket;
9150
9227
  var retryTimer;
@@ -9264,6 +9341,7 @@ function connect() {
9264
9341
  } else if (message.type === "view") {
9265
9342
  page = message.page;
9266
9343
  syncPage();
9344
+ starNotice(page);
9267
9345
  } else if (message.type === "error") setStatus(message.message, "error");
9268
9346
  });
9269
9347
  connection.addEventListener("close", (event) => {
package/dist/web.mjs CHANGED
@@ -3703,9 +3703,10 @@ var require_websocket_server = __commonJS({
3703
3703
  });
3704
3704
 
3705
3705
  // src/web/cli.ts
3706
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync5, realpathSync as realpathSync2, rmSync as rmSync4, statSync as statSync2 } from "node:fs";
3707
- import { dirname as dirname8, join as join6, resolve as resolve2 } from "node:path";
3706
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync6, realpathSync as realpathSync3, rmSync as rmSync4, statSync as statSync2 } from "node:fs";
3707
+ import { dirname as dirname9, join as join7, resolve as resolve2 } from "node:path";
3708
3708
  import { fileURLToPath, pathToFileURL } from "node:url";
3709
+ import { homedir } from "node:os";
3709
3710
 
3710
3711
  // src/domain/types.ts
3711
3712
  var ok = (value) => ({ ok: true, value });
@@ -5435,12 +5436,117 @@ function attachTerminalService(server, options) {
5435
5436
  };
5436
5437
  }
5437
5438
 
5439
+ // src/support/open-star.ts
5440
+ import { spawn as spawn3 } from "node:child_process";
5441
+ import { win32 } from "node:path";
5442
+
5443
+ // src/support/star-reminder.ts
5444
+ import { existsSync as existsSync4, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "node:fs";
5445
+ import { basename as basename3, dirname as dirname8, join as join6 } from "node:path";
5446
+ var STAR_URL = "https://github.com/GuangminJu/mellos-mapping";
5447
+ var DAY_MS = 864e5;
5448
+ function isNpmInstallation(entry) {
5449
+ try {
5450
+ const root = dirname8(dirname8(realpathSync2(entry)));
5451
+ if (basename3(dirname8(root)).toLowerCase() !== "node_modules" || existsSync4(join6(root, ".git")) || existsSync4(join6(root, ".codex-plugin")) || existsSync4(join6(root, ".claude-plugin"))) return false;
5452
+ const pkg = JSON.parse(readFileSync5(join6(root, "package.json"), "utf8"));
5453
+ return pkg.name === "mellos-mapping" && pkg.bin?.mmap === "dist/mmap.mjs";
5454
+ } catch {
5455
+ return false;
5456
+ }
5457
+ }
5458
+ function starReminderFile(userBase) {
5459
+ return join6(userBase, ".mellos", "support", "star-reminder.json");
5460
+ }
5461
+ function enabledFlag(value) {
5462
+ return !!value && value !== "0" && value.toLowerCase() !== "false";
5463
+ }
5464
+ function validState(value) {
5465
+ if (!value || typeof value !== "object") return false;
5466
+ const s = value;
5467
+ return s.version === 1 && Number.isFinite(s.firstSeen) && s.firstSeen >= 0 && Number.isSafeInteger(s.lastDay) && s.lastDay >= Math.floor(s.firstSeen / DAY_MS) && Number.isSafeInteger(s.days) && s.days >= 1 && s.days <= 3 && typeof s.notified === "boolean";
5468
+ }
5469
+ function createStarReminder(options) {
5470
+ const eligible = isNpmInstallation(options.entry) && !enabledFlag(options.env["CI"]) && !enabledFlag(options.env["MELLOS_MAPPING_NO_STAR"]);
5471
+ const file = starReminderFile(options.userBase);
5472
+ return {
5473
+ visit() {
5474
+ if (!eligible) return false;
5475
+ try {
5476
+ return withStoreLock(file, () => {
5477
+ const now = (options.now ?? Date.now)();
5478
+ if (!Number.isFinite(now) || now < 0) return false;
5479
+ const day = Math.floor(now / DAY_MS);
5480
+ let state;
5481
+ try {
5482
+ const saved2 = JSON.parse(readFileSync5(file, "utf8"));
5483
+ if (!validState(saved2)) return false;
5484
+ state = saved2;
5485
+ } catch (error) {
5486
+ if (error.code !== "ENOENT") return false;
5487
+ state = { version: 1, firstSeen: now, lastDay: day, days: 1, notified: false };
5488
+ }
5489
+ if (state.notified || now < state.firstSeen || day < state.lastDay) return false;
5490
+ if (day > state.lastDay) {
5491
+ state.days = Math.min(3, state.days + 1);
5492
+ state.lastDay = day;
5493
+ }
5494
+ const show = state.days >= 3 && now - state.firstSeen >= 7 * DAY_MS;
5495
+ if (show) state.notified = true;
5496
+ const saved = writeAtomic(file, JSON.stringify(state, null, 2) + "\n", 1);
5497
+ return saved.ok && show;
5498
+ });
5499
+ } catch {
5500
+ return false;
5501
+ }
5502
+ }
5503
+ };
5504
+ }
5505
+
5506
+ // src/support/open-star.ts
5507
+ function starBrowserCommand(platform, windowsRoot = "C:\\Windows") {
5508
+ if (platform === "win32") return {
5509
+ file: win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
5510
+ args: [
5511
+ "-NoLogo",
5512
+ "-NoProfile",
5513
+ "-NonInteractive",
5514
+ "-WindowStyle",
5515
+ "Hidden",
5516
+ "-Command",
5517
+ `$ErrorActionPreference = 'Stop'; Start-Process -FilePath '${STAR_URL}'`
5518
+ ]
5519
+ };
5520
+ if (platform === "darwin") return { file: "/usr/bin/open", args: [STAR_URL] };
5521
+ if (platform === "linux") return { file: "xdg-open", args: [STAR_URL] };
5522
+ throw new Error("No supported desktop browser launcher");
5523
+ }
5524
+ function openStarInDefaultBrowser() {
5525
+ const command = starBrowserCommand(process.platform, process.env["SystemRoot"]);
5526
+ return new Promise((resolve3, reject) => {
5527
+ const child = spawn3(command.file, command.args, { windowsHide: true, stdio: "ignore" });
5528
+ let finished = false;
5529
+ const finish = (error) => {
5530
+ if (finished) return;
5531
+ finished = true;
5532
+ clearTimeout(deadline);
5533
+ child.unref();
5534
+ if (error) reject(error);
5535
+ else resolve3();
5536
+ };
5537
+ const deadline = setTimeout(() => finish(), 5e3);
5538
+ child.once("error", (error) => finish(error));
5539
+ child.once("exit", (code) => finish(code === 0 ? void 0 : new Error("Default browser launch failed")));
5540
+ });
5541
+ }
5542
+
5438
5543
  // src/web/service.ts
5439
5544
  async function startWebService(defaultFile, assets, options = {}) {
5440
5545
  const token = options.token ?? randomBytes(24).toString("hex");
5441
5546
  if (!/^[a-f0-9]{48}$/.test(token)) throw new Error("Invalid web token");
5442
5547
  const prefix = `/${token}/`;
5443
5548
  let origin = "", lastRequest = Date.now(), closed = false;
5549
+ let openingStar = false;
5444
5550
  const send = (res, code, body, type = "application/json; charset=utf-8") => {
5445
5551
  res.writeHead(code, {
5446
5552
  "Content-Type": type,
@@ -5492,6 +5598,36 @@ async function startWebService(defaultFile, assets, options = {}) {
5492
5598
  send(res, 200, JSON.stringify({ file: defaultFile, pid: process.pid, formats: [1, 2], surfaces: terminalService ? ["web", "web-terminal"] : ["web"] }));
5493
5599
  return;
5494
5600
  }
5601
+ if (req.method === "POST" && route === "api/star-reminder/open") {
5602
+ if (req.headers.origin !== origin) {
5603
+ send(res, 403, '{"error":"Origin refused"}');
5604
+ return;
5605
+ }
5606
+ if (req.url !== `${prefix}api/star-reminder/open` || req.headers["transfer-encoding"] || Number(req.headers["content-length"] ?? 0) !== 0) {
5607
+ send(res, 400, '{"error":"No parameters accepted"}');
5608
+ return;
5609
+ }
5610
+ if (openingStar) {
5611
+ send(res, 409, '{"error":"Browser is already opening"}');
5612
+ return;
5613
+ }
5614
+ openingStar = true;
5615
+ void Promise.resolve().then(options.openStar ?? openStarInDefaultBrowser).then(() => {
5616
+ send(res, 200, '{"opened":true}');
5617
+ }, () => {
5618
+ send(res, 503, '{"error":"\u65E0\u6CD5\u6253\u5F00\u9ED8\u8BA4\u6D4F\u89C8\u5668"}');
5619
+ }).finally(() => {
5620
+ openingStar = false;
5621
+ });
5622
+ return;
5623
+ }
5624
+ if (req.method === "POST" && route === "api/star-reminder/visit") {
5625
+ const requested = new URL(req.url, origin).searchParams.get("page");
5626
+ const id = !requested || requested === "_default" ? "" : requested;
5627
+ const hasMap = options.starReminder && readWebSnapshot(defaultFile).value.pages.some((page) => page.id === id && !page.error && (page.map?.nodes.length ?? 0) > 0);
5628
+ send(res, 200, JSON.stringify({ show: !!hasMap && options.starReminder.visit() }));
5629
+ return;
5630
+ }
5495
5631
  if (req.method === "GET" && route === "api/state") {
5496
5632
  const snapshot = readWebSnapshot(defaultFile);
5497
5633
  const etag = `"${snapshot.revision}"`;
@@ -5582,19 +5718,19 @@ async function runWeb(args) {
5582
5718
  if (args[0] === "--serve" && args.length === 2) {
5583
5719
  const file2 = resolve2(args[1]);
5584
5720
  if (await runningWebUrl(file2)) return;
5585
- const directory = dirname8(webRuntimeFile(file2));
5721
+ const directory = dirname9(webRuntimeFile(file2));
5586
5722
  mkdirSync4(directory, { recursive: true });
5587
- if (realpathSync2(directory).toLowerCase() !== join6(realpathSync2(dirname8(directory)), "web").toLowerCase()) throw new Error("Redirected web runtime directory");
5588
- const assets = join6(dirname8(entry), "web");
5723
+ if (realpathSync3(directory).toLowerCase() !== join7(realpathSync3(dirname9(directory)), "web").toLowerCase()) throw new Error("Redirected web runtime directory");
5724
+ const assets = join7(dirname9(entry), "web");
5589
5725
  let service;
5590
5726
  service = await startWebService(file2, {
5591
- html: readFileSync5(join6(assets, "index.html"), "utf8"),
5592
- javascript: readFileSync5(join6(assets, "app.js"), "utf8"),
5593
- css: readFileSync5(join6(assets, "app.css"), "utf8"),
5594
- terminal: { html: readFileSync5(join6(assets, "terminal.html"), "utf8"), javascript: readFileSync5(join6(assets, "terminal.js"), "utf8"), css: readFileSync5(join6(assets, "terminal.css"), "utf8"), xtermCss: readFileSync5(join6(assets, "xterm.css"), "utf8") }
5595
- }, { terminalWorker: join6(dirname8(entry), "terminal-worker.mjs"), onClose: () => {
5727
+ html: readFileSync6(join7(assets, "index.html"), "utf8"),
5728
+ javascript: readFileSync6(join7(assets, "app.js"), "utf8"),
5729
+ css: readFileSync6(join7(assets, "app.css"), "utf8"),
5730
+ terminal: { html: readFileSync6(join7(assets, "terminal.html"), "utf8"), javascript: readFileSync6(join7(assets, "terminal.js"), "utf8"), css: readFileSync6(join7(assets, "terminal.css"), "utf8"), xtermCss: readFileSync6(join7(assets, "xterm.css"), "utf8") }
5731
+ }, { starReminder: createStarReminder({ entry, userBase: homedir(), env: process.env }), terminalWorker: join7(dirname9(entry), "terminal-worker.mjs"), onClose: () => {
5596
5732
  try {
5597
- if (JSON.parse(readFileSync5(webRuntimeFile(file2), "utf8")).token === service.token) rmSync4(webRuntimeFile(file2));
5733
+ if (JSON.parse(readFileSync6(webRuntimeFile(file2), "utf8")).token === service.token) rmSync4(webRuntimeFile(file2));
5598
5734
  } catch {
5599
5735
  }
5600
5736
  } });
@@ -5625,8 +5761,8 @@ async function runWeb(args) {
5625
5761
  else throw new Error(usage);
5626
5762
  }
5627
5763
  const project = resolve2(args[0]);
5628
- if (!existsSync4(project) || !statSync2(project).isDirectory()) throw new Error(`Project directory does not exist: ${project}`);
5629
- const file = join6(project, STATE_FILE_RELATIVE_PATH);
5764
+ if (!existsSync5(project) || !statSync2(project).isDirectory()) throw new Error(`Project directory does not exist: ${project}`);
5765
+ const file = join7(project, STATE_FILE_RELATIVE_PATH);
5630
5766
  if (stop) {
5631
5767
  const url = await runningWebUrl(file);
5632
5768
  if (url) await fetch(`${url}api/stop`, { method: "POST", signal: AbortSignal.timeout(2e3) });
@@ -5635,7 +5771,7 @@ async function runWeb(args) {
5635
5771
  }
5636
5772
  console.log(JSON.stringify({ surface: terminal ? "web-terminal" : "web", url: await openWebPreview(file, entry, page, terminal), visibility: "unconfirmed" }));
5637
5773
  }
5638
- if (process.argv[1] && existsSync4(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync2(process.argv[1])).href) {
5774
+ if (process.argv[1] && existsSync5(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync3(process.argv[1])).href) {
5639
5775
  runWeb(process.argv.slice(2)).catch((error) => {
5640
5776
  console.error(String(error));
5641
5777
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "mcpName": "io.github.GuangminJu/mellos-mapping",
5
5
  "description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
6
6
  "type": "module",