castle-web-cli 0.4.169 → 0.4.170
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 +23 -13
- package/dist/atomicFile.d.ts +29 -0
- package/dist/atomicFile.js +120 -0
- package/dist/castle-host/host.js +37 -0
- package/dist/get-deck.js +2 -11
- package/dist/headlessCover.d.ts +4 -0
- package/dist/headlessCover.js +7 -5
- package/dist/ide.js +14 -6
- package/dist/index.js +10 -6
- package/dist/init.d.ts +1 -1
- package/dist/init.js +11 -1
- package/dist/remix.js +2 -14
- package/dist/save-deck.d.ts +3 -0
- package/dist/save-deck.js +40 -73
- package/dist/saveCover.d.ts +18 -0
- package/dist/saveCover.js +61 -0
- package/dist/serve.js +18 -5
- package/dist/serveSecurity.d.ts +11 -1
- package/dist/serveSecurity.js +38 -4
- package/dist/shell/assets/index-BvQmVwlO.css +1 -0
- package/dist/shell/assets/index-CV5sBby1.js +445 -0
- package/dist/shell/index.html +2 -2
- package/dist/versionStore.js +13 -4
- package/kits/base/CLAUDE.md +9 -0
- package/kits/base/castle.json +16 -11
- package/kits/base/sdk/README.md +66 -1
- package/kits/base/sdk/commands.d.ts +23 -1
- package/kits/base/sdk/commands.js +9 -0
- package/kits/base/sdk/lifecycle.d.ts +20 -0
- package/kits/base/sdk/lifecycle.js +7 -1
- package/kits/base/sdk/resumeState.d.ts +2 -0
- package/kits/base/sdk/resumeState.js +84 -0
- package/kits/base/sdk/runtime.js +65 -0
- package/kits/base/sdk/transport.d.ts +3 -0
- package/kits/base/sdk/transport.js +39 -5
- package/kits/base/sdk/unloadHandshake.d.ts +6 -0
- package/kits/base/sdk/unloadHandshake.js +47 -0
- package/kits/multiplayer-2d/castle.json +3 -3
- package/kits/multiplayer-2d/package-lock.json +0 -27
- package/kits/multiplayer-2d/package.json +0 -1
- package/kits/multiplayer-3d/castle.json +4 -4
- package/kits/multiplayer-3d/package-lock.json +0 -27
- package/kits/multiplayer-3d/package.json +0 -1
- package/kits/physics-2d/CLAUDE.md +75 -43
- package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
- package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
- package/kits/physics-2d/castle.json +6 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +83 -180
- package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
- package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
- package/kits/physics-2d/editors/overlayCanvas.js +77 -0
- package/kits/physics-2d/editors/pathOverlay.js +5 -13
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
- package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
- package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
- package/kits/physics-2d/editors/styleEditor.module.css +23 -4
- package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
- package/kits/physics-2d/engine/SceneUI.jsx +6 -12
- package/kits/physics-2d/engine/icons.js +56 -0
- package/kits/physics-2d/engine/panelSorting.jsx +7 -14
- package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
- package/kits/physics-2d/engine/physics/controls.js +76 -12
- package/kits/physics-2d/engine/scene.js +47 -9
- package/kits/physics-2d/engine/ui.jsx +13 -125
- package/kits/physics-2d/engine/ui.module.css +53 -28
- package/kits/physics-2d/package-lock.json +0 -29
- package/kits/physics-2d/package.json +3 -1
- package/kits/physics-2d/pnpm-lock.yaml +1 -17
- package/kits/physics-3d/castle.json +5 -5
- package/kits/physics-3d/package-lock.json +0 -29
- package/kits/physics-3d/package.json +0 -1
- package/kits/physics-3d/pnpm-lock.yaml +0 -17
- package/kits/turn-based/castle.json +3 -3
- package/kits/turn-based/package-lock.json +0 -27
- package/kits/turn-based/package.json +0 -1
- package/package.json +5 -4
- package/dist/shell/assets/index-CVHj503j.css +0 -1
- package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
package/dist/agent.js
CHANGED
|
@@ -24,6 +24,7 @@ import * as path from 'path';
|
|
|
24
24
|
import { nanoid } from 'nanoid';
|
|
25
25
|
import { WebSocketServer } from 'ws';
|
|
26
26
|
import { rawDataToString } from './rawData.js';
|
|
27
|
+
import { atomicWriteFileSync } from './atomicFile.js';
|
|
27
28
|
import { AGENT_ATTACHMENT_PREFIX, AGENT_PLAYTEST_PREFIX, PLAN_FILE } from './localPaths.js';
|
|
28
29
|
import { applyPlanOps, buildRouterPrompt, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
|
|
29
30
|
import { readCastleJson } from './castleJson.js';
|
|
@@ -2218,7 +2219,7 @@ async function runAgentTurn(opts) {
|
|
|
2218
2219
|
}
|
|
2219
2220
|
// -- task store ---------------------------------------------------------------
|
|
2220
2221
|
function persistTaskFile(tasksDir, task) {
|
|
2221
|
-
|
|
2222
|
+
atomicWriteFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
|
|
2222
2223
|
}
|
|
2223
2224
|
// The task index: one row per task this deck has ever spawned, addressed by the
|
|
2224
2225
|
// plan item it advanced. It is the complete address book (step 2 searches it by
|
|
@@ -2261,18 +2262,17 @@ function writeTaskIndex(tasksDir, tasks) {
|
|
|
2261
2262
|
catch {
|
|
2262
2263
|
/* no index yet -- writing it below is the change */
|
|
2263
2264
|
}
|
|
2264
|
-
const tmp = `${file}.tmp`;
|
|
2265
2265
|
try {
|
|
2266
2266
|
// Written aside and renamed: a running task agent may be reading this file
|
|
2267
2267
|
// at any moment, and a torn read is a claims board with rows missing.
|
|
2268
|
-
|
|
2269
|
-
fs.renameSync(tmp, file);
|
|
2268
|
+
atomicWriteFileSync(file, body);
|
|
2270
2269
|
}
|
|
2271
2270
|
catch (err) {
|
|
2272
2271
|
console.error(`[tasks] could not write ${TASK_INDEX_FILE}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2273
2272
|
}
|
|
2274
2273
|
}
|
|
2275
|
-
// Tasks left "running" by a dead serve are as finished as they
|
|
2274
|
+
// Tasks left "running" or "waiting" by a dead serve are as finished as they
|
|
2275
|
+
// will get. They are interrupted rather than scheduled again on boot. A
|
|
2276
2276
|
// persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
|
|
2277
2277
|
// never reconsiders it and it can't wedge or auto-start; it just sits on the
|
|
2278
2278
|
// board (blockedBy intact) until the router stops it, same as before restart.
|
|
@@ -2282,7 +2282,7 @@ function loadTasks(tasksDir) {
|
|
|
2282
2282
|
const rec = readJsonFile(path.join(tasksDir, entry, 'task.json'));
|
|
2283
2283
|
if (!rec)
|
|
2284
2284
|
continue;
|
|
2285
|
-
if (rec.status === 'running') {
|
|
2285
|
+
if (rec.status === 'running' || rec.status === 'waiting') {
|
|
2286
2286
|
rec.status = 'interrupted';
|
|
2287
2287
|
rec.updatedAt = nowIso();
|
|
2288
2288
|
persistTaskFile(tasksDir, rec);
|
|
@@ -2624,9 +2624,9 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2624
2624
|
// (by skipping the slot free) nor crash the serve.
|
|
2625
2625
|
function startTask(ctx, task) {
|
|
2626
2626
|
const dir = path.join(ctx.tasksDir, task.id);
|
|
2627
|
-
|
|
2627
|
+
atomicWriteFileSync(path.join(dir, 'progress'), '0\n');
|
|
2628
2628
|
if (!fs.existsSync(path.join(dir, 'notes.md')))
|
|
2629
|
-
|
|
2629
|
+
atomicWriteFileSync(path.join(dir, 'notes.md'), '');
|
|
2630
2630
|
task.status = 'running';
|
|
2631
2631
|
task.startedAt = nowIso();
|
|
2632
2632
|
ctx.touch(task);
|
|
@@ -3001,7 +3001,7 @@ function saveAttachments(attachmentsDir, messageId, images) {
|
|
|
3001
3001
|
const fileName = `${messageId}-${index}.${ext}`;
|
|
3002
3002
|
try {
|
|
3003
3003
|
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
3004
|
-
|
|
3004
|
+
atomicWriteFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], 'base64'));
|
|
3005
3005
|
saved.push(fileName);
|
|
3006
3006
|
}
|
|
3007
3007
|
catch {
|
|
@@ -3148,7 +3148,7 @@ function createMessageLog(messagesPath, broadcast, welcomeMessage, forClient) {
|
|
|
3148
3148
|
.filter((m) => m.text.trim() !== '' || m.role === 'user')
|
|
3149
3149
|
.map((m) => (m.status === 'streaming' ? { ...m, status: 'done' } : m));
|
|
3150
3150
|
function persist() {
|
|
3151
|
-
|
|
3151
|
+
atomicWriteFileSync(messagesPath, JSON.stringify(messages, null, 2) + '\n');
|
|
3152
3152
|
}
|
|
3153
3153
|
if (messages.length === 0) {
|
|
3154
3154
|
messages.push({
|
|
@@ -3624,7 +3624,7 @@ function applyAgentSettings(incoming, ctx) {
|
|
|
3624
3624
|
}
|
|
3625
3625
|
if (changes.length === 0)
|
|
3626
3626
|
return;
|
|
3627
|
-
|
|
3627
|
+
atomicWriteFileSync(ctx.settingsPath, serializeAgentSettings(settings));
|
|
3628
3628
|
// The value is saved and broadcast IMMEDIATELY -- validation never gates a
|
|
3629
3629
|
// write. The verdict follows in a second frame once the catalog answers.
|
|
3630
3630
|
ctx.broadcast({ type: 'settings', settings });
|
|
@@ -3860,7 +3860,7 @@ function reclaimInFlightSends(pendingSends, inFlightSends) {
|
|
|
3860
3860
|
// Mirror the in-memory queue to disk. Called on every mutation (enqueue,
|
|
3861
3861
|
// drain, cancel, recover) so a restart never loses an unsent queued message.
|
|
3862
3862
|
function persistPendingSends(ctx) {
|
|
3863
|
-
|
|
3863
|
+
atomicWriteFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + '\n');
|
|
3864
3864
|
}
|
|
3865
3865
|
function computeQueuedSnippets(pendingSends) {
|
|
3866
3866
|
return pendingSends.map((p) => p.text.trim()).filter(Boolean);
|
|
@@ -4028,7 +4028,17 @@ function handleQueueUserMessage(ctx, text, images, clientId) {
|
|
|
4028
4028
|
// The id is the SENDER's when it supplied one: the client shows the message
|
|
4029
4029
|
// the moment you hit send, and reusing that row's id means the echo lands on
|
|
4030
4030
|
// the same row rather than beside it as a duplicate.
|
|
4031
|
-
const
|
|
4031
|
+
const sentId = safeClientMessageId(clientId);
|
|
4032
|
+
// seenClientIds dies with the process; the client's stored copy of an
|
|
4033
|
+
// unacknowledged message does not, so a resend can outlive a restart and
|
|
4034
|
+
// arrive at a serve that never witnessed the first delivery. The log and
|
|
4035
|
+
// the queue did: either holding the id means this send already landed.
|
|
4036
|
+
if (sentId !== null &&
|
|
4037
|
+
(ctx.messages.some((m) => m.id === sentId) ||
|
|
4038
|
+
state.pendingSends.some((p) => p.id === sentId))) {
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4041
|
+
const id = sentId ?? nanoid(8);
|
|
4032
4042
|
const attachments = saveAttachments(ctx.attachmentsDir, id, images);
|
|
4033
4043
|
state.pendingSends.push({ id, text, attachments });
|
|
4034
4044
|
persistPendingSends(ctx);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
export interface AtomicFileOps {
|
|
3
|
+
closeSync(fd: number): void;
|
|
4
|
+
fchmodSync(fd: number, mode: number): void;
|
|
5
|
+
fsyncSync(fd: number): void;
|
|
6
|
+
linkSync(existingPath: string, newPath: string): void;
|
|
7
|
+
lstatSync(file: string): fs.Stats;
|
|
8
|
+
openSync(file: string, flags: number, mode?: number): number;
|
|
9
|
+
renameSync(oldPath: string, newPath: string): void;
|
|
10
|
+
unlinkSync(file: string): void;
|
|
11
|
+
writeFileSync(file: number, data: string | NodeJS.ArrayBufferView): void;
|
|
12
|
+
}
|
|
13
|
+
export interface AtomicWriteOptions {
|
|
14
|
+
/** Installed mode. Existing destinations keep their mode when omitted. */
|
|
15
|
+
mode?: number;
|
|
16
|
+
/** Install only when the destination does not already exist. */
|
|
17
|
+
exclusive?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Reserved sibling temporary names that checkpoint capture must exclude. */
|
|
20
|
+
export declare const ATOMIC_TEMP_FILE_RE: RegExp;
|
|
21
|
+
/**
|
|
22
|
+
* Replace a regular file without exposing partial bytes to readers.
|
|
23
|
+
*
|
|
24
|
+
* The temporary is unique, exclusively created beside the destination, synced
|
|
25
|
+
* before rename, and cannot follow a pre-planted temporary symlink. The parent
|
|
26
|
+
* directory is synced after rename so the new name is durable.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createAtomicFileWriter(ops: AtomicFileOps): (file: string, content: string | NodeJS.ArrayBufferView, options?: AtomicWriteOptions) => void;
|
|
29
|
+
export declare const atomicWriteFileSync: (file: string, content: string | NodeJS.ArrayBufferView, options?: AtomicWriteOptions) => void;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
function lstatIfPresent(ops, file) {
|
|
5
|
+
try {
|
|
6
|
+
return ops.lstatSync(file);
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (error.code === 'ENOENT')
|
|
10
|
+
return null;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function checkedDestination(ops, file) {
|
|
15
|
+
const stat = lstatIfPresent(ops, file);
|
|
16
|
+
if (stat?.isSymbolicLink())
|
|
17
|
+
throw new Error(`Refusing to replace symlink: ${file}`);
|
|
18
|
+
return stat;
|
|
19
|
+
}
|
|
20
|
+
function uniqueTempPath(file) {
|
|
21
|
+
return path.join(path.dirname(file), `.castle-atomic-${process.pid}-${crypto.randomBytes(12).toString('hex')}.tmp`);
|
|
22
|
+
}
|
|
23
|
+
/** Reserved sibling temporary names that checkpoint capture must exclude. */
|
|
24
|
+
export const ATOMIC_TEMP_FILE_RE = /^\.castle-atomic-[1-9]\d*-[0-9a-f]{24}\.tmp$/;
|
|
25
|
+
/**
|
|
26
|
+
* Replace a regular file without exposing partial bytes to readers.
|
|
27
|
+
*
|
|
28
|
+
* The temporary is unique, exclusively created beside the destination, synced
|
|
29
|
+
* before rename, and cannot follow a pre-planted temporary symlink. The parent
|
|
30
|
+
* directory is synced after rename so the new name is durable.
|
|
31
|
+
*/
|
|
32
|
+
export function createAtomicFileWriter(ops) {
|
|
33
|
+
return function atomicWriteFileSync(file, content, options = {}) {
|
|
34
|
+
const existing = checkedDestination(ops, file);
|
|
35
|
+
if (existing && !existing.isFile())
|
|
36
|
+
throw new Error(`Refusing to replace non-file: ${file}`);
|
|
37
|
+
const mode = options.mode ?? (existing ? existing.mode & 0o777 : 0o666);
|
|
38
|
+
const setExactMode = options.mode !== undefined || existing !== null;
|
|
39
|
+
const temp = uniqueTempPath(file);
|
|
40
|
+
let tempExists = false;
|
|
41
|
+
let fd = null;
|
|
42
|
+
let directoryFd = null;
|
|
43
|
+
let failure;
|
|
44
|
+
try {
|
|
45
|
+
const flags = fs.constants.O_WRONLY |
|
|
46
|
+
fs.constants.O_CREAT |
|
|
47
|
+
fs.constants.O_EXCL |
|
|
48
|
+
(fs.constants.O_NOFOLLOW ?? 0);
|
|
49
|
+
fd = ops.openSync(temp, flags, mode);
|
|
50
|
+
tempExists = true;
|
|
51
|
+
ops.writeFileSync(fd, content);
|
|
52
|
+
// A new file with no requested mode keeps the mode produced by open +
|
|
53
|
+
// process umask. Replacements preserve their old mode, and explicit
|
|
54
|
+
// modes (the version store's 0444 files) are exact.
|
|
55
|
+
if (setExactMode)
|
|
56
|
+
ops.fchmodSync(fd, mode);
|
|
57
|
+
ops.fsyncSync(fd);
|
|
58
|
+
ops.closeSync(fd);
|
|
59
|
+
fd = null;
|
|
60
|
+
// Catch ordinary accidental symlink replacement. The containing
|
|
61
|
+
// directory must still be trusted against a hostile rename race.
|
|
62
|
+
checkedDestination(ops, file);
|
|
63
|
+
if (options.exclusive) {
|
|
64
|
+
// link(2) is the portable no-replace primitive: exactly one competing
|
|
65
|
+
// writer can install this name. The temporary is then removed while
|
|
66
|
+
// the installed hard link keeps the fully synced inode.
|
|
67
|
+
ops.linkSync(temp, file);
|
|
68
|
+
ops.unlinkSync(temp);
|
|
69
|
+
tempExists = false;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
ops.renameSync(temp, file);
|
|
73
|
+
tempExists = false;
|
|
74
|
+
}
|
|
75
|
+
// Windows does not permit opening directories this way. The rename is
|
|
76
|
+
// still atomic there, but only Unix deployments get directory-entry
|
|
77
|
+
// durability across a power loss.
|
|
78
|
+
if (process.platform !== 'win32') {
|
|
79
|
+
directoryFd = ops.openSync(path.dirname(file), fs.constants.O_RDONLY);
|
|
80
|
+
ops.fsyncSync(directoryFd);
|
|
81
|
+
ops.closeSync(directoryFd);
|
|
82
|
+
directoryFd = null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
failure = error;
|
|
87
|
+
}
|
|
88
|
+
if (fd !== null) {
|
|
89
|
+
try {
|
|
90
|
+
ops.closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
failure ??= error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (directoryFd !== null) {
|
|
97
|
+
try {
|
|
98
|
+
ops.closeSync(directoryFd);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
failure ??= error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (tempExists) {
|
|
105
|
+
try {
|
|
106
|
+
ops.unlinkSync(temp);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error.code !== 'ENOENT')
|
|
110
|
+
failure ??= error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (failure) {
|
|
114
|
+
throw failure instanceof Error
|
|
115
|
+
? failure
|
|
116
|
+
: new Error('Atomic file operation failed', { cause: failure });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export const atomicWriteFileSync = createAtomicFileWriter(fs);
|
package/dist/castle-host/host.js
CHANGED
|
@@ -39,6 +39,7 @@ const COMMAND_NAMES = [
|
|
|
39
39
|
"cauldronStorage.boardSubmit",
|
|
40
40
|
"cauldronStorage.boardTop",
|
|
41
41
|
"cauldronStorage.boardGet",
|
|
42
|
+
"lifecycle.restoreState",
|
|
42
43
|
];
|
|
43
44
|
// Platform/capability commands: NOT serviced by graphqlFetch. They're dispatched
|
|
44
45
|
// to the host's optional platformHandler (mobile renders native UI; web shows an
|
|
@@ -50,6 +51,7 @@ const PLATFORM_COMMAND_NAMES = [
|
|
|
50
51
|
"portal.open",
|
|
51
52
|
"portal.prefetch",
|
|
52
53
|
"haptics.play",
|
|
54
|
+
"lifecycle.restoreState",
|
|
53
55
|
];
|
|
54
56
|
// Mirrors CASTLE_LEADERBOARD_PAYLOAD_VERSION in leaderboardPanel.ts. Duplicated
|
|
55
57
|
// rather than imported because every import here has to stay type-only; bump
|
|
@@ -146,6 +148,7 @@ function runCommand(ctx, command, params, caps) {
|
|
|
146
148
|
case "portal.open":
|
|
147
149
|
case "portal.prefetch":
|
|
148
150
|
case "haptics.play":
|
|
151
|
+
case "lifecycle.restoreState":
|
|
149
152
|
return runPlatformCommand(ctx, command, params, caps);
|
|
150
153
|
}
|
|
151
154
|
}
|
|
@@ -208,6 +211,8 @@ async function runPlatformCommand(ctx, command, params, caps) {
|
|
|
208
211
|
return portalPrefetch(ctx, params, caps);
|
|
209
212
|
case "haptics.play":
|
|
210
213
|
return hapticsPlay(ctx, params, caps);
|
|
214
|
+
case "lifecycle.restoreState":
|
|
215
|
+
return lifecycleRestoreState(ctx, caps);
|
|
211
216
|
default:
|
|
212
217
|
return unavailableOutcome();
|
|
213
218
|
}
|
|
@@ -321,6 +326,38 @@ function normalizeHapticsOutcome(value) {
|
|
|
321
326
|
}
|
|
322
327
|
return { status: "unavailable" };
|
|
323
328
|
}
|
|
329
|
+
// Hand back whatever the host kept for this deck the last time it unloaded it.
|
|
330
|
+
// Unlike pass/portal, an absent deckId is `unavailable` rather than a thrown
|
|
331
|
+
// MISSING_DECK_ID: a deck asking to resume must never have to catch — an unsaved
|
|
332
|
+
// deck, a host with no store, and a host with nothing kept all read the same.
|
|
333
|
+
async function lifecycleRestoreState(ctx, caps) {
|
|
334
|
+
if (!caps.platformHandler || !ctx.deckId)
|
|
335
|
+
return { status: "unavailable" };
|
|
336
|
+
const outcome = await caps.platformHandler("lifecycle.restoreState", { deckId: ctx.deckId }, ctx);
|
|
337
|
+
return normalizeRestoreStateOutcome(outcome);
|
|
338
|
+
}
|
|
339
|
+
function normalizeRestoreStateOutcome(value) {
|
|
340
|
+
const record = typeof value === "object" && value !== null
|
|
341
|
+
? value
|
|
342
|
+
: {};
|
|
343
|
+
const status = record.status;
|
|
344
|
+
const valid = [
|
|
345
|
+
"restored",
|
|
346
|
+
"none",
|
|
347
|
+
"unavailable",
|
|
348
|
+
];
|
|
349
|
+
if (typeof status !== "string" || !valid.includes(status)) {
|
|
350
|
+
return { status: "none" };
|
|
351
|
+
}
|
|
352
|
+
if (status !== "restored") {
|
|
353
|
+
return { status: status };
|
|
354
|
+
}
|
|
355
|
+
// A "restored" with no string state would reach the deck as a resume with
|
|
356
|
+
// nothing to resume from, which JSON.parse would then throw on.
|
|
357
|
+
return typeof record.state === "string"
|
|
358
|
+
? { status: "restored", state: record.state }
|
|
359
|
+
: { status: "none" };
|
|
360
|
+
}
|
|
324
361
|
function unavailableOutcome() {
|
|
325
362
|
return { status: "unavailable" };
|
|
326
363
|
}
|
package/dist/get-deck.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as path from 'path';
|
|
|
4
4
|
import { nanoid } from 'nanoid';
|
|
5
5
|
import * as api from './api.js';
|
|
6
6
|
import { normalizeDeckPackageJson } from './normalize.js';
|
|
7
|
-
import { archiveSource, runTar, SOURCE_ARCHIVE_EXCLUDES } from './save-deck.js';
|
|
7
|
+
import { archiveSource, downloadSourceArchive, runTar, SOURCE_ARCHIVE_EXCLUDES, } from './save-deck.js';
|
|
8
8
|
import { readCastleJson } from './castleJson.js';
|
|
9
9
|
// Refreshing a deck already in the target replaces its source, so a file deleted
|
|
10
10
|
// upstream actually disappears here -- untarring over the old tree would leave it
|
|
@@ -87,16 +87,7 @@ export async function getDeck(dir, options = {}) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
// Everything that can fail over the network happens before anything is deleted.
|
|
90
|
-
|
|
91
|
-
const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
|
|
92
|
-
if (!res.ok) {
|
|
93
|
-
throw new Error(`Archive fetch failed: HTTP ${res.status}`);
|
|
94
|
-
}
|
|
95
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
96
|
-
const sizeKB = buf.length / 1024;
|
|
97
|
-
console.log(`Archive: ${sizeKB.toFixed(1)}KB (updated ${source.updatedAt})`);
|
|
98
|
-
const tmpFile = path.join(os.tmpdir(), `castle-get-${nanoid(8)}.tar.gz`);
|
|
99
|
-
fs.writeFileSync(tmpFile, buf);
|
|
90
|
+
const tmpFile = await downloadSourceArchive(source, 'castle-get');
|
|
100
91
|
try {
|
|
101
92
|
if (refreshing) {
|
|
102
93
|
// A corrupt archive discovered mid-extract would find the old source already
|
package/dist/headlessCover.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type PlaytestBrowserManager } from "./native/playtest-browser.js";
|
|
2
2
|
export interface HeadlessCoverResult {
|
|
3
3
|
ok: boolean;
|
|
4
|
+
outcome?: 'succeeded' | 'timed_out' | 'capture_failed';
|
|
4
5
|
outPath?: string;
|
|
5
6
|
error?: string;
|
|
6
7
|
installedMs?: number;
|
|
@@ -10,4 +11,7 @@ export declare function captureCoverHeadless(opts: {
|
|
|
10
11
|
outPath: string;
|
|
11
12
|
manager?: PlaytestBrowserManager;
|
|
12
13
|
onProgress?: (message: string) => void;
|
|
14
|
+
/** Remaining caller budget after content-grant discovery; defaults to the fixed remote cap. */
|
|
15
|
+
remoteBudgetMs?: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
13
17
|
}): Promise<HeadlessCoverResult>;
|
package/dist/headlessCover.js
CHANGED
|
@@ -54,7 +54,7 @@ const CARD_SELECTOR = "#castle-card, [data-castle-card]";
|
|
|
54
54
|
export async function captureCoverHeadless(opts) {
|
|
55
55
|
const remoteGrant = contentGrantFromBrowserUrl(opts.serveUrl);
|
|
56
56
|
if (remoteGrant) {
|
|
57
|
-
return captureCoverRemote(remoteGrant, opts.outPath);
|
|
57
|
+
return captureCoverRemote(remoteGrant, opts.outPath, opts.remoteBudgetMs ?? REMOTE_CAPTURE_BUDGET_MS, opts.signal);
|
|
58
58
|
}
|
|
59
59
|
// A manager we made is a manager we must close. `withBrowser` deliberately
|
|
60
60
|
// leaves the browser RUNNING so the next call reuses it -- right for the
|
|
@@ -94,8 +94,9 @@ export async function captureCoverHeadless(opts) {
|
|
|
94
94
|
await manager.shutdown().catch(() => undefined);
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
-
async function captureCoverRemote(contentGrant, outPath) {
|
|
98
|
-
const
|
|
97
|
+
async function captureCoverRemote(contentGrant, outPath, budgetMs, callerSignal) {
|
|
98
|
+
const budgetSignal = AbortSignal.timeout(Math.max(1, budgetMs));
|
|
99
|
+
const signal = callerSignal ? AbortSignal.any([budgetSignal, callerSignal]) : budgetSignal;
|
|
99
100
|
try {
|
|
100
101
|
const result = await api.runBrowserJob(contentGrant, { kind: 'cover' }, signal);
|
|
101
102
|
const pngBase64 = result.pngBase64;
|
|
@@ -117,10 +118,11 @@ async function captureCoverRemote(contentGrant, outPath) {
|
|
|
117
118
|
if (signal.aborted) {
|
|
118
119
|
return {
|
|
119
120
|
ok: false,
|
|
120
|
-
|
|
121
|
+
outcome: 'timed_out',
|
|
122
|
+
error: `remote cover gave up after ${budgetMs / 1000}s`,
|
|
121
123
|
};
|
|
122
124
|
}
|
|
123
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
125
|
+
return { ok: false, outcome: 'capture_failed', error: error instanceof Error ? error.message : String(error) };
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
128
|
// Resolve with `work`, or with null if the budget runs out first. The loser is
|
package/dist/ide.js
CHANGED
|
@@ -482,6 +482,15 @@ function handleVersionRestore(deckDir, req, res, restart) {
|
|
|
482
482
|
}
|
|
483
483
|
});
|
|
484
484
|
}
|
|
485
|
+
// A curated deck hides anything not covered by editor.visiblePaths. Writes,
|
|
486
|
+
// moves, and uploads already mkdir parent dirs; they also have to register the
|
|
487
|
+
// dest folder or a new path lands on disk and stays invisible in Files.
|
|
488
|
+
function ensureFileParentVisible(deckDir, rel) {
|
|
489
|
+
const dir = path.posix.dirname(rel);
|
|
490
|
+
if (dir === '.')
|
|
491
|
+
return ensureVisibleGlob(deckDir, rel, rel);
|
|
492
|
+
return ensureVisiblePath(deckDir, dir);
|
|
493
|
+
}
|
|
485
494
|
function handleFilesWrite(deckDir, req, res) {
|
|
486
495
|
withMutationPath(deckDir, req, res, (target, body) => {
|
|
487
496
|
if (typeof body.contents !== 'string') {
|
|
@@ -490,7 +499,8 @@ function handleFilesWrite(deckDir, req, res) {
|
|
|
490
499
|
try {
|
|
491
500
|
fs.mkdirSync(path.dirname(target.abs), { recursive: true });
|
|
492
501
|
fs.writeFileSync(target.abs, body.contents, 'utf8');
|
|
493
|
-
|
|
502
|
+
const visiblePathAdded = ensureFileParentVisible(deckDir, target.rel);
|
|
503
|
+
sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
|
|
494
504
|
}
|
|
495
505
|
catch (err) {
|
|
496
506
|
sendFailure(res, 'write', target.rel, err);
|
|
@@ -571,10 +581,7 @@ function handleFilesUpload(deckDir, req, res) {
|
|
|
571
581
|
catch (err) {
|
|
572
582
|
return sendFailure(res, 'upload', target.rel, err);
|
|
573
583
|
}
|
|
574
|
-
const
|
|
575
|
-
const visiblePathAdded = dir === '.'
|
|
576
|
-
? ensureVisibleGlob(deckDir, target.rel, target.rel)
|
|
577
|
-
: ensureVisiblePath(deckDir, dir);
|
|
584
|
+
const visiblePathAdded = ensureFileParentVisible(deckDir, target.rel);
|
|
578
585
|
sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
|
|
579
586
|
}, (err) => {
|
|
580
587
|
if (err instanceof RangeError)
|
|
@@ -703,7 +710,8 @@ function handleFilesRename(deckDir, req, res) {
|
|
|
703
710
|
try {
|
|
704
711
|
fs.mkdirSync(path.dirname(to.abs), { recursive: true });
|
|
705
712
|
fs.renameSync(from.abs, to.abs);
|
|
706
|
-
|
|
713
|
+
const visiblePathAdded = ensureFileParentVisible(deckDir, to.rel);
|
|
714
|
+
sendJson(res, 200, { ok: true, path: to.rel, visiblePathAdded });
|
|
707
715
|
}
|
|
708
716
|
catch (err) {
|
|
709
717
|
sendFailure(res, 'rename', from.rel, err);
|
package/dist/index.js
CHANGED
|
@@ -62,6 +62,10 @@ function readPositionals() {
|
|
|
62
62
|
}
|
|
63
63
|
return out;
|
|
64
64
|
}
|
|
65
|
+
function readImportFileArgs() {
|
|
66
|
+
const positionals = readPositionals();
|
|
67
|
+
return { deckDir: path.resolve(positionals[1] ?? '.'), relPosix: positionals[0] ?? '' };
|
|
68
|
+
}
|
|
65
69
|
function getFlagValue(flag) {
|
|
66
70
|
const idx = args.indexOf(flag);
|
|
67
71
|
return idx >= 0 ? args[idx + 1] : undefined;
|
|
@@ -89,7 +93,7 @@ function getServeSocketPort(dir) {
|
|
|
89
93
|
}
|
|
90
94
|
function usage() {
|
|
91
95
|
console.log(`Usage:
|
|
92
|
-
castle-web init <dir> [--kit NAME] (kits: physics-2d (default), physics-3d, none)
|
|
96
|
+
castle-web init <dir> [--deck-id ID] [--card-id ID] [--kit NAME] (kits: physics-2d (default), physics-3d, none)
|
|
93
97
|
castle-web init <dir> --remix <deckId> [--deck-id ID] [--card-id ID] (starts from a published deck instead of a kit)
|
|
94
98
|
castle-web serve [dir] [--port PORT] [--host HOST] [--open] [--detach]
|
|
95
99
|
castle-web restart [--port PORT]
|
|
@@ -130,7 +134,7 @@ async function main() {
|
|
|
130
134
|
case 'init': {
|
|
131
135
|
const dir = findPositionalDir();
|
|
132
136
|
if (dir === '.') {
|
|
133
|
-
console.error('Usage: castle-web init <dir> [--kit NAME | --remix DECK_ID]');
|
|
137
|
+
console.error('Usage: castle-web init <dir> [--deck-id ID] [--card-id ID] [--kit NAME | --remix DECK_ID]');
|
|
134
138
|
process.exit(1);
|
|
135
139
|
}
|
|
136
140
|
const kit = getFlagValue('--kit');
|
|
@@ -177,8 +181,8 @@ async function main() {
|
|
|
177
181
|
}
|
|
178
182
|
case 'fork-import-file': {
|
|
179
183
|
// `castle-web fork-import-file <imports/...> [dir]`
|
|
180
|
-
const
|
|
181
|
-
const result = forkImportFile(
|
|
184
|
+
const { deckDir, relPosix } = readImportFileArgs();
|
|
185
|
+
const result = forkImportFile(deckDir, relPosix);
|
|
182
186
|
if (!result.ok) {
|
|
183
187
|
console.error(result.error);
|
|
184
188
|
process.exit(1);
|
|
@@ -187,8 +191,8 @@ async function main() {
|
|
|
187
191
|
break;
|
|
188
192
|
}
|
|
189
193
|
case 'revert-import-file': {
|
|
190
|
-
const
|
|
191
|
-
const result = revertImportFile(
|
|
194
|
+
const { deckDir, relPosix } = readImportFileArgs();
|
|
195
|
+
const result = revertImportFile(deckDir, relPosix);
|
|
192
196
|
if (!result.ok) {
|
|
193
197
|
console.error(result.error);
|
|
194
198
|
process.exit(1);
|
package/dist/init.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare function init(dir: string, opts?: {
|
|
|
19
19
|
serve?: boolean;
|
|
20
20
|
/** Remix this published deck instead of starting from a kit. */
|
|
21
21
|
remixOf?: string;
|
|
22
|
-
/** The identity the server already minted for
|
|
22
|
+
/** The identity the server already minted for this new deck. */
|
|
23
23
|
deckId?: string;
|
|
24
24
|
cardId?: string;
|
|
25
25
|
}): Promise<void>;
|
package/dist/init.js
CHANGED
|
@@ -55,7 +55,7 @@ if (!(await mountEditorRoute())) startGame(initCard());
|
|
|
55
55
|
// Registry version of castle-web-sdk to inject when scaffolding from a
|
|
56
56
|
// globally-installed castle-web (not from inside the workspace). Bumped
|
|
57
57
|
// alongside cli/sdk version bumps.
|
|
58
|
-
const PUBLISHED_SDK_VERSION = '0.4.
|
|
58
|
+
const PUBLISHED_SDK_VERSION = '0.4.27';
|
|
59
59
|
// Range, not a pin: `castle-web-fonts` is its own package precisely so a tenth
|
|
60
60
|
// face can ship without a cli release, and a caret range means an existing
|
|
61
61
|
// deck's next install picks it up. Nothing here has to move when it does.
|
|
@@ -651,6 +651,16 @@ export async function init(dir, opts = {}) {
|
|
|
651
651
|
else {
|
|
652
652
|
scaffoldFromKitImport(kit, projectDir);
|
|
653
653
|
}
|
|
654
|
+
if (!opts.remixOf && (opts.deckId || opts.cardId)) {
|
|
655
|
+
// Apply server identity before install/version 1, so retries and a restore
|
|
656
|
+
// to the original scaffold cannot allocate a second deck or card.
|
|
657
|
+
const configPath = path.join(projectDir, 'castle.json');
|
|
658
|
+
writeJsonFile(configPath, {
|
|
659
|
+
...readJsonFile(configPath),
|
|
660
|
+
...(opts.deckId ? { deckId: opts.deckId } : {}),
|
|
661
|
+
...(opts.cardId ? { cardId: opts.cardId } : {}),
|
|
662
|
+
});
|
|
663
|
+
}
|
|
654
664
|
if (!opts.remixOf) {
|
|
655
665
|
console.log(`Created project in ${projectDir}/${bare ? '' : ` (from kit "${kit}")`}`);
|
|
656
666
|
}
|
package/dist/remix.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
-
import * as os from 'os';
|
|
3
2
|
import * as path from 'path';
|
|
4
|
-
import { nanoid } from 'nanoid';
|
|
5
3
|
import * as api from './api.js';
|
|
6
|
-
import { runTar } from './save-deck.js';
|
|
4
|
+
import { downloadSourceArchive, runTar } from './save-deck.js';
|
|
7
5
|
// Scaffolding a deck from another published deck, for `init --remix`.
|
|
8
6
|
// Dropped from the copied castle.json. `autoUpdateWhenImported` is the parent's
|
|
9
7
|
// promise to ITS importers, which a remix hasn't made.
|
|
@@ -34,17 +32,7 @@ export async function fetchParentSource(parentDeckId) {
|
|
|
34
32
|
console.error(`Deck ${parentDeckId} has no saved source to remix. Only decks saved by the web CLI can be remixed.`);
|
|
35
33
|
process.exit(1);
|
|
36
34
|
}
|
|
37
|
-
|
|
38
|
-
const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
|
|
39
|
-
if (!res.ok) {
|
|
40
|
-
throw new Error(`Archive fetch failed: HTTP ${res.status}`);
|
|
41
|
-
}
|
|
42
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
43
|
-
console.log(`Archive: ${(buf.length / 1024).toFixed(1)}KB (updated ${source.updatedAt})`);
|
|
44
|
-
// Read and untarred out of, never moved out of: in a sandbox the deck is on a
|
|
45
|
-
// different filesystem from tmp, and a rename across that is EXDEV.
|
|
46
|
-
const archivePath = path.join(os.tmpdir(), `castle-remix-${nanoid(8)}.tar.gz`);
|
|
47
|
-
fs.writeFileSync(archivePath, buf);
|
|
35
|
+
const archivePath = await downloadSourceArchive(source, 'castle-remix');
|
|
48
36
|
// `./castle.json`, because archiveSource packs with `-C <dir> .`.
|
|
49
37
|
const text = await runTar(['-xzOf', archivePath, './castle.json'], { capture: true });
|
|
50
38
|
return { archivePath, castleJson: parseJson(text) };
|
package/dist/save-deck.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import * as api from './api.js';
|
|
1
2
|
export declare const SOURCE_ARCHIVE_EXCLUDES: string[];
|
|
2
3
|
export declare const SAVE_STAGE_TELEMETRY_PREFIX = "CASTLE_SAVE_TELEMETRY ";
|
|
4
|
+
export declare const SAVE_COVER_SELECTION_TELEMETRY_PREFIX = "CASTLE_SAVE_COVER_SELECTION ";
|
|
3
5
|
export type SaveStage = 'bundle' | 'cover' | 'scene_upload_config' | 'scene_upload' | 'content_publish' | 'server_publish' | 'source_archive' | 'source_upload_config' | 'source_upload' | 'source_commit';
|
|
4
6
|
export declare function runTar(args: string[], opts?: {
|
|
5
7
|
capture?: boolean;
|
|
6
8
|
}): Promise<string>;
|
|
7
9
|
export declare function archiveSource(projectDir: string): Promise<Buffer>;
|
|
10
|
+
export declare function downloadSourceArchive(source: api.WebDeckSource, namePrefix: string): Promise<string>;
|
|
8
11
|
export type SaveVisibility = 'unlisted' | 'private';
|
|
9
12
|
export interface SaveDeckOptions {
|
|
10
13
|
title?: string;
|