aethel 0.2.6 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-04-05)
4
+
5
+ - Refactor to Repository pattern; add TUI command system with catalog, CLI runner, and tests
6
+
3
7
  ## 0.2.6 (2026-04-05)
4
8
 
5
9
  - Rewrite README with clearer structure and usage examples
package/README.md CHANGED
@@ -123,6 +123,8 @@ Dual-pane file browser — local filesystem on the left, Google Drive on the rig
123
123
  | `Space` | Toggle selection in Drive pane |
124
124
  | `t` / `d` | Trash / permanently delete selected Drive items |
125
125
  | `/` | Filter by name |
126
+ | `f` | Open the commands page and choose a TUI action |
127
+ | `:` | Run any Aethel CLI command inside the TUI |
126
128
 
127
129
  ## Ignore Patterns
128
130
 
@@ -147,7 +149,31 @@ build/
147
149
 
148
150
  ## Architecture
149
151
 
150
- See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for module structure and data flow.
152
+ Aethel uses a **Repository pattern** — a single `Repository` class (`src/core/repository.js`) wraps all core modules and serves as the unified data-access layer for both the CLI and the TUI.
153
+
154
+ ```
155
+ src/
156
+ ├── cli.js CLI entry — all handlers use Repository
157
+ ├── core/
158
+ │ ├── repository.js Unified data-access layer
159
+ │ ├── auth.js OAuth authentication
160
+ │ ├── config.js Workspace config & state persistence
161
+ │ ├── diff.js Change detection between states
162
+ │ ├── drive-api.js Google Drive API wrapper
163
+ │ ├── local-fs.js Local filesystem operations
164
+ │ ├── remote-cache.js Short-lived remote file cache
165
+ │ ├── snapshot.js Local scanning & snapshot creation
166
+ │ ├── staging.js Stage/unstage operations
167
+ │ ├── sync.js Execute staged changes
168
+ │ └── ignore.js .aethelignore pattern matching
169
+ └── tui/
170
+ ├── app.js React (Ink) dual-pane component
171
+ ├── index.js TUI entry
172
+ ├── commands.js CLI command parser for TUI
173
+ └── command-catalog.js Available TUI commands
174
+ ```
175
+
176
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed module structure and data flow.
151
177
 
152
178
  ## Contributing
153
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aethel",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Git-style Google Drive sync CLI with interactive TUI",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -3,34 +3,18 @@
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { Command } from "commander";
6
- import { authenticate, resolveCredentialsPath, resolveTokenPath } from "./core/auth.js";
6
+ import { resolveCredentialsPath, resolveTokenPath } from "./core/auth.js";
7
7
  import {
8
- AETHEL_DIR,
9
- HISTORY_DIR,
10
- LATEST_SNAPSHOT,
11
- SNAPSHOTS_DIR,
12
8
  initWorkspace,
13
- readConfig,
14
- readLatestSnapshot,
15
9
  requireRoot,
16
- writeSnapshot,
17
10
  } from "./core/config.js";
18
- import { ChangeType, computeDiff } from "./core/diff.js";
11
+ import { ChangeType } from "./core/diff.js";
19
12
  import {
20
- assertNoDuplicateFolders,
21
- batchOperateFiles,
22
13
  dedupeDuplicateFolders,
23
- getRemoteState,
24
- getAccountInfo,
25
- listAccessibleFiles,
26
14
  DuplicateFoldersError,
27
- withDriveRetry,
28
15
  } from "./core/drive-api.js";
29
16
  import { createDefaultIgnoreFile, loadIgnoreRules } from "./core/ignore.js";
30
- import { invalidateRemoteCache, readRemoteCache, writeRemoteCache } from "./core/remote-cache.js";
31
- import { buildSnapshot, scanLocal } from "./core/snapshot.js";
32
- import { stageChange, stageChanges, stageConflictResolution, stagedEntries, unstageAll, unstagePath } from "./core/staging.js";
33
- import { executeStaged } from "./core/sync.js";
17
+ import { Repository } from "./core/repository.js";
34
18
  import { runTui } from "./tui/index.js";
