castle-web-cli 0.4.110 → 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.
@@ -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/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;
@@ -0,0 +1,240 @@
1
+ // The editor's Import picker, server side: `/__castle/import/*` on the serve.
2
+ // Answers "which decks could this person import" and then does the import.
3
+ //
4
+ // It runs the GraphQL with the CLI's own login (`config.getToken()`), which is
5
+ // what a local serve has. In the cloud the shell will instead ask its castle-www
6
+ // host over postMessage, so the sandbox never holds the token -- that path is
7
+ // deliberately not built yet, and this one is what the local editor uses either
8
+ // way.
9
+ //
10
+ // Nothing here goes through the deck iframe or the kit: the Files panel that
11
+ // opens this picker is a builtin shell panel, and it has to keep working on a
12
+ // `--kit none` deck that ships no editor app at all.
13
+ import * as fs from "fs";
14
+ import * as os from "os";
15
+ import * as path from "path";
16
+ import { nanoid } from "nanoid";
17
+ import * as api from "./api.js";
18
+ import * as config from "./config.js";
19
+ import { runTar } from "./save-deck.js";
20
+ import { readCastleJson } from "./castleJson.js";
21
+ import { ImportError, addImportTo, importedDeckIds, parseDeckRef } from "./imports.js";
22
+ import { errorMessage, readRequestBody, sendJson } from "./httpJson.js";
23
+ export const IMPORT_API_PREFIX = "/__castle/import/";
24
+ // Rows per list, and how many decks we are willing to ask the source question
25
+ // about to fill one. An account can hold hundreds of decks and only some are
26
+ // web decks, so the scan has to look past the first fifty -- but not forever.
27
+ const LIST_LIMIT = 50;
28
+ const SCAN_CAP = 200;
29
+ const SOURCE_CHUNK = 100;
30
+ // Playlists read for the Saved list. Flattened together with bookmarks, so a
31
+ // handful is already more decks than the list shows.
32
+ const PLAYLIST_CAP = 10;
33
+ // The bookmarks feed already covers this playlist; listing both would just
34
+ // duplicate it.
35
+ const BOOKMARKS_PLAYLIST_PREFIX = "bookmarks-";
36
+ // The kits list, hardcoded. A playlist can't hold it: playlist feeds drop
37
+ // unlisted decks, and a kit is published unlisted. Adding a kit means shipping
38
+ // an update to this list.
39
+ const KIT_DECK_IDS = [
40
+ "ckRZGFW4iPrx", // physics-2d
41
+ ];
42
+ // Listing is several server round trips, and the picker is opened, closed and
43
+ // reopened while someone decides. Cached briefly so that costs once.
44
+ const LIST_TTL_MS = 60_000;
45
+ const listCache = new Map();
46
+ function asTab(value) {
47
+ return value === "saved" || value === "kits" ? value : "mine";
48
+ }
49
+ function toCandidate(row, hasSource) {
50
+ return {
51
+ deckId: row.deckId,
52
+ title: row.title?.trim() ? row.title.trim() : "Untitled",
53
+ creator: row.creator?.username ?? null,
54
+ imageUrl: row.initialCard?.backgroundImage?.smallUrl ?? null,
55
+ parentCreator: row.parentDeck?.creator?.username ?? null,
56
+ hasSource,
57
+ imported: false,
58
+ };
59
+ }
60
+ // Which of these the deck already has. Stamped on the way OUT rather than
61
+ // baked into the cached list: the pins change while the picker is open (an
62
+ // import from here, an `add-import` in the terminal), and re-reading one file
63
+ // is cheaper than throwing the list away.
64
+ function stampImported(deckDir, decks) {
65
+ const already = importedDeckIds(deckDir);
66
+ return decks.map((deck) => ({ ...deck, imported: already.has(deck.deckId) }));
67
+ }
68
+ // First occurrence of each deck wins, and the deck being edited never appears:
69
+ // a deck can't import itself, so offering it is offering an error.
70
+ function dedupe(rows, currentDeckId) {
71
+ const seen = new Set();
72
+ const out = [];
73
+ for (const row of rows) {
74
+ if (!row?.deckId || row.deckId === currentDeckId || seen.has(row.deckId))
75
+ continue;
76
+ seen.add(row.deckId);
77
+ out.push(row);
78
+ }
79
+ return out;
80
+ }
81
+ // Keep the decks that have source on the server -- having source IS being
82
+ // importable -- taking them in the order they arrived until the list is full.
83
+ // Asks in chunks so one bad batch doesn't cost the whole scan.
84
+ async function filterImportable(rows) {
85
+ const decks = [];
86
+ let scanned = 0;
87
+ while (scanned < rows.length && scanned < SCAN_CAP && decks.length < LIST_LIMIT) {
88
+ const chunk = rows.slice(scanned, scanned + SOURCE_CHUNK);
89
+ scanned += chunk.length;
90
+ const versions = await api.webDeckSourceVersions(chunk.map((r) => r.deckId));
91
+ for (const row of chunk) {
92
+ if (decks.length >= LIST_LIMIT)
93
+ break;
94
+ if (versions.has(row.deckId))
95
+ decks.push(toCandidate(row, true));
96
+ }
97
+ }
98
+ return { signedIn: true, decks, truncated: scanned < rows.length };
99
+ }
100
+ // Bookmarks plus the user's playlists, flattened. Both are "decks I kept", and
101
+ // which of the two something landed in is not a distinction worth a tab.
102
+ async function savedRows() {
103
+ const userId = config.getUserId();
104
+ const bookmarks = await api.feedDecks("bookmarks", LIST_LIMIT);
105
+ if (!userId)
106
+ return bookmarks;
107
+ const playlists = (await api.myPlaylists(userId, PLAYLIST_CAP * 2))
108
+ .filter((p) => !p.playlistId.startsWith(BOOKMARKS_PLAYLIST_PREFIX))
109
+ .slice(0, PLAYLIST_CAP);
110
+ const lists = await Promise.all(playlists.map((p) => api.feedDecks(`playlist:${p.playlistId}`, LIST_LIMIT).catch(() => [])));
111
+ return [bookmarks, ...lists].flat();
112
+ }
113
+ async function tabRows(tab) {
114
+ if (tab === "saved")
115
+ return savedRows();
116
+ if (tab === "kits")
117
+ return api.deckRows(KIT_DECK_IDS);
118
+ return api.myDecks();
119
+ }
120
+ const EMPTY_LIST = { signedIn: false, decks: [], truncated: false };
121
+ export async function listImportable(deckDir, tab) {
122
+ if (!config.getToken())
123
+ return EMPTY_LIST;
124
+ const key = `${deckDir}|${tab}`;
125
+ const cached = listCache.get(key);
126
+ const value = cached && Date.now() - cached.at < LIST_TTL_MS ? cached.value : null;
127
+ if (value)
128
+ return { ...value, decks: stampImported(deckDir, value.decks) };
129
+ const currentDeckId = readCastleJson(deckDir)?.deckId ?? null;
130
+ const fresh = await filterImportable(dedupe(await tabRows(tab), currentDeckId));
131
+ listCache.set(key, { at: Date.now(), value: fresh });
132
+ return { ...fresh, decks: stampImported(deckDir, fresh.decks) };
133
+ }
134
+ // A deck someone pasted a link (or an id) to. Unlike the lists, this one does
135
+ // NOT hide a deck with no source -- naming a specific deck and getting nothing
136
+ // back says less than naming it and being told it was never saved.
137
+ export async function resolveDeckRef(deckDir, ref) {
138
+ const deckId = parseDeckRef(ref);
139
+ if (!deckId) {
140
+ throw new ImportError("bad-deck-ref", `Not a deck id or a castle deck link: ${ref}`);
141
+ }
142
+ if (readCastleJson(deckDir)?.deckId === deckId) {
143
+ throw new ImportError("self-import", `That is this deck -- a deck can't import itself.`);
144
+ }
145
+ const [rows, source] = await Promise.all([
146
+ api.deckRows([deckId]),
147
+ api.webDeckSource(deckId).catch(() => null),
148
+ ]);
149
+ const row = rows[0];
150
+ if (!row)
151
+ throw new ImportError("bad-deck-ref", `No deck ${deckId} on the server.`);
152
+ return stampImported(deckDir, [toCandidate(row, source !== null)])[0];
153
+ }
154
+ // How many files a deck's source holds. Not on any list query, so it is counted
155
+ // from the archive itself -- which means downloading it, which is why the picker
156
+ // asks only about the one deck someone has selected rather than every row.
157
+ // Keyed by version, so re-selecting a row is free until its deck is saved again.
158
+ const fileCountCache = new Map();
159
+ export async function sourceFileCount(deckId) {
160
+ if (!api.isDeckIdShaped(deckId))
161
+ return null;
162
+ const source = await api.webDeckSource(deckId).catch(() => null);
163
+ if (!source)
164
+ return null;
165
+ const key = `${deckId}@${source.updatedAt}`;
166
+ const cached = fileCountCache.get(key);
167
+ if (cached !== undefined)
168
+ return cached;
169
+ const tmpFile = path.join(os.tmpdir(), `castle-import-count-${nanoid(8)}.tar.gz`);
170
+ try {
171
+ const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(30000) });
172
+ if (!res.ok)
173
+ return null;
174
+ fs.writeFileSync(tmpFile, Buffer.from(await res.arrayBuffer()));
175
+ const listing = await runTar(["-tzf", tmpFile], { capture: true });
176
+ const count = listing.split("\n").filter((line) => line && !line.endsWith("/")).length;
177
+ fileCountCache.set(key, count);
178
+ return count;
179
+ }
180
+ catch {
181
+ return null; // an unreadable archive just means no count to show
182
+ }
183
+ finally {
184
+ try {
185
+ fs.unlinkSync(tmpFile);
186
+ }
187
+ catch {
188
+ /* nothing to clean */
189
+ }
190
+ }
191
+ }
192
+ // An ImportError is the caller's problem (a bad link, this deck, a deck that
193
+ // was never saved), so it comes back as a 400 with its code -- the picker shows
194
+ // a different thing for each. Anything else is ours: a 500 with the message.
195
+ function sendResult(res, work) {
196
+ void work()
197
+ .then((body) => sendJson(res, 200, body))
198
+ .catch((err) => {
199
+ if (err instanceof ImportError)
200
+ return sendJson(res, 400, { error: err.message, code: err.code });
201
+ sendJson(res, 500, { error: errorMessage(err) });
202
+ });
203
+ }
204
+ export function handleImportApi(deckDir, req, res, reqPath) {
205
+ const action = reqPath.slice(IMPORT_API_PREFIX.length);
206
+ const url = new URL(req.url ?? "/", "http://localhost");
207
+ if (action === "list") {
208
+ sendResult(res, () => listImportable(deckDir, asTab(url.searchParams.get("tab"))));
209
+ return true;
210
+ }
211
+ if (action === "resolve") {
212
+ const ref = url.searchParams.get("ref") ?? "";
213
+ sendResult(res, async () => ({ deck: await resolveDeckRef(deckDir, ref) }));
214
+ return true;
215
+ }
216
+ if (action === "files") {
217
+ const deckId = url.searchParams.get("deckId") ?? "";
218
+ sendResult(res, async () => ({ fileCount: await sourceFileCount(deckId) }));
219
+ return true;
220
+ }
221
+ if (action === "add") {
222
+ sendResult(res, async () => {
223
+ let body;
224
+ try {
225
+ body = JSON.parse(await readRequestBody(req));
226
+ }
227
+ catch {
228
+ throw new ImportError("no-deck-ref", "Invalid JSON body.");
229
+ }
230
+ const deckRef = typeof body.deckRef === "string" ? body.deckRef : "";
231
+ const result = await addImportTo(deckDir, { deckRef });
232
+ // The lists said which decks were importable; one of them just stopped
233
+ // being a candidate (or changed version), so the cached answers are stale.
234
+ listCache.clear();
235
+ return result;
236
+ });
237
+ return true;
238
+ }
239
+ return (sendJson(res, 404, { error: `Unknown import action: ${action}` }), true);
240
+ }
package/dist/imports.d.ts CHANGED
@@ -1,4 +1,10 @@
1
1
  export declare const IMPORTS_DIR = "imports";
