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