mellos-mapping 0.24.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 +82 -12
- package/README.zh-CN.md +69 -10
- package/dist/hook-session-start.mjs +69 -13
- package/dist/native/LICENSES.txt +443 -0
- package/dist/native/darwin-arm64/fs-native-extensions.node +0 -0
- package/dist/native/darwin-x64/fs-native-extensions.node +0 -0
- package/dist/native/linux-arm64/fs-native-extensions.node +0 -0
- package/dist/native/linux-x64/fs-native-extensions.node +0 -0
- package/dist/native/win32-arm64/fs-native-extensions.node +0 -0
- package/dist/native/win32-x64/fs-native-extensions.node +0 -0
- package/dist/native-lock.cjs +346 -0
- package/dist/omp-extension.mjs +359 -0
- package/dist/server.mjs +199 -139
- package/dist/terminal-worker.mjs +116 -74
- package/dist/watch.mjs +125 -83
- package/dist/web.mjs +170 -113
- package/docs/codex.md +4 -1
- package/docs/locking.md +115 -0
- package/docs/map-api.md +29 -13
- package/lib/store/migration.js +19 -12
- package/lib/store/native-lock.d.ts +5 -0
- package/lib/store/native-lock.js +27 -0
- package/lib/store/transaction.d.ts +9 -2
- package/lib/store/transaction.js +79 -50
- package/package.json +11 -3
- package/scripts/install-mmap-command.mjs +179 -13
package/docs/map-api.md
CHANGED
|
@@ -64,20 +64,35 @@ a group does not silently move members: include their intended node moves in
|
|
|
64
64
|
the same update or transaction. Normal single-field changes keep the old graph
|
|
65
65
|
constraints; use a batch for changes that require a coordinated final graph.
|
|
66
66
|
|
|
67
|
-
Every graph writer in the current MCP, HTTP viewer and watcher uses
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
67
|
+
Every graph writer in the current MCP, HTTP viewer and watcher uses the same
|
|
68
|
+
`withStoreLock` boundary. It takes a non-blocking OS exclusive lock on one fixed
|
|
69
|
+
regular file, `.mellos/.write-lock`; named pages and the default page share it.
|
|
70
|
+
That file is separate from the map JSON files and is never removed or renamed
|
|
71
|
+
as part of locking. The read, revision check, change and save happen while the
|
|
72
|
+
lock is held.
|
|
73
|
+
Contention returns `BUSY`; retry after the other operation completes. There is
|
|
74
|
+
no background lock polling. `LOCK_MIGRATION_REQUIRED` means the lock path is
|
|
75
|
+
still a directory from the old protocol; `LOCK_UNAVAILABLE` means the required
|
|
76
|
+
native binding or supported platform is unavailable. Neither error permits an
|
|
77
|
+
unlocked write. Normal completion/error releases the lock, and the OS also
|
|
78
|
+
releases it when the owning process exits or crashes. A leftover lock
|
|
79
|
+
file is expected and does not indicate a live or stale owner.
|
|
80
|
+
|
|
81
|
+
MCP `expectedRevision` is optional. When supplied, it is compared inside the
|
|
82
|
+
lock; `CONFLICT` requires a fresh read and reconsideration of the edit. Omitting
|
|
83
|
+
it still serializes the operation against other writers, but does not check
|
|
84
|
+
whether its inputs came from an older read. A later update can overwrite the
|
|
85
|
+
same field or replace an entire `context` or `sources` value. Pass the revision
|
|
86
|
+
from the page read whenever an edit depends on that read.
|
|
75
87
|
|
|
76
88
|
Low-level library saveMapFile, hand edits, and older running processes do not
|
|
77
|
-
participate in this contract.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
participate in this contract. Atomic file replacement alone does not make a
|
|
90
|
+
caller's stale read/modify/write safe. Stop all old MCP, HTTP viewer and watcher
|
|
91
|
+
processes for the project before upgrading; restart the HTTP service as well
|
|
92
|
+
as reopening its tabs. A leftover old lock directory requires the manual
|
|
93
|
+
[migration steps](locking.md#upgrade-from-directory-locks). There is no hot
|
|
94
|
+
upgrade of the lock protocol, and supporting map format 2 alone does not
|
|
95
|
+
establish that all writers use the new lock.
|
|
81
96
|
|
|
82
97
|
Legacy `mmap_remove {pages:[...]}` remains a separately documented batch of file
|
|
83
98
|
deletions, with explicit partial success and historical reference behavior. It
|
|
@@ -106,7 +121,8 @@ and the page is saved once; a refusal preserves the original file bytes.
|
|
|
106
121
|
```
|
|
107
122
|
|
|
108
123
|
Machine-readable error codes include NOT_FOUND, INVALID_STORE, REFUSED,
|
|
109
|
-
CONFLICT, BUSY,
|
|
124
|
+
CONFLICT, BUSY, LOCK_MIGRATION_REQUIRED, LOCK_UNAVAILABLE, INVALID_CURSOR,
|
|
125
|
+
INVALID_ARGUMENT, REFERENCED and SAVE_FAILED.
|
|
110
126
|
Malformed schema inputs remain MCP invalid-argument errors. Read calls do not
|
|
111
127
|
open panels or create page files.
|
|
112
128
|
|
package/lib/store/migration.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, renameSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { PAGES_DIR_NAME } from './pages.js';
|
|
4
4
|
import { configFilePath } from './policy.js';
|
|
5
|
+
import { withStoreLock } from './transaction.js';
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// legacy migration — stores written under the old host-coupled location
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -29,18 +30,24 @@ export function migrateLegacyStore(defaultFile) {
|
|
|
29
30
|
const legacyDefault = join(projectRoot, LEGACY_STATE_FILE_RELATIVE_PATH);
|
|
30
31
|
const legacyPages = join(dirname(legacyDefault), LEGACY_PAGES_DIR_NAME);
|
|
31
32
|
const legacyConfig = join(dirname(legacyDefault), LEGACY_CONFIG_FILE_NAME);
|
|
32
|
-
const hasLegacy = existsSync(legacyDefault) || existsSync(legacyPages) || existsSync(legacyConfig);
|
|
33
|
-
const hasCurrent = existsSync(defaultFile)
|
|
33
|
+
const hasLegacy = () => existsSync(legacyDefault) || existsSync(legacyPages) || existsSync(legacyConfig);
|
|
34
|
+
const hasCurrent = () => existsSync(defaultFile)
|
|
34
35
|
|| existsSync(join(dirname(defaultFile), PAGES_DIR_NAME))
|
|
35
36
|
|| existsSync(configFilePath(defaultFile));
|
|
36
|
-
if (!hasLegacy || hasCurrent)
|
|
37
|
+
if (!hasLegacy() || hasCurrent())
|
|
37
38
|
return false;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
return withStoreLock(defaultFile, () => {
|
|
40
|
+
// A writer or another startup may have committed after the fast checks.
|
|
41
|
+
// Recheck under the same lock used by all current project mutations;
|
|
42
|
+
// rename is allowed to replace its destination on every supported OS.
|
|
43
|
+
if (!hasLegacy() || hasCurrent())
|
|
44
|
+
return false;
|
|
45
|
+
if (existsSync(legacyDefault))
|
|
46
|
+
renameSync(legacyDefault, defaultFile);
|
|
47
|
+
if (existsSync(legacyPages))
|
|
48
|
+
renameSync(legacyPages, join(dirname(defaultFile), PAGES_DIR_NAME));
|
|
49
|
+
if (existsSync(legacyConfig))
|
|
50
|
+
renameSync(legacyConfig, configFilePath(defaultFile));
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
46
53
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Load the shipped N-API implementation without a runtime npm dependency. */
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
let loaded;
|
|
6
|
+
export function nativeLock() {
|
|
7
|
+
if (loaded)
|
|
8
|
+
return loaded;
|
|
9
|
+
if (Number(process.versions.napi ?? 0) < 9) {
|
|
10
|
+
throw new Error('Project locks require Node-API 9 (Node 18.17+ or 20.3+).');
|
|
11
|
+
}
|
|
12
|
+
// Bundled entry points live in dist/; source and npm library modules live
|
|
13
|
+
// two directories below the package root. Never resolve from the user's cwd.
|
|
14
|
+
const candidates = [
|
|
15
|
+
new URL('./native-lock.cjs', import.meta.url),
|
|
16
|
+
new URL('../../dist/native-lock.cjs', import.meta.url),
|
|
17
|
+
];
|
|
18
|
+
const entry = candidates.find(candidate => existsSync(candidate));
|
|
19
|
+
if (!entry)
|
|
20
|
+
throw new Error('The installed package is missing dist/native-lock.cjs; reinstall the complete package.');
|
|
21
|
+
const backend = createRequire(import.meta.url)(fileURLToPath(entry));
|
|
22
|
+
if (typeof backend.tryLock !== 'function' || typeof backend.unlock !== 'function') {
|
|
23
|
+
throw new Error('The installed native lock backend is invalid.');
|
|
24
|
+
}
|
|
25
|
+
loaded = backend;
|
|
26
|
+
return backend;
|
|
27
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { MellosMap } from '../domain/types.js';
|
|
2
|
+
/** Viewer health must identify the writer protocol before a new client reuses it. */
|
|
3
|
+
export declare const STORE_LOCK_PROTOCOL = "os-file-v1";
|
|
2
4
|
export declare class LedgerError extends Error {
|
|
3
5
|
readonly code: string;
|
|
4
6
|
readonly details: Record<string, unknown>;
|
|
@@ -8,5 +10,10 @@ export declare const revisionOf: (map: MellosMap) => string;
|
|
|
8
10
|
export declare function assertRevision(actual: string, expected?: string): void;
|
|
9
11
|
/** A named page and the default page share the same project lock. */
|
|
10
12
|
export declare function storeDirectory(file: string): string;
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A synchronous transaction on a permanent, independent OS lock file.
|
|
15
|
+
* The action must finish synchronously; all cooperating writers use this entry.
|
|
16
|
+
* Never unlink/rename this file: another opener must reach the same lock object.
|
|
17
|
+
* Contention is BUSY immediately, with no timer, stale lease or PID recovery.
|
|
18
|
+
*/
|
|
19
|
+
export declare function withStoreLock<T>(file: string, action: () => T & (T extends PromiseLike<unknown> ? never : unknown)): T;
|
package/lib/store/transaction.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/** Cooperative, cross-process transactions for MCP and HTTP writers. */
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
|
-
import {
|
|
2
|
+
import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
5
|
import { serializeMap } from './format.js';
|
|
6
|
+
import { nativeLock } from './native-lock.js';
|
|
7
|
+
/** Viewer health must identify the writer protocol before a new client reuses it. */
|
|
8
|
+
export const STORE_LOCK_PROTOCOL = 'os-file-v1';
|
|
6
9
|
export class LedgerError extends Error {
|
|
7
10
|
code;
|
|
8
11
|
details;
|
|
@@ -20,72 +23,98 @@ export function assertRevision(actual, expected) {
|
|
|
20
23
|
/** A named page and the default page share the same project lock. */
|
|
21
24
|
export function storeDirectory(file) {
|
|
22
25
|
const dir = dirname(file);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
// Keep a configured pages directory tied to its project even when that
|
|
27
|
+
// directory itself is a symlink. Case variants must share the lock on
|
|
28
|
+
// Windows and case-insensitive macOS volumes (and are harmless elsewhere).
|
|
29
|
+
if (basename(dir).toLowerCase() === 'pages')
|
|
30
|
+
return dirname(dir);
|
|
31
|
+
let canonical = dir;
|
|
26
32
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
catch (e) {
|
|
35
|
-
return e.code === 'ESRCH';
|
|
36
|
-
}
|
|
33
|
+
canonical = realpathSync.native(dir);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code !== 'ENOENT')
|
|
37
|
+
throw error;
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
// An explicit --file may reach pages through a differently named alias.
|
|
40
|
+
return basename(canonical).toLowerCase() === 'pages' ? dirname(canonical) : canonical;
|
|
41
|
+
}
|
|
42
|
+
function checkLockPath(lock) {
|
|
43
|
+
const entry = lstatSync(lock, { throwIfNoEntry: false });
|
|
44
|
+
if (entry?.isDirectory()) {
|
|
45
|
+
throw new LedgerError('LOCK_MIGRATION_REQUIRED', `Legacy directory lock at ${lock}. Stop all old MCP servers, viewers and watchers for this project, then move that directory aside for inspection and retry. Never remove the new regular lock file.`, { path: lock });
|
|
46
|
+
}
|
|
47
|
+
if (entry && !entry.isFile()) {
|
|
48
|
+
throw new LedgerError('LOCK_UNAVAILABLE', `Project lock must be a regular file, not a symlink or special file: ${lock}`, { path: lock });
|
|
40
49
|
}
|
|
41
50
|
}
|
|
42
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* A synchronous transaction on a permanent, independent OS lock file.
|
|
53
|
+
* The action must finish synchronously; all cooperating writers use this entry.
|
|
54
|
+
* Never unlink/rename this file: another opener must reach the same lock object.
|
|
55
|
+
* Contention is BUSY immediately, with no timer, stale lease or PID recovery.
|
|
56
|
+
*/
|
|
43
57
|
export function withStoreLock(file, action) {
|
|
58
|
+
let backend;
|
|
59
|
+
try {
|
|
60
|
+
backend = nativeLock();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new LedgerError('LOCK_UNAVAILABLE', `Cannot load the operating-system lock backend: ${error instanceof Error ? error.message : String(error)}`);
|
|
64
|
+
}
|
|
44
65
|
const dir = storeDirectory(file);
|
|
45
66
|
mkdirSync(dir, { recursive: true });
|
|
46
67
|
const lock = join(dir, '.write-lock');
|
|
47
|
-
|
|
48
|
-
|
|
68
|
+
checkLockPath(lock);
|
|
69
|
+
let fd;
|
|
70
|
+
// a+ atomically opens/creates without truncation. At a legacy/new startup
|
|
71
|
+
// race, either old mkdir wins (we refuse its directory), or our file wins
|
|
72
|
+
// (old mkdir gets EEXIST and cannot find owner.json, so refuses to write).
|
|
49
73
|
try {
|
|
50
|
-
|
|
74
|
+
fd = openSync(lock, 'a+', 0o600);
|
|
51
75
|
}
|
|
52
76
|
catch (error) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
checkLockPath(lock);
|
|
78
|
+
throw new LedgerError('LOCK_UNAVAILABLE', `Cannot open project lock ${lock}: ${error instanceof Error ? error.message : String(error)}`, { path: lock });
|
|
79
|
+
}
|
|
80
|
+
let acquired = false;
|
|
81
|
+
try {
|
|
82
|
+
checkLockPath(lock);
|
|
83
|
+
if (!fstatSync(fd).isFile())
|
|
84
|
+
throw new LedgerError('LOCK_UNAVAILABLE', `Project lock is not a regular file: ${lock}`);
|
|
59
85
|
try {
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
throw new LedgerError('BUSY', 'Another process is recovering the writer lock.');
|
|
86
|
+
acquired = backend.tryLock(fd);
|
|
64
87
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
throw new LedgerError('BUSY', 'Writer ownership changed.');
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw new LedgerError('LOCK_UNAVAILABLE', `Cannot acquire project lock ${lock}: ${error instanceof Error ? error.message : String(error)}`, { path: lock });
|
|
68
90
|
}
|
|
69
|
-
|
|
91
|
+
if (!acquired)
|
|
92
|
+
throw new LedgerError('BUSY', `Another writer owns ${lock}; retry after it completes.`);
|
|
93
|
+
return action();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
// Closing also releases this descriptor's lock if explicit unlock fails.
|
|
97
|
+
// Cleanup must not misreport an already committed map as unsaved.
|
|
70
98
|
try {
|
|
71
|
-
|
|
99
|
+
if (acquired)
|
|
100
|
+
backend.unlock(fd);
|
|
72
101
|
}
|
|
73
102
|
catch {
|
|
74
|
-
|
|
103
|
+
warnLockCleanup(`Could not explicitly unlock ${lock}; closing its file handle.`);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
try {
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
warnLockCleanup(`Could not close the project lock handle for ${lock}; restart this process before retrying writes.`);
|
|
111
|
+
}
|
|
75
112
|
}
|
|
76
113
|
}
|
|
77
|
-
|
|
114
|
+
}
|
|
115
|
+
function warnLockCleanup(message) {
|
|
78
116
|
try {
|
|
79
|
-
|
|
80
|
-
initialized = true;
|
|
81
|
-
return action();
|
|
82
|
-
}
|
|
83
|
-
finally {
|
|
84
|
-
// Only remove the directory still owned by this invocation.
|
|
85
|
-
try {
|
|
86
|
-
if (!initialized || JSON.parse(readFileSync(owner, 'utf8')).token === token)
|
|
87
|
-
rmSync(lock, { recursive: true });
|
|
88
|
-
}
|
|
89
|
-
catch { /* A cleanup error must not misreport a successfully committed map as unsaved. */ }
|
|
117
|
+
process.stderr.write(`mellos-mapping: ${message}\n`);
|
|
90
118
|
}
|
|
119
|
+
catch { /* Preserve the action's result. */ }
|
|
91
120
|
}
|
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,12 +65,18 @@
|
|
|
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",
|
|
71
76
|
"README.zh-CN.md",
|
|
72
77
|
"docs/codex.md",
|
|
73
78
|
"docs/map-api.md",
|
|
79
|
+
"docs/locking.md",
|
|
74
80
|
"scripts/codex-register.mjs",
|
|
75
81
|
"scripts/codex-cli.mjs",
|
|
76
82
|
"scripts/install-mmap-command.mjs",
|
|
@@ -82,7 +88,7 @@
|
|
|
82
88
|
"scripts/watcher-command.mjs"
|
|
83
89
|
],
|
|
84
90
|
"engines": {
|
|
85
|
-
"node": ">=
|
|
91
|
+
"node": "^18.17.0 || >=20.3.0"
|
|
86
92
|
},
|
|
87
93
|
"scripts": {
|
|
88
94
|
"test": "vitest run",
|
|
@@ -94,9 +100,10 @@
|
|
|
94
100
|
"check:package": "node scripts/check-package-surface.mjs",
|
|
95
101
|
"check:codex": "node scripts/check-codex-package.mjs",
|
|
96
102
|
"check:reuse": "node scripts/check-reuse.mjs",
|
|
103
|
+
"check:locks": "node scripts/check-store-lock.mjs",
|
|
97
104
|
"benchmark:render": "node scripts/benchmark-render.mjs",
|
|
98
105
|
"prepack": "npm run build",
|
|
99
|
-
"verify": "npm run typecheck && npm run test && npm run build && npm run check:reuse && npm run check:package && npm run check:codex && npm run check:release"
|
|
106
|
+
"verify": "npm run typecheck && npm run test && npm run build && npm run check:locks && npm run check:reuse && npm run check:package && npm run check:codex && npm run check:release"
|
|
100
107
|
},
|
|
101
108
|
"devDependencies": {
|
|
102
109
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
@@ -105,6 +112,7 @@
|
|
|
105
112
|
"@xterm/addon-fit": "0.11.0",
|
|
106
113
|
"@xterm/xterm": "6.0.0",
|
|
107
114
|
"esbuild": "^0.25.0",
|
|
115
|
+
"fs-native-extensions": "1.5.1",
|
|
108
116
|
"typescript": "^5.8.0",
|
|
109
117
|
"vitest": "^4.1.11",
|
|
110
118
|
"ws": "8.21.3",
|
|
@@ -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}`);
|