castle-web-cli 0.4.106 → 0.4.108

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 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ // Printed for the editor terminal's shims to eval: the environment this
2
+ // particular run of this particular CLI should have, plus the real binary to
3
+ // exec (see installCliShims in byo-auth.ts for why the decision can't live in
4
+ // the shell's own environment).
5
+ //
6
+ // argv is `<shimDir> <cli> [the run's own arguments...]`.
7
+ import { ensureClaudeOnboarded, shimEnvScript } from "./byo-auth.js";
8
+ const [shimDir = "", cli = "", ...args] = process.argv.slice(2);
9
+ // Its own call rather than a side effect of building the script: this WRITES,
10
+ // and shimEnvScript is read-only by contract.
11
+ if (cli === "claude")
12
+ ensureClaudeOnboarded();
13
+ process.stdout.write(shimEnvScript(cli, process.env, shimDir, args));
package/dist/get-deck.js CHANGED
@@ -5,6 +5,7 @@ import { nanoid } from 'nanoid';
5
5
  import * as api from './api.js';
6
6
  import { normalizeDeckPackageJson } from './normalize.js';
7
7
  import { archiveSource, runTar, SOURCE_ARCHIVE_EXCLUDES } from './save-deck.js';
8
+ import { readCastleJson } from './castleJson.js';
8
9
  // Refreshing a deck already in the target replaces its source, so a file deleted
9
10
  // upstream actually disappears here -- untarring over the old tree would leave it
10
11
  // behind. What survives is exactly what the source archive doesn't carry, and it
@@ -12,17 +13,6 @@ import { archiveSource, runTar, SOURCE_ARCHIVE_EXCLUDES } from './save-deck.js';
12
13
  // below (which is that same archive), so deleting one destroys it outright. They're
13
14
  // all machine-local anyway -- deps, build output, .castle runtime state, .git.
14
15
  const KEEP = SOURCE_ARCHIVE_EXCLUDES;
15
- function readCastleJson(dir) {
16
- const p = path.join(dir, 'castle.json');
17
- if (!fs.existsSync(p))
18
- return null;
19
- try {
20
- return JSON.parse(fs.readFileSync(p, 'utf-8'));
21
- }
22
- catch {
23
- return null;
24
- }
25
- }
26
16
  // Whether the target already holds a deck, as opposed to being new, empty, or
27
17
  // holding only the dirs a refresh preserves.
28
18
  function hasSource(dir) {
package/dist/ide.js CHANGED
@@ -15,7 +15,7 @@ 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 { envForUserShell } from "./byo-auth.js";
18
+ import { envForUserShell, installCliShims } from "./byo-auth.js";
19
19
  const HeadlessTerminal = headlessPkg.Terminal;
20
20
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
21
21
  // The bundled shell app (vite build output). `/` serves its index.html and
@@ -315,7 +315,7 @@ function readRequestBody(req) {
315
315
  req.on("error", reject);
316
316
  });
317
317
  }
