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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.5 (2026-04-05)
4
+
5
+ - Add progress bars and spinners for all time-consuming CLI operations
6
+
3
7
  ## 0.3.4 (2026-04-05)
4
8
 
5
9
  - Add empty folder sync support between local and Google Drive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aethel",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
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
@@ -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
- await repo.connect();
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, total, verb, name) => {
155
- console.log(`[${done}/${total}] ${verb}: ${name}`);
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
- console.log("Fetching root-level Drive folders...");
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.loadState();
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.loadState();
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.loadState();
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
- console.log(`Committing ${staged.length} change(s)...`);
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
- console.log(`\nCommit complete: ${result.summary}`);
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
- console.log("Saving snapshot...");
411
+ const spinner = createSpinner("Saving snapshot...");
387
412
  await repo.saveSnapshot(message);
388
- console.log(`Snapshot saved: "${message}"`);
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
- console.log("Fetching remote file list...");
437
+ const spinner = createSpinner("Fetching remote file list...");
413
438
  const remoteState = await repo.getRemoteState({ useCache: false });
414
439
  const remote = remoteState.files;
415
- console.log(`Found ${remote.length} file(s) on Drive.`);
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.loadState({ useCache: false });
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.loadState({ useCache: false });
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.loadState();
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
- console.log(` Restoring ${targetPath} from Drive...`);
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
- console.log(` Restored: ${targetPath}`);
757
+ spinner.succeed(`Restored: ${targetPath}`);
733
758
  } catch (err) {
734
- console.log(` Failed to restore ${targetPath}: ${err.message}`);
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.loadState();
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
+ }