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
@@ -4,8 +4,8 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Castle Editor</title>
7
- <script type="module" crossorigin src="/__castle/ide/assets/index-BFCG4tLs.js"></script>
8
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-DSIr52Kl.css">
7
+ <script type="module" crossorigin src="/__castle/ide/assets/index-_C2BvstY.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-CWNH9QiB.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -1,2 +1,3 @@
1
1
  import type { Plugin } from 'vite';
2
+ export declare function importsAliasPlugin(): Plugin;
2
3
  export declare function sceneFilesPlugin(): Plugin;
@@ -1,4 +1,11 @@
1
+ import * as path from 'path';
1
2
  import * as fs from 'fs';
3
+ import { IMPORTS_DIR } from './imports.js';
4
+ // Mirrors castle-web-sdk's IMPORTS_PREFIX, which is the definition deck code
5
+ // uses. Duplicated rather than imported: the CLI compiles under node16 module
6
+ // resolution and the SDK's published types don't, and one short string is not
7
+ // worth reconciling that.
8
+ const IMPORTS_PREFIX = '@imports/';
2
9
  // Safety net for `.scene` / `.drawing` / `.pxart` files imported as JS modules.
3
10
  //
4
11
  // These files are DATA (JSON), not modules. The blessed way to read them is the
@@ -11,6 +18,32 @@ import * as fs from 'fs';
11
18
  // `?raw` (and any other query suffix) is left untouched so the engine's
12
19
  // `import.meta.glob(..., { query: '?raw' })` still gets raw text.
13
20
  const DATA_FILE_EXTS = ['.scene', '.drawing', '.pxart'];
