mellos-mapping 0.20.3 → 0.23.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 +130 -69
- package/README.zh-CN.md +126 -65
- package/dist/hook-session-start.mjs +28 -20
- package/dist/mmap.mjs +303 -171
- package/dist/preview.mjs +1457 -0
- package/dist/server.mjs +1984 -741
- package/dist/store-paths.mjs +69 -22
- package/dist/terminal-worker.mjs +3331 -0
- package/dist/watch.mjs +957 -571
- package/dist/web/TERMINAL-LICENSES.txt +70 -0
- package/dist/web/app.css +967 -0
- package/dist/web/app.js +1356 -0
- package/dist/web/index.html +9 -0
- package/dist/web/terminal.css +9 -0
- package/dist/web/terminal.html +7 -0
- package/dist/web/terminal.js +9293 -0
- package/dist/web/xterm.css +285 -0
- package/dist/web.mjs +5646 -0
- package/docs/codex.md +189 -0
- package/docs/map-api.md +148 -0
- package/lib/domain/context.d.ts +11 -0
- package/lib/domain/context.js +27 -0
- package/lib/domain/text.d.ts +9 -0
- package/lib/domain/text.js +54 -0
- package/lib/domain/types.d.ts +3 -0
- package/lib/preview/index.d.ts +3 -0
- package/lib/preview/index.js +3 -0
- package/lib/preview/markdown.d.ts +8 -0
- package/lib/preview/markdown.js +54 -0
- package/lib/preview/presentation.d.ts +6 -0
- package/lib/preview/presentation.js +14 -0
- package/lib/preview/publisher.d.ts +23 -0
- package/lib/preview/publisher.js +143 -0
- package/lib/preview/svg.d.ts +3 -0
- package/lib/preview/svg.js +74 -0
- package/lib/preview/text.d.ts +4 -0
- package/lib/preview/text.js +13 -0
- package/lib/render/canvas.d.ts +1 -1
- package/lib/render/canvas.js +4 -2
- package/lib/render/draw.js +9 -6
- package/lib/render/render.d.ts +6 -0
- package/lib/render/render.js +50 -18
- package/lib/render/width.js +3 -1
- package/lib/store/atomic.d.ts +18 -0
- package/lib/store/atomic.js +86 -0
- package/lib/store/channels.d.ts +40 -0
- package/lib/store/channels.js +135 -0
- package/lib/store/format.js +21 -4
- package/lib/store/json-text.d.ts +9 -0
- package/lib/store/json-text.js +16 -0
- package/lib/store/maps.d.ts +12 -0
- package/lib/store/maps.js +42 -0
- package/lib/store/migration.d.ts +12 -0
- package/lib/store/migration.js +46 -0
- package/lib/store/pages.d.ts +46 -0
- package/lib/store/pages.js +89 -0
- package/lib/store/policy.d.ts +74 -0
- package/lib/store/policy.js +144 -0
- package/lib/store/project.d.ts +2 -0
- package/lib/store/project.js +29 -0
- package/lib/store/store.d.ts +15 -257
- package/lib/store/store.js +16 -695
- package/lib/store/transaction.d.ts +12 -0
- package/lib/store/transaction.js +91 -0
- package/lib/store/viewers.d.ts +81 -0
- package/lib/store/viewers.js +186 -0
- package/package.json +27 -5
- package/scripts/codex-cli.mjs +42 -0
- package/scripts/codex-register.mjs +27 -94
- package/scripts/mmap.mjs +27 -17
- package/scripts/open-pane.mjs +37 -18
- package/scripts/pane-core.mjs +57 -220
- package/scripts/terminal-session.mjs +137 -0
- package/scripts/tmux-session.mjs +90 -0
- package/scripts/watcher-command.mjs +16 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { makePageId } from './format.js';
|
|
4
|
+
import { isRecord, stripBom } from './json-text.js';
|
|
5
|
+
import { viewersDirPath } from './viewers.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// focus requests — "show this page" messages from pane openers to the watcher
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
//
|
|
10
|
+
// State files flow one way, MCP server → watcher; a launcher that wants an
|
|
11
|
+
// ALREADY-RUNNING pane to show a particular page has no channel to it. The
|
|
12
|
+
// focus file is that channel, one-shot on purpose: the watcher consumes the
|
|
13
|
+
// request AND DELETES the file, so a request lives about one poll tick —
|
|
14
|
+
// nothing stale survives to misdirect tomorrow's pane, and the project's git
|
|
15
|
+
// status barely ever sees the file exist.
|
|
16
|
+
/** Sibling of the default file carrying a one-shot "show this page" request. */
|
|
17
|
+
export const FOCUS_FILE_NAME = 'focus';
|
|
18
|
+
export function focusFilePath(defaultFile, pid) {
|
|
19
|
+
return paneChannelPath(defaultFile, FOCUS_FILE_NAME, pid);
|
|
20
|
+
}
|
|
21
|
+
/** A targeted message cannot be consumed by another pane of the same project. */
|
|
22
|
+
function paneChannelPath(defaultFile, channel, pid) {
|
|
23
|
+
if (pid === undefined)
|
|
24
|
+
return join(dirname(defaultFile), channel);
|
|
25
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
26
|
+
throw new Error('Invalid pane process id');
|
|
27
|
+
return join(viewersDirPath(defaultFile), `${pid}.${channel}`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Consume a pending focus request: read it, delete the file, return it.
|
|
31
|
+
* Absent file — the overwhelmingly common case — or junk content means no
|
|
32
|
+
* request; the channel is best-effort and junk is swept by the same delete.
|
|
33
|
+
*/
|
|
34
|
+
export function takeFocusRequest(defaultFile, pid) {
|
|
35
|
+
const targeted = pid === undefined ? undefined : focusFilePath(defaultFile, pid);
|
|
36
|
+
const path = targeted !== undefined && existsSync(targeted) ? targeted : focusFilePath(defaultFile);
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = readFileSync(path, 'utf8');
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
rmSync(path, { force: true });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// deletion is a courtesy: re-consuming next tick is harmless because
|
|
49
|
+
// switching to the already-shown page is a no-op
|
|
50
|
+
}
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
59
|
+
return undefined;
|
|
60
|
+
const page = parsed.page;
|
|
61
|
+
if (page === undefined || page === null)
|
|
62
|
+
return { page: undefined };
|
|
63
|
+
if (typeof page !== 'string')
|
|
64
|
+
return undefined;
|
|
65
|
+
const id = makePageId(page);
|
|
66
|
+
return id.ok ? { page: id.value } : undefined;
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// quit requests — "close yourself" messages from the toggle to the watcher
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
//
|
|
72
|
+
// The mirror of the focus file, and there for the same reason: a human who
|
|
73
|
+
// types `mmap` in some OTHER terminal has no channel to the pane that is
|
|
74
|
+
// already running. The quit file is that channel, one-shot on purpose — the
|
|
75
|
+
// watcher consumes the request AND DELETES the file, so a request lives about
|
|
76
|
+
// one poll tick and nothing stale survives to close tomorrow's pane.
|
|
77
|
+
//
|
|
78
|
+
// The request carries no payload. A pane belongs to one store, so "close the
|
|
79
|
+
// pane watching this store" has nothing to say beyond being asked.
|
|
80
|
+
/** Sibling of the default file carrying a one-shot "close the pane" request. */
|
|
81
|
+
export const QUIT_FILE_NAME = 'quit';
|
|
82
|
+
export function quitFilePath(defaultFile, pid) {
|
|
83
|
+
return paneChannelPath(defaultFile, QUIT_FILE_NAME, pid);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Consume a pending quit request: read it, delete the file, say whether there
|
|
87
|
+
* was one. Absent file — the overwhelmingly common case — or content that is
|
|
88
|
+
* not a JSON object means NO request; the channel is best-effort and junk is
|
|
89
|
+
* swept by the same delete.
|
|
90
|
+
*
|
|
91
|
+
* The empty JSON object is the whole grammar. It exists so that a stray file
|
|
92
|
+
* of this name — an editor backup, a half-written write from a foreign tool —
|
|
93
|
+
* cannot take a live pane down by accident; a pane closing is the one thing
|
|
94
|
+
* in this channel a user cannot undo by waiting.
|
|
95
|
+
*/
|
|
96
|
+
export function takeQuitRequest(defaultFile, pid) {
|
|
97
|
+
const targeted = pid === undefined ? undefined : quitFilePath(defaultFile, pid);
|
|
98
|
+
const path = targeted !== undefined && existsSync(targeted) ? targeted : quitFilePath(defaultFile);
|
|
99
|
+
let raw;
|
|
100
|
+
try {
|
|
101
|
+
raw = readFileSync(path, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
sweepQuitRequest(defaultFile, path === targeted ? pid : undefined);
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(stripBom(raw));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return isRecord(parsed);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Delete a quit request WITHOUT acting on it — the same file, read as a
|
|
118
|
+
* leftover rather than as a message.
|
|
119
|
+
*
|
|
120
|
+
* A toggle that wrote the request and then lost its watcher (a crash, a
|
|
121
|
+
* closed window, a `taskkill`) leaves the file behind, and the next pane to
|
|
122
|
+
* open would consume it on its first tick and close instantly. The watcher
|
|
123
|
+
* sweeps at STARTUP for exactly that: a request that predates the pane cannot
|
|
124
|
+
* have been addressed to it. Best-effort, like every delete in this channel.
|
|
125
|
+
*/
|
|
126
|
+
export function sweepQuitRequest(defaultFile, pid) {
|
|
127
|
+
try {
|
|
128
|
+
rmSync(quitFilePath(defaultFile, pid), { force: true });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// The file is unreachable for some reason the next tick will meet again;
|
|
132
|
+
// re-consuming a request we cannot delete only closes a pane the user
|
|
133
|
+
// asked to close.
|
|
134
|
+
}
|
|
135
|
+
}
|
package/lib/store/format.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* ./store.ts, the Node-side half.
|
|
22
22
|
*/
|
|
23
23
|
import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
|
|
24
|
+
import { mapTextError } from '../domain/text.js';
|
|
25
|
+
import { sourceError, contextError } from '../domain/context.js';
|
|
24
26
|
import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeRank, makeSubmapRef, ok, } from '../domain/types.js';
|
|
25
27
|
/** On-disk format version. Bump only with a documented migration. */
|
|
26
28
|
export const STATE_FILE_VERSION = 1;
|
|
@@ -98,8 +100,8 @@ function optionalString(rec, key, where, path) {
|
|
|
98
100
|
export function parseMap(raw, path) {
|
|
99
101
|
if (!isRecord(raw))
|
|
100
102
|
return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
|
|
101
|
-
if (raw['version'] !== STATE_FILE_VERSION) {
|
|
102
|
-
return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION}` });
|
|
103
|
+
if (raw['version'] !== STATE_FILE_VERSION && raw['version'] !== 2) {
|
|
104
|
+
return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION} or 2` });
|
|
103
105
|
}
|
|
104
106
|
const layers = arrayField(raw, 'layers', path, 'required');
|
|
105
107
|
if (!layers.ok)
|
|
@@ -117,6 +119,12 @@ export function parseMap(raw, path) {
|
|
|
117
119
|
if (!groups.ok)
|
|
118
120
|
return groups;
|
|
119
121
|
let map = EMPTY_MAP;
|
|
122
|
+
if (raw['context'] !== undefined) {
|
|
123
|
+
const error = contextError(raw['context']);
|
|
124
|
+
if (error)
|
|
125
|
+
return err({ kind: 'bad-shape', path, detail: error });
|
|
126
|
+
map = { ...map, context: raw['context'] };
|
|
127
|
+
}
|
|
120
128
|
const title = optionalString(raw, 'title', 'map', path);
|
|
121
129
|
if (!title.ok)
|
|
122
130
|
return title;
|
|
@@ -291,6 +299,12 @@ export function parseMap(raw, path) {
|
|
|
291
299
|
return err({ kind: 'invariant-violation', path, violation: updated.error });
|
|
292
300
|
map = updated.value;
|
|
293
301
|
}
|
|
302
|
+
if (rawNode['sources'] !== undefined) {
|
|
303
|
+
const error = sourceError(rawNode['sources']);
|
|
304
|
+
if (error)
|
|
305
|
+
return err({ kind: 'bad-shape', path, detail: `${where}: ${error}` });
|
|
306
|
+
map = { ...map, nodes: map.nodes.map(n => n.id === id.value ? { ...n, sources: rawNode['sources'] } : n) };
|
|
307
|
+
}
|
|
294
308
|
}
|
|
295
309
|
for (const [i, rawEdge] of edges.value.entries()) {
|
|
296
310
|
const where = `edges[${i}]`;
|
|
@@ -316,12 +330,15 @@ export function parseMap(raw, path) {
|
|
|
316
330
|
return err({ kind: 'invariant-violation', path, violation: linked.error });
|
|
317
331
|
map = linked.value;
|
|
318
332
|
}
|
|
319
|
-
|
|
333
|
+
const textError = mapTextError(map);
|
|
334
|
+
return textError ? err({ kind: 'bad-shape', path, detail: textError }) : ok(map);
|
|
320
335
|
}
|
|
321
336
|
/** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
|
|
322
337
|
export function serializeMap(map) {
|
|
323
338
|
const body = {
|
|
324
|
-
|
|
339
|
+
// Older runtimes must refuse maps with provenance rather than silently erasing it.
|
|
340
|
+
version: map.context !== undefined || map.nodes.some(n => n.sources !== undefined) ? 2 : STATE_FILE_VERSION,
|
|
341
|
+
...(map.context !== undefined ? { context: map.context } : {}),
|
|
325
342
|
...(map.title !== undefined ? { title: map.title } : {}),
|
|
326
343
|
...(map.kind !== undefined ? { kind: map.kind } : {}),
|
|
327
344
|
layers: map.layers,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function isRecord(v: unknown): v is Record<string, unknown>;
|
|
2
|
+
/**
|
|
3
|
+
* Drop a leading UTF-8 byte-order mark. Windows editors (Notepad, some
|
|
4
|
+
* PowerShell redirections) add one when a human edits a state file by hand,
|
|
5
|
+
* and JSON.parse refuses the result — an invisible character would otherwise
|
|
6
|
+
* read as "your map is corrupt". The BOM carries no meaning for us: the
|
|
7
|
+
* files are UTF-8 by contract.
|
|
8
|
+
*/
|
|
9
|
+
export declare function stripBom(text: string): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// reading text that a human may have touched — shared by every load below
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
export function isRecord(v) {
|
|
5
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Drop a leading UTF-8 byte-order mark. Windows editors (Notepad, some
|
|
9
|
+
* PowerShell redirections) add one when a human edits a state file by hand,
|
|
10
|
+
* and JSON.parse refuses the result — an invisible character would otherwise
|
|
11
|
+
* read as "your map is corrupt". The BOM carries no meaning for us: the
|
|
12
|
+
* files are UTF-8 by contract.
|
|
13
|
+
*/
|
|
14
|
+
export function stripBom(text) {
|
|
15
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type MellosMap, type Result } from '../domain/types.js';
|
|
2
|
+
import { type StoreError } from './format.js';
|
|
3
|
+
/** Load and validate the map file at `path`. */
|
|
4
|
+
export declare function loadMapFile(path: string): Result<MellosMap, StoreError>;
|
|
5
|
+
/**
|
|
6
|
+
* Write the map to `path` atomically (P2): serialize to a private sibling
|
|
7
|
+
* temp file, then rename it over the target, retrying a rename the OS
|
|
8
|
+
* refuses transiently. Creates the parent directory if missing.
|
|
9
|
+
* @returns ok when the file holds the new map; save-failed when it does not,
|
|
10
|
+
* in which case the previous content is intact and the call may be retried.
|
|
11
|
+
*/
|
|
12
|
+
export declare function saveMapFile(path: string, map: MellosMap): Result<void, StoreError>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { err } from '../domain/types.js';
|
|
3
|
+
import { mapTextError } from '../domain/text.js';
|
|
4
|
+
import { parseMap, serializeMap } from './format.js';
|
|
5
|
+
import { writeFileAtomic } from './atomic.js';
|
|
6
|
+
import { stripBom } from './json-text.js';
|
|
7
|
+
/** Load and validate the map file at `path`. */
|
|
8
|
+
export function loadMapFile(path) {
|
|
9
|
+
let text;
|
|
10
|
+
try {
|
|
11
|
+
text = readFileSync(path, 'utf8');
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
const code = e.code;
|
|
15
|
+
if (code === 'ENOENT')
|
|
16
|
+
return err({ kind: 'not-found', path });
|
|
17
|
+
throw e; // unexpected I/O fault: fail fast, nothing meaningful to recover here
|
|
18
|
+
}
|
|
19
|
+
let raw;
|
|
20
|
+
try {
|
|
21
|
+
raw = JSON.parse(stripBom(text));
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
// Expected at this boundary: hand-edited files, or a reader racing a
|
|
25
|
+
// non-atomic writer from a foreign tool.
|
|
26
|
+
return err({ kind: 'malformed-json', path, detail: e.message });
|
|
27
|
+
}
|
|
28
|
+
return parseMap(raw, path);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Write the map to `path` atomically (P2): serialize to a private sibling
|
|
32
|
+
* temp file, then rename it over the target, retrying a rename the OS
|
|
33
|
+
* refuses transiently. Creates the parent directory if missing.
|
|
34
|
+
* @returns ok when the file holds the new map; save-failed when it does not,
|
|
35
|
+
* in which case the previous content is intact and the call may be retried.
|
|
36
|
+
*/
|
|
37
|
+
export function saveMapFile(path, map) {
|
|
38
|
+
const textError = mapTextError(map);
|
|
39
|
+
if (textError)
|
|
40
|
+
return err({ kind: 'save-failed', path, detail: textError });
|
|
41
|
+
return writeFileAtomic(path, serializeMap(map));
|
|
42
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Project-relative location of the pre-0.20 default page file. */
|
|
2
|
+
export declare const LEGACY_STATE_FILE_RELATIVE_PATH: string;
|
|
3
|
+
/**
|
|
4
|
+
* Move a legacy `.claude` store — map, pages, and mapping-policy config —
|
|
5
|
+
* into the tool-owned `.mellos` location. Never merges: a project whose new
|
|
6
|
+
* store already holds anything keeps it untouched, whatever the legacy
|
|
7
|
+
* directory still contains.
|
|
8
|
+
* @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
|
|
9
|
+
* the legacy store is looked up relative to `<root>`.
|
|
10
|
+
* @returns whether a legacy store was moved.
|
|
11
|
+
*/
|
|
12
|
+
export declare function migrateLegacyStore(defaultFile: string): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { PAGES_DIR_NAME } from './pages.js';
|
|
4
|
+
import { configFilePath } from './policy.js';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// legacy migration — stores written under the old host-coupled location
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
//
|
|
9
|
+
// Up to 0.19 the store lived in `.claude/mellos-mapping.*`: the map's home
|
|
10
|
+
// was coupled to one host brand, which turned absurd the moment another host
|
|
11
|
+
// (a harness, Codex) drove the same server. The move is one-time and
|
|
12
|
+
// explicit — entry points call it before touching the store; nothing here
|
|
13
|
+
// runs as a hidden side effect of ordinary loads.
|
|
14
|
+
/** Project-relative location of the pre-0.20 default page file. */
|
|
15
|
+
export const LEGACY_STATE_FILE_RELATIVE_PATH = join('.claude', 'mellos-mapping.json');
|
|
16
|
+
const LEGACY_PAGES_DIR_NAME = 'mellos-mapping.pages';
|
|
17
|
+
const LEGACY_CONFIG_FILE_NAME = 'mellos-mapping.config.json';
|
|
18
|
+
/**
|
|
19
|
+
* Move a legacy `.claude` store — map, pages, and mapping-policy config —
|
|
20
|
+
* into the tool-owned `.mellos` location. Never merges: a project whose new
|
|
21
|
+
* store already holds anything keeps it untouched, whatever the legacy
|
|
22
|
+
* directory still contains.
|
|
23
|
+
* @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
|
|
24
|
+
* the legacy store is looked up relative to `<root>`.
|
|
25
|
+
* @returns whether a legacy store was moved.
|
|
26
|
+
*/
|
|
27
|
+
export function migrateLegacyStore(defaultFile) {
|
|
28
|
+
const projectRoot = dirname(dirname(defaultFile));
|
|
29
|
+
const legacyDefault = join(projectRoot, LEGACY_STATE_FILE_RELATIVE_PATH);
|
|
30
|
+
const legacyPages = join(dirname(legacyDefault), LEGACY_PAGES_DIR_NAME);
|
|
31
|
+
const legacyConfig = join(dirname(legacyDefault), LEGACY_CONFIG_FILE_NAME);
|
|
32
|
+
const hasLegacy = existsSync(legacyDefault) || existsSync(legacyPages) || existsSync(legacyConfig);
|
|
33
|
+
const hasCurrent = existsSync(defaultFile)
|
|
34
|
+
|| existsSync(join(dirname(defaultFile), PAGES_DIR_NAME))
|
|
35
|
+
|| existsSync(configFilePath(defaultFile));
|
|
36
|
+
if (!hasLegacy || hasCurrent)
|
|
37
|
+
return false;
|
|
38
|
+
mkdirSync(dirname(defaultFile), { recursive: true });
|
|
39
|
+
if (existsSync(legacyDefault))
|
|
40
|
+
renameSync(legacyDefault, defaultFile);
|
|
41
|
+
if (existsSync(legacyPages))
|
|
42
|
+
renameSync(legacyPages, join(dirname(defaultFile), PAGES_DIR_NAME));
|
|
43
|
+
if (existsSync(legacyConfig))
|
|
44
|
+
renameSync(legacyConfig, configFilePath(defaultFile));
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Result } from '../domain/types.js';
|
|
2
|
+
import { type StoreError } from './format.js';
|
|
3
|
+
import { type PageId } from './format.js';
|
|
4
|
+
/**
|
|
5
|
+
* The directory a store lives in, under a project root or under a user's home.
|
|
6
|
+
* It is tool-owned: the map belongs to mellos-mapping, not to whichever host
|
|
7
|
+
* (Claude Code, Codex, a harness) happens to drive the server, so no host
|
|
8
|
+
* brand appears in the path. Pre-0.20 stores under `.claude/` are moved once
|
|
9
|
+
* by {@link migrateLegacyStore}.
|
|
10
|
+
*/
|
|
11
|
+
export declare const STORE_DIR_NAME = ".mellos";
|
|
12
|
+
/** Project-relative location of the DEFAULT page's state file. */
|
|
13
|
+
export declare const STATE_FILE_RELATIVE_PATH: string;
|
|
14
|
+
/** Directory (next to the default file) holding the named pages. */
|
|
15
|
+
export declare const PAGES_DIR_NAME = "pages";
|
|
16
|
+
/** Where a page's map file lives, given the default page's file path. */
|
|
17
|
+
export declare function pageFilePath(defaultFile: string, page?: PageId): string;
|
|
18
|
+
/** The page id a file path denotes; undefined = the default page. */
|
|
19
|
+
export declare function pageIdOfFile(defaultFile: string, path: string): PageId | undefined;
|
|
20
|
+
/** Existing page files: the default page first (when present), then named pages sorted by slug. */
|
|
21
|
+
export declare function listPageFiles(defaultFile: string): string[];
|
|
22
|
+
/**
|
|
23
|
+
* Delete one page's file — a named page, or the DEFAULT page (whose file is
|
|
24
|
+
* optional by design, so removing it is a legal state, not a mutilation).
|
|
25
|
+
*
|
|
26
|
+
* Preconditions: none. Postcondition on ok: no file at `path` — an already
|
|
27
|
+
* absent one is ok too, because the goal state is what is promised, not the
|
|
28
|
+
* act. Postcondition on error: the file is still there and the caller may
|
|
29
|
+
* retry or report; the errno is carried in the detail.
|
|
30
|
+
*
|
|
31
|
+
* Concurrency, stated plainly: deletion races a concurrent writer and THE
|
|
32
|
+
* WRITER WINS. A server saving that page while this runs simply recreates the
|
|
33
|
+
* file (its rename is atomic and needs no existing target), so the page comes
|
|
34
|
+
* back. That is accepted rather than defended against — the store has no
|
|
35
|
+
* lost-update protection anywhere (see the module header), and locking one
|
|
36
|
+
* operation would only make the race rarer, never absent, while claiming
|
|
37
|
+
* otherwise. Pages are the isolation unit: nobody deletes a page another
|
|
38
|
+
* session is writing.
|
|
39
|
+
*
|
|
40
|
+
* What it deliberately does NOT do: sweep `<path>.<pid>.<random>.tmp`
|
|
41
|
+
* siblings. Those temps are private to a save IN FLIGHT, and a live writer
|
|
42
|
+
* whose temp vanished would fail its rename — turning a harmless leftover
|
|
43
|
+
* into a broken save. A stray temp only exists when a write failed AND its
|
|
44
|
+
* own cleanup failed; it is inert, and the README documents it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function deletePageFile(path: string): Result<void, StoreError>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { err, ok } from '../domain/types.js';
|
|
4
|
+
import { errnoOf } from './atomic.js';
|
|
5
|
+
/**
|
|
6
|
+
* The directory a store lives in, under a project root or under a user's home.
|
|
7
|
+
* It is tool-owned: the map belongs to mellos-mapping, not to whichever host
|
|
8
|
+
* (Claude Code, Codex, a harness) happens to drive the server, so no host
|
|
9
|
+
* brand appears in the path. Pre-0.20 stores under `.claude/` are moved once
|
|
10
|
+
* by {@link migrateLegacyStore}.
|
|
11
|
+
*/
|
|
12
|
+
export const STORE_DIR_NAME = '.mellos';
|
|
13
|
+
/** Project-relative location of the DEFAULT page's state file. */
|
|
14
|
+
export const STATE_FILE_RELATIVE_PATH = join(STORE_DIR_NAME, 'map.json');
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// pages — a project may keep several maps side by side (one effort = one page)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
//
|
|
19
|
+
// The default page IS the classic map.json. Named pages live in a sibling
|
|
20
|
+
// directory, one file each: file-per-page keeps concurrent sessions isolated —
|
|
21
|
+
// two writers on two pages can never clobber each other, because every save
|
|
22
|
+
// renames a whole file.
|
|
23
|
+
/** Directory (next to the default file) holding the named pages. */
|
|
24
|
+
export const PAGES_DIR_NAME = 'pages';
|
|
25
|
+
/** Where a page's map file lives, given the default page's file path. */
|
|
26
|
+
export function pageFilePath(defaultFile, page) {
|
|
27
|
+
return page === undefined ? defaultFile : join(dirname(defaultFile), PAGES_DIR_NAME, `${page}.json`);
|
|
28
|
+
}
|
|
29
|
+
/** The page id a file path denotes; undefined = the default page. */
|
|
30
|
+
export function pageIdOfFile(defaultFile, path) {
|
|
31
|
+
if (path === defaultFile)
|
|
32
|
+
return undefined;
|
|
33
|
+
const name = basename(path);
|
|
34
|
+
return name.endsWith('.json') ? name.slice(0, -'.json'.length) : name;
|
|
35
|
+
}
|
|
36
|
+
/** Existing page files: the default page first (when present), then named pages sorted by slug. */
|
|
37
|
+
export function listPageFiles(defaultFile) {
|
|
38
|
+
const out = [];
|
|
39
|
+
if (existsSync(defaultFile))
|
|
40
|
+
out.push(defaultFile);
|
|
41
|
+
let entries = [];
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(join(dirname(defaultFile), PAGES_DIR_NAME));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// no pages directory — a single-page project, the common case
|
|
47
|
+
}
|
|
48
|
+
for (const e of entries.sort()) {
|
|
49
|
+
if (e.endsWith('.json'))
|
|
50
|
+
out.push(join(dirname(defaultFile), PAGES_DIR_NAME, e));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Delete one page's file — a named page, or the DEFAULT page (whose file is
|
|
56
|
+
* optional by design, so removing it is a legal state, not a mutilation).
|
|
57
|
+
*
|
|
58
|
+
* Preconditions: none. Postcondition on ok: no file at `path` — an already
|
|
59
|
+
* absent one is ok too, because the goal state is what is promised, not the
|
|
60
|
+
* act. Postcondition on error: the file is still there and the caller may
|
|
61
|
+
* retry or report; the errno is carried in the detail.
|
|
62
|
+
*
|
|
63
|
+
* Concurrency, stated plainly: deletion races a concurrent writer and THE
|
|
64
|
+
* WRITER WINS. A server saving that page while this runs simply recreates the
|
|
65
|
+
* file (its rename is atomic and needs no existing target), so the page comes
|
|
66
|
+
* back. That is accepted rather than defended against — the store has no
|
|
67
|
+
* lost-update protection anywhere (see the module header), and locking one
|
|
68
|
+
* operation would only make the race rarer, never absent, while claiming
|
|
69
|
+
* otherwise. Pages are the isolation unit: nobody deletes a page another
|
|
70
|
+
* session is writing.
|
|
71
|
+
*
|
|
72
|
+
* What it deliberately does NOT do: sweep `<path>.<pid>.<random>.tmp`
|
|
73
|
+
* siblings. Those temps are private to a save IN FLIGHT, and a live writer
|
|
74
|
+
* whose temp vanished would fail its rename — turning a harmless leftover
|
|
75
|
+
* into a broken save. A stray temp only exists when a write failed AND its
|
|
76
|
+
* own cleanup failed; it is inert, and the README documents it.
|
|
77
|
+
*/
|
|
78
|
+
export function deletePageFile(path) {
|
|
79
|
+
try {
|
|
80
|
+
// force: an absent file is the goal state already, not a failure.
|
|
81
|
+
// No `recursive`: a DIRECTORY where a page file belongs is a fault to
|
|
82
|
+
// report, never a tree to erase.
|
|
83
|
+
rmSync(path, { force: true });
|
|
84
|
+
return ok(undefined);
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
return err({ kind: 'delete-failed', path, detail: errnoOf(e) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type Result } from '../domain/types.js';
|
|
2
|
+
import { type StoreError } from './format.js';
|
|
3
|
+
/** Name of the file holding a mapping-policy configuration, in either scope. */
|
|
4
|
+
export declare const CONFIG_FILE_NAME = "config.json";
|
|
5
|
+
/** On-disk config format version. Bump only with a documented migration. */
|
|
6
|
+
export declare const CONFIG_FILE_VERSION = 1;
|
|
7
|
+
/** The PROJECT-scope configuration file: sibling of the default page. */
|
|
8
|
+
export declare function configFilePath(defaultFile: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* The USER-scope configuration file: the same store directory name, under the
|
|
11
|
+
* user's own base directory.
|
|
12
|
+
*
|
|
13
|
+
* @param userBase - the user's home directory. Always passed in, never read
|
|
14
|
+
* from the environment down here: a function that reached for os.homedir()
|
|
15
|
+
* itself would make every spec a gamble on the developer's real
|
|
16
|
+
* configuration, and one of them would eventually write it. Entry points
|
|
17
|
+
* resolve the home once and hand it down.
|
|
18
|
+
*/
|
|
19
|
+
export declare function userConfigFilePath(userBase: string): string;
|
|
20
|
+
export declare const MAPPING_POLICIES: readonly ["always", "complex", "on-request"];
|
|
21
|
+
/** How eagerly maps are opened; 'complex' is the behavior of an unconfigured project. */
|
|
22
|
+
export type MappingPolicy = (typeof MAPPING_POLICIES)[number];
|
|
23
|
+
export interface InvalidPolicy {
|
|
24
|
+
readonly kind: 'invalid-policy';
|
|
25
|
+
readonly raw: string;
|
|
26
|
+
readonly allowed: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
export declare function makeMappingPolicy(raw: string): Result<MappingPolicy, InvalidPolicy>;
|
|
29
|
+
/** One line of meaning per policy — the wording every surface repeats. */
|
|
30
|
+
export declare function describeMappingPolicy(policy: MappingPolicy): string;
|
|
31
|
+
/**
|
|
32
|
+
* The policy recorded in ONE configuration file, or ok(undefined) when nobody
|
|
33
|
+
* has chosen there (missing file or missing key — both mean the same thing).
|
|
34
|
+
* A file that exists but does not parse is an error, never silently ignored.
|
|
35
|
+
*
|
|
36
|
+
* @param path - the configuration file itself: {@link configFilePath} for a
|
|
37
|
+
* project, {@link userConfigFilePath} for the user. One loader, two scopes.
|
|
38
|
+
*/
|
|
39
|
+
export declare function loadMappingPolicy(path: string): Result<MappingPolicy | undefined, StoreError>;
|
|
40
|
+
/**
|
|
41
|
+
* Persist the policy atomically (P2), same write as the map files.
|
|
42
|
+
* @param path - the configuration file to write; see {@link loadMappingPolicy}.
|
|
43
|
+
* @returns ok when the file holds the policy; save-failed leaves the previous
|
|
44
|
+
* configuration in place.
|
|
45
|
+
*/
|
|
46
|
+
export declare function saveMappingPolicy(path: string, policy: MappingPolicy): Result<void, StoreError>;
|
|
47
|
+
/** The two places a mapping policy can be recorded, in override order. */
|
|
48
|
+
export declare const POLICY_SCOPES: readonly ["user", "project"];
|
|
49
|
+
export type PolicyScope = (typeof POLICY_SCOPES)[number];
|
|
50
|
+
/** What both scopes say, and which of them actually governs. */
|
|
51
|
+
export interface MappingPolicyScopes {
|
|
52
|
+
readonly project: MappingPolicy | undefined;
|
|
53
|
+
readonly user: MappingPolicy | undefined;
|
|
54
|
+
/** What to act on. undefined = nobody has chosen yet, anywhere. */
|
|
55
|
+
readonly effective: MappingPolicy | undefined;
|
|
56
|
+
/** Where `effective` came from; undefined exactly when `effective` is. */
|
|
57
|
+
readonly source: PolicyScope | undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the policy that governs this project: the PROJECT file if it names
|
|
61
|
+
* one, otherwise the USER file, otherwise nothing.
|
|
62
|
+
*
|
|
63
|
+
* Both scopes are reported, not just the winner — a surface that says "always"
|
|
64
|
+
* without saying where it came from cannot tell a user why changing their
|
|
65
|
+
* user-level choice did nothing here.
|
|
66
|
+
*
|
|
67
|
+
* @param projectConfigFile - see {@link configFilePath}.
|
|
68
|
+
* @param userConfigFile - see {@link userConfigFilePath}.
|
|
69
|
+
* @returns err as soon as EITHER file exists and is broken, project first: a
|
|
70
|
+
* configuration nobody can read is not the same as a configuration nobody
|
|
71
|
+
* wrote, and silently falling through to the other scope would act on a
|
|
72
|
+
* choice the user did not make.
|
|
73
|
+
*/
|
|
74
|
+
export declare function effectiveMappingPolicy(projectConfigFile: string, userConfigFile: string): Result<MappingPolicyScopes, StoreError>;
|