@doubling/compound-sync 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/package.json +19 -0
- package/sync.js +697 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Compound Sync Daemon
|
|
2
|
+
|
|
3
|
+
Bidirectional sync between Firestore and local markdown files.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js
|
|
8
|
+
- Google Cloud Application Default Credentials: `gcloud auth application-default login`
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cd sync
|
|
14
|
+
npm install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Run the interactive setup for each environment:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
node sync.js --config config-sandbox.json --setup # Project: doubling-compound-sandbox
|
|
21
|
+
node sync.js --config config-dev.json --setup # Project: doubling-compound-dev
|
|
22
|
+
node sync.js --config config-prod.json --setup # Project: doubling-compound-prod
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The setup wizard will ask for:
|
|
26
|
+
- **Firebase project ID** (e.g. `doubling-compound-sandbox`)
|
|
27
|
+
- **Organization** to sync
|
|
28
|
+
- **Email address** for private file sync
|
|
29
|
+
- **Local sync folder** (e.g. `~/Compound-sandbox`)
|
|
30
|
+
|
|
31
|
+
Config files (`config-*.json`) are gitignored and stored locally.
|
|
32
|
+
|
|
33
|
+
## Running
|
|
34
|
+
|
|
35
|
+
Run one or more environments in separate terminal tabs:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
node sync.js --config config-sandbox.json
|
|
39
|
+
node sync.js --config config-dev.json
|
|
40
|
+
node sync.js --config config-prod.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Local folder structure
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
{Sync Folder}/
|
|
47
|
+
{TeamName} Teamspace/ -- team files (bidirectional)
|
|
48
|
+
Private/ -- your private files (bidirectional)
|
|
49
|
+
Shared by Me/ -- symlinks to files you've shared
|
|
50
|
+
Shared with Me/ -- files shared with you (read-only)
|
|
51
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@doubling/compound-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Bidirectional sync between Compound and local markdown files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"compound-sync": "./sync.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"sync": "node sync.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"firebase": "^11.7.0",
|
|
14
|
+
"chokidar": "^4.0.0"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/sync.js
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bidirectional sync between Firestore files and local markdown files.
|
|
5
|
+
* Uses Firebase client SDK with browser-based auth — no gcloud needed.
|
|
6
|
+
*
|
|
7
|
+
* Local folder structure:
|
|
8
|
+
* {SyncFolder}/
|
|
9
|
+
* {TeamName} Teamspace/ ← team files (bidirectional)
|
|
10
|
+
* Private/ ← user's private files (bidirectional)
|
|
11
|
+
* Shared by Me/ ← symlinks to files you've shared
|
|
12
|
+
* Shared with Me/ ← files shared with you (read-only)
|
|
13
|
+
*
|
|
14
|
+
* Usage: node sync.js [--config <file>] [--setup]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { initializeApp } from 'firebase/app';
|
|
18
|
+
import { getAuth, signInWithEmailAndPassword, signInWithCredential, GoogleAuthProvider } from 'firebase/auth';
|
|
19
|
+
import {
|
|
20
|
+
getFirestore, collection, doc, getDocs, getDoc, addDoc, updateDoc, deleteDoc,
|
|
21
|
+
query, where, onSnapshot
|
|
22
|
+
} from 'firebase/firestore';
|
|
23
|
+
import chokidar from 'chokidar';
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import readline from 'readline';
|
|
27
|
+
import os from 'os';
|
|
28
|
+
import http from 'http';
|
|
29
|
+
import { exec } from 'child_process';
|
|
30
|
+
import { fileURLToPath } from 'url';
|
|
31
|
+
|
|
32
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
|
|
34
|
+
// --- Firebase configs (same as the web app) ---
|
|
35
|
+
|
|
36
|
+
const firebaseConfigs = {
|
|
37
|
+
'doubling-compound-sandbox': {
|
|
38
|
+
apiKey: "AIzaSyBnskQNh--bRaRN7tsVFbb01oll8OSdVH8",
|
|
39
|
+
authDomain: "doubling-compound-sandbox.firebaseapp.com",
|
|
40
|
+
projectId: "doubling-compound-sandbox",
|
|
41
|
+
storageBucket: "doubling-compound-sandbox.firebasestorage.app",
|
|
42
|
+
messagingSenderId: "83118259611",
|
|
43
|
+
appId: "1:83118259611:web:cf9cda61c9ecdc161b353e"
|
|
44
|
+
},
|
|
45
|
+
'doubling-compound-dev': {
|
|
46
|
+
apiKey: "AIzaSyBPKJqfy77_qmP_x9zOVzoHKuY8PHFCUm0",
|
|
47
|
+
authDomain: "doubling-compound-dev.firebaseapp.com",
|
|
48
|
+
projectId: "doubling-compound-dev",
|
|
49
|
+
storageBucket: "doubling-compound-dev.firebasestorage.app",
|
|
50
|
+
messagingSenderId: "967509355891",
|
|
51
|
+
appId: "1:967509355891:web:c253ebdb8205d3ef012906"
|
|
52
|
+
},
|
|
53
|
+
'doubling-compound-prod': {
|
|
54
|
+
apiKey: "AIzaSyDvnPV5dLxzubPE8pwq_S80gMsF8E97LaI",
|
|
55
|
+
authDomain: "compound.doubling.io",
|
|
56
|
+
projectId: "doubling-compound-prod",
|
|
57
|
+
storageBucket: "doubling-compound-prod.firebasestorage.app",
|
|
58
|
+
messagingSenderId: "831648925388",
|
|
59
|
+
appId: "1:831648925388:web:87550a55f807f2a2a1a469"
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// --- Helpers ---
|
|
64
|
+
|
|
65
|
+
function ask(question) {
|
|
66
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
67
|
+
return new Promise(resolve => {
|
|
68
|
+
rl.question(question, answer => {
|
|
69
|
+
rl.close();
|
|
70
|
+
resolve(answer.trim());
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function expandHome(p) {
|
|
76
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
77
|
+
return p;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function openBrowser(url) {
|
|
81
|
+
const cmd = process.platform === 'darwin' ? 'open' :
|
|
82
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
83
|
+
exec(`${cmd} "${url}"`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Browser-based auth ---
|
|
87
|
+
|
|
88
|
+
function buildLoginPage(fbConfig) {
|
|
89
|
+
return `<!DOCTYPE html>
|
|
90
|
+
<html><head><meta charset="utf-8"><title>Compound Sync - Sign In</title>
|
|
91
|
+
<style>
|
|
92
|
+
body { font-family: -apple-system, system-ui, sans-serif; max-width: 400px; margin: 80px auto; text-align: center; }
|
|
93
|
+
h2 { margin-bottom: 8px; }
|
|
94
|
+
p { color: #666; margin-bottom: 32px; }
|
|
95
|
+
button { display: block; width: 100%; padding: 12px; margin: 8px 0; border: 1px solid #ddd; border-radius: 8px;
|
|
96
|
+
background: white; font-size: 16px; cursor: pointer; }
|
|
97
|
+
button:hover { background: #f5f5f5; }
|
|
98
|
+
.google { font-weight: 500; }
|
|
99
|
+
.divider { color: #999; margin: 16px 0; }
|
|
100
|
+
input { display: block; width: 100%; padding: 12px; margin: 8px 0; border: 1px solid #ddd; border-radius: 8px;
|
|
101
|
+
font-size: 16px; box-sizing: border-box; }
|
|
102
|
+
.error { color: red; margin: 8px 0; }
|
|
103
|
+
.success { color: green; font-size: 18px; margin-top: 40px; }
|
|
104
|
+
</style>
|
|
105
|
+
</head><body>
|
|
106
|
+
<h2>Compound Sync</h2>
|
|
107
|
+
<p>Sign in to start syncing</p>
|
|
108
|
+
<div id="login">
|
|
109
|
+
<button class="google" onclick="googleSignIn()">Sign in with Google</button>
|
|
110
|
+
<div class="divider">or sign in with email</div>
|
|
111
|
+
<input id="email" type="email" placeholder="Email">
|
|
112
|
+
<input id="password" type="password" placeholder="Password">
|
|
113
|
+
<button onclick="emailSignIn()">Sign in</button>
|
|
114
|
+
<div id="error" class="error"></div>
|
|
115
|
+
</div>
|
|
116
|
+
<div id="done" class="success" style="display:none">
|
|
117
|
+
Signed in! You can close this tab.
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<script type="module">
|
|
121
|
+
import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.7.1/firebase-app.js';
|
|
122
|
+
import { getAuth, signInWithPopup, signInWithEmailAndPassword, GoogleAuthProvider }
|
|
123
|
+
from 'https://www.gstatic.com/firebasejs/11.7.1/firebase-auth.js';
|
|
124
|
+
|
|
125
|
+
const fbConfig = ${JSON.stringify(fbConfig)};
|
|
126
|
+
const app = initializeApp(fbConfig);
|
|
127
|
+
const auth = getAuth(app);
|
|
128
|
+
|
|
129
|
+
async function sendCredential(data) {
|
|
130
|
+
await fetch('/auth-callback', {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json' },
|
|
133
|
+
body: JSON.stringify(data),
|
|
134
|
+
});
|
|
135
|
+
document.getElementById('login').style.display = 'none';
|
|
136
|
+
document.getElementById('done').style.display = 'block';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
window.googleSignIn = async function() {
|
|
140
|
+
try {
|
|
141
|
+
const result = await signInWithPopup(auth, new GoogleAuthProvider());
|
|
142
|
+
const credential = GoogleAuthProvider.credentialFromResult(result);
|
|
143
|
+
await sendCredential({
|
|
144
|
+
method: 'google',
|
|
145
|
+
idToken: credential.idToken,
|
|
146
|
+
accessToken: credential.accessToken,
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
document.getElementById('error').textContent = err.message;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
window.emailSignIn = async function() {
|
|
154
|
+
const email = document.getElementById('email').value;
|
|
155
|
+
const password = document.getElementById('password').value;
|
|
156
|
+
try {
|
|
157
|
+
await signInWithEmailAndPassword(auth, email, password);
|
|
158
|
+
await sendCredential({ method: 'email', email, password });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
document.getElementById('error').textContent = err.message;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
</script>
|
|
164
|
+
</body></html>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function browserAuth(fbConfig) {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const server = http.createServer((req, res) => {
|
|
170
|
+
if (req.method === 'GET' && req.url === '/login') {
|
|
171
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
172
|
+
res.end(buildLoginPage(fbConfig));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (req.method === 'POST' && req.url === '/auth-callback') {
|
|
177
|
+
let body = '';
|
|
178
|
+
req.on('data', chunk => { body += chunk; });
|
|
179
|
+
req.on('end', () => {
|
|
180
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
181
|
+
res.end('OK');
|
|
182
|
+
server.close();
|
|
183
|
+
resolve(JSON.parse(body));
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
res.writeHead(404);
|
|
189
|
+
res.end('Not found');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
server.listen(0, () => {
|
|
193
|
+
const port = server.address().port;
|
|
194
|
+
const url = `http://localhost:${port}/login`;
|
|
195
|
+
console.log(`Opening browser for sign-in...`);
|
|
196
|
+
console.log(`If the browser doesn't open, visit: ${url}`);
|
|
197
|
+
openBrowser(url);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- Interactive setup ---
|
|
203
|
+
|
|
204
|
+
async function setupConfig() {
|
|
205
|
+
console.log('');
|
|
206
|
+
console.log('Welcome to the Compound sync daemon! Let\'s set things up.');
|
|
207
|
+
console.log('');
|
|
208
|
+
|
|
209
|
+
// Default to prod; --env flag overrides for internal use
|
|
210
|
+
const projectId = envOverride || 'doubling-compound-prod';
|
|
211
|
+
|
|
212
|
+
const fbConfig = firebaseConfigs[projectId];
|
|
213
|
+
|
|
214
|
+
// Browser sign-in to discover orgs
|
|
215
|
+
console.log('');
|
|
216
|
+
const authResult = await browserAuth(fbConfig);
|
|
217
|
+
const app = initializeApp(fbConfig, 'setup');
|
|
218
|
+
const auth = getAuth(app);
|
|
219
|
+
const db = getFirestore(app);
|
|
220
|
+
|
|
221
|
+
let userCredential;
|
|
222
|
+
if (authResult.method === 'google') {
|
|
223
|
+
const credential = GoogleAuthProvider.credential(authResult.idToken, authResult.accessToken);
|
|
224
|
+
userCredential = await signInWithCredential(auth, credential);
|
|
225
|
+
} else {
|
|
226
|
+
userCredential = await signInWithEmailAndPassword(auth, authResult.email, authResult.password);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const userId = userCredential.user.uid;
|
|
230
|
+
const userEmail = userCredential.user.email;
|
|
231
|
+
console.log(`Signed in as ${userEmail}`);
|
|
232
|
+
|
|
233
|
+
// List available orgs for this user
|
|
234
|
+
console.log('');
|
|
235
|
+
console.log('Fetching your organizations...');
|
|
236
|
+
const membersSnapshot = await getDocs(
|
|
237
|
+
query(collection(db, 'orgMembers'), where('userId', '==', userId))
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const orgIds = [];
|
|
241
|
+
membersSnapshot.forEach(doc => {
|
|
242
|
+
orgIds.push(doc.data().orgId);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (orgIds.length === 0) {
|
|
246
|
+
console.error('No organizations found for your account.');
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const orgs = [];
|
|
251
|
+
for (const orgId of orgIds) {
|
|
252
|
+
const orgDoc = await getDoc(doc(db, 'orgs', orgId));
|
|
253
|
+
if (orgDoc.exists()) {
|
|
254
|
+
orgs.push({ id: orgDoc.id, ...orgDoc.data() });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log('');
|
|
259
|
+
console.log('Your organizations:');
|
|
260
|
+
orgs.forEach((org, i) => {
|
|
261
|
+
console.log(` ${i + 1}. ${org.name}`);
|
|
262
|
+
});
|
|
263
|
+
console.log('');
|
|
264
|
+
|
|
265
|
+
let orgId;
|
|
266
|
+
if (orgs.length === 1) {
|
|
267
|
+
const confirm = await ask(`Use "${orgs[0].name}"? [Y/n]: `) || 'y';
|
|
268
|
+
if (confirm.toLowerCase() === 'y') {
|
|
269
|
+
orgId = orgs[0].id;
|
|
270
|
+
} else {
|
|
271
|
+
process.exit(0);
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
const choice = await ask(`Select organization [1-${orgs.length}]: `);
|
|
275
|
+
const idx = parseInt(choice) - 1;
|
|
276
|
+
if (idx < 0 || idx >= orgs.length) {
|
|
277
|
+
console.error('Invalid selection.');
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
orgId = orgs[idx].id;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Local path
|
|
284
|
+
console.log('');
|
|
285
|
+
const orgName = orgs.find(o => o.id === orgId).name;
|
|
286
|
+
const defaultPath = path.join(os.homedir(), orgName + ' Workspace');
|
|
287
|
+
const localPath = await ask(`Local sync folder [${defaultPath}]: `) || defaultPath;
|
|
288
|
+
const resolvedPath = expandHome(localPath);
|
|
289
|
+
|
|
290
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
291
|
+
const create = await ask(`"${resolvedPath}" doesn't exist. Create it? [Y/n]: `) || 'y';
|
|
292
|
+
if (create.toLowerCase() === 'y') {
|
|
293
|
+
fs.mkdirSync(resolvedPath, { recursive: true });
|
|
294
|
+
console.log('Created.');
|
|
295
|
+
} else {
|
|
296
|
+
process.exit(0);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Save config (no credentials stored)
|
|
301
|
+
const savedConfig = {
|
|
302
|
+
projectId,
|
|
303
|
+
orgId,
|
|
304
|
+
localPath: resolvedPath,
|
|
305
|
+
_userId: userId,
|
|
306
|
+
_app: app,
|
|
307
|
+
_db: db,
|
|
308
|
+
};
|
|
309
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ projectId, orgId, localPath: resolvedPath }, null, 2) + '\n');
|
|
310
|
+
console.log('');
|
|
311
|
+
console.log(`Config saved to ${CONFIG_PATH}`);
|
|
312
|
+
console.log('');
|
|
313
|
+
|
|
314
|
+
return savedConfig;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --- Config ---
|
|
318
|
+
|
|
319
|
+
const args = process.argv.slice(2);
|
|
320
|
+
let configFile = 'config.json';
|
|
321
|
+
let forceSetup = false;
|
|
322
|
+
let envOverride = null;
|
|
323
|
+
for (let i = 0; i < args.length; i++) {
|
|
324
|
+
if (args[i] === '--config' && args[i + 1]) { configFile = args[++i]; }
|
|
325
|
+
if (args[i] === '--setup') { forceSetup = true; }
|
|
326
|
+
if (args[i] === '--env' && args[i + 1]) { envOverride = `doubling-compound-${args[++i]}`; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const CONFIG_PATH = path.join(__dirname, configFile);
|
|
330
|
+
let config = {};
|
|
331
|
+
if (!forceSetup && fs.existsSync(CONFIG_PATH)) {
|
|
332
|
+
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
333
|
+
}
|
|
334
|
+
if (forceSetup) { config = {}; }
|
|
335
|
+
|
|
336
|
+
// Interactive setup if no config
|
|
337
|
+
if (!config.projectId || !config.orgId) {
|
|
338
|
+
config = await setupConfig();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// --- Initialize Firebase client SDK ---
|
|
342
|
+
|
|
343
|
+
let app, db, userId;
|
|
344
|
+
|
|
345
|
+
if (config._app) {
|
|
346
|
+
// Reuse app and auth from setup
|
|
347
|
+
app = config._app;
|
|
348
|
+
db = config._db;
|
|
349
|
+
userId = config._userId;
|
|
350
|
+
} else {
|
|
351
|
+
const fbConfig = firebaseConfigs[config.projectId];
|
|
352
|
+
if (!fbConfig) {
|
|
353
|
+
console.error(`Unknown project: ${config.projectId}`);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
app = initializeApp(fbConfig);
|
|
358
|
+
const auth = getAuth(app);
|
|
359
|
+
db = getFirestore(app);
|
|
360
|
+
|
|
361
|
+
// Sign in via browser
|
|
362
|
+
console.log('');
|
|
363
|
+
const authResult = await browserAuth(fbConfig);
|
|
364
|
+
|
|
365
|
+
if (authResult.method === 'google') {
|
|
366
|
+
const credential = GoogleAuthProvider.credential(authResult.idToken, authResult.accessToken);
|
|
367
|
+
const cred = await signInWithCredential(auth, credential);
|
|
368
|
+
userId = cred.user.uid;
|
|
369
|
+
console.log(`Signed in as ${cred.user.email}`);
|
|
370
|
+
} else {
|
|
371
|
+
const cred = await signInWithEmailAndPassword(auth, authResult.email, authResult.password);
|
|
372
|
+
userId = cred.user.uid;
|
|
373
|
+
console.log(`Signed in as ${cred.user.email}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const ORG_ID = config.orgId;
|
|
378
|
+
const USER_ID = userId;
|
|
379
|
+
const LOCAL_PATH = expandHome(config.localPath || path.join(os.homedir(), 'Sync'));
|
|
380
|
+
|
|
381
|
+
const filesRef = collection(db, `orgs/${ORG_ID}/files`);
|
|
382
|
+
|
|
383
|
+
// --- Load teams ---
|
|
384
|
+
|
|
385
|
+
const teams = new Map();
|
|
386
|
+
const teamIdsByName = new Map();
|
|
387
|
+
|
|
388
|
+
async function loadTeams() {
|
|
389
|
+
const snapshot = await getDocs(collection(db, `orgs/${ORG_ID}/teams`));
|
|
390
|
+
snapshot.forEach(doc => {
|
|
391
|
+
const data = doc.data();
|
|
392
|
+
teams.set(doc.id, data);
|
|
393
|
+
teamIdsByName.set(data.name, doc.id);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
await loadTeams();
|
|
398
|
+
|
|
399
|
+
console.log(`Sync daemon starting...`);
|
|
400
|
+
console.log(` Env: ${config.projectId}`);
|
|
401
|
+
console.log(` Org: ${ORG_ID}`);
|
|
402
|
+
console.log(` User: ${USER_ID}`);
|
|
403
|
+
console.log(` Local: ${LOCAL_PATH}`);
|
|
404
|
+
console.log(` Teams: ${[...teams.values()].map(t => t.name).join(', ') || 'none'}`);
|
|
405
|
+
|
|
406
|
+
// --- Scope-aware path helpers ---
|
|
407
|
+
|
|
408
|
+
const PRIVATE_FOLDER = 'Private';
|
|
409
|
+
const SHARED_WITH_ME_FOLDER = 'Shared with Me';
|
|
410
|
+
const SHARED_BY_ME_FOLDER = 'Shared by Me';
|
|
411
|
+
|
|
412
|
+
const lastKnownUpdate = new Map();
|
|
413
|
+
const suppressLocal = new Set();
|
|
414
|
+
|
|
415
|
+
function updateSharedByMeSymlink(fileDoc, realPath) {
|
|
416
|
+
const sharedWith = fileDoc.sharedWith || [];
|
|
417
|
+
const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, fileDoc.path);
|
|
418
|
+
|
|
419
|
+
if (sharedWith.length > 0) {
|
|
420
|
+
ensureDir(symlinkPath);
|
|
421
|
+
const relativeTo = path.relative(path.dirname(symlinkPath), realPath);
|
|
422
|
+
try {
|
|
423
|
+
if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
|
|
424
|
+
fs.unlinkSync(symlinkPath);
|
|
425
|
+
}
|
|
426
|
+
} catch (e) { /* doesn't exist */ }
|
|
427
|
+
try {
|
|
428
|
+
fs.symlinkSync(relativeTo, symlinkPath);
|
|
429
|
+
console.log(` [symlink] ${fileDoc.path} → Shared by Me/`);
|
|
430
|
+
} catch (e) {
|
|
431
|
+
if (e.code !== 'EEXIST') console.error(` Symlink error: ${e.message}`);
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
try {
|
|
435
|
+
if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
|
|
436
|
+
fs.unlinkSync(symlinkPath);
|
|
437
|
+
console.log(` [symlink] removed Shared by Me/${fileDoc.path}`);
|
|
438
|
+
}
|
|
439
|
+
} catch (e) { /* doesn't exist */ }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function scopeFromLocalPath(filePath) {
|
|
444
|
+
const rel = path.relative(LOCAL_PATH, filePath);
|
|
445
|
+
|
|
446
|
+
if (rel.startsWith(PRIVATE_FOLDER + path.sep)) {
|
|
447
|
+
return { scope: 'private', docPath: rel.slice(PRIVATE_FOLDER.length + 1) };
|
|
448
|
+
}
|
|
449
|
+
if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) {
|
|
450
|
+
return { scope: 'shared-with-me', docPath: rel.slice(SHARED_WITH_ME_FOLDER.length + 1) };
|
|
451
|
+
}
|
|
452
|
+
if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) {
|
|
453
|
+
return { scope: 'shared-by-me', docPath: rel.slice(SHARED_BY_ME_FOLDER.length + 1) };
|
|
454
|
+
}
|
|
455
|
+
const parts = rel.split(path.sep);
|
|
456
|
+
const folderName = parts[0];
|
|
457
|
+
const teamName = folderName.endsWith(' Teamspace') ? folderName.slice(0, -' Teamspace'.length) : folderName;
|
|
458
|
+
const teamId = teamIdsByName.get(teamName);
|
|
459
|
+
return { scope: 'team', teamId, docPath: parts.slice(1).join('/') };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function ensureDir(filePath) {
|
|
463
|
+
const dir = path.dirname(filePath);
|
|
464
|
+
if (!fs.existsSync(dir)) {
|
|
465
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// --- Create local folder structure ---
|
|
470
|
+
|
|
471
|
+
function ensureFolderStructure() {
|
|
472
|
+
const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
|
|
473
|
+
if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
|
|
474
|
+
|
|
475
|
+
const sharedWithMePath = path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER);
|
|
476
|
+
if (!fs.existsSync(sharedWithMePath)) fs.mkdirSync(sharedWithMePath, { recursive: true });
|
|
477
|
+
|
|
478
|
+
const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
|
|
479
|
+
if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
|
|
480
|
+
|
|
481
|
+
for (const [, teamData] of teams) {
|
|
482
|
+
const teamPath = path.join(LOCAL_PATH, teamData.name + ' Teamspace');
|
|
483
|
+
if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
ensureFolderStructure();
|
|
488
|
+
|
|
489
|
+
// --- Firestore → Local (multiple listeners) ---
|
|
490
|
+
|
|
491
|
+
function handleFirestoreChange(change, getLocalPath) {
|
|
492
|
+
const data = change.doc.data();
|
|
493
|
+
const docPath = data.path;
|
|
494
|
+
if (!docPath) return;
|
|
495
|
+
|
|
496
|
+
const localPath = getLocalPath(data);
|
|
497
|
+
const trackingKey = `${data.scope || 'team'}:${docPath}`;
|
|
498
|
+
|
|
499
|
+
if (change.type === 'removed') {
|
|
500
|
+
if (fs.existsSync(localPath)) {
|
|
501
|
+
console.log(` [firestore → local] DELETE ${docPath}`);
|
|
502
|
+
suppressLocal.add(localPath);
|
|
503
|
+
fs.unlinkSync(localPath);
|
|
504
|
+
setTimeout(() => suppressLocal.delete(localPath), 1000);
|
|
505
|
+
}
|
|
506
|
+
lastKnownUpdate.delete(trackingKey);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const remoteUpdated = data.updatedAt || data.createdAt || '';
|
|
511
|
+
const lastKnown = lastKnownUpdate.get(trackingKey);
|
|
512
|
+
|
|
513
|
+
if (lastKnown && lastKnown >= remoteUpdated) return;
|
|
514
|
+
|
|
515
|
+
if (fs.existsSync(localPath)) {
|
|
516
|
+
const localMtime = fs.statSync(localPath).mtime.toISOString();
|
|
517
|
+
if (localMtime > remoteUpdated) return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const action = change.type === 'added' ? 'CREATE' : 'UPDATE';
|
|
521
|
+
console.log(` [firestore → local] ${action} ${docPath} (${data.scope || 'team'})`);
|
|
522
|
+
ensureDir(localPath);
|
|
523
|
+
suppressLocal.add(localPath);
|
|
524
|
+
fs.writeFileSync(localPath, data.content || '', 'utf-8');
|
|
525
|
+
lastKnownUpdate.set(trackingKey, remoteUpdated);
|
|
526
|
+
setTimeout(() => suppressLocal.delete(localPath), 1000);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function startFirestoreListeners() {
|
|
530
|
+
console.log('Starting Firestore listeners...');
|
|
531
|
+
|
|
532
|
+
for (const [teamId, teamData] of teams) {
|
|
533
|
+
const teamFolder = teamData.name + ' Teamspace';
|
|
534
|
+
const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
|
|
535
|
+
onSnapshot(q, snapshot => {
|
|
536
|
+
snapshot.docChanges().forEach(change => {
|
|
537
|
+
handleFirestoreChange(change, (data) => {
|
|
538
|
+
return path.join(LOCAL_PATH, teamFolder, data.path);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
}, err => console.error(`Team "${teamFolder}" listener error:`, err));
|
|
542
|
+
console.log(` Listening: ${teamFolder}`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (USER_ID) {
|
|
546
|
+
const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', USER_ID));
|
|
547
|
+
onSnapshot(privateQ, snapshot => {
|
|
548
|
+
snapshot.docChanges().forEach(change => {
|
|
549
|
+
const data = change.doc.data();
|
|
550
|
+
const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
|
|
551
|
+
|
|
552
|
+
handleFirestoreChange(change, () => realPath);
|
|
553
|
+
|
|
554
|
+
if (change.type !== 'removed') {
|
|
555
|
+
updateSharedByMeSymlink(data, realPath);
|
|
556
|
+
} else {
|
|
557
|
+
const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, data.path);
|
|
558
|
+
try { fs.lstatSync(symlinkPath); fs.unlinkSync(symlinkPath); } catch (e) { /* ok */ }
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
}, err => console.error('Private files listener error:', err));
|
|
562
|
+
console.log(' Listening: Private files + Shared by Me symlinks');
|
|
563
|
+
|
|
564
|
+
const sharedQ = query(filesRef, where('sharedWith', 'array-contains', USER_ID));
|
|
565
|
+
onSnapshot(sharedQ, snapshot => {
|
|
566
|
+
snapshot.docChanges().forEach(change => {
|
|
567
|
+
const data = change.doc.data();
|
|
568
|
+
if (data.ownerId === USER_ID) return;
|
|
569
|
+
handleFirestoreChange(change, () => {
|
|
570
|
+
return path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
}, err => console.error('Shared with me listener error:', err));
|
|
574
|
+
console.log(' Listening: Shared with Me');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// --- Local → Firestore ---
|
|
579
|
+
|
|
580
|
+
async function pushToFirestore(filePath, content) {
|
|
581
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(filePath);
|
|
582
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
583
|
+
const now = new Date().toISOString();
|
|
584
|
+
lastKnownUpdate.set(trackingKey, now);
|
|
585
|
+
|
|
586
|
+
if (scope === 'shared-with-me' || scope === 'shared-by-me') return;
|
|
587
|
+
|
|
588
|
+
let snapshot;
|
|
589
|
+
if (scope === 'private') {
|
|
590
|
+
snapshot = await getDocs(query(filesRef,
|
|
591
|
+
where('scope', '==', 'private'),
|
|
592
|
+
where('ownerId', '==', USER_ID),
|
|
593
|
+
where('path', '==', docPath)
|
|
594
|
+
));
|
|
595
|
+
} else if (scope === 'team' && teamId) {
|
|
596
|
+
snapshot = await getDocs(query(filesRef,
|
|
597
|
+
where('scope', '==', 'team'),
|
|
598
|
+
where('teamId', '==', teamId),
|
|
599
|
+
where('path', '==', docPath)
|
|
600
|
+
));
|
|
601
|
+
} else {
|
|
602
|
+
snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!snapshot || snapshot.empty) {
|
|
606
|
+
console.log(` [local → firestore] CREATE ${docPath} (${scope})`);
|
|
607
|
+
const fileData = {
|
|
608
|
+
path: docPath,
|
|
609
|
+
content,
|
|
610
|
+
createdAt: now,
|
|
611
|
+
updatedAt: now,
|
|
612
|
+
scope,
|
|
613
|
+
teamId: scope === 'team' ? (teamId || null) : null,
|
|
614
|
+
ownerId: scope === 'private' ? USER_ID : null,
|
|
615
|
+
sharedWith: [],
|
|
616
|
+
};
|
|
617
|
+
await addDoc(filesRef, fileData);
|
|
618
|
+
} else {
|
|
619
|
+
const docSnap = snapshot.docs[0];
|
|
620
|
+
const existing = docSnap.data();
|
|
621
|
+
if (existing.content === content) return;
|
|
622
|
+
console.log(` [local → firestore] UPDATE ${docPath} (${scope})`);
|
|
623
|
+
await updateDoc(docSnap.ref, { content, updatedAt: now });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function deleteFromFirestore(filePath) {
|
|
628
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(filePath);
|
|
629
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
630
|
+
lastKnownUpdate.delete(trackingKey);
|
|
631
|
+
|
|
632
|
+
if (scope === 'shared-with-me' || scope === 'shared-by-me') return;
|
|
633
|
+
|
|
634
|
+
const snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
|
|
635
|
+
if (!snapshot.empty) {
|
|
636
|
+
const data = snapshot.docs[0].data();
|
|
637
|
+
if ((data.scope || 'team') === scope) {
|
|
638
|
+
console.log(` [local → firestore] DELETE ${docPath} (${scope})`);
|
|
639
|
+
await deleteDoc(snapshot.docs[0].ref);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function startLocalWatcher() {
|
|
645
|
+
console.log('Watching local files...');
|
|
646
|
+
|
|
647
|
+
const watcher = chokidar.watch(LOCAL_PATH, {
|
|
648
|
+
ignored: [
|
|
649
|
+
/(^|[\/\\])\./,
|
|
650
|
+
/node_modules/,
|
|
651
|
+
/\.csv$/,
|
|
652
|
+
/\.py$/,
|
|
653
|
+
/index\.html$/,
|
|
654
|
+
],
|
|
655
|
+
persistent: true,
|
|
656
|
+
ignoreInitial: false,
|
|
657
|
+
followSymlinks: false,
|
|
658
|
+
awaitWriteFinish: {
|
|
659
|
+
stabilityThreshold: 500,
|
|
660
|
+
pollInterval: 100,
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
watcher.on('add', filePath => handleLocalChange(filePath));
|
|
665
|
+
watcher.on('change', filePath => handleLocalChange(filePath));
|
|
666
|
+
watcher.on('unlink', filePath => {
|
|
667
|
+
if (suppressLocal.has(filePath)) return;
|
|
668
|
+
deleteFromFirestore(filePath).catch(err => console.error(`Delete error: ${err.message}`));
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
watcher.on('ready', () => {
|
|
672
|
+
console.log('Local watcher ready.');
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function handleLocalChange(filePath) {
|
|
677
|
+
if (suppressLocal.has(filePath)) return;
|
|
678
|
+
|
|
679
|
+
const rel = path.relative(LOCAL_PATH, filePath);
|
|
680
|
+
if (!rel.endsWith('.md') && !rel.endsWith('.txt')) return;
|
|
681
|
+
|
|
682
|
+
if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
|
|
683
|
+
if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
|
|
684
|
+
|
|
685
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
686
|
+
pushToFirestore(filePath, content).catch(err => console.error(`Push error: ${err.message}`));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// --- Main ---
|
|
690
|
+
|
|
691
|
+
console.log('');
|
|
692
|
+
startFirestoreListeners();
|
|
693
|
+
startLocalWatcher();
|
|
694
|
+
|
|
695
|
+
console.log('');
|
|
696
|
+
console.log('Sync running. Press Ctrl+C to stop.');
|
|
697
|
+
console.log(` Local folders: ${[...teams.values()].map(t => t.name + ' Teamspace').join(', ')}, ${PRIVATE_FOLDER}, ${SHARED_WITH_ME_FOLDER}, ${SHARED_BY_ME_FOLDER}`);
|