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.
@@ -0,0 +1,227 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { readConfig, readIndex, writeIndex } from "./config.js";
4
+ import { downloadFile, ensureFolder, trashFile, uploadFile } from "./drive-api.js";
5
+
6
+ function readPositiveIntEnv(name, fallback) {
7
+ const rawValue = Number.parseInt(process.env[name] || "", 10);
8
+ return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : fallback;
9
+ }
10
+
11
+ const CONCURRENCY = readPositiveIntEnv("AETHEL_DRIVE_CONCURRENCY", 10);
12
+
13
+ function toLocalAbsolutePath(root, relativePath) {
14
+ return path.join(root, ...relativePath.split("/"));
15
+ }
16
+
17
+ export class CommitResult {
18
+ constructor() {
19
+ this.downloaded = 0;
20
+ this.uploaded = 0;
21
+ this.deletedLocal = 0;
22
+ this.deletedRemote = 0;
23
+ this.errors = [];
24
+ }
25
+
26
+ get total() {
27
+ return (
28
+ this.downloaded +
29
+ this.uploaded +
30
+ this.deletedLocal +
31
+ this.deletedRemote
32
+ );
33
+ }
34
+
35
+ get summary() {
36
+ const parts = [];
37
+
38
+ if (this.downloaded) {
39
+ parts.push(`${this.downloaded} downloaded`);
40
+ }
41
+ if (this.uploaded) {
42
+ parts.push(`${this.uploaded} uploaded`);
43
+ }
44
+ if (this.deletedLocal) {
45
+ parts.push(`${this.deletedLocal} deleted locally`);
46
+ }
47
+ if (this.deletedRemote) {
48
+ parts.push(`${this.deletedRemote} deleted on Drive`);
49
+ }
50
+ if (this.errors.length) {
51
+ parts.push(`${this.errors.length} errors`);
52
+ }
53
+
54
+ return parts.length ? parts.join(", ") : "nothing to do";
55
+ }
56
+ }
57
+
58
+ async function downloadStagedFile(drive, entry, root) {
59
+ const fileId = entry.fileId;
60
+ const localRelativePath = entry.localPath || entry.path;
61
+ const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
62
+ const response = await drive.files.get({
63
+ fileId,
64
+ fields: "id,name,mimeType",
65
+ });
66
+
67
+ await downloadFile(drive, { ...response.data, id: fileId }, localAbsolutePath);
68
+ }
69
+
70
+ async function uploadStagedFile(drive, entry, root, driveFolderId) {
71
+ const localRelativePath = entry.localPath || entry.path;
72
+ const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
73
+ const remotePath = entry.remotePath || entry.path;
74
+
75
+ if (!fs.existsSync(localAbsolutePath)) {
76
+ throw new Error(`Local file not found: ${localAbsolutePath}`);
77
+ }
78
+
79
+ const parentPath = path.posix.dirname(remotePath);
80
+ let parentId = driveFolderId || "root";
81
+
82
+ if (parentPath && parentPath !== ".") {
83
+ parentId = await ensureFolder(drive, parentPath, driveFolderId);
84
+ }
85
+
86
+ await uploadFile(drive, localAbsolutePath, remotePath, {
87
+ parentId,
88
+ existingId: entry.fileId || null,
89
+ });
90
+ }
91
+
92
+ async function deleteLocalFile(entry, root) {
93
+ const localRelativePath = entry.localPath || entry.path;
94
+ const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
95
+
96
+ if (!fs.existsSync(localAbsolutePath)) {
97
+ return;
98
+ }
99
+
100
+ await fs.promises.unlink(localAbsolutePath);
101
+
102
+ let currentPath = path.dirname(localAbsolutePath);
103
+ const resolvedRoot = path.resolve(root);
104
+
105
+ while (currentPath !== resolvedRoot) {
106
+ const contents = await fs.promises.readdir(currentPath);
107
+ if (contents.length > 0) {
108
+ break;
109
+ }
110
+
111
+ await fs.promises.rmdir(currentPath);
112
+ currentPath = path.dirname(currentPath);
113
+ }
114
+ }
115
+
116
+ async function deleteRemoteFile(drive, entry) {
117
+ if (!entry.fileId) {
118
+ throw new Error("No fileId was found for delete_remote.");
119
+ }
120
+
121
+ await trashFile(drive, entry.fileId);
122
+ }
123
+
124
+ // ── Bounded-concurrency runner ───────────────────────────────────────
125
+
126
+ async function runConcurrent(tasks, limit, onDone) {
127
+ let next = 0;
128
+ let running = 0;
129
+ let done = 0;
130
+
131
+ return new Promise((resolve, reject) => {
132
+ function launch() {
133
+ while (running < limit && next < tasks.length) {
134
+ const index = next++;
135
+ running++;
136
+ tasks[index]()
137
+ .then((result) => {
138
+ running--;
139
+ done++;
140
+ onDone?.(done, tasks.length, index, null, result);
141
+ if (done === tasks.length) resolve();
142
+ else launch();
143
+ })
144
+ .catch((err) => {
145
+ running--;
146
+ done++;
147
+ onDone?.(done, tasks.length, index, err, null);
148
+ if (done === tasks.length) resolve();
149
+ else launch();
150
+ });
151
+ }
152
+ }
153
+ if (tasks.length === 0) resolve();
154
+ else launch();
155
+ });
156
+ }
157
+
158
+ // ── Main executor ────────────────────────────────────────────────────
159
+
160
+ export async function executeStaged(drive, root, progress) {
161
+ const config = readConfig(root);
162
+ const index = readIndex(root);
163
+ const staged = index.staged || [];
164
+ const driveFolderId = config.drive_folder_id || null;
165
+ const result = new CommitResult();
166
+
167
+ // Local deletes can run fully in parallel — no API rate limits.
168
+ // Remote operations (download, upload, delete_remote) share a concurrency pool.
169
+ const localDeletes = [];
170
+ const remoteOps = [];
171
+
172
+ for (const [i, entry] of staged.entries()) {
173
+ if (entry.action === "delete_local") {
174
+ localDeletes.push({ index: i, entry });
175
+ } else {
176
+ remoteOps.push({ index: i, entry });
177
+ }
178
+ }
179
+
180
+ // Run local deletes first (fast, no API)
181
+ await Promise.all(
182
+ localDeletes.map(async ({ entry }) => {
183
+ try {
184
+ await deleteLocalFile(entry, root);
185
+ result.deletedLocal++;
186
+ } catch (err) {
187
+ result.errors.push(`delete_local ${entry.path}: ${err.message}`);
188
+ }
189
+ })
190
+ );
191
+
192
+ // Run remote operations with bounded concurrency
193
+ const tasks = remoteOps.map(({ entry }) => {
194
+ return async () => {
195
+ const action = entry.action;
196
+ if (action === "download") {
197
+ await downloadStagedFile(drive, entry, root);
198
+ result.downloaded++;
199
+ } else if (action === "upload") {
200
+ await uploadStagedFile(drive, entry, root, driveFolderId);
201
+ result.uploaded++;
202
+ } else if (action === "delete_remote") {
203
+ await deleteRemoteFile(drive, entry);
204
+ result.deletedRemote++;
205
+ } else {
206
+ throw new Error(`Unknown action '${action}'`);
207
+ }
208
+ return entry;
209
+ };
210
+ });
211
+
212
+ let completed = localDeletes.length;
213
+ await runConcurrent(tasks, CONCURRENCY, (done, total, idx, err, entry) => {
214
+ completed++;
215
+ const op = remoteOps[idx];
216
+ if (err) {
217
+ result.errors.push(`${op.entry.action} ${op.entry.path}: ${err.message}`);
218
+ }
219
+ progress?.(completed - 1, staged.length, op.entry.action, path.posix.basename(op.entry.path || ""));
220
+ });
221
+
222
+ progress?.(staged.length, staged.length, "done", "");
223
+ index.staged = [];
224
+ writeIndex(root, index);
225
+
226
+ return result;
227
+ }