@doubling/compound-sync 1.3.1 → 1.4.1

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 +103 -0
  3. package/package.json +7 -3
  4. package/paths.js +52 -0
  5. package/sync.js +294 -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,103 @@
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
+ }
96
+
97
+ // Write a config object to disk. Creates the parent directory chain if
98
+ // it doesn't already exist (so callers can target paths like
99
+ // ~/.config/compound-sync/work.json without manual mkdir).
100
+ export function saveConfig(absPath, configObject) {
101
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
102
+ fs.writeFileSync(absPath, JSON.stringify(configObject, null, 2) + '\n');
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
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
@@ -33,6 +33,14 @@ import http from 'http';
33
33
  import { exec } from 'child_process';
34
34
  import { fileURLToPath } from 'url';
35
35
  import { pushFileToCloud, deleteFileFromCloud, readFileContent } from './dual-write.js';
36
+ import { resolveConfigPath, loadConfig, saveConfig } 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';
36
44
 
37
45
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
46
 
@@ -301,56 +309,66 @@ async function setupConfig() {
301
309
  });
302
310
  console.log('');
303
311
 
304
- let orgId;
312
+ let selectedIndices;
305
313
  if (orgs.length === 1) {
306
314
  const confirm = await ask(`Use "${orgs[0].name}"? [Y/n]: `) || 'y';
307
- if (confirm.toLowerCase() === 'y') {
308
- orgId = orgs[0].id;
309
- } else {
310
- process.exit(0);
311
- }
315
+ if (confirm.toLowerCase() !== 'y') process.exit(0);
316
+ selectedIndices = [0];
312
317
  } else {
313
- const choice = await ask(`Select organization [1-${orgs.length}]: `);
314
- const idx = parseInt(choice) - 1;
315
- if (idx < 0 || idx >= orgs.length) {
316
- console.error('Invalid selection.');
317
- 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
+ }
318
332
  }
319
- orgId = orgs[idx].id;
320
333
  }
321
334
 
322
- // Local path
323
- console.log('');
324
- const orgName = orgs.find(o => o.id === orgId).name;
325
- const defaultPath = path.join(os.homedir(), orgName + ' Workspace');
326
- const localPath = await ask(`Local sync folder [${defaultPath}]: `) || defaultPath;
327
- const resolvedPath = expandHome(localPath);
328
-
329
- if (!fs.existsSync(resolvedPath)) {
330
- const create = await ask(`"${resolvedPath}" doesn't exist. Create it? [Y/n]: `) || 'y';
331
- if (create.toLowerCase() === 'y') {
332
- fs.mkdirSync(resolvedPath, { recursive: true });
333
- console.log('Created.');
334
- } else {
335
- 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
+ }
336
354
  }
355
+ orgEntries.push({ orgId: org.id, localPath: resolvedPath });
337
356
  }
338
357
 
339
- // Save config (no credentials stored)
340
- const savedConfig = {
358
+ // Save config (no credentials stored). saveConfig creates parent dirs
359
+ // so paths like ~/.config/compound-sync/work.json work without manual mkdir.
360
+ saveConfig(CONFIG_PATH, { projectId, orgs: orgEntries });
361
+ console.log('');
362
+ console.log(`Config saved to ${CONFIG_PATH}`);
363
+ console.log('');
364
+
365
+ return {
341
366
  projectId,
342
- orgId,
343
- localPath: resolvedPath,
367
+ orgs: orgEntries,
344
368
  _userId: userId,
345
369
  _app: app,
346
370
  _db: db,
347
371
  };
348
- fs.writeFileSync(CONFIG_PATH, JSON.stringify({ projectId, orgId, localPath: resolvedPath }, null, 2) + '\n');
349
- console.log('');
350
- console.log(`Config saved to ${CONFIG_PATH}`);
351
- console.log('');
352
-
353
- return savedConfig;
354
372
  }
355
373
 
356
374
  // --- App Check (custom provider for CLI) ---
@@ -455,15 +473,19 @@ for (let i = 0; i < args.length; i++) {
455
473
  if (args[i] === '--env' && args[i + 1]) { envOverride = `doubling-compound-${args[++i]}`; }
456
474
  }
457
475
 
458
- const CONFIG_PATH = path.join(__dirname, configFile);
459
- let config = {};
460
- if (!forceSetup && fs.existsSync(CONFIG_PATH)) {
461
- 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
+ }
462
485
  }
463
- if (forceSetup) { config = {}; }
464
486
 
