aethel 0.3.4 → 0.3.5
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 +4 -0
- package/package.json +1 -1
- package/src/cli.js +55 -28
- package/src/core/progress.js +90 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
DuplicateFoldersError,
|
|
32
32
|
} from "./core/drive-api.js";
|
|
33
33
|
import { createDefaultIgnoreFile, loadIgnoreRules } from "./core/ignore.js";
|
|
34
|
+
import { createProgressBar, createSpinner } from "./core/progress.js";
|
|
34
35
|
import { Repository } from "./core/repository.js";
|
|
35
36
|
import { runTui } from "./tui/index.js";
|
|
36
37
|
|
|
@@ -42,16 +43,36 @@ function addAuthOptions(command) {
|
|
|
42
43
|
.option("--token <path>", "Path to cached OAuth token JSON");
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
async function openRepo(options, { requireWorkspace = true } = {}) {
|
|
46
|
+
async function openRepo(options, { requireWorkspace = true, silent = false } = {}) {
|
|
46
47
|
const root = requireWorkspace ? requireRoot() : null;
|
|
47
48
|
const repo = new Repository(root, {
|
|
48
49
|
credentials: options.credentials,
|
|
49
50
|
token: options.token,
|
|
50
51
|
});
|
|
51
|
-
|
|
52
|
+
const spinner = silent ? null : createSpinner("Connecting to Google Drive...");
|
|
53
|
+
try {
|
|
54
|
+
await repo.connect();
|
|
55
|
+
spinner?.succeed("Connected to Google Drive");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
spinner?.fail("Connection failed");
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
52
60
|
return repo;
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
async function loadStateWithProgress(repo, opts) {
|
|
64
|
+
const spinner = createSpinner("Loading workspace state...");
|
|
65
|
+
try {
|
|
66
|
+
const state = await repo.loadState(opts);
|
|
67
|
+
const n = state.diff.changes.length;
|
|
68
|
+
spinner.succeed(n ? `Loaded state — ${n} change(s) detected` : "Loaded state — everything up to date");
|
|
69
|
+
return state;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
spinner.fail("Failed to load workspace state");
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
function matchesPattern(targetPath, pattern) {
|
|
56
77
|
if (targetPath === pattern) {
|
|
57
78
|
return true;
|
|
@@ -117,7 +138,9 @@ function requireConfirmation(options) {
|
|
|
117
138
|
|
|
118
139
|
async function handleAuth(options) {
|
|
119
140
|
const repo = await openRepo(options, { requireWorkspace: false });
|
|
141
|
+
const spinner = createSpinner("Fetching account info...");
|
|
120
142
|
const account = await repo.getAccountInfo();
|
|
143
|
+
spinner.succeed(`Authenticated as ${account.email}`);
|
|
121
144
|
|
|
122
145
|
const credentialsPath = resolveCredentialsPath(options.credentials);
|
|
123
146
|
await persistCredentials(credentialsPath);
|
|
@@ -134,7 +157,9 @@ async function handleAuth(options) {
|
|
|
134
157
|
async function handleClean(options) {
|
|
135
158
|
requireConfirmation(options);
|
|
136
159
|
const repo = await openRepo(options, { requireWorkspace: false });
|
|
160
|
+
const spinner = createSpinner("Listing remote files...");
|
|
137
161
|
const files = await repo.listRemoteFiles({ includeSharedDrives: Boolean(options.sharedDrives) });
|
|
162
|
+
spinner.succeed(`Found ${files.length} file(s) on Drive`);
|
|
138
163
|
|
|
139
164
|
printCleanerPlan(files, options);
|
|
140
165
|
|
|
@@ -148,13 +173,15 @@ async function handleClean(options) {
|
|
|
148
173
|
return;
|
|
149
174
|
}
|
|
150
175
|
|
|
176
|
+
const bar = createProgressBar(`Cleaning ${files.length} file(s)`, files.length);
|
|
151
177
|
const result = await repo.batchOperateFiles(files, {
|
|
152
178
|
permanent: Boolean(options.permanent),
|
|
153
179
|
includeSharedDrives: Boolean(options.sharedDrives),
|
|
154
|
-
onProgress: (done
|
|
155
|
-
|
|
180
|
+
onProgress: (done) => {
|
|
181
|
+
bar.update(done);
|
|
156
182
|
},
|
|
157
183
|
});
|
|
184
|
+
bar.done(`Cleaned ${files.length} file(s)`);
|
|
158
185
|
|
|
159
186
|
if (result.errors) {
|
|
160
187
|
console.log(`Completed with ${result.errors} error(s) out of ${files.length} file(s).`);
|
|
@@ -171,8 +198,9 @@ async function handleInit(options) {
|
|
|
171
198
|
// Interactive folder selection when no --drive-folder is provided
|
|
172
199
|
if (!driveFolderId) {
|
|
173
200
|
const repo = await openRepo(options, { requireWorkspace: false });
|
|
174
|
-
|
|
201
|
+
const spinner = createSpinner("Fetching root-level Drive folders...");
|
|
175
202
|
const folders = await repo.listRootFolders();
|
|
203
|
+
spinner.succeed(`Found ${folders.length} folder(s) in Drive root`);
|
|
176
204
|
|
|
177
205
|
if (folders.length === 0) {
|
|
178
206
|
console.log("No folders found in Drive root. Syncing entire My Drive.");
|
|
@@ -225,7 +253,7 @@ async function handleInit(options) {
|
|
|
225
253
|
|
|
226
254
|
async function handleStatus(options) {
|
|
227
255
|
const repo = await openRepo(options);
|
|
228
|
-
const { diff } = await repo
|
|
256
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
229
257
|
const staged = repo.getStagedEntries();
|
|
230
258
|
|
|
231
259
|
if (diff.isClean && staged.length === 0) {
|
|
@@ -264,7 +292,7 @@ async function handleStatus(options) {
|
|
|
264
292
|
|
|
265
293
|
async function handleDiff(options) {
|
|
266
294
|
const repo = await openRepo(options);
|
|
267
|
-
const { diff } = await repo
|
|
295
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
268
296
|
|
|
269
297
|
if (diff.isClean) {
|
|
270
298
|
console.log("No changes detected.");
|
|
@@ -298,7 +326,7 @@ async function handleDiff(options) {
|
|
|
298
326
|
|
|
299
327
|
async function handleAdd(paths, options) {
|
|
300
328
|
const repo = await openRepo(options);
|
|
301
|
-
const { diff } = await repo
|
|
329
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
302
330
|
|
|
303
331
|
if (options.all) {
|
|
304
332
|
const toStage = diff.changes.filter(
|
|
@@ -367,25 +395,22 @@ async function handleCommit(options, { repo: existingRepo } = {}) {
|
|
|
367
395
|
}
|
|
368
396
|
|
|
369
397
|
const message = options.message || "sync";
|
|
398
|
+
const bar = createProgressBar(`Syncing ${staged.length} change(s)`, staged.length);
|
|
370
399
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const result = await repo.executeStaged((done, total, verb, name) => {
|
|
374
|
-
if (done < total) {
|
|
375
|
-
console.log(` [${done + 1}/${total}] ${verb}: ${name}`);
|
|
376
|
-
}
|
|
400
|
+
const result = await repo.executeStaged((done) => {
|
|
401
|
+
bar.update(done + 1);
|
|
377
402
|
});
|
|
378
403
|
|
|
379
|
-
|
|
404
|
+
bar.done(`Commit complete: ${result.summary}`);
|
|
380
405
|
if (result.errors.length) {
|
|
381
406
|
for (const error of result.errors) {
|
|
382
407
|
console.log(` ERROR: ${error}`);
|
|
383
408
|
}
|
|
384
409
|
}
|
|
385
410
|
|
|
386
|
-
|
|
411
|
+
const spinner = createSpinner("Saving snapshot...");
|
|
387
412
|
await repo.saveSnapshot(message);
|
|
388
|
-
|
|
413
|
+
spinner.succeed(`Snapshot saved: "${message}"`);
|
|
389
414
|
}
|
|
390
415
|
|
|
391
416
|
function handleLog(options) {
|
|
@@ -409,10 +434,10 @@ async function handleFetch(options) {
|
|
|
409
434
|
const repo = await openRepo(options);
|
|
410
435
|
|
|
411
436
|
repo.invalidateRemoteCache();
|
|
412
|
-
|
|
437
|
+
const spinner = createSpinner("Fetching remote file list...");
|
|
413
438
|
const remoteState = await repo.getRemoteState({ useCache: false });
|
|
414
439
|
const remote = remoteState.files;
|
|
415
|
-
|
|
440
|
+
spinner.succeed(`Found ${remote.length} file(s) on Drive`);
|
|
416
441
|
|
|
417
442
|
const snapshot = repo.getSnapshot();
|
|
418
443
|
if (snapshot) {
|
|
@@ -445,7 +470,7 @@ async function handleFetch(options) {
|
|
|
445
470
|
|
|
446
471
|
async function handlePull(paths, options) {
|
|
447
472
|
const repo = await openRepo(options);
|
|
448
|
-
const { diff } = await repo
|
|
473
|
+
const { diff } = await loadStateWithProgress(repo, { useCache: false });
|
|
449
474
|
|
|
450
475
|
let remoteChanges = diff.changes.filter((change) =>
|
|
451
476
|
[
|
|
@@ -494,7 +519,7 @@ async function handlePull(paths, options) {
|
|
|
494
519
|
|
|
495
520
|
async function handlePush(paths, options) {
|
|
496
521
|
const repo = await openRepo(options);
|
|
497
|
-
const { diff } = await repo
|
|
522
|
+
const { diff } = await loadStateWithProgress(repo, { useCache: false });
|
|
498
523
|
|
|
499
524
|
let localChanges = diff.changes.filter((change) =>
|
|
500
525
|
[
|
|
@@ -543,7 +568,7 @@ async function handlePush(paths, options) {
|
|
|
543
568
|
|
|
544
569
|
async function handleResolve(paths, options) {
|
|
545
570
|
const repo = await openRepo(options);
|
|
546
|
-
const { diff } = await repo
|
|
571
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
547
572
|
const conflicts = diff.conflicts;
|
|
548
573
|
|
|
549
574
|
if (conflicts.length === 0) {
|
|
@@ -719,7 +744,7 @@ async function handleRestore(paths, options) {
|
|
|
719
744
|
}
|
|
720
745
|
|
|
721
746
|
const localDest = path.join(root, entry.localPath || entry.path);
|
|
722
|
-
|
|
747
|
+
const spinner = createSpinner(`Restoring ${targetPath}...`);
|
|
723
748
|
|
|
724
749
|
try {
|
|
725
750
|
const meta = await repo.drive.files.get({
|
|
@@ -729,16 +754,16 @@ async function handleRestore(paths, options) {
|
|
|
729
754
|
|
|
730
755
|
const { downloadFile } = await import("./core/drive-api.js");
|
|
731
756
|
await downloadFile(repo.drive, { ...meta.data, id: entry.id }, localDest);
|
|
732
|
-
|
|
757
|
+
spinner.succeed(`Restored: ${targetPath}`);
|
|
733
758
|
} catch (err) {
|
|
734
|
-
|
|
759
|
+
spinner.fail(`Failed to restore ${targetPath}: ${err.message}`);
|
|
735
760
|
}
|
|
736
761
|
}
|
|
737
762
|
}
|
|
738
763
|
|
|
739
764
|
async function handleRm(paths, options) {
|
|
740
765
|
const repo = await openRepo(options);
|
|
741
|
-
const { diff } = await repo
|
|
766
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
742
767
|
const root = repo.root;
|
|
743
768
|
|
|
744
769
|
for (const targetPath of paths) {
|
|
@@ -784,7 +809,7 @@ async function handleMv(source, dest, options) {
|
|
|
784
809
|
}
|
|
785
810
|
|
|
786
811
|
async function handleTui(options) {
|
|
787
|
-
const repo = await openRepo(options, { requireWorkspace: false });
|
|
812
|
+
const repo = await openRepo(options, { requireWorkspace: false, silent: true });
|
|
788
813
|
const cliArgs = [];
|
|
789
814
|
if (options.credentials) {
|
|
790
815
|
cliArgs.push("--credentials", options.credentials);
|
|
@@ -819,6 +844,7 @@ async function handleDedupeFolders(options) {
|
|
|
819
844
|
const config = repo.getConfig();
|
|
820
845
|
const rootFolderId = config.drive_folder_id || null;
|
|
821
846
|
const ignoreRules = loadIgnoreRules(repo.root);
|
|
847
|
+
const dedupeSpinner = createSpinner("Scanning for duplicate folders...");
|
|
822
848
|
const result = await dedupeDuplicateFolders(repo.drive, rootFolderId, {
|
|
823
849
|
execute: Boolean(options.execute),
|
|
824
850
|
ignoreRules,
|
|
@@ -848,8 +874,9 @@ async function handleDedupeFolders(options) {
|
|
|
848
874
|
},
|
|
849
875
|
});
|
|
850
876
|
|
|
877
|
+
dedupeSpinner.succeed(`Scan complete — ${result.duplicateFolders.length} duplicate group(s) found`);
|
|
878
|
+
|
|
851
879
|
if (result.duplicateFolders.length === 0) {
|
|
852
|
-
console.log("No duplicate folders detected.");
|
|
853
880
|
return;
|
|
854
881
|
}
|
|
855
882
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight terminal progress indicators (spinner + bar).
|
|
3
|
+
* Writes to stderr so stdout stays clean for piped output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
|
+
const SPINNER_INTERVAL = 80;
|
|
8
|
+
const BAR_WIDTH = 25;
|
|
9
|
+
|
|
10
|
+
const isTTY = process.stderr.isTTY;
|
|
11
|
+
|
|
12
|
+
function clearLine() {
|
|
13
|
+
if (isTTY) process.stderr.write("\r\x1b[K");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createSpinner(message) {
|
|
17
|
+
if (!isTTY) {
|
|
18
|
+
process.stderr.write(`${message}\n`);
|
|
19
|
+
return {
|
|
20
|
+
update() {},
|
|
21
|
+
succeed(msg) { if (msg) process.stderr.write(`${msg}\n`); },
|
|
22
|
+
fail(msg) { if (msg) process.stderr.write(`${msg}\n`); },
|
|
23
|
+
stop() {},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let frame = 0;
|
|
28
|
+
let currentMessage = message;
|
|
29
|
+
|
|
30
|
+
const timer = setInterval(() => {
|
|
31
|
+
clearLine();
|
|
32
|
+
process.stderr.write(`${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${currentMessage}`);
|
|
33
|
+
frame++;
|
|
34
|
+
}, SPINNER_INTERVAL);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
update(msg) { currentMessage = msg; },
|
|
38
|
+
succeed(msg) {
|
|
39
|
+
clearInterval(timer);
|
|
40
|
+
clearLine();
|
|
41
|
+
process.stderr.write(`✔ ${msg || currentMessage}\n`);
|
|
42
|
+
},
|
|
43
|
+
fail(msg) {
|
|
44
|
+
clearInterval(timer);
|
|
45
|
+
clearLine();
|
|
46
|
+
process.stderr.write(`✖ ${msg || currentMessage}\n`);
|
|
47
|
+
},
|
|
48
|
+
stop() {
|
|
49
|
+
clearInterval(timer);
|
|
50
|
+
clearLine();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createProgressBar(label, total) {
|
|
56
|
+
let lastRendered = -1;
|
|
57
|
+
|
|
58
|
+
function render(current) {
|
|
59
|
+
if (current === lastRendered) return;
|
|
60
|
+
lastRendered = current;
|
|
61
|
+
|
|
62
|
+
const ratio = total > 0 ? Math.min(current / total, 1) : 0;
|
|
63
|
+
const filled = Math.round(BAR_WIDTH * ratio);
|
|
64
|
+
const empty = BAR_WIDTH - filled;
|
|
65
|
+
const pct = Math.round(ratio * 100);
|
|
66
|
+
const bar = "█".repeat(filled) + "░".repeat(empty);
|
|
67
|
+
const line = `${label} [${bar}] ${current}/${total} (${pct}%)`;
|
|
68
|
+
|
|
69
|
+
if (isTTY) {
|
|
70
|
+
clearLine();
|
|
71
|
+
process.stderr.write(line);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Initial render
|
|
76
|
+
render(0);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
update(current) { render(current); },
|
|
80
|
+
done(msg) {
|
|
81
|
+
render(total);
|
|
82
|
+
if (isTTY) {
|
|
83
|
+
clearLine();
|
|
84
|
+
process.stderr.write(`✔ ${msg || label}\n`);
|
|
85
|
+
} else {
|
|
86
|
+
process.stderr.write(`${msg || label}\n`);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|