castle-web-cli 0.4.112 → 0.4.114

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.
@@ -5,12 +5,67 @@
5
5
  // The agent needs this because `add-import` takes a deck id, and until now
6
6
  // nothing told it which ids exist -- so it could only import a deck the user
7
7
  // had already named.
8
+ //
9
+ // Listing needs a Castle session, and there are two places one can be. A local
10
+ // CLI has its own login. A cloud sandbox deliberately has NO token -- so there
11
+ // we ask the browser that has this deck open, over the serve's WebSocket, and
12
+ // the shell answers from its castle-www host (see shell/importApi.ts). Telling
13
+ // a cloud user to `castle-web login` would be pointing at the wrong machine.
14
+ import * as fs from 'fs';
8
15
  import * as path from 'path';
9
16
  import { listImportable } from './importBrowse.js';
17
+ import { connectWS } from './preview.js';
10
18
  const KINDS = ['mine', 'saved', 'kits'];
19
+ // The browser's answer travels on to castle-www and its GraphQL scans, so this
20
+ // is the whole round trip, not one hop.
21
+ const BROWSER_TIMEOUT_MS = 20_000;
11
22
  function isKind(value) {
12
23
  return KINDS.includes(value);
13
24
  }
25
+ function serveWsPort(deckDir) {
26
+ try {
27
+ const raw = fs.readFileSync(path.join(deckDir, '.castle', 'serve.json'), 'utf8');
28
+ const port = JSON.parse(raw).wsPort;
29
+ return typeof port === 'number' ? port : null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ // Null means nobody answered: no serve, or no browser on the other end of it.
36
+ // A browser that answers "not signed in" is an ANSWER and comes back as one.
37
+ async function askBrowser(deckDir, kind) {
38
+ const wsPort = serveWsPort(deckDir);
39
+ if (wsPort === null)
40
+ return null;
41
+ const ws = await connectWS(wsPort).catch(() => null);
42
+ if (!ws)
43
+ return null;
44
+ const requestId = Math.random().toString(36).slice(2);
45
+ const answer = new Promise((resolve) => {
46
+ const timer = setTimeout(() => resolve(null), BROWSER_TIMEOUT_MS);
47
+ ws.on('message', (raw) => {
48
+ try {
49
+ const msg = JSON.parse(raw.toString());
50
+ if (msg.type !== 'import_list_response' || msg.requestId !== requestId)
51
+ return;
52
+ clearTimeout(timer);
53
+ resolve(msg.list ?? null);
54
+ }
55
+ catch {
56
+ // the serve broadcasts other traffic on this socket; ignore what we
57
+ // can't read
58
+ }
59
+ });
60
+ });
61
+ ws.send(JSON.stringify({ type: 'import_list_request', requestId, tab: kind }));
62
+ try {
63
+ return await answer;
64
+ }
65
+ finally {
66
+ ws.close();
67
+ }
68
+ }
14
69
  // One deck per line, id first: the id is what `add-import` takes, so it reads
15
70
  // straight out of the listing without the agent having to parse a sentence.
16
71
  function formatDeck(deck) {
@@ -27,9 +82,19 @@ export async function listDecks(dir, options = {}) {
27
82
  console.error(`Unknown kind "${kind}". Use one of: ${KINDS.join(', ')}.`);
28
83
  process.exit(1);
29
84
  }
30
- const list = await listImportable(path.resolve(dir), kind);
85
+ const deckDir = path.resolve(dir);
86
+ const local = await listImportable(deckDir, kind);
87
+ const list = local.signedIn ? local : await askBrowser(deckDir, kind);
88
+ if (!list) {
89
+ // No serve at all means this is a plain local CLI, where the login is ours
90
+ // to have. With a serve up, the missing piece is a browser on it instead.
91
+ console.error(serveWsPort(deckDir) === null
92
+ ? 'Not signed in. Run `castle-web login` first.'
93
+ : 'Cannot list decks: nothing is connected to this deck in a browser, and the listing comes from the browser session. Open this deck in the editor and run this again.');
94
+ process.exit(1);
95
+ }
31
96
  if (!list.signedIn) {
32
- console.error('Not signed in. Run `castle-web login` first.');
97
+ console.error('Cannot list decks: that browser is not signed in to Castle. Sign in and run this again.');
33
98
  process.exit(1);
34
99
  }
35
100
  if (list.decks.length === 0) {
package/dist/serve.js CHANGED
@@ -149,7 +149,10 @@ function readDeckContext(projectDir) {
149
149
  }
150
150
  }
151
151
  const DEFAULT_PORT = 5757;
152
- const SCREENSHOT_REQUEST_TTL_MS = 15_000;
152
+ // How long a forwarded request stays routable. Long enough for the slowest of
153
+ // them (an import listing goes on to the browser's castle-www host and its
154
+ // GraphQL scans); an unanswered id just sits in the map until this expires.
155
+ const PENDING_REQUEST_TTL_MS = 30_000;
153
156
  function readServeJson(projectDir) {
154
157
  try {
155
158
  const raw = fs.readFileSync(path.join(projectDir, '.castle', 'serve.json'), 'utf8');
@@ -440,19 +443,41 @@ function startWSServer(port, projectDir, logFile, screenshotsDir, viteHolder) {
440
443
  c.send(fwd);
441
444
  }
442
445
  }
443
- if (msg.type === 'screenshot_request') {
446
+ // Things one client asks the OTHERS to answer: a tab's canvas for
447
+ // `screenshot`, and the browser's signed-in deck listing for
448
+ // `list-decks` (a cloud sandbox holds no Castle token; the browser
449
+ // that has the deck open does -- see shell/importApi.ts).
450
+ if (msg.type === 'screenshot_request' || msg.type === 'import_list_request') {
444
451
  // Record the requester, then forward to the other clients (the tab).
445
452
  if (typeof msg.requestId === 'string') {
446
453
  forgetRequest(msg.requestId);
447
454
  const timeout = setTimeout(() => {
448
455
  requestOrigins.delete(msg.requestId);
449
- }, SCREENSHOT_REQUEST_TTL_MS);
456
+ }, PENDING_REQUEST_TTL_MS);
450
457
  requestOrigins.set(msg.requestId, { origin: ws, timeout });
451
458
  }
452
459
  const fwd = JSON.stringify(msg);
460
+ let delivered = 0;
453
461
  for (const c of clients) {
454
- if (c !== ws && c.readyState === WebSocket.OPEN)
462
+ if (c !== ws && c.readyState === WebSocket.OPEN) {
455
463
  c.send(fwd);
464
+ delivered += 1;
465
+ }
466
+ }
467
+ // Nobody to ask -- say so now rather than leaving the CLI to sit out
468
+ // its whole timeout for an answer that can't come.
469
+ if (delivered === 0 && msg.type === 'import_list_request' && typeof msg.requestId === 'string') {
470
+ forgetRequest(msg.requestId);
471
+ ws.send(JSON.stringify({ type: 'import_list_response', requestId: msg.requestId }));
472
+ }
473
+ }
474
+ // The shell's answer, back to just the asker. A second tab answering
475
+ // the same request finds no pending origin and is dropped.
476
+ if (msg.type === 'import_list_response' && typeof msg.requestId === 'string') {
477
+ const pending = requestOrigins.get(msg.requestId);
478
+ forgetRequest(msg.requestId);
479
+ if (pending && pending.origin.readyState === WebSocket.OPEN) {
480
+ pending.origin.send(JSON.stringify(msg));
456
481
  }
457
482
  }
458
483
  if (msg.type === 'log') {