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.
@@ -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?: {
package/dist/imports.js CHANGED
@@ -25,6 +25,37 @@ import { readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
25
25
  // node_modules gets. That also means `get-deck` leaves an existing `imports/`
26
26
  // alone when it refreshes a deck.
27
27
  export const IMPORTS_DIR = 'imports';
28
+ export class ImportError extends Error {
29
+ code;
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.name = 'ImportError';
33
+ this.code = code;
34
+ }
35
+ }
36
+ // What someone has in hand is a deck id or the URL of the page they were just
37
+ // looking at, so both are accepted here rather than in a separate resolve step.
38
+ // Recognized: a bare id, a deck page (`castle.xyz/d/<id>`), and the cloud editor
39
+ // (`castle.xyz/admin/cloud/<id>`) -- for anything else the last path segment is
40
+ // tried, since every castle deck URL ends in the id.
41
+ export function parseDeckRef(raw) {
42
+ const trimmed = raw.trim();
43
+ if (!trimmed)
44
+ return null;
45
+ if (!trimmed.includes('/'))
46
+ return api.isDeckIdShaped(trimmed) ? trimmed : null;
47
+ let url;
48
+ try {
49
+ url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ const segments = url.pathname.split('/').filter(Boolean);
55
+ const afterD = segments.indexOf('d');
56
+ const candidate = afterD >= 0 ? segments[afterD + 1] : segments[segments.length - 1];
57
+ return candidate && api.isDeckIdShaped(candidate) ? candidate : null;
58
+ }
28
59
  // Only the `imports` key is ours; everything else in castle.json (deck identity,
29
60
  // editor config) is preserved as-is.
30
61
  function writeImportPin(dir, alias, pin) {
@@ -70,6 +101,25 @@ function qualifiedAlias(meta, deckId) {
70
101
  return `${user}.${title}`;
71
102
  return title || slugifyAlias(deckId) || deckId;
72
103
  }
104
+ // A derived alias can already be taken -- two people with a same-named deck, or
105
+ // the same title twice. Reusing it would silently replace a different
106
+ // dependency, so a taken name gets a number. A name already pointing at THIS
107
+ // deck is not a collision: that one is the update case, and keeping it is what
108
+ // makes re-importing a deck update it in place.
109
+ const MAX_ALIAS_ATTEMPTS = 100;
110
+ function availableAlias(deckDir, alias, deckId) {
111
+ const pins = readCastleJson(deckDir)?.imports ?? {};
112
+ const taken = (name) => pins[name] !== undefined
113
+ ? pins[name].deckId !== deckId
114
+ : fs.existsSync(path.join(deckDir, IMPORTS_DIR, name));
115
+ if (!taken(alias))
116
+ return alias;
117
+ for (let n = 2; n <= MAX_ALIAS_ATTEMPTS; n++) {
118
+ if (!taken(`${alias}-${n}`))
119
+ return `${alias}-${n}`;
120
+ }
121
+ throw new ImportError('bad-alias', `Too many imports already named "${alias}"; pass --as to name this one.`);
122
+ }
73
123
  // Dependencies are made read-only ON DISK, not just refused by the files API.
74
124
  // The API guard only covers the editor; an agent or a shell command writes
75
125
  // straight to the filesystem, and the common "write a temp file and rename it"
@@ -386,49 +436,71 @@ async function addTransitiveImports(deckDir, viaAlias, seen) {
386
436
  }
387
437
  // Every deckId this deck already has, so a transitive walk doesn't refetch or
388
438
  // loop on a cycle.
389
- function importedDeckIds(deckDir) {
439
+ export function importedDeckIds(deckDir) {
390
440
  const pins = readCastleJson(deckDir)?.imports ?? {};
391
441
  return new Set(Object.values(pins).map((p) => p?.deckId).filter((id) => !!id));
392
442
  }
393
- export async function addImport(dir, options = {}) {
443
+ // The import itself. Throws ImportError rather than exiting, because this also
444
+ // runs inside the serve (from the editor's Import panel), where taking the
445
+ // process down is not an option. The CLI wrapper below prints and exits.
446
+ export async function addImportTo(dir, options = {}) {
394
447
  const targetDir = path.resolve(dir);
395
- const deckId = options.deckId;
448
+ const ref = options.deckRef?.trim();
449
+ if (!ref) {
450
+ throw new ImportError('no-deck-ref', 'No deck given. Pass a deck id or a castle.xyz deck link.');
451
+ }
452
+ const deckId = parseDeckRef(ref);
396
453
  if (!deckId) {
397
- console.error('No deck id. Usage: castle-web add-import <deckId> [dir] [--as ALIAS]');
398
- process.exit(1);
454
+ throw new ImportError('bad-deck-ref', `Not a deck id or a castle deck link: ${ref}`);
399
455
  }
400
456
  if (!fs.existsSync(targetDir)) {
401
- console.error(`No such directory: ${targetDir}`);
402
- process.exit(1);
457
+ throw new ImportError('no-such-dir', `No such directory: ${targetDir}`);
403
458
  }
404
459
  if (readCastleJson(targetDir)?.deckId === deckId) {
405
- console.error(`Deck ${deckId} is this deck -- a deck can't import itself.`);
406
- process.exit(1);
460
+ throw new ImportError('self-import', `Deck ${deckId} is this deck -- a deck can't import itself.`);
407
461
  }
408
462
  const [source, meta] = await Promise.all([
409
463
  api.webDeckSource(deckId),
410
464
  api.deckMeta(deckId).catch(() => null),
411
465
  ]);
412
466
  if (!source) {
413
- console.error(`No source archive on the server for deck ${deckId}.`);
414
- console.error(`Its owner needs to run \`castle-web save-deck\` before it can be imported.`);
415
- process.exit(1);
467
+ throw new ImportError('no-source', `No source archive on the server for deck ${deckId}. ` +
468
+ 'Its owner needs to run `castle-web save-deck` before it can be imported.');
416
469
  }
417
- const alias = options.alias ? slugifyGivenAlias(options.alias) : qualifiedAlias(meta, deckId);
418
- if (!alias) {
419
- console.error(`Could not derive a usable name from --as; pick one with letters or digits.`);
420
- process.exit(1);
470
+ const given = options.alias ? slugifyGivenAlias(options.alias) : '';
471
+ if (options.alias && !given) {
472
+ throw new ImportError('bad-alias', 'Could not derive a usable name; pick one with letters or digits.');
421
473
  }
474
+ // An explicit `--as` is the caller's call, collision and all. A derived one
475
+ // gets a number instead, since nothing asked for that particular name.
476
+ const alias = given || availableAlias(targetDir, qualifiedAlias(meta, deckId), deckId);
422
477
  const { replaced } = await placeImport(targetDir, alias, deckId, source);
423
- console.log(`${replaced ? 'Updated' : 'Imported'} ${deckId} at ${IMPORTS_DIR}/${alias} (read-only on disk).`);
424
- const seen = importedDeckIds(targetDir);
425
- const transitive = await addTransitiveImports(targetDir, alias, seen);
426
- for (const t of transitive)
427
- console.log(`Also imported ${IMPORTS_DIR}/${t} (needed by ${alias})`);
428
- const added = syncImportDependencies(targetDir);
429
- for (const dep of added)
478
+ const transitive = await addTransitiveImports(targetDir, alias, importedDeckIds(targetDir));
479
+ const dependencies = syncImportDependencies(targetDir);
480
+ return { deckId, alias, replaced, transitive, dependencies };
481
+ }
482
+ export async function addImport(dir, options = {}) {
483
+ let result;
484
+ try {
485
+ result = await addImportTo(dir, options);
486
+ }
487
+ catch (err) {
488
+ if (!(err instanceof ImportError))
489
+ throw err;
490
+ console.error(err.message);
491
+ if (err.code === 'no-deck-ref') {
492
+ console.error('Usage: castle-web add-import <deckId|url> [dir] [--as ALIAS]');
493
+ }
494
+ process.exit(1);
495
+ }
496
+ console.log(`${result.replaced ? 'Updated' : 'Imported'} ${result.deckId} at ` +
497
+ `${IMPORTS_DIR}/${result.alias} (read-only on disk).`);
498
+ for (const t of result.transitive) {
499
+ console.log(`Also imported ${IMPORTS_DIR}/${t} (needed by ${result.alias})`);
500
+ }
501
+ for (const dep of result.dependencies)
430
502
  console.log(`Added dependency ${dep}`);
431
- if (added.length > 0)
503
+ if (result.dependencies.length > 0)
432
504
  console.log('Run `castle-web install` to install them.');
433
505
  }
434
506
  // `castle-web update-import [alias] [dir]`: re-fetch an import (or all of them)
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { serve } from './serve.js';
6
6
  import { saveDeck } from './save-deck.js';
7
7
  import { getDeck } from './get-deck.js';
8
8
  import { addImport, updateImport } from './imports.js';
9
+ import { listDecks } from './list-decks.js';
9
10
  import { getCliVersion, init } from './init.js';
10
11
  import { install } from './install.js';
11
12
  import { connectWS, savePreviewImage, savePreviewIfNeeded, takeScreenshot } from './preview.js';
@@ -21,6 +22,7 @@ const FLAGS_WITH_VALUES = new Set([
21
22
  '--caption',
22
23
  '--visibility',
23
24
  '--as',
25
+ '--kind',
24
26
  ]);
25
27
  function findPositionalDir() {
26
28
  for (let i = 1; i < args.length; i++) {
@@ -82,7 +84,8 @@ function usage() {
82
84
  castle-web save-preview-image [dir] [--port PORT] [--no-restart]
83
85
  castle-web save-deck [dir] [--title TITLE] [--caption TEXT] [--visibility unlisted|private]
84
86
  castle-web get-deck [dir] [--deck-id ID] [--force] (replaces the source if the deck is already there)
85
- castle-web add-import <deckId> [dir] [--as ALIAS] (adds another deck as a read-only dependency in imports/)
87
+ castle-web list-decks [dir] [--kind mine|saved|kits] (decks this one could import; ids are what add-import takes)
88
+ castle-web add-import <deckId|url> [dir] [--as ALIAS] (adds another deck as a read-only dependency in imports/)
86
89
  castle-web update-import [alias] [dir] [--check] [--revert] (re-fetches imports; no alias means all)
87
90
  castle-web install [dir]
88
91
  castle-web login
@@ -146,12 +149,16 @@ async function main() {
146
149
  });
147
150
  break;
148
151
  }
152
+ case 'list-decks': {
153
+ await listDecks(findPositionalDir(), { kind: getFlagValue('--kind') });
154
+ break;
155
+ }
149
156
  case 'add-import': {
150
- // The deck id is the first positional; the importing deck's dir is an
151
- // optional second, defaulting to the cwd.
157
+ // The deck -- an id or the link to its page -- is the first positional;
158
+ // the importing deck's dir is an optional second, defaulting to the cwd.
152
159
  const positionals = readPositionals();
153
160
  await addImport(positionals[1] ?? '.', {
154
- deckId: getFlagValue('--deck-id') ?? positionals[0],
161
+ deckRef: getFlagValue('--deck-id') ?? positionals[0],
155
162
  alias: getFlagValue('--as'),
156
163
  });
157
164
  break;
@@ -0,0 +1,3 @@
1
+ export declare function listDecks(dir: string, options?: {
2
+ kind?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,44 @@
1
+ // `castle-web list-decks` -- what this deck could import, for an agent rather
2
+ // than for the editor's picker. Same answer the picker gets (importBrowse's
3
+ // `/__castle/import/*` backend), printed one deck per line.
4
+ //
5
+ // The agent needs this because `add-import` takes a deck id, and until now
6
+ // nothing told it which ids exist -- so it could only import a deck the user
7
+ // had already named.
8
+ import * as path from 'path';
9
+ import { listImportable } from './importBrowse.js';
10
+ const KINDS = ['mine', 'saved', 'kits'];
11
+ function isKind(value) {
12
+ return KINDS.includes(value);
13
+ }
14
+ // One deck per line, id first: the id is what `add-import` takes, so it reads
15
+ // straight out of the listing without the agent having to parse a sentence.
16
+ function formatDeck(deck) {
17
+ const parts = [deck.deckId, deck.title];
18
+ if (deck.creator)
19
+ parts.push(`@${deck.creator}`);
20
+ if (deck.imported)
21
+ parts.push('(already imported)');
22
+ return parts.join(' ');
23
+ }
24
+ export async function listDecks(dir, options = {}) {
25
+ const kind = options.kind ?? 'mine';
26
+ if (!isKind(kind)) {
27
+ console.error(`Unknown kind "${kind}". Use one of: ${KINDS.join(', ')}.`);
28
+ process.exit(1);
29
+ }
30
+ const list = await listImportable(path.resolve(dir), kind);
31
+ if (!list.signedIn) {
32
+ console.error('Not signed in. Run `castle-web login` first.');
33
+ process.exit(1);
34
+ }
35
+ if (list.decks.length === 0) {
36
+ console.log(`No ${kind} decks with source files to import.`);
37
+ return;
38
+ }
39
+ for (const deck of list.decks)
40
+ console.log(formatDeck(deck));
41
+ if (list.truncated) {
42
+ console.log(`(first ${list.decks.length}; there are more)`);
43
+ }
44
+ }
@@ -43,6 +43,7 @@ export interface CastleBudget {
43
43
  limitMicros: number | null;
44
44
  resetAtMs: number;
45
45
  blocked: boolean;
46
+ blockedModelPrefixes: string[];
46
47
  }
47
48
  /**
48
49
  * The daily Castle-paid AI budget for the user this sandbox belongs to, or null
package/dist/metering.js CHANGED
@@ -145,6 +145,9 @@ export async function fetchBudget() {
145
145
  limitMicros: typeof body.limitMicros === "number" ? body.limitMicros : null,
146
146
  resetAtMs: typeof body.resetAtMs === "number" ? body.resetAtMs : 0,
147
147
  blocked: body.blocked,
148
+ blockedModelPrefixes: Array.isArray(body.blockedModelPrefixes)
149
+ ? body.blockedModelPrefixes.filter((p) => typeof p === "string")
150
+ : [],
148
151
  };
149
152
  }
150
153
  catch {
@@ -1,5 +1,7 @@
1
1
  export declare const SOURCE_ARCHIVE_EXCLUDES: string[];
2
- export declare function runTar(args: string[]): Promise<void>;
2
+ export declare function runTar(args: string[], opts?: {
3
+ capture?: boolean;
4
+ }): Promise<string>;
3
5
  export declare function archiveSource(projectDir: string): Promise<Buffer>;
4
6
  export type SaveVisibility = 'unlisted' | 'private';
5
7
  export interface SaveDeckOptions {
package/dist/save-deck.js CHANGED
@@ -12,10 +12,14 @@ import { bundleProject } from './bundle.js';
12
12
  // part of it. (Being on this list also means `get-deck` leaves an existing
13
13
  // `imports/` in place when it refreshes a deck.)
14
14
  export const SOURCE_ARCHIVE_EXCLUDES = ['node_modules', 'dist', '.castle', '.git', 'imports'];
15
- export function runTar(args) {
15
+ // `capture` keeps tar's stdout and resolves with it -- for listing an archive
16
+ // (`-tzf`), where the output IS the answer. Packing and unpacking ignore it.
17
+ export function runTar(args, opts = {}) {
16
18
  return new Promise((resolve, reject) => {
17
- const child = spawn('tar', args, { stdio: ['ignore', 'ignore', 'pipe'] });
19
+ const child = spawn('tar', args, { stdio: ['ignore', opts.capture ? 'pipe' : 'ignore', 'pipe'] });
20
+ let stdout = '';
18
21
  let stderr = '';
22
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
19
23
  child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
20
24
  child.on('error', reject);
21
25
  child.on('close', (code) => {
@@ -23,7 +27,7 @@ export function runTar(args) {
23
27
  reject(new Error(`tar exited with code ${code}: ${stderr}`));
24
28
  return;
25
29
  }
26
- resolve();
30
+ resolve(stdout);
27
31
  });
28
32
  });
29
33
  }
package/dist/serve.js CHANGED
@@ -4,7 +4,7 @@ import * as path from 'path';
4
4
  import { spawn } from 'child_process';
5
5
  import { createServer } from 'vite';
6
6
  import { WebSocketServer, WebSocket } from 'ws';
7
- import { createIdeServer } from './ide.js';
7
+ import { createIdeServer, FAVICON_LINK_TAGS } from './ide.js';
8
8
  import { createAgentServer } from './agent.js';
9
9
  import { sceneFilesPlugin, importsAliasPlugin } from './vitePlugins.js';
10
10
  import { installFilesChangedWatcher } from './filesChanged.js';
@@ -58,7 +58,15 @@ function castlePlugin(wsPort, ideServer, agentServer) {
58
58
  transformIndexHtml: {
59
59
  order: 'pre',
60
60
  handler(html) {
61
- return html.replace(/<head(\s[^>]*)?>/i, (match) => `${match}\n <script>window.CastleEmbed={edit:true,host:'dev'};</script>\n ${CONSOLE_CAPTURE}`);
61
+ // Give the deck page Castle's favicon too -- it's a real page when
62
+ // opened directly (play surface / `?edit=0`), not just the editor's
63
+ // iframe. Injected here rather than in the kit templates so bare
64
+ // `--kit none` decks and already-scaffolded decks get it as well; a
65
+ // deck that declares its own icon keeps it.
66
+ const favicon = /<link[^>]+rel=["']?[^"'>]*icon/i.test(html)
67
+ ? ''
68
+ : `\n ${FAVICON_LINK_TAGS}`;
69
+ return html.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${favicon}\n <script>window.CastleEmbed={edit:true,host:'dev'};</script>\n ${CONSOLE_CAPTURE}`);
62
70
  },
63
71
  },
64
72
  configureServer(server) {