castle-web-cli 0.4.87 → 0.4.89
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 +55 -16
- package/dist/get-deck.d.ts +1 -0
- package/dist/get-deck.js +5 -1
- package/dist/index.js +15 -0
- package/dist/init.d.ts +7 -0
- package/dist/init.js +2 -52
- package/dist/install.d.ts +2 -0
- package/dist/install.js +64 -0
- package/dist/native/openrouter.js +4 -1
- package/dist/normalize.d.ts +1 -0
- package/dist/normalize.js +66 -0
- package/dist/openrouter-catalog.js +5 -1
- package/dist/pull.d.ts +4 -0
- package/dist/pull.js +119 -0
- package/package.json +1 -1
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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
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 =
|
|
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.
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
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(
|
|
744
|
-
return auth.apiKey
|
|
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
|
-
|
|
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
|
}
|
package/dist/get-deck.d.ts
CHANGED
package/dist/get-deck.js
CHANGED
|
@@ -4,13 +4,14 @@ import * as path from 'path';
|
|
|
4
4
|
import { spawn } from 'child_process';
|
|
5
5
|
import { nanoid } from 'nanoid';
|
|
6
6
|
import * as api from './api.js';
|
|
7
|
+
import { normalizeDeckPackageJson } from './normalize.js';
|
|
7
8
|
function readCastleJson(dir) {
|
|
8
9
|
const p = path.join(dir, 'castle.json');
|
|
9
10
|
if (!fs.existsSync(p))
|
|
10
11
|
return null;
|
|
11
12
|
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
12
13
|
}
|
|
13
|
-
function extractTarball(archivePath, targetDir) {
|
|
14
|
+
export function extractTarball(archivePath, targetDir) {
|
|
14
15
|
return new Promise((resolve, reject) => {
|
|
15
16
|
const child = spawn('tar', ['-xzf', archivePath, '-C', targetDir], {
|
|
16
17
|
stdio: ['ignore', 'inherit', 'pipe'],
|
|
@@ -60,5 +61,8 @@ export async function getDeck(dir, options = {}) {
|
|
|
60
61
|
}
|
|
61
62
|
catch { /* nothing to clean */ }
|
|
62
63
|
}
|
|
64
|
+
if (normalizeDeckPackageJson(targetDir)) {
|
|
65
|
+
console.log(`Repointed package.json at this machine's sdk/cli`);
|
|
66
|
+
}
|
|
63
67
|
console.log(`Extracted to ${targetDir}`);
|
|
64
68
|
}
|
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.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
export declare function resolveScaffoldRefs(): {
|
|
2
|
+
workspaceMode: boolean;
|
|
3
|
+
sdkRef: string;
|
|
4
|
+
cliCommand: string;
|
|
5
|
+
cliDistAbs: string | null;
|
|
6
|
+
sdkPathPosix: string | null;
|
|
7
|
+
};
|
|
1
8
|
export declare function init(dir: string, opts?: {
|
|
2
9
|
kit?: string;
|
|
3
10
|
serve?: boolean;
|
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>
|
|
@@ -88,7 +88,7 @@ function stripCastleJsonIdentity(projectDir) {
|
|
|
88
88
|
// sdk -> file: absolute path to sdk/, scripts -> `node <abs cli dist>/index.js`
|
|
89
89
|
// published mode (globally-installed castle-web, no sibling sdk/):
|
|
90
90
|
// sdk -> registry `^${PUBLISHED_SDK_VERSION}`, scripts -> the `castle-web` binary
|
|
91
|
-
function resolveScaffoldRefs() {
|
|
91
|
+
export function resolveScaffoldRefs() {
|
|
92
92
|
const sdkPath = getSdkPackagePath();
|
|
93
93
|
// "Workspace mode" = the cli is running from a castle-experimental-web
|
|
94
94
|
// checkout (sdk/ exists next to cli/). Otherwise we're a globally-installed
|
|
@@ -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) {
|
package/dist/install.js
ADDED
|
@@ -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
|
-
|
|
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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function normalizeDeckPackageJson(projectDir: string): boolean;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { resolveScaffoldRefs } from './init.js';
|
|
4
|
+
// `<path>/cli/dist/<entry>.js` -- a path into a castle-experimental-web checkout,
|
|
5
|
+
// as `init` writes into a deck scaffolded in workspace mode.
|
|
6
|
+
//
|
|
7
|
+
// The lookbehind stops a match from starting mid-token, which is what makes this
|
|
8
|
+
// idempotent: without it, the published `castle-web-cli/dist/bundle.js` specifier
|
|
9
|
+
// this very function writes contains a literal `cli/dist/bundle.js`, so a second
|
|
10
|
+
// pass would rewrite that tail again into `castle-web-castle-web-cli/dist/...`.
|
|
11
|
+
function cliPathPattern(lead, entry) {
|
|
12
|
+
return new RegExp(`(?<![^\\s'"(])${lead}(?:[^\\s'"]+/)?cli/dist/${entry}\\.js`, 'g');
|
|
13
|
+
}
|
|
14
|
+
function resolvesHere(projectDir, ref) {
|
|
15
|
+
return fs.existsSync(path.resolve(projectDir, ref));
|
|
16
|
+
}
|
|
17
|
+
// Point a deck's `castle-web-sdk` dependency and its script commands at whatever
|
|
18
|
+
// this machine actually has. `save-deck` archives the deck's package.json verbatim,
|
|
19
|
+
// so a deck saved from a castle-experimental-web checkout carries that checkout's
|
|
20
|
+
// absolute paths (`file:/Users/.../sdk`, `node /Users/.../cli/dist/index.js`);
|
|
21
|
+
// fetched anywhere else -- a cloud sandbox, another laptop -- those paths don't
|
|
22
|
+
// exist and the install fails outright.
|
|
23
|
+
//
|
|
24
|
+
// Only refs that DON'T resolve here are rewritten, so fetching your own deck back
|
|
25
|
+
// into the checkout it came from leaves its local wiring alone.
|
|
26
|
+
export function normalizeDeckPackageJson(projectDir) {
|
|
27
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
28
|
+
if (!fs.existsSync(pkgPath))
|
|
29
|
+
return false;
|
|
30
|
+
let pkg;
|
|
31
|
+
try {
|
|
32
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false; // unparseable package.json -- leave it for the user to fix
|
|
36
|
+
}
|
|
37
|
+
const refs = resolveScaffoldRefs();
|
|
38
|
+
let changed = false;
|
|
39
|
+
const sdk = pkg.dependencies?.['castle-web-sdk'];
|
|
40
|
+
if (typeof sdk === 'string' &&
|
|
41
|
+
sdk.startsWith('file:') &&
|
|
42
|
+
!resolvesHere(projectDir, sdk.slice('file:'.length))) {
|
|
43
|
+
pkg.dependencies['castle-web-sdk'] = refs.sdkRef;
|
|
44
|
+
changed = true;
|
|
45
|
+
}
|
|
46
|
+
const bundleRef = refs.workspaceMode
|
|
47
|
+
? `${refs.cliDistAbs}/bundle.js`
|
|
48
|
+
: 'castle-web-cli/dist/bundle.js';
|
|
49
|
+
for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
|
|
50
|
+
if (typeof command !== 'string')
|
|
51
|
+
continue;
|
|
52
|
+
const rewritten = command
|
|
53
|
+
// The whole `node <path>` goes, not just the path: published mode runs the
|
|
54
|
+
// `castle-web` binary directly, with no `node` in front of it.
|
|
55
|
+
.replace(cliPathPattern('node\\s+', 'index'), (match) => resolvesHere(projectDir, match.replace(/^node\s+/, '')) ? match : refs.cliCommand)
|
|
56
|
+
.replace(cliPathPattern('', 'bundle'), (match) => resolvesHere(projectDir, match) ? match : bundleRef);
|
|
57
|
+
if (rewritten !== command) {
|
|
58
|
+
pkg.scripts[name] = rewritten;
|
|
59
|
+
changed = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (changed) {
|
|
63
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
return changed;
|
|
66
|
+
}
|
|
@@ -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 ??
|
|
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
package/dist/pull.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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 { normalizeDeckPackageJson } from './normalize.js';
|
|
9
|
+
import { archiveSource } from './save-deck.js';
|
|
10
|
+
// Everything else in the deck dir is source and gets replaced wholesale, so that a
|
|
11
|
+
// file deleted upstream actually disappears here -- untarring over the old tree
|
|
12
|
+
// would leave it behind. These three survive: node_modules is expensive and gets
|
|
13
|
+
// reinstalled against the new lockfile anyway, .castle is this machine's runtime
|
|
14
|
+
// state (serve ports, logs, the agent's ledger), and .git is the user's own history.
|
|
15
|
+
const KEEP = ['node_modules', '.castle', '.git'];
|
|
16
|
+
function readCastleJson(dir) {
|
|
17
|
+
const p = path.join(dir, 'castle.json');
|
|
18
|
+
if (!fs.existsSync(p))
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Newest mtime across the deck's source, i.e. when this copy was last edited.
|
|
28
|
+
function newestSourceMtime(dir) {
|
|
29
|
+
let newest = 0;
|
|
30
|
+
const walk = (current) => {
|
|
31
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
32
|
+
if (current === dir && KEEP.includes(entry.name))
|
|
33
|
+
continue;
|
|
34
|
+
const full = path.join(current, entry.name);
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = fs.statSync(full);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue; // raced deletion / broken symlink
|
|
41
|
+
}
|
|
42
|
+
newest = Math.max(newest, stat.mtimeMs);
|
|
43
|
+
if (entry.isDirectory())
|
|
44
|
+
walk(full);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
walk(dir);
|
|
48
|
+
return newest;
|
|
49
|
+
}
|
|
50
|
+
function backupSource(projectDir, archive) {
|
|
51
|
+
const file = path.join(os.tmpdir(), `castle-pull-backup-${nanoid(8)}.tar.gz`);
|
|
52
|
+
fs.writeFileSync(file, archive);
|
|
53
|
+
return file;
|
|
54
|
+
}
|
|
55
|
+
// Replace a deck's source with the latest saved on the server. Where `get-deck`
|
|
56
|
+
// fetches a deck into a directory, this refreshes one that's already there --
|
|
57
|
+
// e.g. after edits made on another machine (or another sandbox host) that this
|
|
58
|
+
// copy predates.
|
|
59
|
+
export async function pull(dir, options = {}) {
|
|
60
|
+
const projectDir = path.resolve(dir);
|
|
61
|
+
if (!fs.existsSync(projectDir)) {
|
|
62
|
+
console.error(`No deck at ${projectDir}. Use \`castle-web get-deck\` to fetch a new one.`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
const deckId = options.deckId ?? readCastleJson(projectDir)?.deckId;
|
|
66
|
+
if (!deckId) {
|
|
67
|
+
console.error(`No deckId. Either pass --deck-id <id> or run against a directory whose castle.json has one.`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const source = await api.webDeckSource(deckId);
|
|
71
|
+
if (!source) {
|
|
72
|
+
console.error(`No source archive on server for deck ${deckId}. Run \`castle-web save-deck\` first.`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
// Local edits newer than what's on the server would be destroyed by the replace,
|
|
76
|
+
// and they're the copy worth keeping -- stop rather than guess. (A pull leaves
|
|
77
|
+
// the archive's own mtimes in place, so pulling twice doesn't trip this.)
|
|
78
|
+
const serverMs = Date.parse(source.updatedAt);
|
|
79
|
+
const localMs = newestSourceMtime(projectDir);
|
|
80
|
+
if (!options.force && Number.isFinite(serverMs) && localMs > serverMs) {
|
|
81
|
+
console.error(`This copy has changes newer than the saved deck (local ${new Date(localMs).toISOString()} > server ${source.updatedAt}).`);
|
|
82
|
+
console.error(`Run \`castle-web save-deck\` to keep them, or \`--force\` to discard them.`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
// Everything that can fail over the network happens before anything is deleted.
|
|
86
|
+
console.log(`Fetching ${source.archiveUrl}`);
|
|
87
|
+
const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
throw new Error(`Archive fetch failed: HTTP ${res.status}`);
|
|
90
|
+
}
|
|
91
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
92
|
+
console.log(`Archive: ${(buf.length / 1024).toFixed(1)}KB (updated ${source.updatedAt})`);
|
|
93
|
+
const backup = backupSource(projectDir, await archiveSource(projectDir));
|
|
94
|
+
console.log(`Backed up the current source to ${backup}`);
|
|
95
|
+
for (const entry of fs.readdirSync(projectDir)) {
|
|
96
|
+
if (KEEP.includes(entry))
|
|
97
|
+
continue;
|
|
98
|
+
fs.rmSync(path.join(projectDir, entry), { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
const tmpFile = path.join(os.tmpdir(), `castle-pull-${nanoid(8)}.tar.gz`);
|
|
101
|
+
fs.writeFileSync(tmpFile, buf);
|
|
102
|
+
try {
|
|
103
|
+
await extractTarball(tmpFile, projectDir);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
try {
|
|
107
|
+
fs.unlinkSync(tmpFile);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* nothing to clean */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (normalizeDeckPackageJson(projectDir)) {
|
|
114
|
+
console.log(`Repointed package.json at this machine's sdk/cli`);
|
|
115
|
+
}
|
|
116
|
+
console.log(`Updated ${projectDir}`);
|
|
117
|
+
// The pulled tree can want different deps than the one just deleted.
|
|
118
|
+
installDeps(projectDir, fs.existsSync(path.join(projectDir, 'pnpm-lock.yaml')));
|
|
119
|
+
}
|