codemini-cli 0.6.3 → 0.6.4

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.
Files changed (48) hide show
  1. package/codemini-web/dist/assets/{AboutDialog-jgqGjQgl.js → AboutDialog-MRopwNIL.js} +2 -2
  2. package/codemini-web/dist/assets/CodeWikiPanel-UpK5xGE3.js +1 -0
  3. package/codemini-web/dist/assets/ConfigDialog-CNl28wsj.js +1 -0
  4. package/codemini-web/dist/assets/GitDiffDialog-gSysUg2J.js +3 -0
  5. package/codemini-web/dist/assets/{MemoryDialog-BhxQgG0I.js → MemoryDialog-DFUmo3Kl.js} +3 -3
  6. package/codemini-web/dist/assets/MessageBubble-CGnnViv0.js +12 -0
  7. package/codemini-web/dist/assets/PatchDiff-B8rwvEg5.js +230 -0
  8. package/codemini-web/dist/assets/ProjectSelector-BF59M1zb.js +1 -0
  9. package/codemini-web/dist/assets/{SkillDialog-DxS43NpR.js → SkillDialog-CQTjbSiw.js} +4 -4
  10. package/codemini-web/dist/assets/SoulDialog-BLjUGqqB.js +1 -0
  11. package/codemini-web/dist/assets/chevron-right--85xg7qk.js +1 -0
  12. package/codemini-web/dist/assets/{chunk-BO2N2NFS-Budy_hfO.js → chunk-BO2N2NFS-6uELoidu.js} +6 -6
  13. package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-CQS1PAvD.js → highlighted-body-OFNGDK62-gb1UMBZ5.js} +1 -1
  14. package/codemini-web/dist/assets/index-1xqD0R5t.css +2 -0
  15. package/codemini-web/dist/assets/index-CDXQGwPs.js +65 -0
  16. package/codemini-web/dist/assets/{input-CNQgbKe6.js → input-Ca8O_061.js} +1 -1
  17. package/codemini-web/dist/assets/lib-BXWizt13.js +1 -0
  18. package/codemini-web/dist/assets/mermaid-GHXKKRXX-ROliF8Yd.js +1 -0
  19. package/codemini-web/dist/assets/{pencil-Ce_LFiEh.js → pencil-BhT11Ztp.js} +1 -1
  20. package/codemini-web/dist/assets/{refresh-cw-BKL-AZu5.js → refresh-cw-D7R5Lth6.js} +1 -1
  21. package/codemini-web/dist/assets/select-DBvcHBzs.js +1 -0
  22. package/codemini-web/dist/assets/{trash-2-KmAlCwXd.js → trash-2-BfNZcWfX.js} +1 -1
  23. package/codemini-web/dist/index.html +2 -2
  24. package/codemini-web/lib/runtime-bridge.js +325 -296
  25. package/codemini-web/server.js +310 -243
  26. package/package.json +1 -1
  27. package/src/core/agent-loop.js +188 -97
  28. package/src/core/chat-runtime.js +674 -571
  29. package/src/core/config-store.js +11 -3
  30. package/src/core/git-oplog-change-tracker.js +387 -0
  31. package/src/core/non-git-backup.js +116 -0
  32. package/src/core/paths.js +123 -123
  33. package/src/core/session-store.js +148 -99
  34. package/src/core/tools.js +499 -456
  35. package/src/tui/chat-app.js +196 -56
  36. package/codemini-web/dist/assets/CodeWikiPanel-EPuoerNv.js +0 -1
  37. package/codemini-web/dist/assets/ConfigDialog-B5IGZCc9.js +0 -1
  38. package/codemini-web/dist/assets/GitDiffDialog-Bb_Tw5ZK.js +0 -222
  39. package/codemini-web/dist/assets/MessageBubble-wUff4GP4.js +0 -6
  40. package/codemini-web/dist/assets/ProjectSelector-C0leTf6f.js +0 -1
  41. package/codemini-web/dist/assets/SoulDialog-XDTEGWvH.js +0 -1
  42. package/codemini-web/dist/assets/chevron-right-Dbzw7YzA.js +0 -1
  43. package/codemini-web/dist/assets/index-D0EGtNPr.js +0 -65
  44. package/codemini-web/dist/assets/index-wOUf3WkN.css +0 -2
  45. package/codemini-web/dist/assets/lib-BOngVP_M.js +0 -11
  46. package/codemini-web/dist/assets/lib-DrOTTm_N.js +0 -1
  47. package/codemini-web/dist/assets/mermaid-GHXKKRXX-DrBu5KyC.js +0 -1
  48. package/codemini-web/dist/assets/select-BZXfigic.js +0 -1
