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.
- package/CHANGELOG.md +26 -0
- package/README.md +57 -2
- package/docs/ARCHITECTURE.md +24 -0
- package/package.json +6 -3
- package/src/cli.js +33 -4
- package/src/core/compress.js +285 -0
- package/src/core/config.js +119 -0
- package/src/core/diff.js +165 -7
- package/src/core/pack-manifest.js +163 -0
- package/src/core/pack.js +355 -0
- package/src/core/snapshot.js +55 -9
- package/src/core/sync.js +25 -5
- package/src/tui/command-catalog.js +9 -0
package/src/core/config.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|