@mod-computer/cli 0.2.3 → 0.2.5

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 (75) hide show
  1. package/dist/cli.bundle.js +216 -36371
  2. package/package.json +3 -3
  3. package/dist/app.js +0 -227
  4. package/dist/cli.bundle.js.map +0 -7
  5. package/dist/cli.js +0 -132
  6. package/dist/commands/add.js +0 -245
  7. package/dist/commands/agents-run.js +0 -71
  8. package/dist/commands/auth.js +0 -259
  9. package/dist/commands/branch.js +0 -1411
  10. package/dist/commands/claude-sync.js +0 -772
  11. package/dist/commands/comment.js +0 -568
  12. package/dist/commands/diff.js +0 -182
  13. package/dist/commands/index.js +0 -73
  14. package/dist/commands/init.js +0 -597
  15. package/dist/commands/ls.js +0 -135
  16. package/dist/commands/members.js +0 -687
  17. package/dist/commands/mv.js +0 -282
  18. package/dist/commands/recover.js +0 -207
  19. package/dist/commands/rm.js +0 -257
  20. package/dist/commands/spec.js +0 -386
  21. package/dist/commands/status.js +0 -296
  22. package/dist/commands/sync.js +0 -119
  23. package/dist/commands/trace.js +0 -1752
  24. package/dist/commands/workspace.js +0 -447
  25. package/dist/components/conflict-resolution-ui.js +0 -120
  26. package/dist/components/messages.js +0 -5
  27. package/dist/components/thread.js +0 -8
  28. package/dist/config/features.js +0 -83
  29. package/dist/containers/branches-container.js +0 -140
  30. package/dist/containers/directory-container.js +0 -92
  31. package/dist/containers/thread-container.js +0 -214
  32. package/dist/containers/threads-container.js +0 -27
  33. package/dist/containers/workspaces-container.js +0 -27
  34. package/dist/daemon/conflict-resolution.js +0 -172
  35. package/dist/daemon/content-hash.js +0 -31
  36. package/dist/daemon/file-sync.js +0 -985
  37. package/dist/daemon/index.js +0 -203
  38. package/dist/daemon/mime-types.js +0 -166
  39. package/dist/daemon/offline-queue.js +0 -211
  40. package/dist/daemon/path-utils.js +0 -64
  41. package/dist/daemon/share-policy.js +0 -83
  42. package/dist/daemon/wasm-errors.js +0 -189
  43. package/dist/daemon/worker.js +0 -557
  44. package/dist/daemon-worker.js +0 -258
  45. package/dist/errors/workspace-errors.js +0 -48
  46. package/dist/lib/auth-server.js +0 -216
  47. package/dist/lib/browser.js +0 -35
  48. package/dist/lib/diff.js +0 -284
  49. package/dist/lib/formatters.js +0 -204
  50. package/dist/lib/git.js +0 -137
  51. package/dist/lib/local-fs.js +0 -201
  52. package/dist/lib/prompts.js +0 -56
  53. package/dist/lib/storage.js +0 -213
  54. package/dist/lib/trace-formatters.js +0 -314
  55. package/dist/services/add-service.js +0 -554
  56. package/dist/services/add-validation.js +0 -124
  57. package/dist/services/automatic-file-tracker.js +0 -303
  58. package/dist/services/cli-orchestrator.js +0 -227
  59. package/dist/services/feature-flags.js +0 -187
  60. package/dist/services/file-import-service.js +0 -283
  61. package/dist/services/file-transformation-service.js +0 -218
  62. package/dist/services/logger.js +0 -44
  63. package/dist/services/mod-config.js +0 -67
  64. package/dist/services/modignore-service.js +0 -328
  65. package/dist/services/sync-daemon.js +0 -244
  66. package/dist/services/thread-notification-service.js +0 -50
  67. package/dist/services/thread-service.js +0 -147
  68. package/dist/stores/use-directory-store.js +0 -96
  69. package/dist/stores/use-threads-store.js +0 -46
  70. package/dist/stores/use-workspaces-store.js +0 -54
  71. package/dist/types/add-types.js +0 -99
  72. package/dist/types/config.js +0 -16
  73. package/dist/types/index.js +0 -2
  74. package/dist/types/workspace-connection.js +0 -53
  75. package/dist/types.js +0 -1
