claude-code-session-manager 0.10.5 → 0.11.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/dist/assets/{TiptapBody-BroECZ_z.js → TiptapBody-CRmtVlnv.js} +1 -1
- package/dist/assets/{cssMode-Crq-Rykh.js → cssMode-jKB9FuI1.js} +1 -1
- package/dist/assets/{freemarker2-B6CC21Ql.js → freemarker2-BPjwx9LW.js} +1 -1
- package/dist/assets/{handlebars-BLgR-12n.js → handlebars-BLoV3dqv.js} +1 -1
- package/dist/assets/{html-CiQkt_KY.js → html-D-6YItp_.js} +1 -1
- package/dist/assets/{htmlMode-Cy8mc91p.js → htmlMode-Dtc92cqk.js} +1 -1
- package/dist/assets/{index-DW-tvyin.css → index-4x5C_duH.css} +1 -1
- package/dist/assets/{index-DU-o-LEm.js → index-DG1rozxP.js} +1286 -1255
- package/dist/assets/{javascript-CHNCN8qj.js → javascript-BQcvFYdn.js} +1 -1
- package/dist/assets/{jsonMode-BSN7mvBT.js → jsonMode-CnFmKTUr.js} +1 -1
- package/dist/assets/{liquid-B0kmZauA.js → liquid-ogD2CQU1.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-DI_RToRa.js → lspLanguageFeatures-DKVkL-Ro.js} +1 -1
- package/dist/assets/{mdx-BSF-fsyJ.js → mdx-BHbTIp7G.js} +1 -1
- package/dist/assets/{python-DUl3Fmgk.js → python-fa-8uToX.js} +1 -1
- package/dist/assets/{razor-Df7WxBjo.js → razor-D4OJJrW7.js} +1 -1
- package/dist/assets/{tsMode-qccVs0_G.js → tsMode-J9ZwPyiC.js} +1 -1
- package/dist/assets/{typescript-BEwM5qbq.js → typescript-1H3_b_hk.js} +1 -1
- package/dist/assets/{xml-CCtx-_Kw.js → xml-Djys1Yq_.js} +1 -1
- package/dist/assets/{yaml-B66nOkCW.js → yaml-CAZ0iMcZ.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agentMemory.cjs +267 -0
- package/src/main/files.cjs +346 -0
- package/src/main/git.cjs +333 -0
- package/src/main/historyAggregator.cjs +70 -0
- package/src/main/index.cjs +12 -0
- package/src/main/ipcSchemas.cjs +62 -0
- package/src/main/projectSkills.cjs +124 -0
- package/src/main/superagent.cjs +202 -0
- package/src/preload/api.d.ts +203 -0
- package/src/preload/index.cjs +47 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentMemory.cjs — per-subagent memory backend.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from the workspace-scoped Memory tool (memoryTool.cjs): this stores
|
|
5
|
+
* memory entries keyed by **agentId** (the subagent name, e.g. "code-reviewer"),
|
|
6
|
+
* not by workspace cwd. Conceptually mirrors Anthropic's "give each subagent its
|
|
7
|
+
* own scratchpad" pattern.
|
|
8
|
+
*
|
|
9
|
+
* Storage: `~/.claude/session-manager/agent-memory/<agentId>.json`
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* "agentId": "code-reviewer",
|
|
13
|
+
* "entries": [
|
|
14
|
+
* { "id": "mem_<ts>_<rand>", "body": "<markdown>",
|
|
15
|
+
* "category": "command|preference|pattern|failure|workflow" (optional),
|
|
16
|
+
* "createdAt": <epoch ms>, "updatedAt": <epoch ms> }
|
|
17
|
+
* ],
|
|
18
|
+
* "lastUpdated": <epoch ms>
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* Deviation from Unleashed (which keys by projectPath hash and writes to
|
|
22
|
+
* `~/.claudecodeui/agent-memory/<hash>.json` with raw fs.writeFile): we key by
|
|
23
|
+
* agentId per the user's spec, write under `~/.claude/session-manager/` (the
|
|
24
|
+
* conventional spot for this app's own state), and route every mutation through
|
|
25
|
+
* config.cjs's writeJson / validatePath helpers.
|
|
26
|
+
*
|
|
27
|
+
* Hard caps:
|
|
28
|
+
* - agentId must match /^[A-Za-z0-9._-]{1,128}$/
|
|
29
|
+
* - entryId must match /^[A-Za-z0-9._-]{1,128}$/
|
|
30
|
+
* - body up to 1 MiB
|
|
31
|
+
* - up to 200 entries per agent (oldest dropped when over cap on set())
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const { ipcMain } = require('electron');
|
|
37
|
+
const fs = require('node:fs');
|
|
38
|
+
const fsp = require('node:fs/promises');
|
|
39
|
+
const path = require('node:path');
|
|
40
|
+
const os = require('node:os');
|
|
41
|
+
const config = require('./config.cjs');
|
|
42
|
+
|
|
43
|
+
const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB
|
|
44
|
+
const MAX_ENTRIES = 200;
|
|
45
|
+
const AGENT_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
46
|
+
const ENTRY_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
47
|
+
|
|
48
|
+
function rootDir() {
|
|
49
|
+
return path.join(os.homedir(), '.claude', 'session-manager', 'agent-memory');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function recordPath(agentId) {
|
|
53
|
+
if (!AGENT_ID_RE.test(agentId)) {
|
|
54
|
+
throw new Error(`invalid agentId (must match ${AGENT_ID_RE.source})`);
|
|
55
|
+
}
|
|
56
|
+
return path.join(rootDir(), `${agentId}.json`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function ensureRoot() {
|
|
60
|
+
await fsp.mkdir(rootDir(), { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function emptyRecord(agentId) {
|
|
64
|
+
return { agentId, entries: [], lastUpdated: 0 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadRecord(agentId) {
|
|
68
|
+
const abs = recordPath(agentId);
|
|
69
|
+
const r = await config.readJson(abs).catch(() => ({ exists: false, data: null }));
|
|
70
|
+
if (!r || !r.exists || !r.data) return emptyRecord(agentId);
|
|
71
|
+
const data = r.data;
|
|
72
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.entries)) {
|
|
73
|
+
return emptyRecord(agentId);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
agentId,
|
|
77
|
+
entries: data.entries.filter(
|
|
78
|
+
(e) => e && typeof e === 'object' && typeof e.id === 'string' && typeof e.body === 'string'
|
|
79
|
+
),
|
|
80
|
+
lastUpdated: typeof data.lastUpdated === 'number' ? data.lastUpdated : 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function persist(record) {
|
|
85
|
+
await ensureRoot();
|
|
86
|
+
// Cap entry count: drop oldest by updatedAt (or createdAt) ascending.
|
|
87
|
+
if (record.entries.length > MAX_ENTRIES) {
|
|
88
|
+
record.entries = record.entries
|
|
89
|
+
.slice()
|
|
90
|
+
.sort((a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0))
|
|
91
|
+
.slice(0, MAX_ENTRIES);
|
|
92
|
+
}
|
|
93
|
+
const abs = recordPath(record.agentId);
|
|
94
|
+
const out = { ...record, lastUpdated: Date.now() };
|
|
95
|
+
await config.writeJson(abs, out);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ────────────────────────────────────────────────── handlers
|
|
100
|
+
|
|
101
|
+
async function list({ agentId }) {
|
|
102
|
+
if (!AGENT_ID_RE.test(agentId || '')) {
|
|
103
|
+
return { entries: [], agentId: agentId || '', error: 'invalid agentId' };
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const record = await loadRecord(agentId);
|
|
107
|
+
const entries = record.entries
|
|
108
|
+
.slice()
|
|
109
|
+
.sort((a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0))
|
|
110
|
+
.map((e) => ({
|
|
111
|
+
id: e.id,
|
|
112
|
+
body: e.body,
|
|
113
|
+
category: e.category ?? null,
|
|
114
|
+
createdAt: e.createdAt ?? 0,
|
|
115
|
+
updatedAt: e.updatedAt ?? e.createdAt ?? 0,
|
|
116
|
+
bytes: Buffer.byteLength(e.body, 'utf8'),
|
|
117
|
+
}));
|
|
118
|
+
return { entries, agentId, error: null };
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return { entries: [], agentId, error: e.message };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function get({ agentId, entryId }) {
|
|
125
|
+
if (!AGENT_ID_RE.test(agentId || '') || !ENTRY_ID_RE.test(entryId || '')) {
|
|
126
|
+
return { entry: null, error: 'invalid id' };
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const record = await loadRecord(agentId);
|
|
130
|
+
const entry = record.entries.find((e) => e.id === entryId) ?? null;
|
|
131
|
+
if (!entry) return { entry: null, error: null };
|
|
132
|
+
return {
|
|
133
|
+
entry: {
|
|
134
|
+
id: entry.id,
|
|
135
|
+
body: entry.body,
|
|
136
|
+
category: entry.category ?? null,
|
|
137
|
+
createdAt: entry.createdAt ?? 0,
|
|
138
|
+
updatedAt: entry.updatedAt ?? entry.createdAt ?? 0,
|
|
139
|
+
bytes: Buffer.byteLength(entry.body, 'utf8'),
|
|
140
|
+
},
|
|
141
|
+
error: null,
|
|
142
|
+
};
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return { entry: null, error: e.message };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function set({ agentId, entryId, body, category }) {
|
|
149
|
+
if (!AGENT_ID_RE.test(agentId || '')) {
|
|
150
|
+
return { ok: false, error: 'invalid agentId' };
|
|
151
|
+
}
|
|
152
|
+
if (!ENTRY_ID_RE.test(entryId || '')) {
|
|
153
|
+
return { ok: false, error: 'invalid entryId' };
|
|
154
|
+
}
|
|
155
|
+
if (typeof body !== 'string') {
|
|
156
|
+
return { ok: false, error: 'body must be a string' };
|
|
157
|
+
}
|
|
158
|
+
const bytes = Buffer.byteLength(body, 'utf8');
|
|
159
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
160
|
+
return { ok: false, error: `body exceeds 1 MiB cap (${bytes} bytes)` };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const record = await loadRecord(agentId);
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const idx = record.entries.findIndex((e) => e.id === entryId);
|
|
166
|
+
if (idx >= 0) {
|
|
167
|
+
// Update — preserve createdAt
|
|
168
|
+
record.entries[idx] = {
|
|
169
|
+
...record.entries[idx],
|
|
170
|
+
id: entryId,
|
|
171
|
+
body,
|
|
172
|
+
category: category ?? record.entries[idx].category ?? null,
|
|
173
|
+
updatedAt: now,
|
|
174
|
+
};
|
|
175
|
+
} else {
|
|
176
|
+
// Enforce cap on create
|
|
177
|
+
if (record.entries.length >= MAX_ENTRIES) {
|
|
178
|
+
return { ok: false, error: `agent at ${MAX_ENTRIES}-entry cap` };
|
|
179
|
+
}
|
|
180
|
+
record.entries.push({
|
|
181
|
+
id: entryId,
|
|
182
|
+
body,
|
|
183
|
+
category: category ?? null,
|
|
184
|
+
createdAt: now,
|
|
185
|
+
updatedAt: now,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
await persist(record);
|
|
189
|
+
return { ok: true, error: null };
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return { ok: false, error: e.message };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function deleteEntry({ agentId, entryId }) {
|
|
196
|
+
if (!AGENT_ID_RE.test(agentId || '') || !ENTRY_ID_RE.test(entryId || '')) {
|
|
197
|
+
return { ok: false, error: 'invalid id' };
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const record = await loadRecord(agentId);
|
|
201
|
+
const before = record.entries.length;
|
|
202
|
+
record.entries = record.entries.filter((e) => e.id !== entryId);
|
|
203
|
+
if (record.entries.length === before) {
|
|
204
|
+
// No-op delete (entry didn't exist) — treat as success, same as fs unlink ENOENT.
|
|
205
|
+
return { ok: true, error: null };
|
|
206
|
+
}
|
|
207
|
+
// If we just emptied the record, remove the file outright so listProjects()
|
|
208
|
+
// doesn't report a stale ghost agent.
|
|
209
|
+
if (record.entries.length === 0) {
|
|
210
|
+
const abs = recordPath(agentId);
|
|
211
|
+
let real;
|
|
212
|
+
try {
|
|
213
|
+
real = config.validatePath(abs);
|
|
214
|
+
config.validateWrite(real);
|
|
215
|
+
await fsp.unlink(real);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
if (e.code !== 'ENOENT') {
|
|
218
|
+
// Fall back to persisting an empty record; better than throwing.
|
|
219
|
+
await persist(record);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, error: null };
|
|
223
|
+
}
|
|
224
|
+
await persist(record);
|
|
225
|
+
return { ok: true, error: null };
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { ok: false, error: e.message };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function listAgents() {
|
|
232
|
+
try {
|
|
233
|
+
await ensureRoot();
|
|
234
|
+
const dir = rootDir();
|
|
235
|
+
const r = await config.listDir(dir, { filesOnly: true });
|
|
236
|
+
if (!r.ok) return { agents: [], error: r.error };
|
|
237
|
+
const agents = r.entries
|
|
238
|
+
.filter((e) => e.name.endsWith('.json'))
|
|
239
|
+
.map((e) => ({
|
|
240
|
+
agentId: e.name.replace(/\.json$/, ''),
|
|
241
|
+
bytes: e.size,
|
|
242
|
+
mtimeMs: e.mtimeMs,
|
|
243
|
+
}))
|
|
244
|
+
.sort((a, b) => a.agentId.localeCompare(b.agentId));
|
|
245
|
+
return { agents, error: null };
|
|
246
|
+
} catch (e) {
|
|
247
|
+
return { agents: [], error: e.message };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function registerAgentMemoryHandlers() {
|
|
252
|
+
const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
|
|
253
|
+
ipcMain.handle('agent-memory:list', v(s.agentMemoryList, list));
|
|
254
|
+
ipcMain.handle('agent-memory:get', v(s.agentMemoryGet, get));
|
|
255
|
+
ipcMain.handle('agent-memory:set', v(s.agentMemorySet, set));
|
|
256
|
+
ipcMain.handle('agent-memory:delete', v(s.agentMemoryDelete, deleteEntry));
|
|
257
|
+
ipcMain.handle('agent-memory:list-agents', () => listAgents());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = {
|
|
261
|
+
registerAgentMemoryHandlers,
|
|
262
|
+
// exported for tests
|
|
263
|
+
rootDir,
|
|
264
|
+
recordPath,
|
|
265
|
+
AGENT_ID_RE,
|
|
266
|
+
ENTRY_ID_RE,
|
|
267
|
+
};
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files IPC — file-tree-sidebar backend.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Unleashed's files IPC but routes every path through config.cjs's
|
|
5
|
+
* `validatePath` (allowedRoots = home dir), and uses `shell.trashItem` for
|
|
6
|
+
* delete so renames/deletes are recoverable from the OS trash.
|
|
7
|
+
*
|
|
8
|
+
* Notes:
|
|
9
|
+
* - Reads are constrained to anywhere inside the home dir.
|
|
10
|
+
* - Writes (create/rename/delete) likewise stay inside home but additionally
|
|
11
|
+
* reject anything that would land on `.credentials.json`.
|
|
12
|
+
* - All listings sort directories first, then alphabetically.
|
|
13
|
+
* - The renderer is expected to pass absolute paths only. Tilde is expanded.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { ipcMain, shell } = require('electron');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const fsp = require('node:fs/promises');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
|
|
22
|
+
const { z } = require('zod');
|
|
23
|
+
|
|
24
|
+
function expandHome(p) {
|
|
25
|
+
if (!p) return p;
|
|
26
|
+
if (p === '~') return os.homedir();
|
|
27
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve to realpath; on ENOENT resolve the parent then re-append basename so
|
|
33
|
+
* we can still validate create destinations. Mirrors config.cjs.
|
|
34
|
+
*/
|
|
35
|
+
function realResolve(abs) {
|
|
36
|
+
const lex = path.resolve(expandHome(abs));
|
|
37
|
+
try {
|
|
38
|
+
return fs.realpathSync(lex);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e.code === 'ENOENT') {
|
|
41
|
+
const parent = path.dirname(lex);
|
|
42
|
+
try {
|
|
43
|
+
return path.join(fs.realpathSync(parent), path.basename(lex));
|
|
44
|
+
} catch {
|
|
45
|
+
return lex;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HOME = os.homedir();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validates that the path is under the home directory. Returns the realpath
|
|
56
|
+
* or throws. Files IPC is intentionally home-scoped — broader than
|
|
57
|
+
* config.cjs's write boundaries, since the user may browse any project under
|
|
58
|
+
* ~ — but never escapes the home tree.
|
|
59
|
+
*/
|
|
60
|
+
function validateHomePath(abs) {
|
|
61
|
+
const real = realResolve(abs);
|
|
62
|
+
let realHome;
|
|
63
|
+
try { realHome = fs.realpathSync(HOME); } catch { realHome = HOME; }
|
|
64
|
+
if (real === realHome || real.startsWith(realHome + path.sep)) return real;
|
|
65
|
+
throw new Error(`Path outside home directory: ${real}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Reject .credentials.json writes regardless of where they sit. */
|
|
69
|
+
function rejectCredentials(real) {
|
|
70
|
+
if (path.basename(real) === '.credentials.json') {
|
|
71
|
+
throw new Error('Write to .credentials.json denied');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Invalid characters for file/folder names (cross-platform).
|
|
76
|
+
const INVALID_NAME_CHARS = /[<>:"/\\|?*\x00-\x1f]/;
|
|
77
|
+
const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
78
|
+
|
|
79
|
+
function validateName(name) {
|
|
80
|
+
if (!name || name.trim().length === 0) return 'Name cannot be empty';
|
|
81
|
+
if (name !== name.trim()) return 'Name cannot start or end with whitespace';
|
|
82
|
+
if (name === '.' || name === '..') return 'Name cannot be "." or ".."';
|
|
83
|
+
if (INVALID_NAME_CHARS.test(name)) return 'Name contains invalid characters';
|
|
84
|
+
if (RESERVED_NAMES.test(name.split('.')[0])) return 'Name is a reserved system name';
|
|
85
|
+
if (name.length > 255) return 'Name is too long (max 255 characters)';
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function listDir(dirPath, showHidden) {
|
|
90
|
+
let resolved;
|
|
91
|
+
try { resolved = validateHomePath(dirPath); }
|
|
92
|
+
catch (e) { return { ok: false, entries: [], error: e.message }; }
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const entries = await fsp.readdir(resolved, { withFileTypes: true });
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!showHidden && entry.name.startsWith('.')) continue;
|
|
99
|
+
const full = path.join(resolved, entry.name);
|
|
100
|
+
let size = 0;
|
|
101
|
+
let mtimeMs = 0;
|
|
102
|
+
try {
|
|
103
|
+
const st = await fsp.stat(full);
|
|
104
|
+
size = st.size;
|
|
105
|
+
mtimeMs = st.mtimeMs;
|
|
106
|
+
} catch { /* skip unreadable */ continue; }
|
|
107
|
+
out.push({
|
|
108
|
+
name: entry.name,
|
|
109
|
+
path: full,
|
|
110
|
+
isDirectory: entry.isDirectory(),
|
|
111
|
+
isFile: entry.isFile(),
|
|
112
|
+
size,
|
|
113
|
+
mtimeMs,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
out.sort((a, b) => {
|
|
117
|
+
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
|
118
|
+
return a.name.localeCompare(b.name);
|
|
119
|
+
});
|
|
120
|
+
return { ok: true, entries: out, error: null };
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return { ok: false, entries: [], error: e.message };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readFile(filePath) {
|
|
127
|
+
let resolved;
|
|
128
|
+
try { resolved = validateHomePath(filePath); }
|
|
129
|
+
catch (e) { return { ok: false, text: '', error: e.message, size: 0 }; }
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const st = await fsp.stat(resolved);
|
|
133
|
+
if (st.isDirectory()) return { ok: false, text: '', error: 'Path is a directory', size: 0 };
|
|
134
|
+
// 5 MB cap — preview pane shouldn't try to load huge logs.
|
|
135
|
+
if (st.size > 5 * 1024 * 1024) {
|
|
136
|
+
return { ok: false, text: '', error: 'File too large to preview (> 5 MB)', size: st.size };
|
|
137
|
+
}
|
|
138
|
+
const text = await fsp.readFile(resolved, 'utf8');
|
|
139
|
+
return { ok: true, text, error: null, size: st.size };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return { ok: false, text: '', error: e.message, size: 0 };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function writeFile(filePath, content) {
|
|
146
|
+
let resolved;
|
|
147
|
+
try {
|
|
148
|
+
resolved = validateHomePath(filePath);
|
|
149
|
+
rejectCredentials(resolved);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { ok: false, error: e.message };
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const dir = path.dirname(resolved);
|
|
155
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
156
|
+
const tmp = `${resolved}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
157
|
+
await fsp.writeFile(tmp, content, 'utf8');
|
|
158
|
+
await fsp.rename(tmp, resolved);
|
|
159
|
+
return { ok: true, error: null };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { ok: false, error: e.message };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function createEntry(parentPath, name, kind) {
|
|
166
|
+
const nameError = validateName(name);
|
|
167
|
+
if (nameError) return { ok: false, error: nameError };
|
|
168
|
+
|
|
169
|
+
let parent;
|
|
170
|
+
try { parent = validateHomePath(parentPath); }
|
|
171
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
172
|
+
|
|
173
|
+
const target = path.join(parent, name);
|
|
174
|
+
// Re-validate target — name passed validateName but join could still
|
|
175
|
+
// produce something outside parent (defense in depth).
|
|
176
|
+
try { validateHomePath(target); }
|
|
177
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
178
|
+
try { rejectCredentials(target); }
|
|
179
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const parentStat = await fsp.stat(parent);
|
|
183
|
+
if (!parentStat.isDirectory()) {
|
|
184
|
+
return { ok: false, error: 'Parent path is not a directory' };
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
await fsp.access(target);
|
|
188
|
+
return { ok: false, error: 'A file or folder with that name already exists' };
|
|
189
|
+
} catch { /* expected — doesn't exist */ }
|
|
190
|
+
|
|
191
|
+
if (kind === 'folder') {
|
|
192
|
+
await fsp.mkdir(target, { recursive: false });
|
|
193
|
+
} else {
|
|
194
|
+
// 'wx' is exclusive — fails if it races with another writer.
|
|
195
|
+
await fsp.writeFile(target, '', { encoding: 'utf8', flag: 'wx' });
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, path: target, error: null };
|
|
198
|
+
} catch (e) {
|
|
199
|
+
if (e.code === 'EEXIST') return { ok: false, error: 'A file or folder with that name already exists' };
|
|
200
|
+
return { ok: false, error: e.message };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function renameEntry(oldPath, newName) {
|
|
205
|
+
const nameError = validateName(newName);
|
|
206
|
+
if (nameError) return { ok: false, error: nameError };
|
|
207
|
+
|
|
208
|
+
let resolvedOld;
|
|
209
|
+
try { resolvedOld = validateHomePath(oldPath); }
|
|
210
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
211
|
+
|
|
212
|
+
const newPath = path.join(path.dirname(resolvedOld), newName);
|
|
213
|
+
try { validateHomePath(newPath); }
|
|
214
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
215
|
+
try { rejectCredentials(newPath); }
|
|
216
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await fsp.access(resolvedOld);
|
|
220
|
+
try {
|
|
221
|
+
await fsp.access(newPath);
|
|
222
|
+
return { ok: false, error: 'A file or folder with that name already exists' };
|
|
223
|
+
} catch { /* good */ }
|
|
224
|
+
await fsp.rename(resolvedOld, newPath);
|
|
225
|
+
return { ok: true, newPath, error: null };
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { ok: false, error: e.message };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const CRITICAL_PATHS = new Set([HOME, '/', '/usr', '/bin', '/etc', '/var', '/System', '/Applications']);
|
|
232
|
+
|
|
233
|
+
async function deleteEntry(filePath) {
|
|
234
|
+
let resolved;
|
|
235
|
+
try { resolved = validateHomePath(filePath); }
|
|
236
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
237
|
+
|
|
238
|
+
if (CRITICAL_PATHS.has(resolved)) {
|
|
239
|
+
return { ok: false, error: 'Cannot delete system-critical paths' };
|
|
240
|
+
}
|
|
241
|
+
try { rejectCredentials(resolved); }
|
|
242
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
// Prefer trash so deletes are recoverable. Fall back to hard delete only
|
|
246
|
+
// if the platform doesn't support it (very old Linux desktops).
|
|
247
|
+
try {
|
|
248
|
+
await shell.trashItem(resolved);
|
|
249
|
+
return { ok: true, error: null };
|
|
250
|
+
} catch {
|
|
251
|
+
const st = await fsp.lstat(resolved);
|
|
252
|
+
if (st.isDirectory() && !st.isSymbolicLink()) {
|
|
253
|
+
await fsp.rm(resolved, { recursive: true });
|
|
254
|
+
} else {
|
|
255
|
+
await fsp.unlink(resolved);
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, error: null };
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return { ok: false, error: e.message };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function openExternal(filePath) {
|
|
265
|
+
let resolved;
|
|
266
|
+
try { resolved = validateHomePath(filePath); }
|
|
267
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
268
|
+
try {
|
|
269
|
+
await fsp.access(resolved);
|
|
270
|
+
const err = await shell.openPath(resolved);
|
|
271
|
+
if (err) return { ok: false, error: err };
|
|
272
|
+
return { ok: true };
|
|
273
|
+
} catch (e) {
|
|
274
|
+
return { ok: false, error: e.message };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function showInFinder(filePath) {
|
|
279
|
+
let resolved;
|
|
280
|
+
try { resolved = validateHomePath(filePath); }
|
|
281
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
282
|
+
try {
|
|
283
|
+
await fsp.access(resolved);
|
|
284
|
+
shell.showItemInFolder(resolved);
|
|
285
|
+
return { ok: true };
|
|
286
|
+
} catch (e) {
|
|
287
|
+
return { ok: false, error: e.message };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ──────────────────────────────────────────── schemas
|
|
292
|
+
const filesPath = z.object({ path: z.string().min(1).max(4096) });
|
|
293
|
+
const filesList = z.object({ path: z.string().min(1).max(4096), showHidden: z.boolean().optional() });
|
|
294
|
+
const filesWrite = z.object({ path: z.string().min(1).max(4096), content: z.string() });
|
|
295
|
+
const filesCreate = z.object({
|
|
296
|
+
parentPath: z.string().min(1).max(4096),
|
|
297
|
+
name: z.string().min(1).max(255),
|
|
298
|
+
kind: z.enum(['file', 'folder']),
|
|
299
|
+
});
|
|
300
|
+
const filesRename = z.object({ path: z.string().min(1).max(4096), newName: z.string().min(1).max(255) });
|
|
301
|
+
|
|
302
|
+
function registerFilesHandlers() {
|
|
303
|
+
ipcMain.handle('files:list', (_e, payload) => {
|
|
304
|
+
const { path: p, showHidden } = filesList.parse(payload);
|
|
305
|
+
return listDir(p, !!showHidden);
|
|
306
|
+
});
|
|
307
|
+
ipcMain.handle('files:read', (_e, payload) => {
|
|
308
|
+
const { path: p } = filesPath.parse(payload);
|
|
309
|
+
return readFile(p);
|
|
310
|
+
});
|
|
311
|
+
ipcMain.handle('files:write', (_e, payload) => {
|
|
312
|
+
const { path: p, content } = filesWrite.parse(payload);
|
|
313
|
+
return writeFile(p, content);
|
|
314
|
+
});
|
|
315
|
+
ipcMain.handle('files:create', (_e, payload) => {
|
|
316
|
+
const { parentPath, name, kind } = filesCreate.parse(payload);
|
|
317
|
+
return createEntry(parentPath, name, kind);
|
|
318
|
+
});
|
|
319
|
+
ipcMain.handle('files:rename', (_e, payload) => {
|
|
320
|
+
const { path: p, newName } = filesRename.parse(payload);
|
|
321
|
+
return renameEntry(p, newName);
|
|
322
|
+
});
|
|
323
|
+
ipcMain.handle('files:delete', (_e, payload) => {
|
|
324
|
+
const { path: p } = filesPath.parse(payload);
|
|
325
|
+
return deleteEntry(p);
|
|
326
|
+
});
|
|
327
|
+
ipcMain.handle('files:open-external', (_e, payload) => {
|
|
328
|
+
const { path: p } = filesPath.parse(payload);
|
|
329
|
+
return openExternal(p);
|
|
330
|
+
});
|
|
331
|
+
ipcMain.handle('files:show-in-finder', (_e, payload) => {
|
|
332
|
+
const { path: p } = filesPath.parse(payload);
|
|
333
|
+
return showInFinder(p);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
registerFilesHandlers,
|
|
339
|
+
// exported for tests
|
|
340
|
+
listDir,
|
|
341
|
+
readFile,
|
|
342
|
+
writeFile,
|
|
343
|
+
createEntry,
|
|
344
|
+
renameEntry,
|
|
345
|
+
deleteEntry,
|
|
346
|
+
};
|