@mod-computer/cli 0.2.4 → 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.
- package/package.json +3 -3
- package/dist/app.js +0 -227
- package/dist/cli.bundle.js.map +0 -7
- package/dist/cli.js +0 -132
- package/dist/commands/add.js +0 -245
- package/dist/commands/agents-run.js +0 -71
- package/dist/commands/auth.js +0 -259
- package/dist/commands/branch.js +0 -1411
- package/dist/commands/claude-sync.js +0 -772
- package/dist/commands/comment.js +0 -568
- package/dist/commands/diff.js +0 -182
- package/dist/commands/index.js +0 -73
- package/dist/commands/init.js +0 -597
- package/dist/commands/ls.js +0 -135
- package/dist/commands/members.js +0 -687
- package/dist/commands/mv.js +0 -282
- package/dist/commands/recover.js +0 -207
- package/dist/commands/rm.js +0 -257
- package/dist/commands/spec.js +0 -386
- package/dist/commands/status.js +0 -296
- package/dist/commands/sync.js +0 -119
- package/dist/commands/trace.js +0 -1752
- package/dist/commands/workspace.js +0 -447
- package/dist/components/conflict-resolution-ui.js +0 -120
- package/dist/components/messages.js +0 -5
- package/dist/components/thread.js +0 -8
- package/dist/config/features.js +0 -83
- package/dist/containers/branches-container.js +0 -140
- package/dist/containers/directory-container.js +0 -92
- package/dist/containers/thread-container.js +0 -214
- package/dist/containers/threads-container.js +0 -27
- package/dist/containers/workspaces-container.js +0 -27
- package/dist/daemon/conflict-resolution.js +0 -172
- package/dist/daemon/content-hash.js +0 -31
- package/dist/daemon/file-sync.js +0 -985
- package/dist/daemon/index.js +0 -203
- package/dist/daemon/mime-types.js +0 -166
- package/dist/daemon/offline-queue.js +0 -211
- package/dist/daemon/path-utils.js +0 -64
- package/dist/daemon/share-policy.js +0 -83
- package/dist/daemon/wasm-errors.js +0 -189
- package/dist/daemon/worker.js +0 -557
- package/dist/daemon-worker.js +0 -258
- package/dist/errors/workspace-errors.js +0 -48
- package/dist/lib/auth-server.js +0 -216
- package/dist/lib/browser.js +0 -35
- package/dist/lib/diff.js +0 -284
- package/dist/lib/formatters.js +0 -204
- package/dist/lib/git.js +0 -137
- package/dist/lib/local-fs.js +0 -201
- package/dist/lib/prompts.js +0 -56
- package/dist/lib/storage.js +0 -213
- package/dist/lib/trace-formatters.js +0 -314
- package/dist/services/add-service.js +0 -554
- package/dist/services/add-validation.js +0 -124
- package/dist/services/automatic-file-tracker.js +0 -303
- package/dist/services/cli-orchestrator.js +0 -227
- package/dist/services/feature-flags.js +0 -187
- package/dist/services/file-import-service.js +0 -283
- package/dist/services/file-transformation-service.js +0 -218
- package/dist/services/logger.js +0 -44
- package/dist/services/mod-config.js +0 -67
- package/dist/services/modignore-service.js +0 -328
- package/dist/services/sync-daemon.js +0 -244
- package/dist/services/thread-notification-service.js +0 -50
- package/dist/services/thread-service.js +0 -147
- package/dist/stores/use-directory-store.js +0 -96
- package/dist/stores/use-threads-store.js +0 -46
- package/dist/stores/use-workspaces-store.js +0 -54
- package/dist/types/add-types.js +0 -99
- package/dist/types/config.js +0 -16
- package/dist/types/index.js +0 -2
- package/dist/types/workspace-connection.js +0 -53
- package/dist/types.js +0 -1
package/dist/daemon/worker.js
DELETED
|
@@ -1,557 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// glassware[type="implementation", id="cli-daemon-worker--f5cd12a8", requirements="requirement-cli-sync-app-3--c2dca5d7,requirement-cli-sync-app-4--ed3c2529,requirement-cli-sync-app-5--a969b725,requirement-cli-sync-qual-3--99893467,requirement-cli-sync-qual-5--3d62024d,requirement-cli-sync-infra-1--4ec9e05c,requirement-cli-files-app-2--6765b2c1,requirement-cli-files-app-3--552aece6,requirement-cli-files-app-4--c5ef157e"]
|
|
3
|
-
// glassware[type="implementation", id="impl-daemon-worker-distributed--2503a678", specifications="specification-spec-daemon-process--ed4facd5,specification-spec-daemon-websocket--708091f7,specification-spec-daemon-reconnect--1da6516e,specification-spec-daemon-backoff--0fa7d2ed,specification-spec-reconnect-logging--54499791,specification-spec-file-watch--ec0b56ec,specification-spec-offline-cli--c444a9a2,specification-spec-offline-persist--f4fc5073,specification-spec-sync-on-start--d1b0cc16"]
|
|
4
|
-
// glassware[type="implementation", id="impl-daemon-offline-integration--4650abf8", requirements="requirement-cli-sync-offline-queue--08f04bd5,requirement-cli-sync-queue-persist--e35cadaa,requirement-cli-sync-queue-replay--a7954975"]
|
|
5
|
-
// glassware[type="implementation", id="impl-daemon-wasm-integration--272a8dda", requirements="requirement-cli-sync-wasm-detect--5517bb61,requirement-cli-sync-wasm-recover--ca8c0cb6"]
|
|
6
|
-
// glassware[type="implementation", id="impl-daemon-git-branch-scoping--7796ecba", requirements="requirement-cli-git-ux-2--e0fdbd0e,requirement-cli-git-app-4--ffac806a,requirement-cli-git-app-5--d0f0cbd5,requirement-cli-git-qual-3--5af20852"]
|
|
7
|
-
/**
|
|
8
|
-
* Daemon worker - runs as a background process watching directories.
|
|
9
|
-
* Loads workspace connections from ~/.mod/workspaces/ and watches each.
|
|
10
|
-
* Syncs file changes to Automerge documents in the workspace.
|
|
11
|
-
*
|
|
12
|
-
* Features:
|
|
13
|
-
* - Offline queue: Changes are queued when offline and replayed on reconnect
|
|
14
|
-
* - WASM error recovery: Automerge WASM errors are caught and retried
|
|
15
|
-
* - Conflict resolution: Last-write-wins with conflict logging
|
|
16
|
-
*/
|
|
17
|
-
import fs from 'fs';
|
|
18
|
-
import path from 'path';
|
|
19
|
-
import chokidar from 'chokidar';
|
|
20
|
-
import { repo as getRepo } from '@mod/mod-core/repos/repo.node';
|
|
21
|
-
import { cliSharePolicy } from './share-policy.js';
|
|
22
|
-
import { listWorkspaceConnections, getSyncLogPath, getLogsDir, readConfig, } from '../lib/storage.js';
|
|
23
|
-
import { detectGitRepo, readCurrentBranch, isGitOperationInProgress } from '../lib/git.js';
|
|
24
|
-
import { createFileSyncContext, handleFileCreate, handleFileChange, handleFileDelete, performInitialSync, subscribeToRemoteChanges, cleanupRemoteSubscriptions, isPendingWrite, } from './file-sync.js';
|
|
25
|
-
import { DEFAULT_IGNORE_PATTERNS, shouldIgnore } from '@mod/mod-core';
|
|
26
|
-
import { OfflineQueueManager } from './offline-queue.js';
|
|
27
|
-
import { WasmErrorRecovery } from './wasm-errors.js';
|
|
28
|
-
import { normalizePath } from './path-utils.js';
|
|
29
|
-
class DaemonWorker {
|
|
30
|
-
constructor() {
|
|
31
|
-
this.repo = null;
|
|
32
|
-
this.watchers = new Map();
|
|
33
|
-
this.headWatchers = new Map();
|
|
34
|
-
this.fileSyncContexts = new Map();
|
|
35
|
-
this.connections = [];
|
|
36
|
-
this.debounceTimers = new Map();
|
|
37
|
-
this.debounceMs = 100;
|
|
38
|
-
this.isShuttingDown = false;
|
|
39
|
-
this.gitBranches = new Map();
|
|
40
|
-
// Run initial sync by default (can be disabled with MOD_INITIAL_SYNC=false)
|
|
41
|
-
this.runInitialSync = process.env.MOD_INITIAL_SYNC !== 'false';
|
|
42
|
-
// Track online/offline state
|
|
43
|
-
this.isOnline = true;
|
|
44
|
-
this.offlineQueue = new OfflineQueueManager((msg) => this.log(msg));
|
|
45
|
-
this.wasmRecovery = new WasmErrorRecovery({
|
|
46
|
-
maxRetries: 3,
|
|
47
|
-
baseDelayMs: 100,
|
|
48
|
-
logger: (msg) => this.log(msg),
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
async start() {
|
|
52
|
-
this.log('Daemon worker starting');
|
|
53
|
-
// Initialize Automerge repo with CLI sharePolicy
|
|
54
|
-
try {
|
|
55
|
-
this.repo = await getRepo({ sharePolicy: cliSharePolicy });
|
|
56
|
-
this.log('Automerge repo initialized with scoped sharePolicy');
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
this.log(`Failed to initialize Automerge repo: ${error?.message || error}`);
|
|
60
|
-
process.exit(1);
|
|
61
|
-
}
|
|
62
|
-
// Load workspace connections
|
|
63
|
-
this.connections = listWorkspaceConnections();
|
|
64
|
-
if (this.connections.length === 0) {
|
|
65
|
-
this.log('No workspace connections found, exiting');
|
|
66
|
-
process.exit(0);
|
|
67
|
-
}
|
|
68
|
-
this.log(`Found ${this.connections.length} workspace connection(s)`);
|
|
69
|
-
// Start watching each connected directory
|
|
70
|
-
for (const connection of this.connections) {
|
|
71
|
-
await this.watchDirectory(connection);
|
|
72
|
-
}
|
|
73
|
-
// Check auth state
|
|
74
|
-
const config = readConfig();
|
|
75
|
-
if (config.auth) {
|
|
76
|
-
this.log(`Authenticated as ${config.auth.email}`);
|
|
77
|
-
// Cloud sync is handled automatically by mod-core repo with WebSocket
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
this.log('Running in local-only mode (not authenticated)');
|
|
81
|
-
}
|
|
82
|
-
// Set up shutdown handlers
|
|
83
|
-
process.on('SIGTERM', () => this.shutdown('SIGTERM'));
|
|
84
|
-
process.on('SIGINT', () => this.shutdown('SIGINT'));
|
|
85
|
-
// Periodically check for new workspace connections
|
|
86
|
-
setInterval(() => this.checkForNewConnections(), 30000);
|
|
87
|
-
// Start WASM error recovery periodic processing
|
|
88
|
-
this.wasmRecovery.startPeriodicProcessing(1000);
|
|
89
|
-
// Replay any queued offline changes
|
|
90
|
-
if (this.offlineQueue.hasPending()) {
|
|
91
|
-
this.log(`Found ${this.offlineQueue.getPendingCount()} pending offline changes, replaying...`);
|
|
92
|
-
await this.replayOfflineQueue();
|
|
93
|
-
}
|
|
94
|
-
this.log('Daemon worker ready');
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Replay offline queue changes after reconnection.
|
|
98
|
-
*/
|
|
99
|
-
async replayOfflineQueue() {
|
|
100
|
-
const result = await this.offlineQueue.replay(async (change) => {
|
|
101
|
-
const connection = this.connections.find(c => c.workspaceId === change.workspaceId);
|
|
102
|
-
if (!connection) {
|
|
103
|
-
this.log(`[offline-queue] No connection found for workspace ${change.workspaceId}`);
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
const ctx = this.fileSyncContexts.get(connection.path);
|
|
107
|
-
if (!ctx) {
|
|
108
|
-
this.log(`[offline-queue] No sync context for ${connection.path}`);
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
const absolutePath = path.join(connection.path, change.relativePath);
|
|
112
|
-
try {
|
|
113
|
-
switch (change.type) {
|
|
114
|
-
case 'create':
|
|
115
|
-
case 'update':
|
|
116
|
-
if (fs.existsSync(absolutePath)) {
|
|
117
|
-
await handleFileChange(ctx, absolutePath);
|
|
118
|
-
}
|
|
119
|
-
break;
|
|
120
|
-
case 'delete':
|
|
121
|
-
await handleFileDelete(ctx, absolutePath);
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
catch (error) {
|
|
127
|
-
this.log(`[offline-queue] Error replaying ${change.relativePath}: ${error?.message || error}`);
|
|
128
|
-
return false;
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
this.log(`[offline-queue] Replay complete: ${result.synced} synced, ${result.failed} failed`);
|
|
132
|
-
}
|
|
133
|
-
async watchDirectory(connection) {
|
|
134
|
-
if (!fs.existsSync(connection.path)) {
|
|
135
|
-
this.log(`Directory not found: ${connection.path}, skipping`);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
if (!this.repo) {
|
|
139
|
-
this.log(`Repo not initialized, cannot watch ${connection.path}`);
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
// Detect git repo and branch
|
|
143
|
-
const gitInfo = detectGitRepo(connection.path);
|
|
144
|
-
const gitBranch = gitInfo.isGitRepo ? gitInfo.branch : null;
|
|
145
|
-
this.log(`[git-detect] detectGitRepo(${connection.path}): isGitRepo=${gitInfo.isGitRepo}, branch=${gitInfo.branch}, headFile=${gitInfo.headFile}`);
|
|
146
|
-
if (gitInfo.isGitRepo) {
|
|
147
|
-
this.gitBranches.set(connection.path, gitBranch);
|
|
148
|
-
this.log(`Watching: ${connection.path} → ${connection.workspaceName} (git branch: ${gitBranch || 'unknown'})`);
|
|
149
|
-
// Watch .git/HEAD for branch changes
|
|
150
|
-
if (gitInfo.headFile) {
|
|
151
|
-
this.log(`[git-detect] Calling watchGitHead with headFile: ${gitInfo.headFile}`);
|
|
152
|
-
this.watchGitHead(connection, gitInfo.headFile);
|
|
153
|
-
}
|
|
154
|
-
else {
|
|
155
|
-
this.log(`[git-detect] No headFile found, skipping watchGitHead`);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
else {
|
|
159
|
-
this.gitBranches.set(connection.path, null);
|
|
160
|
-
this.log(`Watching: ${connection.path} → ${connection.workspaceName} (not a git repo)`);
|
|
161
|
-
}
|
|
162
|
-
// Create file sync context with logger
|
|
163
|
-
try {
|
|
164
|
-
const ctx = await createFileSyncContext(this.repo, connection, gitBranch, (msg) => this.log(msg));
|
|
165
|
-
this.fileSyncContexts.set(connection.path, ctx);
|
|
166
|
-
this.log(`File sync context created for ${connection.workspaceName}`);
|
|
167
|
-
// Run initial sync if enabled
|
|
168
|
-
if (this.runInitialSync) {
|
|
169
|
-
const ignorePatterns = this.loadIgnorePatterns(connection.path);
|
|
170
|
-
this.log(`Running initial sync for ${connection.workspaceName}...`);
|
|
171
|
-
const stats = await performInitialSync(ctx, ignorePatterns);
|
|
172
|
-
this.log(`Initial sync complete: ${stats.synced} files synced, ${stats.skipped} skipped`);
|
|
173
|
-
}
|
|
174
|
-
// Subscribe to remote changes for bidirectional sync
|
|
175
|
-
await subscribeToRemoteChanges(ctx);
|
|
176
|
-
}
|
|
177
|
-
catch (error) {
|
|
178
|
-
this.log(`Failed to create file sync context for ${connection.workspaceName}: ${error?.message || error}`);
|
|
179
|
-
}
|
|
180
|
-
// Load ignore patterns
|
|
181
|
-
const ignorePatterns = this.loadIgnorePatterns(connection.path);
|
|
182
|
-
const watcher = chokidar.watch(connection.path, {
|
|
183
|
-
ignored: ignorePatterns,
|
|
184
|
-
persistent: true,
|
|
185
|
-
ignoreInitial: true,
|
|
186
|
-
awaitWriteFinish: {
|
|
187
|
-
stabilityThreshold: 100,
|
|
188
|
-
pollInterval: 50,
|
|
189
|
-
},
|
|
190
|
-
});
|
|
191
|
-
watcher.on('add', (filePath) => this.handleFileChange(connection, filePath, 'add'));
|
|
192
|
-
watcher.on('change', (filePath) => this.handleFileChange(connection, filePath, 'change'));
|
|
193
|
-
watcher.on('unlink', (filePath) => this.handleFileChange(connection, filePath, 'unlink'));
|
|
194
|
-
watcher.on('error', (error) => {
|
|
195
|
-
this.log(`Watcher error for ${connection.path}: ${error.message}`);
|
|
196
|
-
});
|
|
197
|
-
this.watchers.set(connection.path, watcher);
|
|
198
|
-
}
|
|
199
|
-
// glassware[type="implementation", id="impl-cli-watch-git-head--fde1da6d", specifications="specification-spec-watch-head--e3035ccf"]
|
|
200
|
-
watchGitHead(connection, headFile) {
|
|
201
|
-
// Watch the .git/HEAD file for branch changes
|
|
202
|
-
this.log(`[git-head] Setting up HEAD watcher for ${connection.workspaceName}: ${headFile}`);
|
|
203
|
-
const headWatcher = chokidar.watch(headFile, {
|
|
204
|
-
persistent: true,
|
|
205
|
-
ignoreInitial: true,
|
|
206
|
-
});
|
|
207
|
-
headWatcher.on('change', async () => {
|
|
208
|
-
const newBranch = readCurrentBranch(headFile);
|
|
209
|
-
const oldBranch = this.gitBranches.get(connection.path);
|
|
210
|
-
this.log(`[git-head] HEAD file changed: ${headFile}, old=${oldBranch}, new=${newBranch}`);
|
|
211
|
-
if (newBranch !== oldBranch) {
|
|
212
|
-
await this.handleBranchSwitch(connection, oldBranch, newBranch);
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
headWatcher.on('error', (error) => {
|
|
216
|
-
this.log(`Git HEAD watcher error for ${connection.path}: ${error.message}`);
|
|
217
|
-
});
|
|
218
|
-
this.headWatchers.set(connection.path, headWatcher);
|
|
219
|
-
}
|
|
220
|
-
/**
|
|
221
|
-
* Handle git branch switch.
|
|
222
|
-
* Updates connection state, logs the change, and re-scopes sync.
|
|
223
|
-
*/
|
|
224
|
-
// glassware[type="implementation", id="impl-cli-handle-branch-switch--87dd375f", specifications="specification-spec-branch-switch--243409fb"]
|
|
225
|
-
async handleBranchSwitch(connection, oldBranch, newBranch) {
|
|
226
|
-
// Update git branch tracking
|
|
227
|
-
this.gitBranches.set(connection.path, newBranch);
|
|
228
|
-
// Log branch change per spec requirement cli-git-ux-2
|
|
229
|
-
this.log(`Git branch changed: ${oldBranch || 'unknown'} → ${newBranch || 'unknown'}`);
|
|
230
|
-
this.log(`Re-scoping sync to branch: ${newBranch || 'default'}`);
|
|
231
|
-
// Update file sync context with new branch
|
|
232
|
-
const ctx = this.fileSyncContexts.get(connection.path);
|
|
233
|
-
if (ctx) {
|
|
234
|
-
// Update the context's git branch
|
|
235
|
-
ctx.gitBranch = newBranch;
|
|
236
|
-
// Record the branch detection so web app sees the change
|
|
237
|
-
this.log(`[branch-switch] ctx.gitBranchService=${!!ctx.gitBranchService}, newBranch=${newBranch}`);
|
|
238
|
-
if (ctx.gitBranchService && newBranch) {
|
|
239
|
-
try {
|
|
240
|
-
// Source ID must match pattern: cli:<alphanumeric-id>
|
|
241
|
-
const clientId = connection.workspaceId.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 20);
|
|
242
|
-
const sourceId = `cli:${clientId}`;
|
|
243
|
-
await ctx.gitBranchService.recordDetection(connection.workspaceId, sourceId, {
|
|
244
|
-
branch: newBranch,
|
|
245
|
-
revision: null,
|
|
246
|
-
dirty: false,
|
|
247
|
-
detached: false
|
|
248
|
-
});
|
|
249
|
-
this.log(`Recorded branch detection for: ${newBranch}`);
|
|
250
|
-
}
|
|
251
|
-
catch (error) {
|
|
252
|
-
this.log(`Failed to record branch detection: ${error?.message || error}`);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
// Re-sync directory for new branch
|
|
256
|
-
await this.resyncDirectory(connection, ctx);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* Re-sync directory after branch switch.
|
|
261
|
-
* Hydrates local state from workspace documents tagged with the current branch.
|
|
262
|
-
*
|
|
263
|
-
* spec-branch-switch--wip: Handles branch switching in CLI
|
|
264
|
-
*/
|
|
265
|
-
// glassware[type="implementation", id="impl-daemon-resync-directory--43425a12", specifications="specification-spec-branch-switch--243409fb,specification-spec-git-operations--addf801f"]
|
|
266
|
-
async resyncDirectory(connection, ctx) {
|
|
267
|
-
const branch = ctx.gitBranch;
|
|
268
|
-
const logPrefix = `[${connection.workspaceName}][${branch || 'default'}]`;
|
|
269
|
-
this.log(`${logPrefix} Re-syncing directory for branch: ${branch || 'default'}`);
|
|
270
|
-
// Check if a git operation is in progress - if so, wait briefly
|
|
271
|
-
if (await isGitOperationInProgress(connection.path)) {
|
|
272
|
-
this.log(`${logPrefix} Git operation in progress, waiting before resync...`);
|
|
273
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
274
|
-
// Check again - if still in progress, skip this sync cycle
|
|
275
|
-
if (await isGitOperationInProgress(connection.path)) {
|
|
276
|
-
this.log(`${logPrefix} Git operation still in progress, skipping resync`);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
if (!branch) {
|
|
281
|
-
this.log(`${logPrefix} No branch context, using default behavior`);
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
try {
|
|
285
|
-
// Get the branch tree using listTree (works with the OLD format that applyOverride writes to)
|
|
286
|
-
const treeResult = await ctx.workspace.gitBranch.listTree({ branch });
|
|
287
|
-
const overrides = treeResult.overrides || {};
|
|
288
|
-
const inherited = treeResult.inherited || {};
|
|
289
|
-
this.log(`${logPrefix} Branch tree: ${Object.keys(overrides).length} overrides, ${Object.keys(inherited).length} inherited`);
|
|
290
|
-
if (Object.keys(overrides).length > 0) {
|
|
291
|
-
this.log(`${logPrefix} Override files: ${Object.keys(overrides).join(', ')}`);
|
|
292
|
-
}
|
|
293
|
-
// Combine overrides and inherited for full tree (path -> documentId)
|
|
294
|
-
const allFiles = { ...inherited, ...overrides }; // overrides take precedence
|
|
295
|
-
const inheritedPaths = new Set(Object.keys(inherited));
|
|
296
|
-
// Clear existing caches to rebuild from branch state
|
|
297
|
-
ctx.filePathCache.clear();
|
|
298
|
-
ctx.docIdToPathCache.clear();
|
|
299
|
-
// Populate caches from branch tree
|
|
300
|
-
for (const [relativePath, docIdStr] of Object.entries(allFiles)) {
|
|
301
|
-
const docId = docIdStr;
|
|
302
|
-
ctx.filePathCache.set(relativePath, docId);
|
|
303
|
-
ctx.docIdToPathCache.set(docId, relativePath);
|
|
304
|
-
}
|
|
305
|
-
// Materialize branch-specific files to local filesystem if they differ
|
|
306
|
-
// Only materialize files that are NOT inherited (branch-specific changes)
|
|
307
|
-
const branchSpecificPaths = Object.keys(overrides);
|
|
308
|
-
this.log(`${logPrefix} ${branchSpecificPaths.length} branch-specific files to check`);
|
|
309
|
-
for (const relativePath of branchSpecificPaths) {
|
|
310
|
-
const docIdStr = overrides[relativePath];
|
|
311
|
-
const absolutePath = path.join(connection.path, relativePath);
|
|
312
|
-
try {
|
|
313
|
-
// Get the document content
|
|
314
|
-
const fileHandle = await ctx.workspace.file.get(docIdStr);
|
|
315
|
-
if (!fileHandle) {
|
|
316
|
-
this.log(`${logPrefix} Could not get file handle for: ${relativePath}`);
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
const doc = fileHandle.doc();
|
|
320
|
-
if (!doc)
|
|
321
|
-
continue;
|
|
322
|
-
// Check if local file exists and differs
|
|
323
|
-
let shouldWrite = false;
|
|
324
|
-
if (!fs.existsSync(absolutePath)) {
|
|
325
|
-
shouldWrite = true;
|
|
326
|
-
}
|
|
327
|
-
else {
|
|
328
|
-
// Compare content hash if available
|
|
329
|
-
const localContent = fs.readFileSync(absolutePath);
|
|
330
|
-
const localHash = (await import('./content-hash.js')).computeContentHash(localContent);
|
|
331
|
-
const remoteHash = doc.metadata?.contentHash;
|
|
332
|
-
if (remoteHash && localHash !== remoteHash) {
|
|
333
|
-
shouldWrite = true;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
if (shouldWrite) {
|
|
337
|
-
// Mark as pending write to avoid circular update
|
|
338
|
-
ctx.pendingWrites.set(relativePath, Date.now() + 2000);
|
|
339
|
-
// Ensure parent directory exists
|
|
340
|
-
const parentDir = path.dirname(absolutePath);
|
|
341
|
-
if (!fs.existsSync(parentDir)) {
|
|
342
|
-
fs.mkdirSync(parentDir, { recursive: true });
|
|
343
|
-
}
|
|
344
|
-
// Write content to local filesystem
|
|
345
|
-
const content = doc.text || doc.binary;
|
|
346
|
-
if (doc.binary) {
|
|
347
|
-
fs.writeFileSync(absolutePath, Buffer.from(doc.binary, 'base64'));
|
|
348
|
-
}
|
|
349
|
-
else if (content) {
|
|
350
|
-
const { getTextContent } = await import('@mod/mod-core');
|
|
351
|
-
const textContent = getTextContent(fileHandle, ['text']);
|
|
352
|
-
fs.writeFileSync(absolutePath, textContent || '');
|
|
353
|
-
}
|
|
354
|
-
this.log(`${logPrefix} Materialized branch file: ${relativePath}`);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
catch (error) {
|
|
358
|
-
this.log(`${logPrefix} Error materializing ${relativePath}: ${error?.message || error}`);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
this.log(`${logPrefix} Branch resync complete`);
|
|
362
|
-
}
|
|
363
|
-
catch (error) {
|
|
364
|
-
this.log(`${logPrefix} Error during resync: ${error?.message || error}`);
|
|
365
|
-
// Fall back to basic behavior
|
|
366
|
-
this.log(`${logPrefix} Branch context updated, sync will use branch: ${branch || 'default'}`);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
loadIgnorePatterns(directoryPath) {
|
|
370
|
-
const patterns = [...DEFAULT_IGNORE_PATTERNS];
|
|
371
|
-
// Load .modignore if it exists
|
|
372
|
-
const modignorePath = path.join(directoryPath, '.modignore');
|
|
373
|
-
if (fs.existsSync(modignorePath)) {
|
|
374
|
-
try {
|
|
375
|
-
const content = fs.readFileSync(modignorePath, 'utf-8');
|
|
376
|
-
const customPatterns = content
|
|
377
|
-
.split('\n')
|
|
378
|
-
.map((line) => line.trim())
|
|
379
|
-
.filter((line) => line && !line.startsWith('#'));
|
|
380
|
-
patterns.push(...customPatterns);
|
|
381
|
-
}
|
|
382
|
-
catch (error) {
|
|
383
|
-
this.log(`Could not read .modignore: ${error?.message || error}`);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
return patterns;
|
|
387
|
-
}
|
|
388
|
-
handleFileChange(connection, filePath, event) {
|
|
389
|
-
// Debounce rapid changes
|
|
390
|
-
const key = filePath;
|
|
391
|
-
const existingTimer = this.debounceTimers.get(key);
|
|
392
|
-
if (existingTimer) {
|
|
393
|
-
clearTimeout(existingTimer);
|
|
394
|
-
}
|
|
395
|
-
const timer = setTimeout(() => {
|
|
396
|
-
this.debounceTimers.delete(key);
|
|
397
|
-
this.processFileChange(connection, filePath, event);
|
|
398
|
-
}, this.debounceMs);
|
|
399
|
-
this.debounceTimers.set(key, timer);
|
|
400
|
-
}
|
|
401
|
-
async processFileChange(connection, filePath, event) {
|
|
402
|
-
const relativePath = normalizePath(filePath, connection.path);
|
|
403
|
-
const branch = this.gitBranches.get(connection.path);
|
|
404
|
-
const branchPrefix = branch ? `[${branch}]` : '';
|
|
405
|
-
const logPrefix = `[${connection.workspaceName}]${branchPrefix}`;
|
|
406
|
-
// Defensive check: skip ignored files (chokidar should filter, but double-check)
|
|
407
|
-
if (shouldIgnore(relativePath)) {
|
|
408
|
-
// Don't log to avoid noise - this is just a safety net
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
// Get file sync context
|
|
412
|
-
const ctx = this.fileSyncContexts.get(connection.path);
|
|
413
|
-
if (!ctx) {
|
|
414
|
-
this.log(`${logPrefix} No sync context, skipping: ${relativePath}`);
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
// Skip if this is a pending write from remote sync (avoid circular updates)
|
|
418
|
-
if (isPendingWrite(ctx, relativePath)) {
|
|
419
|
-
this.log(`${logPrefix} Skipping pending write: ${relativePath}`);
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
// Don't sync during active git operations (rebase, merge, etc.)
|
|
423
|
-
if (isGitOperationInProgress(connection.path)) {
|
|
424
|
-
this.log(`${logPrefix} Git operation in progress, skipping: ${relativePath}`);
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
// If offline, queue the change for later
|
|
428
|
-
if (!this.isOnline) {
|
|
429
|
-
this.log(`${logPrefix} Offline, queuing: ${relativePath}`);
|
|
430
|
-
this.offlineQueue.add({
|
|
431
|
-
type: event === 'add' ? 'create' : event === 'unlink' ? 'delete' : 'update',
|
|
432
|
-
relativePath,
|
|
433
|
-
workspaceId: connection.workspaceId,
|
|
434
|
-
});
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
// Execute with WASM error recovery
|
|
438
|
-
const result = await this.wasmRecovery.executeWithRecovery(`${connection.workspaceId}:${relativePath}`, async () => {
|
|
439
|
-
switch (event) {
|
|
440
|
-
case 'add':
|
|
441
|
-
this.log(`${logPrefix} File added: ${relativePath}`);
|
|
442
|
-
await handleFileCreate(ctx, filePath);
|
|
443
|
-
break;
|
|
444
|
-
case 'change':
|
|
445
|
-
this.log(`${logPrefix} File changed: ${relativePath}`);
|
|
446
|
-
await handleFileChange(ctx, filePath);
|
|
447
|
-
break;
|
|
448
|
-
case 'unlink':
|
|
449
|
-
this.log(`${logPrefix} File deleted: ${relativePath}`);
|
|
450
|
-
await handleFileDelete(ctx, filePath);
|
|
451
|
-
break;
|
|
452
|
-
}
|
|
453
|
-
return true;
|
|
454
|
-
});
|
|
455
|
-
if (result === null) {
|
|
456
|
-
// WASM error occurred, already queued for retry
|
|
457
|
-
this.log(`${logPrefix} WASM error for ${relativePath}, queued for retry`);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
/**
|
|
461
|
-
* Set online/offline state. When going online, replay queued changes.
|
|
462
|
-
*/
|
|
463
|
-
async setOnlineState(online) {
|
|
464
|
-
if (this.isOnline === online)
|
|
465
|
-
return;
|
|
466
|
-
this.isOnline = online;
|
|
467
|
-
this.log(`Connection state changed: ${online ? 'online' : 'offline'}`);
|
|
468
|
-
if (online && this.offlineQueue.hasPending()) {
|
|
469
|
-
this.log('Back online, replaying offline queue...');
|
|
470
|
-
await this.replayOfflineQueue();
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
async checkForNewConnections() {
|
|
474
|
-
const currentConnections = listWorkspaceConnections();
|
|
475
|
-
// Check for new connections
|
|
476
|
-
for (const connection of currentConnections) {
|
|
477
|
-
if (!this.watchers.has(connection.path)) {
|
|
478
|
-
this.log(`New workspace connection detected: ${connection.path}`);
|
|
479
|
-
await this.watchDirectory(connection);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
// Check for removed connections
|
|
483
|
-
for (const watchedPath of this.watchers.keys()) {
|
|
484
|
-
if (!currentConnections.some((c) => c.path === watchedPath)) {
|
|
485
|
-
this.log(`Workspace connection removed: ${watchedPath}`);
|
|
486
|
-
const watcher = this.watchers.get(watchedPath);
|
|
487
|
-
if (watcher) {
|
|
488
|
-
await watcher.close();
|
|
489
|
-
this.watchers.delete(watchedPath);
|
|
490
|
-
}
|
|
491
|
-
// Also cleanup git HEAD watcher
|
|
492
|
-
const headWatcher = this.headWatchers.get(watchedPath);
|
|
493
|
-
if (headWatcher) {
|
|
494
|
-
await headWatcher.close();
|
|
495
|
-
this.headWatchers.delete(watchedPath);
|
|
496
|
-
}
|
|
497
|
-
// Cleanup file sync context
|
|
498
|
-
this.fileSyncContexts.delete(watchedPath);
|
|
499
|
-
this.gitBranches.delete(watchedPath);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
this.connections = currentConnections;
|
|
503
|
-
}
|
|
504
|
-
async shutdown(signal) {
|
|
505
|
-
if (this.isShuttingDown)
|
|
506
|
-
return;
|
|
507
|
-
this.isShuttingDown = true;
|
|
508
|
-
this.log(`Received ${signal}, shutting down...`);
|
|
509
|
-
// Stop WASM recovery processing
|
|
510
|
-
this.wasmRecovery.stopPeriodicProcessing();
|
|
511
|
-
this.log('Stopped WASM recovery processing');
|
|
512
|
-
// Cleanup remote change subscriptions
|
|
513
|
-
for (const [watchPath, ctx] of this.fileSyncContexts) {
|
|
514
|
-
cleanupRemoteSubscriptions(ctx);
|
|
515
|
-
this.log(`Cleaned up subscriptions for ${watchPath}`);
|
|
516
|
-
}
|
|
517
|
-
// Close all file watchers
|
|
518
|
-
for (const [watchPath, watcher] of this.watchers) {
|
|
519
|
-
await watcher.close();
|
|
520
|
-
this.log(`Closed watcher for ${watchPath}`);
|
|
521
|
-
}
|
|
522
|
-
// Close all git HEAD watchers
|
|
523
|
-
for (const [watchPath, watcher] of this.headWatchers) {
|
|
524
|
-
await watcher.close();
|
|
525
|
-
this.log(`Closed git HEAD watcher for ${watchPath}`);
|
|
526
|
-
}
|
|
527
|
-
// Clear debounce timers
|
|
528
|
-
for (const timer of this.debounceTimers.values()) {
|
|
529
|
-
clearTimeout(timer);
|
|
530
|
-
}
|
|
531
|
-
this.log('Daemon worker stopped');
|
|
532
|
-
process.exit(0);
|
|
533
|
-
}
|
|
534
|
-
log(message) {
|
|
535
|
-
const timestamp = new Date().toISOString();
|
|
536
|
-
const line = `${timestamp} [INFO] ${message}\n`;
|
|
537
|
-
// Write to log file
|
|
538
|
-
try {
|
|
539
|
-
const logPath = getSyncLogPath();
|
|
540
|
-
const logDir = getLogsDir();
|
|
541
|
-
if (!fs.existsSync(logDir)) {
|
|
542
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
543
|
-
}
|
|
544
|
-
fs.appendFileSync(logPath, line);
|
|
545
|
-
}
|
|
546
|
-
catch (error) {
|
|
547
|
-
// Fall back to console if logging fails
|
|
548
|
-
console.log(line);
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
// Start the daemon worker
|
|
553
|
-
const worker = new DaemonWorker();
|
|
554
|
-
worker.start().catch((error) => {
|
|
555
|
-
console.error('Daemon worker failed to start:', error);
|
|
556
|
-
process.exit(1);
|
|
557
|
-
});
|