@@ -42,6 +42,7 @@ const DEFAULT_CONFIG = {
42
42
  },
43
43
  execution: {
44
44
  mode: 'normal',
45
+ approval_mode: 'review',
45
46
  always_allow_tools: [
46
47
  'read',
47
48
  'grep',
@@ -148,9 +149,16 @@ function normalizePolicyLists(config) {
148
149
  }
149
150
  next.model.name = String(next.model.name || DEFAULT_CONFIG.model.name).trim() || DEFAULT_CONFIG.model.name;
150
151
  next.model.fast_name = String(next.model.fast_name || '').trim();
151
- next.execution.mode = ['auto', 'normal', 'plan'].includes(String(next.execution.mode || '').toLowerCase())
152
- ? String(next.execution.mode).toLowerCase()
153
- : 'normal';
152
+ const rawExecutionMode = String(next.execution.mode || '').toLowerCase();
153
+ const rawApprovalMode = String(next.execution.approval_mode || next.execution.approvalMode || '').toLowerCase().replace(/-/g, '_');
154
+ next.execution.mode = ['normal', 'plan'].includes(rawExecutionMode)
155
+ ? rawExecutionMode
156
+ : 'normal';
157
+ next.execution.approval_mode = ['review', 'auto', 'full_access'].includes(rawApprovalMode)
158
+ ? rawApprovalMode
159
+ : rawExecutionMode === 'auto'
160
+ ? 'auto'
161
+ : 'review';
154
162
  const rawTools = Array.isArray(next.execution.always_allow_tools)
155
163
  ? next.execution.always_allow_tools
156
164
  : [];
@@ -0,0 +1,387 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { spawn } from 'node:child_process';
5
+ import { normalizePath } from './string-utils.js';
6
+
7
+ const CHANGE_OPLOG_VERSION = 1;
8
+ const FILE_TOOLS = new Set(['edit', 'write', 'delete']);
9
+
10
+ function runGit(args, { cwd, input = null, allowFailure = false, timeoutMs = 120_000 } = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const child = spawn('git', args, {
13
+ cwd,
14
+ windowsHide: true,
15
+ stdio: ['pipe', 'pipe', 'pipe']
16
+ });
17
+ const stdoutChunks = [];
18
+ const stderrChunks = [];
19
+ const timer = setTimeout(() => {
20
+ try { child.kill(); } catch {}
21
+ }, timeoutMs);
22
+ child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
23
+ child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
24
+ child.on('error', (error) => {
25
+ clearTimeout(timer);
26
+ reject(error);
27
+ });
28
+ child.on('close', (code) => {
29
+ clearTimeout(timer);
30
+ const stdoutBuffer = Buffer.concat(stdoutChunks);
31
+ const stderrBuffer = Buffer.concat(stderrChunks);
32
+ const result = {
33
+ code,
34
+ stdout: stdoutBuffer.toString('utf8'),
35
+ stderr: stderrBuffer.toString('utf8'),
36
+ stdoutBuffer,
37
+ stderrBuffer
38
+ };
39
+ if (code === 0 || allowFailure) resolve(result);
40
+ else reject(new Error(result.stderr.trim() || result.stdout.trim() || `git exited with code ${code}`));
41
+ });
42
+ if (input != null) child.stdin.end(input);
43
+ else child.stdin.end();
44
+ });
45
+ }
46
+
47
+ function changeId(prefix = 'op') {
48
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
49
+ const rand = Math.random().toString(36).slice(2, 8);
50
+ return `${prefix}-${stamp}-${rand}`;
51
+ }
52
+
53
+ function normalizeRelativePath(value, { root = '' } = {}) {
54
+ let text = normalizePath(String(value || '').trim()).replace(/:\d+(?:-\d+)?$/, '');
55
+ if (!text || text === '.') return '';
56
+ if (/^[a-zA-Z]:\//.test(text) || text.startsWith('/')) {
57
+ if (!root) return '';
58
+ const absRoot = path.resolve(root);
59
+ const absTarget = path.resolve(text);
60
+ const rel = path.relative(absRoot, absTarget);
61
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
62
+ text = normalizePath(rel);
63
+ }
64
+ if (text.startsWith('../')) return '';
65
+ return text;
66
+ }
67
+
68
+ function extractPathCandidates(args = {}, declaredChanges = [], options = {}) {
69
+ const candidates = [];
70
+ for (const change of Array.isArray(declaredChanges) ? declaredChanges : [declaredChanges]) {
71
+ if (change?.path) candidates.push(change.path);
72
+ }
73
+ for (const value of [
74
+ args?.path,
75
+ args?.file,
76
+ args?.file_path,
77
+ args?.target,
78
+ args?.edit?.path,
79
+ args?.edit?.file,
80
+ args?.edit?.file_path,
81
+ args?.edit?.target?.path
82
+ ]) {
83
+ if (typeof value === 'string') candidates.push(value);
84
+ }
85
+ const out = [];
86
+ const seen = new Set();
87
+ for (const value of candidates) {
88
+ const normalized = normalizeRelativePath(value, options);
89
+ if (!normalized || seen.has(normalized)) continue;
90
+ seen.add(normalized);
91
+ out.push(normalized);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function parseStatusPaths(text) {
97
+ const parts = String(text || '').split('\0').filter(Boolean);
98
+ const paths = [];
99
+ for (let i = 0; i < parts.length; i += 1) {
100
+ const entry = parts[i];
101
+ if (entry.length < 4) continue;
102
+ const status = entry.slice(0, 2);
103
+ const filePath = normalizeRelativePath(entry.slice(3));
104
+ if (filePath) paths.push(filePath);
105
+ if (status.includes('R') || status.includes('C')) {
106
+ i += 1;
107
+ const renamedPath = normalizeRelativePath(parts[i] || '');
108
+ if (renamedPath) paths.push(renamedPath);
109
+ }
110
+ }
111
+ return [...new Set(paths)];
112
+ }
113
+
114
+ async function readWorktreeSnapshot(root, relativePath) {
115
+ const abs = path.resolve(root, relativePath);
116
+ if (!abs.startsWith(`${root}${path.sep}`) && abs !== root) {
117
+ return { exists: false, content: Buffer.alloc(0), hash: '' };
118
+ }
119
+ try {
120
+ const content = await fs.readFile(abs);
121
+ return { exists: true, content, hash: hashBuffer(content) };
122
+ } catch (error) {
123
+ if (error?.code === 'ENOENT') return { exists: false, content: Buffer.alloc(0), hash: '' };
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ async function readHeadSnapshot(root, relativePath) {
129
+ try {
130
+ const result = await runGit(['show', `HEAD:${relativePath}`], {
131
+ cwd: root,
132
+ allowFailure: true,
133
+ timeoutMs: 60_000
134
+ });
135
+ if (result.code !== 0) return { exists: false, content: Buffer.alloc(0), hash: '' };
136
+ return { exists: true, content: result.stdoutBuffer, hash: hashBuffer(result.stdoutBuffer) };
137
+ } catch {
138
+ return { exists: false, content: Buffer.alloc(0), hash: '' };
139
+ }
140
+ }
141
+
142
+ function hashBuffer(buffer) {
143
+ let hash = 5381;
144
+ for (const byte of buffer) hash = ((hash << 5) + hash + byte) >>> 0;
145
+ return hash.toString(16);
146
+ }
147
+
148
+ function countPatchLines(patch) {
149
+ let added = 0;
150
+ let removed = 0;
151
+ for (const line of String(patch || '').split(/\r?\n/)) {
152
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
153
+ if (line.startsWith('+')) added += 1;
154
+ else if (line.startsWith('-')) removed += 1;
155
+ }
156
+ return { added, removed };
157
+ }
158
+
159
+ function firstChangedLineFromPatch(patch) {
160
+ const match = String(patch || '').match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/m);
161
+ return match ? Math.max(1, Number(match[1]) || 1) : 0;
162
+ }
163
+
164
+ function actionFromSnapshots(before, after) {
165
+ if (!before.exists && after.exists) return 'create';
166
+ if (before.exists && !after.exists) return 'delete';
167
+ return 'edit';
168
+ }
169
+
170
+ async function writeTempSnapshot(dir, name, snapshot) {
171
+ const filePath = path.join(dir, name);
172
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
173
+ await fs.writeFile(filePath, snapshot.content);
174
+ return filePath;
175
+ }
176
+
177
+ async function buildPatchForFile(root, relativePath, before, after) {
178
+ if (before.exists === after.exists && before.hash === after.hash) return '';
179
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'codemini-oplog-'));
180
+ try {
181
+ const oldPath = normalizePath(path.posix.join('old', relativePath));
182
+ const newPath = normalizePath(path.posix.join('new', relativePath));
183
+ const beforePath = await writeTempSnapshot(tmp, oldPath, before.exists ? before : { content: Buffer.alloc(0) });
184
+ const afterPath = await writeTempSnapshot(tmp, newPath, after.exists ? after : { content: Buffer.alloc(0) });
185
+ const result = await runGit([
186
+ 'diff',
187
+ '--no-index',
188
+ '--no-color',
189
+ '--binary',
190
+ oldPath,
191
+ newPath
192
+ ], { cwd: tmp, allowFailure: true, timeoutMs: 120_000 });
193
+ if (result.code !== 1) return '';
194
+ const escaped = relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
195
+ return result.stdout
196
+ .replace(new RegExp(`diff --git a/old/${escaped} b/new/${escaped}`), `diff --git a/${relativePath} b/${relativePath}`)
197
+ .replace(new RegExp(`--- a/old/${escaped}`), before.exists ? `--- a/${relativePath}` : '--- /dev/null')
198
+ .replace(new RegExp(`\\+\\+\\+ b/new/${escaped}`), after.exists ? `+++ b/${relativePath}` : '+++ /dev/null');
199
+ } finally {
200
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => {});
201
+ }
202
+ }
203
+
204
+ async function writeJson(filePath, value) {
205
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
206
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
207
+ }
208
+
209
+ async function readJson(filePath) {
210
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
211
+ }
212
+
213
+ export async function createGitOplogChangeTracker({ workspaceRoot = process.cwd(), sessionId } = {}) {
214
+ const startRoot = path.resolve(workspaceRoot);
215
+ const id = String(sessionId || '').trim();
216
+ if (!id) return { enabled: false, reason: 'missing-session' };
217
+
218
+ try {
219
+ const inside = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: startRoot, allowFailure: true });
220
+ if (inside.code !== 0 || inside.stdout.trim() !== 'true') {
221
+ return { enabled: false, reason: 'not-git-repo', workspaceRoot: startRoot };
222
+ }
223
+ const root = path.resolve((await runGit(['rev-parse', '--show-toplevel'], { cwd: startRoot })).stdout.trim());
224
+ const head = await runGit(['rev-parse', '--verify', 'HEAD'], { cwd: root, allowFailure: true });
225
+ const hasHead = head.code === 0;
226
+ const gitPath = (await runGit(['rev-parse', '--git-path', `codemini/sessions/${id}`], { cwd: root })).stdout.trim();
227
+ const oplogDir = path.resolve(root, gitPath);
228
+ const patchesDir = path.join(oplogDir, 'patches');
229
+ const opsDir = path.join(oplogDir, 'ops');
230
+ await fs.mkdir(patchesDir, { recursive: true });
231
+ await fs.mkdir(opsDir, { recursive: true });
232
+ if (hasHead) {
233
+ await runGit(['update-ref', `refs/codemini/sessions/${id}`, 'HEAD'], { cwd: root, allowFailure: true });
234
+ }
235
+ return {
236
+ enabled: true,
237
+ mode: 'git-oplog',
238
+ workspaceRoot: root,
239
+ sessionId: id,
240
+ baseCommit: hasHead ? head.stdout.trim() : '',
241
+ oplogDir,
242
+ patchesDir,
243
+ opsDir
244
+ };
245
+ } catch (error) {
246
+ return { enabled: false, reason: error instanceof Error ? error.message : String(error), workspaceRoot: startRoot };
247
+ }
248
+ }
249
+
250
+ export function isGitOplogChangeTrackerAvailable(tracker) {
251
+ return Boolean(tracker?.enabled && tracker.mode === 'git-oplog');
252
+ }
253
+
254
+ export async function beginGitOplogCapture(tracker, { toolName = '', args = {} } = {}) {
255
+ if (!isGitOplogChangeTrackerAvailable(tracker)) return null;
256
+ const explicitPaths = FILE_TOOLS.has(String(toolName || '')) ? extractPathCandidates(args, [], { root: tracker.workspaceRoot }) : [];
257
+ const status = await runGit(['status', '--porcelain=v1', '-z'], { cwd: tracker.workspaceRoot, allowFailure: true });
258
+ const dirtyPaths = parseStatusPaths(status.stdout);
259
+ const paths = explicitPaths.length ? explicitPaths : dirtyPaths;
260
+ const snapshots = new Map();
261
+ for (const filePath of paths) {
262
+ snapshots.set(filePath, await readWorktreeSnapshot(tracker.workspaceRoot, filePath));
263
+ }
264
+ return {
265
+ toolName,
266
+ explicit: explicitPaths.length > 0,
267
+ beforeDirtyPaths: dirtyPaths,
268
+ paths,
269
+ snapshots
270
+ };
271
+ }
272
+
273
+ export async function captureGitOplogChanges(tracker, capture, { toolName = '', toolCallId = '', summary = '', args = {}, declaredFileChanges = [] } = {}) {
274
+ if (!isGitOplogChangeTrackerAvailable(tracker) || !capture) return null;
275
+ const afterStatus = await runGit(['status', '--porcelain=v1', '-z'], { cwd: tracker.workspaceRoot, allowFailure: true });
276
+ const afterDirtyPaths = parseStatusPaths(afterStatus.stdout);
277
+ const declaredPaths = extractPathCandidates(args, declaredFileChanges, { root: tracker.workspaceRoot });
278
+ const candidates = new Set([
279
+ ...capture.paths,
280
+ ...declaredPaths,
281
+ ...(capture.explicit ? [] : capture.beforeDirtyPaths),
282
+ ...(capture.explicit ? [] : afterDirtyPaths)
283
+ ]);
284
+
285
+ const files = [];
286
+ const patches = [];
287
+ for (const filePath of candidates) {
288
+ const normalized = normalizeRelativePath(filePath, { root: tracker.workspaceRoot });
289
+ if (!normalized) continue;
290
+ let before = capture.snapshots.get(normalized);
291
+ if (!before) before = await readHeadSnapshot(tracker.workspaceRoot, normalized);
292
+ const after = await readWorktreeSnapshot(tracker.workspaceRoot, normalized);
293
+ const patch = await buildPatchForFile(tracker.workspaceRoot, normalized, before, after);
294
+ if (!patch.trim()) continue;
295
+ const stats = countPatchLines(patch);
296
+ files.push({
297
+ path: normalized,
298
+ action: actionFromSnapshots(before, after),
299
+ linesAdded: stats.added,
300
+ linesRemoved: stats.removed,
301
+ changedLine: firstChangedLineFromPatch(patch),
302
+ diffPreview: patch
303
+ });
304
+ patches.push(patch);
305
+ }
306
+ if (!files.length) return null;
307
+
308
+ const opId = changeId('op');
309
+ const patch = patches.join('\n');
310
+ const patchPath = path.join(tracker.patchesDir, `${opId}.patch`);
311
+ const opPath = path.join(tracker.opsDir, `${opId}.json`);
312
+ await fs.writeFile(patchPath, patch, 'utf8');
313
+ const op = {
314
+ version: CHANGE_OPLOG_VERSION,
315
+ id: opId,
316
+ sessionId: tracker.sessionId,
317
+ createdAt: new Date().toISOString(),
318
+ toolName,
319
+ toolCallId,
320
+ summary,
321
+ patchPath,
322
+ files: files.map(({ diffPreview, ...file }) => file),
323
+ revertedAt: null
324
+ };
325
+ await writeJson(opPath, op);
326
+ await fs.appendFile(path.join(tracker.oplogDir, 'ops.jsonl'), `${JSON.stringify({ ...op, patchPath: undefined })}\n`, 'utf8');
327
+ return files.map((file) => ({
328
+ ...file,
329
+ changeSetId: opId,
330
+ files: [{ path: file.path, action: file.action, linesAdded: file.linesAdded, linesRemoved: file.linesRemoved }]
331
+ }));
332
+ }
333
+
334
+ export async function listGitOplogChanges(tracker) {
335
+ if (!isGitOplogChangeTrackerAvailable(tracker)) return [];
336
+ let entries = [];
337
+ try {
338
+ entries = await fs.readdir(tracker.opsDir, { withFileTypes: true });
339
+ } catch {
340
+ return [];
341
+ }
342
+ const out = [];
343
+ for (const entry of entries) {
344
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
345
+ try { out.push(await readJson(path.join(tracker.opsDir, entry.name))); } catch {}
346
+ }
347
+ out.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
348
+ return out;
349
+ }
350
+
351
+ export async function readGitOplogChange(tracker, opId) {
352
+ if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
353
+ const id = String(opId || '').trim();
354
+ if (!id) throw new Error('Missing change id');
355
+ return readJson(path.join(tracker.opsDir, `${id}.json`));
356
+ }
357
+
358
+ export async function readGitOplogPatch(tracker, opId) {
359
+ const op = await readGitOplogChange(tracker, opId);
360
+ return fs.readFile(op.patchPath, 'utf8');
361
+ }
362
+
363
+ export async function undoGitOplogChange(tracker, opId) {
364
+ if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
365
+ const op = await readGitOplogChange(tracker, opId);
366
+ if (op.revertedAt) {
367
+ return { ok: false, alreadyReverted: true, changeSetId: op.id, message: 'Change already reverted' };
368
+ }
369
+ const patch = await fs.readFile(op.patchPath, 'utf8');
370
+ try {
371
+ await runGit(['apply', '-R', '--check', '--whitespace=nowarn'], {
372
+ cwd: tracker.workspaceRoot,
373
+ input: patch,
374
+ timeoutMs: 120_000
375
+ });
376
+ } catch (error) {
377
+ throw new Error(`Cannot undo this change cleanly because newer edits conflict with it. Undo newer changes first, or revert it manually. ${error?.message || ''}`.trim());
378
+ }
379
+ await runGit(['apply', '-R', '--whitespace=nowarn'], {
380
+ cwd: tracker.workspaceRoot,
381
+ input: patch,
382
+ timeoutMs: 120_000
383
+ });
384
+ op.revertedAt = new Date().toISOString();
385
+ await writeJson(path.join(tracker.opsDir, `${op.id}.json`), op);
386
+ return { ok: true, changeSetId: op.id };
387
+ }
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { normalizePath } from './string-utils.js';
4
+
5
+ function normalizeRelativePath(value) {
6
+ const text = normalizePath(String(value || '').trim()).replace(/:\d+(?:-\d+)?$/, '');
7
+ if (!text || text === '.' || path.isAbsolute(text) || text.startsWith('../')) return '';
8
+ return text;
9
+ }
10
+
11
+ function safeBackupRelativePath(relativePath) {
12
+ return normalizeRelativePath(relativePath)
13
+ .split('/')
14
+ .map((part) => part.replace(/[<>:"\\|?*\x00-\x1F]/g, '_'))
15
+ .join('/');
16
+ }
17
+
18
+ async function readJson(filePath, fallback) {
19
+ try {
20
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
21
+ } catch {
22
+ return fallback;
23
+ }
24
+ }
25
+
26
+ async function writeJson(filePath, value) {
27
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
28
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
29
+ }
30
+
31
+ export async function createNonGitBackupManager({ workspaceRoot = process.cwd(), sessionId } = {}) {
32
+ const root = path.resolve(workspaceRoot);
33
+ const id = String(sessionId || '').trim();
34
+ if (!id) return null;
35
+ const backupRoot = path.join(root, '.codemini', 'backups', 'sessions', id);
36
+ const filesRoot = path.join(backupRoot, 'files');
37
+ const manifestPath = path.join(backupRoot, 'manifest.json');
38
+ await fs.mkdir(filesRoot, { recursive: true });
39
+ const manifest = await readJson(manifestPath, {
40
+ version: 1,
41
+ sessionId: id,
42
+ createdAt: new Date().toISOString(),
43
+ files: {}
44
+ });
45
+
46
+ async function backupOnce(relativePath) {
47
+ const normalized = normalizeRelativePath(relativePath);
48
+ if (!normalized) return { ok: false, reason: 'invalid-path' };
49
+ const existing = manifest.files?.[normalized];
50
+ if (existing) {
51
+ return { ...existing, ok: true, reused: true, created: false };
52
+ }
53
+
54
+ const target = path.resolve(root, normalized);
55
+ const rel = path.relative(root, target);
56
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
57
+ return { ok: false, path: normalized, reason: 'outside-workspace' };
58
+ }
59
+
60
+ let stat;
61
+ try {
62
+ stat = await fs.stat(target);
63
+ } catch (error) {
64
+ if (error?.code !== 'ENOENT') throw error;
65
+ const entry = {
66
+ path: normalized,
67
+ existed: false,
68
+ backupPath: '',
69
+ backupRelativePath: '',
70
+ createdAt: new Date().toISOString()
71
+ };
72
+ manifest.files[normalized] = entry;
73
+ await writeJson(manifestPath, manifest);
74
+ return { ...entry, ok: true, reused: false, created: false };
75
+ }
76
+
77
+ if (!stat.isFile()) {
78
+ const entry = {
79
+ path: normalized,
80
+ existed: true,
81
+ backupPath: '',
82
+ backupRelativePath: '',
83
+ skipped: true,
84
+ reason: stat.isDirectory() ? 'directory' : 'not-file',
85
+ createdAt: new Date().toISOString()
86
+ };
87
+ manifest.files[normalized] = entry;
88
+ await writeJson(manifestPath, manifest);
89
+ return { ...entry, ok: true, reused: false, created: false };
90
+ }
91
+
92
+ const safeRelative = safeBackupRelativePath(normalized);
93
+ const backupPath = path.join(filesRoot, safeRelative);
94
+ await fs.mkdir(path.dirname(backupPath), { recursive: true });
95
+ await fs.copyFile(target, backupPath);
96
+ const entry = {
97
+ path: normalized,
98
+ existed: true,
99
+ backupPath,
100
+ backupRelativePath: normalizePath(path.relative(root, backupPath)),
101
+ createdAt: new Date().toISOString()
102
+ };
103
+ manifest.files[normalized] = entry;
104
+ await writeJson(manifestPath, manifest);
105
+ return { ...entry, ok: true, reused: false, created: true };
106
+ }
107
+
108
+ return {
109
+ mode: 'non-git-backup',
110
+ workspaceRoot: root,
111
+ sessionId: id,
112
+ backupRoot,
113
+ manifestPath,
114
+ backupOnce
115
+ };
116
+ }