dave-code 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/bin/aiClient.js +658 -164
- package/bin/cliMenu.js +127 -185
- package/bin/commandRouter.js +50 -0
- package/bin/configManager.js +85 -20
- package/bin/contextManager.js +151 -0
- package/bin/index.js +809 -449
- package/bin/planManager.js +239 -0
- package/bin/runtimeEvents.js +62 -0
- package/bin/sessionManager.js +166 -0
- package/bin/terminalRenderer.js +283 -0
- package/bin/toolRuntime.js +1052 -0
- package/package.json +5 -2
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const STORE_VERSION = 1;
|
|
7
|
+
const VALID_STATUSES = new Set(['ready', 'running', 'completed', 'blocked']);
|
|
8
|
+
let plansBaseDir = path.join(os.homedir(), '.dave-code-plans');
|
|
9
|
+
|
|
10
|
+
export function setPlansBaseDirForTesting(directory) {
|
|
11
|
+
plansBaseDir = directory;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function canonicalWorkspaceRoot(workspaceRoot) {
|
|
15
|
+
const resolved = path.resolve(workspaceRoot || process.cwd());
|
|
16
|
+
let canonical = resolved;
|
|
17
|
+
try {
|
|
18
|
+
canonical = fs.realpathSync.native(resolved);
|
|
19
|
+
} catch {
|
|
20
|
+
// A workspace is validated by the caller. Keep the resolved value for diagnostics.
|
|
21
|
+
}
|
|
22
|
+
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function workspaceKey(workspaceRoot) {
|
|
26
|
+
return crypto.createHash('sha256').update(canonicalWorkspaceRoot(workspaceRoot)).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getPlansFile(workspaceRoot) {
|
|
30
|
+
return path.join(plansBaseDir, `${workspaceKey(workspaceRoot)}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensurePlansDir() {
|
|
34
|
+
fs.mkdirSync(plansBaseDir, { recursive: true, mode: 0o700 });
|
|
35
|
+
try {
|
|
36
|
+
fs.chmodSync(plansBaseDir, 0o700);
|
|
37
|
+
} catch {
|
|
38
|
+
// Windows does not implement POSIX modes in the same way.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeName(name) {
|
|
43
|
+
return String(name || '').trim().toLocaleLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validatePlanName(name) {
|
|
47
|
+
const value = String(name || '').trim();
|
|
48
|
+
if (!value) throw new Error('Plan name is required.');
|
|
49
|
+
if ([...value].length > 60) throw new Error('Plan name must be 60 characters or fewer.');
|
|
50
|
+
if (value.includes(':') || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
51
|
+
throw new Error('Plan name cannot contain a colon or control characters.');
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validatePlanContent(content) {
|
|
57
|
+
const text = String(content || '');
|
|
58
|
+
const checks = [
|
|
59
|
+
{ label: 'goal and acceptance criteria', pattern: /目标|验收|goal|acceptance/i },
|
|
60
|
+
{ label: 'verified current-state facts', pattern: /当前|现状|事实|current state|verified facts/i },
|
|
61
|
+
{ label: 'ordered implementation steps', pattern: /步骤|step\s*\d|implementation steps/i },
|
|
62
|
+
{ label: 'what and how details', pattern: /做什么|怎么做|具体实现|what|how|implementation/i },
|
|
63
|
+
{ label: 'validation', pattern: /验证|测试|validation|test plan/i },
|
|
64
|
+
{ label: 'risks and rollback', pattern: /风险|回退|回滚|risk|rollback/i }
|
|
65
|
+
];
|
|
66
|
+
return checks.filter(check => !check.pattern.test(text)).map(check => check.label);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateStoredPlan(plan) {
|
|
70
|
+
if (!plan || typeof plan !== 'object') return null;
|
|
71
|
+
try {
|
|
72
|
+
const name = validatePlanName(plan.name);
|
|
73
|
+
const status = VALID_STATUSES.has(plan.status) ? plan.status : 'ready';
|
|
74
|
+
return {
|
|
75
|
+
id: String(plan.id || crypto.randomUUID()),
|
|
76
|
+
name,
|
|
77
|
+
status,
|
|
78
|
+
request: String(plan.request || ''),
|
|
79
|
+
content: String(plan.content || ''),
|
|
80
|
+
createdAt: Number(plan.createdAt) || Date.now(),
|
|
81
|
+
updatedAt: Number(plan.updatedAt) || Date.now(),
|
|
82
|
+
lastRun: plan.lastRun && typeof plan.lastRun === 'object' ? plan.lastRun : null
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function emptyStore(workspaceRoot) {
|
|
90
|
+
return {
|
|
91
|
+
version: STORE_VERSION,
|
|
92
|
+
workspaceRoot: canonicalWorkspaceRoot(workspaceRoot),
|
|
93
|
+
plans: []
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function loadPlanStore(workspaceRoot) {
|
|
98
|
+
const filePath = getPlansFile(workspaceRoot);
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
101
|
+
if (!parsed || parsed.version !== STORE_VERSION || !Array.isArray(parsed.plans)) {
|
|
102
|
+
return emptyStore(workspaceRoot);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
version: STORE_VERSION,
|
|
106
|
+
workspaceRoot: canonicalWorkspaceRoot(workspaceRoot),
|
|
107
|
+
plans: parsed.plans.map(validateStoredPlan).filter(Boolean)
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return emptyStore(workspaceRoot);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function atomicWriteJson(filePath, value) {
|
|
115
|
+
ensurePlansDir();
|
|
116
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
117
|
+
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
118
|
+
try {
|
|
119
|
+
fs.chmodSync(tempPath, 0o600);
|
|
120
|
+
} catch {
|
|
121
|
+
// Best effort on platforms without POSIX permissions.
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
fs.renameSync(tempPath, filePath);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (process.platform !== 'win32' || !fs.existsSync(filePath)) throw error;
|
|
127
|
+
fs.unlinkSync(filePath);
|
|
128
|
+
fs.renameSync(tempPath, filePath);
|
|
129
|
+
} finally {
|
|
130
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function savePlanStore(workspaceRoot, store) {
|
|
135
|
+
const normalized = {
|
|
136
|
+
version: STORE_VERSION,
|
|
137
|
+
workspaceRoot: canonicalWorkspaceRoot(workspaceRoot),
|
|
138
|
+
plans: (store.plans || []).map(validateStoredPlan).filter(Boolean)
|
|
139
|
+
};
|
|
140
|
+
atomicWriteJson(getPlansFile(workspaceRoot), normalized);
|
|
141
|
+
return normalized;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function listPlans(workspaceRoot) {
|
|
145
|
+
return loadPlanStore(workspaceRoot).plans.sort((a, b) => {
|
|
146
|
+
const rank = status => status === 'running' ? 0 : status === 'ready' ? 1 : status === 'blocked' ? 2 : 3;
|
|
147
|
+
return rank(a.status) - rank(b.status) || b.updatedAt - a.updatedAt;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function findPlan(workspaceRoot, name) {
|
|
152
|
+
const key = normalizeName(name);
|
|
153
|
+
return listPlans(workspaceRoot).find(plan => normalizeName(plan.name) === key) || null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function upsertPlan(workspaceRoot, { name, request, content }, { replace = false } = {}) {
|
|
157
|
+
const validName = validatePlanName(name);
|
|
158
|
+
const validContent = String(content || '').trim();
|
|
159
|
+
const missingSections = validatePlanContent(validContent);
|
|
160
|
+
if (missingSections.length > 0) throw new Error(`Plan content is incomplete: missing ${missingSections.join(', ')}.`);
|
|
161
|
+
const store = loadPlanStore(workspaceRoot);
|
|
162
|
+
const index = store.plans.findIndex(plan => normalizeName(plan.name) === normalizeName(validName));
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
if (index !== -1 && !replace) throw new Error(`Plan already exists: ${store.plans[index].name}`);
|
|
165
|
+
|
|
166
|
+
const next = index === -1
|
|
167
|
+
? {
|
|
168
|
+
id: crypto.randomUUID(),
|
|
169
|
+
name: validName,
|
|
170
|
+
status: 'ready',
|
|
171
|
+
request: String(request || '').trim(),
|
|
172
|
+
content: validContent,
|
|
173
|
+
createdAt: now,
|
|
174
|
+
updatedAt: now,
|
|
175
|
+
lastRun: null
|
|
176
|
+
}
|
|
177
|
+
: {
|
|
178
|
+
...store.plans[index],
|
|
179
|
+
name: validName,
|
|
180
|
+
status: 'ready',
|
|
181
|
+
request: String(request || '').trim(),
|
|
182
|
+
content: validContent,
|
|
183
|
+
updatedAt: now,
|
|
184
|
+
lastRun: null
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (index === -1) store.plans.push(next);
|
|
188
|
+
else store.plans[index] = next;
|
|
189
|
+
savePlanStore(workspaceRoot, store);
|
|
190
|
+
return next;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function updatePlan(workspaceRoot, planId, updates = {}) {
|
|
194
|
+
const store = loadPlanStore(workspaceRoot);
|
|
195
|
+
const index = store.plans.findIndex(plan => plan.id === planId);
|
|
196
|
+
if (index === -1) return null;
|
|
197
|
+
const nextStatus = updates.status === undefined ? store.plans[index].status : updates.status;
|
|
198
|
+
if (!VALID_STATUSES.has(nextStatus)) throw new Error(`Invalid plan status: ${nextStatus}`);
|
|
199
|
+
store.plans[index] = {
|
|
200
|
+
...store.plans[index],
|
|
201
|
+
...updates,
|
|
202
|
+
id: store.plans[index].id,
|
|
203
|
+
name: updates.name === undefined ? store.plans[index].name : validatePlanName(updates.name),
|
|
204
|
+
status: nextStatus,
|
|
205
|
+
updatedAt: Date.now()
|
|
206
|
+
};
|
|
207
|
+
savePlanStore(workspaceRoot, store);
|
|
208
|
+
return store.plans[index];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function renamePlan(workspaceRoot, planId, nextName) {
|
|
212
|
+
const validName = validatePlanName(nextName);
|
|
213
|
+
const store = loadPlanStore(workspaceRoot);
|
|
214
|
+
if (store.plans.some(plan => plan.id !== planId && normalizeName(plan.name) === normalizeName(validName))) {
|
|
215
|
+
throw new Error(`Plan already exists: ${validName}`);
|
|
216
|
+
}
|
|
217
|
+
const index = store.plans.findIndex(plan => plan.id === planId);
|
|
218
|
+
if (index === -1) return null;
|
|
219
|
+
store.plans[index] = { ...store.plans[index], name: validName, updatedAt: Date.now() };
|
|
220
|
+
savePlanStore(workspaceRoot, store);
|
|
221
|
+
return store.plans[index];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function deletePlan(workspaceRoot, planId) {
|
|
225
|
+
const store = loadPlanStore(workspaceRoot);
|
|
226
|
+
const next = store.plans.filter(plan => plan.id !== planId);
|
|
227
|
+
if (next.length === store.plans.length) return false;
|
|
228
|
+
store.plans = next;
|
|
229
|
+
savePlanStore(workspaceRoot, store);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function planStatusLines(workspaceRoot, limit = 4) {
|
|
234
|
+
const plans = listPlans(workspaceRoot);
|
|
235
|
+
const symbols = { ready: '○', running: '▶', completed: '✓', blocked: '!' };
|
|
236
|
+
const lines = plans.slice(0, limit).map(plan => `${symbols[plan.status] || '○'} ${plan.name}`);
|
|
237
|
+
if (plans.length > limit) lines.push(`+${plans.length - limit} more`);
|
|
238
|
+
return lines;
|
|
239
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
|
|
3
|
+
export const RUNTIME_EVENT_TYPES = new Set([
|
|
4
|
+
'turn.started',
|
|
5
|
+
'turn.completed',
|
|
6
|
+
'turn.failed',
|
|
7
|
+
'turn.cancelled',
|
|
8
|
+
'model.started',
|
|
9
|
+
'model.delta',
|
|
10
|
+
'model.tool_call',
|
|
11
|
+
'model.completed',
|
|
12
|
+
'model.retry',
|
|
13
|
+
'tool.requested',
|
|
14
|
+
'tool.started',
|
|
15
|
+
'tool.progress',
|
|
16
|
+
'tool.completed',
|
|
17
|
+
'tool.failed',
|
|
18
|
+
'permission.requested',
|
|
19
|
+
'permission.resolved',
|
|
20
|
+
'context.compacted'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export function createRuntimeEvents({ turnId, now = Date.now } = {}) {
|
|
24
|
+
const emitter = new EventEmitter();
|
|
25
|
+
const resolvedTurnId = turnId || `turn-${now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
26
|
+
let seq = 0;
|
|
27
|
+
let closed = false;
|
|
28
|
+
|
|
29
|
+
function emit(type, data = {}) {
|
|
30
|
+
if (closed) return null;
|
|
31
|
+
if (!RUNTIME_EVENT_TYPES.has(type)) {
|
|
32
|
+
throw new Error(`Unknown runtime event type: ${type}`);
|
|
33
|
+
}
|
|
34
|
+
const event = {
|
|
35
|
+
version: 1,
|
|
36
|
+
seq: ++seq,
|
|
37
|
+
type,
|
|
38
|
+
turnId: resolvedTurnId,
|
|
39
|
+
timestamp: now(),
|
|
40
|
+
data: data && typeof data === 'object' ? data : { value: data }
|
|
41
|
+
};
|
|
42
|
+
emitter.emit('event', event);
|
|
43
|
+
return event;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function subscribe(listener) {
|
|
47
|
+
emitter.on('event', listener);
|
|
48
|
+
return () => emitter.off('event', listener);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function close() {
|
|
52
|
+
closed = true;
|
|
53
|
+
emitter.removeAllListeners();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
turnId: resolvedTurnId,
|
|
58
|
+
emit,
|
|
59
|
+
subscribe,
|
|
60
|
+
close
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── DESIGN PHILOSOPHY / 视觉设计与交互规范 ──
|
|
3
|
+
* 为了向用户提供极致、顺滑的交互体验,Dave Code 规定:
|
|
4
|
+
* 1. 所有耗时的 API 请求、大文件处理和本地工具调用,都必须具有动画化(Animated)或动态(Dynamic)的终端 UI。
|
|
5
|
+
* 2. 严禁使用静态、刷屏式的连续 Log 输出。对于多步骤操作(如分片消化),应使用单行原地更新(In-place update)的进度条和加载动画。
|
|
6
|
+
* 3. 使用富文本终端色彩(如精心调配的 HSL/ANSI 颜色)和微型字符动画(如 Spinner)来提供实时的视觉反馈。
|
|
7
|
+
* 后续维护与功能续写时,必须严格遵守此设计理念。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
export let SESSIONS_DIR = path.join(os.homedir(), '.dave-code-sessions');
|
|
16
|
+
|
|
17
|
+
export function setSessionsDirForTesting(directory) {
|
|
18
|
+
SESSIONS_DIR = directory;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeMessage(message) {
|
|
22
|
+
if (!message || typeof message !== 'object') return null;
|
|
23
|
+
if (!['user', 'assistant', 'tool'].includes(message.role)) return null;
|
|
24
|
+
if (typeof message.content !== 'string') return null;
|
|
25
|
+
return { ...message, role: message.role, content: message.content };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeSession(session) {
|
|
29
|
+
if (!session || typeof session !== 'object' || !session.id || !Array.isArray(session.messages)) return null;
|
|
30
|
+
if (!/^[A-Za-z0-9_-]+$/.test(String(session.id))) return null;
|
|
31
|
+
const messages = session.messages.map(normalizeMessage).filter(Boolean);
|
|
32
|
+
return {
|
|
33
|
+
...session,
|
|
34
|
+
id: String(session.id),
|
|
35
|
+
title: String(session.title || 'New Chat'),
|
|
36
|
+
timestamp: Number(session.timestamp) || Date.now(),
|
|
37
|
+
messages,
|
|
38
|
+
workspaceRoot: typeof session.workspaceRoot === 'string' ? session.workspaceRoot : null,
|
|
39
|
+
activeOpenFile: typeof session.activeOpenFile === 'string' ? session.activeOpenFile : null
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function atomicWriteSession(filePath, sessionData) {
|
|
44
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`;
|
|
45
|
+
fs.writeFileSync(tempPath, JSON.stringify(sessionData, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
46
|
+
try {
|
|
47
|
+
fs.chmodSync(tempPath, 0o600);
|
|
48
|
+
} catch {
|
|
49
|
+
// Best effort on Windows.
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
fs.renameSync(tempPath, filePath);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (process.platform !== 'win32' || !fs.existsSync(filePath)) throw error;
|
|
55
|
+
fs.unlinkSync(filePath);
|
|
56
|
+
fs.renameSync(tempPath, filePath);
|
|
57
|
+
} finally {
|
|
58
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ensureSessionsDir() {
|
|
63
|
+
try {
|
|
64
|
+
if (!fs.existsSync(SESSIONS_DIR)) {
|
|
65
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
fs.chmodSync(SESSIONS_DIR, 0o700);
|
|
69
|
+
} catch {
|
|
70
|
+
// Best effort on Windows.
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Ignore folder creation errors (handled gracefully during saves)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* List all saved chat sessions sorted by timestamp descending (newest first).
|
|
79
|
+
* @returns {Array} Array of session objects
|
|
80
|
+
*/
|
|
81
|
+
export function listSessions() {
|
|
82
|
+
ensureSessionsDir();
|
|
83
|
+
try {
|
|
84
|
+
if (!fs.existsSync(SESSIONS_DIR)) return [];
|
|
85
|
+
|
|
86
|
+
const files = fs.readdirSync(SESSIONS_DIR);
|
|
87
|
+
const sessions = [];
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
if (file.endsWith('.json')) {
|
|
90
|
+
try {
|
|
91
|
+
const filePath = path.join(SESSIONS_DIR, file);
|
|
92
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
93
|
+
const session = normalizeSession(JSON.parse(content));
|
|
94
|
+
if (session && session.id) {
|
|
95
|
+
sessions.push(session);
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// Skip invalid JSON files
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return sessions.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Save or update a session file.
|
|
110
|
+
* @param {string} sessionId Unique session identifier
|
|
111
|
+
* @param {object} sessionData Session object containing messages, title, timestamp, and workspace metadata
|
|
112
|
+
*/
|
|
113
|
+
export function saveSession(sessionId, sessionData) {
|
|
114
|
+
ensureSessionsDir();
|
|
115
|
+
try {
|
|
116
|
+
const safeId = String(sessionId || '');
|
|
117
|
+
if (!/^[A-Za-z0-9_-]+$/.test(safeId)) return false;
|
|
118
|
+
const normalized = normalizeSession({ ...sessionData, id: safeId });
|
|
119
|
+
if (!normalized) return false;
|
|
120
|
+
const filePath = path.join(SESSIONS_DIR, `${safeId}.json`);
|
|
121
|
+
atomicWriteSession(filePath, normalized);
|
|
122
|
+
return true;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Load a session from its identifier.
|
|
130
|
+
* @param {string} sessionId Unique session identifier
|
|
131
|
+
* @returns {object|null} Loaded session or null if not found
|
|
132
|
+
*/
|
|
133
|
+
export function loadSession(sessionId) {
|
|
134
|
+
ensureSessionsDir();
|
|
135
|
+
try {
|
|
136
|
+
if (!/^[A-Za-z0-9_-]+$/.test(String(sessionId || ''))) return null;
|
|
137
|
+
const filePath = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
138
|
+
if (fs.existsSync(filePath)) {
|
|
139
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
140
|
+
return normalizeSession(JSON.parse(content));
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
// Return null on parsing or read failures
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Delete a session from storage.
|
|
150
|
+
* @param {string} sessionId Unique session identifier
|
|
151
|
+
* @returns {boolean} True if successfully deleted
|
|
152
|
+
*/
|
|
153
|
+
export function deleteSession(sessionId) {
|
|
154
|
+
ensureSessionsDir();
|
|
155
|
+
try {
|
|
156
|
+
if (!/^[A-Za-z0-9_-]+$/.test(String(sessionId || ''))) return false;
|
|
157
|
+
const filePath = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
158
|
+
if (fs.existsSync(filePath)) {
|
|
159
|
+
fs.unlinkSync(filePath);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// Ignore unlink failures
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|