mellos-mapping 0.20.0 → 0.20.2
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 +360 -63
- package/README.zh-CN.md +314 -54
- package/dist/hook-session-start.mjs +239 -0
- package/dist/mmap.mjs +338 -0
- package/dist/server.mjs +1614 -809
- package/dist/store-paths.mjs +107 -0
- package/dist/watch.mjs +1391 -760
- package/lib/domain/ops.d.ts +71 -12
- package/lib/domain/ops.js +145 -14
- package/lib/domain/types.d.ts +47 -6
- package/lib/domain/types.js +34 -3
- package/lib/render/canvas.d.ts +50 -0
- package/lib/render/canvas.js +210 -0
- package/lib/render/draw.d.ts +37 -0
- package/lib/render/draw.js +111 -0
- package/lib/render/layout.d.ts +89 -0
- package/lib/render/layout.js +200 -0
- package/lib/render/options.d.ts +39 -0
- package/lib/render/options.js +10 -0
- package/lib/render/render.d.ts +32 -46
- package/lib/render/render.js +58 -789
- package/lib/render/routing.d.ts +56 -0
- package/lib/render/routing.js +244 -0
- package/lib/render/skins.d.ts +54 -0
- package/lib/render/skins.js +99 -0
- package/lib/render/width.d.ts +24 -0
- package/lib/render/width.js +139 -0
- package/lib/render/zoom-geometry.d.ts +52 -0
- package/lib/render/zoom-geometry.js +56 -0
- package/lib/semantics/semantics.d.ts +53 -4
- package/lib/semantics/semantics.js +130 -6
- package/lib/semantics/vocabulary.d.ts +79 -0
- package/lib/semantics/vocabulary.js +112 -0
- package/lib/store/format.d.ts +17 -0
- package/lib/store/format.js +185 -66
- package/lib/store/store.d.ts +220 -20
- package/lib/store/store.js +491 -38
- package/package.json +12 -4
- package/scripts/codex-register.mjs +89 -20
- package/scripts/install-mmap-command.mjs +293 -0
- package/scripts/mmap.mjs +213 -0
- package/scripts/open-pane.mjs +115 -254
- package/scripts/pane-core.mjs +418 -0
|
@@ -12,37 +12,106 @@
|
|
|
12
12
|
* directory, which is exactly the contract dist/server.mjs already expects.
|
|
13
13
|
* The registered path is absolute and version-specific, so re-run after
|
|
14
14
|
* every plugin update.
|
|
15
|
+
*
|
|
16
|
+
* Two Windows facts shape the process handling below:
|
|
17
|
+
* - A missing `codex` is NOT a spawn error there. The call goes through
|
|
18
|
+
* cmd.exe (only a shell resolves both codex.exe and an npm .cmd shim), so
|
|
19
|
+
* "not recognized as an internal or external command" arrives as an
|
|
20
|
+
* ordinary non-zero exit, and a check for spawnSync's `error` field can
|
|
21
|
+
* never fire. PATH is probed up front instead.
|
|
22
|
+
* - The child writes its diagnostics in the console's OEM code page.
|
|
23
|
+
* Decoding those bytes as UTF-8 turns every non-English message into
|
|
24
|
+
* mojibake, so they are passed through as bytes and the terminal decodes
|
|
25
|
+
* them as it decodes everything else it prints.
|
|
26
|
+
*
|
|
27
|
+
* Pure helpers are exported for the spec; registration runs only as an entry
|
|
28
|
+
* point, so importing this file is inert.
|
|
15
29
|
*/
|
|
16
30
|
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { realpathSync } from 'node:fs';
|
|
17
32
|
import { dirname, join } from 'node:path';
|
|
18
33
|
import { fileURLToPath } from 'node:url';
|
|
19
34
|
|
|
20
|
-
|
|
21
|
-
const
|
|
35
|
+
/** cmd.exe's exit code for a command it cannot resolve on PATH. */
|
|
36
|
+
export const COMMAND_NOT_FOUND_EXIT_CODE = 9009;
|
|
37
|
+
|
|
38
|
+
/** Quote one argument for a cmd.exe command line. */
|
|
39
|
+
export function quoteForShell(arg) {
|
|
40
|
+
return /[\s"]/.test(arg) ? `"${arg}"` : arg;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What went wrong, in the user's terms.
|
|
45
|
+
* @param status - the child's exit code (null = it never ran).
|
|
46
|
+
* @returns the line to print; a missing CLI is named as such on every
|
|
47
|
+
* platform, because that is the failure a first-time reader actually hits.
|
|
48
|
+
*/
|
|
49
|
+
export function describeFailure(status) {
|
|
50
|
+
return status === COMMAND_NOT_FOUND_EXIT_CODE || status === null
|
|
51
|
+
? 'codex CLI not found on PATH — install Codex first, then re-run this script.'
|
|
52
|
+
: `codex mcp add failed (exit ${status}).`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether `codex` resolves on PATH. */
|
|
56
|
+
function codexOnPath() {
|
|
57
|
+
const probe =
|
|
58
|
+
process.platform === 'win32'
|
|
59
|
+
? spawnSync('where', ['codex'], { stdio: 'ignore', windowsHide: true })
|
|
60
|
+
: spawnSync('command', ['-v', 'codex'], { shell: true, stdio: 'ignore' });
|
|
61
|
+
return probe.error === undefined && probe.status === 0;
|
|
62
|
+
}
|
|
22
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Run the codex CLI. Output is captured as BYTES (no `encoding`), so the
|
|
66
|
+
* child's console-code-page diagnostics reach the terminal unaltered.
|
|
67
|
+
*/
|
|
23
68
|
function codex(args) {
|
|
24
69
|
if (process.platform !== 'win32') {
|
|
25
|
-
return spawnSync('codex', args, { stdio: 'pipe'
|
|
70
|
+
return spawnSync('codex', args, { stdio: 'pipe' });
|
|
26
71
|
}
|
|
27
72
|
// Windows: codex may be an .exe or an npm .cmd shim; only a shell resolves
|
|
28
73
|
// both. The shell needs explicit quoting for paths with spaces.
|
|
29
|
-
const line = ['codex', ...args].map(
|
|
30
|
-
return spawnSync(line, { shell: true, stdio: 'pipe',
|
|
74
|
+
const line = ['codex', ...args].map(quoteForShell).join(' ');
|
|
75
|
+
return spawnSync(line, { shell: true, stdio: 'pipe', windowsHide: true });
|
|
31
76
|
}
|
|
32
77
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
);
|
|
44
|
-
|
|
78
|
+
function main() {
|
|
79
|
+
const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
80
|
+
const serverPath = join(pluginRoot, 'dist', 'server.mjs');
|
|
81
|
+
|
|
82
|
+
if (!codexOnPath()) {
|
|
83
|
+
console.error(describeFailure(COMMAND_NOT_FOUND_EXIT_CODE));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Idempotent: drop any previous (possibly stale, version-specific) entry.
|
|
88
|
+
codex(['mcp', 'remove', 'mellos-mapping']);
|
|
89
|
+
|
|
90
|
+
const added = codex(['mcp', 'add', 'mellos-mapping', '--', 'node', serverPath]);
|
|
91
|
+
if (added.status !== 0) {
|
|
92
|
+
if (added.stderr) process.stderr.write(added.stderr);
|
|
93
|
+
console.error(describeFailure(added.status));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
if (added.stdout) process.stdout.write(added.stdout);
|
|
97
|
+
console.log(`mellos-mapping MCP registered with Codex: node ${serverPath}`);
|
|
98
|
+
console.log('State files resolve to each session’s working directory (.mellos/map.json).');
|
|
45
99
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run only as an entry point, so the spec can import the helpers above
|
|
103
|
+
* without registering anything. Each script in this directory carries its own
|
|
104
|
+
* copy: package.json ships them file-by-file, so a shared scripts/ module
|
|
105
|
+
* would simply be missing from an npm install.
|
|
106
|
+
*/
|
|
107
|
+
function launchedAsEntry() {
|
|
108
|
+
const entry = process.argv[1];
|
|
109
|
+
if (entry === undefined) return false;
|
|
110
|
+
try {
|
|
111
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (launchedAsEntry()) main();
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Put `mmap` on the PATH of a plugin install.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/install-mmap-command.mjs [--uninstall] [--json]
|
|
6
|
+
*
|
|
7
|
+
* npm users get `mmap` for free — package.json declares it in `bin`. Claude
|
|
8
|
+
* Code installs this plugin by CLONING the repo, with no npm install and no
|
|
9
|
+
* bin shims, so the one command a human is meant to type has nowhere to be
|
|
10
|
+
* typed from. This script is that missing step, and nothing more: it writes
|
|
11
|
+
* two tiny shims into `%LOCALAPPDATA%\mellos-mapping\bin` — `mmap.cmd` for
|
|
12
|
+
* cmd/PowerShell, `mmap` for git-bash — and adds that one directory to the
|
|
13
|
+
* USER PATH.
|
|
14
|
+
*
|
|
15
|
+
* Nobody has to run it by hand: the SessionStart hook
|
|
16
|
+
* (src/hook/session-start.ts) notices a missing or stale shim when a session
|
|
17
|
+
* starts and runs this script with `--json`, which prints the outcome as one
|
|
18
|
+
* JSON line instead of prose — that outcome object is the contract between
|
|
19
|
+
* the two files. The script stays a standalone command for the cases the
|
|
20
|
+
* hook does not cover: `--uninstall`, and re-adding a PATH entry the user
|
|
21
|
+
* removed while the shims still exist (the hook checks only the shims).
|
|
22
|
+
*
|
|
23
|
+
* What it will NEVER do, and says so instead:
|
|
24
|
+
*
|
|
25
|
+
* - Touch the SYSTEM PATH. This is a per-user tool.
|
|
26
|
+
* - Rewrite a user PATH that `setx` cannot carry safely. `setx` writes a
|
|
27
|
+
* plain string and truncates past SETX_VALUE_MAX, so a PATH holding
|
|
28
|
+
* `%USERPROFILE%`-style references would come back with the references
|
|
29
|
+
* flattened, and a long one would come back SHORTER. Both are damage that
|
|
30
|
+
* outlives this tool, so in either case the shims are still installed and
|
|
31
|
+
* the exact line to add by hand is printed.
|
|
32
|
+
*
|
|
33
|
+
* Windows-first, deliberately: this plugin's pane is Windows Terminal. On
|
|
34
|
+
* other platforms `mmap` is `npm i -g mellos-mapping`, or a shell alias.
|
|
35
|
+
*
|
|
36
|
+
* Pure helpers are exported for the spec; the installer runs only as an entry
|
|
37
|
+
* point, so importing this file is inert.
|
|
38
|
+
*/
|
|
39
|
+
import { spawnSync } from 'node:child_process';
|
|
40
|
+
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
41
|
+
import { join } from 'node:path';
|
|
42
|
+
|
|
43
|
+
import { launchedAsEntry, pluginRootOf } from './pane-core.mjs';
|
|
44
|
+
|
|
45
|
+
export const USAGE = 'usage: node scripts/install-mmap-command.mjs [--uninstall] [--json]';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Longest value `setx` stores without truncating it, in characters. Documented
|
|
49
|
+
* by the tool itself ("the maximum size is 1024 characters") and the reason
|
|
50
|
+
* this script would rather refuse than shorten someone's PATH.
|
|
51
|
+
*/
|
|
52
|
+
export const SETX_VALUE_MAX = 1024;
|
|
53
|
+
|
|
54
|
+
/** The one directory this installer owns, given a `%LOCALAPPDATA%`. */
|
|
55
|
+
export function binDirIn(localAppData) {
|
|
56
|
+
return join(localAppData, 'mellos-mapping', 'bin');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The cmd/PowerShell shim: forwards every argument, prints nothing of its own. */
|
|
60
|
+
export function cmdShim(mmapPath) {
|
|
61
|
+
return ['@echo off', `node "${mmapPath}" %*`, ''].join('\r\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The git-bash shim. The target is written with forward slashes: a Windows
|
|
66
|
+
* path inside sh double quotes keeps its backslashes verbatim, and `\U` in
|
|
67
|
+
* `C:\Users` is one escape away from being someone else's bug.
|
|
68
|
+
*/
|
|
69
|
+
export function shShim(mmapPath) {
|
|
70
|
+
return ['#!/bin/sh', `exec node "${mmapPath.replaceAll('\\', '/')}" "$@"`, ''].join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** PATH entries as written, empties dropped — the unit every rule below works on. */
|
|
74
|
+
function entriesOf(rawPath) {
|
|
75
|
+
return rawPath.split(';').filter((e) => e.trim() !== '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Two PATH entries naming the same directory: case and a trailing slash do not count. */
|
|
79
|
+
function samePathEntry(a, b) {
|
|
80
|
+
const norm = (s) => s.trim().replace(/^"|"$/g, '').replace(/[\\/]+$/, '').toLowerCase();
|
|
81
|
+
return norm(a) === norm(b);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Is `dir` already on this PATH? Then installing it again must change nothing. */
|
|
85
|
+
export function pathContains(rawPath, dir) {
|
|
86
|
+
return entriesOf(rawPath).some((e) => samePathEntry(e, dir));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `rawPath` with `dir` appended — unchanged when it is already there. */
|
|
90
|
+
export function pathWith(rawPath, dir) {
|
|
91
|
+
return pathContains(rawPath, dir) ? rawPath : [...entriesOf(rawPath), dir].join(';');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `rawPath` with every mention of `dir` removed. */
|
|
95
|
+
export function pathWithout(rawPath, dir) {
|
|
96
|
+
return entriesOf(rawPath)
|
|
97
|
+
.filter((e) => !samePathEntry(e, dir))
|
|
98
|
+
.join(';');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Why `setx` must not be used to write `nextValue`, or undefined when it can.
|
|
103
|
+
*
|
|
104
|
+
* @param rawValue - the USER PATH exactly as the registry holds it.
|
|
105
|
+
* @param nextValue - what this script would write.
|
|
106
|
+
* @returns a sentence naming the damage that would be done, for the caller to
|
|
107
|
+
* print beside the manual instructions.
|
|
108
|
+
*/
|
|
109
|
+
export function setxRefusal(rawValue, nextValue) {
|
|
110
|
+
if (rawValue.includes('%')) {
|
|
111
|
+
return 'your user PATH contains %VARIABLE% references, and setx would store them flattened to whatever they mean right now';
|
|
112
|
+
}
|
|
113
|
+
if (nextValue.length > SETX_VALUE_MAX) {
|
|
114
|
+
return `your user PATH would be ${nextValue.length} characters and setx truncates past ${SETX_VALUE_MAX}`;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What bringing the PATH from `raw` to `wanted` takes — decided, not done.
|
|
121
|
+
* The single place the write/refuse/no-op judgment lives; both the prose
|
|
122
|
+
* path and the `--json` path execute exactly this plan.
|
|
123
|
+
* @returns {{action: 'unchanged'} | {action: 'write'} | {action: 'refused', reason: string}}
|
|
124
|
+
*/
|
|
125
|
+
export function planPath(raw, wanted) {
|
|
126
|
+
if (wanted === raw) return { action: 'unchanged' };
|
|
127
|
+
const reason = setxRefusal(raw, wanted);
|
|
128
|
+
return reason === undefined ? { action: 'write' } : { action: 'refused', reason };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The USER PATH exactly as the registry holds it — unexpanded, so a
|
|
133
|
+
* `%USERPROFILE%` in it is still a `%USERPROFILE%` here.
|
|
134
|
+
*
|
|
135
|
+
* node's own `process.env.Path` is the merged, expanded, per-process value and
|
|
136
|
+
* writing THAT back would fold the machine's PATH into the user's.
|
|
137
|
+
* @returns the raw value, or '' when the user has no PATH of their own.
|
|
138
|
+
*/
|
|
139
|
+
function readUserPath() {
|
|
140
|
+
const r = spawnSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf8', windowsHide: true });
|
|
141
|
+
const m = (r.stdout ?? '').match(/^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*)$/m);
|
|
142
|
+
return m === null ? '' : m[1].trimEnd();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Ask Windows to store the user's PATH, and to tell running shells about it. */
|
|
146
|
+
function writeUserPath(value) {
|
|
147
|
+
const r = spawnSync('setx', ['PATH', value], { encoding: 'utf8', windowsHide: true });
|
|
148
|
+
return r.status === 0 ? { ok: true, value: undefined } : { ok: false, error: (r.stderr || r.stdout || '').trim() };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bring the USER PATH to where it should be, saying out loud what changes
|
|
153
|
+
* before anything does.
|
|
154
|
+
* @param wanted - the PATH value this run wants.
|
|
155
|
+
* @param raw - the PATH value as it stands.
|
|
156
|
+
*/
|
|
157
|
+
function applyPath(raw, wanted) {
|
|
158
|
+
const plan = planPath(raw, wanted);
|
|
159
|
+
if (plan.action === 'unchanged') {
|
|
160
|
+
console.log('Your user PATH already says what it should — leaving it alone.');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (plan.action === 'refused') {
|
|
164
|
+
console.log(`NOT touching your user PATH: ${plan.reason}.`);
|
|
165
|
+
console.log('Add (or remove) this entry yourself, in Settings > "Edit environment variables for your account":');
|
|
166
|
+
console.log(` ${wanted}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
console.log('Changing ONE thing in your Windows user environment — your user PATH becomes:');
|
|
170
|
+
console.log(` ${wanted}`);
|
|
171
|
+
const written = writeUserPath(wanted);
|
|
172
|
+
if (!written.ok) {
|
|
173
|
+
console.error(`setx refused: ${written.error}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The install itself, with the narration stripped out: write the shims, bring
|
|
180
|
+
* the PATH in line, report what happened as data. This outcome object IS the
|
|
181
|
+
* `--json` output and the contract the SessionStart hook reads — change a
|
|
182
|
+
* field here and src/hook/session-start.ts must follow.
|
|
183
|
+
*
|
|
184
|
+
* @returns one of
|
|
185
|
+
* {kind: 'not-built', missing} — dist/mmap.mjs absent, nothing written;
|
|
186
|
+
* {kind: 'installed', binDir, cmdPath, shPath, path, wanted, reason?} —
|
|
187
|
+
* shims written; `path` says what happened to the USER PATH:
|
|
188
|
+
* 'unchanged' — the entry was already there,
|
|
189
|
+
* 'updated' — the entry was appended,
|
|
190
|
+
* 'refused' — setx would damage this PATH (`reason` says how); the
|
|
191
|
+
* entry in `wanted` must be added by hand,
|
|
192
|
+
* 'error' — setx itself failed (`reason` is its message).
|
|
193
|
+
*/
|
|
194
|
+
export function install(localAppData, pluginRoot) {
|
|
195
|
+
const mmapPath = join(pluginRoot, 'dist', 'mmap.mjs');
|
|
196
|
+
if (!existsSync(mmapPath)) return { kind: 'not-built', missing: mmapPath };
|
|
197
|
+
|
|
198
|
+
const binDir = binDirIn(localAppData);
|
|
199
|
+
const cmdPath = join(binDir, 'mmap.cmd');
|
|
200
|
+
const shPath = join(binDir, 'mmap');
|
|
201
|
+
mkdirSync(binDir, { recursive: true });
|
|
202
|
+
writeFileSync(cmdPath, cmdShim(mmapPath));
|
|
203
|
+
writeFileSync(shPath, shShim(mmapPath));
|
|
204
|
+
try {
|
|
205
|
+
chmodSync(shPath, 0o755); // git-bash honors the execute bit it can see
|
|
206
|
+
} catch {
|
|
207
|
+
// A filesystem without POSIX modes still runs the shim through `sh`;
|
|
208
|
+
// refusing to install over a chmod would be theatre.
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const raw = readUserPath();
|
|
212
|
+
const wanted = pathWith(raw, binDir);
|
|
213
|
+
const plan = planPath(raw, wanted);
|
|
214
|
+
if (plan.action === 'unchanged') return { kind: 'installed', binDir, cmdPath, shPath, path: 'unchanged', wanted };
|
|
215
|
+
if (plan.action === 'refused') {
|
|
216
|
+
return { kind: 'installed', binDir, cmdPath, shPath, path: 'refused', wanted, reason: plan.reason };
|
|
217
|
+
}
|
|
218
|
+
const written = writeUserPath(wanted);
|
|
219
|
+
return written.ok
|
|
220
|
+
? { kind: 'installed', binDir, cmdPath, shPath, path: 'updated', wanted }
|
|
221
|
+
: { kind: 'installed', binDir, cmdPath, shPath, path: 'error', wanted, reason: written.error };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function main() {
|
|
225
|
+
const argv = process.argv.slice(2);
|
|
226
|
+
const uninstall = argv.includes('--uninstall');
|
|
227
|
+
const json = argv.includes('--json');
|
|
228
|
+
const unknown = argv.find((a) => a !== '--uninstall' && a !== '--json');
|
|
229
|
+
if (unknown !== undefined) {
|
|
230
|
+
console.error(`unknown argument "${unknown}"\n${USAGE}`);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
if (uninstall && json) {
|
|
234
|
+
console.error(`--uninstall talks to a human; it has no --json mode\n${USAGE}`);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
if (process.platform !== 'win32') {
|
|
238
|
+
console.error('This installer is for Windows. Elsewhere, `npm i -g mellos-mapping` provides the same `mmap` command.');
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
const localAppData = process.env['LOCALAPPDATA'];
|
|
242
|
+
if (localAppData === undefined || localAppData === '') {
|
|
243
|
+
console.error('LOCALAPPDATA is not set — there is no per-user place to install into.');
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (uninstall) {
|
|
248
|
+
const binDir = binDirIn(localAppData);
|
|
249
|
+
const raw = readUserPath();
|
|
250
|
+
for (const path of [join(binDir, 'mmap.cmd'), join(binDir, 'mmap')]) {
|
|
251
|
+
rmSync(path, { force: true });
|
|
252
|
+
console.log(`removed ${path}`);
|
|
253
|
+
}
|
|
254
|
+
applyPath(raw, pathWithout(raw, binDir));
|
|
255
|
+
console.log('Close your terminal app entirely and reopen it for the change to take effect — a new tab keeps the old PATH.');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const outcome = install(localAppData, pluginRootOf(import.meta.url));
|
|
260
|
+
if (json) {
|
|
261
|
+
console.log(JSON.stringify(outcome));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (outcome.kind === 'not-built') {
|
|
265
|
+
console.error(`the plugin is not built — run "npm run build" (missing ${outcome.missing})`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
console.log(`wrote ${outcome.cmdPath}`);
|
|
269
|
+
console.log(`wrote ${outcome.shPath}`);
|
|
270
|
+
switch (outcome.path) {
|
|
271
|
+
case 'unchanged':
|
|
272
|
+
console.log('Your user PATH already says what it should — leaving it alone.');
|
|
273
|
+
break;
|
|
274
|
+
case 'updated':
|
|
275
|
+
console.log('Changed ONE thing in your Windows user environment — your user PATH is now:');
|
|
276
|
+
console.log(` ${outcome.wanted}`);
|
|
277
|
+
break;
|
|
278
|
+
case 'refused':
|
|
279
|
+
console.log(`NOT touching your user PATH: ${outcome.reason}.`);
|
|
280
|
+
console.log('Add this entry yourself, in Settings > "Edit environment variables for your account":');
|
|
281
|
+
console.log(` ${outcome.binDir}`);
|
|
282
|
+
break;
|
|
283
|
+
case 'error':
|
|
284
|
+
console.error(`setx refused: ${outcome.reason}`);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
console.log(
|
|
288
|
+
'Close Windows Terminal entirely and reopen it — a running terminal, new tabs included, keeps the old PATH.',
|
|
289
|
+
);
|
|
290
|
+
console.log('Then type `mmap` in any project: it opens the map pane, or closes the open one.');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (launchedAsEntry(process.argv[1], import.meta.url)) main();
|
package/scripts/mmap.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `mmap` — the human's entrance to the map pane. A TOGGLE.
|
|
4
|
+
*
|
|
5
|
+
* mmap open the pane for this project, or close the one
|
|
6
|
+
* that is already open
|
|
7
|
+
* mmap <slug> open the pane ON that page, or retarget the pane
|
|
8
|
+
* that is already open. Never closes anything:
|
|
9
|
+
* naming a page is asking to SEE it
|
|
10
|
+
* mmap [--window] [--ascii] [--no-color] [--no-mouse] [--no-follow]
|
|
11
|
+
* [--interval <ms>] [--force]
|
|
12
|
+
*
|
|
13
|
+
* Two things make this a different command from scripts/open-pane.mjs rather
|
|
14
|
+
* than a flag on it:
|
|
15
|
+
*
|
|
16
|
+
* 1. It has no project argument. A human types `mmap` wherever they happen
|
|
17
|
+
* to be standing, so the project is DISCOVERED — walk up from the cwd to
|
|
18
|
+
* the nearest directory holding the store, the way git finds its root.
|
|
19
|
+
* 2. It closes. `mmap` with a pane open writes the store's one-shot quit
|
|
20
|
+
* request and the pane exits within a poll tick. The agent's launcher
|
|
21
|
+
* deliberately cannot do that.
|
|
22
|
+
*
|
|
23
|
+
* Everything else — the window probe, the already-running check, the `wt`
|
|
24
|
+
* payload — is ./pane-core.mjs, shared verbatim with open-pane.mjs.
|
|
25
|
+
*
|
|
26
|
+
* Pure helpers are exported for the spec; the command runs only as an entry
|
|
27
|
+
* point, so importing this file is inert.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync } from 'node:fs';
|
|
30
|
+
import { dirname, join, resolve } from 'node:path';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
DEDICATED_WINDOW_NAME,
|
|
34
|
+
PANE_MODE,
|
|
35
|
+
launchedAsEntry,
|
|
36
|
+
loadPluginPaths,
|
|
37
|
+
placePane,
|
|
38
|
+
pluginRootOf,
|
|
39
|
+
takeWatcherFlag,
|
|
40
|
+
paneIsOpen,
|
|
41
|
+
writeFocusRequest,
|
|
42
|
+
writeQuitRequest,
|
|
43
|
+
} from './pane-core.mjs';
|
|
44
|
+
|
|
45
|
+
export const USAGE =
|
|
46
|
+
'usage: mmap [<page-slug>] [--window] [--force] [--ascii] [--no-color] [--no-mouse]' +
|
|
47
|
+
' [--no-follow] [--interval <ms>]\n' +
|
|
48
|
+
' bare `mmap` toggles: it opens the map pane for this project, or closes the open one.';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse the toggle's command line.
|
|
52
|
+
*
|
|
53
|
+
* @param argv - arguments after the script path.
|
|
54
|
+
* @param idRule - the store's page-slug grammar (ID_RULE), passed in rather
|
|
55
|
+
* than restated, so this parser cannot drift from the ids the store accepts.
|
|
56
|
+
* @returns ok(config) or err(message). One positional argument at most, and it
|
|
57
|
+
* is a PAGE SLUG — not a directory: `mmap` is typed inside the project it
|
|
58
|
+
* means. An unknown flag is refused rather than dropped, exactly as the
|
|
59
|
+
* watcher and the agent launcher refuse one.
|
|
60
|
+
*/
|
|
61
|
+
export function parseMmapArgs(argv, idRule) {
|
|
62
|
+
const positional = [];
|
|
63
|
+
const watcherFlags = [];
|
|
64
|
+
let mode = PANE_MODE.split;
|
|
65
|
+
let force = false;
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const a = argv[i];
|
|
68
|
+
const watcher = takeWatcherFlag(argv, i);
|
|
69
|
+
if (watcher.kind === 'bad-value') return { ok: false, error: `${watcher.message}\n${USAGE}` };
|
|
70
|
+
if (watcher.kind === 'taken') {
|
|
71
|
+
watcherFlags.push(...watcher.flags);
|
|
72
|
+
i = watcher.next;
|
|
73
|
+
} else if (a === '--window') {
|
|
74
|
+
mode = PANE_MODE.window;
|
|
75
|
+
} else if (a === '--force') {
|
|
76
|
+
force = true;
|
|
77
|
+
} else if (a === '--page') {
|
|
78
|
+
// The slug is the positional here; accepting both spellings would make
|
|
79
|
+
// `mmap api --page other` a question with no answer.
|
|
80
|
+
return { ok: false, error: `mmap takes the page slug as its argument, not --page\n${USAGE}` };
|
|
81
|
+
} else if (a.startsWith('--')) {
|
|
82
|
+
return { ok: false, error: `unknown flag "${a}"\n${USAGE}` };
|
|
83
|
+
} else {
|
|
84
|
+
positional.push(a);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (positional.length > 1) return { ok: false, error: `mmap takes at most one page slug\n${USAGE}` };
|
|
88
|
+
const pageSlug = positional[0];
|
|
89
|
+
if (pageSlug !== undefined && !idRule.test(pageSlug)) {
|
|
90
|
+
return { ok: false, error: `a page is a kebab-case slug (got "${pageSlug}")\n${USAGE}` };
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, value: { pageSlug, mode, force, watcherFlags } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The directories to look in for a project's store, nearest first: the
|
|
97
|
+
* starting directory, then every ancestor up to and including the filesystem
|
|
98
|
+
* root.
|
|
99
|
+
*
|
|
100
|
+
* Pure on purpose — the caller asks the filesystem which of these actually
|
|
101
|
+
* holds a store — so the walk itself is specifiable without a project tree.
|
|
102
|
+
*/
|
|
103
|
+
export function storeSearchPath(startDir) {
|
|
104
|
+
const dirs = [];
|
|
105
|
+
let dir = resolve(startDir);
|
|
106
|
+
for (;;) {
|
|
107
|
+
dirs.push(dir);
|
|
108
|
+
const parent = dirname(dir);
|
|
109
|
+
if (parent === dir) return dirs; // the filesystem root is its own parent
|
|
110
|
+
dir = parent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The directory whose presence marks a project, taken from the store's own
|
|
116
|
+
* default-page path (its first segment). Derived rather than restated: this
|
|
117
|
+
* script must not carry a second copy of the store's layout.
|
|
118
|
+
*/
|
|
119
|
+
export function storeMarkerOf(storeRelativePath) {
|
|
120
|
+
return storeRelativePath.split(/[\\/]/)[0];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Which project `mmap` was typed in: the NEAREST candidate that holds a store,
|
|
125
|
+
* the way git discovers its root.
|
|
126
|
+
*
|
|
127
|
+
* @param candidates - storeSearchPath's answer, nearest first.
|
|
128
|
+
* @param holdsStore - whether each candidate holds the store directory, in the
|
|
129
|
+
* same order. A parallel array of answers rather than a predicate to call:
|
|
130
|
+
* the filesystem question belongs to the entry point, and this rule is then
|
|
131
|
+
* a fold over values that a spec can state in full.
|
|
132
|
+
* @returns the project root, and whether a store was actually found —
|
|
133
|
+
* `found: false` means the starting directory is being used because nothing
|
|
134
|
+
* above it has a map yet, which is a legitimate first open, not an error.
|
|
135
|
+
*/
|
|
136
|
+
export function nearestProject(candidates, holdsStore) {
|
|
137
|
+
const at = holdsStore.indexOf(true);
|
|
138
|
+
return at >= 0 ? { root: candidates[at], found: true } : { root: candidates[0], found: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What a `mmap` invocation means, given whether a pane is already up.
|
|
143
|
+
*
|
|
144
|
+
* The toggle rule in one place: a BARE `mmap` against a running pane closes
|
|
145
|
+
* it; naming a page never does — asking to see something is not asking to
|
|
146
|
+
* lose it — and retargets the running pane instead. With no pane running,
|
|
147
|
+
* every form opens one.
|
|
148
|
+
*
|
|
149
|
+
* @param running - a watcher for this project's map file is alive.
|
|
150
|
+
* @param pageSlug - the page named on the command line, if any.
|
|
151
|
+
* @param force - `--force`: open another pane regardless.
|
|
152
|
+
*/
|
|
153
|
+
export function toggleAction(running, pageSlug, force = false) {
|
|
154
|
+
if (!running || force) return { kind: 'open' };
|
|
155
|
+
return pageSlug === undefined ? { kind: 'quit' } : { kind: 'focus', page: pageSlug };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function main() {
|
|
159
|
+
const loaded = await loadPluginPaths(pluginRootOf(import.meta.url));
|
|
160
|
+
if (!loaded.ok) {
|
|
161
|
+
console.error(loaded.error);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
const { store, watchPath } = loaded.value;
|
|
165
|
+
|
|
166
|
+
const parsed = parseMmapArgs(process.argv.slice(2), store.ID_RULE);
|
|
167
|
+
if (!parsed.ok) {
|
|
168
|
+
console.error(parsed.error);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
if (process.platform !== 'win32') {
|
|
172
|
+
console.error('mmap opens the pane through Windows Terminal — on other platforms run the watcher yourself:');
|
|
173
|
+
console.error(` node "${watchPath}" --file "<project>/${store.STATE_FILE_RELATIVE_PATH}"`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const candidates = storeSearchPath(process.cwd());
|
|
178
|
+
const marker = storeMarkerOf(store.STATE_FILE_RELATIVE_PATH);
|
|
179
|
+
const project = nearestProject(candidates, candidates.map((dir) => existsSync(join(dir, marker))));
|
|
180
|
+
const cfg = { ...parsed.value, projectDir: project.root };
|
|
181
|
+
const mapFile = join(project.root, store.STATE_FILE_RELATIVE_PATH);
|
|
182
|
+
const action = toggleAction(paneIsOpen(store, mapFile), cfg.pageSlug, cfg.force);
|
|
183
|
+
|
|
184
|
+
if (action.kind === 'quit') {
|
|
185
|
+
writeQuitRequest(store.quitFilePath(mapFile));
|
|
186
|
+
console.log(`Closing the map pane for ${project.root}.`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (action.kind === 'focus') {
|
|
190
|
+
writeFocusRequest(store.focusFilePath(mapFile), action.page);
|
|
191
|
+
console.log(`The map pane for ${project.root} is already open — showing page "${action.page}".`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const placed = placePane(cfg, watchPath, mapFile);
|
|
196
|
+
if (!placed.ok) {
|
|
197
|
+
console.error(placed.error);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
const where =
|
|
201
|
+
placed.value.mode === PANE_MODE.window
|
|
202
|
+
? `in the dedicated "${DEDICATED_WINDOW_NAME}" window (${placed.value.reason})`
|
|
203
|
+
: 'beside this terminal (vertical split)';
|
|
204
|
+
console.log(`Map opened for ${project.root} ${where}.`);
|
|
205
|
+
if (!project.found) {
|
|
206
|
+
console.log(
|
|
207
|
+
'This project has no map yet, so the pane will sit on its standby screen ' +
|
|
208
|
+
'until the assistant declares one.',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (launchedAsEntry(process.argv[1], import.meta.url)) await main();
|