linksee-memory 0.17.0 → 0.17.1
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 +958 -1081
- package/dist/bin/guard-hook.js +19 -4
- package/dist/bin/setup.js +191 -76
- package/dist/bin/share-draft.js +28 -28
- package/dist/bin/sync-session.js +10 -1
- package/dist/db/migrate.d.ts +30 -0
- package/dist/db/migrate.js +178 -37
- package/dist/lib/consolidate.d.ts +9 -0
- package/dist/lib/consolidate.js +14 -0
- package/dist/lib/drift-anchors.js +3 -3
- package/dist/lib/drift-detection.js +20 -20
- package/dist/lib/drift-view.js +16 -16
- package/dist/lib/guard-wiring.d.ts +47 -1
- package/dist/lib/guard-wiring.js +114 -11
- package/dist/lib/hook-runtime.d.ts +41 -0
- package/dist/lib/hook-runtime.js +197 -0
- package/dist/lib/map-import.js +17 -17
- package/dist/lib/native-hint.d.ts +5 -0
- package/dist/lib/native-hint.js +42 -0
- package/dist/lib/settings-file.d.ts +23 -0
- package/dist/lib/settings-file.js +137 -0
- package/dist/mcp/prompts.js +59 -59
- package/dist/mcp/resources.js +7 -7
- package/dist/mcp/sampling.js +20 -20
- package/dist/mcp/server.js +180 -160
- package/dist/skill/SKILL.md +6 -14
- package/package.json +107 -107
package/dist/lib/guard-wiring.js
CHANGED
|
@@ -10,17 +10,119 @@
|
|
|
10
10
|
export const GUARD_EVENTS = ['SessionStart', 'PreToolUse'];
|
|
11
11
|
/** The bin every wiring form resolves to; used as the idempotency key. */
|
|
12
12
|
export const GUARD_BIN = 'linksee-memory-guard';
|
|
13
|
+
/** The npx form setup wrote before 0.17.1. Still the fallback when no stable install exists. */
|
|
13
14
|
export const GUARD_COMMAND = 'npx -y linksee-memory guard';
|
|
14
|
-
export const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
export const SYNC_COMMAND = 'npx -y linksee-memory sync';
|
|
16
|
+
export const NPX_HOOK_COMMANDS = { guard: GUARD_COMMAND, sync: SYNC_COMMAND };
|
|
17
|
+
/**
|
|
18
|
+
* Marker setup appends to every direct-node hook it writes. It is an ordinary trailing argument
|
|
19
|
+
* (the hook scripts ignore argv), so it survives cmd.exe and sh alike — a `#` comment would not
|
|
20
|
+
* (cmd.exe has no inline comments). Only a command carrying it is treated as setup-owned.
|
|
21
|
+
*/
|
|
22
|
+
export const MANAGED_MARKER = '--linksee-managed';
|
|
23
|
+
/**
|
|
24
|
+
* Direct `node` form (0.17.1, L-1-106 / audit F11): `npx -y` costs 6–9 s per call on a warm
|
|
25
|
+
* cache (npm start-up + a registry check), against an 8 s PreToolUse timeout — so the guard
|
|
26
|
+
* could time out and silently not fire. `"<node>" "<root>/dist/bin/<bin>.js" --linksee-managed`
|
|
27
|
+
* starts in well under a second.
|
|
28
|
+
*
|
|
29
|
+
* Shells: Claude Code runs a hook command through cmd.exe on Windows and through sh on POSIX
|
|
30
|
+
* (PowerShell is not used for hooks — assumed here; under PowerShell this form would need `&`).
|
|
31
|
+
* Double quotes protect spaces in both. They do NOT neutralise `$` or backticks (sh) or `%`
|
|
32
|
+
* (cmd.exe), and a path containing `"` cannot be quoted at all — see `shellSafePath`; setup falls
|
|
33
|
+
* back to the npx form for such paths instead of writing a command that might expand.
|
|
34
|
+
*/
|
|
35
|
+
export function nodeHookCommands(nodePath, packageRoot) {
|
|
36
|
+
const fwd = (p) => p.replace(/\\/g, '/');
|
|
37
|
+
const bin = (file) => `"${fwd(nodePath)}" "${fwd(packageRoot).replace(/\/$/, '')}/dist/bin/${file}" ${MANAGED_MARKER}`;
|
|
38
|
+
return { guard: bin('guard-hook.js'), sync: bin('sync-session.js') };
|
|
39
|
+
}
|
|
40
|
+
/** Can this path go inside double quotes in both cmd.exe and sh without being expanded? */
|
|
41
|
+
export function shellSafePath(p) {
|
|
42
|
+
return !/["$`%!\r\n]/.test(p);
|
|
43
|
+
}
|
|
44
|
+
/** Exactly the npx strings setup wrote up to 0.17.0. */
|
|
45
|
+
const LEGACY_NPX = {
|
|
46
|
+
guard: 'npx -y linksee-memory guard',
|
|
47
|
+
sync: 'npx -y linksee-memory sync',
|
|
23
48
|
};
|
|
49
|
+
/** The form setup itself writes from 0.17.1 on — quoted node, quoted script, the marker. */
|
|
50
|
+
const MANAGED_NODE_FORM = /^"[^"]+" "[^"]+\/dist\/bin\/(guard-hook|sync-session)\.js" --linksee-managed$/;
|
|
51
|
+
/**
|
|
52
|
+
* Is this command one setup wrote — (a) the exact 0.17.0-and-earlier npx string, or (b) the
|
|
53
|
+
* 0.17.1+ direct form carrying MANAGED_MARKER? Anything else (wrappers, unquoted paths, extra
|
|
54
|
+
* flags, someone else's script) is not ours to change.
|
|
55
|
+
*/
|
|
56
|
+
export function setupWrittenKind(command) {
|
|
57
|
+
if (typeof command !== 'string')
|
|
58
|
+
return null;
|
|
59
|
+
const c = command.trim();
|
|
60
|
+
for (const kind of ['guard', 'sync'])
|
|
61
|
+
if (LEGACY_NPX[kind] === c)
|
|
62
|
+
return kind;
|
|
63
|
+
const m = MANAGED_NODE_FORM.exec(c);
|
|
64
|
+
if (m)
|
|
65
|
+
return m[1] === 'guard-hook' ? 'guard' : 'sync';
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function eachCommandHook(settings, fn) {
|
|
69
|
+
for (const entries of Object.values(settings.hooks ?? {})) {
|
|
70
|
+
if (!Array.isArray(entries))
|
|
71
|
+
continue;
|
|
72
|
+
for (const entry of entries)
|
|
73
|
+
for (const h of entry?.hooks ?? [])
|
|
74
|
+
if (h && h.type === 'command')
|
|
75
|
+
fn(h);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Point the setup-written hooks of the given kinds at `commands`. Exec-form hooks (`args`), other
|
|
80
|
+
* tools' hooks and hand-edited commands are never touched. Only `command` changes — matcher,
|
|
81
|
+
* timeout and any other field stay. Mutates `settings`; returns the replaced commands.
|
|
82
|
+
*/
|
|
83
|
+
export function rewireLegacyHooks(settings, commands, kinds = ['guard', 'sync']) {
|
|
84
|
+
const replaced = [];
|
|
85
|
+
eachCommandHook(settings, (h) => {
|
|
86
|
+
if (Array.isArray(h.args) && h.args.length)
|
|
87
|
+
return;
|
|
88
|
+
const kind = setupWrittenKind(h.command);
|
|
89
|
+
if (!kind || !kinds.includes(kind) || h.command === commands[kind])
|
|
90
|
+
return;
|
|
91
|
+
replaced.push(h.command);
|
|
92
|
+
h.command = commands[kind];
|
|
93
|
+
});
|
|
94
|
+
return { replaced };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Hooks that look like ours (they run the guard or the sync script) but were not written by setup —
|
|
98
|
+
* hand-wired paths, wrappers, exec form. setup leaves them alone and lists them so the user knows.
|
|
99
|
+
*/
|
|
100
|
+
export function unmanagedLinkseeHooks(settings) {
|
|
101
|
+
const out = [];
|
|
102
|
+
eachCommandHook(settings, (h) => {
|
|
103
|
+
const hay = [h.command, ...(h.args ?? [])].filter((x) => typeof x === 'string').join(' ');
|
|
104
|
+
const looksOurs = /guard-hook|sync-session/.test(hay) || (/linksee-memory/.test(hay) && /\b(guard|sync)\b/.test(hay));
|
|
105
|
+
if (!looksOurs)
|
|
106
|
+
return;
|
|
107
|
+
if ((!h.args || !h.args.length) && setupWrittenKind(h.command))
|
|
108
|
+
return;
|
|
109
|
+
out.push(hay);
|
|
110
|
+
});
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
export function guardHooks(command = GUARD_COMMAND) {
|
|
114
|
+
return {
|
|
115
|
+
SessionStart: {
|
|
116
|
+
matcher: 'startup|resume|compact',
|
|
117
|
+
hooks: [{ type: 'command', command, timeout: 15 }],
|
|
118
|
+
},
|
|
119
|
+
PreToolUse: {
|
|
120
|
+
matcher: 'Edit|Write|Bash',
|
|
121
|
+
hooks: [{ type: 'command', command, timeout: 8 }],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export const GUARD_HOOKS = guardHooks();
|
|
24
126
|
/** Everything a hook entry could carry an identifier in: `command`, or `args` for the exec form. */
|
|
25
127
|
function hookHaystack(h) {
|
|
26
128
|
return [h?.command, ...(h?.args ?? [])].filter((x) => typeof x === 'string').join(' ');
|
|
@@ -69,14 +171,15 @@ export function guardFullyWired(settings) {
|
|
|
69
171
|
* Add the guard to any event it does not already own. Mutates and returns `settings`, plus the
|
|
70
172
|
* events that were actually added (empty when it was already wired).
|
|
71
173
|
*/
|
|
72
|
-
export function wireGuard(settings) {
|
|
174
|
+
export function wireGuard(settings, command = GUARD_COMMAND) {
|
|
73
175
|
const hooks = (settings.hooks ??= {});
|
|
74
176
|
const added = [];
|
|
177
|
+
const wanted = guardHooks(command);
|
|
75
178
|
for (const ev of GUARD_EVENTS) {
|
|
76
179
|
if (!Array.isArray(hooks[ev]))
|
|
77
180
|
hooks[ev] = [];
|
|
78
181
|
if (!guardWiredFor(settings, ev)) {
|
|
79
|
-
hooks[ev].push(
|
|
182
|
+
hooks[ev].push(wanted[ev]);
|
|
80
183
|
added.push(ev);
|
|
81
184
|
}
|
|
82
185
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type HookCommands } from './guard-wiring.js';
|
|
2
|
+
export type HookRuntime = {
|
|
3
|
+
commands: HookCommands;
|
|
4
|
+
/** 'direct' = this install is stable; 'pinned' = installed under runtime/; 'npx' = fallback. */
|
|
5
|
+
mode: 'direct' | 'pinned' | 'npx';
|
|
6
|
+
root?: string;
|
|
7
|
+
note?: string;
|
|
8
|
+
/** runtime/ folder and the version kept there, when pinned (setup reports where runtimes live). */
|
|
9
|
+
runtimeBase?: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
/** Size of the pinned runtime in bytes, when one is in use. */
|
|
12
|
+
bytes?: number;
|
|
13
|
+
};
|
|
14
|
+
/** Is this package root inside a cache that npm / pnpm / bun may rebuild or delete? */
|
|
15
|
+
export declare function isEphemeralRoot(packageRoot: string): boolean;
|
|
16
|
+
/** Can a node process load the native dependency from this root? (the part most likely to break) */
|
|
17
|
+
export declare function verifyRoot(root: string, nodePath: string): boolean;
|
|
18
|
+
/** Locate npm's CLI script so it can be run with this node, without a shell. */
|
|
19
|
+
export declare function findNpmCli(nodePath: string, env?: NodeJS.ProcessEnv): string | null;
|
|
20
|
+
export type Installer = (spec: string, prefix: string, nodePath: string) => {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
detail?: string;
|
|
23
|
+
};
|
|
24
|
+
/** `npm install --prefix <prefix> --no-save --omit=dev <spec>` with this node, no shell. */
|
|
25
|
+
export declare const npmInstall: Installer;
|
|
26
|
+
export declare function dirBytes(dir: string): number;
|
|
27
|
+
export declare function formatBytes(n: number): string;
|
|
28
|
+
export type ResolveOpts = {
|
|
29
|
+
packageRoot: string;
|
|
30
|
+
nodePath: string;
|
|
31
|
+
memoryDir: string;
|
|
32
|
+
version: string;
|
|
33
|
+
/** What to install; defaults to linksee-memory@<version>. */
|
|
34
|
+
spec?: string;
|
|
35
|
+
dryRun?: boolean;
|
|
36
|
+
/** Injected in tests; default = real child-process load check. */
|
|
37
|
+
verify?: (root: string, nodePath: string) => boolean;
|
|
38
|
+
/** Injected in tests; default = npm install with this node. */
|
|
39
|
+
install?: Installer;
|
|
40
|
+
};
|
|
41
|
+
export declare function resolveHookRuntime(o: ResolveOpts): HookRuntime;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// hook-runtime — decide what command setup writes into the Claude Code hooks.
|
|
2
|
+
//
|
|
3
|
+
// 0.17.1 (L-1-106 / audit F11): hooks call `node <abs>/dist/bin/<bin>.js` directly instead of
|
|
4
|
+
// `npx -y linksee-memory <sub>` (6–9 s per call, against an 8 s PreToolUse timeout).
|
|
5
|
+
//
|
|
6
|
+
// The catch is WHERE that dist lives. `npx -y linksee-memory setup` runs out of npm's npx cache,
|
|
7
|
+
// which npm may rebuild or wipe at any time. So when setup runs from such a temporary place, it
|
|
8
|
+
// installs this exact version once into <memory dir>/runtime/<version>/ with
|
|
9
|
+
// npm install --prefix <memory dir>/runtime/<version> --no-save --omit=dev linksee-memory@<version>
|
|
10
|
+
// (the only network call, and only during setup) and points the hooks there. A global install or
|
|
11
|
+
// a git clone is already stable and is used where it is.
|
|
12
|
+
//
|
|
13
|
+
// Codex review of #15: copying npx's node_modules tree was replaced by this install — a real
|
|
14
|
+
// install has no links back into the cache, its size is known, and old versions are removed.
|
|
15
|
+
// • the new runtime must load better-sqlite3 before anything else happens;
|
|
16
|
+
// • older runtime/<version> folders are NOT deleted (0.17.1); setup says where they are;
|
|
17
|
+
// • on any failure the existing runtime is left as it was and the hooks keep the npx form.
|
|
18
|
+
// The node path written is process.execPath at setup time. If Node is later upgraded to another
|
|
19
|
+
// major, the guard hook catches the driver load failure, says "re-run setup" on stderr and exits 0.
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, renameSync, rmSync, mkdirSync, realpathSync, lstatSync } from 'node:fs';
|
|
21
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { NPX_HOOK_COMMANDS, nodeHookCommands, shellSafePath } from './guard-wiring.js';
|
|
25
|
+
function norm(p) {
|
|
26
|
+
let r = resolve(p);
|
|
27
|
+
try {
|
|
28
|
+
r = realpathSync(r);
|
|
29
|
+
}
|
|
30
|
+
catch { /* keep resolved */ }
|
|
31
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
32
|
+
}
|
|
33
|
+
/** Is this package root inside a cache that npm / pnpm / bun may rebuild or delete? */
|
|
34
|
+
export function isEphemeralRoot(packageRoot) {
|
|
35
|
+
const p = norm(packageRoot);
|
|
36
|
+
const parts = p.split(/[\\/]+/);
|
|
37
|
+
if (parts.includes('_npx') || parts.includes('dlx') || parts.some((s) => s.startsWith('bunx-')))
|
|
38
|
+
return true;
|
|
39
|
+
const t = norm(tmpdir());
|
|
40
|
+
return p === t || p.startsWith(t + sep) || p.startsWith(t + '/');
|
|
41
|
+
}
|
|
42
|
+
/** Can a node process load the native dependency from this root? (the part most likely to break) */
|
|
43
|
+
export function verifyRoot(root, nodePath) {
|
|
44
|
+
const pkg = join(root, 'package.json');
|
|
45
|
+
if (!existsSync(join(root, 'dist', 'bin', 'guard-hook.js')) || !existsSync(pkg))
|
|
46
|
+
return false;
|
|
47
|
+
const r = spawnSync(nodePath, ['-e', `const r=require('node:module').createRequire(${JSON.stringify(pkg)});const D=r('better-sqlite3');new D(':memory:').close()`], { encoding: 'utf8', timeout: 30000 });
|
|
48
|
+
return r.status === 0;
|
|
49
|
+
}
|
|
50
|
+
/** Locate npm's CLI script so it can be run with this node, without a shell. */
|
|
51
|
+
export function findNpmCli(nodePath, env = process.env) {
|
|
52
|
+
const cands = [];
|
|
53
|
+
const ep = env.npm_execpath;
|
|
54
|
+
if (ep) {
|
|
55
|
+
if (/npm-cli\.js$/.test(ep))
|
|
56
|
+
cands.push(ep);
|
|
57
|
+
if (/npx-cli\.js$/.test(ep))
|
|
58
|
+
cands.push(join(dirname(ep), 'npm-cli.js'));
|
|
59
|
+
}
|
|
60
|
+
const nodeDir = dirname(nodePath);
|
|
61
|
+
cands.push(join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js')); // Windows installer
|
|
62
|
+
cands.push(join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')); // POSIX layout
|
|
63
|
+
return cands.find((c) => existsSync(c)) ?? null;
|
|
64
|
+
}
|
|
65
|
+
/** `npm install --prefix <prefix> --no-save --omit=dev <spec>` with this node, no shell. */
|
|
66
|
+
export const npmInstall = (spec, prefix, nodePath) => {
|
|
67
|
+
const cli = findNpmCli(nodePath);
|
|
68
|
+
if (!cli)
|
|
69
|
+
return { ok: false, detail: 'npm not found next to this node' };
|
|
70
|
+
const r = spawnSync(nodePath, [cli, 'install', '--prefix', prefix, '--no-save', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error', spec], { encoding: 'utf8', timeout: 300000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
71
|
+
if (r.status === 0)
|
|
72
|
+
return { ok: true };
|
|
73
|
+
return { ok: false, detail: (r.stderr || r.error?.message || `exit ${r.status}`).trim().split('\n').slice(-2).join(' ') };
|
|
74
|
+
};
|
|
75
|
+
export function dirBytes(dir) {
|
|
76
|
+
let total = 0;
|
|
77
|
+
const walk = (d) => {
|
|
78
|
+
let names = [];
|
|
79
|
+
try {
|
|
80
|
+
names = readdirSync(d);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const n of names) {
|
|
86
|
+
const p = join(d, n);
|
|
87
|
+
let st;
|
|
88
|
+
try {
|
|
89
|
+
st = lstatSync(p);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (st.isSymbolicLink())
|
|
95
|
+
continue;
|
|
96
|
+
if (st.isDirectory())
|
|
97
|
+
walk(p);
|
|
98
|
+
else
|
|
99
|
+
total += st.size;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
walk(dir);
|
|
103
|
+
return total;
|
|
104
|
+
}
|
|
105
|
+
export function formatBytes(n) {
|
|
106
|
+
return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(0)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`;
|
|
107
|
+
}
|
|
108
|
+
export function resolveHookRuntime(o) {
|
|
109
|
+
const verify = o.verify ?? verifyRoot;
|
|
110
|
+
const install = o.install ?? npmInstall;
|
|
111
|
+
const npx = (note) => ({ commands: NPX_HOOK_COMMANDS, mode: 'npx', note });
|
|
112
|
+
if (!shellSafePath(o.nodePath))
|
|
113
|
+
return npx(`the node path contains a character a hook command cannot quote safely (${o.nodePath})`);
|
|
114
|
+
if (!isEphemeralRoot(o.packageRoot)) {
|
|
115
|
+
if (!shellSafePath(o.packageRoot))
|
|
116
|
+
return npx(`the package path contains a character a hook command cannot quote safely (${o.packageRoot})`);
|
|
117
|
+
return { commands: nodeHookCommands(o.nodePath, o.packageRoot), mode: 'direct', root: o.packageRoot };
|
|
118
|
+
}
|
|
119
|
+
const base = join(o.memoryDir, 'runtime');
|
|
120
|
+
const dest = join(base, o.version);
|
|
121
|
+
const destRoot = join(dest, 'node_modules', 'linksee-memory');
|
|
122
|
+
if (!shellSafePath(destRoot))
|
|
123
|
+
return npx(`the memory folder path contains a character a hook command cannot quote safely (${destRoot})`);
|
|
124
|
+
const pinned = (note) => {
|
|
125
|
+
const bytes = dirBytes(dest);
|
|
126
|
+
return { commands: nodeHookCommands(o.nodePath, destRoot), mode: 'pinned', root: destRoot, runtimeBase: base, version: o.version, bytes, note: `${note}; ${formatBytes(bytes)}` };
|
|
127
|
+
};
|
|
128
|
+
const versionAt = (root) => {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
// Already installed and loadable → reuse, no network.
|
|
137
|
+
if (versionAt(destRoot) === o.version && verify(destRoot, o.nodePath)) {
|
|
138
|
+
return pinned(`using the pinned runtime at ${dest}`);
|
|
139
|
+
}
|
|
140
|
+
if (o.dryRun) {
|
|
141
|
+
return { commands: nodeHookCommands(o.nodePath, destRoot), mode: 'pinned', root: destRoot, note: `[dry-run] would run npm install into ${dest}` };
|
|
142
|
+
}
|
|
143
|
+
const spec = o.spec ?? `linksee-memory@${o.version}`;
|
|
144
|
+
const staging = join(base, `.tmp-${o.version}-${process.pid}`);
|
|
145
|
+
const stagingRoot = join(staging, 'node_modules', 'linksee-memory');
|
|
146
|
+
const fail = (why) => {
|
|
147
|
+
try {
|
|
148
|
+
rmSync(staging, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
catch { /* ignore */ }
|
|
151
|
+
return npx(`${why}; kept the npx form, existing runtime left as it was`);
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
rmSync(staging, { recursive: true, force: true });
|
|
155
|
+
mkdirSync(staging, { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
return fail(`could not create ${staging} (${e?.message ?? e})`);
|
|
159
|
+
}
|
|
160
|
+
const res = install(spec, staging, o.nodePath);
|
|
161
|
+
if (!res.ok)
|
|
162
|
+
return fail(`npm install ${spec} failed (${res.detail ?? 'unknown error'})`);
|
|
163
|
+
if (versionAt(stagingRoot) !== o.version)
|
|
164
|
+
return fail(`npm installed ${versionAt(stagingRoot) ?? 'nothing'} instead of ${o.version}`);
|
|
165
|
+
if (!verify(stagingRoot, o.nodePath))
|
|
166
|
+
return fail(`the installed runtime could not load better-sqlite3`);
|
|
167
|
+
// Same-version replacement must be undoable: move the old folder aside, move the new one in,
|
|
168
|
+
// and only then delete the old one. If the second move fails, put the old one back.
|
|
169
|
+
const aside = `${dest}.old-${process.pid}`;
|
|
170
|
+
let movedAside = false;
|
|
171
|
+
try {
|
|
172
|
+
if (existsSync(dest)) {
|
|
173
|
+
renameSync(dest, aside);
|
|
174
|
+
movedAside = true;
|
|
175
|
+
}
|
|
176
|
+
renameSync(staging, dest);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
if (movedAside) {
|
|
180
|
+
try {
|
|
181
|
+
renameSync(aside, dest);
|
|
182
|
+
}
|
|
183
|
+
catch { /* reported below */ }
|
|
184
|
+
}
|
|
185
|
+
const restored = !movedAside || existsSync(dest);
|
|
186
|
+
return fail(`could not move the runtime into ${dest} (${e?.message ?? e})${restored ? '' : `; the previous one is at ${aside}`}`);
|
|
187
|
+
}
|
|
188
|
+
if (movedAside) {
|
|
189
|
+
try {
|
|
190
|
+
rmSync(aside, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
catch { /* harmless leftover */ }
|
|
193
|
+
}
|
|
194
|
+
// Older versions are never removed automatically (see above).
|
|
195
|
+
return pinned(`installed ${spec} into ${dest}`);
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=hook-runtime.js.map
|
package/dist/lib/map-import.js
CHANGED
|
@@ -71,13 +71,13 @@ export function importMap(db, map) {
|
|
|
71
71
|
const anchorExists = db.prepare('SELECT 1 FROM drift_anchors WHERE id = ?');
|
|
72
72
|
let linked = 0;
|
|
73
73
|
const tx = db.transaction(() => {
|
|
74
|
-
db.prepare(`
|
|
75
|
-
INSERT INTO map_projects (project, job, audience, product_status, template, stages, related_projects, updated_at)
|
|
76
|
-
VALUES (@project, @job, @audience, @product_status, @template, @stages, @related_projects, unixepoch())
|
|
77
|
-
ON CONFLICT(project) DO UPDATE SET
|
|
78
|
-
job=excluded.job, audience=excluded.audience, product_status=excluded.product_status,
|
|
79
|
-
template=excluded.template, stages=excluded.stages, related_projects=excluded.related_projects,
|
|
80
|
-
updated_at=unixepoch()
|
|
74
|
+
db.prepare(`
|
|
75
|
+
INSERT INTO map_projects (project, job, audience, product_status, template, stages, related_projects, updated_at)
|
|
76
|
+
VALUES (@project, @job, @audience, @product_status, @template, @stages, @related_projects, unixepoch())
|
|
77
|
+
ON CONFLICT(project) DO UPDATE SET
|
|
78
|
+
job=excluded.job, audience=excluded.audience, product_status=excluded.product_status,
|
|
79
|
+
template=excluded.template, stages=excluded.stages, related_projects=excluded.related_projects,
|
|
80
|
+
updated_at=unixepoch()
|
|
81
81
|
`).run({
|
|
82
82
|
project: map.project,
|
|
83
83
|
job: map.job ?? null,
|
|
@@ -89,13 +89,13 @@ export function importMap(db, map) {
|
|
|
89
89
|
});
|
|
90
90
|
db.prepare('DELETE FROM map_edges WHERE project = ?').run(map.project);
|
|
91
91
|
db.prepare('DELETE FROM map_nodes WHERE project = ?').run(map.project);
|
|
92
|
-
const insNode = db.prepare(`
|
|
93
|
-
INSERT INTO map_nodes
|
|
94
|
-
(id, project, layer, stage, statement, status, facets, role, note, due,
|
|
95
|
-
paused_reason, related_project, spinout_candidate, anchor_id, review_by, revival_condition, reality, extra, updated_at)
|
|
96
|
-
VALUES
|
|
97
|
-
(@id, @project, @layer, @stage, @statement, @status, @facets, @role, @note, @due,
|
|
98
|
-
@paused_reason, @related_project, @spinout_candidate, @anchor_id, @review_by, @revival_condition, @reality, @extra, unixepoch())
|
|
92
|
+
const insNode = db.prepare(`
|
|
93
|
+
INSERT INTO map_nodes
|
|
94
|
+
(id, project, layer, stage, statement, status, facets, role, note, due,
|
|
95
|
+
paused_reason, related_project, spinout_candidate, anchor_id, review_by, revival_condition, reality, extra, updated_at)
|
|
96
|
+
VALUES
|
|
97
|
+
(@id, @project, @layer, @stage, @statement, @status, @facets, @role, @note, @due,
|
|
98
|
+
@paused_reason, @related_project, @spinout_candidate, @anchor_id, @review_by, @revival_condition, @reality, @extra, unixepoch())
|
|
99
99
|
`);
|
|
100
100
|
for (const n of map.nodes) {
|
|
101
101
|
if (!n.id)
|
|
@@ -134,9 +134,9 @@ export function importMap(db, map) {
|
|
|
134
134
|
extra: JSON.stringify(extra),
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
|
-
const insEdge = db.prepare(`
|
|
138
|
-
INSERT OR IGNORE INTO map_edges (project, from_id, to_id, type, strength, note)
|
|
139
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
137
|
+
const insEdge = db.prepare(`
|
|
138
|
+
INSERT OR IGNORE INTO map_edges (project, from_id, to_id, type, strength, note)
|
|
139
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
140
140
|
`);
|
|
141
141
|
for (const e of map.edges)
|
|
142
142
|
insEdge.run(map.project, e.from, e.to, e.type, e.strength ?? null, e.note ?? null);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const NATIVE_HINT = "linksee-memory: native module failed to load (Node changed?). Re-run: npx -y linksee-memory setup";
|
|
2
|
+
/** Does this error (or child-process stderr) look like the native addon failing to load? */
|
|
3
|
+
export declare function isNativeLoadError(e: unknown): boolean;
|
|
4
|
+
/** Append one line to <memory dir>/hook.log; never throws. */
|
|
5
|
+
export declare function hookLog(msg: string): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// native-hint — tell the user, in one line, when the SQLite native addon cannot load.
|
|
2
|
+
//
|
|
3
|
+
// 0.17.1 (Codex re-review of #15): hooks call `node <runtime>/dist/bin/*.js` with the node that ran
|
|
4
|
+
// setup. After a Node major upgrade the pinned better-sqlite3 addon no longer matches the ABI. That
|
|
5
|
+
// failure happens at `new Database()` (better-sqlite3 loads the .node lazily), not at import — so
|
|
6
|
+
// the hooks catch it around opening the DB. They still fail open (exit 0), but say why once on
|
|
7
|
+
// stderr; any other failure stays silent and goes to hook.log only.
|
|
8
|
+
//
|
|
9
|
+
// Must not import better-sqlite3 (it is used exactly when that import is the problem).
|
|
10
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
export const NATIVE_HINT = 'linksee-memory: native module failed to load (Node changed?). Re-run: npx -y linksee-memory setup';
|
|
14
|
+
const NATIVE_PATTERNS = [
|
|
15
|
+
/NODE_MODULE_VERSION/,
|
|
16
|
+
/was compiled against a different Node\.js version/i,
|
|
17
|
+
/Could not locate the bindings file/i,
|
|
18
|
+
/ERR_DLOPEN_FAILED/,
|
|
19
|
+
/not a valid Win32 application/i,
|
|
20
|
+
/invalid ELF header/i,
|
|
21
|
+
/wrong ELF class/i,
|
|
22
|
+
/mach-o, but wrong architecture|not a mach-o file/i,
|
|
23
|
+
/better_sqlite3\.node/,
|
|
24
|
+
/Cannot find (module|package) 'better-sqlite3'/,
|
|
25
|
+
];
|
|
26
|
+
/** Does this error (or child-process stderr) look like the native addon failing to load? */
|
|
27
|
+
export function isNativeLoadError(e) {
|
|
28
|
+
const text = typeof e === 'string'
|
|
29
|
+
? e
|
|
30
|
+
: [e?.code, e?.message, e?.stack].filter(Boolean).join('\n');
|
|
31
|
+
return NATIVE_PATTERNS.some((re) => re.test(text));
|
|
32
|
+
}
|
|
33
|
+
/** Append one line to <memory dir>/hook.log; never throws. */
|
|
34
|
+
export function hookLog(msg) {
|
|
35
|
+
try {
|
|
36
|
+
const dir = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
appendFileSync(join(dir, 'hook.log'), `[${new Date().toISOString()}] ${msg}\n`);
|
|
39
|
+
}
|
|
40
|
+
catch { /* never throw from log */ }
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=native-hint.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type SettingsRead = {
|
|
2
|
+
ok: true;
|
|
3
|
+
exists: boolean;
|
|
4
|
+
settings: Record<string, unknown>;
|
|
5
|
+
raw: string;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function stripBom(raw: string): string;
|
|
11
|
+
/** Read a settings file. A missing file is `{}`; an unreadable or non-object file is a refusal. */
|
|
12
|
+
export declare function readSettingsFile(path: string): SettingsRead;
|
|
13
|
+
export declare function backupTimestamp(d?: Date): string;
|
|
14
|
+
/** `backup` is set only on the write that created it. */
|
|
15
|
+
export type SettingsWrite = {
|
|
16
|
+
written: boolean;
|
|
17
|
+
backup?: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Write `settings` to `path` if (and only if) it differs from what is on disk.
|
|
21
|
+
* Leaves `<path>.bak-<timestamp>` before the first write of this process; writes atomically.
|
|
22
|
+
*/
|
|
23
|
+
export declare function writeSettingsFile(path: string, settings: Record<string, unknown>): SettingsWrite;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// settings-file — read and write ~/.claude/settings.json without ever losing what was there.
|
|
2
|
+
//
|
|
3
|
+
// Why (L-1-106 / audit F4, 2026-11-04): setup's Stop-hook step parsed the file, and on a parse
|
|
4
|
+
// failure carried on with `{}` and wrote it back — wiping every other hook and setting the user
|
|
5
|
+
// had. A UTF-8 BOM (Notepad's default on Windows) was enough to trigger it. The guard step
|
|
6
|
+
// already refused to touch a file it could not parse; this module gives both steps one set of
|
|
7
|
+
// rules:
|
|
8
|
+
//
|
|
9
|
+
// • strip a leading BOM before parsing
|
|
10
|
+
// • if the file exists but does not parse to an object, refuse — never write
|
|
11
|
+
// • before the first write of a run, leave one `settings.json.bak-<timestamp>` copy
|
|
12
|
+
// • write to a temp file in the same folder, then rename (no half-written settings.json)
|
|
13
|
+
// • write nothing when nothing changed (a second setup run is a no-op)
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, mkdirSync, unlinkSync, statSync, chmodSync, lstatSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
export function stripBom(raw) {
|
|
17
|
+
return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
18
|
+
}
|
|
19
|
+
/** Read a settings file. A missing file is `{}`; an unreadable or non-object file is a refusal. */
|
|
20
|
+
export function readSettingsFile(path) {
|
|
21
|
+
if (!existsSync(path)) {
|
|
22
|
+
let dangling = false;
|
|
23
|
+
try {
|
|
24
|
+
dangling = lstatSync(path).isSymbolicLink();
|
|
25
|
+
}
|
|
26
|
+
catch { /* really absent */ }
|
|
27
|
+
if (dangling)
|
|
28
|
+
return { ok: false, reason: 'symbolic link whose target does not exist' };
|
|
29
|
+
return { ok: true, exists: false, settings: {}, raw: '' };
|
|
30
|
+
}
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(path, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return { ok: false, reason: `could not read: ${e?.message ?? e}` };
|
|
37
|
+
}
|
|
38
|
+
const body = stripBom(raw);
|
|
39
|
+
if (body.trim() === '')
|
|
40
|
+
return { ok: true, exists: true, settings: {}, raw };
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(body);
|
|
43
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
44
|
+
return { ok: false, reason: 'top level is not a JSON object' };
|
|
45
|
+
}
|
|
46
|
+
return { ok: true, exists: true, settings: parsed, raw };
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
return { ok: false, reason: `invalid JSON: ${e?.message ?? e}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Paths this process already backed up (value = the .bak path) or created (value = ''), so one
|
|
54
|
+
* setup run leaves at most one .bak per file — and none for a file the run itself created.
|
|
55
|
+
*/
|
|
56
|
+
const backedUp = new Map();
|
|
57
|
+
export function backupTimestamp(d = new Date()) {
|
|
58
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
59
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Write `settings` to `path` if (and only if) it differs from what is on disk.
|
|
63
|
+
* Leaves `<path>.bak-<timestamp>` before the first write of this process; writes atomically.
|
|
64
|
+
*/
|
|
65
|
+
export function writeSettingsFile(path, settings) {
|
|
66
|
+
const next = JSON.stringify(settings, null, 2);
|
|
67
|
+
let current = null;
|
|
68
|
+
if (existsSync(path)) {
|
|
69
|
+
current = readFileSync(path, 'utf8');
|
|
70
|
+
// Compare structurally so formatting or a BOM alone never triggers a rewrite.
|
|
71
|
+
try {
|
|
72
|
+
if (JSON.stringify(JSON.parse(stripBom(current)), null, 2) === next)
|
|
73
|
+
return { written: false };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// The caller should have refused an unparseable file; refuse here too rather than clobber it.
|
|
77
|
+
throw new Error(`refusing to overwrite unparseable ${path}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
+
let backup = backedUp.get(path);
|
|
82
|
+
let created = false;
|
|
83
|
+
if (backup === undefined) {
|
|
84
|
+
created = true;
|
|
85
|
+
if (current !== null) {
|
|
86
|
+
backup = `${path}.bak-${backupTimestamp()}`;
|
|
87
|
+
copyFileSync(path, backup);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
backup = '';
|
|
91
|
+
}
|
|
92
|
+
backedUp.set(path, backup);
|
|
93
|
+
}
|
|
94
|
+
// If settings.json is a symlink (dotfiles repos), replace the file it points to, not the link.
|
|
95
|
+
let target = path;
|
|
96
|
+
let isLink = false;
|
|
97
|
+
try {
|
|
98
|
+
isLink = lstatSync(path).isSymbolicLink();
|
|
99
|
+
}
|
|
100
|
+
catch { /* no file, no link */ }
|
|
101
|
+
if (isLink) {
|
|
102
|
+
// A dangling link (target missing) is refused: replacing it would silently turn the user's
|
|
103
|
+
// link into a plain file, and following it would create a file somewhere they did not expect.
|
|
104
|
+
try {
|
|
105
|
+
target = realpathSync(path);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new Error(`refusing to write ${path}: it is a symbolic link whose target does not exist`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Keep the original permission bits (e.g. 600) — a fresh temp file gets the umask default.
|
|
112
|
+
// On Windows chmod only toggles read-only, so this is effectively a no-op there.
|
|
113
|
+
let mode;
|
|
114
|
+
try {
|
|
115
|
+
mode = statSync(target).mode & 0o7777;
|
|
116
|
+
}
|
|
117
|
+
catch { /* new file */ }
|
|
118
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
119
|
+
try {
|
|
120
|
+
// Create the temp file with the original mode from the start (umask may widen it, so chmod too).
|
|
121
|
+
writeFileSync(tmp, next, mode !== undefined ? { encoding: 'utf8', mode } : 'utf8');
|
|
122
|
+
if (mode !== undefined)
|
|
123
|
+
chmodSync(tmp, mode);
|
|
124
|
+
renameSync(tmp, target);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
try {
|
|
128
|
+
if (existsSync(tmp))
|
|
129
|
+
unlinkSync(tmp);
|
|
130
|
+
}
|
|
131
|
+
catch { /* ignore */ }
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
// Report the backup only on the write that made it, so callers print it once.
|
|
135
|
+
return { written: true, backup: created && backup ? backup : undefined };
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=settings-file.js.map
|