mellos-mapping 0.25.0 → 0.26.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 +54 -2
- package/README.zh-CN.md +47 -1
- package/dist/hook-session-start.mjs +68 -12
- package/dist/omp-extension.mjs +359 -0
- package/dist/server.mjs +2 -2
- package/package.json +6 -1
- package/scripts/install-mmap-command.mjs +179 -13
package/README.md
CHANGED
|
@@ -94,6 +94,32 @@ committed, so a clone runs as-is —
|
|
|
94
94
|
`dist/store-paths.mjs` (the store's path vocabulary, which the plain-node pane
|
|
95
95
|
launcher imports instead of restating filenames).
|
|
96
96
|
|
|
97
|
+
**omp (Oh My Pi)** reads the same plugin layout the Claude Code edition ships —
|
|
98
|
+
the same marketplace catalog, `.mcp.json`, skill and slash command — so one
|
|
99
|
+
release serves both hosts:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
omp plugin marketplace add GuangminJu/mellos-mapping
|
|
103
|
+
omp plugin install mellos-mapping@mellos-mapping
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
That install follows `main`, where a release is a version bump. To freeze a
|
|
107
|
+
version instead, add a checkout of one — the Claude Code edition ZIP from the
|
|
108
|
+
GitHub Release, or a clone at the release tag:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
omp plugin marketplace add "<checkout or extracted release directory>"
|
|
112
|
+
omp plugin install mellos-mapping@mellos-mapping
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
omp never reads `hooks/hooks.json`, so the session paragraph arrives through the
|
|
116
|
+
plugin's omp host adapter (`dist/omp-extension.mjs`, declared in
|
|
117
|
+
`package.json#omp.extensions`): the same store, the same policy text, and the
|
|
118
|
+
same `mmap` shim install as the Claude hook. The pane is the same terminal
|
|
119
|
+
split beside the session. Install it *as a plugin*: neither `omp plugin link`
|
|
120
|
+
nor the npm package is a plugin, and both would leave the tools behind. See
|
|
121
|
+
[omp installation and limitations](docs/distributions/omp.md).
|
|
122
|
+
|
|
97
123
|
The first session after installing asks you **one** question — how eager
|
|
98
124
|
mapping should be — and records the answer for every project you will ever
|
|
99
125
|
open. From then on the hook carries it into each new session by itself; there
|
|
@@ -110,8 +136,14 @@ Terminal inherits the old environment, so close the terminal app entirely and
|
|
|
110
136
|
reopen it before the first `mmap`. The PATH edit keeps the
|
|
111
137
|
installer's guarantees: nothing happens when the entry is already there, and
|
|
112
138
|
a PATH that `setx` would damage (flattened `%VARIABLE%` references, truncation
|
|
113
|
-
past its limit) is refused outright
|
|
114
|
-
|
|
139
|
+
past its limit) is refused outright.
|
|
140
|
+
|
|
141
|
+
A refused PATH is not a dead end: the same two shims are then written into
|
|
142
|
+
`%LOCALAPPDATA%\Microsoft\WindowsApps` (or `~/.local/bin`) — a directory your
|
|
143
|
+
PATH already names — so `mmap` is runnable in a new terminal with no PATH
|
|
144
|
+
change at all. Machines with a long PATH are exactly where that happens, and
|
|
145
|
+
where it matters most. The command takes the page to open as an argument
|
|
146
|
+
(`mmap omp-host-support`), and closes the pane when it is already open.
|
|
115
147
|
|
|
116
148
|
The step behind it is still a command of its own, for the cases the hook does
|
|
117
149
|
not cover — `--uninstall`, or re-adding a PATH entry you removed while the
|
|
@@ -136,6 +168,26 @@ marketplace clone — the first command is what actually pulls this repo.
|
|
|
136
168
|
Restart Claude Code to apply. Releases are version bumps on `main`.
|
|
137
169
|
(In-app, `/plugin` opens the same management UI.)
|
|
138
170
|
|
|
171
|
+
omp updates with its own two steps:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
omp plugin marketplace update mellos-mapping && omp plugin upgrade mellos-mapping@mellos-mapping
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Close the pane first (`q` in it): the watcher runs from the plugin copy, and on
|
|
178
|
+
Windows an open file cannot be renamed away, so an upgrade attempted while a
|
|
179
|
+
pane is open can fail with `EPERM` and leave the plugin cache empty — repaired
|
|
180
|
+
by `omp plugin install mellos-mapping@mellos-mapping --force`. Then: the first
|
|
181
|
+
command refreshes the catalog, the second reinstalls from it. omp also
|
|
182
|
+
refreshes a catalog entry it has not updated for a day at startup, unless
|
|
183
|
+
`marketplace.autoUpdate` is `off` — in the default `notify` mode that check
|
|
184
|
+
writes its finding to the debug log only, so `marketplace update` is the step
|
|
185
|
+
that makes an update visible. `upgrade` does not compare versions: it
|
|
186
|
+
force-reinstalls whatever the catalog names, which is why a release is
|
|
187
|
+
identified by its version bump rather than gated by it. Restart omp afterwards,
|
|
188
|
+
and reopen a pane still showing the old bundle (`q` in it, then `mmap_open`
|
|
189
|
+
again).
|
|
190
|
+
|
|
139
191
|
### Upgrading from 0.19
|
|
140
192
|
|
|
141
193
|
0.20 moved the store out of `.claude/` — the map belongs to this tool, not to
|
package/README.zh-CN.md
CHANGED
|
@@ -84,6 +84,30 @@ claude plugin marketplace add GuangminJu/mellos-mapping && claude plugin install
|
|
|
84
84
|
钩子),以及 `dist/store-paths.mjs`(存储的路径词汇;纯 node 的面板启动
|
|
85
85
|
脚本从这里导入,而不是自己抄一份文件名)。
|
|
86
86
|
|
|
87
|
+
**omp(Oh My Pi)** 读的是 Claude Code 版本用的同一套插件布局——同一份市场
|
|
88
|
+
目录、`.mcp.json`、技能与斜杠命令——一份发行包同时服务两个宿主:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
omp plugin marketplace add GuangminJu/mellos-mapping
|
|
92
|
+
omp plugin install mellos-mapping@mellos-mapping
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
这样装跟的是 `main`,而发布就是它上面的版本号提升。要冻结版本,就改把某个
|
|
96
|
+
版本的检出目录加为市场——GitHub Release 里的 Claude Code 版本 ZIP,或按
|
|
97
|
+
tag 检出的克隆:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
omp plugin marketplace add "<检出或解压出来的发行目录>"
|
|
101
|
+
omp plugin install mellos-mapping@mellos-mapping
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
omp 不读 `hooks/hooks.json`,所以那段会话说明由插件的 omp 宿主适配器送达
|
|
105
|
+
(`dist/omp-extension.mjs`,在 `package.json#omp.extensions` 里声明):同一个
|
|
106
|
+
存储、同一段策略文字,Windows 上的 `mmap` shim 也由它安装。面板仍旧是会话
|
|
107
|
+
旁边的终端分屏。请把它**当插件**安装——`omp plugin link` 与 npm 包都不是插
|
|
108
|
+
件,用它们装会只剩适配器、没有工具。见
|
|
109
|
+
[omp 安装与限制](docs/distributions/omp.md)。
|
|
110
|
+
|
|
87
111
|
装完之后的第一个会话只会问你**一个**问题——建图要多积极——并把答案记成
|
|
88
112
|
你以后打开的每一个项目的默认。此后钩子会自己把它带进每个新会话;再也没有
|
|
89
113
|
"每个项目设置一遍"这回事。见
|
|
@@ -97,7 +121,13 @@ PowerShell)和 `mmap`(git-bash)写进 `%LOCALAPPDATA%\mellos-mapping\bin`
|
|
|
97
121
|
所以第一次敲 `mmap` 之前要把终端应用整个关掉重开。PATH 的编辑保持安装器
|
|
98
122
|
原有的承诺:条目已经在里面
|
|
99
123
|
就什么都不做;遇到 `setx` 会损坏的 PATH(`%VARIABLE%` 被展平、超长被截
|
|
100
|
-
|
|
124
|
+
断),它干脆拒绝。
|
|
125
|
+
|
|
126
|
+
PATH 被拒绝不等于没命令可用:同样两个 shim 会再写一份到
|
|
127
|
+
`%LOCALAPPDATA%\Microsoft\WindowsApps`(或 `~/.local/bin`)——一个你的 PATH
|
|
128
|
+
本来就有名字的目录——于是**新终端里直接敲 `mmap` 就能用,完全不用改 PATH**。
|
|
129
|
+
PATH 很长的机器正是这种情况,也最需要它。命令可以带要打开的页名
|
|
130
|
+
(`mmap omp-host-support`);面板已经开着时再敲一次就是关掉它。
|
|
101
131
|
|
|
102
132
|
它背后的那一步仍然是个独立命令,留给钩子管不到的情形——`--uninstall`,
|
|
103
133
|
或者 shim 还在、PATH 条目却被你删掉之后重新加回去:
|
|
@@ -120,6 +150,22 @@ claude plugin marketplace update mellos-mapping && claude plugin update mellos-m
|
|
|
120
150
|
拉取本仓库的是第一条命令。重启 Claude Code 生效。发布即 `main` 分支
|
|
121
151
|
上的版本号提升。(在对话里输入 `/plugin` 也能打开同一个管理界面。)
|
|
122
152
|
|
|
153
|
+
omp 也有自己的两步:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
omp plugin marketplace update mellos-mapping && omp plugin upgrade mellos-mapping@mellos-mapping
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**先关面板**(面板里按 `q`):watcher 跑的就是插件副本里的文件,而 Windows 不
|
|
160
|
+
允许改名一个正被打开的文件——开着面板升级会以 `EPERM` 失败并把插件缓存清空
|
|
161
|
+
(旧副本已删、新副本未就位),用 `omp plugin install mellos-mapping@mellos-mapping
|
|
162
|
+
--force` 修回来。然后:第一条刷新市场目录,第二条按目录重装。omp 还会在启动时尽力刷新超过一天没更新过
|
|
163
|
+
的目录条目——除非 `marketplace.autoUpdate` 设为 `off`;默认的 `notify` 模式只把
|
|
164
|
+
"有可用更新"写进 debug 日志,所以让更新**看得见**的是 `marketplace update` 这一
|
|
165
|
+
步。`upgrade` 不比较版本:它按目录里现在指的内容强制重装,同版本号也会重下——
|
|
166
|
+
因此版本号提升是给发行**贴标签**,而不是送达的门槛。之后重启 omp,并把仍显示旧
|
|
167
|
+
运行文件的面板关掉重开(面板里按 `q`,再 `mmap_open`)。
|
|
168
|
+
|
|
123
169
|
### 从 0.19 升级
|
|
124
170
|
|
|
125
171
|
0.20 把地图存储从 `.claude/` 挪到了 `.mellos/`——地图属于这个工具,不属于
|
|
@@ -129,11 +129,47 @@ function sessionStartContext(input) {
|
|
|
129
129
|
function hasMap(stateFile) {
|
|
130
130
|
return existsSync(stateFile) || existsSync(join4(dirname4(stateFile), PAGES_DIR_NAME));
|
|
131
131
|
}
|
|
132
|
+
function mmapBinDir(localAppData) {
|
|
133
|
+
return join4(localAppData, "mellos-mapping", "bin");
|
|
134
|
+
}
|
|
132
135
|
function mmapShimFilePath(localAppData) {
|
|
133
|
-
return join4(localAppData, "
|
|
136
|
+
return join4(mmapBinDir(localAppData), "mmap.cmd");
|
|
137
|
+
}
|
|
138
|
+
function mmapFallbackDirs(localAppData, home) {
|
|
139
|
+
return [join4(localAppData, "Microsoft", "WindowsApps"), join4(home, ".local", "bin")];
|
|
140
|
+
}
|
|
141
|
+
function pathNames(rawPath, dir) {
|
|
142
|
+
const norm = (s) => s.trim().replace(/^"|"$/g, "").replace(/[\\/]+$/, "").toLowerCase();
|
|
143
|
+
return rawPath.split(";").some((entry) => entry.trim() !== "" && norm(entry) === norm(dir));
|
|
144
|
+
}
|
|
145
|
+
function readIfPresent(path) {
|
|
146
|
+
try {
|
|
147
|
+
return readFileSync2(path, "utf8");
|
|
148
|
+
} catch {
|
|
149
|
+
return void 0;
|
|
150
|
+
}
|
|
134
151
|
}
|
|
135
152
|
function mmapShimCurrent(shimContent, mmapPath) {
|
|
136
|
-
|
|
153
|
+
if (shimContent === void 0) return false;
|
|
154
|
+
return shimContent.includes(`"${mmapPath}"`) || shimContent.includes(`"${mmapPath.replaceAll("\\", "/")}"`);
|
|
155
|
+
}
|
|
156
|
+
function shimIsOurs(content) {
|
|
157
|
+
if (content.includes("mellos-mapping mmap shim")) return true;
|
|
158
|
+
const quoted = /"([^"]*mmap\.mjs)"/.exec(content)?.[1];
|
|
159
|
+
return quoted !== void 0 && /mellos-mapping/i.test(quoted);
|
|
160
|
+
}
|
|
161
|
+
function mmapCommandResolves(localAppData, mmapPath, environment, read, exists) {
|
|
162
|
+
if (pathNames(environment.path, mmapBinDir(localAppData))) return true;
|
|
163
|
+
if (environment.home === void 0 || environment.home === "") return false;
|
|
164
|
+
for (const dir of mmapFallbackDirs(localAppData, environment.home)) {
|
|
165
|
+
if (!exists(dir) || !pathNames(environment.path, dir)) continue;
|
|
166
|
+
const files = ["mmap.cmd", "mmap"].map((name) => read(join4(dir, name))).filter((content) => content !== void 0);
|
|
167
|
+
if (files.length === 0) continue;
|
|
168
|
+
const ours = files.filter((content) => shimIsOurs(content));
|
|
169
|
+
if (ours.length === 0) return true;
|
|
170
|
+
return ours.every((content) => mmapShimCurrent(content, mmapPath));
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
137
173
|
}
|
|
138
174
|
function installContextLine(outcome) {
|
|
139
175
|
if (typeof outcome !== "object" || outcome === null) return void 0;
|
|
@@ -150,11 +186,22 @@ function installContextLine(outcome) {
|
|
|
150
186
|
}
|
|
151
187
|
if (o.path === "refused" || o.path === "error") {
|
|
152
188
|
const reason = typeof o.reason === "string" ? o.reason : "the PATH edit failed";
|
|
189
|
+
const aliasDir = typeof o.alias === "object" && o.alias !== null && "dir" in o.alias && typeof o.alias.dir === "string" ? o.alias.dir : void 0;
|
|
190
|
+
if (aliasDir !== void 0) {
|
|
191
|
+
return [
|
|
192
|
+
`mellos-mapping: the \`mmap\` terminal command should work: its shims live in`,
|
|
193
|
+
`${o.binDir}, and \u2014 because the user PATH could not be changed (${reason}) \u2014 a second copy`,
|
|
194
|
+
`was written to ${aliasDir}, a directory PATH already names (so \`mmap\` resolves there unless`,
|
|
195
|
+
"something earlier in PATH claims the name first).",
|
|
196
|
+
"The user can therefore type `mmap` in a new terminal right now; the command opens the map",
|
|
197
|
+
"pane for the project it is typed in, or closes the open one."
|
|
198
|
+
].join("\n");
|
|
199
|
+
}
|
|
153
200
|
return [
|
|
154
201
|
`mellos-mapping: the \`mmap\` command's launcher was written to ${o.binDir},`,
|
|
155
202
|
`but the user PATH was NOT changed: ${reason}.`,
|
|
156
203
|
"If the user wants the `mmap` pane-toggle command, tell them to add that directory to",
|
|
157
|
-
'their user PATH (Settings > "Edit environment variables for
|
|
204
|
+
'their user PATH (Settings > "Edit environment variables for their account").'
|
|
158
205
|
].join("\n");
|
|
159
206
|
}
|
|
160
207
|
return void 0;
|
|
@@ -164,15 +211,18 @@ function ensureMmapCommand(pluginRoot) {
|
|
|
164
211
|
const localAppData = process.env["LOCALAPPDATA"];
|
|
165
212
|
if (localAppData === void 0 || localAppData === "") return void 0;
|
|
166
213
|
const mmapPath = join4(pluginRoot, "dist", "mmap.mjs");
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
214
|
+
const shim = readIfPresent(mmapShimFilePath(localAppData));
|
|
215
|
+
const resolves = mmapShimCurrent(shim, mmapPath) && mmapCommandResolves(
|
|
216
|
+
localAppData,
|
|
217
|
+
mmapPath,
|
|
218
|
+
{ path: process.env["PATH"] ?? "", home: process.env["USERPROFILE"] ?? process.env["HOME"] },
|
|
219
|
+
readIfPresent,
|
|
220
|
+
existsSync
|
|
221
|
+
);
|
|
222
|
+
if (resolves) return void 0;
|
|
223
|
+
const runtime = "bun" in process.versions ? "node" : process.execPath;
|
|
174
224
|
const run = spawnSync(
|
|
175
|
-
|
|
225
|
+
runtime,
|
|
176
226
|
[join4(pluginRoot, "scripts", "install-mmap-command.mjs"), "--json"],
|
|
177
227
|
{ encoding: "utf8", windowsHide: true, timeout: 15e3 }
|
|
178
228
|
);
|
|
@@ -236,12 +286,18 @@ if (launchedAsEntry(process.argv[1], import.meta.url)) {
|
|
|
236
286
|
main().catch(() => process.exit(0));
|
|
237
287
|
}
|
|
238
288
|
export {
|
|
289
|
+
ensureMmapCommand,
|
|
239
290
|
hasMap,
|
|
240
291
|
hookOutput,
|
|
241
292
|
installContextLine,
|
|
242
293
|
launchedAsEntry,
|
|
294
|
+
mmapBinDir,
|
|
295
|
+
mmapCommandResolves,
|
|
296
|
+
mmapFallbackDirs,
|
|
243
297
|
mmapShimCurrent,
|
|
244
298
|
mmapShimFilePath,
|
|
245
299
|
parseHookInput,
|
|
246
|
-
|
|
300
|
+
pathNames,
|
|
301
|
+
sessionStartContext,
|
|
302
|
+
shimIsOurs
|
|
247
303
|
};
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// src/host/omp/extension.ts
|
|
2
|
+
import { homedir as homedir2 } from "node:os";
|
|
3
|
+
import { dirname as dirname5, join as join5 } from "node:path";
|
|
4
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
5
|
+
|
|
6
|
+
// src/hook/session-start.ts
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync as readFileSync2, realpathSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname as dirname4, join as join4 } from "node:path";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
// src/domain/types.ts
|
|
14
|
+
var ok = (value) => ({ ok: true, value });
|
|
15
|
+
var err = (error) => ({ ok: false, error });
|
|
16
|
+
var RANK_MIN = 0;
|
|
17
|
+
var RANK_MAX = 99;
|
|
18
|
+
var RANK_RULE_TEXT = `an integer in ${RANK_MIN}..${RANK_MAX}, 0 = bottom / most primitive`;
|
|
19
|
+
|
|
20
|
+
// src/store/pages.ts
|
|
21
|
+
import { basename, dirname, join } from "node:path";
|
|
22
|
+
var STORE_DIR_NAME = ".mellos";
|
|
23
|
+
var STATE_FILE_RELATIVE_PATH = join(STORE_DIR_NAME, "map.json");
|
|
24
|
+
var PAGES_DIR_NAME = "pages";
|
|
25
|
+
|
|
26
|
+
// src/store/json-text.ts
|
|
27
|
+
function isRecord(v) {
|
|
28
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
29
|
+
}
|
|
30
|
+
function stripBom(text) {
|
|
31
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/store/policy.ts
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
37
|
+
var CONFIG_FILE_NAME = "config.json";
|
|
38
|
+
var CONFIG_FILE_VERSION = 1;
|
|
39
|
+
function configFilePath(defaultFile) {
|
|
40
|
+
return join2(dirname2(defaultFile), CONFIG_FILE_NAME);
|
|
41
|
+
}
|
|
42
|
+
function userConfigFilePath(userBase) {
|
|
43
|
+
return join2(userBase, STORE_DIR_NAME, CONFIG_FILE_NAME);
|
|
44
|
+
}
|
|
45
|
+
var MAPPING_POLICIES = ["always", "complex", "on-request"];
|
|
46
|
+
function makeMappingPolicy(raw) {
|
|
47
|
+
return MAPPING_POLICIES.includes(raw) ? ok(raw) : err({ kind: "invalid-policy", raw, allowed: MAPPING_POLICIES });
|
|
48
|
+
}
|
|
49
|
+
function describeMappingPolicy(policy) {
|
|
50
|
+
switch (policy) {
|
|
51
|
+
case "always":
|
|
52
|
+
return "map every structured task \u2014 workflows, designs, architecture, technical dependencies";
|
|
53
|
+
case "complex":
|
|
54
|
+
return "map only medium or complex tasks \u2014 several modules, a new subsystem, roughly an hour or more";
|
|
55
|
+
case "on-request":
|
|
56
|
+
return "map only when the user explicitly asks";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function loadMappingPolicy(path) {
|
|
60
|
+
let text;
|
|
61
|
+
try {
|
|
62
|
+
text = readFileSync(path, "utf8");
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (e.code === "ENOENT") return ok(void 0);
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = JSON.parse(stripBom(text));
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return err({ kind: "malformed-json", path, detail: e.message });
|
|
72
|
+
}
|
|
73
|
+
if (!isRecord(raw)) return err({ kind: "bad-shape", path, detail: "root is not an object" });
|
|
74
|
+
if (raw["version"] !== CONFIG_FILE_VERSION) {
|
|
75
|
+
return err({ kind: "bad-shape", path, detail: `version is ${String(raw["version"])}, expected ${CONFIG_FILE_VERSION}` });
|
|
76
|
+
}
|
|
77
|
+
const rawPolicy = raw["policy"];
|
|
78
|
+
if (rawPolicy === void 0) return ok(void 0);
|
|
79
|
+
if (typeof rawPolicy !== "string") return err({ kind: "bad-shape", path, detail: "policy is not a string" });
|
|
80
|
+
const policy = makeMappingPolicy(rawPolicy);
|
|
81
|
+
return policy.ok ? ok(policy.value) : err({ kind: "bad-shape", path, detail: `policy is "${rawPolicy}", expected one of: ${MAPPING_POLICIES.join(" | ")}` });
|
|
82
|
+
}
|
|
83
|
+
function effectiveMappingPolicy(projectConfigFile, userConfigFile) {
|
|
84
|
+
const project = loadMappingPolicy(projectConfigFile);
|
|
85
|
+
if (!project.ok) return project;
|
|
86
|
+
const user = loadMappingPolicy(userConfigFile);
|
|
87
|
+
if (!user.ok) return user;
|
|
88
|
+
const effective = project.value ?? user.value;
|
|
89
|
+
const source = project.value !== void 0 ? "project" : user.value !== void 0 ? "user" : void 0;
|
|
90
|
+
return ok({ project: project.value, user: user.value, effective, source });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/store/migration.ts
|
|
94
|
+
import { dirname as dirname3, join as join3 } from "node:path";
|
|
95
|
+
var LEGACY_STATE_FILE_RELATIVE_PATH = join3(".claude", "mellos-mapping.json");
|
|
96
|
+
|
|
97
|
+
// src/hook/session-start.ts
|
|
98
|
+
function sessionStartContext(input) {
|
|
99
|
+
const { policy, hasStore } = input;
|
|
100
|
+
if (policy === void 0) {
|
|
101
|
+
return [
|
|
102
|
+
"mellos-mapping \u2014 first-run setup. This question is asked ONCE EVER, not once per project.",
|
|
103
|
+
"Before starting structured work here, ask the user how eager mapping should be:",
|
|
104
|
+
...MAPPING_POLICIES.map((p) => ` ${p} \u2014 ${describeMappingPolicy(p)}`),
|
|
105
|
+
'Record their answer with mmap_setup {policy: "<their choice>", scope: "user"} \u2014 it then applies',
|
|
106
|
+
'to every project they open, and a single project can still override it with scope: "project".',
|
|
107
|
+
"Do not choose for them, and do not ask again once it is recorded."
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
if (policy === "on-request") {
|
|
111
|
+
return hasStore ? "mellos-mapping: this project has a Mellos map. The user's policy is on-request \u2014 use the mmap tools only when they ask." : void 0;
|
|
112
|
+
}
|
|
113
|
+
const scope = policy === "always" ? "For ANY structured task in this project \u2014 workflows, designs, architecture, technical dependencies:" : "For any MEDIUM OR COMPLEX task in this project \u2014 several modules, a new subsystem, roughly an hour of work or more (skip trivial edits):";
|
|
114
|
+
return [
|
|
115
|
+
`mellos-mapping policy: ${policy} \u2014 ${describeMappingPolicy(policy)}.`,
|
|
116
|
+
scope,
|
|
117
|
+
" 1. Load the mellos-mapping skill and follow it.",
|
|
118
|
+
" 2. Read existing pages and context with mmap_read; a new conversation is not a new effort.",
|
|
119
|
+
" Reuse the matching page and verified nodes. Use mmap_declare only for missing structure.",
|
|
120
|
+
" 3. Open the map pane WITHOUT asking \u2014 the recorded policy is the user's standing consent:",
|
|
121
|
+
' mmap_open {page: "<the page this effort lives on>"}',
|
|
122
|
+
" It opens the pane beside this conversation, or retargets one that is already open.",
|
|
123
|
+
" 4. Watch the `pane:` line every write answers with: it says whether anybody is actually",
|
|
124
|
+
" looking. `pane: CLOSED` means the user cannot see this map \u2014 call mmap_open then too.",
|
|
125
|
+
" 5. Keep the map current as the work proceeds: in-progress when a node is started,",
|
|
126
|
+
" done WITH EVIDENCE when its verification passes, regressed when something breaks.",
|
|
127
|
+
" Save a concise context.next checkpoint and use expectedRevision for incremental writes.",
|
|
128
|
+
"An explicit request from the user always outranks this."
|
|
129
|
+
].join("\n");
|
|
130
|
+
}
|
|
131
|
+
function hasMap(stateFile) {
|
|
132
|
+
return existsSync(stateFile) || existsSync(join4(dirname4(stateFile), PAGES_DIR_NAME));
|
|
133
|
+
}
|
|
134
|
+
function mmapBinDir(localAppData) {
|
|
135
|
+
return join4(localAppData, "mellos-mapping", "bin");
|
|
136
|
+
}
|
|
137
|
+
function mmapShimFilePath(localAppData) {
|
|
138
|
+
return join4(mmapBinDir(localAppData), "mmap.cmd");
|
|
139
|
+
}
|
|
140
|
+
function mmapFallbackDirs(localAppData, home) {
|
|
141
|
+
return [join4(localAppData, "Microsoft", "WindowsApps"), join4(home, ".local", "bin")];
|
|
142
|
+
}
|
|
143
|
+
function pathNames(rawPath, dir) {
|
|
144
|
+
const norm = (s) => s.trim().replace(/^"|"$/g, "").replace(/[\\/]+$/, "").toLowerCase();
|
|
145
|
+
return rawPath.split(";").some((entry) => entry.trim() !== "" && norm(entry) === norm(dir));
|
|
146
|
+
}
|
|
147
|
+
function readIfPresent(path) {
|
|
148
|
+
try {
|
|
149
|
+
return readFileSync2(path, "utf8");
|
|
150
|
+
} catch {
|
|
151
|
+
return void 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function mmapShimCurrent(shimContent, mmapPath) {
|
|
155
|
+
if (shimContent === void 0) return false;
|
|
156
|
+
return shimContent.includes(`"${mmapPath}"`) || shimContent.includes(`"${mmapPath.replaceAll("\\", "/")}"`);
|
|
157
|
+
}
|
|
158
|
+
function shimIsOurs(content) {
|
|
159
|
+
if (content.includes("mellos-mapping mmap shim")) return true;
|
|
160
|
+
const quoted = /"([^"]*mmap\.mjs)"/.exec(content)?.[1];
|
|
161
|
+
return quoted !== void 0 && /mellos-mapping/i.test(quoted);
|
|
162
|
+
}
|
|
163
|
+
function mmapCommandResolves(localAppData, mmapPath, environment, read, exists) {
|
|
164
|
+
if (pathNames(environment.path, mmapBinDir(localAppData))) return true;
|
|
165
|
+
if (environment.home === void 0 || environment.home === "") return false;
|
|
166
|
+
for (const dir of mmapFallbackDirs(localAppData, environment.home)) {
|
|
167
|
+
if (!exists(dir) || !pathNames(environment.path, dir)) continue;
|
|
168
|
+
const files = ["mmap.cmd", "mmap"].map((name) => read(join4(dir, name))).filter((content) => content !== void 0);
|
|
169
|
+
if (files.length === 0) continue;
|
|
170
|
+
const ours = files.filter((content) => shimIsOurs(content));
|
|
171
|
+
if (ours.length === 0) return true;
|
|
172
|
+
return ours.every((content) => mmapShimCurrent(content, mmapPath));
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
function installContextLine(outcome) {
|
|
177
|
+
if (typeof outcome !== "object" || outcome === null) return void 0;
|
|
178
|
+
const o = outcome;
|
|
179
|
+
if (o.kind !== "installed" || typeof o.binDir !== "string") return void 0;
|
|
180
|
+
if (o.path === "updated") {
|
|
181
|
+
return [
|
|
182
|
+
"mellos-mapping: the `mmap` terminal command was just installed for the user",
|
|
183
|
+
`(${o.binDir} was added to their user PATH). Typed in any project terminal, \`mmap\``,
|
|
184
|
+
"toggles the map pane. The PATH change reaches only NEW processes \u2014 and a new tab of",
|
|
185
|
+
"a running Windows Terminal inherits the old environment, so if the user says `mmap`",
|
|
186
|
+
"is not recognized, tell them to close Windows Terminal entirely and reopen it."
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
189
|
+
if (o.path === "refused" || o.path === "error") {
|
|
190
|
+
const reason = typeof o.reason === "string" ? o.reason : "the PATH edit failed";
|
|
191
|
+
const aliasDir = typeof o.alias === "object" && o.alias !== null && "dir" in o.alias && typeof o.alias.dir === "string" ? o.alias.dir : void 0;
|
|
192
|
+
if (aliasDir !== void 0) {
|
|
193
|
+
return [
|
|
194
|
+
`mellos-mapping: the \`mmap\` terminal command should work: its shims live in`,
|
|
195
|
+
`${o.binDir}, and \u2014 because the user PATH could not be changed (${reason}) \u2014 a second copy`,
|
|
196
|
+
`was written to ${aliasDir}, a directory PATH already names (so \`mmap\` resolves there unless`,
|
|
197
|
+
"something earlier in PATH claims the name first).",
|
|
198
|
+
"The user can therefore type `mmap` in a new terminal right now; the command opens the map",
|
|
199
|
+
"pane for the project it is typed in, or closes the open one."
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
return [
|
|
203
|
+
`mellos-mapping: the \`mmap\` command's launcher was written to ${o.binDir},`,
|
|
204
|
+
`but the user PATH was NOT changed: ${reason}.`,
|
|
205
|
+
"If the user wants the `mmap` pane-toggle command, tell them to add that directory to",
|
|
206
|
+
'their user PATH (Settings > "Edit environment variables for their account").'
|
|
207
|
+
].join("\n");
|
|
208
|
+
}
|
|
209
|
+
return void 0;
|
|
210
|
+
}
|
|
211
|
+
function ensureMmapCommand(pluginRoot) {
|
|
212
|
+
if (process.platform !== "win32") return void 0;
|
|
213
|
+
const localAppData = process.env["LOCALAPPDATA"];
|
|
214
|
+
if (localAppData === void 0 || localAppData === "") return void 0;
|
|
215
|
+
const mmapPath = join4(pluginRoot, "dist", "mmap.mjs");
|
|
216
|
+
const shim = readIfPresent(mmapShimFilePath(localAppData));
|
|
217
|
+
const resolves = mmapShimCurrent(shim, mmapPath) && mmapCommandResolves(
|
|
218
|
+
localAppData,
|
|
219
|
+
mmapPath,
|
|
220
|
+
{ path: process.env["PATH"] ?? "", home: process.env["USERPROFILE"] ?? process.env["HOME"] },
|
|
221
|
+
readIfPresent,
|
|
222
|
+
existsSync
|
|
223
|
+
);
|
|
224
|
+
if (resolves) return void 0;
|
|
225
|
+
const runtime = "bun" in process.versions ? "node" : process.execPath;
|
|
226
|
+
const run = spawnSync(
|
|
227
|
+
runtime,
|
|
228
|
+
[join4(pluginRoot, "scripts", "install-mmap-command.mjs"), "--json"],
|
|
229
|
+
{ encoding: "utf8", windowsHide: true, timeout: 15e3 }
|
|
230
|
+
);
|
|
231
|
+
if (run.status !== 0 || typeof run.stdout !== "string") return void 0;
|
|
232
|
+
let outcome;
|
|
233
|
+
try {
|
|
234
|
+
outcome = JSON.parse(run.stdout);
|
|
235
|
+
} catch {
|
|
236
|
+
return void 0;
|
|
237
|
+
}
|
|
238
|
+
return installContextLine(outcome);
|
|
239
|
+
}
|
|
240
|
+
function parseHookInput(raw) {
|
|
241
|
+
let parsed;
|
|
242
|
+
try {
|
|
243
|
+
parsed = JSON.parse(raw);
|
|
244
|
+
} catch {
|
|
245
|
+
return { cwd: void 0 };
|
|
246
|
+
}
|
|
247
|
+
if (typeof parsed !== "object" || parsed === null) return { cwd: void 0 };
|
|
248
|
+
const cwd = parsed.cwd;
|
|
249
|
+
return { cwd: typeof cwd === "string" && cwd !== "" ? cwd : void 0 };
|
|
250
|
+
}
|
|
251
|
+
function hookOutput(additionalContext) {
|
|
252
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext } });
|
|
253
|
+
}
|
|
254
|
+
async function readAll(stream) {
|
|
255
|
+
const chunks = [];
|
|
256
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
257
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
258
|
+
}
|
|
259
|
+
async function main() {
|
|
260
|
+
const raw = process.stdin.isTTY === true ? "" : await readAll(process.stdin);
|
|
261
|
+
const projectDir = parseHookInput(raw).cwd ?? process.cwd();
|
|
262
|
+
const stateFile = join4(projectDir, STATE_FILE_RELATIVE_PATH);
|
|
263
|
+
const scopes = effectiveMappingPolicy(configFilePath(stateFile), userConfigFilePath(homedir()));
|
|
264
|
+
if (!scopes.ok) return;
|
|
265
|
+
const pluginRoot = dirname4(dirname4(fileURLToPath(import.meta.url)));
|
|
266
|
+
const context = sessionStartContext({
|
|
267
|
+
policy: scopes.value.effective,
|
|
268
|
+
hasStore: hasMap(stateFile)
|
|
269
|
+
});
|
|
270
|
+
let installNote;
|
|
271
|
+
try {
|
|
272
|
+
installNote = ensureMmapCommand(pluginRoot);
|
|
273
|
+
} catch {
|
|
274
|
+
installNote = void 0;
|
|
275
|
+
}
|
|
276
|
+
const parts = [context, installNote].filter((p) => p !== void 0);
|
|
277
|
+
if (parts.length > 0) process.stdout.write(hookOutput(parts.join("\n\n")));
|
|
278
|
+
}
|
|
279
|
+
function launchedAsEntry(argv1, moduleUrl) {
|
|
280
|
+
if (argv1 === void 0) return false;
|
|
281
|
+
try {
|
|
282
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
283
|
+
} catch {
|
|
284
|
+
return pathToFileURL(argv1).href === moduleUrl;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (launchedAsEntry(process.argv[1], import.meta.url)) {
|
|
288
|
+
main().catch(() => process.exit(0));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/host/omp/extension.ts
|
|
292
|
+
var SESSION_CONTEXT_TYPE = "mellos-mapping.session-context";
|
|
293
|
+
function sessionParagraph(projectDir) {
|
|
294
|
+
const stateFile = join5(projectDir, STATE_FILE_RELATIVE_PATH);
|
|
295
|
+
const scopes = effectiveMappingPolicy(configFilePath(stateFile), userConfigFilePath(homedir2()));
|
|
296
|
+
if (!scopes.ok) return void 0;
|
|
297
|
+
return sessionStartContext({ policy: scopes.value.effective, hasStore: hasMap(stateFile) });
|
|
298
|
+
}
|
|
299
|
+
function mellosMappingOmp(pi) {
|
|
300
|
+
const pluginRoot = dirname5(dirname5(fileURLToPath2(import.meta.url)));
|
|
301
|
+
let armed = true;
|
|
302
|
+
let installNote;
|
|
303
|
+
let told;
|
|
304
|
+
const rearm = async () => {
|
|
305
|
+
armed = true;
|
|
306
|
+
};
|
|
307
|
+
pi.setLabel?.("Mellos Mapping");
|
|
308
|
+
pi.on("session_start", async () => {
|
|
309
|
+
armed = true;
|
|
310
|
+
try {
|
|
311
|
+
installNote = ensureMmapCommand(pluginRoot);
|
|
312
|
+
} catch {
|
|
313
|
+
installNote = void 0;
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
pi.on("session_switch", rearm);
|
|
317
|
+
pi.on("session_branch", rearm);
|
|
318
|
+
pi.on("session_compact", rearm);
|
|
319
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
320
|
+
const cwd = ctx !== null && typeof ctx === "object" && "cwd" in ctx ? ctx.cwd : void 0;
|
|
321
|
+
const projectDir = typeof cwd === "string" && cwd !== "" ? cwd : process.cwd();
|
|
322
|
+
let paragraph;
|
|
323
|
+
let broken = false;
|
|
324
|
+
try {
|
|
325
|
+
paragraph = sessionParagraph(projectDir);
|
|
326
|
+
} catch {
|
|
327
|
+
paragraph = void 0;
|
|
328
|
+
}
|
|
329
|
+
if (paragraph === void 0) {
|
|
330
|
+
broken = !storeIsReadable(projectDir);
|
|
331
|
+
}
|
|
332
|
+
const content = [paragraph, installNote].filter((part) => part !== void 0).join("\n\n");
|
|
333
|
+
const owed = armed || paragraph !== void 0 && paragraph !== told;
|
|
334
|
+
if (!owed || broken || content === "") {
|
|
335
|
+
if (!broken) armed = false;
|
|
336
|
+
return void 0;
|
|
337
|
+
}
|
|
338
|
+
armed = false;
|
|
339
|
+
told = paragraph;
|
|
340
|
+
const message = {
|
|
341
|
+
customType: SESSION_CONTEXT_TYPE,
|
|
342
|
+
content,
|
|
343
|
+
// Standing instruction, not conversation: the model reads it, and the
|
|
344
|
+
// transcript does not grow a message the user never sent.
|
|
345
|
+
display: false,
|
|
346
|
+
attribution: "agent"
|
|
347
|
+
};
|
|
348
|
+
return { message };
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
function storeIsReadable(projectDir) {
|
|
352
|
+
const stateFile = join5(projectDir, STATE_FILE_RELATIVE_PATH);
|
|
353
|
+
return effectiveMappingPolicy(configFilePath(stateFile), userConfigFilePath(homedir2())).ok;
|
|
354
|
+
}
|
|
355
|
+
export {
|
|
356
|
+
SESSION_CONTEXT_TYPE,
|
|
357
|
+
mellosMappingOmp as default,
|
|
358
|
+
sessionParagraph
|
|
359
|
+
};
|
package/dist/server.mjs
CHANGED
|
@@ -24383,7 +24383,7 @@ function viewTool() {
|
|
|
24383
24383
|
function openTool() {
|
|
24384
24384
|
return {
|
|
24385
24385
|
title: "Open the map pane",
|
|
24386
|
-
description: 'For automatic display beside the current ChatGPT desktop conversation in Codex mode, use surface: "web-terminal", then call open_in_codex with the returned browser hostOpen object. No paste or Computer Use is needed. For the native host terminal, use surface: "codex-terminal": prepare absolute watcher commands for the current project, then ask the host to open its right terminal. This does not launch the watcher or type into that terminal. Agent exec PTYs cannot be attached using their numeric session ids. For a document panel, use surface: "markdown": generate MD + SVG files, enable automatic preview updates after successful map writes, then use the HOST file-opening tool to display the returned absolute Markdown path on the right of the current conversation. Generated does not mean visible: this server cannot open or observe the desktop side panel. For interactive maps, choose surface: "web": start or reuse a project-local web viewer and pass the returned URL to the host browser-opening tool. Markdown and terminal remain available. The default surface is "terminal", preserving the terminal workflow. Put the live map on the user\'s screen: a terminal pane beside this conversation that redraws on every write. Call it whenever a result says `pane: CLOSED` \u2014 and do NOT ask permission first, because a user who has set a mapping policy has already said they want to see the map. With a pane already open this RETARGETS it to `page` instead of opening a second one, so it is also how you show the user a particular page when they ask for one. It never closes a pane: taking the map off the screen belongs to the user (the `q` key in the pane, or typing `mmap` in a terminal). The reply says whether a pane actually reported itself in afterwards, not merely that a command was run. Automatic terminal opening supports Windows Terminal and tmux on Linux/macOS. If opening fails, relay the reason and copyable command; retry only after the environment changes or the user asks.',
|
|
24386
|
+
description: 'For automatic display beside the current ChatGPT desktop conversation in Codex mode, use surface: "web-terminal", then call open_in_codex with the returned browser hostOpen object. No paste or Computer Use is needed. For the native host terminal, use surface: "codex-terminal": prepare absolute watcher commands for the current project, then ask the host to open its right terminal. This does not launch the watcher or type into that terminal. Agent exec PTYs cannot be attached using their numeric session ids. For a document panel, use surface: "markdown": generate MD + SVG files, enable automatic preview updates after successful map writes, then use the HOST file-opening tool to display the returned absolute Markdown path on the right of the current conversation. Generated does not mean visible: this server cannot open or observe the desktop side panel. For interactive maps, choose surface: "web": start or reuse a project-local web viewer and pass the returned URL to the host browser-opening tool. Markdown and terminal remain available. The default surface is "terminal", preserving the terminal workflow. Put the live map on the user\'s screen: a terminal pane beside this conversation that redraws on every write. Call it whenever a result says `pane: CLOSED` \u2014 and do NOT ask permission first, because a user who has set a mapping policy has already said they want to see the map. With a pane already open this RETARGETS it to `page` instead of opening a second one, so it is also how you show the user a particular page when they ask for one. It never closes a pane: taking the map off the screen belongs to the user (the `q` key in the pane, or typing `mmap` in a terminal). The reply says whether a pane actually reported itself in afterwards, not merely that a command was run. Automatic terminal opening supports Windows Terminal and tmux on Linux/macOS. omp (Oh My Pi) has no side panel of its own: there the routes are this same terminal pane (a Windows Terminal split, or a tmux split on Linux/macOS) and the web viewer, whose URL the session opens with its own browser tool. If opening fails, relay the reason and copyable command; retry only after the environment changes or the user asks.',
|
|
24387
24387
|
inputSchema: closed({
|
|
24388
24388
|
surface: external_exports.enum(["terminal", "codex-terminal", "markdown", "web", "web-terminal"]).optional().describe("web-terminal = automatically started mmap terminal in a local browser page; codex-terminal = prepare a command for the desktop host terminal; web = local browser viewer; markdown = MD/SVG; terminal = Windows Terminal or tmux launcher (default)"),
|
|
24389
24389
|
page: id(
|
|
@@ -24650,7 +24650,7 @@ function launcherViewerPid(run) {
|
|
|
24650
24650
|
|
|
24651
24651
|
// src/server/server.ts
|
|
24652
24652
|
var SERVER_NAME = "mellos-mapping";
|
|
24653
|
-
var SERVER_VERSION = "0.
|
|
24653
|
+
var SERVER_VERSION = "0.26.0";
|
|
24654
24654
|
function text(s, isError = false) {
|
|
24655
24655
|
return { content: [{ type: "text", text: s }], ...isError ? { isError: true } : {} };
|
|
24656
24656
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mellos-mapping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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",
|
|
@@ -65,6 +65,11 @@
|
|
|
65
65
|
"publishConfig": {
|
|
66
66
|
"registry": "https://registry.npmjs.org/"
|
|
67
67
|
},
|
|
68
|
+
"omp": {
|
|
69
|
+
"extensions": [
|
|
70
|
+
"./dist/omp-extension.mjs"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
68
73
|
"files": [
|
|
69
74
|
"dist",
|
|
70
75
|
"lib",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* point, so importing this file is inert.
|
|
38
38
|
*/
|
|
39
39
|
import { spawnSync } from 'node:child_process';
|
|
40
|
-
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
41
41
|
import { join } from 'node:path';
|
|
42
42
|
|
|
43
43
|
import { launchedAsEntry, pluginRootOf } from './pane-core.mjs';
|
|
@@ -56,9 +56,36 @@ export function binDirIn(localAppData) {
|
|
|
56
56
|
return join(localAppData, 'mellos-mapping', 'bin');
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Per-user command directories that are USUALLY already on the PATH, in the
|
|
61
|
+
* order they should be tried, given a `%LOCALAPPDATA%` and a home directory.
|
|
62
|
+
*
|
|
63
|
+
* These exist for one case: `setx` refuses to write a user PATH this script
|
|
64
|
+
* will not carry safely (see setxRefusal), and the canonical `bin` directory is
|
|
65
|
+
* therefore unreachable for good. A machine with a long PATH — conda, five
|
|
66
|
+
* Pythons, several toolchains — then has no way to run `mmap` at all, which is
|
|
67
|
+
* the opposite of what the command is for. Dropping the same two shims into a
|
|
68
|
+
* directory the PATH already names fixes that without editing anything:
|
|
69
|
+
*
|
|
70
|
+
* - `WindowsApps` is Windows' own per-user command directory (the one App
|
|
71
|
+
* Execution Aliases live in) and is on the PATH of every interactive user;
|
|
72
|
+
* - `~/.local/bin` is the same idea as the rest of the world spells it.
|
|
73
|
+
*
|
|
74
|
+
* Only ever consulted when the PATH route is refused, and only if the directory
|
|
75
|
+
* exists, is reachable on the CURRENT PATH, and accepts a write.
|
|
76
|
+
*/
|
|
77
|
+
export function linkDirCandidates(localAppData, home) {
|
|
78
|
+
return [join(localAppData, 'Microsoft', 'WindowsApps'), join(home, '.local', 'bin')];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The candidates this PATH can already reach, in the order they were given. */
|
|
82
|
+
export function reachableDirs(candidates, rawPath, exists) {
|
|
83
|
+
return candidates.filter((dir) => exists(dir) && pathContains(rawPath, dir));
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
/** The cmd/PowerShell shim: forwards every argument, prints nothing of its own. */
|
|
60
87
|
export function cmdShim(mmapPath) {
|
|
61
|
-
return ['@echo off', `node "${mmapPath}" %*`, ''].join('\r\n');
|
|
88
|
+
return ['@echo off', `rem ${SHIM_MARKER}`, `node "${mmapPath}" %*`, ''].join('\r\n');
|
|
62
89
|
}
|
|
63
90
|
|
|
64
91
|
/**
|
|
@@ -67,7 +94,7 @@ export function cmdShim(mmapPath) {
|
|
|
67
94
|
* `C:\Users` is one escape away from being someone else's bug.
|
|
68
95
|
*/
|
|
69
96
|
export function shShim(mmapPath) {
|
|
70
|
-
return ['#!/bin/sh', `exec node "${mmapPath.replaceAll('\\', '/')}" "$@"`, ''].join('\n');
|
|
97
|
+
return ['#!/bin/sh', `# ${SHIM_MARKER}`, `exec node "${mmapPath.replaceAll('\\', '/')}" "$@"`, ''].join('\n');
|
|
71
98
|
}
|
|
72
99
|
|
|
73
100
|
/** PATH entries as written, empties dropped — the unit every rule below works on. */
|
|
@@ -175,6 +202,68 @@ function applyPath(raw, wanted) {
|
|
|
175
202
|
}
|
|
176
203
|
}
|
|
177
204
|
|
|
205
|
+
/** The command name this plugin installs, and the script it must point at. */
|
|
206
|
+
export const SHIM_COMMAND = 'mmap';
|
|
207
|
+
export const SHIM_SCRIPT = 'mmap.mjs';
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Written into both shim shapes. The path alone says nothing reliable about
|
|
211
|
+
* who wrote a file in a shared directory — the same plugin may be installed
|
|
212
|
+
* under any checkout name — so ownership gets its own line, and old shims are
|
|
213
|
+
* still recognized by their target.
|
|
214
|
+
*/
|
|
215
|
+
export const SHIM_MARKER = 'mellos-mapping mmap shim';
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The `mmap.mjs` bundle a shim launches, normalized to forward slashes, or
|
|
219
|
+
* undefined when the text quotes no such path. One reader for every question
|
|
220
|
+
* about ownership: the cmd shape writes the Windows path, the git-bash shape
|
|
221
|
+
* the forward-slash one, and a rule that knew only one form would answer
|
|
222
|
+
* differently about a shim's two files.
|
|
223
|
+
*/
|
|
224
|
+
export function shimTarget(content) {
|
|
225
|
+
const quoted = /"([^"]*mmap\.mjs)"/.exec(content)?.[1];
|
|
226
|
+
return quoted === undefined ? undefined : quoted.replaceAll('\\', '/');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Is this shim file one of ours — a `mmap` command that launches a
|
|
231
|
+
* `dist/mmap.mjs` of this plugin family?
|
|
232
|
+
*
|
|
233
|
+
* The fallback directory is SHARED (`WindowsApps`, `~/.local/bin`): a file
|
|
234
|
+
* called `mmap` there may belong to somebody else, and a plugin that overwrites
|
|
235
|
+
* a stranger's command on its way in has no standing to be careful on the way
|
|
236
|
+
* out. Ownership is read from the file's own text. Every shim written since the
|
|
237
|
+
* marker exists carries it — an install under ANY directory name owns its
|
|
238
|
+
* copies — and shims written before it are recognized by their target, whose
|
|
239
|
+
* path named the plugin.
|
|
240
|
+
*/
|
|
241
|
+
export function shimIsOurs(content) {
|
|
242
|
+
if (content.includes(SHIM_MARKER)) return true;
|
|
243
|
+
const target = shimTarget(content);
|
|
244
|
+
return target !== undefined && /mellos-mapping/i.test(target);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Does this shim launch exactly `mmapPath`? Both shapes write the path their
|
|
249
|
+
* own way, so the comparison happens on the normalized form — the rule every
|
|
250
|
+
* removal in this file uses, or a git-bash copy would outlive the uninstall
|
|
251
|
+
* that removed its cmd sibling.
|
|
252
|
+
*/
|
|
253
|
+
export function shimLaunches(content, mmapPath) {
|
|
254
|
+
const target = shimTarget(content);
|
|
255
|
+
return target !== undefined && target === mmapPath.replaceAll('\\', '/');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Read a file's text, or undefined when it is absent or unreadable. */
|
|
259
|
+
function readIfPresent(path) {
|
|
260
|
+
try {
|
|
261
|
+
return readFileSync(path, 'utf8');
|
|
262
|
+
} catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
178
267
|
/**
|
|
179
268
|
* The install itself, with the narration stripped out: write the shims, bring
|
|
180
269
|
* the PATH in line, report what happened as data. This outcome object IS the
|
|
@@ -183,13 +272,16 @@ function applyPath(raw, wanted) {
|
|
|
183
272
|
*
|
|
184
273
|
* @returns one of
|
|
185
274
|
* {kind: 'not-built', missing} — dist/mmap.mjs absent, nothing written;
|
|
186
|
-
* {kind: 'installed', binDir, cmdPath, shPath, path, wanted, reason?} —
|
|
275
|
+
* {kind: 'installed', binDir, cmdPath, shPath, path, wanted, reason?, alias?} —
|
|
187
276
|
* shims written; `path` says what happened to the USER PATH:
|
|
188
277
|
* 'unchanged' — the entry was already there,
|
|
189
278
|
* 'updated' — the entry was appended,
|
|
190
279
|
* 'refused' — setx would damage this PATH (`reason` says how); the
|
|
191
280
|
* entry in `wanted` must be added by hand,
|
|
192
281
|
* 'error' — setx itself failed (`reason` is its message).
|
|
282
|
+
* `alias` is the second copy in a directory the PATH already names, written
|
|
283
|
+
* only when the PATH route came up short OR when one is already there and
|
|
284
|
+
* needs to keep pointing at this install.
|
|
193
285
|
*/
|
|
194
286
|
export function install(localAppData, pluginRoot) {
|
|
195
287
|
const mmapPath = join(pluginRoot, 'dist', 'mmap.mjs');
|
|
@@ -211,14 +303,56 @@ export function install(localAppData, pluginRoot) {
|
|
|
211
303
|
const raw = readUserPath();
|
|
212
304
|
const wanted = pathWith(raw, binDir);
|
|
213
305
|
const plan = planPath(raw, wanted);
|
|
214
|
-
|
|
306
|
+
/**
|
|
307
|
+
* When the PATH cannot be brought to the shim, bring the shim to the PATH:
|
|
308
|
+
* same two files, dropped into a directory this machine already searches.
|
|
309
|
+
*
|
|
310
|
+
* Two rules keep that from becoming a second install nobody owns. A
|
|
311
|
+
* candidate is skipped when its `mmap` belongs to somebody else — a shared
|
|
312
|
+
* directory is not ours to overwrite. And a copy that IS ours is refreshed
|
|
313
|
+
* on every install, not only when the PATH route fails: the shim embeds the
|
|
314
|
+
* absolute path of one plugin copy, and an untouched copy from an earlier
|
|
315
|
+
* install would keep launching a directory the next upgrade deletes.
|
|
316
|
+
*
|
|
317
|
+
* @param force - create the copy even when none exists yet (the PATH route
|
|
318
|
+
* came up short, so this is the only runnable command).
|
|
319
|
+
* @returns `{dir, cmdPath, shPath, refreshed}` for the copy, or undefined.
|
|
320
|
+
*/
|
|
321
|
+
const alias = (force) => {
|
|
322
|
+
const home = process.env['USERPROFILE'] ?? process.env['HOME'];
|
|
323
|
+
if (home === undefined || home === '') return undefined;
|
|
324
|
+
// every candidate gets its own try: a directory that exists and is on the
|
|
325
|
+
// PATH can still refuse the write, and that is a reason to try the next
|
|
326
|
+
// one, not to give up on having a runnable command
|
|
327
|
+
for (const dir of reachableDirs(linkDirCandidates(localAppData, home), process.env['PATH'] ?? '', existsSync)) {
|
|
328
|
+
const linkedCmd = join(dir, 'mmap.cmd');
|
|
329
|
+
const linkedSh = join(dir, 'mmap');
|
|
330
|
+
const existing = [readIfPresent(linkedCmd), readIfPresent(linkedSh)].filter((text) => text !== undefined);
|
|
331
|
+
const foreign = existing.some((text) => !shimIsOurs(text));
|
|
332
|
+
if (foreign) continue; // somebody else's command: not ours to touch
|
|
333
|
+
const refreshed = existing.length > 0;
|
|
334
|
+
if (!refreshed && !force) continue; // nothing there and nothing to fix
|
|
335
|
+
try {
|
|
336
|
+
writeFileSync(linkedCmd, cmdShim(mmapPath));
|
|
337
|
+
writeFileSync(linkedSh, shShim(mmapPath));
|
|
338
|
+
return { dir, cmdPath: linkedCmd, shPath: linkedSh, refreshed };
|
|
339
|
+
} catch {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
};
|
|
215
345
|
if (plan.action === 'refused') {
|
|
216
|
-
|
|
346
|
+
// refused means setx would damage this PATH: the alias is the whole answer
|
|
347
|
+
return { kind: 'installed', binDir, cmdPath, shPath, path: 'refused', wanted, reason: plan.reason, alias: alias(true) };
|
|
348
|
+
}
|
|
349
|
+
if (plan.action === 'unchanged') {
|
|
350
|
+
return { kind: 'installed', binDir, cmdPath, shPath, path: 'unchanged', wanted, alias: alias(false) };
|
|
217
351
|
}
|
|
218
352
|
const written = writeUserPath(wanted);
|
|
219
353
|
return written.ok
|
|
220
|
-
? { kind: 'installed', binDir, cmdPath, shPath, path: 'updated', wanted }
|
|
221
|
-
: { kind: 'installed', binDir, cmdPath, shPath, path: 'error', wanted, reason: written.error };
|
|
354
|
+
? { kind: 'installed', binDir, cmdPath, shPath, path: 'updated', wanted, alias: alias(false) }
|
|
355
|
+
: { kind: 'installed', binDir, cmdPath, shPath, path: 'error', wanted, reason: written.error, alias: alias(true) };
|
|
222
356
|
}
|
|
223
357
|
|
|
224
358
|
function main() {
|
|
@@ -247,11 +381,37 @@ function main() {
|
|
|
247
381
|
if (uninstall) {
|
|
248
382
|
const binDir = binDirIn(localAppData);
|
|
249
383
|
const raw = readUserPath();
|
|
384
|
+
const mine = join(pluginRootOf(import.meta.url), 'dist', 'mmap.mjs');
|
|
385
|
+
// Remove only what launches THIS install. The canonical pair is shared
|
|
386
|
+
// with every other host running this plugin (one file per shape, last
|
|
387
|
+
// installer wins), so when a file belongs to another install — or to a
|
|
388
|
+
// stranger — it stays, and with it the directory and its PATH entry: the
|
|
389
|
+
// command those files leave behind is theirs, not this uninstall's to take.
|
|
390
|
+
let stillInUse = false;
|
|
250
391
|
for (const path of [join(binDir, 'mmap.cmd'), join(binDir, 'mmap')]) {
|
|
251
|
-
|
|
252
|
-
|
|
392
|
+
const content = readIfPresent(path);
|
|
393
|
+
if (content === undefined) continue;
|
|
394
|
+
if (shimLaunches(content, mine)) {
|
|
395
|
+
rmSync(path, { force: true });
|
|
396
|
+
console.log(`removed ${path}`);
|
|
397
|
+
} else stillInUse = true;
|
|
398
|
+
}
|
|
399
|
+
// The fallback copies — the route a refused PATH leaves — obey the same
|
|
400
|
+
// rule, per file, in every candidate directory.
|
|
401
|
+
const home = process.env['USERPROFILE'] ?? process.env['HOME'] ?? '';
|
|
402
|
+
for (const dir of home === '' ? [] : linkDirCandidates(localAppData, home)) {
|
|
403
|
+
for (const candidate of [join(dir, 'mmap.cmd'), join(dir, 'mmap')]) {
|
|
404
|
+
const content = readIfPresent(candidate);
|
|
405
|
+
if (content === undefined || !shimLaunches(content, mine)) continue;
|
|
406
|
+
rmSync(candidate, { force: true });
|
|
407
|
+
console.log(`removed ${candidate}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (stillInUse) {
|
|
411
|
+
console.log(`kept ${binDir} and its PATH entry: another install of this plugin still launches from there.`);
|
|
412
|
+
} else {
|
|
413
|
+
applyPath(raw, pathWithout(raw, binDir));
|
|
253
414
|
}
|
|
254
|
-
applyPath(raw, pathWithout(raw, binDir));
|
|
255
415
|
console.log('Close your terminal app entirely and reopen it for the change to take effect — a new tab keeps the old PATH.');
|
|
256
416
|
return;
|
|
257
417
|
}
|
|
@@ -277,8 +437,14 @@ function main() {
|
|
|
277
437
|
break;
|
|
278
438
|
case 'refused':
|
|
279
439
|
console.log(`NOT touching your user PATH: ${outcome.reason}.`);
|
|
280
|
-
|
|
281
|
-
|
|
440
|
+
if (outcome.alias !== undefined) {
|
|
441
|
+
console.log(`The command works anyway: both shims were also written to ${outcome.alias.dir},`);
|
|
442
|
+
console.log('which is already on your PATH — open a new terminal and type `mmap`.');
|
|
443
|
+
console.log(`(${outcome.binDir} stays the canonical home; add it in Settings if you prefer one place.)`);
|
|
444
|
+
} else {
|
|
445
|
+
console.log('Add this entry yourself, in Settings > "Edit environment variables for your account":');
|
|
446
|
+
console.log(` ${outcome.binDir}`);
|
|
447
|
+
}
|
|
282
448
|
break;
|
|
283
449
|
case 'error':
|
|
284
450
|
console.error(`setx refused: ${outcome.reason}`);
|