leduo-patrol 2.0.0 → 2.0.1

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/README.md CHANGED
@@ -97,6 +97,7 @@
97
97
  - Node.js 22+
98
98
  - 已能正常运行 Claude Code
99
99
  - 服务器环境里已配置 `ANTHROPIC_API_KEY`
100
+ - 若 `claude` 不在默认 `PATH` 中,请设置 `LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude`
100
101
 
101
102
  ## 启动
102
103
 
@@ -148,15 +149,17 @@ LEDUO_PATROL_BIND_MODE=server
148
149
  LEDUO_PATROL_APP_NAME=乐多汪汪队
149
150
  LEDUO_PATROL_WORKSPACE_PATH=/absolute/workspace/path
150
151
  LEDUO_PATROL_ALLOWED_ROOTS=/absolute/workspace/path,/another/allowed/root
152
+ LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
153
+ LEDUO_PATROL_SHELL=/absolute/path/to/zsh
151
154
  ANTHROPIC_API_KEY=sk-...
152
155
  LEDUO_PATROL_ACCESS_KEY=your-fixed-key
153
- LEDUO_PATROL_AGENT_BIN=/absolute/path/to/claude-code-acp
154
156
  LEDUO_ENABLE_SHELL=true
155
157
  ```
156
158
 
157
159
  如果设置了 `LEDUO_PATROL_ALLOWED_ROOTS`,网页中只能连接这些根目录之下的路径;未设置时默认只允许启动命令所在目录。
158
160
  如果未设置 `LEDUO_PATROL_WORKSPACE_PATH`,默认工作目录为启动命令所在目录(`process.cwd()`),并在启动日志中提示如何通过环境变量修改。
159
161
  如果未设置 `LEDUO_PATROL_ALLOWED_ROOTS`,默认允许根目录同样为启动命令所在目录,并会在启动日志中提示可配置项。
162
+ 如果发布安装后的内嵌终端无法启动,可通过 `LEDUO_PATROL_SHELL` 显式指定 shell 路径;例如 macOS 上常见的 `/bin/zsh`。
160
163
 
161
164
  ## 状态持久化
162
165
 
@@ -187,7 +190,7 @@ LEDUO_ENABLE_SHELL=true
187
190
 
188
191
  ```bash
189
192
  LEDUO_PATROL_ACCESS_KEY=your-fixed-key
