castle-web-cli 0.4.121 → 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.
@@ -10,8 +10,8 @@
10
10
  <link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
11
11
  <link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
12
12
  <link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
13
- <script type="module" crossorigin src="/__castle/ide/assets/index-UEwjXWKI.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-BkJ87APM.css">
13
+ <script type="module" crossorigin src="/__castle/ide/assets/index-DWgt4KzC.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-CARLHafh.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -0,0 +1,54 @@
1
+ export interface VersionChange {
2
+ /** Hash before this version, or null when the version added the file. */
3
+ before: string | null;
4
+ /** Hash after this version, or null when the version deleted the file. */
5
+ after: string | null;
6
+ }
7
+ export interface Version {
8
+ id: string;
9
+ parentId: string | null;
10
+ createdAt: string;
11
+ message: string;
12
+ /** Complete manifest at this version: deck-relative path -> content hash. */
13
+ files: Record<string, string>;
14
+ /** Only the paths this version touched. */
15
+ changes: Record<string, VersionChange>;
16
+ }
17
+ export declare function versionsRoot(projectDir: string): string;
18
+ export declare function readHead(projectDir: string): string | null;
19
+ export declare function writeHead(projectDir: string, id: string): void;
20
+ export declare const VERSION_EXT = ".version";
21
+ export declare function versionExists(projectDir: string, id: string): boolean;
22
+ export declare function readVersion(projectDir: string, id: string): Version;
23
+ export declare function writeVersion(projectDir: string, version: Version): void;
24
+ export declare function hashBytes(content: Buffer): string;
25
+ export declare function putBlob(projectDir: string, content: Buffer): string;
26
+ export declare function getBlob(projectDir: string, hash: string): Buffer;
27
+ export declare function blobCount(projectDir: string): number;
28
+ /** Hash of the file as it is on disk right now, or null when it isn't there. */
29
+ export declare function hashWorkingFile(projectDir: string, relPath: string): string | null;
30
+ /** Hash the file and store its content, or null when it isn't there. */
31
+ export declare function storeWorkingFile(projectDir: string, relPath: string): string | null;
32
+ export declare function isIgnoredPath(relPath: string): boolean;
33
+ /**
34
+ * Every tracked file in the deck, as sorted deck-relative POSIX paths. Files are
35
+ * bytes here -- nothing assumes text, and nothing looks at an extension.
36
+ */
37
+ export declare function walkDeckFiles(projectDir: string): string[];
38
+ /** Turn a path as typed on the command line into a deck-relative POSIX path. */
39
+ export declare function toDeckRelative(projectDir: string, given: string): string | null;
40
+ /**
41
+ * Versions from `fromId` back along `parentId`, newest first. Follows the stored
42
+ * parent links rather than any list order, so a forked history walks correctly.
43
+ */
44
+ export declare function versionChain(projectDir: string, fromId: string): Version[];
45
+ export declare function readAllVersions(projectDir: string): Version[];
46
+ /**
47
+ * The chain of children from `fromId`, nearest first, following the most
48
+ * recently created child at each step. One chain, not a subtree: a fork picks a
49
+ * side, deterministically, rather than fanning out.
50
+ */
51
+ export declare function forwardChain(projectDir: string, fromId: string): Version[];
52
+ /** Manifest of a version, or an empty one for the very first version. */
53
+ export declare function parentManifest(projectDir: string, parentId: string | null): Record<string, string>;
54
+ export declare function diffManifests(before: Record<string, string>, after: Record<string, string>, onlyPaths?: string[]): Record<string, VersionChange>;
@@ -0,0 +1,281 @@
1
+ // The on-disk store behind save-version / list-versions / restore-version.
2
+ //
3
+ // Everything lives under the deck at `.castle/versions/`:
4
+ // data/<sha256> gzipped file content, addressed by the hash of the RAW
5
+ // bytes -- so identical content across files or versions
6
+ // is stored once.
7
+ // versions/<id>.version one version object.
8
+ // head id of the latest version.
9
+ //
10
+ // `.version` rather than `.json` so a version is a real file at a real path
11
+ // with a type of its own: the deck's `editor.fileTypes` routing can hand it an
12
+ // editor later without a synthetic path or an open-by-id panel. `.castle` is
13
+ // ignored, so versions never version themselves.
14
+ //
15
+ // A version carries a COMPLETE manifest (`files`) plus the paths it touched
16
+ // (`changes`). `changes` is derivable from the parent's manifest, but is stored
17
+ // explicitly so a restore works without the parent object being present -- which
18
+ // is what lets only the last N versions be materialized locally later.
19
+ //
20
+ // Two rules keep branching open even though this build has none: a version
21
+ // always records its `parentId`, and a version is only ever identified by that
22
+ // opaque id -- never by a position in a list, and nothing here assumes the
23
+ // history is linear.
24
+ import * as crypto from 'crypto';
25
+ import * as fs from 'fs';
26
+ import * as path from 'path';
27
+ import * as zlib from 'zlib';
28
+ import { toPosixPath } from './localPaths.js';
29
+ // Not tracked: dependencies, build output, our own store, git, and imported
30
+ // decks (whose files belong to the deck that published them).
31
+ const IGNORED_SEGMENTS = new Set(['node_modules', '.castle', '.git', 'dist', 'imports']);
32
+ export function versionsRoot(projectDir) {
33
+ return path.join(projectDir, '.castle', 'versions');
34
+ }
35
+ function dataDir(projectDir) {
36
+ return path.join(versionsRoot(projectDir), 'data');
37
+ }
38
+ function versionsDir(projectDir) {
39
+ return path.join(versionsRoot(projectDir), 'versions');
40
+ }
41
+ function headPath(projectDir) {
42
+ return path.join(versionsRoot(projectDir), 'head');
43
+ }
44
+ export function readHead(projectDir) {
45
+ try {
46
+ const id = fs.readFileSync(headPath(projectDir), 'utf-8').trim();
47
+ return id || null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ export function writeHead(projectDir, id) {
54
+ fs.mkdirSync(versionsRoot(projectDir), { recursive: true });
55
+ fs.writeFileSync(headPath(projectDir), id + '\n', 'utf-8');
56
+ }
57
+ // A version's own file. Its extension is part of the format, not an internal
58
+ // detail: a deck can route `.version` to an editor of its own.
59
+ export const VERSION_EXT = '.version';
60
+ function versionFile(projectDir, id) {
61
+ return path.join(versionsDir(projectDir), `${id}${VERSION_EXT}`);
62
+ }
63
+ export function versionExists(projectDir, id) {
64
+ return fs.existsSync(versionFile(projectDir, id));
65
+ }
66
+ export function readVersion(projectDir, id) {
67
+ const file = versionFile(projectDir, id);
68
+ if (!fs.existsSync(file))
69
+ throw new Error(`No such version: ${id}`);
70
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
71
+ }
72
+ // Blobs and version files are write-once: a blob is named by its own content
73
+ // and a version id is never reused, so nothing here is ever rewritten. Marking
74
+ // them read-only AT CREATION therefore costs nothing and is never unlocked --
75
+ // no race, no unlock window. `head` stays writable, being the one mutable thing
76
+ // in the store, and the directories stay writable so new files can be created
77
+ // and a future gc can delete.
78
+ const READ_ONLY = 0o444;
79
+ function writeOnce(file, content) {
80
+ fs.writeFileSync(file, content);
81
+ fs.chmodSync(file, READ_ONLY);
82
+ }
83
+ export function writeVersion(projectDir, version) {
84
+ fs.mkdirSync(versionsDir(projectDir), { recursive: true });
85
+ writeOnce(versionFile(projectDir, version.id), JSON.stringify(version, null, 2) + '\n');
86
+ }
87
+ export function hashBytes(content) {
88
+ return crypto.createHash('sha256').update(content).digest('hex');
89
+ }
90
+ // Content-addressed, so an identical blob is written once and every later
91
+ // reference is free.
92
+ export function putBlob(projectDir, content) {
93
+ const hash = hashBytes(content);
94
+ const blob = path.join(dataDir(projectDir), hash);
95
+ if (!fs.existsSync(blob)) {
96
+ fs.mkdirSync(dataDir(projectDir), { recursive: true });
97
+ writeOnce(blob, zlib.gzipSync(content));
98
+ }
99
+ return hash;
100
+ }
101
+ // Read is where corruption surfaces, so the hash is checked here: the content is
102
+ // addressed BY its hash, so re-hashing what came back is the whole verification.
103
+ // A restore that would write the wrong bytes over someone's file stops instead.
104
+ export function getBlob(projectDir, hash) {
105
+ const blob = path.join(dataDir(projectDir), hash);
106
+ if (!fs.existsSync(blob))
107
+ throw new Error(`Missing stored content ${hash}`);
108
+ let content;
109
+ try {
110
+ content = zlib.gunzipSync(fs.readFileSync(blob));
111
+ }
112
+ catch (e) {
113
+ throw new Error(`Stored content ${hash} is unreadable: ${e instanceof Error ? e.message : String(e)}`);
114
+ }
115
+ const actual = hashBytes(content);
116
+ if (actual !== hash) {
117
+ throw new Error(`Stored content ${hash} is corrupt (hashes as ${actual}).`);
118
+ }
119
+ return content;
120
+ }
121
+ export function blobCount(projectDir) {
122
+ try {
123
+ return fs.readdirSync(dataDir(projectDir)).length;
124
+ }
125
+ catch {
126
+ return 0;
127
+ }
128
+ }
129
+ // Bytes of a tracked file, or null when there is no file there -- which is how
130
+ // a deletion reads to everything above.
131
+ function readWorkingFile(projectDir, relPath) {
132
+ const abs = path.join(projectDir, relPath);
133
+ try {
134
+ if (!fs.statSync(abs).isFile())
135
+ return null;
136
+ return fs.readFileSync(abs);
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ /** Hash of the file as it is on disk right now, or null when it isn't there. */
143
+ export function hashWorkingFile(projectDir, relPath) {
144
+ const content = readWorkingFile(projectDir, relPath);
145
+ return content ? hashBytes(content) : null;
146
+ }
147
+ /** Hash the file and store its content, or null when it isn't there. */
148
+ export function storeWorkingFile(projectDir, relPath) {
149
+ const content = readWorkingFile(projectDir, relPath);
150
+ return content ? putBlob(projectDir, content) : null;
151
+ }
152
+ export function isIgnoredPath(relPath) {
153
+ return relPath.split('/').some((segment) => IGNORED_SEGMENTS.has(segment));
154
+ }
155
+ /**
156
+ * Every tracked file in the deck, as sorted deck-relative POSIX paths. Files are
157
+ * bytes here -- nothing assumes text, and nothing looks at an extension.
158
+ */
159
+ export function walkDeckFiles(projectDir) {
160
+ const out = [];
161
+ const walk = (absDir, relDir) => {
162
+ let entries;
163
+ try {
164
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
165
+ }
166
+ catch {
167
+ return;
168
+ }
169
+ for (const entry of entries) {
170
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
171
+ if (isIgnoredPath(rel))
172
+ continue;
173
+ // Symlinks are followed no further than their own entry: a link is not a
174
+ // file whose bytes we can restore.
175
+ if (entry.isDirectory())
176
+ walk(path.join(absDir, entry.name), rel);
177
+ else if (entry.isFile())
178
+ out.push(rel);
179
+ }
180
+ };
181
+ walk(projectDir, '');
182
+ return out.sort();
183
+ }
184
+ /** Turn a path as typed on the command line into a deck-relative POSIX path. */
185
+ export function toDeckRelative(projectDir, given) {
186
+ const abs = path.resolve(given);
187
+ const rel = path.relative(projectDir, abs);
188
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
189
+ return null;
190
+ return toPosixPath(rel);
191
+ }
192
+ /**
193
+ * Versions from `fromId` back along `parentId`, newest first. Follows the stored
194
+ * parent links rather than any list order, so a forked history walks correctly.
195
+ */
196
+ export function versionChain(projectDir, fromId) {
197
+ const chain = [];
198
+ const seen = new Set();
199
+ let cursor = fromId;
200
+ while (cursor) {
201
+ if (seen.has(cursor))
202
+ throw new Error(`Version history loops at ${cursor}`);
203
+ seen.add(cursor);
204
+ const version = readVersion(projectDir, cursor);
205
+ chain.push(version);
206
+ cursor = version.parentId;
207
+ }
208
+ return chain;
209
+ }
210
+ // Every version in the store. Only `parentId` is stored, so finding a version's
211
+ // children means reading them all -- fine at this scale, and a child index is a
212
+ // later optimisation if it ever stops being. A file that won't parse is skipped
213
+ // rather than taking the whole listing down with it.
214
+ export function readAllVersions(projectDir) {
215
+ let names;
216
+ try {
217
+ names = fs.readdirSync(versionsDir(projectDir));
218
+ }
219
+ catch {
220
+ return [];
221
+ }
222
+ const out = [];
223
+ for (const name of names) {
224
+ if (!name.endsWith(VERSION_EXT))
225
+ continue;
226
+ try {
227
+ out.push(readVersion(projectDir, name.slice(0, -VERSION_EXT.length)));
228
+ }
229
+ catch {
230
+ continue;
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+ /**
236
+ * The chain of children from `fromId`, nearest first, following the most
237
+ * recently created child at each step. One chain, not a subtree: a fork picks a
238
+ * side, deterministically, rather than fanning out.
239
+ */
240
+ export function forwardChain(projectDir, fromId) {
241
+ const childrenOf = new Map();
242
+ for (const version of readAllVersions(projectDir)) {
243
+ if (!version.parentId)
244
+ continue;
245
+ const siblings = childrenOf.get(version.parentId);
246
+ if (siblings)
247
+ siblings.push(version);
248
+ else
249
+ childrenOf.set(version.parentId, [version]);
250
+ }
251
+ const chain = [];
252
+ const seen = new Set([fromId]);
253
+ let cursor = fromId;
254
+ for (;;) {
255
+ const children = childrenOf.get(cursor);
256
+ if (!children || children.length === 0)
257
+ return chain;
258
+ // Newest wins; the id breaks a same-timestamp tie so the walk is stable.
259
+ const next = [...children].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))[0];
260
+ if (seen.has(next.id))
261
+ return chain;
262
+ seen.add(next.id);
263
+ chain.push(next);
264
+ cursor = next.id;
265
+ }
266
+ }
267
+ /** Manifest of a version, or an empty one for the very first version. */
268
+ export function parentManifest(projectDir, parentId) {
269
+ return parentId ? readVersion(projectDir, parentId).files : {};
270
+ }
271
+ export function diffManifests(before, after, onlyPaths) {
272
+ const paths = onlyPaths ?? [...new Set([...Object.keys(before), ...Object.keys(after)])];
273
+ const changes = {};
274
+ for (const p of paths.sort()) {
275
+ const from = before[p] ?? null;
276
+ const to = after[p] ?? null;
277
+ if (from !== to)
278
+ changes[p] = { before: from, after: to };
279
+ }
280
+ return changes;
281
+ }
@@ -0,0 +1,82 @@
1
+ import * as store from './versionStore.js';
2
+ export interface SaveVersionOptions {
3
+ message?: string;
4
+ /** Paths as typed on the command line; empty means the whole tree. */
5
+ paths?: string[];
6
+ }
7
+ export interface SaveOutcome {
8
+ /** The version written, or null when nothing had changed. */
9
+ version: store.Version | null;
10
+ /** True when paths were named, so unselected files stayed dirty. */
11
+ partial: boolean;
12
+ }
13
+ export declare function createVersion(projectDir: string, opts?: SaveVersionOptions): SaveOutcome;
14
+ export declare function saveVersion(dir: string, opts?: SaveVersionOptions): void;
15
+ export type ChangeKind = 'added' | 'changed' | 'deleted';
16
+ /** One version as a list wants it: no manifest, changes already classified. */
17
+ export interface VersionSummary {
18
+ id: string;
19
+ parentId: string | null;
20
+ createdAt: string;
21
+ message: string;
22
+ changes: {
23
+ path: string;
24
+ kind: ChangeKind;
25
+ }[];
26
+ /** True for versions after the one being listed from -- see versionSummaries. */
27
+ later: boolean;
28
+ }
29
+ /**
30
+ * What has changed on disk since head, in the same shape a version's `changes`
31
+ * takes. Hashes the tree WITHOUT storing anything -- asking what is outstanding
32
+ * must not write to the store.
33
+ */
34
+ export declare function pendingChanges(projectDir: string): {
35
+ path: string;
36
+ kind: ChangeKind;
37
+ }[];
38
+ /**
39
+ * The deck's history newest first. From head (no `fromId`): the chain forward
40
+ * from head -- marked `later` -- then head, then its ancestors. From a named
41
+ * version: that version and its ancestors, as it has always been.
42
+ *
43
+ * The forward walk is why a restore no longer makes the versions after it
44
+ * vanish from the panel and from `list-versions`. It follows one chain of
45
+ * children, newest child at each step. After a save there is nothing to follow,
46
+ * because the new version is a leaf, so the forward rows drop away on their own
47
+ * -- the abandoned versions are still on disk, just no longer reachable forward
48
+ * from head.
49
+ *
50
+ * Naming a version asks a different question -- how did THIS version come to be
51
+ * -- so that form stays a walk back and is unchanged.
52
+ */
53
+ export declare function versionSummaries(projectDir: string, fromId?: string): VersionSummary[];
54
+ export declare function listVersions(dir: string, version?: string): void;
55
+ /**
56
+ * What one version changed, as diffs of the CHANGED FILES ONLY -- built from the
57
+ * two blobs each entry in `changes` already names, so nothing new is stored and
58
+ * the whole tree is never read. The CLI counterpart of the panel's row
59
+ * expansion, for an agent working out what a change did.
60
+ */
61
+ export declare function showVersion(dir: string, versionId: string): void;
62
+ export declare class UnsavedChanges extends Error {
63
+ readonly paths: string[];
64
+ constructor(paths: string[]);
65
+ }
66
+ export declare class NotOnThisLine extends Error {
67
+ constructor(targetId: string, head: string);
68
+ }
69
+ export interface RestoreOutcome {
70
+ /** True when head was already the target, so nothing was looked at. */
71
+ alreadyThere: boolean;
72
+ /** Path -> hash written, null meaning the file was deleted. */
73
+ applied: [string, string | null][];
74
+ /** How many versions the head move took off the chain. */
75
+ offChain: number;
76
+ }
77
+ export declare function applyVersionRestore(projectDir: string, targetId: string, opts?: {
78
+ force?: boolean;
79
+ }): RestoreOutcome;
80
+ export declare function restoreVersion(dir: string, targetId: string, opts?: {
81
+ force?: boolean;
82
+ }): void;