35
19
 
36
20
  const REQUIRED_CONFIRMATION = "DELETE ALL MY GOOGLE DRIVE FILES";
@@ -41,22 +25,14 @@ function addAuthOptions(command) {
41
25
  .option("--token <path>", "Path to cached OAuth token JSON");
42
26
  }
43
27
 
44
- async function getDrive(options = {}) {
45
- const drive = await authenticate(options.credentials, options.token);
46
- return withDriveRetry(drive);
47
- }
48
-
49
- async function loadRemoteState(root, drive, config, { useCache = true } = {}) {
50
- const rootFolderId = config.drive_folder_id || null;
51
- let remoteState = useCache ? readRemoteCache(root, rootFolderId) : null;
52
-
53
- if (!remoteState) {
54
- remoteState = await getRemoteState(drive, rootFolderId);
55
- writeRemoteCache(root, remoteState, rootFolderId);
56
- }
57
-
58
- assertNoDuplicateFolders(remoteState.duplicateFolders);
59
- return remoteState;
28
+ async function openRepo(options, { requireWorkspace = true } = {}) {
29
+ const root = requireWorkspace ? requireRoot() : null;
30
+ const repo = new Repository(root, {
31
+ credentials: options.credentials,
32
+ token: options.token,
33
+ });
34
+ await repo.connect();
35
+ return repo;
60
36
  }
61
37
 
62
38
  function matchesPattern(targetPath, pattern) {
@@ -122,33 +98,9 @@ function requireConfirmation(options) {
122
98
  }
123
99
  }
124
100
 
125
- async function loadWorkspaceState(root, options, { useCache = true } = {}) {
126
- const config = readConfig(root);
127
-
128
- // Start auth, local scan, and snapshot read all in parallel.
129
- const [drive, local, snapshot] = await Promise.all([
130
- getDrive(options),
131
- scanLocal(root),
132
- Promise.resolve(readLatestSnapshot(root)),
133
- ]);
134
-
135
- // Try the short-lived remote cache first (saves a full API round-trip)
136
- const remoteState = await loadRemoteState(root, drive, config, { useCache });
137
- const remote = remoteState.files;
138
-
139
- return {
140
- config,
141
- drive,
142
- remote,
143
- local,
144
- snapshot,
145
- diff: computeDiff(snapshot, remote, local, { root }),
146
- };
147
- }
148
-
149
101
  async function handleAuth(options) {
150
- const drive = await getDrive(options);
151
- const account = await getAccountInfo(drive);
102
+ const repo = await openRepo(options, { requireWorkspace: false });
103
+ const account = await repo.getAccountInfo();
152
104
 
153
105
  console.log("OAuth initialization completed.");
154
106
  console.log(`Credentials path: ${resolveCredentialsPath(options.credentials)}`);
@@ -161,8 +113,8 @@ async function handleAuth(options) {
161
113
 
162
114
  async function handleClean(options) {
163
115
  requireConfirmation(options);
164
- const drive = await getDrive(options);
165
- const files = await listAccessibleFiles(drive, Boolean(options.sharedDrives));
116
+ const repo = await openRepo(options, { requireWorkspace: false });
117
+ const files = await repo.listRemoteFiles({ includeSharedDrives: Boolean(options.sharedDrives) });
166
118
 
167
119
  printCleanerPlan(files, options);
168
120
 
@@ -176,7 +128,7 @@ async function handleClean(options) {
176
128
  return;
177
129
  }
178
130
 
179
- const result = await batchOperateFiles(drive, files, {
131
+ const result = await repo.batchOperateFiles(files, {
180
132
  permanent: Boolean(options.permanent),
181
133
  includeSharedDrives: Boolean(options.sharedDrives),
182
134
  onProgress: (done, total, verb, name) => {
@@ -219,9 +171,9 @@ async function handleInit(options) {
219
171
  }
220
172
 
221
173
  async function handleStatus(options) {
222
- const root = requireRoot();
223
- const { diff } = await loadWorkspaceState(root, options);
224
- const staged = stagedEntries(root);
174
+ const repo = await openRepo(options);
175
+ const { diff } = await repo.loadState();
176
+ const staged = repo.getStagedEntries();
225
177
 
226
178
  if (diff.isClean && staged.length === 0) {
227
179
  console.log("Everything up to date.");
@@ -258,8 +210,8 @@ async function handleStatus(options) {
258
210
  }
259
211
 
260
212
  async function handleDiff(options) {
261
- const root = requireRoot();
262
- const { diff } = await loadWorkspaceState(root, options);
213
+ const repo = await openRepo(options);
214
+ const { diff } = await repo.loadState();
263
215
 
264
216
  if (diff.isClean) {
265
217
  console.log("No changes detected.");
@@ -292,14 +244,14 @@ async function handleDiff(options) {
292
244
  }
293
245
 
294
246
  async function handleAdd(paths, options) {
295
- const root = requireRoot();
296
- const { diff } = await loadWorkspaceState(root, options);
247
+ const repo = await openRepo(options);
248
+ const { diff } = await repo.loadState();
297
249
 
298
250
  if (options.all) {
299
251
  const toStage = diff.changes.filter(
300
252
  (change) => change.suggestedAction !== "conflict"
301
253
  );
302
- const count = stageChanges(root, toStage);
254
+ const count = repo.stageChanges(toStage);
303
255
  console.log(`Staged ${count} change(s).`);
304
256
  return;
305
257
  }
@@ -323,7 +275,7 @@ async function handleAdd(paths, options) {
323
275
  continue;
324
276
  }
325
277
 
326
- stageChange(root, change);
278
+ repo.stageChange(change);
327
279
  stagedCount += 1;
328
280
  console.log(` Staged: ${change.path}`);
329
281
  }
@@ -334,15 +286,16 @@ async function handleAdd(paths, options) {
334
286
 
335
287
  function handleReset(paths, options) {
336
288
  const root = requireRoot();
289
+ const repo = new Repository(root);
337
290
 
338
291
  if (options.all) {
339
- const count = unstageAll(root);
292
+ const count = repo.unstageAll();
340
293
  console.log(`Unstaged ${count} change(s).`);
341
294
  return;
342
295
  }
343
296
 
344
297
  for (const targetPath of paths || []) {
345
- if (unstagePath(root, targetPath)) {
298
+ if (repo.unstagePath(targetPath)) {
346
299
  console.log(` Unstaged: ${targetPath}`);
347
300
  continue;
348
301
  }
@@ -351,23 +304,20 @@ function handleReset(paths, options) {
351
304
  }
352
305
  }
353
306
 
354
- async function handleCommit(options) {
355
- const root = requireRoot();
356
- const config = readConfig(root);
357
- const staged = stagedEntries(root);
307
+ async function handleCommit(options, { repo: existingRepo } = {}) {
308
+ const repo = existingRepo || await openRepo(options);
309
+ const staged = repo.getStagedEntries();
358
310
 
359
311
  if (!staged.length) {
360
312
  console.log("Nothing staged. Use 'aethel add' first.");
361
313
  return;
362
314
  }
363
315
 
364
- const drive = await getDrive(options);
365
316
  const message = options.message || "sync";
366
- await loadRemoteState(root, drive, config, { useCache: true });
367
317
 
368
318
  console.log(`Committing ${staged.length} change(s)...`);
369
319
 
370
- const result = await executeStaged(drive, root, (done, total, verb, name) => {
320
+ const result = await repo.executeStaged((done, total, verb, name) => {
371
321
  if (done < total) {
372
322
  console.log(` [${done + 1}/${total}] ${verb}: ${name}`);
373
323
  }
@@ -381,47 +331,21 @@ async function handleCommit(options) {
381
331
  }
382
332
 
383
333
  console.log("Saving snapshot...");
384
- invalidateRemoteCache(root);
385
- const [remoteState, local] = await Promise.all([
386
- getRemoteState(drive, config.drive_folder_id || null),
387
- scanLocal(root),
388
- ]);
389
- assertNoDuplicateFolders(remoteState.duplicateFolders);
390
- writeRemoteCache(root, remoteState, config.drive_folder_id || null);
391
- writeSnapshot(root, buildSnapshot(remoteState.files, local, message));
334
+ await repo.saveSnapshot(message);
392
335
  console.log(`Snapshot saved: "${message}"`);
393
336
  }
394
337
 
395
338
  function handleLog(options) {
396
339
  const root = requireRoot();
397
- const snapshotsPath = path.join(root, AETHEL_DIR, SNAPSHOTS_DIR);
398
- const entries = [];
399
- const latestPath = path.join(snapshotsPath, LATEST_SNAPSHOT);
400
-
401
- if (fs.existsSync(latestPath)) {
402
- entries.push(JSON.parse(fs.readFileSync(latestPath, "utf8")));
403
- }
404
-
405
- const historyPath = path.join(snapshotsPath, HISTORY_DIR);
406
- if (fs.existsSync(historyPath)) {
407
- const historyFiles = fs
408
- .readdirSync(historyPath)
409
- .filter((fileName) => fileName.endsWith(".json"))
410
- .sort()
411
- .reverse();
412
-
413
- for (const fileName of historyFiles) {
414
- const fullPath = path.join(historyPath, fileName);
415
- entries.push(JSON.parse(fs.readFileSync(fullPath, "utf8")));
416
- }
417
- }
340
+ const repo = new Repository(root);
341
+ const entries = repo.getHistory(options.limit || 10);
418
342
 
419
343
  if (!entries.length) {
420
344
  console.log("No commits yet.");
421
345
  return;
422
346
  }
423
347
 
424
- for (const snapshot of entries.slice(0, options.limit || 10)) {
348
+ for (const snapshot of entries) {
425
349
  console.log(
426
350
  ` ${snapshot.timestamp || "?"} ${snapshot.message || "(no message)"} (${Object.keys(snapshot.files || {}).length} files)`
427
351
  );
@@ -429,23 +353,18 @@ function handleLog(options) {
429
353
  }
430
354
 
431
355
  async function handleFetch(options) {
432
- const root = requireRoot();
433
- const config = readConfig(root);
434
- const drive = await getDrive(options);
435
- const snapshot = readLatestSnapshot(root);
356
+ const repo = await openRepo(options);
436
357
 
437
- invalidateRemoteCache(root);
358
+ repo.invalidateRemoteCache();
438
359
  console.log("Fetching remote file list...");
439
- const remoteState = await getRemoteState(drive, config.drive_folder_id || null);
440
- writeRemoteCache(root, remoteState, config.drive_folder_id || null);
441
- assertNoDuplicateFolders(remoteState.duplicateFolders);
360
+ const remoteState = await repo.getRemoteState({ useCache: false });
442
361
  const remote = remoteState.files;
443
362
  console.log(`Found ${remote.length} file(s) on Drive.`);
444
363
 
445
- // Show what changed on remote since last snapshot
364
+ const snapshot = repo.getSnapshot();
446
365
  if (snapshot) {
447
- const local = await scanLocal(root);
448
- const diff = computeDiff(snapshot, remote, local, { root });
366
+ const local = await repo.scanLocal();
367
+ const diff = repo.computeDiff(snapshot, remote, local);
449
368
  const remoteChanges = diff.remoteChanges;
450
369
  const conflicts = diff.conflicts;
451
370
 
@@ -472,8 +391,8 @@ async function handleFetch(options) {
472
391
  }
473
392
 
474
393
  async function handlePull(paths, options) {
475
- const root = requireRoot();
476
- const { diff } = await loadWorkspaceState(root, options, { useCache: false });
394
+ const repo = await openRepo(options);
395
+ const { diff } = await repo.loadState({ useCache: false });
477
396
 
478
397
  let remoteChanges = diff.changes.filter((change) =>
479
398
  [
@@ -483,18 +402,16 @@ async function handlePull(paths, options) {
483
402
  ].includes(change.changeType)
484
403
  );
485
404
 
486
- // Include conflicts resolved as "theirs" when --force is set
487
405
  if (options.force) {
488
406
  const conflicts = diff.conflicts;
489
407
  if (conflicts.length) {
490
408
  console.log(`Force-pulling ${conflicts.length} conflict(s) (remote wins)...`);
491
409
  for (const c of conflicts) {
492
- stageConflictResolution(root, c, "theirs");
410
+ repo.stageConflictResolution(c, "theirs");
493
411
  }
494
412
  }
495
413
  }
496
414
 
497
- // Filter to specific paths if provided
498
415
  if (paths && paths.length > 0) {
499
416
  remoteChanges = remoteChanges.filter((change) =>
500
417
  paths.some((p) => matchesPattern(change.path, p))
@@ -517,14 +434,14 @@ async function handlePull(paths, options) {
517
434
  return;
518
435
  }
519
436
 
520
- const count = stageChanges(root, remoteChanges);
437
+ const count = repo.stageChanges(remoteChanges);
521
438
  console.log(`Staged ${count} remote change(s). Committing...`);
522
- await handleCommit({ ...options, message: options.message || "pull" });
439
+ await handleCommit({ ...options, message: options.message || "pull" }, { repo });
523
440
  }
524
441
 
525
442
  async function handlePush(paths, options) {
526
- const root = requireRoot();
527
- const { diff } = await loadWorkspaceState(root, options, { useCache: false });
443
+ const repo = await openRepo(options);
444
+ const { diff } = await repo.loadState({ useCache: false });
528
445
 
529
446
  let localChanges = diff.changes.filter((change) =>
530
447
  [
@@ -534,18 +451,16 @@ async function handlePush(paths, options) {
534
451
  ].includes(change.changeType)
535
452
  );
536
453
 
537
- // Include conflicts resolved as "ours" when --force is set
538
454
  if (options.force) {
539
455
  const conflicts = diff.conflicts;
540
456
  if (conflicts.length) {
541
457
  console.log(`Force-pushing ${conflicts.length} conflict(s) (local wins)...`);
542
458
  for (const c of conflicts) {
543
- stageConflictResolution(root, c, "ours");
459
+ repo.stageConflictResolution(c, "ours");
544
460
  }
545
461
  }
546
462
  }
547
463
 
548
- // Filter to specific paths if provided
549
464
  if (paths && paths.length > 0) {
550
465
  localChanges = localChanges.filter((change) =>
551
466
  paths.some((p) => matchesPattern(change.path, p))
@@ -568,14 +483,14 @@ async function handlePush(paths, options) {
568
483
  return;
569
484
  }
570
485
 
571
- const count = stageChanges(root, localChanges);
486
+ const count = repo.stageChanges(localChanges);
572
487
  console.log(`Staged ${count} local change(s). Committing...`);
573
- await handleCommit({ ...options, message: options.message || "push" });
488
+ await handleCommit({ ...options, message: options.message || "push" }, { repo });
574
489
  }
575
490
 
576
491
  async function handleResolve(paths, options) {
577
- const root = requireRoot();
578
- const { diff } = await loadWorkspaceState(root, options);
492
+ const repo = await openRepo(options);
493
+ const { diff } = await repo.loadState();
579
494
  const conflicts = diff.conflicts;
580
495
 
581
496
  if (conflicts.length === 0) {
@@ -618,7 +533,7 @@ async function handleResolve(paths, options) {
618
533
  const strategyLabel = { ours: "local wins", theirs: "remote wins", both: "keep both" };
619
534
 
620
535
  for (const conflict of toResolve) {
621
- stageConflictResolution(root, conflict, strategy);
536
+ repo.stageConflictResolution(conflict, strategy);
622
537
  console.log(` Resolved: ${conflict.path} → ${strategyLabel[strategy]}`);
623
538
  }
624
539
 
@@ -669,34 +584,24 @@ function handleIgnore(subcommand, args) {
669
584
 
670
585
  function handleShow(ref, options) {
671
586
  const root = requireRoot();
672
- const snapshotsPath = path.join(root, AETHEL_DIR, SNAPSHOTS_DIR);
587
+ const repo = new Repository(root);
673
588
 
674
- let snapshot;
589
+ const snapshot = repo.getSnapshotByRef(ref);
675
590
 
676
- if (!ref || ref === "HEAD" || ref === "latest") {
677
- snapshot = readLatestSnapshot(root);
678
- if (!snapshot) {
591
+ if (!snapshot) {
592
+ if (!ref || ref === "HEAD" || ref === "latest") {
679
593
  console.log("No commits yet.");
680
- return;
681
- }
682
- } else {
683
- // Try to match a history file by prefix
684
- const historyPath = path.join(snapshotsPath, HISTORY_DIR);
685
- if (!fs.existsSync(historyPath)) {
686
- console.log("No commit history found.");
687
- return;
688
- }
689
- const files = fs.readdirSync(historyPath).filter((f) => f.endsWith(".json")).sort().reverse();
690
- const match = files.find((f) => f.startsWith(ref));
691
- if (!match) {
594
+ } else {
692
595
  console.log(`No snapshot matching '${ref}' found.`);
693
- console.log("Available snapshots:");
694
- for (const f of files.slice(0, 10)) {
695
- console.log(` ${f.replace(".json", "")}`);
596
+ const history = repo.getHistory(10);
597
+ if (history.length) {
598
+ console.log("Available snapshots:");
599
+ for (const s of history) {
600
+ console.log(` ${s.timestamp || "?"}`);
601
+ }
696
602
  }
697
- return;
698
603
  }
699
- snapshot = JSON.parse(fs.readFileSync(path.join(historyPath, match), "utf-8"));
604
+ return;
700
605
  }
701
606
 
702
607
  console.log(`Snapshot: ${snapshot.timestamp || "?"}`);
@@ -738,16 +643,15 @@ function handleShow(ref, options) {
738
643
  }
739
644
 
740
645
  async function handleRestore(paths, options) {
741
- const root = requireRoot();
742
- const config = readConfig(root);
743
- const snapshot = readLatestSnapshot(root);
646
+ const repo = await openRepo(options);
647
+ const snapshot = repo.getSnapshot();
744
648
 
745
649
  if (!snapshot) {
746
650
  console.log("No snapshot to restore from. Run 'aethel commit' first.");
747
651
  return;
748
652
  }
749
653
 
750
- const drive = await getDrive(options);
654
+ const root = repo.root;
751
655
  const remoteFiles = snapshot.files || {};
752
656
 
753
657
  for (const targetPath of paths) {
@@ -765,13 +669,13 @@ async function handleRestore(paths, options) {
765
669
  console.log(` Restoring ${targetPath} from Drive...`);
766
670
 
767
671
  try {
768
- const meta = await drive.files.get({
672
+ const meta = await repo.drive.files.get({
769
673
  fileId: entry.id,
770
674
  fields: "id,name,mimeType",
771
675
  });
772
676
 
773
677
  const { downloadFile } = await import("./core/drive-api.js");
774
- await downloadFile(drive, { ...meta.data, id: entry.id }, localDest);
678
+ await downloadFile(repo.drive, { ...meta.data, id: entry.id }, localDest);
775
679
  console.log(` Restored: ${targetPath}`);
776
680
  } catch (err) {
777
681
  console.log(` Failed to restore ${targetPath}: ${err.message}`);
@@ -780,23 +684,22 @@ async function handleRestore(paths, options) {
780
684
  }
781
685
 
782
686
  async function handleRm(paths, options) {
783
- const root = requireRoot();
784
- const { diff } = await loadWorkspaceState(root, options);
687
+ const repo = await openRepo(options);
688
+ const { diff } = await repo.loadState();
689
+ const root = repo.root;
785
690
 
786
691
  for (const targetPath of paths) {
787
- // Delete locally
788
692
  const localAbs = path.join(root, targetPath);
789
693
  if (fs.existsSync(localAbs)) {
790
694
  await fs.promises.rm(localAbs, { recursive: true });
791
695
  console.log(` Deleted locally: ${targetPath}`);
792
696
  }
793
697
 
794
- // If it exists on remote, stage a delete_remote
795
698
  const remoteChange = diff.changes.find(
796
699
  (c) => c.path === targetPath && c.fileId
797
700
  );
798
701
  if (remoteChange) {
799
- stageChange(root, {
702
+ repo.stageChange({
800
703
  ...remoteChange,
801
704
  changeType: ChangeType.LOCAL_DELETED,
802
705
  suggestedAction: "delete_remote",
@@ -828,10 +731,19 @@ async function handleMv(source, dest, options) {
828
731
  }
829
732
 
830
733
  async function handleTui(options) {
831
- const drive = await getDrive(options);
734
+ const repo = await openRepo(options, { requireWorkspace: false });
735
+ const cliArgs = [];
736
+ if (options.credentials) {
737
+ cliArgs.push("--credentials", options.credentials);
738
+ }
739
+ if (options.token) {
740
+ cliArgs.push("--token", options.token);
741
+ }
832
742
  await runTui({
833
- drive,
743
+ repo,
834
744
  includeSharedDrives: Boolean(options.sharedDrives),
745
+ cliPath: path.resolve(process.argv[1]),
746
+ cliArgs,
835
747
  });
836
748
  }
837
749
 
@@ -850,12 +762,11 @@ function printDedupeSummary(result) {
850
762
  }
851
763
 
852
764
  async function handleDedupeFolders(options) {
853
- const root = requireRoot();
854
- const config = readConfig(root);
855
- const drive = await getDrive(options);
765
+ const repo = await openRepo(options);
766
+ const config = repo.getConfig();
856
767
  const rootFolderId = config.drive_folder_id || null;
857
- const ignoreRules = loadIgnoreRules(root);
858
- const result = await dedupeDuplicateFolders(drive, rootFolderId, {
768
+ const ignoreRules = loadIgnoreRules(repo.root);
769
+ const result = await dedupeDuplicateFolders(repo.drive, rootFolderId, {
859
770
  execute: Boolean(options.execute),
860
771
  ignoreRules,
861
772
  onProgress: (event) => {
@@ -893,7 +804,7 @@ async function handleDedupeFolders(options) {
893
804
  printDedupeSummary(result);
894
805
 
895
806
  if (options.execute) {
896
- invalidateRemoteCache(root);
807
+ repo.invalidateRemoteCache();
897
808
  if (result.remainingDuplicateFolders.length > 0) {
898
809
  throw new DuplicateFoldersError(result.remainingDuplicateFolders);
899
810
  }
package/src/core/diff.js CHANGED
@@ -163,16 +163,12 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
163
163
  const changes = [];
164
164
  const snapshotFiles = snapshot?.files || {};
165
165
  const snapshotLocalFiles = snapshot?.localFiles || {};
166
- const snapshotById = new Map();
167
-
168
- for (const [fileId, meta] of Object.entries(snapshotFiles)) {
169
- snapshotById.set(fileId, meta);
170
- }
171
-
172
- const remoteById = new Map(remoteFiles.map((file) => [file.id, file]));
173
166
 
167
+ // Build remote lookup and detect additions/modifications in one pass
168
+ const remoteById = new Map();
174
169
  for (const remoteFile of remoteFiles) {
175
- const snapshotEntry = snapshotById.get(remoteFile.id);
170
+ remoteById.set(remoteFile.id, remoteFile);
171
+ const snapshotEntry = snapshotFiles[remoteFile.id];
176
172
 
177
173
  if (!snapshotEntry) {
178
174
  changes.push(
@@ -199,8 +195,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
199
195
  }
200
196
  }
201
197
 
202
- for (const [fileId, snapshotEntry] of snapshotById.entries()) {
198
+ // Detect remote deletions snapshot entries missing from remote
199
+ for (const fileId of Object.keys(snapshotFiles)) {
203
200
  if (!remoteById.has(fileId)) {
201
+ const snapshotEntry = snapshotFiles[fileId];
204
202
  changes.push(
205
203
  createChange({
206
204
  changeType: ChangeType.REMOTE_DELETED,