@doubling/compound-sync 1.3.0 → 1.4.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.
Files changed (5) hide show
  1. package/README.md +29 -0
  2. package/config.js +95 -0
  3. package/package.json +7 -3
  4. package/paths.js +52 -0
  5. package/sync.js +320 -314
package/README.md CHANGED
@@ -42,6 +42,22 @@ npx @doubling/compound-sync
42
42
 
43
43
  The sync daemon runs until you press Ctrl+C.
44
44
 
45
+ ## Multiple workspaces
46
+
47
+ If you're a member of more than one workspace, you can sync them all from a single daemon. During `--setup` you'll be asked which workspaces to sync (comma-separated or `all`) and given a per-workspace local folder prompt.
48
+
49
+ You can also keep multiple separate configs (one per workspace, or one for "all my workspaces") with `--config` pointing at an absolute path:
50
+
51
+ ```bash
52
+ # Setup
53
+ npx @doubling/compound-sync --config ~/.config/compound-sync/work.json --setup
54
+
55
+ # Run
56
+ npx @doubling/compound-sync --config ~/.config/compound-sync/work.json
57
+ ```
58
+
59
+ `--config` accepts absolute paths (with `~` expanded), so you can keep configs outside the package install directory. Bare filenames still resolve relative to the package install for backward compatibility.
60
+
45
61
  ## Security
46
62
 
47
63
  - No credentials are stored on disk
@@ -66,6 +82,19 @@ npx @doubling/compound-sync --env dev --config config-dev.json
66
82
 
67
83
  Config files (`config-*.json`) are gitignored and stored locally.
68
84
 
85
+ ### Tests
86
+
87
+ ```bash
88
+ # Pure unit tests (no emulator required)
89
+ npm run test:unit
90
+
91
+ # Integration tests (boots firestore + storage emulators)
92
+ npm run test:integration
93
+
94
+ # Both
95
+ npm test
96
+ ```
97
+
69
98
  ### Publishing to npm
70
99
 
71
100
  After making changes to the sync daemon, publish a new version:
package/config.js ADDED
@@ -0,0 +1,95 @@
1
+ // Config loading + normalization for the sync daemon.
2
+ //
3
+ // Two schemas are supported on disk:
4
+ //
5
+ // Legacy (single org):
6
+ // {
7
+ // "projectId": "doubling-compound-prod",
8
+ // "orgId": "abc123",
9
+ // "localPath": "/Users/me/Compound"
10
+ // }
11
+ //
12
+ // Multi-org:
13
+ // {
14
+ // "projectId": "doubling-compound-prod",
15
+ // "orgs": [
16
+ // { "orgId": "abc123", "localPath": "/Users/me/Doubling" },
17
+ // { "orgId": "def456", "localPath": "/Users/me/Hoom" }
18
+ // ]
19
+ // }
20
+ //
21
+ // `normalizeConfig` produces the multi-org shape from either.
22
+
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+
27
+ function expandHome(p) {
28
+ if (typeof p !== 'string') return p;
29
+ if (p === '~') return os.homedir();
30
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
31
+ return p;
32
+ }
33
+
34
+ // Resolve a --config arg to an absolute path.
35
+ // Absolute paths are returned as-is. ~/ is expanded to the user home.
36
+ // Otherwise the arg is treated as relative to defaultDir.
37
+ export function resolveConfigPath(arg, defaultDir) {
38
+ const expanded = expandHome(arg);
39
+ if (path.isAbsolute(expanded)) return expanded;
40
+ return path.join(defaultDir, expanded);
41
+ }
42
+
43
+ // Normalize a raw config object into the multi-org shape.
44
+ // Throws if required fields are missing or contradictory.
45
+ export function normalizeConfig(raw) {
46
+ if (!raw || typeof raw !== 'object') {
47
+ throw new Error('Config must be an object');
48
+ }
49
+ if (!raw.projectId) {
50
+ throw new Error('Config is missing required field: projectId');
51
+ }
52
+
53
+ let orgs;
54
+ if (Array.isArray(raw.orgs)) {
55
+ orgs = raw.orgs;
56
+ } else if (raw.orgId) {
57
+ orgs = [{ orgId: raw.orgId, localPath: raw.localPath }];
58
+ } else {
59
+ throw new Error('Config must define either orgs[] or orgId');
60
+ }
61
+
62
+ if (orgs.length === 0) {
63
+ throw new Error('Config must define at least one org');
64
+ }
65
+
66
+ const seenPaths = new Set();
67
+ const normalizedOrgs = orgs.map((entry, idx) => {
68
+ if (!entry || typeof entry !== 'object') {
69
+ throw new Error(`orgs[${idx}] must be an object`);
70
+ }
71
+ if (!entry.orgId) {
72
+ throw new Error(`orgs[${idx}] is missing required field: orgId`);
73
+ }
74
+ if (!entry.localPath) {
75
+ throw new Error(`orgs[${idx}] is missing required field: localPath`);
76
+ }
77
+ const localPath = expandHome(entry.localPath);
78
+ if (seenPaths.has(localPath)) {
79
+ throw new Error(`orgs[${idx}] has a duplicate localPath: ${localPath}`);
80
+ }
81
+ seenPaths.add(localPath);
82
+ return { orgId: entry.orgId, localPath };
83
+ });
84
+
85
+ return { projectId: raw.projectId, orgs: normalizedOrgs };
86
+ }
87
+
88
+ // Load a config file from disk and return it normalized.
89
+ // Returns null if the file doesn't exist (caller can trigger setup).
90
+ // Throws if the file exists but is invalid JSON or fails normalization.
91
+ export function loadConfig(absPath) {
92
+ if (!fs.existsSync(absPath)) return null;
93
+ const raw = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
94
+ return normalizeConfig(raw);
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,16 +8,20 @@
8
8
  },
