chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
#!/bin/bash
# Electron Linux post-install script
# Set SUID bit on chrome-sandbox to enable proper sandboxing
#
# Security considerations:
# - Validates file type to prevent symlink attacks
# - Only sets SUID on valid ELF executables
# - Provides clear error messages for troubleshooting
#
# Note: This script runs as root during package installation
set -e
# Possible chrome-sandbox locations based on electron-builder defaults
# deb installs to /opt/{productName}/
# rpm installs to /opt/{productName}/ or /usr/lib/{packageName}/
SANDBOX_PATHS=(
"/opt/QimingClaw/chrome-sandbox"
"/opt/QimingClaw/resources/chrome-sandbox"
"/opt/qimingclaw/chrome-sandbox"
"/opt/qimingclaw/resources/chrome-sandbox"
"/usr/lib/qimingclaw/chrome-sandbox"
"/usr/lib64/qimingclaw/chrome-sandbox"
)
SANDBOX_FOUND=false
# Check if 'file' command is available (most systems have it, but be safe)
HAS_FILE_CMD=$(command -v file >/dev/null 2>&1 && echo "yes" || echo "no")
for SANDBOX_PATH in "${SANDBOX_PATHS[@]}"; do
if [ -e "$SANDBOX_PATH" ]; then
# Security: Skip symlinks to prevent symlink attacks
if [ -L "$SANDBOX_PATH" ]; then
echo "Warning: $SANDBOX_PATH is a symlink, skipping for security"
continue
fi
# Security: Verify it's a regular file
if [ ! -f "$SANDBOX_PATH" ]; then
echo "Warning: $SANDBOX_PATH is not a regular file, skipping"
continue
fi
# Security: Verify file type is ELF executable (if 'file' command is available)
if [ "$HAS_FILE_CMD" = "yes" ]; then
if ! file "$SANDBOX_PATH" 2>/dev/null | grep -qE "ELF.*(executable|shared object)"; then
echo "Warning: $SANDBOX_PATH is not a valid ELF binary, skipping"
continue
fi
else
# Fallback: Check file magic bytes for ELF header (0x7f ELF)
ELF_HEADER=$(head -c 4 "$SANDBOX_PATH" 2>/dev/null | od -A n -t x1 | tr -d ' ')
if [ "$ELF_HEADER" != "7f454c46" ]; then
echo "Warning: $SANDBOX_PATH does not have valid ELF header, skipping"
continue
fi
fi
echo "Found chrome-sandbox at $SANDBOX_PATH"
# Set ownership and SUID bit
if chown root:root "$SANDBOX_PATH" 2>/dev/null; then
if chmod 4755 "$SANDBOX_PATH" 2>/dev/null; then
echo "SUID sandbox enabled successfully."
SANDBOX_FOUND=true
break
else
echo "Warning: Failed to set permissions on $SANDBOX_PATH"
fi
else
echo "Warning: Failed to change ownership of $SANDBOX_PATH"
fi
fi
done
if [ "$SANDBOX_FOUND" = false ]; then
echo "Warning: chrome-sandbox not found or validation failed."
echo "The application may need to run with ELECTRON_DISABLE_SANDBOX=1"
fi
exit 0

View File