190
- LEDUO_PATROL_AGENT_BIN=/absolute/path/to/claude-code-acp
193
+ LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
191
194
  ```
192
195
 
193
196
  ## 已知限制
@@ -0,0 +1,24 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { mkdtempSync, writeFileSync } from "node:fs";
6
+ import { claudeCliSessionTestables } from "../claude-cli-session.js";
7
+ test("claudeCliSessionTestables.findExecutableOnPath resolves commands from PATH entries", () => {
8
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-claude-bin-"));
9
+ const binaryName = process.platform === "win32" ? "claude.cmd" : "claude";
10
+ const binaryPath = path.join(tempDir, binaryName);
11
+ writeFileSync(binaryPath, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n");
12
+ const resolved = claudeCliSessionTestables.findExecutableOnPath("claude", tempDir);
13
+ assert.equal(resolved, binaryPath);
14
+ });
15
+ test("claudeCliSessionTestables.resolveClaudeBin accepts explicit executable paths", () => {
16
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-claude-explicit-"));
17
+ const binaryPath = path.join(tempDir, "claude");
18
+ writeFileSync(binaryPath, "#!/bin/sh\n");
19
+ const resolved = claudeCliSessionTestables.resolveClaudeBin(binaryPath, { PATH: "" });
20
+ assert.equal(resolved, binaryPath);
21
+ });
22
+ test("claudeCliSessionTestables.resolveClaudeBin throws actionable error when claude is missing", () => {
23
+ assert.throws(() => claudeCliSessionTestables.resolveClaudeBin(undefined, { PATH: "" }), /LEDUO_PATROL_CLAUDE_BIN/);
24
+ });
@@ -1,7 +1,9 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import path from "node:path";
4
- import { formatError, resolveAllowedPath } from "../server-helpers.js";
4
+ import os from "node:os";
5
+ import { mkdtempSync, writeFileSync } from "node:fs";
6
+ import { buildSpawnFailureMessage, ensureDirectoryExistsSync, formatError, resolveAllowedPath, } from "../server-helpers.js";
5
7
  test("server helpers formatError handles Error and primitives", () => {
6
8
  assert.equal(formatError(new Error("boom")), "boom");
7
9
  assert.equal(formatError("plain"), '"plain"');
@@ -16,3 +18,17 @@ test("server helpers resolveAllowedPath rejects outside roots", () => {
16
18
  const root = path.resolve("/tmp/repo");
17
19
  assert.throws(() => resolveAllowedPath("/etc", [root]), /outside allowed roots/);
18
20
  });
21
+ test("server helpers ensureDirectoryExistsSync accepts existing directories", () => {
22
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-dir-"));
23
+ assert.equal(ensureDirectoryExistsSync(tempDir, "Workspace"), tempDir);
24
+ });
25
+ test("server helpers ensureDirectoryExistsSync rejects files and missing paths", () => {
26
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-file-"));
27
+ const filePath = path.join(tempDir, "file.txt");
28
+ writeFileSync(filePath, "hello");
29
+ assert.throws(() => ensureDirectoryExistsSync(filePath, "Workspace"), /is not a directory/);
30
+ assert.throws(() => ensureDirectoryExistsSync(path.join(tempDir, "missing"), "Workspace"), /does not exist/);
31
+ });
32
+ test("server helpers buildSpawnFailureMessage includes command cwd and hint", () => {
33
+ assert.equal(buildSpawnFailureMessage("shell", "/bin/zsh", "/repo", new Error("posix_spawnp failed"), "Try another shell."), 'Failed to start shell "/bin/zsh" in "/repo": posix_spawnp failed. Try another shell.');
34
+ });
@@ -0,0 +1,35 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { shellSessionTestables } from "../shell-session.js";
4
+ test("shellSessionTestables.resolveShellLaunch prefers configured shell over defaults", () => {
5
+ const resolved = shellSessionTestables.resolveShellLaunch({
6
+ LEDUO_PATROL_SHELL: "/custom/bin/zsh",
7
+ SHELL: "/bin/bash",
8
+ }, (candidate) => candidate === "/custom/bin/zsh" || candidate === "/bin/bash");
9
+ assert.deepEqual(resolved, {
10
+ command: "/custom/bin/zsh",
11
+ args: ["-l"],
12
+ });
13
+ });
14
+ test("shellSessionTestables.resolveShellLaunch falls back to /bin/sh when bash and zsh are unavailable", () => {
15
+ const resolved = shellSessionTestables.resolveShellLaunch({
16
+ SHELL: "/missing/shell",
17
+ }, (candidate) => candidate === "/bin/sh");
18
+ assert.deepEqual(resolved, {
19
+ command: "/bin/sh",
20
+ args: [],
21
+ });
22
+ });
23
+ test("shellSessionTestables.resolveShellLaunch accepts plain configured shell names as a last resort", () => {
24
+ const resolved = shellSessionTestables.resolveShellLaunch({
25
+ LEDUO_PATROL_SHELL: "fish",
26
+ SHELL: "",
27
+ }, () => false);
28
+ assert.deepEqual(resolved, {
29
+ command: "fish",
30
+ args: ["-l"],
31
+ });
32
+ });
33
+ test("shellSessionTestables.resolveShellLaunch throws actionable error when no shell is available", () => {
34
+ assert.throws(() => shellSessionTestables.resolveShellLaunch({ SHELL: "" }, () => false), /LEDUO_PATROL_SHELL/);
35
+ });
@@ -1,5 +1,8 @@
1
1
  import { spawn } from "node-pty";
2
2
  import { EventEmitter } from "node:events";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { buildSpawnFailureMessage, ensureDirectoryExistsSync } from "./server-helpers.js";
3
6
  /**
4
7
  * A PTY-backed session that runs the native Claude Code CLI.
5
8
  *
@@ -15,24 +18,30 @@ export class ClaudeCliSession extends EventEmitter {
15
18
  constructor(opts) {
16
19
  super();
17
20
  this.sessionId = opts.sessionId;
18
- const bin = opts.claudeBin ?? "claude";
21
+ ensureDirectoryExistsSync(opts.workspacePath, "Session workspace");
22
+ const bin = resolveClaudeBin(opts.claudeBin);
19
23
  const args = opts.resume
20
24
  ? ["--resume", opts.sessionId]
21
25
  : ["--session-id", opts.sessionId];
22
26
  if (opts.allowSkipPermissions) {
23
27
  args.push("--allow-dangerously-skip-permissions");
24
28
  }
25
- this.pty = spawn(bin, args, {
26
- name: "xterm-256color",
27
- cols: opts.cols ?? 80,
28
- rows: opts.rows ?? 24,
29
- cwd: opts.workspacePath,
30
- env: {
31
- ...process.env,
32
- TERM: "xterm-256color",
33
- COLORTERM: "truecolor",
34
- },
35
- });
29
+ try {
30
+ this.pty = spawn(bin, args, {
31
+ name: "xterm-256color",
32
+ cols: opts.cols ?? 80,
33
+ rows: opts.rows ?? 24,
34
+ cwd: opts.workspacePath,
35
+ env: {
36
+ ...process.env,
37
+ TERM: "xterm-256color",
38
+ COLORTERM: "truecolor",
39
+ },
40
+ });
41
+ }
42
+ catch (error) {
43
+ throw new Error(buildSpawnFailureMessage("Claude CLI", bin, opts.workspacePath, error, "Check that the command is installed correctly, or override it with LEDUO_PATROL_CLAUDE_BIN."));
44
+ }
36
45
  this.pty.onData((data) => {
37
46
  this.emit("output", data);
38
47
  });
@@ -61,3 +70,38 @@ export class ClaudeCliSession extends EventEmitter {
61
70
  }
62
71
  }
63
72
  }
73
+ function findExecutableOnPath(command, envPath = process.env.PATH) {
74
+ if (!envPath)
75
+ return null;
76
+ const extensions = process.platform === "win32"
77
+ ? (process.env.PATHEXT?.split(";").filter(Boolean) ?? [".exe", ".cmd", ".bat"])
78
+ : [""];
79
+ for (const entry of envPath.split(path.delimiter).filter(Boolean)) {
80
+ for (const extension of extensions) {
81
+ const candidate = path.join(entry, `${command}${extension}`);
82
+ if (existsSync(candidate)) {
83
+ return candidate;
84
+ }
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ function resolveClaudeBin(configuredBin, env = process.env) {
90
+ const candidate = configuredBin?.trim() || "claude";
91
+ const hasExplicitPath = candidate.includes("/") || candidate.includes("\\");
92
+ if (hasExplicitPath) {
93
+ if (existsSync(candidate)) {
94
+ return candidate;
95
+ }
96
+ throw new Error(`Claude CLI not found at "${candidate}". Set LEDUO_PATROL_CLAUDE_BIN to a valid Claude executable path.`);
97
+ }
98
+ const resolved = findExecutableOnPath(candidate, env.PATH);
99
+ if (resolved) {
100
+ return resolved;
101
+ }
102
+ throw new Error(`Claude CLI "${candidate}" was not found in PATH. Install Claude Code first, or set LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude.`);
103
+ }
104
+ export const claudeCliSessionTestables = {
105
+ findExecutableOnPath,
106
+ resolveClaudeBin,
107
+ };
@@ -27,7 +27,8 @@ const vscodeRemoteUri = process.env.LEDUO_PATROL_VSCODE_URI ??
27
27
  (sshHost ? `vscode://vscode-remote/ssh-remote+${encodeURIComponent(sshHost)}${sshPath}` : "");