318
- function handleFilesWrite(deckDir, req, res) {
318
+ function withJsonBody(req, res, handler) {
319
319
  void (async () => {
320
320
  let body;
321
321
  try {
@@ -324,24 +324,37 @@ function handleFilesWrite(deckDir, req, res) {
324
324
  catch {
325
325
  return sendJson(res, 400, { error: "Invalid JSON body." });
326
326
  }
327
+ handler(body);
328
+ })();
329
+ }
330
+ // The shape every single-path mutation handler starts with: parse the body,
331
+ // resolve `path` against the deck, and reject anything outside it.
332
+ function withMutationPath(deckDir, req, res, handler) {
333
+ withJsonBody(req, res, (body) => {
327
334
  const resolved = resolveDeckPath(deckDir, body.path, { mutation: true });
328
335
  if (!resolved.ok)
329
336
  return sendJson(res, 400, { error: resolved.error });
337
+ handler(resolved, body);
338
+ });
339
+ }
340
+ function sendFailure(res, action, rel, err) {
341
+ const message = err instanceof Error ? err.message : String(err);
342
+ sendJson(res, 500, { error: `Could not ${action} ${rel}: ${message}` });
343
+ }
344
+ function handleFilesWrite(deckDir, req, res) {
345
+ withMutationPath(deckDir, req, res, (target, body) => {
330
346
  if (typeof body.contents !== "string") {
331
347
  return sendJson(res, 400, { error: "File contents must be a string." });
332
348
  }
333
349
  try {
334
- fs.mkdirSync(path.dirname(resolved.abs), { recursive: true });
335
- fs.writeFileSync(resolved.abs, body.contents, "utf8");
336
- sendJson(res, 200, { ok: true, path: resolved.rel });
350
+ fs.mkdirSync(path.dirname(target.abs), { recursive: true });
351
+ fs.writeFileSync(target.abs, body.contents, "utf8");
352
+ sendJson(res, 200, { ok: true, path: target.rel });
337
353
  }
338
354
  catch (err) {
339
- const message = err instanceof Error ? err.message : String(err);
340
- sendJson(res, 500, {
341
- error: `Could not write ${resolved.rel}: ${message}`,
342
- });
355
+ sendFailure(res, "write", target.rel, err);
343
356
  }
344
- })();
357
+ });
345
358
  }
346
359
  // Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
347
360
  // newly-made folder show in the curated Files tree. Only touches a deck that is
@@ -378,29 +391,16 @@ function ensureVisiblePath(deckDir, rel) {
378
391
  }
379
392
  }
380
393
  function handleFilesMkdir(deckDir, req, res) {
381
- void (async () => {
382
- let body;
394
+ withMutationPath(deckDir, req, res, (target) => {
383
395
  try {
384
- body = JSON.parse(await readRequestBody(req));
385
- }
386
- catch {
387
- return sendJson(res, 400, { error: "Invalid JSON body." });
388
- }
389
- const resolved = resolveDeckPath(deckDir, body.path, { mutation: true });
390
- if (!resolved.ok)
391
- return sendJson(res, 400, { error: resolved.error });
392
- try {
393
- fs.mkdirSync(resolved.abs, { recursive: true });
396
+ fs.mkdirSync(target.abs, { recursive: true });
394
397
  }
395
398
  catch (err) {
396
- const message = err instanceof Error ? err.message : String(err);
397
- return sendJson(res, 500, {
398
- error: `Could not create folder ${resolved.rel}: ${message}`,
399
- });
399
+ return sendFailure(res, "create folder", target.rel, err);
400
400
  }
401
- const visiblePathAdded = ensureVisiblePath(deckDir, resolved.rel);
402
- sendJson(res, 200, { ok: true, path: resolved.rel, visiblePathAdded });
403
- })();
401
+ const visiblePathAdded = ensureVisiblePath(deckDir, target.rel);
402
+ sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
403
+ });
404
404
  }
405
405
  // True when two paths resolve to the same underlying file (same inode+device) --
406
406
  // e.g. the source and target of a case-only rename on a case-insensitive FS.
@@ -415,14 +415,7 @@ function isSameFile(a, b) {
415
415
  }
416
416
  }
417
417
  function handleFilesRename(deckDir, req, res) {
418
- void (async () => {
419
- let body;
420
- try {
421
- body = JSON.parse(await readRequestBody(req));
422
- }
423
- catch {
424
- return sendJson(res, 400, { error: "Invalid JSON body." });
425
- }
418
+ withJsonBody(req, res, (body) => {
426
419
  const from = resolveDeckPath(deckDir, body.from, { mutation: true });
427
420
  if (!from.ok)
428
421
  return sendJson(res, 400, { error: from.error });
@@ -447,37 +440,23 @@ function handleFilesRename(deckDir, req, res) {
447
440
  sendJson(res, 200, { ok: true, path: to.rel });
448
441
  }
449
442
  catch (err) {
450
- const message = err instanceof Error ? err.message : String(err);
451
- sendJson(res, 500, { error: `Could not rename ${from.rel}: ${message}` });
443
+ sendFailure(res, "rename", from.rel, err);
452
444
  }
453
- })();
445
+ });
454
446
  }
