castle-web-cli 0.4.91 → 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.
- package/dist/agent.js +141 -22
- package/dist/api.d.ts +8 -0
- package/dist/api.js +10 -0
- package/dist/bundle.js +2 -2
- package/dist/get-deck.d.ts +1 -1
- package/dist/get-deck.js +93 -22
- package/dist/ide.js +123 -15
- package/dist/imports.d.ts +22 -0
- package/dist/imports.js +549 -0
- package/dist/index.js +45 -15
- package/dist/init.js +169 -1
- package/dist/install.d.ts +1 -1
- package/dist/install.js +14 -1
- package/dist/metering.d.ts +1 -0
- package/dist/metering.js +1 -1
- package/dist/native/loop.js +1 -0
- package/dist/native/openrouter.d.ts +1 -0
- package/dist/native/openrouter.js +13 -6
- package/dist/native/types.d.ts +1 -0
- package/dist/normalize.js +4 -0
- package/dist/openrouter-catalog.d.ts +3 -1
- package/dist/openrouter-catalog.js +15 -10
- package/dist/save-deck.d.ts +2 -0
- package/dist/save-deck.js +25 -19
- package/dist/serve.js +2 -2
- package/dist/shell/assets/{index-DSIr52Kl.css → index-CWNH9QiB.css} +1 -1
- package/dist/shell/assets/{index-BFCG4tLs.js → index-_C2BvstY.js} +21 -21
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.d.ts +1 -0
- package/dist/vitePlugins.js +33 -0
- package/kits/basic-2d/CLAUDE.md +20 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +6 -1
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +14 -8
- package/kits/basic-2d/editors/SceneEditor.jsx +39 -6
- package/kits/basic-2d/editors/behaviorRegistry.js +8 -2
- package/kits/basic-2d/engine/behaviorExtensions.js +5 -1
- package/kits/basic-2d/engine/blueprint.js +39 -3
- package/kits/basic-2d/engine/files.js +26 -5
- package/kits/basic-2d/engine/scene.js +14 -3
- package/kits/basic-2d/engine/systemRegistry.js +5 -1
- package/kits/physics-2d/behaviors/Collider.jsx +190 -153
- package/kits/physics-2d/editors/SceneEditor.jsx +42 -6
- package/kits/physics-2d/editors/SelectionOverlay.jsx +57 -28
- package/kits/physics-2d/engine/collider.js +123 -105
- package/kits/physics-2d/engine/scene.js +10 -2
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +3 -0
- package/kits/physics-2d/physics/matterBridge.js +73 -24
- package/package.json +1 -1
- package/dist/pull.d.ts +0 -4
- package/dist/pull.js +0 -119
- package/kits/physics-2d/engine/behaviorExtensions.js +0 -28
- package/kits/physics-2d/physics/extensions/collider.js +0 -15
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
389
|
-
:
|
|
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
|
|
795
|
-
//
|
|
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
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
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:
|
|
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
|
|
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: {
|
package/dist/get-deck.d.ts
CHANGED
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
|
-
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
13
25
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
const deckId = options.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
|
-
|
|
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(
|
|
138
|
+
console.log(`${refreshing ? 'Updated' : 'Extracted to'} ${targetDir}`);
|
|
68
139
|
}
|