castle-web-cli 0.4.105 → 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.
@@ -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 handleFilesWrite(deckDir, req, res) {
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(resolved.abs), { recursive: true });
335
- fs.writeFileSync(resolved.abs, body.contents, "utf8");
336
- sendJson(res, 200, { ok: true, path: resolved.rel });
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
- const message = err instanceof Error ? err.message : String(err);
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
- void (async () => {
382
- let body;
394
+ withMutationPath(deckDir, req, res, (target) => {
383
395
  try {
384
- body = JSON.parse(await readRequestBody(req));
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
- const message = err instanceof Error ? err.message : String(err);
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, resolved.rel);
402
- sendJson(res, 200, { ok: true, path: resolved.rel, visiblePathAdded });
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
- void (async () => {
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
- const message = err instanceof Error ? err.message : String(err);
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
- void (async () => {
457
- let body;
458
- try {
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(resolved.abs, { recursive: true, force: true });
472
- sendJson(res, 200, { ok: true, path: resolved.rel });
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
- const message = err instanceof Error ? err.message : String(err);
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
- function chmodTree(dir, fileMode, dirMode) {
92
- let entries;
93
- try {
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(dir, dirMode);
85
+ fs.chmodSync(target, mode);
86
+ return true;
115
87
  }
116
88
  catch {
117
- /* as above */
89
+ return false;
118
90
  }
119
91
  }
120
- export function lockImportTree(dir) {
121
- chmodTree(dir, 0o444, 0o555);
122
- }
123
- // Restore write permission so the tree can be replaced or removed. Directories
124
- // first, or their contents are unreachable.
125
- function unlockImportTree(dir) {
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
- unlockImportTree(child);
143
- else {
144
- try {
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 = (readCastleJson(deckDir)?.imports ?? {});
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 = (readCastleJson(deckDir)?.imports ?? {});
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 = (readCastleJson(viaDir)?.imports ?? {});
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 = (readCastleJson(deckDir)?.imports ?? {});
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 = (readCastleJson(deckDir)?.imports ?? {});
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 = (readCastleJson(deckDir)?.imports ?? {});
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.');