@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/auth-persistence.js +34 -2
- package/files.js +21 -4
- package/org-sync.js +607 -0
- package/package.json +7 -3
- package/pre-mint-app-check.js +84 -0
- package/setup-auth-with-pre-mint.js +84 -0
- package/sync-state.js +32 -0
- package/sync.js +134 -463
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
|
-
|
|
545
|
-
|
|
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
|
|
552
|
-
//
|
|
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
|
-
|
|
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
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
|
|
598
|
-
|
|
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 ---
|