aethel 0.1.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/.env.example +2 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/docs/ARCHITECTURE.md +237 -0
- package/package.json +60 -0
- package/src/cli.js +1063 -0
- package/src/core/auth.js +288 -0
- package/src/core/config.js +117 -0
- package/src/core/diff.js +254 -0
- package/src/core/drive-api.js +1442 -0
- package/src/core/ignore.js +146 -0
- package/src/core/local-fs.js +109 -0
- package/src/core/remote-cache.js +65 -0
- package/src/core/snapshot.js +159 -0
- package/src/core/staging.js +125 -0
- package/src/core/sync.js +227 -0
- package/src/tui/app.js +1025 -0
- package/src/tui/index.js +10 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { authenticate, resolveCredentialsPath, resolveTokenPath } from "./core/auth.js";
|
|
7
|
+
import {
|
|
8
|
+
AETHEL_DIR,
|
|
9
|
+
HISTORY_DIR,
|
|
10
|
+
LATEST_SNAPSHOT,
|
|
11
|
+
SNAPSHOTS_DIR,
|
|
12
|
+
initWorkspace,
|
|
13
|
+
readConfig,
|
|
14
|
+
readLatestSnapshot,
|
|
15
|
+
requireRoot,
|
|
16
|
+
writeSnapshot,
|
|
17
|
+
} from "./core/config.js";
|
|
18
|
+
import { ChangeType, computeDiff } from "./core/diff.js";
|
|
19
|
+
import {
|
|
20
|
+
assertNoDuplicateFolders,
|
|
21
|
+
batchOperateFiles,
|
|
22
|
+
dedupeDuplicateFolders,
|
|
23
|
+
getRemoteState,
|
|
24
|
+
getAccountInfo,
|
|
25
|
+
listAccessibleFiles,
|
|
26
|
+
DuplicateFoldersError,
|
|
27
|
+
withDriveRetry,
|
|
28
|
+
} from "./core/drive-api.js";
|
|
29
|
+
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";
|
|
34
|
+
import { runTui } from "./tui/index.js";
|
|
35
|
+
|
|
36
|
+
const REQUIRED_CONFIRMATION = "DELETE ALL MY GOOGLE DRIVE FILES";
|
|
37
|
+
|
|
38
|
+
function addAuthOptions(command) {
|
|
39
|
+
return command
|
|
40
|
+
.option("--credentials <path>", "Path to OAuth client credentials JSON")
|
|
41
|
+
.option("--token <path>", "Path to cached OAuth token JSON");
|
|
42
|
+
}
|
|
43
|
+
|
|
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;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function matchesPattern(targetPath, pattern) {
|
|
63
|
+
if (targetPath === pattern) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const expression = pattern
|
|
68
|
+
.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")
|
|
69
|
+
.replace(/\*/g, ".*");
|
|
70
|
+
|
|
71
|
+
return new RegExp(`^${expression}$`).test(targetPath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printChangeDetail(change) {
|
|
75
|
+
console.log(` ${change.shortStatus} ${change.path}`);
|
|
76
|
+
console.log(` ${change.description}`);
|
|
77
|
+
|
|
78
|
+
if (change.remoteMeta) {
|
|
79
|
+
const remoteMd5 = change.remoteMeta.md5Checksum || "?";
|
|
80
|
+
console.log(
|
|
81
|
+
` remote: md5=${String(remoteMd5).slice(0, 8)} modified=${
|
|
82
|
+
change.remoteMeta.modifiedTime || "?"
|
|
83
|
+
}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (change.localMeta) {
|
|
88
|
+
const localMd5 = change.localMeta.md5 || "?";
|
|
89
|
+
console.log(
|
|
90
|
+
` local: md5=${String(localMd5).slice(0, 8)} modified=${
|
|
91
|
+
change.localMeta.modifiedTime || "?"
|
|
92
|
+
}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (change.snapshotMeta) {
|
|
97
|
+
const snapshotMd5 =
|
|
98
|
+
change.snapshotMeta.md5Checksum || change.snapshotMeta.md5 || "?";
|
|
99
|
+
console.log(` snap: md5=${String(snapshotMd5).slice(0, 8)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function printCleanerPlan(files, { permanent, execute }) {
|
|
104
|
+
const action = permanent ? "permanently delete" : "move to trash";
|
|
105
|
+
const mode = execute ? "EXECUTION" : "DRY RUN";
|
|
106
|
+
|
|
107
|
+
console.log(`${mode}: the script will ${action} ${files.length} file(s).`);
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
console.log(`- ${file.name} | id=${file.id} | mimeType=${file.mimeType}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function requireConfirmation(options) {
|
|
114
|
+
if (!options.execute) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (options.confirm !== REQUIRED_CONFIRMATION) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`The confirmation phrase is incorrect. Pass --confirm "${REQUIRED_CONFIRMATION}" to execute.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
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
|
+
async function handleAuth(options) {
|
|
150
|
+
const drive = await getDrive(options);
|
|
151
|
+
const account = await getAccountInfo(drive);
|
|
152
|
+
|
|
153
|
+
console.log("OAuth initialization completed.");
|
|
154
|
+
console.log(`Credentials path: ${resolveCredentialsPath(options.credentials)}`);
|
|
155
|
+
console.log(`Token path: ${resolveTokenPath(options.token)}`);
|
|
156
|
+
console.log(`Authenticated user: ${account.name}`);
|
|
157
|
+
console.log(`Authenticated email: ${account.email}`);
|
|
158
|
+
console.log(`Storage usage: ${account.usage}`);
|
|
159
|
+
console.log(`Storage limit: ${account.limit}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function handleClean(options) {
|
|
163
|
+
requireConfirmation(options);
|
|
164
|
+
const drive = await getDrive(options);
|
|
165
|
+
const files = await listAccessibleFiles(drive, Boolean(options.sharedDrives));
|
|
166
|
+
|
|
167
|
+
printCleanerPlan(files, options);
|
|
168
|
+
|
|
169
|
+
if (files.length === 0) {
|
|
170
|
+
console.log("No non-trashed files were found.");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!options.execute) {
|
|
175
|
+
console.log("Dry run completed. Re-run with --execute to perform the operation.");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = await batchOperateFiles(drive, files, {
|
|
180
|
+
permanent: Boolean(options.permanent),
|
|
181
|
+
includeSharedDrives: Boolean(options.sharedDrives),
|
|
182
|
+
onProgress: (done, total, verb, name) => {
|
|
183
|
+
console.log(`[${done}/${total}] ${verb}: ${name}`);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (result.errors) {
|
|
188
|
+
console.log(`Completed with ${result.errors} error(s) out of ${files.length} file(s).`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log("Operation completed.");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function handleInit(options) {
|
|
195
|
+
const localPath = path.resolve(options.localPath);
|
|
196
|
+
|
|
197
|
+
if (!fs.existsSync(localPath)) {
|
|
198
|
+
await fs.promises.mkdir(localPath, { recursive: true });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const root = initWorkspace(
|
|
202
|
+
localPath,
|
|
203
|
+
options.driveFolder || null,
|
|
204
|
+
options.driveFolderName || "My Drive"
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const created = createDefaultIgnoreFile(root);
|
|
208
|
+
console.log(`Initialised Aethel workspace at ${root}`);
|
|
209
|
+
if (created) {
|
|
210
|
+
console.log(" Created .aethelignore with default patterns");
|
|
211
|
+
}
|
|
212
|
+
if (options.driveFolder) {
|
|
213
|
+
console.log(
|
|
214
|
+
` Drive folder: ${options.driveFolderName || "My Drive"} (${options.driveFolder})`
|
|
215
|
+
);
|
|
216
|
+
} else {
|
|
217
|
+
console.log(" Syncing entire My Drive");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function handleStatus(options) {
|
|
222
|
+
const root = requireRoot();
|
|
223
|
+
const { diff } = await loadWorkspaceState(root, options);
|
|
224
|
+
const staged = stagedEntries(root);
|
|
225
|
+
|
|
226
|
+
if (diff.isClean && staged.length === 0) {
|
|
227
|
+
console.log("Everything up to date.");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (staged.length) {
|
|
232
|
+
console.log(`\nStaged changes (${staged.length}):`);
|
|
233
|
+
for (const entry of staged) {
|
|
234
|
+
console.log(` ${entry.action.padStart(15, " ")} ${entry.path}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (diff.remoteChanges.length) {
|
|
239
|
+
console.log(`\nRemote changes (${diff.remoteChanges.length}):`);
|
|
240
|
+
for (const change of diff.remoteChanges) {
|
|
241
|
+
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (diff.localChanges.length) {
|
|
246
|
+
console.log(`\nLocal changes (${diff.localChanges.length}):`);
|
|
247
|
+
for (const change of diff.localChanges) {
|
|
248
|
+
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (diff.conflicts.length) {
|
|
253
|
+
console.log(`\nConflicts (${diff.conflicts.length}):`);
|
|
254
|
+
for (const change of diff.conflicts) {
|
|
255
|
+
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function handleDiff(options) {
|
|
261
|
+
const root = requireRoot();
|
|
262
|
+
const { diff } = await loadWorkspaceState(root, options);
|
|
263
|
+
|
|
264
|
+
if (diff.isClean) {
|
|
265
|
+
console.log("No changes detected.");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const showRemote = options.side === "all" || options.side === "remote";
|
|
270
|
+
const showLocal = options.side === "all" || options.side === "local";
|
|
271
|
+
|
|
272
|
+
if (showRemote && diff.remoteChanges.length) {
|
|
273
|
+
console.log("Remote changes:");
|
|
274
|
+
for (const change of diff.remoteChanges) {
|
|
275
|
+
printChangeDetail(change);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (showLocal && diff.localChanges.length) {
|
|
280
|
+
console.log("Local changes:");
|
|
281
|
+
for (const change of diff.localChanges) {
|
|
282
|
+
printChangeDetail(change);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (diff.conflicts.length) {
|
|
287
|
+
console.log("Conflicts:");
|
|
288
|
+
for (const change of diff.conflicts) {
|
|
289
|
+
printChangeDetail(change);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function handleAdd(paths, options) {
|
|
295
|
+
const root = requireRoot();
|
|
296
|
+
const { diff } = await loadWorkspaceState(root, options);
|
|
297
|
+
|
|
298
|
+
if (options.all) {
|
|
299
|
+
const toStage = diff.changes.filter(
|
|
300
|
+
(change) => change.suggestedAction !== "conflict"
|
|
301
|
+
);
|
|
302
|
+
const count = stageChanges(root, toStage);
|
|
303
|
+
console.log(`Staged ${count} change(s).`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const changesByPath = new Map(diff.changes.map((change) => [change.path, change]));
|
|
308
|
+
let stagedCount = 0;
|
|
309
|
+
|
|
310
|
+
for (const pattern of paths || []) {
|
|
311
|
+
const matched = [...changesByPath.entries()]
|
|
312
|
+
.filter(([changePath]) => matchesPattern(changePath, pattern))
|
|
313
|
+
.map(([, change]) => change);
|
|
314
|
+
|
|
315
|
+
if (matched.length === 0) {
|
|
316
|
+
console.log(` No changes match '${pattern}'`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const change of matched) {
|
|
321
|
+
if (change.suggestedAction === "conflict") {
|
|
322
|
+
console.log(` !! ${change.path} is conflicted - resolve before staging`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
stageChange(root, change);
|
|
327
|
+
stagedCount += 1;
|
|
328
|
+
console.log(` Staged: ${change.path}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
console.log(`Staged ${stagedCount} change(s).`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function handleReset(paths, options) {
|
|
336
|
+
const root = requireRoot();
|
|
337
|
+
|
|
338
|
+
if (options.all) {
|
|
339
|
+
const count = unstageAll(root);
|
|
340
|
+
console.log(`Unstaged ${count} change(s).`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (const targetPath of paths || []) {
|
|
345
|
+
if (unstagePath(root, targetPath)) {
|
|
346
|
+
console.log(` Unstaged: ${targetPath}`);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
console.log(` Not staged: ${targetPath}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function handleCommit(options) {
|
|
355
|
+
const root = requireRoot();
|
|
356
|
+
const config = readConfig(root);
|
|
357
|
+
const staged = stagedEntries(root);
|
|
358
|
+
|
|
359
|
+
if (!staged.length) {
|
|
360
|
+
console.log("Nothing staged. Use 'aethel add' first.");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const drive = await getDrive(options);
|
|
365
|
+
const message = options.message || "sync";
|
|
366
|
+
await loadRemoteState(root, drive, config, { useCache: true });
|
|
367
|
+
|
|
368
|
+
console.log(`Committing ${staged.length} change(s)...`);
|
|
369
|
+
|
|
370
|
+
const result = await executeStaged(drive, root, (done, total, verb, name) => {
|
|
371
|
+
if (done < total) {
|
|
372
|
+
console.log(` [${done + 1}/${total}] ${verb}: ${name}`);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
console.log(`\nCommit complete: ${result.summary}`);
|
|
377
|
+
if (result.errors.length) {
|
|
378
|
+
for (const error of result.errors) {
|
|
379
|
+
console.log(` ERROR: ${error}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
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));
|
|
392
|
+
console.log(`Snapshot saved: "${message}"`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function handleLog(options) {
|
|
396
|
+
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
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!entries.length) {
|
|
420
|
+
console.log("No commits yet.");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
for (const snapshot of entries.slice(0, options.limit || 10)) {
|
|
425
|
+
console.log(
|
|
426
|
+
` ${snapshot.timestamp || "?"} ${snapshot.message || "(no message)"} (${Object.keys(snapshot.files || {}).length} files)`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function handleFetch(options) {
|
|
432
|
+
const root = requireRoot();
|
|
433
|
+
const config = readConfig(root);
|
|
434
|
+
const drive = await getDrive(options);
|
|
435
|
+
const snapshot = readLatestSnapshot(root);
|
|
436
|
+
|
|
437
|
+
invalidateRemoteCache(root);
|
|
438
|
+
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);
|
|
442
|
+
const remote = remoteState.files;
|
|
443
|
+
console.log(`Found ${remote.length} file(s) on Drive.`);
|
|
444
|
+
|
|
445
|
+
// Show what changed on remote since last snapshot
|
|
446
|
+
if (snapshot) {
|
|
447
|
+
const local = await scanLocal(root);
|
|
448
|
+
const diff = computeDiff(snapshot, remote, local, { root });
|
|
449
|
+
const remoteChanges = diff.remoteChanges;
|
|
450
|
+
const conflicts = diff.conflicts;
|
|
451
|
+
|
|
452
|
+
if (remoteChanges.length === 0 && conflicts.length === 0) {
|
|
453
|
+
console.log("\nRemote is up to date with last snapshot.");
|
|
454
|
+
} else {
|
|
455
|
+
if (remoteChanges.length) {
|
|
456
|
+
console.log(`\nRemote changes since last commit (${remoteChanges.length}):`);
|
|
457
|
+
for (const c of remoteChanges) {
|
|
458
|
+
console.log(` ${c.shortStatus} ${c.path} (${c.description})`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (conflicts.length) {
|
|
462
|
+
console.log(`\nConflicts detected (${conflicts.length}):`);
|
|
463
|
+
for (const c of conflicts) {
|
|
464
|
+
console.log(` ${c.shortStatus} ${c.path} (${c.description})`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
console.log("\nUse 'aethel pull' to apply remote changes, or 'aethel resolve' for conflicts.");
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
console.log("\nNo snapshot yet. Use 'aethel pull --all' or 'aethel add --all && aethel commit' to create initial snapshot.");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function handlePull(paths, options) {
|
|
475
|
+
const root = requireRoot();
|
|
476
|
+
const { diff } = await loadWorkspaceState(root, options, { useCache: false });
|
|
477
|
+
|
|
478
|
+
let remoteChanges = diff.changes.filter((change) =>
|
|
479
|
+
[
|
|
480
|
+
ChangeType.REMOTE_ADDED,
|
|
481
|
+
ChangeType.REMOTE_MODIFIED,
|
|
482
|
+
ChangeType.REMOTE_DELETED,
|
|
483
|
+
].includes(change.changeType)
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
// Include conflicts resolved as "theirs" when --force is set
|
|
487
|
+
if (options.force) {
|
|
488
|
+
const conflicts = diff.conflicts;
|
|
489
|
+
if (conflicts.length) {
|
|
490
|
+
console.log(`Force-pulling ${conflicts.length} conflict(s) (remote wins)...`);
|
|
491
|
+
for (const c of conflicts) {
|
|
492
|
+
stageConflictResolution(root, c, "theirs");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Filter to specific paths if provided
|
|
498
|
+
if (paths && paths.length > 0) {
|
|
499
|
+
remoteChanges = remoteChanges.filter((change) =>
|
|
500
|
+
paths.some((p) => matchesPattern(change.path, p))
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!remoteChanges.length && !options.force) {
|
|
505
|
+
console.log("Already up to date.");
|
|
506
|
+
if (diff.conflicts.length) {
|
|
507
|
+
console.log(` (${diff.conflicts.length} conflict(s) exist — use --force to accept remote versions)`);
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (options.dryRun) {
|
|
513
|
+
console.log(`Would pull ${remoteChanges.length} change(s):`);
|
|
514
|
+
for (const c of remoteChanges) {
|
|
515
|
+
console.log(` ${c.shortStatus} ${c.path} (${c.description})`);
|
|
516
|
+
}
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const count = stageChanges(root, remoteChanges);
|
|
521
|
+
console.log(`Staged ${count} remote change(s). Committing...`);
|
|
522
|
+
await handleCommit({ ...options, message: options.message || "pull" });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function handlePush(paths, options) {
|
|
526
|
+
const root = requireRoot();
|
|
527
|
+
const { diff } = await loadWorkspaceState(root, options, { useCache: false });
|
|
528
|
+
|
|
529
|
+
let localChanges = diff.changes.filter((change) =>
|
|
530
|
+
[
|
|
531
|
+
ChangeType.LOCAL_ADDED,
|
|
532
|
+
ChangeType.LOCAL_MODIFIED,
|
|
533
|
+
ChangeType.LOCAL_DELETED,
|
|
534
|
+
].includes(change.changeType)
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
// Include conflicts resolved as "ours" when --force is set
|
|
538
|
+
if (options.force) {
|
|
539
|
+
const conflicts = diff.conflicts;
|
|
540
|
+
if (conflicts.length) {
|
|
541
|
+
console.log(`Force-pushing ${conflicts.length} conflict(s) (local wins)...`);
|
|
542
|
+
for (const c of conflicts) {
|
|
543
|
+
stageConflictResolution(root, c, "ours");
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Filter to specific paths if provided
|
|
549
|
+
if (paths && paths.length > 0) {
|
|
550
|
+
localChanges = localChanges.filter((change) =>
|
|
551
|
+
paths.some((p) => matchesPattern(change.path, p))
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!localChanges.length && !options.force) {
|
|
556
|
+
console.log("Nothing to push.");
|
|
557
|
+
if (diff.conflicts.length) {
|
|
558
|
+
console.log(` (${diff.conflicts.length} conflict(s) exist — use --force to push local versions)`);
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (options.dryRun) {
|
|
564
|
+
console.log(`Would push ${localChanges.length} change(s):`);
|
|
565
|
+
for (const c of localChanges) {
|
|
566
|
+
console.log(` ${c.shortStatus} ${c.path} (${c.description})`);
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const count = stageChanges(root, localChanges);
|
|
572
|
+
console.log(`Staged ${count} local change(s). Committing...`);
|
|
573
|
+
await handleCommit({ ...options, message: options.message || "push" });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function handleResolve(paths, options) {
|
|
577
|
+
const root = requireRoot();
|
|
578
|
+
const { diff } = await loadWorkspaceState(root, options);
|
|
579
|
+
const conflicts = diff.conflicts;
|
|
580
|
+
|
|
581
|
+
if (conflicts.length === 0) {
|
|
582
|
+
console.log("No conflicts to resolve.");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Determine strategy
|
|
587
|
+
const strategy = options.ours ? "ours" : options.theirs ? "theirs" : options.both ? "both" : null;
|
|
588
|
+
|
|
589
|
+
if (!strategy) {
|
|
590
|
+
console.log(`Conflicts (${conflicts.length}):`);
|
|
591
|
+
for (const c of conflicts) {
|
|
592
|
+
printChangeDetail(c);
|
|
593
|
+
}
|
|
594
|
+
console.log("\nResolve with:");
|
|
595
|
+
console.log(" aethel resolve --ours [paths...] Keep local version (upload)");
|
|
596
|
+
console.log(" aethel resolve --theirs [paths...] Keep remote version (download)");
|
|
597
|
+
console.log(" aethel resolve --both [paths...] Keep both (remote saved as .remote copy)");
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Filter to specific paths or resolve all
|
|
602
|
+
let toResolve = conflicts;
|
|
603
|
+
if (paths && paths.length > 0) {
|
|
604
|
+
toResolve = conflicts.filter((c) =>
|
|
605
|
+
paths.some((p) => matchesPattern(c.path, p))
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
if (toResolve.length === 0) {
|
|
609
|
+
console.log("No conflicts match the given path(s).");
|
|
610
|
+
console.log("Current conflicts:");
|
|
611
|
+
for (const c of conflicts) {
|
|
612
|
+
console.log(` ${c.shortStatus} ${c.path}`);
|
|
613
|
+
}
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const strategyLabel = { ours: "local wins", theirs: "remote wins", both: "keep both" };
|
|
619
|
+
|
|
620
|
+
for (const conflict of toResolve) {
|
|
621
|
+
stageConflictResolution(root, conflict, strategy);
|
|
622
|
+
console.log(` Resolved: ${conflict.path} → ${strategyLabel[strategy]}`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
console.log(`\nResolved ${toResolve.length} conflict(s) with strategy: ${strategyLabel[strategy]}`);
|
|
626
|
+
console.log("Run 'aethel commit' to apply.");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function handleIgnore(subcommand, args) {
|
|
630
|
+
const root = requireRoot();
|
|
631
|
+
const rules = loadIgnoreRules(root);
|
|
632
|
+
|
|
633
|
+
if (subcommand === "list") {
|
|
634
|
+
if (rules.userPatterns.length === 0) {
|
|
635
|
+
console.log("No user-defined ignore patterns (.aethelignore is empty or missing).");
|
|
636
|
+
} else {
|
|
637
|
+
console.log("User-defined patterns:");
|
|
638
|
+
for (const p of rules.userPatterns) {
|
|
639
|
+
console.log(` ${p}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
console.log("\nBuiltin patterns (always ignored):");
|
|
643
|
+
for (const p of [".aethel", ".git", "node_modules", ".DS_Store", "Thumbs.db"]) {
|
|
644
|
+
console.log(` ${p}`);
|
|
645
|
+
}
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (subcommand === "test") {
|
|
650
|
+
for (const testPath of args) {
|
|
651
|
+
const ignored = rules.ignores(testPath);
|
|
652
|
+
console.log(` ${ignored ? "ignored" : "tracked"} ${testPath}`);
|
|
653
|
+
}
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (subcommand === "create") {
|
|
658
|
+
const created = createDefaultIgnoreFile(root);
|
|
659
|
+
if (created) {
|
|
660
|
+
console.log("Created .aethelignore with default patterns.");
|
|
661
|
+
} else {
|
|
662
|
+
console.log(".aethelignore already exists.");
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
console.log("Usage: aethel ignore <list|test|create> [paths...]");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function handleShow(ref, options) {
|
|
671
|
+
const root = requireRoot();
|
|
672
|
+
const snapshotsPath = path.join(root, AETHEL_DIR, SNAPSHOTS_DIR);
|
|
673
|
+
|
|
674
|
+
let snapshot;
|
|
675
|
+
|
|
676
|
+
if (!ref || ref === "HEAD" || ref === "latest") {
|
|
677
|
+
snapshot = readLatestSnapshot(root);
|
|
678
|
+
if (!snapshot) {
|
|
679
|
+
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) {
|
|
692
|
+
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", "")}`);
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
snapshot = JSON.parse(fs.readFileSync(path.join(historyPath, match), "utf-8"));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
console.log(`Snapshot: ${snapshot.timestamp || "?"}`);
|
|
703
|
+
console.log(`Message: ${snapshot.message || "(no message)"}`);
|
|
704
|
+
|
|
705
|
+
const remoteFiles = Object.values(snapshot.files || {});
|
|
706
|
+
const localFiles = Object.keys(snapshot.localFiles || {});
|
|
707
|
+
|
|
708
|
+
console.log(`\nRemote files (${remoteFiles.length}):`);
|
|
709
|
+
if (options.verbose) {
|
|
710
|
+
for (const f of remoteFiles) {
|
|
711
|
+
const md5 = f.md5Checksum ? f.md5Checksum.slice(0, 8) : "--------";
|
|
712
|
+
console.log(` ${md5} ${f.path || f.name}`);
|
|
713
|
+
}
|
|
714
|
+
} else {
|
|
715
|
+
for (const f of remoteFiles.slice(0, 20)) {
|
|
716
|
+
console.log(` ${f.path || f.name}`);
|
|
717
|
+
}
|
|
718
|
+
if (remoteFiles.length > 20) {
|
|
719
|
+
console.log(` ... and ${remoteFiles.length - 20} more`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
console.log(`\nLocal files (${localFiles.length}):`);
|
|
724
|
+
if (options.verbose) {
|
|
725
|
+
for (const p of localFiles) {
|
|
726
|
+
const meta = snapshot.localFiles[p];
|
|
727
|
+
const md5 = meta.md5 ? meta.md5.slice(0, 8) : "--------";
|
|
728
|
+
console.log(` ${md5} ${p}`);
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
for (const p of localFiles.slice(0, 20)) {
|
|
732
|
+
console.log(` ${p}`);
|
|
733
|
+
}
|
|
734
|
+
if (localFiles.length > 20) {
|
|
735
|
+
console.log(` ... and ${localFiles.length - 20} more`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function handleRestore(paths, options) {
|
|
741
|
+
const root = requireRoot();
|
|
742
|
+
const config = readConfig(root);
|
|
743
|
+
const snapshot = readLatestSnapshot(root);
|
|
744
|
+
|
|
745
|
+
if (!snapshot) {
|
|
746
|
+
console.log("No snapshot to restore from. Run 'aethel commit' first.");
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const drive = await getDrive(options);
|
|
751
|
+
const remoteFiles = snapshot.files || {};
|
|
752
|
+
|
|
753
|
+
for (const targetPath of paths) {
|
|
754
|
+
// Find the file in the snapshot by path
|
|
755
|
+
const entry = Object.values(remoteFiles).find(
|
|
756
|
+
(f) => f.path === targetPath || f.localPath === targetPath
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
if (!entry) {
|
|
760
|
+
console.log(` Not found in snapshot: ${targetPath}`);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const localDest = path.join(root, entry.localPath || entry.path);
|
|
765
|
+
console.log(` Restoring ${targetPath} from Drive...`);
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
const meta = await drive.files.get({
|
|
769
|
+
fileId: entry.id,
|
|
770
|
+
fields: "id,name,mimeType",
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
const { downloadFile } = await import("./core/drive-api.js");
|
|
774
|
+
await downloadFile(drive, { ...meta.data, id: entry.id }, localDest);
|
|
775
|
+
console.log(` Restored: ${targetPath}`);
|
|
776
|
+
} catch (err) {
|
|
777
|
+
console.log(` Failed to restore ${targetPath}: ${err.message}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function handleRm(paths, options) {
|
|
783
|
+
const root = requireRoot();
|
|
784
|
+
const { diff } = await loadWorkspaceState(root, options);
|
|
785
|
+
|
|
786
|
+
for (const targetPath of paths) {
|
|
787
|
+
// Delete locally
|
|
788
|
+
const localAbs = path.join(root, targetPath);
|
|
789
|
+
if (fs.existsSync(localAbs)) {
|
|
790
|
+
await fs.promises.rm(localAbs, { recursive: true });
|
|
791
|
+
console.log(` Deleted locally: ${targetPath}`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// If it exists on remote, stage a delete_remote
|
|
795
|
+
const remoteChange = diff.changes.find(
|
|
796
|
+
(c) => c.path === targetPath && c.fileId
|
|
797
|
+
);
|
|
798
|
+
if (remoteChange) {
|
|
799
|
+
stageChange(root, {
|
|
800
|
+
...remoteChange,
|
|
801
|
+
changeType: ChangeType.LOCAL_DELETED,
|
|
802
|
+
suggestedAction: "delete_remote",
|
|
803
|
+
});
|
|
804
|
+
console.log(` Staged remote deletion: ${targetPath}`);
|
|
805
|
+
} else {
|
|
806
|
+
// After local delete, rescan will pick it up as local_deleted
|
|
807
|
+
console.log(` Removed: ${targetPath} (re-run 'aethel status' to see changes)`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function handleMv(source, dest, options) {
|
|
813
|
+
const root = requireRoot();
|
|
814
|
+
|
|
815
|
+
const srcAbs = path.join(root, source);
|
|
816
|
+
const destAbs = path.join(root, dest);
|
|
817
|
+
|
|
818
|
+
if (!fs.existsSync(srcAbs)) {
|
|
819
|
+
console.log(`Source not found: ${source}`);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Ensure destination directory exists
|
|
824
|
+
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
825
|
+
await fs.promises.rename(srcAbs, destAbs);
|
|
826
|
+
console.log(` Moved: ${source} → ${dest}`);
|
|
827
|
+
console.log(" Run 'aethel status' to see the resulting changes (old path deleted, new path added).");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function handleTui(options) {
|
|
831
|
+
const drive = await getDrive(options);
|
|
832
|
+
await runTui({
|
|
833
|
+
drive,
|
|
834
|
+
includeSharedDrives: Boolean(options.sharedDrives),
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function printDedupeSummary(result) {
|
|
839
|
+
for (const group of result.duplicateFolders) {
|
|
840
|
+
console.log(
|
|
841
|
+
`- ${group.path} | canonical=${group.canonical.id} | duplicates=${group.folders.length}`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
console.log(`Duplicate paths: ${result.duplicatePaths}`);
|
|
845
|
+
console.log(`Moved items: ${result.movedItems}`);
|
|
846
|
+
console.log(`Trashed duplicate files: ${result.trashedDuplicateFiles}`);
|
|
847
|
+
console.log(`Trashed folders: ${result.trashedFolders}`);
|
|
848
|
+
console.log(`Skipped conflicts: ${result.skippedConflicts}`);
|
|
849
|
+
console.log(`Remaining duplicate paths: ${result.remainingDuplicateFolders.length}`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async function handleDedupeFolders(options) {
|
|
853
|
+
const root = requireRoot();
|
|
854
|
+
const config = readConfig(root);
|
|
855
|
+
const drive = await getDrive(options);
|
|
856
|
+
const rootFolderId = config.drive_folder_id || null;
|
|
857
|
+
const ignoreRules = loadIgnoreRules(root);
|
|
858
|
+
const result = await dedupeDuplicateFolders(drive, rootFolderId, {
|
|
859
|
+
execute: Boolean(options.execute),
|
|
860
|
+
ignoreRules,
|
|
861
|
+
onProgress: (event) => {
|
|
862
|
+
if (!options.execute) {
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (event.type === "move") {
|
|
867
|
+
console.log(` moved ${event.itemType}: ${event.path}`);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (event.type === "trash_duplicate_file") {
|
|
872
|
+
console.log(` trashed duplicate file: ${event.path}`);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (event.type === "trash_folder") {
|
|
877
|
+
console.log(` trashed folder: ${event.path}`);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (event.type === "skip_conflict") {
|
|
882
|
+
console.log(` skipped conflict: ${event.path}`);
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
if (result.duplicateFolders.length === 0) {
|
|
888
|
+
console.log("No duplicate folders detected.");
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
console.log(options.execute ? "Execution summary:" : "Dry run summary:");
|
|
893
|
+
printDedupeSummary(result);
|
|
894
|
+
|
|
895
|
+
if (options.execute) {
|
|
896
|
+
invalidateRemoteCache(root);
|
|
897
|
+
if (result.remainingDuplicateFolders.length > 0) {
|
|
898
|
+
throw new DuplicateFoldersError(result.remainingDuplicateFolders);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function main() {
|
|
904
|
+
const program = new Command();
|
|
905
|
+
|
|
906
|
+
program
|
|
907
|
+
.name("aethel")
|
|
908
|
+
.description("Git-like Google Drive sync management and cleanup")
|
|
909
|
+
.showHelpAfterError();
|
|
910
|
+
|
|
911
|
+
addAuthOptions(
|
|
912
|
+
program
|
|
913
|
+
.command("auth")
|
|
914
|
+
.description("Run OAuth initialization and verify Google Drive access")
|
|
915
|
+
).action(handleAuth);
|
|
916
|
+
|
|
917
|
+
addAuthOptions(
|
|
918
|
+
program
|
|
919
|
+
.command("clean")
|
|
920
|
+
.description("List accessible Drive files and optionally trash or delete them")
|
|
921
|
+
.option("--shared-drives", "Include shared drives")
|
|
922
|
+
.option("--permanent", "Permanently delete files instead of moving them to trash")
|
|
923
|
+
.option("--execute", "Execute the selected operation")
|
|
924
|
+
.option("--confirm <phrase>", "Confirmation phrase required for --execute", "")
|
|
925
|
+
).action(handleClean);
|
|
926
|
+
|
|
927
|
+
program
|
|
928
|
+
.command("init")
|
|
929
|
+
.description("Initialise a sync workspace")
|
|
930
|
+
.option("--local-path <path>", "Local directory to sync", ".")
|
|
931
|
+
.option("--drive-folder <id>", "Drive folder ID to sync")
|
|
932
|
+
.option("--drive-folder-name <name>", "Display name for the Drive folder")
|
|
933
|
+
.action(handleInit);
|
|
934
|
+
|
|
935
|
+
addAuthOptions(program.command("status").description("Show sync status")).action(
|
|
936
|
+
handleStatus
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
addAuthOptions(
|
|
940
|
+
program
|
|
941
|
+
.command("diff")
|
|
942
|
+
.description("Show detailed changes")
|
|
943
|
+
.option("--side <side>", "Which side to show: remote, local, or all", "all")
|
|
944
|
+
).action(handleDiff);
|
|
945
|
+
|
|
946
|
+
addAuthOptions(
|
|
947
|
+
program
|
|
948
|
+
.command("add")
|
|
949
|
+
.description("Stage changes for commit")
|
|
950
|
+
.argument("[paths...]", "Paths or glob patterns to stage")
|
|
951
|
+
.option("--all, -a", "Stage all changes")
|
|
952
|
+
).action((paths, options) => handleAdd(paths, options));
|
|
953
|
+
|
|
954
|
+
program
|
|
955
|
+
.command("reset")
|
|
956
|
+
.description("Unstage changes")
|
|
957
|
+
.argument("[paths...]", "Paths to unstage")
|
|
958
|
+
.option("--all", "Unstage everything")
|
|
959
|
+
.action((paths, options) => handleReset(paths, options));
|
|
960
|
+
|
|
961
|
+
addAuthOptions(
|
|
962
|
+
program
|
|
963
|
+
.command("commit")
|
|
964
|
+
.description("Apply staged changes and save snapshot")
|
|
965
|
+
.option("-m, --message <message>", "Commit message")
|
|
966
|
+
).action(handleCommit);
|
|
967
|
+
|
|
968
|
+
program
|
|
969
|
+
.command("log")
|
|
970
|
+
.description("Show commit history")
|
|
971
|
+
.option("-n, --limit <number>", "Number of entries to show", Number, 10)
|
|
972
|
+
.action(handleLog);
|
|
973
|
+
|
|
974
|
+
addAuthOptions(program.command("fetch").description("Check remote state")).action(
|
|
975
|
+
handleFetch
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
addAuthOptions(
|
|
979
|
+
program
|
|
980
|
+
.command("dedupe-folders")
|
|
981
|
+
.description("Detect and optionally remediate duplicate remote folders")
|
|
982
|
+
.option("--execute", "Move items and trash empty duplicate folders")
|
|
983
|
+
).action(handleDedupeFolders);
|
|
984
|
+
|
|
985
|
+
addAuthOptions(
|
|
986
|
+
program
|
|
987
|
+
.command("pull")
|
|
988
|
+
.description("Download remote changes")
|
|
989
|
+
.argument("[paths...]", "Specific paths to pull (default: all)")
|
|
990
|
+
.option("-m, --message <message>", "Commit message")
|
|
991
|
+
.option("--force", "Force-pull conflicts (remote wins)")
|
|
992
|
+
.option("--dry-run", "Preview changes without applying")
|
|
993
|
+
).action((paths, options) => handlePull(paths, options));
|
|
994
|
+
|
|
995
|
+
addAuthOptions(
|
|
996
|
+
program
|
|
997
|
+
.command("push")
|
|
998
|
+
.description("Upload local changes")
|
|
999
|
+
.argument("[paths...]", "Specific paths to push (default: all)")
|
|
1000
|
+
.option("-m, --message <message>", "Commit message")
|
|
1001
|
+
.option("--force", "Force-push conflicts (local wins)")
|
|
1002
|
+
.option("--dry-run", "Preview changes without applying")
|
|
1003
|
+
).action((paths, options) => handlePush(paths, options));
|
|
1004
|
+
|
|
1005
|
+
addAuthOptions(
|
|
1006
|
+
program
|
|
1007
|
+
.command("resolve")
|
|
1008
|
+
.description("Resolve file conflicts")
|
|
1009
|
+
.argument("[paths...]", "Conflicted paths to resolve (default: all)")
|
|
1010
|
+
.option("--ours", "Keep local version (upload to Drive)")
|
|
1011
|
+
.option("--theirs", "Keep remote version (download from Drive)")
|
|
1012
|
+
.option("--both", "Keep both versions (remote saved as .remote copy)")
|
|
1013
|
+
).action((paths, options) => handleResolve(paths, options));
|
|
1014
|
+
|
|
1015
|
+
program
|
|
1016
|
+
.command("ignore")
|
|
1017
|
+
.description("Manage .aethelignore patterns")
|
|
1018
|
+
.argument("<subcommand>", "list, test, or create")
|
|
1019
|
+
.argument("[paths...]", "Paths to test (for 'test' subcommand)")
|
|
1020
|
+
.action((subcommand, paths) => handleIgnore(subcommand, paths));
|
|
1021
|
+
|
|
1022
|
+
program
|
|
1023
|
+
.command("show")
|
|
1024
|
+
.description("Show details of a commit/snapshot")
|
|
1025
|
+
.argument("[ref]", "Snapshot reference (HEAD, latest, or timestamp prefix)", "HEAD")
|
|
1026
|
+
.option("-v, --verbose", "Show all files with checksums")
|
|
1027
|
+
.action((ref, options) => handleShow(ref, options));
|
|
1028
|
+
|
|
1029
|
+
addAuthOptions(
|
|
1030
|
+
program
|
|
1031
|
+
.command("restore")
|
|
1032
|
+
.description("Restore file(s) from the last snapshot")
|
|
1033
|
+
.argument("<paths...>", "Paths to restore")
|
|
1034
|
+
).action((paths, options) => handleRestore(paths, options));
|
|
1035
|
+
|
|
1036
|
+
addAuthOptions(
|
|
1037
|
+
program
|
|
1038
|
+
.command("rm")
|
|
1039
|
+
.description("Delete file(s) locally and stage remote deletion")
|
|
1040
|
+
.argument("<paths...>", "Paths to remove")
|
|
1041
|
+
).action((paths, options) => handleRm(paths, options));
|
|
1042
|
+
|
|
1043
|
+
program
|
|
1044
|
+
.command("mv")
|
|
1045
|
+
.description("Move/rename a file locally")
|
|
1046
|
+
.argument("<source>", "Source path (relative to workspace)")
|
|
1047
|
+
.argument("<dest>", "Destination path (relative to workspace)")
|
|
1048
|
+
.action((source, dest, options) => handleMv(source, dest, options));
|
|
1049
|
+
|
|
1050
|
+
addAuthOptions(
|
|
1051
|
+
program
|
|
1052
|
+
.command("tui")
|
|
1053
|
+
.description("Launch the interactive Ink terminal UI")
|
|
1054
|
+
.option("--shared-drives", "Include shared drives")
|
|
1055
|
+
).action(handleTui);
|
|
1056
|
+
|
|
1057
|
+
await program.parseAsync(process.argv);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
main().catch((error) => {
|
|
1061
|
+
console.error(`Error: ${error.message}`);
|
|
1062
|
+
process.exitCode = 1;
|
|
1063
|
+
});
|