@doubling/compound-sync 1.7.1 → 1.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sync.js CHANGED
@@ -48,6 +48,8 @@ import {
48
48
  const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
49
49
  import { resolveConfigPath, loadConfig, saveConfig } from './config.js';
50
50
  import { makeFilePersistence, resolveAuthFilePath } from './auth-persistence.js';
51
+ import { readPersistedRefreshToken, exchangeRefreshTokenForIdToken } from './pre-mint-app-check.js';
52
+ import { setupAuthWithPreMint } from './setup-auth-with-pre-mint.js';
51
53
  import {
52
54
  scopeFromLocalPath,
53
55
  teamFolderName,
@@ -56,6 +58,7 @@ import {
56
58
  SHARED_BY_ME_FOLDER,
57
59
  } from './paths.js';
58
60
  import { TeamRegistry } from './team-registry.js';
61
+ import { startOrgSync } from './org-sync.js';
59
62
 
60
63
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
61
64
 
@@ -452,6 +455,93 @@ async function initAppCheck(app, auth, tokenEndpoint) {
452
455
  await initAppCheckWithIdToken(app, tokenEndpoint, idToken);
453
456
  }
454
457
 
458
+ // --- Non-interactive account discovery (for the desktop app) ---
459
+ //
460
+ // Sign in (restoring a persisted session if present, else via the
461
+ // browser handoff), persisting credentials to COMPOUND_AUTH_FILE so
462
+ // later launches are silent, then list the user's orgs and print a
463
+ // single machine-readable line:
464
+ // COMPOUND_DISCOVER {"userId":..,"email":..,"orgs":[{"id":..,"name":..}]}
465
+ // The desktop parses that to register the account and write its
466
+ // per-account config. Exits when done.
467
+ async function discoverOrgs(projectId) {
468
+ const fbConfig = firebaseConfigs[projectId];
469
+ if (!fbConfig) {
470
+ console.error(`Unknown project: ${projectId}`);
471
+ process.exit(1);
472
+ }
473
+
474
+ // Use the DEFAULT app name (not a named 'discover' app): Firebase
475
+ // derives its auth-persistence storage key from the app name, and the
476
+ // daemon reads the default-app key. A named app here would persist
477
+ // under a different key, so the daemon wouldn't find the session and
478
+ // would prompt for sign-in a second time. discover exits before the
479
+ // daemon block runs, so there's no app-name collision.
480
+ const app = initializeApp(fbConfig);
481
+ const authFilePath = resolveAuthFilePath({
482
+ authFileEnv: process.env.COMPOUND_AUTH_FILE,
483
+ homeDir: os.homedir(),
484
+ projectId,
485
+ accountKey: 'discover',
486
+ });
487
+ // Pre-mint App Check on the app, THEN initializeAuth. The ordering
488
+ // matters: see setup-auth-with-pre-mint.js for the call-order
489
+ // contract and why the 1.9.3 code (initializeAuth before pre-mint)
490
+ // raced the SDK's startup :lookup and lost.
491
+ const appCheckUrl = appCheckEndpoints[projectId];
492
+ const { auth } = await setupAuthWithPreMint(
493
+ { app, authFilePath, apiKey: fbConfig.apiKey, appCheckUrl },
494
+ {
495
+ readPersistedRefreshToken,
496
+ exchangeRefreshTokenForIdToken,
497
+ initAppCheckWithIdToken,
498
+ makeFilePersistence,
499
+ initializeAuth,
500
+ },
501
+ );
502
+
503
+ let userId;
504
+ let userEmail;
505
+
506
+ if (auth.currentUser) {
507
+ userId = auth.currentUser.uid;
508
+ userEmail = auth.currentUser.email;
509
+ // App Check is already initialized above when we had a persisted
510
+ // user; no need to re-init.
511
+ } else {
512
+ const authResult = await browserAuth(fbConfig);
513
+ if (appCheckUrl && authResult.firebaseIdToken) {
514
+ await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
515
+ }
516
+ let cred;
517
+ if (authResult.method === 'google') {
518
+ cred = await signInWithCredential(
519
+ auth,
520
+ GoogleAuthProvider.credential(authResult.idToken, authResult.accessToken),
521
+ );
522
+ } else {
523
+ cred = await signInWithEmailAndPassword(auth, authResult.email, authResult.password);
524
+ }
525
+ userId = cred.user.uid;
526
+ userEmail = cred.user.email;
527
+ }
528
+
529
+ const db = getFirestore(app);
530
+ const snapshot = await getDocs(
531
+ query(collection(db, 'orgMembers'), where('userId', '==', userId)),
532
+ );
533
+ const orgIds = [];
534
+ snapshot.forEach(d => orgIds.push(d.data().orgId));
535
+ const orgs = [];
536
+ for (const orgId of orgIds) {
537
+ const orgDoc = await getDoc(doc(db, 'orgs', orgId));
538
+ if (orgDoc.exists()) orgs.push({ id: orgDoc.id, name: orgDoc.data().name });
539
+ }
540
+
541
+ console.log(`COMPOUND_DISCOVER ${JSON.stringify({ userId, email: userEmail, orgs })}`);
542
+ process.exit(0);
543
+ }
544
+
455
545
  // --- Config ---
456
546
 
457
547
  const args = process.argv.slice(2);
@@ -482,10 +572,21 @@ More: https://github.com/Doubling-Inc/doubling-compound`);
482
572
  let configFile = 'config.json';
483
573
  let forceSetup = false;
484
574
  let envOverride = null;
575
+ let discoverMode = false;
485
576
  for (let i = 0; i < args.length; i++) {
486
577
  if (args[i] === '--config' && args[i + 1]) { configFile = args[++i]; }
487
578
  if (args[i] === '--setup') { forceSetup = true; }
488
579
  if (args[i] === '--env' && args[i + 1]) { envOverride = `doubling-compound-${args[++i]}`; }
580
+ // Non-interactive account discovery for the desktop app: sign in
581
+ // (persisting credentials to COMPOUND_AUTH_FILE), print the user's
582
+ // orgs as a COMPOUND_DISCOVER JSON line, and exit. The desktop
583
+ // parses it to register the account and write its config.
584
+ if (args[i] === '--discover') { discoverMode = true; }
585
+ }
586
+
587
+ if (discoverMode) {
588
+ await discoverOrgs(envOverride || 'doubling-compound-prod');
589
+ // discoverOrgs exits the process.
489
590
  }
490
591
 
491
592
  const CONFIG_PATH = resolveConfigPath(configFile, __dirname);
@@ -541,18 +642,31 @@ if (config._app) {
541
642
  projectId: config.projectId,
542
643
  accountKey: config.email || config.orgId,
543
644
  });
544
- const auth = initializeAuth(app, { persistence: makeFilePersistence(authFilePath) });
545
- await auth.authStateReady();
546
-
645
+ // Pre-mint App Check on the app, THEN initializeAuth. The ordering
646
+ // matters: see setup-auth-with-pre-mint.js for the call-order
647
+ // contract and why the 1.9.3 code (initializeAuth before pre-mint)
648
+ // raced the SDK's startup :lookup and lost.
547
649
  const appCheckUrl = appCheckEndpoints[config.projectId];
650
+ const { auth, appCheckPreMinted } = await setupAuthWithPreMint(
651
+ { app, authFilePath, apiKey: fbConfig.apiKey, appCheckUrl },
652
+ {
653
+ readPersistedRefreshToken,
654
+ exchangeRefreshTokenForIdToken,
655
+ initAppCheckWithIdToken,
656
+ makeFilePersistence,
657
+ initializeAuth,
658
+ },
659
+ );
548
660
 
549
661
  if (auth.currentUser) {
550
662
  // Silent re-auth: session restored from persisted credentials, no
551
- // browser. App Check (custom provider) mints its token from a
552
- // fresh ID token, which the refresh-token flow obtains without a
553
- // browser.
663
+ // browser. App Check was already pre-minted above when we had a
664
+ // persisted user, so no need to re-init.
554
665
  userId = auth.currentUser.uid;
555
- if (appCheckUrl) {
666
+ if (appCheckUrl && !appCheckPreMinted) {
667
+ // Defensive: persisted user existed but pre-mint failed; still try
668
+ // to bring App Check up via the current user's ID token so the
669
+ // rest of the daemon's calls aren't blocked.
556
670
  const idToken = await auth.currentUser.getIdToken();
557
671
  await initAppCheckWithIdToken(app, appCheckUrl, idToken);
558
672
  }
@@ -586,466 +700,23 @@ if (config._app) {
586
700
  const USER_ID = userId;
587
701
  const storage = getStorage(app);
588
702
 
589
- console.log(`Sync daemon starting...`);
703
+ // Read the bundled package version so the log line confirms which
704
+ // daemon copy is actually running. Saves the "did the rebuild include
705
+ // the fix?" diagnostic round-trip when a desktop bundle is mid-update.
706
+ const __syncPkgVersion = JSON.parse(
707
+ fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'),
708
+ ).version;
709
+
710
+ console.log(`Sync daemon starting (compound-sync ${__syncPkgVersion})...`);
590
711
  console.log(` Env: ${config.projectId}`);
591
712
  console.log(` User: ${USER_ID}`);
592
713
  console.log(` Orgs: ${config.orgs.length}`);
593
714
 
594
- // Per-org runtime: each org gets its own closure with isolated state
595
- // (LOCAL_PATH, filesRef, teams map, debounce sets, watcher, listeners).
596
- // This keeps two orgs' sync logic from interfering with each other.
597
- async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
598
- const LOCAL_PATH = expandHome(rawLocalPath);
599
- const filesRef = collection(db, `orgs/${orgId}/files`);
600
-
601
- const lastKnownUpdate = new Map();
602
- const suppressLocal = new Set();
603
- // Per-team Firestore-listener unsubscribe handles, keyed by teamId.
604
- // The TeamRegistry's onSetup populates this; onTeardown calls and
605
- // drops the entry so a removed teamspace stops pulling files.
606
- const teamUnsubscribers = new Map();
607
- // Hoisted forward-decl: setupTeam closes over handleFirestoreChange,
608
- // which is defined later in this function. Wired below.
609
- let setupTeam = () => {};
610
- function teardownTeam(teamId, teamData) {
611
- const unsub = teamUnsubscribers.get(teamId);
612
- if (unsub) {
613
- try { unsub(); } catch (e) { /* ignore */ }
614
- teamUnsubscribers.delete(teamId);
615
- const folder = teamData && teamData.name ? teamFolderName(teamData.name) : teamId;
616
- console.log(` [${orgId}] Stopped listening: ${folder}`);
617
- }
618
- }
619
- const teamRegistry = new TeamRegistry({
620
- onSetup: (teamId, teamData) => setupTeam(teamId, teamData),
621
- onTeardown: teardownTeam,
622
- });
623
- // scopeFromLocalPath expects a Map<teamName, teamId>; the registry
624
- // exposes one matching that shape.
625
- const teamIdsByName = teamRegistry.teamIdsByName;
626
-
627
- console.log('');
628
- console.log(` [${orgId}] Local: ${LOCAL_PATH}`);
629
-
630
- function ensureDir(filePath) {
631
- const dir = path.dirname(filePath);
632
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
633
- }
634
-
635
- function updateSharedByMeSymlink(fileDoc, realPath) {
636
- const sharedWith = fileDoc.sharedWith || [];
637
- const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, fileDoc.path);
638
-
639
- if (sharedWith.length > 0) {
640
- ensureDir(symlinkPath);
641
- const relativeTo = path.relative(path.dirname(symlinkPath), realPath);
642
- try {
643
- if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
644
- fs.unlinkSync(symlinkPath);
645
- }
646
- } catch (e) { /* doesn't exist */ }
647
- try {
648
- fs.symlinkSync(relativeTo, symlinkPath);
649
- console.log(` [${orgId}] [symlink] ${fileDoc.path} → Shared by Me/`);
650
- } catch (e) {
651
- if (e.code !== 'EEXIST') console.error(` [${orgId}] Symlink error: ${e.message}`);
652
- }
653
- } else {
654
- try {
655
- if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
656
- fs.unlinkSync(symlinkPath);
657
- console.log(` [${orgId}] [symlink] removed Shared by Me/${fileDoc.path}`);
658
- }
659
- } catch (e) { /* doesn't exist */ }
660
- }
661
- }
662
-
663
- // Static (user-level) folders. Team folders are created per-team by
664
- // setupTeam below as soon as the registry sees each teamspace.
665
- function ensureFolderStructure() {
666
- const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
667
- if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
668
-
669
- const sharedWithMePath = path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER);
670
- if (!fs.existsSync(sharedWithMePath)) fs.mkdirSync(sharedWithMePath, { recursive: true });
671
-
672
- const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
673
- if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
674
- }
675
-
676
- ensureFolderStructure();
677
-
678
- async function handleFirestoreChange(change, getLocalPath) {
679
- const data = change.doc.data();
680
- const docPath = data.path;
681
- if (!docPath) return;
682
-
683
- const localFilePath = getLocalPath(data);
684
- const trackingKey = `${data.scope || 'team'}:${docPath}`;
685
- const isFolder = data.type === 'folder';
686
-
687
- if (change.type === 'removed') {
688
- if (isFolder) {
689
- // Best-effort directory removal. The daemon's own file-delete
690
- // events should have cleared all children by the time we get
691
- // here; if not, rmdirSync throws ENOTEMPTY and we keep the
692
- // directory rather than risk losing data the user hasn't seen.
693
- try {
694
- suppressLocal.add(localFilePath);
695
- fs.rmdirSync(localFilePath);
696
- console.log(` [${orgId}] [firestore → local] RMDIR ${docPath}`);
697
- setTimeout(() => suppressLocal.delete(localFilePath), 1000);
698
- } catch (err) {
699
- suppressLocal.delete(localFilePath);
700
- if (err.code !== 'ENOENT' && err.code !== 'ENOTEMPTY') {
701
- console.error(` [${orgId}] [firestore → local] RMDIR ${docPath} failed: ${err.message}`);
702
- }
703
- }
704
- } else if (fs.existsSync(localFilePath)) {
705
- console.log(` [${orgId}] [firestore → local] DELETE ${docPath}`);
706
- suppressLocal.add(localFilePath);
707
- fs.unlinkSync(localFilePath);
708
- setTimeout(() => suppressLocal.delete(localFilePath), 1000);
709
- }
710
- lastKnownUpdate.delete(trackingKey);
711
- return;
712
- }
713
-
714
- const remoteUpdated = data.updatedAt || data.createdAt || '';
715
- const lastKnown = lastKnownUpdate.get(trackingKey);
716
- if (lastKnown && lastKnown >= remoteUpdated) return;
717
-
718
- if (isFolder) {
719
- // Folder docs mirror to a local directory. No blob, no content
720
- // comparison; if the directory doesn't exist yet, create it.
721
- if (!fs.existsSync(localFilePath)) {
722
- suppressLocal.add(localFilePath);
723
- fs.mkdirSync(localFilePath, { recursive: true });
724
- console.log(` [${orgId}] [firestore → local] MKDIR ${docPath}`);
725
- setTimeout(() => suppressLocal.delete(localFilePath), 1000);
726
- }
727
- lastKnownUpdate.set(trackingKey, remoteUpdated);
728
- return;
729
- }
730
-
731
- if (fs.existsSync(localFilePath)) {
732
- const localMtime = fs.statSync(localFilePath).mtime.toISOString();
733
- if (localMtime > remoteUpdated) return;
734
- }
735
-
736
- const action = change.type === 'added' ? 'CREATE' : 'UPDATE';
737
- console.log(` [${orgId}] [firestore → local] ${action} ${docPath} (${data.scope || 'team'})`);
738
- ensureDir(localFilePath);
739
- suppressLocal.add(localFilePath);
740
-
741
- const ext = extFromPath(docPath);
742
- if (isTextMimeType(data.mimeType)) {
743
- const content = await readBlobAsText({
744
- storage,
745
- orgId,
746
- fileId: change.doc.id,
747
- ext,
748
- });
749
- fs.writeFileSync(localFilePath, content || '', 'utf-8');
750
- } else {
751
- const bytes = await readBlob({
752
- storage,
753
- orgId,
754
- fileId: change.doc.id,
755
- ext,
756
- });
757
- fs.writeFileSync(localFilePath, bytes);
758
- }
759
- lastKnownUpdate.set(trackingKey, remoteUpdated);
760
- setTimeout(() => suppressLocal.delete(localFilePath), 1000);
761
- }
762
-
763
- // Set up per-team resources: mkdir the local folder, register the
764
- // Firestore listener for that team's files. Wired into the
765
- // TeamRegistry; called once per teamspace as it appears.
766
- setupTeam = (teamId, teamData) => {
767
- const teamFolder = teamFolderName(teamData.name);
768
- const teamPath = path.join(LOCAL_PATH, teamFolder);
769
- if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
770
-
771
- const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
772
- const unsubscribe = onSnapshot(q, snapshot => {
773
- snapshot.docChanges().forEach(change => {
774
- handleFirestoreChange(change, data =>
775
- path.join(LOCAL_PATH, teamFolder, data.path),
776
- ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
777
- });
778
- }, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
779
-
780
- teamUnsubscribers.set(teamId, unsubscribe);
781
- console.log(` [${orgId}] Listening: ${teamFolder}`);
782
- };
783
-
784
- // Live listener on the teams collection. Initial snapshot bootstraps
785
- // the known teamspaces; subsequent changes pick up new/renamed/
786
- // removed teamspaces without restarting the daemon. We await the
787
- // first snapshot so the local watcher sees a populated teamIdsByName
788
- // before it tries to route any addDir/add events.
789
- await new Promise((resolve, reject) => {
790
- let firstSnapshotResolved = false;
791
- onSnapshot(collection(db, `orgs/${orgId}/teams`), (snapshot) => {
792
- snapshot.docChanges().forEach((change) => {
793
- teamRegistry.applyChange({
794
- type: change.type,
795
- id: change.doc.id,
796
- data: change.doc.data(),
797
- });
798
- });
799
- if (!firstSnapshotResolved) {
800
- firstSnapshotResolved = true;
801
- console.log(` [${orgId}] Teams: ${teamRegistry.names().join(', ') || 'none'}`);
802
- resolve();
803
- }
804
- }, (err) => {
805
- console.error(` [${orgId}] Teams listener error:`, err);
806
- if (!firstSnapshotResolved) {
807
- firstSnapshotResolved = true;
808
- reject(err);
809
- }
810
- });
811
- });
812
-
813
- function startFirestoreListeners() {
814
- console.log(` [${orgId}] Starting Firestore listeners...`);
815
-
816
- if (USER_ID) {
817
- const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', USER_ID));
818
- onSnapshot(privateQ, snapshot => {
819
- snapshot.docChanges().forEach(change => {
820
- const data = change.doc.data();
821
- const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
822
-
823
- handleFirestoreChange(change, () => realPath)
824
- .catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
825
-
826
- if (change.type !== 'removed') {
827
- updateSharedByMeSymlink(data, realPath);
828
- } else {
829
- const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, data.path);
830
- try { fs.lstatSync(symlinkPath); fs.unlinkSync(symlinkPath); } catch (e) { /* ok */ }
831
- }
832
- });
833
- }, err => console.error(` [${orgId}] Private files listener error:`, err));
834
- console.log(` [${orgId}] Listening: Private files + Shared by Me symlinks`);
835
-
836
- // scope=='private' is required: Firestore rules only grant
837
- // sharedWith-based reads on private files, so the query must
838
- // constrain scope or the whole listen is denied (rules are not
839
- // filters). See tests/rules.test.js "shared-with-me query".
840
- const sharedQ = query(
841
- filesRef,
842
- where('scope', '==', 'private'),
843
- where('sharedWith', 'array-contains', USER_ID),
844
- );
845
- onSnapshot(sharedQ, snapshot => {
846
- snapshot.docChanges().forEach(change => {
847
- const data = change.doc.data();
848
- if (data.ownerId === USER_ID) return;
849
- handleFirestoreChange(change, () =>
850
- path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path),
851
- ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
852
- });
853
- }, err => console.error(` [${orgId}] Shared with me listener error:`, err));
854
- console.log(` [${orgId}] Listening: Shared with Me`);
855
- }
856
- }
857
-
858
- async function pushToFirestore(filePath, data, mimeType) {
859
- const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
860
- const trackingKey = `${scope}:${docPath}`;
861
- const now = new Date().toISOString();
862
- lastKnownUpdate.set(trackingKey, now);
863
-
864
- const result = await pushFileToCloud({
865
- filesRef,
866
- storage,
867
- orgId,
868
- userId: USER_ID,
869
- scope,
870
- teamId,
871
- docPath,
872
- data,
873
- mimeType,
874
- now,
875
- });
876
-
877
- if (result.action === 'created') {
878
- console.log(` [${orgId}] [local → firestore] CREATE ${docPath} (${scope}, ${mimeType})`);
879
- } else if (result.action === 'updated') {
880
- console.log(` [${orgId}] [local → firestore] UPDATE ${docPath} (${scope})`);
881
- }
882
- if (result.storageError) {
883
- console.error(` [${orgId}] [storage] upload failed for ${docPath}: ${result.storageError.message}`);
884
- }
885
- }
886
-
887
- async function deleteFromFirestore(filePath) {
888
- const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
889
- const trackingKey = `${scope}:${docPath}`;
890
- lastKnownUpdate.delete(trackingKey);
891
-
892
- const result = await deleteFileFromCloud({
893
- filesRef,
894
- storage,
895
- orgId,
896
- userId: USER_ID,
897
- scope,
898
- teamId,
899
- docPath,
900
- });
901
-
902
- if (result.action === 'deleted') {
903
- console.log(` [${orgId}] [local → firestore] DELETE ${docPath} (${scope})`);
904
- }
905
- }
906
-
907
- // Determine whether a local path corresponds to a syncable directory.
908
- // Excludes the scope-root directories themselves (Private, Shared
909
- // with Me, Shared by Me, and the per-team roots) since those are
910
- // managed by ensureFolderStructure, not the user.
911
- function isSyncableLocalDir(rel) {
912
- if (!rel) return false;
913
- if (rel === PRIVATE_FOLDER || rel === SHARED_WITH_ME_FOLDER || rel === SHARED_BY_ME_FOLDER) {
914
- return false;
915
- }
916
- if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return false;
917
- if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return false;
918
- const parts = rel.split(path.sep);
919
- // A bare team root (e.g. "Engineering Teamspace") corresponds to
920
- // the scope root, not a subdirectory. Skip these.
921
- if (parts.length === 1 && parts[0].endsWith(' Teamspace')) return false;
922
- return true;
923
- }
924
-
925
- async function pushDirToFirestore(dirPath) {
926
- const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
927
- if (!docPath) return; // scope root
928
- const trackingKey = `${scope}:${docPath}`;
929
- const now = new Date().toISOString();
930
- lastKnownUpdate.set(trackingKey, now);
931
-
932
- const result = await pushFolderToCloud({
933
- filesRef,
934
- scope,
935
- teamId,
936
- userId: USER_ID,
937
- docPath,
938
- now,
939
- });
940
-
941
- if (result.action === 'ensured' && result.folderId) {
942
- console.log(` [${orgId}] [local → firestore] FOLDER ${docPath} (${scope})`);
943
- }
944
- }
945
-
946
- async function deleteDirFromFirestore(dirPath) {
947
- const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
948
- if (!docPath) return;
949
- const trackingKey = `${scope}:${docPath}`;
950
- lastKnownUpdate.delete(trackingKey);
951
-
952
- const result = await deleteFolderFromCloud({
953
- filesRef,
954
- scope,
955
- teamId,
956
- userId: USER_ID,
957
- docPath,
958
- });
959
-
960
- if (result.action === 'deleted') {
961
- console.log(` [${orgId}] [local → firestore] RMFOLDER ${docPath} (${scope})`);
962
- }
963
- }
964
-
965
- function handleLocalChange(filePath) {
966
- if (suppressLocal.has(filePath)) return;
967
-
968
- const rel = path.relative(LOCAL_PATH, filePath);
969
- if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
970
- if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
971
-
972
- let stat;
973
- try {
974
- stat = fs.statSync(filePath);
975
- } catch (err) {
976
- return; // disappeared between event and read
977
- }
978
- if (!stat.isFile()) return;
979
- if (stat.size > MAX_UPLOAD_BYTES) {
980
- console.error(` [${orgId}] [skip] ${rel} exceeds ${MAX_UPLOAD_BYTES} byte cap (${stat.size} bytes)`);
981
- return;
982
- }
983
-
984
- const ext = extFromPath(rel);
985
- const mimeType = mimeTypeFromExt(ext);
986
- // Text files: read as a UTF-8 string so future text-aware features
987
- // (line-level diffing, conflict markers) can work. Binary files:
988
- // read as a Buffer so bytes round-trip without corruption.
989
- const data = isTextMimeType(mimeType)
990
- ? fs.readFileSync(filePath, 'utf-8')
991
- : fs.readFileSync(filePath);
992
- pushToFirestore(filePath, data, mimeType).catch(err =>
993
- console.error(` [${orgId}] Push error: ${err.message}`)
994
- );
995
- }
996
-
997
- function handleLocalDirAdded(dirPath) {
998
- if (suppressLocal.has(dirPath)) return;
999
- const rel = path.relative(LOCAL_PATH, dirPath);
1000
- if (!isSyncableLocalDir(rel)) return;
1001
- pushDirToFirestore(dirPath).catch(err =>
1002
- console.error(` [${orgId}] Folder push error for ${rel}: ${err.message}`)
1003
- );
1004
- }
1005
-
1006
- function handleLocalDirRemoved(dirPath) {
1007
- if (suppressLocal.has(dirPath)) return;
1008
- const rel = path.relative(LOCAL_PATH, dirPath);
1009
- if (!isSyncableLocalDir(rel)) return;
1010
- deleteDirFromFirestore(dirPath).catch(err =>
1011
- console.error(` [${orgId}] Folder delete error for ${rel}: ${err.message}`)
1012
- );
1013
- }
1014
-
1015
- function startLocalWatcher() {
1016
- console.log(` [${orgId}] Watching local files...`);
1017
-
1018
- const watcher = chokidar.watch(LOCAL_PATH, {
1019
- ignored: [
1020
- /(^|[\/\\])\./,
1021
- /node_modules/,
1022
- /\.csv$/,
1023
- /\.py$/,
1024
- /index\.html$/,
1025
- ],
1026
- persistent: true,
1027
- ignoreInitial: false,
1028
- followSymlinks: false,
1029
- awaitWriteFinish: {
1030
- stabilityThreshold: 500,
1031
- pollInterval: 100,
1032
- },
1033
- });
1034
-
1035
- watcher.on('add', filePath => handleLocalChange(filePath));
1036
- watcher.on('change', filePath => handleLocalChange(filePath));
1037
- watcher.on('unlink', filePath => {
1038
- if (suppressLocal.has(filePath)) return;
1039
- deleteFromFirestore(filePath).catch(err => console.error(` [${orgId}] Delete error: ${err.message}`));
1040
- });
1041
- watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
1042
- watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
1043
-
1044
- watcher.on('ready', () => console.log(` [${orgId}] Local watcher ready.`));
1045
- }
1046
-
1047
- startFirestoreListeners();
1048
- startLocalWatcher();
715
+ // Per-org runtime, extracted to ./org-sync.js so it is importable and
716
+ // testable against the emulator (DOU-156). Inject the module-level
717
+ // db / storage / userId here.
718
+ function setupOrgSync({ orgId, localPath }) {
719
+ return startOrgSync({ db, storage, userId: USER_ID, orgId, localPath: expandHome(localPath) });
1049
720
  }
1050
721
 
1051
722
  // --- Main ---