465
- // Interactive setup if no config
466
- if (!config.projectId || !config.orgId) {
487
+ // Interactive setup if no config exists or --setup was passed
488
+ if (!config) {
467
489
  config = await setupConfig();
468
490
  }
469
491
 
@@ -518,327 +540,285 @@ if (config._app) {
518
540
 
519
541
  }
520
542
 
521
- const ORG_ID = config.orgId;
522
543
  const USER_ID = userId;
523
- const LOCAL_PATH = expandHome(config.localPath || path.join(os.homedir(), 'Sync'));
524
-
525
- const filesRef = collection(db, `orgs/${ORG_ID}/files`);
526
-
527
- // --- Cloud Storage handle ---
528
- //
529
- // Dual-write file content to Cloud Storage in addition to the Firestore
530
- // `content` field. Implementation lives in ./dual-write.js so it can be
531
- // imported by tests and other callers without triggering the daemon's
532
- // browser-auth side effects.
533
-
534
544
  const storage = getStorage(app);
535
545
 
536
- // --- Load teams ---
537
-
538
- const teams = new Map();
539
- const teamIdsByName = new Map();
540
-
541
- async function loadTeams() {
542
- const snapshot = await getDocs(collection(db, `orgs/${ORG_ID}/teams`));
543
- snapshot.forEach(doc => {
544
- const data = doc.data();
545
- teams.set(doc.id, data);
546
- teamIdsByName.set(data.name, doc.id);
547
- });
548
- }
549
-
550
- await loadTeams();
551
-
552
546
  console.log(`Sync daemon starting...`);
553
547
  console.log(` Env: ${config.projectId}`);
554
- console.log(` Org: ${ORG_ID}`);
555
548
  console.log(` User: ${USER_ID}`);
556
- console.log(` Local: ${LOCAL_PATH}`);
557
- 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
+ }
558
571
 
559
- // --- Scope-aware path helpers ---
572
+ await loadTeams();
560
573
 
561
- const PRIVATE_FOLDER = 'Private';
562
- const SHARED_WITH_ME_FOLDER = 'Shared with Me';
563
- 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'}`);
564
577
 
565
- const lastKnownUpdate = new Map();
566
- 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
+ }
567
582
 
568
- function updateSharedByMeSymlink(fileDoc, realPath) {
569
- const sharedWith = fileDoc.sharedWith || [];
570
- 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);
571
586
 
572
- if (sharedWith.length > 0) {
573
- ensureDir(symlinkPath);
574
- const relativeTo = path.relative(path.dirname(symlinkPath), realPath);
575
- try {
576
- if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
577
- 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}`);
578
600
  }
579
- } catch (e) { /* doesn't exist */ }
580
- try {
581
- fs.symlinkSync(relativeTo, symlinkPath);
582
- console.log(` [symlink] ${fileDoc.path} → Shared by Me/`);
583
- } catch (e) {
584
- 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 */ }
585
608
  }
586
- } else {
587
- try {
588
- if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
589
- fs.unlinkSync(symlinkPath);
590
- console.log(` [symlink] removed Shared by Me/${fileDoc.path}`);
591
- }
592
- } catch (e) { /* doesn't exist */ }
593
609
  }
594
- }
595
610
 
596
- function scopeFromLocalPath(filePath) {
597
- 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 });
598
614
 
599
- if (rel.startsWith(PRIVATE_FOLDER + path.sep)) {
600
- return { scope: 'private', docPath: rel.slice(PRIVATE_FOLDER.length + 1) };
601
- }
602
- if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) {
603
- return { scope: 'shared-with-me', docPath: rel.slice(SHARED_WITH_ME_FOLDER.length + 1) };
604
- }
605
- if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) {
606
- return { scope: 'shared-by-me', docPath: rel.slice(SHARED_BY_ME_FOLDER.length + 1) };
607
- }
608
- const parts = rel.split(path.sep);
609
- const folderName = parts[0];
610
- const teamName = folderName.endsWith(' Teamspace') ? folderName.slice(0, -' Teamspace'.length) : folderName;
611
- const teamId = teamIdsByName.get(teamName);
612
- return { scope: 'team', teamId, docPath: parts.slice(1).join('/') };
613
- }
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 });
614
620
 
615
- function ensureDir(filePath) {
616
- const dir = path.dirname(filePath);
617
- if (!fs.existsSync(dir)) {
618
- 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
+ }
619
625
  }
620
- }
621
626
 
622
- // --- Create local folder structure ---
627
+ ensureFolderStructure();
623
628
 
624
- function ensureFolderStructure() {
625
- const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
626
- 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;
627
633
 
628
- const sharedWithMePath = path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER);
629
- if (!fs.existsSync(sharedWithMePath)) fs.mkdirSync(sharedWithMePath, { recursive: true });
634
+ const localFilePath = getLocalPath(data);
635
+ const trackingKey = `${data.scope || 'team'}:${docPath}`;
630
636
 
631
- const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
632
- 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
+ }
633
647
 
634
- for (const [, teamData] of teams) {
635
- const teamPath = path.join(LOCAL_PATH, teamData.name + ' Teamspace');
636
- if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
637
- }
638
- }
648
+ const remoteUpdated = data.updatedAt || data.createdAt || '';
649
+ const lastKnown = lastKnownUpdate.get(trackingKey);
650
+ if (lastKnown && lastKnown >= remoteUpdated) return;
639
651
 
640
- ensureFolderStructure();
652
+ if (fs.existsSync(localFilePath)) {
653
+ const localMtime = fs.statSync(localFilePath).mtime.toISOString();
654
+ if (localMtime > remoteUpdated) return;
655
+ }
641
656
 
642
- // --- 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);
643
661
 
644
- async function handleFirestoreChange(change, getLocalPath) {
645
- const data = change.doc.data();
646
- const docPath = data.path;
647
- 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
+ }
648
671
 
649
- const localPath = getLocalPath(data);
650
- 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
+ }
651
687
 
652
- if (change.type === 'removed') {
653
- if (fs.existsSync(localPath)) {
654
- console.log(` [firestore local] DELETE ${docPath}`);
655
- suppressLocal.add(localPath);
656
- fs.unlinkSync(localPath);
657
- 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`);
658
719
  }
