dsh-version-check 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -2
- package/cordis.patch.yml +23 -0
- package/icon.svg +13 -0
- package/lib/client.js +977 -0
- package/lib/deployment.d.ts +36 -0
- package/lib/deployment.js +154 -0
- package/lib/gate.d.ts +22 -0
- package/lib/gate.js +92 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +833 -0
- package/lib/install.d.ts +29 -0
- package/lib/install.js +109 -0
- package/lib/preferences.d.ts +41 -0
- package/lib/preferences.js +132 -0
- package/lib/registry.d.ts +53 -0
- package/lib/registry.js +124 -0
- package/lib/styles/client.css +80 -0
- package/lib/versions.d.ts +32 -0
- package/lib/versions.js +100 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 定位 DSH 安装目录、npm 全局前缀与 systemd 服务单元。
|
|
3
|
+
*/
|
|
4
|
+
/** DSH 部署形态。 */
|
|
5
|
+
export interface DeploymentInfo {
|
|
6
|
+
packageRoot: string;
|
|
7
|
+
version: string;
|
|
8
|
+
npmPrefix: string | null;
|
|
9
|
+
unitName: string | null;
|
|
10
|
+
restartMode: 'systemd' | 'manual';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 从 `/proc/self/cgroup` 的内容中取所在服务的单元名。
|
|
14
|
+
* 按 `/` 切分 cgroup 路径,从末尾向前找第一个 `.service` 段;
|
|
15
|
+
* `.scope` 段继续向上查找(服务内嵌套的 scope 仍指向所属服务)。
|
|
16
|
+
* `user@N.service` 是 systemd 用户管理器,重启它等于注销整个用户会话,
|
|
17
|
+
* 因此该单元被排除并返回 null。
|
|
18
|
+
* @param text cgroup 文件内容。
|
|
19
|
+
* @returns 没有可用的 service 单元时返回 null。
|
|
20
|
+
*/
|
|
21
|
+
export declare function unitNameFromCgroup(text: unknown): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* 探测 DSH 的部署形态,同步执行。
|
|
24
|
+
*/
|
|
25
|
+
export declare function detectDeployment(): DeploymentInfo;
|
|
26
|
+
/**
|
|
27
|
+
* 读取磁盘上当前的 DSH 版本。每次调用都重新读取 package.json。
|
|
28
|
+
* @param packageRoot 包根目录。
|
|
29
|
+
*/
|
|
30
|
+
export declare function readInstalledVersion(packageRoot: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* 在 PATH 中查找 npm 可执行文件。
|
|
33
|
+
* @param env 环境变量,默认使用 process.env。
|
|
34
|
+
* @returns 找不到返回 null。
|
|
35
|
+
*/
|
|
36
|
+
export declare function findNpmExecutable(env?: NodeJS.ProcessEnv): string | null;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { delimiter, dirname, join } from "node:path";
|
|
4
|
+
// src/deployment.ts
|
|
5
|
+
/**
|
|
6
|
+
* 定位 DSH 安装目录、npm 全局前缀与 systemd 服务单元。
|
|
7
|
+
*/
|
|
8
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh";
|
|
9
|
+
const ROOT_SEARCH_DEPTH = 8;
|
|
10
|
+
const CGROUP_PATH = "/proc/self/cgroup";
|
|
11
|
+
const SYSTEMCTL_TIMEOUT_MS = 5e3;
|
|
12
|
+
const USER_MANAGER_PREFIX = "user@";
|
|
13
|
+
/**
|
|
14
|
+
* 从 `/proc/self/cgroup` 的内容中取所在服务的单元名。
|
|
15
|
+
* 按 `/` 切分 cgroup 路径,从末尾向前找第一个 `.service` 段;
|
|
16
|
+
* `.scope` 段继续向上查找(服务内嵌套的 scope 仍指向所属服务)。
|
|
17
|
+
* `user@N.service` 是 systemd 用户管理器,重启它等于注销整个用户会话,
|
|
18
|
+
* 因此该单元被排除并返回 null。
|
|
19
|
+
* @param text cgroup 文件内容。
|
|
20
|
+
* @returns 没有可用的 service 单元时返回 null。
|
|
21
|
+
*/
|
|
22
|
+
function unitNameFromCgroup(text) {
|
|
23
|
+
if (typeof text !== "string") return null;
|
|
24
|
+
for (const line of text.split("\n")) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (trimmed.length === 0) continue;
|
|
27
|
+
const segments = trimmed.slice(trimmed.lastIndexOf(":") + 1).split("/");
|
|
28
|
+
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
|
29
|
+
const segment = segments[index];
|
|
30
|
+
if (!segment.endsWith(".service")) continue;
|
|
31
|
+
if (segment.startsWith(USER_MANAGER_PREFIX)) return null;
|
|
32
|
+
return segment;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 读取 package.json;文件缺失或 JSON 非法就地抛出。
|
|
39
|
+
* @param packageRoot 包目录。
|
|
40
|
+
*/
|
|
41
|
+
function readManifest(packageRoot) {
|
|
42
|
+
const text = readFileSync(join(packageRoot, "package.json"), "utf8");
|
|
43
|
+
const manifest = JSON.parse(text);
|
|
44
|
+
if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error(`package.json 不是对象: ${packageRoot}`);
|
|
45
|
+
return manifest;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 从入口文件所在目录向上查找 `@deepseek-ai/dsh` 的安装目录。
|
|
49
|
+
* @param startDir 查找起点目录。
|
|
50
|
+
* @returns 包根目录。
|
|
51
|
+
*/
|
|
52
|
+
function findPackageRoot(startDir) {
|
|
53
|
+
let current = startDir;
|
|
54
|
+
for (let depth = 0; depth <= ROOT_SEARCH_DEPTH; depth += 1) {
|
|
55
|
+
const manifestPath = join(current, "package.json");
|
|
56
|
+
if (existsSync(manifestPath)) {
|
|
57
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
58
|
+
if (manifest !== null && typeof manifest === "object" && manifest.name === PACKAGE_NAME) return current;
|
|
59
|
+
}
|
|
60
|
+
const parent = dirname(current);
|
|
61
|
+
if (parent === current) break;
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`在 ${startDir} 的上级目录中找不到 ${PACKAGE_NAME} 的安装目录`);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 从安装目录推断 npm 全局前缀。
|
|
68
|
+
* POSIX 安装位置形如 `<prefix>/lib/node_modules/@deepseek-ai/dsh`,
|
|
69
|
+
* Windows 安装位置形如 `<prefix>/node_modules/@deepseek-ai/dsh`。
|
|
70
|
+
* @param packageRoot 包根目录。
|
|
71
|
+
* @returns 推断失败返回 null。
|
|
72
|
+
*/
|
|
73
|
+
function deriveNpmPrefix(packageRoot) {
|
|
74
|
+
const match = (process.platform === "win32" ? /^(.+)[\\/]node_modules[\\/]@deepseek-ai[\\/]dsh$/ : /^(.+)\/lib\/node_modules\/@deepseek-ai\/dsh$/).exec(packageRoot);
|
|
75
|
+
if (match === null) return null;
|
|
76
|
+
return match[1];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 读取当前进程所属的 systemd 服务单元名。
|
|
80
|
+
*/
|
|
81
|
+
function readUnitName() {
|
|
82
|
+
if (!existsSync(CGROUP_PATH)) return null;
|
|
83
|
+
return unitNameFromCgroup(readFileSync(CGROUP_PATH, "utf8"));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 确认当前进程是该服务的 MainPID。
|
|
87
|
+
* 只有服务的主进程才允许触发自身重启,否则会重启到错误的单元。
|
|
88
|
+
* @param unitName systemd 服务单元名。
|
|
89
|
+
*/
|
|
90
|
+
function isServiceMainPid(unitName) {
|
|
91
|
+
const result = spawnSync("systemctl", [
|
|
92
|
+
"--user",
|
|
93
|
+
"show",
|
|
94
|
+
unitName,
|
|
95
|
+
"-p",
|
|
96
|
+
"MainPID",
|
|
97
|
+
"--value"
|
|
98
|
+
], {
|
|
99
|
+
encoding: "utf8",
|
|
100
|
+
timeout: SYSTEMCTL_TIMEOUT_MS
|
|
101
|
+
});
|
|
102
|
+
if (result.error !== void 0 || result.status !== 0) return false;
|
|
103
|
+
return String(result.stdout).trim() === String(process.pid);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 探测 DSH 的部署形态,同步执行。
|
|
107
|
+
*/
|
|
108
|
+
function detectDeployment() {
|
|
109
|
+
const entryPath = process.argv[1];
|
|
110
|
+
if (typeof entryPath !== "string" || entryPath.length === 0) throw new Error("无法确定 DSH 入口文件: process.argv[1] 为空");
|
|
111
|
+
const realEntry = realpathSync(entryPath);
|
|
112
|
+
const packageRoot = findPackageRoot(dirname(realEntry));
|
|
113
|
+
const version = readManifest(packageRoot).version;
|
|
114
|
+
if (typeof version !== "string" || version.length === 0) throw new Error(`package.json 缺少 version 字段: ${packageRoot}`);
|
|
115
|
+
const candidateUnit = process.platform === "linux" ? readUnitName() : null;
|
|
116
|
+
const unitName = candidateUnit !== null && isServiceMainPid(candidateUnit) ? candidateUnit : null;
|
|
117
|
+
return {
|
|
118
|
+
packageRoot,
|
|
119
|
+
version,
|
|
120
|
+
npmPrefix: deriveNpmPrefix(packageRoot),
|
|
121
|
+
unitName,
|
|
122
|
+
restartMode: unitName === null ? "manual" : "systemd"
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 读取磁盘上当前的 DSH 版本。每次调用都重新读取 package.json。
|
|
127
|
+
* @param packageRoot 包根目录。
|
|
128
|
+
*/
|
|
129
|
+
function readInstalledVersion(packageRoot) {
|
|
130
|
+
const version = readManifest(packageRoot).version;
|
|
131
|
+
if (typeof version !== "string" || version.length === 0) throw new Error(`package.json 缺少 version 字段: ${packageRoot}`);
|
|
132
|
+
return version;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 在 PATH 中查找 npm 可执行文件。
|
|
136
|
+
* @param env 环境变量,默认使用 process.env。
|
|
137
|
+
* @returns 找不到返回 null。
|
|
138
|
+
*/
|
|
139
|
+
function findNpmExecutable(env = process.env) {
|
|
140
|
+
const rawPath = env === null || env === void 0 ? "" : env.PATH ?? "";
|
|
141
|
+
const candidates = process.platform === "win32" ? ["npm.cmd"] : ["npm"];
|
|
142
|
+
for (const directory of rawPath.split(delimiter)) {
|
|
143
|
+
if (directory.length === 0) continue;
|
|
144
|
+
for (const name of candidates) {
|
|
145
|
+
const candidate = join(directory, name);
|
|
146
|
+
const stat = statSync(candidate, { throwIfNoEntry: false });
|
|
147
|
+
if (stat === void 0 || !stat.isFile()) continue;
|
|
148
|
+
if (process.platform !== "win32" && (stat.mode & 73) === 0) continue;
|
|
149
|
+
return candidate;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
export { detectDeployment, findNpmExecutable, readInstalledVersion, unitNameFromCgroup };
|
package/lib/gate.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求来源校验:只接受本机 loopback 页面发起的请求。
|
|
3
|
+
*/
|
|
4
|
+
/** 来源校验只读取请求的 socket 与 headers,测试可以构造同形状的对象。 */
|
|
5
|
+
export interface GateRequestLike {
|
|
6
|
+
socket?: {
|
|
7
|
+
remoteAddress?: string | null;
|
|
8
|
+
encrypted?: boolean;
|
|
9
|
+
} | null;
|
|
10
|
+
headers: {
|
|
11
|
+
host?: string | string[] | undefined;
|
|
12
|
+
origin?: string | string[] | undefined;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** 拒绝原因;通过时返回 null。 */
|
|
16
|
+
export type GateRefusal = 'bad-remote' | 'bad-host' | 'cross-origin' | null;
|
|
17
|
+
/**
|
|
18
|
+
* 校验请求来源。
|
|
19
|
+
* @param req 请求对象。
|
|
20
|
+
* @param allowedOrigins 反向代理或隧道场景下额外允许的源。
|
|
21
|
+
*/
|
|
22
|
+
export declare function gateRequest(req: GateRequestLike, allowedOrigins?: string[]): GateRefusal;
|
package/lib/gate.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/gate.ts
|
|
2
|
+
/**
|
|
3
|
+
* 请求来源校验:只接受本机 loopback 页面发起的请求。
|
|
4
|
+
*/
|
|
5
|
+
const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/;
|
|
6
|
+
/**
|
|
7
|
+
* 取可比较的主机名:转小写并去掉 IPv6 方括号。
|
|
8
|
+
*/
|
|
9
|
+
function bareHostname(value) {
|
|
10
|
+
return value.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 判断主机名是否指向 loopback 接口。
|
|
14
|
+
*/
|
|
15
|
+
function isLoopbackHostname(hostname) {
|
|
16
|
+
const text = bareHostname(hostname);
|
|
17
|
+
if (text === "localhost" || text === "::1") return true;
|
|
18
|
+
return LOOPBACK_IPV4.test(text);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 解析 `Host` 头为 `{hostname, port}`。
|
|
22
|
+
* @param value `Host` 头原始值。
|
|
23
|
+
*/
|
|
24
|
+
function splitHost(value) {
|
|
25
|
+
if (typeof value !== "string") return null;
|
|
26
|
+
const text = value.trim();
|
|
27
|
+
const bracketed = text.match(/^\[([^\]]+)\](?::(\d{1,5}))?$/);
|
|
28
|
+
if (bracketed !== null) return {
|
|
29
|
+
hostname: bracketed[1],
|
|
30
|
+
port: bracketed[2] ?? ""
|
|
31
|
+
};
|
|
32
|
+
const plain = text.match(/^([A-Za-z0-9._-]+)(?::(\d{1,5}))?$/);
|
|
33
|
+
if (plain === null) return null;
|
|
34
|
+
return {
|
|
35
|
+
hostname: plain[1],
|
|
36
|
+
port: plain[2] ?? ""
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 解析 Origin 为可比较的形式。
|
|
41
|
+
* @param value Origin 头原始值。
|
|
42
|
+
* @returns 非 http/https 源返回 null。
|
|
43
|
+
*/
|
|
44
|
+
function parseOrigin(value) {
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = new URL(value);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
52
|
+
const hostname = bareHostname(parsed.hostname);
|
|
53
|
+
const port = parsed.port;
|
|
54
|
+
return {
|
|
55
|
+
key: `${parsed.protocol}//${hostname}${port.length > 0 ? `:${port}` : ""}`,
|
|
56
|
+
hostname,
|
|
57
|
+
scheme: parsed.protocol,
|
|
58
|
+
port
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 校验请求来源。
|
|
63
|
+
* @param req 请求对象。
|
|
64
|
+
* @param allowedOrigins 反向代理或隧道场景下额外允许的源。
|
|
65
|
+
*/
|
|
66
|
+
function gateRequest(req, allowedOrigins = []) {
|
|
67
|
+
const allowedKeys = /* @__PURE__ */ new Set();
|
|
68
|
+
const allowedHostnames = /* @__PURE__ */ new Set();
|
|
69
|
+
for (const entry of Array.isArray(allowedOrigins) ? allowedOrigins : []) {
|
|
70
|
+
const origin = parseOrigin(entry);
|
|
71
|
+
if (origin === null) continue;
|
|
72
|
+
allowedKeys.add(origin.key);
|
|
73
|
+
allowedHostnames.add(origin.hostname);
|
|
74
|
+
}
|
|
75
|
+
if (!isLoopbackHostname(String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, ""))) return "bad-remote";
|
|
76
|
+
const host = splitHost(req.headers.host);
|
|
77
|
+
if (host === null) return "bad-host";
|
|
78
|
+
const hostname = bareHostname(host.hostname);
|
|
79
|
+
if (!isLoopbackHostname(hostname) && !allowedHostnames.has(hostname)) return "bad-host";
|
|
80
|
+
const origin = req.headers.origin;
|
|
81
|
+
if (typeof origin !== "string" || origin.length === 0) return null;
|
|
82
|
+
if (origin === "dsh-app://app") return null;
|
|
83
|
+
const parsed = parseOrigin(origin);
|
|
84
|
+
if (parsed === null) return "cross-origin";
|
|
85
|
+
if (allowedKeys.has(parsed.key)) return null;
|
|
86
|
+
if (parsed.hostname !== hostname) return "cross-origin";
|
|
87
|
+
const originPort = parsed.port.length > 0 ? parsed.port : parsed.scheme === "https:" ? "443" : "80";
|
|
88
|
+
const connectionPort = req.socket?.encrypted === true ? "443" : "80";
|
|
89
|
+
if (originPort !== (host.port.length > 0 ? host.port : connectionPort)) return "cross-origin";
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
export { gateRequest };
|