9
9
  "files": [
10
10
  "sync.js",
11
+ "config.js",
12
+ "paths.js",
11
13
  "dual-write.js",
12
14
  "README.md"
13
15
  ],
14
16
  "scripts": {
15
17
  "sync": "node sync.js",
16
- "test": "firebase emulators:exec --only firestore,storage 'node --test dual-write.test.js'"
18
+ "test:unit": "node --test config.test.js paths.test.js",
19
+ "test:integration": "firebase emulators:exec --only firestore,storage 'node --test dual-write.test.js'",
20
+ "test": "npm run test:unit && npm run test:integration"
17
21
  },
18
22
  "dependencies": {
19
23
  "firebase": "^11.7.0",
20
- "chokidar": "^4.0.0"
24
+ "chokidar": "^5.0.0"
21
25
  },
22
26
  "devDependencies": {
23
27
  "@firebase/rules-unit-testing": "^4.0.0",
package/paths.js ADDED
@@ -0,0 +1,52 @@
1
+ // Pure path/scope helpers. No fs, no Firebase, no module-level state.
2
+ //
3
+ // The "scope" vocabulary used here (team / private / shared-with-me /
4
+ // shared-by-me) reflects the current Firestore data model and is
5
+ // expected to be swapped if/when authorization moves to a Zanzibar-
6
+ // style relationship model. Keep this module's interface narrow so the
7
+ // swap stays localized.
8
+
9
+ import path from 'node:path';
10
+
11
+ export const PRIVATE_FOLDER = 'Private';
12
+ export const SHARED_WITH_ME_FOLDER = 'Shared with Me';
13
+ export const SHARED_BY_ME_FOLDER = 'Shared by Me';
14
+ const TEAMSPACE_SUFFIX = ' Teamspace';
15
+
16
+ export function teamFolderName(teamName) {
17
+ return teamName + TEAMSPACE_SUFFIX;
18
+ }
19
+
20
+ // Given an absolute file path within one of the daemon's local sync
21
+ // roots, return { scope, teamId?, docPath }. The caller passes the
22
+ // matching localPath root and the team-name → team-id map.
23
+ export function scopeFromLocalPath(filePath, localPath, teamIdsByName) {
24
+ const rel = path.relative(localPath, filePath);
25
+
26
+ if (rel.startsWith(PRIVATE_FOLDER + path.sep)) {
27
+ return {
28
+ scope: 'private',
29
+ docPath: rel.slice(PRIVATE_FOLDER.length + 1),
30
+ };
31
+ }
32
+ if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) {
33
+ return {
34
+ scope: 'shared-with-me',
35
+ docPath: rel.slice(SHARED_WITH_ME_FOLDER.length + 1),
36
+ };
37
+ }
38
+ if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) {
39
+ return {
40
+ scope: 'shared-by-me',
41
+ docPath: rel.slice(SHARED_BY_ME_FOLDER.length + 1),
42
+ };
43
+ }
44
+
45
+ const parts = rel.split(path.sep);
46
+ const folderName = parts[0];
47
+ const teamName = folderName.endsWith(TEAMSPACE_SUFFIX)
48
+ ? folderName.slice(0, -TEAMSPACE_SUFFIX.length)
49
+ : folderName;
50
+ const teamId = teamIdsByName.get(teamName);
51
+ return { scope: 'team', teamId, docPath: parts.slice(1).join('/') };
52
+ }
package/sync.js CHANGED
@@ -11,7 +11,9 @@
11
11
  * Shared by Me/ ← symlinks to files you've shared
12
12
  * Shared with Me/ ← files shared with you (read-only)
13
13
  *
14
- * Usage: node sync.js [--config <file>] [--setup]
14
+ * Usage: node sync.js [--config <file>] [--setup] [--env <name>]
15
+ * node sync.js --version
16
+ * node sync.js --help
15
17
  */
16
18
 
17
19
  import { initializeApp } from 'firebase/app';
@@ -31,6 +33,14 @@ import http from 'http';
31
33
  import { exec } from 'child_process';
32
34
  import { fileURLToPath } from 'url';
33
35
  import { pushFileToCloud, deleteFileFromCloud, readFileContent } from './dual-write.js';