21
+ // `@imports/<alias>/...` is how a deck names a file of another deck it imports,
22
+ // in JS as in everything else (see castle-web-sdk's resolveDeckFile). Imports are
23
+ // flattened into one `imports/` at the deck root, so the alias resolves there --
24
+ // and means the same thing from any file at any depth, which a relative path
25
+ // could not: a dependency's `./imports/x` is right while that dependency is the
26
+ // deck being served and one level too deep once it is itself an import.
27
+ //
28
+ // Registered for the dev server AND the bundler. A specifier that resolves in
29
+ // one and not the other is a deck that plays in the editor and breaks when it
30
+ // ships.
31
+ export function importsAliasPlugin() {
32
+ return {
33
+ name: 'castle-imports-alias',
34
+ enforce: 'pre',
35
+ async resolveId(source, importer, options) {
36
+ if (!source.startsWith(IMPORTS_PREFIX))
37
+ return null;
38
+ const root = this.environment?.config?.root ?? process.cwd();
39
+ const target = path.join(root, IMPORTS_DIR, source.slice(IMPORTS_PREFIX.length));
40
+ // Hand the rewritten path back to vite rather than returning it resolved:
41
+ // extensions, index files and the rest of normal resolution still have to
42
+ // happen, and duplicating that here would only get it subtly wrong.
43
+ return (await this.resolve(target, importer, { ...options, skipSelf: true })) ?? target;
44
+ },
45
+ };
46
+ }
14
47
  export function sceneFilesPlugin() {
15
48
  return {
16
49
  name: 'castle-scene-files',
@@ -19,8 +19,28 @@ Do you already know what you want to make, or do you want to figure it out toget
19
19
  - After any code, scene, or drawing edit, run `npm run restart`.
20
20
  - Space is reserved by the editor for play/stop; do not bind Space to gameplay.
21
21
  - Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Collider.jsx`, `Camera.jsx`) to build a game. Their public API is documented below.
22
+ - Naming files: a plain path (`drawings/ship.pxart`) is a file of THIS deck; `@imports/<alias>/...` is a file of a deck this one imports. Same in JS (`import x from '@imports/someone.pack/thing.js'`), in scene/blueprint refs, and in `Sprite.file`. See `## Files and imports`.
22
23
  - Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
23
24
 
25
+ ## Files and imports
26
+
27
+ One rule wherever a file is named -- JS imports, `"blueprint"` refs, `Sprite.file`,
28
+ anything a behavior invents:
29
+
30
+ - `drawings/ship.pxart` -- a file of the deck the reference is WRITTEN IN. In this
31
+ deck's own files that means this deck; in a file belonging to an import, that
32
+ import. So a kit's blueprint saying `drawings/cauldron.pxart` keeps meaning the
33
+ kit's drawing once the kit is imported by someone else.
34
+ - `@imports/<alias>/drawings/ship.pxart` -- a file of the deck imported under
35
+ `<alias>`. This is the only way to name another deck's file, so cross-deck
36
+ references are visible as such, and it means the same thing from any file at
37
+ any depth.
38
+
39
+ Imports are read-only: their files belong to the deck they came from. Use them,
40
+ don't edit them. `castle-web add-import <deckId>` adds one, `update-import`
41
+ re-fetches it. `resolveDeckFile` from `castle-web-sdk` is the rule itself, if a
42
+ behavior needs to resolve a path it was handed.
43
+
24
44
  ## Scope
25
45
 
26
46
  Write the smallest game that satisfies what the user asked for. No sound, particles, menus, multi-level progression, or visual polish unless they specifically asked for it. A typical behavior is 30–80 lines — if yours is hitting 200, you're over-engineering: cut feel-good extras, fewer fields on props, fewer edge cases, fewer comments. Ship the core loop first; the user can ask for more.
@@ -1,3 +1,4 @@
1
+ import { resolveDeckFile } from 'castle-web-sdk';
1
2
  import React from 'react';
2
3
  import { frameCount, renderSpriteFrame } from '../engine/pxart';
3
4
  import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
@@ -37,7 +38,11 @@ export class Sprite {
37
38
  }
38
39
 
39
40
  resolveSprite(scene) {
40
- return scene.sprites?.[this.props.file] ?? scene.sprites?.[FALLBACK_FILE] ?? null;
41
+ // `drawings/x.pxart` is this deck's; `@imports/<alias>/drawings/x.pxart` is
42
+ // an import's. (A value inherited from an imported blueprint arrives already
43
+ // resolved against that blueprint's deck -- see engine/blueprint.js.)
44
+ const file = resolveDeckFile(this.props.file);
45
+ return scene.sprites?.[file] ?? scene.sprites?.[FALLBACK_FILE] ?? null;
41
46
  }
42
47
 
43
48
  update(actor, scene, dt) {
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import { renderSpriteFrame } from '../engine/pxart';
3
3
  import { screenToCard } from '../engine/scene';
4
- import { countBlueprintInstances, listBlueprints } from '../engine/blueprint';
4
+ import { countBlueprintInstances, isImportedPath, listBlueprints } from '../engine/blueprint';
5
5
  import { ConfirmDialog, ContextMenu, cx, Icon, styles } from '../engine/ui';
6
6
 
7
7
  const DRAG_THRESHOLD = 4;
@@ -157,13 +157,19 @@ export function BlueprintLibrary({
157
157
  x={contextMenu.x}
158
158
  y={contextMenu.y}
159
159
  onClose={() => setContextMenu(null)}
160
- items={[
161
- {
162
- key: 'delete',
163
- label: `Delete ${contextMenu.blueprint.name}...`,
164
- onClick: () => setConfirmDelete(contextMenu.blueprint),
165
- },
166
- ]}
160
+ items={
161
+ // An imported blueprint belongs to the deck it came from: placeable
162
+ // here, but there is nothing this deck can delete.
163
+ isImportedPath(contextMenu.blueprint.path)
164
+ ? [{ key: 'imported', label: 'From an import (read-only)', disabled: true }]
165
+ : [
166
+ {
167
+ key: 'delete',
168
+ label: `Delete ${contextMenu.blueprint.name}...`,
169
+ onClick: () => setConfirmDelete(contextMenu.blueprint),
170
+ },
171
+ ]
172
+ }
167
173
  />
168
174
  ) : null}
169
175
  {confirmDelete ? (
@@ -3,9 +3,15 @@
3
3
  // picked up on the next reload/restart. The physics glob is what lets the
4
4
  // self-contained physics module ship its behaviors without polluting the core
5
5
  // `behaviors/` folder — a kit adopts physics by copying `physics/` in.
6
+ // Root-anchored so the kit finds the DECK's behaviors even when the kit is
7
+ // itself an import (see engine/files.js). Imports are swept first and the deck's
8
+ // own behaviors last: collectBehaviors keys by behaviorName, so a behavior the
9
+ // deck defines wins over one of the same name from a dependency.
6
10
  const modules = {
7
- ...import.meta.glob('../behaviors/*.jsx', { eager: true }),
8
- ...import.meta.glob('../physics/behaviors/*.jsx', { eager: true }),
11
+ ...import.meta.glob('/imports/*/behaviors/*.jsx', { eager: true }),
12
+ ...import.meta.glob('/imports/*/physics/behaviors/*.jsx', { eager: true }),
13
+ ...import.meta.glob('/behaviors/*.jsx', { eager: true }),
14
+ ...import.meta.glob('/physics/behaviors/*.jsx', { eager: true }),
9
15
  };
10
16
  function isBehaviorClass(value) {
11
17
  return typeof value === 'function' && typeof value.behaviorName === 'string';
@@ -14,7 +14,11 @@
14
14
  // already renders leftover defaultProps generically, so registered fields show
15
15
  // up with no inspector change. Empty in a kit with no `*/extensions/` dir (e.g.
16
16
  // basic-2d). Symmetric with engine/systemRegistry.js and editors/behaviorRegistry.js.
17
- const modules = import.meta.glob('../*/extensions/*.js', { eager: true });
17
+ // Root-anchored, deck + imports (see engine/files.js).
18
+ const modules = {
19
+ ...import.meta.glob('/imports/*/*/extensions/*.js', { eager: true }),
20
+ ...import.meta.glob('/*/extensions/*.js', { eager: true }),
21
+ };
18
22
  const extensions = Object.values(modules)
19
23
  .map((mod) => mod.behaviorExtension)
20
24
  .filter(Boolean);
@@ -6,6 +6,7 @@
6
6
  // panel propagates to every scene that places it. Shared by the runtime
7
7
  // (`scene.js`, so play mode and `PlayOnly` resolve blueprints too) and the
8
8
  // editors (which also mint/fork/migrate/cascade-delete blueprint files).
9
+ import { resolveDeckFile } from 'castle-web-sdk';
9
10
  import { formatJson } from './files';
10
11
  import { DEFAULT_RESOLUTION, TRANSPARENT, serializeCompact } from './pxart';
11
12
  import { cardSize, dedupeActorId } from './scene';
@@ -13,8 +14,19 @@ import { cardSize, dedupeActorId } from './scene';
13
14
  export const BLUEPRINTS_DIR = 'blueprints';
14
15
  export const DRAWINGS_DIR = 'drawings';
15
16
 
17
+ // A blueprint of this deck (`blueprints/x.scene`) OR of one it imports
18
+ // (`imports/<alias>/blueprints/x.scene`) -- an imported deck's blueprints are
19
+ // placeable like any other, which is most of the point of importing one.
20
+ const BLUEPRINT_PATH_RE = new RegExp(`^(imports/[^/]+/)?${BLUEPRINTS_DIR}/`);
21
+
16
22
  export function isBlueprintPath(path) {
17
- return typeof path === 'string' && path.startsWith(`${BLUEPRINTS_DIR}/`) && path.endsWith('.scene');
23
+ return typeof path === 'string' && path.endsWith('.scene') && BLUEPRINT_PATH_RE.test(path);
24
+ }
25
+
26
+ // Blueprints an import owns: placeable, but not editable or deletable from here
27
+ // (they belong to the deck they came from).
28
+ export function isImportedPath(path) {
29
+ return typeof path === 'string' && path.startsWith('imports/');
18
30
  }
19
31
 
20
32
  // Per-property inherit metadata for a behavior prop. Behaviors opt a prop out
@@ -49,12 +61,33 @@ function parseBlueprintText(text) {
49
61
  return { name, components: actor.components };
50
62
  }
51
63
 
64
+ // A reference names a file the way its own deck sees it -- `drawings/rock.pxart`
65
+ // for one of its own, `@imports/<alias>/...` for one of a deck it imports (the
66
+ // SDK's resolveDeckFile is the single definition of that rule). Templates are
67
+ // resolved against the file they were written in, so a kit's blueprint keeps
68
+ // meaning the kit's drawing after the kit is imported by somebody else.
69
+ function resolveTemplateRefs(template, files, fromPath) {
70
+ if (!template) return template;
71
+ for (const props of Object.values(template.components ?? {})) {
72
+ for (const [key, value] of Object.entries(props ?? {})) {
73
+ if (typeof value !== 'string' || !value) continue;
74
+ const resolved = resolveDeckFile(value, fromPath);
75
+ if (resolved !== value && files?.[resolved] !== undefined) props[key] = resolved;
76
+ }
77
+ }
78
+ return template;
79
+ }
80
+
52
81
  // Resolve a blueprint's template from the LIVE files map -- the load-bearing
53
82
  // call for live propagation. Returns null when the file is missing, deleted,
54
83
  // or malformed (a dangling `blueprint` ref degrades to "no template" rather
55
84
  // than throwing).
56
85
  export function getBlueprintTemplate(files, blueprintPath) {
57
- return parseBlueprintText(files?.[blueprintPath]);
86
+ // The ref may name this deck's blueprint or an import's; the template's own
87
+ // refs then resolve against whichever deck the blueprint turned out to be in.
88
+ const key = resolveDeckFile(blueprintPath);
89
+ const template = parseBlueprintText(files?.[key]);
90
+ return resolveTemplateRefs(template, files, key);
58
91
  }
59
92
 
60
93
  // Every blueprint currently in the deck, for the library/hotbar. Skips
@@ -63,7 +96,10 @@ export function listBlueprints(files) {
63
96
  const out = [];
64
97
  for (const path of Object.keys(files ?? {})) {
65
98
  if (!isBlueprintPath(path)) continue;
66
- const template = parseBlueprintText(files[path]);
99
+ // Same origin resolution getBlueprintTemplate does: an imported blueprint's
100
+ // `drawings/x.pxart` means the import's drawing, and the library previews
101
+ // these templates -- unresolved, the slot renders with no art.
102
+ const template = resolveTemplateRefs(parseBlueprintText(files[path]), files, path);
67
103
  if (!template) continue;
68
104
  out.push({ path, name: template.name, components: template.components });
69
105
  }
@@ -1,12 +1,33 @@
1
1
  // Seed the file map by scanning the deck dir. Vite module-cache is invalidated
2
2
  // on restart, so a newly-created file just shows up on the next reload.
3
- const rawModules = import.meta.glob(
4
- ['../scenes/*.scene', '../blueprints/*.scene', '../drawings/*.pxart', '../behaviors/*.jsx'],
5
- { query: '?raw', import: 'default', eager: true }
6
- );
3
+ // Anchored at the DECK ROOT (`/scenes/...`) rather than relative to this module,
4
+ // because the kit itself may be an import: living at `imports/<alias>/`, a
5
+ // `../scenes/*.scene` would find the KIT's scenes instead of the deck's. Anchored
6
+ // at the root, one pattern works whether the kit is the deck or a dependency.
7
+ //
8
+ // The first glob adds every import's content, keyed by its full path
9
+ // (`imports/<alias>/drawings/rock.pxart`) -- which is exactly how a scene or
10
+ // blueprint refers to a dependency's file, so no lookup elsewhere changes.
11
+ // (Patterns and options must be literals: import.meta.glob is a compile-time
12
+ // transform, so these can't be hoisted into shared constants.)
13
+ const rawModules = {
14
+ ...import.meta.glob(
15
+ [
16
+ '/imports/*/scenes/*.scene',
17
+ '/imports/*/blueprints/*.scene',
18
+ '/imports/*/drawings/*.pxart',
19
+ '/imports/*/behaviors/*.jsx',
20
+ ],
21
+ { query: '?raw', import: 'default', eager: true }
22
+ ),
23
+ ...import.meta.glob(
24
+ ['/scenes/*.scene', '/blueprints/*.scene', '/drawings/*.pxart', '/behaviors/*.jsx'],
25
+ { query: '?raw', import: 'default', eager: true }
26
+ ),
27
+ };
7
28
  export const initialFiles = Object.fromEntries(
8
29
  Object.entries(rawModules)
9
- .map(([globPath, text]) => [globPath.replace(/^\.\.\//, ''), text])
30
+ .map(([globPath, text]) => [globPath.replace(/^\//, ''), text])
10
31
  .sort(([a], [b]) => a.localeCompare(b))
11
32
  );
12
33
  export function getFileKind(path) {
@@ -1,3 +1,4 @@
1
+ import { resolveDeckFile } from 'castle-web-sdk';
1
2
  import { initialFiles, parseJsonFile } from './files';
2
3
  import { getBlueprintTemplate, mergeComponents } from './blueprint';
3
4
  import { getColliderRect, intersects, spriteIsEmpty } from './collider';
@@ -18,7 +19,9 @@ export const cardSize = { width: CARD_WIDTH, height: CARD_HEIGHT };
18
19
  // 'main.scene', 'scenes/main.scene', './scenes/main.scene', or 'main' -- the
19
20
  // `.scene` extension and a leading `scenes/` are both optional.
20
21
  function resolveSceneFileKey(name) {
21
- const key = String(name).replace(/^\.?\//, '');
22
+ // `@imports/<alias>/scenes/x.scene` names an import's scene; anything else is
23
+ // this deck's, with the `scenes/` folder and `.scene` suffix both optional.
24
+ const key = resolveDeckFile(String(name));
22
25
  const candidates = [key];
23
26
  if (!key.endsWith('.scene')) candidates.push(`${key}.scene`);
24
27
  for (const candidate of [...candidates]) {
@@ -6,7 +6,11 @@
6
6
  // updates. Empty in a kit with no `systems/` dir (e.g. basic-2d); a kit adds a
7
7
  // system by dropping a file here -- no edits to the engine required. Symmetric
8
8
  // with editors/behaviorRegistry.js.
9
- const modules = import.meta.glob('../systems/*.js', { eager: true });
9
+ // Root-anchored, deck + imports (see engine/files.js).
10
+ const modules = {
11
+ ...import.meta.glob('/imports/*/systems/*.js', { eager: true }),
12
+ ...import.meta.glob('/systems/*.js', { eager: true }),
13
+ };
10
14
  export const systemInstallers = Object.values(modules)
11
15
  .map((mod) => mod.installSystem)
12
16
  .filter((fn) => typeof fn === 'function');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.92",
3
+ "version": "0.4.93",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
package/dist/pull.d.ts DELETED
@@ -1,4 +0,0 @@
1
- export declare function pull(dir: string, options?: {
2
- deckId?: string;
3
- force?: boolean;
4
- }): Promise<void>;
package/dist/pull.js DELETED
@@ -1,119 +0,0 @@
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
- }