@@ -1,140 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
3
- import { Box, Text, useInput } from 'ink';
4
- import { readModConfig, writeModConfig } from '../services/mod-config.js';
5
- import { BranchService } from '@mod/mod-core/services/branch-service';
6
- import { ThreadService } from '@mod/mod-core/services/thread-service';
7
- export default function BranchesContainer({ repo, workspace, onSelect }) {
8
- const [threads, setThreads] = useState([]);
9
- const [idx, setIdx] = useState(0);
10
- const [error, setError] = useState(null);
11
- const desiredBranchRef = useRef(null);
12
- useEffect(() => {
13
- try {
14
- desiredBranchRef.current = readModConfig()?.activeBranchId || null;
15
- }
16
- catch {
17
- desiredBranchRef.current = null;
18
- }
19
- }, []);
20
- useEffect(() => {
21
- let cancelled = false;
22
- let subHandle = null;
23
- (async () => {
24
- try {
25
- if (!workspace)
26
- return;
27
- const wsHandle = await repo.find(workspace.id);
28
- let wsDoc = await wsHandle.doc();
29
- if (!wsDoc && !cancelled) {
30
- const deadline = Date.now() + 5000;
31
- while (!wsDoc && Date.now() < deadline) {
32
- try {
33
- wsDoc = await wsHandle.doc();
34
- }
35
- catch { }
36
- if (wsDoc)
37
- break;
38
- await new Promise(r => setTimeout(r, 120));
39
- }
40
- }
41
- const branchesDocId = wsDoc?.branchesDocId;
42
- const buildList = async () => {
43
- try {
44
- const branchService = new BranchService(repo);
45
- const branches = await branchService.getBranchesForWorkspace(branchesDocId);
46
- const list = [];
47
- for (const b of (branches || [])) {
48
- const bId = String(b?.id || '');
49
- if (!bId)
50
- continue;
51
- const tId = b?.threadId ? String(b.threadId) : bId;
52
- list.push({
53
- id: tId,
54
- name: b?.name || 'Thread',
55
- branchId: bId,
56
- hasThread: Boolean(b?.threadId),
57
- });
58
- }
59
- if (!cancelled)
60
- setThreads(list);
61
- if (!cancelled && desiredBranchRef.current) {
62
- const matchIdx = list.findIndex(t => t.branchId === desiredBranchRef.current);
63
- if (matchIdx >= 0) {
64
- setIdx(matchIdx);
65
- desiredBranchRef.current = null;
66
- }
67
- }
68
- }
69
- catch (e) {
70
- if (!cancelled)
71
- setError(e?.message || 'Failed to load threads');
72
- }
73
- };
74
- if (!branchesDocId) {
75
- if (!cancelled)
76
- setThreads([]);
77
- return;
78
- }
79
- // Subscribe to branchesDoc hydration and rebuild on change
80
- try {
81
- subHandle = await repo.find(branchesDocId);
82
- const onChange = async () => { await buildList(); };
83
- try {
84
- subHandle.on && subHandle.on('change', onChange);
85
- }
86
- catch { }
87
- }
88
- catch { }
89
- await buildList();
90
- }
91
- catch (e) {
92
- if (!cancelled)
93
- setError(e?.message || 'Failed to load threads');
94
- }
95
- })();
96
- return () => { cancelled = true; try {
97
- subHandle && subHandle.off && subHandle.off('change');
98
- }
99
- catch { } ; };
100
- }, [repo, workspace]);
101
- useInput((input, key) => {
102
- if (key.downArrow)
103
- setIdx(i => Math.min(i + 1, Math.max(0, threads.length - 1)));
104
- else if (key.upArrow)
105
- setIdx(i => Math.max(i - 1, 0));
106
- else if (key.return) {
107
- (async () => {
108
- const t = threads[idx];
109
- if (!t)
110
- return;
111
- let next = t;
112
- if (t.branchId)
113
- writeModConfig({ activeBranchId: t.branchId });
114
- // If id equals branchId, there is no thread yet; create lazily now
115
- if (t.branchId && String(t.id) === String(t.branchId)) {
116
- try {
117
- const wsHandle = await repo.find(workspace.id);
118
- const threadService = new ThreadService(repo);
119
- const createdId = await threadService.createThread(t.name || 'New Thread', t.branchId);
120
- // Persist on branch for branch-first model
121
- try {
122
- const bService = new BranchService(repo);
123
- const wsDoc = await wsHandle.doc();
124
- if (wsDoc?.branchesDocId) {
125
- await bService.setThreadId(t.branchId, createdId, wsDoc.branchesDocId);
126
- }
127
- }
128
- catch { }
129
- next = { ...t, id: createdId, hasThread: true };
130
- }
131
- catch { }
132
- }
133
- onSelect(next);
134
- })();
135
- }
136
- });
137
- if (error)
138
- return _jsx(Text, { color: "red", children: error });
139
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Branches (threads)" }), threads.length === 0 ? (_jsx(Text, { color: "gray", children: "No threads" })) : (threads.map((t, i) => (_jsxs(Text, { color: i === idx ? 'green' : undefined, children: [t.name, ' ', _jsxs(Text, { color: "gray", children: ["(", t.branchId || 'no-branch', ")", t.hasThread === false ? ' — no thread (will create on select)' : ''] })] }, t.id)))), _jsx(Text, { color: "cyan", children: "Enter to select" })] }));
140
- }
@@ -1,92 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
3
- import { Box, Text, useInput } from 'ink';
4
- import { useDirectoryStore, useDirectoryLoader } from '../stores/use-directory-store.js';
5
- import fs from 'fs';
6
- import path from 'path';
7
- export default function DirectoryPageContainer({ repo, selectedWorkspace, selectedThread }) {
8
- const { loadRootDirectory } = useDirectoryLoader();
9
- const items = useDirectoryStore((s) => s.items);
10
- const rootIds = useDirectoryStore((s) => s.rootIds);
11
- const loading = useDirectoryStore((s) => s.loading);
12
- const error = useDirectoryStore((s) => s.error);
13
- const [selectedIndex, setSelectedIndex] = useState(0);
14
- const [status, setStatus] = useState(null);
15
- useEffect(() => {
16
- let cancelled = false;
17
- async function loadOnly() {
18
- loadRootDirectory({ repo, selectedWorkspace, selectedThread });
19
- }
20
- loadOnly();
21
- return () => {
22
- cancelled = true;
23
- };
24
- }, [repo, selectedWorkspace, selectedThread, loadRootDirectory]);
25
- useInput((input, key) => {
26
- if (key.downArrow) {
27
- setSelectedIndex(i => Math.min(i + 1, rootIds.length - 1));
28
- }
29
- else if (key.upArrow) {
30
- setSelectedIndex(i => Math.max(i - 1, 0));
31
- }
32
- else if (key.return && key.shift) {
33
- // TODO: Handle Shift+Enter (expand/collapse folder)
34
- }
35
- else if (key.return) {
36
- // Handle Enter (open file/folder)
37
- const id = rootIds[selectedIndex];
38
- const item = items[id];
39
- if (item && item.type === 'file') {
40
- openFileInEditor(item).catch(e => setStatus('Failed to open file: ' + e.message));
41
- }
42
- // TODO: Handle folder navigation
43
- }
44
- });
45
- async function openFileInEditor(item) {
46
- setStatus('Updating working directory...');
47
- // 1. Fetch file content from Automerge in branch context
48
- let content = '';
49
- let fileWrap = null;
50
- try {
51
- const { BranchableRepo } = await import('@mod/mod-core/services/branchable-repo');
52
- const br = new BranchableRepo(repo);
53
- const branchId = selectedThread?.branchId || selectedThread?.id;
54
- const workspaceId = selectedWorkspace?.id;
55
- fileWrap = await br.openHandle(item.id, { branchId, workspaceId });
56
- const fileDoc = fileWrap.doc();
57
- content = (fileDoc.content || fileDoc.text || fileDoc.markdown || '');
58
- }
59
- catch {
60
- // Fallback to plain repo handle
61
- const fh = await repo.find(item.id);
62
- const d = await fh.doc();
63
- content = d.content || d.text || '';
64
- fileWrap = fh;
65
- }
66
- // 2. Write to working directory (materialize)
67
- try {
68
- const fileName = (item?.name || `${item.id}.txt`);
69
- const outPath = path.resolve(process.cwd(), fileName);
70
- // Ensure parent directory exists
71
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
72
- fs.writeFileSync(outPath, content ?? '', 'utf8');
73
- setStatus(`Wrote ${fileName} to working directory.`);
74
- }
75
- catch (e) {
76
- setStatus(`Failed to write file: ${e.message}`);
77
- }
78
- setTimeout(() => setStatus(null), 2000);
79
- }
80
- if (loading)
81
- return _jsx(Text, { children: "Loading files..." });
82
- if (error)
83
- return _jsx(Text, { color: "red", children: error });
84
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Files & Folders (root)" }), rootIds.length === 0 ? (_jsx(Text, { color: "gray", children: "No files or folders found." })) : (rootIds.map((id, idx) => {
85
- const item = items[id];
86
- if (!item)
87
- return null;
88
- // Make key unique by prefixing with type and index
89
- const key = `${item.type}-${item.id}-${idx}`;
90
- return (_jsxs(Text, { color: idx === selectedIndex ? 'green' : undefined, children: [item.type === 'folder' ? '\ud83d\udcc1' : '\ud83d\udcc4', " ", item.name] }, key));
91
- })), status && _jsx(Text, { color: "yellow", children: status })] }));
92
- }
@@ -1,214 +0,0 @@
1
- import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useState, useCallback } from 'react';
3
- import ThreadView from '../components/thread.js';
4
- import { CLIThreadService } from '../services/thread-service.js';
5
- import { log } from '../services/logger.js';
6
- export default function ThreadPageContainer({ repo, activeThread, workspace, pendingChatInput, onChatInputHandled, handleInputSubmit, }) {
7
- // Remove input state, only use for agent stream if needed
8
- const [messages, setMessages] = useState([]);
9
- const [agentChunks, setAgentChunks] = useState([]);
10
- const [threadService, setThreadService] = useState(null);
11
- // Helper to parse bold (**bold**) in content for CLI
12
- const parseContentSegments = useCallback((content) => {
13
- const segments = [];
14
- let currentIndex = 0;
15
- while (currentIndex < content.length) {
16
- const boldStart = content.indexOf('**', currentIndex);
17
- if (boldStart === -1) {
18
- segments.push({ text: content.slice(currentIndex), isBold: false });
19
- break;
20
- }
21
- if (boldStart > currentIndex) {
22
- segments.push({ text: content.slice(currentIndex, boldStart), isBold: false });
23
- }
24
- const boldEnd = content.indexOf('**', boldStart + 2);
25
- if (boldEnd === -1) {
26
- segments.push({ text: content.slice(currentIndex), isBold: false });
27
- break;
28
- }
29
- segments.push({ text: content.slice(boldStart + 2, boldEnd), isBold: true });
30
- currentIndex = boldEnd + 2;
31
- }
32
- return segments;
33
- }, []);
34
- // Setup thread service
35
- useEffect(() => {
36
- setThreadService(new CLIThreadService(repo));
37
- }, [repo]);
38
- // Subscribe to thread changes for real-time updates
39
- useEffect(() => {
40
- if (!activeThread || !repo)
41
- return;
42
- let cancelled = false;
43
- let messageUnsubs = [];
44
- let threadUnsub;
45
- async function subscribe() {
46
- const threadId = activeThread.id;
47
- let tHandle;
48
- try {
49
- tHandle = await repo.find(threadId);
50
- }
51
- catch (e) {
52
- console.warn('[CLI][Thread] thread handle not available yet, will retry on next tick');
53
- return;
54
- }
55
- async function updateMessagesAndSubs() {
56
- if (cancelled)
57
- return;
58
- let tDoc;
59
- try {
60
- tDoc = tHandle.doc();
61
- }
62
- catch {
63
- return;
64
- }
65
- const itemIds = Array.isArray(tDoc?.itemIds) ? tDoc.itemIds : [];
66
- // Unsubscribe previous message listeners
67
- messageUnsubs.forEach(unsub => unsub());
68
- // Subscribe to all message docs
69
- const results = await Promise.allSettled(itemIds.map(id => repo.find(id)));
70
- const messageHandles = results
71
- .filter(r => r.status === 'fulfilled')
72
- .map(r => r.value);
73
- function updateMessages() {
74
- if (cancelled)
75
- return;
76
- const msgs = [];
77
- for (const h of messageHandles) {
78
- try {
79
- const mDoc = h.doc();
80
- msgs.push({
81
- id: mDoc.id,
82
- text: mDoc.content || mDoc.text || '',
83
- timestamp: mDoc.timestamp || Date.now(),
84
- userType: mDoc.userType || 'assistant',
85
- user: mDoc.user || null,
86
- _contentHash: (mDoc.content || mDoc.text || '').length,
87
- });
88
- }
89
- catch { }
90
- }
91
- setMessages(msgs);
92
- }
93
- messageUnsubs = messageHandles.map(h => {
94
- h.on('change', updateMessages);
95
- return () => h.off('change', updateMessages);
96
- });
97
- updateMessages();
98
- }
99
- tHandle.on('change', updateMessagesAndSubs);
100
- threadUnsub = () => tHandle.off('change', updateMessagesAndSubs);
101
- await updateMessagesAndSubs();
102
- }
103
- subscribe();
104
- return () => {
105
- cancelled = true;
106
- messageUnsubs.forEach(unsub => unsub());
107
- if (threadUnsub)
108
- threadUnsub();
109
- };
110
- }, [activeThread, repo]);
111
- // If pendingChatInput changes, send it as a message
112
- useEffect(() => {
113
- if (pendingChatInput && pendingChatInput.trim() !== '') {
114
- handleChatInput(pendingChatInput);
115
- onChatInputHandled();
116
- }
117
- // eslint-disable-next-line react-hooks/exhaustive-deps
118
- }, [pendingChatInput]);
119
- // Handle chat/command input
120
- const handleChatInput = async (value) => {
121
- // Navigation commands are handled by App
122
- if (value.trim().startsWith('/')) {
123
- handleInputSubmit(value);
124
- return;
125
- }
126
- if (!activeThread || !threadService || !workspace || !repo)
127
- return;
128
- const wsHandle = await repo.find(workspace.id);
129
- const wsDoc = await wsHandle.doc();
130
- // Support @agent mention override (e.g., "@dev: fix this bug")
131
- const extractAgentMention = (text) => {
132
- const leading = text.replace(/^\s+/, '');
133
- const match = leading.match(/^@([a-zA-Z][\w-]*)\b:?\s*/);
134
- if (!match)
135
- return null;
136
- const name = match[1].toLowerCase();
137
- const message = leading.slice(match[0].length);
138
- return { name, message };
139
- };
140
- async function loadAgentByName(name) {
141
- const n = String(name || '').toLowerCase();
142
- try {
143
- if (n === 'dev' || n === 'developer') {
144
- const url = new URL('../../../mod-agents/dev-agent/dist/index.mjs', import.meta.url).toString();
145
- const mod = await import(url);
146
- try {
147
- log('[ThreadContainer] loaded dev agent module keys:', Object.keys(mod));
148
- }
149
- catch { }
150
- return mod.devAgent || mod.default?.devAgent || mod.default || mod;
151
- }
152
- if (n === 'planner' || n === 'plan') {
153
- const url = new URL('../../../mod-agents/planner/dist/index.mjs', import.meta.url).toString();
154
- const mod = await import(url);
155
- try {
156
- log('[ThreadContainer] loaded planner agent module keys:', Object.keys(mod));
157
- }
158
- catch { }
159
- return mod.plannerAgent || mod.default?.plannerAgent || mod.default || mod;
160
- }
161
- if (n === 'general' || n === 'assistant') {
162
- const url = new URL('../../../mod-agents/dev-agent/dist/index.mjs', import.meta.url).toString();
163
- const mod = await import(url);
164
- try {
165
- log('[ThreadContainer] loaded general agent module keys:', Object.keys(mod));
166
- }
167
- catch { }
168
- return mod.devAgent || mod.default?.devAgent || mod.default || mod;
169
- }
170
- }
171
- catch (e) {
172
- try {
173
- log('[ThreadContainer] loadAgentByName error:', String(e));
174
- }
175
- catch { }
176
- }
177
- return undefined;
178
- }
179
- // Prefer explicit @agent mention without loading a default agent first to avoid unnecessary module graphs
180
- let textToSend = value;
181
- const mention = extractAgentMention(value);
182
- let agent;
183
- if (mention) {
184
- agent = (await loadAgentByName(mention.name)) || { id: mention.name, name: mention.name };
185
- textToSend = mention.message.trimStart();
186
- }
187
- else {
188
- const baseAgentId = (wsDoc.activeAgents && wsDoc.activeAgents[0]) || 'general';
189
- agent = (await loadAgentByName(baseAgentId)) || { id: baseAgentId, name: baseAgentId };
190
- }
191
- // 3. Stream agent chat (user message + agent response)
192
- let sawChunk = false;
193
- setAgentChunks([]); // Clear previous stream
194
- try {
195
- for await (const chunk of threadService.streamAgentChat({
196
- threadId: activeThread.id,
197
- userMessage: textToSend,
198
- user: { id: 'user-1', name: 'You' },
199
- workspace: wsDoc,
200
- agent,
201
- })) {
202
- sawChunk = true;
203
- setAgentChunks(prev => [...prev, chunk]); // Add chunk to static stream
204
- }
205
- if (!sawChunk) {
206
- // Optionally show a message or log
207
- }
208
- }
209
- catch (err) {
210
- // Optionally show error
211
- }
212
- };
213
- return (_jsx(_Fragment, { children: _jsx(ThreadView, { activeThread: activeThread, messages: Array.isArray(messages) ? messages : [], parseContentSegments: parseContentSegments, messageKeyProp: "_contentHash" }) }));
214
- }
@@ -1,27 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect } from 'react';
3
- import { Box, Text } from 'ink';
4
- import SelectInput from 'ink-select-input';
5
- import { useThreadsStore } from '../stores/use-threads-store.js';
6
- export default function ThreadsListContainer({ repo, workspace, onSelect }) {
7
- const threads = useThreadsStore(s => s.threads);
8
- const loading = useThreadsStore(s => s.loading);
9
- const error = useThreadsStore(s => s.error);
10
- const fetchThreads = useThreadsStore(s => s.fetchThreads);
11
- useEffect(() => {
12
- if (repo && workspace)
13
- fetchThreads(repo, workspace);
14
- }, [repo, workspace, fetchThreads]);
15
- if (loading)
16
- return _jsx(Text, { children: "Loading threads..." });
17
- if (error)
18
- return _jsxs(Text, { color: "red", children: ["Error: ", error] });
19
- if (!threads.length)
20
- return _jsx(Text, { children: "No threads found in workspace." });
21
- const items = threads.map(t => ({ label: t.name, value: t.id }));
22
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select a thread:" }), _jsx(SelectInput, { items: items, onSelect: item => {
23
- const thread = threads.find(t => t.id === item.value);
24
- if (thread)
25
- onSelect(thread);
26
- } })] }));
27
- }
@@ -1,27 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect } from 'react';
3
- import { Box, Text } from 'ink';
4
- import SelectInput from 'ink-select-input';
5
- import { useWorkspacesStore } from '../stores/use-workspaces-store.js';
6
- export default function WorkspacesListContainer({ repo, onSelect }) {
7
- const workspaces = useWorkspacesStore(s => s.workspaces);
8
- const loading = useWorkspacesStore(s => s.loading);
9
- const error = useWorkspacesStore(s => s.error);
10
- const fetchWorkspaces = useWorkspacesStore(s => s.fetchWorkspaces);
11
- useEffect(() => {
12
- if (repo)
13
- fetchWorkspaces(repo);
14
- }, [repo, fetchWorkspaces]);
15
- if (loading)
16
- return _jsx(Text, { children: "Loading workspaces..." });
17
- if (error)
18
- return _jsxs(Text, { color: "red", children: ["Error: ", error] });
19
- if (!workspaces.length)
20
- return _jsx(Text, { children: "No workspaces found in root doc." });
21
- const items = workspaces.map(w => ({ label: w.name, value: w.id }));
22
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select a workspace:" }), _jsx(SelectInput, { items: items, onSelect: item => {
23
- const ws = workspaces.find(w => w.id === item.value);
24
- if (ws)
25
- onSelect(ws);
26
- } })] }));
27
- }
@@ -1,172 +0,0 @@
1
- // glassware[type="implementation", id="impl-conflict-resolution--323bddc9", requirements="requirement-cli-sync-conflict-lww--626f73f4,requirement-cli-sync-conflict-log--7ac92a94"]
2
- /**
3
- * Conflict resolution for bidirectional sync.
4
- * Implements last-write-wins strategy with conflict logging.
5
- */
6
- import fs from 'fs';
7
- import path from 'path';
8
- import { getLogsDir } from '../lib/storage.js';
9
- /**
10
- * Resolve a conflict between local and remote file states.
11
- * Uses last-write-wins strategy with local preference for concurrent edits.
12
- *
13
- * @param local - Local file state
14
- * @param remote - Remote file state (from Automerge doc)
15
- * @returns Resolution decision
16
- */
17
- export function resolveConflict(local, remote) {
18
- // If content hashes match, no conflict (same content)
19
- if (local.contentHash && remote.contentHash && local.contentHash === remote.contentHash) {
20
- return {
21
- resolution: 'use-local', // Doesn't matter, they're the same
22
- reason: 'Content identical (same hash)',
23
- };
24
- }
25
- // If local file hasn't been modified since last sync, use remote
26
- // Check both timestamp AND content hash difference to handle cases where
27
- // remote metadata.updatedAt isn't updated (e.g., web editor edits)
28
- if (!local.modified) {
29
- // Local hasn't changed, so any remote difference should be applied
30
- if (remote.updatedAt > local.lastSyncedAt) {
31
- return {
32
- resolution: 'use-remote',
33
- reason: 'Local unchanged, remote updated (by timestamp)',
34
- };
35
- }
36
- // Even if timestamp isn't newer, if content is different, use remote
37
- // This handles cases where the web editor doesn't update metadata.updatedAt
38
- if (local.contentHash && remote.contentHash && local.contentHash !== remote.contentHash) {
39
- return {
40
- resolution: 'use-remote',
41
- reason: 'Local unchanged, remote content differs',
42
- };
43
- }
44
- }
45
- // If local was modified and remote hasn't changed since last sync, use local
46
- if (local.modified && remote.updatedAt <= local.lastSyncedAt) {
47
- return {
48
- resolution: 'use-local',
49
- reason: 'Local modified, remote unchanged since last sync',
50
- };
51
- }
52
- // Both modified concurrently - local wins (user's active work takes precedence)
53
- // Future enhancement: implement actual merge for text files
54
- return {
55
- resolution: 'use-local',
56
- reason: 'Both modified concurrently, local wins (user active work)',
57
- };
58
- }
59
- /**
60
- * Get the path to the conflict log file.
61
- */
62
- export function getConflictLogPath() {
63
- return path.join(getLogsDir(), 'conflicts.log');
64
- }
65
- /**
66
- * Log a conflict record to the conflicts log file.
67
- */
68
- export function logConflict(conflict) {
69
- const logPath = getConflictLogPath();
70
- const logDir = path.dirname(logPath);
71
- // Ensure log directory exists
72
- if (!fs.existsSync(logDir)) {
73
- fs.mkdirSync(logDir, { recursive: true });
74
- }
75
- const line = `${conflict.timestamp} [${conflict.resolution.toUpperCase()}] ${conflict.path} ` +
76
- `(local: ${conflict.localTimestamp}, remote: ${conflict.remoteTimestamp}) - ${conflict.reason}\n`;
77
- fs.appendFileSync(logPath, line);
78
- }
79
- /**
80
- * Create a conflict record and log it.
81
- */
82
- export function recordConflict(local, remote, resolution, reason) {
83
- const record = {
84
- path: local.path,
85
- localTimestamp: local.localMtime,
86
- remoteTimestamp: remote.updatedAt,
87
- resolution,
88
- timestamp: new Date().toISOString(),
89
- reason,
90
- };
91
- logConflict(record);
92
- return record;
93
- }
94
- /**
95
- * Read recent conflicts from the log file.
96
- * @param limit - Maximum number of conflicts to return
97
- * @returns Array of conflict records (most recent first)
98
- */
99
- export function readRecentConflicts(limit = 100) {
100
- const logPath = getConflictLogPath();
101
- if (!fs.existsSync(logPath)) {
102
- return [];
103
- }
104
- try {
105
- const content = fs.readFileSync(logPath, 'utf-8');
106
- const lines = content.trim().split('\n').filter(Boolean);
107
- // Parse lines and return most recent first
108
- return lines
109
- .slice(-limit)
110
- .reverse()
111
- .map(parseLine)
112
- .filter((record) => record !== null);
113
- }
114
- catch (error) {
115
- console.warn('[conflict-resolution] Could not read conflict log');
116
- return [];
117
- }
118
- }
119
- /**
120
- * Parse a conflict log line back into a record.
121
- * Format: TIMESTAMP [RESOLUTION] PATH (local: TIMESTAMP, remote: TIMESTAMP) - REASON
122
- */
123
- function parseLine(line) {
124
- const match = line.match(/^(\S+) \[(\w+-\w+)\] (.+?) \(local: ([^,]+), remote: ([^)]+)\) - (.+)$/);
125
- if (!match) {
126
- return null;
127
- }
128
- return {
129
- timestamp: match[1],
130
- resolution: match[2],
131
- path: match[3],
132
- localTimestamp: match[4],
133
- remoteTimestamp: match[5],
134
- reason: match[6],
135
- };
136
- }
137
- /**
138
- * ConflictResolver - manages conflict resolution for a sync session.
139
- */
140
- export class ConflictResolver {
141
- constructor(logger = console.log) {
142
- this.conflictCount = 0;
143
- this.log = logger;
144
- }
145
- /**
146
- * Check and resolve a potential conflict between local and remote states.
147
- * @returns The resolution decision and whether a conflict was detected
148
- */
149
- checkAndResolve(local, remote) {
150
- const { resolution, reason } = resolveConflict(local, remote);
151
- // It's only a "conflict" if both sides were modified
152
- const isConflict = local.modified && remote.updatedAt > local.lastSyncedAt;
153
- if (isConflict) {
154
- this.conflictCount++;
155
- recordConflict(local, remote, resolution, reason);
156
- this.log(`[conflict] ${local.path}: ${reason} → ${resolution}`);
157
- }
158
- return { resolution, reason, isConflict };
159
- }
160
- /**
161
- * Get the number of conflicts detected in this session.
162
- */
163
- getConflictCount() {
164
- return this.conflictCount;
165
- }
166
- /**
167
- * Reset the conflict count (e.g., at start of new sync session).
168
- */
169
- resetCount() {
170
- this.conflictCount = 0;
171
- }
172
- }