36
+ import { resolveConfigPath, loadConfig } from './config.js';
37
+ import {
38
+ scopeFromLocalPath,
39
+ teamFolderName,
40
+ PRIVATE_FOLDER,
41
+ SHARED_WITH_ME_FOLDER,
42
+ SHARED_BY_ME_FOLDER,
43
+ } from './paths.js';
34
44
 
35
45
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
46
 
@@ -299,56 +309,66 @@ async function setupConfig() {
299
309
  });
300
310
  console.log('');
301
311
 
302
- let orgId;
312
+ let selectedIndices;
303
313
  if (orgs.length === 1) {
304
314
  const confirm = await ask(`Use "${orgs[0].name}"? [Y/n]: `) || 'y';
305
- if (confirm.toLowerCase() === 'y') {
306
- orgId = orgs[0].id;
307
- } else {
308
- process.exit(0);
309
- }
315
+ if (confirm.toLowerCase() !== 'y') process.exit(0);
316
+ selectedIndices = [0];
310
317
  } else {
311
- const choice = await ask(`Select organization [1-${orgs.length}]: `);
312
- const idx = parseInt(choice) - 1;
313
- if (idx < 0 || idx >= orgs.length) {
314
- console.error('Invalid selection.');
315
- process.exit(1);
318
+ const choice = await ask(
319
+ `Select organizations to sync (e.g. "1,3" or "all") [1-${orgs.length}]: `,
320
+ );
321
+ if (choice.toLowerCase() === 'all') {
322
+ selectedIndices = orgs.map((_, i) => i);
323
+ } else {
324
+ selectedIndices = choice
325
+ .split(',')
326
+ .map(s => parseInt(s.trim(), 10) - 1)
327
+ .filter(i => Number.isInteger(i) && i >= 0 && i < orgs.length);
328
+ if (selectedIndices.length === 0) {
329
+ console.error('Invalid selection.');
330
+ process.exit(1);
331
+ }
316
332
  }
317
- orgId = orgs[idx].id;
318
333
  }
319
334
 
320
- // Local path
321
- console.log('');
322
- const orgName = orgs.find(o => o.id === orgId).name;
323
- const defaultPath = path.join(os.homedir(), orgName + ' Workspace');
324
- const localPath = await ask(`Local sync folder [${defaultPath}]: `) || defaultPath;
325
- const resolvedPath = expandHome(localPath);
326
-
327
- if (!fs.existsSync(resolvedPath)) {
328
- const create = await ask(`"${resolvedPath}" doesn't exist. Create it? [Y/n]: `) || 'y';
329
- if (create.toLowerCase() === 'y') {
330
- fs.mkdirSync(resolvedPath, { recursive: true });
331
- console.log('Created.');
332
- } else {
333
- process.exit(0);
335
+ // Per-org local path
336
+ const orgEntries = [];
337
+ for (const idx of selectedIndices) {
338
+ const org = orgs[idx];
339
+ console.log('');
340
+ const defaultPath = path.join(os.homedir(), org.name + ' Workspace');
341
+ const localPath = await ask(
342
+ `Local sync folder for "${org.name}" [${defaultPath}]: `,
343
+ ) || defaultPath;
344
+ const resolvedPath = expandHome(localPath);
345
+
346
+ if (!fs.existsSync(resolvedPath)) {
347
+ const create = await ask(`"${resolvedPath}" doesn't exist. Create it? [Y/n]: `) || 'y';
348
+ if (create.toLowerCase() === 'y') {
349
+ fs.mkdirSync(resolvedPath, { recursive: true });
350
+ console.log('Created.');
351
+ } else {
352
+ process.exit(0);
353
+ }
334
354
  }
355
+ orgEntries.push({ orgId: org.id, localPath: resolvedPath });
335
356
  }
336
357
 
337
358
  // Save config (no credentials stored)
338
- const savedConfig = {
359
+ const onDisk = { projectId, orgs: orgEntries };
360
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(onDisk, null, 2) + '\n');
361
+ console.log('');
362
+ console.log(`Config saved to ${CONFIG_PATH}`);
363
+ console.log('');
364
+
365
+ return {
339
366
  projectId,
340
- orgId,
341
- localPath: resolvedPath,
367
+ orgs: orgEntries,
342
368
  _userId: userId,
343
369
  _app: app,
344
370
  _db: db,
345
371
  };
346
- fs.writeFileSync(CONFIG_PATH, JSON.stringify({ projectId, orgId, localPath: resolvedPath }, null, 2) + '\n');
347
- console.log('');
348
- console.log(`Config saved to ${CONFIG_PATH}`);
349
- console.log('');
350
-
351
- return savedConfig;
352
372
  }
353
373
 
354
374
  // --- App Check (custom provider for CLI) ---
@@ -420,6 +440,30 @@ async function initAppCheck(app, auth, tokenEndpoint) {
420
440
  // --- Config ---
421
441
 
422
442
  const args = process.argv.slice(2);
443
+
444
+ // Handle --version and --help early so they short-circuit before any
445
+ // Firebase init or browser-auth side effects.
446
+ if (args.includes('--version') || args.includes('-v')) {
447
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'));
448
+ console.log(pkg.version);
449
+ process.exit(0);
450
+ }
451
+
452
+ if (args.includes('--help') || args.includes('-h')) {
453
+ console.log(`compound-sync - Doubling Compound sync daemon
454
+
455
+ Usage:
456
+ compound-sync Start the daemon (interactive setup on first run)
457
+ compound-sync --config <file> Use a specific config file
458
+ compound-sync --setup Force the setup wizard
459
+ compound-sync --env <name> Override env (sandbox|dev|prod)
460
+ compound-sync --version, -v Print version and exit
461
+ compound-sync --help, -h Print this help and exit
462
+
463
+ More: https://github.com/Doubling-Inc/doubling-compound`);
464
+ process.exit(0);
465
+ }
466
+
423
467
  let configFile = 'config.json';
424
468
  let forceSetup = false;
425
469
  let envOverride = null;
@@ -429,15 +473,19 @@ for (let i = 0; i < args.length; i++) {
429
473
  if (args[i] === '--env' && args[i + 1]) { envOverride = `doubling-compound-${args[++i]}`; }
430
474
  }
431
475
 
432
- const CONFIG_PATH = path.join(__dirname, configFile);
433
- let config = {};
434
- if (!forceSetup && fs.existsSync(CONFIG_PATH)) {
435
- config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
476
+ const CONFIG_PATH = resolveConfigPath(configFile, __dirname);
477
+ let config = null;
478
+ if (!forceSetup) {
479
+ try {
480
+ config = loadConfig(CONFIG_PATH);
481
+ } catch (err) {
482
+ console.error(`Failed to load ${CONFIG_PATH}: ${err.message}`);
483
+ process.exit(1);
484
+ }
436
485
  }
437
- if (forceSetup) { config = {}; }
438
486
 
439
- // Interactive setup if no config
440
- if (!config.projectId || !config.orgId) {
487
+ // Interactive setup if no config exists or --setup was passed
488
+ if (!config) {
441
489
  config = await setupConfig();
442
490
  }
443
491
 
@@ -492,327 +540,285 @@ if (config._app) {
492
540
 
493
541
  }
494
542
 
495
- const ORG_ID = config.orgId;
496
543
  const USER_ID = userId;
497
- const LOCAL_PATH = expandHome(config.localPath || path.join(os.homedir(), 'Sync'));
498
-
499
- const filesRef = collection(db, `orgs/${ORG_ID}/files`);
500
-
501
- // --- Cloud Storage handle ---
502
- //
503
- // Dual-write file content to Cloud Storage in addition to the Firestore
504
- // `content` field. Implementation lives in ./dual-write.js so it can be
505
- // imported by tests and other callers without triggering the daemon's
506
- // browser-auth side effects.
507
-
508
544
  const storage = getStorage(app);
509
545
 
510
- // --- Load teams ---
511
-
512
- const teams = new Map();
513
- const teamIdsByName = new Map();
514
-
515
- async function loadTeams() {
516
- const snapshot = await getDocs(collection(db, `orgs/${ORG_ID}/teams`));
517
- snapshot.forEach(doc => {
518
- const data = doc.data();
519
- teams.set(doc.id, data);
520
- teamIdsByName.set(data.name, doc.id);
521
- });
522
- }
523
-
524
- await loadTeams();
525
-
526
546
  console.log(`Sync daemon starting...`);
527
547
  console.log(` Env: ${config.projectId}`);
528
- console.log(` Org: ${ORG_ID}`);
529
548
  console.log(` User: ${USER_ID}`);
530
- console.log(` Local: ${LOCAL_PATH}`);
531
- console.log(` Teams: ${[...teams.values()].map(t => t.name).join(', ') || 'none'}`);
549
+ console.log(` Orgs: ${config.orgs.length}`);
550
+
551
+ // Per-org runtime: each org gets its own closure with isolated state
552
+ // (LOCAL_PATH, filesRef, teams map, debounce sets, watcher, listeners).
553
+ // This keeps two orgs' sync logic from interfering with each other.
554
+ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
555
+ const LOCAL_PATH = expandHome(rawLocalPath);
556
+ const filesRef = collection(db, `orgs/${orgId}/files`);
557
+
558
+ const teams = new Map();
559
+ const teamIdsByName = new Map();
560
+ const lastKnownUpdate = new Map();
561
+ const suppressLocal = new Set();
562
+
563
+ async function loadTeams() {
564
+ const snapshot = await getDocs(collection(db, `orgs/${orgId}/teams`));
565
+ snapshot.forEach(d => {
566
+ const data = d.data();
567
+ teams.set(d.id, data);
568
+ teamIdsByName.set(data.name, d.id);
569
+ });
570
+ }
532
571
 
533
- // --- Scope-aware path helpers ---
572
+ await loadTeams();
534
573
 
535
- const PRIVATE_FOLDER = 'Private';
536
- const SHARED_WITH_ME_FOLDER = 'Shared with Me';
537
- const SHARED_BY_ME_FOLDER = 'Shared by Me';
574
+ console.log('');
575
+ console.log(` [${orgId}] Local: ${LOCAL_PATH}`);
576
+ console.log(` [${orgId}] Teams: ${[...teams.values()].map(t => t.name).join(', ') || 'none'}`);
538
577
 
539
- const lastKnownUpdate = new Map();
540
- const suppressLocal = new Set();
578
+ function ensureDir(filePath) {
579
+ const dir = path.dirname(filePath);
580
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
581
+ }
541
582
 
542
- function updateSharedByMeSymlink(fileDoc, realPath) {
543
- const sharedWith = fileDoc.sharedWith || [];
544
- const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, fileDoc.path);
583
+ function updateSharedByMeSymlink(fileDoc, realPath) {
584
+ const sharedWith = fileDoc.sharedWith || [];
585
+ const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, fileDoc.path);
545
586
 
546
- if (sharedWith.length > 0) {
547
- ensureDir(symlinkPath);
548
- const relativeTo = path.relative(path.dirname(symlinkPath), realPath);
549
- try {
550
- if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
551
- fs.unlinkSync(symlinkPath);
587
+ if (sharedWith.length > 0) {
588
+ ensureDir(symlinkPath);
589
+ const relativeTo = path.relative(path.dirname(symlinkPath), realPath);
590
+ try {
591
+ if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
592
+ fs.unlinkSync(symlinkPath);
593
+ }
594
+ } catch (e) { /* doesn't exist */ }
595
+ try {
596
+ fs.symlinkSync(relativeTo, symlinkPath);
597
+ console.log(` [${orgId}] [symlink] ${fileDoc.path} → Shared by Me/`);
598
+ } catch (e) {
599
+ if (e.code !== 'EEXIST') console.error(` [${orgId}] Symlink error: ${e.message}`);
552
600
  }
553
- } catch (e) { /* doesn't exist */ }
554
- try {
555
- fs.symlinkSync(relativeTo, symlinkPath);
556
- console.log(` [symlink] ${fileDoc.path} → Shared by Me/`);
557
- } catch (e) {
558
- if (e.code !== 'EEXIST') console.error(` Symlink error: ${e.message}`);
601
+ } else {
602
+ try {
603
+ if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
604
+ fs.unlinkSync(symlinkPath);
605
+ console.log(` [${orgId}] [symlink] removed Shared by Me/${fileDoc.path}`);
606
+ }
607
+ } catch (e) { /* doesn't exist */ }
559
608
  }
560
- } else {
561
- try {
562
- if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
563
- fs.unlinkSync(symlinkPath);
564
- console.log(` [symlink] removed Shared by Me/${fileDoc.path}`);
565
- }
566
- } catch (e) { /* doesn't exist */ }
567
609
  }
568
- }
569
610
 
570
- function scopeFromLocalPath(filePath) {
571
- const rel = path.relative(LOCAL_PATH, filePath);
611
+ function ensureFolderStructure() {
612
+ const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
613
+ if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
572
614
 
573
- if (rel.startsWith(PRIVATE_FOLDER + path.sep)) {
574
- return { scope: 'private', docPath: rel.slice(PRIVATE_FOLDER.length + 1) };
575
- }
576
- if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) {
577
- return { scope: 'shared-with-me', docPath: rel.slice(SHARED_WITH_ME_FOLDER.length + 1) };
578
- }
579
- if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) {
580
- return { scope: 'shared-by-me', docPath: rel.slice(SHARED_BY_ME_FOLDER.length + 1) };
581
- }
582
- const parts = rel.split(path.sep);
583
- const folderName = parts[0];
584
- const teamName = folderName.endsWith(' Teamspace') ? folderName.slice(0, -' Teamspace'.length) : folderName;
585
- const teamId = teamIdsByName.get(teamName);
586
- return { scope: 'team', teamId, docPath: parts.slice(1).join('/') };
587
- }
615
+ const sharedWithMePath = path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER);
616
+ if (!fs.existsSync(sharedWithMePath)) fs.mkdirSync(sharedWithMePath, { recursive: true });
617
+
618
+ const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
619
+ if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
588
620
 