28
28
  const requestedPort = Number(process.env.PORT ?? 3001);
29
29
  const devWebPort = Number(process.env.LEDUO_PATROL_WEB_PORT ?? 5173);
30
- const isDevServer = process.env.npm_lifecycle_event === "dev:server";
30
+ const npmLifecycleEvent = process.env.npm_lifecycle_event ?? "";
31
+ const isDevServer = npmLifecycleEvent === "dev:server" || npmLifecycleEvent === "dev:server:local";
31
32
  const bindMode = await resolveBindMode();
32
33
  const listenHost = bindMode === "local" ? "127.0.0.1" : "0.0.0.0";
33
34
  const launchHost = bindMode === "local" ? "127.0.0.1" : pickPreferredLanIp();
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import path from "node:path";
2
3
  export function formatError(error) {
3
4
  if (error instanceof Error) {
@@ -21,3 +22,24 @@ export function resolveAllowedPath(requestedPath, allowedRoots) {
21
22
  }
22
23
  return resolvedPath;
23
24
  }
25
+ export function ensureDirectoryExistsSync(requestedPath, label) {
26
+ try {
27
+ const stats = statSync(requestedPath);
28
+ if (!stats.isDirectory()) {
29
+ throw new Error(`${label} is not a directory: ${requestedPath}`);
30
+ }
31
+ return requestedPath;
32
+ }
33
+ catch (error) {
34
+ if (error instanceof Error && error.message.includes("is not a directory")) {
35
+ throw error;
36
+ }
37
+ throw new Error(`${label} does not exist or is not accessible: ${requestedPath}`);
38
+ }
39
+ }
40
+ export function buildSpawnFailureMessage(commandLabel, command, cwd, error, hint) {
41
+ const quotedCommand = JSON.stringify(command);
42
+ const quotedCwd = JSON.stringify(cwd);
43
+ const message = `Failed to start ${commandLabel} ${quotedCommand} in ${quotedCwd}: ${formatError(error)}`;
44
+ return hint ? `${message}. ${hint}` : message;
45
+ }
@@ -56,7 +56,13 @@ export class SessionManager {
56
56
  }
57
57
  async initialize() {
58
58
  const persistedState = await this.readPersistedState();
59
+ let skippedPersistedSessions = false;
59
60
  for (const persisted of persistedState.sessions) {
61
+ if (!(await this.isRestorableWorkspace(persisted.workspacePath))) {
62
+ skippedPersistedSessions = true;
63
+ console.warn(`[SessionManager] Skipping persisted session with unavailable workspace: ${persisted.workspacePath}`);
64
+ continue;
65
+ }
60
66
  const snapshot = {
61
67
  clientSessionId: persisted.clientSessionId,
62
68
  title: persisted.title,
@@ -75,6 +81,9 @@ export class SessionManager {
75
81
  this.sessionIdIndex.set(snapshot.sessionId, snapshot.clientSessionId);
76
82
  this.activityMonitor.watch(snapshot.sessionId, snapshot.workspacePath);
77
83
  }
84
+ if (skippedPersistedSessions) {
85
+ await this.writePersistedState().catch(() => undefined);
86
+ }
78
87
  for (const entry of this.sessions.values()) {
79
88
  this.startCliSession(entry, true).catch((error) => {
80
89
  this.handleManagerError(entry.snapshot.clientSessionId, error);
@@ -279,6 +288,15 @@ export class SessionManager {
279
288
  await access(resolvedWorkspacePath);
280
289
  return resolvedWorkspacePath;
281
290
  }
291
+ async isRestorableWorkspace(workspacePath) {
292
+ try {
293
+ const workspaceStats = await stat(workspacePath);
294
+ return workspaceStats.isDirectory();
295
+ }
296
+ catch {
297
+ return false;
298
+ }
299
+ }
282
300
  // ---------------------------------------------------------------------------
283
301
  // /clear detection → session ID discovery
284
302
  // ---------------------------------------------------------------------------
@@ -1,45 +1,80 @@
1
1
  import { spawn } from "node-pty";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { existsSync } from "node:fs";
4
- // Resolve bash path; prefer the user's login shell if it is bash, then common locations
5
- function resolveBashPath() {
6
- const loginShell = process.env.SHELL ?? "";
7
- if (loginShell && /bash$/i.test(loginShell) && existsSync(loginShell)) {
8
- return loginShell;
4
+ import path from "node:path";
5
+ import { buildSpawnFailureMessage, ensureDirectoryExistsSync } from "./server-helpers.js";
6
+ function buildShellLoginArgs(command) {
7
+ const shellName = path.basename(command).toLowerCase();
8
+ if (["bash", "zsh", "ksh", "fish"].includes(shellName)) {
9
+ return ["-l"];
9
10
  }
10
- for (const candidate of ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]) {
11
- if (existsSync(candidate)) {
12
- return candidate;
11
+ return [];
12
+ }
13
+ function resolveShellLaunch(env = process.env, shellExists = existsSync) {
14
+ const configuredShell = env.LEDUO_PATROL_SHELL?.trim() ?? "";
15
+ const loginShell = env.SHELL?.trim() ?? "";
16
+ const absoluteCandidates = [
17
+ configuredShell,
18
+ loginShell,
19
+ "/bin/bash",
20
+ "/bin/zsh",
21
+ "/bin/sh",
22
+ "/usr/bin/bash",
23
+ "/usr/bin/zsh",
24
+ "/usr/local/bin/bash",
25
+ "/usr/local/bin/zsh",
26
+ "/opt/homebrew/bin/bash",
27
+ "/opt/homebrew/bin/zsh",
28
+ ].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
29
+ for (const candidate of absoluteCandidates) {
30
+ if (shellExists(candidate)) {
31
+ return {
32
+ command: candidate,
33
+ args: buildShellLoginArgs(candidate),
34
+ };
13
35
  }
14
36
  }
15
- // Fall back to plain "bash" and let the OS resolve it via PATH
16
- return "bash";
37
+ if (configuredShell) {
38
+ return {
39
+ command: configuredShell,
40
+ args: buildShellLoginArgs(configuredShell),
41
+ };
42
+ }
43
+ throw new Error(`No supported shell was found. Set LEDUO_PATROL_SHELL to an absolute shell path if your system does not provide /bin/bash, /bin/zsh, or /bin/sh.`);
17
44
  }
18
45
  /**
19
46
  * An interactive shell session backed by a PTY.
20
47
  *
21
- * Spawns a login shell (`bash --login`) that inherits the full user
22
- * environment and loads ~/.bash_profile / ~/.bashrc so that tools like
23
- * pyenv, nvm, brew, cargo, aliases, etc. are all available.
48
+ * Spawns the best-available interactive shell for the host environment.
49
+ * We prefer a login shell when possible so that user toolchains such as
50
+ * pyenv, nvm, brew, cargo, aliases, etc. are available in published installs too.
24
51
  */
25
52
  export class ShellSession extends EventEmitter {
26
53
  pty;
27
54
  _alive = true;
28
55
  constructor(workspacePath, cols = 80, rows = 24) {
29
56
  super();
57
+ ensureDirectoryExistsSync(workspacePath, "Shell workspace");
58
+ const shellLaunch = resolveShellLaunch();
30
59
  const env = {
31
60
  ...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] != null)),
32
61
  TERM: "xterm-256color",
33
62
  COLORTERM: "truecolor",
34
63
  PWD: workspacePath,
64
+ SHELL: shellLaunch.command,
35
65
  };
36
- this.pty = spawn(resolveBashPath(), ["--login"], {
37
- name: "xterm-256color",
38
- cols,
39
- rows,
40
- cwd: workspacePath,
41
- env,
42
- });
66
+ try {
67
+ this.pty = spawn(shellLaunch.command, shellLaunch.args, {
68
+ name: "xterm-256color",
69
+ cols,
70
+ rows,
71
+ cwd: workspacePath,
72
+ env,
73
+ });
74
+ }
75
+ catch (error) {
76
+ throw new Error(buildSpawnFailureMessage("shell", shellLaunch.command, workspacePath, error, "Set LEDUO_PATROL_SHELL to a valid shell path if this environment uses a non-standard shell location."));
77
+ }
43
78
  this.pty.onData((data) => {
44
79
  this.emit("output", data);
45
80
  });
@@ -68,3 +103,7 @@ export class ShellSession extends EventEmitter {
68
103
  }
69
104
  }
70
105
  }
106
+ export const shellSessionTestables = {
107
+ buildShellLoginArgs,
108
+ resolveShellLaunch,
109
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leduo-patrol",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "乐多汪汪队(leduo-patrol)web console with embedded Claude Code CLI terminals.",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",