@doubling/compound-sync 1.0.2 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -30
- package/package.json +1 -1
- package/sync.js +119 -3
package/README.md
CHANGED
|
@@ -1,44 +1,26 @@
|
|
|
1
|
-
# Compound Sync
|
|
1
|
+
# Compound Sync
|
|
2
2
|
|
|
3
|
-
Bidirectional sync between
|
|
3
|
+
Bidirectional sync between Compound and local markdown files. Edit files locally in your favorite editor and they sync to Compound in real time.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
- Node.js
|
|
8
|
-
- Google Cloud Application Default Credentials: `gcloud auth application-default login`
|
|
9
|
-
|
|
10
|
-
## Setup
|
|
5
|
+
## Quick Start
|
|
11
6
|
|
|
12
7
|
```bash
|
|
13
|
-
|
|
14
|
-
npm install
|
|
8
|
+
npx @doubling/compound-sync
|
|
15
9
|
```
|
|
16
10
|
|
|
17
|
-
|
|
11
|
+
That's it. A browser window will open for sign-in (Google or email/password), then the setup wizard will walk you through selecting your organization and sync folder.
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
node sync.js --config config-sandbox.json --setup # Project: doubling-compound-sandbox
|
|
21
|
-
node sync.js --config config-dev.json --setup # Project: doubling-compound-dev
|
|
22
|
-
node sync.js --config config-prod.json --setup # Project: doubling-compound-prod
|
|
23
|
-
```
|
|
13
|
+
### Prerequisites
|
|
24
14
|
|
|
25
|
-
|
|
26
|
-
- **Firebase project ID** (e.g. `doubling-compound-sandbox`)
|
|
27
|
-
- **Organization** to sync
|
|
28
|
-
- **Email address** for private file sync
|
|
29
|
-
- **Local sync folder** (e.g. `~/Compound-sandbox`)
|
|
15
|
+
- [Node.js](https://nodejs.org/) (v18 or later)
|
|
30
16
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
## Running
|
|
17
|
+
## What it does
|
|
34
18
|
|
|
35
|
-
|
|
19
|
+
Compound Sync watches a local folder and your Compound workspace simultaneously. Changes in either direction are synced automatically:
|
|
36
20
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
node sync.js --config config-prod.json
|
|
41
|
-
```
|
|
21
|
+
- Edit a file locally → it updates in Compound
|
|
22
|
+
- Edit a file in Compound → it updates locally
|
|
23
|
+
- Create or delete files in either place → synced
|
|
42
24
|
|
|
43
25
|
## Local folder structure
|
|
44
26
|
|
|
@@ -49,3 +31,49 @@ node sync.js --config config-prod.json
|
|
|
49
31
|
Shared by Me/ -- symlinks to files you've shared
|
|
50
32
|
Shared with Me/ -- files shared with you (read-only)
|
|
51
33
|
```
|
|
34
|
+
|
|
35
|
+
## Running
|
|
36
|
+
|
|
37
|
+
After setup, start syncing with:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx @doubling/compound-sync
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The sync daemon runs until you press Ctrl+C.
|
|
44
|
+
|
|
45
|
+
## Security
|
|
46
|
+
|
|
47
|
+
- No credentials are stored on disk
|
|
48
|
+
- Authentication happens via your browser each time you start the daemon
|
|
49
|
+
- All data access respects Compound's security rules
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Internal Development
|
|
54
|
+
|
|
55
|
+
For Doubling team members testing against sandbox or dev environments:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Setup
|
|
59
|
+
npx @doubling/compound-sync --env sandbox --config config-sandbox.json --setup
|
|
60
|
+
npx @doubling/compound-sync --env dev --config config-dev.json --setup
|
|
61
|
+
|
|
62
|
+
# Run
|
|
63
|
+
npx @doubling/compound-sync --env sandbox --config config-sandbox.json
|
|
64
|
+
npx @doubling/compound-sync --env dev --config config-dev.json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Config files (`config-*.json`) are gitignored and stored locally.
|
|
68
|
+
|
|
69
|
+
### Publishing to npm
|
|
70
|
+
|
|
71
|
+
After making changes to the sync daemon, publish a new version:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
cd sync
|
|
75
|
+
npm version patch # or minor/major
|
|
76
|
+
npm publish
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This is required whenever sync daemon code changes — the `npx` command pulls from npm, not from the repo. Consider automating this in CI for releases.
|
package/package.json
CHANGED
package/sync.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
getFirestore, collection, doc, getDocs, getDoc, addDoc, updateDoc, deleteDoc,
|
|
21
21
|
query, where, onSnapshot
|
|
22
22
|
} from 'firebase/firestore';
|
|
23
|
+
import { initializeAppCheck, CustomProvider } from 'firebase/app-check';
|
|
23
24
|
import chokidar from 'chokidar';
|
|
24
25
|
import fs from 'fs';
|
|
25
26
|
import path from 'path';
|
|
@@ -60,6 +61,13 @@ const firebaseConfigs = {
|
|
|
60
61
|
}
|
|
61
62
|
};
|
|
62
63
|
|
|
64
|
+
// App Check token endpoints per environment
|
|
65
|
+
const appCheckEndpoints = {
|
|
66
|
+
'doubling-compound-prod': 'https://mintappchecktoken-oaimzbv67a-uc.a.run.app',
|
|
67
|
+
'doubling-compound-dev': 'https://mintappchecktoken-molg2wq42a-uc.a.run.app',
|
|
68
|
+
'doubling-compound-sandbox': 'https://mintappchecktoken-pbpkn3igxa-uc.a.run.app',
|
|
69
|
+
};
|
|
70
|
+
|
|
63
71
|
// --- Helpers ---
|
|
64
72
|
|
|
65
73
|
function ask(question) {
|
|
@@ -121,9 +129,26 @@ function buildLoginPage(fbConfig) {
|
|
|
121
129
|
import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.7.1/firebase-app.js';
|
|
122
130
|
import { getAuth, signInWithPopup, signInWithEmailAndPassword, GoogleAuthProvider }
|
|
123
131
|
from 'https://www.gstatic.com/firebasejs/11.7.1/firebase-auth.js';
|
|
132
|
+
import { initializeAppCheck, ReCaptchaEnterpriseProvider }
|
|
133
|
+
from 'https://www.gstatic.com/firebasejs/11.7.1/firebase-app-check.js';
|
|
124
134
|
|
|
125
135
|
const fbConfig = ${JSON.stringify(fbConfig)};
|
|
126
136
|
const app = initializeApp(fbConfig);
|
|
137
|
+
|
|
138
|
+
// Initialize App Check (required for Auth enforcement)
|
|
139
|
+
const recaptchaKeys = {
|
|
140
|
+
'doubling-compound-prod': '6Ld9o7IsAAAAAJDAsa3YCUq8ZeCzuvB9Mly6vlxX',
|
|
141
|
+
'doubling-compound-dev': '6Le56rIsAAAAAGfDgd1jZEoiDNgooWv8DkvoLEzu',
|
|
142
|
+
'doubling-compound-sandbox': '6LdN6LIsAAAAAICw1QXRdtIGUPkkh5XLbXLf2LfB',
|
|
143
|
+
};
|
|
144
|
+
const rcKey = recaptchaKeys[fbConfig.projectId];
|
|
145
|
+
if (rcKey) {
|
|
146
|
+
initializeAppCheck(app, {
|
|
147
|
+
provider: new ReCaptchaEnterpriseProvider(rcKey),
|
|
148
|
+
isTokenAutoRefreshEnabled: true,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
const auth = getAuth(app);
|
|
128
153
|
|
|
129
154
|
async function sendCredential(data) {
|
|
@@ -140,10 +165,12 @@ window.googleSignIn = async function() {
|
|
|
140
165
|
try {
|
|
141
166
|
const result = await signInWithPopup(auth, new GoogleAuthProvider());
|
|
142
167
|
const credential = GoogleAuthProvider.credentialFromResult(result);
|
|
168
|
+
const firebaseIdToken = await result.user.getIdToken();
|
|
143
169
|
await sendCredential({
|
|
144
170
|
method: 'google',
|
|
145
171
|
idToken: credential.idToken,
|
|
146
172
|
accessToken: credential.accessToken,
|
|
173
|
+
firebaseIdToken,
|
|
147
174
|
});
|
|
148
175
|
} catch (err) {
|
|
149
176
|
document.getElementById('error').textContent = err.message;
|
|
@@ -154,8 +181,9 @@ window.emailSignIn = async function() {
|
|
|
154
181
|
const email = document.getElementById('email').value;
|
|
155
182
|
const password = document.getElementById('password').value;
|
|
156
183
|
try {
|
|
157
|
-
await signInWithEmailAndPassword(auth, email, password);
|
|
158
|
-
|
|
184
|
+
const result = await signInWithEmailAndPassword(auth, email, password);
|
|
185
|
+
const firebaseIdToken = await result.user.getIdToken();
|
|
186
|
+
await sendCredential({ method: 'email', email, password, firebaseIdToken });
|
|
159
187
|
} catch (err) {
|
|
160
188
|
document.getElementById('error').textContent = err.message;
|
|
161
189
|
}
|
|
@@ -216,6 +244,13 @@ async function setupConfig() {
|
|
|
216
244
|
const authResult = await browserAuth(fbConfig);
|
|
217
245
|
const app = initializeApp(fbConfig, 'setup');
|
|
218
246
|
const auth = getAuth(app);
|
|
247
|
+
|
|
248
|
+
// Initialize App Check before re-authenticating in Node.js
|
|
249
|
+
const appCheckUrl = appCheckEndpoints[projectId];
|
|
250
|
+
if (appCheckUrl && authResult.firebaseIdToken) {
|
|
251
|
+
await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
|
|
252
|
+
}
|
|
253
|
+
|
|
219
254
|
const db = getFirestore(app);
|
|
220
255
|
|
|
221
256
|
let userCredential;
|
|
@@ -314,6 +349,72 @@ async function setupConfig() {
|
|
|
314
349
|
return savedConfig;
|
|
315
350
|
}
|
|
316
351
|
|
|
352
|
+
// --- App Check (custom provider for CLI) ---
|
|
353
|
+
|
|
354
|
+
// Fetch an App Check token using a Firebase ID token (before Node.js sign-in)
|
|
355
|
+
async function fetchAppCheckToken(tokenEndpoint, firebaseIdToken) {
|
|
356
|
+
const response = await fetch(tokenEndpoint, {
|
|
357
|
+
method: 'POST',
|
|
358
|
+
headers: {
|
|
359
|
+
'Authorization': `Bearer ${firebaseIdToken}`,
|
|
360
|
+
'Content-Type': 'application/json',
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
if (!response.ok) {
|
|
365
|
+
const errBody = await response.text().catch(() => '');
|
|
366
|
+
console.error(`App Check token request failed (${response.status}): ${errBody}`);
|
|
367
|
+
throw new Error(`App Check token request failed: ${response.status}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return await response.json();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Initialize App Check with a pre-fetched ID token (used on first sign-in)
|
|
374
|
+
async function initAppCheckWithIdToken(app, tokenEndpoint, firebaseIdToken) {
|
|
375
|
+
console.log('Initializing App Check...');
|
|
376
|
+
// Pre-fetch the first token
|
|
377
|
+
const initial = await fetchAppCheckToken(tokenEndpoint, firebaseIdToken);
|
|
378
|
+
let cachedToken = {
|
|
379
|
+
token: initial.token,
|
|
380
|
+
expireTimeMillis: Date.now() + initial.ttlMillis,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const customProvider = new CustomProvider({
|
|
384
|
+
getToken: async () => {
|
|
385
|
+
// If token is still valid, return cached
|
|
386
|
+
if (cachedToken.expireTimeMillis > Date.now() + 60000) {
|
|
387
|
+
return cachedToken;
|
|
388
|
+
}
|
|
389
|
+
// Otherwise refresh — at this point the user should be signed in
|
|
390
|
+
const auth = getAuth(app);
|
|
391
|
+
const user = auth.currentUser;
|
|
392
|
+
if (!user) return cachedToken; // fallback
|
|
393
|
+
const idToken = await user.getIdToken();
|
|
394
|
+
const refreshed = await fetchAppCheckToken(tokenEndpoint, idToken);
|
|
395
|
+
cachedToken = {
|
|
396
|
+
token: refreshed.token,
|
|
397
|
+
expireTimeMillis: Date.now() + refreshed.ttlMillis,
|
|
398
|
+
};
|
|
399
|
+
return cachedToken;
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
initializeAppCheck(app, {
|
|
404
|
+
provider: customProvider,
|
|
405
|
+
isTokenAutoRefreshEnabled: true,
|
|
406
|
+
});
|
|
407
|
+
console.log('App Check initialized.');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Initialize App Check with an already-signed-in user (used during setup reuse)
|
|
411
|
+
async function initAppCheck(app, auth, tokenEndpoint) {
|
|
412
|
+
const user = auth.currentUser;
|
|
413
|
+
if (!user) throw new Error('Not signed in');
|
|
414
|
+
const idToken = await user.getIdToken();
|
|
415
|
+
await initAppCheckWithIdToken(app, tokenEndpoint, idToken);
|
|
416
|
+
}
|
|
417
|
+
|
|
317
418
|
// --- Config ---
|
|
318
419
|
|
|
319
420
|
const args = process.argv.slice(2);
|
|
@@ -347,6 +448,13 @@ if (config._app) {
|
|
|
347
448
|
app = config._app;
|
|
348
449
|
db = config._db;
|
|
349
450
|
userId = config._userId;
|
|
451
|
+
|
|
452
|
+
// Initialize App Check if endpoint exists for this env
|
|
453
|
+
const appCheckUrl = appCheckEndpoints[config.projectId];
|
|
454
|
+
if (appCheckUrl) {
|
|
455
|
+
const auth = getAuth(app);
|
|
456
|
+
await initAppCheck(app, auth, appCheckUrl);
|
|
457
|
+
}
|
|
350
458
|
} else {
|
|
351
459
|
const fbConfig = firebaseConfigs[config.projectId];
|
|
352
460
|
if (!fbConfig) {
|
|
@@ -358,10 +466,17 @@ if (config._app) {
|
|
|
358
466
|
const auth = getAuth(app);
|
|
359
467
|
db = getFirestore(app);
|
|
360
468
|
|
|
361
|
-
// Sign in via browser
|
|
469
|
+
// Sign in via browser (browser handles reCAPTCHA App Check)
|
|
362
470
|
console.log('');
|
|
363
471
|
const authResult = await browserAuth(fbConfig);
|
|
364
472
|
|
|
473
|
+
// Initialize App Check BEFORE signing in on the Node.js side
|
|
474
|
+
// Use the Firebase ID token from the browser to get an App Check token
|
|
475
|
+
const appCheckUrl = appCheckEndpoints[config.projectId];
|
|
476
|
+
if (appCheckUrl && authResult.firebaseIdToken) {
|
|
477
|
+
await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
|
|
478
|
+
}
|
|
479
|
+
|
|
365
480
|
if (authResult.method === 'google') {
|
|
366
481
|
const credential = GoogleAuthProvider.credential(authResult.idToken, authResult.accessToken);
|
|
367
482
|
const cred = await signInWithCredential(auth, credential);
|
|
@@ -372,6 +487,7 @@ if (config._app) {
|
|
|
372
487
|
userId = cred.user.uid;
|
|
373
488
|
console.log(`Signed in as ${cred.user.email}`);
|
|
374
489
|
}
|
|
490
|
+
|
|
375
491
|
}
|
|
376
492
|
|
|
377
493
|
const ORG_ID = config.orgId;
|