455
447
  function handleFilesDelete(deckDir, req, res) {
456
- void (async () => {
457
- let body;
458
- try {
459
- body = JSON.parse(await readRequestBody(req));
460
- }
461
- catch {
462
- return sendJson(res, 400, { error: "Invalid JSON body." });
463
- }
464
- const resolved = resolveDeckPath(deckDir, body.path, { mutation: true });
465
- if (!resolved.ok)
466
- return sendJson(res, 400, { error: resolved.error });
467
- if (!fs.existsSync(resolved.abs)) {
468
- return sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
448
+ withMutationPath(deckDir, req, res, (target) => {
449
+ if (!fs.existsSync(target.abs)) {
450
+ return sendJson(res, 404, { error: `Not found: ${target.rel}` });
469
451
  }
470
452
  try {
471
- fs.rmSync(resolved.abs, { recursive: true, force: true });
472
- sendJson(res, 200, { ok: true, path: resolved.rel });
453
+ fs.rmSync(target.abs, { recursive: true, force: true });
454
+ sendJson(res, 200, { ok: true, path: target.rel });
473
455
  }
474
456
  catch (err) {
475
- const message = err instanceof Error ? err.message : String(err);
476
- sendJson(res, 500, {
477
- error: `Could not delete ${resolved.rel}: ${message}`,
478
- });
457
+ sendFailure(res, "delete", target.rel, err);
479
458
  }
480
- })();
459
+ });
481
460
  }
482
461
  // The builtin Files + code-editor backend: list / read / write deck files and
483
462
  // report kit-owned editor extensions. Paths are deck-relative; resolveDeckPath
@@ -611,7 +590,7 @@ function defaultShell() {
611
590
  }) ?? "/bin/sh";
612
591
  return { command, args: ["-l"] };
613
592
  }
