@alfe.ai/openclaw-sync 0.0.15 → 0.0.17
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/dist/cli/index.cjs +45 -109
- package/dist/cli/index.js +45 -109
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +4 -11
- package/dist/index.d.cts +798 -190
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +798 -190
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -3
- package/dist/plugin.d.cts +16 -18
- package/dist/plugin.d.cts.map +1 -1
- package/dist/plugin.d.ts +16 -18
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin2.cjs +111 -104
- package/dist/plugin2.js +110 -103
- package/dist/plugin2.js.map +1 -1
- package/dist/sync-engine.cjs +263 -344
- package/dist/sync-engine.js +226 -318
- package/dist/sync-engine.js.map +1 -1
- package/package.json +2 -1
- package/dist/ignore.cjs +0 -120
- package/dist/ignore.js +0 -74
- package/dist/ignore.js.map +0 -1
package/dist/sync-engine.js
CHANGED
|
@@ -1,96 +1,45 @@
|
|
|
1
|
-
import { r as loadIgnorePatterns, t as filterIgnored } from "./ignore.js";
|
|
2
1
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
2
|
import { createReadStream, existsSync } from "node:fs";
|
|
4
|
-
import { basename, dirname, extname, join } from "node:path";
|
|
5
3
|
import { createHash } from "node:crypto";
|
|
4
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import micromatch from "micromatch";
|
|
6
7
|
import { createLogger } from "@auriclabs/logger";
|
|
7
|
-
//#region src/config.ts
|
|
8
|
-
/**
|
|
9
|
-
* AlfeSync configuration — read/write `.alfesync/config.json` in workspace root.
|
|
10
|
-
*/
|
|
11
|
-
const CONFIG_DIR = ".alfesync";
|
|
12
|
-
const CONFIG_FILE = "config.json";
|
|
13
|
-
/**
|
|
14
|
-
* Resolve the .alfesync directory path for a given workspace root.
|
|
15
|
-
*/
|
|
16
|
-
function configDir(workspacePath) {
|
|
17
|
-
return join(workspacePath, CONFIG_DIR);
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Resolve the config file path for a given workspace root.
|
|
21
|
-
*/
|
|
22
|
-
function configPath(workspacePath) {
|
|
23
|
-
return join(workspacePath, CONFIG_DIR, CONFIG_FILE);
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Check if a workspace has been initialized with AlfeSync.
|
|
27
|
-
*/
|
|
28
|
-
function isInitialized(workspacePath) {
|
|
29
|
-
return existsSync(configPath(workspacePath));
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Read the AlfeSync config from a workspace.
|
|
33
|
-
* Returns null if not initialized.
|
|
34
|
-
*/
|
|
35
|
-
async function readConfig(workspacePath) {
|
|
36
|
-
const path = configPath(workspacePath);
|
|
37
|
-
if (!existsSync(path)) return null;
|
|
38
|
-
try {
|
|
39
|
-
const raw = await readFile(path, "utf-8");
|
|
40
|
-
const parsed = JSON.parse(raw);
|
|
41
|
-
if (!parsed.agentId || !parsed.token || !parsed.apiUrl) return null;
|
|
42
|
-
return {
|
|
43
|
-
...parsed,
|
|
44
|
-
workspacePath
|
|
45
|
-
};
|
|
46
|
-
} catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Write the AlfeSync config to a workspace.
|
|
52
|
-
* Creates the .alfesync directory if it doesn't exist.
|
|
53
|
-
*/
|
|
54
|
-
async function writeConfig(config) {
|
|
55
|
-
await mkdir(configDir(config.workspacePath), { recursive: true });
|
|
56
|
-
const path = configPath(config.workspacePath);
|
|
57
|
-
const data = {
|
|
58
|
-
agentId: config.agentId,
|
|
59
|
-
orgId: config.orgId,
|
|
60
|
-
token: config.token,
|
|
61
|
-
workspacePath: config.workspacePath,
|
|
62
|
-
apiUrl: config.apiUrl
|
|
63
|
-
};
|
|
64
|
-
await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Load config from workspace, throwing if not initialized.
|
|
68
|
-
*/
|
|
69
|
-
async function requireConfig(workspacePath) {
|
|
70
|
-
const config = await readConfig(workspacePath);
|
|
71
|
-
if (!config) throw new Error(`AlfeSync not initialized in ${workspacePath}. Run: alfesync init`);
|
|
72
|
-
return config;
|
|
73
|
-
}
|
|
74
|
-
//#endregion
|
|
75
8
|
//#region src/manifest.ts
|
|
76
9
|
/**
|
|
77
|
-
* AlfeSync manifest — local file manifest at
|
|
10
|
+
* AlfeSync manifest — local file manifest at `~/.alfe/sync/manifest.json`.
|
|
78
11
|
*
|
|
79
12
|
* Tracks file hashes, sizes, sync timestamps, and storage classes
|
|
80
|
-
* to enable efficient diff-based syncing.
|
|
13
|
+
* to enable efficient diff-based syncing. Lives under `~/.alfe/sync/`
|
|
14
|
+
* so workspace directories stay clean.
|
|
15
|
+
*
|
|
16
|
+
* `workspacePath` is accepted by every function for consistency with the
|
|
17
|
+
* rest of the package, but the manifest itself is workspace-independent
|
|
18
|
+
* (one agent, one workspace, one manifest).
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Local state directory for sync — manifest, etc. Lives under `~/.alfe/sync/`
|
|
22
|
+
* so it stays alongside the rest of Alfe's agent state instead of polluting
|
|
23
|
+
* the workspace.
|
|
81
24
|
*/
|
|
25
|
+
const SYNC_STATE_DIR = join(homedir(), ".alfe", "sync");
|
|
82
26
|
const MANIFEST_FILE = "manifest.json";
|
|
83
27
|
/**
|
|
84
|
-
* Resolve the manifest file path
|
|
28
|
+
* Resolve the manifest file path. Lives under `~/.alfe/sync/`, independent
|
|
29
|
+
* of the workspace path — one agent has one manifest.
|
|
85
30
|
*/
|
|
86
|
-
function manifestPath(
|
|
87
|
-
return join(
|
|
31
|
+
function manifestPath() {
|
|
32
|
+
return join(SYNC_STATE_DIR, MANIFEST_FILE);
|
|
88
33
|
}
|
|
89
34
|
/**
|
|
90
35
|
* Read the local manifest. Returns empty manifest if not found.
|
|
36
|
+
*
|
|
37
|
+
* `workspacePath` is accepted for call-site symmetry with the rest of the
|
|
38
|
+
* package but is not used — the manifest path is resolved from
|
|
39
|
+
* `~/.alfe/sync/` regardless of which workspace the call comes from.
|
|
91
40
|
*/
|
|
92
41
|
async function readManifest(workspacePath) {
|
|
93
|
-
const path = manifestPath(
|
|
42
|
+
const path = manifestPath();
|
|
94
43
|
if (!existsSync(path)) return { files: {} };
|
|
95
44
|
try {
|
|
96
45
|
const raw = await readFile(path, "utf-8");
|
|
@@ -100,11 +49,13 @@ async function readManifest(workspacePath) {
|
|
|
100
49
|
}
|
|
101
50
|
}
|
|
102
51
|
/**
|
|
103
|
-
* Write the local manifest.
|
|
52
|
+
* Write the local manifest. `workspacePath` is accepted for call-site
|
|
53
|
+
* symmetry but unused (see `readManifest`).
|
|
104
54
|
*/
|
|
105
55
|
async function writeManifest(workspacePath, manifest) {
|
|
106
|
-
|
|
107
|
-
await
|
|
56
|
+
const path = manifestPath();
|
|
57
|
+
await mkdir(SYNC_STATE_DIR, { recursive: true });
|
|
58
|
+
await writeFile(path, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
108
59
|
}
|
|
109
60
|
/**
|
|
110
61
|
* Update a single file entry in the local manifest.
|
|
@@ -169,118 +120,100 @@ function diffManifests(local, remote) {
|
|
|
169
120
|
};
|
|
170
121
|
}
|
|
171
122
|
//#endregion
|
|
172
|
-
//#region src/
|
|
123
|
+
//#region src/ignore.ts
|
|
173
124
|
/**
|
|
174
|
-
*
|
|
125
|
+
* AlfeSync ignore — parse `.alfesyncignore` (gitignore-style glob matching).
|
|
126
|
+
*
|
|
127
|
+
* Uses micromatch for glob pattern matching, compatible with .gitignore syntax.
|
|
175
128
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
throw new Error(`AlfeSync API error (${String(response.status)}): ${errorMsg}`);
|
|
129
|
+
/** Default ignore patterns — always applied */
|
|
130
|
+
const DEFAULT_IGNORES = [
|
|
131
|
+
".alfesync/**",
|
|
132
|
+
"node_modules/**",
|
|
133
|
+
"*.tmp",
|
|
134
|
+
".DS_Store",
|
|
135
|
+
".git/**",
|
|
136
|
+
".sst/**",
|
|
137
|
+
".build/**",
|
|
138
|
+
"dist/**"
|
|
139
|
+
];
|
|
140
|
+
/**
|
|
141
|
+
* Load ignore patterns from `.alfesyncignore` file + defaults.
|
|
142
|
+
*/
|
|
143
|
+
async function loadIgnorePatterns(workspacePath) {
|
|
144
|
+
const ignoreFile = join(workspacePath, ".alfesyncignore");
|
|
145
|
+
const patterns = [...DEFAULT_IGNORES];
|
|
146
|
+
if (existsSync(ignoreFile)) try {
|
|
147
|
+
const lines = (await readFile(ignoreFile, "utf-8")).split("\n");
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
const trimmed = line.trim();
|
|
150
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
151
|
+
patterns.push(trimmed);
|
|
200
152
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return request("GET", `/sync/agents/${agentId}/stats`);
|
|
244
|
-
},
|
|
245
|
-
async registerAgent(displayName) {
|
|
246
|
-
return request("POST", "/sync/agents", {
|
|
247
|
-
agentId,
|
|
248
|
-
displayName
|
|
249
|
-
});
|
|
250
|
-
},
|
|
251
|
-
async getFileHistory(filePath) {
|
|
252
|
-
return (await request("GET", `/sync/agents/${agentId}/files/${filePath}/versions`)).versions;
|
|
253
|
-
},
|
|
254
|
-
async reconstruct(mode = "full") {
|
|
255
|
-
return request("POST", `/sync/agents/${agentId}/reconstruct`, { mode });
|
|
153
|
+
} catch {}
|
|
154
|
+
return patterns;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Check if a relative path should be ignored.
|
|
158
|
+
*/
|
|
159
|
+
function shouldIgnore(relativePath, patterns) {
|
|
160
|
+
return micromatch.isMatch(relativePath, patterns, {
|
|
161
|
+
dot: true,
|
|
162
|
+
matchBase: true
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Filter a list of relative paths, removing ignored ones.
|
|
167
|
+
*/
|
|
168
|
+
function filterIgnored(paths, patterns) {
|
|
169
|
+
return paths.filter((p) => !shouldIgnore(p, patterns));
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/retry.ts
|
|
173
|
+
/**
|
|
174
|
+
* Tiny retry helper used by uploader/downloader. Extracted so both
|
|
175
|
+
* use the same timing and surface the same final error shape.
|
|
176
|
+
*/
|
|
177
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
178
|
+
const DEFAULT_BASE_DELAY_MS = 1e3;
|
|
179
|
+
/**
|
|
180
|
+
* Run `fn` up to `maxRetries + 1` times with exponential backoff
|
|
181
|
+
* (`base * 2^attempt`). Returns the first successful value, or rethrows
|
|
182
|
+
* the last error.
|
|
183
|
+
*/
|
|
184
|
+
async function withRetry(fn, options = {}) {
|
|
185
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
186
|
+
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
187
|
+
let lastError;
|
|
188
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) try {
|
|
189
|
+
return await fn();
|
|
190
|
+
} catch (err) {
|
|
191
|
+
lastError = err;
|
|
192
|
+
if (attempt < maxRetries) {
|
|
193
|
+
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
256
195
|
}
|
|
257
|
-
}
|
|
196
|
+
}
|
|
197
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
258
198
|
}
|
|
259
199
|
//#endregion
|
|
260
200
|
//#region src/uploader.ts
|
|
261
201
|
/**
|
|
262
|
-
* AlfeSync uploader —
|
|
202
|
+
* AlfeSync uploader — push changed files to S3.
|
|
263
203
|
*
|
|
264
|
-
*
|
|
265
|
-
* 1.
|
|
266
|
-
* 2. PUT
|
|
267
|
-
* 3.
|
|
268
|
-
* 4. Update local manifest
|
|
204
|
+
* Per file:
|
|
205
|
+
* 1. Ask the agent API for a presigned PUT URL
|
|
206
|
+
* 2. PUT bytes directly to S3 (raw fetch — S3 isn't an Alfe API)
|
|
207
|
+
* 3. Tell the agent API the upload completed (`syncConfirmUpload`)
|
|
208
|
+
* 4. Update the local manifest
|
|
269
209
|
*
|
|
270
|
-
*
|
|
210
|
+
* Each step retries with exponential backoff via `withRetry`.
|
|
271
211
|
*/
|
|
272
212
|
const log$2 = createLogger("SyncUploader");
|
|
273
|
-
const
|
|
274
|
-
const BASE_DELAY_MS$1 = 1e3;
|
|
275
|
-
/**
|
|
276
|
-
* Determine the storage class based on file path.
|
|
277
|
-
*/
|
|
213
|
+
const ARCHIVE_PREFIXES = ["sessions/archive/", "context/archive/"];
|
|
278
214
|
function getStorageClass(relativePath) {
|
|
279
|
-
return
|
|
215
|
+
return ARCHIVE_PREFIXES.some((p) => relativePath.startsWith(p)) ? "GLACIER_IR" : "STANDARD";
|
|
280
216
|
}
|
|
281
|
-
/**
|
|
282
|
-
* Determine MIME type from file path.
|
|
283
|
-
*/
|
|
284
217
|
function getContentType(relativePath) {
|
|
285
218
|
if (relativePath.endsWith(".json")) return "application/json";
|
|
286
219
|
if (relativePath.endsWith(".md")) return "text/markdown";
|
|
@@ -289,62 +222,63 @@ function getContentType(relativePath) {
|
|
|
289
222
|
if (relativePath.endsWith(".yaml") || relativePath.endsWith(".yml")) return "text/yaml";
|
|
290
223
|
return "application/octet-stream";
|
|
291
224
|
}
|
|
292
|
-
|
|
293
|
-
* Upload a single file with retry logic.
|
|
294
|
-
*/
|
|
295
|
-
async function uploadFileWithRetry(workspacePath, relativePath, client) {
|
|
225
|
+
async function uploadOne(workspacePath, relativePath, client) {
|
|
296
226
|
const absolutePath = join(workspacePath, relativePath);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
227
|
+
try {
|
|
228
|
+
return await withRetry(async () => {
|
|
229
|
+
const [hash, fileStat] = await Promise.all([computeFileHash(absolutePath), stat(absolutePath)]);
|
|
230
|
+
const size = fileStat.size;
|
|
231
|
+
const storageClass = getStorageClass(relativePath);
|
|
232
|
+
const contentType = getContentType(relativePath);
|
|
233
|
+
const url = (await client.syncPresign({ files: [{
|
|
234
|
+
path: relativePath,
|
|
235
|
+
operation: "put",
|
|
236
|
+
contentType
|
|
237
|
+
}] })).urls[0]?.url;
|
|
238
|
+
if (!url) throw new Error("No presigned URL returned");
|
|
239
|
+
const fileContent = await readFile(absolutePath);
|
|
240
|
+
const putResponse = await fetch(url, {
|
|
241
|
+
method: "PUT",
|
|
242
|
+
headers: { "Content-Type": contentType },
|
|
243
|
+
body: fileContent
|
|
244
|
+
});
|
|
245
|
+
if (!putResponse.ok) throw new Error(`S3 PUT failed (${String(putResponse.status)}): ${await putResponse.text()}`);
|
|
246
|
+
await client.syncConfirmUpload({
|
|
247
|
+
filePath: relativePath,
|
|
248
|
+
hash,
|
|
249
|
+
size,
|
|
250
|
+
storageClass
|
|
251
|
+
});
|
|
252
|
+
await updateManifestEntry(workspacePath, relativePath, {
|
|
253
|
+
hash,
|
|
254
|
+
size,
|
|
255
|
+
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
256
|
+
storageClass
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
path: relativePath,
|
|
260
|
+
success: true,
|
|
261
|
+
hash,
|
|
262
|
+
size
|
|
263
|
+
};
|
|
317
264
|
});
|
|
265
|
+
} catch (err) {
|
|
318
266
|
return {
|
|
319
267
|
path: relativePath,
|
|
320
|
-
success:
|
|
321
|
-
|
|
322
|
-
size
|
|
268
|
+
success: false,
|
|
269
|
+
error: err instanceof Error ? err.message : String(err)
|
|
323
270
|
};
|
|
324
|
-
} catch (err) {
|
|
325
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
326
|
-
if (attempt < MAX_RETRIES$1) {
|
|
327
|
-
const delay = BASE_DELAY_MS$1 * Math.pow(2, attempt);
|
|
328
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
329
|
-
}
|
|
330
271
|
}
|
|
331
|
-
return {
|
|
332
|
-
path: relativePath,
|
|
333
|
-
success: false,
|
|
334
|
-
error: lastError?.message ?? "Unknown error"
|
|
335
|
-
};
|
|
336
272
|
}
|
|
337
273
|
/**
|
|
338
|
-
* Upload
|
|
339
|
-
*
|
|
340
|
-
* Uploads are performed in parallel with a concurrency limit.
|
|
274
|
+
* Upload many files, batched by `concurrency`.
|
|
341
275
|
*/
|
|
342
276
|
async function uploadFiles(workspacePath, relativePaths, client, options = {}) {
|
|
343
277
|
const { concurrency = 5, quiet = false } = options;
|
|
344
278
|
const results = [];
|
|
345
279
|
for (let i = 0; i < relativePaths.length; i += concurrency) {
|
|
346
280
|
const batch = relativePaths.slice(i, i + concurrency);
|
|
347
|
-
const batchResults = await Promise.all(batch.map((path) =>
|
|
281
|
+
const batchResults = await Promise.all(batch.map((path) => uploadOne(workspacePath, path, client)));
|
|
348
282
|
for (const result of batchResults) {
|
|
349
283
|
results.push(result);
|
|
350
284
|
if (!quiet) if (result.success) log$2.info(`Uploaded ${result.path} (${formatBytes$1(result.size ?? 0)})`);
|
|
@@ -361,65 +295,55 @@ function formatBytes$1(bytes) {
|
|
|
361
295
|
//#endregion
|
|
362
296
|
//#region src/downloader.ts
|
|
363
297
|
/**
|
|
364
|
-
* AlfeSync downloader —
|
|
298
|
+
* AlfeSync downloader — pull files from S3 to disk.
|
|
365
299
|
*
|
|
366
|
-
*
|
|
367
|
-
* 1.
|
|
368
|
-
* 2. GET
|
|
369
|
-
* 3. Write to
|
|
370
|
-
* 4. Update local manifest
|
|
300
|
+
* Per file:
|
|
301
|
+
* 1. Ask the agent API for a presigned GET URL
|
|
302
|
+
* 2. GET bytes directly from S3 (raw fetch — S3 isn't an Alfe API)
|
|
303
|
+
* 3. Write to disk
|
|
304
|
+
* 4. Update the local manifest
|
|
371
305
|
*/
|
|
372
306
|
const log$1 = createLogger("SyncDownloader");
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
307
|
+
async function downloadOne(workspacePath, relativePath, client, remoteEntry) {
|
|
308
|
+
try {
|
|
309
|
+
return await withRetry(async () => {
|
|
310
|
+
const url = (await client.syncPresign({ files: [{
|
|
311
|
+
path: relativePath,
|
|
312
|
+
operation: "get"
|
|
313
|
+
}] })).urls[0]?.url;
|
|
314
|
+
if (!url) throw new Error("No presigned URL returned");
|
|
315
|
+
const response = await fetch(url);
|
|
316
|
+
if (!response.ok) throw new Error(`S3 GET failed (${String(response.status)}): ${await response.text()}`);
|
|
317
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
318
|
+
const absolutePath = join(workspacePath, relativePath);
|
|
319
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
320
|
+
await writeFile(absolutePath, buffer);
|
|
321
|
+
await updateManifestEntry(workspacePath, relativePath, {
|
|
322
|
+
hash: remoteEntry?.hash ?? "",
|
|
323
|
+
size: buffer.length,
|
|
324
|
+
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
325
|
+
storageClass: remoteEntry?.storageClass ?? "STANDARD"
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
path: relativePath,
|
|
329
|
+
success: true,
|
|
330
|
+
size: buffer.length
|
|
331
|
+
};
|
|
393
332
|
});
|
|
333
|
+
} catch (err) {
|
|
394
334
|
return {
|
|
395
335
|
path: relativePath,
|
|
396
|
-
success:
|
|
397
|
-
|
|
336
|
+
success: false,
|
|
337
|
+
error: err instanceof Error ? err.message : String(err)
|
|
398
338
|
};
|
|
399
|
-
} catch (err) {
|
|
400
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
401
|
-
if (attempt < MAX_RETRIES) {
|
|
402
|
-
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
|
|
403
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
404
|
-
}
|
|
405
339
|
}
|
|
406
|
-
return {
|
|
407
|
-
path: relativePath,
|
|
408
|
-
success: false,
|
|
409
|
-
error: lastError?.message ?? "Unknown error"
|
|
410
|
-
};
|
|
411
340
|
}
|
|
412
|
-
/**
|
|
413
|
-
* Download multiple files from S3.
|
|
414
|
-
*
|
|
415
|
-
* Downloads are performed in parallel with a concurrency limit.
|
|
416
|
-
*/
|
|
417
341
|
async function downloadFiles(workspacePath, relativePaths, client, remoteManifest, options = {}) {
|
|
418
342
|
const { concurrency = 5, quiet = false } = options;
|
|
419
343
|
const results = [];
|
|
420
344
|
for (let i = 0; i < relativePaths.length; i += concurrency) {
|
|
421
345
|
const batch = relativePaths.slice(i, i + concurrency);
|
|
422
|
-
const batchResults = await Promise.all(batch.map((path) =>
|
|
346
|
+
const batchResults = await Promise.all(batch.map((path) => downloadOne(workspacePath, path, client, remoteManifest?.files[path])));
|
|
423
347
|
for (const result of batchResults) {
|
|
424
348
|
results.push(result);
|
|
425
349
|
if (!quiet) if (result.success) log$1.info(`Downloaded ${result.path} (${formatBytes(result.size ?? 0)})`);
|
|
@@ -436,36 +360,31 @@ function formatBytes(bytes) {
|
|
|
436
360
|
//#endregion
|
|
437
361
|
//#region src/sync-engine.ts
|
|
438
362
|
/**
|
|
439
|
-
* AlfeSync engine — orchestrates push, pull, and full sync
|
|
363
|
+
* AlfeSync engine — orchestrates push, pull, and full sync.
|
|
440
364
|
*
|
|
441
|
-
* Handles:
|
|
442
365
|
* - push(paths[]): upload changed files to S3
|
|
443
366
|
* - pull(): download files newer on remote
|
|
444
367
|
* - fullSync(): bidirectional sync with conflict detection
|
|
445
368
|
*
|
|
446
|
-
* Conflict resolution:
|
|
447
|
-
* AND local file has changed
|
|
369
|
+
* Conflict resolution: when a remote file is newer than the local manifest
|
|
370
|
+
* entry AND the local file has also changed, the local copy is renamed to
|
|
371
|
+
* `<name>.conflict-<timestamp>.<ext>` and the remote version wins.
|
|
448
372
|
*/
|
|
449
373
|
const log = createLogger("SyncEngine");
|
|
450
374
|
/**
|
|
451
|
-
*
|
|
375
|
+
* Construct a sync engine bound to a workspace + agent API client.
|
|
376
|
+
*
|
|
377
|
+
* The client is constructed once in plugin.ts (or the CLI) and passed in,
|
|
378
|
+
* so credentials never leak into multiple places.
|
|
452
379
|
*/
|
|
453
|
-
|
|
454
|
-
const config = await requireConfig(workspacePath);
|
|
455
|
-
const client = createApiClient({
|
|
456
|
-
apiUrl: config.apiUrl,
|
|
457
|
-
token: config.token,
|
|
458
|
-
agentId: config.agentId
|
|
459
|
-
});
|
|
380
|
+
function createSyncEngine({ workspacePath, client }) {
|
|
460
381
|
return {
|
|
461
|
-
|
|
382
|
+
workspacePath,
|
|
462
383
|
client,
|
|
463
384
|
async push(paths, options = {}) {
|
|
464
385
|
const { quiet = false, filter } = options;
|
|
465
386
|
const ignorePatterns = await loadIgnorePatterns(workspacePath);
|
|
466
|
-
let filesToPush;
|
|
467
|
-
if (paths && paths.length > 0) filesToPush = filterIgnored(paths, ignorePatterns);
|
|
468
|
-
else filesToPush = await detectLocalChanges(workspacePath, ignorePatterns);
|
|
387
|
+
let filesToPush = paths && paths.length > 0 ? filterIgnored(paths, ignorePatterns) : await detectLocalChanges(workspacePath, ignorePatterns);
|
|
469
388
|
if (filter) filesToPush = filesToPush.filter((p) => p.startsWith(filter));
|
|
470
389
|
if (filesToPush.length === 0) {
|
|
471
390
|
if (!quiet) log.info("Nothing to push");
|
|
@@ -490,10 +409,9 @@ async function createSyncEngine(workspacePath) {
|
|
|
490
409
|
},
|
|
491
410
|
async pull(options = {}) {
|
|
492
411
|
const { quiet = false } = options;
|
|
493
|
-
const [localManifest, remoteManifest] = await Promise.all([readManifest(workspacePath), client.
|
|
412
|
+
const [localManifest, remoteManifest] = await Promise.all([readManifest(workspacePath), client.syncGetManifest()]);
|
|
494
413
|
const diff = diffManifests(localManifest, remoteManifest);
|
|
495
|
-
|
|
496
|
-
if (filesToPull.length === 0) {
|
|
414
|
+
if (diff.toPull.length === 0) {
|
|
497
415
|
if (!quiet) log.info("Nothing to pull");
|
|
498
416
|
return {
|
|
499
417
|
pushed: 0,
|
|
@@ -502,8 +420,8 @@ async function createSyncEngine(workspacePath) {
|
|
|
502
420
|
errors: 0
|
|
503
421
|
};
|
|
504
422
|
}
|
|
505
|
-
if (!quiet) log.info(`Pulling ${String(
|
|
506
|
-
const results = await downloadFiles(workspacePath,
|
|
423
|
+
if (!quiet) log.info(`Pulling ${String(diff.toPull.length)} file(s)`);
|
|
424
|
+
const results = await downloadFiles(workspacePath, [...diff.toPull], client, remoteManifest, { quiet });
|
|
507
425
|
const pulled = results.filter((r) => r.success).length;
|
|
508
426
|
const errors = results.filter((r) => !r.success).length;
|
|
509
427
|
if (!quiet) log.info(`Pull complete: ${String(pulled)} downloaded, ${String(errors)} failed`);
|
|
@@ -516,7 +434,7 @@ async function createSyncEngine(workspacePath) {
|
|
|
516
434
|
},
|
|
517
435
|
async fullSync(options = {}) {
|
|
518
436
|
const { quiet = false } = options;
|
|
519
|
-
const [localManifest, remoteManifest] = await Promise.all([readManifest(workspacePath), client.
|
|
437
|
+
const [localManifest, remoteManifest] = await Promise.all([readManifest(workspacePath), client.syncGetManifest()]);
|
|
520
438
|
const ignorePatterns = await loadIgnorePatterns(workspacePath);
|
|
521
439
|
const localChanges = await detectLocalChanges(workspacePath, ignorePatterns);
|
|
522
440
|
const diff = diffManifests(localManifest, remoteManifest);
|
|
@@ -525,15 +443,14 @@ async function createSyncEngine(workspacePath) {
|
|
|
525
443
|
let conflictCount = 0;
|
|
526
444
|
for (const conflictPath of trueConflicts) {
|
|
527
445
|
const absolutePath = join(workspacePath, conflictPath);
|
|
528
|
-
if (existsSync(absolutePath))
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
446
|
+
if (!existsSync(absolutePath)) continue;
|
|
447
|
+
const ext = extname(conflictPath);
|
|
448
|
+
const base = basename(conflictPath, ext);
|
|
449
|
+
const dir = dirname(conflictPath);
|
|
450
|
+
const conflictName = `${base}.conflict-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}${ext}`;
|
|
451
|
+
await writeFile(join(workspacePath, dir, conflictName), await readFile(absolutePath));
|
|
452
|
+
if (!quiet) log.warn(`Conflict: ${conflictPath} — saved as ${conflictName}`);
|
|
453
|
+
conflictCount++;
|
|
537
454
|
}
|
|
538
455
|
const filesToPush = filterIgnored([...diff.toPush, ...localChanges.filter((p) => !diff.conflicts.includes(p))], ignorePatterns);
|
|
539
456
|
const filesToPull = [
|
|
@@ -578,13 +495,12 @@ async function createSyncEngine(workspacePath) {
|
|
|
578
495
|
conflicts: 0,
|
|
579
496
|
errors: 0
|
|
580
497
|
};
|
|
581
|
-
const remoteManifest = await client.
|
|
498
|
+
const remoteManifest = await client.syncGetManifest();
|
|
582
499
|
const localManifest = await readManifest(workspacePath);
|
|
583
500
|
const filesToPull = paths.filter((p) => {
|
|
584
501
|
if (!(p in remoteManifest.files)) return false;
|
|
585
|
-
const remoteEntry = remoteManifest.files[p];
|
|
586
502
|
if (!(p in localManifest.files)) return true;
|
|
587
|
-
return localManifest.files[p].hash !==
|
|
503
|
+
return localManifest.files[p].hash !== remoteManifest.files[p].hash;
|
|
588
504
|
});
|
|
589
505
|
if (filesToPull.length === 0) {
|
|
590
506
|
if (!quiet) log.info("All notified files already in sync");
|
|
@@ -624,10 +540,8 @@ async function createSyncEngine(workspacePath) {
|
|
|
624
540
|
};
|
|
625
541
|
}
|
|
626
542
|
/**
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
* Compares current file hashes against the local manifest.
|
|
630
|
-
* Returns list of relative paths that need pushing.
|
|
543
|
+
* Walk the workspace and return paths whose hash differs from the manifest
|
|
544
|
+
* (or that are missing from it entirely).
|
|
631
545
|
*/
|
|
632
546
|
async function detectLocalChanges(workspacePath, ignorePatterns) {
|
|
633
547
|
const manifest = await readManifest(workspacePath);
|
|
@@ -640,33 +554,27 @@ async function detectLocalChanges(workspacePath, ignorePatterns) {
|
|
|
640
554
|
const relativePath = fullPath.slice(workspacePath.length + 1).replace(/\\/g, "/");
|
|
641
555
|
if (shouldSkipDir(entry.name)) continue;
|
|
642
556
|
if (entry.isDirectory()) {
|
|
643
|
-
if (!ignorePatterns.some((p) =>
|
|
644
|
-
if (p.endsWith("/**")) return relativePath.startsWith(p.slice(0, -3));
|
|
645
|
-
return false;
|
|
646
|
-
})) await walk(fullPath);
|
|
557
|
+
if (!ignorePatterns.some((p) => p.endsWith("/**") && relativePath.startsWith(p.slice(0, -3)))) await walk(fullPath);
|
|
647
558
|
} else if (entry.isFile()) {
|
|
648
|
-
const { shouldIgnore } = await import("./ignore.js").then((n) => n.n);
|
|
649
559
|
if (shouldIgnore(relativePath, ignorePatterns)) continue;
|
|
650
|
-
if (!(relativePath in manifest.files))
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
try {
|
|
654
|
-
if (await computeFileHash(fullPath) !== manifestEntry.hash) changed.push(relativePath);
|
|
655
|
-
} catch {}
|
|
560
|
+
if (!(relativePath in manifest.files)) {
|
|
561
|
+
changed.push(relativePath);
|
|
562
|
+
continue;
|
|
656
563
|
}
|
|
564
|
+
try {
|
|
565
|
+
if (await computeFileHash(fullPath) !== manifest.files[relativePath].hash) changed.push(relativePath);
|
|
566
|
+
} catch {}
|
|
657
567
|
}
|
|
658
568
|
}
|
|
659
569
|
}
|
|
660
570
|
await walk(workspacePath);
|
|
661
571
|
return changed;
|
|
662
572
|
}
|
|
663
|
-
/**
|
|
664
|
-
* Directories to always skip during walks.
|
|
665
|
-
*/
|
|
573
|
+
/** Directories the engine never descends into. */
|
|
666
574
|
function shouldSkipDir(name) {
|
|
667
|
-
return name === "node_modules" || name === ".git" || name === ".sst" || name === ".
|
|
575
|
+
return name === "node_modules" || name === ".git" || name === ".sst" || name === ".build" || name === "dist";
|
|
668
576
|
}
|
|
669
577
|
//#endregion
|
|
670
|
-
export {
|
|
578
|
+
export { filterIgnored as a, computeFileHash as c, removeManifestEntry as d, updateManifestEntry as f, withRetry as i, diffManifests as l, downloadFiles as n, loadIgnorePatterns as o, writeManifest as p, uploadFiles as r, shouldIgnore as s, createSyncEngine as t, readManifest as u };
|
|
671
579
|
|
|
672
580
|
//# sourceMappingURL=sync-engine.js.map
|