@doubling/compound-sync 1.6.0 → 1.7.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/auth-persistence.js +79 -0
- package/package.json +3 -2
- package/storage-helpers.js +10 -0
- package/sync.js +47 -18
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// File-backed Firebase Auth persistence for Node.
|
|
2
|
+
//
|
|
3
|
+
// Firebase's Node SDK defaults to in-memory persistence, so the daemon
|
|
4
|
+
// re-authenticates (browser popup) on every launch. This persistence
|
|
5
|
+
// stores the auth state (including the refresh token Firebase uses to
|
|
6
|
+
// silently mint new ID tokens) in a JSON file, so a relaunch restores
|
|
7
|
+
// the session without a browser.
|
|
8
|
+
//
|
|
9
|
+
// Firebase consumes a persistence as a CLASS: it does `new P()` and
|
|
10
|
+
// calls the prototype methods below, and reads the static `type`
|
|
11
|
+
// ('LOCAL' = durable). We can't pass the file path through the
|
|
12
|
+
// zero-arg constructor the SDK uses, so `makeFilePersistence(path)` is
|
|
13
|
+
// a factory returning a class bound to that path. This lets the
|
|
14
|
+
// desktop app give each account its own credentials file.
|
|
15
|
+
//
|
|
16
|
+
// Security: the file holds a refresh token, so it's written 0600.
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
// Where to store an account's persisted auth. The desktop app passes
|
|
22
|
+
// an explicit per-account path via the COMPOUND_AUTH_FILE env var
|
|
23
|
+
// (each account gets its own file). The CLI falls back to a stable
|
|
24
|
+
// per-(project, account) path under the user's config dir, keyed by
|
|
25
|
+
// email (or orgId) so two accounts on the same project don't collide.
|
|
26
|
+
export function resolveAuthFilePath({ authFileEnv, homeDir, projectId, accountKey }) {
|
|
27
|
+
if (authFileEnv) return authFileEnv;
|
|
28
|
+
const safeAccount = String(accountKey || 'default').replace(/[^a-zA-Z0-9._@-]/g, '_');
|
|
29
|
+
const safeProject = String(projectId || 'default').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
30
|
+
return path.join(homeDir, '.config', 'compound-sync', `${safeProject}__${safeAccount}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function makeFilePersistence(filePath) {
|
|
34
|
+
function readStore() {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
// Missing or unreadable file: start from an empty store.
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeStore(store) {
|
|
44
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
45
|
+
fs.writeFileSync(filePath, JSON.stringify(store), { mode: 0o600 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return class FilePersistence {
|
|
49
|
+
// Read by the SDK both as a static and as an instance property in
|
|
50
|
+
// different code paths; set both. 'LOCAL' = survives restarts.
|
|
51
|
+
static type = 'LOCAL';
|
|
52
|
+
type = 'LOCAL';
|
|
53
|
+
|
|
54
|
+
async _isAvailable() {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async _set(key, value) {
|
|
59
|
+
const store = readStore();
|
|
60
|
+
store[key] = value;
|
|
61
|
+
writeStore(store);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async _get(key) {
|
|
65
|
+
const store = readStore();
|
|
66
|
+
return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _remove(key) {
|
|
70
|
+
const store = readStore();
|
|
71
|
+
delete store[key];
|
|
72
|
+
writeStore(store);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Cross-tab change listeners are a browser concern; no-ops in Node.
|
|
76
|
+
_addListener(_key, _listener) {}
|
|
77
|
+
_removeListener(_key, _listener) {}
|
|
78
|
+
};
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doubling/compound-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Bidirectional sync between Compound and local markdown files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
"folder-chain.js",
|
|
15
15
|
"storage-helpers.js",
|
|
16
16
|
"team-registry.js",
|
|
17
|
+
"auth-persistence.js",
|
|
17
18
|
"README.md"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"sync": "node sync.js",
|
|
21
|
-
"test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js",
|
|
22
|
+
"test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js auth-persistence.test.js",
|
|
22
23
|
"test:integration": "firebase emulators:exec --only firestore,storage 'node --test files.test.js'",
|
|
23
24
|
"test": "npm run test:unit && npm run test:integration"
|
|
24
25
|
},
|
package/storage-helpers.js
CHANGED
|
@@ -80,6 +80,16 @@ const EXT_TO_MIME = {
|
|
|
80
80
|
webm: 'video/webm',
|
|
81
81
|
mov: 'video/quicktime',
|
|
82
82
|
zip: 'application/zip',
|
|
83
|
+
// Office Open XML (modern Office). The full type strings are
|
|
84
|
+
// mandatory: tools that dispatch by mime (Excel, third-party readers)
|
|
85
|
+
// ignore alternative spellings.
|
|
86
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
87
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
88
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
89
|
+
// Legacy Office formats (pre-2007 binary containers).
|
|
90
|
+
xls: 'application/vnd.ms-excel',
|
|
91
|
+
doc: 'application/msword',
|
|
92
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
83
93
|
};
|
|
84
94
|
|
|
85
95
|
export function mimeTypeFromExt(ext) {
|
package/sync.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { initializeApp } from 'firebase/app';
|
|
20
|
-
import { getAuth, signInWithEmailAndPassword, signInWithCredential, GoogleAuthProvider } from 'firebase/auth';
|
|
20
|
+
import { getAuth, initializeAuth, signInWithEmailAndPassword, signInWithCredential, GoogleAuthProvider } from 'firebase/auth';
|
|
21
21
|
import {
|
|
22
22
|
getFirestore, collection, doc, getDocs, getDoc, setDoc, addDoc, updateDoc, deleteDoc,
|
|
23
23
|
query, where, onSnapshot
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
// Max file size enforced before upload. Mirrored in storage.rules.
|
|
48
48
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
49
49
|
import { resolveConfigPath, loadConfig, saveConfig } from './config.js';
|
|
50
|
+
import { makeFilePersistence, resolveAuthFilePath } from './auth-persistence.js';
|
|
50
51
|
import {
|
|
51
52
|
scopeFromLocalPath,
|
|
52
53
|
teamFolderName,
|
|
@@ -527,29 +528,57 @@ if (config._app) {
|
|
|
527
528
|
}
|
|
528
529
|
|
|
529
530
|
app = initializeApp(fbConfig);
|
|
530
|
-
const auth = getAuth(app);
|
|
531
531
|
db = getFirestore(app);
|
|
532
532
|
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
|
|
533
|
+
// File-backed auth persistence. A previously-signed-in session is
|
|
534
|
+
// restored here with no browser. The desktop app sets
|
|
535
|
+
// COMPOUND_AUTH_FILE per account; the CLI derives a per-(project,
|
|
536
|
+
// account) path under ~/.config/compound-sync. initializeAuth must
|
|
537
|
+
// run before any getAuth(app) for this app.
|
|
538
|
+
const authFilePath = resolveAuthFilePath({
|
|
539
|
+
authFileEnv: process.env.COMPOUND_AUTH_FILE,
|
|
540
|
+
homeDir: os.homedir(),
|
|
541
|
+
projectId: config.projectId,
|
|
542
|
+
accountKey: config.email || config.orgId,
|
|
543
|
+
});
|
|
544
|
+
const auth = initializeAuth(app, { persistence: makeFilePersistence(authFilePath) });
|
|
545
|
+
await auth.authStateReady();
|
|
536
546
|
|
|
537
|
-
// Initialize App Check BEFORE signing in on the Node.js side
|
|
538
|
-
// Use the Firebase ID token from the browser to get an App Check token
|
|
539
547
|
const appCheckUrl = appCheckEndpoints[config.projectId];
|
|
540
|
-
if (appCheckUrl && authResult.firebaseIdToken) {
|
|
541
|
-
await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
|
|
542
|
-
}
|
|
543
548
|
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
+
if (auth.currentUser) {
|
|
550
|
+
// Silent re-auth: session restored from persisted credentials, no
|
|
551
|
+
// browser. App Check (custom provider) mints its token from a
|
|
552
|
+
// fresh ID token, which the refresh-token flow obtains without a
|
|
553
|
+
// browser.
|
|
554
|
+
userId = auth.currentUser.uid;
|
|
555
|
+
if (appCheckUrl) {
|
|
556
|
+
const idToken = await auth.currentUser.getIdToken();
|
|
557
|
+
await initAppCheckWithIdToken(app, appCheckUrl, idToken);
|
|
558
|
+
}
|
|
559
|
+
console.log(`Signed in (silent) as ${auth.currentUser.email || userId}`);
|
|
549
560
|
} else {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
console.log(
|
|
561
|
+
// First run for this account: interactive browser sign-in. The
|
|
562
|
+
// result is persisted, so later launches take the silent path.
|
|
563
|
+
console.log('');
|
|
564
|
+
const authResult = await browserAuth(fbConfig);
|
|
565
|
+
|
|
566
|
+
// Initialize App Check BEFORE signing in on the Node.js side, using
|
|
567
|
+
// the Firebase ID token from the browser.
|
|
568
|
+
if (appCheckUrl && authResult.firebaseIdToken) {
|
|
569
|
+
await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (authResult.method === 'google') {
|
|
573
|
+
const credential = GoogleAuthProvider.credential(authResult.idToken, authResult.accessToken);
|
|
574
|
+
const cred = await signInWithCredential(auth, credential);
|
|
575
|
+
userId = cred.user.uid;
|
|
576
|
+
console.log(`Signed in as ${cred.user.email}`);
|
|
577
|
+
} else {
|
|
578
|
+
const cred = await signInWithEmailAndPassword(auth, authResult.email, authResult.password);
|
|
579
|
+
userId = cred.user.uid;
|
|
580
|
+
console.log(`Signed in as ${cred.user.email}`);
|
|
581
|
+
}
|
|
553
582
|
}
|
|
554
583
|
|
|
555
584
|
}
|