castle-web-cli 0.4.122 → 0.4.123

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.
@@ -0,0 +1,446 @@
1
+ // save-version / list-versions / restore-version.
2
+ //
3
+ // A version is a manual commit of the deck's files. Storage details are in
4
+ // versionStore.ts; this file is the behavior:
5
+ //
6
+ // save-version hashes the whole tree and records what moved
7
+ // save-version <paths...> records ONLY those paths -- a curated state, not
8
+ // the working tree at that moment
9
+ // list-versions [<id>] walks back from head (or from the given version)
10
+ // restore-version <id> puts the working tree back to that version,
11
+ // touching only paths some later version touched
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import { customAlphabet } from 'nanoid';
15
+ import { looksBinary, unifiedDiff } from './diffText.js';
16
+ import { COVER_FILE } from './localPaths.js';
17
+ import * as store from './versionStore.js';
18
+ // Version ids are alphanumeric on purpose: they are filenames, command-line
19
+ // positionals and (later) url segments, and nanoid's default alphabet includes
20
+ // `-`, which reads as a flag wherever an id is typed.
21
+ const newVersionId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 12);
22
+ function resolveProjectDir(dir) {
23
+ const projectDir = path.resolve(dir);
24
+ if (!fs.existsSync(projectDir))
25
+ throw new Error(`No such directory: ${projectDir}`);
26
+ return projectDir;
27
+ }
28
+ // A path on the command line may name a directory, which stands for every
29
+ // tracked file under it -- on disk AND in the parent manifest, so deleting a
30
+ // whole directory and saving it still records the deletions.
31
+ function expandSelection(projectDir, given, parentFiles) {
32
+ const workingFiles = store.walkDeckFiles(projectDir);
33
+ const selected = new Set();
34
+ for (const raw of given) {
35
+ const rel = store.toDeckRelative(projectDir, raw);
36
+ if (!rel)
37
+ throw new Error(`Path is outside the deck: ${raw}`);
38
+ if (store.isIgnoredPath(rel))
39
+ throw new Error(`Path is not tracked: ${rel}`);
40
+ const isDir = fs.existsSync(path.join(projectDir, rel)) && fs.statSync(path.join(projectDir, rel)).isDirectory();
41
+ if (isDir) {
42
+ const prefix = `${rel}/`;
43
+ for (const p of workingFiles)
44
+ if (p.startsWith(prefix))
45
+ selected.add(p);
46
+ for (const p of Object.keys(parentFiles))
47
+ if (p.startsWith(prefix))
48
+ selected.add(p);
49
+ continue;
50
+ }
51
+ if (!fs.existsSync(path.join(projectDir, rel)) && !(rel in parentFiles)) {
52
+ throw new Error(`No such file: ${rel}`);
53
+ }
54
+ selected.add(rel);
55
+ }
56
+ return [...selected].sort();
57
+ }
58
+ // The whole tree becomes the manifest. Content is stored as we go, so an
59
+ // unchanged file costs a read and a hash and writes nothing new.
60
+ function manifestFromTree(projectDir) {
61
+ const files = {};
62
+ for (const rel of store.walkDeckFiles(projectDir)) {
63
+ const hash = store.storeWorkingFile(projectDir, rel);
64
+ if (hash)
65
+ files[rel] = hash;
66
+ }
67
+ return files;
68
+ }
69
+ // The parent's manifest with only the selected paths overridden. Everything
70
+ // else keeps the parent's hash -- so the version describes a curated state, and
71
+ // the paths left out stay dirty on disk for the next save to pick up.
72
+ function manifestFromSelection(projectDir, parentFiles, selected) {
73
+ const files = { ...parentFiles };
74
+ for (const rel of selected) {
75
+ const hash = store.storeWorkingFile(projectDir, rel);
76
+ if (hash)
77
+ files[rel] = hash;
78
+ else
79
+ delete files[rel];
80
+ }
81
+ return files;
82
+ }
83
+ // The save itself, with no output -- what the CLI command and the editor's
84
+ // Save version button both run.
85
+ export function createVersion(projectDir, opts = {}) {
86
+ const parentId = store.readHead(projectDir);
87
+ const parentFiles = store.parentManifest(projectDir, parentId);
88
+ const given = opts.paths ?? [];
89
+ const selected = given.length > 0 ? expandSelection(projectDir, given, parentFiles) : null;
90
+ const files = selected
91
+ ? manifestFromSelection(projectDir, parentFiles, selected)
92
+ : manifestFromTree(projectDir);
93
+ const changes = store.diffManifests(parentFiles, files, selected ?? undefined);
94
+ if (Object.keys(changes).length === 0)
95
+ return { version: null, partial: selected !== null };
96
+ const version = {
97
+ id: newVersionId(),
98
+ parentId,
99
+ createdAt: new Date().toISOString(),
100
+ message: opts.message ?? '',
101
+ files,
102
+ changes,
103
+ };
104
+ store.writeVersion(projectDir, version);
105
+ store.writeHead(projectDir, version.id);
106
+ return { version, partial: selected !== null };
107
+ }
108
+ export function saveVersion(dir, opts = {}) {
109
+ const projectDir = resolveProjectDir(dir);
110
+ const { version, partial } = createVersion(projectDir, opts);
111
+ if (!version) {
112
+ console.log(partial ? 'No changes in the selected paths. Nothing saved.' : 'No changes. Nothing saved.');
113
+ return;
114
+ }
115
+ const changes = version.changes;
116
+ const count = Object.keys(changes).length;
117
+ console.log(`Saved version ${version.id} (${count} file${count === 1 ? '' : 's'} changed).`);
118
+ for (const [p, change] of Object.entries(changes)) {
119
+ const mark = change.before === null ? 'added' : change.after === null ? 'deleted' : 'changed';
120
+ console.log(` ${mark} ${p}`);
121
+ }
122
+ if (partial) {
123
+ console.log('Partial save: unselected files are still dirty on disk.');
124
+ }
125
+ }
126
+ // The deck cover is rewritten every time the deck is played, so it is
127
+ // permanently dirty. It is versioned and restored like any other file, but it
128
+ // never counts as a change worth reporting and never blocks a restore -- the
129
+ // old cover it writes back regenerates on the next play. Without this, every
130
+ // restore refused on a file nobody edited.
131
+ function isReportableChange(path) {
132
+ return path !== COVER_FILE;
133
+ }
134
+ function changeKind(change) {
135
+ if (change.before === null)
136
+ return 'added';
137
+ if (change.after === null)
138
+ return 'deleted';
139
+ return 'changed';
140
+ }
141
+ /**
142
+ * What has changed on disk since head, in the same shape a version's `changes`
143
+ * takes. Hashes the tree WITHOUT storing anything -- asking what is outstanding
144
+ * must not write to the store.
145
+ */
146
+ export function pendingChanges(projectDir) {
147
+ const headFiles = store.parentManifest(projectDir, store.readHead(projectDir));
148
+ const current = {};
149
+ for (const rel of store.walkDeckFiles(projectDir)) {
150
+ const hash = store.hashWorkingFile(projectDir, rel);
151
+ if (hash)
152
+ current[rel] = hash;
153
+ }
154
+ return Object.entries(store.diffManifests(headFiles, current))
155
+ .filter(([path]) => isReportableChange(path))
156
+ .map(([path, change]) => ({ path, kind: changeKind(change) }));
157
+ }
158
+ function toSummary(version, later) {
159
+ return {
160
+ id: version.id,
161
+ parentId: version.parentId,
162
+ createdAt: version.createdAt,
163
+ message: version.message,
164
+ changes: Object.entries(version.changes)
165
+ .filter(([path]) => isReportableChange(path))
166
+ .map(([path, change]) => ({ path, kind: changeKind(change) })),
167
+ later,
168
+ };
169
+ }
170
+ /**
171
+ * The deck's history newest first. From head (no `fromId`): the chain forward
172
+ * from head -- marked `later` -- then head, then its ancestors. From a named
173
+ * version: that version and its ancestors, as it has always been.
174
+ *
175
+ * The forward walk is why a restore no longer makes the versions after it
176
+ * vanish from the panel and from `list-versions`. It follows one chain of
177
+ * children, newest child at each step. After a save there is nothing to follow,
178
+ * because the new version is a leaf, so the forward rows drop away on their own
179
+ * -- the abandoned versions are still on disk, just no longer reachable forward
180
+ * from head.
181
+ *
182
+ * Naming a version asks a different question -- how did THIS version come to be
183
+ * -- so that form stays a walk back and is unchanged.
184
+ */
185
+ export function versionSummaries(projectDir, fromId) {
186
+ const startId = fromId ?? store.readHead(projectDir);
187
+ if (!startId)
188
+ return [];
189
+ if (!store.versionExists(projectDir, startId))
190
+ throw new Error(`No such version: ${startId}`);
191
+ const forward = fromId ? [] : store.forwardChain(projectDir, startId);
192
+ return [
193
+ // Nearest child first out of the walk, so reverse it: the furthest
194
+ // descendant is the newest thing there is.
195
+ ...[...forward].reverse().map((v) => toSummary(v, true)),
196
+ ...store.versionChain(projectDir, startId).map((v) => toSummary(v, false)),
197
+ ];
198
+ }
199
+ export function listVersions(dir, version) {
200
+ const summaries = versionSummaries(resolveProjectDir(dir), version);
201
+ if (summaries.length === 0) {
202
+ console.log('No versions yet.');
203
+ return;
204
+ }
205
+ for (const v of summaries) {
206
+ const count = v.changes.length;
207
+ const message = v.message ? ` ${v.message}` : '';
208
+ // The same history the panel shows, marked the same way, so the two never
209
+ // disagree about what happened.
210
+ const later = v.later ? ' (later)' : '';
211
+ console.log(`${v.id} ${v.createdAt} ${count} file${count === 1 ? '' : 's'}${later}${message}`);
212
+ }
213
+ }
214
+ // One file's side of a change, as text -- or null when it wasn't there (an add
215
+ // or a delete) and `binary: true` when it must not be rendered as a patch.
216
+ function blobText(projectDir, hash) {
217
+ if (hash === null)
218
+ return null;
219
+ const content = store.getBlob(projectDir, hash);
220
+ return looksBinary(content) ? { text: '', binary: true } : { text: content.toString('utf8'), binary: false };
221
+ }
222
+ function showFileChange(projectDir, filePath, change) {
223
+ const kind = changeKind(change);
224
+ const before = blobText(projectDir, change.before);
225
+ const after = blobText(projectDir, change.after);
226
+ // A sprite or an image must never come out as a garbled patch; a line saying
227
+ // what happened to it is the whole useful answer.
228
+ if (before?.binary || after?.binary) {
229
+ console.log(`${filePath} ${kind}, binary`);
230
+ return;
231
+ }
232
+ console.log(`${filePath} ${kind}`);
233
+ const hunks = unifiedDiff(before?.text ?? '', after?.text ?? '');
234
+ if (!hunks) {
235
+ console.log(' (too large to show as a diff)');
236
+ return;
237
+ }
238
+ for (const line of hunks)
239
+ console.log(line);
240
+ }
241
+ /**
242
+ * What one version changed, as diffs of the CHANGED FILES ONLY -- built from the
243
+ * two blobs each entry in `changes` already names, so nothing new is stored and
244
+ * the whole tree is never read. The CLI counterpart of the panel's row
245
+ * expansion, for an agent working out what a change did.
246
+ */
247
+ export function showVersion(dir, versionId) {
248
+ const projectDir = resolveProjectDir(dir);
249
+ if (!store.versionExists(projectDir, versionId))
250
+ throw new Error(`No such version: ${versionId}`);
251
+ const version = store.readVersion(projectDir, versionId);
252
+ const paths = Object.keys(version.changes).sort();
253
+ console.log(`${version.id} ${version.createdAt} ${paths.length} file${paths.length === 1 ? '' : 's'}`);
254
+ if (version.message)
255
+ console.log(version.message);
256
+ console.log('');
257
+ for (const filePath of paths) {
258
+ showFileChange(projectDir, filePath, version.changes[filePath]);
259
+ console.log('');
260
+ }
261
+ }
262
+ /**
263
+ * The versions from `from` back to `to`, exclusive of `to`, newest first -- or
264
+ * null when `to` is not an ancestor of `from`. Only `parentId` is stored, so
265
+ * this is the only direction the history can be walked directly.
266
+ */
267
+ function chainDownTo(projectDir, from, to) {
268
+ const chain = [];
269
+ const seen = new Set();
270
+ let cursor = from;
271
+ while (cursor && cursor !== to) {
272
+ if (seen.has(cursor))
273
+ throw new Error(`Version history loops at ${cursor}`);
274
+ seen.add(cursor);
275
+ const version = store.readVersion(projectDir, cursor);
276
+ chain.push(version);
277
+ cursor = version.parentId;
278
+ }
279
+ return cursor === to ? chain : null;
280
+ }
281
+ /**
282
+ * What a restore should write, and what it expects to find on disk first.
283
+ *
284
+ * The two directions are mirrors of each other over the SAME set of versions --
285
+ * the ones between head and the target -- read from opposite ends:
286
+ *
287
+ * backward (the target is an ancestor of head): put back the EARLIEST
288
+ * `before`, the state closest to the target; expect the LATEST `after`, the
289
+ * state closest to head.
290
+ * forward (the target is a descendant of head): put back the LATEST `after`,
291
+ * again the state closest to the target; expect the EARLIEST `before`,
292
+ * again the state closest to head.
293
+ *
294
+ * Either way a path no version between the two touched never enters the plan,
295
+ * so unsaved work on it survives.
296
+ */
297
+ function planRestore(projectDir, targetId) {
298
+ const head = store.readHead(projectDir);
299
+ if (!head)
300
+ throw new Error('No versions yet.');
301
+ const backward = chainDownTo(projectDir, head, targetId);
302
+ if (backward)
303
+ return planFromChain(backward, 'backward');
304
+ const forward = chainDownTo(projectDir, targetId, head);
305
+ if (forward)
306
+ return planFromChain(forward, 'forward');
307
+ throw new NotOnThisLine(targetId, head);
308
+ }
309
+ // `chain` is always newest first, so "first seen wins" reads the end nearest the
310
+ // newer version and "last write wins" reads the end nearest the older one.
311
+ function planFromChain(chain, direction) {
312
+ const plan = {
313
+ wanted: {},
314
+ expected: {},
315
+ offChain: direction === 'backward' ? chain.length : 0,
316
+ };
317
+ for (const version of chain) {
318
+ for (const [p, change] of Object.entries(version.changes)) {
319
+ if (direction === 'backward') {
320
+ plan.wanted[p] = change.before;
321
+ if (!(p in plan.expected))
322
+ plan.expected[p] = change.after;
323
+ }
324
+ else {
325
+ if (!(p in plan.wanted))
326
+ plan.wanted[p] = change.after;
327
+ plan.expected[p] = change.before;
328
+ }
329
+ }
330
+ }
331
+ return plan;
332
+ }
333
+ function removeEmptyParents(projectDir, relPath) {
334
+ let dir = path.dirname(path.join(projectDir, relPath));
335
+ while (dir.startsWith(projectDir) && dir !== projectDir) {
336
+ try {
337
+ if (fs.readdirSync(dir).length > 0)
338
+ return;
339
+ fs.rmdirSync(dir);
340
+ }
341
+ catch {
342
+ return;
343
+ }
344
+ dir = path.dirname(dir);
345
+ }
346
+ }
347
+ function applyRestore(projectDir, actions) {
348
+ for (const [rel, hash] of actions) {
349
+ const abs = path.join(projectDir, rel);
350
+ if (hash === null) {
351
+ fs.rmSync(abs, { force: true });
352
+ removeEmptyParents(projectDir, rel);
353
+ continue;
354
+ }
355
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
356
+ fs.writeFileSync(abs, store.getBlob(projectDir, hash));
357
+ }
358
+ }
359
+ // Work no version has recorded, which a restore would overwrite. The paths ARE
360
+ // the answer to "what would I lose", so they ride on the error and each caller
361
+ // says what to do about it -- the CLI offers `--force`, the panel has no such
362
+ // thing and says to save a version first.
363
+ export class UnsavedChanges extends Error {
364
+ paths;
365
+ constructor(paths) {
366
+ const count = paths.length;
367
+ super(`Unsaved changes would be lost in ${count} file${count === 1 ? '' : 's'}:\n` +
368
+ paths.map((p) => ` ${p}`).join('\n'));
369
+ this.name = 'UnsavedChanges';
370
+ this.paths = paths;
371
+ }
372
+ }
373
+ // A version on a different branch: neither an ancestor of head nor a descendant
374
+ // of it. Restore moves along one line in either direction; getting from here to
375
+ // there would mean merging, which is not something this build does silently.
376
+ export class NotOnThisLine extends Error {
377
+ constructor(targetId, head) {
378
+ super(`Version ${targetId} is on a different branch than ${head}. ` +
379
+ 'Restore moves back or forward along one line, not across a fork.');
380
+ this.name = 'NotOnThisLine';
381
+ }
382
+ }
383
+ // The restore itself, with no output. Throws on unsaved changes unless forced --
384
+ // the message names every conflicting path, since that list IS the answer to
385
+ // "what would I lose".
386
+ export function applyVersionRestore(projectDir, targetId, opts = {}) {
387
+ if (!store.versionExists(projectDir, targetId))
388
+ throw new Error(`No such version: ${targetId}`);
389
+ if (store.readHead(projectDir) === targetId) {
390
+ return { alreadyThere: true, applied: [], offChain: 0 };
391
+ }
392
+ const plan = planRestore(projectDir, targetId);
393
+ // Only paths whose content would actually move are checked and applied: a
394
+ // path already holding what the restore wants is neither a conflict nor work.
395
+ const actions = [];
396
+ const conflicts = [];
397
+ for (const [rel, wanted] of Object.entries(plan.wanted)) {
398
+ const current = store.hashWorkingFile(projectDir, rel);
399
+ if (current === wanted)
400
+ continue;
401
+ // The cover is exempt: it is dirty by construction, so refusing on it would
402
+ // refuse every restore. It still gets written back.
403
+ if (current !== plan.expected[rel] && isReportableChange(rel))
404
+ conflicts.push(rel);
405
+ actions.push([rel, wanted]);
406
+ }
407
+ if (conflicts.length > 0 && !opts.force)
408
+ throw new UnsavedChanges(conflicts.sort());
409
+ applyRestore(projectDir, actions.sort((a, b) => a[0].localeCompare(b[0])));
410
+ // Head moves to the target, so the next save continues from there and the
411
+ // versions we walked past fork off. They stay on disk and stay loadable by
412
+ // id -- they are just no longer on head's chain.
413
+ store.writeHead(projectDir, targetId);
414
+ return { alreadyThere: false, applied: actions, offChain: plan.offChain };
415
+ }
416
+ export function restoreVersion(dir, targetId, opts = {}) {
417
+ const projectDir = resolveProjectDir(dir);
418
+ let outcome;
419
+ try {
420
+ outcome = applyVersionRestore(projectDir, targetId, opts);
421
+ }
422
+ catch (e) {
423
+ // Only the command line has a --force, so only the command line mentions it.
424
+ if (e instanceof UnsavedChanges) {
425
+ throw new Error(`${e.message}\nSave a version first, or pass --force to restore anyway.`);
426
+ }
427
+ throw e;
428
+ }
429
+ if (outcome.alreadyThere) {
430
+ console.log(`Already at version ${targetId}. Nothing to restore.`);
431
+ return;
432
+ }
433
+ const applied = outcome.applied;
434
+ if (applied.length === 0) {
435
+ console.log(`The working tree already matched version ${targetId}.`);
436
+ }
437
+ else {
438
+ console.log(`Restored ${applied.length} file${applied.length === 1 ? '' : 's'} to version ${targetId}.`);
439
+ for (const [rel, hash] of applied)
440
+ console.log(` ${hash === null ? 'deleted' : 'restored'} ${rel}`);
441
+ }
442
+ // A silent head move is the thing people discover a week later. What the move
443
+ // left behind needs no announcement: `list-versions` shows those versions,
444
+ // marked `(later)`.
445
+ console.log(`head is now ${targetId}.`);
446
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.122",
3
+ "version": "0.4.123",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"