aethel 1.0.0 → 1.2.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/src/core/diff.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isWorkspaceType } from "./drive-api.js";
2
2
  import { loadIgnoreRules } from "./ignore.js";
3
+ import { loadPackManifest } from "./config.js";
3
4
 
4
5
  export const ChangeType = Object.freeze({
5
6
  REMOTE_ADDED: "remote_added",
@@ -9,6 +10,12 @@ export const ChangeType = Object.freeze({
9
10
  LOCAL_MODIFIED: "local_modified",
10
11
  LOCAL_DELETED: "local_deleted",
11
12
  CONFLICT: "conflict",
13
+ // Pack-specific change types
14
+ PACK_LOCAL_MODIFIED: "pack_local_modified",
15
+ PACK_REMOTE_MODIFIED: "pack_remote_modified",
16
+ PACK_SYNCED: "pack_synced",
17
+ PACK_CONFLICT: "pack_conflict",
18
+ PACK_NEW: "pack_new",
12
19
  });
13
20
 
14
21
  const SHORT_STATUS = {
@@ -19,6 +26,11 @@ const SHORT_STATUS = {
19
26
  [ChangeType.LOCAL_MODIFIED]: "ML",
20
27
  [ChangeType.LOCAL_DELETED]: "-L",
21
28
  [ChangeType.CONFLICT]: "!!",
29
+ [ChangeType.PACK_LOCAL_MODIFIED]: "PL",
30
+ [ChangeType.PACK_REMOTE_MODIFIED]: "PR",
31
+ [ChangeType.PACK_SYNCED]: "P=",
32
+ [ChangeType.PACK_CONFLICT]: "P!",
33
+ [ChangeType.PACK_NEW]: "P+",
22
34
  };
23
35
 
24
36
  const DESCRIPTION = {
@@ -29,6 +41,11 @@ const DESCRIPTION = {
29
41
  [ChangeType.LOCAL_MODIFIED]: "modified locally",
30
42
  [ChangeType.LOCAL_DELETED]: "deleted locally",
31
43
  [ChangeType.CONFLICT]: "both sides changed",
44
+ [ChangeType.PACK_LOCAL_MODIFIED]: "pack changed locally",
45
+ [ChangeType.PACK_REMOTE_MODIFIED]: "pack changed on Drive",
46
+ [ChangeType.PACK_SYNCED]: "pack up to date",
47
+ [ChangeType.PACK_CONFLICT]: "pack conflict",
48
+ [ChangeType.PACK_NEW]: "new pack",
32
49
  };
33
50
 
34
51
  const SUGGESTED_ACTION = {
@@ -39,6 +56,11 @@ const SUGGESTED_ACTION = {
39
56
  [ChangeType.LOCAL_MODIFIED]: "upload",
40
57
  [ChangeType.LOCAL_DELETED]: "delete_remote",
41
58
  [ChangeType.CONFLICT]: "conflict",
59
+ [ChangeType.PACK_LOCAL_MODIFIED]: "push_pack",
60
+ [ChangeType.PACK_REMOTE_MODIFIED]: "pull_pack",
61
+ [ChangeType.PACK_SYNCED]: "none",
62
+ [ChangeType.PACK_CONFLICT]: "resolve_pack",
63
+ [ChangeType.PACK_NEW]: "push_pack",
42
64
  };
43
65
 
44
66
  function createChange({
@@ -62,9 +84,10 @@ function createChange({
62
84
  };
63
85
  }
64
86
 
65
- function buildDiffResult(changes) {
87
+ function buildDiffResult(changes, packChanges = []) {
66
88
  return {
67
89
  changes,
90
+ packChanges,
68
91
  get remoteChanges() {
69
92
  return this.changes.filter((change) =>
70
93
  change.changeType.startsWith("remote")
@@ -80,8 +103,29 @@ function buildDiffResult(changes) {
80
103
  (change) => change.changeType === ChangeType.CONFLICT
81
104
  );
82
105
  },
106
+ get packConflicts() {
107
+ return this.packChanges.filter(
108
+ (change) => change.changeType === ChangeType.PACK_CONFLICT
109
+ );
110
+ },
111
+ get pendingPackChanges() {
112
+ return this.packChanges.filter(
113
+ (change) =>
114
+ change.changeType === ChangeType.PACK_LOCAL_MODIFIED ||
115
+ change.changeType === ChangeType.PACK_REMOTE_MODIFIED ||
116
+ change.changeType === ChangeType.PACK_NEW
117
+ );
118
+ },
119
+ get syncedPacks() {
120
+ return this.packChanges.filter(
121
+ (change) => change.changeType === ChangeType.PACK_SYNCED
122
+ );
123
+ },
124
+ get hasPackChanges() {
125
+ return this.pendingPackChanges.length > 0 || this.packConflicts.length > 0;
126
+ },
83
127
  get isClean() {
84
- return this.changes.length === 0;
128
+ return this.changes.length === 0 && this.pendingPackChanges.length === 0;
85
129
  },
86
130
  };
87
131
  }
@@ -154,6 +198,93 @@ function promoteConflicts(changes) {
154
198
  return filtered;
155
199
  }
156
200
 
201
+ /**
202
+ * Compute pack-level changes by comparing local packedDirs against manifest.
203
+ * @param {string|null} root - Workspace root for loading manifest
204
+ * @param {object} packedDirs - Local packed directories from scanLocal
205
+ * @param {object|null} snapshot - Previous snapshot (may contain packedDirs)
206
+ * @returns {object[]} Array of pack change objects
207
+ */
208
+ function computePackChanges(root, packedDirs, snapshot) {
209
+ if (!root || !packedDirs || Object.keys(packedDirs).length === 0) {
210
+ return [];
211
+ }
212
+
213
+ const manifest = loadPackManifest(root);
214
+ const snapshotPackedDirs = snapshot?.packedDirs || {};
215
+ const changes = [];
216
+
217
+ for (const [packPath, packInfo] of Object.entries(packedDirs)) {
218
+ const manifestEntry = manifest.packs?.[packPath];
219
+ const snapshotEntry = snapshotPackedDirs[packPath];
220
+ const localTreeHash = packInfo.treeHash;
221
+
222
+ if (!manifestEntry) {
223
+ // Pack not in manifest = new pack
224
+ changes.push(
225
+ createChange({
226
+ changeType: ChangeType.PACK_NEW,
227
+ path: packPath,
228
+ localMeta: packInfo,
229
+ })
230
+ );
231
+ continue;
232
+ }
233
+
234
+ const { localTreeHash: manifestLocalHash, remoteTreeHash: manifestRemoteHash } = manifestEntry;
235
+
236
+ // Check for local modification
237
+ const localChanged = localTreeHash !== manifestLocalHash;
238
+ // Check for remote modification (comparing against what we last synced)
239
+ const remoteChanged = manifestRemoteHash && manifestRemoteHash !== manifestLocalHash;
240
+
241
+ if (localChanged && remoteChanged) {
242
+ // Both sides changed = conflict
243
+ changes.push(
244
+ createChange({
245
+ changeType: ChangeType.PACK_CONFLICT,
246
+ path: packPath,
247
+ localMeta: { ...packInfo, treeHash: localTreeHash },
248
+ snapshotMeta: { treeHash: manifestLocalHash },
249
+ remoteMeta: { treeHash: manifestRemoteHash },
250
+ })
251
+ );
252
+ } else if (localChanged) {
253
+ // Only local changed
254
+ changes.push(
255
+ createChange({
256
+ changeType: ChangeType.PACK_LOCAL_MODIFIED,
257
+ path: packPath,
258
+ localMeta: { ...packInfo, treeHash: localTreeHash },
259
+ snapshotMeta: { treeHash: manifestLocalHash },
260
+ })
261
+ );
262
+ } else if (remoteChanged) {
263
+ // Only remote changed
264
+ changes.push(
265
+ createChange({
266
+ changeType: ChangeType.PACK_REMOTE_MODIFIED,
267
+ path: packPath,
268
+ localMeta: packInfo,
269
+ remoteMeta: { treeHash: manifestRemoteHash },
270
+ snapshotMeta: { treeHash: manifestLocalHash },
271
+ })
272
+ );
273
+ } else {
274
+ // No changes = synced
275
+ changes.push(
276
+ createChange({
277
+ changeType: ChangeType.PACK_SYNCED,
278
+ path: packPath,
279
+ localMeta: packInfo,
280
+ })
281
+ );
282
+ }
283
+ }
284
+
285
+ return changes;
286
+ }
287
+
157
288
  /**
158
289
  * @param {object|null} snapshot
159
290
  * @param {object[]} remoteFiles
@@ -182,6 +313,11 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
182
313
  if (ignoreRules) {
183
314
  remoteFiles = remoteFiles.filter((f) => !ignoreRules.ignores(f.path));
184
315
  }
316
+
317
+ // Handle new localFiles format with .files and .packedDirs
318
+ const localFilesData = localFiles?.files ?? localFiles;
319
+ const packedDirs = localFiles?.packedDirs ?? {};
320
+
185
321
  const changes = [];
186
322
  const snapshotFiles = snapshot?.files || {};
187
323
  const snapshotLocalFiles = snapshot?.localFiles || {};
@@ -189,13 +325,13 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
189
325
  // Build sets of all folder paths that implicitly exist on each side
190
326
  // (from parent directories of files), so we can skip redundant folder additions.
191
327
  const remoteFolderPaths = collectFolderPaths(remoteFiles.map((f) => f.path));
192
- const localFolderPaths = collectFolderPaths(Object.keys(localFiles));
328
+ const localFolderPaths = collectFolderPaths(Object.keys(localFilesData));
193
329
 
194
330
  // Also include explicit folder entries
195
331
  for (const f of remoteFiles) {
196
332
  if (f.isFolder) remoteFolderPaths.add(f.path);
197
333
  }
198
- for (const [p, meta] of Object.entries(localFiles)) {
334
+ for (const [p, meta] of Object.entries(localFilesData)) {
199
335
  if (meta.isFolder) localFolderPaths.add(p);
200
336
  }
201
337
 
@@ -254,7 +390,7 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
254
390
  }
255
391
  }
256
392
 
257
- for (const [relativePath, localMeta] of Object.entries(localFiles)) {
393
+ for (const [relativePath, localMeta] of Object.entries(localFilesData)) {
258
394
  const snapshotEntry = snapshotLocalFiles[relativePath];
259
395
 
260
396
  if (!snapshotEntry) {
@@ -285,7 +421,7 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
285
421
  }
286
422
 
287
423
  for (const [relativePath, snapshotEntry] of Object.entries(snapshotLocalFiles)) {
288
- if (!(relativePath in localFiles)) {
424
+ if (!(relativePath in localFilesData)) {
289
425
  // Skip folder deletion if the folder still implicitly exists locally
290
426
  if (snapshotEntry.isFolder && localFolderPaths.has(relativePath)) {
291
427
  continue;
@@ -300,5 +436,8 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
300
436
  }
301
437
  }
302
438
 
303
- return buildDiffResult(promoteConflicts(changes));
439
+ // Compute pack changes
440
+ const packChanges = computePackChanges(root, packedDirs, snapshot);
441
+
442
+ return buildDiffResult(promoteConflicts(changes), packChanges);
304
443
  }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Pack manifest CRUD operations.
3
+ * Manages the pack-manifest.json data structure.
4
+ */
5
+
6
+ import crypto from "node:crypto";
7
+
8
+ const MANIFEST_VERSION = 1;
9
+
10
+ /**
11
+ * Create a new empty manifest.
12
+ * @returns {{ version: number, packs: {} }}
13
+ */
14
+ export function createManifest() {
15
+ return {
16
+ version: MANIFEST_VERSION,
17
+ packs: {},
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Get pack information for a specific path.
23
+ * @param {object} manifest - Manifest object
24
+ * @param {string} packPath - Directory path (e.g., "node_modules")
25
+ * @returns {object|null} Pack info or null if not found
26
+ */
27
+ export function getPack(manifest, packPath) {
28
+ const normalized = normalizePath(packPath);
29
+ return manifest.packs[normalized] ?? null;
30
+ }
31
+
32
+ /**
33
+ * Set or update pack information.
34
+ * @param {object} manifest - Manifest object
35
+ * @param {string} packPath - Directory path
36
+ * @param {object} data - Pack data to set/merge
37
+ * @returns {object} Updated manifest
38
+ */
39
+ export function setPack(manifest, packPath, data) {
40
+ const normalized = normalizePath(packPath);
41
+ const existing = manifest.packs[normalized] ?? {};
42
+
43
+ manifest.packs[normalized] = {
44
+ ...existing,
45
+ ...data,
46
+ // Always update lastModified when setting
47
+ lastModified: new Date().toISOString(),
48
+ };
49
+
50
+ return manifest;
51
+ }
52
+
53
+ /**
54
+ * Remove a pack from the manifest.
55
+ * @param {object} manifest - Manifest object
56
+ * @param {string} packPath - Directory path
57
+ * @returns {object} Updated manifest
58
+ */
59
+ export function removePack(manifest, packPath) {
60
+ const normalized = normalizePath(packPath);
61
+ delete manifest.packs[normalized];
62
+ return manifest;
63
+ }
64
+
65
+ /**
66
+ * List all packs in the manifest.
67
+ * @param {object} manifest - Manifest object
68
+ * @returns {Array<{ path: string, info: object }>}
69
+ */
70
+ export function listPacks(manifest) {
71
+ return Object.entries(manifest.packs).map(([path, info]) => ({
72
+ path,
73
+ info,
74
+ }));
75
+ }
76
+
77
+ /**
78
+ * Check if a path is covered by any pack rule.
79
+ * @param {object} manifest - Manifest object
80
+ * @param {string} filePath - File path to check
81
+ * @returns {{ isPacked: boolean, packPath: string|null }}
82
+ */
83
+ export function isPathPacked(manifest, filePath) {
84
+ const normalized = normalizePath(filePath);
85
+
86
+ for (const packPath of Object.keys(manifest.packs)) {
87
+ // Check if filePath starts with packPath
88
+ if (normalized === packPath || normalized.startsWith(packPath + "/")) {
89
+ return { isPacked: true, packPath };
90
+ }
91
+ }
92
+
93
+ return { isPacked: false, packPath: null };
94
+ }
95
+
96
+ /**
97
+ * Validate manifest structure.
98
+ * @param {object} manifest - Manifest to validate
99
+ * @returns {{ valid: boolean, errors: string[] }}
100
+ */
101
+ export function validateManifest(manifest) {
102
+ const errors = [];
103
+
104
+ if (!manifest || typeof manifest !== "object") {
105
+ errors.push("Manifest must be an object");
106
+ return { valid: false, errors };
107
+ }
108
+
109
+ if (typeof manifest.version !== "number") {
110
+ errors.push("Manifest version must be a number");
111
+ }
112
+
113
+ if (manifest.version !== MANIFEST_VERSION) {
114
+ errors.push(`Unsupported manifest version: ${manifest.version} (expected ${MANIFEST_VERSION})`);
115
+ }
116
+
117
+ if (!manifest.packs || typeof manifest.packs !== "object") {
118
+ errors.push("Manifest packs must be an object");
119
+ } else {
120
+ for (const [path, info] of Object.entries(manifest.packs)) {
121
+ if (typeof path !== "string" || path.length === 0) {
122
+ errors.push(`Invalid pack path: ${path}`);
123
+ }
124
+ if (!info || typeof info !== "object") {
125
+ errors.push(`Pack info for "${path}" must be an object`);
126
+ } else {
127
+ if (!info.packId || typeof info.packId !== "string") {
128
+ errors.push(`Pack "${path}" missing valid packId`);
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ return { valid: errors.length === 0, errors };
135
+ }
136
+
137
+ /**
138
+ * Generate a unique pack ID.
139
+ * @param {string} packPath - Directory path
140
+ * @returns {string} Pack ID like "pack-node_modules-a1b2c3d4"
141
+ */
142
+ export function generatePackId(packPath) {
143
+ const normalized = normalizePath(packPath);
144
+ // Sanitize path for ID: replace / with _ and remove special chars
145
+ const sanitized = normalized
146
+ .replace(/\//g, "_")
147
+ .replace(/[^a-zA-Z0-9_-]/g, "");
148
+ const shortHash = crypto.randomBytes(4).toString("hex");
149
+ return `pack-${sanitized}-${shortHash}`;
150
+ }
151
+
152
+ /**
153
+ * Normalize a path for consistent manifest keys.
154
+ * @param {string} p - Path to normalize
155
+ * @returns {string}
156
+ */
157
+ function normalizePath(p) {
158
+ // Remove leading/trailing slashes and normalize to forward slashes
159
+ return p
160
+ .replace(/\\/g, "/")
161
+ .replace(/^\/+/, "")
162
+ .replace(/\/+$/, "");
163
+ }