castle-web-cli 0.4.92 → 0.4.93

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.
Files changed (42) hide show
  1. package/dist/agent.js +141 -22
  2. package/dist/api.d.ts +8 -0
  3. package/dist/api.js +10 -0
  4. package/dist/bundle.js +2 -2
  5. package/dist/get-deck.d.ts +1 -1
  6. package/dist/get-deck.js +93 -22
  7. package/dist/ide.js +123 -15
  8. package/dist/imports.d.ts +22 -0
  9. package/dist/imports.js +549 -0
  10. package/dist/index.js +45 -15
  11. package/dist/init.js +169 -1
  12. package/dist/install.d.ts +1 -1
  13. package/dist/install.js +14 -1
  14. package/dist/metering.d.ts +1 -0
  15. package/dist/metering.js +1 -1
  16. package/dist/native/loop.js +1 -0
  17. package/dist/native/openrouter.d.ts +1 -0
  18. package/dist/native/openrouter.js +13 -6
  19. package/dist/native/types.d.ts +1 -0
  20. package/dist/normalize.js +4 -0
  21. package/dist/openrouter-catalog.d.ts +3 -1
  22. package/dist/openrouter-catalog.js +15 -10
  23. package/dist/save-deck.d.ts +2 -0
  24. package/dist/save-deck.js +25 -19
  25. package/dist/serve.js +2 -2
  26. package/dist/shell/assets/{index-DSIr52Kl.css → index-CWNH9QiB.css} +1 -1
  27. package/dist/shell/assets/{index-BFCG4tLs.js → index-_C2BvstY.js} +21 -21
  28. package/dist/shell/index.html +2 -2
  29. package/dist/vitePlugins.d.ts +1 -0
  30. package/dist/vitePlugins.js +33 -0
  31. package/kits/basic-2d/CLAUDE.md +20 -0
  32. package/kits/basic-2d/behaviors/Sprite.jsx +6 -1
  33. package/kits/basic-2d/editors/BlueprintLibrary.jsx +14 -8
  34. package/kits/basic-2d/editors/behaviorRegistry.js +8 -2
  35. package/kits/basic-2d/engine/behaviorExtensions.js +5 -1
  36. package/kits/basic-2d/engine/blueprint.js +39 -3
  37. package/kits/basic-2d/engine/files.js +26 -5
  38. package/kits/basic-2d/engine/scene.js +4 -1
  39. package/kits/basic-2d/engine/systemRegistry.js +5 -1
  40. package/package.json +1 -1
  41. package/dist/pull.d.ts +0 -4
  42. package/dist/pull.js +0 -119
