castle-web-cli 0.4.87 → 0.4.88

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/agent.js CHANGED
@@ -139,12 +139,19 @@ function normalizeProviderTier(value) {
139
139
  return null;
140
140
  return trimmed;
141
141
  }
142
- // OpenRouter's Anthropic-compatible endpoint (confirmed current, 2026: it
143
- // accepts the standard Anthropic Messages API shape -- text/tool-use/
144
- // extended-thinking -- for ANY OpenRouter model slug, not just Anthropic
145
- // ones). Using it directly means claude CLI's OWN stream-json + tool loop
146
- // talks to OpenRouter with zero translation layer -- no proxy needed.
147
- const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
142
+ // Base for OpenRouter's Anthropic-compatible endpoint (confirmed current, 2026: it
143
+ // accepts the standard Anthropic Messages API shape -- text/tool-use/extended-thinking
144
+ // -- for ANY OpenRouter model slug, not just Anthropic ones), which the claude CLI
145
+ // appends /v1/messages to -- so its OWN stream-json + tool loop talks to OpenRouter with
146
+ // zero translation layer.
147
+ //
148
+ // In a Castle sandbox the host injects OPENROUTER_BASE_URL (…/api/v1) pointing at the
149
+ // per-host llm-proxy (which holds the real key and meters usage); use its origin, dropping
150
+ // the /v1 the CLI re-adds. Otherwise openrouter.ai directly.
151
+ function openrouterAnthropicBase() {
152
+ const injected = process.env.OPENROUTER_BASE_URL;
153
+ return injected ? injected.replace(/\/v1\/?$/, "") : "https://openrouter.ai/api";
154
+ }
148
155
  // Anthropic credential sources the claude CLI will fall back to on its own.
149
156
  // This env points the CLI at a THIRD PARTY, so every one of these has to be
150
157
  // cleared or that third party receives the user's Anthropic credential. Note
@@ -192,7 +199,7 @@ function envForOpenrouterSpawn(apiKey) {
192
199
  const env = { ...process.env };
193
200
  for (const name of ANTHROPIC_CREDENTIAL_ENV)
194
201
  delete env[name];
195
- env.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL;
202
+ env.ANTHROPIC_BASE_URL = openrouterAnthropicBase();
196
203
  env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
197
204
  env.ANTHROPIC_AUTH_TOKEN = apiKey;
198
205
  return env;
@@ -733,20 +740,48 @@ const BACKEND_KEY_ENV = {
733
740
  };
734
741
  // cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
735
742
  // including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
736
- // mean a user logged in. A real login (OAuth, or a tester's own key) overwrites
737
- // it and drops Castle's apiKey; a file whose apiKey is still Castle's key is just
738
- // our cache. Treating that cache as a login would suppress the injected key, and
739
- // once its ~60-min token expires cursor-agent (no headless refresh) fails auth.
743
+ // mean a user logged in. Distinguish by the apiKey field: a real login is OAuth,
744
+ // which drops apiKey and leaves only session tokens; any auth.json that still
745
+ // carries an apiKey is an env-key cache cursor wrote from an injected key --
746
+ // OURS, including a PREVIOUS key after a rotation. Treating that cache as a login
747
+ // suppresses the injected key and drops cursor into the stale (dead) session.
748
+ //
749
+ // An earlier apiKey === castleKeys().CURSOR_API_KEY comparison misfired on a key
750
+ // switch: the stale cache holds the OLD key, reads as "!= current" => "user
751
+ // login" => key withheld => auth fails until auth.json is deleted by hand.
752
+ // Deferring only on OAuth is rotation-proof. The cost: a tester's own-API-key
753
+ // login is no longer distinguishable from our stale cache, so it is not deferred
754
+ // to -- OAuth login still is (the common bypass path).
755
+ function cursorAuthPath(home) {
756
+ return path.join(home, ".config", "cursor", "auth.json");
757
+ }
740
758
  function cursorHasUserLogin(home) {
741
- const authPath = path.join(home, ".config", "cursor", "auth.json");
742
759
  try {
743
- const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
744
- return auth.apiKey !== castleKeys().CURSOR_API_KEY;
760
+ const auth = JSON.parse(fs.readFileSync(cursorAuthPath(home), "utf8"));
761
+ return !auth.apiKey && !!auth.accessToken;
745
762
  }
746
763
  catch {
747
764
  return false;
748
765
  }
749
766
  }
767
+ // When we inject Castle's key, any auth.json cursor cached from a DIFFERENT key
768
+ // -- a rotated-out old key, or a tester's own key we've chosen to override -- is
769
+ // dead weight: cursor re-auths from the injected env key and ignores it. Leaving
770
+ // it means a stale API key sits on the sandbox fs, so drop it and keep only the
771
+ // live key cached. Best-effort; an absent file is the normal case. An OAuth login
772
+ // (no apiKey field) is never reached here -- that path withholds the key instead.
773
+ function purgeStaleCursorAuth(home, injectedKey) {
774
+ try {
775
+ const authPath = cursorAuthPath(home);
776
+ const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
777
+ if (auth.apiKey && auth.apiKey !== injectedKey) {
778
+ fs.rmSync(authPath, { force: true });
779
+ }
780
+ }
781
+ catch {
782
+ // no auth.json, or unreadable/unparseable -- nothing to purge
783
+ }
784
+ }
750
785
  // True when the user has their OWN saved auth for this backend -- a login that we
751
786
  // should defer to (and bill to them) instead of injecting Castle's key.
752
787
  //
@@ -782,10 +817,14 @@ function envForAgentSpawn(backend) {
782
817
  }
783
818
  else {
784
819
  const val = castleKeys()[keyName] ?? process.env[keyName];
785
- if (val)
820
+ if (val) {
786
821
  env[keyName] = val;
787
- else
822
+ if (backend === "cursor")
823
+ purgeStaleCursorAuth(os.homedir(), val);
824
+ }
825
+ else {
788
826
  delete env[keyName];
827
+ }
789
828
  }
790
829
  return env;
791
830
  }
