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