castle-web-cli 0.4.122 → 0.4.124
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/castleJson.d.ts +2 -0
- package/dist/castleJson.js +39 -0
- package/dist/diffText.d.ts +14 -0
- package/dist/diffText.js +138 -0
- package/dist/headlessCover.d.ts +13 -0
- package/dist/headlessCover.js +99 -0
- package/dist/ide.d.ts +2 -1
- package/dist/ide.js +100 -43
- package/dist/imports.js +42 -2
- package/dist/index.js +49 -11
- package/dist/init.d.ts +1 -0
- package/dist/init.js +45 -1
- package/dist/localPaths.d.ts +1 -0
- package/dist/localPaths.js +5 -0
- package/dist/preview.d.ts +0 -1
- package/dist/preview.js +0 -58
- package/dist/save-deck.js +52 -1
- package/dist/serve.js +16 -0
- package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
- package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
- package/dist/shell/assets/index-BfOPkSej.css +1 -0
- package/dist/shell/assets/index-u0nYFqbF.js +434 -0
- 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/kits/physics-2d/CLAUDE.md +2 -2
- package/kits/physics-2d/castle.json +10 -2
- package/kits/physics-2d/docs/pxart-format.md +33 -26
- package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
- package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
- package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
- package/kits/physics-2d/editors/pathOverlay.js +1 -1
- package/kits/physics-2d/editors/pathTools.js +9 -1
- package/kits/physics-2d/editors/pixelGeometry.js +14 -13
- package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
- package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
- package/kits/physics-2d/editors/pxArtTools.js +3 -43
- package/kits/physics-2d/editors/styleEditor.module.css +105 -0
- package/kits/physics-2d/editors/styleTheme.js +16 -0
- package/kits/physics-2d/engine/files.js +2 -1
- package/kits/physics-2d/engine/liveReload.js +4 -3
- package/kits/physics-2d/engine/palettes.js +636 -0
- package/kits/physics-2d/engine/pxart.js +6 -6
- package/kits/physics-2d/engine/svgImport.js +1056 -0
- package/kits/physics-2d/engine/ui.jsx +2 -0
- package/kits/physics-2d/engine/ui.module.css +54 -9
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-2d/package.json +1 -0
- package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
- package/kits/physics-2d/scripts/draw.mjs +5 -3
- package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
- package/kits/physics-2d/scripts/palette.mjs +10 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
- package/kits/physics-2d/theme.style +3 -0
- package/package.json +1 -1
- package/dist/shell/assets/index-BkJ87APM.css +0 -1
- package/dist/shell/assets/index-DXBpj3-y.js +0 -434
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.
|
package/dist/castleJson.d.ts
CHANGED
|
@@ -20,3 +20,5 @@ export declare const DEFAULT_MAIN = "main.jsx";
|
|
|
20
20
|
export declare function deckMainFile(dir: string): string;
|
|
21
21
|
export declare function readCastleJson(dir: string): CastleJson | null;
|
|
22
22
|
export declare function readCastleJsonOrThrow(dir: string): CastleJson | null;
|
|
23
|
+
export declare function ensureVisiblePath(deckDir: string, rel: string): boolean;
|
|
24
|
+
export declare function ensureVisibleGlob(deckDir: string, glob: string, probe: string): boolean;
|
package/dist/castleJson.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
+
import picomatch from 'picomatch';
|
|
3
4
|
export const DEFAULT_STARTER_SCENE = 'scenes/main.scene';
|
|
4
5
|
export function deckStarterScene(dir) {
|
|
5
6
|
const declared = readCastleJson(dir)?.starterScene;
|
|
@@ -32,3 +33,41 @@ export function readCastleJsonOrThrow(dir) {
|
|
|
32
33
|
throw new Error(`Could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
33
34
|
});
|
|
34
35
|
}
|
|
36
|
+
// Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
|
|
37
|
+
// newly-made folder show in the curated Files tree.
|
|
38
|
+
export function ensureVisiblePath(deckDir, rel) {
|
|
39
|
+
return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
|
|
40
|
+
}
|
|
41
|
+
// Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
|
|
42
|
+
// ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
|
|
43
|
+
// everything is visible, and adding a glob would wrongly start hiding things.
|
|
44
|
+
// No-op if an existing glob already covers `probe`, a path the new glob would
|
|
45
|
+
// match. Returns whether it wrote.
|
|
46
|
+
export function ensureVisibleGlob(deckDir, glob, probe) {
|
|
47
|
+
const file = path.join(deckDir, 'castle.json');
|
|
48
|
+
let data;
|
|
49
|
+
try {
|
|
50
|
+
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false; // no castle.json yet (deck never saved) -> treat as not curated
|
|
54
|
+
}
|
|
55
|
+
const visible = data.editor && Array.isArray(data.editor.visiblePaths)
|
|
56
|
+
? data.editor.visiblePaths.filter((v) => typeof v === 'string')
|
|
57
|
+
: null;
|
|
58
|
+
if (!visible || visible.length === 0)
|
|
59
|
+
return false; // not curated -> all visible
|
|
60
|
+
if (visible.includes(glob))
|
|
61
|
+
return false;
|
|
62
|
+
if (picomatch(visible)(probe))
|
|
63
|
+
return false; // already covered
|
|
64
|
+
visible.push(glob);
|
|
65
|
+
data.editor.visiblePaths = visible;
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type PlaytestBrowserManager } from "./native/playtest-browser.js";
|
|
2
|
+
export interface HeadlessCoverResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
outPath?: string;
|
|
5
|
+
error?: string;
|
|
6
|
+
installedMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function captureCoverHeadless(opts: {
|
|
9
|
+
serveUrl: string;
|
|
10
|
+
outPath: string;
|
|
11
|
+
manager?: PlaytestBrowserManager;
|
|
12
|
+
onProgress?: (message: string) => void;
|
|
13
|
+
}): Promise<HeadlessCoverResult>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Capture a deck's cover with a real browser instead of asking the page to
|
|
2
|
+
// draw itself.
|
|
3
|
+
//
|
|
4
|
+
// The in-page path (the SDK's html2canvas compositing) re-implements CSS
|
|
5
|
+
// rendering in JS, so it inherits a support list: a WebGL canvas is blank
|
|
6
|
+
// unless the deck happened to pass `preserveDrawingBuffer`, and any element
|
|
7
|
+
// using `filter` / `clip-path` / `mask-image` / a blend mode is dropped. Both
|
|
8
|
+
// fail SILENTLY -- the capture succeeds and returns a plausible image that is
|
|
9
|
+
// missing part of the game -- and both put the burden on the deck to know a
|
|
10
|
+
// platform quirk.
|
|
11
|
+
//
|
|
12
|
+
// Screenshotting through the browser removes that whole class: the engine
|
|
13
|
+
// composites the frame it was already going to composite, so whatever the deck
|
|
14
|
+
// legitimately renders is what lands in the cover. This is the same move the
|
|
15
|
+
// mobile app made for its exp-web snapshots (WKWebView takeSnapshot / PixelCopy)
|
|
16
|
+
// -- ask the platform for its pixels rather than re-deriving them.
|
|
17
|
+
//
|
|
18
|
+
// It buys correctness, not judgment: a cold headless load sees the deck's FIRST
|
|
19
|
+
// moments, where the in-page path saw whatever the creator had on screen. That
|
|
20
|
+
// is why this is the fallback for an unattended save rather than a replacement
|
|
21
|
+
// for the editor's capture button -- see `SETTLE_MS`.
|
|
22
|
+
import fs from "fs";
|
|
23
|
+
import path from "path";
|
|
24
|
+
import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
|
|
25
|
+
// The card is 5:7; this is the same fixed viewport the playtest tool uses, so a
|
|
26
|
+
// deck laid out for one is laid out for the other.
|
|
27
|
+
const VIEWPORT = { width: 500, height: 700 };
|
|
28
|
+
// Retina-ish, so a cover holds up scaled down in a feed and blown up on a deck
|
|
29
|
+
// page. 2x of a 500x700 card is 1000x1400.
|
|
30
|
+
const SCALE = 2;
|
|
31
|
+
const NAV_TIMEOUT_MS = 20_000;
|
|
32
|
+
// Long enough for a deck to boot, load assets and render real frames -- a cover
|
|
33
|
+
// of frame zero is a loading screen. Deliberately generous: this runs once per
|
|
34
|
+
// save, never in a player's path.
|
|
35
|
+
const SETTLE_MS = 3_500;
|
|
36
|
+
// The card the SDK mounts (`initCard`) or a deck marks itself. Falling back to
|
|
37
|
+
// the whole viewport keeps a deck that does neither from getting no cover at
|
|
38
|
+
// all -- the viewport IS the card in play mode.
|
|
39
|
+
const CARD_SELECTOR = "#castle-card, [data-castle-card]";
|
|
40
|
+
// Grab the deck's cover from `serveUrl` and write it to `<projectDir>/.castle`.
|
|
41
|
+
// Never throws; a failure is reported, not raised.
|
|
42
|
+
export async function captureCoverHeadless(opts) {
|
|
43
|
+
const manager = opts.manager ?? createPlaytestBrowserManager();
|
|
44
|
+
const outcome = await manager.withBrowser((browser) => shoot(browser, opts.serveUrl), { onProgress: opts.onProgress });
|
|
45
|
+
if (!outcome.ok)
|
|
46
|
+
return { ok: false, error: outcome.error };
|
|
47
|
+
if (!outcome.value.png)
|
|
48
|
+
return { ok: false, error: outcome.value.error ?? "capture produced no image" };
|
|
49
|
+
try {
|
|
50
|
+
fs.mkdirSync(path.dirname(opts.outPath), { recursive: true });
|
|
51
|
+
fs.writeFileSync(opts.outPath, outcome.value.png);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, outPath: opts.outPath, installedMs: outcome.installedMs };
|
|
57
|
+
}
|
|
58
|
+
async function shoot(browser, serveUrl) {
|
|
59
|
+
const context = await browser.newContext({
|
|
60
|
+
viewport: { ...VIEWPORT },
|
|
61
|
+
deviceScaleFactor: SCALE,
|
|
62
|
+
// A cover is a still of a game: give it a touch-shaped context, since decks
|
|
63
|
+
// are phone-first and some branch their controls on it.
|
|
64
|
+
hasTouch: true,
|
|
65
|
+
isMobile: true,
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
const page = await context.newPage();
|
|
69
|
+
// `/index.html`, NOT `/`: the serve's root is the editor shell, and the deck
|
|
70
|
+
// itself is what the shell loads into its iframe (see serve.ts). Pointing at
|
|
71
|
+
// `/` photographs the editor. `?edit=0` then asks for the play surface, so
|
|
72
|
+
// the deck renders in its card with no editor UI -- which is what a cover is.
|
|
73
|
+
const url = `${serveUrl.replace(/\/$/, "")}/index.html?edit=0`;
|
|
74
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
|
|
75
|
+
// Let it boot and actually draw. `waitForTimeout` rather than a load event:
|
|
76
|
+
// a deck is a game, and "loaded" says nothing about whether it has rendered
|
|
77
|
+
// anything worth showing.
|
|
78
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
79
|
+
// A deck that failed to build renders vite's error overlay, and a browser
|
|
80
|
+
// screenshots that just as faithfully as it screenshots a game -- so without
|
|
81
|
+
// this, a broken deck publishes a cover of its own stack trace. The in-page
|
|
82
|
+
// path could not do this (nothing renders, so nothing is captured), so it is
|
|
83
|
+
// a failure mode this approach introduces and has to close itself.
|
|
84
|
+
const broken = await page.$("vite-error-overlay");
|
|
85
|
+
if (broken)
|
|
86
|
+
return { error: "the deck is showing a build error, so there is nothing to photograph" };
|
|
87
|
+
const card = await page.$(CARD_SELECTOR);
|
|
88
|
+
// Element screenshot clips to the card for us. Without a card, the viewport
|
|
89
|
+
// IS the card in play mode, so shoot that.
|
|
90
|
+
const png = card ? await card.screenshot({ type: "png" }) : await page.screenshot({ type: "png" });
|
|
91
|
+
return { png };
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await context.close().catch(() => undefined);
|
|
98
|
+
}
|
|
99
|
+
}
|
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
|
@@ -16,10 +16,13 @@ import headlessPkg from '@xterm/headless';
|
|
|
16
16
|
import { SerializeAddon } from '@xterm/addon-serialize';
|
|
17
17
|
import { WebSocketServer } from 'ws';
|
|
18
18
|
import { IMPORTS_DIR, importStatuses, updateImport } from './imports.js';
|
|
19
|
+
import { ensureVisibleGlob, ensureVisiblePath } from './castleJson.js';
|
|
19
20
|
import { readEditorConfig, resolveFileTypes, } from './editorConfig.js';
|
|
20
21
|
import { UNSUPPORTED_MEDIA } from './unsupportedMedia.js';
|
|
21
22
|
import { IMPORT_API_PREFIX, handleImportApi } from './importBrowse.js';
|
|
22
23
|
import { readRequestBody, sendJson } from './httpJson.js';
|
|
24
|
+
import { COVER_FILE } from './localPaths.js';
|
|
25
|
+
import { applyVersionRestore, createVersion, NotOnThisLine, pendingChanges, UnsavedChanges, versionSummaries, } from './versions.js';
|
|
23
26
|
import { envForUserShell, installCliShims } from './byo-auth.js';
|
|
24
27
|
const HeadlessTerminal = headlessPkg.Terminal;
|
|
25
28
|
const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -135,15 +138,34 @@ export const FAVICON_LINK_TAGS = [
|
|
|
135
138
|
// hands those files to the kit iframe and keeps the builtin editor as the
|
|
136
139
|
// default for everything else).
|
|
137
140
|
export const FILES_API_PREFIX = '/__castle/files/';
|
|
138
|
-
// The deck's local cover image.
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
141
|
+
// The deck's local cover image. Nothing in THIS shell posts here any more --
|
|
142
|
+
// covers are captured headlessly by `save-deck` (see headlessCover.ts), which
|
|
143
|
+
// photographs the deck with a real browser instead of asking the page to
|
|
144
|
+
// rasterize itself. The endpoint stays because the shell is baked into the
|
|
145
|
+
// sandbox image: a warm sandbox keeps running an older shell that still posts
|
|
146
|
+
// its automatic captures here, and accepting them is cheaper than making those
|
|
147
|
+
// requests 404. `save-deck` still reads preview.png as a last-resort fallback.
|
|
148
|
+
//
|
|
149
|
+
// Its own endpoint rather than a files/upload because this file is written over
|
|
150
|
+
// and over and upload deliberately refuses to clobber. A cover is platform
|
|
151
|
+
// vocabulary, not a kit's, so it belongs here.
|
|
142
152
|
export const COVER_API_PATH = '/__castle/cover';
|
|
143
|
-
|
|
153
|
+
// Re-exported so `save-deck` and anything else that already asks the serve for
|
|
154
|
+
// the cover's name keeps working.
|
|
155
|
+
export { COVER_FILE };
|
|
144
156
|
// Directories never surfaced in the file list / never read or written through
|
|
145
157
|
// the builtin editor: VCS, deck-private state, and dependency trees.
|
|
146
158
|
const FILES_IGNORE_DIRS = new Set(['.git', '.castle', 'node_modules', 'dist']);
|
|
159
|
+
// Never writable through the files API, whatever the tree shows. `.castle` holds
|
|
160
|
+
// the deck's private state, and since build 1 that includes the version store --
|
|
161
|
+
// the one place where losing bytes loses history rather than a file. The list
|
|
162
|
+
// above is about what the tree SHOWS and could reasonably change; this is about
|
|
163
|
+
// what a write may touch, so the store stays safe if it ever does.
|
|
164
|
+
//
|
|
165
|
+
// A guard, not a lock: the directory keeps its normal permissions. Making it
|
|
166
|
+
// read-only and unlocking around writes races with concurrent processes, gets
|
|
167
|
+
// clobbered by tools, and still would not stop a raw shell write.
|
|
168
|
+
const PROTECTED_WRITE_DIRS = new Set(['.castle']);
|
|
147
169
|
// Ceiling on one uploaded file. A deck is saved whole (source tar + bundle), so
|
|
148
170
|
// a huge asset is a problem for the deck long before it is a problem here --
|
|
149
171
|
// refuse it at the door with a message rather than let it land and break `save`.
|
|
@@ -197,6 +219,12 @@ function resolveDeckPath(deckDir, requestedPath, opts = {}) {
|
|
|
197
219
|
return { ok: false, error: `Path outside the deck: ${requestedPath}` };
|
|
198
220
|
}
|
|
199
221
|
const parts = normalized.split(path.sep);
|
|
222
|
+
if (opts.mutation && parts.some((p) => PROTECTED_WRITE_DIRS.has(p))) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
error: `${requestedPath} is the deck's own state (the version store lives here) and is not writable.`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
200
228
|
if (parts.some((p) => FILES_IGNORE_DIRS.has(p))) {
|
|
201
229
|
return { ok: false, error: `Protected deck path: ${requestedPath}` };
|
|
202
230
|
}
|
|
@@ -347,6 +375,61 @@ function sendFailure(res, action, rel, err) {
|
|
|
347
375
|
const message = err instanceof Error ? err.message : String(err);
|
|
348
376
|
sendJson(res, 500, { error: `Could not ${action} ${rel}: ${message}` });
|
|
349
377
|
}
|
|
378
|
+
// The version panel's three calls, on the same store the `save-version` /
|
|
379
|
+
// `restore-version` commands use -- the editor is another way to run them, not
|
|
380
|
+
// a second history.
|
|
381
|
+
function handleVersionsList(deckDir, res) {
|
|
382
|
+
try {
|
|
383
|
+
// The history and what is outstanding against it, together: the panel shows
|
|
384
|
+
// them as one picture, and two calls could disagree about the moment.
|
|
385
|
+
sendJson(res, 200, { versions: versionSummaries(deckDir), pending: pendingChanges(deckDir) });
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Saves everything dirty: the panel has no path selection, so partial saves
|
|
392
|
+
// stay a CLI and agent thing.
|
|
393
|
+
function handleVersionSave(deckDir, req, res) {
|
|
394
|
+
withJsonBody(req, res, (body) => {
|
|
395
|
+
const message = typeof body.message === 'string' ? body.message : '';
|
|
396
|
+
try {
|
|
397
|
+
const { version } = createVersion(deckDir, { message });
|
|
398
|
+
sendJson(res, 200, { ok: true, id: version?.id ?? null });
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// Never forced. Unsaved work that a restore would overwrite comes back as a 409
|
|
406
|
+
// naming the files, which the panel shows -- saving a version first is the fix,
|
|
407
|
+
// and it is one click away in the same panel.
|
|
408
|
+
function handleVersionRestore(deckDir, req, res) {
|
|
409
|
+
withJsonBody(req, res, (body) => {
|
|
410
|
+
if (typeof body.id !== 'string' || !body.id) {
|
|
411
|
+
return sendJson(res, 400, { error: 'Missing version id.' });
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const outcome = applyVersionRestore(deckDir, body.id);
|
|
415
|
+
sendJson(res, 200, {
|
|
416
|
+
ok: true,
|
|
417
|
+
restored: outcome.applied.length,
|
|
418
|
+
offChain: outcome.offChain,
|
|
419
|
+
alreadyThere: outcome.alreadyThere,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
catch (e) {
|
|
423
|
+
// Both refusals are answers, not faults: the caller asked for something
|
|
424
|
+
// this history can't do, and the message says which.
|
|
425
|
+
const refused = e instanceof UnsavedChanges || e instanceof NotOnThisLine;
|
|
426
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
427
|
+
sendJson(res, refused ? 409 : 500, {
|
|
428
|
+
error: e instanceof UnsavedChanges ? `${message}\nSave a version first.` : message,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
350
433
|
function handleFilesWrite(deckDir, req, res) {
|
|
351
434
|
withMutationPath(deckDir, req, res, (target, body) => {
|
|
352
435
|
if (typeof body.contents !== 'string') {
|
|
@@ -362,44 +445,6 @@ function handleFilesWrite(deckDir, req, res) {
|
|
|
362
445
|
}
|
|
363
446
|
});
|
|
364
447
|
}
|
|
365
|
-
// Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
|
|
366
|
-
// newly-made folder show in the curated Files tree.
|
|
367
|
-
function ensureVisiblePath(deckDir, rel) {
|
|
368
|
-
return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
|
|
369
|
-
}
|
|
370
|
-
// Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
|
|
371
|
-
// ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
|
|
372
|
-
// everything is visible, and adding a glob would wrongly start hiding things.
|
|
373
|
-
// No-op if an existing glob already covers `probe`, a path the new glob would
|
|
374
|
-
// match. Returns whether it wrote.
|
|
375
|
-
function ensureVisibleGlob(deckDir, glob, probe) {
|
|
376
|
-
const file = path.join(deckDir, 'castle.json');
|
|
377
|
-
let data;
|
|
378
|
-
try {
|
|
379
|
-
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
380
|
-
}
|
|
381
|
-
catch {
|
|
382
|
-
return false; // no castle.json yet (deck never saved) -> treat as not curated
|
|
383
|
-
}
|
|
384
|
-
const visible = data.editor && Array.isArray(data.editor.visiblePaths)
|
|
385
|
-
? data.editor.visiblePaths.filter((v) => typeof v === 'string')
|
|
386
|
-
: null;
|
|
387
|
-
if (!visible || visible.length === 0)
|
|
388
|
-
return false; // not curated -> all visible
|
|
389
|
-
if (visible.includes(glob))
|
|
390
|
-
return false;
|
|
391
|
-
if (picomatch(visible)(probe))
|
|
392
|
-
return false; // already covered
|
|
393
|
-
visible.push(glob);
|
|
394
|
-
data.editor.visiblePaths = visible;
|
|
395
|
-
try {
|
|
396
|
-
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
397
|
-
return true;
|
|
398
|
-
}
|
|
399
|
-
catch {
|
|
400
|
-
return false;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
448
|
function handleFilesMkdir(deckDir, req, res) {
|
|
404
449
|
withMutationPath(deckDir, req, res, (target) => {
|
|
405
450
|
try {
|
|
@@ -688,6 +733,18 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
688
733
|
})();
|
|
689
734
|
return true;
|
|
690
735
|
}
|
|
736
|
+
if (action === 'versions') {
|
|
737
|
+
handleVersionsList(deckDir, res);
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
if (action === 'save-version') {
|
|
741
|
+
handleVersionSave(deckDir, req, res);
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
if (action === 'restore-version') {
|
|
745
|
+
handleVersionRestore(deckDir, req, res);
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
691
748
|
if (action === 'list') {
|
|
692
749
|
// `?all=1` returns the unfiltered listing (the "show hidden files & folders"
|
|
693
750
|
// toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
|
package/dist/imports.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as api from './api.js';
|
|
|
5
5
|
import { runTar } from './save-deck.js';
|
|
6
6
|
import { normalizeDeckPackageJson } from './normalize.js';
|
|
7
7
|
import { getKitsDir } from './localPaths.js';
|
|
8
|
-
import { readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
|
|
8
|
+
import { ensureVisibleGlob, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
|
|
9
9
|
// Adding another deck as a dependency (`castle-web add-import`; removing one is a
|
|
10
10
|
// later command). Deliberately NOT `get-deck`: that one
|
|
11
11
|
// replaces THIS deck's own source from the server (and carries guards for the
|
|
@@ -485,7 +485,7 @@ export async function addImportTo(dir, options = {}) {
|
|
|
485
485
|
const declaredMain = tryReadCastleJson(importDir)?.main;
|
|
486
486
|
let adoptedMain = null;
|
|
487
487
|
if (typeof declaredMain === 'string' && declaredMain.trim()) {
|
|
488
|
-
const { isPristineBareDeck, adoptImportEntry, writeStarterScene } = await import('./init.js');
|
|
488
|
+
const { isPristineBareDeck, adoptImportEntry, writeStarterScene, writeStarterTheme } = await import('./init.js');
|
|
489
489
|
if (isPristineBareDeck(targetDir)) {
|
|
490
490
|
adoptImportEntry(targetDir, alias, declaredMain.trim());
|
|
491
491
|
adoptedMain = declaredMain.trim();
|
|
@@ -500,6 +500,14 @@ export async function addImportTo(dir, options = {}) {
|
|
|
500
500
|
console.warn(`Adopted ${alias} but could not write its starter scene: ${e instanceof Error ? e.message : String(e)}`);
|
|
501
501
|
}
|
|
502
502
|
}
|
|
503
|
+
// Same for the kit's starter theme.style (skips on its own if the deck
|
|
504
|
+
// already has one or the kit ships none).
|
|
505
|
+
try {
|
|
506
|
+
writeStarterTheme(targetDir, importDir);
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
console.warn(`Adopted ${alias} but could not write its starter theme.style: ${e instanceof Error ? e.message : String(e)}`);
|
|
510
|
+
}
|
|
503
511
|
}
|
|
504
512
|
}
|
|
505
513
|
// An import's packages have to be ON DISK, not just named in package.json:
|
|
@@ -557,9 +565,41 @@ export async function addImport(dir, options = {}) {
|
|
|
557
565
|
// `update-import` and the serve-start auto update come through here, so there
|
|
558
566
|
// is one path that replaces an import's files rather than two that have to be
|
|
559
567
|
// kept in step. `log` is how the caller labels the lines it produces.
|
|
568
|
+
// A kit update can introduce deck-level starter content that an existing deck
|
|
569
|
+
// predates. Same additive spirit as syncImportDependencies (which adds a kit's
|
|
570
|
+
// new package deps to the deck's own package.json on update): seed the kit's
|
|
571
|
+
// `theme.style` at the deck root when the deck has none. The engine reads it
|
|
572
|
+
// from the deck root only -- it is the creator's file, like scenes/ -- so
|
|
573
|
+
// without this, decks scaffolded before the kit shipped one would never get
|
|
574
|
+
// it. Never overwrites: a deck that has the file keeps it, whatever the kit
|
|
575
|
+
// ships. Also registers the file in a curated deck's editor.visiblePaths so it
|
|
576
|
+
// shows in the Files tree (the same courtesy mkdir/upload extend).
|
|
577
|
+
function seedKitTheme(deckDir, alias, log) {
|
|
578
|
+
const importDir = path.join(deckDir, IMPORTS_DIR, alias);
|
|
579
|
+
// Only kits (imports that declare an entry in castle.json `main`) seed deck
|
|
580
|
+
// content -- a plain deck import that happens to carry a theme.style of its
|
|
581
|
+
// own must not set this deck's palette.
|
|
582
|
+
const main = tryReadCastleJson(importDir)?.main;
|
|
583
|
+
if (typeof main !== 'string' || !main.trim())
|
|
584
|
+
return;
|
|
585
|
+
const src = path.join(importDir, 'theme.style');
|
|
586
|
+
const dest = path.join(deckDir, 'theme.style');
|
|
587
|
+
if (!fs.existsSync(src) || fs.existsSync(dest))
|
|
588
|
+
return;
|
|
589
|
+
try {
|
|
590
|
+
fs.copyFileSync(src, dest);
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
console.warn(`Could not seed theme.style from ${alias}: ${e instanceof Error ? e.message : String(e)}`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
ensureVisibleGlob(deckDir, 'theme.style', 'theme.style');
|
|
597
|
+
log(`Seeded theme.style from ${alias} (new in this version; the deck's to edit)`);
|
|
598
|
+
}
|
|
560
599
|
async function applyImportUpdate(deckDir, alias, update, source, log) {
|
|
561
600
|
await placeImport(deckDir, alias, update.deckId, source, update.via);
|
|
562
601
|
log(`Updated ${alias} (${update.from} -> ${source.updatedAt})`);
|
|
602
|
+
seedKitTheme(deckDir, alias, log);
|
|
563
603
|
// Its own dependencies may have moved too.
|
|
564
604
|
const transitive = await addTransitiveImports(deckDir, alias, importedDeckIds(deckDir));
|
|
565
605
|
for (const t of transitive)
|