2
+ export type ImportErrorCode = 'no-deck-ref' | 'bad-deck-ref' | 'no-such-dir' | 'self-import' | 'no-source' | 'bad-alias';
3
+ export declare class ImportError extends Error {
4
+ code: ImportErrorCode;
5
+ constructor(code: ImportErrorCode, message: string);
6
+ }
7
+ export declare function parseDeckRef(raw: string): string | null;
2
8
  export declare function lockImportTree(dir: string): void;
3
9
  export declare function syncImportDependencies(deckDir: string): string[];
4
10
  export declare function restoreMissingImports(deckDir: string): Promise<string[]>;
@@ -11,8 +17,22 @@ export interface ImportStatus {
11
17
  via?: string;
12
18
  }
13
19
  export declare function importStatuses(deckDir: string): Promise<ImportStatus[]>;
20
+ export declare function importedDeckIds(deckDir: string): Set<string>;
21
+ export interface AddImportResult {
22
+ deckId: string;
23
+ alias: string;
24
+ replaced: boolean;
25
+ /** Aliases pulled in because the imported deck depends on them. */
26
+ transitive: string[];
27
+ /** `name@range (from alias)` entries merged into the deck's package.json. */
28
+ dependencies: string[];
29
+ }
30
+ export declare function addImportTo(dir: string, options?: {
31
+ deckRef?: string;
32
+ alias?: string;
33
+ }): Promise<AddImportResult>;
14
34
  export declare function addImport(dir: string, options?: {
15
- deckId?: string;
35
+ deckRef?: string;
16
36
  alias?: string;
17
37
  }): Promise<void>;
18
38
  export declare function updateImport(dir: string, options?: {