659
- lastKnownUpdate.delete(trackingKey);
660
- return;
661
720
  }
662
721
 
663
- const remoteUpdated = data.updatedAt || data.createdAt || '';
664
- const lastKnown = lastKnownUpdate.get(trackingKey);
665
-
666
- 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
+ });
667
739
 
668
- if (fs.existsSync(localPath)) {
669
- const localMtime = fs.statSync(localPath).mtime.toISOString();
670
- 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
+ }
671
748
  }
672
749
 
673
- const action = change.type === 'added' ? 'CREATE' : 'UPDATE';
674
- console.log(` [firestore local] ${action} ${docPath} (${data.scope || 'team'})`);
675
- ensureDir(localPath);
676
- suppressLocal.add(localPath);
677
-
678
- // Read content from Cloud Storage. As of PR 6 there is no
679
- // fallback; any read failure propagates to the caller's catch
680
- // handler (handleFirestoreChange's promise rejection logger).
681
- const content = await readFileContent({
682
- storage,
683
- orgId: ORG_ID,
684
- fileId: change.doc.id,
685
- });
686
- fs.writeFileSync(localPath, content || '', 'utf-8');
687
- lastKnownUpdate.set(trackingKey, remoteUpdated);
688
- setTimeout(() => suppressLocal.delete(localPath), 1000);
689
- }
750
+ async function deleteFromFirestore(filePath) {
751
+ const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
752
+ const trackingKey = `${scope}:${docPath}`;
753
+ lastKnownUpdate.delete(trackingKey);
690
754
 
691
- function startFirestoreListeners() {
692
- console.log('Starting Firestore listeners...');
693
-
694
- for (const [teamId, teamData] of teams) {
695
- const teamFolder = teamData.name + ' Teamspace';
696
- const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
697
- onSnapshot(q, snapshot => {
698
- snapshot.docChanges().forEach(change => {
699
- handleFirestoreChange(change, (data) => {
700
- return path.join(LOCAL_PATH, teamFolder, data.path);
701
- }).catch(err => console.error('Firestore change handler error:', err && err.message));
702
- });
703
- }, err => console.error(`Team "${teamFolder}" listener error:`, err));
704
- console.log(` Listening: ${teamFolder}`);
705
- }
755
+ const result = await deleteFileFromCloud({
756
+ filesRef,
757
+ storage,
758
+ orgId,
759
+ userId: USER_ID,
760
+ scope,
761
+ teamId,
762
+ docPath,
763
+ });
706
764
 
707
- if (USER_ID) {
708
- const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', USER_ID));
709
- onSnapshot(privateQ, snapshot => {
710
- snapshot.docChanges().forEach(change => {
711
- const data = change.doc.data();
712
- const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
713
-
714
- handleFirestoreChange(change, () => realPath)
715
- .catch(err => console.error('Firestore change handler error:', err && err.message));
716
-
717
- if (change.type !== 'removed') {
718
- updateSharedByMeSymlink(data, realPath);
719
- } else {
720
- const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, data.path);
721
- try { fs.lstatSync(symlinkPath); fs.unlinkSync(symlinkPath); } catch (e) { /* ok */ }
722
- }
723
- });
724
- }, err => console.error('Private files listener error:', err));
725
- console.log(' Listening: Private files + Shared by Me symlinks');
726
-
727
- const sharedQ = query(filesRef, where('sharedWith', 'array-contains', USER_ID));
728
- onSnapshot(sharedQ, snapshot => {
729
- snapshot.docChanges().forEach(change => {
730
- const data = change.doc.data();
731
- if (data.ownerId === USER_ID) return;
732
- handleFirestoreChange(change, () => {
733
- return path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path);
734
- }).catch(err => console.error('Firestore change handler error:', err && err.message));
735
- });
736
- }, err => console.error('Shared with me listener error:', err));
737
- console.log(' Listening: Shared with Me');
765
+ if (result.action === 'deleted') {
766
+ console.log(` [${orgId}] [local firestore] DELETE ${docPath} (${scope})`);
767
+ }
738
768
  }