@@ -0,0 +1,301 @@
#!/usr/bin/env node
/**
* Sandboxed Bash MCP Server
*
* Replaces Claude Code's built-in Bash tool with a sandboxed version
* that routes all commands through qiming-sandbox-helper.exe run.
*
* Environment variables:
* QIMING_SANDBOX_HELPER_PATH — Path to qiming-sandbox-helper.exe
* QIMING_SANDBOX_MODE — "read-only" | "workspace-write"
* QIMING_SANDBOX_NETWORK_ENABLED — "1" | "0"
* QIMING_SANDBOX_WRITABLE_ROOTS — JSON array of writable paths
* QIMING_SANDBOX_PATH — Pre-built PATH with bundled node/git/uv
* QIMING_SANDBOX_GIT_BASH_PATH — Path to bundled bash.exe (Git for Windows)
*
* The built-in Bash is disabled via _meta.claudeCode.options.disallowedTools,
* so this MCP tool becomes the only way to execute commands.
*
* Tool name: "Bash" (appears as mcp__sandboxed-bash__Bash to the model)
*/
import { spawn } from "node:child_process";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import {
buildSandboxHelperEnv,
resolveSandboxWorkingDirectory,
} from "./sandboxed-bash-security.mjs";
// ---- Configuration from environment ----
const HELPER_PATH = process.env.QIMING_SANDBOX_HELPER_PATH;
const SANDBOX_MODE = process.env.QIMING_SANDBOX_MODE || "read-only";
const NETWORK_ENABLED = process.env.QIMING_SANDBOX_NETWORK_ENABLED !== "0";
const WRITABLE_ROOTS = JSON.parse(
process.env.QIMING_SANDBOX_WRITABLE_ROOTS || "[]",
);
const SANDBOX_PATH = process.env.QIMING_SANDBOX_PATH || "";
const GIT_BASH_PATH = process.env.QIMING_SANDBOX_GIT_BASH_PATH || "";
if (!HELPER_PATH) {
process.stderr.write(
"[sandboxed-bash] FATAL: QIMING_SANDBOX_HELPER_PATH not set\n",
);
process.exit(1);
}
// Resolve shell executable:
// 1. Git Bash (supports &&, ||, 2>/dev/null, pipes, etc.) — preferred
// 2. Fallback: PowerShell (limited bash compat but always available)
function resolveShell() {
if (GIT_BASH_PATH) {
return { cmd: GIT_BASH_PATH, args: ["-c"], type: "bash" };
}
return {
cmd: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
args: ["-NoProfile", "-NonInteractive", "-Command"],
type: "powershell",
};
}
const shell = resolveShell();
const SANDBOX_CWD = resolveSandboxWorkingDirectory(
process.cwd(),
SANDBOX_MODE,
WRITABLE_ROOTS,
);
// ---- Tool definition (matches built-in Bash schema) ----
const BASH_TOOL = {
name: "Bash",
description: `Executes a bash command in a sandboxed environment.
The command runs inside a Windows restricted token sandbox that limits
file system writes and network access according to the configured policy.
In sessions with mcp__sandboxed-bash__Bash always use it instead of the
built-in Bash tool (which is disabled).`,
inputSchema: {
type: "object",
properties: {
command: {
type: "string",
description: "The bash command to execute",
},
timeout: {
type: "number",
description:
"Optional timeout in milliseconds (max 120000). The sandbox helper has a 5-minute hard limit.",
},
description: {
type: "string",
description:
"Clear, concise description of what this command does in 5-10 words, in active voice.",
},
},
required: ["command"],
},
};
// ---- Create MCP server ----
const server = new Server(
{ name: "sandboxed-bash", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
// ---- List tools handler ----
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [BASH_TOOL],
}));
// ---- Call tool handler ----
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== "Bash") {
return {
content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
isError: true,
};
}
const { command, timeout: timeoutMs } = request.params.arguments || {};
if (!command || typeof command !== "string") {
return {
content: [{ type: "text", text: "Error: 'command' argument is required" }],
isError: true,
};
}
// Build sandbox policy JSON (matches Rust SandboxPolicy enum)
const policy = {
type: SANDBOX_MODE === "workspace-write" ? "workspace-write" : "read-only",
network_access: NETWORK_ENABLED,
...(WRITABLE_ROOTS.length > 0 ? { writable_roots: WRITABLE_ROOTS } : {}),
};
// The sandbox helper's split_command() takes args.command[0] as the
// executable and command[1..] as its arguments. We wrap the user's
// command with Git Bash (-c) for full bash syntax compatibility
// (&&, ||, 2>/dev/null, pipes, redirects). Falls back to PowerShell.
const helperArgs = [
"run",
"--no-write-restricted",
"--mode",
SANDBOX_MODE,
"--cwd",
SANDBOX_CWD,
"--policy-json",
JSON.stringify(policy),
"--",
shell.cmd,
...shell.args,
command,
];
log("spawning sandbox helper", {
mode: SANDBOX_MODE,
cwd: SANDBOX_CWD,
networkEnabled: NETWORK_ENABLED,
shell: shell.type,
commandPreview: command.slice(0, 120),
});
try {
const result = await executeHelper(helperArgs, timeoutMs);
// Format output like built-in Bash
const parts = [];
if (result.stdout) parts.push(result.stdout);
if (result.stderr) parts.push(result.stderr);
const output = parts.join("\n") || "(no output)";
return {
content: [{ type: "text", text: output }],
isError: result.exit_code !== 0,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Sandbox execution error: ${msg}` }],
isError: true,
};
}
});
// ---- Helper execution ----
/**
* Spawn qiming-sandbox-helper.exe and collect JSON output.
*
* The helper's `run` subcommand returns:
* { exit_code: number, stdout: string, stderr: string, timed_out: boolean }
*/
function executeHelper(helperArgs, timeoutMs) {
return new Promise((resolve, reject) => {
// Build sanitized environment to avoid leaking host secrets to helper.
const env = buildSandboxHelperEnv(process.env, SANDBOX_PATH);
const child = spawn(HELPER_PATH, helperArgs, {
cwd: SANDBOX_CWD,
env,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data.toString();
});
child.stderr.on("data", (data) => {
stderr += data.toString();
});
// Optional Node.js-level timeout (supplement helper's built-in 5min limit)
let timer = null;
if (timeoutMs && timeoutMs > 0) {
const capped = Math.min(timeoutMs, 120_000);
timer = setTimeout(() => {
log("timeout reached, killing process", { timeoutMs: capped });
try {
child.kill("SIGKILL");
} catch {
// ignore
}
}, capped);
}
child.on("close", (code, signal) => {
if (timer) clearTimeout(timer);
// The helper outputs JSON to stdout on success
try {
const parsed = JSON.parse(stdout);
log("helper result", {
exit_code: parsed.exit_code,
stdoutLen: (parsed.stdout || "").length,
stderrLen: (parsed.stderr || "").length,
timed_out: parsed.timed_out,
});
resolve({
exit_code: parsed.exit_code ?? (code ?? 1),
stdout: parsed.stdout || "",
stderr: parsed.stderr || "",
timed_out: parsed.timed_out || false,
});
} catch {
// JSON parse failed — helper may have crashed or returned raw output
log("JSON parse failed, returning raw output", {
code,
signal,
stdoutLen: stdout.length,
stderrLen: stderr.length,
});
resolve({
exit_code: code ?? 1,
stdout: stdout,
stderr: stderr,
timed_out: signal === "SIGKILL",
});
}
});
child.on("error", (err) => {
if (timer) clearTimeout(timer);
reject(err);
});
});
}
// ---- Logging (to stderr — stdout is MCP JSON-RPC) ----
function log(message, data) {
process.stderr.write(
`[sandboxed-bash] ${message}${data ? " " + JSON.stringify(data) : ""}\n`,
);
}
// ---- Start ----
const transport = new StdioServerTransport();
await server.connect(transport);
log("ready", {
helper: HELPER_PATH,
mode: SANDBOX_MODE,
networkEnabled: NETWORK_ENABLED,
writableRoots: WRITABLE_ROOTS,
shell: shell.type,
shellCmd: shell.cmd,
sandboxPath: SANDBOX_PATH ? SANDBOX_PATH.split(";").length + " dirs" : "(none)",
});

View File

@@ -0,0 +1,73 @@
import path from "node:path";
export const SANDBOX_SAFE_ENV_KEYS = [
"PATH",
"Path",
"SYSTEMROOT",
"SystemRoot",
"WINDIR",
"windir",
"SYSTEMDRIVE",
"SystemDrive",
"COMSPEC",
"ComSpec",
"PATHEXT",
"PATHExt",
"TEMP",
"TMP",
"USERPROFILE",
"HOME",
"LOCALAPPDATA",
"APPDATA",
"COMPUTERNAME",
"USERNAME",
"OS",
"PROCESSOR_ARCHITECTURE",
"LANG",
"TZ",
];
export function isWithinRoot(candidate, root) {
const rel = path.relative(root, candidate);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
export function resolveSandboxWorkingDirectory(
requestedCwd,
sandboxMode,
writableRoots,
) {
const resolvedCwd = path.resolve(requestedCwd);
if (sandboxMode !== "workspace-write") {
return resolvedCwd;
}
const roots = writableRoots.map((root) => path.resolve(root));
if (roots.length === 0) {
return resolvedCwd;
}
if (roots.some((root) => isWithinRoot(resolvedCwd, root))) {
return resolvedCwd;
}
return roots[0];
}
export function buildSandboxHelperEnv(baseEnv, sandboxPath) {
const env = {};
for (const key of SANDBOX_SAFE_ENV_KEYS) {
const value = baseEnv[key];
if (value !== undefined && value !== null) {
env[key] = String(value);
}
}
if (sandboxPath) {
env.PATH = sandboxPath + ";" + (env.PATH || "");
}
// Avoid inheriting electron-specific execution mode.
delete env.ELECTRON_RUN_AS_NODE;
return env;
}

View File

@@ -0,0 +1,373 @@
#!/usr/bin/env node
/**
* Sandboxed File System MCP Server
*
* Replaces Claude Code's built-in Write and Edit tools with sandboxed versions
* that validate all target paths against QIMING_SANDBOX_WRITABLE_ROOTS before
* performing file I/O.
*
* Environment variables:
* QIMING_SANDBOX_WRITABLE_ROOTS — JSON array of writable paths
* QIMING_SANDBOX_MODE — "strict" | "compat" (permissive skips this MCP entirely)
* TEMP / TMP — System temp directories (always allowed)
* APPDATA / LOCALAPPDATA — Application data (allowed in compat mode)
*
* The built-in Write/Edit/NotebookEdit are disabled via
* _meta.claudeCode.options.disallowedTools, so this MCP provides the only
* way to write files in sandbox mode.
*
* Tools:
* - Write (mcp__sandboxed-fs__Write)
* - Edit (mcp__sandboxed-fs__Edit)
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync, realpathSync } from "node:fs";
import path from "node:path";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { isWithinRoot } from "./sandboxed-bash-security.mjs";
// ---- Configuration from environment ----
// Resolve a path, falling back gracefully if it does not exist yet.
function resolveReal(p) {
try {
return realpathSync(p);
} catch {
return path.resolve(p);
}
}
const SANDBOX_MODE = process.env.QIMING_SANDBOX_MODE || "strict";
const WRITABLE_ROOTS = JSON.parse(
process.env.QIMING_SANDBOX_WRITABLE_ROOTS || "[]",
).map((p) => resolveReal(path.resolve(p)));
// Always include TEMP/TMP as writable (matching Rust allow.rs behavior)
const ADDITIONAL_ROOTS = [process.env.TEMP, process.env.TMP]
.filter(Boolean)
.map((p) => resolveReal(path.resolve(p)));
// compat mode: also allow APPDATA / LOCALAPPDATA (application data dirs)
const COMPAT_ROOTS = SANDBOX_MODE === "compat"
? [process.env.APPDATA, process.env.LOCALAPPDATA]
.filter(Boolean)
.map((p) => resolveReal(path.resolve(p)))
: [];
const ALL_WRITABLE_ROOTS = [...WRITABLE_ROOTS, ...ADDITIONAL_ROOTS, ...COMPAT_ROOTS];
// ---- Path validation ----
function validatePath(targetPath) {
if (!targetPath || typeof targetPath !== "string") {
return { allowed: false, error: "Sandbox: file_path is required" };
}
let resolved = path.resolve(targetPath);
// Resolve symlinks for defense-in-depth
try {
resolved = realpathSync(resolved);
} catch {
// File doesn't exist yet — resolve the parent directory
try {
const parentReal = realpathSync(path.dirname(resolved));
resolved = path.join(parentReal, path.basename(resolved));
} catch {
// Parent also doesn't exist — best effort with path.resolve
}
}
if (ALL_WRITABLE_ROOTS.length === 0) {
return {
allowed: false,
error: `Sandbox: No writable roots configured. Path rejected: ${resolved}`,
};
}
for (const root of ALL_WRITABLE_ROOTS) {
if (isWithinRoot(resolved, root)) {
return { allowed: true, resolved };
}
}
return {
allowed: false,
error: `Sandbox: Path outside writable roots: ${resolved}. Allowed: ${ALL_WRITABLE_ROOTS.join(", ")}`,
};
}
// ---- Helpers ----
async function ensureParentDir(filePath) {
await mkdir(path.dirname(filePath), { recursive: true });
}
// ---- Tool definitions ----
const WRITE_TOOL = {
name: "Write",
description: `Writes content to a file, creating it if it does not exist.
All file paths are validated against the sandbox writable roots.
In sessions with mcp__sandboxed-fs__Write always use it instead of the
built-in Write tool (which is disabled in sandbox mode).`,
inputSchema: {
type: "object",
properties: {
file_path: {
type: "string",
description:
"Absolute path to the file to write (must be within writable roots)",
},
content: {
type: "string",
description: "The content to write to the file",
},
},
required: ["file_path", "content"],
},
};
const EDIT_TOOL = {
name: "Edit",
description: `Performs exact string replacements in a file.
All file paths are validated against the sandbox writable roots.
In sessions with mcp__sandboxed-fs__Edit always use it instead of the
built-in Edit tool (which is disabled in sandbox mode).`,
inputSchema: {
type: "object",
properties: {
file_path: {
type: "string",
description:
"Absolute path to the file to edit (must be within writable roots)",
},
old_string: {
type: "string",
description: "The text to replace",
},
new_string: {
type: "string",
description: "The text to replace it with",
},
replace_all: {
type: "boolean",
description: "Replace all occurrences of old_string (default false)",
},
},
required: ["file_path", "old_string", "new_string"],
},
};
// ---- Create MCP server ----
const server = new Server(
{ name: "sandboxed-fs", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
// ---- List tools handler ----
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [WRITE_TOOL, EDIT_TOOL],
}));
// ---- Call tool handler ----
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name } = request.params;
const args = request.params.arguments || {};
if (name === "Write") {
return await handleWrite(args);
} else if (name === "Edit") {
return await handleEdit(args);
}
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
});
// ---- Write handler ----
async function handleWrite(args) {
const { file_path, content } = args;
if (!file_path || typeof file_path !== "string") {
return {
content: [{ type: "text", text: "Error: file_path is required" }],
isError: true,
};
}
if (typeof content !== "string") {
return {
content: [{ type: "text", text: "Error: content must be a string" }],
isError: true,
};
}
const validation = validatePath(file_path);
if (!validation.allowed) {
return { content: [{ type: "text", text: validation.error }], isError: true };
}
try {
await ensureParentDir(validation.resolved);
await writeFile(validation.resolved, content, "utf-8");
log("file written", { path: validation.resolved, size: content.length });
return {
content: [
{ type: "text", text: `Successfully wrote to ${validation.resolved}` },
],
isError: false,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Write error: ${msg}` }],
isError: true,
};
}
}
// ---- Edit handler ----
async function handleEdit(args) {
const { file_path, old_string, new_string, replace_all } = args;
if (!file_path || typeof file_path !== "string") {
return {
content: [{ type: "text", text: "Error: file_path is required" }],
isError: true,
};
}
if (!old_string || typeof old_string !== "string") {
return {
content: [
{ type: "text", text: "Error: old_string must be a non-empty string" },
],
isError: true,
};
}
if (typeof new_string !== "string") {
return {
content: [
{
type: "text",
text: "Error: new_string must be a string",
},
],
isError: true,
};
}
const validation = validatePath(file_path);
if (!validation.allowed) {
return { content: [{ type: "text", text: validation.error }], isError: true };
}
try {
if (!existsSync(validation.resolved)) {
return {
content: [
{ type: "text", text: `File not found: ${validation.resolved}` },
],
isError: true,
};
}
const currentContent = await readFile(validation.resolved, "utf-8");
if (!currentContent.includes(old_string)) {
return {
content: [
{
type: "text",
text: `old_string not found in file. The exact text was not found.`,
},
],
isError: true,
};
}
let count = 0;
let newContent;
if (replace_all) {
// Count occurrences
let idx = 0;
while ((idx = currentContent.indexOf(old_string, idx)) !== -1) {
count++;
idx += old_string.length;
}
newContent = currentContent.split(old_string).join(new_string);
} else {
// Check for multiple occurrences when replace_all is not set
const firstIdx = currentContent.indexOf(old_string);
const secondIdx = currentContent.indexOf(old_string, firstIdx + 1);
if (secondIdx !== -1) {
const total =
currentContent.split(old_string).length - 1;
return {
content: [
{
type: "text",
text: `old_string matches ${total} occurrences. Use replace_all: true to replace all, or provide a larger old_string to uniquely identify the location.`,
},
],
isError: true,
};
}
count = 1;
newContent = currentContent.replace(old_string, new_string);
}
await writeFile(validation.resolved, newContent, "utf-8");
log("file edited", {
path: validation.resolved,
replacements: count,
replaceAll: !!replace_all,
});
return {
content: [
{
type: "text",
text: `Successfully edited ${validation.resolved} (${count} replacement${count > 1 ? "s" : ""})`,
},
],
isError: false,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Edit error: ${msg}` }],
isError: true,
};
}
}
// ---- Logging (to stderr — stdout is MCP JSON-RPC) ----
function log(message, data) {
process.stderr.write(
`[sandboxed-fs] ${message}${data ? " " + JSON.stringify(data) : ""}\n`,
);
}
// ---- Start ----
const transport = new StdioServerTransport();
await server.connect(transport);
log("ready", {
mode: SANDBOX_MODE,
writableRoots: ALL_WRITABLE_ROOTS,
});

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# prepare-source-claude-code-acp-ts.sh
# 从 GitHub 克隆并构建 claude-code-acp-ts 源码
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ELECTRON_CLIENT_DIR="$(dirname "$SCRIPT_DIR")"
PROJECT_ROOT="$(dirname "$ELECTRON_CLIENT_DIR")"
SOURCE_DIR="$ELECTRON_CLIENT_DIR/sources/claude-code-acp-ts"
GIT_REPO="https://github.com/qiming-ai/claude-code-acp-ts.git"
BRANCH="feat/claude-code-acp-ts"
echo "[prepare-source-claude-code-acp-ts] 开始准备源码..."
# 1. 克隆或更新源码
if [ -d "$SOURCE_DIR/.git" ]; then
echo "[prepare-source-claude-code-acp-ts] 更新源码..."
cd "$SOURCE_DIR" && git checkout "$BRANCH" && git pull
else
echo "[prepare-source-claude-code-acp-ts] 克隆源码..."
mkdir -p "$(dirname "$SOURCE_DIR")"
git clone --branch "$BRANCH" "$GIT_REPO" "$SOURCE_DIR"
fi
# 2. 清理旧的 node_modules
if [ -d "$SOURCE_DIR/node_modules" ]; then
echo "[prepare-source-claude-code-acp-ts] 清理旧的 node_modules..."
rm -rf "$SOURCE_DIR/node_modules"
fi
# 3. 安装依赖
echo "[prepare-source-claude-code-acp-ts] 安装依赖..."
cd "$SOURCE_DIR" && npm install --ignore-scripts
# 4. 构建 TypeScript使用 npx tsc 替代 npm run build避免 Windows 上 ./node_modules/.bin/tsc 路径不兼容)
echo "[prepare-source-claude-code-acp-ts] 构建项目..."
cd "$SOURCE_DIR" && npx tsc
echo "[prepare-source-claude-code-acp-ts] ✓ 源码准备完成 (claude-code-acp-ts@$(node -p "require('./package.json').version"))"

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# prepare-source-qiming-file-server.sh
# 从 GitHub 克隆并构建 qiming-file-server 源码
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ELECTRON_CLIENT_DIR="$(dirname "$SCRIPT_DIR")"
PROJECT_ROOT="$(dirname "$ELECTRON_CLIENT_DIR")"
SOURCE_DIR="$ELECTRON_CLIENT_DIR/sources/qiming-file-server"
GIT_REPO="https://github.com/qiming-ai/qiming-file-server.git"
BRANCH="main"
echo "[prepare-source-qiming-file-server] 开始准备源码..."
# 1. 克隆或更新源码
if [ -d "$SOURCE_DIR/.git" ]; then
echo "[prepare-source-qiming-file-server] 更新源码..."
cd "$SOURCE_DIR" && git checkout "$BRANCH" && git pull
else
echo "[prepare-source-qiming-file-server] 克隆源码..."
mkdir -p "$(dirname "$SOURCE_DIR")"
git clone --branch "$BRANCH" "$GIT_REPO" "$SOURCE_DIR"
fi
# 2. 清理旧的 node_modules
if [ -d "$SOURCE_DIR/node_modules" ]; then
echo "[prepare-source-qiming-file-server] 清理旧的 node_modules..."
rm -rf "$SOURCE_DIR/node_modules"
fi
# 3. 安装依赖
echo "[prepare-source-qiming-file-server] 安装依赖..."
cd "$SOURCE_DIR" && npm install --ignore-scripts
# 4. 构建
echo "[prepare-source-qiming-file-server] 构建项目..."
cd "$SOURCE_DIR" && npm run build
echo "[prepare-source-qiming-file-server] ✓ 源码准备完成 (qiming-file-server@$(node -p "require('./package.json').version"))"

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* 构建 Windows 专用 Sandbox helperqiming-sandbox-helper.exe
* 产物仅被 Windows 版 Electron 客户端加载;建议在 Windows 宿主上执行,或在 macOS/Linux 上使用 --win 交叉编译。
*
* Usage:
* npm run build:sandbox-helper # Windows 宿主:本机构建
* npm run build:sandbox-helper -- --win # 非 Windows交叉编译 Windows x64
*
* Output: resources/sandbox-helper/qiming-sandbox-helper.exe
*/
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const { getProjectRoot, resolveFromProject } = require("../utils/project-paths");
const projectRoot = getProjectRoot();
/**
* crate 位于 monorepo crates/ 下,与 agent-electron-client 同级。
* getProjectRoot() 返回 crates/agent-electron-client故 crate 路径为
* path.resolve(projectRoot, '..', 'windows-sandbox-helper')。
*/
const crateDir = path.resolve(projectRoot, "..", "windows-sandbox-helper");
const outputDir = resolveFromProject("resources", "sandbox-helper");
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function main() {
const args = process.argv.slice(2);
const isWinTarget = args.includes("--win");
// Check if Rust is available
try {
execSync("rustc --version", { stdio: "pipe" });
} catch {
console.warn(
"[build-sandbox-helper] Rust 未安装跳过Windows Sandbox helper 仅在 Windows 上需要)",
);
return;
}
// Check if we're on Windows or targeting Windows
const onWindows = process.platform === "win32";
if (!onWindows && !isWinTarget) {
console.warn(
"[build-sandbox-helper] 非 Windows 环境;如需交叉编译请使用: npm run build:sandbox-helper -- --win",
);
return;
}
if (!fs.existsSync(path.join(crateDir, "Cargo.toml"))) {
console.error(
`[build-sandbox-helper] 未找到 crate 目录: ${crateDir}(请确认 monorepo 中存在 crates/windows-sandbox-helper`,
);
process.exit(1);
}
ensureDir(outputDir);
const buildArgs = ["cargo", "build", "--release", "--bin", "qiming-sandbox-helper"];
if (isWinTarget) {
if (process.platform === "win32") {
console.warn("[build-sandbox-helper] --win 在 Windows 上无效(已是 Windows");
} else {
// Cross-compile for Windows x64
const target = "x86_64-pc-windows-msvc";
console.log(`[build-sandbox-helper] 交叉编译 Windows x64target: ${target}`);
buildArgs.push("--target", target);
}
}
console.log(`[build-sandbox-helper] 构建: ${buildArgs.join(" ")}`);
console.log(`[build-sandbox-helper] crate: ${crateDir}`);
try {
execSync(buildArgs.join(" "), {
cwd: crateDir,
stdio: "inherit",
});
// Determine source path
let srcPath;
if (isWinTarget && process.platform !== "win32") {
srcPath = path.join(
crateDir,
"target",
"x86_64-pc-windows-msvc",
"release",
"qiming-sandbox-helper.exe",
);
} else {
srcPath = path.join(crateDir, "target", "release", "qiming-sandbox-helper.exe");
}
if (!fs.existsSync(srcPath)) {
if (isWinTarget && process.platform !== "win32") {
srcPath = path.join(
crateDir,
"target",
"x86_64-pc-windows-msvc",
"debug",
"qiming-sandbox-helper.exe",
);
} else {
srcPath = path.join(crateDir, "target", "debug", "qiming-sandbox-helper.exe");
}
}
if (!fs.existsSync(srcPath)) {
throw new Error(`构建产物未找到: ${srcPath}`);
}
const destPath = path.join(outputDir, "qiming-sandbox-helper.exe");
fs.copyFileSync(srcPath, destPath);
const stats = fs.statSync(destPath);
console.log(
`[build-sandbox-helper] 完成: ${destPath} (${(stats.size / 1024).toFixed(1)} KB)`,
);
} catch (error) {
if (error instanceof Error && error.message.includes("build产物未找到")) {
throw error;
}
console.error(
"[build-sandbox-helper] 构建失败(可能是因为非 Windows 平台):",
error instanceof Error ? error.message : String(error),
);
process.exit(1);
}
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* 准备 claude-code-acp-ts 源码并复制到 resources/
*
* 逻辑:
* 1. 若 sources/claude-code-acp-ts 不存在 → git clone + npm install + npm run build
* 2. 若存在但无 node_modules → npm install + npm run build
* 3. 否则跳过构建(认为已就绪)
* 4. 复制到 resources/claude-code-acp-ts/
*
* 产物:
* resources/claude-code-acp-ts/
* ├── dist/
* ├── node_modules/
* └── package.json
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { getProjectRoot } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const electronClientRoot = projectRoot;
// 从 package.json 读取源码地址
const pkgJson = JSON.parse(fs.readFileSync(path.join(electronClientRoot, 'package.json'), 'utf8'));
const { url: GIT_REPO, branch: GIT_BRANCH } = pkgJson.bundledSources['claude-code-acp-ts'];
const SOURCE_DIR = path.join(electronClientRoot, 'sources', 'claude-code-acp-ts');
const destDir = path.join(electronClientRoot, 'resources', 'claude-code-acp-ts');
function exec(cmd, opts = {}) {
console.log(` $ ${cmd}`);
execSync(cmd, { stdio: 'inherit', ...opts });
}
function main() {
// 1. 克隆或更新源码
if (!fs.existsSync(path.join(SOURCE_DIR, '.git'))) {
console.log('[prepare-claude-code-acp-ts] 克隆源码...');
exec(`git clone --branch ${GIT_BRANCH} ${GIT_REPO} "${SOURCE_DIR}"`);
} else {
console.log('[prepare-claude-code-acp-ts] 更新源码...');
exec(`cd "${SOURCE_DIR}" && git checkout ${GIT_BRANCH} && git pull`);
}
// 2. 检查构建产物是否存在
const hasBuild = fs.existsSync(path.join(SOURCE_DIR, 'dist'));
const hasNodeModules = fs.existsSync(path.join(SOURCE_DIR, 'node_modules'));
if (!hasBuild || !hasNodeModules) {
// 清理旧的 node_modules若有
if (fs.existsSync(path.join(SOURCE_DIR, 'node_modules'))) {
console.log('[prepare-claude-code-acp-ts] 清理旧的 node_modules...');
exec(`rm -rf "${path.join(SOURCE_DIR, 'node_modules')}"`);
}
// 3. 安装依赖
console.log('[prepare-claude-code-acp-ts] 安装依赖...');
exec(`cd "${SOURCE_DIR}" && npm install --ignore-scripts`);
// 4. 构建
// 注意:不用 npm run build因为其 build 脚本可能是 ./node_modules/.bin/tscUnix 风格Windows 不认识
//改用 npx tsc 替代,可跨平台
console.log('[prepare-claude-code-acp-ts] 构建项目...');
exec(`cd "${SOURCE_DIR}" && npx tsc`);
} else {
console.log('[prepare-claude-code-acp-ts] 构建产物已就绪,跳过构建');
}
// 5. 读取版本
const srcPkg = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'package.json'), 'utf8'));
console.log(`[prepare-claude-code-acp-ts] 源码版本: ${srcPkg.name}@${srcPkg.version}`);
// 6. 清理并创建目标目录
if (fs.existsSync(destDir)) {
fs.rmSync(destDir, { recursive: true });
}
fs.mkdirSync(destDir, { recursive: true });
// 7. 复制 dist/
console.log('[prepare-claude-code-acp-ts] 复制 dist/...');
exec(`cp -R "${path.join(SOURCE_DIR, 'dist')}" "${destDir}/"`);
// 8. 复制 package.json
fs.copyFileSync(
path.join(SOURCE_DIR, 'package.json'),
path.join(destDir, 'package.json')
);
// 9. 复制 node_modules/
console.log('[prepare-claude-code-acp-ts] 复制 node_modules/...');
exec(`cp -R "${path.join(SOURCE_DIR, 'node_modules')}" "${destDir}/"`);
// 10. 复制 LICENSE
const licenseSrc = path.join(SOURCE_DIR, 'LICENSE');
if (fs.existsSync(licenseSrc)) {
fs.copyFileSync(licenseSrc, path.join(destDir, 'LICENSE'));
}
console.log(`[prepare-claude-code-acp-ts] ✓ resources/claude-code-acp-ts/ (${srcPkg.version})`);
}
main();

View File

@@ -0,0 +1,343 @@
#!/usr/bin/env node
/**
* Git 集成(仅 Windows 客户端)
*
* 仅在 Windows 构建时执行:准备 resources/git/PortableGit + bash供端口检查等脚本使用。
* macOS/Linux 使用系统 git/bash不打包内置 Git。
*
* 参考 LobsterAI 方案scripts/setup-mingit.jsworkspace/lobsterAI
*
* - Windows下载 PortableGit.7z.exe并用 7zip-bin 解压,或使用本地归档/缓存
* - 非 Windows默认跳过可通过 --required 或 QIMING_SETUP_GIT_FORCE=1 在 macOS 上为 Windows 打包做准备
*
* 环境变量:
* QIMING_PORTABLE_GIT_ARCHIVE - 本地离线归档路径CI 推荐预先下载后设置)
* QIMING_GIT_URL - 下载地址覆盖(镜像等)
* QIMING_SETUP_GIT_FORCE=1 - 非 Windows 主机也执行准备(用于在 macOS 上打 Windows 包)
*
* CLI
* --required - 若无法准备则失败CI 中建议配合 QIMING_PORTABLE_GIT_ARCHIVE 使用)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { Readable } = require('stream');
const { pipeline } = require('stream/promises');
const { getProjectRoot } = require('../utils/project-paths');
// 与 LobsterAI 保持一致
const GIT_VERSION = '2.47.1';
const PORTABLE_GIT_FILE = `PortableGit-${GIT_VERSION}-64-bit.7z.exe`;
const DEFAULT_PORTABLE_GIT_URL =
`https://github.com/git-for-windows/git/releases/download/v${GIT_VERSION}.windows.1/${PORTABLE_GIT_FILE}`;
const PROJECT_ROOT = getProjectRoot();
const GIT_ROOT = path.join(PROJECT_ROOT, 'resources', 'git');
const CACHE_DIR = path.join(GIT_ROOT, '.cache');
const DEFAULT_ARCHIVE_PATH = path.join(CACHE_DIR, PORTABLE_GIT_FILE);
// 需要删除的目录(与 LobsterAI 一致,减小体积)
const DIRS_TO_PRUNE = [
'doc',
'ReleaseNotes.html',
'README.portable',
path.join('mingw64', 'doc'),
path.join('mingw64', 'share', 'doc'),
path.join('mingw64', 'share', 'gtk-doc'),
path.join('mingw64', 'share', 'man'),
path.join('mingw64', 'share', 'gitweb'),
path.join('mingw64', 'share', 'git-gui'),
path.join('mingw64', 'libexec', 'git-core', 'git-gui'),
path.join('mingw64', 'libexec', 'git-core', 'git-gui--askpass'),
path.join('usr', 'share', 'doc'),
path.join('usr', 'share', 'man'),
path.join('usr', 'share', 'vim'),
path.join('usr', 'share', 'perl5'),
path.join('usr', 'lib', 'perl5'),
];
function parseArgs(argv) {
return {
required: argv.includes('--required'),
};
}
function resolveInputPath(input) {
if (typeof input !== 'string') return null;
const trimmed = input.trim();
if (!trimmed) return null;
return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
}
function isNonEmptyFile(filePath) {
try {
return fs.statSync(filePath).isFile() && fs.statSync(filePath).size > 0;
} catch {
return false;
}
}
function getDirSize(dir) {
let size = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
size += getDirSize(full);
} else {
size += fs.statSync(full).size;
}
}
return size;
}
/** 解析 7zip-bin 可执行路径(与 LobsterAI 一致) */
function resolve7zaPath() {
let path7za;
try {
({ path7za } = require('7zip-bin'));
} catch (error) {
throw new Error(
'缺少依赖 "7zip-bin",请先 npm install 后重试。' +
`原始错误: ${error instanceof Error ? error.message : String(error)}`
);
}
if (!path7za || !fs.existsSync(path7za)) {
throw new Error(`7zip-bin 可执行文件未找到: ${path7za || '(空路径)'}`);
}
return path7za;
}
/**
* 查找 PortableGit 的 bash.exe 路径(与 LobsterAI findPortableGitBash 一致)
* @param {string} [baseDir=GIT_ROOT]
* @returns {string|null}
*/
function findPortableGitBash(baseDir = GIT_ROOT) {
const candidates = [
path.join(baseDir, 'bin', 'bash.exe'),
path.join(baseDir, 'usr', 'bin', 'bash.exe'),
path.join(baseDir, 'mingw64', 'bin', 'bash.exe'),
path.join(baseDir, 'mingw64', 'usr', 'bin', 'bash.exe'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/** 使用 fetch + stream 下载归档(与 LobsterAI 一致Node 18+ */
async function downloadArchive(url, destination) {
const response = await fetch(url, { redirect: 'follow' });
if (!response.ok || !response.body) {
throw new Error(`下载失败 (${response.status} ${response.statusText}): ${url}`);
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
const tmpFile = `${destination}.download`;
try {
const stream = fs.createWriteStream(tmpFile);
await pipeline(Readable.fromWeb(response.body), stream);
if (!isNonEmptyFile(tmpFile)) {
throw new Error('下载的归档为空。');
}
fs.renameSync(tmpFile, destination);
} catch (error) {
try {
fs.rmSync(tmpFile, { force: true });
} catch {
// ignore
}
throw error;
}
}
function pruneUnneededFiles() {
let prunedCount = 0;
for (const relPath of DIRS_TO_PRUNE) {
const fullPath = path.join(GIT_ROOT, relPath);
if (!fs.existsSync(fullPath)) continue;
try {
fs.rmSync(fullPath, { recursive: true, force: true });
prunedCount++;
} catch (error) {
console.warn(
`[prepare-git] 删除失败: ${relPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
console.log(`[prepare-git] 已删除 ${prunedCount} 个不需要的目录/文件`);
}
/**
* 使用 7zip-bin 解压 .7z.exe 到临时目录,再将内容移动到 GIT_ROOT
* 归档内通常有一层根目录(如 PortableGit 或 mingw64需把其内容移到 GIT_ROOT
*/
function extractArchive(archivePath) {
const sevenZip = resolve7zaPath();
const extractDir = path.join(GIT_ROOT, '.extract');
if (fs.existsSync(extractDir)) {
fs.rmSync(extractDir, { recursive: true, force: true });
}
fs.mkdirSync(extractDir, { recursive: true });
console.log(`[prepare-git] 使用 7zip-bin 解压: ${archivePath}`);
const result = spawnSync(sevenZip, ['x', archivePath, `-o${extractDir}`, '-y'], {
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`7z 解压失败,退出码 ${result.status}`);
}
// 将解压出的第一层目录内容移到 GIT_ROOT归档根可能是 PortableGit 或 mingw64 等)
const entries = fs.readdirSync(extractDir);
if (entries.length === 1) {
const single = path.join(extractDir, entries[0]);
if (fs.statSync(single).isDirectory()) {
const subEntries = fs.readdirSync(single);
for (const e of subEntries) {
const src = path.join(single, e);
const dest = path.join(GIT_ROOT, e);
if (fs.existsSync(dest)) {
fs.rmSync(dest, { recursive: true, force: true });
}
fs.renameSync(src, dest);
}
} else {
fs.renameSync(single, path.join(GIT_ROOT, entries[0]));
}
} else {
for (const entry of entries) {
const src = path.join(extractDir, entry);
const dest = path.join(GIT_ROOT, entry);
if (fs.existsSync(dest)) {
fs.rmSync(dest, { recursive: true, force: true });
}
fs.renameSync(src, dest);
}
}
fs.rmSync(extractDir, { recursive: true, force: true });
}
/**
* 解析归档来源:环境变量本地文件 → 缓存文件 → 下载
* @param {boolean} required - 若为 true 且无法得到归档则抛错
*/
async function resolveArchive(required) {
const envArchive = resolveInputPath(process.env.QIMING_PORTABLE_GIT_ARCHIVE);
if (envArchive) {
if (!isNonEmptyFile(envArchive)) {
throw new Error(
`QIMING_PORTABLE_GIT_ARCHIVE 指向无效文件: ${envArchive}`
);
}
console.log(`[prepare-git] 使用本地归档 QIMING_PORTABLE_GIT_ARCHIVE: ${envArchive}`);
return { archivePath: envArchive, source: 'env-archive' };
}
if (isNonEmptyFile(DEFAULT_ARCHIVE_PATH)) {
console.log(`[prepare-git] 使用缓存: ${DEFAULT_ARCHIVE_PATH}`);
return { archivePath: DEFAULT_ARCHIVE_PATH, source: 'cache' };
}
const urlFromEnv =
typeof process.env.QIMING_GIT_URL === 'string'
? process.env.QIMING_GIT_URL.trim()
: '';
const downloadUrl = urlFromEnv || DEFAULT_PORTABLE_GIT_URL;
try {
console.log(`[prepare-git] 下载 PortableGit: ${downloadUrl}`);
await downloadArchive(downloadUrl, DEFAULT_ARCHIVE_PATH);
const fileSizeMB = (fs.statSync(DEFAULT_ARCHIVE_PATH).size / 1024 / 1024).toFixed(1);
console.log(`[prepare-git] 已下载 (${fileSizeMB} MB): ${DEFAULT_ARCHIVE_PATH}`);
return { archivePath: DEFAULT_ARCHIVE_PATH, source: 'download' };
} catch (error) {
if (required) {
throw new Error(
'无法获取 PortableGit 归档。' +
'可设置 QIMING_PORTABLE_GIT_ARCHIVE 为本地离线包路径,或 QIMING_GIT_URL 为可访问镜像。' +
`原始错误: ${error instanceof Error ? error.message : String(error)}`
);
}
console.warn(
'[prepare-git] PortableGit 归档不可用,已跳过(未使用 --required。' +
`原因: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
/**
* 确保 PortableGit 已准备(与 LobsterAI ensurePortableGit 逻辑一致)
* @param {{ required?: boolean }} [options]
*/
async function ensurePortableGit(options = {}) {
const required = Boolean(options.required);
const isWindows = process.platform === 'win32';
const force = process.env.QIMING_SETUP_GIT_FORCE === '1';
const shouldRun = isWindows || required || force;
if (!shouldRun) {
console.log(
'[prepare-git] 非 Windows 主机跳过(可用 --required 或 QIMING_SETUP_GIT_FORCE=1 强制为 Windows 打包做准备)'
);
return { ok: true, skipped: true, bashPath: null };
}
const existingBash = findPortableGitBash();
if (existingBash) {
console.log(`[prepare-git] PortableGit 已就绪: ${existingBash}`);
return { ok: true, skipped: false, bashPath: existingBash };
}
const archive = await resolveArchive(required);
if (!archive) {
return { ok: true, skipped: true, bashPath: null };
}
extractArchive(archive.archivePath);
const resolvedBash = findPortableGitBash();
if (!resolvedBash) {
throw new Error(
'PortableGit 解压完成但未找到 bash.exe。' +
`已检查: ${path.join(GIT_ROOT, 'bin', 'bash.exe')} 与 usr/bin、mingw64 等。`
);
}
pruneUnneededFiles();
const finalSize = getDirSize(GIT_ROOT);
console.log(`[prepare-git] PortableGit 准备完成: ${resolvedBash}`);
console.log(`[prepare-git] 总大小: ~${(finalSize / 1024 / 1024).toFixed(1)} MB`);
return { ok: true, skipped: false, bashPath: resolvedBash };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
await ensurePortableGit({ required: args.required });
}
if (require.main === module) {
main().catch((error) => {
console.error('[prepare-git] 错误:', error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
module.exports = {
ensurePortableGit,
findPortableGitBash,
GIT_VERSION,
GIT_ROOT,
};

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env node
/**
* 从 node_modules 复制 agent-gui-server 到 resources/
*
* 前提:
* 1. pnpm install 已执行workspace 链接生效)
* 2. agent-gui-server 已构建npm run build
*
* 产物:
* resources/agent-gui-server/
* ├── dist/index.js — esbuild CLI bundleESM原生依赖标记为 external
* ├── dist/lib.bundle.cjs — esbuild SDK bundleCJS供 Electron runtime require
* ├── node_modules/ — external 原生依赖sharp, @nut-tree-fork/*, clipboardy
* └── package.json — 精简版name/version/main/bin
*
* 打包时 electron-builder extraResources 会打包到
* .app/Contents/Resources/agent-gui-server/
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { getProjectRoot } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const srcDir = path.join(projectRoot, 'node_modules', 'agent-gui-server');
const destDir = path.join(projectRoot, 'resources', 'agent-gui-server');
// esbuild 中标记为 external 的原生依赖(需要在运行时可用)
const NATIVE_EXTERNALS = ['sharp', '@nut-tree-fork/nut-js', 'clipboardy'];
function main() {
// 1. 验证 node_modules 中存在 agent-gui-server
if (!fs.existsSync(path.join(srcDir, 'package.json'))) {
console.error('[prepare-gui-server] node_modules 中未找到 agent-gui-server');
console.error('[prepare-gui-server] 请先执行 pnpm install');
process.exit(1);
}
const srcPkg = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
console.log(`[prepare-gui-server] 源码版本: ${srcPkg.name}@${srcPkg.version}`);
// 2. 验证必要文件存在
const srcIndexJs = path.join(srcDir, 'dist', 'index.js');
if (!fs.existsSync(srcIndexJs)) {
console.error(`[prepare-gui-server] CLI 入口不存在: ${srcIndexJs}`);
console.error('[prepare-gui-server] 请先在 crates/agent-gui-server 中执行 npm run build');
process.exit(1);
}
// 3. 检查目标是否已是最新版本
const destPkgPath = path.join(destDir, 'package.json');
const destLibBundle = path.join(destDir, 'dist', 'lib.bundle.cjs');
if (fs.existsSync(destPkgPath) && fs.existsSync(destLibBundle)) {
try {
const destPkg = JSON.parse(fs.readFileSync(destPkgPath, 'utf8'));
if (destPkg.version === srcPkg.version) {
const destIndexJs = path.join(destDir, 'dist', 'index.js');
if (fs.existsSync(destIndexJs)) {
const srcSize = fs.statSync(srcIndexJs).size;
const destSize = fs.statSync(destIndexJs).size;
if (srcSize === destSize) {
console.log(`[prepare-gui-server] ${srcPkg.version} 已是最新,跳过`);
return;
}
}
}
} catch {
// 目标损坏,重新复制
}
}
// 4. 清理并创建目标目录
console.log('[prepare-gui-server] 复制到 resources/agent-gui-server/...');
if (fs.existsSync(destDir)) {
fs.rmSync(destDir, { recursive: true });
}
fs.mkdirSync(path.join(destDir, 'dist'), { recursive: true });
// 5. 复制 dist/index.js (esbuild CLI bundle)
const destIndexJs = path.join(destDir, 'dist', 'index.js');
fs.copyFileSync(srcIndexJs, destIndexJs);
fs.chmodSync(destIndexJs, 0o755);
console.log(` dist/index.js (${(fs.statSync(destIndexJs).size / 1024).toFixed(0)} KB)`);
// 5b. 复制 dist/lib.bundle.cjs (esbuild CJS SDK bundle, for Electron runtime require)
const srcLibBundle = path.join(srcDir, 'dist', 'lib.bundle.cjs');
if (fs.existsSync(srcLibBundle)) {
fs.copyFileSync(srcLibBundle, destLibBundle);
console.log(` dist/lib.bundle.cjs (${(fs.statSync(destLibBundle).size / 1024).toFixed(0)} KB)`);
} else {
console.warn('[prepare-gui-server] dist/lib.bundle.cjs not found — CJS SDK bundle will be unavailable');
}
// 6. 安装 external 原生依赖到 resources/agent-gui-server/node_modules/
// 使用 npm install 确保原生模块为当前平台编译
const depsToInstall = [];
for (const dep of NATIVE_EXTERNALS) {
const ver = srcPkg.dependencies?.[dep];
if (ver) {
depsToInstall.push(`${dep}@${ver.replace(/^[\^~]/, '')}`);
}
}
if (depsToInstall.length > 0) {
// 先写一个临时 package.json 让 npm install 工作
const tmpPkg = { name: 'agent-gui-server-runtime', version: '0.0.0', private: true };
fs.writeFileSync(destPkgPath, JSON.stringify(tmpPkg, null, 2) + '\n');
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
// CI 交叉编译支持TARGET_ARCH 环境变量指定目标架构(如 macOS arm64 runner 构建 x64 包)
// npm install 通过 --cpu 标志安装目标架构的原生模块
const targetArch = process.env.TARGET_ARCH;
const archFlag = targetArch ? ` --cpu=${targetArch}` : '';
const archLabel = targetArch ? ` (target: ${targetArch})` : '';
console.log(` 安装原生依赖${archLabel}: ${depsToInstall.join(', ')}...`);
try {
execSync(`${npmCmd} install --no-save${archFlag} ${depsToInstall.join(' ')}`, {
cwd: destDir,
stdio: 'pipe',
});
console.log(' node_modules/ (原生依赖安装完成)');
} catch (e) {
console.error('[prepare-gui-server] 原生依赖安装失败:', e.stderr?.toString() || e.message);
process.exit(1);
}
}
// 7. 生成最终精简版 package.json
const slimPkg = {
name: srcPkg.name,
version: srcPkg.version,
type: 'module',
main: './dist/lib.bundle.cjs',
bin: { 'agent-gui-server': './dist/index.js' },
};
fs.writeFileSync(destPkgPath, JSON.stringify(slimPkg, null, 2) + '\n');
console.log(' package.json (slim)');
console.log(`[prepare-gui-server] ✓ resources/agent-gui-server/ (${srcPkg.version})`);
}
main();

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env node
/**
* 多平台 qiming-lanproxy 集成:构建前按当前平台准备 resources/lanproxy/bin/
*
* 源resources/lanproxy/binaries/ 下的多平台二进制
* 文件名格式qiming-lanproxy-<rust-target>[.exe]
*
* 目标resources/lanproxy/bin/qiming-lanproxy (或 .exe)
*
* 打包时 electron-builder 的 extraResources 会打包
* resources/lanproxy → .app/Contents/Resources/lanproxy
* 运行时 getLanproxyBinPath() 优先用 lanproxy/binaries/<平台名>,其次 bin/qiming-lanproxy[.exe]
*
* 平台映射 (Node → Rust target)
* darwin-arm64 → aarch64-apple-darwin
* darwin-x64 → x86_64-apple-darwin
* win32-x64 → x86_64-pc-windows-msvc
* linux-x64 → x86_64-unknown-linux-gnu
* linux-arm64 → aarch64-unknown-linux-gnu
*/
const path = require('path');
const fs = require('fs');
const { getProjectRoot } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const srcBinDir = path.join(projectRoot, 'resources', 'lanproxy', 'binaries');
const destBinDir = path.join(projectRoot, 'resources', 'lanproxy', 'bin');
// Node platform-arch → Rust target triple
const PLATFORM_MAP = {
'darwin-arm64': 'aarch64-apple-darwin',
'darwin-x64': 'x86_64-apple-darwin',
'win32-x64': 'x86_64-pc-windows-msvc',
'win32-ia32': 'i686-pc-windows-msvc',
'linux-x64': 'x86_64-unknown-linux-gnu',
'linux-arm64': 'aarch64-unknown-linux-gnu',
};
// Fallback for macOS arm64 (universal binary)
function getFallbackPath(key) {
if (key === 'darwin-arm64') {
return 'qiming-lanproxy-universal-apple-darwin';
}
return null;
}
function getPlatformKey() {
const a = process.env.TARGET_ARCH || process.arch;
return `${process.platform}-${a}`;
}
function main() {
const key = getPlatformKey();
const target = PLATFORM_MAP[key];
if (!target) {
console.error(`[prepare-lanproxy] 不支持的平台: ${key}`);
console.error(`[prepare-lanproxy] 支持的平台: ${Object.keys(PLATFORM_MAP).join(', ')}`);
process.exit(1);
}
const isWin = process.platform === 'win32';
const srcName = `qiming-lanproxy-${target}${isWin ? '.exe' : ''}`;
const destName = `qiming-lanproxy${isWin ? '.exe' : ''}`;
let srcPath = path.join(srcBinDir, srcName);
const destPath = path.join(destBinDir, destName);
// macOS arm64: 尝试 universal binary 作为 fallback
if (!fs.existsSync(srcPath)) {
const fallbackName = getFallbackPath(key);
if (fallbackName) {
const fallbackPath = path.join(srcBinDir, fallbackName);
if (fs.existsSync(fallbackPath)) {
console.log(`[prepare-lanproxy] ${key}${fallbackName} (fallback)`);
srcPath = fallbackPath;
}
}
}
// 如果源文件仍不存在,创建空目录并继续
if (!fs.existsSync(srcPath)) {
console.warn(`[prepare-lanproxy] 源文件不存在: ${srcPath},将跳过 lanproxy 二进制(安装包可正常产出,运行时内网穿透不可用)`);
fs.mkdirSync(destBinDir, { recursive: true });
return;
}
// 如果目标已存在且大小一致,检查架构匹配
const platformKeyFile = path.join(destBinDir, '.platform-key');
if (fs.existsSync(destPath)) {
if (fs.existsSync(platformKeyFile)) {
const existingKey = fs.readFileSync(platformKeyFile, 'utf-8').trim();
if (existingKey !== key) {
console.log(`[prepare-lanproxy] 架构不匹配: 已有 ${existingKey}, 需要 ${key}, 清理并重新复制`);
fs.rmSync(destBinDir, { recursive: true, force: true });
} else {
const srcStat = fs.statSync(srcPath);
const destStat = fs.statSync(destPath);
if (srcStat.size === destStat.size) {
console.log(`[prepare-lanproxy] ${key}${destName} (已是最新,跳过)`);
return;
}
}
} else {
// No .platform-key — legacy check by size only
const srcStat = fs.statSync(srcPath);
const destStat = fs.statSync(destPath);
if (srcStat.size === destStat.size) {
console.log(`[prepare-lanproxy] ${key}${destName} (已是最新,跳过)`);
return;
}
}
}
// 复制
const srcBasename = path.basename(srcPath);
console.log(`[prepare-lanproxy] ${key}${srcBasename}`);
fs.mkdirSync(destBinDir, { recursive: true });
fs.copyFileSync(srcPath, destPath);
fs.chmodSync(destPath, 0o755);
fs.writeFileSync(platformKeyFile, key, 'utf-8');
console.log(`[prepare-lanproxy] ✓ ${destPath} (${(fs.statSync(destPath).size / 1024 / 1024).toFixed(1)} MB)`);
console.log(`[prepare-lanproxy] 已写入 .platform-key: ${key}`);
}
main();

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* 从 node_modules 复制 qiming-mcp-stdio-proxy 到 resources/
*
* 前提:
* 1. pnpm install 已执行workspace 链接生效)
* 2. qiming-mcp-stdio-proxy 已构建npm run build
*
* 产物3 个文件):
* resources/qiming-mcp-stdio-proxy/
* ├── dist/index.js — CLI bundleesbuild 单文件,含 shebang
* ├── dist/lib.bundle.js — 库 bundlePersistentMcpBridge 等导出)
* └── package.json — 精简版name/version/bin/main
*
* 打包时 electron-builder extraResources 会打包到
* .app/Contents/Resources/qiming-mcp-stdio-proxy/
*/
const path = require('path');
const fs = require('fs');
const { getProjectRoot } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const srcDir = path.join(projectRoot, 'node_modules', 'qiming-mcp-stdio-proxy');
const destDir = path.join(projectRoot, 'resources', 'qiming-mcp-stdio-proxy');
function main() {
// 1. 验证 node_modules 中存在 qiming-mcp-stdio-proxy
if (!fs.existsSync(path.join(srcDir, 'package.json'))) {
console.error(`[prepare-mcp-proxy] node_modules 中未找到 qiming-mcp-stdio-proxy`);
console.error('[prepare-mcp-proxy] 请先执行 pnpm install');
process.exit(1);
}
const srcPkg = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
console.log(`[prepare-mcp-proxy] 源码版本: ${srcPkg.name}@${srcPkg.version}`);
// 2. 验证必要文件存在
const srcIndexJs = path.join(srcDir, 'dist', 'index.js');
const srcLibJs = path.join(srcDir, 'dist', 'lib.js');
if (!fs.existsSync(srcIndexJs)) {
console.error(`[prepare-mcp-proxy] CLI 入口不存在: ${srcIndexJs}`);
console.error('[prepare-mcp-proxy] 请先在 crates/qiming-mcp-stdio-proxy 中执行 npm run build');
process.exit(1);
}
if (!fs.existsSync(srcLibJs)) {
console.error(`[prepare-mcp-proxy] 库入口不存在: ${srcLibJs}`);
console.error('[prepare-mcp-proxy] 请先在 crates/qiming-mcp-stdio-proxy 中执行 npm run build');
process.exit(1);
}
// 3. 检查目标是否已是最新版本
const destPkgPath = path.join(destDir, 'package.json');
if (fs.existsSync(destPkgPath)) {
try {
const destPkg = JSON.parse(fs.readFileSync(destPkgPath, 'utf8'));
if (destPkg.version === srcPkg.version) {
const destIndexJs = path.join(destDir, 'dist', 'index.js');
const destLibJs = path.join(destDir, 'dist', 'lib.bundle.js');
if (fs.existsSync(destIndexJs) && fs.existsSync(destLibJs)) {
console.log(`[prepare-mcp-proxy] ${srcPkg.version} 已是最新,跳过`);
return;
}
}
} catch {
// 目标损坏,重新复制
}
}
// 4. 构建 lib bundle使用 esbuild
console.log('[prepare-mcp-proxy] 构建 lib bundle (esbuild)...');
const esbuildBin = path.join(srcDir, 'node_modules', '.bin', 'esbuild');
// 如果 proxy 包中没有 esbuild使用项目根目录的 esbuild
const esbuildToUse = fs.existsSync(esbuildBin)
? esbuildBin
: path.join(projectRoot, 'node_modules', '.bin', 'esbuild');
// Windows 兼容
const esbuildCmd = process.platform === 'win32' && !esbuildToUse.endsWith('.cmd')
? esbuildToUse + '.cmd'
: esbuildToUse;
const libEntry = srcLibJs;
const libBundlePath = path.join(srcDir, 'dist', 'lib.bundle.js');
const { execSync } = require('child_process');
try {
execSync(
`"${esbuildCmd}" "${libEntry}" --bundle --platform=node --target=node22 --format=cjs --outfile="${libBundlePath}" --legal-comments=none`,
{ cwd: srcDir, stdio: 'inherit' },
);
} catch (e) {
console.error('[prepare-mcp-proxy] esbuild 打包失败,尝试直接复制 lib.js');
// 如果 esbuild 失败,直接使用 lib.js 作为 lib.bundle.js
if (fs.existsSync(libEntry)) {
fs.copyFileSync(libEntry, libBundlePath);
} else {
console.error('[prepare-mcp-proxy] lib.js 也不存在,无法继续');
process.exit(1);
}
}
// 5. 复制到 resources/qiming-mcp-stdio-proxy/
console.log('[prepare-mcp-proxy] 复制到 resources/qiming-mcp-stdio-proxy/...');
// 清理目标目录
if (fs.existsSync(destDir)) {
fs.rmSync(destDir, { recursive: true });
}
fs.mkdirSync(path.join(destDir, 'dist'), { recursive: true });
// 复制 dist/index.js (CLI bundle)
const destIndexJs = path.join(destDir, 'dist', 'index.js');
fs.copyFileSync(srcIndexJs, destIndexJs);
fs.chmodSync(destIndexJs, 0o755);
console.log(` dist/index.js (${(fs.statSync(destIndexJs).size / 1024).toFixed(0)} KB)`);
// 复制 dist/lib.bundle.js (library bundle)
const destLibJs = path.join(destDir, 'dist', 'lib.bundle.js');
fs.copyFileSync(libBundlePath, destLibJs);
console.log(` dist/lib.bundle.js (${(fs.statSync(destLibJs).size / 1024).toFixed(0)} KB)`);
// 6. 生成精简版 package.json
const slimPkg = {
name: srcPkg.name,
version: srcPkg.version,
bin: { 'qiming-mcp-stdio-proxy': './dist/index.js' },
main: './dist/lib.bundle.js',
};
fs.writeFileSync(destPkgPath, JSON.stringify(slimPkg, null, 2) + '\n');
console.log(' package.json (slim)');
console.log(`[prepare-mcp-proxy] ✓ resources/qiming-mcp-stdio-proxy/ (${srcPkg.version})`);
}
main();

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* 将 monaco-editor 的静态资源min/vs复制到 public/ 下,确保 Electron 打包后可离线加载。
*
* 目标产物:
* public/monaco/vs/**
*
* 运行时:
* 渲染进程通过 @monaco-editor/react 的 loader.config({ paths: { vs: "./monaco/vs" } })
* 指向该目录。
*/
/* eslint-disable no-console */
'use strict';
const path = require('path');
const fs = require('fs');
const { getProjectRoot } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const srcVsDir = path.join(projectRoot, 'node_modules', 'monaco-editor', 'min', 'vs');
const destVsDir = path.join(projectRoot, 'public', 'monaco', 'vs');
function copyDirRecursive(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const name of fs.readdirSync(src)) {
const s = path.join(src, name);
const d = path.join(dest, name);
const stat = fs.statSync(s);
if (stat.isDirectory()) {
copyDirRecursive(s, d);
} else {
fs.copyFileSync(s, d);
}
}
}
function main() {
if (!fs.existsSync(srcVsDir)) {
console.error('[prepare-monaco] 未找到 monaco-editor 资源目录:');
console.error(` ${srcVsDir}`);
console.error('[prepare-monaco] 请先执行 npm install');
process.exit(1);
}
// 清理旧产物,避免残留导致加载异常
if (fs.existsSync(destVsDir)) {
fs.rmSync(destVsDir, { recursive: true, force: true });
}
console.log('[prepare-monaco] 复制 monaco 静态资源...');
copyDirRecursive(srcVsDir, destVsDir);
// 基本校验loader 需要 loader.js
const loaderJs = path.join(destVsDir, 'loader.js');
if (!fs.existsSync(loaderJs)) {
console.warn('[prepare-monaco] ⚠️ 缺少 vs/loader.jsmonaco 可能无法加载');
}
console.log('[prepare-monaco] ✓ public/monaco/vs 已准备完成');
}
main();

View File

@@ -0,0 +1,343 @@
#!/usr/bin/env node
/**
* Node.js 24 集成(所有平台)
*
* 为所有平台准备 resources/node/<platform-arch>/,供运行时使用。
*
* 参考 LobsterAI 方案https://github.com/netease-youdao/LobsterAI
*
* 1) 若已存在 resources/node/<platform-arch>/,则跳过
* 2) 否则从 nodejs.org 下载对应平台的包,解压到 <platform-arch>/
* 3) 可选:验证下载文件的 SHA256 校验和
*
* 平台 keydarwin-x64, darwin-arm64, win32-x64, win32-arm64, linux-x64, linux-arm64
*/
const path = require('path');
const fs = require('fs');
const https = require('https');
const crypto = require('crypto');
const { execSync } = require('child_process');
const { getProjectRoot } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const nodeRoot = path.join(projectRoot, 'resources', 'node');
// Node.js 版本
const NODE_VERSION = '24.14.0';
// 是否验证 SHA256可通过环境变量禁用SKIP_SHA256=1 npm run prepare:node
const SKIP_SHA256 = process.env.SKIP_SHA256 === '1';
// 平台 key
function getPlatformKey() {
const p = process.platform;
const a = process.env.TARGET_ARCH || process.arch;
return `${p}-${a}`;
}
// Node.js 官方 release 资源文件名
const NODE_ASSET_SUFFIX = {
'darwin-arm64': 'node-v${VERSION}-darwin-arm64.tar.xz',
'darwin-x64': 'node-v${VERSION}-darwin-x64.tar.xz',
'win32-x64': 'node-v${VERSION}-win-x64.zip',
'win32-arm64': 'node-v${VERSION}-win-arm64.zip',
'linux-x64': 'node-v${VERSION}-linux-x64.tar.xz',
'linux-arm64': 'node-v${VERSION}-linux-arm64.tar.xz',
};
/**
* 下载文件
*/
function download(url, filename) {
return new Promise((resolve, reject) => {
const file = path.join(nodeRoot, '.cache', filename);
if (fs.existsSync(file)) {
console.log(`[prepare-node] 使用缓存: ${filename}`);
resolve(file);
return;
}
fs.mkdirSync(path.dirname(file), { recursive: true });
const stream = fs.createWriteStream(file);
https.get(url, (res) => {
if (res.statusCode === 302 || res.statusCode === 301) {
const loc = res.headers.location;
const fullUrl = loc.startsWith('http') ? loc : new URL(loc, url).href;
stream.close();
fs.unlink(file, () => {});
return download(fullUrl, filename).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
stream.close();
fs.unlink(file, () => {});
return reject(new Error(`HTTP ${res.statusCode}`));
}
res.pipe(stream);
stream.on('finish', () => { stream.close(); resolve(file); });
stream.on('error', reject);
}).on('error', reject);
});
}
/**
* 计算文件的 SHA256 哈希值
*/
function computeSha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* 获取 Node.js 官方 SHASUMS256.txt 内容
*/
async function fetchShasums(urlOverride) {
const url = urlOverride || `https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt`;
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode === 302 || res.statusCode === 301) {
const loc = res.headers.location;
const fullUrl = loc.startsWith('http') ? loc : new URL(loc, url).href;
return fetchShasums(fullUrl).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve(data));
res.on('error', reject);
}).on('error', reject);
});
}
/**
* 验证下载文件的 SHA256
*/
async function verifySha256(filePath, filename) {
if (SKIP_SHA256) {
console.log('[prepare-node] SHA256 校验已跳过 (SKIP_SHA256=1)');
return true;
}
try {
console.log('[prepare-node] 验证 SHA256 校验和...');
const shasums = await fetchShasums();
const expectedHash = shasums
.split('\n')
.find(line => line.endsWith(filename))
?.split(/\s+/)[0];
if (!expectedHash) {
console.warn('[prepare-node] ⚠️ 未找到对应的 SHA256 校验和,跳过验证');
return true;
}
const actualHash = await computeSha256(filePath);
if (actualHash !== expectedHash) {
console.error(`[prepare-node] ❌ SHA256 校验失败!`);
console.error(`[prepare-node] 期望: ${expectedHash}`);
console.error(`[prepare-node] 实际: ${actualHash}`);
return false;
}
console.log('[prepare-node] ✅ SHA256 校验通过');
return true;
} catch (err) {
console.warn('[prepare-node] ⚠️ SHA256 校验失败(网络问题?:', err.message);
// 网络问题时允许继续
return true;
}
}
/**
* 解压
*/
function extractArchive(archivePath, outDir) {
fs.mkdirSync(outDir, { recursive: true });
const ext = path.extname(archivePath).toLowerCase();
if (ext === '.zip') {
if (process.platform === 'win32') {
const psPath = archivePath.replace(/'/g, "''");
const psDest = outDir.replace(/'/g, "''");
execSync(
`powershell -NoProfile -Command "Expand-Archive -LiteralPath '${psPath}' -DestinationPath '${psDest}' -Force"`,
{ stdio: 'inherit' }
);
} else {
execSync(`unzip -o -q "${archivePath}" -d "${outDir}"`, { stdio: 'inherit' });
}
} else {
execSync(`tar -xJf "${archivePath}" -C "${outDir}"`, { stdio: 'inherit' });
}
}
/**
* 复制目录,保留符号链接
*/
function copyDirRecursiveWithSymlinks(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const name of fs.readdirSync(src)) {
const s = path.join(src, name);
const d = path.join(dest, name);
const stat = fs.lstatSync(s);
if (stat.isSymbolicLink()) {
const linkTarget = fs.readlinkSync(s);
fs.symlinkSync(linkTarget, d);
} else if (stat.isDirectory()) {
copyDirRecursiveWithSymlinks(s, d);
} else {
fs.copyFileSync(s, d);
}
}
}
async function prepareNode(key, suffix) {
const cacheDir = path.join(nodeRoot, '.cache');
const platformDir = path.join(nodeRoot, key);
// 检查是否已存在
const platformKeyFile = path.join(platformDir, '.platform-key');
if (fs.existsSync(platformDir)) {
if (fs.existsSync(platformKeyFile)) {
const existingKey = fs.readFileSync(platformKeyFile, 'utf-8').trim();
if (existingKey === key) {
console.log(`[prepare-node] Node.js ${NODE_VERSION} (${key}) 已存在且架构匹配,跳过`);
return;
}
console.log(`[prepare-node] 架构不匹配: 已有 ${existingKey}, 需要 ${key}, 清理并重新下载`);
fs.rmSync(platformDir, { recursive: true, force: true });
} else {
// No .platform-key — legacy, treat as matching
console.log(`[prepare-node] Node.js ${NODE_VERSION} (${key}) 已存在,跳过`);
return;
}
}
const filename = suffix.replace('${VERSION}', NODE_VERSION);
const downloadUrl = `https://nodejs.org/dist/v${NODE_VERSION}/${filename}`;
console.log(`[prepare-node] 下载 Node.js ${NODE_VERSION} (${filename})...`);
try {
const archivePath = await download(downloadUrl, filename);
// 验证 SHA256
const sha256Valid = await verifySha256(archivePath, filename);
if (!sha256Valid) {
fs.unlinkSync(archivePath);
console.error('[prepare-node] 下载文件已删除,请重试');
process.exit(1);
}
const extractDir = path.join(cacheDir, `node-extract-${key}`);
if (fs.existsSync(extractDir)) {
fs.rmSync(extractDir, { recursive: true, force: true });
}
console.log(`[prepare-node] 解压...`);
extractArchive(archivePath, extractDir);
// 移动到目标目录(保留符号链接)
const entries = fs.readdirSync(extractDir);
if (entries.length === 1) {
const inner = path.join(extractDir, entries[0]);
if (fs.statSync(inner).isDirectory()) {
copyDirRecursiveWithSymlinks(inner, platformDir);
}
}
// 清理不需要的文件以减小包体积
// 1. 删除 include 目录C++ 头文件,运行时不需要)
const includeDir = path.join(platformDir, 'include');
if (fs.existsSync(includeDir)) {
fs.rmSync(includeDir, { recursive: true, force: true });
console.log(`[prepare-node] 已删除 include/ 目录(运行时不需要)`);
}
// 2. 删除 CHANGELOG.md, LICENSE, README.md可选减小体积
['CHANGELOG.md', 'LICENSE', 'README.md'].forEach(file => {
const filePath = path.join(platformDir, file);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
});
// Windows: 统一目录结构,将 node.exe 移动到 bin/ 目录
// 这样所有平台都使用 resources/node/<platform>/bin/node 结构
if (key.startsWith('win32-')) {
const nodeExe = path.join(platformDir, 'node.exe');
const binDir = path.join(platformDir, 'bin');
if (fs.existsSync(nodeExe) && !fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
fs.renameSync(nodeExe, path.join(binDir, 'node.exe'));
// 移动相关文件到 bin/
['npm.cmd', 'npx.cmd', 'corepack.cmd', 'npm', 'npx', 'corepack'].forEach(cmd => {
const src = path.join(platformDir, cmd);
if (fs.existsSync(src)) {
fs.renameSync(src, path.join(binDir, cmd));
}
});
// 移动 node_modules 到 bin/ 目录npm.cmd 期望 node_modules 在同一目录)
const nodeModulesSrc = path.join(platformDir, 'node_modules');
const nodeModulesDest = path.join(binDir, 'node_modules');
if (fs.existsSync(nodeModulesSrc) && !fs.existsSync(nodeModulesDest)) {
fs.renameSync(nodeModulesSrc, nodeModulesDest);
}
console.log(`[prepare-node] 已将 Windows Node.js 重组为 bin/ 结构`);
}
}
// macOS: 对 Node.js 二进制进行 ad-hoc 签名,使其能继承主应用的辅助功能权限
// 避免用户需要单独授权 node 子进程
if (key.startsWith('darwin-')) {
const nodeBin = path.join(platformDir, 'bin', 'node');
if (fs.existsSync(nodeBin)) {
try {
execSync(`codesign --force --sign - "${nodeBin}"`, { stdio: 'inherit' });
console.log(`[prepare-node] 已对 Node.js 二进制进行 ad-hoc 签名`);
} catch (signErr) {
console.warn(`[prepare-node] ⚠️ 签名失败(不影响功能):`, signErr.message);
}
}
}
console.log(`[prepare-node] Node.js ${NODE_VERSION} (${key}) 准备完成!`);
// Write .platform-key marker
fs.writeFileSync(platformKeyFile, key, 'utf-8');
console.log(`[prepare-node] 已写入 .platform-key: ${key}`);
} catch (err) {
console.error(`[prepare-node] 下载或解压失败:`, err.message);
process.exit(1);
}
}
async function main() {
// 所有平台都需要内置 Node.js 24不依赖用户系统安装
const key = getPlatformKey();
const suffix = NODE_ASSET_SUFFIX[key];
if (!suffix) {
console.warn(`[prepare-node] 未支持的平台: ${key}`);
return;
}
// 创建目录
fs.mkdirSync(nodeRoot, { recursive: true });
await prepareNode(key, suffix);
}
main();

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* 准备 qiming-file-server 源码并复制到 resources/
*
* 逻辑:
* 1. 若 sources/qiming-file-server 不存在 → git clone + npm install + npm run build
* 2. 若存在但无 node_modules → npm install + npm run build
* 3. 否则跳过构建(认为已就绪)
* 4. 复制到 resources/qiming-file-server/
*
* 产物:
* resources/qiming-file-server/
* ├── dist/
* ├── node_modules/
* └── package.json
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { getProjectRoot } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const electronClientRoot = projectRoot;
// 从 package.json 读取源码地址
const pkgJson = JSON.parse(fs.readFileSync(path.join(electronClientRoot, 'package.json'), 'utf8'));
const { url: GIT_REPO, branch: GIT_BRANCH } = pkgJson.bundledSources['qiming-file-server'];
const SOURCE_DIR = path.join(electronClientRoot, 'sources', 'qiming-file-server');
const destDir = path.join(electronClientRoot, 'resources', 'qiming-file-server');
function exec(cmd, opts = {}) {
console.log(` $ ${cmd}`);
execSync(cmd, { stdio: 'inherit', ...opts });
}
function main() {
// 1. 克隆或更新源码
if (!fs.existsSync(path.join(SOURCE_DIR, '.git'))) {
console.log('[prepare-qiming-file-server] 克隆源码...');
exec(`git clone --branch ${GIT_BRANCH} ${GIT_REPO} "${SOURCE_DIR}"`);
} else {
console.log('[prepare-qiming-file-server] 更新源码...');
exec(`cd "${SOURCE_DIR}" && git checkout ${GIT_BRANCH} && git pull`);
}
// 2. 检查构建产物是否存在
const hasBuild = fs.existsSync(path.join(SOURCE_DIR, 'dist'));
const hasNodeModules = fs.existsSync(path.join(SOURCE_DIR, 'node_modules'));
if (!hasBuild || !hasNodeModules) {
// 清理旧的 node_modules若有
if (fs.existsSync(path.join(SOURCE_DIR, 'node_modules'))) {
console.log('[prepare-qiming-file-server] 清理旧的 node_modules...');
exec(`rm -rf "${path.join(SOURCE_DIR, 'node_modules')}"`);
}
// 3. 安装依赖
console.log('[prepare-qiming-file-server] 安装依赖...');
exec(`cd "${SOURCE_DIR}" && npm install --ignore-scripts`);
// 4. 构建
console.log('[prepare-qiming-file-server] 构建项目...');
exec(`cd "${SOURCE_DIR}" && npm run build`);
} else {
console.log('[prepare-qiming-file-server] 构建产物已就绪,跳过构建');
}
// 5. 读取版本
const srcPkg = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'package.json'), 'utf8'));
console.log(`[prepare-qiming-file-server] 源码版本: ${srcPkg.name}@${srcPkg.version}`);
// 6. 清理并创建目标目录
if (fs.existsSync(destDir)) {
fs.rmSync(destDir, { recursive: true });
}
fs.mkdirSync(destDir, { recursive: true });
// 7. 复制 dist/
console.log('[prepare-qiming-file-server] 复制 dist/...');
exec(`cp -R "${path.join(SOURCE_DIR, 'dist')}" "${destDir}/"`);
// 8. 复制 package.json
fs.copyFileSync(
path.join(SOURCE_DIR, 'package.json'),
path.join(destDir, 'package.json')
);
// 9. 复制 node_modules/
console.log('[prepare-qiming-file-server] 复制 node_modules/...');
exec(`cp -R "${path.join(SOURCE_DIR, 'node_modules')}" "${destDir}/"`);
// 10. 复制 LICENSE
const licenseSrc = path.join(SOURCE_DIR, 'LICENSE');
if (fs.existsSync(licenseSrc)) {
fs.copyFileSync(licenseSrc, path.join(destDir, 'LICENSE'));
}
console.log(`[prepare-qiming-file-server] ✓ resources/qiming-file-server/ (${srcPkg.version})`);
}
main();

View File

@@ -0,0 +1,622 @@
#!/usr/bin/env node
/**
* qimingcode 多平台集成:准备 resources/qimingcode/{platform}/bin/
*
* 两种模式:
* 1) 本地 dist 复制(设置 QIMINGCODE_DIST_DIR 环境变量,开发调试用)
* QIMINGCODE_DIST_DIR=~/workspace/qimingcode/packages/opencode/dist npm run prepare:qimingcode
* 2) GitHub Release 下载默认CI/正式构建用)
* npm run prepare:qimingcode
*
* 打包时 electron-builder extraResources 将 resources/qimingcode 打包到应用内
* 运行时 getQimingCodeBundledBinPath() 解析对应平台二进制
*
* 用法:
* node scripts/prepare/prepare-qimingcode.js # 当前平台
* node scripts/prepare/prepare-qimingcode.js --all # 全平台
*
* 环境变量:
* QIMINGCODE_DIST_DIR — qimingcode 本地构建产物目录(设置后走本地复制模式)
* QIMINGCODE_REPO — GitHub 仓库(默认 qiming-ai/qimingcode
* GITHUB_TOKEN — GitHub token私有仓库或提高速率限制用
*/
const path = require('path');
const fs = require('fs');
const https = require('https');
const { URL } = require('url');
const { execSync, execFileSync } = require('child_process');
const { getProjectRoot } = require('../utils/project-paths');
const QIMINGCODE_VERSION = '1.1.97';
const QIMINGCODE_REPO = process.env.QIMINGCODE_REPO || 'qiming-ai/qimingcode';
const projectRoot = getProjectRoot();
const resDir = path.join(projectRoot, 'resources', 'qimingcode');
const cacheDir = path.join(projectRoot, 'scripts', 'resources', 'qimingcode-cache');
// Node platform-arch → dist 文件夹名 / Release asset 名
const PLATFORM_MAP = {
'darwin-arm64': 'qimingcode-darwin-arm64',
'darwin-x64': 'qimingcode-darwin-x64',
'linux-arm64': 'qimingcode-linux-arm64',
'linux-arm64-musl': 'qimingcode-linux-arm64-musl',
'linux-x64': 'qimingcode-linux-x64',
'linux-x64-musl': 'qimingcode-linux-x64-musl',
'win32-x64': 'qimingcode-windows-x64',
};
// 资源目录名需与运行时 getQimingCodeBundledBinPath() 一致
const RESOURCE_PLATFORM_KEY_MAP = {
'win32-x64': 'windows-x64',
};
function getPlatformKey() {
const a = process.env.TARGET_ARCH || process.arch;
return `${process.platform}-${a}`;
}
function getResourcePlatformKey(key) {
return RESOURCE_PLATFORM_KEY_MAP[key] || key;
}
function isWindows(key) {
return key.startsWith('win32');
}
function getBinaryName(key) {
return isWindows(key) ? 'qimingcode.exe' : 'qimingcode';
}
/**
* 兼容 release 二进制命名差异:
* - 历史格式: qimingcode / qimingcode.exe
* - 新格式: opencode / opencode.exe
*
* 说明:
* 1) 运行时入口仍统一为 resources/.../bin/qimingcode(.exe)
* 2) 这里仅放宽“解压后查找源二进制”的候选名,不改变运行时对外约定
*/
function getBinaryCandidates(key) {
const preferred = getBinaryName(key);
const fallback = isWindows(key) ? 'opencode.exe' : 'opencode';
return [preferred, fallback];
}
/**
* 清理目标 bin 目录,避免旧版本资源文件残留。
*
* 背景:
* - 历史上这里是“增量覆盖复制”,当新包删除了某些文件(例如 assets/models.json
* 而旧包中仍存在时,旧文件会继续留在目标目录,造成“看似升级成功但实际混入旧资源”。
*
* 规则:
* - 每次准备二进制前先删后建,保证目标目录仅包含当前包内容。
* - 使用 force:true确保目录不存在时也不会抛错。
*/
function resetDestBinDir(destDir) {
fs.rmSync(destDir, { recursive: true, force: true });
fs.mkdirSync(destDir, { recursive: true });
}
/**
* 确保目标目录存在 assets/model.json。
*
* 背景:
* - 新版 release 有时只包含单二进制,不再附带 assets/model.json。
* - 业务侧仍有路径会读取该文件,因此这里统一兜底创建最小占位文件。
*
* 约束:
* - 若上游已提供 model.json则保持原样不覆盖。
* - 仅在缺失时创建,内容保持最小且可 JSON.parse。
*/
function ensureModelJson(destDir, version) {
const assetsDir = path.join(destDir, 'assets');
const modelJsonPath = path.join(assetsDir, 'model.json');
if (fs.existsSync(modelJsonPath)) return;
fs.mkdirSync(assetsDir, { recursive: true });
const fallback = {
models: [],
source: 'generated-fallback',
version,
};
fs.writeFileSync(modelJsonPath, `${JSON.stringify(fallback, null, 2)}\n`, 'utf-8');
}
/**
* 即便命中版本与 SHA也执行一次“目录重铺”。
*
* 原因:
* - 过去采用增量覆盖复制,可能在目标目录留下旧版本 assets 残留。
* - 仅靠二进制 SHA 命中无法发现“额外残留文件”。
*
* 处理:
* - 命中后不提前 return而是继续走解压/复制流程(优先使用本地缓存包),
* 通过 resetDestBinDir() 达到“先清理后落盘”的确定性结果。
*/
const FORCE_REFRESH_ON_MATCH = true;
// ==================== 模式 1: 本地 dist 复制 ====================
function copyFromDist(key) {
const qimingcodeDist = process.env.QIMINGCODE_DIST_DIR || path.join(
process.env.HOME || '/root',
'workspace/qimingcode/packages/opencode/dist',
);
const distName = PLATFORM_MAP[key];
if (!distName) {
console.error(`[prepare-qimingcode] 不支持的平台: ${key}`);
return false;
}
const resourceKey = getResourcePlatformKey(key);
const binary = getBinaryName(key);
const srcPath = path.join(qimingcodeDist, distName, 'bin', binary);
const destDir = path.join(resDir, resourceKey, 'bin');
const destPath = path.join(destDir, binary);
if (!fs.existsSync(srcPath)) {
console.warn(`[prepare-qimingcode] ${key}: 构建产物不存在 ${srcPath}`);
return false;
}
// 检查是否已是最新SHA256 一致 + 版本匹配)
// 注意codesign 会修改二进制,所以用保存的 .sha256 记录比对 dest签名后
// 而非比对 src未签名vs dest已签名
if (fs.existsSync(destPath)) {
const versionFile = path.join(resDir, '.version');
if (fs.existsSync(versionFile) && fs.readFileSync(versionFile, 'utf-8').trim() === QIMINGCODE_VERSION) {
const shaFile = path.join(resDir, `.sha256-${resourceKey}`);
if (fs.existsSync(shaFile)) {
const expectedHash = fs.readFileSync(shaFile, 'utf-8').trim();
const currentHash = sha256File(destPath);
if (currentHash === expectedHash) {
// 额外校验:二进制内部版本号可能与 .version 不一致(曾发生过标记更新但二进制未更新)
const innerVersion = verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, currentHash);
if (innerVersion && innerVersion !== QIMINGCODE_VERSION) {
console.warn(
`[prepare-qimingcode] ${key}: 版本标记为 ${QIMINGCODE_VERSION} 但二进制为 ${innerVersion},将重新复制覆盖`,
);
} else {
console.log(
`[prepare-qimingcode] ${key} ✓ (已是最新, SHA256=${currentHash.slice(0, 16)}...)`
+ (FORCE_REFRESH_ON_MATCH ? ',将执行目录重铺以清理残留文件' : ''),
);
if (!FORCE_REFRESH_ON_MATCH) return true;
}
}
console.warn(`[prepare-qimingcode] ${key}: SHA256 不匹配,需重新复制 (saved=${expectedHash.slice(0, 16)}... current=${currentHash.slice(0, 16)}...)`);
}
}
}
// 复制前先清理目标目录,确保不会夹带旧版本 assets 残留文件。
resetDestBinDir(destDir);
// 复制整个 bin 目录(包含二进制 + assets 等)
const srcBinDir = path.join(qimingcodeDist, distName, 'bin');
fs.cpSync(srcBinDir, destDir, { recursive: true });
ensureModelJson(destDir, QIMINGCODE_VERSION);
fs.chmodSync(destPath, 0o755);
const sizeMB = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1);
console.log(`[prepare-qimingcode] ${key} ✓ 从本地 dist 复制 (${sizeMB} MB)`);
// macOS ad-hoc 签名
codesign(destPath, key);
// 计算 SHA256签名后用于打印 + 保存
const hash = sha256File(destPath);
// 验证二进制内部版本号 + 打印 SHA256
verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, hash);
// 保存 SHA256 记录,下次可精确跳过
fs.writeFileSync(path.join(resDir, `.sha256-${resourceKey}`), hash, 'utf-8');
return true;
}
// ==================== 模式 2: GitHub Release 下载 ====================
/**
* 下载文件到缓存目录
* @param {{ force?: boolean }} [options] force=true 时忽略已有缓存(解压失败/缓存截断时用)
*/
function download(url, preferredFilename, options = {}) {
const force = !!options.force;
return new Promise((resolve, reject) => {
const filename = preferredFilename || path.basename(url.split('?')[0]) || 'download';
const file = path.join(cacheDir, filename);
// 缓存检查(仅看大小,不校验 gzip损坏时需 force 重下)
if (!force && fs.existsSync(file)) {
try {
const stats = fs.statSync(file);
if (stats.size > 100 * 1024) {
console.log(`[prepare-qimingcode] 使用缓存: ${filename} (${Math.round(stats.size / 1024 / 1024)} MB)`);
resolve(file);
return;
}
} catch (_) {}
try { fs.unlinkSync(file); } catch (_) {}
}
if (force && fs.existsSync(file)) {
try { fs.unlinkSync(file); } catch (_) {}
}
const headers = { 'User-Agent': 'QimingClaw-Build' };
if (process.env.GITHUB_TOKEN) {
headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`;
}
fs.mkdirSync(cacheDir, { recursive: true });
const doRequest = (reqUrl, redirects) => {
if (redirects > 10) return reject(new Error('Too many redirects'));
https.get(reqUrl, { headers }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
const loc = res.headers.location;
const nextUrl = loc.startsWith('http') ? loc : new URL(loc, reqUrl).href;
doRequest(nextUrl, redirects + 1);
return;
}
if (res.statusCode !== 200) {
res.resume();
try { fs.unlinkSync(file); } catch (_) {}
return reject(new Error(`HTTP ${res.statusCode} for ${reqUrl}`));
}
const stream = fs.createWriteStream(file);
res.pipe(stream);
stream.on('finish', () => { stream.close(); resolve(file); });
stream.on('error', (e) => {
stream.close();
try { fs.unlinkSync(file); } catch (_) {}
reject(e);
});
}).on('error', reject);
};
doRequest(url, 0);
});
}
/**
* 从 GitHub Release 下载并解压
*/
async function downloadFromRelease(key) {
const distName = PLATFORM_MAP[key];
if (!distName) {
console.error(`[prepare-qimingcode] 不支持的平台: ${key}`);
return false;
}
const resourceKey = getResourcePlatformKey(key);
const binary = getBinaryName(key);
const destDir = path.join(resDir, resourceKey, 'bin');
const destPath = path.join(destDir, binary);
// 检查是否已是最新(版本匹配 + SHA256 一致)
const versionFile = path.join(resDir, '.version');
if (fs.existsSync(destPath) && fs.existsSync(versionFile)) {
if (fs.readFileSync(versionFile, 'utf-8').trim() === QIMINGCODE_VERSION) {
// 额外校验:比对已缓存 tar.gz 与当前 dest 的 SHA256 是否记录一致
// 防止 release 被 force-push 后 .version 匹配但二进制已过期
const shaFile = path.join(resDir, `.sha256-${getResourcePlatformKey(key)}`);
if (fs.existsSync(shaFile)) {
const expectedHash = fs.readFileSync(shaFile, 'utf-8').trim();
const currentHash = sha256File(destPath);
if (currentHash === expectedHash) {
// 关键:即使命中 SHA256 + .version也必须校验二进制内部版本号
// 否则一旦资源目录里的二进制未更新但标记文件被更新,会导致永远跳过下载。
const innerVersion = verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, currentHash);
if (innerVersion && innerVersion !== QIMINGCODE_VERSION) {
console.warn(
`[prepare-qimingcode] ${key}: 版本标记为 ${QIMINGCODE_VERSION} 但二进制为 ${innerVersion},将强制重新下载覆盖`,
);
} else {
const sizeMB = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1);
console.log(
`[prepare-qimingcode] ${key} ✓ (已是最新 ${sizeMB} MB, SHA256=${currentHash.slice(0, 16)}...)`
+ (FORCE_REFRESH_ON_MATCH ? ',将执行目录重铺以清理残留文件' : ''),
);
if (!FORCE_REFRESH_ON_MATCH) return true;
}
}
console.warn(`[prepare-qimingcode] ${key}: SHA256 不匹配,需重新下载 (expected=${expectedHash.slice(0, 16)}... current=${currentHash.slice(0, 16)}...)`);
} else {
// 无 SHA256 记录时也做一次内部版本校验:避免“标记已更新、二进制未更新”被掩盖
const innerVersion = verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, null);
if (innerVersion && innerVersion !== QIMINGCODE_VERSION) {
console.warn(
`[prepare-qimingcode] ${key}: 版本标记为 ${QIMINGCODE_VERSION} 但二进制为 ${innerVersion},将重新下载覆盖`,
);
} else {
const sizeMB = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1);
console.log(
`[prepare-qimingcode] ${key} ✓ (版本匹配 ${sizeMB} MB无 SHA256 记录)`
+ (FORCE_REFRESH_ON_MATCH ? ',将执行目录重铺以清理残留文件' : ',跳过下载'),
);
if (!FORCE_REFRESH_ON_MATCH) return true;
}
}
}
}
// Release 资产命名兼容策略:
// 1) 新命名带版本后缀qimingcode-xxx-v1.1.96.tar.gz
// 2) 旧命名不带版本后缀qimingcode-xxx.tar.gz
// 先尝试新命名404 再回退旧命名,兼容历史 release 与新 CI 命名规则。
const assetCandidates = [`${distName}-v${QIMINGCODE_VERSION}.tar.gz`, `${distName}.tar.gz`];
// WindowsPATH 里常见的是 System32 的 bsdtar它不认 MSYS 的 /d/a/... 路径,
// 只认盘符路径D:\... 或 D:/...。Git for Windows 的 GNU tar 也接受 D:/...。
const toTarPath = (p) => {
if (process.platform !== 'win32') return p;
const match = /^([A-Za-z]):[\\/](.*)$/.exec(p);
if (!match) return p.replace(/\\/g, '/');
const drive = match[1];
const rest = match[2].replace(/\\/g, '/');
return `${drive}:/${rest}`;
};
let lastErr = null;
for (const assetName of assetCandidates) {
const downloadUrl = `https://github.com/${QIMINGCODE_REPO}/releases/download/v${QIMINGCODE_VERSION}/${assetName}`;
console.log(`[prepare-qimingcode] ${key}: 尝试下载 ${assetName} ...`);
try {
for (let attempt = 0; attempt < 2; attempt++) {
const force = attempt > 0;
if (force) {
console.warn(
`[prepare-qimingcode] ${key}: 归档解压失败(常见于缓存被截断),将删除缓存并重新下载 ${assetName}`,
);
}
const archivePath = await download(downloadUrl, assetName, { force });
// 解压到临时目录
const extractDir = path.join(cacheDir, `extract-${key}`);
if (fs.existsSync(extractDir)) {
try { fs.rmSync(extractDir, { recursive: true }); } catch (_) {}
}
fs.mkdirSync(extractDir, { recursive: true });
const tarArchivePath = toTarPath(archivePath);
const tarExtractDir = toTarPath(extractDir);
// 使用参数数组调用 tar避免在 Windows/MSYS 下对 C:\ 路径的错误解析。
// --force-local 仅在 win32 且 tar 支持时使用macOS BSD tar 不支持该选项)。
const tarArgs = ['-xzf', tarArchivePath, '-C', tarExtractDir];
if (process.platform === 'win32') {
try {
const tarHelp = execFileSync('tar', ['--help'], { encoding: 'utf-8', stdio: 'pipe' });
if (typeof tarHelp === 'string' && tarHelp.includes('--force-local')) {
tarArgs.unshift('--force-local');
}
} catch (_) {}
}
try {
execFileSync('tar', tarArgs, { stdio: 'pipe' });
} catch (tarErr) {
if (attempt === 1) throw tarErr;
continue;
}
// 查找二进制文件:优先新名称 qimingcode其次兼容旧名称 opencode。
// 背景:部分历史 release 资产仍产出 opencode 可执行文件。
const binaryCandidates = getBinaryCandidates(key);
const binaryPath = findBinary(extractDir, binaryCandidates);
if (!binaryPath) {
if (attempt === 1) {
throw new Error(`解压后未找到可执行文件(候选: ${binaryCandidates.join(', ')}`);
}
continue;
}
// 复制前先清理目标目录,避免旧版本 assets 文件被“增量复制”保留下来。
resetDestBinDir(destDir);
const extractedBinDir = path.dirname(binaryPath);
const extractedBaseName = path.basename(binaryPath);
// 复制策略:
// A. 命中标准名qimingcode复制整个 bin 目录,尽量保留同目录 assets
// B. 命中别名opencode按目标标准名落盘保证后续路径稳定
if (extractedBaseName === binary) {
fs.cpSync(extractedBinDir, destDir, { recursive: true });
} else {
fs.copyFileSync(binaryPath, destPath);
// 复制同目录 assets如 models.json
const assetsDir = path.join(extractedBinDir, 'assets');
if (fs.existsSync(assetsDir)) {
const destAssetsDir = path.join(destDir, 'assets');
fs.mkdirSync(destAssetsDir, { recursive: true });
fs.cpSync(assetsDir, destAssetsDir, { recursive: true });
}
}
ensureModelJson(destDir, QIMINGCODE_VERSION);
fs.chmodSync(destPath, 0o755);
const sizeMB = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1);
console.log(`[prepare-qimingcode] ${key} ✓ 从 GitHub Release 下载 (${sizeMB} MB)`);
// macOS ad-hoc 签名
codesign(destPath, key);
// 计算 SHA256签名后用于打印 + 保存
const hash = sha256File(destPath);
// 验证二进制内部版本号 + 打印 SHA256
const innerVersion = verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, hash);
if (innerVersion && innerVersion !== QIMINGCODE_VERSION) {
// 常见于:本地缓存的 tar.gz 仍是旧内容(例如同名资产被替换、或缓存命中导致一直用旧包)
// 第一次发现不一致时,删除缓存并强制重新下载再试一次。
if (attempt === 0) {
console.warn(
`[prepare-qimingcode] ${key}: 检测到二进制版本不一致,将删除缓存并强制重新下载 ${assetName} 再验证一次`,
);
continue;
}
}
// 保存 SHA256 记录,下次可精确跳过
fs.writeFileSync(path.join(resDir, `.sha256-${resourceKey}`), hash, 'utf-8');
return true;
}
} catch (err) {
lastErr = err;
// 如果新命名不存在404自动回退旧命名继续尝试其他错误也继续尝试下一候选。
console.warn(`[prepare-qimingcode] ${key}: 资产 ${assetName} 失败 (${err.message}),尝试下一个命名...`);
}
}
console.error(`[prepare-qimingcode] ${key}: 下载失败: ${lastErr ? lastErr.message : 'unknown error'}`);
console.error(`[prepare-qimingcode] 请确认 GitHub Release 存在: https://github.com/${QIMINGCODE_REPO}/releases/tag/v${QIMINGCODE_VERSION}`);
return false;
}
/**
* 在解压目录中递归查找二进制文件
*/
function findBinary(dir, binaryNames) {
const names = Array.isArray(binaryNames) ? binaryNames : [binaryNames];
// 先走最常见目录bin/ 与 package/bin/
for (const name of names) {
const direct = path.join(dir, 'bin', name);
if (fs.existsSync(direct)) return direct;
const pkgBin = path.join(dir, 'package', 'bin', name);
if (fs.existsSync(pkgBin)) return pkgBin;
}
// 新 release 可能是“根目录单文件”
for (const name of names) {
const rootFile = path.join(dir, name);
if (fs.existsSync(rootFile)) return rootFile;
}
// 最后兜底递归搜索(最多 3 层)
for (const name of names) {
const found = _findRecursive(dir, name, 3);
if (found) return found;
}
return null;
}
function _findRecursive(dir, binaryName, maxDepth) {
if (maxDepth <= 0) return null;
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === binaryName) return fullPath;
if (entry.isDirectory()) {
const found = _findRecursive(fullPath, binaryName, maxDepth - 1);
if (found) return found;
}
}
} catch (_) {}
return null;
}
// ==================== 通用 ====================
/**
* 计算文件 SHA256
*/
function sha256File(filePath) {
try {
return execFileSync('shasum', ['-a', '256', filePath], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split(/\s+/)[0];
} catch {
// Windows 或无 shasum 时,用 Node.js crypto
const crypto = require('crypto');
const data = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(data).digest('hex');
}
}
/**
* 验证二进制内部版本号是否与期望版本匹配
* qimingcode 的 release tag 可能与二进制内部版本不一致,
* 需要检测以避免 .version 标记与实际二进制不符。
*/
function verifyBinaryVersion(binaryPath, expectedVersion, key, hash) {
// 打印 SHA256由调用方传入避免重复计算
if (hash) {
console.log(`[prepare-qimingcode] ${key}: SHA256=${hash}`);
}
try {
const output = execFileSync(binaryPath, ['-v'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
if (output !== expectedVersion) {
console.warn(
`[prepare-qimingcode] ${key}: ⚠️ 二进制内部版本 ${output} 与期望版本 ${expectedVersion} 不一致release tag 版本与二进制版本不同步)`,
);
}
return output;
} catch (e) {
// 二进制可能不支持 -v 或无法在本平台执行(交叉编译场景),跳过校验
console.warn(`[prepare-qimingcode] ${key}: 无法验证二进制版本(${e.message}),跳过校验`);
return null;
}
}
function codesign(binaryPath, key) {
if (process.platform === 'darwin') {
try {
execSync(`codesign --force --sign - "${binaryPath}"`, { stdio: 'pipe' });
} catch {
console.warn(`[prepare-qimingcode] ${key} 签名失败(不影响功能)`);
}
}
}
async function main() {
const allPlatforms = process.argv.includes('--all') || process.argv.includes('--all-platforms');
const useLocalDist = !!process.env.QIMINGCODE_DIST_DIR;
const mode = useLocalDist ? '本地 dist 复制' : 'GitHub Release 下载';
fs.mkdirSync(resDir, { recursive: true });
const keys = allPlatforms ? Object.keys(PLATFORM_MAP) : [getPlatformKey()];
console.log(`[prepare-qimingcode] 模式: ${mode}`);
console.log(`[prepare-qimingcode] 版本: v${QIMINGCODE_VERSION}`);
console.log(`[prepare-qimingcode] 平台: ${keys.join(', ')}`);
if (!allPlatforms && !PLATFORM_MAP[keys[0]]) {
console.error(`[prepare-qimingcode] 不支持的平台: ${keys[0]}`);
console.error(`[prepare-qimingcode] 支持的平台: ${Object.keys(PLATFORM_MAP).join(', ')}`);
process.exit(1);
}
let ok = 0;
let fail = 0;
for (const key of keys) {
const success = useLocalDist ? copyFromDist(key) : await downloadFromRelease(key);
if (success) {
ok++;
} else {
fail++;
}
}
if (ok > 0) {
// 写入版本标记
fs.writeFileSync(path.join(resDir, '.version'), QIMINGCODE_VERSION, 'utf-8');
console.log(`[prepare-qimingcode] ✓ 版本: ${QIMINGCODE_VERSION}`);
}
console.log(`[prepare-qimingcode] 完成: ${ok} 成功, ${fail} 失败`);
if (fail > 0 && !allPlatforms) {
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
/**
* 场景仅限 Windows 客户端沙箱:仅在 win32 宿主上构建 qiming-sandbox-helper.exe
* 供 prepare:all 串联。macOS/Linux 上立即退出 0不执行 cargo。
*/
const { execSync } = require("child_process");
const path = require("path");
if (process.platform !== "win32") {
console.log("[prepare-sandbox-helper-win] 非 Windows跳过 build:sandbox-helper");
process.exit(0);
}
const script = path.join(__dirname, "build-sandbox-helper.js");
execSync(`node "${script}"`, { stdio: "inherit" });

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* 准备三端沙箱运行时资源
*
* 约定:
* - 子模块目录:../agent-sandbox-runtime相对 crates/agent-electron-client
* - 清单文件manifest.json
* - 目标目录resources/sandbox-runtime/bin
*
* (仅 Windows 客户端Sandbox helpermanifest 的 win32 产物或另行构建的
* qiming-sandbox-helper.exe亦可由 Rust crate crates/windows-sandbox-helper
* 通过 npm run build:sandbox-helper 生成到 resources/sandbox-helper。
*/
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { getProjectRoot } = require("../utils/project-paths");
const projectRoot = getProjectRoot();
const cratesDir = path.resolve(projectRoot, "..");
const sandboxRuntimeSubmodule = path.join(cratesDir, "agent-sandbox-runtime");
const manifestPath = path.join(sandboxRuntimeSubmodule, "manifest.json");
const targetRoot = path.join(projectRoot, "resources", "sandbox-runtime");
const targetBin = path.join(targetRoot, "bin");
function getPlatformKey() {
const arch = process.env.TARGET_ARCH || process.arch;
return `${process.platform}-${arch}`;
}
function sha256(filePath) {
const hash = crypto.createHash("sha256");
const data = fs.readFileSync(filePath);
hash.update(data);
return hash.digest("hex");
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function normalizeEntry(entry) {
if (!entry) return null;
if (typeof entry === "string") {
return { source: entry };
}
if (typeof entry !== "object") return null;
const source = entry.source || entry.path || entry.file;
if (!source || typeof source !== "string") return null;
return {
source,
sha256: typeof entry.sha256 === "string" ? entry.sha256.toLowerCase() : null,
targetName: typeof entry.targetName === "string" ? entry.targetName : null,
};
}
function loadManifest() {
if (!fs.existsSync(manifestPath)) {
console.warn(
`[prepare-sandbox-runtime] 未找到 manifest: ${manifestPath},跳过(子模块未初始化或未接入)`,
);
return null;
}
const raw = fs.readFileSync(manifestPath, "utf-8");
return JSON.parse(raw);
}
function resolvePlatformArtifact(manifest, key) {
const table = manifest.platforms || manifest.artifacts || {};
return normalizeEntry(table[key]);
}
function main() {
const key = getPlatformKey();
const manifest = loadManifest();
if (!manifest) return;
const artifact = resolvePlatformArtifact(manifest, key);
if (!artifact) {
console.warn(
`[prepare-sandbox-runtime] manifest 未提供平台 ${key} 的产物定义,跳过`,
);
return;
}
const sourcePath = path.isAbsolute(artifact.source)
? artifact.source
: path.join(sandboxRuntimeSubmodule, artifact.source);
if (!fs.existsSync(sourcePath)) {
throw new Error(
`[prepare-sandbox-runtime] 产物不存在: ${sourcePath}platform=${key}`,
);
}
if (artifact.sha256) {
const current = sha256(sourcePath);
if (current !== artifact.sha256) {
throw new Error(
`[prepare-sandbox-runtime] 校验失败: expected=${artifact.sha256}, actual=${current}, file=${sourcePath}`,
);
}
}
ensureDir(targetBin);
const targetName = artifact.targetName || path.basename(sourcePath);
const targetPath = path.join(targetBin, targetName);
fs.copyFileSync(sourcePath, targetPath);
if (process.platform !== "win32") {
try {
fs.chmodSync(targetPath, 0o755);
} catch (_) {}
}
const resolved = {
version: manifest.version || "unknown",
platform: key,
source: sourcePath,
target: targetPath,
sha256: artifact.sha256 || null,
preparedAt: new Date().toISOString(),
};
fs.writeFileSync(
path.join(targetRoot, "resolved-manifest.json"),
JSON.stringify(resolved, null, 2),
"utf-8",
);
fs.writeFileSync(path.join(targetBin, ".platform-key"), key, "utf-8");
console.log(`[prepare-sandbox-runtime] ${key} -> ${targetName}`);
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env node
/**
* 多平台 uv 集成:构建前按当前平台准备 resources/uv/bin/
*
* 1) 若已存在 resources/uv/<platform-arch>/bin/,则复制到 resources/uv/bin/
* 2) 否则从 GitHub Releases (astral-sh/uv) 下载当前平台包,解压到 <platform-arch>/ 再复制到 bin/
*
* 打包时 electron-builder 的 extraResources 会打包 resources/uv → .app/Contents/Resources/uv
* 运行时 getUvBinPath() 使用 Resources/uv/bin/uv 或 uv.exe
*
* 平台 keydarwin-arm64 | darwin-x64 | win32-x64 | win32-arm64 | linux-x64 | linux-arm64
*/
const path = require('path');
const fs = require('fs');
const https = require('https');
const { execSync } = require('child_process');
const { getProjectRoot } = require('../utils/project-paths');
const UV_VERSION = '0.10.8';
const projectRoot = getProjectRoot();
const uvRoot = path.join(projectRoot, 'resources', 'uv');
const cacheDir = path.join(uvRoot, '.cache');
// Node 与 Electron 一致darwin | win32 | linuxx64 | arm64
function getPlatformKey() {
const p = process.platform;
const a = process.env.TARGET_ARCH || process.arch;
return `${p}-${a}`;
}
// 当前平台对应的 uv 官方 release 资源文件名(不含 .tar.gz / .zip
const UV_ASSET_SUFFIX = {
'darwin-arm64': 'uv-aarch64-apple-darwin',
'darwin-x64': 'uv-x86_64-apple-darwin',
'win32-x64': 'uv-x86_64-pc-windows-msvc',
'win32-arm64': 'uv-aarch64-pc-windows-msvc',
'linux-x64': 'uv-x86_64-unknown-linux-gnu',
'linux-arm64': 'uv-aarch64-unknown-linux-gnu',
};
function copyDirRecursive(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const name of fs.readdirSync(src)) {
const s = path.join(src, name);
const d = path.join(dest, name);
if (fs.statSync(s).isDirectory()) {
copyDirRecursive(s, d);
} else {
fs.copyFileSync(s, d);
}
}
}
function copyToDestBin(key) {
const srcBin = path.join(uvRoot, key, 'bin');
const destBin = path.join(uvRoot, 'bin');
if (!fs.existsSync(srcBin)) return false;
if (!fs.existsSync(destBin)) fs.mkdirSync(destBin, { recursive: true });
for (const f of fs.readdirSync(srcBin)) {
const src = path.join(srcBin, f);
if (fs.statSync(src).isFile()) {
fs.copyFileSync(src, path.join(destBin, f));
console.log(`[prepare-uv] ${key} -> bin/${f}`);
}
}
return true;
}
/**
* 下载文件到 cache 目录。使用 preferredFilename 避免重定向后 URL 过长导致 ENAMETOOLONG。
* @param {string} url - 下载地址
* @param {string} [preferredFilename] - 保存文件名(建议传入,如 uv-aarch64-apple-darwin.tar.gz
*/
function download(url, preferredFilename) {
return new Promise((resolve, reject) => {
const filename = preferredFilename || path.basename(url.split('?')[0]) || 'download';
const file = path.join(cacheDir, filename);
// 检查缓存是否存在且有效Windows: > 1MB
if (fs.existsSync(file)) {
try {
const stats = fs.statSync(file);
const minSize = process.platform === 'win32' ? 1024 * 1024 : 100 * 1024;
if (stats.size > minSize) {
console.log(`[prepare-uv] 使用缓存: ${filename} (${Math.round(stats.size / 1024 / 1024)} MB)`);
resolve(file);
return;
}
} catch (e) {
// 文件可能被锁定,删除后重新下载
console.log(`[prepare-uv] 缓存文件异常,删除后重新下载`);
try { fs.unlinkSync(file); } catch (_) {}
}
}
const stream = fs.createWriteStream(file);
https.get(url, { headers: { 'User-Agent': 'Qiming-Agent-Build' } }, (res) => {
if (res.statusCode === 302 || res.statusCode === 301) {
stream.close();
fs.unlink(file, () => {});
const loc = res.headers.location;
return download(loc.startsWith('http') ? loc : new URL(loc, url).href, preferredFilename).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
stream.close();
fs.unlink(file, () => {});
return reject(new Error(`HTTP ${res.statusCode} ${url}`));
}
res.pipe(stream);
stream.on('finish', () => { stream.close(); resolve(file); });
stream.on('error', (e) => { stream.close(); reject(e); });
}).on('error', (e) => { stream.close(); reject(e); });
});
}
function extractArchive(archivePath, outDir) {
fs.mkdirSync(outDir, { recursive: true });
const ext = path.extname(archivePath).toLowerCase();
const isZip = ext === '.zip';
if (isZip) {
if (process.platform === 'win32') {
// Windows: 使用 tar 解压 zipWindows 10+ 内置 tar
// 注意:需要等待文件流完全关闭,避免 "being used by another process" 错误
try {
execSync(`tar -xf "${archivePath}" -C "${outDir}"`, { stdio: 'inherit' });
} catch (e) {
// 如果 tar 失败,尝试使用 PowerShell可能遇到文件锁定问题
console.log('[prepare-uv] tar 解压失败,尝试 PowerShell...');
const psPath = archivePath.replace(/'/g, "''");
const psDest = outDir.replace(/'/g, "''");
execSync(
`powershell -NoProfile -Command "Expand-Archive -LiteralPath '${psPath}' -DestinationPath '${psDest}' -Force"`,
{ stdio: 'inherit' },
);
}
} else {
try {
execSync(`unzip -o -q "${archivePath}" -d "${outDir}"`, { stdio: 'inherit' });
} catch (e) {
execSync(`tar -xf "${archivePath}" -C "${outDir}"`, { stdio: 'inherit' });
}
}
} else {
execSync(`tar -xzf "${archivePath}" -C "${outDir}"`, { stdio: 'inherit' });
}
}
function moveExtractedToKey(extractDir, key) {
const entries = fs.readdirSync(extractDir);
const targetRoot = path.join(uvRoot, key);
if (entries.length === 1) {
const inner = path.join(extractDir, entries[0]);
if (fs.statSync(inner).isDirectory()) {
copyDirRecursive(inner, targetRoot);
} else {
fs.mkdirSync(path.join(targetRoot, 'bin'), { recursive: true });
fs.copyFileSync(inner, path.join(targetRoot, 'bin', entries[0]));
}
} else {
copyDirRecursive(extractDir, targetRoot);
}
// Windows zip 解压后 uv.exe 在根目录,需要移动到 bin/
const binDir = path.join(targetRoot, 'bin');
const uvExe = path.join(targetRoot, 'uv.exe');
const uvBin = path.join(targetRoot, 'uv');
const uvxExe = path.join(targetRoot, 'uvx.exe');
const uvxBin = path.join(targetRoot, 'uvx');
const uvwExe = path.join(targetRoot, 'uvw.exe');
// 确保 bin 目录存在
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
}
// 将根目录的 uv 相关文件移动到 bin/
if (fs.existsSync(uvExe)) fs.renameSync(uvExe, path.join(binDir, 'uv.exe'));
if (fs.existsSync(uvBin)) fs.renameSync(uvBin, path.join(binDir, 'uv'));
if (fs.existsSync(uvxExe)) fs.renameSync(uvxExe, path.join(binDir, 'uvx.exe'));
if (fs.existsSync(uvxBin)) fs.renameSync(uvxBin, path.join(binDir, 'uvx'));
if (fs.existsSync(uvwExe)) fs.renameSync(uvwExe, path.join(binDir, 'uvw.exe'));
}
async function downloadAndPrepare(key, suffix, version) {
const isZip = process.platform === 'win32';
const ext = isZip ? '.zip' : '.tar.gz';
const assetName = suffix + ext;
const downloadUrl = `https://github.com/astral-sh/uv/releases/download/${version}/${assetName}`;
console.log(`[prepare-uv] 下载 uv ${version} (${assetName}) ...`);
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
const archivePath = await download(downloadUrl, assetName);
const extractDir = path.join(cacheDir, `uv-extract-${key}`);
if (fs.existsSync(extractDir)) {
try { fs.rmSync(extractDir, { recursive: true }); } catch (_) {}
}
extractArchive(archivePath, extractDir);
moveExtractedToKey(extractDir, key);
if (!copyToDestBin(key)) {
console.warn('[prepare-uv] 解压后未找到 bin 目录,请检查包结构');
}
}
async function main() {
const key = getPlatformKey();
const srcDir = path.join(uvRoot, key);
const destBin = path.join(uvRoot, 'bin');
const uvName = process.platform === 'win32' ? 'uv.exe' : 'uv';
const destUv = path.join(destBin, uvName);
const platformKeyFile = path.join(destBin, '.platform-key');
console.log(`[prepare-uv] 平台: ${key}, 源码目录: ${srcDir}, 目标目录: ${destBin}`);
// 检查 .platform-key 是否匹配,不匹配则清理并重新下载
if (fs.existsSync(destUv)) {
if (fs.existsSync(platformKeyFile)) {
const existingKey = fs.readFileSync(platformKeyFile, 'utf-8').trim();
if (existingKey === key) {
console.log(`[prepare-uv] uv 已存在且架构匹配 (${key}), 跳过下载`);
return;
}
console.log(`[prepare-uv] 架构不匹配: 已有 ${existingKey}, 需要 ${key}, 清理并重新下载`);
} else {
console.log(`[prepare-uv] uv 已存在但缺少 .platform-key, 无法确认架构, 清理并重新下载`);
}
fs.rmSync(destBin, { recursive: true, force: true });
}
// 如果源码目录存在但 bin 不完整,尝试复制
if (fs.existsSync(srcDir) && copyToDestBin(key)) {
if (fs.existsSync(destUv)) {
fs.writeFileSync(platformKeyFile, key, 'utf-8');
console.log(`[prepare-uv] 使用已有 uv (${key}), 已复制到 bin/ 并写入 .platform-key`);
return;
}
}
const suffix = UV_ASSET_SUFFIX[key];
if (!suffix) {
console.warn(`[prepare-uv] 未支持的平台: ${key}`);
return;
}
const version = UV_VERSION;
console.log(`[prepare-uv] 使用 uv 版本: ${version}`);
try {
await downloadAndPrepare(key, suffix, version);
// Write .platform-key marker after successful download
if (fs.existsSync(destBin)) {
fs.writeFileSync(platformKeyFile, key, 'utf-8');
console.log(`[prepare-uv] 已写入 .platform-key: ${key}`);
}
} catch (err) {
console.error('[prepare-uv] 下载或解压失败:', err.message);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* Prepare offline wheels for windows-mcp under resources/windows-mcp/wheels.
*
* Runtime installation will happen on end-user machine to avoid non-relocatable
* uv tool launcher issues when app is installed in a different path.
*
* Note: Bundled uv no longer provides `uv pip download`; we use
* `uv run ... python -m pip download` with a pinned Python (3.13+) so wheel tags
* match package `Requires-Python` and typical `uv tool install` on Windows.
*/
const path = require('path');
const fs = require('fs');
const { execFileSync } = require('child_process');
const { getProjectRoot, resolveFromProject } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const resDir = path.join(projectRoot, 'resources', 'windows-mcp');
const wheelsDir = path.join(resDir, 'wheels');
const manifestPath = path.join(resDir, 'manifest.json');
const PACKAGE_NAME = 'windows-mcp';
/** Interpreter for pip download (windows-mcp currently requires Python >= 3.13). 若修改须同步 windowsMcp.ts 的 WINDOWS_MCP_UV_PYTHON。 */
const PIP_DOWNLOAD_PYTHON = '3.13';
function getUvBinPath() {
const uvBinName = process.platform === 'win32' ? 'uv.exe' : 'uv';
return resolveFromProject('resources', 'uv', 'bin', uvBinName);
}
function runPipDownload(uvBin, destDir, packageName, env) {
execFileSync(
uvBin,
[
'run',
'--no-project',
'--isolated',
'--python',
PIP_DOWNLOAD_PYTHON,
'-w',
'pip',
'python',
'-m',
'pip',
'download',
'--dest',
destDir,
'--only-binary',
':all:',
packageName,
],
{ stdio: 'inherit', env },
);
}
function resolvePinnedVersion(files) {
const wheel = files.find((name) => /^windows_mcp-([^-]+)-.*\.whl$/i.test(name));
if (!wheel) {
return null;
}
const match = wheel.match(/^windows_mcp-([^-]+)-.*\.whl$/i);
return match ? match[1] : null;
}
function main() {
if (process.platform !== 'win32') {
console.log('[prepare-windows-mcp] Skipped: not Windows platform');
return;
}
const uvBin = getUvBinPath();
if (!fs.existsSync(uvBin)) {
console.error('[prepare-windows-mcp] uv not found at:', uvBin);
process.exit(1);
}
console.log('[prepare-windows-mcp] Using uv:', uvBin);
if (fs.existsSync(resDir)) {
console.log('[prepare-windows-mcp] Removing old resources/windows-mcp ...');
fs.rmSync(resDir, { recursive: true, force: true });
}
fs.mkdirSync(wheelsDir, { recursive: true });
console.log('[prepare-windows-mcp] Downloading wheels for offline installation ...');
try {
runPipDownload(uvBin, wheelsDir, PACKAGE_NAME, {
...process.env,
UV_PYTHON_DOWNLOADS: process.env.UV_PYTHON_DOWNLOADS ?? 'automatic',
});
} catch (error) {
console.error('[prepare-windows-mcp] Failed to download windows-mcp wheels:', error.message);
process.exit(1);
}
const files = fs
.readdirSync(wheelsDir)
.filter((name) => {
const full = path.join(wheelsDir, name);
return fs.statSync(full).isFile() && name.toLowerCase().endsWith('.whl');
})
.sort();
if (files.length === 0) {
console.error('[prepare-windows-mcp] No files downloaded into wheels directory');
process.exit(1);
}
const version = resolvePinnedVersion(files);
if (!version) {
console.error('[prepare-windows-mcp] Could not resolve pinned windows-mcp version from downloaded wheels');
console.error('[prepare-windows-mcp] Downloaded files:', files.join(', '));
process.exit(1);
}
const manifest = {
packageName: PACKAGE_NAME,
version,
resolvedSpec: `${PACKAGE_NAME}==${version}`,
generatedAt: new Date().toISOString(),
files,
};
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
console.log(`[prepare-windows-mcp] ✓ Downloaded ${files.length} files`);
console.log(`[prepare-windows-mcp] ✓ Pinned ${manifest.resolvedSpec}`);
console.log(`[prepare-windows-mcp] ✓ Wrote manifest: ${manifestPath}`);
console.log('[prepare-windows-mcp] ✓ resources/windows-mcp ready');
}
main();

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# 同步 Electron Release 到阿里云 OSS仅同步不重新打包
# 触发 qimingclaw 仓库的 sync-electron-to-oss.yml仅同步、不构建
# 从 GitHub Release 下载已有资产 → 生成 latest.json → 上传到 OSS。
# 仅根据 tag + channel 同步,分支固定为仓库默认分支(用于取 workflow 定义),用户无需关心分支。
# 注意:真正执行同步的是目标 release 仓库(默认 qimingclaw里的 workflow
# 若本仓库 workflow 有更新,请同步到目标仓库后再使用。
#
# 用法(在 crates/agent-electron-client 目录下):
# ./scripts/sync-oss.sh <tag> [channel]
# 用法(在仓库根目录):
# ./crates/agent-electron-client/scripts/sync-oss.sh <tag> [channel]
# 示例(已有 Release 时只推 OSS不触发构建:
# ./scripts/sync-oss.sh electron-v0.9.0 # 默认 stable
# ./scripts/sync-oss.sh electron-v0.9.0 beta # 仅更新 beta/latest.json
#
# 依赖: gh (GitHub CLI)、jq且需已 gh auth login。
# Windows Git Bash 下若直接找不到 gh可与 sign-release-win.sh 一样设置:
# GH_BIN="/c/Program Files/GitHub CLI/gh.exe"
# 脚本会自动用 where.exe / 常见安装路径解析(逻辑与 sign-release-win.sh 对齐)。
set -e
# 正式发布仓库Electron 包在 qimingclaw 仓库的 Releases 中(如 electron-v0.9.0
REPO="${GITHUB_REPOSITORY:-qiming-ai/qimingclaw}"
# 与 scripts/build/sign-release-win.sh 中 resolve_gh 保持一致Git Bash 常未继承含 gh 的 Windows PATH。
resolve_powershell() {
if command -v pwsh.exe >/dev/null 2>&1; then
echo "pwsh.exe"
return 0
fi
if command -v pwsh >/dev/null 2>&1; then
echo "pwsh"
return 0
fi
if command -v powershell.exe >/dev/null 2>&1; then
echo "powershell.exe"
return 0
fi
if command -v powershell >/dev/null 2>&1; then
echo "powershell"
return 0
fi
local win_ps="/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"
if [[ -x "$win_ps" ]]; then
echo "$win_ps"
return 0
fi
return 1
}
resolve_gh() {
if [[ -n "${GH_BIN:-}" ]]; then
echo "$GH_BIN"
return 0
fi
if command -v gh >/dev/null 2>&1; then
echo "gh"
return 0
fi
local gh_win_path=""
gh_win_path="$(where.exe gh 2>/dev/null | awk 'NR==1{print;exit}' | tr -d '\r')"
if [[ -n "$gh_win_path" ]] && command -v cygpath >/dev/null 2>&1; then
cygpath -u "$gh_win_path"
return 0
fi
local candidate_win_paths=(
"C:\\Program Files\\GitHub CLI\\gh.exe"
"C:\\Program Files (x86)\\GitHub CLI\\gh.exe"
"${LOCALAPPDATA:-}\\Programs\\GitHub CLI\\gh.exe"
"${USERPROFILE:-}\\scoop\\apps\\gh\\current\\bin\\gh.exe"
)
local p=""
for p in "${candidate_win_paths[@]}"; do
[[ -z "$p" ]] && continue
p="$(echo "$p" | tr -d '\r')"
if [[ -n "$p" ]] && [[ -f "$(cygpath -u "$p" 2>/dev/null)" ]]; then
cygpath -u "$p"
return 0
fi
done
local ps_bin=""
ps_bin="$(resolve_powershell || true)"
if [[ -n "$ps_bin" ]]; then
if "$ps_bin" -Command "gh --version" >/dev/null 2>&1; then
echo "__POWERSHELL_GH__:$ps_bin"
return 0
fi
fi
return 1
}
run_gh() {
if [[ "$GH_BIN" == __POWERSHELL_GH__:* ]]; then
local ps_bin="${GH_BIN#__POWERSHELL_GH__:}"
local ps_cmd="gh"
local arg esc
for arg in "$@"; do
esc="${arg//\'/\'\'}"
ps_cmd+=" '$esc'"
done
"$ps_bin" -NoProfile -Command "$ps_cmd"
return $?
fi
"$GH_BIN" "$@"
}
# 解析参数tag + 可选 channel不涉及分支
if [ $# -eq 0 ]; then
echo "用法: $0 <tag> [channel]"
echo "示例: $0 electron-v0.8.0 stable"
echo "示例: $0 electron-v0.8.0 beta"
exit 1
fi
TAG="$1"
CHANNEL="${2:-stable}"
# 验证 tag 格式
if [[ ! "$TAG" =~ ^electron-v ]]; then
echo "错误: tag 必须以 'electron-v' 开头"
echo "当前: $TAG"
exit 1
fi
if [[ "$CHANNEL" != "stable" && "$CHANNEL" != "beta" ]]; then
echo "错误: channel 仅支持 stable 或 beta"
echo "当前: $CHANNEL"
exit 1
fi
GH_BIN=""
GH_BIN="$(resolve_gh)" || true
if [[ -z "$GH_BIN" ]]; then
echo "错误: 未找到 GitHub CLI (gh)。Git Bash 下 PATH 往往不含「GitHub CLI」安装目录而 sign-release-win.sh 会主动解析 gh.exe 路径。"
echo "可选:"
echo " - 设置 GH_BIN=\"/c/Program Files/GitHub CLI/gh.exe\" 后再运行本脚本"
echo " - 或将 C:\\Program Files\\GitHub CLI 加入 Windows 用户 PATH 后重新打开终端"
exit 127
fi
# workflow_dispatch 需要 ref使用仓库默认分支仅 tag 由用户指定
REF=$(run_gh repo view "$REPO" --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || echo "main")
# 获取 GitHub token
TOKEN=$(run_gh auth token)
# 触发 workflow_dispatchref 用默认分支;新版 workflow 支持 inputs.tag + inputs.channel
dispatch_sync_workflow() {
local payload="$1"
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/$REPO/actions/workflows/sync-electron-to-oss.yml/dispatches" \
-d "$payload"
}
RESPONSE=$(dispatch_sync_workflow "{\"ref\":\"$REF\",\"inputs\":{\"tag\":\"$TAG\",\"channel\":\"$CHANNEL\"}}")
if [ -n "$RESPONSE" ]; then
# 远程 qimingclaw 若仍为旧版 YAML仅有 tagGitHub 会 422Unexpected inputs: ["channel"]
MSG=$(echo "$RESPONSE" | jq -r '.message // empty' 2>/dev/null || echo "")
if [[ "$MSG" == *"Unexpected inputs"* && "$MSG" == *"channel"* ]]; then
if [[ "$CHANNEL" == "beta" ]]; then
echo "错误: 远程 workflow 不支持 channel=beta"
exit 1
fi
# 兼容旧版 workflow未声明 channel inputstable 场景退回仅传 tag
RESPONSE=$(dispatch_sync_workflow "{\"ref\":\"$REF\",\"inputs\":{\"tag\":\"$TAG\"}}")
fi
fi
if [ -n "$RESPONSE" ]; then
echo "错误: $RESPONSE"
exit 1
fi
echo "✓ 触发成功"
exit 0

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const PROJECT_ROOT = process.env.QIMING_BOUNDARY_PROJECT_ROOT
? path.resolve(process.env.QIMING_BOUNDARY_PROJECT_ROOT)
: path.resolve(__dirname, '..', '..');
const SRC_ROOT = path.join(PROJECT_ROOT, 'src');
const MAIN_ROOT = path.join(SRC_ROOT, 'main');
const RENDERER_ROOT = path.join(SRC_ROOT, 'renderer');
const PRELOAD_ROOT = path.join(SRC_ROOT, 'preload');
const BANNED_BRIDGE_FILES = [
'src/main/startup.ts',
'src/main/logConfig.ts',
'src/main/serviceManager.ts',
'src/main/trayManager.ts',
'src/main/autoLaunchManager.ts',
'src/main/preload.ts',
'src/renderer/services/api.ts',
'src/renderer/services/setup.ts',
'src/renderer/services/auth.ts',
'src/renderer/services/ai.ts',
'src/renderer/services/fileServer.ts',
'src/renderer/services/lanproxy.ts',
'src/renderer/services/agentRunner.ts',
'src/renderer/services/sandbox.ts',
'src/renderer/services/permissions.ts',
'src/renderer/services/skills.ts',
'src/renderer/services/im.ts',
'src/renderer/services/scheduler.ts',
'src/renderer/services/logService.ts',
'src/renderer/components/ClientPage.tsx',
'src/renderer/components/SettingsPage.tsx',
'src/renderer/components/DependenciesPage.tsx',
'src/renderer/components/AboutPage.tsx',
'src/renderer/components/PermissionsPage.tsx',
'src/renderer/components/LogViewer.tsx',
'src/renderer/components/MCPSettings.tsx',
'src/renderer/components/AgentSettings.tsx',
'src/renderer/components/AgentRunnerSettings.tsx',
'src/renderer/components/LanproxySettings.tsx',
'src/renderer/components/SkillsSync.tsx',
'src/renderer/components/IMSettings.tsx',
'src/renderer/components/TaskSettings.tsx',
'src/renderer/components/SetupWizard.tsx',
'src/renderer/components/SetupDependencies.tsx',
'src/renderer/components/PermissionModal.tsx',
];
const BANNED_BRIDGE_PATHS = new Set(
BANNED_BRIDGE_FILES.map((p) => path.normalize(path.join(PROJECT_ROOT, p)))
);
function isSourceFile(filePath) {
return filePath.endsWith('.ts') || filePath.endsWith('.tsx');
}
function walk(dirPath) {
const out = [];
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
out.push(...walk(fullPath));
continue;
}
if (isSourceFile(fullPath)) out.push(fullPath);
}
return out;
}
function existsFile(candidate) {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
function resolveImport(specifier, fromFile) {
let basePath = null;
if (specifier.startsWith('.')) {
basePath = path.resolve(path.dirname(fromFile), specifier);
} else if (specifier.startsWith('@/')) {
basePath = path.join(SRC_ROOT, specifier.slice(2));
} else if (specifier.startsWith('@main/')) {
basePath = path.join(MAIN_ROOT, specifier.slice('@main/'.length));
} else if (specifier.startsWith('@renderer/')) {
basePath = path.join(RENDERER_ROOT, specifier.slice('@renderer/'.length));
} else if (specifier.startsWith('@preload/')) {
basePath = path.join(PRELOAD_ROOT, specifier.slice('@preload/'.length));
} else if (specifier.startsWith('@shared/')) {
basePath = path.join(SRC_ROOT, 'shared', specifier.slice('@shared/'.length));
} else {
return null;
}
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
path.join(basePath, 'index.ts'),
path.join(basePath, 'index.tsx'),
path.join(basePath, 'index.js'),
];
for (const candidate of candidates) {
if (existsFile(candidate)) return path.normalize(candidate);
}
return null;
}
function lineFromIndex(content, index) {
let line = 1;
for (let i = 0; i < index; i += 1) {
if (content.charCodeAt(i) === 10) line += 1;
}
return line;
}
function collectImports(fileContent) {
const specs = [];
const patterns = [
/\bimport\s+(?:[^'"]+from\s+)?['"]([^'"]+)['"]/g,
/\bexport\s+[^'"]*from\s+['"]([^'"]+)['"]/g,
/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g,
/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(fileContent)) !== null) {
specs.push({ specifier: match[1], index: match.index });
}
}
return specs;
}
function isWithin(filePath, rootPath) {
const rel = path.relative(rootPath, filePath);
return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function checkFile(filePath) {
const violations = [];
const content = fs.readFileSync(filePath, 'utf8');
const imports = collectImports(content);
for (const imp of imports) {
const resolved = resolveImport(imp.specifier, filePath);
const line = lineFromIndex(content, imp.index);
if (resolved && BANNED_BRIDGE_PATHS.has(resolved)) {
violations.push({
line,
rule: 'no-bridge-imports',
message: `Do not import bridge file '${imp.specifier}'. Import the new grouped path directly.`,
});
continue;
}
const inRenderer = isWithin(filePath, RENDERER_ROOT);
const inMain = isWithin(filePath, MAIN_ROOT);
if (inRenderer) {
const directAliasViolation = imp.specifier.startsWith('@main/') || imp.specifier.startsWith('@preload/');
const resolvedViolation = resolved && (isWithin(resolved, MAIN_ROOT) || isWithin(resolved, PRELOAD_ROOT));
if (directAliasViolation || resolvedViolation) {
violations.push({
line,
rule: 'renderer-process-boundary',
message: `Renderer must not import main/preload code directly ('${imp.specifier}'). Use IPC bridge only.`,
});
}
}
if (inMain) {
const directAliasViolation = imp.specifier.startsWith('@renderer/');
const resolvedViolation = resolved && isWithin(resolved, RENDERER_ROOT);
if (directAliasViolation || resolvedViolation) {
violations.push({
line,
rule: 'main-process-boundary',
message: `Main process must not import renderer code ('${imp.specifier}').`,
});
}
}
}
return violations;
}
function runBoundaryCheck() {
const files = walk(SRC_ROOT);
const allViolations = [];
for (const file of files) {
const violations = checkFile(file);
for (const v of violations) {
allViolations.push({
file: path.relative(PROJECT_ROOT, file),
...v,
});
}
}
return allViolations;
}
function main() {
const allViolations = runBoundaryCheck();
if (allViolations.length > 0) {
console.error('Import boundary check failed:\n');
for (const v of allViolations) {
console.error(`- ${v.file}:${v.line} [${v.rule}] ${v.message}`);
}
console.error(`\nTotal violations: ${allViolations.length}`);
process.exit(1);
}
console.log('Import boundary check passed.');
}
if (require.main === module) {
main();
}
module.exports = {
runBoundaryCheck,
};

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# 检查端口是否被占用,输出占用进程 PID若有exit 0=占用 exit 1=未占用
# 与 main 进程 startupPorts.ts 内嵌逻辑一致Windows 依赖集成 Git Bash (prepare-git) 或系统 PATH 中的 netstat/findstr
# 用法: bash check-port.sh PORT
port="$1"
if [[ -z "$port" ]]; then
echo "usage: check-port.sh PORT" >&2
exit 2
fi
if [[ "$OSTYPE" == msys* ]] || [[ "$OSTYPE" == cygwin* ]]; then
# Windows (Git Bash / MSYS2): 使用 cmd 的 netstat + findstr
out=$(cmd //c "netstat -ano 2>nul | findstr \":${port} \"" 2>/dev/null)
else
# macOS / Linux
out=$(lsof -t -i ":${port}" 2>/dev/null)
fi
if [[ -n "$out" ]]; then
# 取首行最后一列作为 PIDnetstat 最后一列是 PIDlsof -t 整行即 PID
pid=$(echo "$out" | head -1 | awk '{print $NF}')
if [[ -n "$pid" && "$pid" =~ ^[0-9]+$ ]]; then
echo "$pid"
fi
exit 0
fi
exit 1

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* 启动前端口检查脚本(聚合逻辑的 CLI 入口)
*
* 端口默认值与解析规则与 src/shared/startupPorts.ts 保持一致,请同步修改。
* 从 ~/.qimingclaw/qimingclaw.dbWindows: %USERPROFILE%\.qimingclaw\)读取配置。
* 端口占用检查:优先统一用 bash 执行 scripts/tools/check-port.shWindows 需先 npm run prepare:git 集成 Git Bash
* 无 bash 或脚本时回退到 Node 内联 netstat/lsof。
* 依赖Node读取 DB 需系统 sqlite3Windows 上多数未预装,无则用默认端口)。
*/
const path = require('path');
const fs = require('fs');
const { execSync, spawnSync } = require('child_process');
const { getProjectRoot } = require('../utils/project-paths');
const isWin = process.platform === 'win32';
const home = process.env.HOME || process.env.USERPROFILE;
const dbPath = path.join(home, '.qimingclaw', 'qimingclaw.db');
const projectRoot = getProjectRoot();
const checkPortShPath = path.join(__dirname, 'check-port.sh');
const winBashPath = path.join(projectRoot, 'resources', 'git', 'bin', 'bash.exe');
// 与 shared/constants + shared/startupPorts 保持一致
const DEFAULTS = {
agent: 60001,
fileServer: 60000,
mcp: 18099,
lanproxyLocal: 60002,
vite: 60173,
};
const LABELS = {
agent: 'Agent(ComputerServer)',
fileServer: 'FileServer',
mcp: 'MCP Proxy',
lanproxyLocal: 'Lanproxy',
vite: 'Vite',
};
function getSetting(dbPath, key) {
try {
// key 仅来自内部常量仍做引号转义以防将来扩展Windows 路径含反斜杠,双引号包裹路径即可
const safeKey = String(key).replace(/'/g, "''");
const quotedPath = dbPath.replace(/\\/g, '/'); // sqlite3 在 Windows 上通常也接受正斜杠
const out = execSync(`sqlite3 "${quotedPath}" "SELECT value FROM settings WHERE key='${safeKey}';"`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
...(isWin && { shell: true }), // Windows 下若 sqlite3 为 .cmd 或路径含空格,需 shell
}).trim();
if (!out) return null;
try {
return JSON.parse(out);
} catch {
return out;
}
} catch {
return null;
}
}
function resolvePortsFromSettings(getSettingFn) {
const step1 = getSettingFn('step1_config');
const agent = (step1 && step1.agentPort != null) ? step1.agentPort : DEFAULTS.agent;
const fileServer = (step1 && step1.fileServerPort != null) ? step1.fileServerPort : DEFAULTS.fileServer;
const mcpRaw = getSettingFn('mcp_proxy_port');
let mcp = DEFAULTS.mcp;
if (typeof mcpRaw === 'number' && Number.isInteger(mcpRaw)) mcp = mcpRaw;
else if (typeof mcpRaw === 'string') { const n = parseInt(mcpRaw, 10); if (!Number.isNaN(n)) mcp = n; }
return {
agent,
fileServer,
mcp,
lanproxyLocal: DEFAULTS.lanproxyLocal,
vite: DEFAULTS.vite,
};
}
function getPortsToCheck(ports, includeVite) {
const list = [
{ name: 'agent', label: LABELS.agent, port: ports.agent },
{ name: 'fileServer', label: LABELS.fileServer, port: ports.fileServer },
{ name: 'mcp', label: LABELS.mcp, port: ports.mcp },
{ name: 'lanproxyLocal', label: LABELS.lanproxyLocal, port: ports.lanproxyLocal },
];
if (includeVite) list.push({ name: 'vite', label: LABELS.vite, port: ports.vite });
return list;
}
/** 优先用 bash 执行 scripts/tools/check-port.sh与 main 进程一致),否则回退 Node 内联逻辑 */
function isPortInUse(port) {
const bashPath = isWin ? (fs.existsSync(winBashPath) ? winBashPath : null) : 'bash';
const scriptContent = fs.existsSync(checkPortShPath)
? fs.readFileSync(checkPortShPath, 'utf8')
: null;
if (bashPath && scriptContent) {
const result = spawnSync(bashPath, ['-c', scriptContent, '_', String(port)], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
if (result.status === 0 && result.stdout) {
const text = (result.stdout && String(result.stdout)).trim();
const pid = text && /^\d+$/.test(text) ? parseInt(text, 10) : undefined;
return { inUse: true, pid };
}
return { inUse: false };
}
// 回退:无 bash 或脚本时用 Node 内联 netstat/lsof
try {
if (isWin) {
const out = execSync(`netstat -ano 2>nul | findstr ":${port} "`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
shell: true,
});
const text = (out && String(out)).trim();
const inUse = text.length > 0;
let pid;
if (inUse) {
const firstLine = text.split(/\r?\n/)[0] || '';
const lastCol = firstLine.split(/\s+/).filter(Boolean).pop();
if (lastCol && /^\d+$/.test(lastCol)) pid = parseInt(lastCol, 10);
}
return { inUse, pid };
}
const out = execSync(`lsof -t -i :${port} 2>/dev/null`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
const text = (out && String(out)).trim();
const inUse = text.length > 0;
const pid = inUse && /^\d+$/.test(text) ? parseInt(text.split('\n')[0], 10) : undefined;
return { inUse, pid };
} catch {
return { inUse: false };
}
}
function main() {
const includeVite = process.argv.includes('--vite');
const getSettingFn = (key) => getSetting(dbPath, key);
let ports;
let source = '';
if (!fs.existsSync(dbPath)) {
ports = { ...DEFAULTS };
source = '未找到 DB使用默认端口';
} else {
try {
ports = resolvePortsFromSettings(getSettingFn);
source = `已从 ${dbPath} 读取配置`;
} catch (err) {
ports = { ...DEFAULTS };
source = '读取 DB 失败(如未安装 sqlite3使用默认端口';
}
}
console.log('端口来源:', source);
const list = getPortsToCheck(ports, includeVite);
console.log('检查端口:', list.map(({ label, port }) => `${label}=${port}`).join(', '));
console.log('');
let hasInUse = false;
for (const { label, port } of list) {
const { inUse, pid } = isPortInUse(port);
if (inUse) {
hasInUse = true;
console.log(` [占用] ${label} (${port}) ${pid ? `PID=${pid}` : ''}`);
} else {
console.log(` [空闲] ${label} (${port})`);
}
}
process.exit(hasInUse ? 1 : 0);
}
main();

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env node
/**
* 清理 Electron / electron-builder 缓存用于修复「zip: not a valid zip file」等因缓存损坏导致的打包失败。
* 清理后重新执行 build:electron 会重新下载对应平台的 Electron。
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const home = os.homedir();
const dirs =
process.platform === 'darwin'
? [
path.join(home, 'Library', 'Caches', 'electron'),
path.join(home, 'Library', 'Caches', 'electron-builder'),
]
: process.platform === 'win32'
? [
path.join(process.env.LOCALAPPDATA || home, 'electron', 'Cache'),
path.join(process.env.LOCALAPPDATA || home, 'electron-builder', 'Cache'),
]
: [
path.join(home, '.cache', 'electron'),
path.join(home, '.cache', 'electron-builder'),
];
for (const dir of dirs) {
try {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true });
console.log('[clean-electron-cache] 已删除:', dir);
}
} catch (e) {
console.warn('[clean-electron-cache] 删除失败:', dir, e.message);
}
}
console.log('[clean-electron-cache] 完成,可重新执行 npm run build:electron -- --win');

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* 生成 / 检查托盘图标
*
* public/tray/ 下的文件说明:
*
* macOS已手工制作提交在 git 中):
* trayTemplate.png — 22x22 黑色剪影 + alphamacOS Template Image @1x
* trayTemplate@2x.png — 44x44 黑色剪影 + alphamacOS Template Image @2x
*
* Windows / Linux此脚本自动从 64x64.png 生成):
* tray.png — 32x32 彩色
* tray@2x.png — 64x64 彩色
*
* Usage:
* node scripts/tools/generate-tray-icons.js
*/
const path = require('path');
const fs = require('fs');
const { getProjectRoot } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const LOGO_ICON = path.join(projectRoot, 'public', '64x64.png');
const OUTPUT_DIR = path.join(projectRoot, 'public', 'tray');
async function generateTrayIcons() {
const sharp = require('sharp');
if (!fs.existsSync(LOGO_ICON)) {
console.error('Source icon not found:', LOGO_ICON);
process.exit(1);
}
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
// ---- macOS: 检查手工模板图标是否存在 ----
for (const f of ['trayTemplate.png', 'trayTemplate@2x.png']) {
const p = path.join(OUTPUT_DIR, f);
if (!fs.existsSync(p)) {
console.warn(` WARNING: macOS template icon missing: ${f} — please craft manually (black silhouette + alpha)`);
} else {
console.log(` macOS template: ${f} (exists)`);
}
}
// ---- Windows / Linux: 从 64x64 logo 生成彩色图标 ----
// Logo 占画布 ~75%,四周留透明 padding
for (const { size, suffix } of [
{ size: 32, suffix: 'tray.png' },
{ size: 64, suffix: 'tray@2x.png' },
]) {
const logoSize = Math.round(size * 0.75);
const padding = Math.round((size - logoSize) / 2);
const outPath = path.join(OUTPUT_DIR, suffix);
await sharp(LOGO_ICON)
.resize(logoSize, logoSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.extend({
top: padding,
bottom: size - logoSize - padding,
left: padding,
right: size - logoSize - padding,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toFile(outPath);
console.log(` colored: ${suffix} (${size}x${size})`);
}
console.log('Done! Tray dir:', OUTPUT_DIR);
}
generateTrayIcons().catch(err => {
console.error('Error generating tray icons:', err);
process.exit(1);
});

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* 测试集成的 Node.js 是否正确配置
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { getProjectRoot } = require('../utils/project-paths');
const projectRoot = getProjectRoot();
const distMain = path.join(projectRoot, 'dist/main');
console.log('Testing integrated Node.js 24 configuration...\n');
// 1. 检查资源目录
const platform = `${process.platform}-${process.arch}`;
console.log(`Platform: ${platform}`);
const nodeDir = path.join(projectRoot, 'resources', 'node', platform);
const nodeBin = path.join(nodeDir, 'bin', process.platform === 'win32' ? 'node.exe' : 'node');
console.log(`Node directory: ${nodeDir}`);
console.log(`Node binary: ${nodeBin}\n`);
// 2. 检查目录存在
if (!fs.existsSync(nodeDir)) {
console.error(`❌ Node.js directory not found: ${nodeDir}`);
console.log('💡 Run: npm run prepare:node');
process.exit(1);
}
console.log('✅ Node.js directory exists');
// 3. 检查二进制文件
if (!fs.existsSync(nodeBin)) {
console.error(`❌ Node binary not found: ${nodeBin}`);
process.exit(1);
}
console.log('✅ Node binary exists\n');
// 4. 测试版本
try {
const result = execSync(`"${nodeBin}" --version`, { encoding: 'utf8' }).trim();
console.log(`Node version: ${result}`);
// 读取 prepare-node.js 中定义的 NODE_VERSION
const prepareScript = fs.readFileSync(path.join(projectRoot, 'scripts', 'prepare', 'prepare-node.js'), 'utf8');
const versionMatch = prepareScript.match(/const NODE_VERSION = '([^']+)'/);
const expectedVersion = versionMatch ? versionMatch[1] : null;
if (expectedVersion && result === `v${expectedVersion}`) {
console.log(`✅ Version is v${expectedVersion} (correct)\n`);
} else if (expectedVersion) {
console.log(`⚠️ Expected v${expectedVersion}, got ${result}\n`);
} else {
console.log(`⚠️ Could not determine expected version from prepare-node.js\n`);
}
} catch (err) {
console.error(`❌ Failed to run node: ${err.message}`);
process.exit(1);
}
// 5. 测试编译后的 getNodeBinPath
console.log('Testing getNodeBinPath() function...');
try {
// 模拟 Electron 环境
const oldApp = global.app;
global.app = {
getPath: (name) => {
if (name === 'home') return '/mock/home';
return projectRoot;
},
};
const dependenciesPath = path.join(distMain, 'services/system/dependencies.js');
if (!fs.existsSync(dependenciesPath)) {
console.log('⚠️ Compiled dependencies.js not found (need to run: npm run build:main)');
} else {
// 清除缓存重新加载
delete require.cache[require.resolve(dependenciesPath)];
const { getNodeBinPath } = require(dependenciesPath);
const resolvedPath = getNodeBinPath();
console.log(`getNodeBinPath() returned: ${resolvedPath}`);
if (resolvedPath) {
console.log('✅ getNodeBinPath() returns correct path\n');
} else {
console.log('❌ getNodeBinPath() returned null\n');
}
}
// 恢复 app
global.app = oldApp;
} catch (err) {
console.log(`⚠️ Could not test getNodeBinPath(): ${err.message}\n`);
}
console.log('✅ Integrated Node.js 24 is ready!');
console.log('\n💡 When MCP proxy starts, it will use this integrated node.');

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env bash
#
# 本地测试自动更新流程
#
# 步骤:
# 1. 打包当前版本作为「旧版」客户端
# 2. 临时 bump 到高版本,打包作为「新版」更新源
# 3. 用 HTTP 服务托管新版产物
# 4. 启动旧版客户端,指向本地更新源
#
# 用法:
# bash scripts/tools/test-update.sh
#
# 前置条件:
# - npm install 已完成
# - npx serve 可用(或全局安装 serve
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$PROJECT_DIR"
# ---------- 配置 ----------
OLD_VERSION=$(node -p "require('./package.json').version")
NEW_VERSION="99.0.0" # 足够高,保证触发更新
SERVE_PORT=8080
echo "========================================="
echo " 本地更新测试"
echo "========================================="
echo " 当前版本 (旧版): $OLD_VERSION"
echo " 模拟版本 (新版): $NEW_VERSION"
echo " 更新源端口: $SERVE_PORT"
echo "========================================="
echo ""
# ---------- Step 1: 打包旧版 ----------
echo "[1/4] 打包旧版 v${OLD_VERSION} ..."
OLD_RELEASE_DIR="$PROJECT_DIR/release/$OLD_VERSION"
if [ -d "$OLD_RELEASE_DIR" ] && ls "$OLD_RELEASE_DIR"/*.dmg "$OLD_RELEASE_DIR"/*.exe "$OLD_RELEASE_DIR"/*.AppImage 2>/dev/null | head -1 > /dev/null; then
echo " -> 已存在 $OLD_RELEASE_DIR,跳过打包"
else
npm run dist:unsigned:local
fi
echo ""
# ---------- Step 2: 临时 bump 版本,打包新版 ----------
echo "[2/4] 打包新版 v${NEW_VERSION} ..."
NEW_RELEASE_DIR="$PROJECT_DIR/release/$NEW_VERSION"
if [ -d "$NEW_RELEASE_DIR" ] && ls "$NEW_RELEASE_DIR"/latest*.yml 2>/dev/null | head -1 > /dev/null; then
echo " -> 已存在 $NEW_RELEASE_DIR,跳过打包"
else
# 临时修改版本号
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = '$NEW_VERSION';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo " -> package.json version 临时改为 $NEW_VERSION"
npm run dist:unsigned:local || true
# 恢复版本号
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = '$OLD_VERSION';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo " -> package.json version 已恢复为 $OLD_VERSION"
fi
echo ""
# ---------- Step 3: 启动本地更新服务器 ----------
echo "[3/4] 启动本地更新服务器 ..."
echo " -> 托管目录: $NEW_RELEASE_DIR"
echo " -> 地址: http://localhost:$SERVE_PORT"
echo ""
# 检查 latest*.yml 是否存在
if ! ls "$NEW_RELEASE_DIR"/latest*.yml 2>/dev/null | head -1 > /dev/null; then
echo "❌ 错误: $NEW_RELEASE_DIR 中缺少 latest*.yml 文件"
echo " electron-updater 需要此文件来检测更新"
exit 1
fi
echo " latest*.yml 内容:"
echo " ---"
cat "$NEW_RELEASE_DIR"/latest*.yml | head -20 | sed 's/^/ /'
echo " ---"
echo ""
# 后台启动 HTTP 服务
npx serve "$NEW_RELEASE_DIR" -p $SERVE_PORT --no-clipboard &
SERVE_PID=$!
sleep 2
# 验证服务启动
if ! curl -s "http://localhost:$SERVE_PORT/" > /dev/null 2>&1; then
echo "❌ HTTP 服务启动失败"
kill $SERVE_PID 2>/dev/null || true
exit 1
fi
echo " -> HTTP 服务已启动 (PID: $SERVE_PID)"
echo ""
# ---------- Step 4: 启动旧版客户端 ----------
echo "[4/4] 启动旧版客户端 ..."
echo ""
APP_PATH=""
if [ "$(uname)" = "Darwin" ]; then
# macOS: 找 .app
APP_PATH=$(find "$OLD_RELEASE_DIR" -name "*.app" -maxdepth 2 | head -1)
if [ -z "$APP_PATH" ]; then
# 可能在 mac-arm64 或 mac 子目录
APP_PATH=$(find "$OLD_RELEASE_DIR" -name "*.app" -maxdepth 3 | head -1)
fi
elif [ "$(uname)" = "Linux" ]; then
APP_PATH=$(find "$OLD_RELEASE_DIR" -name "*.AppImage" | head -1)
fi
if [ -z "$APP_PATH" ]; then
echo "⚠️ 未找到旧版安装包,请手动启动:"
echo ""
echo " macOS:"
echo " QIMING_UPDATE_SERVER=http://localhost:$SERVE_PORT open \"$OLD_RELEASE_DIR/mac-arm64/QimingClaw.app\""
echo ""
echo " 或直接运行二进制:"
echo " QIMING_UPDATE_SERVER=http://localhost:$SERVE_PORT \"$OLD_RELEASE_DIR/mac-arm64/QimingClaw.app/Contents/MacOS/QimingClaw\""
echo ""
else
echo " 找到旧版应用: $APP_PATH"
echo ""
if [ "$(uname)" = "Darwin" ]; then
BINARY_PATH="$APP_PATH/Contents/MacOS/QimingClaw"
echo " 启动命令:"
echo " QIMING_UPDATE_SERVER=http://localhost:$SERVE_PORT \"$BINARY_PATH\""
echo ""
echo " 按 Enter 启动,或 Ctrl+C 取消 ..."
read -r
QIMING_UPDATE_SERVER="http://localhost:$SERVE_PORT" "$BINARY_PATH" &
else
echo " 启动命令:"
echo " QIMING_UPDATE_SERVER=http://localhost:$SERVE_PORT \"$APP_PATH\""
echo ""
echo " 按 Enter 启动,或 Ctrl+C 取消 ..."
read -r
QIMING_UPDATE_SERVER="http://localhost:$SERVE_PORT" "$APP_PATH" &
fi
fi
echo ""
echo "========================================="
echo " 测试指引"
echo "========================================="
echo " 1. 打开「关于」页面"
echo " 2. 点「检查更新」或等 10 秒自动检查"
echo " 3. 应显示「发现新版本: v$NEW_VERSION"
echo " 4. 点「下载更新」,观察进度条"
echo " 5. 下载完成后点「重启安装」"
echo "========================================="
echo ""
echo " 测试完成后按 Ctrl+C 停止更新服务器"
# 等待 Ctrl+C
trap "echo ''; echo '正在清理...'; kill $SERVE_PID 2>/dev/null; echo '完成'; exit 0" INT TERM
wait $SERVE_PID 2>/dev/null

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
'use strict';
const path = require('path');
/**
* Electron client project root (crates/agent-electron-client).
* This file lives at scripts/utils/, so ../.. always points to project root.
*/
function getProjectRoot() {
return path.resolve(__dirname, '..', '..');
}
function resolveFromProject(...parts) {
return path.join(getProjectRoot(), ...parts);
}
module.exports = {
getProjectRoot,
resolveFromProject,
};