castle-web-cli 0.4.106 → 0.4.107
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 +64 -33
- package/dist/byo-accounts.d.ts +38 -0
- package/dist/byo-accounts.js +186 -0
- package/dist/byo-auth.d.ts +7 -1
- package/dist/byo-auth.js +198 -9
- package/dist/byo-login.d.ts +14 -0
- package/dist/byo-login.js +196 -0
- package/dist/castleJson.d.ts +15 -0
- package/dist/castleJson.js +24 -0
- package/dist/claude-shim-env.d.ts +1 -0
- package/dist/claude-shim-env.js +6 -0
- package/dist/get-deck.js +1 -11
- package/dist/ide.js +45 -62
- package/dist/imports.js +31 -61
- package/dist/init.js +3 -116
- package/dist/metering.d.ts +7 -0
- package/dist/metering.js +9 -0
- package/dist/native/playtest-browser.js +1 -1
- package/dist/native/tools.js +20 -18
- package/dist/save-deck.js +6 -3
- package/dist/shell/assets/index-B5mHPsrX.js +144 -0
- package/dist/shell/assets/index-wU4ol--C.css +1 -0
- package/dist/shell/index.html +2 -2
- package/package.json +2 -2
- package/dist/shell/assets/index-B-RU1Sa9.js +0 -144
- package/dist/shell/assets/index-B2quTIav.css +0 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type LoginProvider = "claude" | "cursor";
|
|
2
|
+
export type LoginPhase = "starting" | "awaiting-user" | "awaiting-code" | "verifying" | "error";
|
|
3
|
+
export interface LoginState {
|
|
4
|
+
provider: LoginProvider;
|
|
5
|
+
phase: LoginPhase;
|
|
6
|
+
url?: string;
|
|
7
|
+
message?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function activeLogin(): LoginState | null;
|
|
10
|
+
export declare function providerHasLogin(provider: LoginProvider): boolean;
|
|
11
|
+
export declare function startLogin(provider: LoginProvider, onChange: () => void): void;
|
|
12
|
+
export declare function submitLoginCode(code: string): void;
|
|
13
|
+
export declare function logout(provider: LoginProvider, onChange: () => void): void;
|
|
14
|
+
export declare function cancelLogin(): void;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Driving `claude auth login` / `cursor-agent login` from the editor, so a user
|
|
2
|
+
// can put a run on their own subscription without knowing the terminal exists.
|
|
3
|
+
//
|
|
4
|
+
// Both CLIs are usable HEADLESS -- measured, not documented (probed 2026-07-30
|
|
5
|
+
// against claude 2.1.220 and the current cursor-agent):
|
|
6
|
+
//
|
|
7
|
+
// claude auth login with no TTY and stdin from a pipe, prints
|
|
8
|
+
// "Opening browser to sign in…" then the OAuth URL, then
|
|
9
|
+
// blocks reading a pasted code from STDIN. The URL is
|
|
10
|
+
// wrapped in an OSC-8 hyperlink, so it appears TWICE in
|
|
11
|
+
// the raw bytes.
|
|
12
|
+
// cursor-agent login with NO_OPEN_BROWSER=1, prints
|
|
13
|
+
// "Open a browser and navigate to this link: <url>" and
|
|
14
|
+
// then POLLS the challenge itself -- no code to paste.
|
|
15
|
+
//
|
|
16
|
+
// Neither uses a localhost callback, which is what makes this work at all in a
|
|
17
|
+
// sandbox: the browser is on the user's machine and the CLI is in a container,
|
|
18
|
+
// so a loopback redirect would have nowhere to land.
|
|
19
|
+
//
|
|
20
|
+
// `claude setup-token` is NOT usable here: with no TTY it prints nothing, and
|
|
21
|
+
// under one it launches the full Claude Code TUI.
|
|
22
|
+
import { spawn } from "child_process";
|
|
23
|
+
import * as os from "os";
|
|
24
|
+
import { ANTHROPIC_PROXY_ENV, claudeHasSavedLogin, cursorHasUserLogin, } from "./byo-auth.js";
|
|
25
|
+
// A login is a singleton: two at once would race for the same credential file,
|
|
26
|
+
// and the UI only ever offers one. A second start replaces the first.
|
|
27
|
+
let active = null;
|
|
28
|
+
export function activeLogin() {
|
|
29
|
+
return active ? active.state : null;
|
|
30
|
+
}
|
|
31
|
+
export function providerHasLogin(provider) {
|
|
32
|
+
return provider === "claude"
|
|
33
|
+
? claudeHasSavedLogin()
|
|
34
|
+
: cursorHasUserLogin(os.homedir());
|
|
35
|
+
}
|
|
36
|
+
// Long enough for a real sign-in (find the browser, log in, maybe sign up),
|
|
37
|
+
// short enough that an abandoned flow doesn't leave a child running forever.
|
|
38
|
+
// The env override is a QA seam, like CASTLE_USER_KEYS_PATH: ten minutes is
|
|
39
|
+
// unwaitable in a test.
|
|
40
|
+
const LOGIN_TIMEOUT_MS = Number(process.env.CASTLE_LOGIN_TIMEOUT_MS ?? "") || 10 * 60 * 1000;
|
|
41
|
+
// OSC-8 hyperlinks and colour codes come through on both CLIs; strip them
|
|
42
|
+
// before matching so a URL isn't cut short at an escape byte.
|
|
43
|
+
// eslint-disable-next-line no-control-regex
|
|
44
|
+
const ANSI = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g;
|
|
45
|
+
const URL_PATTERN = {
|
|
46
|
+
claude: /https:\/\/claude\.com\/\S*oauth\S*/,
|
|
47
|
+
cursor: /https:\/\/cursor\.com\/\S*/,
|
|
48
|
+
};
|
|
49
|
+
function commandFor(provider) {
|
|
50
|
+
const env = { ...process.env, NO_OPEN_BROWSER: "1" };
|
|
51
|
+
if (provider === "cursor") {
|
|
52
|
+
// Castle's key would let cursor-agent consider itself authenticated and
|
|
53
|
+
// skip the OAuth it was just asked for.
|
|
54
|
+
delete env.CURSOR_API_KEY;
|
|
55
|
+
return { file: "cursor-agent", args: ["login"], env };
|
|
56
|
+
}
|
|
57
|
+
// Castle's proxy pair outranks a claude.ai login (see byo-auth), and the
|
|
58
|
+
// sandbox always has it set -- leaving it in place would have claude decide
|
|
59
|
+
// it is already authenticated and skip the flow entirely.
|
|
60
|
+
for (const name of ANTHROPIC_PROXY_ENV)
|
|
61
|
+
delete env[name];
|
|
62
|
+
// Nothing here should open a browser on the SERVE's machine: in a sandbox
|
|
63
|
+
// that is the container, where it would silently fail; locally it would
|
|
64
|
+
// steal focus from the person who clicked.
|
|
65
|
+
env.BROWSER = "true";
|
|
66
|
+
return { file: "claude", args: ["auth", "login"], env };
|
|
67
|
+
}
|
|
68
|
+
function publish(next) {
|
|
69
|
+
if (!active)
|
|
70
|
+
return;
|
|
71
|
+
active.state = { ...active.state, ...next };
|
|
72
|
+
active.onChange();
|
|
73
|
+
}
|
|
74
|
+
function finish(message) {
|
|
75
|
+
if (!active)
|
|
76
|
+
return;
|
|
77
|
+
clearTimeout(active.timer);
|
|
78
|
+
const { onChange } = active;
|
|
79
|
+
if (message === null) {
|
|
80
|
+
active = null;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// The flow is over, so nothing will read from the child again -- without
|
|
84
|
+
// this kill a timed-out `claude auth login` sits blocked on stdin forever,
|
|
85
|
+
// the very leak LOGIN_TIMEOUT_MS exists to prevent. A no-op on the paths
|
|
86
|
+
// where the child already exited.
|
|
87
|
+
active.child?.kill();
|
|
88
|
+
active.state = { ...active.state, phase: "error", message };
|
|
89
|
+
// Kept, not cleared: the phase IS the error surface, and a cleared state
|
|
90
|
+
// would read to the client as "no flow ran" -- indistinguishable from a
|
|
91
|
+
// button that did nothing. cancelLogin / the next start clears it.
|
|
92
|
+
}
|
|
93
|
+
onChange();
|
|
94
|
+
}
|
|
95
|
+
function handleOutput(text) {
|
|
96
|
+
if (!active)
|
|
97
|
+
return;
|
|
98
|
+
const clean = text.replace(ANSI, "");
|
|
99
|
+
// A mistyped code does NOT end the process -- claude prints this and prompts
|
|
100
|
+
// again (measured: "Invalid code. Please make sure the full code was
|
|
101
|
+
// copied."). Without catching it the flow sits in `verifying` until the
|
|
102
|
+
// timeout, looking like a hang, and the user has no way to retry short of
|
|
103
|
+
// cancelling. Put them back on the code step with the CLI's own wording.
|
|
104
|
+
const invalid = /Invalid code[^\n]*/.exec(clean);
|
|
105
|
+
if (invalid) {
|
|
106
|
+
publish({ phase: "awaiting-code", message: invalid[0].trim() });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!active.state.url) {
|
|
110
|
+
const match = URL_PATTERN[active.provider].exec(clean);
|
|
111
|
+
if (match) {
|
|
112
|
+
publish({
|
|
113
|
+
url: match[0],
|
|
114
|
+
// claude will ask for a pasted code next; cursor polls on its own.
|
|
115
|
+
phase: active.provider === "claude" ? "awaiting-code" : "awaiting-user",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function startLogin(provider, onChange) {
|
|
121
|
+
cancelLogin();
|
|
122
|
+
const { file, args, env } = commandFor(provider);
|
|
123
|
+
let child;
|
|
124
|
+
try {
|
|
125
|
+
child = spawn(file, args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
active = {
|
|
129
|
+
provider,
|
|
130
|
+
child: null,
|
|
131
|
+
state: { provider, phase: "error", message: `could not run ${file}` },
|
|
132
|
+
timer: setTimeout(() => undefined, 0),
|
|
133
|
+
onChange,
|
|
134
|
+
};
|
|
135
|
+
onChange();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
active = {
|
|
139
|
+
provider,
|
|
140
|
+
child,
|
|
141
|
+
state: { provider, phase: "starting" },
|
|
142
|
+
timer: setTimeout(() => finish("Timed out waiting for sign-in."), LOGIN_TIMEOUT_MS),
|
|
143
|
+
onChange,
|
|
144
|
+
};
|
|
145
|
+
child.stdout?.on("data", (b) => handleOutput(b.toString()));
|
|
146
|
+
child.stderr?.on("data", (b) => handleOutput(b.toString()));
|
|
147
|
+
child.on("error", () => finish(`could not run ${file}`));
|
|
148
|
+
child.on("close", () => {
|
|
149
|
+
if (!active || active.child !== child)
|
|
150
|
+
return;
|
|
151
|
+
// Already settled as an error: this close is finish()'s own kill landing
|
|
152
|
+
// (the timeout path), and evaluating it as a fresh outcome would overwrite
|
|
153
|
+
// the message that explains what happened.
|
|
154
|
+
if (active.state.phase === "error")
|
|
155
|
+
return;
|
|
156
|
+
publish({ phase: "verifying" });
|
|
157
|
+
// The RESOLVER decides, never the exit code: what matters is whether the
|
|
158
|
+
// credential this process routes on is now there. A CLI that exits 0
|
|
159
|
+
// without leaving one behind must not read as success.
|
|
160
|
+
if (providerHasLogin(provider))
|
|
161
|
+
finish(null);
|
|
162
|
+
else
|
|
163
|
+
finish("Sign-in did not complete.");
|
|
164
|
+
});
|
|
165
|
+
onChange();
|
|
166
|
+
}
|
|
167
|
+
export function submitLoginCode(code) {
|
|
168
|
+
if (!active || active.state.phase !== "awaiting-code")
|
|
169
|
+
return;
|
|
170
|
+
const trimmed = code.trim();
|
|
171
|
+
if (!trimmed)
|
|
172
|
+
return;
|
|
173
|
+
active.child.stdin?.write(trimmed + "\n");
|
|
174
|
+
publish({ phase: "verifying" });
|
|
175
|
+
}
|
|
176
|
+
// Sign out, so the modal isn't a one-way door. Fire-and-forget by shape but
|
|
177
|
+
// awaited for its close, because the snapshot is only right once the CLI has
|
|
178
|
+
// actually dropped the credential -- the resolver, again, not the exit code.
|
|
179
|
+
export function logout(provider, onChange) {
|
|
180
|
+
cancelLogin();
|
|
181
|
+
const { file, args, env } = provider === "cursor"
|
|
182
|
+
? { file: "cursor-agent", args: ["logout"], env: process.env }
|
|
183
|
+
: { file: "claude", args: ["auth", "logout"], env: commandFor("claude").env };
|
|
184
|
+
const child = spawn(file, args, { env, stdio: "ignore" });
|
|
185
|
+
child.on("error", onChange);
|
|
186
|
+
child.on("close", onChange);
|
|
187
|
+
}
|
|
188
|
+
export function cancelLogin() {
|
|
189
|
+
if (!active)
|
|
190
|
+
return;
|
|
191
|
+
clearTimeout(active.timer);
|
|
192
|
+
active.child?.kill();
|
|
193
|
+
const { onChange } = active;
|
|
194
|
+
active = null;
|
|
195
|
+
onChange();
|
|
196
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface DeckImport {
|
|
2
|
+
deckId?: string;
|
|
3
|
+
source?: 'builtin';
|
|
4
|
+
kit?: string;
|
|
5
|
+
via?: string;
|
|
6
|
+
version: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CastleJson {
|
|
9
|
+
deckId?: string;
|
|
10
|
+
cardId?: string;
|
|
11
|
+
imports?: Record<string, DeckImport>;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
export declare function readCastleJson(dir: string): CastleJson | null;
|
|
15
|
+
export declare function readCastleJsonOrThrow(dir: string): CastleJson | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
// A missing castle.json is normal (a deck has none until its first save), so it
|
|
4
|
+
// reads as `null` rather than an error. An unparseable one is not -- `onInvalid`
|
|
5
|
+
// is how a caller says whether that should be fatal here or just another `null`.
|
|
6
|
+
function readCastleJsonFile(dir, onInvalid) {
|
|
7
|
+
const file = path.join(dir, 'castle.json');
|
|
8
|
+
if (!fs.existsSync(file))
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
return onInvalid(file, e);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function readCastleJson(dir) {
|
|
18
|
+
return readCastleJsonFile(dir, () => null);
|
|
19
|
+
}
|
|
20
|
+
export function readCastleJsonOrThrow(dir) {
|
|
21
|
+
return readCastleJsonFile(dir, (file, e) => {
|
|
22
|
+
throw new Error(`Could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Printed for the editor terminal's `claude` shim to eval: the environment this
|
|
2
|
+
// particular run should have, plus the real binary to exec (see
|
|
3
|
+
// installClaudeShim in byo-auth.ts for why the decision can't live in the
|
|
4
|
+
// shell's own environment).
|
|
5
|
+
import { claudeShellEnvScript } from "./byo-auth.js";
|
|
6
|
+
process.stdout.write(claudeShellEnvScript(process.env, process.argv[2] ?? ""));
|
package/dist/get-deck.js
CHANGED
|
@@ -5,6 +5,7 @@ import { nanoid } from 'nanoid';
|
|
|
5
5
|
import * as api from './api.js';
|
|
6
6
|
import { normalizeDeckPackageJson } from './normalize.js';
|
|
7
7
|
import { archiveSource, runTar, SOURCE_ARCHIVE_EXCLUDES } from './save-deck.js';
|
|
8
|
+
import { readCastleJson } from './castleJson.js';
|
|
8
9
|
// Refreshing a deck already in the target replaces its source, so a file deleted
|
|
9
10
|
// upstream actually disappears here -- untarring over the old tree would leave it
|
|
10
11
|
// behind. What survives is exactly what the source archive doesn't carry, and it
|
|
@@ -12,17 +13,6 @@ import { archiveSource, runTar, SOURCE_ARCHIVE_EXCLUDES } from './save-deck.js';
|
|
|
12
13
|
// below (which is that same archive), so deleting one destroys it outright. They're
|
|
13
14
|
// all machine-local anyway -- deps, build output, .castle runtime state, .git.
|
|
14
15
|
const KEEP = SOURCE_ARCHIVE_EXCLUDES;
|
|
15
|
-
function readCastleJson(dir) {
|
|
16
|
-
const p = path.join(dir, 'castle.json');
|
|
17
|
-
if (!fs.existsSync(p))
|
|
18
|
-
return null;
|
|
19
|
-
try {
|
|
20
|
-
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
16
|
// Whether the target already holds a deck, as opposed to being new, empty, or
|
|
27
17
|
// holding only the dirs a refresh preserves.
|
|
28
18
|
function hasSource(dir) {
|
package/dist/ide.js
CHANGED
|
@@ -15,7 +15,7 @@ import headlessPkg from "@xterm/headless";
|
|
|
15
15
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
16
16
|
import { WebSocketServer } from "ws";
|
|
17
17
|
import { IMPORTS_DIR, importStatuses, updateImport } from "./imports.js";
|
|
18
|
-
import { envForUserShell } from "./byo-auth.js";
|
|
18
|
+
import { envForUserShell, installClaudeShim } from "./byo-auth.js";
|
|
19
19
|
const HeadlessTerminal = headlessPkg.Terminal;
|
|
20
20
|
const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
21
21
|
// The bundled shell app (vite build output). `/` serves its index.html and
|
|
@@ -315,7 +315,7 @@ function readRequestBody(req) {
|
|
|
315
315
|
req.on("error", reject);
|
|
316
316
|
});
|
|
317
317
|
}
|
|
318
|
-
function
|
|
318
|
+
function withJsonBody(req, res, handler) {
|
|
319
319
|
void (async () => {
|
|
320
320
|
let body;
|
|
321
321
|
try {
|
|
@@ -324,24 +324,37 @@ function handleFilesWrite(deckDir, req, res) {
|
|
|
324
324
|
catch {
|
|
325
325
|
return sendJson(res, 400, { error: "Invalid JSON body." });
|
|
326
326
|
}
|
|
327
|
+
handler(body);
|
|
328
|
+
})();
|
|
329
|
+
}
|
|
330
|
+
// The shape every single-path mutation handler starts with: parse the body,
|
|
331
|
+
// resolve `path` against the deck, and reject anything outside it.
|
|
332
|
+
function withMutationPath(deckDir, req, res, handler) {
|
|
333
|
+
withJsonBody(req, res, (body) => {
|
|
327
334
|
const resolved = resolveDeckPath(deckDir, body.path, { mutation: true });
|
|
328
335
|
if (!resolved.ok)
|
|
329
336
|
return sendJson(res, 400, { error: resolved.error });
|
|
337
|
+
handler(resolved, body);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
function sendFailure(res, action, rel, err) {
|
|
341
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
342
|
+
sendJson(res, 500, { error: `Could not ${action} ${rel}: ${message}` });
|
|
343
|
+
}
|
|
344
|
+
function handleFilesWrite(deckDir, req, res) {
|
|
345
|
+
withMutationPath(deckDir, req, res, (target, body) => {
|
|
330
346
|
if (typeof body.contents !== "string") {
|
|
331
347
|
return sendJson(res, 400, { error: "File contents must be a string." });
|
|
332
348
|
}
|
|
333
349
|
try {
|
|
334
|
-
fs.mkdirSync(path.dirname(
|
|
335
|
-
fs.writeFileSync(
|
|
336
|
-
sendJson(res, 200, { ok: true, path:
|
|
350
|
+
fs.mkdirSync(path.dirname(target.abs), { recursive: true });
|
|
351
|
+
fs.writeFileSync(target.abs, body.contents, "utf8");
|
|
352
|
+
sendJson(res, 200, { ok: true, path: target.rel });
|
|
337
353
|
}
|
|
338
354
|
catch (err) {
|
|
339
|
-
|
|
340
|
-
sendJson(res, 500, {
|
|
341
|
-
error: `Could not write ${resolved.rel}: ${message}`,
|
|
342
|
-
});
|
|
355
|
+
sendFailure(res, "write", target.rel, err);
|
|
343
356
|
}
|
|
344
|
-
})
|
|
357
|
+
});
|
|
345
358
|
}
|
|
346
359
|
// Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
|
|
347
360
|
// newly-made folder show in the curated Files tree. Only touches a deck that is
|
|
@@ -378,29 +391,16 @@ function ensureVisiblePath(deckDir, rel) {
|
|
|
378
391
|
}
|
|
379
392
|
}
|
|
380
393
|
function handleFilesMkdir(deckDir, req, res) {
|
|
381
|
-
|
|
382
|
-
let body;
|
|
394
|
+
withMutationPath(deckDir, req, res, (target) => {
|
|
383
395
|
try {
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
return sendJson(res, 400, { error: "Invalid JSON body." });
|
|
388
|
-
}
|
|
389
|
-
const resolved = resolveDeckPath(deckDir, body.path, { mutation: true });
|
|
390
|
-
if (!resolved.ok)
|
|
391
|
-
return sendJson(res, 400, { error: resolved.error });
|
|
392
|
-
try {
|
|
393
|
-
fs.mkdirSync(resolved.abs, { recursive: true });
|
|
396
|
+
fs.mkdirSync(target.abs, { recursive: true });
|
|
394
397
|
}
|
|
395
398
|
catch (err) {
|
|
396
|
-
|
|
397
|
-
return sendJson(res, 500, {
|
|
398
|
-
error: `Could not create folder ${resolved.rel}: ${message}`,
|
|
399
|
-
});
|
|
399
|
+
return sendFailure(res, "create folder", target.rel, err);
|
|
400
400
|
}
|
|
401
|
-
const visiblePathAdded = ensureVisiblePath(deckDir,
|
|
402
|
-
sendJson(res, 200, { ok: true, path:
|
|
403
|
-
})
|
|
401
|
+
const visiblePathAdded = ensureVisiblePath(deckDir, target.rel);
|
|
402
|
+
sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
|
|
403
|
+
});
|
|
404
404
|
}
|
|
405
405
|
// True when two paths resolve to the same underlying file (same inode+device) --
|
|
406
406
|
// e.g. the source and target of a case-only rename on a case-insensitive FS.
|
|
@@ -415,14 +415,7 @@ function isSameFile(a, b) {
|
|
|
415
415
|
}
|
|
416
416
|
}
|
|
417
417
|
function handleFilesRename(deckDir, req, res) {
|
|
418
|
-
|
|
419
|
-
let body;
|
|
420
|
-
try {
|
|
421
|
-
body = JSON.parse(await readRequestBody(req));
|
|
422
|
-
}
|
|
423
|
-
catch {
|
|
424
|
-
return sendJson(res, 400, { error: "Invalid JSON body." });
|
|
425
|
-
}
|
|
418
|
+
withJsonBody(req, res, (body) => {
|
|
426
419
|
const from = resolveDeckPath(deckDir, body.from, { mutation: true });
|
|
427
420
|
if (!from.ok)
|
|
428
421
|
return sendJson(res, 400, { error: from.error });
|
|
@@ -447,37 +440,23 @@ function handleFilesRename(deckDir, req, res) {
|
|
|
447
440
|
sendJson(res, 200, { ok: true, path: to.rel });
|
|
448
441
|
}
|
|
449
442
|
catch (err) {
|
|
450
|
-
|
|
451
|
-
sendJson(res, 500, { error: `Could not rename ${from.rel}: ${message}` });
|
|
443
|
+
sendFailure(res, "rename", from.rel, err);
|
|
452
444
|
}
|
|
453
|
-
})
|
|
445
|
+
});
|
|
454
446
|
}
|
|
455
447
|
function handleFilesDelete(deckDir, req, res) {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
body = JSON.parse(await readRequestBody(req));
|
|
460
|
-
}
|
|
461
|
-
catch {
|
|
462
|
-
return sendJson(res, 400, { error: "Invalid JSON body." });
|
|
463
|
-
}
|
|
464
|
-
const resolved = resolveDeckPath(deckDir, body.path, { mutation: true });
|
|
465
|
-
if (!resolved.ok)
|
|
466
|
-
return sendJson(res, 400, { error: resolved.error });
|
|
467
|
-
if (!fs.existsSync(resolved.abs)) {
|
|
468
|
-
return sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
|
|
448
|
+
withMutationPath(deckDir, req, res, (target) => {
|
|
449
|
+
if (!fs.existsSync(target.abs)) {
|
|
450
|
+
return sendJson(res, 404, { error: `Not found: ${target.rel}` });
|
|
469
451
|
}
|
|
470
452
|
try {
|
|
471
|
-
fs.rmSync(
|
|
472
|
-
sendJson(res, 200, { ok: true, path:
|
|
453
|
+
fs.rmSync(target.abs, { recursive: true, force: true });
|
|
454
|
+
sendJson(res, 200, { ok: true, path: target.rel });
|
|
473
455
|
}
|
|
474
456
|
catch (err) {
|
|
475
|
-
|
|
476
|
-
sendJson(res, 500, {
|
|
477
|
-
error: `Could not delete ${resolved.rel}: ${message}`,
|
|
478
|
-
});
|
|
457
|
+
sendFailure(res, "delete", target.rel, err);
|
|
479
458
|
}
|
|
480
|
-
})
|
|
459
|
+
});
|
|
481
460
|
}
|
|
482
461
|
// The builtin Files + code-editor backend: list / read / write deck files and
|
|
483
462
|
// report kit-owned editor extensions. Paths are deck-relative; resolveDeckPath
|
|
@@ -611,7 +590,7 @@ function defaultShell() {
|
|
|
611
590
|
}) ?? "/bin/sh";
|
|
612
591
|
return { command, args: ["-l"] };
|
|
613
592
|
}
|
|
614
|
-
function ptyEnv() {
|
|
593
|
+
function ptyEnv(shimDir) {
|
|
615
594
|
const env = {
|
|
616
595
|
...envForUserShell(process.env),
|
|
617
596
|
TERM: PTY_TERM,
|
|
@@ -623,6 +602,9 @@ function ptyEnv() {
|
|
|
623
602
|
};
|
|
624
603
|
delete env.NO_COLOR;
|
|
625
604
|
delete env.NODE_DISABLE_COLORS;
|
|
605
|
+
if (shimDir) {
|
|
606
|
+
env.PATH = env.PATH ? `${shimDir}${path.delimiter}${env.PATH}` : shimDir;
|
|
607
|
+
}
|
|
626
608
|
return env;
|
|
627
609
|
}
|
|
628
610
|
function clampSize(value, fallback) {
|
|
@@ -669,6 +651,7 @@ export function createIdeServer(opts) {
|
|
|
669
651
|
let session = null;
|
|
670
652
|
function spawnSession() {
|
|
671
653
|
const { command, args } = defaultShell();
|
|
654
|
+
const shimDir = installClaudeShim(deckDir);
|
|
672
655
|
const screen = new HeadlessTerminal({
|
|
673
656
|
allowProposedApi: true,
|
|
674
657
|
cols: INITIAL_COLS,
|
|
@@ -683,7 +666,7 @@ export function createIdeServer(opts) {
|
|
|
683
666
|
cols: INITIAL_COLS,
|
|
684
667
|
rows: INITIAL_ROWS,
|
|
685
668
|
cwd: deckDir,
|
|
686
|
-
env: ptyEnv(),
|
|
669
|
+
env: ptyEnv(shimDir),
|
|
687
670
|
});
|
|
688
671
|
const s = {
|
|
689
672
|
pty,
|
package/dist/imports.js
CHANGED
|
@@ -5,6 +5,7 @@ import * as api from './api.js';
|
|
|
5
5
|
import { runTar } from './save-deck.js';
|
|
6
6
|
import { normalizeDeckPackageJson } from './normalize.js';
|
|
7
7
|
import { getKitsDir } from './localPaths.js';
|
|
8
|
+
import { readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
|
|
8
9
|
// Adding another deck as a dependency (`castle-web add-import`; removing one is a
|
|
9
10
|
// later command). Deliberately NOT `get-deck`: that one
|
|
10
11
|
// replaces THIS deck's own source from the server (and carries guards for the
|
|
@@ -24,17 +25,6 @@ import { getKitsDir } from './localPaths.js';
|
|
|
24
25
|
// node_modules gets. That also means `get-deck` leaves an existing `imports/`
|
|
25
26
|
// alone when it refreshes a deck.
|
|
26
27
|
export const IMPORTS_DIR = 'imports';
|
|
27
|
-
function readCastleJson(dir) {
|
|
28
|
-
const p = path.join(dir, 'castle.json');
|
|
29
|
-
if (!fs.existsSync(p))
|
|
30
|
-
return null;
|
|
31
|
-
try {
|
|
32
|
-
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
33
|
-
}
|
|
34
|
-
catch (e) {
|
|
35
|
-
throw new Error(`Could not read ${p}: ${e instanceof Error ? e.message : String(e)}`);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
28
|
// Only the `imports` key is ours; everything else in castle.json (deck identity,
|
|
39
29
|
// editor config) is preserved as-is.
|
|
40
30
|
function writeImportPin(dir, alias, pin) {
|
|
@@ -88,47 +78,24 @@ function qualifiedAlias(meta, deckId) {
|
|
|
88
78
|
//
|
|
89
79
|
// The cost is that `imports/<alias>` can't be deleted until it's unlocked, which
|
|
90
80
|
// is why every path in this file that replaces a dependency unlocks it first.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
for (const entry of entries) {
|
|
100
|
-
const child = path.join(dir, entry.name);
|
|
101
|
-
if (entry.isDirectory())
|
|
102
|
-
chmodTree(child, fileMode, dirMode);
|
|
103
|
-
else {
|
|
104
|
-
try {
|
|
105
|
-
fs.chmodSync(child, fileMode);
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
/* a file we can't chmod is not worth failing the import over */
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
// The directory goes last: its contents have to be reachable while we walk it.
|
|
81
|
+
// A file we can't chmod is not worth failing the import over, so every chmod
|
|
82
|
+
// here is best-effort.
|
|
83
|
+
function chmod(target, mode) {
|
|
113
84
|
try {
|
|
114
|
-
fs.chmodSync(
|
|
85
|
+
fs.chmodSync(target, mode);
|
|
86
|
+
return true;
|
|
115
87
|
}
|
|
116
88
|
catch {
|
|
117
|
-
|
|
89
|
+
return false;
|
|
118
90
|
}
|
|
119
91
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
fs.chmodSync(dir, 0o755);
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
92
|
+
// `dirFirst` is the one thing that differs between locking and unlocking, and it
|
|
93
|
+
// has to: locking chmods a directory AFTER its contents (they must stay
|
|
94
|
+
// reachable while we walk it), unlocking chmods it BEFORE (or the walk can't get
|
|
95
|
+
// in at all -- so a failure there aborts this subtree).
|
|
96
|
+
function chmodTree(dir, modes) {
|
|
97
|
+
if (modes.dirFirst && !chmod(dir, modes.dir))
|
|
130
98
|
return;
|
|
131
|
-
}
|
|
132
99
|
let entries;
|
|
133
100
|
try {
|
|
134
101
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -139,16 +106,19 @@ function unlockImportTree(dir) {
|
|
|
139
106
|
for (const entry of entries) {
|
|
140
107
|
const child = path.join(dir, entry.name);
|
|
141
108
|
if (entry.isDirectory())
|
|
142
|
-
|
|
143
|
-
else
|
|
144
|
-
|
|
145
|
-
fs.chmodSync(child, 0o644);
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
/* best effort */
|
|
149
|
-
}
|
|
150
|
-
}
|
|
109
|
+
chmodTree(child, modes);
|
|
110
|
+
else
|
|
111
|
+
chmod(child, modes.file);
|
|
151
112
|
}
|
|
113
|
+
if (!modes.dirFirst)
|
|
114
|
+
chmod(dir, modes.dir);
|
|
115
|
+
}
|
|
116
|
+
export function lockImportTree(dir) {
|
|
117
|
+
chmodTree(dir, { file: 0o444, dir: 0o555, dirFirst: false });
|
|
118
|
+
}
|
|
119
|
+
// Restore write permission so the tree can be replaced or removed.
|
|
120
|
+
function unlockImportTree(dir) {
|
|
121
|
+
chmodTree(dir, { file: 0o644, dir: 0o755, dirFirst: true });
|
|
152
122
|
}
|
|
153
123
|
// An import's code runs from the IMPORTING deck's node_modules -- there is one
|
|
154
124
|
// install at the deck root, and a dependency's own node_modules is neither
|
|
@@ -209,7 +179,7 @@ export function syncImportDependencies(deckDir) {
|
|
|
209
179
|
// won't run until they are back. Rebuilding them from the pins is `install`'s
|
|
210
180
|
// job, alongside node_modules.
|
|
211
181
|
export async function restoreMissingImports(deckDir) {
|
|
212
|
-
const pins =
|
|
182
|
+
const pins = readCastleJson(deckDir)?.imports ?? {};
|
|
213
183
|
const restored = [];
|
|
214
184
|
for (const [alias, pin] of Object.entries(pins)) {
|
|
215
185
|
const destDir = path.join(deckDir, IMPORTS_DIR, alias);
|
|
@@ -298,7 +268,7 @@ export async function importStatuses(deckDir) {
|
|
|
298
268
|
const cached = statusCache.get(deckDir);
|
|
299
269
|
if (cached && Date.now() - cached.at < STATUS_TTL_MS)
|
|
300
270
|
return cached.value;
|
|
301
|
-
const pins =
|
|
271
|
+
const pins = readCastleJson(deckDir)?.imports ?? {};
|
|
302
272
|
const value = await Promise.all(Object.entries(pins).map(async ([alias, pin]) => {
|
|
303
273
|
const base = {
|
|
304
274
|
alias,
|
|
@@ -386,12 +356,12 @@ async function placeImport(deckDir, alias, deckId, source, via) {
|
|
|
386
356
|
// silently renamed, since renaming would break the refs of whoever named it.
|
|
387
357
|
async function addTransitiveImports(deckDir, viaAlias, seen) {
|
|
388
358
|
const viaDir = path.join(deckDir, IMPORTS_DIR, viaAlias);
|
|
389
|
-
const pins =
|
|
359
|
+
const pins = readCastleJson(viaDir)?.imports ?? {};
|
|
390
360
|
const added = [];
|
|
391
361
|
for (const [alias, pin] of Object.entries(pins)) {
|
|
392
362
|
if (!pin?.deckId || seen.has(pin.deckId))
|
|
393
363
|
continue;
|
|
394
|
-
const existing =
|
|
364
|
+
const existing = readCastleJson(deckDir)?.imports ?? {};
|
|
395
365
|
const holder = existing[alias];
|
|
396
366
|
if (holder && holder.deckId !== pin.deckId) {
|
|
397
367
|
console.warn(`"${viaAlias}" wants ${pin.deckId} as "${alias}", which is already ${holder.deckId} here -- skipping; ` +
|
|
@@ -417,7 +387,7 @@ async function addTransitiveImports(deckDir, viaAlias, seen) {
|
|
|
417
387
|
// Every deckId this deck already has, so a transitive walk doesn't refetch or
|
|
418
388
|
// loop on a cycle.
|
|
419
389
|
function importedDeckIds(deckDir) {
|
|
420
|
-
const pins =
|
|
390
|
+
const pins = readCastleJson(deckDir)?.imports ?? {};
|
|
421
391
|
return new Set(Object.values(pins).map((p) => p?.deckId).filter((id) => !!id));
|
|
422
392
|
}
|
|
423
393
|
export async function addImport(dir, options = {}) {
|
|
@@ -468,7 +438,7 @@ export async function addImport(dir, options = {}) {
|
|
|
468
438
|
// wasn't.
|
|
469
439
|
export async function updateImport(dir, options = {}) {
|
|
470
440
|
const deckDir = path.resolve(dir);
|
|
471
|
-
const pins =
|
|
441
|
+
const pins = readCastleJson(deckDir)?.imports ?? {};
|
|
472
442
|
const aliases = options.alias ? [options.alias] : Object.keys(pins);
|
|
473
443
|
if (aliases.length === 0) {
|
|
474
444
|
console.log('This deck has no imports.');
|