aethel 0.2.6 → 0.3.1

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/src/core/auth.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import fsSyncFallback from "node:fs";
3
3
  import http from "node:http";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
6
  import { URL } from "node:url";
7
7
  import { google } from "googleapis";
8
8
  import open from "open";
@@ -10,8 +10,7 @@ import open from "open";
10
10
  const SCOPES = ["https://www.googleapis.com/auth/drive"];
11
11
  const DEFAULT_CREDENTIALS_PATH = "credentials.json";
12
12
  const DEFAULT_TOKEN_PATH = "token.json";
13
- const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
14
- const PROJECT_ROOT = path.resolve(MODULE_DIR, "..", "..");
13
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "aethel");
15
14
  const AUTH_TIMEOUT_MS = 120_000;
16
15
 
17
16
  // ── Path resolution ─────────────────────────────────────────────────
@@ -22,7 +21,12 @@ function resolvePath(candidatePath, fallbackFileName) {
22
21
  ? candidatePath
23
22
  : path.resolve(process.cwd(), candidatePath);
24
23
  }
25
- return path.join(PROJECT_ROOT, fallbackFileName);
24
+ // Also check cwd before falling back to ~/.config/aethel/
25
+ const cwdPath = path.resolve(process.cwd(), fallbackFileName);
26
+ if (fsSyncFallback.existsSync(cwdPath)) {
27
+ return cwdPath;
28
+ }
29
+ return path.join(CONFIG_DIR, fallbackFileName);
26
30
  }
27
31
 
