castle-web-cli 0.4.109 → 0.4.111

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.
@@ -1,5 +1,5 @@
1
1
  export type FailureKind = "config" | "limit" | "transient" | "no-work" | "spawn" | "timeout" | "exit";
2
- export type ConfigReason = "no-key" | "bad-key" | "no-credits" | "unknown-model" | "no-tools" | "no-endpoints" | "flagged" | "context-length";
2
+ export type ConfigReason = "no-key" | "bad-key" | "no-credits" | "model-not-allowed" | "unknown-model" | "no-tools" | "no-endpoints" | "flagged" | "context-length";
3
3
  export interface AgentFailure {
4
4
  kind: FailureKind;
5
5
  reason?: ConfigReason;
@@ -150,6 +150,8 @@ function configCopy(failure) {
150
150
  return "OpenRouter rejected this session's API key. The person running this session needs to check it -- reach out to Castle if you need help.";
151
151
  case "no-credits":
152
152
  return "OpenRouter is out of credits for this session's key. Reach out to Castle to top it up, or switch to a different model in settings.";
153
+ case "model-not-allowed":
154
+ return `${model} isn't available on this Castle account. Pick a different model in settings, or run it on your own API key or login.`;
153
155
  case "unknown-model": {
154
156
  const hint = failure.suggestion ? ` Did you mean "${failure.suggestion}"?` : "";
155
157
  return `I can't use the model this session is set to -- OpenRouter doesn't recognize ${model}.${hint} Pick a different model in settings. If you think that model should work, reach out to Castle.`;
@@ -26,6 +26,7 @@ What a deck is: a normal web project served by vite -- index.html plus plain JS/
26
26
 
27
27
  Hard rules:
28
28
  - You NEVER edit files or run state-changing commands. All building and fixing happens through background task agents -- always hand the longer work to them.
29
+ - IMPORTS ARE YOURS, and they are the one exception to the rule above. Run \`castle-web list-decks --kind kits\` (also \`mine\` and \`saved\`) to see what this deck can import, and \`castle-web add-import <deckIdOrUrl>\` to add one -- both directly, not through a task. Do NOT claim you can list or import anything until you have actually run these; do not describe imports you have not looked at. \`list-decks\` prints one deck per line starting with the id \`add-import\` takes, and marks the ones this deck already has. A pasted castle.xyz deck link works in place of an id. Everything else about a deck's files still goes to task agents.
29
30
  - You are the fast lane: get to your final reply as quickly as possible. When the user reports something broken, do NOT dig into the code to diagnose it first -- spawn a task whose job is to investigate AND fix it. Only read deck files when your reply itself needs them (answering a question about the deck, grounding a claim -- never make things up); never read as pre-work before spawning a task, and never read files just to learn conventions already covered by the Quick reference.
30
31
  - Launch a SET of small steps the user tests one by one -- a pipeline, never one big task they wait on, never untestable fragments. One interacting mechanic = one task (paddle + ball + bricks = one playable core, not three). First step = the smallest genuinely playable thing; later steps build it out. Match breadth to ambition ("basic" = a few steps; "go wild" = many). You're optimizing the user's taste and feedback -- more small testable steps = more points where they steer it into something theirs.
31
32
  - The whole goal: every piece of work TESTABLE in actual gameplay ASAP. Start every task as early as possible and run them in PARALLEL. Do NOT break tasks down by which files they touch, and never add \`after:\` just to avoid two tasks editing the same file -- tasks make surgical edits and overlap fine. The only real dependency between tasks is INFORMATION: a task is blocked only when it needs a fact it does not yet have.
package/dist/agent.js CHANGED
@@ -92,6 +92,23 @@ function normalizeClaudeModel(value) {
92
92
  ? value
93
93
  : null;
94
94
  }
95
+ // A claude run is spawned with an alias (`--model fable`) but reaches the proxy
96
+ // as a concrete id (`claude-fable-5`), and it is the ids the proxy refuses. This
97
+ // is the one place that knows both, so the model picker and the pre-flight
98
+ // refusal can't disagree about which models a user actually has.
99
+ const CLAUDE_MODEL_ID_PREFIXES = {
100
+ sonnet: "claude-sonnet-",
101
+ opus: "claude-opus-",
102
+ fable: "claude-fable-",
103
+ };
104
+ // Blocked when the two prefixes agree as far as the shorter one goes: the proxy
105
+ // may name a family ("claude-fable-") or one model within it.
106
+ function claudeModelBlocked(model, budget) {
107
+ const id = CLAUDE_MODEL_ID_PREFIXES[model];
108
+ if (!id || !budget)
109
+ return false;
110
+ return budget.blockedModelPrefixes.some((p) => p.startsWith(id) || id.startsWith(p));
111
+ }
95
112
  // Free-form, so validation is just "non-empty, not absurdly long" (guards
96
113
  // against a stray huge paste landing in settings.json / the CLI argv).
97
114
  const OPENROUTER_MODEL_MAX_LEN = 200;
@@ -1654,14 +1671,29 @@ function anyRoleIsCastlePaid(settings) {
1654
1671
  return (runIsCastlePaid(settings.router, settings.routerClaudeModel, null) ||
1655
1672
  runIsCastlePaid(settings.tasks, settings.tasksClaudeModel, null));
1656
1673
  }
1657
- // The proxy 403s a spent-out user mid-stream, which a CLI surfaces as a generic
1658
- // provider error after a spawn. Asking first turns that into one sentence and
1659
- // no spawn. Fails open on every non-answer: the proxy is the real backstop.
1660
- async function budgetRefusal(backend, claudeModel, orAuth) {
1674
+ // The proxy 403s a spent-out user -- or one who asked for a model they don't
1675
+ // have -- mid-stream, which a CLI surfaces as a generic provider error after a
1676
+ // spawn. Asking first turns either into one sentence and no spawn. Fails open on
1677
+ // every non-answer: the proxy is the real backstop.
1678
+ //
1679
+ // Only the claude aliases are checked, which is exactly what the picker offers.
1680
+ // A free-form OpenRouter slug naming a restricted model is left to the proxy:
1681
+ // resolving an arbitrary slug to what it bills as is its job, not the editor's.
1682
+ async function castleSpendRefusal(backend, claudeModel, orAuth) {
1661
1683
  if (!runIsCastlePaid(backend, claudeModel, orAuth))
1662
1684
  return null;
1663
1685
  const budget = await fetchBudget();
1664
- if (!budget?.blocked)
1686
+ if (!budget)
1687
+ return null;
1688
+ if (claudeModelBlocked(claudeModel, budget)) {
1689
+ return {
1690
+ kind: "config",
1691
+ reason: "model-not-allowed",
1692
+ detail: `${claudeModel} is not available on this Castle account`,
1693
+ model: claudeModel,
1694
+ };
1695
+ }
1696
+ if (!budget.blocked)
1665
1697
  return null;
1666
1698
  return {
1667
1699
  kind: "limit",
@@ -1673,6 +1705,33 @@ async function budgetRefusal(backend, claudeModel, orAuth) {
1673
1705
  // finished run already refreshes. This is for the spend this serve never sees
1674
1706
  // -- a `claude` invoked straight from the sandbox terminal.
1675
1707
  const USAGE_POLL_MS = 60_000;
1708
+ const PICKER_CLAUDE_MODELS = ["sonnet", "opus", "fable"];
1709
+ /**
1710
+ * Which of the picker's claude models this editor can't use. Gated on the
1711
+ * ANTHROPIC credential specifically, not on `anyRoleIsCastlePaid` (which draws
1712
+ * the usage bar): those two disagree exactly when one role runs on Castle's
1713
+ * OpenRouter key -- or on Castle's cursor key -- while the user's own Anthropic
1714
+ * key or login covers every claude run. Those runs never reach the proxy, so
1715
+ * nothing about them is Castle's to restrict, and the picker must keep offering
1716
+ * the model. A claude run on a fixed alias always resolves through
1717
+ * resolveAnthropicAuth, so no role's settings enter into this.
1718
+ */
1719
+ function blockedClaudeModels(budget) {
1720
+ if (resolveAnthropicAuth().mode !== "proxy")
1721
+ return [];
1722
+ return PICKER_CLAUDE_MODELS.filter((m) => claudeModelBlocked(m, budget));
1723
+ }
1724
+ function usageFrame(budget) {
1725
+ if (!budget)
1726
+ return null;
1727
+ return {
1728
+ usedMicros: budget.usedMicros,
1729
+ limitMicros: budget.limitMicros,
1730
+ resetAtMs: budget.resetAtMs,
1731
+ blocked: budget.blocked,
1732
+ blockedClaudeModels: blockedClaudeModels(budget),
1733
+ };
1734
+ }
1676
1735
  /**
1677
1736
  * The editor's daily-usage feed, pushed over the agent socket exactly the way
1678
1737
  * settings are: the current value rides `hello`, and a change is broadcast.
@@ -1689,7 +1748,7 @@ const USAGE_POLL_MS = 60_000;
1689
1748
  function createUsageFeed(opts) {
1690
1749
  let latest = null;
1691
1750
  async function refreshAsync() {
1692
- const next = opts.castlePaid() ? await fetchBudget() : null;
1751
+ const next = usageFrame(opts.castlePaid() ? await fetchBudget() : null);
1693
1752
  if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
1694
1753
  return;
1695
1754
  latest = next;
@@ -1725,11 +1784,11 @@ async function runAgentTurn(opts) {
1725
1784
  // Deterministic config errors stop here: nothing spawned, no request issued,
1726
1785
  // nothing billed. Returned (not thrown) because the callers' catch paths
1727
1786
  // emit generic "something went wrong" copy, which would bury the specific
1728
- // reason this pre-flight exists to produce. A spent-out daily budget is the
1729
- // same shape of answer, and comes second so a misconfigured run is still
1730
- // reported as misconfigured.
1787
+ // reason this pre-flight exists to produce. What Castle's spend policy
1788
+ // refuses is the same shape of answer, and comes second so a misconfigured
1789
+ // run is still reported as misconfigured.
1731
1790
  const failure = (await preflightOpenrouterRun({ ...opts, orAuth })) ??
1732
- (await budgetRefusal(opts.backend, opts.claudeModel, orAuth));
1791
+ (await castleSpendRefusal(opts.backend, opts.claudeModel, orAuth));
1733
1792
  if (failure) {
1734
1793
  return {
1735
1794
  ok: false,
package/dist/api.d.ts CHANGED
@@ -29,14 +29,33 @@ export interface MeProfile {
29
29
  } | null;
30
30
  }
31
31
  export declare function me(): Promise<MeProfile | null>;
32
- export interface DeckSummary {
32
+ export interface DeckRow {
33
33
  deckId: string;
34
- title: string;
35
- initialCard?: {
36
- cardId: string;
34
+ title: string | null;
35
+ creator: {
36
+ username: string;
37
37
  } | null;
38
+ initialCard: {
39
+ backgroundImage: {
40
+ smallUrl: string | null;
41
+ } | null;
42
+ } | null;
43
+ parentDeck: {
44
+ creator: {
45
+ username: string;
46
+ } | null;
47
+ } | null;
48
+ }
49
+ export declare function myDecks(): Promise<DeckRow[]>;
50
+ export declare function feedDecks(feedId: string, limit: number): Promise<DeckRow[]>;
51
+ export interface PlaylistSummary {
52
+ playlistId: string;
53
+ title: string;
38
54
  }
39
- export declare function myDecks(): Promise<DeckSummary[]>;
55
+ export declare function myPlaylists(userId: string, limit: number): Promise<PlaylistSummary[]>;
56
+ export declare function isDeckIdShaped(value: string): boolean;
57
+ export declare function webDeckSourceVersions(deckIds: string[]): Promise<Map<string, string>>;
58
+ export declare function deckRows(deckIds: string[]): Promise<DeckRow[]>;
40
59
  export declare function updateCardAndDeckV2(deck: Record<string, unknown>, card: Record<string, unknown>): Promise<{
41
60
  deckId: string;
42
61
  cardId: string;
package/dist/api.js CHANGED
@@ -51,20 +51,82 @@ export async function me() {
51
51
  return null;
52
52
  }
53
53
  }
54
+ // Everything an import-picker row shows for one deck: what it is, whose it is,
55
+ // what it looks like, and who it was remixed from. One fragment, so the three
56
+ // list queries below can't drift apart.
57
+ const DECK_ROW_FIELDS = `
58
+ deckId
59
+ title
60
+ creator { username }
61
+ initialCard { backgroundImage { smallUrl } }
62
+ parentDeck { creator { username } }
63
+ `;
64
+ // The signed-in user's own decks, in the server's order (newest first).
65
+ // Unbounded -- an account can hold hundreds -- so callers page through it
66
+ // themselves rather than the server guessing a cutoff.
54
67
  export async function myDecks() {
55
- const data = await graphql(`query {
56
- me {
57
- decks {
58
- deckId
59
- title
60
- initialCard { cardId }
61
- }
62
- }
63
- }`);
68
+ const data = await graphql(`query { me { decks { ${DECK_ROW_FIELDS} } } }`);
64
69
  handleAPIError(data);
65
70
  const meData = data.data?.me;
66
71
  return meData?.decks ?? [];
67
72
  }
73
+ // Any explore feed, as deck rows. `bookmarks` is the saved-decks feed;
74
+ // `playlist:<id>` is one playlist's decks.
75
+ export async function feedDecks(feedId, limit) {
76
+ const data = await graphql(`query($feedId: ID!, $limit: Int) {
77
+ paginateFeed(feedId: $feedId, limit: $limit) { ${DECK_ROW_FIELDS} }
78
+ }`, { feedId, limit });
79
+ handleAPIError(data);
80
+ return pickData(data, 'paginateFeed') ?? [];
81
+ }
82
+ // The user's playlists. Their bookmarks playlist is one of these (id
83
+ // `bookmarks-<userId>`), so a caller listing both has to dedupe.
84
+ export async function myPlaylists(userId, limit) {
85
+ const data = await graphql(`query($userId: ID!, $limit: Int) {
86
+ playlistsForUser(userId: $userId, limit: $limit) {
87
+ items { playlistId title }
88
+ }
89
+ }`, { userId, limit });
90
+ handleAPIError(data);
91
+ const result = data.data?.playlistsForUser;
92
+ return (result?.items ?? []).filter((p) => p && typeof p.playlistId === 'string');
93
+ }
94
+ // Deck ids are server-issued, but this one builds a query string out of them,
95
+ // so anything that isn't id-shaped is dropped rather than interpolated.
96
+ const DECK_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
97
+ export function isDeckIdShaped(value) {
98
+ return DECK_ID_RE.test(value);
99
+ }
100
+ // Ask the same per-deck question about many decks in ONE request, by aliasing
101
+ // the field once per id. The picker asks these about every candidate row, and a
102
+ // request per deck would be dozens of round trips -- 200 aliases answer in about
103
+ // half a second. Results come back in alias order; a deck the server won't
104
+ // answer for (deleted, not visible) is simply absent.
105
+ async function aliasedDeckQuery(deckIds, field) {
106
+ const ids = deckIds.filter(isDeckIdShaped);
107
+ if (ids.length === 0)
108
+ return [];
109
+ const aliases = ids.map((id, i) => `a${i}: ${field(id)}`).join('\n');
110
+ const data = await graphql(`query {\n${aliases}\n}`);
111
+ return Object.values(data.data ?? {}).filter(Boolean);
112
+ }
113
+ // Which of these decks have web source on the server, and at what version.
114
+ // Having source is what makes a deck importable, so this is the picker's filter.
115
+ export async function webDeckSourceVersions(deckIds) {
116
+ const sources = await aliasedDeckQuery(deckIds, (id) => `webDeckSource(deckId: "${id}") { deckId updatedAt }`);
117
+ const found = new Map();
118
+ for (const source of sources) {
119
+ if (source.deckId && typeof source.updatedAt === 'string') {
120
+ found.set(source.deckId, source.updatedAt);
121
+ }
122
+ }
123
+ return found;
124
+ }
125
+ // Named decks as picker rows -- for a deck someone pasted a link to, and for
126
+ // the kits list when it is a fixed set of decks rather than a playlist.
127
+ export async function deckRows(deckIds) {
128
+ return aliasedDeckQuery(deckIds, (id) => `deck(deckId: "${id}") { ${DECK_ROW_FIELDS} }`);
129
+ }
68
130
  export async function updateCardAndDeckV2(deck, card) {
69
131
  const data = await graphql(`mutation($deck: DeckInput!, $card: CardInput!) {
70
132
  updateCardAndDeckV2(deck: $deck, card: $card) {
@@ -1 +1 @@
1
- export declare const COMMON_INSTRUCTIONS = "## Assets (every deck)\n\n- **Load static assets (drawings, audio, etc.) through the bundler \u2014 never runtime-`fetch` a loose file path.** Use a static `import`, `import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so `fetch('drawings/qb.svg')` looks like it works locally \u2014 but `save-deck` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw `fetch`.\n\n## Touch controls (every deck)\n\n- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch \u2014 direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics \u2014 a game with no directional movement should have no movement controls at all.\n\n## Fit the card (every deck)\n\n- **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, `min()`, `clamp()`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports `CARD_RATIO` (= 5 / 7) if you need the exact ratio.\n- **Hand-rolled `<canvas>` elements must account for devicePixelRatio, or the game looks blurry on phones.** Size the backing store to the CSS layout size times `devicePixelRatio` (e.g. `canvas.width = rect.width * dpr`), keep the CSS width/height as the layout size, and scale the 2D context (`ctx.scale(dpr, dpr)`) so drawing code stays in CSS units \u2014 re-apply on resize. Kit decks don't need to do this by hand; the kit's engine already configures its canvas for DPR.\n - Exception: deliberate pixel art wants a fixed low-resolution backing store with `image-rendering: pixelated` CSS instead \u2014 don't DPR-scale that; the crisp chunky look is the point.\n";
1
+ export declare const COMMON_INSTRUCTIONS = "## Imports are read-only (every deck)\n\n- **`imports/` holds other decks' files and is locked read-only on disk.** Files are mode 0444 and directories 0555, so any shell command that writes, moves, or deletes inside `imports/` fails with `EACCES` / \"Permission denied\" \u2014 that is the lock working, not a broken checkout. Do not `chmod` around it, do not `sudo`, and do not retry the command a different way.\n- **Change an import through the CLI, never the filesystem.** `castle-web add-import <deckIdOrUrl>` adds one and `castle-web update-import [alias]` re-fetches it (`--check` to see if it is outdated, `--revert` to undo). These handle the unlock/relock themselves.\n- **`castle-web list-decks [--kind mine|saved|kits]` is how you find out WHAT can be imported** \u2014 one deck per line, starting with the id `add-import` takes, and marked when this deck already has it. `imports/` only shows what is here already; it is not a catalogue.\n- **To change an imported deck's behavior, copy what you need into this deck and edit the copy**, then reference your copy. Editing in place is not available, and an `update-import` would overwrite it anyway.\n- **Deleting the deck directory itself needs the lock released first** (`chmod -R u+w` on the deck dir) \u2014 that is the one legitimate reason to touch the modes, and only for a directory being thrown away.\n\n## Assets (every deck)\n\n- **Load static assets (drawings, audio, etc.) through the bundler \u2014 never runtime-`fetch` a loose file path.** Use a static `import`, `import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so `fetch('drawings/qb.svg')` looks like it works locally \u2014 but `save-deck` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw `fetch`.\n\n## Touch controls (every deck)\n\n- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch \u2014 direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics \u2014 a game with no directional movement should have no movement controls at all.\n\n## Fit the card (every deck)\n\n- **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, `min()`, `clamp()`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports `CARD_RATIO` (= 5 / 7) if you need the exact ratio.\n- **Hand-rolled `<canvas>` elements must account for devicePixelRatio, or the game looks blurry on phones.** Size the backing store to the CSS layout size times `devicePixelRatio` (e.g. `canvas.width = rect.width * dpr`), keep the CSS width/height as the layout size, and scale the 2D context (`ctx.scale(dpr, dpr)`) so drawing code stays in CSS units \u2014 re-apply on resize. Kit decks don't need to do this by hand; the kit's engine already configures its canvas for DPR.\n - Exception: deliberate pixel art wants a fixed low-resolution backing store with `image-rendering: pixelated` CSS instead \u2014 don't DPR-scale that; the crisp chunky look is the point.\n";
@@ -2,7 +2,15 @@
2
2
  // deck's CLAUDE.md, regardless of kit (or no kit). Single source of truth —
3
3
  // edit here, not in the kits. Keep it truly kit-agnostic; kit-specific rules
4
4
  // (e.g. Space being reserved for play/stop) live in each kit's own CLAUDE.md.
5
- export const COMMON_INSTRUCTIONS = `## Assets (every deck)
5
+ export const COMMON_INSTRUCTIONS = `## Imports are read-only (every deck)
6
+
7
+ - **\`imports/\` holds other decks' files and is locked read-only on disk.** Files are mode 0444 and directories 0555, so any shell command that writes, moves, or deletes inside \`imports/\` fails with \`EACCES\` / "Permission denied" — that is the lock working, not a broken checkout. Do not \`chmod\` around it, do not \`sudo\`, and do not retry the command a different way.
8
+ - **Change an import through the CLI, never the filesystem.** \`castle-web add-import <deckIdOrUrl>\` adds one and \`castle-web update-import [alias]\` re-fetches it (\`--check\` to see if it is outdated, \`--revert\` to undo). These handle the unlock/relock themselves.
9
+ - **\`castle-web list-decks [--kind mine|saved|kits]\` is how you find out WHAT can be imported** — one deck per line, starting with the id \`add-import\` takes, and marked when this deck already has it. \`imports/\` only shows what is here already; it is not a catalogue.
10
+ - **To change an imported deck's behavior, copy what you need into this deck and edit the copy**, then reference your copy. Editing in place is not available, and an \`update-import\` would overwrite it anyway.
11
+ - **Deleting the deck directory itself needs the lock released first** (\`chmod -R u+w\` on the deck dir) — that is the one legitimate reason to touch the modes, and only for a directory being thrown away.
12
+
13
+ ## Assets (every deck)
6
14
 
7
15
  - **Load static assets (drawings, audio, etc.) through the bundler — never runtime-\`fetch\` a loose file path.** Use a static \`import\`, \`import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })\`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so \`fetch('drawings/qb.svg')\` looks like it works locally — but \`save-deck\` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw \`fetch\`.
8
16
 
@@ -0,0 +1,4 @@
1
+ import * as http from "http";
2
+ export declare function sendJson(res: http.ServerResponse, status: number, body: unknown): void;
3
+ export declare function readRequestBody(req: http.IncomingMessage): Promise<string>;
4
+ export declare function errorMessage(err: unknown): string;
@@ -0,0 +1,21 @@
1
+ // The two things every JSON endpoint on the serve needs: read a request body,
2
+ // write a response. Shared by the files API and the import API so they answer
3
+ // in the same shape (and so neither grows its own copy).
4
+ export function sendJson(res, status, body) {
5
+ res.writeHead(status, {
6
+ "content-type": "application/json; charset=utf-8",
7
+ "cache-control": "no-store",
8
+ });
9
+ res.end(JSON.stringify(body));
10
+ }
11
+ export function readRequestBody(req) {
12
+ return new Promise((resolve, reject) => {
13
+ const chunks = [];
14
+ req.on("data", (c) => chunks.push(c));
15
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
16
+ req.on("error", reject);
17
+ });
18
+ }
19
+ export function errorMessage(err) {
20
+ return err instanceof Error ? err.message : String(err);
21
+ }
package/dist/ide.d.ts CHANGED
@@ -3,6 +3,8 @@ import { Duplex } from "stream";
3
3
  import { type RawData } from "ws";
4
4
  export declare const IDE_ASSET_PREFIX = "/__castle/ide/";
5
5
  export declare const PTY_WS_PATH = "/__castle/pty";
6
+ export declare const FAVICON_FILES: string[];
7
+ export declare const FAVICON_LINK_TAGS: string;
6
8
  export declare const FILES_API_PREFIX = "/__castle/files/";
7
9
  export declare function rawDataToString(data: RawData): string;
8
10
  export interface IdeServer {
package/dist/ide.js CHANGED
@@ -15,6 +15,8 @@ import headlessPkg from "@xterm/headless";
15
15
  import { SerializeAddon } from "@xterm/addon-serialize";
16
16
  import { WebSocketServer } from "ws";
17
17
  import { IMPORTS_DIR, importStatuses, updateImport } from "./imports.js";
18
+ import { IMPORT_API_PREFIX, handleImportApi } from "./importBrowse.js";
19
+ import { readRequestBody, sendJson } from "./httpJson.js";
18
20
  import { envForUserShell, installCliShims } from "./byo-auth.js";
19
21
  const HeadlessTerminal = headlessPkg.Terminal;
20
22
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -33,12 +35,19 @@ const SHELL_MIME = {
33
35
  ".json": "application/json; charset=utf-8",
34
36
  ".svg": "image/svg+xml",
35
37
  ".png": "image/png",
38
+ ".ico": "image/x-icon",
36
39
  ".jpg": "image/jpeg",
37
40
  ".woff": "font/woff",
38
41
  ".woff2": "font/woff2",
39
42
  ".ttf": "font/ttf",
40
43
  ".map": "application/json; charset=utf-8",
41
44
  };
45
+ // Does the deck serve this root-level file itself? Vite serves both the deck
46
+ // root and its `public/` dir at `/`, so either location counts.
47
+ function deckHasFile(deckDir, name) {
48
+ return (fs.existsSync(path.join(deckDir, name)) ||
49
+ fs.existsSync(path.join(deckDir, "public", name)));
50
+ }
42
51
  // Serve a file from the bundled shell dir, guarding against path traversal.
43
52
  function serveShellFile(res, asset) {
44
53
  const rel = path.normalize(asset).replace(/^(\.\.[/\\])+/, "");
@@ -65,6 +74,22 @@ function serveShellFile(res, asset) {
65
74
  // upgrade handler on Vite's HTTP server).
66
75
  export const IDE_ASSET_PREFIX = "/__castle/ide/";
67
76
  export const PTY_WS_PATH = "/__castle/pty";
77
+ // Castle's favicon (the same files castle.xyz serves), shipped in the shell
78
+ // bundle and also served from the origin root so every page under the serve
79
+ // gets it -- the shell at `/`, the deck page at `/index.html`, and the
80
+ // browser's implicit `/favicon.ico` probe. The deck wins if it ships its own.
81
+ export const FAVICON_FILES = [
82
+ "favicon.ico",
83
+ "favicon-16x16.png",
84
+ "favicon-32x32.png",
85
+ ];
86
+ // `<link rel="icon">` tags for the root-served favicons, injected into the deck
87
+ // page (see serve.ts). The shell's own tags live in `src/shell/index.html`.
88
+ export const FAVICON_LINK_TAGS = [
89
+ '<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />',
90
+ '<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
91
+ '<link rel="icon" href="/favicon.ico" sizes="any" />',
92
+ ].join("\n ");
68
93
  // Builtin Files + code-editor panels talk to the deck through these endpoints
69
94
  // (the shell no longer routes file browsing / code editing through the kit
70
95
  // iframe). `list`/`read`/`write` operate on files within the deck dir;
@@ -300,21 +325,6 @@ function filterImportedFiles(deckDir, imported) {
300
325
  }
301
326
  return out;
302
327
  }
303
- function sendJson(res, status, body) {
304
- res.writeHead(status, {
305
- "content-type": "application/json; charset=utf-8",
306
- "cache-control": "no-store",
307
- });
308
- res.end(JSON.stringify(body));
309
- }
310
- function readRequestBody(req) {
311
- return new Promise((resolve, reject) => {
312
- const chunks = [];
313
- req.on("data", (c) => chunks.push(c));
314
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
315
- req.on("error", reject);
316
- });
317
- }
318
328
  function withJsonBody(req, res, handler) {
319
329
  void (async () => {
320
330
  let body;
@@ -782,12 +792,21 @@ export function createIdeServer(opts) {
782
792
  // `/` -> the shell's index.html; `/__castle/ide/<asset>` -> bundle assets.
783
793
  if (reqPath === "/")
784
794
  return serveShellFile(res, "index.html");
795
+ // Root-served favicons, unless the deck ships its own (then fall through
796
+ // to Vite, which serves the deck's file).
797
+ const favicon = FAVICON_FILES.find((name) => reqPath === `/${name}`);
798
+ if (favicon && !deckHasFile(deckDir, favicon)) {
799
+ return serveShellFile(res, favicon);
800
+ }
785
801
  if (reqPath.startsWith(IDE_ASSET_PREFIX)) {
786
802
  return serveShellFile(res, reqPath.slice(IDE_ASSET_PREFIX.length) || "index.html");
787
803
  }
788
804
  if (reqPath.startsWith(FILES_API_PREFIX)) {
789
805
  return handleFilesApi(deckDir, req, res, reqPath);
790
806
  }
807
+ if (reqPath.startsWith(IMPORT_API_PREFIX)) {
808
+ return handleImportApi(deckDir, req, res, reqPath);
809
+ }
791
810
  return false;
792
811
  }
793
812
  function shutdown() {
@@ -0,0 +1,21 @@
1
+ import * as http from "http";
2
+ export declare const IMPORT_API_PREFIX = "/__castle/import/";
3
+ export interface ImportCandidate {
4
+ deckId: string;
5
+ title: string;
6
+ creator: string | null;
7
+ imageUrl: string | null;
8
+ parentCreator: string | null;
9
+ hasSource: boolean;
10
+ imported: boolean;
11
+ }
12
+ export interface ImportList {
13
+ signedIn: boolean;
14
+ decks: ImportCandidate[];
15
+ truncated: boolean;
16
+ }
17
+ export type ImportTab = "mine" | "saved" | "kits";
18
+ export declare function listImportable(deckDir: string, tab: ImportTab): Promise<ImportList>;
19
+ export declare function resolveDeckRef(deckDir: string, ref: string): Promise<ImportCandidate>;
20
+ export declare function sourceFileCount(deckId: string): Promise<number | null>;
21
+ export declare function handleImportApi(deckDir: string, req: http.IncomingMessage, res: http.ServerResponse, reqPath: string): boolean;