@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.
@@ -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 `.alfesync/manifest.json`.
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 for a given workspace root.
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(workspacePath) {
87
- return join(configDir(workspacePath), MANIFEST_FILE);
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(workspacePath);
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
- await mkdir(configDir(workspacePath), { recursive: true });
107
- await writeFile(manifestPath(workspacePath), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
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/api-client.ts
123
+ //#region src/ignore.ts
173
124
  /**
174
- * Create an AlfeSync API client.
125
+ * AlfeSync ignore parse `.alfesyncignore` (gitignore-style glob matching).
126
+ *
127
+ * Uses micromatch for glob pattern matching, compatible with .gitignore syntax.
175
128
  */
176
- function createApiClient(config) {
177
- const { apiUrl, token, agentId } = config;
178
- const baseUrl = apiUrl.replace(/\/$/, "");
179
- async function request(method, path, body) {
180
- const url = `${baseUrl}${path}`;
181
- const headers = {
182
- Authorization: `Bearer ${token}`,
183
- "Content-Type": "application/json"
184
- };
185
- const response = await fetch(url, {
186
- method,
187
- headers,
188
- body: body ? JSON.stringify(body) : void 0
189
- });
190
- if (!response.ok) {
191
- const text = await response.text();
192
- let errorMsg;
193
- try {
194
- const parsed = JSON.parse(text);
195
- errorMsg = (typeof parsed.error === "string" ? parsed.error : void 0) ?? (typeof parsed.message === "string" ? parsed.message : void 0) ?? text;
196
- } catch {
197
- errorMsg = text;
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
- const json = await response.json();
202
- if (!json.success) throw new Error("AlfeSync API returned unsuccessful response");
203
- return json.data;
204
- }
205
- return {
206
- async getManifest() {
207
- return request("GET", `/sync/agents/${agentId}/manifest`);
208
- },
209
- async presignPut(filePath, contentType = "application/octet-stream") {
210
- return (await request("POST", `/sync/agents/${agentId}/presign`, { files: [{
211
- path: filePath,
212
- operation: "put",
213
- contentType
214
- }] })).urls[0];
215
- },
216
- async presignPutBatch(files) {
217
- return (await request("POST", `/sync/agents/${agentId}/presign`, { files: files.map((f) => ({
218
- path: f.path,
219
- operation: "put",
220
- contentType: f.contentType ?? "application/octet-stream"
221
- })) })).urls;
222
- },
223
- async confirmUpload(filePath, hash, size, storageClass = "STANDARD") {
224
- return request("POST", `/sync/agents/${agentId}/files/${filePath}/confirm`, {
225
- hash,
226
- size,
227
- storageClass
228
- });
229
- },
230
- async presignGet(filePath) {
231
- return (await request("POST", `/sync/agents/${agentId}/presign`, { files: [{
232
- path: filePath,
233
- operation: "get"
234
- }] })).urls[0];
235
- },
236
- async presignGetBatch(paths) {
237
- return (await request("POST", `/sync/agents/${agentId}/presign`, { files: paths.map((p) => ({
238
- path: p,
239
- operation: "get"
240
- })) })).urls;
241
- },
242
- async getStats() {
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 — upload changed files to S3 via presigned URLs.
202
+ * AlfeSync uploader — push changed files to S3.
263
203
  *
264
- * Flow per file:
265
- * 1. Request presigned PUT URL from API
266
- * 2. PUT file content directly to S3
267
- * 3. Notify API of completion (confirm endpoint)
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
- * Retries 3x with exponential backoff on transient failures.
210
+ * Each step retries with exponential backoff via `withRetry`.
271
211
  */
272
212
  const log$2 = createLogger("SyncUploader");
273
- const MAX_RETRIES$1 = 3;
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 relativePath.startsWith("sessions/archive/") || relativePath.startsWith("context/archive/") ? "GLACIER_IR" : "STANDARD";
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
- let lastError;
298
- for (let attempt = 0; attempt <= MAX_RETRIES$1; attempt++) try {
299
- const [hash, fileStat] = await Promise.all([computeFileHash(absolutePath), stat(absolutePath)]);
300
- const size = fileStat.size;
301
- const storageClass = getStorageClass(relativePath);
302
- const contentType = getContentType(relativePath);
303
- const presigned = await client.presignPut(relativePath, contentType);
304
- const fileContent = await readFile(absolutePath);
305
- const putResponse = await fetch(presigned.url, {
306
- method: "PUT",
307
- headers: { "Content-Type": contentType },
308
- body: fileContent
309
- });
310
- if (!putResponse.ok) throw new Error(`S3 PUT failed (${String(putResponse.status)}): ${await putResponse.text()}`);
311
- await client.confirmUpload(relativePath, hash, size, storageClass);
312
- await updateManifestEntry(workspacePath, relativePath, {
313
- hash,
314
- size,
315
- lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
316
- storageClass
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: true,
321
- hash,
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 multiple files to S3.
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) => uploadFileWithRetry(workspacePath, path, client)));
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 — download files from S3 via presigned URLs.
298
+ * AlfeSync downloader — pull files from S3 to disk.
365
299
  *
366
- * Flow per file:
367
- * 1. Request presigned GET URL from API
368
- * 2. GET file content from S3
369
- * 3. Write to local disk
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
- const MAX_RETRIES = 3;
374
- const BASE_DELAY_MS = 1e3;
375
- /**
376
- * Download a single file with retry logic.
377
- */
378
- async function downloadFileWithRetry(workspacePath, relativePath, client, remoteEntry) {
379
- let lastError;
380
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) try {
381
- const presigned = await client.presignGet(relativePath);
382
- const response = await fetch(presigned.url);
383
- if (!response.ok) throw new Error(`S3 GET failed (${String(response.status)}): ${await response.text()}`);
384
- const buffer = Buffer.from(await response.arrayBuffer());
385
- const absolutePath = join(workspacePath, relativePath);
386
- await mkdir(dirname(absolutePath), { recursive: true });
387
- await writeFile(absolutePath, buffer);
388
- await updateManifestEntry(workspacePath, relativePath, {
389
- hash: remoteEntry?.hash ?? "",
390
- size: buffer.length,
391
- lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
392
- storageClass: remoteEntry?.storageClass ?? "STANDARD"
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: true,
397
- size: buffer.length
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) => downloadFileWithRetry(workspacePath, path, client, remoteManifest?.files[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 operations.
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: if remote file is newer than local manifest entry
447
- * AND local file has changed write `.conflict-{timestamp}` alongside original.
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
- * Create a sync engine for a workspace.
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
- async function createSyncEngine(workspacePath) {
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
- config,
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.getManifest()]);
412
+ const [localManifest, remoteManifest] = await Promise.all([readManifest(workspacePath), client.syncGetManifest()]);
494
413
  const diff = diffManifests(localManifest, remoteManifest);
495
- const filesToPull = [...diff.toPull];
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(filesToPull.length)} file(s)`);
506
- const results = await downloadFiles(workspacePath, filesToPull, client, remoteManifest, { quiet });
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.getManifest()]);
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
- const ext = extname(conflictPath);
530
- const base = basename(conflictPath, ext);
531
- const dir = dirname(conflictPath);
532
- const conflictName = `${base}.conflict-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}${ext}`;
533
- await writeFile(join(workspacePath, dir, conflictName), await readFile(absolutePath));
534
- if (!quiet) log.warn(`Conflict: ${conflictPath} — saved as ${conflictName}`);
535
- conflictCount++;
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.getManifest();
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 !== remoteEntry.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
- * Detect files that have changed locally since last sync.
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)) changed.push(relativePath);
651
- else {
652
- const manifestEntry = manifest.files[relativePath];
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 === ".alfesync" || name === ".build" || name === "dist";
575
+ return name === "node_modules" || name === ".git" || name === ".sst" || name === ".build" || name === "dist";
668
576
  }
669
577
  //#endregion
670
- export { computeFileHash as a, removeManifestEntry as c, configDir as d, configPath as f, writeConfig as g, requireConfig as h, createApiClient as i, updateManifestEntry as l, readConfig as m, downloadFiles as n, diffManifests as o, isInitialized as p, uploadFiles as r, readManifest as s, createSyncEngine as t, writeManifest as u };
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