589
- function ensureDir(filePath) {
590
- const dir = path.dirname(filePath);
591
- if (!fs.existsSync(dir)) {
592
- fs.mkdirSync(dir, { recursive: true });
621
+ for (const [, teamData] of teams) {
622
+ const teamPath = path.join(LOCAL_PATH, teamFolderName(teamData.name));
623
+ if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
624
+ }
593
625
  }
594
- }
595
626
 
596
- // --- Create local folder structure ---
627
+ ensureFolderStructure();
597
628
 
598
- function ensureFolderStructure() {
599
- const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
600
- if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
629
+ async function handleFirestoreChange(change, getLocalPath) {
630
+ const data = change.doc.data();
631
+ const docPath = data.path;
632
+ if (!docPath) return;
601
633
 
602
- const sharedWithMePath = path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER);
603
- if (!fs.existsSync(sharedWithMePath)) fs.mkdirSync(sharedWithMePath, { recursive: true });
634
+ const localFilePath = getLocalPath(data);
635
+ const trackingKey = `${data.scope || 'team'}:${docPath}`;
604
636
 
605
- const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
606
- if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
637
+ if (change.type === 'removed') {
638
+ if (fs.existsSync(localFilePath)) {
639
+ console.log(` [${orgId}] [firestore → local] DELETE ${docPath}`);
640
+ suppressLocal.add(localFilePath);
641
+ fs.unlinkSync(localFilePath);
642
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
643
+ }
644
+ lastKnownUpdate.delete(trackingKey);
645
+ return;
646
+ }
607
647
 
608
- for (const [, teamData] of teams) {
609
- const teamPath = path.join(LOCAL_PATH, teamData.name + ' Teamspace');
610
- if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
611
- }
612
- }
648
+ const remoteUpdated = data.updatedAt || data.createdAt || '';
649
+ const lastKnown = lastKnownUpdate.get(trackingKey);
650
+ if (lastKnown && lastKnown >= remoteUpdated) return;
613
651
 