package/dist/agent.js CHANGED
@@ -190,8 +190,15 @@ const ANTHROPIC_CREDENTIAL_ENV = [
190
190
  // for openrouter.ai: it sent `Authorization: Bearer sk-ant-...`. Callers must
191
191
  // resolve a key first (preflightOpenrouterRun does); there is deliberately no
192
192
  // code path from here to openrouter.ai without an OpenRouter token.
193
- function envForOpenrouterSpawn(apiKey) {
194
- if (!apiKey) {
193
+ // ANTHROPIC_BASE_URL for a user's OWN OpenRouter key -- straight to openrouter.ai
194
+ // (the CLI appends /v1/messages), bypassing the proxy. The proxy branch instead
195
+ // uses openrouterAnthropicBase() (the injected OPENROUTER_BASE_URL origin).
196
+ const OPENROUTER_DIRECT_ANTHROPIC_BASE = "https://openrouter.ai/api";
197
+ // OpenAI-shaped chat-completions ORIGIN for the smith native loop on a user's
198
+ // own OpenRouter key (native/openrouter.ts appends /chat/completions).
199
+ const OPENROUTER_DIRECT_CHAT_BASE = "https://openrouter.ai/api/v1";
200
+ function envForOpenrouterSpawn(auth) {
201
+ if (!auth.key) {
195
202
  // Unreachable via runAgentTurn (pre-flight rejects a keyless run before
196
203
  // any spawn). A backstop, so a future caller that skips pre-flight fails
197
204
  // loudly instead of quietly leaking.
@@ -200,9 +207,17 @@ function envForOpenrouterSpawn(apiKey) {
200
207
  const env = { ...process.env };
201
208
  for (const name of ANTHROPIC_CREDENTIAL_ENV)
202
209
  delete env[name];
203
- env.ANTHROPIC_BASE_URL = openrouterAnthropicBase();
210
+ if (auth.mode === "user-key") {
211
+ env.ANTHROPIC_BASE_URL = OPENROUTER_DIRECT_ANTHROPIC_BASE;
212
+ // An inherited ANTHROPIC_CUSTOM_HEADERS can carry x-castle-* metering
213
+ // lines (see ANTHROPIC_PROXY_ENV) -- never forward those on a direct run.
214
+ delete env.ANTHROPIC_CUSTOM_HEADERS;
215
+ }
216
+ else {
217
+ env.ANTHROPIC_BASE_URL = openrouterAnthropicBase();
218
+ }
204
219
  env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
205
- env.ANTHROPIC_AUTH_TOKEN = apiKey;
220
+ env.ANTHROPIC_AUTH_TOKEN = auth.key;
206
221
  return env;
207
222
  }
208
223
  // The one OpenRouter credential, shared by BOTH OpenRouter paths: smith's
@@ -351,6 +366,14 @@ openrouterModel,
351
366
  metering) {
352
367
  if (backend === "claude") {
353
368
  const viaOpenrouter = claudeModel === "openrouter";
369
+ const orAuth = viaOpenrouter ? resolveOpenrouterAuth() : null;
370
+ const anAuth = viaOpenrouter ? null : resolveAnthropicAuth();
371
+ // Direct = the user's own credential/login is in play, so this run bypasses
372
+ // the proxy and must NOT carry metering headers (metering.ts would otherwise
373
+ // attach them off process.env and the direct spawn would leak them upstream).
374
+ const direct = viaOpenrouter
375
+ ? orAuth.mode === "user-key"
376
+ : anAuth.mode !== "proxy";
354
377
  return {
355
378
  command: "claude",
356
379
  args: [
@@ -385,11 +408,12 @@ metering) {
385
408
  prompt,
386
409
  ],
387
410
  env: withCustomHeaders(viaOpenrouter
388
- ? envForOpenrouterSpawn(openrouterApiKey())
389
- : envForAgentSpawn(backend), meteringHeaders({
411
+ ? envForOpenrouterSpawn(orAuth)
412
+ : envForClaudeSpawn(anAuth), meteringHeaders({
390
413
  deckDir: metering.deckDir,
391
414
  sessionId: metering.sessionId,
392
415
  route: viaOpenrouter ? "openrouter" : "anthropic",
416
+ direct,
393
417
  })),
394
418
  };
395
419
  }
@@ -740,6 +764,43 @@ function castleKeys() {
740
764
  return {};
741
765
  }
742
766
  }
767
+ // A user's OWN provider credentials, kept SEPARATE from Castle's keys.json so
768
+ // castle-www's per-serve re-sync of keys.json (cloudSandbox.ts syncCastleKeys)
769
+ // can't clobber them. Same shape as keys.json (env-var-name keys); the user (or
770
+ // a future editor UI) writes this file, nothing in-process does. When a key is
771
+ // present the run goes DIRECT to that provider on the user's own credential and
772
+ // is NOT metered -- deleting the key reverts to Castle's proxy on the next run.
773
+ // The path override mirrors CASTLE_KEYS_PATH so the QA battery stays isolated
774
+ // from a developer's real ~/.castle. No env fallback on read: the file is the
775
+ // only source, so a delete fully reverts.
776
+ const CASTLE_USER_KEYS_PATH = process.env.CASTLE_USER_KEYS_PATH ??
777
+ path.join(os.homedir(), ".castle", "user-keys.json");
778
+ function userKeys() {
779
+ try {
780
+ return JSON.parse(fs.readFileSync(CASTLE_USER_KEYS_PATH, "utf8"));
781
+ }
782
+ catch {
783
+ return {};
784
+ }
785
+ }
786
+ function userKey(envName) {
787
+ const v = userKeys()[envName]?.trim();
788
+ return v ? v : null;
789
+ }
790
+ function resolveAnthropicAuth() {
791
+ const k = userKey("ANTHROPIC_API_KEY");
792
+ if (k)
793
+ return { mode: "user-key", key: k };
794
+ if (backendHasSavedAuth("claude"))
795
+ return { mode: "user-login" };
796
+ return { mode: "proxy" };
797
+ }
798
+ function resolveOpenrouterAuth() {
799
+ const k = userKey("OPENROUTER_API_KEY");
800
+ if (k)
801
+ return { mode: "user-key", key: k };
802
+ return { mode: "proxy", key: openrouterApiKey() };
803
+ }
743
804
  // Keys for the SPAWNING backends' env injection (envForAgentSpawn). Smith is
744
805
  // absent by design: it never spawns a CLI -- its OpenRouter key flows through
745
806
  // openrouterApiKey() into runAgentNative's Authorization header instead.
@@ -791,31 +852,76 @@ function purgeStaleCursorAuth(home, injectedKey) {
791
852
  // no auth.json, or unreadable/unparseable -- nothing to purge
792
853
  }
793
854
  }
794
- // True when the user has their OWN saved auth for this backend -- a login that we
795
- // should defer to (and bill to them) instead of injecting Castle's key.
855
+ // True when the user has their OWN saved auth for this backend -- a login we
856
+ // route to directly (and bill to them) instead of Castle's proxy / key.
796
857
  //
797
858
  // KNOWN GAP (macOS): the claude check is a false negative for most logged-in
798
859
  // users. `claude /login` stores credentials in the KEYCHAIN there, not in
799
- // ~/.claude/.credentials.json, so this returns false and Castle's key gets
800
- // injected and billed even though the user has a perfectly good subscription
801
- // login the CLI would have used. Verified while tracing the OpenRouter
802
- // credential leak: on a machine with no .credentials.json at all, the CLI still
860
+ // ~/.claude/.credentials.json, so this returns false and the run stays on
861
+ // Castle's proxy even though the user has a perfectly good subscription login
862
+ // the CLI would have used. Verified while tracing the OpenRouter credential
863
+ // leak: on a machine with no .credentials.json at all, the CLI still
803
864
  // authenticated from the Keychain. Left alone deliberately -- reading the
804
865
  // Keychain (`security find-generic-password`) changes who pays for a run, which
805
- // is a product decision, not a cleanup.
866
+ // is a product decision, not a cleanup. In a Linux sandbox (the case that
867
+ // matters for BYO routing) the file IS authoritative, so the gap doesn't bite.
868
+ //
869
+ // CASTLE_CLAUDE_CREDENTIALS_PATH overrides the claude credentials location so
870
+ // the QA battery can isolate from a developer's REAL ~/.claude login -- which,
871
+ // now that this gates proxy-vs-direct routing (see resolveAnthropicAuth), would
872
+ // otherwise flip every plain-claude scenario to "direct" on a logged-in machine.
873
+ // Mirrors the CASTLE_KEYS_PATH seam.
806
874
  function backendHasSavedAuth(backend) {
807
875
  const home = os.homedir();
808
876
  if (backend === "claude") {
809
- return fs.existsSync(path.join(home, ".claude", ".credentials.json"));
877
+ const credPath = process.env.CASTLE_CLAUDE_CREDENTIALS_PATH ??
878
+ path.join(home, ".claude", ".credentials.json");
879
+ return fs.existsSync(credPath);
810
880
  }
811
881
  if (backend === "cursor") {
812
882
  return cursorHasUserLogin(home);
813
883
  }
814
884
  return false;
815
885
  }
816
- // Env for an agent spawn: inject Castle's key ONLY when the backend has no saved
817
- // auth of the user's own. This is what lets internal testers run on their own
818
- // subscription (log in once in the terminal) instead of Castle's key.
886
+ // Everything that points the claude CLI at Castle's llm-proxy, or that it could
887
+ // otherwise send there: the injected proxy pair, every Anthropic credential the
888
+ // CLI falls back to, and an inherited ANTHROPIC_CUSTOM_HEADERS. That last one
889
+ // matters because the sandbox host-agent's pty env carries x-castle-* metering
890
+ // lines (castle-sandboxes agentRunner), so a serve started from that terminal
891
+ // inherits them and withCustomHeaders deliberately preserves inherited values.
892
+ // Cleared wholesale on any DIRECT run so none of it can ride along to
893
+ // api.anthropic.com or to the user's own account.
894
+ const ANTHROPIC_PROXY_ENV = [
895
+ "ANTHROPIC_BASE_URL",
896
+ "ANTHROPIC_AUTH_TOKEN",
897
+ "ANTHROPIC_CUSTOM_HEADERS",
898
+ ...ANTHROPIC_CREDENTIAL_ENV,
899
+ ];
900
+ // Env for the plain claude CLI path (NOT claude-via-OpenRouter -- that's
901
+ // envForOpenrouterSpawn). resolveAnthropicAuth decides the routing:
902
+ // - proxy: inherit the host-injected ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN
903
+ // (scoped token) untouched -- the metered default in a sandbox, and a no-op
904
+ // locally where nothing is injected.
905
+ // - user-login: strip the proxy pair + all stray Anthropic creds so the CLI
906
+ // authenticates from the user's own saved login (~/.claude/.credentials.json
907
+ // or, on macOS, the Keychain) and bills them, direct + unmetered.
908
+ // - user-key: same strip, then set ANTHROPIC_API_KEY (x-api-key) to the user's
909
+ // own key, direct + unmetered.
910
+ function envForClaudeSpawn(auth) {
911
+ const env = { ...process.env };
912
+ if (auth.mode === "proxy")
913
+ return env;
914
+ for (const name of ANTHROPIC_PROXY_ENV)
915
+ delete env[name];
916
+ if (auth.mode === "user-key")
917
+ env.ANTHROPIC_API_KEY = auth.key;
918
+ return env;
919
+ }
920
+ // Env for a cursor-agent spawn: inject Castle's key ONLY when the backend has no
921
+ // saved auth of the user's own. This is what lets internal testers run on their
922
+ // own subscription (log in once in the terminal) instead of Castle's key. The
923
+ // claude path no longer routes through here -- see envForClaudeSpawn /
924
+ // resolveAnthropicAuth (this stays generic but is only ever called for cursor).
819
925
  function envForAgentSpawn(backend) {
820
926
  const env = { ...process.env };
821
927
  const keyName = BACKEND_KEY_ENV[backend];
@@ -1467,7 +1573,8 @@ async function runAgentSmith(opts) {
1467
1573
  role: opts.role,
1468
1574
  extraHeaders: opts.extraHeaders,
1469
1575
  model: opts.model,
1470
- apiKey: openrouterApiKey(),
1576
+ apiKey: opts.apiKey,
1577
+ baseUrl: opts.baseUrl,
1471
1578
  reasoningEffort: opts.openrouterTuning?.reasoningEffort,
1472
1579
  routing: opts.openrouterTuning?.routing,
1473
1580
  // "" (auto) becomes undefined so no provider.order is sent.
@@ -1530,9 +1637,10 @@ function configFailure(reason, detail, extra) {
1530
1637
  async function preflightOpenrouterRun(opts) {
1531
1638
  if (!roleUsesOpenrouter(opts.backend, opts.claudeModel))
1532
1639
  return null;
1533
- const apiKey = openrouterApiKey();
1640
+ const auth = opts.orAuth ?? resolveOpenrouterAuth();
1641
+ const apiKey = auth.key;
1534
1642
  if (!apiKey) {
1535
- return configFailure("no-key", `no ${OPENROUTER_KEY_NAME} is set (checked ${CASTLE_KEYS_PATH} and the environment)`);
1643
+ return configFailure("no-key", `no ${OPENROUTER_KEY_NAME} is set (checked ${CASTLE_USER_KEYS_PATH}, ${CASTLE_KEYS_PATH}, and the environment)`);
1536
1644
  }
1537
1645
  const model = opts.openrouterModel.trim();
1538
1646
  if (!model) {
@@ -1541,7 +1649,7 @@ async function preflightOpenrouterRun(opts) {
1541
1649
  // Key and slug checks are independent, so overlap them rather than paying
1542
1650
  // both round-trips in series. Both are cached and single-flighted.
1543
1651
  const [key, slug] = await Promise.all([
1544
- checkOpenrouterKey(apiKey),
1652
+ checkOpenrouterKey(apiKey, { direct: auth.mode === "user-key" }),
1545
1653
  checkOpenrouterModel(model),
1546
1654
  ]);
1547
1655
  if (key.status === "bad-key") {
@@ -1568,11 +1676,17 @@ async function preflightOpenrouterRun(opts) {
1568
1676
  // (buildAgentInvocation -> runAgentCli). Everything downstream consumes the
1569
1677
  // same CliRunResult contract either way.
1570
1678
  async function runAgentTurn(opts) {
1679
+ // Resolve the OpenRouter credential ONCE and reuse it for pre-flight, the run,
1680
+ // and metering, so validation, spend, and the direct/proxy routing decision
1681
+ // can never diverge. Null on a non-OpenRouter path.
1682
+ const orAuth = roleUsesOpenrouter(opts.backend, opts.claudeModel)
1683
+ ? resolveOpenrouterAuth()
1684
+ : null;
1571
1685
  // Deterministic config errors stop here: nothing spawned, no request issued,
1572
1686
  // nothing billed. Returned (not thrown) because the callers' catch paths
1573
1687
  // emit generic "something went wrong" copy, which would bury the specific
1574
1688
  // reason this pre-flight exists to produce.
1575
- const failure = await preflightOpenrouterRun(opts);
1689
+ const failure = await preflightOpenrouterRun({ ...opts, orAuth });
1576
1690
  if (failure) {
1577
1691
  return {
1578
1692
  ok: false,
@@ -1589,13 +1703,18 @@ async function runAgentTurn(opts) {
1589
1703
  // conversation -- nothing is resumed).
1590
1704
  const sessionId = newAgentSessionId(opts.role);
1591
1705
  if (opts.backend === "smith") {
1706
+ // roleUsesOpenrouter is true for smith, so orAuth is non-null here.
1707
+ const direct = orAuth.mode === "user-key";
1592
1708
  return runAgentSmith({
1593
1709
  cwd: opts.cwd,
1594
1710
  role: opts.role,
1711
+ apiKey: orAuth.key,
1712
+ baseUrl: direct ? OPENROUTER_DIRECT_CHAT_BASE : undefined,
1595
1713
  extraHeaders: meteringHeaders({
1596
1714
  deckDir: opts.cwd,
1597
1715
  sessionId,
1598
1716
  route: "openrouter",
1717
+ direct,
1599
1718
  }),
1600
1719
  model: opts.openrouterModel,
1601
1720
  prompt: opts.prompt,
package/dist/api.d.ts CHANGED
@@ -77,6 +77,14 @@ export interface WebDeckSource {
77
77
  archiveUrl: string;
78
78
  updatedAt: string;
79
79
  }
80
+ export interface DeckMeta {
81
+ deckId: string;
82
+ title: string | null;
83
+ creator: {
84
+ username: string;
85
+ } | null;
86
+ }
87
+ export declare function deckMeta(deckId: string): Promise<DeckMeta | null>;
80
88
  export declare function webDeckSource(deckId: string): Promise<WebDeckSource | null>;
81
89
  export declare function saveWebDeckSource(deckId: string, uploadId: string): Promise<WebDeckSource>;
82
90
  export {};
package/dist/api.js CHANGED
@@ -127,6 +127,16 @@ export async function createWebDeckSourceUploadConfig(deckId) {
127
127
  handleAPIError(data);
128
128
  return pickData(data, 'createWebDeckSourceUploadConfig');
129
129
  }
130
+ // Title + author, for naming an import. Readable without auth for the same decks
131
+ // webDeckSource is (unlisted ones), so this works in a sandbox with no login.
132
+ export async function deckMeta(deckId) {
133
+ const data = await graphql(`query($deckId: ID!) {
134
+ deck(deckId: $deckId) {
135
+ deckId title creator { username }
136
+ }
137
+ }`, { deckId });
138
+ return data?.data?.deck ?? null;
139
+ }
130
140
  export async function webDeckSource(deckId) {
131
141
  const data = await graphql(`query($deckId: ID!) {
132
142
  webDeckSource(deckId: $deckId) {
package/dist/bundle.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import * as path from 'path';
2
2
  import { build } from 'vite';
3
3
  import { viteSingleFile } from 'vite-plugin-singlefile';
4
- import { sceneFilesPlugin } from './vitePlugins.js';
4
+ import { sceneFilesPlugin, importsAliasPlugin } from './vitePlugins.js';
5
5
  export async function bundleProject(dir) {
6
6
  const result = await build({
7
7
  root: dir,
8
- plugins: [viteSingleFile(), sceneFilesPlugin()],
8
+ plugins: [importsAliasPlugin(), viteSingleFile(), sceneFilesPlugin()],
9
9
  build: {
10
10
  write: false,
11
11
  rollupOptions: {
@@ -1,4 +1,4 @@
1
- export declare function extractTarball(archivePath: string, targetDir: string): Promise<void>;
2
1
  export declare function getDeck(dir: string, options?: {
3
2
  deckId?: string;
3
+ force?: boolean;
4
4
  }): Promise<void>;
package/dist/get-deck.js CHANGED
@@ -1,46 +1,102 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
- import { spawn } from 'child_process';
5
4
  import { nanoid } from 'nanoid';
6
5
  import * as api from './api.js';
7
6
  import { normalizeDeckPackageJson } from './normalize.js';
7
+ import { archiveSource, runTar, SOURCE_ARCHIVE_EXCLUDES } from './save-deck.js';
8
+ // Refreshing a deck already in the target replaces its source, so a file deleted
9
+ // upstream actually disappears here -- untarring over the old tree would leave it
10
+ // behind. What survives is exactly what the source archive doesn't carry, and it
11
+ // has to be: those paths are missing from the server's copy AND from the backup
12
+ // below (which is that same archive), so deleting one destroys it outright. They're
13
+ // all machine-local anyway -- deps, build output, .castle runtime state, .git.
14
+ const KEEP = SOURCE_ARCHIVE_EXCLUDES;
8
15
  function readCastleJson(dir) {
9
16
  const p = path.join(dir, 'castle.json');
10
17
  if (!fs.existsSync(p))
11
18
  return null;
12
- return JSON.parse(fs.readFileSync(p, 'utf-8'));
19
+ try {
20
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
21
+ }
22
+ catch {
23
+ return null;
24
+ }
13
25
  }
14
- export function extractTarball(archivePath, targetDir) {
15
- return new Promise((resolve, reject) => {
16
- const child = spawn('tar', ['-xzf', archivePath, '-C', targetDir], {
17
- stdio: ['ignore', 'inherit', 'pipe'],
18
- });
19
- let stderr = '';
20
- child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
21
- child.on('error', reject);
22
- child.on('close', (code) => {
23
- if (code !== 0) {
24
- reject(new Error(`tar exited with code ${code}: ${stderr}`));
25
- return;
26
+ // Whether the target already holds a deck, as opposed to being new, empty, or
27
+ // holding only the dirs a refresh preserves.
28
+ function hasSource(dir) {
29
+ if (!fs.existsSync(dir))
30
+ return false;
31
+ return fs.readdirSync(dir).some((entry) => !KEEP.includes(entry));
32
+ }
33
+ function newestSourceMtime(dir) {
34
+ let newest = 0;
35
+ const walk = (current) => {
36
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
37
+ if (current === dir && KEEP.includes(entry.name))
38
+ continue;
39
+ const full = path.join(current, entry.name);
40
+ let stat;
41
+ try {
42
+ stat = fs.statSync(full);
43
+ }
44
+ catch {
45
+ continue; // raced deletion / broken symlink
26
46
  }
27
- resolve();
28
- });
29
- });
47
+ newest = Math.max(newest, stat.mtimeMs);
48
+ if (entry.isDirectory())
49
+ walk(full);
50
+ }
51
+ };
52
+ walk(dir);
53
+ return newest;
30
54
  }
55
+ // Fetch a deck's saved source into <dir>. If the deck is already there, its source
56
+ // is REPLACED with the server's latest -- the way to refresh a copy that has fallen
57
+ // behind (edited on another machine, or on a sandbox host this one isn't).
58
+ //
59
+ // Deps are left to `castle-web install`, like a git pull leaves them to you.
31
60
  export async function getDeck(dir, options = {}) {
32
61
  const targetDir = path.resolve(dir);
33
- const existing = readCastleJson(targetDir);
34
- const deckId = options.deckId ?? existing?.deckId;
62
+ const localDeckId = readCastleJson(targetDir)?.deckId;
63
+ const deckId = options.deckId ?? localDeckId;
35
64
  if (!deckId) {
36
65
  console.error(`No deckId. Either pass --deck-id <id> or run from a directory whose castle.json has one.`);
37
66
  process.exit(1);
38
67
  }
68
+ // The staleness guard below can't catch fetching deck B over a copy of deck A --
69
+ // nothing about A's mtimes says "wrong target", so a cold copy passes and gets
70
+ // silently replaced.
71
+ if (localDeckId && deckId !== localDeckId && !options.force) {
72
+ console.error(`This directory holds deck ${localDeckId}, but --deck-id ${deckId} was passed.`);
73
+ console.error(`Pass --force to replace deck ${localDeckId}'s source with deck ${deckId}'s.`);
74
+ process.exit(1);
75
+ }
39
76
  const source = await api.webDeckSource(deckId);
40
77
  if (!source) {
41
78
  console.error(`No source archive on server for deck ${deckId}. Run \`castle-web save-deck\` first.`);
42
79
  process.exit(1);
43
80
  }
81
+ const refreshing = hasSource(targetDir);
82
+ // Local edits newer than the server's copy are the ones worth keeping, and the
83
+ // replace would destroy them -- stop rather than guess. Extraction restores the
84
+ // archive's own mtimes, so refreshing twice doesn't trip this.
85
+ if (refreshing && !options.force) {
86
+ const serverMs = Date.parse(source.updatedAt);
87
+ if (!Number.isFinite(serverMs)) {
88
+ console.error(`Can't tell whether this copy is newer than the saved deck: unreadable save time from the server (${source.updatedAt}).`);
89
+ console.error(`Pass \`--force\` to replace this copy anyway.`);
90
+ process.exit(1);
91
+ }
92
+ const localMs = newestSourceMtime(targetDir);
93
+ if (localMs > serverMs) {
94
+ console.error(`This copy has changes newer than the saved deck (local ${new Date(localMs).toISOString()} > server ${source.updatedAt}).`);
95
+ console.error(`Run \`castle-web save-deck\` to keep them, or \`--force\` to discard them.`);
96
+ process.exit(1);
97
+ }
98
+ }
99
+ // Everything that can fail over the network happens before anything is deleted.
44
100
  console.log(`Fetching ${source.archiveUrl}`);
45
101
  const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
46
102
  if (!res.ok) {
@@ -49,11 +105,26 @@ export async function getDeck(dir, options = {}) {
49
105
  const buf = Buffer.from(await res.arrayBuffer());
50
106
  const sizeKB = buf.length / 1024;
51
107
  console.log(`Archive: ${sizeKB.toFixed(1)}KB (updated ${source.updatedAt})`);
52
- fs.mkdirSync(targetDir, { recursive: true });
53
108
  const tmpFile = path.join(os.tmpdir(), `castle-get-${nanoid(8)}.tar.gz`);
54
109
  fs.writeFileSync(tmpFile, buf);
55
110
  try {
56
- await extractTarball(tmpFile, targetDir);
111
+ if (refreshing) {
112
+ // A corrupt archive discovered mid-extract would find the old source already
113
+ // deleted; prove tar can read the whole thing before touching anything.
114
+ await runTar(['-tzf', tmpFile]);
115
+ // Kept out of the deck's parent on purpose: castle-www lists a user's cloud
116
+ // decks by globbing the decks dir, so a backup beside the deck reads as one.
117
+ const backup = path.join(os.tmpdir(), `castle-get-deck-backup-${nanoid(8)}.tar.gz`);
118
+ fs.writeFileSync(backup, await archiveSource(targetDir));
119
+ console.log(`Backed up the current source to ${backup}`);
120
+ for (const entry of fs.readdirSync(targetDir)) {
121
+ if (KEEP.includes(entry))
122
+ continue;
123
+ fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true });
124
+ }
125
+ }
126
+ fs.mkdirSync(targetDir, { recursive: true });
127
+ await runTar(['-xzf', tmpFile, '-C', targetDir]);
57
128
  }
58
129
  finally {
59
130
  try {
@@ -64,5 +135,5 @@ export async function getDeck(dir, options = {}) {
64
135
  if (normalizeDeckPackageJson(targetDir)) {
65
136
  console.log(`Repointed package.json at this machine's sdk/cli`);
66
137
  }
67
- console.log(`Extracted to ${targetDir}`);
138
+ console.log(`${refreshing ? 'Updated' : 'Extracted to'} ${targetDir}`);
68
139
  }