@doubling/compound-sync 1.0.2 → 1.1.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 +58 -30
- package/package.json +1 -1
- package/sync.js +112 -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
|
}
|
|
@@ -314,6 +342,72 @@ async function setupConfig() {
|
|
|
314
342
|
return savedConfig;
|
|
315
343
|
}
|
|
316
344
|
|
|
345
|
+
// --- App Check (custom provider for CLI) ---
|
|
346
|
+
|
|
347
|
+
// Fetch an App Check token using a Firebase ID token (before Node.js sign-in)
|
|
348
|
+
async function fetchAppCheckToken(tokenEndpoint, firebaseIdToken) {
|
|
349
|
+
const response = await fetch(tokenEndpoint, {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
headers: {
|
|
352
|
+
'Authorization': `Bearer ${firebaseIdToken}`,
|
|
353
|
+
'Content-Type': 'application/json',
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
const errBody = await response.text().catch(() => '');
|
|
359
|
+
console.error(`App Check token request failed (${response.status}): ${errBody}`);
|
|
360
|
+
throw new Error(`App Check token request failed: ${response.status}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return await response.json();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Initialize App Check with a pre-fetched ID token (used on first sign-in)
|
|
367
|
+
async function initAppCheckWithIdToken(app, tokenEndpoint, firebaseIdToken) {
|
|
368
|
+
console.log('Initializing App Check...');
|
|
369
|
+
// Pre-fetch the first token
|
|
370
|
+
const initial = await fetchAppCheckToken(tokenEndpoint, firebaseIdToken);
|
|
371
|
+
let cachedToken = {
|
|
372
|
+
token: initial.token,
|
|
373
|
+
expireTimeMillis: Date.now() + initial.ttlMillis,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const customProvider = new CustomProvider({
|
|
377
|
+
getToken: async () => {
|
|
378
|
+
// If token is still valid, return cached
|
|
379
|
+
if (cachedToken.expireTimeMillis > Date.now() + 60000) {
|
|
380
|
+
return cachedToken;
|
|
381
|
+
}
|
|
382
|
+
// Otherwise refresh — at this point the user should be signed in
|
|
383
|
+
const auth = getAuth(app);
|
|
384
|
+
const user = auth.currentUser;
|
|
385
|
+
if (!user) return cachedToken; // fallback
|
|
386
|
+
const idToken = await user.getIdToken();
|
|
387
|
+
const refreshed = await fetchAppCheckToken(tokenEndpoint, idToken);
|
|
388
|
+
cachedToken = {
|
|
389
|
+
token: refreshed.token,
|
|
390
|
+
expireTimeMillis: Date.now() + refreshed.ttlMillis,
|
|
391
|
+
};
|
|
392
|
+
return cachedToken;
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
initializeAppCheck(app, {
|
|
397
|
+
provider: customProvider,
|
|
398
|
+
isTokenAutoRefreshEnabled: true,
|
|
399
|
+
});
|
|
400
|
+
console.log('App Check initialized.');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Initialize App Check with an already-signed-in user (used during setup reuse)
|
|
404
|
+
async function initAppCheck(app, auth, tokenEndpoint) {
|
|
405
|
+
const user = auth.currentUser;
|
|
406
|
+
if (!user) throw new Error('Not signed in');
|
|
407
|
+
const idToken = await user.getIdToken();
|
|
408
|
+
await initAppCheckWithIdToken(app, tokenEndpoint, idToken);
|
|
409
|
+
}
|
|
410
|
+
|
|
317
411
|
// --- Config ---
|
|
318
412
|
|
|
319
413
|
const args = process.argv.slice(2);
|
|
@@ -347,6 +441,13 @@ if (config._app) {
|
|
|
347
441
|
app = config._app;
|
|
348
442
|
db = config._db;
|
|
349
443
|
userId = config._userId;
|
|
444
|
+
|
|
445
|
+
// Initialize App Check if endpoint exists for this env
|
|
446
|
+
const appCheckUrl = appCheckEndpoints[config.projectId];
|
|
447
|
+
if (appCheckUrl) {
|
|
448
|
+
const auth = getAuth(app);
|
|
449
|
+
await initAppCheck(app, auth, appCheckUrl);
|
|
450
|
+
}
|
|
350
451
|
} else {
|
|
351
452
|
const fbConfig = firebaseConfigs[config.projectId];
|
|
352
453
|
if (!fbConfig) {
|
|
@@ -358,10 +459,17 @@ if (config._app) {
|
|
|
358
459
|
const auth = getAuth(app);
|
|
359
460
|
db = getFirestore(app);
|
|
360
461
|
|
|
361
|
-
// Sign in via browser
|
|
462
|
+
// Sign in via browser (browser handles reCAPTCHA App Check)
|
|
362
463
|
console.log('');
|
|
363
464
|
const authResult = await browserAuth(fbConfig);
|
|
364
465
|
|
|
466
|
+
// Initialize App Check BEFORE signing in on the Node.js side
|
|
467
|
+
// Use the Firebase ID token from the browser to get an App Check token
|
|
468
|
+
const appCheckUrl = appCheckEndpoints[config.projectId];
|
|
469
|
+
if (appCheckUrl && authResult.firebaseIdToken) {
|
|
470
|
+
await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
|
|
471
|
+
}
|
|
472
|
+
|
|
365
473
|
if (authResult.method === 'google') {
|
|
366
474
|
const credential = GoogleAuthProvider.credential(authResult.idToken, authResult.accessToken);
|
|
367
475
|
const cred = await signInWithCredential(auth, credential);
|
|
@@ -372,6 +480,7 @@ if (config._app) {
|
|
|
372
480
|
userId = cred.user.uid;
|
|
373
481
|
console.log(`Signed in as ${cred.user.email}`);
|
|
374
482
|
}
|
|
483
|
+
|
|
375
484
|
}
|
|
376
485
|
|
|
377
486
|
const ORG_ID = config.orgId;
|