28
32
  export function resolveCredentialsPath(customPath) {
@@ -47,7 +51,15 @@ async function loadClientConfig(credentialsPath) {
47
51
  raw = await fs.readFile(credentialsPath, "utf8");
48
52
  } catch {
49
53
  throw new Error(
50
- `OAuth credentials file was not found. Expected path: ${credentialsPath}`
54
+ `OAuth credentials file not found: ${credentialsPath}\n\n` +
55
+ "Setup steps:\n" +
56
+ " 1. Go to https://console.cloud.google.com/\n" +
57
+ " 2. Create a project and enable the Google Drive API\n" +
58
+ " 3. Create an OAuth 2.0 Client ID (Desktop application)\n" +
59
+ " 4. Download the credentials JSON and save it as:\n" +
60
+ ` ${path.join(CONFIG_DIR, DEFAULT_CREDENTIALS_PATH)}\n\n` +
61
+ "Or pass a custom path:\n" +
62
+ " aethel auth --credentials /path/to/credentials.json"
51
63
  );
52
64
  }
53
65
 
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,
@@ -255,11 +255,59 @@ async function fetchAllItems(drive, { fields, includeSharedDrives = false } = {}
255
255
  return { folders, files };
256
256
  }
257
257
 
258
+ /**
259
+ * Build a cached orphan checker. A folder is "orphaned" if its parent
260
+ * chain leads to a trashed/inaccessible folder rather than "root".
261
+ *
262
+ * When a folder is trashed, Google Drive does NOT mark its children as
263
+ * trashed. They remain `trashed = false` but their parent is gone from
264
+ * our fetched folder map, making them "orphaned".
265
+ */
266
+ function createOrphanChecker(folders) {
267
+ const cache = new Map();
268
+
269
+ return function isOrphaned(folderId) {
270
+ if (cache.has(folderId)) return cache.get(folderId);
271
+
272
+ // Walk the chain, collecting unresolved IDs
273
+ const pending = [];
274
+ let current = folderId;
275
+
276
+ while (current) {
277
+ if (current === "root") {
278
+ for (const id of pending) cache.set(id, false);
279
+ return false;
280
+ }
281
+ if (cache.has(current)) {
282
+ const result = cache.get(current);
283
+ for (const id of pending) cache.set(id, result);
284
+ return result;
285
+ }
286
+ pending.push(current);
287
+ const folder = folders.get(current);
288
+ if (!folder) {
289
+ for (const id of pending) cache.set(id, true);
290
+ return true;
291
+ }
292
+ current = folder.parents?.[0] || "";
293
+ }
294
+
295
+ // No parent — root-level
296
+ for (const id of pending) cache.set(id, false);
297
+ return false;
298
+ };
299
+ }
300
+
258
301
  function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
259
302
  const resolve = createFolderResolver(folders, rootFolderId);
303
+ const isOrphaned = createOrphanChecker(folders);
260
304
  const files = [];
261
305
  for (const file of rawFiles) {
262
306
  const parentId = file.parents?.[0] || "";
307
+
308
+ // Skip files whose parent folder was trashed (orphaned children)
309
+ if (parentId && isOrphaned(parentId)) continue;
310
+
263
311
  const parentPath = parentId === rootFolderId ? "" : resolve(parentId);
264
312
 
265
313
  if (rootFolderId && parentPath === null) continue;
@@ -646,26 +694,21 @@ export async function listAccessibleFiles(drive, includeSharedDrives = false) {
646
694
  includeSharedDrives,
647
695
  });
648
696
 
649
- // Build resolver from the folders already collected
650
- const pathCache = new Map();
651
- function resolveFolderPath(folderId) {
652
- if (pathCache.has(folderId)) return pathCache.get(folderId);
653
- if (!folderId) { pathCache.set(folderId, ""); return ""; }
654
- const folder = folders.get(folderId);
655
- if (!folder) { pathCache.set(folderId, ""); return ""; }
656
- const parentPath = resolveFolderPath(folder.parents?.[0] || "");
657
- const result = parentPath ? path.posix.join(parentPath, folder.name) : folder.name;
658
- pathCache.set(folderId, result);
659
- return result;
660
- }
697
+ const resolveFolderPath = createFolderResolver(folders, null);
698
+ const isOrphaned = createOrphanChecker(folders);
661
699
 
662
700
  // Combine folders + files into result list (TUI needs folders too)
701
+ // Filter out orphaned items (children of trashed folders)
663
702
  const allItems = [...folders.values(), ...rawItems];
664
703
  const result = [];
665
704
 
666
705
  for (const file of allItems) {
667
706
  const parentId = file.parents?.[0] || "";
668
- const parentPath = resolveFolderPath(parentId);
707
+
708
+ // Skip orphaned items (children of trashed folders)
709
+ if (parentId && isOrphaned(parentId)) continue;
710
+
711
+ const parentPath = resolveFolderPath(parentId) || "";
669
712
  const itemPath = parentPath
670
713
  ? path.posix.join(parentPath, file.name)
671
714
  : file.name;
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Repository — unified data-access layer for Aethel workspaces.
3
+ *
4
+ * Wraps all core modules behind a single interface so that both
5
+ * the CLI and the TUI share the same entry point for state loading,
6
+ * staging, syncing, file browsing, and history.
7
+ */
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { authenticate } from "./auth.js";
12
+ import {
13
+ AETHEL_DIR,
14
+ HISTORY_DIR,
15
+ LATEST_SNAPSHOT,
16
+ SNAPSHOTS_DIR,
17
+ readConfig,
18
+ readLatestSnapshot,
19
+ writeConfig,
20
+ writeSnapshot,
21
+ } from "./config.js";
22
+ import { computeDiff } from "./diff.js";
23
+ import {
24
+ assertNoDuplicateFolders,
25
+ batchOperateFiles,
26
+ getAccountInfo,
27
+ getRemoteState,
28
+ listAccessibleFiles,
29
+ syncLocalDirectoryToParent,
30
+ uploadLocalEntry,
31
+ withDriveRetry,
32
+ } from "./drive-api.js";
33
+ import {
34
+ invalidateRemoteCache,
35
+ readRemoteCache,
36
+ writeRemoteCache,
37
+ } from "./remote-cache.js";
38
+ import { buildSnapshot, scanLocal } from "./snapshot.js";
39
+ import {
40
+ stageChange,
41
+ stageChanges,
42
+ stageConflictResolution,
43
+ stagedEntries,
44
+ unstageAll,
45
+ unstagePath,
46
+ } from "./staging.js";
47
+ import { executeStaged } from "./sync.js";
48
+ import {
49
+ deleteLocalEntry,
50
+ listLocalEntries,
51
+ renameLocalEntry,
52
+ } from "./local-fs.js";
53
+
54
+ export class Repository {
55
+ /**
56
+ * @param {string|null} root Workspace root (null for workspace-less commands like auth/clean)
57
+ * @param {object} [options]
58
+ * @param {object} [options.drive] Pre-authenticated drive instance (skips auth)
59
+ * @param {string} [options.credentials] Path to OAuth credentials JSON
60
+ * @param {string} [options.token] Path to OAuth token JSON
61
+ */
62
+ constructor(root, options = {}) {
63
+ this._root = root;
64
+ this._options = options;
65
+ this._drive = options.drive || null;
66
+ this._config = null;
67
+ }
68
+
69
+ get root() {
70
+ return this._root;
71
+ }
72
+
73
+ get isConnected() {
74
+ return this._drive !== null;
75
+ }
76
+
77
+ get drive() {
78
+ if (!this._drive) {
79
+ throw new Error("Repository is not connected. Call connect() first.");
80
+ }
81
+ return this._drive;
82
+ }
83
+
84
+ /** Authenticate and prepare the drive connection. Idempotent. */
85
+ async connect() {
86
+ if (this._drive) return;
87
+ const raw = await authenticate(this._options.credentials, this._options.token);
88
+ this._drive = withDriveRetry(raw);
89
+ }
90
+
91
+ // ── Config ──────────────────────────────────────────────────────────
92
+
93
+ getConfig() {
94
+ if (!this._config) {
95
+ this._config = readConfig(this._root);
96
+ }
97
+ return this._config;
98
+ }
99
+
100
+ setConfig(data) {
101
+ writeConfig(this._root, data);
102
+ this._config = null;
103
+ }
104
+
105
+ // ── State loading (sync workflow) ───────────────────────────────────
106
+
107
+ /**
108
+ * Load full workspace state in parallel, replacing the old
109
+ * loadWorkspaceState() helper from cli.js.
110
+ */
111
+ async loadState({ useCache = true } = {}) {
112
+ const config = this.getConfig();
113
+
114
+ const [local, snapshot] = await Promise.all([
115
+ scanLocal(this._root),
116
+ Promise.resolve(readLatestSnapshot(this._root)),
117
+ ]);
118
+
119
+ const remoteState = await this._loadRemoteState({ useCache });
120
+ const remote = remoteState.files;
121
+
122
+ return {
123
+ config,
124
+ remote,
125
+ local,
126
+ snapshot,
127
+ diff: computeDiff(snapshot, remote, local, { root: this._root }),
128
+ };
129
+ }
130
+
131
+ async getRemoteState({ useCache = true } = {}) {
132
+ return this._loadRemoteState({ useCache });
133
+ }
134
+
135
+ async scanLocal() {
136
+ return scanLocal(this._root);
137
+ }
138
+
139
+ getSnapshot() {
140
+ return readLatestSnapshot(this._root);
141
+ }
142
+
143
+ computeDiff(snapshot, remote, local) {
144
+ return computeDiff(snapshot, remote, local, { root: this._root });
145
+ }
146
+
147
+ // ── Staging ─────────────────────────────────────────────────────────
148
+
149
+ getStagedEntries() {
150
+ return stagedEntries(this._root);
151
+ }
152
+
153
+ stageChange(change) {
154
+ return stageChange(this._root, change);
155
+ }
156
+
157
+ stageChanges(changes) {
158
+ return stageChanges(this._root, changes);
159
+ }
160
+
161
+ unstagePath(targetPath) {
162
+ return unstagePath(this._root, targetPath);
163
+ }
164
+
165
+ unstageAll() {
166
+ return unstageAll(this._root);
167
+ }
168
+
169
+ stageConflictResolution(change, strategy) {
170
+ return stageConflictResolution(this._root, change, strategy);
171
+ }
172
+
173
+ // ── Sync execution ──────────────────────────────────────────────────
174
+
175
+ async executeStaged(progress) {
176
+ return executeStaged(this.drive, this._root, progress);
177
+ }
178
+
179
+ /**
180
+ * Invalidate cache, re-fetch remote + re-scan local, write snapshot.
181
+ */
182
+ async saveSnapshot(message = "sync") {
183
+ const config = this.getConfig();
184
+ const rootFolderId = config.drive_folder_id || null;
185
+
186
+ invalidateRemoteCache(this._root);
187
+ const [remoteState, local] = await Promise.all([
188
+ getRemoteState(this.drive, rootFolderId),
189
+ scanLocal(this._root),
190
+ ]);
191
+ assertNoDuplicateFolders(remoteState.duplicateFolders);
192
+ writeRemoteCache(this._root, remoteState, rootFolderId);
193
+ writeSnapshot(this._root, buildSnapshot(remoteState.files, local, message));
194
+ }
195
+
196
+ // ── Cache management ────────────────────────────────────────────────
197
+
198
+ invalidateRemoteCache() {
199
+ invalidateRemoteCache(this._root);
200
+ }
201
+
202
+ // ── File browser (TUI) ─────────────────────────────────────────────
203
+
204
+ async listRemoteFiles({ includeSharedDrives = false } = {}) {
205
+ return listAccessibleFiles(this.drive, includeSharedDrives);
206
+ }
207
+
208
+ async listLocalEntries(targetPath) {
209
+ return listLocalEntries(targetPath);
210
+ }
211
+
212
+ async deleteLocalEntry(targetPath) {
213
+ return deleteLocalEntry(targetPath);
214
+ }
215
+
216
+ async renameLocalEntry(targetPath, newName) {
217
+ return renameLocalEntry(targetPath, newName);
218
+ }
219
+
220
+ async uploadLocalEntry(localPath, parentId, onProgress) {
221
+ return uploadLocalEntry(this.drive, localPath, parentId, onProgress);
222
+ }
223
+
224
+ async syncLocalDirectory(localPath, parentId, onProgress) {
225
+ return syncLocalDirectoryToParent(this.drive, localPath, parentId, onProgress);
226
+ }
227
+
228
+ async batchOperateFiles(files, options) {
229
+ return batchOperateFiles(this.drive, files, options);
230
+ }
231
+
232
+ async getAccountInfo() {
233
+ return getAccountInfo(this.drive);
234
+ }
235
+
236
+ // ── History ─────────────────────────────────────────────────────────
237
+
238
+ getHistory(limit = 10) {
239
+ const snapshotsPath = path.join(this._root, AETHEL_DIR, SNAPSHOTS_DIR);
240
+ const entries = [];
241
+
242
+ const latestPath = path.join(snapshotsPath, LATEST_SNAPSHOT);
243
+ if (fs.existsSync(latestPath)) {
244
+ entries.push(JSON.parse(fs.readFileSync(latestPath, "utf8")));
245
+ }
246
+
247
+ const historyPath = path.join(snapshotsPath, HISTORY_DIR);
248
+ if (fs.existsSync(historyPath)) {
249
+ const historyFiles = fs
250
+ .readdirSync(historyPath)
251
+ .filter((f) => f.endsWith(".json"))
252
+ .sort()
253
+ .reverse();
254
+
255
+ for (const fileName of historyFiles) {
256
+ entries.push(
257
+ JSON.parse(fs.readFileSync(path.join(historyPath, fileName), "utf8"))
258
+ );
259
+ }
260
+ }
261
+
262
+ return entries.slice(0, limit);
263
+ }
264
+
265
+ getSnapshotByRef(ref) {
266
+ if (!ref || ref === "HEAD" || ref === "latest") {
267
+ return readLatestSnapshot(this._root);
268
+ }
269
+
270
+ const historyPath = path.join(
271
+ this._root,
272
+ AETHEL_DIR,
273
+ SNAPSHOTS_DIR,
274
+ HISTORY_DIR
275
+ );
276
+
277
+ if (!fs.existsSync(historyPath)) return null;
278
+
279
+ const files = fs
280
+ .readdirSync(historyPath)
281
+ .filter((f) => f.endsWith(".json"))
282
+ .sort()
283
+ .reverse();
284
+
285
+ const match = files.find((f) => f.startsWith(ref));
286
+ if (!match) return null;
287
+
288
+ return JSON.parse(fs.readFileSync(path.join(historyPath, match), "utf-8"));
289
+ }
290
+
291
+ // ── Private helpers ─────────────────────────────────────────────────
292
+
293
+ async _loadRemoteState({ useCache = true } = {}) {
294
+ const config = this.getConfig();
295
+ const rootFolderId = config.drive_folder_id || null;
296
+
297
+ let remoteState = useCache
298
+ ? readRemoteCache(this._root, rootFolderId)
299
+ : null;
300
+
301
+ if (!remoteState) {
302
+ remoteState = await getRemoteState(this.drive, rootFolderId);
303
+ writeRemoteCache(this._root, remoteState, rootFolderId);
304
+ }
305
+
306
+ assertNoDuplicateFolders(remoteState.duplicateFolders);
307
+ return remoteState;
308
+ }
309
+ }
@@ -61,6 +61,9 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
61
61
  return;
62
62
  }
63
63
 
64
+ const subdirs = [];
65
+ const statPromises = [];
66
+
64
67
  for (const entry of entries) {
65
68
  const fullPath = path.join(currentPath, entry.name);
66
69
  const relativePath = path
@@ -73,7 +76,7 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
73
76
  }
74
77
 
75
78
  if (entry.isDirectory()) {
76
- await walk(fullPath);
79
+ subdirs.push(fullPath);
77
80
  continue;
78
81
  }
79
82
 
@@ -81,9 +84,17 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
81
84
  continue;
82
85
  }
83
86
 
84
- const stat = await fs.promises.stat(fullPath);
85
- filesToHash.push({ fullPath, relativePath, stat });
87
+ statPromises.push(
88
+ fs.promises.stat(fullPath).then((stat) => {
89
+ filesToHash.push({ fullPath, relativePath, stat });
90
+ })
91
+ );
86
92
  }
93
+
94
+ await Promise.all([
95
+ ...statPromises,
96
+ ...subdirs.map((dir) => walk(dir)),
97
+ ]);
87
98
  }
88
99
 
89
100
  await walk(resolvedRoot);