614
- function ptyEnv() {
593
+ function ptyEnv(shimDir) {
615
594
  const env = {
616
595
  ...envForUserShell(process.env),
617
596
  TERM: PTY_TERM,
@@ -623,6 +602,9 @@ function ptyEnv() {
623
602
  };
624
603
  delete env.NO_COLOR;
625
604
  delete env.NODE_DISABLE_COLORS;
605
+ if (shimDir) {
606
+ env.PATH = env.PATH ? `${shimDir}${path.delimiter}${env.PATH}` : shimDir;
607
+ }
626
608
  return env;
627
609
  }
628
610
  function clampSize(value, fallback) {
@@ -669,6 +651,7 @@ export function createIdeServer(opts) {
669
651
  let session = null;
670
652
  function spawnSession() {
671
653
  const { command, args } = defaultShell();
654
+ const shimDir = installCliShims(deckDir);
672
655
  const screen = new HeadlessTerminal({
673
656
  allowProposedApi: true,
674
657
  cols: INITIAL_COLS,
@@ -683,7 +666,7 @@ export function createIdeServer(opts) {
683
666
  cols: INITIAL_COLS,
684
667
  rows: INITIAL_ROWS,
685
668
  cwd: deckDir,
686
- env: ptyEnv(),
669
+ env: ptyEnv(shimDir),
687
670
  });
688
671
  const s = {
689
672
  pty,
package/dist/imports.js CHANGED
@@ -5,6 +5,7 @@ import * as api from './api.js';
5
5
  import { runTar } from './save-deck.js';
6
6
  import { normalizeDeckPackageJson } from './normalize.js';
7
7
  import { getKitsDir } from './localPaths.js';
8
+ import { readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
8
9
  // Adding another deck as a dependency (`castle-web add-import`; removing one is a
9
10
  // later command). Deliberately NOT `get-deck`: that one
10
11
  // replaces THIS deck's own source from the server (and carries guards for the
@@ -24,17 +25,6 @@ import { getKitsDir } from './localPaths.js';
24
25
  // node_modules gets. That also means `get-deck` leaves an existing `imports/`
25
26
  // alone when it refreshes a deck.
26
27
  export const IMPORTS_DIR = 'imports';
27
- function readCastleJson(dir) {
28
- const p = path.join(dir, 'castle.json');
29
- if (!fs.existsSync(p))
30
- return null;
31
- try {
32
- return JSON.parse(fs.readFileSync(p, 'utf-8'));
33
- }
34
- catch (e) {
35
- throw new Error(`Could not read ${p}: ${e instanceof Error ? e.message : String(e)}`);
36
- }
37
- }
38
28
  // Only the `imports` key is ours; everything else in castle.json (deck identity,
39
29
  // editor config) is preserved as-is.
40
30
  function writeImportPin(dir, alias, pin) {
@@ -88,47 +78,24 @@ function qualifiedAlias(meta, deckId) {
88
78
  //
89
79
  // The cost is that `imports/<alias>` can't be deleted until it's unlocked, which
90
80
  // is why every path in this file that replaces a dependency unlocks it first.
91
- function chmodTree(dir, fileMode, dirMode) {
92
- let entries;
93
- try {
94
- entries = fs.readdirSync(dir, { withFileTypes: true });
95
- }
96
- catch {
97
- return;
98
- }
99
- for (const entry of entries) {
100
- const child = path.join(dir, entry.name);
101
- if (entry.isDirectory())
102
- chmodTree(child, fileMode, dirMode);
103
- else {
104
- try {
105
- fs.chmodSync(child, fileMode);
106
- }
107
- catch {
108
- /* a file we can't chmod is not worth failing the import over */
109
- }
110
- }
111
- }
112
- // The directory goes last: its contents have to be reachable while we walk it.
81
+ // A file we can't chmod is not worth failing the import over, so every chmod
82
+ // here is best-effort.
83
+ function chmod(target, mode) {
113
84
  try {
114
- fs.chmodSync(dir, dirMode);
85
+ fs.chmodSync(target, mode);
86
+ return true;
115
87
  }
116
88
  catch {
117
- /* as above */
89
+ return false;
118
90
  }
119
91
  }
120
- export function lockImportTree(dir) {
121
- chmodTree(dir, 0o444, 0o555);
122
- }
123
- // Restore write permission so the tree can be replaced or removed. Directories
124
- // first, or their contents are unreachable.
125
- function unlockImportTree(dir) {
126
- try {
127
- fs.chmodSync(dir, 0o755);
128
- }
129
- catch {
92
+ // `dirFirst` is the one thing that differs between locking and unlocking, and it
93
+ // has to: locking chmods a directory AFTER its contents (they must stay
94
+ // reachable while we walk it), unlocking chmods it BEFORE (or the walk can't get
95
+ // in at all -- so a failure there aborts this subtree).
96
+ function chmodTree(dir, modes) {
97
+ if (modes.dirFirst && !chmod(dir, modes.dir))
130
98
  return;
131
- }
132
99
  let entries;
133
100
  try {
134
101
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -139,16 +106,19 @@ function unlockImportTree(dir) {
139
106
  for (const entry of entries) {
140
107
  const child = path.join(dir, entry.name);
141
108
  if (entry.isDirectory())
142
- unlockImportTree(child);
143
- else {
144
- try {
145
- fs.chmodSync(child, 0o644);
146
- }
147
- catch {
148
- /* best effort */
149
- }
150
- }
109
+ chmodTree(child, modes);
110
+ else
111
+ chmod(child, modes.file);
151
112
  }
113
+ if (!modes.dirFirst)
114
+ chmod(dir, modes.dir);
115
+ }
116
+ export function lockImportTree(dir) {
117
+ chmodTree(dir, { file: 0o444, dir: 0o555, dirFirst: false });
118
+ }
119
+ // Restore write permission so the tree can be replaced or removed.
120
+ function unlockImportTree(dir) {
121
+ chmodTree(dir, { file: 0o644, dir: 0o755, dirFirst: true });
152
122
  }
153
123
  // An import's code runs from the IMPORTING deck's node_modules -- there is one
154
124
  // install at the deck root, and a dependency's own node_modules is neither
@@ -209,7 +179,7 @@ export function syncImportDependencies(deckDir) {
209
179
  // won't run until they are back. Rebuilding them from the pins is `install`'s
210
180
  // job, alongside node_modules.
211
181
  export async function restoreMissingImports(deckDir) {
212
- const pins = (readCastleJson(deckDir)?.imports ?? {});
182
+ const pins = readCastleJson(deckDir)?.imports ?? {};
213
183
  const restored = [];
214
184
  for (const [alias, pin] of Object.entries(pins)) {
215
185
  const destDir = path.join(deckDir, IMPORTS_DIR, alias);
@@ -298,7 +268,7 @@ export async function importStatuses(deckDir) {
298
268
  const cached = statusCache.get(deckDir);
299
269
  if (cached && Date.now() - cached.at < STATUS_TTL_MS)
300
270
  return cached.value;
301
- const pins = (readCastleJson(deckDir)?.imports ?? {});
271
+ const pins = readCastleJson(deckDir)?.imports ?? {};
302
272
  const value = await Promise.all(Object.entries(pins).map(async ([alias, pin]) => {
303
273
  const base = {
304
274
  alias,
@@ -386,12 +356,12 @@ async function placeImport(deckDir, alias, deckId, source, via) {
386
356
  // silently renamed, since renaming would break the refs of whoever named it.
387
357
  async function addTransitiveImports(deckDir, viaAlias, seen) {
388
358
  const viaDir = path.join(deckDir, IMPORTS_DIR, viaAlias);
389
- const pins = (readCastleJson(viaDir)?.imports ?? {});
359
+ const pins = readCastleJson(viaDir)?.imports ?? {};
390
360
  const added = [];
391
361
  for (const [alias, pin] of Object.entries(pins)) {
392
362
  if (!pin?.deckId || seen.has(pin.deckId))
393
363
  continue;
394
- const existing = (readCastleJson(deckDir)?.imports ?? {});
364
+ const existing = readCastleJson(deckDir)?.imports ?? {};
395
365
  const holder = existing[alias];
396
366
  if (holder && holder.deckId !== pin.deckId) {
397
367
  console.warn(`"${viaAlias}" wants ${pin.deckId} as "${alias}", which is already ${holder.deckId} here -- skipping; ` +
@@ -417,7 +387,7 @@ async function addTransitiveImports(deckDir, viaAlias, seen) {
417
387
  // Every deckId this deck already has, so a transitive walk doesn't refetch or
418
388
  // loop on a cycle.
419
389
  function importedDeckIds(deckDir) {
420
- const pins = (readCastleJson(deckDir)?.imports ?? {});
390
+ const pins = readCastleJson(deckDir)?.imports ?? {};
421
391
  return new Set(Object.values(pins).map((p) => p?.deckId).filter((id) => !!id));
422
392
  }
423
393
  export async function addImport(dir, options = {}) {
@@ -468,7 +438,7 @@ export async function addImport(dir, options = {}) {
468
438
  // wasn't.
469
439
  export async function updateImport(dir, options = {}) {
470
440
  const deckDir = path.resolve(dir);
471
- const pins = (readCastleJson(deckDir)?.imports ?? {});
441
+ const pins = readCastleJson(deckDir)?.imports ?? {};
472
442
  const aliases = options.alias ? [options.alias] : Object.keys(pins);
473
443
  if (aliases.length === 0) {
474
444
  console.log('This deck has no imports.');
package/dist/init.js CHANGED
@@ -37,29 +37,16 @@ const DEFAULT_KIT = "physics-2d";
37
37
  // globally-installed castle-web (not from inside the workspace). Bumped
38
38
  // alongside cli/sdk version bumps.
39
39
  const PUBLISHED_SDK_VERSION = "0.4.11";
40
- // Never copied into a fresh deck: build/dependency junk. castle.json IS copied
41
- // (the kit ships a config-only one with the editor layout / file filters), but
42
- // `scaffoldFromKit` strips any identity fields off it first -- a fresh deck has
43
- // no deckId until its first save-deck, which owns those fields.
44
40
  // The account the first-party kits are (to be) published under, so a kit
45
41
  // imported from the CLI's copy is named the same as one fetched from the server.
46
42
  const KIT_AUTHOR = "castle";
43
+ // Never copied into a deck's import: build/dependency junk.
47
44
  const KIT_COPY_EXCLUDE = new Set([
48
45
  "node_modules",
49
46
  ".castle",
50
47
  "dist",
51
48
  ".git",
52
49
  ]);
53
- // Identity fields owned by save-deck, never shipped in a kit's config-only
54
- // castle.json. Stripped from a copied castle.json (and absent from the bare
55
- // default) so a fresh deck has no deckId until its first save.
56
- const CASTLE_JSON_IDENTITY_FIELDS = [
57
- "deckId",
58
- "cardId",
59
- "title",
60
- "caption",
61
- "visibility",
62
- ];
63
50
  // The bare (`--kit none`) deck's default editor config: just the Files panel and
64
51
  // a Play panel. No file filtering -- a bare deck is hand-rolled, so the author
65
52
  // knows what's there; show everything for now.
@@ -70,22 +57,6 @@ const BARE_CASTLE_JSON = {
70
57
  visiblePaths: [],
71
58
  },
72
59
  };
73
- // Strip save-deck-owned identity fields from a copied kit castle.json, leaving
74
- // only its config (the editor block). No-op if the kit shipped no castle.json.
75
- function stripCastleJsonIdentity(projectDir) {
76
- const castlePath = path.join(projectDir, "castle.json");
77
- if (!fs.existsSync(castlePath))
78
- return;
79
- try {
80
- const data = JSON.parse(fs.readFileSync(castlePath, "utf8"));
81
- for (const field of CASTLE_JSON_IDENTITY_FIELDS)
82
- delete data[field];
83
- fs.writeFileSync(castlePath, JSON.stringify(data, null, 2) + "\n");
84
- }
85
- catch {
86
- // kit shipped an unparseable castle.json -- leave it for the user to fix
87
- }
88
- }
89
60
  // Resolve how a scaffolded deck should reference the sdk + cli. Both the bare
90
61
  // and kit scaffold paths go through here so they stay in sync.
91
62
  // workspace mode (sdk/ sits next to cli/, i.e. running from a checkout):
@@ -354,7 +325,8 @@ function scaffoldFromKitImport(kit, projectDir) {
354
325
  });
355
326
  const kitConfig = readJsonFile(path.join(kitDir, "castle.json")) ?? {};
356
327
  const kitPkg = readJsonFile(path.join(kitDir, "package.json")) ?? {};
357
- writeDeckIndexHtml(projectDir, alias, String(kitConfig.title ?? kit));
328
+ const title = typeof kitConfig.title === "string" ? kitConfig.title : kit;
329
+ writeDeckIndexHtml(projectDir, alias, title);
358
330
  writeStarterScene(projectDir, kitDir, alias);
359
331
  // The deck's own castle.json: the kit's editor config (panel layout, file
360
332
  // filters) as a starting point -- it is the deck's to edit from here -- plus
@@ -385,91 +357,6 @@ function scaffoldFromKitImport(kit, projectDir) {
385
357
  appendCommonInstructions(projectDir);
386
358
  lockImportTree(importDir);
387
359
  }
388
- function scaffoldFromKit(kit, projectDir) {
389
- const kitDir = path.join(getKitsDir(), kit);
390
- if (!fs.existsSync(kitDir) || !fs.statSync(kitDir).isDirectory()) {
391
- console.error(`Kit "${kit}" not found at ${kitDir}.`);
392
- console.error("Available kits:");
393
- try {
394
- const kits = fs
395
- .readdirSync(getKitsDir())
396
- .filter((name) => fs.statSync(path.join(getKitsDir(), name)).isDirectory());
397
- if (kits.length)
398
- for (const name of kits)
399
- console.error(` ${name}`);
400
- else
401
- console.error(" (none)");
402
- }
403
- catch {
404
- console.error(" (none — kits/ directory is missing)");
405
- }
406
- console.error("Or use `--kit none` for a bare code-only deck.");
407
- process.exit(1);
408
- }
409
- fs.cpSync(kitDir, projectDir, {
410
- recursive: true,
411
- // Keep symlinks verbatim so the kit's AGENTS.md -> CLAUDE.md stays a link.
412
- verbatimSymlinks: true,
413
- filter: (src) => src === kitDir || !KIT_COPY_EXCLUDE.has(path.basename(src)),
414
- });
415
- // The kit ships a config-only castle.json (editor layout / file filters); keep
416
- // its config but drop any identity fields so this fresh deck has no deckId
417
- // until its first save-deck.
418
- stripCastleJsonIdentity(projectDir);
419
- // The kit's package.json carries the kit's name; rename it to the deck dir.
420
- // Kit-relative refs to `../../sdk` and `../../cli/dist` only resolve when the
421
- // deck lives at castle-experimental-web/decks/<name>/. Rewrite both to
422
- // absolute paths so the scaffolded deck works anywhere -- including under
423
- // /tmp where macOS's /tmp -> /private/tmp symlink breaks relative-path math.
424
- const pkgPath = path.join(projectDir, "package.json");
425
- if (fs.existsSync(pkgPath)) {
426
- try {
427
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
428
- pkg.name = path.basename(projectDir);
429
- // Local-dev paths (`file:../../sdk` / `node ../../cli/dist/index.js`) only
430
- // work when the deck lives inside the castle-experimental-web workspace.
431
- // For a deck scaffolded from a globally-installed castle-web, rewrite to
432
- // the published packages instead. Same workspace-vs-published resolution
433
- // the bare scaffold path uses.
434
- const { workspaceMode, sdkRef, cliDistAbs, sdkPathPosix } = resolveScaffoldRefs();
435
- if (pkg.dependencies &&
436
- typeof pkg.dependencies["castle-web-sdk"] === "string" &&
437
- pkg.dependencies["castle-web-sdk"].startsWith("file:")) {
438
- pkg.dependencies["castle-web-sdk"] = sdkRef;
439
- }
440
- if (pkg.scripts) {
441
- for (const k of Object.keys(pkg.scripts)) {
442
- if (typeof pkg.scripts[k] !== "string")
443
- continue;
444
- if (workspaceMode) {
445
- pkg.scripts[k] = pkg.scripts[k]
446
- .replace(/\.\.\/\.\.\/cli\/dist/g, cliDistAbs)
447
- .replace(/\.\.\/\.\.\/sdk/g, sdkPathPosix);
448
- }
449
- else {
450
- // Globally-installed: route through the `castle-web` binary on PATH.
451
- pkg.scripts[k] = pkg.scripts[k]
452
- .replace(/node\s+\.\.\/\.\.\/cli\/dist\/index\.js/g, "castle-web")
453
- .replace(/await import\((['"])\.\.\/\.\.\/cli\/dist\/bundle\.js\1\)/g, "await import('castle-web-cli/dist/bundle.js')")
454
- .replace(/\.\.\/\.\.\/sdk/g, "");
455
- }
456
- }
457
- }
458
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
459
- }
460
- catch {
461
- // kit shipped an unparseable package.json — leave it for the user to fix
462
- }
463
- }
464
- // Every deck needs a CLAUDE.md so coding agents know how castle-web works.
465
- // Keep the kit's own if it ships one; otherwise generate from the upstream.
466
- const claudePath = path.join(projectDir, "CLAUDE.md");
467
- if (!fs.existsSync(claudePath)) {
468
- fs.writeFileSync(claudePath, makeClaudeMd());
469
- }
470
- appendCommonInstructions(projectDir);
471
- ensureAgentsSymlink(projectDir);
472
- }
473
360
  export async function init(dir, opts = {}) {
474
361
  const projectDir = path.resolve(dir);
475
362
  if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length > 0) {
@@ -30,6 +30,13 @@ export declare function reportCursorRun(opts: {
30
30
  durationMs: number;
31
31
  ok: boolean;
32
32
  }): void;
33
+ /**
34
+ * True when this serve runs inside a Castle sandbox -- i.e. the host injected the
35
+ * llm-proxy pair, which is what makes a run metered and limit-gated in the first
36
+ * place. Outside one there is no Castle budget being spent, so nothing to escape
37
+ * with a credential of your own; the CLIs just use whatever the host machine has.
38
+ */
39
+ export declare function inCastleSandbox(): boolean;
33
40
  /** Mirrors `CastleBudgetResponse` in castle-sandboxes/shared. */
34
41
  export interface CastleBudget {
35
42
  usedMicros: number;
package/dist/metering.js CHANGED
@@ -100,6 +100,15 @@ export function reportCursorRun(opts) {
100
100
  /* best-effort: metering must never surface in a finished run */
101
101
  });
102
102
  }
103
+ /**
104
+ * True when this serve runs inside a Castle sandbox -- i.e. the host injected the
105
+ * llm-proxy pair, which is what makes a run metered and limit-gated in the first
106
+ * place. Outside one there is no Castle budget being spent, so nothing to escape
107
+ * with a credential of your own; the CLIs just use whatever the host machine has.
108
+ */
109
+ export function inCastleSandbox() {
110
+ return Boolean(process.env.CASTLE_LLM_PROXY_URL && process.env.CASTLE_LLM_PROXY_TOKEN);
111
+ }
103
112
  // Path on the proxy that reports this sandbox's daily budget. Must match
104
113
  // CASTLE_BUDGET_PATH in castle-sandboxes/shared/src/index.ts.
105
114
  const CASTLE_BUDGET_PATH = "/castle/budget";
@@ -79,7 +79,7 @@ function launchFailureMessage(err) {
79
79
  return `could not launch chromium: ${message}`;
80
80
  }
81
81
  async function loadPlaywrightReal() {
82
- return (await import("playwright-core"));
82
+ return await import("playwright-core");
83
83
  }
84
84
  // playwright-core's exports map does NOT expose ./cli.js (verified against
85
85
  // 1.61: resolving it throws ERR_PACKAGE_PATH_NOT_EXPORTED), but it DOES
@@ -40,6 +40,18 @@ function resolveInDeck(deckDir, rawPath) {
40
40
  return null;
41
41
  return { abs, rel: rel.split(path.sep).join("/") };
42
42
  }
43
+ function statFile(target) {
44
+ let stat;
45
+ try {
46
+ stat = fs.statSync(target.abs);
47
+ }
48
+ catch {
49
+ return { error: err(`no such file: ${target.rel}`) };
50
+ }
51
+ if (!stat.isFile())
52
+ return { error: err(`not a file: ${target.rel}`) };
53
+ return { stat };
54
+ }
43
55
  // Sorted recursive file walk (deterministic order matters for grep/list_files
44
56
  // results and for tests), skipping IGNORED_DIRS at any depth. Capped as a
45
57
  // safety valve against pathological trees, not as a normal limit -- decks are
@@ -93,15 +105,10 @@ function readFileRun(args, ctx) {
93
105
  const resolved = resolveInDeck(ctx.deckDir, args.path);
94
106
  if (!resolved)
95
107
  return err(`path escapes the deck directory: ${String(args.path)}`);
96
- let stat;
97
- try {
98
- stat = fs.statSync(resolved.abs);
99
- }
100
- catch {
101
- return err(`no such file: ${resolved.rel}`);
102
- }
103
- if (!stat.isFile())
104
- return err(`not a file: ${resolved.rel}`);
108
+ const statted = statFile(resolved);
109
+ if (statted.error)
110
+ return statted.error;
111
+ const stat = statted.stat;
105
112
  const ext = path.extname(resolved.rel).toLowerCase();
106
113
  if (BINARY_READ_EXTS.has(ext)) {
107
114
  return err(`${resolved.rel} is a binary/image file -- it cannot be read as text. Use the view_image tool to look at image files.`);
@@ -167,15 +174,10 @@ function viewImageRun(args, ctx) {
167
174
  if (!mime) {
168
175
  return err(`${resolved.rel} is not a viewable image -- view_image supports png/jpg/jpeg/gif/webp. SVG and other text formats can be read with read_file.`);
169
176
  }
170
- let stat;
171
- try {
172
- stat = fs.statSync(resolved.abs);
173
- }
174
- catch {
175
- return err(`no such file: ${resolved.rel}`);
176
- }
177
- if (!stat.isFile())
178
- return err(`not a file: ${resolved.rel}`);
177
+ const statted = statFile(resolved);
178
+ if (statted.error)
179
+ return statted.error;
180
+ const stat = statted.stat;
179
181
  if (stat.size > VIEW_IMAGE_SIZE_CAP) {
180
182
  return err(`${resolved.rel} is too large to view (${(stat.size / (1024 * 1024)).toFixed(1)}MB > ${VIEW_IMAGE_SIZE_CAP / (1024 * 1024)}MB).`);
181
183
  }