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 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
@@ -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) {
@@ -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;
@@ -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 ?? "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,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
+ }
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.89",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"