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.
@@ -63,6 +63,7 @@ comma-separated active-task titles or ids, or \`all\`
63
63
  - Never claim the board is cleared without actually emitting the fence.
64
64
  - Tasks are one-and-done -- when the user gives feedback on a finished task, spawn a new fix task (and \`castle-done\` the old row) rather than reopening it.
65
65
  - Task agents are capable coding agents working in this same deck directory, but they know nothing about this conversation beyond your prompt.
66
+ - The deck keeps its own version history, and you may READ it: \`castle-web list-versions\` shows the versions newest first, and \`castle-web show-version <id>\` shows what one changed, as diffs of just those files. Use them to answer "what changed" or to ground a claim about recent work. You NEVER run \`save-version\` -- you did not make the changes, so a version of yours would not correspond to a unit of work -- and you NEVER run \`restore-version\`: going back is the user's own decision, not something you do on their behalf.
66
67
 
67
68
  Asking with options (the \`\`\`ask block). When you need the user to settle a few choices at once, emit ONE fenced block tagged \`ask\` containing JSON -- it renders inline in the chat as grouped options they tap and submit together (far better than stacking questions they can only half-answer). Reach for it to pin a direction fast when their ask is vague ("make me a game" -> ask what kind), NOT to interrogate. Options only, no free text.
68
69
 
@@ -233,7 +234,7 @@ export function buildTaskPrompt(opts) {
233
234
  // -- blind whole-file rewrites made parallel agents clobber each other's
234
235
  // edits. Read-then-edit is slower but safe; that is the right tradeoff.)
235
236
  const wrapUp = opts.backend === "claude" || opts.backend === "smith"
236
- ? `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine the 90-progress write, the final \`npm run restart\`, and writing the notes file into a single shell command (\`;\`-separated so the notes land even if the restart hiccups). Then stop -- no extra turns after it.`
237
+ ? `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine the 90-progress write, the \`castle-web save-version\` for your paths, the final \`npm run restart\`, and writing the notes file into a single shell command (\`;\`-separated so the notes land even if the restart hiccups). Then stop -- no extra turns after it.`
237
238
  : "";