@@ -1,3 +1,4 @@
1
+ export declare function extractTarball(archivePath: string, targetDir: string): Promise<void>;
1
2
  export declare function getDeck(dir: string, options?: {
2
3
  deckId?: string;
3
4
  }): Promise<void>;
package/dist/get-deck.js CHANGED
@@ -10,7 +10,7 @@ function readCastleJson(dir) {
10
10
  return null;
11
11
  return JSON.parse(fs.readFileSync(p, 'utf-8'));
12
12
  }
13
- function extractTarball(archivePath, targetDir) {
13
+ export function extractTarball(archivePath, targetDir) {
14
14
  return new Promise((resolve, reject) => {
15
15
  const child = spawn('tar', ['-xzf', archivePath, '-C', targetDir], {
16
16
  stdio: ['ignore', 'inherit', 'pipe'],
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ import { serve } from './serve.js';
6
6
  import { saveDeck } from './save-deck.js';
7
7
  import { getDeck } from './get-deck.js';
8
8
  import { init } from './init.js';
9
+ import { install } from './install.js';
10
+ import { pull } from './pull.js';
9
11
  import { connectWS, savePreviewImage, savePreviewIfNeeded, takeScreenshot } from './preview.js';
10
12
  const args = process.argv.slice(2);
11
13
  const command = args[0];
@@ -64,6 +66,8 @@ function usage() {
64
66
  castle-web save-preview-image [dir] [--port PORT] [--no-restart]
65
67
  castle-web save-deck [dir] [--title TITLE] [--caption TEXT] [--visibility unlisted|private]
66
68
  castle-web get-deck <dir> [--deck-id ID]
69
+ castle-web pull [dir] [--deck-id ID] [--force]
70
+ castle-web install [dir]
67
71
  castle-web login
68
72
 
69
73
  Kits:
@@ -117,6 +121,17 @@ async function main() {
117
121
  await getDeck(dir, { deckId });
118
122
  break;
119
123
  }
124
+ case 'pull': {
125
+ await pull(findPositionalDir(), {
126
+ deckId: getFlagValue('--deck-id'),
127
+ force: hasFlag('--force'),
128
+ });
129
+ break;
130
+ }
131
+ case 'install': {
132
+ install(findPositionalDir());
133
+ break;
134
+ }
120
135
  case 'restart': {
121
136
  const dir = findPositionalDir();
122
137
  const wsPort = getWsPort(dir);
package/dist/init.js CHANGED
@@ -1,7 +1,7 @@
1
- import { execSync } from "child_process";
2
1
  import * as fs from "fs";
3
2
  import * as path from "path";
4
3
  import { COMMON_INSTRUCTIONS } from "./commonInstructions.js";
4
+ import { installDeps } from "./install.js";
5
5
  import { getCliEntryPath, getKitsDir, getRepoRoot, getSdkPackagePath, toPosixPath, } from "./localPaths.js";
6
6
  import { serve } from "./serve.js";
7
7
  const INDEX_HTML = `<!DOCTYPE html>
@@ -266,56 +266,6 @@ function scaffoldFromKit(kit, projectDir) {
266
266
  appendCommonInstructions(projectDir);
267
267
  ensureAgentsSymlink(projectDir);
268
268
  }
269
- function hasPnpm() {
270
- try {
271
- execSync("pnpm --version", { stdio: "ignore" });
272
- return true;
273
- }
274
- catch {
275
- return false;
276
- }
277
- }
278
- // Install the scaffolded deck's deps. Prefer pnpm -- in the e2b template a pnpm
279
- // store is baked in, so this is near-instant (hardlinks from the store, no
280
- // download). Fall back to npm when pnpm isn't on PATH (e.g. a laptop that never
281
- // installed it). --prefer-offline uses the store/cache first; --ignore-scripts
282
- // skips dep build scripts (pnpm 10+ gates them and exits non-zero otherwise,
283
- // and the deck's deps are all prebuilt pure JS that don't need them). `frozen`
284
- // installs straight from the shipped lockfile (skips resolution -> no network,
285
- // fast + deterministic); used in published mode where the kit lockfile matches.
286
- function installDeps(projectDir, frozen) {
287
- if (hasPnpm()) {
288
- console.log("Installing deps (pnpm)...");
289
- const frozenFlag = frozen ? "--frozen-lockfile " : "";
290
- try {
291
- execSync(`pnpm install ${frozenFlag}--prefer-offline --ignore-scripts`, {
292
- cwd: projectDir,
293
- stdio: "inherit",
294
- });
295
- }
296
- catch (err) {
297
- if (!frozen)
298
- throw err;
299
- // A frozen install hard-fails when the shipped kit lockfile has drifted
300
- // from the scaffold's package.json (e.g. the sdk version bumped but the
301
- // kit's pnpm-lock.yaml wasn't regenerated). Rather than leave the deck
302
- // with no node_modules, retry with resolution allowed -- a little slower
303
- // (re-resolves + may fetch), but self-heals the drift instead of breaking.
304
- console.warn("Frozen install failed; retrying with resolution (kit lockfile drift?)...");
305
- execSync("pnpm install --prefer-offline --ignore-scripts", {
306
- cwd: projectDir,
307
- stdio: "inherit",
308
- });
309
- }
310
- }
311
- else {
312
- console.log("Installing deps (npm)...");
313
- execSync("npm install --no-audit --no-fund --loglevel=error", {
314
- cwd: projectDir,
315
- stdio: "inherit",
316
- });
317
- }
318
- }
319
269
  export async function init(dir, opts = {}) {
320
270
  const projectDir = path.resolve(dir);
321
271
  if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length > 0) {
@@ -0,0 +1,2 @@
1
+ export declare function installDeps(projectDir: string, frozen: boolean): void;
2
+ export declare function install(dir: string): void;
@@ -0,0 +1,64 @@
1
+ import { execSync } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ function hasPnpm() {
5
+ try {
6
+ execSync("pnpm --version", { stdio: "ignore" });
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ // Install a deck's deps. Prefer pnpm -- in the e2b template a pnpm store is baked
14
+ // in, so this is near-instant (hardlinks from the store, no download). Fall back to
15
+ // npm when pnpm isn't on PATH (e.g. a laptop that never installed it).
16
+ // --prefer-offline uses the store/cache first; --ignore-scripts skips dep build
17
+ // scripts (pnpm 10+ gates them and exits non-zero otherwise, and the deck's deps are
18
+ // all prebuilt pure JS that don't need them). `frozen` installs straight from the
19
+ // shipped lockfile (skips resolution -> no network, fast + deterministic).
20
+ export function installDeps(projectDir, frozen) {
21
+ if (hasPnpm()) {
22
+ console.log("Installing deps (pnpm)...");
23
+ const frozenFlag = frozen ? "--frozen-lockfile " : "";
24
+ try {
25
+ execSync(`pnpm install ${frozenFlag}--prefer-offline --ignore-scripts`, {
26
+ cwd: projectDir,
27
+ stdio: "inherit",
28
+ });
29
+ }
30
+ catch (err) {
31
+ if (!frozen)
32
+ throw err;
33
+ // A frozen install hard-fails when the lockfile has drifted from
34
+ // package.json (e.g. the sdk version bumped but the kit's pnpm-lock.yaml
35
+ // wasn't regenerated). Rather than leave the deck with no node_modules,
36
+ // retry with resolution allowed -- a little slower (re-resolves + may
37
+ // fetch), but self-heals the drift instead of breaking.
38
+ console.warn("Frozen install failed; retrying with resolution (lockfile drift?)...");
39
+ execSync("pnpm install --prefer-offline --ignore-scripts", {
40
+ cwd: projectDir,
41
+ stdio: "inherit",
42
+ });
43
+ }
44
+ }
45
+ else {
46
+ console.log("Installing deps (npm)...");
47
+ execSync("npm install --no-audit --no-fund --loglevel=error", {
48
+ cwd: projectDir,
49
+ stdio: "inherit",
50
+ });
51
+ }
52
+ }
53
+ // `castle-web install <dir>`: put node_modules in place for a deck that already has
54
+ // its source -- one fetched with `get-deck`, or one whose node_modules was dropped
55
+ // (the cloud hosts treat it as regenerable and don't snapshot it).
56
+ export function install(dir) {
57
+ const projectDir = path.resolve(dir);
58
+ if (!fs.existsSync(path.join(projectDir, "package.json"))) {
59
+ console.error(`No package.json in ${projectDir}.`);
60
+ process.exit(1);
61
+ }
62
+ installDeps(projectDir, fs.existsSync(path.join(projectDir, "pnpm-lock.yaml")));
63
+ console.log(`Installed deps in ${projectDir}`);
64
+ }
@@ -20,7 +20,10 @@ import { failureForStatus } from "../agent-failures.js";
20
20
  // to a module-level const) so a test process that imports this module once
21
21
  // can still point successive runAgentNative calls at different fake servers.
22
22
  function openrouterUrl() {
23
- return process.env.CASTLE_OPENROUTER_URL || "https://openrouter.ai/api/v1/chat/completions";
23
+ // In a Castle sandbox the host injects OPENROUTER_BASE_URL pointing at the per-host
24
+ // llm-proxy (holds the real key, meters usage); otherwise talk to openrouter.ai directly.
25
+ return (process.env.CASTLE_OPENROUTER_URL ||
26
+ `${process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"}/chat/completions`);
24
27
  }
25
28
  const DEFAULT_MAX_RETRIES = 2; // -> 3 total connect attempts
26
29
  const RETRY_BASE_MS = 500;
@@ -28,8 +28,12 @@ function modelsUrl() {
28
28
  return (process.env.CASTLE_OPENROUTER_MODELS_URL ??
29
29
  "https://openrouter.ai/api/v1/models");
30
30
  }
31
+ // The key check sends the credential, so in a Castle sandbox it goes through the llm-proxy
32
+ // (OPENROUTER_BASE_URL) — validating the real key the proxy swaps in. modelsUrl above is a
33
+ // public GET and stays on openrouter.ai (the proxy requires a token).
31
34
  function keyUrl() {
32
- return process.env.CASTLE_OPENROUTER_KEY_URL ?? "https://openrouter.ai/api/v1/key";
35
+ return (process.env.CASTLE_OPENROUTER_KEY_URL ??
36
+ `${process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"}/key`);
33
37
  }
34
38
  function cachePath() {
35
39
  return (process.env.CASTLE_OPENROUTER_CATALOG_CACHE ??
package/dist/pull.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare function pull(dir: string, options?: {
2
+ deckId?: string;
3
+ force?: boolean;
4
+ }): Promise<void>;
package/dist/pull.js ADDED
@@ -0,0 +1,115 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { nanoid } from 'nanoid';
5
+ import * as api from './api.js';
6
+ import { extractTarball } from './get-deck.js';
7
+ import { installDeps } from './install.js';
8
+ import { archiveSource } from './save-deck.js';
9
+ // Everything else in the deck dir is source and gets replaced wholesale, so that a
10
+ // file deleted upstream actually disappears here -- untarring over the old tree
11
+ // would leave it behind. These three survive: node_modules is expensive and gets
12
+ // reinstalled against the new lockfile anyway, .castle is this machine's runtime
13
+ // state (serve ports, logs, the agent's ledger), and .git is the user's own history.
14
+ const KEEP = ['node_modules', '.castle', '.git'];
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
+ // Newest mtime across the deck's source, i.e. when this copy was last edited.
27
+ function newestSourceMtime(dir) {
28
+ let newest = 0;
29
+ const walk = (current) => {
30
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
31
+ if (current === dir && KEEP.includes(entry.name))
32
+ continue;
33
+ const full = path.join(current, entry.name);
34
+ let stat;
35
+ try {
36
+ stat = fs.statSync(full);
37
+ }
38
+ catch {
39
+ continue; // raced deletion / broken symlink
40
+ }
41
+ newest = Math.max(newest, stat.mtimeMs);
42
+ if (entry.isDirectory())
43
+ walk(full);
44
+ }
45
+ };
46
+ walk(dir);
47
+ return newest;
48
+ }
49
+ function backupSource(projectDir, archive) {
50
+ const file = path.join(os.tmpdir(), `castle-pull-backup-${nanoid(8)}.tar.gz`);
51
+ fs.writeFileSync(file, archive);
52
+ return file;
53
+ }
54
+ // Replace a deck's source with the latest saved on the server. Where `get-deck`
55
+ // fetches a deck into a directory, this refreshes one that's already there --
56
+ // e.g. after edits made on another machine (or another sandbox host) that this
57
+ // copy predates.
58
+ export async function pull(dir, options = {}) {
59
+ const projectDir = path.resolve(dir);
60
+ if (!fs.existsSync(projectDir)) {
61
+ console.error(`No deck at ${projectDir}. Use \`castle-web get-deck\` to fetch a new one.`);
62
+ process.exit(1);
63
+ }
64
+ const deckId = options.deckId ?? readCastleJson(projectDir)?.deckId;
65
+ if (!deckId) {
66
+ console.error(`No deckId. Either pass --deck-id <id> or run against a directory whose castle.json has one.`);
67
+ process.exit(1);
68
+ }
69
+ const source = await api.webDeckSource(deckId);
70
+ if (!source) {
71
+ console.error(`No source archive on server for deck ${deckId}. Run \`castle-web save-deck\` first.`);
72
+ process.exit(1);
73
+ }
74
+ // Local edits newer than what's on the server would be destroyed by the replace,
75
+ // and they're the copy worth keeping -- stop rather than guess. (A pull leaves
76
+ // the archive's own mtimes in place, so pulling twice doesn't trip this.)
77
+ const serverMs = Date.parse(source.updatedAt);
78
+ const localMs = newestSourceMtime(projectDir);
79
+ if (!options.force && Number.isFinite(serverMs) && localMs > serverMs) {
80
+ console.error(`This copy has changes newer than the saved deck (local ${new Date(localMs).toISOString()} > server ${source.updatedAt}).`);
81
+ console.error(`Run \`castle-web save-deck\` to keep them, or \`--force\` to discard them.`);
82
+ process.exit(1);
83
+ }
84
+ // Everything that can fail over the network happens before anything is deleted.
85
+ console.log(`Fetching ${source.archiveUrl}`);
86
+ const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
87
+ if (!res.ok) {
88
+ throw new Error(`Archive fetch failed: HTTP ${res.status}`);
89
+ }
90
+ const buf = Buffer.from(await res.arrayBuffer());
91
+ console.log(`Archive: ${(buf.length / 1024).toFixed(1)}KB (updated ${source.updatedAt})`);
92
+ const backup = backupSource(projectDir, await archiveSource(projectDir));
93
+ console.log(`Backed up the current source to ${backup}`);
94
+ for (const entry of fs.readdirSync(projectDir)) {
95
+ if (KEEP.includes(entry))
96
+ continue;
97
+ fs.rmSync(path.join(projectDir, entry), { recursive: true, force: true });
98
+ }
99
+ const tmpFile = path.join(os.tmpdir(), `castle-pull-${nanoid(8)}.tar.gz`);
100
+ fs.writeFileSync(tmpFile, buf);
101
+ try {
102
+ await extractTarball(tmpFile, projectDir);
103
+ }
104
+ finally {
105
+ try {
106
+ fs.unlinkSync(tmpFile);
107
+ }
108
+ catch {
109
+ /* nothing to clean */
110
+ }
111
+ }
112
+ console.log(`Updated ${projectDir}`);
113
+ // The pulled tree can want different deps than the one just deleted.
114
+ installDeps(projectDir, fs.existsSync(path.join(projectDir, 'pnpm-lock.yaml')));
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.87",
3
+ "version": "0.4.88",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"