chore: initialize qiming workspace repository
This commit is contained in:
@@ -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
|
||||
@@ -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)",
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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"))"
|
||||
@@ -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"))"
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 构建 Windows 专用 Sandbox helper(qiming-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 x64(target: ${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);
|
||||
}
|
||||
@@ -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/tsc(Unix 风格),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();
|
||||
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Git 集成(仅 Windows 客户端)
|
||||
*
|
||||
* 仅在 Windows 构建时执行:准备 resources/git/(PortableGit + bash),供端口检查等脚本使用。
|
||||
* macOS/Linux 使用系统 git/bash,不打包内置 Git。
|
||||
*
|
||||
* 参考 LobsterAI 方案:scripts/setup-mingit.js(workspace/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,
|
||||
};
|
||||
@@ -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 bundle(ESM,原生依赖标记为 external)
|
||||
* ├── dist/lib.bundle.cjs — esbuild SDK bundle(CJS,供 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();
|
||||
@@ -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();
|
||||
@@ -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 bundle(esbuild 单文件,含 shebang)
|
||||
* ├── dist/lib.bundle.js — 库 bundle(PersistentMcpBridge 等导出)
|
||||
* └── 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();
|
||||
@@ -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.js,monaco 可能无法加载');
|
||||
}
|
||||
|
||||
console.log('[prepare-monaco] ✓ public/monaco/vs 已准备完成');
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -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 校验和
|
||||
*
|
||||
* 平台 key:darwin-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();
|
||||
@@ -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();
|
||||
@@ -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`];
|
||||
|
||||
// Windows:PATH 里常见的是 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();
|
||||
@@ -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" });
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 准备三端沙箱运行时资源
|
||||
*
|
||||
* 约定:
|
||||
* - 子模块目录:../agent-sandbox-runtime(相对 crates/agent-electron-client)
|
||||
* - 清单文件:manifest.json
|
||||
* - 目标目录:resources/sandbox-runtime/bin
|
||||
*
|
||||
* (仅 Windows 客户端)Sandbox helper:manifest 的 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);
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
* 平台 key:darwin-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 | linux;x64 | 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 解压 zip(Windows 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();
|
||||
@@ -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();
|
||||
180
qimingclaw/crates/agent-electron-client/scripts/sync-oss.sh
Normal file
180
qimingclaw/crates/agent-electron-client/scripts/sync-oss.sh
Normal 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_dispatch:ref 用默认分支;新版 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(仅有 tag),GitHub 会 422:Unexpected 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 input):stable 场景退回仅传 tag
|
||||
RESPONSE=$(dispatch_sync_workflow "{\"ref\":\"$REF\",\"inputs\":{\"tag\":\"$TAG\"}}")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$RESPONSE" ]; then
|
||||
echo "错误: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ 触发成功"
|
||||
exit 0
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
# 取首行最后一列作为 PID(netstat 最后一列是 PID;lsof -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
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 启动前端口检查脚本(聚合逻辑的 CLI 入口)
|
||||
*
|
||||
* 端口默认值与解析规则与 src/shared/startupPorts.ts 保持一致,请同步修改。
|
||||
* 从 ~/.qimingclaw/qimingclaw.db(Windows: %USERPROFILE%\.qimingclaw\)读取配置。
|
||||
* 端口占用检查:优先统一用 bash 执行 scripts/tools/check-port.sh(Windows 需先 npm run prepare:git 集成 Git Bash),
|
||||
* 无 bash 或脚本时回退到 Node 内联 netstat/lsof。
|
||||
* 依赖:Node;读取 DB 需系统 sqlite3(Windows 上多数未预装,无则用默认端口)。
|
||||
*/
|
||||
|
||||
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();
|
||||
@@ -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');
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 生成 / 检查托盘图标
|
||||
*
|
||||
* public/tray/ 下的文件说明:
|
||||
*
|
||||
* macOS(已手工制作,提交在 git 中):
|
||||
* trayTemplate.png — 22x22 黑色剪影 + alpha(macOS Template Image @1x)
|
||||
* trayTemplate@2x.png — 44x44 黑色剪影 + alpha(macOS 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);
|
||||
});
|
||||
@@ -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.');
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user