linksee-memory 0.16.1 → 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 +97 -183
- package/dist/bin/guard-hook.js +19 -4
- package/dist/bin/setup.js +178 -63
- package/dist/bin/share-draft.d.ts +2 -0
- package/dist/bin/share-draft.js +90 -0
- package/dist/bin/sync-session.js +10 -1
- package/dist/db/migrate.d.ts +30 -0
- package/dist/db/migrate.js +150 -9
- package/dist/lib/consolidate.d.ts +9 -0
- package/dist/lib/consolidate.js +14 -0
- 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/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/lib/share-draft.d.ts +56 -0
- package/dist/lib/share-draft.js +234 -0
- package/dist/mcp/prompts.js +1 -1
- package/dist/mcp/server.js +93 -26
- package/dist/skill/SKILL.md +6 -14
- package/package.json +4 -3
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export declare const DRAFT_PAGE = "https://kansei-link.com/agents/note/new.html";
|
|
2
|
+
export declare const URL_MAX = 2000;
|
|
3
|
+
export declare const KINDS: readonly ["execution", "documentation", "hypothesis"];
|
|
4
|
+
export declare const RUNTIMES: readonly ["claude", "codex", "gpt", "gemini", "other"];
|
|
5
|
+
/** Text fields in URL order, with the page's caps (same numbers as the page's maxlength). */
|
|
6
|
+
export declare const TEXT_FIELDS: readonly [{
|
|
7
|
+
readonly key: "task";
|
|
8
|
+
readonly max: 300;
|
|
9
|
+
readonly required: true;
|
|
10
|
+
}, {
|
|
11
|
+
readonly key: "symptom";
|
|
12
|
+
readonly max: 600;
|
|
13
|
+
readonly required: true;
|
|
14
|
+
}, {
|
|
15
|
+
readonly key: "tried";
|
|
16
|
+
readonly max: 2000;
|
|
17
|
+
readonly required: true;
|
|
18
|
+
}, {
|
|
19
|
+
readonly key: "result";
|
|
20
|
+
readonly max: 1000;
|
|
21
|
+
readonly required: true;
|
|
22
|
+
}, {
|
|
23
|
+
readonly key: "conditions";
|
|
24
|
+
readonly max: 600;
|
|
25
|
+
readonly required: false;
|
|
26
|
+
}];
|
|
27
|
+
export declare const ALLOWED_KEYS: readonly ["service", ...("task" | "symptom" | "tried" | "result" | "conditions")[], "kind", "runtime"];
|
|
28
|
+
export declare const SERVICE_RE: RegExp;
|
|
29
|
+
export declare const NOTICE: readonly ["この内容は、開いた画面で確認してから送られます。送るまでどこにも出ていません。", "URL の中身は KanseiLINK の通信の記録(Cloudflare)を通ります。秘密は入れないでください。"];
|
|
30
|
+
export declare const NOTICE_EN: readonly ["Nothing is sent yet: the page opens with these fields filled, and only your click there sends them.", "The URL passes through KanseiLINK's request logs (Cloudflare). Do not put secrets in it."];
|
|
31
|
+
/** Which secret shapes a text contains (kinds, in check order). Empty = clean. */
|
|
32
|
+
export declare function findSecretShapes(text: string): Array<{
|
|
33
|
+
kind: string;
|
|
34
|
+
ja: string;
|
|
35
|
+
}>;
|
|
36
|
+
export interface ShareDraftProblem {
|
|
37
|
+
field: string;
|
|
38
|
+
reason: string;
|
|
39
|
+
kinds?: string[];
|
|
40
|
+
}
|
|
41
|
+
export type ShareDraftResult = {
|
|
42
|
+
ok: true;
|
|
43
|
+
url: string;
|
|
44
|
+
length: number;
|
|
45
|
+
notice: readonly string[];
|
|
46
|
+
notice_en: readonly string[];
|
|
47
|
+
} | {
|
|
48
|
+
ok: false;
|
|
49
|
+
problems: ShareDraftProblem[];
|
|
50
|
+
hint: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Check the fields and build the draft URL. Pure: no I/O, no network, no DB.
|
|
54
|
+
* Returns problems (field + reason) instead of a URL when anything is off.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildShareDraft(input: unknown): ShareDraftResult;
|