mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-07-07 06:34:32 +08:00
fix(storage): harden settings and project identity
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,12 +1,12 @@
|
||||
var __IMPORT_META_URL__ = require("node:url").pathToFileURL(__filename).href;
|
||||
"use strict";var U=Object.defineProperty;var se=Object.getOwnPropertyDescriptor;var te=Object.getOwnPropertyNames;var re=Object.prototype.hasOwnProperty;var ne=(E,e)=>{for(var s in e)U(E,s,{get:e[s],enumerable:!0})},oe=(E,e,s,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of te(e))!re.call(E,r)&&r!==s&&U(E,r,{get:()=>e[r],enumerable:!(t=se(e,r))||t.enumerable});return E};var ie=E=>oe(U({},"__esModule",{value:!0}),E);var be={};ne(be,{SessionStore:()=>H});module.exports=ie(be);var B=require("bun:sqlite");var R=require("path"),M=require("os"),D=require("fs"),$=require("url");function ae(){return typeof __dirname<"u"?__dirname:(0,R.dirname)((0,$.fileURLToPath)(__IMPORT_META_URL__))}var Se=ae();function de(){if(process.env.CLAUDE_MEM_DATA_DIR)return process.env.CLAUDE_MEM_DATA_DIR;let E=(0,R.join)((0,M.homedir)(),".claude-mem"),e=(0,R.join)(E,"settings.json");try{if((0,D.existsSync)(e)){let s=JSON.parse((0,D.readFileSync)(e,"utf-8")),t=s.env??s;if(t.CLAUDE_MEM_DATA_DIR)return t.CLAUDE_MEM_DATA_DIR}}catch{}return E}var O=de(),_e=process.env.CLAUDE_CONFIG_DIR||(0,R.join)((0,M.homedir)(),".claude"),Ie=(0,R.join)(_e,"plugins","marketplaces","thedotmack"),pe=(0,R.join)(O,"logs"),he=(0,R.join)(O,"settings.json"),j=(0,R.join)(O,"claude-mem.db"),Ee=(0,R.join)(O,"observer-sessions"),F=(0,R.basename)(Ee);function W(E){(0,D.mkdirSync)(E,{recursive:!0})}var x={dataDir:()=>O,workerPid:()=>(0,R.join)(O,"worker.pid"),serverPid:()=>(0,R.join)(O,".server-beta.pid"),serverPort:()=>(0,R.join)(O,".server-beta.port"),serverRuntime:()=>(0,R.join)(O,".server-beta.runtime.json"),settings:()=>(0,R.join)(O,"settings.json"),database:()=>(0,R.join)(O,"claude-mem.db"),chroma:()=>(0,R.join)(O,"chroma"),combinedCerts:()=>(0,R.join)(O,"combined_certs.pem"),transcriptsConfig:()=>(0,R.join)(O,"transcript-watch.json"),transcriptsState:()=>(0,R.join)(O,"transcript-watch-state.json"),corpora:()=>(0,R.join)(O,"corpora"),supervisorRegistry:()=>(0,R.join)(O,"supervisor.json"),envFile:()=>(0,R.join)(O,".env"),logsDir:()=>pe};var C=require("fs"),V=require("path");var ue=null;function me(E){return(ue??process.stderr.write.bind(process.stderr))(E)}function w(E){me(E)}var X=(n=>(n[n.DEBUG=0]="DEBUG",n[n.INFO=1]="INFO",n[n.WARN=2]="WARN",n[n.ERROR=3]="ERROR",n[n.SILENT=4]="SILENT",n))(X||{}),k=null,G=class{level=null;useColor;logFilePath=null;logFileInitialized=!1;constructor(){this.useColor=process.stdout.isTTY??!1}ensureLogFileInitialized(){if(!this.logFileInitialized){this.logFileInitialized=!0;try{let e=x.logsDir();(0,C.existsSync)(e)||(0,C.mkdirSync)(e,{recursive:!0});let s=new Date().toISOString().split("T")[0];this.logFilePath=(0,V.join)(e,`claude-mem-${s}.log`)}catch(e){console.error("[LOGGER] Failed to initialize log file:",e instanceof Error?e.message:String(e)),this.logFilePath=null}}}getLevel(){if(this.level===null)try{let e=x.settings();if((0,C.existsSync)(e)){let s=(0,C.readFileSync)(e,"utf-8"),r=(JSON.parse(s).CLAUDE_MEM_LOG_LEVEL||"INFO").toUpperCase();this.level=X[r]??1}else this.level=1}catch(e){console.error("[LOGGER] Failed to load log level from settings:",e instanceof Error?e.message:String(e)),this.level=1}return this.level}formatData(e){if(e==null)return"";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return e.toString();if(typeof e=="object"){if(e instanceof Error)return this.getLevel()===0?`${e.message}
|
||||
"use strict";var U=Object.defineProperty;var se=Object.getOwnPropertyDescriptor;var te=Object.getOwnPropertyNames;var re=Object.prototype.hasOwnProperty;var ne=(E,e)=>{for(var s in e)U(E,s,{get:e[s],enumerable:!0})},oe=(E,e,s,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of te(e))!re.call(E,r)&&r!==s&&U(E,r,{get:()=>e[r],enumerable:!(t=se(e,r))||t.enumerable});return E};var ie=E=>oe(U({},"__esModule",{value:!0}),E);var Re={};ne(Re,{SessionStore:()=>H});module.exports=ie(Re);var B=require("bun:sqlite");var R=require("path"),M=require("os"),D=require("fs"),$=require("url");function ae(){return typeof __dirname<"u"?__dirname:(0,R.dirname)((0,$.fileURLToPath)(__IMPORT_META_URL__))}var Ie=ae();function de(){if(process.env.CLAUDE_MEM_DATA_DIR)return process.env.CLAUDE_MEM_DATA_DIR;let E=(0,R.join)((0,M.homedir)(),".claude-mem"),e=(0,R.join)(E,"settings.json");try{if((0,D.existsSync)(e)){let s=JSON.parse((0,D.readFileSync)(e,"utf-8")),t=s.env??s;if(t.CLAUDE_MEM_DATA_DIR)return t.CLAUDE_MEM_DATA_DIR}}catch{}return E}var O=de(),_e=process.env.CLAUDE_CONFIG_DIR||(0,R.join)((0,M.homedir)(),".claude"),he=(0,R.join)(_e,"plugins","marketplaces","thedotmack"),pe=(0,R.join)(O,"logs"),Oe=(0,R.join)(O,"settings.json"),j=(0,R.join)(O,"claude-mem.db"),Ee=(0,R.join)(O,"observer-sessions"),F=(0,R.basename)(Ee);function W(E){(0,D.mkdirSync)(E,{recursive:!0})}var w={dataDir:()=>O,workerPid:()=>(0,R.join)(O,"worker.pid"),serverPid:()=>(0,R.join)(O,".server-beta.pid"),serverPort:()=>(0,R.join)(O,".server-beta.port"),serverRuntime:()=>(0,R.join)(O,".server-beta.runtime.json"),settings:()=>(0,R.join)(O,"settings.json"),database:()=>(0,R.join)(O,"claude-mem.db"),chroma:()=>(0,R.join)(O,"chroma"),combinedCerts:()=>(0,R.join)(O,"combined_certs.pem"),transcriptsConfig:()=>(0,R.join)(O,"transcript-watch.json"),transcriptsState:()=>(0,R.join)(O,"transcript-watch-state.json"),corpora:()=>(0,R.join)(O,"corpora"),supervisorRegistry:()=>(0,R.join)(O,"supervisor.json"),envFile:()=>(0,R.join)(O,".env"),logsDir:()=>pe};var C=require("fs"),V=require("path");var ue=null;function me(E){return(ue??process.stderr.write.bind(process.stderr))(E)}function x(E){me(E)}var X=(n=>(n[n.DEBUG=0]="DEBUG",n[n.INFO=1]="INFO",n[n.WARN=2]="WARN",n[n.ERROR=3]="ERROR",n[n.SILENT=4]="SILENT",n))(X||{}),k=null,G=class{level=null;useColor;logFilePath=null;logFileInitialized=!1;constructor(){this.useColor=process.stdout.isTTY??!1}ensureLogFileInitialized(){if(!this.logFileInitialized){this.logFileInitialized=!0;try{let e=w.logsDir();(0,C.existsSync)(e)||(0,C.mkdirSync)(e,{recursive:!0});let s=new Date().toISOString().split("T")[0];this.logFilePath=(0,V.join)(e,`claude-mem-${s}.log`)}catch(e){console.error("[LOGGER] Failed to initialize log file:",e instanceof Error?e.message:String(e)),this.logFilePath=null}}}getLevel(){if(this.level===null)try{let e=w.settings();if((0,C.existsSync)(e)){let s=(0,C.readFileSync)(e,"utf-8"),r=(JSON.parse(s).CLAUDE_MEM_LOG_LEVEL||"INFO").toUpperCase();this.level=X[r]??1}else this.level=1}catch(e){console.error("[LOGGER] Failed to load log level from settings:",e instanceof Error?e.message:String(e)),this.level=1}return this.level}formatData(e){if(e==null)return"";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return e.toString();if(typeof e=="object"){if(e instanceof Error)return this.getLevel()===0?`${e.message}
|
||||
${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Object.keys(e);return s.length===0?"{}":s.length<=3?JSON.stringify(e):`{${s.length} keys: ${s.slice(0,3).join(", ")}...}`}return String(e)}formatTool(e,s){if(!s)return e;let t=s;if(typeof s=="string")try{t=JSON.parse(s)}catch{t=s}if(e==="Bash"&&t.command)return`${e}(${t.command})`;if(t.file_path)return`${e}(${t.file_path})`;if(t.notebook_path)return`${e}(${t.notebook_path})`;if(e==="Glob"&&t.pattern)return`${e}(${t.pattern})`;if(e==="Grep"&&t.pattern)return`${e}(${t.pattern})`;if(t.url)return`${e}(${t.url})`;if(t.query)return`${e}(${t.query})`;if(e==="Task"){if(t.subagent_type)return`${e}(${t.subagent_type})`;if(t.description)return`${e}(${t.description})`}return e==="Skill"&&t.skill?`${e}(${t.skill})`:e==="LSP"&&t.operation?`${e}(${t.operation})`:e}formatTimestamp(e){let s=e.getFullYear(),t=String(e.getMonth()+1).padStart(2,"0"),r=String(e.getDate()).padStart(2,"0"),n=String(e.getHours()).padStart(2,"0"),o=String(e.getMinutes()).padStart(2,"0"),i=String(e.getSeconds()).padStart(2,"0"),_=String(e.getMilliseconds()).padStart(3,"0");return`${s}-${t}-${r} ${n}:${o}:${i}.${_}`}log(e,s,t,r,n){if(e<this.getLevel())return;this.ensureLogFileInitialized();let o=this.formatTimestamp(new Date),i=X[e].padEnd(5),_=s.padEnd(6),d="";r?.correlationId?d=`[${r.correlationId}] `:r?.sessionId&&(d=`[session-${r.sessionId}] `);let u="";if(n!=null)if(n instanceof Error)u=this.getLevel()===0?`
|
||||
${n.message}
|
||||
${n.stack}`:` ${n.message}`;else if(this.getLevel()===0&&typeof n=="object")try{u=`
|
||||
`+JSON.stringify(n,null,2)}catch{u=" "+this.formatData(n)}else u=" "+this.formatData(n);let I="";if(r){let{sessionId:S,memorySessionId:g,correlationId:N,...m}=r;Object.keys(m).length>0&&(I=` {${Object.entries(m).map(([f,v])=>`${f}=${v}`).join(", ")}}`)}let c=`[${o}] [${i}] [${_}] ${d}${t}${I}${u}`;if(this.logFilePath)try{(0,C.appendFileSync)(this.logFilePath,c+`
|
||||
`,"utf8")}catch(S){let g=S instanceof Error?S:new Error(String(S));w(`[LOGGER] Failed to write to log file: ${g.message}
|
||||
`,"utf8")}catch(S){let g=S instanceof Error?S:new Error(String(S));x(`[LOGGER] Failed to write to log file: ${g.message}
|
||||
${g.stack??""}
|
||||
`)}else w(c+`
|
||||
`)}else x(c+`
|
||||
`)}debug(e,s,t,r){this.log(0,e,s,t,r)}info(e,s,t,r){this.log(1,e,s,t,r)}warn(e,s,t,r){this.log(2,e,s,t,r)}setErrorSink(e){k=e}error(e,s,t,r){this.log(3,e,s,t,r),this.routeErrorToSink(s,t,r)}routeErrorToSink(e,s,t){try{if(!k||!(t instanceof Error))return;k(t)}catch{}}dataIn(e,s,t,r){this.info(e,`\u2192 ${s}`,t,r)}dataOut(e,s,t,r){this.info(e,`\u2190 ${s}`,t,r)}success(e,s,t,r){this.info(e,`\u2713 ${s}`,t,r)}failure(e,s,t,r){this.error(e,`\u2717 ${s}`,t,r)}},a=new G;var q=require("crypto");function Y(E,e,s){return(0,q.createHash)("sha256").update([E||"",e||"",s||""].join("\0")).digest("hex").slice(0,16)}var p="claude";function le(E){return E.trim().toLowerCase().replace(/\s+/g,"-")}function L(E){if(!E)return p;let e=le(E);return e?e==="transcript"||e.includes("codex")?"codex":e.includes("cursor")?"cursor":e.includes("claude")?"claude":e:p}function K(E){let e=["claude","codex","cursor"];return[...E].sort((s,t)=>{let r=e.indexOf(s),n=e.indexOf(t);return r!==-1||n!==-1?r===-1?1:n===-1?-1:r-n:s.localeCompare(t)})}function Q(E,e,s,t,r){let n=Date.now()-t,o=r!==void 0?"up.session_db_id = ?":"up.content_session_id = ?",i=r??e;return E.prepare(`
|
||||
SELECT
|
||||
up.*,
|
||||
@@ -20,7 +20,7 @@ ${g.stack??""}
|
||||
AND up.created_at_epoch >= ?
|
||||
ORDER BY up.created_at_epoch DESC
|
||||
LIMIT 1
|
||||
`).get(i,s,n)??void 0}var Z=["private","claude-mem-context","system_instruction","system-instruction","persisted-output","system-reminder"],J=new RegExp(`<(${Z.join("|")})\\b[^>]*>[\\s\\S]*?</\\1>`,"g");var z=100;function ce(E){let e=Object.fromEntries(Z.map(r=>[r,0]));J.lastIndex=0;let s=0,t=E.replace(J,(r,n)=>(e[n]=(e[n]??0)+1,s+=1,""));return s>z&&a.warn("SYSTEM","tag count exceeds limit",void 0,{tagCount:s,maxAllowed:z,contentLength:E.length}),{stripped:t.trim(),counts:e}}function ee(E){return ce(E).stripped}var Te=["task-notification"],Me=new RegExp(`^\\s*<(${Te.join("|")})\\b[^>]*>(?:(?!<\\1\\b|</\\1\\b)[\\s\\S])*</\\1>\\s*$`),Fe=256*1024;var P=4e3;function y(E){let e=E.trim(),t=ee(E).trim()||e;return t.length<=P?t:(a.debug("DB","Truncated stored prompt text to the configured cap",{originalLength:t.length,storedLength:P}),`${t.slice(0,P-1)}\u2026`)}var H=class{db;constructor(e=j){e instanceof B.Database?this.db=e:(e!==":memory:"&&W(O),this.db=new B.Database(e),this.db.run("PRAGMA journal_mode = WAL"),this.db.run("PRAGMA synchronous = NORMAL"),this.db.run("PRAGMA foreign_keys = ON"),this.db.run("PRAGMA journal_size_limit = 4194304")),this.initializeSchema(),this.ensureWorkerPortColumn(),this.ensurePromptTrackingColumns(),this.removeSessionSummariesUniqueConstraint(),this.addObservationHierarchicalFields(),this.makeObservationsTextNullable(),this.createUserPromptsTable(),this.ensureDiscoveryTokensColumn(),this.createPendingMessagesTable(),this.renameSessionIdColumns(),this.addFailedAtEpochColumn(),this.addOnUpdateCascadeToForeignKeys(),this.addObservationContentHashColumn(),this.addSessionCustomTitleColumn(),this.addSessionPlatformSourceColumn(),this.addObservationModelColumns(),this.ensureMergedIntoProjectColumns(),this.addObservationSubagentColumns(),this.addObservationsUniqueContentHashIndex(),this.addObservationsMetadataColumn(),this.dropDeadPendingMessagesColumns(),this.ensurePendingMessagesToolUseIdColumn(),this.dropWorkerPidColumn(),this.ensureSDKSessionsPlatformContentIdentity(),this.ensureUserPromptsSessionDbId(),this.ensurePendingMessagesSessionToolUniqueIndex()}getIndexColumns(e){return this.db.query(`PRAGMA index_info(${JSON.stringify(e)})`).all().map(s=>s.name)}hasUniqueIndexOnColumns(e,s){return this.db.query(`PRAGMA index_list(${e})`).all().some(r=>{if(r.unique!==1)return!1;let n=this.getIndexColumns(r.name);return n.length===s.length&&n.every((o,i)=>o===s[i])})}resolvePromptSessionDbId(e,s,t){if(s!==void 0)return s;let r=t?L(t):void 0;return r?this.db.prepare(`
|
||||
`).get(i,s,n)??void 0}var Z=["private","claude-mem-context","system_instruction","system-instruction","persisted-output","system-reminder"],J=new RegExp(`<(${Z.join("|")})\\b[^>]*>[\\s\\S]*?</\\1>`,"g");var z=100;function ce(E){let e=Object.fromEntries(Z.map(r=>[r,0]));J.lastIndex=0;let s=0,t=E.replace(J,(r,n)=>(e[n]=(e[n]??0)+1,s+=1,""));return s>z&&a.warn("SYSTEM","tag count exceeds limit",void 0,{tagCount:s,maxAllowed:z,contentLength:E.length}),{stripped:t.trim(),counts:e}}function ee(E){return ce(E).stripped}var Te=["task-notification"],Fe=new RegExp(`^\\s*<(${Te.join("|")})\\b[^>]*>(?:(?!<\\1\\b|</\\1\\b)[\\s\\S])*</\\1>\\s*$`),we=256*1024;var P=4e3;function y(E){let e=E.trim(),t=ee(E).trim()||e;return t.length<=P?t:(a.debug("DB","Truncated stored prompt text to the configured cap",{originalLength:t.length,storedLength:P}),`${t.slice(0,P-1)}\u2026`)}var be=5e3,H=class{db;constructor(e=j){e instanceof B.Database?this.db=e:(e!==":memory:"&&W(O),this.db=new B.Database(e),this.db.run("PRAGMA journal_mode = WAL"),this.db.run("PRAGMA synchronous = NORMAL"),this.db.run("PRAGMA foreign_keys = ON"),this.db.run("PRAGMA journal_size_limit = 4194304")),this.db.run(`PRAGMA busy_timeout = ${be}`),this.initializeSchema(),this.ensureWorkerPortColumn(),this.ensurePromptTrackingColumns(),this.removeSessionSummariesUniqueConstraint(),this.addObservationHierarchicalFields(),this.makeObservationsTextNullable(),this.createUserPromptsTable(),this.ensureDiscoveryTokensColumn(),this.createPendingMessagesTable(),this.renameSessionIdColumns(),this.addFailedAtEpochColumn(),this.addOnUpdateCascadeToForeignKeys(),this.addObservationContentHashColumn(),this.addSessionCustomTitleColumn(),this.addSessionPlatformSourceColumn(),this.addObservationModelColumns(),this.ensureMergedIntoProjectColumns(),this.addObservationSubagentColumns(),this.addObservationsUniqueContentHashIndex(),this.addObservationsMetadataColumn(),this.dropDeadPendingMessagesColumns(),this.ensurePendingMessagesToolUseIdColumn(),this.dropWorkerPidColumn(),this.ensureSDKSessionsPlatformContentIdentity(),this.ensureUserPromptsSessionDbId(),this.ensurePendingMessagesSessionToolUniqueIndex()}getIndexColumns(e){return this.db.query(`PRAGMA index_info(${JSON.stringify(e)})`).all().map(s=>s.name)}hasUniqueIndexOnColumns(e,s){return this.db.query(`PRAGMA index_list(${e})`).all().some(r=>{if(r.unique!==1)return!1;let n=this.getIndexColumns(r.name);return n.length===s.length&&n.every((o,i)=>o===s[i])})}resolvePromptSessionDbId(e,s,t){if(s!==void 0)return s;let r=t?L(t):void 0;return r?this.db.prepare(`
|
||||
SELECT id
|
||||
FROM sdk_sessions
|
||||
WHERE COALESCE(NULLIF(platform_source, ''), ?) = ?
|
||||
@@ -175,8 +175,6 @@ ${g.stack??""}
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_status ON sdk_sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_started ON sdk_sessions(started_at_epoch DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_platform_source ON sdk_sessions(platform_source);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_sdk_sessions_platform_content ON sdk_sessions(platform_source, content_session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeSync,
|
||||
} from 'fs';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { basename, dirname, join, resolve } from 'path';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
@@ -99,112 +84,4 @@ export function ensureDirectoryExists(directoryPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export { readJsonSafe } from '../../utils/json-utils.js';
|
||||
|
||||
/**
|
||||
* Write JSON to disk with crash-safe atomic-rename semantics.
|
||||
*
|
||||
* Sequence: resolve symlinks at the destination, write payload to a uniquely
|
||||
* named temp file in the same directory as the resolved target, loop writeSync
|
||||
* until the full payload is on disk, fsync the fd, close, rename over the
|
||||
* resolved target, then fsync the parent directory for crash durability. The
|
||||
* rename is atomic on POSIX and on Windows Vista+ (Node uses
|
||||
* MoveFileExW/MOVEFILE_REPLACE_EXISTING under the hood). A crash mid-write
|
||||
* leaves either the old contents or the new contents — never a truncated file.
|
||||
*
|
||||
* Symlink-safe: POSIX rename(2) replaces the symlink itself rather than the
|
||||
* target file, so a naive rename over a symlinked destination would break the
|
||||
* link. We lstat/realpath up front so the temp file lives next to the real
|
||||
* target and the rename writes through the link.
|
||||
*
|
||||
* Preserves the destination file's mode bits when the file already exists so
|
||||
* we don't accidentally widen permissions on user-owned configs like
|
||||
* ~/.claude/settings.json.
|
||||
*/
|
||||
export function writeJsonFileAtomic(filepath: string, data: any): void {
|
||||
// POSIX rename(2) operates on the symlink itself, so an atomic rename over
|
||||
// a symlinked destination would replace the link rather than writing through
|
||||
// it. Resolve up front so temp + rename both live on the real target's fs.
|
||||
let resolved = filepath;
|
||||
try {
|
||||
if (lstatSync(filepath).isSymbolicLink()) {
|
||||
try {
|
||||
resolved = realpathSync(filepath);
|
||||
} catch (realpathErr) {
|
||||
// Dangling symlink (target missing) — resolve one level manually.
|
||||
const realpathError = realpathErr instanceof Error ? realpathErr : new Error(String(realpathErr));
|
||||
console.warn(`claude-mem: realpathSync failed for ${filepath}, resolving symlink manually:`, realpathError);
|
||||
const linkTarget = readlinkSync(filepath);
|
||||
resolved = resolve(dirname(filepath), linkTarget);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
||||
throw err;
|
||||
}
|
||||
// Destination doesn't exist yet - write directly to the literal path.
|
||||
}
|
||||
|
||||
ensureDirectoryExists(dirname(resolved));
|
||||
|
||||
const dir = dirname(resolved);
|
||||
const base = basename(resolved);
|
||||
const tmpPath = join(dir, `.${base}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
||||
const payload = Buffer.from(JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
|
||||
// Preserve existing mode if the destination already exists; otherwise let
|
||||
// the OS apply the standard new-file default (0o666 minus umask via openSync).
|
||||
let mode: number | undefined;
|
||||
try {
|
||||
mode = statSync(resolved).mode & 0o777;
|
||||
} catch {
|
||||
// File doesn't exist yet — fall through to default mode.
|
||||
}
|
||||
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = mode !== undefined ? openSync(tmpPath, 'w', mode) : openSync(tmpPath, 'w');
|
||||
|
||||
// writeSync wraps POSIX write(2), which may short-write — loop until the
|
||||
// full payload is committed before fsync.
|
||||
let written = 0;
|
||||
while (written < payload.length) {
|
||||
const n = writeSync(fd, payload, written, payload.length - written);
|
||||
if (n === 0) {
|
||||
throw new Error(`writeSync stalled at ${written}/${payload.length} bytes`);
|
||||
}
|
||||
written += n;
|
||||
}
|
||||
|
||||
fsyncSync(fd);
|
||||
closeSync(fd);
|
||||
fd = undefined;
|
||||
renameSync(tmpPath, resolved);
|
||||
|
||||
// fsync the parent directory so the rename's directory-entry change
|
||||
// survives a crash. Best-effort: Windows can't fsync a directory and
|
||||
// some filesystems disallow it — skip silently in those cases.
|
||||
if (!IS_WINDOWS) {
|
||||
let dirFd: number | undefined;
|
||||
try {
|
||||
dirFd = openSync(dir, 'r');
|
||||
fsyncSync(dirFd);
|
||||
} catch (dirSyncErr) {
|
||||
// Best-effort durability.
|
||||
const dirSyncError = dirSyncErr instanceof Error ? dirSyncErr : new Error(String(dirSyncErr));
|
||||
console.warn(`claude-mem: directory fsync failed for ${dir}:`, dirSyncError);
|
||||
} finally {
|
||||
if (dirFd !== undefined) {
|
||||
try { closeSync(dirFd); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (fd !== undefined) {
|
||||
try { closeSync(fd); } catch { /* ignore close-after-error */ }
|
||||
}
|
||||
try { unlinkSync(tmpPath); } catch { /* tempfile may not exist */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
export { readJsonSafe, writeJsonFileAtomic } from '../../utils/json-utils.js';
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from './types.js';
|
||||
import { DEFAULT_PLATFORM_SOURCE, normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
const SQLITE_BUSY_TIMEOUT_MS = 5000;
|
||||
|
||||
export class SessionSearch {
|
||||
private db: Database;
|
||||
|
||||
@@ -28,6 +30,7 @@ export class SessionSearch {
|
||||
this.db = new Database(dbPathOrDb);
|
||||
this.db.run('PRAGMA journal_mode = WAL');
|
||||
}
|
||||
this.db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
||||
|
||||
this._fts5Available = this.isFts5Available();
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import { DEFAULT_PLATFORM_SOURCE, normalizePlatformSource, sortPlatformSources }
|
||||
import { findRecentDuplicateUserPrompt as findRecentDuplicateUserPromptRecord } from './prompts/get.js';
|
||||
import { normalizeStoredPromptText } from './prompt-storage.js';
|
||||
|
||||
const SQLITE_BUSY_TIMEOUT_MS = 5000;
|
||||
|
||||
interface IndexColumnInfo {
|
||||
seqno: number;
|
||||
cid: number;
|
||||
@@ -80,6 +82,7 @@ export class SessionStore {
|
||||
this.db.run('PRAGMA foreign_keys = ON');
|
||||
this.db.run('PRAGMA journal_size_limit = 4194304');
|
||||
}
|
||||
this.db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
||||
|
||||
this.initializeSchema();
|
||||
|
||||
@@ -506,8 +509,6 @@ export class SessionStore {
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_status ON sdk_sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_started ON sdk_sessions(started_at_epoch DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_platform_source ON sdk_sessions(platform_source);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_sdk_sessions_platform_content ON sdk_sessions(platform_source, content_session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { USER_SETTINGS_PATH, DB_PATH } from '../../shared/paths.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import type { DBSession } from '../worker-types.js';
|
||||
|
||||
const SQLITE_BUSY_TIMEOUT_MS = 5000;
|
||||
|
||||
export class DatabaseManager {
|
||||
private db: Database | null = null;
|
||||
private sessionStore: SessionStore | null = null;
|
||||
@@ -16,6 +18,7 @@ export class DatabaseManager {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.db = new Database(DB_PATH);
|
||||
this.db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
||||
|
||||
this.sessionStore = new SessionStore(this.db);
|
||||
this.sessionSearch = new SessionSearch(this.db);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { readFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { HOOK_TIMEOUTS, getTimeout } from './hook-constants.js';
|
||||
import { parseJsonText, writeJsonFileAtomic } from '../utils/json-utils.js';
|
||||
|
||||
export interface SettingsDefaults {
|
||||
CLAUDE_MEM_MODEL: string;
|
||||
@@ -203,7 +204,7 @@ export class SettingsDefaultsManager {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(settingsPath, JSON.stringify(defaults, null, 2), 'utf-8');
|
||||
writeJsonFileAtomic(settingsPath, defaults);
|
||||
// stderr, never stdout: this fires on the first boot in a fresh data
|
||||
// dir, and CLI commands like `start` promise machine-readable JSON
|
||||
// on stdout to the hook framework.
|
||||
@@ -215,17 +216,18 @@ export class SettingsDefaultsManager {
|
||||
}
|
||||
|
||||
const settingsData = readFileSync(settingsPath, 'utf-8');
|
||||
// Strip UTF-8 BOM if present — Windows tools (editors, formatters, CLI
|
||||
// hooks) may prepend U+FEFF which Bun's JSON.parse rejects silently,
|
||||
// causing a full fallback to defaults and breaking server-beta routing.
|
||||
const settings = JSON.parse(settingsData.replace(/^\uFEFF/, ''));
|
||||
const parsedSettings = parseJsonText<unknown>(settingsData);
|
||||
if (!parsedSettings || typeof parsedSettings !== 'object' || Array.isArray(parsedSettings)) {
|
||||
throw new Error('settings.json must contain a JSON object');
|
||||
}
|
||||
const settings = parsedSettings as Record<string, unknown>;
|
||||
|
||||
let flatSettings = settings;
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
flatSettings = settings.env;
|
||||
if (settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env)) {
|
||||
flatSettings = settings.env as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
writeFileSync(settingsPath, JSON.stringify(flatSettings, null, 2), 'utf-8');
|
||||
writeJsonFileAtomic(settingsPath, flatSettings);
|
||||
// stderr, never stdout — same JSON-on-stdout contract as above.
|
||||
console.warn('[SETTINGS] Migrated settings file from nested to flat schema:', settingsPath);
|
||||
} catch (error: unknown) {
|
||||
@@ -236,8 +238,9 @@ export class SettingsDefaultsManager {
|
||||
|
||||
const result: SettingsDefaults = { ...this.DEFAULTS };
|
||||
for (const key of Object.keys(this.DEFAULTS) as Array<keyof SettingsDefaults>) {
|
||||
if (flatSettings[key] !== undefined) {
|
||||
result[key] = flatSettings[key];
|
||||
const value = flatSettings[key];
|
||||
if (typeof value === 'string') {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,112 @@
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeSync,
|
||||
} from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { basename, dirname, join, resolve } from 'path';
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
export function stripJsonBom(contents: string): string {
|
||||
return contents.replace(/^\uFEFF/, '');
|
||||
}
|
||||
|
||||
export function parseJsonText<T>(contents: string): T {
|
||||
return JSON.parse(stripJsonBom(contents)) as T;
|
||||
}
|
||||
|
||||
export function readJsonSafe<T>(filePath: string, defaultValue: T): T {
|
||||
if (!existsSync(filePath)) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return parseJsonText<T>(readFileSync(filePath, 'utf-8'));
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Corrupt JSON file, refusing to overwrite: ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonFileAtomic(filepath: string, data: unknown): void {
|
||||
let resolved = filepath;
|
||||
try {
|
||||
if (lstatSync(filepath).isSymbolicLink()) {
|
||||
try {
|
||||
resolved = realpathSync(filepath);
|
||||
} catch (realpathErr) {
|
||||
const realpathError = realpathErr instanceof Error ? realpathErr : new Error(String(realpathErr));
|
||||
console.warn(`claude-mem: realpathSync failed for ${filepath}, resolving symlink manually:`, realpathError);
|
||||
const linkTarget = readlinkSync(filepath);
|
||||
resolved = resolve(dirname(filepath), linkTarget);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(dirname(resolved), { recursive: true });
|
||||
|
||||
const dir = dirname(resolved);
|
||||
const base = basename(resolved);
|
||||
const tmpPath = join(dir, `.${base}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
||||
const payload = Buffer.from(JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
|
||||
let mode: number | undefined;
|
||||
try {
|
||||
mode = statSync(resolved).mode & 0o777;
|
||||
} catch {
|
||||
// File does not exist yet; use the process umask through openSync.
|
||||
}
|
||||
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = mode !== undefined ? openSync(tmpPath, 'w', mode) : openSync(tmpPath, 'w');
|
||||
|
||||
let written = 0;
|
||||
while (written < payload.length) {
|
||||
const n = writeSync(fd, payload, written, payload.length - written);
|
||||
if (n === 0) {
|
||||
throw new Error(`writeSync stalled at ${written}/${payload.length} bytes`);
|
||||
}
|
||||
written += n;
|
||||
}
|
||||
|
||||
fsyncSync(fd);
|
||||
closeSync(fd);
|
||||
fd = undefined;
|
||||
renameSync(tmpPath, resolved);
|
||||
|
||||
if (!IS_WINDOWS) {
|
||||
let dirFd: number | undefined;
|
||||
try {
|
||||
dirFd = openSync(dir, 'r');
|
||||
fsyncSync(dirFd);
|
||||
} catch (dirSyncErr) {
|
||||
const dirSyncError = dirSyncErr instanceof Error ? dirSyncErr : new Error(String(dirSyncErr));
|
||||
console.warn(`claude-mem: directory fsync failed for ${dir}:`, dirSyncError);
|
||||
} finally {
|
||||
if (dirFd !== undefined) {
|
||||
try { closeSync(dirFd); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (fd !== undefined) {
|
||||
try { closeSync(fd); } catch { /* ignore close-after-error */ }
|
||||
}
|
||||
try { unlinkSync(tmpPath); } catch { /* tempfile may not exist */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { homedir } from 'os'
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import { logger } from './logger.js';
|
||||
import { detectWorktree } from './worktree.js';
|
||||
|
||||
const PROJECT_MANIFESTS = [
|
||||
'package.json',
|
||||
'pyproject.toml',
|
||||
'Cargo.toml',
|
||||
'go.mod',
|
||||
'deno.json',
|
||||
'deno.jsonc',
|
||||
'composer.json',
|
||||
'Gemfile',
|
||||
'mix.exs',
|
||||
];
|
||||
|
||||
function expandTilde(p: string): string {
|
||||
if (p === '~' || p.startsWith('~/')) {
|
||||
return p.replace(/^~/, homedir())
|
||||
@@ -34,6 +47,68 @@ function findGitRepoRoot(dir: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate: string, root: string): boolean {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function findNearestManifestDir(cwd: string, repoRoot: string): string | null {
|
||||
let current = path.resolve(cwd);
|
||||
const root = path.resolve(repoRoot);
|
||||
|
||||
while (isPathInsideOrEqual(current, root) && current !== root) {
|
||||
if (PROJECT_MANIFESTS.some(manifest => existsSync(path.join(current, manifest)))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRepoProjectKey(repoRoot: string, cwd: string): string {
|
||||
const root = path.resolve(repoRoot);
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
const repoName = path.basename(root);
|
||||
|
||||
if (!repoName || !isPathInsideOrEqual(resolvedCwd, root)) {
|
||||
return repoName;
|
||||
}
|
||||
|
||||
const relativeCwd = path.relative(root, resolvedCwd);
|
||||
if (relativeCwd === '') {
|
||||
return repoName;
|
||||
}
|
||||
|
||||
const nearestManifestDir = findNearestManifestDir(resolvedCwd, root);
|
||||
const firstSegment = relativeCwd.split(path.sep).filter(Boolean)[0];
|
||||
const boundary = nearestManifestDir ?? (firstSegment ? path.join(root, firstSegment) : root);
|
||||
const relativeBoundary = path.relative(root, boundary).split(path.sep).join('/');
|
||||
|
||||
return relativeBoundary ? `${repoName}/${relativeBoundary}` : repoName;
|
||||
}
|
||||
|
||||
function findNearestGitContextRoot(dir: string): string | null {
|
||||
let current = path.resolve(dir);
|
||||
|
||||
while (true) {
|
||||
const gitPath = path.join(current, '.git');
|
||||
try {
|
||||
if (statSync(gitPath).isFile()) {
|
||||
return current;
|
||||
}
|
||||
} catch {
|
||||
// Keep walking upward until a gitdir file or filesystem root is found.
|
||||
}
|
||||
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectName(cwd: string | null | undefined): string {
|
||||
if (!cwd || cwd.trim() === '') {
|
||||
logger.warn('PROJECT_NAME', 'Empty cwd provided, using fallback', { cwd });
|
||||
@@ -46,9 +121,11 @@ export function getProjectName(cwd: string | null | undefined): string {
|
||||
// the name is stable across subdirectories/worktrees. Fall back to the cwd
|
||||
// basename when not in a repo.
|
||||
const repoRoot = findGitRepoRoot(expanded);
|
||||
const nameSource = repoRoot ?? expanded;
|
||||
if (repoRoot) {
|
||||
return getRepoProjectKey(repoRoot, expanded);
|
||||
}
|
||||
|
||||
const basename = path.basename(nameSource);
|
||||
const basename = path.basename(expanded);
|
||||
|
||||
if (basename === '') {
|
||||
const isWindows = process.platform === 'win32';
|
||||
@@ -76,17 +153,22 @@ export interface ProjectContext {
|
||||
}
|
||||
|
||||
export function getProjectContext(cwd: string | null | undefined): ProjectContext {
|
||||
const cwdProjectName = getProjectName(cwd);
|
||||
|
||||
if (!cwd) {
|
||||
const cwdProjectName = getProjectName(cwd);
|
||||
return { primary: cwdProjectName, parent: null, isWorktree: false, allProjects: [cwdProjectName] };
|
||||
}
|
||||
|
||||
const expandedCwd = expandTilde(cwd);
|
||||
const worktreeInfo = detectWorktree(expandedCwd);
|
||||
const repoRoot = findGitRepoRoot(expandedCwd);
|
||||
const contextRoot = findNearestGitContextRoot(expandedCwd);
|
||||
const validContextRoot = contextRoot && (!repoRoot || isPathInsideOrEqual(contextRoot, repoRoot))
|
||||
? contextRoot
|
||||
: null;
|
||||
const worktreeInfo = detectWorktree(validContextRoot ?? repoRoot ?? expandedCwd);
|
||||
|
||||
if (worktreeInfo.isWorktree && worktreeInfo.parentProjectName) {
|
||||
const composite = `${worktreeInfo.parentProjectName}/${cwdProjectName}`;
|
||||
const worktreeName = worktreeInfo.worktreeName ?? path.basename(validContextRoot ?? expandedCwd);
|
||||
const composite = `${worktreeInfo.parentProjectName}/${worktreeName}`;
|
||||
return {
|
||||
primary: composite,
|
||||
parent: worktreeInfo.parentProjectName,
|
||||
@@ -95,5 +177,16 @@ export function getProjectContext(cwd: string | null | undefined): ProjectContex
|
||||
};
|
||||
}
|
||||
|
||||
if (worktreeInfo.isSubmodule && worktreeInfo.parentProjectName) {
|
||||
return {
|
||||
primary: worktreeInfo.parentProjectName,
|
||||
parent: null,
|
||||
isWorktree: false,
|
||||
allProjects: [worktreeInfo.parentProjectName]
|
||||
};
|
||||
}
|
||||
|
||||
const cwdProjectName = getProjectName(cwd);
|
||||
|
||||
return { primary: cwdProjectName, parent: null, isWorktree: false, allProjects: [cwdProjectName] };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { logger } from './logger.js';
|
||||
|
||||
export interface WorktreeInfo {
|
||||
isWorktree: boolean;
|
||||
isSubmodule: boolean;
|
||||
kind: 'worktree' | 'submodule' | null;
|
||||
worktreeName: string | null;
|
||||
parentRepoPath: string | null;
|
||||
parentProjectName: string | null;
|
||||
@@ -12,6 +14,8 @@ export interface WorktreeInfo {
|
||||
|
||||
const NOT_A_WORKTREE: WorktreeInfo = {
|
||||
isWorktree: false,
|
||||
isSubmodule: false,
|
||||
kind: null,
|
||||
worktreeName: null,
|
||||
parentRepoPath: null,
|
||||
parentProjectName: null
|
||||
@@ -47,21 +51,36 @@ export function detectWorktree(cwd: string): WorktreeInfo {
|
||||
return NOT_A_WORKTREE;
|
||||
}
|
||||
|
||||
const gitdirPath = match[1];
|
||||
const gitdirPath = path.resolve(path.dirname(gitPath), match[1]);
|
||||
|
||||
const worktreesMatch = gitdirPath.match(/^(.+)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)$/);
|
||||
if (!worktreesMatch) {
|
||||
return NOT_A_WORKTREE;
|
||||
if (worktreesMatch) {
|
||||
const parentRepoPath = worktreesMatch[1];
|
||||
const worktreeName = path.basename(cwd);
|
||||
const parentProjectName = path.basename(parentRepoPath);
|
||||
|
||||
return {
|
||||
isWorktree: true,
|
||||
isSubmodule: false,
|
||||
kind: 'worktree',
|
||||
worktreeName,
|
||||
parentRepoPath,
|
||||
parentProjectName
|
||||
};
|
||||
}
|
||||
|
||||
const parentRepoPath = worktreesMatch[1];
|
||||
const worktreeName = path.basename(cwd);
|
||||
const parentProjectName = path.basename(parentRepoPath);
|
||||
const modulesMatch = gitdirPath.match(/^(.+)[/\\]\.git[/\\]modules[/\\].+$/);
|
||||
if (modulesMatch) {
|
||||
const parentRepoPath = modulesMatch[1];
|
||||
return {
|
||||
isWorktree: false,
|
||||
isSubmodule: true,
|
||||
kind: 'submodule',
|
||||
worktreeName: path.basename(cwd),
|
||||
parentRepoPath,
|
||||
parentProjectName: path.basename(parentRepoPath)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isWorktree: true,
|
||||
worktreeName,
|
||||
parentRepoPath,
|
||||
parentProjectName
|
||||
};
|
||||
return NOT_A_WORKTREE;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,24 @@ describe('JSON Utils', () => {
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('reads JSON objects with a UTF-8 BOM', () => {
|
||||
const filePath = join(tempDir, 'bom-object.json');
|
||||
writeFileSync(filePath, '\uFEFF' + JSON.stringify({ ok: true }));
|
||||
|
||||
const result = readJsonSafe(filePath, {});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('reads JSON arrays with a UTF-8 BOM', () => {
|
||||
const filePath = join(tempDir, 'bom-array.json');
|
||||
writeFileSync(filePath, '\uFEFF' + JSON.stringify(['a', 'b']));
|
||||
|
||||
const result = readJsonSafe<string[]>(filePath, []);
|
||||
|
||||
expect(result).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('reads deeply nested JSON correctly', () => {
|
||||
const filePath = join(tempDir, 'nested.json');
|
||||
const deepData = {
|
||||
|
||||
39
tests/services/sqlite/database-pragmas.test.ts
Normal file
39
tests/services/sqlite/database-pragmas.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
|
||||
import { SessionSearch } from '../../../src/services/sqlite/SessionSearch.js';
|
||||
|
||||
function busyTimeout(db: Database): number {
|
||||
return (db.query('PRAGMA busy_timeout').get() as { timeout: number }).timeout;
|
||||
}
|
||||
|
||||
describe('SQLite connection pragmas', () => {
|
||||
it('sets busy_timeout on SessionStore-owned connections', () => {
|
||||
const store = new SessionStore(':memory:');
|
||||
try {
|
||||
expect(busyTimeout(store.db)).toBe(5000);
|
||||
} finally {
|
||||
store.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets busy_timeout on existing connections handed to SessionStore', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
new SessionStore(db);
|
||||
expect(busyTimeout(db)).toBe(5000);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets busy_timeout on existing connections handed to SessionSearch', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
new SessionSearch(db);
|
||||
expect(busyTimeout(db)).toBe(5000);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { SettingsDefaultsManager } from '../../src/shared/SettingsDefaultsManager.js';
|
||||
@@ -54,6 +54,13 @@ describe('SettingsDefaultsManager', () => {
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not leave a temp file after creating defaults', () => {
|
||||
SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
const leftovers = readdirSync(tempDir).filter(name => name.includes('.settings.json.') && name.endsWith('.tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should write pretty-printed JSON (2-space indent)', () => {
|
||||
SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
@@ -163,11 +170,13 @@ describe('SettingsDefaultsManager', () => {
|
||||
});
|
||||
|
||||
it('should return defaults when file contains invalid JSON', () => {
|
||||
writeFileSync(settingsPath, 'not valid json {{{{');
|
||||
const corrupt = 'not valid json {{{{';
|
||||
writeFileSync(settingsPath, corrupt);
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(corrupt);
|
||||
});
|
||||
|
||||
it('should return defaults when file contains only whitespace', () => {
|
||||
@@ -234,6 +243,16 @@ describe('SettingsDefaultsManager', () => {
|
||||
expect(parsed.env).toBeUndefined();
|
||||
expect(parsed.CLAUDE_MEM_MODEL).toBe('migrated-model');
|
||||
});
|
||||
|
||||
it('ignores stale temp files from an interrupted settings write', () => {
|
||||
const settings = { CLAUDE_MEM_MODEL: 'stable-model' };
|
||||
writeFileSync(settingsPath, JSON.stringify(settings));
|
||||
writeFileSync(join(tempDir, '.settings.json.999999.deadbeef.tmp'), '{"CLAUDE_MEM_MODEL":');
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('stable-model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
@@ -266,7 +285,7 @@ describe('SettingsDefaultsManager', () => {
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('bom-model');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,6 +395,33 @@ describe('SessionStore migrations', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('migrates a v23 legacy DB missing platform_source through current composite identity', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
seedLegacyGlobalContentIdentityScenario(db);
|
||||
db.run('ALTER TABLE sdk_sessions DROP COLUMN platform_source');
|
||||
db.run('DELETE FROM schema_versions WHERE version >= 24');
|
||||
|
||||
const migrated = new SessionStore(db);
|
||||
|
||||
const session = db.prepare(`
|
||||
SELECT id, platform_source
|
||||
FROM sdk_sessions
|
||||
WHERE content_session_id = 'shared-raw-id'
|
||||
`).get() as { id: number; platform_source: string } | undefined;
|
||||
|
||||
expect(session).toEqual({ id: 101, platform_source: 'claude' });
|
||||
expect(hasUniqueIndexOnColumns(db, 'sdk_sessions', ['content_session_id'])).toBe(false);
|
||||
expect(hasUniqueIndexOnColumns(db, 'sdk_sessions', ['platform_source', 'content_session_id'])).toBe(true);
|
||||
expect((db.prepare('SELECT session_db_id FROM user_prompts WHERE content_session_id = ?').get('shared-raw-id') as { session_db_id: number }).session_db_id).toBe(101);
|
||||
|
||||
const cursorId = migrated.createSDKSession('shared-raw-id', 'cursor-project', 'cursor prompt', undefined, 'cursor');
|
||||
expect(cursorId).not.toBe(101);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the dead pending_messages columns (retry_count / failed_at_epoch / completed_at_epoch / worker_pid) on a legacy db', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
|
||||
@@ -58,9 +58,11 @@ describe('getProjectName', () => {
|
||||
let tmp: string;
|
||||
let repoRoot: string;
|
||||
let nestedDir: string;
|
||||
let packageDir: string;
|
||||
let packageSrcDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { mkdtempSync, mkdirSync, realpathSync } = await import('fs');
|
||||
const { mkdtempSync, mkdirSync, realpathSync, writeFileSync } = await import('fs');
|
||||
const { execFileSync } = await import('child_process');
|
||||
const { join } = await import('path');
|
||||
const { tmpdir } = await import('os');
|
||||
@@ -70,7 +72,11 @@ describe('getProjectName', () => {
|
||||
tmp = realpathSync(mkdtempSync(join(tmpdir(), 'cm-reporoot-')));
|
||||
repoRoot = join(tmp, 'my-real-repo');
|
||||
nestedDir = join(repoRoot, 'packages', 'deeply', 'nested');
|
||||
packageDir = join(repoRoot, 'packages', 'api');
|
||||
packageSrcDir = join(packageDir, 'src', 'routes');
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
mkdirSync(packageSrcDir, { recursive: true });
|
||||
writeFileSync(join(packageDir, 'package.json'), '{"name":"api"}');
|
||||
execFileSync('git', ['init', '-q'], { cwd: repoRoot });
|
||||
});
|
||||
|
||||
@@ -79,8 +85,12 @@ describe('getProjectName', () => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('deep subdirectory inside a repo yields the repo-root name', () => {
|
||||
expect(getProjectName(nestedDir)).toBe('my-real-repo');
|
||||
it('manifest-less subdirectory inside a repo yields a repo-relative first-segment key', () => {
|
||||
expect(getProjectName(nestedDir)).toBe('my-real-repo/packages');
|
||||
});
|
||||
|
||||
it('nested monorepo package yields a repo-relative package key', () => {
|
||||
expect(getProjectName(packageSrcDir)).toBe('my-real-repo/packages/api');
|
||||
});
|
||||
|
||||
it('repo root itself yields the repo-root name', () => {
|
||||
@@ -135,6 +145,7 @@ describe('getProjectContext', () => {
|
||||
let tmp: string;
|
||||
let mainRepo: string;
|
||||
let worktreeCheckout: string;
|
||||
let nestedWorktreeDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { mkdtempSync, mkdirSync, writeFileSync } = await import('fs');
|
||||
@@ -145,9 +156,10 @@ describe('getProjectContext', () => {
|
||||
mainRepo = join(tmp, 'main-repo');
|
||||
const worktreeGitDir = join(mainRepo, '.git', 'worktrees', 'my-worktree');
|
||||
worktreeCheckout = join(tmp, 'my-worktree');
|
||||
nestedWorktreeDir = join(worktreeCheckout, 'packages', 'api', 'src');
|
||||
|
||||
mkdirSync(worktreeGitDir, { recursive: true });
|
||||
mkdirSync(worktreeCheckout, { recursive: true });
|
||||
mkdirSync(nestedWorktreeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(worktreeCheckout, '.git'),
|
||||
`gitdir: ${worktreeGitDir}\n`
|
||||
@@ -173,5 +185,56 @@ describe('getProjectContext', () => {
|
||||
expect(project).not.toBe('main-repo');
|
||||
expect(project).not.toBe('my-worktree');
|
||||
});
|
||||
|
||||
it('nested directories inside a worktree keep the parent/worktree project key', () => {
|
||||
const ctx = getProjectContext(nestedWorktreeDir);
|
||||
expect(ctx.isWorktree).toBe(true);
|
||||
expect(ctx.primary).toBe('main-repo/my-worktree');
|
||||
expect(ctx.allProjects).toEqual(['main-repo', 'main-repo/my-worktree']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submodule parent context', () => {
|
||||
let tmp: string;
|
||||
let mainRepo: string;
|
||||
let submoduleRoot: string;
|
||||
let nestedSubmoduleDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { mkdtempSync, mkdirSync, writeFileSync } = await import('fs');
|
||||
const { join } = await import('path');
|
||||
const { tmpdir } = await import('os');
|
||||
|
||||
tmp = mkdtempSync(join(tmpdir(), 'cm-submodule-'));
|
||||
mainRepo = join(tmp, 'main-repo');
|
||||
submoduleRoot = join(mainRepo, 'libs', 'dep');
|
||||
nestedSubmoduleDir = join(submoduleRoot, 'src', 'deep');
|
||||
|
||||
mkdirSync(join(mainRepo, '.git', 'modules', 'libs', 'dep'), { recursive: true });
|
||||
mkdirSync(nestedSubmoduleDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(submoduleRoot, '.git'),
|
||||
'gitdir: ../../.git/modules/libs/dep\n'
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const { rmSync } = await import('fs');
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses the parent repo project for a submodule root', () => {
|
||||
const ctx = getProjectContext(submoduleRoot);
|
||||
expect(ctx.primary).toBe('main-repo');
|
||||
expect(ctx.parent).toBeNull();
|
||||
expect(ctx.isWorktree).toBe(false);
|
||||
expect(ctx.allProjects).toEqual(['main-repo']);
|
||||
});
|
||||
|
||||
it('uses the parent repo project for nested submodule directories', () => {
|
||||
const ctx = getProjectContext(nestedSubmoduleDir);
|
||||
expect(ctx.primary).toBe('main-repo');
|
||||
expect(ctx.allProjects).toEqual(['main-repo']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user