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.
- package/dist/agent-prompts.js +6 -2
- package/dist/diffText.d.ts +14 -0
- package/dist/diffText.js +138 -0
- package/dist/editorConfig.d.ts +6 -1
- package/dist/editorConfig.js +20 -1
- package/dist/ide.d.ts +2 -1
- package/dist/ide.js +91 -2
- package/dist/index.js +48 -2
- package/dist/init.js +30 -0
- package/dist/localPaths.d.ts +1 -0
- package/dist/localPaths.js +5 -0
- package/dist/shell/assets/index-CARLHafh.css +1 -0
- package/dist/shell/assets/{index-UEwjXWKI.js → index-DWgt4KzC.js} +63 -63
- package/dist/shell/index.html +2 -2
- package/dist/versionStore.d.ts +54 -0
- package/dist/versionStore.js +281 -0
- package/dist/versions.d.ts +82 -0
- package/dist/versions.js +446 -0
- package/dist/vitePlugins.js +21 -8
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/SingleEditor.jsx +43 -2
- package/kits/physics-2d/editors/editorRegistry.jsx +34 -0
- package/kits/physics-2d/main.jsx +11 -2
- package/kits/physics-2d/package-lock.json +1 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-BkJ87APM.css +0 -1
package/dist/agent-prompts.js
CHANGED
|
@@ -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;
|
package/dist/diffText.js
ADDED
|
@@ -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
|
+
}
|
package/dist/editorConfig.d.ts
CHANGED
|
@@ -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[];
|
package/dist/editorConfig.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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]
|
|
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()
|
package/dist/localPaths.d.ts
CHANGED
|
@@ -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;
|
package/dist/localPaths.js
CHANGED
|
@@ -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
|
}
|