238
239
  return `You are a background build agent for the Castle deck "${opts.deckLabel}" (current directory). A separate conversation agent dispatched you with one task. Follow the deck's CLAUDE.md / AGENTS.md conventions, and reload the served deck after changes (\`npm run restart\`).${quickReference}${layout}${deckSource}
239
240
 
@@ -247,6 +248,9 @@ Operating rules:
247
248
  - The USER is the verifier -- the whole tasks system exists so the user playtests every change themselves. Your first priority is to finish as soon as possible with the change genuinely in place and reachable in the running deck, so the user can test it right away. Do NOT run verification (screenshots especially) unless you are really sure it will catch something a re-read of your own change cannot -- and even then at most one cheap check, never a retry loop. Time spent verifying is time the user is left waiting.
248
249
  - The moment implementation is complete and you switch to verifying, write 90 to the progress file -- verification time must not read as stalled progress.
249
250
  - Do this one task completely, then stop. Do not expand scope.
251
+ - SAVE A VERSION when your change is done, so the user can see and undo it: \`castle-web save-version -m "<short description of what you changed>" <each path you touched>\`. Name those paths explicitly -- a bare \`save-version\` with no paths records the WHOLE deck, including files a sibling agent is still working on. One version for the whole task, at the end; not one per file.
252
+ - Overlap with the other agents working in this deck is fine and expected -- do not try to avoid it or wait for them. Saving only your own paths is all that is asked of you; nothing anyone has written is lost by your save.
253
+ - NEVER run \`restore-version\`. Going back to an earlier version is the user's decision alone -- not yours, not even to undo your own work. If your change is wrong, fix it forward.
250
254
  - Other task agents may be editing this same deck IN PARALLEL. When you change an existing file, READ it first and make a targeted edit to just the part you need -- never overwrite a whole file you have not read. A blind full-file rewrite clobbers other agents' in-flight changes. Being a bit slower and careful here is the right tradeoff.
251
255
  - Favor real, editable assets: for game objects, characters, and scenery, make pixel-art drawings and place them as real actors in the scene rather than code-drawn shapes -- it keeps the deck editable in the editor and remixable. (Data-driven UI like health bars, score/text, and HUD gauges, plus dynamic things like bullets/particles/effects, stay procedural -- don't force those into drawings.)
252
256
  - Respect art ownership: if YOUR task is to create art, make it as real, editable drawing files (NOT hand-written pixel grids or code-drawn shapes), following the deck's CLAUDE.md / AGENTS.md for the drawing format and exact command. But if your prompt only REFERENCES drawing names (a sibling task is creating them in parallel), point the scene at those names and do NOT create the drawing files yourself -- two agents drawing the same sprites clobber each other. The deck's CLAUDE.md / AGENTS.md is the source of truth for how to make art. Reference the intended drawing name from the scene right away (the actor renders a plain-block fallback until the file exists), and remember newly written drawing files need \`npm run restart\` before the kit picks them up.
@@ -264,7 +268,7 @@ phase: wiring the paddle to touch
264
268
  - \`phase\` -- a SHORT, plain-language line for this moment ("wiring the paddle to touch", "drawing the flag"). No file or code names. Update \`avatar\` and \`phase\` together each time your stage changes -- a few times across the task, not every turn. (You may also include \`progress: NN\` here, but the progress file above is the primary progress channel.)
265
269
  - Before finishing, write ${opts.notesPath}: a tiny test guide for the PLAYER -- AT MOST 2-3 bullets (markdown \`- \` lines), each ONE short phrase: what to try and what should happen. NO code, NO API or "integration contract" detail, NO file/behavior names or implementation notes -- those are for you, not the player. Occasionally one bullet may run a little longer, but default to terse. Mention a blocker only if you hit one. The user reads this verbatim to check your work.${wrapUp}
266
270
  - If you are truly blocked, write the blocker to the notes file and stop rather than guessing wildly.
267
- - Never touch files under .castle/ other than those two paths.`;
271
+ - Never touch files under .castle/ other than those two paths. (The version store lives there too, but you never edit it by hand -- \`castle-web save-version\` writes it for you.)`;
268
272
  }
269
273
  // Appended to claude task agents' system prompt (portable replacement for the
270
274
  // machine-specific /goal slash command): commit to autonomous completion.
