sqlite-cloud-backup 0.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/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/index.cjs +832 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +95 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +795 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
// src/core/db-manager.ts
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import fs3 from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
// src/utils/checksum.ts
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
var ChecksumUtil = class {
|
|
10
|
+
/**
|
|
11
|
+
* Calculate SHA-256 checksum of a file
|
|
12
|
+
*/
|
|
13
|
+
static async calculateFileChecksum(filePath) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const hash = crypto.createHash("sha256");
|
|
16
|
+
const stream = fs.createReadStream(filePath);
|
|
17
|
+
stream.on("data", (data) => hash.update(data));
|
|
18
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
19
|
+
stream.on("error", reject);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Calculate SHA-256 checksum of a buffer
|
|
24
|
+
*/
|
|
25
|
+
static calculateBufferChecksum(buffer) {
|
|
26
|
+
return crypto.createHash("sha256").update(buffer).digest("hex");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Verify file integrity
|
|
30
|
+
*/
|
|
31
|
+
static async verifyChecksum(filePath, expectedChecksum) {
|
|
32
|
+
const actualChecksum = await this.calculateFileChecksum(filePath);
|
|
33
|
+
return actualChecksum === expectedChecksum;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// src/utils/file-operations.ts
|
|
38
|
+
import fs2 from "fs";
|
|
39
|
+
var FileOperations = class {
|
|
40
|
+
/**
|
|
41
|
+
* Ensure directory exists, create if not
|
|
42
|
+
*/
|
|
43
|
+
static ensureDir(dirPath) {
|
|
44
|
+
if (!fs2.existsSync(dirPath)) {
|
|
45
|
+
fs2.mkdirSync(dirPath, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Copy file atomically
|
|
50
|
+
*/
|
|
51
|
+
static async copyFile(source, destination) {
|
|
52
|
+
const tempDest = `${destination}.tmp`;
|
|
53
|
+
await fs2.promises.copyFile(source, tempDest);
|
|
54
|
+
await fs2.promises.rename(tempDest, destination);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/core/db-manager.ts
|
|
59
|
+
var DatabaseManager = class {
|
|
60
|
+
db = null;
|
|
61
|
+
dbPath;
|
|
62
|
+
metadataPath;
|
|
63
|
+
logger;
|
|
64
|
+
constructor(dbPath, logger) {
|
|
65
|
+
this.dbPath = dbPath;
|
|
66
|
+
this.logger = logger;
|
|
67
|
+
const dbDir = path.dirname(dbPath);
|
|
68
|
+
const dbName = path.basename(dbPath, path.extname(dbPath));
|
|
69
|
+
this.metadataPath = path.join(dbDir, ".sqlite-cloud-backup", dbName, "metadata.json");
|
|
70
|
+
FileOperations.ensureDir(path.dirname(this.metadataPath));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Open database connection
|
|
74
|
+
*/
|
|
75
|
+
open() {
|
|
76
|
+
if (!fs3.existsSync(this.dbPath)) {
|
|
77
|
+
throw new Error(`Database not found: ${this.dbPath}`);
|
|
78
|
+
}
|
|
79
|
+
this.db = new Database(this.dbPath, { readonly: false });
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Close database connection
|
|
83
|
+
*/
|
|
84
|
+
close() {
|
|
85
|
+
if (this.db) {
|
|
86
|
+
this.db.close();
|
|
87
|
+
this.db = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get database file as buffer
|
|
92
|
+
*/
|
|
93
|
+
async getBuffer() {
|
|
94
|
+
this.close();
|
|
95
|
+
return fs3.promises.readFile(this.dbPath);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Calculate current database checksum
|
|
99
|
+
*/
|
|
100
|
+
async getChecksum() {
|
|
101
|
+
this.close();
|
|
102
|
+
return ChecksumUtil.calculateFileChecksum(this.dbPath);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Replace database with buffer
|
|
106
|
+
*/
|
|
107
|
+
async replaceWithBuffer(buffer) {
|
|
108
|
+
this.close();
|
|
109
|
+
const tempPath = `${this.dbPath}.tmp`;
|
|
110
|
+
await fs3.promises.writeFile(tempPath, buffer);
|
|
111
|
+
await fs3.promises.rename(tempPath, this.dbPath);
|
|
112
|
+
this.logger.info("Database replaced from cloud");
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Get local metadata
|
|
116
|
+
*/
|
|
117
|
+
async getLocalMetadata() {
|
|
118
|
+
if (!fs3.existsSync(this.metadataPath)) {
|
|
119
|
+
return {
|
|
120
|
+
lastSyncTimestamp: 0,
|
|
121
|
+
lastSyncChecksum: ""
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const content = await fs3.promises.readFile(this.metadataPath, "utf-8");
|
|
125
|
+
return JSON.parse(content);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Update local metadata
|
|
129
|
+
*/
|
|
130
|
+
async updateLocalMetadata(metadata) {
|
|
131
|
+
const current = await this.getLocalMetadata();
|
|
132
|
+
const updated = { ...current, ...metadata };
|
|
133
|
+
await fs3.promises.writeFile(
|
|
134
|
+
this.metadataPath,
|
|
135
|
+
JSON.stringify(updated, null, 2)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Get modification time of database file
|
|
140
|
+
*/
|
|
141
|
+
async getModifiedTime() {
|
|
142
|
+
const stats = await fs3.promises.stat(this.dbPath);
|
|
143
|
+
return stats.mtimeMs;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// src/core/sync-engine.ts
|
|
148
|
+
var SyncEngine = class {
|
|
149
|
+
dbManager;
|
|
150
|
+
provider;
|
|
151
|
+
logger;
|
|
152
|
+
constructor(dbManager, provider, logger) {
|
|
153
|
+
this.dbManager = dbManager;
|
|
154
|
+
this.provider = provider;
|
|
155
|
+
this.logger = logger;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Push local database to cloud
|
|
159
|
+
*/
|
|
160
|
+
async pushToCloud() {
|
|
161
|
+
const startTime = Date.now();
|
|
162
|
+
try {
|
|
163
|
+
const buffer = await this.dbManager.getBuffer();
|
|
164
|
+
const originalSize = buffer.length;
|
|
165
|
+
const checksum = ChecksumUtil.calculateBufferChecksum(buffer);
|
|
166
|
+
await this.provider.uploadFile("current.db", buffer);
|
|
167
|
+
const metadata = {
|
|
168
|
+
dbName: "current",
|
|
169
|
+
lastSyncTimestamp: Date.now(),
|
|
170
|
+
lastSyncType: "push",
|
|
171
|
+
checksum,
|
|
172
|
+
version: 1
|
|
173
|
+
};
|
|
174
|
+
await this.provider.updateMetadata(metadata);
|
|
175
|
+
await this.dbManager.updateLocalMetadata({
|
|
176
|
+
lastSyncTimestamp: metadata.lastSyncTimestamp,
|
|
177
|
+
lastSyncChecksum: checksum
|
|
178
|
+
});
|
|
179
|
+
const result = {
|
|
180
|
+
success: true,
|
|
181
|
+
type: "push",
|
|
182
|
+
timestamp: Date.now(),
|
|
183
|
+
localChecksum: checksum,
|
|
184
|
+
cloudChecksum: checksum,
|
|
185
|
+
bytesTransferred: buffer.length,
|
|
186
|
+
duration: Date.now() - startTime
|
|
187
|
+
};
|
|
188
|
+
this.logger.info(`Push successful: ${originalSize} bytes`);
|
|
189
|
+
return result;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
this.logger.error("Push failed", error);
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Pull database from cloud to local
|
|
197
|
+
*/
|
|
198
|
+
async pullFromCloud() {
|
|
199
|
+
const startTime = Date.now();
|
|
200
|
+
try {
|
|
201
|
+
const exists = await this.provider.fileExists("current.db");
|
|
202
|
+
if (!exists) {
|
|
203
|
+
throw new Error("No cloud version found");
|
|
204
|
+
}
|
|
205
|
+
const buffer = await this.provider.downloadFile("current.db");
|
|
206
|
+
const checksum = ChecksumUtil.calculateBufferChecksum(buffer);
|
|
207
|
+
const cloudMetadata = await this.provider.getMetadata("current.db");
|
|
208
|
+
if (cloudMetadata && cloudMetadata.checksum !== checksum) {
|
|
209
|
+
throw new Error("Checksum mismatch - data corruption detected");
|
|
210
|
+
}
|
|
211
|
+
await this.dbManager.replaceWithBuffer(buffer);
|
|
212
|
+
await this.dbManager.updateLocalMetadata({
|
|
213
|
+
lastSyncTimestamp: Date.now(),
|
|
214
|
+
lastSyncChecksum: checksum
|
|
215
|
+
});
|
|
216
|
+
const result = {
|
|
217
|
+
success: true,
|
|
218
|
+
type: "pull",
|
|
219
|
+
timestamp: Date.now(),
|
|
220
|
+
localChecksum: checksum,
|
|
221
|
+
cloudChecksum: checksum,
|
|
222
|
+
bytesTransferred: buffer.length,
|
|
223
|
+
duration: Date.now() - startTime
|
|
224
|
+
};
|
|
225
|
+
this.logger.info(`Pull successful: ${buffer.length} bytes`);
|
|
226
|
+
return result;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
this.logger.error("Pull failed", error);
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Bidirectional sync - simple version for v0.1
|
|
234
|
+
*/
|
|
235
|
+
async sync() {
|
|
236
|
+
const startTime = Date.now();
|
|
237
|
+
try {
|
|
238
|
+
const cloudExists = await this.provider.fileExists("current.db");
|
|
239
|
+
if (!cloudExists) {
|
|
240
|
+
this.logger.info("No cloud version found, pushing local database");
|
|
241
|
+
return await this.pushToCloud();
|
|
242
|
+
}
|
|
243
|
+
const localChecksum = await this.dbManager.getChecksum();
|
|
244
|
+
const cloudMetadata = await this.provider.getMetadata("current.db");
|
|
245
|
+
if (!cloudMetadata) {
|
|
246
|
+
this.logger.info("No cloud metadata, pushing local database");
|
|
247
|
+
return await this.pushToCloud();
|
|
248
|
+
}
|
|
249
|
+
if (localChecksum === cloudMetadata.checksum) {
|
|
250
|
+
const result = {
|
|
251
|
+
success: true,
|
|
252
|
+
type: "bidirectional",
|
|
253
|
+
timestamp: Date.now(),
|
|
254
|
+
localChecksum,
|
|
255
|
+
cloudChecksum: cloudMetadata.checksum,
|
|
256
|
+
bytesTransferred: 0,
|
|
257
|
+
duration: Date.now() - startTime
|
|
258
|
+
};
|
|
259
|
+
this.logger.info("Already in sync");
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
const localModified = await this.dbManager.getModifiedTime();
|
|
263
|
+
if (localModified > cloudMetadata.modifiedAt) {
|
|
264
|
+
this.logger.info("Local is newer, pushing");
|
|
265
|
+
return await this.pushToCloud();
|
|
266
|
+
} else {
|
|
267
|
+
this.logger.info("Cloud is newer, pulling");
|
|
268
|
+
return await this.pullFromCloud();
|
|
269
|
+
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
this.logger.error("Sync failed", error);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// src/providers/google-drive/google-drive-provider.ts
|
|
278
|
+
import { google } from "googleapis";
|
|
279
|
+
|
|
280
|
+
// src/providers/base-provider.ts
|
|
281
|
+
var BaseProvider = class {
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// src/providers/google-drive/google-drive-provider.ts
|
|
285
|
+
import { Readable } from "stream";
|
|
286
|
+
var GoogleDriveProvider = class extends BaseProvider {
|
|
287
|
+
drive;
|
|
288
|
+
oauth2Client;
|
|
289
|
+
rootFolderId = null;
|
|
290
|
+
logger;
|
|
291
|
+
dbName;
|
|
292
|
+
constructor(credentials, dbName, logger) {
|
|
293
|
+
super();
|
|
294
|
+
this.logger = logger;
|
|
295
|
+
this.dbName = dbName;
|
|
296
|
+
this.oauth2Client = new google.auth.OAuth2(
|
|
297
|
+
credentials.clientId,
|
|
298
|
+
credentials.clientSecret,
|
|
299
|
+
credentials.redirectUri
|
|
300
|
+
);
|
|
301
|
+
this.oauth2Client.setCredentials({
|
|
302
|
+
refresh_token: credentials.refreshToken
|
|
303
|
+
});
|
|
304
|
+
this.drive = google.drive({ version: "v3", auth: this.oauth2Client });
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Initialize folder structure
|
|
308
|
+
*/
|
|
309
|
+
async ensureRootFolder() {
|
|
310
|
+
if (this.rootFolderId) return this.rootFolderId;
|
|
311
|
+
const response = await this.drive.files.list({
|
|
312
|
+
q: "name='.sqlite-cloud-backup' and mimeType='application/vnd.google-apps.folder' and trashed=false",
|
|
313
|
+
fields: "files(id, name)",
|
|
314
|
+
spaces: "drive"
|
|
315
|
+
});
|
|
316
|
+
if (response.data.files && response.data.files.length > 0) {
|
|
317
|
+
this.rootFolderId = response.data.files[0].id;
|
|
318
|
+
} else {
|
|
319
|
+
const folder = await this.drive.files.create({
|
|
320
|
+
requestBody: {
|
|
321
|
+
name: ".sqlite-cloud-backup",
|
|
322
|
+
mimeType: "application/vnd.google-apps.folder"
|
|
323
|
+
},
|
|
324
|
+
fields: "id"
|
|
325
|
+
});
|
|
326
|
+
this.rootFolderId = folder.data.id;
|
|
327
|
+
}
|
|
328
|
+
const dbFolderId = await this.ensureDbFolder();
|
|
329
|
+
return dbFolderId;
|
|
330
|
+
}
|
|
331
|
+
async ensureDbFolder() {
|
|
332
|
+
const response = await this.drive.files.list({
|
|
333
|
+
q: `name='${this.dbName}' and mimeType='application/vnd.google-apps.folder' and '${this.rootFolderId}' in parents and trashed=false`,
|
|
334
|
+
fields: "files(id, name)"
|
|
335
|
+
});
|
|
336
|
+
if (response.data.files && response.data.files.length > 0) {
|
|
337
|
+
return response.data.files[0].id;
|
|
338
|
+
}
|
|
339
|
+
const folder = await this.drive.files.create({
|
|
340
|
+
requestBody: {
|
|
341
|
+
name: this.dbName,
|
|
342
|
+
mimeType: "application/vnd.google-apps.folder",
|
|
343
|
+
parents: [this.rootFolderId]
|
|
344
|
+
},
|
|
345
|
+
fields: "id"
|
|
346
|
+
});
|
|
347
|
+
return folder.data.id;
|
|
348
|
+
}
|
|
349
|
+
async uploadFile(fileName, buffer) {
|
|
350
|
+
const folderId = await this.ensureRootFolder();
|
|
351
|
+
const existing = await this.findFile(fileName);
|
|
352
|
+
const media = {
|
|
353
|
+
mimeType: "application/x-sqlite3",
|
|
354
|
+
body: Readable.from(buffer)
|
|
355
|
+
};
|
|
356
|
+
if (existing) {
|
|
357
|
+
await this.drive.files.update({
|
|
358
|
+
fileId: existing.id,
|
|
359
|
+
media,
|
|
360
|
+
fields: "id"
|
|
361
|
+
});
|
|
362
|
+
this.logger.info(`Updated file in Google Drive: ${fileName}`);
|
|
363
|
+
} else {
|
|
364
|
+
await this.drive.files.create({
|
|
365
|
+
requestBody: {
|
|
366
|
+
name: fileName,
|
|
367
|
+
parents: [folderId]
|
|
368
|
+
},
|
|
369
|
+
media,
|
|
370
|
+
fields: "id"
|
|
371
|
+
});
|
|
372
|
+
this.logger.info(`Uploaded file to Google Drive: ${fileName}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async downloadFile(fileName) {
|
|
376
|
+
const file = await this.findFile(fileName);
|
|
377
|
+
if (!file) {
|
|
378
|
+
throw new Error(`File not found: ${fileName}`);
|
|
379
|
+
}
|
|
380
|
+
const response = await this.drive.files.get(
|
|
381
|
+
{ fileId: file.id, alt: "media" },
|
|
382
|
+
{ responseType: "arraybuffer" }
|
|
383
|
+
);
|
|
384
|
+
this.logger.info(`Downloaded file from Google Drive: ${fileName}`);
|
|
385
|
+
return Buffer.from(response.data);
|
|
386
|
+
}
|
|
387
|
+
async fileExists(fileName) {
|
|
388
|
+
const file = await this.findFile(fileName);
|
|
389
|
+
return file !== null;
|
|
390
|
+
}
|
|
391
|
+
async getMetadata(_fileName) {
|
|
392
|
+
const metadataFile = await this.findFile("metadata.json");
|
|
393
|
+
if (!metadataFile) return null;
|
|
394
|
+
const buffer = await this.downloadFile("metadata.json");
|
|
395
|
+
const metadata = JSON.parse(buffer.toString("utf-8"));
|
|
396
|
+
return {
|
|
397
|
+
checksum: metadata.checksum,
|
|
398
|
+
modifiedAt: metadata.lastSyncTimestamp,
|
|
399
|
+
size: 0
|
|
400
|
+
// Not tracked in metadata
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
async updateMetadata(metadata) {
|
|
404
|
+
const buffer = Buffer.from(JSON.stringify(metadata, null, 2));
|
|
405
|
+
await this.uploadFile("metadata.json", buffer);
|
|
406
|
+
}
|
|
407
|
+
async deleteFile(fileName) {
|
|
408
|
+
const file = await this.findFile(fileName);
|
|
409
|
+
if (file) {
|
|
410
|
+
await this.drive.files.delete({ fileId: file.id });
|
|
411
|
+
this.logger.info(`Deleted file from Google Drive: ${fileName}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async findFile(fileName) {
|
|
415
|
+
const folderId = await this.ensureRootFolder();
|
|
416
|
+
const response = await this.drive.files.list({
|
|
417
|
+
q: `name='${fileName}' and '${folderId}' in parents and trashed=false`,
|
|
418
|
+
fields: "files(id, name, modifiedTime)"
|
|
419
|
+
});
|
|
420
|
+
return response.data.files?.[0] || null;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// src/providers/google-drive/oauth-flow.ts
|
|
425
|
+
import http from "http";
|
|
426
|
+
import { URL } from "url";
|
|
427
|
+
import open from "open";
|
|
428
|
+
var OAuthFlow = class {
|
|
429
|
+
logger;
|
|
430
|
+
server = null;
|
|
431
|
+
redirectUri = "http://localhost:3000/oauth/callback";
|
|
432
|
+
scopes = ["https://www.googleapis.com/auth/drive.file"];
|
|
433
|
+
constructor(logger) {
|
|
434
|
+
this.logger = logger;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Start OAuth flow - opens browser and waits for callback
|
|
438
|
+
*/
|
|
439
|
+
async authenticate(clientId, clientSecret) {
|
|
440
|
+
this.logger.info("Starting OAuth flow...");
|
|
441
|
+
const authCode = await this.startLocalServerAndWaitForCode(clientId);
|
|
442
|
+
const tokens = await this.exchangeCodeForTokens(authCode, clientId, clientSecret);
|
|
443
|
+
this.logger.info("OAuth authentication successful");
|
|
444
|
+
return tokens;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Start local HTTP server and wait for OAuth callback
|
|
448
|
+
*/
|
|
449
|
+
async startLocalServerAndWaitForCode(clientId) {
|
|
450
|
+
return new Promise((resolve, reject) => {
|
|
451
|
+
this.server = http.createServer((req, res) => {
|
|
452
|
+
const url = new URL(req.url || "", `http://localhost:3000`);
|
|
453
|
+
if (url.pathname === "/oauth/callback") {
|
|
454
|
+
const code = url.searchParams.get("code");
|
|
455
|
+
const error = url.searchParams.get("error");
|
|
456
|
+
if (error) {
|
|
457
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
458
|
+
res.end("<h1>Authentication Failed</h1><p>You can close this window.</p>");
|
|
459
|
+
this.stopServer();
|
|
460
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (code) {
|
|
464
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
465
|
+
res.end("<h1>Authentication Successful!</h1><p>You can close this window and return to the app.</p>");
|
|
466
|
+
this.stopServer();
|
|
467
|
+
resolve(code);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
471
|
+
res.end("<h1>Invalid Request</h1><p>No authorization code received.</p>");
|
|
472
|
+
this.stopServer();
|
|
473
|
+
reject(new Error("No authorization code received"));
|
|
474
|
+
} else {
|
|
475
|
+
res.writeHead(404);
|
|
476
|
+
res.end();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
this.server.listen(3e3, () => {
|
|
480
|
+
this.logger.info("Local OAuth server started on http://localhost:3000");
|
|
481
|
+
const authUrl = this.buildAuthUrl(clientId);
|
|
482
|
+
this.logger.info("Opening browser for authentication...");
|
|
483
|
+
open(authUrl).catch((err) => {
|
|
484
|
+
this.logger.error("Failed to open browser", err);
|
|
485
|
+
this.logger.info(`Please open this URL manually: ${authUrl}`);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
setTimeout(() => {
|
|
489
|
+
this.stopServer();
|
|
490
|
+
reject(new Error("OAuth flow timed out after 5 minutes"));
|
|
491
|
+
}, 5 * 60 * 1e3);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Build Google OAuth authorization URL
|
|
496
|
+
*/
|
|
497
|
+
buildAuthUrl(clientId) {
|
|
498
|
+
const params = new URLSearchParams({
|
|
499
|
+
client_id: clientId,
|
|
500
|
+
redirect_uri: this.redirectUri,
|
|
501
|
+
response_type: "code",
|
|
502
|
+
scope: this.scopes.join(" "),
|
|
503
|
+
access_type: "offline",
|
|
504
|
+
prompt: "consent"
|
|
505
|
+
// Force to get refresh token
|
|
506
|
+
});
|
|
507
|
+
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Exchange authorization code for tokens
|
|
511
|
+
*/
|
|
512
|
+
async exchangeCodeForTokens(code, clientId, clientSecret) {
|
|
513
|
+
this.logger.info("Exchanging authorization code for tokens...");
|
|
514
|
+
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
515
|
+
method: "POST",
|
|
516
|
+
headers: {
|
|
517
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
518
|
+
},
|
|
519
|
+
body: new URLSearchParams({
|
|
520
|
+
code,
|
|
521
|
+
client_id: clientId,
|
|
522
|
+
client_secret: clientSecret,
|
|
523
|
+
redirect_uri: this.redirectUri,
|
|
524
|
+
grant_type: "authorization_code"
|
|
525
|
+
}).toString()
|
|
526
|
+
});
|
|
527
|
+
if (!response.ok) {
|
|
528
|
+
const error = await response.text();
|
|
529
|
+
throw new Error(`Failed to exchange code for tokens: ${error}`);
|
|
530
|
+
}
|
|
531
|
+
const data = await response.json();
|
|
532
|
+
return {
|
|
533
|
+
access_token: data.access_token,
|
|
534
|
+
refresh_token: data.refresh_token,
|
|
535
|
+
expiry_date: data.expires_in ? Date.now() + data.expires_in * 1e3 : void 0
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Stop the local server
|
|
540
|
+
*/
|
|
541
|
+
stopServer() {
|
|
542
|
+
if (this.server) {
|
|
543
|
+
this.server.close();
|
|
544
|
+
this.server = null;
|
|
545
|
+
this.logger.info("Local OAuth server stopped");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// src/providers/google-drive/token-storage.ts
|
|
551
|
+
import fs4 from "fs/promises";
|
|
552
|
+
import path2 from "path";
|
|
553
|
+
|
|
554
|
+
// src/utils/logger.ts
|
|
555
|
+
var Logger = class {
|
|
556
|
+
level;
|
|
557
|
+
constructor(level = "info") {
|
|
558
|
+
this.level = level;
|
|
559
|
+
}
|
|
560
|
+
shouldLog(level) {
|
|
561
|
+
const levels = ["debug", "info", "warn", "error"];
|
|
562
|
+
return levels.indexOf(level) >= levels.indexOf(this.level);
|
|
563
|
+
}
|
|
564
|
+
debug(message, meta) {
|
|
565
|
+
if (this.shouldLog("debug")) {
|
|
566
|
+
console.debug(`[DEBUG] ${message}`, meta || "");
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
info(message, meta) {
|
|
570
|
+
if (this.shouldLog("info")) {
|
|
571
|
+
console.info(`[INFO] ${message}`, meta || "");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
warn(message, meta) {
|
|
575
|
+
if (this.shouldLog("warn")) {
|
|
576
|
+
console.warn(`[WARN] ${message}`, meta || "");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
error(message, error) {
|
|
580
|
+
if (this.shouldLog("error")) {
|
|
581
|
+
console.error(`[ERROR] ${message}`, error || "");
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// src/providers/google-drive/token-storage.ts
|
|
587
|
+
var TokenStorage = class {
|
|
588
|
+
logger;
|
|
589
|
+
tokenDir;
|
|
590
|
+
tokenFile;
|
|
591
|
+
constructor(dbPath, logLevel = "info") {
|
|
592
|
+
this.logger = new Logger(logLevel);
|
|
593
|
+
const dbDir = path2.dirname(dbPath);
|
|
594
|
+
this.tokenDir = path2.join(dbDir, ".sqlite-cloud-backup");
|
|
595
|
+
this.tokenFile = path2.join(this.tokenDir, "tokens.json");
|
|
596
|
+
}
|
|
597
|
+
async hasTokens() {
|
|
598
|
+
try {
|
|
599
|
+
await fs4.access(this.tokenFile);
|
|
600
|
+
return true;
|
|
601
|
+
} catch {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
async getTokens() {
|
|
606
|
+
try {
|
|
607
|
+
const data = await fs4.readFile(this.tokenFile, "utf-8");
|
|
608
|
+
const tokens = JSON.parse(data);
|
|
609
|
+
this.logger.debug("Retrieved stored tokens");
|
|
610
|
+
return tokens;
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (error.code !== "ENOENT") {
|
|
613
|
+
this.logger.warn("Failed to read tokens", error);
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
async saveTokens(tokens) {
|
|
619
|
+
try {
|
|
620
|
+
await fs4.mkdir(this.tokenDir, { recursive: true });
|
|
621
|
+
await fs4.writeFile(
|
|
622
|
+
this.tokenFile,
|
|
623
|
+
JSON.stringify(tokens, null, 2),
|
|
624
|
+
"utf-8"
|
|
625
|
+
);
|
|
626
|
+
this.logger.debug("Saved tokens to storage");
|
|
627
|
+
} catch (error) {
|
|
628
|
+
this.logger.error("Failed to save tokens", error);
|
|
629
|
+
throw new Error("Failed to save authentication tokens");
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async clearTokens() {
|
|
633
|
+
try {
|
|
634
|
+
await fs4.unlink(this.tokenFile);
|
|
635
|
+
this.logger.debug("Cleared stored tokens");
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error.code !== "ENOENT") {
|
|
638
|
+
this.logger.warn("Failed to clear tokens", error);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// src/index.ts
|
|
645
|
+
import path3 from "path";
|
|
646
|
+
var SqliteCloudBackup = class {
|
|
647
|
+
dbManager;
|
|
648
|
+
provider;
|
|
649
|
+
syncEngine;
|
|
650
|
+
logger;
|
|
651
|
+
oauthFlow;
|
|
652
|
+
tokenStorage;
|
|
653
|
+
credentials;
|
|
654
|
+
dbPath;
|
|
655
|
+
constructor(config) {
|
|
656
|
+
this.logger = new Logger(config.options?.logLevel ?? "info");
|
|
657
|
+
this.dbPath = config.dbPath;
|
|
658
|
+
this.credentials = config.credentials;
|
|
659
|
+
this.oauthFlow = new OAuthFlow(this.logger);
|
|
660
|
+
this.tokenStorage = new TokenStorage(config.dbPath, config.options?.logLevel ?? "info");
|
|
661
|
+
this.dbManager = new DatabaseManager(config.dbPath, this.logger);
|
|
662
|
+
this.provider = this.createProvider(config);
|
|
663
|
+
this.syncEngine = new SyncEngine(
|
|
664
|
+
this.dbManager,
|
|
665
|
+
this.provider,
|
|
666
|
+
this.logger
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
createProvider(config) {
|
|
670
|
+
const dbName = path3.basename(config.dbPath, path3.extname(config.dbPath));
|
|
671
|
+
switch (config.provider) {
|
|
672
|
+
case "google-drive":
|
|
673
|
+
return new GoogleDriveProvider(
|
|
674
|
+
config.credentials,
|
|
675
|
+
dbName,
|
|
676
|
+
this.logger
|
|
677
|
+
);
|
|
678
|
+
default:
|
|
679
|
+
throw new Error(`Unsupported provider: ${config.provider}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Check if user needs authentication
|
|
684
|
+
*/
|
|
685
|
+
async needsAuthentication() {
|
|
686
|
+
if (this.credentials.refreshToken) {
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
return !await this.tokenStorage.hasTokens();
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Ensure user is authenticated, trigger OAuth flow if needed
|
|
693
|
+
*/
|
|
694
|
+
async ensureAuthenticated() {
|
|
695
|
+
if (this.credentials.refreshToken) {
|
|
696
|
+
this.logger.debug("Using provided refresh token");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const storedTokens = await this.tokenStorage.getTokens();
|
|
700
|
+
if (storedTokens) {
|
|
701
|
+
this.logger.debug("Using stored refresh token");
|
|
702
|
+
this.credentials.refreshToken = storedTokens.refreshToken;
|
|
703
|
+
const dbName = path3.basename(this.dbPath, path3.extname(this.dbPath));
|
|
704
|
+
this.provider = new GoogleDriveProvider(
|
|
705
|
+
this.credentials,
|
|
706
|
+
dbName,
|
|
707
|
+
this.logger
|
|
708
|
+
);
|
|
709
|
+
this.syncEngine = new SyncEngine(
|
|
710
|
+
this.dbManager,
|
|
711
|
+
this.provider,
|
|
712
|
+
this.logger
|
|
713
|
+
);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
this.logger.info("No authentication found, starting OAuth flow...");
|
|
717
|
+
await this.authenticate();
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Trigger OAuth authentication flow
|
|
721
|
+
*/
|
|
722
|
+
async authenticate() {
|
|
723
|
+
this.logger.info("Starting OAuth authentication flow...");
|
|
724
|
+
const tokens = await this.oauthFlow.authenticate(
|
|
725
|
+
this.credentials.clientId,
|
|
726
|
+
this.credentials.clientSecret
|
|
727
|
+
);
|
|
728
|
+
await this.tokenStorage.saveTokens({
|
|
729
|
+
refreshToken: tokens.refresh_token,
|
|
730
|
+
accessToken: tokens.access_token,
|
|
731
|
+
expiryDate: tokens.expiry_date
|
|
732
|
+
});
|
|
733
|
+
this.credentials.refreshToken = tokens.refresh_token;
|
|
734
|
+
const dbName = path3.basename(this.dbPath, path3.extname(this.dbPath));
|
|
735
|
+
this.provider = new GoogleDriveProvider(
|
|
736
|
+
this.credentials,
|
|
737
|
+
dbName,
|
|
738
|
+
this.logger
|
|
739
|
+
);
|
|
740
|
+
this.syncEngine = new SyncEngine(
|
|
741
|
+
this.dbManager,
|
|
742
|
+
this.provider,
|
|
743
|
+
this.logger
|
|
744
|
+
);
|
|
745
|
+
this.logger.info("Authentication successful");
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Check if user is authenticated
|
|
749
|
+
*/
|
|
750
|
+
async isAuthenticated() {
|
|
751
|
+
return !await this.needsAuthentication();
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Logout and clear stored tokens
|
|
755
|
+
*/
|
|
756
|
+
async logout() {
|
|
757
|
+
await this.tokenStorage.clearTokens();
|
|
758
|
+
this.credentials.refreshToken = void 0;
|
|
759
|
+
this.logger.info("Logged out successfully");
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Push local database to cloud
|
|
763
|
+
*/
|
|
764
|
+
async pushToCloud() {
|
|
765
|
+
await this.ensureAuthenticated();
|
|
766
|
+
return this.syncEngine.pushToCloud();
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Pull database from cloud to local
|
|
770
|
+
*/
|
|
771
|
+
async pullFromCloud() {
|
|
772
|
+
await this.ensureAuthenticated();
|
|
773
|
+
return this.syncEngine.pullFromCloud();
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Bidirectional sync
|
|
777
|
+
*/
|
|
778
|
+
async sync() {
|
|
779
|
+
await this.ensureAuthenticated();
|
|
780
|
+
return this.syncEngine.sync();
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Cleanup and shutdown
|
|
784
|
+
*/
|
|
785
|
+
async shutdown() {
|
|
786
|
+
this.dbManager.close();
|
|
787
|
+
this.logger.info("SqliteCloudBackup shutdown complete");
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
var index_default = SqliteCloudBackup;
|
|
791
|
+
export {
|
|
792
|
+
SqliteCloudBackup,
|
|
793
|
+
index_default as default
|
|
794
|
+
};
|
|
795
|
+
//# sourceMappingURL=index.js.map
|