739
- }
740
769
 
741
- // --- Local → Firestore ---
742
-
743
- async function pushToFirestore(filePath, content) {
744
- const { scope, teamId, docPath } = scopeFromLocalPath(filePath);
745
- const trackingKey = `${scope}:${docPath}`;
746
- const now = new Date().toISOString();
747
- lastKnownUpdate.set(trackingKey, now);
748
-
749
- const result = await pushFileToCloud({
750
- filesRef,
751
- storage,
752
- orgId: ORG_ID,
753
- userId: USER_ID,
754
- scope,
755
- teamId,
756
- docPath,
757
- content,
758
- now,
759
- });
770
+ function handleLocalChange(filePath) {
771
+ if (suppressLocal.has(filePath)) return;
760
772
 
761
- if (result.action === 'created') {
762
- console.log(` [local firestore] CREATE ${docPath} (${scope})`);
763
- } else if (result.action === 'updated') {
764
- console.log(` [local → firestore] UPDATE ${docPath} (${scope})`);
765
- }
766
- if (result.storageError) {
767
- console.error(` [storage] upload failed for ${docPath}: ${result.storageError.message}`);
768
- }
769
- }
773
+ const rel = path.relative(LOCAL_PATH, filePath);
774
+ if (!rel.endsWith('.md') && !rel.endsWith('.txt')) return;
770
775
 
771
- async function deleteFromFirestore(filePath) {
772
- const { scope, teamId, docPath } = scopeFromLocalPath(filePath);
773
- const trackingKey = `${scope}:${docPath}`;
774
- lastKnownUpdate.delete(trackingKey);
775
-
776
- const result = await deleteFileFromCloud({
777
- filesRef,
778
- storage,
779
- orgId: ORG_ID,
780
- userId: USER_ID,
781
- scope,
782
- teamId,
783
- docPath,
784
- });
776
+ if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
777
+ if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
785
778
 
786
- if (result.action === 'deleted') {
787
- 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}`));
788
781
  }
789
- }
790
-
791
- function startLocalWatcher() {
792
- console.log('Watching local files...');
793
-
794
- const watcher = chokidar.watch(LOCAL_PATH, {
795
- ignored: [
796
- /(^|[\/\\])\./,
797
- /node_modules/,
798
- /\.csv$/,
799
- /\.py$/,
800
- /index\.html$/,
801
- ],
802
- persistent: true,
803
- ignoreInitial: false,
804
- followSymlinks: false,
805
- awaitWriteFinish: {
806
- stabilityThreshold: 500,
807
- pollInterval: 100,
808
- },
809
- });
810
782
 
811
- watcher.on('add', filePath => handleLocalChange(filePath));
812
- watcher.on('change', filePath => handleLocalChange(filePath));
813
- watcher.on('unlink', filePath => {
814
- if (suppressLocal.has(filePath)) return;
815
- deleteFromFirestore(filePath).catch(err => console.error(`Delete error: ${err.message}`));
816
- });
817
-
818
- watcher.on('ready', () => {
819
- console.log('Local watcher ready.');
820
- });
821
- }
822
-
823
- function handleLocalChange(filePath) {
824
- 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
+ });
825
802
 
826
- const rel = path.relative(LOCAL_PATH, filePath);
827
- 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
+ });
828
809
 
829
- if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
830
- if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
810
+ watcher.on('ready', () => console.log(` [${orgId}] Local watcher ready.`));
811
+ }
831
812
 
832
- const content = fs.readFileSync(filePath, 'utf-8');
833
- pushToFirestore(filePath, content).catch(err => console.error(`Push error: ${err.message}`));
813
+ startFirestoreListeners();
814
+ startLocalWatcher();
834
815
  }
835
816
 
836
817
  // --- Main ---
837
818
 
838
- console.log('');
839
- startFirestoreListeners();
840
- startLocalWatcher();
819
+ for (const orgEntry of config.orgs) {
820
+ await setupOrgSync(orgEntry);
821
+ }
841
822
 
842
823
  console.log('');
843
824
  console.log('Sync running. Press Ctrl+C to stop.');
844
- console.log(` Local folders: ${[...teams.values()].map(t => t.name + ' Teamspace').join(', ')}, ${PRIVATE_FOLDER}, ${SHARED_WITH_ME_FOLDER}, ${SHARED_BY_ME_FOLDER}`);