aethel 1.0.0 → 1.2.1

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.
@@ -5,6 +5,8 @@
5
5
  import crypto from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
+ import YAML from "yaml";
9
+ import { createManifest } from "./pack-manifest.js";
8
10
 
9
11
  export const AETHEL_DIR = ".aethel";
10
12
  export const CONFIG_FILE = "config.json";
@@ -12,6 +14,8 @@ export const INDEX_FILE = "index.json";
12
14
  export const SNAPSHOTS_DIR = "snapshots";
13
15
  export const HISTORY_DIR = "history";
14
16
  export const LATEST_SNAPSHOT = "latest.json";
17
+ export const PACK_MANIFEST_FILE = "pack-manifest.json";
18
+ export const PACK_CONFIG_FILE = ".aethelconfig";
15
19
 
16
20
  /** Walk up from `start` looking for a .aethel/ directory. */
17
21
  export function findRoot(start = process.cwd()) {
@@ -135,3 +139,118 @@ export function writeSnapshot(root, snapshot) {
135
139
  // Compact JSON — snapshots can be large, pretty-printing is slow + wastes disk
136
140
  fs.writeFileSync(latest, JSON.stringify(snapshot) + "\n");
137
141
  }
142
+
143
+ // ── pack config helpers ───────────────────────────────────────────────
144
+
145
+ const DEFAULT_PACK_CONFIG = {
146
+ packing: {
147
+ enabled: false,
148
+ compression: {
149
+ default: {
150
+ algorithm: "zstd",
151
+ level: 6,
152
+ },
153
+ overrides: [],
154
+ },
155
+ rules: [],
156
+ },
157
+ };
158
+
159
+ /**
160
+ * Load pack configuration from .aethelconfig (YAML).
161
+ * Returns default config if file doesn't exist.
162
+ * @param {string} root - Workspace root
163
+ * @returns {object} Pack configuration
164
+ */
165
+ export function loadPackConfig(root) {
166
+ const p = path.join(root, PACK_CONFIG_FILE);
167
+ if (!fs.existsSync(p)) {
168
+ return structuredClone(DEFAULT_PACK_CONFIG);
169
+ }
170
+ try {
171
+ const content = fs.readFileSync(p, "utf-8");
172
+ const parsed = YAML.parse(content);
173
+ // Merge with defaults for missing keys
174
+ return {
175
+ packing: {
176
+ ...DEFAULT_PACK_CONFIG.packing,
177
+ ...parsed?.packing,
178
+ compression: {
179
+ ...DEFAULT_PACK_CONFIG.packing.compression,
180
+ ...parsed?.packing?.compression,
181
+ },
182
+ },
183
+ };
184
+ } catch {
185
+ return structuredClone(DEFAULT_PACK_CONFIG);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Save pack configuration to .aethelconfig.
191
+ * @param {string} root - Workspace root
192
+ * @param {object} config - Configuration to save
193
+ */
194
+ export function savePackConfig(root, config) {
195
+ const p = path.join(root, PACK_CONFIG_FILE);
196
+ const content = YAML.stringify(config);
197
+ fs.writeFileSync(p, content);
198
+ }
199
+
200
+ /**
201
+ * Read pack manifest from .aethel/pack-manifest.json.
202
+ * Returns empty manifest if file doesn't exist.
203
+ * @param {string} root - Workspace root
204
+ * @returns {object} Pack manifest
205
+ */
206
+ export function loadPackManifest(root) {
207
+ const p = path.join(dot(root), PACK_MANIFEST_FILE);
208
+ if (!fs.existsSync(p)) {
209
+ return createManifest();
210
+ }
211
+ try {
212
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
213
+ } catch {
214
+ return createManifest();
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Save pack manifest to .aethel/pack-manifest.json.
220
+ * @param {string} root - Workspace root
221
+ * @param {object} manifest - Manifest to save
222
+ */
223
+ export function savePackManifest(root, manifest) {
224
+ const p = path.join(dot(root), PACK_MANIFEST_FILE);
225
+ fs.writeFileSync(p, JSON.stringify(manifest, null, 2) + "\n");
226
+ }
227
+
228
+ /**
229
+ * Check if packing feature is enabled.
230
+ * @param {string} root - Workspace root
231
+ * @returns {boolean}
232
+ */
233
+ export function isPackingEnabled(root) {
234
+ const config = loadPackConfig(root);
235
+ return config.packing?.enabled === true;
236
+ }
237
+
238
+ /**
239
+ * Get packing rule for a specific path.
240
+ * @param {object} packConfig - Pack configuration
241
+ * @param {string} relativePath - Path to check
242
+ * @returns {object|null} Matching rule or null
243
+ */
244
+ export function getPackRule(packConfig, relativePath) {
245
+ const rules = packConfig.packing?.rules ?? [];
246
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
247
+
248
+ for (const rule of rules) {
249
+ const rulePath = rule.path.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
250
+ if (normalized === rulePath || normalized.startsWith(rulePath + "/")) {
251
+ return rule;
252
+ }
253
+ }
254
+
255
+ return null;
256
+ }
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
@@ -175,6 +306,20 @@ function collectFolderPaths(filePaths) {
175
306
  return folders;
176
307
  }
177
308
 
309
+ function indexSnapshotFilesByPath(snapshotFiles) {
310
+ const byPath = new Map();
311
+
312
+ for (const [fileId, entry] of Object.entries(snapshotFiles || {})) {
313
+ for (const pathValue of [entry.path, entry.localPath]) {
314
+ if (pathValue && !byPath.has(pathValue)) {
315
+ byPath.set(pathValue, { fileId, entry });
316
+ }
317
+ }
318
+ }
319
+
320
+ return byPath;
321
+ }
322
+
178
323
  export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIgnore = true } = {}) {
179
324
  const ignoreRules = root && respectIgnore ? loadIgnoreRules(root) : null;
180
325
 
@@ -182,20 +327,26 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
182
327
  if (ignoreRules) {
183
328
  remoteFiles = remoteFiles.filter((f) => !ignoreRules.ignores(f.path));
184
329
  }
330
+
331
+ // Handle new localFiles format with .files and .packedDirs
332
+ const localFilesData = localFiles?.files ?? localFiles;
333
+ const packedDirs = localFiles?.packedDirs ?? {};
334
+
185
335
  const changes = [];
186
336
  const snapshotFiles = snapshot?.files || {};
187
337
  const snapshotLocalFiles = snapshot?.localFiles || {};
338
+ const snapshotRemoteByPath = indexSnapshotFilesByPath(snapshotFiles);
188
339
 
189
340
  // Build sets of all folder paths that implicitly exist on each side
190
341
  // (from parent directories of files), so we can skip redundant folder additions.
191
342
  const remoteFolderPaths = collectFolderPaths(remoteFiles.map((f) => f.path));
192
- const localFolderPaths = collectFolderPaths(Object.keys(localFiles));
343
+ const localFolderPaths = collectFolderPaths(Object.keys(localFilesData));
193
344
 
194
345
  // Also include explicit folder entries
195
346
  for (const f of remoteFiles) {
196
347
  if (f.isFolder) remoteFolderPaths.add(f.path);
197
348
  }
198
- for (const [p, meta] of Object.entries(localFiles)) {
349
+ for (const [p, meta] of Object.entries(localFilesData)) {
199
350
  if (meta.isFolder) localFolderPaths.add(p);
200
351
  }
201
352
 
@@ -254,7 +405,7 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
254
405
  }
255
406
  }
256
407
 
257
- for (const [relativePath, localMeta] of Object.entries(localFiles)) {
408
+ for (const [relativePath, localMeta] of Object.entries(localFilesData)) {
258
409
  const snapshotEntry = snapshotLocalFiles[relativePath];
259
410
 
260
411
  if (!snapshotEntry) {
@@ -273,10 +424,12 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
273
424
  }
274
425
 
275
426
  if (localChanged(snapshotEntry, localMeta)) {
427
+ const remoteEntry = snapshotRemoteByPath.get(relativePath);
276
428
  changes.push(
277
429
  createChange({
278
430
  changeType: ChangeType.LOCAL_MODIFIED,
279
431
  path: relativePath,
432
+ fileId: remoteEntry?.fileId || null,
280
433
  localMeta,
281
434
  snapshotMeta: snapshotEntry,
282
435
  })
@@ -285,20 +438,25 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
285
438
  }
286
439
 
287
440
  for (const [relativePath, snapshotEntry] of Object.entries(snapshotLocalFiles)) {
288
- if (!(relativePath in localFiles)) {
441
+ if (!(relativePath in localFilesData)) {
289
442
  // Skip folder deletion if the folder still implicitly exists locally
290
443
  if (snapshotEntry.isFolder && localFolderPaths.has(relativePath)) {
291
444
  continue;
292
445
  }
446
+ const remoteEntry = snapshotRemoteByPath.get(relativePath);
293
447
  changes.push(
294
448
  createChange({
295
449
  changeType: ChangeType.LOCAL_DELETED,
296
450
  path: relativePath,
451
+ fileId: remoteEntry?.fileId || null,
297
452
  snapshotMeta: snapshotEntry,
298
453
  })
299
454
  );
300
455
  }
301
456
  }
302
457
 
303
- return buildDiffResult(promoteConflicts(changes));
458
+ // Compute pack changes
459
+ const packChanges = computePackChanges(root, packedDirs, snapshot);
460
+
461
+ return buildDiffResult(promoteConflicts(changes), packChanges);
304
462
  }
@@ -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
+ }