castle-web-cli 0.4.110 → 0.4.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
+ }
@@ -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) {