@@ -0,0 +1,14 @@
1
+ export type DiffOp = {
2
+ kind: 'same' | 'add' | 'remove';
3
+ text: string;
4
+ };
5
+ /** True for content that is not text: a NUL byte, or bytes that aren't UTF-8. */
6
+ export declare function looksBinary(content: Buffer): boolean;
7
+ /** Every line of both sides, in order, marked same / add / remove. */
8
+ export declare function diffOps(beforeText: string, afterText: string): DiffOp[] | null;
9
+ /**
10
+ * A unified diff of two texts, or null when the change is too large to be worth
11
+ * rendering as one. Header lines are the caller's business -- this is only the
12
+ * hunks.
13
+ */
14
+ export declare function unifiedDiff(beforeText: string, afterText: string): string[] | null;
@@ -0,0 +1,138 @@
1
+ // A small unified-diff generator, for `show-version`.
2
+ //
3
+ // Deliberately not a dependency: the only diff this CLI needs is between two
4
+ // blobs it already has, and a hand-rolled line diff is a few dozen lines with
5
+ // no supply chain attached.
6
+ //
7
+ // Shape of the algorithm: strip the common head and tail first -- a real edit
8
+ // usually touches a few lines in a long file, so this leaves a small middle --
9
+ // then run a longest-common-subsequence over what remains. The LCS table is
10
+ // quadratic, so a middle that stays large after trimming reports a summary
11
+ // instead of a patch rather than eating memory.
12
+ const CONTEXT_LINES = 3;
13
+ const MAX_DIFF_LINES = 2000;
14
+ /** True for content that is not text: a NUL byte, or bytes that aren't UTF-8. */
15
+ export function looksBinary(content) {
16
+ const sample = content.subarray(0, 8000);
17
+ if (sample.includes(0))
18
+ return true;
19
+ // A lone replacement char means the decoder hit a byte sequence that isn't
20
+ // valid UTF-8 -- unless the file genuinely contains one, which is rare enough
21
+ // to accept and harmless to treat as binary.
22
+ return sample.toString('utf8').includes('�');
23
+ }
24
+ function splitLines(text) {
25
+ const lines = text.split('\n');
26
+ // A trailing newline yields a final empty element that is not a line.
27
+ if (lines.length > 0 && lines[lines.length - 1] === '')
28
+ lines.pop();
29
+ return lines;
30
+ }
31
+ // Ops for the middle section, by LCS backtracking.
32
+ function diffMiddle(before, after) {
33
+ const rows = before.length;
34
+ const cols = after.length;
35
+ const table = Array.from({ length: rows + 1 }, () => new Array(cols + 1).fill(0));
36
+ for (let i = rows - 1; i >= 0; i--) {
37
+ for (let j = cols - 1; j >= 0; j--) {
38
+ table[i][j] =
39
+ before[i] === after[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
40
+ }
41
+ }
42
+ const ops = [];
43
+ let i = 0;
44
+ let j = 0;
45
+ while (i < rows && j < cols) {
46
+ if (before[i] === after[j]) {
47
+ ops.push({ kind: 'same', text: before[i] });
48
+ i++;
49
+ j++;
50
+ }
51
+ else if (table[i + 1][j] >= table[i][j + 1]) {
52
+ ops.push({ kind: 'remove', text: before[i] });
53
+ i++;
54
+ }
55
+ else {
56
+ ops.push({ kind: 'add', text: after[j] });
57
+ j++;
58
+ }
59
+ }
60
+ while (i < rows)
61
+ ops.push({ kind: 'remove', text: before[i++] });
62
+ while (j < cols)
63
+ ops.push({ kind: 'add', text: after[j++] });
64
+ return ops;
65
+ }
66
+ /** Every line of both sides, in order, marked same / add / remove. */
67
+ export function diffOps(beforeText, afterText) {
68
+ const before = splitLines(beforeText);
69
+ const after = splitLines(afterText);
70
+ let head = 0;
71
+ while (head < before.length && head < after.length && before[head] === after[head])
72
+ head++;
73
+ let tail = 0;
74
+ while (tail < before.length - head &&
75
+ tail < after.length - head &&
76
+ before[before.length - 1 - tail] === after[after.length - 1 - tail]) {
77
+ tail++;
78
+ }
79
+ const beforeMiddle = before.slice(head, before.length - tail);
80
+ const afterMiddle = after.slice(head, after.length - tail);
81
+ if (beforeMiddle.length > MAX_DIFF_LINES || afterMiddle.length > MAX_DIFF_LINES)
82
+ return null;
83
+ return [
84
+ ...before.slice(0, head).map((text) => ({ kind: 'same', text })),
85
+ ...diffMiddle(beforeMiddle, afterMiddle),
86
+ ...before.slice(before.length - tail).map((text) => ({ kind: 'same', text })),
87
+ ];
88
+ }
89
+ function opsToHunks(ops) {
90
+ const changedAt = ops.map((op) => op.kind !== 'same');
91
+ const hunks = [];
92
+ let beforeLine = 1;
93
+ let afterLine = 1;
94
+ let current = null;
95
+ for (let index = 0; index < ops.length; index++) {
96
+ const op = ops[index];
97
+ // A same-line belongs to a hunk when a change is within CONTEXT_LINES of it
98
+ // on either side; that is what turns scattered edits into readable blocks.
99
+ const near = op.kind !== 'same' ||
100
+ changedAt.slice(Math.max(0, index - CONTEXT_LINES), index + CONTEXT_LINES + 1).some(Boolean);
101
+ if (near) {
102
+ if (!current)
103
+ current = { beforeStart: beforeLine, afterStart: afterLine, lines: [], beforeCount: 0, afterCount: 0 };
104
+ current.lines.push(`${op.kind === 'add' ? '+' : op.kind === 'remove' ? '-' : ' '}${op.text}`);
105
+ if (op.kind !== 'add')
106
+ current.beforeCount++;
107
+ if (op.kind !== 'remove')
108
+ current.afterCount++;
109
+ }
110
+ else if (current) {
111
+ hunks.push(current);
112
+ current = null;
113
+ }
114
+ if (op.kind !== 'add')
115
+ beforeLine++;
116
+ if (op.kind !== 'remove')
117
+ afterLine++;
118
+ }
119
+ if (current)
120
+ hunks.push(current);
121
+ return hunks;
122
+ }
123
+ /**
124
+ * A unified diff of two texts, or null when the change is too large to be worth
125
+ * rendering as one. Header lines are the caller's business -- this is only the
126
+ * hunks.
127
+ */
128
+ export function unifiedDiff(beforeText, afterText) {
129
+ const ops = diffOps(beforeText, afterText);
130
+ if (!ops)
131
+ return null;
132
+ const out = [];
133
+ for (const hunk of opsToHunks(ops)) {
134
+ out.push(`@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`);
135
+ out.push(...hunk.lines);
136
+ }
137
+ return out;
138
+ }
@@ -15,11 +15,16 @@ export interface FileTypeConfig {
15
15
  new?: string;
16
16
  icon?: string;
17
17
  editor?: string;
18
- data?: boolean;
18
+ data?: boolean | DataMode;
19
19
  unsupported?: string;
20
20
  }
21
+ export type DataMode = 'json' | 'text';
22
+ export declare function dataModeOf(type: {
23
+ data?: boolean | DataMode;
24
+ }): DataMode | null;
21
25
  export interface ResolvedFileType extends FileTypeConfig {
22
26
  from?: string;
27
+ module?: string;
23
28
  }
24
29
  export interface EditorConfig {
25
30
  initialPanels?: InitialPanel[];
@@ -17,6 +17,13 @@ import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { readCastleJson } from './castleJson.js';
19
19
  import { IMPORTS_DIR } from './imports.js';
20
+ export function dataModeOf(type) {
21
+ if (type.data === true)
22
+ return 'json';
23
+ if (type.data === 'json' || type.data === 'text')
24
+ return type.data;
25
+ return null;
26
+ }
20
27
  // An array with no usable entries stays an (empty) array rather than becoming
21
28
  // undefined: "I declare no file types" is a real answer, distinct from "I say
22
29
  // nothing about file types", and only the second one defers to the imports.
@@ -121,5 +128,17 @@ export function resolveFileTypes(deckDir) {
121
128
  if (imported)
122
129
  add(imported, alias);
123
130
  }
124
- return anyDeclared ? [...byExt.values()] : null;
131
+ if (!anyDeclared)
132
+ return null;
133
+ // Rewrite each declared editor path so it is addressable from the DECK root.
134
+ // This is the whole routing decision: the deck's entry imports the named
135
+ // module, whoever declared it, instead of anything guessing which page owns
136
+ // the type.
137
+ for (const type of byExt.values()) {
138
+ if (!type.editor || type.editor === 'kit')
139
+ continue;
140
+ const declared = type.editor.replace(/^\.\//, '');
141
+ type.module = type.from ? `${IMPORTS_DIR}/${type.from}/${declared}` : declared;
142
+ }
143
+ return [...byExt.values()];
125
144
  }
package/dist/ide.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as http from 'http';
2
2
  import { Duplex } from 'stream';
3
3
  import { type RawData } from 'ws';
4
+ import { COVER_FILE } from './localPaths.js';
4
5
  export declare const IDE_ASSET_PREFIX = "/__castle/ide/";
5
6
  export declare const PTY_WS_PATH = "/__castle/pty";
6
7
  export declare const VENDOR_PREFIX = "/__castle/vendor/";
@@ -8,7 +9,7 @@ export declare const FAVICON_FILES: string[];
8
9
  export declare const FAVICON_LINK_TAGS: string;
9
10
  export declare const FILES_API_PREFIX = "/__castle/files/";
10
11
  export declare const COVER_API_PATH = "/__castle/cover";
11
- export declare const COVER_FILE = "preview.png";
12
+ export { COVER_FILE };
12
13
  export declare function rawDataToString(data: RawData): string;
13
14
  export interface IdeServer {
14
15
  /** Serve the IDE page + its static assets. Returns true if it handled the request. */
package/dist/ide.js CHANGED
@@ -20,6 +20,8 @@ import { readEditorConfig, resolveFileTypes, } from './editorConfig.js';
20
20
  import { UNSUPPORTED_MEDIA } from './unsupportedMedia.js';
21
21
  import { IMPORT_API_PREFIX, handleImportApi } from './importBrowse.js';
22
22
  import { readRequestBody, sendJson } from './httpJson.js';
23
+ import { COVER_FILE } from './localPaths.js';
24
+ import { applyVersionRestore, createVersion, NotOnThisLine, pendingChanges, UnsavedChanges, versionSummaries, } from './versions.js';
23
25
  import { envForUserShell, installCliShims } from './byo-auth.js';
24
26
  const HeadlessTerminal = headlessPkg.Terminal;
25
27
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -140,10 +142,22 @@ export const FILES_API_PREFIX = '/__castle/files/';
140
142
  // upload deliberately refuses to clobber. A cover is platform vocabulary, not a
141
143
  // kit's -- `save-deck` is what publishes preview.png -- so it belongs here.
142
144
  export const COVER_API_PATH = '/__castle/cover';
143
- export const COVER_FILE = 'preview.png';
145
+ // Re-exported so `save-deck` and anything else that already asks the serve for
146
+ // the cover's name keeps working.
147
+ export { COVER_FILE };
144
148
  // Directories never surfaced in the file list / never read or written through
145
149
  // the builtin editor: VCS, deck-private state, and dependency trees.
146
150
  const FILES_IGNORE_DIRS = new Set(['.git', '.castle', 'node_modules', 'dist']);
151
+ // Never writable through the files API, whatever the tree shows. `.castle` holds
152
+ // the deck's private state, and since build 1 that includes the version store --
153
+ // the one place where losing bytes loses history rather than a file. The list
154
+ // above is about what the tree SHOWS and could reasonably change; this is about
155
+ // what a write may touch, so the store stays safe if it ever does.
156
+ //
157
+ // A guard, not a lock: the directory keeps its normal permissions. Making it
158
+ // read-only and unlocking around writes races with concurrent processes, gets
159
+ // clobbered by tools, and still would not stop a raw shell write.
160
+ const PROTECTED_WRITE_DIRS = new Set(['.castle']);
147
161
  // Ceiling on one uploaded file. A deck is saved whole (source tar + bundle), so
148
162
  // a huge asset is a problem for the deck long before it is a problem here --
149
163
  // refuse it at the door with a message rather than let it land and break `save`.
@@ -197,6 +211,12 @@ function resolveDeckPath(deckDir, requestedPath, opts = {}) {
197
211
  return { ok: false, error: `Path outside the deck: ${requestedPath}` };
198
212
  }
199
213
  const parts = normalized.split(path.sep);
214
+ if (opts.mutation && parts.some((p) => PROTECTED_WRITE_DIRS.has(p))) {
215
+ return {
216
+ ok: false,
217
+ error: `${requestedPath} is the deck's own state (the version store lives here) and is not writable.`,
218
+ };
219
+ }
200
220
  if (parts.some((p) => FILES_IGNORE_DIRS.has(p))) {
201
221
  return { ok: false, error: `Protected deck path: ${requestedPath}` };
202
222
  }
@@ -250,7 +270,9 @@ function listDeckFiles(deckDir) {
250
270
  // editors, and the builtin code editor renders everything.
251
271
  function kitEditorExtensions(config, fileTypes) {
252
272
  if (fileTypes) {
253
- return fileTypes.filter((t) => t.editor === 'kit').map((t) => t.ext);
273
+ // ANY declared editor means "not the builtin code editor" -- the legacy
274
+ // "kit" (this deck's own dispatcher) and a named module both count.
275
+ return fileTypes.filter((t) => Boolean(t.editor)).map((t) => t.ext);
254
276
  }
255
277
  // A deck saved before file types existed named its kit extensions directly.
256
278
  return config.extensions ?? [];
@@ -345,6 +367,61 @@ function sendFailure(res, action, rel, err) {
345
367
  const message = err instanceof Error ? err.message : String(err);
346
368
  sendJson(res, 500, { error: `Could not ${action} ${rel}: ${message}` });
347
369
  }
370
+ // The version panel's three calls, on the same store the `save-version` /
371
+ // `restore-version` commands use -- the editor is another way to run them, not
372
+ // a second history.
373
+ function handleVersionsList(deckDir, res) {
374
+ try {
375
+ // The history and what is outstanding against it, together: the panel shows
376
+ // them as one picture, and two calls could disagree about the moment.
377
+ sendJson(res, 200, { versions: versionSummaries(deckDir), pending: pendingChanges(deckDir) });
378
+ }
379
+ catch (e) {
380
+ sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
381
+ }
382
+ }
383
+ // Saves everything dirty: the panel has no path selection, so partial saves
384
+ // stay a CLI and agent thing.
385
+ function handleVersionSave(deckDir, req, res) {
386
+ withJsonBody(req, res, (body) => {
387
+ const message = typeof body.message === 'string' ? body.message : '';
388
+ try {
389
+ const { version } = createVersion(deckDir, { message });
390
+ sendJson(res, 200, { ok: true, id: version?.id ?? null });
391
+ }
392
+ catch (e) {
393
+ sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
394
+ }
395
+ });
396
+ }
397
+ // Never forced. Unsaved work that a restore would overwrite comes back as a 409
398
+ // naming the files, which the panel shows -- saving a version first is the fix,
399
+ // and it is one click away in the same panel.
400
+ function handleVersionRestore(deckDir, req, res) {
401
+ withJsonBody(req, res, (body) => {
402
+ if (typeof body.id !== 'string' || !body.id) {
403
+ return sendJson(res, 400, { error: 'Missing version id.' });
404
+ }
405
+ try {
406
+ const outcome = applyVersionRestore(deckDir, body.id);
407
+ sendJson(res, 200, {
408
+ ok: true,
409
+ restored: outcome.applied.length,
410
+ offChain: outcome.offChain,
411
+ alreadyThere: outcome.alreadyThere,
412
+ });
413
+ }
414
+ catch (e) {
415
+ // Both refusals are answers, not faults: the caller asked for something
416
+ // this history can't do, and the message says which.
417
+ const refused = e instanceof UnsavedChanges || e instanceof NotOnThisLine;
418
+ const message = e instanceof Error ? e.message : String(e);
419
+ sendJson(res, refused ? 409 : 500, {
420
+ error: e instanceof UnsavedChanges ? `${message}\nSave a version first.` : message,
421
+ });
422
+ }
423
+ });
424
+ }
348
425
  function handleFilesWrite(deckDir, req, res) {
349
426
  withMutationPath(deckDir, req, res, (target, body) => {
350
427
  if (typeof body.contents !== 'string') {
@@ -686,6 +763,18 @@ function handleFilesApi(deckDir, req, res, reqPath) {
686
763
  })();
687
764
  return true;
688
765
  }
766
+ if (action === 'versions') {
767
+ handleVersionsList(deckDir, res);
768
+ return true;
769
+ }
770
+ if (action === 'save-version') {
771
+ handleVersionSave(deckDir, req, res);
772
+ return true;
773
+ }
774
+ if (action === 'restore-version') {
775
+ handleVersionRestore(deckDir, req, res);
776
+ return true;
777
+ }
689
778
  if (action === 'list') {
690
779
  // `?all=1` returns the unfiltered listing (the "show hidden files & folders"
691
780
  // toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { listDecks } from './list-decks.js';
10
10
  import { getCliVersion, init } from './init.js';
11
11
  import { install } from './install.js';
12
12
  import { connectWS, savePreviewImage, takeScreenshot } from './preview.js';
13
+ import { listVersions, restoreVersion, saveVersion, showVersion } from './versions.js';
13
14
  const args = process.argv.slice(2);
14
15
  const command = args[0];
15
16
  const FLAGS_WITH_VALUES = new Set([
@@ -23,10 +24,19 @@ const FLAGS_WITH_VALUES = new Set([
23
24
  '--visibility',
24
25
  '--as',
25
26
  '--kind',
27
+ '--dir',
28
+ '--message',
29
+ '-m',
26
30
  ]);
31
+ // `--foo` is always a flag; a single dash only when it is one we know (`-m`).
32
+ // Anything else starting with `-` is a positional -- a path can begin with one,
33
+ // and so can an opaque version id.
34
+ function isFlag(arg) {
35
+ return arg.startsWith('--') || FLAGS_WITH_VALUES.has(arg);
36
+ }
27
37
  function findPositionalDir() {
28
38
  for (let i = 1; i < args.length; i++) {
29
- if (args[i].startsWith('--')) {
39
+ if (isFlag(args[i])) {
30
40
  if (FLAGS_WITH_VALUES.has(args[i]))
31
41
  i++;
32
42
  continue;
@@ -41,7 +51,7 @@ function findPositionalDir() {
41
51
  function readPositionals() {
42
52
  const out = [];
43
53
  for (let i = 1; i < args.length; i++) {
44
- if (args[i].startsWith('--')) {
54
+ if (isFlag(args[i])) {
45
55
  if (FLAGS_WITH_VALUES.has(args[i]))
46
56
  i++;
47
57
  continue;
@@ -88,6 +98,10 @@ function usage() {
88
98
  castle-web add-import <deckId|url> [dir] [--as ALIAS] (adds another deck as a read-only dependency in imports/)
89
99
  castle-web update-import [alias] [dir] [--check] [--revert] (re-fetches imports; no alias means all)
90
100
  castle-web install [dir]
101
+ castle-web save-version [-m MESSAGE] [paths...] [--dir DIR] (no paths saves the whole deck)
102
+ castle-web list-versions [version] [--dir DIR] (defaults to the latest version)
103
+ castle-web show-version <version> [--dir DIR] (diffs of the files that version changed)
104
+ castle-web restore-version <version> [--force] [--dir DIR]
91
105
  castle-web login
92
106
  castle-web --version
93
107
 
@@ -173,6 +187,38 @@ async function main() {
173
187
  await install(findPositionalDir());
174
188
  break;
175
189
  }
190
+ // The version commands spend their positionals on their own arguments --
191
+ // paths to save, a version to list from or restore to -- so the deck dir is
192
+ // `--dir`, defaulting to the cwd.
193
+ case 'save-version': {
194
+ saveVersion(getFlagValue('--dir') ?? '.', {
195
+ message: getFlagValue('-m') ?? getFlagValue('--message'),
196
+ paths: readPositionals(),
197
+ });
198
+ break;
199
+ }
200
+ case 'list-versions': {
201
+ listVersions(getFlagValue('--dir') ?? '.', readPositionals()[0]);
202
+ break;
203
+ }
204
+ case 'show-version': {
205
+ const target = readPositionals()[0];
206
+ if (!target) {
207
+ console.error('Usage: castle-web show-version <version>');
208
+ process.exit(1);
209
+ }
210
+ showVersion(getFlagValue('--dir') ?? '.', target);
211
+ break;
212
+ }
213
+ case 'restore-version': {
214
+ const target = readPositionals()[0];
215
+ if (!target) {
216
+ console.error('Usage: castle-web restore-version <version> [--force]');
217
+ process.exit(1);
218
+ }
219
+ restoreVersion(getFlagValue('--dir') ?? '.', target, { force: hasFlag('--force') });
220
+ break;
221
+ }
176
222
  case 'restart': {
177
223
  const dir = findPositionalDir();
178
224
  const wsPort = getWsPort(dir);
package/dist/init.js CHANGED
@@ -6,6 +6,7 @@ import { deckMainFile, deckStarterScene } from './castleJson.js';
6
6
  import { IMPORTS_DIR, lockImportTree } from './imports.js';
7
7
  import { getCliEntryPath, getKitsDir, getRepoRoot, getSdkPackagePath, toPosixPath, } from './localPaths.js';
8
8
  import { serve } from './serve.js';
9
+ import { createVersion } from './versions.js';
9
10
  const INDEX_HTML = `<!DOCTYPE html>
10
11
  <html>
11
12
  <head>
@@ -386,6 +387,34 @@ function scaffoldFromKitImport(kit, projectDir) {
386
387
  appendCommonInstructions(projectDir);
387
388
  lockImportTree(importDir);
388
389
  }
390
+ // The scaffold as version 1, so a creator can always get back to the pristine
391
+ // deck. AFTER the install: it rewrites package.json and writes a lockfile, so a
392
+ // version taken before it describes a tree that never existed on disk, and a
393
+ // later restore would fight the package manager.
394
+ //
395
+ // This is the ordinary save path with no parent -- `parentId: null` and every
396
+ // file as an addition -- so the store has no special case for it. It composes
397
+ // with the no-op rule for free: `save-version` straight after init records
398
+ // nothing, so a fresh deck has exactly one version.
399
+ //
400
+ // The message is the only place a deck's kit lineage is recorded at all.
401
+ // Existing decks get none of this: their history starts at their first save,
402
+ // because a baseline synthesized from mid-work content would be labelled
403
+ // pristine while being nothing of the sort.
404
+ function saveInitialVersion(projectDir, kit) {
405
+ try {
406
+ const { version } = createVersion(projectDir, {
407
+ message: kit ? `New deck from ${kit}` : 'New deck',
408
+ });
409
+ if (version)
410
+ console.log(`Saved version ${version.id} — the deck as scaffolded.`);
411
+ }
412
+ catch (e) {
413
+ // A deck that scaffolded but failed to record its first version is still a
414
+ // deck. Losing the scaffold over it would be the wrong trade.
415
+ console.log(`Could not save the initial version: ${e instanceof Error ? e.message : String(e)}`);
416
+ }
417
+ }
389
418
  export async function init(dir, opts = {}) {
390
419
  const projectDir = path.resolve(dir);
391
420
  if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length > 0) {
@@ -423,6 +452,7 @@ export async function init(dir, opts = {}) {
423
452
  catch {
424
453
  console.error('dependency install failed; re-run `pnpm install` (or `npm install`) in the deck.');
425
454
  }
455
+ saveInitialVersion(projectDir, bare ? null : kit);
426
456
  const autoServe = opts.serve !== false;
427
457
  if (autoServe && installed) {
428
458
  // Call serve() with detach so init returns once the server is up. serve()
@@ -3,4 +3,5 @@ export declare function getRepoRoot(): string;
3
3
  export declare function getCliEntryPath(): string;
4
4
  export declare function getSdkPackagePath(): string;
5
5
  export declare function getKitsDir(): string;
6
+ export declare const COVER_FILE = "preview.png";
6
7
  export declare function toPosixPath(filepath: string): string;
@@ -28,6 +28,11 @@ export function getKitsDir() {
28
28
  return bundled;
29
29
  return path.join(getRepoRoot(), 'kits');
30
30
  }
31
+ // The deck's local cover image, deck-relative. Lives here rather than in the
32
+ // serve that writes it, because the version code needs to know about it too and
33
+ // `ide.ts` already imports that -- naming it here keeps the two from importing
34
+ // each other.
35
+ export const COVER_FILE = 'preview.png';
31
36
  export function toPosixPath(filepath) {
32
37
  return filepath.split(path.sep).join('/');
33
38
  }