614
- ensureFolderStructure();
652
+ if (fs.existsSync(localFilePath)) {
653
+ const localMtime = fs.statSync(localFilePath).mtime.toISOString();
654
+ if (localMtime > remoteUpdated) return;
655
+ }
615
656
 
616
- // --- Firestore Local (multiple listeners) ---
657
+ const action = change.type === 'added' ? 'CREATE' : 'UPDATE';
658
+ console.log(` [${orgId}] [firestore → local] ${action} ${docPath} (${data.scope || 'team'})`);
659
+ ensureDir(localFilePath);
660
+ suppressLocal.add(localFilePath);
617
661
 
618
- async function handleFirestoreChange(change, getLocalPath) {
619
- const data = change.doc.data();
620
- const docPath = data.path;
621
- if (!docPath) return;
662
+ const content = await readFileContent({
663
+ storage,
664
+ orgId,
665
+ fileId: change.doc.id,
666
+ });
667
+ fs.writeFileSync(localFilePath, content || '', 'utf-8');
668
+ lastKnownUpdate.set(trackingKey, remoteUpdated);
669
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
670
+ }
622
671
 
623
- const localPath = getLocalPath(data);
624
- const trackingKey = `${data.scope || 'team'}:${docPath}`;
672
+ function startFirestoreListeners() {
673
+ console.log(` [${orgId}] Starting Firestore listeners...`);
674
+
675
+ for (const [teamId, teamData] of teams) {
676
+ const teamFolder = teamFolderName(teamData.name);
677
+ const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
678
+ onSnapshot(q, snapshot => {
679
+ snapshot.docChanges().forEach(change => {
680
+ handleFirestoreChange(change, data =>
681
+ path.join(LOCAL_PATH, teamFolder, data.path),
682
+ ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
683
+ });
684
+ }, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
685
+ console.log(` [${orgId}] Listening: ${teamFolder}`);
686
+ }
625
687
 
626
- if (change.type === 'removed') {
627
- if (fs.existsSync(localPath)) {
628
- console.log(` [firestore local] DELETE ${docPath}`);
629
- suppressLocal.add(localPath);
630
- fs.unlinkSync(localPath);
631
- setTimeout(() => suppressLocal.delete(localPath), 1000);
688
+ if (USER_ID) {
689
+ const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', USER_ID));
690
+ onSnapshot(privateQ, snapshot => {
691
+ snapshot.docChanges().forEach(change => {
692
+ const data = change.doc.data();
693
+ const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
694
+
695
+ handleFirestoreChange(change, () => realPath)
696
+ .catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
697
+
698
+ if (change.type !== 'removed') {
699
+ updateSharedByMeSymlink(data, realPath);
700
+ } else {
701
+ const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, data.path);
702
+ try { fs.lstatSync(symlinkPath); fs.unlinkSync(symlinkPath); } catch (e) { /* ok */ }
703
+ }
704
+ });
705
+ }, err => console.error(` [${orgId}] Private files listener error:`, err));
706
+ console.log(` [${orgId}] Listening: Private files + Shared by Me symlinks`);
707
+
708
+ const sharedQ = query(filesRef, where('sharedWith', 'array-contains', USER_ID));
709
+ onSnapshot(sharedQ, snapshot => {
710
+ snapshot.docChanges().forEach(change => {
711
+ const data = change.doc.data();
712
+ if (data.ownerId === USER_ID) return;
713
+ handleFirestoreChange(change, () =>
714
+ path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path),
715
+ ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
716
+ });
717
+ }, err => console.error(` [${orgId}] Shared with me listener error:`, err));
718
+ console.log(` [${orgId}] Listening: Shared with Me`);
632
719
  }
633
- lastKnownUpdate.delete(trackingKey);
634
- return;
635
720
  }
636
721
 
637
- const remoteUpdated = data.updatedAt || data.createdAt || '';
638
- const lastKnown = lastKnownUpdate.get(trackingKey);
639
-
640
- if (lastKnown && lastKnown >= remoteUpdated) return;
722
+ async function pushToFirestore(filePath, content) {
723
+ const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
724
+ const trackingKey = `${scope}:${docPath}`;
725
+ const now = new Date().toISOString();
726
+ lastKnownUpdate.set(trackingKey, now);
727
+
728
+ const result = await pushFileToCloud({
729
+ filesRef,
730
+ storage,
731
+ orgId,
732
+ userId: USER_ID,
733
+ scope,
734
+ teamId,
735
+ docPath,
736
+ content,
737
+ now,
738
+ });
641
739
 
642
- if (fs.existsSync(localPath)) {
643
- const localMtime = fs.statSync(localPath).mtime.toISOString();
644
- if (localMtime > remoteUpdated) return;
740
+ if (result.action === 'created') {
741
+ console.log(` [${orgId}] [local firestore] CREATE ${docPath} (${scope})`);
742
+ } else if (result.action === 'updated') {
743
+ console.log(` [${orgId}] [local → firestore] UPDATE ${docPath} (${scope})`);
744
+ }
745
+ if (result.storageError) {
746
+ console.error(` [${orgId}] [storage] upload failed for ${docPath}: ${result.storageError.message}`);
747
+ }
645
748
  }
646
749
 
647
- const action = change.type === 'added' ? 'CREATE' : 'UPDATE';
648
- console.log(` [firestore local] ${action} ${docPath} (${data.scope || 'team'})`);
649
- ensureDir(localPath);
650
- suppressLocal.add(localPath);
651
-
652
- // Read content from Cloud Storage. As of PR 6 there is no
653
- // fallback; any read failure propagates to the caller's catch
654
- // handler (handleFirestoreChange's promise rejection logger).
655
- const content = await readFileContent({
656
- storage,
657
- orgId: ORG_ID,
658
- fileId: change.doc.id,
659
- });
660
- fs.writeFileSync(localPath, content || '', 'utf-8');
661
- lastKnownUpdate.set(trackingKey, remoteUpdated);
662
- setTimeout(() => suppressLocal.delete(localPath), 1000);
663
- }
750
+ async function deleteFromFirestore(filePath) {
751
+ const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
752
+ const trackingKey = `${scope}:${docPath}`;
753
+ lastKnownUpdate.delete(trackingKey);
664
754
 
665
- function startFirestoreListeners() {
666
- console.log('Starting Firestore listeners...');
667
-
668
- for (const [teamId, teamData] of teams) {
669
- const teamFolder = teamData.name + ' Teamspace';
670
- const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
671
- onSnapshot(q, snapshot => {
672
- snapshot.docChanges().forEach(change => {
673
- handleFirestoreChange(change, (data) => {
674
- return path.join(LOCAL_PATH, teamFolder, data.path);
675
- }).catch(err => console.error('Firestore change handler error:', err && err.message));
676
- });
677
- }, err => console.error(`Team "${teamFolder}" listener error:`, err));
678
- console.log(` Listening: ${teamFolder}`);
679
- }
755
+ const result = await deleteFileFromCloud({
756
+ filesRef,
757
+ storage,
758
+ orgId,
759
+ userId: USER_ID,
760
+ scope,
761
+ teamId,
762
+ docPath,
763
+ });
680
764
 
681
- if (USER_ID) {
682
- const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', USER_ID));
683
- onSnapshot(privateQ, snapshot => {
684
- snapshot.docChanges().forEach(change => {
685
- const data = change.doc.data();
686
- const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
687
-
688
- handleFirestoreChange(change, () => realPath)
689
- .catch(err => console.error('Firestore change handler error:', err && err.message));
690
-
691
- if (change.type !== 'removed') {
692
- updateSharedByMeSymlink(data, realPath);
693
- } else {
694
- const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, data.path);
695
- try { fs.lstatSync(symlinkPath); fs.unlinkSync(symlinkPath); } catch (e) { /* ok */ }
696
- }
697
- });
698
- }, err => console.error('Private files listener error:', err));
699
- console.log(' Listening: Private files + Shared by Me symlinks');
700
-
701
- const sharedQ = query(filesRef, where('sharedWith', 'array-contains', USER_ID));
702
- onSnapshot(sharedQ, snapshot => {
703
- snapshot.docChanges().forEach(change => {
704
- const data = change.doc.data();
705
- if (data.ownerId === USER_ID) return;
706
- handleFirestoreChange(change, () => {
707
- return path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path);
708
- }).catch(err => console.error('Firestore change handler error:', err && err.message));
709
- });
710
- }, err => console.error('Shared with me listener error:', err));
711
- console.log(' Listening: Shared with Me');
765
+ if (result.action === 'deleted') {
766
+ console.log(` [${orgId}] [local firestore] DELETE ${docPath} (${scope})`);
767
+ }
712
768
  }
713
- }
714
769
 
715
- // --- Local → Firestore ---
716
-
717
- async function pushToFirestore(filePath, content) {
718
- const { scope, teamId, docPath } = scopeFromLocalPath(filePath);
719
- const trackingKey = `${scope}:${docPath}`;
720
- const now = new Date().toISOString();
721
- lastKnownUpdate.set(trackingKey, now);
722
-
723
- const result = await pushFileToCloud({
724
- filesRef,
725
- storage,
726
- orgId: ORG_ID,
727
- userId: USER_ID,
728
- scope,
729
- teamId,
730
- docPath,
731
- content,
732
- now,
733
- });
770
+ function handleLocalChange(filePath) {
771
+ if (suppressLocal.has(filePath)) return;
734
772
 
735
- if (result.action === 'created') {
736
- console.log(` [local firestore] CREATE ${docPath} (${scope})`);
737
- } else if (result.action === 'updated') {
738
- console.log(` [local → firestore] UPDATE ${docPath} (${scope})`);
739
- }
740
- if (result.storageError) {
741
- console.error(` [storage] upload failed for ${docPath}: ${result.storageError.message}`);
742
- }
743
- }
773
+ const rel = path.relative(LOCAL_PATH, filePath);
774
+ if (!rel.endsWith('.md') && !rel.endsWith('.txt')) return;
744
775
 
745
- async function deleteFromFirestore(filePath) {
746
- const { scope, teamId, docPath } = scopeFromLocalPath(filePath);
747
- const trackingKey = `${scope}:${docPath}`;
748
- lastKnownUpdate.delete(trackingKey);
749
-
750
- const result = await deleteFileFromCloud({
751
- filesRef,
752
- storage,
753
- orgId: ORG_ID,
754
- userId: USER_ID,
755
- scope,
756
- teamId,
757
- docPath,
758
- });
776
+ if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
777
+ if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
759
778
 
760
- if (result.action === 'deleted') {
761
- console.log(` [local → firestore] DELETE ${docPath} (${scope})`);
779
+ const content = fs.readFileSync(filePath, 'utf-8');
780
+ pushToFirestore(filePath, content).catch(err => console.error(` [${orgId}] Push error: ${err.message}`));
762
781
  }
763
- }
764
-
765
- function startLocalWatcher() {
766
- console.log('Watching local files...');
767
-
768
- const watcher = chokidar.watch(LOCAL_PATH, {
769
- ignored: [
770
- /(^|[\/\\])\./,
771
- /node_modules/,
772
- /\.csv$/,
773
- /\.py$/,
774
- /index\.html$/,
775
- ],
776
- persistent: true,
777
- ignoreInitial: false,
778
- followSymlinks: false,
779
- awaitWriteFinish: {
780
- stabilityThreshold: 500,
781
- pollInterval: 100,
782
- },
783
- });
784
-
785
- watcher.on('add', filePath => handleLocalChange(filePath));
786
- watcher.on('change', filePath => handleLocalChange(filePath));
787
- watcher.on('unlink', filePath => {
788
- if (suppressLocal.has(filePath)) return;
789
- deleteFromFirestore(filePath).catch(err => console.error(`Delete error: ${err.message}`));
790
- });
791
-
792
- watcher.on('ready', () => {
793
- console.log('Local watcher ready.');
794
- });
795
- }
796
782
 
797
- function handleLocalChange(filePath) {
798
- if (suppressLocal.has(filePath)) return;
783
+ function startLocalWatcher() {
784
+ console.log(` [${orgId}] Watching local files...`);
785
+
786
+ const watcher = chokidar.watch(LOCAL_PATH, {
787
+ ignored: [
788
+ /(^|[\/\\])\./,
789
+ /node_modules/,
790
+ /\.csv$/,
791
+ /\.py$/,
792
+ /index\.html$/,
793
+ ],
794
+ persistent: true,
795
+ ignoreInitial: false,
796
+ followSymlinks: false,
797
+ awaitWriteFinish: {
798
+ stabilityThreshold: 500,
799
+ pollInterval: 100,
800
+ },
801
+ });
799
802
 
800
- const rel = path.relative(LOCAL_PATH, filePath);
801
- if (!rel.endsWith('.md') && !rel.endsWith('.txt')) return;
803
+ watcher.on('add', filePath => handleLocalChange(filePath));
804
+ watcher.on('change', filePath => handleLocalChange(filePath));
805
+ watcher.on('unlink', filePath => {
806
+ if (suppressLocal.has(filePath)) return;
807
+ deleteFromFirestore(filePath).catch(err => console.error(` [${orgId}] Delete error: ${err.message}`));
808
+ });
802
809
 
803
- if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
804
- if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
810
+ watcher.on('ready', () => console.log(` [${orgId}] Local watcher ready.`));
811
+ }
805
812
 
806
- const content = fs.readFileSync(filePath, 'utf-8');
807
- pushToFirestore(filePath, content).catch(err => console.error(`Push error: ${err.message}`));
813
+ startFirestoreListeners();
814
+ startLocalWatcher();
808
815
  }
809
816
 
810
817
  // --- Main ---
811
818
 
812
- console.log('');
813
- startFirestoreListeners();
814
- startLocalWatcher();
819
+ for (const orgEntry of config.orgs) {
820
+ await setupOrgSync(orgEntry);
821
+ }
815
822
 
816
823
  console.log('');
817
824
  console.log('Sync running. Press Ctrl+C to stop.');
818
- console.log(` Local folders: ${[...teams.values()].map(t => t.name + ' Teamspace').join(', ')}, ${PRIVATE_FOLDER}, ${SHARED_WITH_ME_FOLDER}, ${SHARED_BY_ME_FOLDER}`);