aethel 0.2.5 → 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 +8 -0
- package/README.md +86 -102
- package/package.json +1 -1
- package/src/cli.js +92 -181
- package/src/core/diff.js +7 -9
- package/src/core/drive-api.js +56 -13
- package/src/core/repository.js +309 -0
- package/src/core/snapshot.js +14 -3
- package/src/tui/app.js +530 -70
- package/src/tui/command-catalog.js +140 -0
- package/src/tui/commands.js +74 -0
- package/src/tui/index.js +12 -2
|
@@ -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
|
+
}
|
package/src/core/snapshot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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);
|