@cjhyy/code-shell-server 0.9.8 → 0.9.9
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 +85 -23
- package/dist/bin/code-shell-project-runtime.d.ts +2 -0
- package/dist/bin/code-shell-project-runtime.js +8 -0
- package/dist/desktop-web/http-api.d.ts +43 -0
- package/dist/desktop-web/http-api.js +376 -0
- package/dist/hub/approval-lease.d.ts +30 -0
- package/dist/hub/approval-lease.js +89 -0
- package/dist/hub/auth-http.d.ts +23 -0
- package/dist/hub/auth-http.js +259 -0
- package/dist/hub/auth-store.d.ts +52 -0
- package/dist/hub/auth-store.js +316 -0
- package/dist/hub/configuration.d.ts +64 -0
- package/dist/hub/configuration.js +555 -0
- package/dist/hub/files.d.ts +9 -0
- package/dist/hub/files.js +278 -0
- package/dist/hub/mcp-configuration.d.ts +51 -0
- package/dist/hub/mcp-configuration.js +558 -0
- package/dist/hub/mcp-probe.d.ts +24 -0
- package/dist/hub/mcp-probe.js +392 -0
- package/dist/hub/model-probe.d.ts +20 -0
- package/dist/hub/model-probe.js +193 -0
- package/dist/hub/outbound-transport.d.ts +36 -0
- package/dist/hub/outbound-transport.js +122 -0
- package/dist/hub/run-replay.d.ts +42 -0
- package/dist/hub/run-replay.js +89 -0
- package/dist/hub/session-management.d.ts +36 -0
- package/dist/hub/session-management.js +405 -0
- package/dist/hub/skills-management.d.ts +43 -0
- package/dist/hub/skills-management.js +487 -0
- package/dist/hub/uploads.d.ts +22 -0
- package/dist/hub/uploads.js +241 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.desktop-web.d.ts +2 -0
- package/dist/index.desktop-web.js +2 -0
- package/dist/index.js +3 -0
- package/dist/index.links.d.ts +6 -0
- package/dist/index.links.js +6 -0
- package/dist/index.panels.d.ts +9 -0
- package/dist/index.panels.js +8 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +2 -0
- package/dist/index.storage.js +2 -0
- package/dist/links/catalog.d.ts +10 -0
- package/dist/links/catalog.js +41 -0
- package/dist/links/device-oauth.d.ts +52 -0
- package/dist/links/device-oauth.js +255 -0
- package/dist/links/http.d.ts +34 -0
- package/dist/links/http.js +144 -0
- package/dist/links/service.d.ts +54 -0
- package/dist/links/service.js +578 -0
- package/dist/links/types.d.ts +6 -0
- package/dist/links/types.js +1 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
- package/dist/mobile-remote/pending-approvals.js +2 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +90 -15
- package/dist/mobile-remote/room-manager.js +17 -5
- package/dist/panels/agent-task-host.d.ts +32 -0
- package/dist/panels/agent-task-host.js +457 -0
- package/dist/panels/agent-task-models.d.ts +27 -0
- package/dist/panels/agent-task-models.js +51 -0
- package/dist/panels/agent-task-service.d.ts +105 -0
- package/dist/panels/agent-task-service.js +283 -0
- package/dist/panels/http.d.ts +34 -0
- package/dist/panels/http.js +37 -0
- package/dist/panels/hub-binding.d.ts +12 -0
- package/dist/panels/hub-binding.js +174 -0
- package/dist/panels/management-http.d.ts +29 -0
- package/dist/panels/management-http.js +119 -0
- package/dist/panels/management.d.ts +39 -0
- package/dist/panels/management.js +494 -0
- package/dist/panels/process-files.d.ts +14 -0
- package/dist/panels/process-files.js +299 -0
- package/dist/panels/process-service.d.ts +96 -0
- package/dist/panels/process-service.js +501 -0
- package/dist/panels/runtime-services.d.ts +24 -0
- package/dist/panels/runtime-services.js +462 -0
- package/dist/panels/runtime.d.ts +51 -0
- package/dist/panels/runtime.js +1063 -0
- package/dist/panels/storage-store.d.ts +9 -0
- package/dist/panels/storage-store.js +154 -0
- package/dist/panels/types.d.ts +52 -0
- package/dist/panels/types.js +1 -0
- package/dist/project-runtime/control-server.d.ts +19 -0
- package/dist/project-runtime/control-server.js +300 -0
- package/dist/project-runtime/docker-provider.d.ts +33 -0
- package/dist/project-runtime/docker-provider.js +487 -0
- package/dist/project-runtime/lock.d.ts +6 -0
- package/dist/project-runtime/lock.js +43 -0
- package/dist/project-runtime/managed-entry.d.ts +10 -0
- package/dist/project-runtime/managed-entry.js +113 -0
- package/dist/project-runtime/manager.d.ts +40 -0
- package/dist/project-runtime/manager.js +249 -0
- package/dist/project-runtime/proxy.d.ts +22 -0
- package/dist/project-runtime/proxy.js +706 -0
- package/dist/project-runtime/registry.d.ts +54 -0
- package/dist/project-runtime/registry.js +286 -0
- package/dist/project-runtime/types.d.ts +43 -0
- package/dist/project-runtime/types.js +1 -0
- package/dist/serve/cli.d.ts +5 -0
- package/dist/serve/cli.js +82 -23
- package/dist/serve/headless-server.d.ts +11 -0
- package/dist/serve/headless-server.js +737 -83
- package/dist/session-titles-store.d.ts +17 -0
- package/dist/session-titles-store.js +174 -0
- package/dist/static-files.js +1 -0
- package/dist/worker-bridge-core.js +17 -6
- package/package.json +19 -5
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { SettingsManager, scanSkills, invalidateSkillCache, userHome, } from "@cjhyy/code-shell-core";
|
|
5
|
+
import { computeEffectiveDisabledLists } from "@cjhyy/code-shell-core/internal";
|
|
6
|
+
import { assertSafeSkillName, assertOwnedSkillDirectory, readSkillBundle, readBoundedSkillFile, validateSkillMarkdown, skillRoot, stageSkillDirectory, commitSkillDirectory, editSkillMarkdown, removeOwnedSkill, SkillConflictError, inspectRepo, getRefCommit, downloadSkillTree, parseSkillSourceMeta, SKILL_META_FILE, } from "@cjhyy/code-shell-core/internal/skills";
|
|
7
|
+
import { HubConfigurationError } from "./configuration.js";
|
|
8
|
+
const ROOT = "/api/v1/skills";
|
|
9
|
+
const REVIEW_TTL = 15 * 60_000;
|
|
10
|
+
const MAX_REVIEWS = 16;
|
|
11
|
+
const MAX_STAGED_BYTES = 128 * 1024 * 1024;
|
|
12
|
+
/** Dispatch only after the host's authenticated same-origin API boundary. */
|
|
13
|
+
export function createHubSkills(options) {
|
|
14
|
+
const now = options.now ?? Date.now;
|
|
15
|
+
const github = options.github ?? {
|
|
16
|
+
inspect: inspectRepo,
|
|
17
|
+
commit: getRefCommit,
|
|
18
|
+
download: downloadSkillTree,
|
|
19
|
+
};
|
|
20
|
+
const reviews = new Map();
|
|
21
|
+
const roots = [
|
|
22
|
+
join(options.cwd, ".code-shell", "skills"),
|
|
23
|
+
join(options.cwd, ".agents", "skills"),
|
|
24
|
+
];
|
|
25
|
+
let networkBusy = 0;
|
|
26
|
+
let closed = false;
|
|
27
|
+
function origin(meta) {
|
|
28
|
+
if (!meta)
|
|
29
|
+
return undefined;
|
|
30
|
+
return {
|
|
31
|
+
kind: "github",
|
|
32
|
+
url: `https://github.com/${meta.owner}/${meta.repo}`,
|
|
33
|
+
ref: meta.ref,
|
|
34
|
+
commit: meta.commit,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function readMeta(file) {
|
|
38
|
+
try {
|
|
39
|
+
return parseSkillSourceMeta(JSON.parse(readBoundedSkillFile(join(dirname(file), SKILL_META_FILE), 64 * 1024).toString("utf8")));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function listed(name) {
|
|
46
|
+
if (typeof name !== "string" || !name || name.length > 256)
|
|
47
|
+
throw new HubConfigurationError(400, "Skill 名称无效。");
|
|
48
|
+
invalidateSkillCache();
|
|
49
|
+
const skill = scanSkills(options.cwd).find((entry) => entry.name === name);
|
|
50
|
+
if (!skill)
|
|
51
|
+
throw new HubConfigurationError(404, "找不到这个 Skill,请刷新后重试。");
|
|
52
|
+
return skill;
|
|
53
|
+
}
|
|
54
|
+
function owned(name) {
|
|
55
|
+
const skill = listed(name);
|
|
56
|
+
if (skill.source !== "project")
|
|
57
|
+
throw new HubConfigurationError(403, "这里只能修改当前工作区的 Skills;服务器用户、插件和 Panel App 的 Skills 由来源管理。");
|
|
58
|
+
assertOwnedSkillDirectory(skill.filePath, roots);
|
|
59
|
+
return skill;
|
|
60
|
+
}
|
|
61
|
+
function snapshot() {
|
|
62
|
+
invalidateSkillCache();
|
|
63
|
+
const manager = new SettingsManager(options.cwd, "full");
|
|
64
|
+
const disabled = computeEffectiveDisabledLists(manager, options.cwd);
|
|
65
|
+
const enabled = new Set(scanSkills(options.cwd, disabled).map((entry) => entry.name));
|
|
66
|
+
const skills = scanSkills(options.cwd).map((skill) => {
|
|
67
|
+
let editable = false;
|
|
68
|
+
let revision = createHash("sha256").update(skill.content).digest("hex");
|
|
69
|
+
let readOnlyReason;
|
|
70
|
+
let source;
|
|
71
|
+
if (skill.source === "project") {
|
|
72
|
+
try {
|
|
73
|
+
const dir = assertOwnedSkillDirectory(skill.filePath, roots);
|
|
74
|
+
revision = readSkillBundle(dir).revision;
|
|
75
|
+
editable = true;
|
|
76
|
+
source = origin(readMeta(skill.filePath));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
readOnlyReason = "此 Skill 的目录结构或文件类型不支持在线修改,请在服务器上管理。";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
readOnlyReason =
|
|
84
|
+
skill.source === "user"
|
|
85
|
+
? "服务器用户级 Skill,请在对应用户目录中管理。"
|
|
86
|
+
: "由插件或 Panel App 管理,可在当前工作区启用或停用。";
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
name: skill.name,
|
|
90
|
+
description: skill.description,
|
|
91
|
+
source: skill.source,
|
|
92
|
+
enabled: enabled.has(skill.name),
|
|
93
|
+
editable,
|
|
94
|
+
removable: editable,
|
|
95
|
+
revision,
|
|
96
|
+
...(source ? { origin: source } : {}),
|
|
97
|
+
...(readOnlyReason ? { readOnlyReason } : {}),
|
|
98
|
+
...(skill.source === "plugin" &&
|
|
99
|
+
disabled.disabledPlugins.includes(skill.name.split(":", 1)[0])
|
|
100
|
+
? { disabledReason: "请先启用所属插件。" }
|
|
101
|
+
: {}),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
return { skills, directories: [...roots, join(userHome(), ".code-shell", "skills")] };
|
|
105
|
+
}
|
|
106
|
+
async function discard(token) {
|
|
107
|
+
const entry = reviews.get(token);
|
|
108
|
+
reviews.delete(token);
|
|
109
|
+
if (entry?.kind === "update")
|
|
110
|
+
await fs.rm(entry.stage, { recursive: true, force: true }).catch(() => { });
|
|
111
|
+
}
|
|
112
|
+
async function prune() {
|
|
113
|
+
for (const [token, entry] of reviews)
|
|
114
|
+
if (entry.expiresAt <= now())
|
|
115
|
+
await discard(token);
|
|
116
|
+
}
|
|
117
|
+
const sweep = setInterval(() => {
|
|
118
|
+
void prune();
|
|
119
|
+
}, 60_000);
|
|
120
|
+
sweep.unref();
|
|
121
|
+
async function issue(entry) {
|
|
122
|
+
await prune();
|
|
123
|
+
if (closed)
|
|
124
|
+
throw new HubConfigurationError(503, "服务正在关闭。");
|
|
125
|
+
if (reviews.size >= MAX_REVIEWS)
|
|
126
|
+
throw new HubConfigurationError(429, "待确认的导入过多,请完成或稍后重试。");
|
|
127
|
+
const stagedBytes = [...reviews.values()].reduce((sum, value) => sum + (value.kind === "update" ? value.bytes : 0), 0);
|
|
128
|
+
if (entry.kind === "update" && stagedBytes + entry.bytes > MAX_STAGED_BYTES)
|
|
129
|
+
throw new HubConfigurationError(429, "待确认的更新文件过多,请先完成已有更新。");
|
|
130
|
+
const token = randomBytes(32).toString("base64url");
|
|
131
|
+
reviews.set(token, entry);
|
|
132
|
+
return token;
|
|
133
|
+
}
|
|
134
|
+
async function reviewFor(token, owner, kind) {
|
|
135
|
+
await prune();
|
|
136
|
+
if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token))
|
|
137
|
+
throw new HubConfigurationError(400, "请先预览来源,再确认导入。");
|
|
138
|
+
const entry = reviews.get(token);
|
|
139
|
+
if (!entry || entry.kind !== kind)
|
|
140
|
+
throw new HubConfigurationError(409, "预览已失效,请重新检查来源。");
|
|
141
|
+
if (entry.owner !== owner)
|
|
142
|
+
throw new HubConfigurationError(403, "这个预览属于另一个设备,请在当前设备重新预览。");
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
async function network(work) {
|
|
146
|
+
if (networkBusy >= 2)
|
|
147
|
+
throw new HubConfigurationError(429, "已有来源检查或下载正在进行,请稍后重试。");
|
|
148
|
+
networkBusy++;
|
|
149
|
+
try {
|
|
150
|
+
return await work();
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
networkBusy--;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function authorizeWrite(req) {
|
|
157
|
+
if (closed)
|
|
158
|
+
throw new HubConfigurationError(503, "服务正在关闭。");
|
|
159
|
+
if (options.isAuthorized && !(await options.isAuthorized(req)))
|
|
160
|
+
throw new HubConfigurationError(401, "登录已失效,请重新登录。");
|
|
161
|
+
if (closed)
|
|
162
|
+
throw new HubConfigurationError(503, "服务正在关闭。");
|
|
163
|
+
}
|
|
164
|
+
async function mutation(req, work) {
|
|
165
|
+
await authorizeWrite(req);
|
|
166
|
+
const write = async () => {
|
|
167
|
+
await authorizeWrite(req);
|
|
168
|
+
return work();
|
|
169
|
+
};
|
|
170
|
+
const result = options.withMutation ? await options.withMutation(write) : await write();
|
|
171
|
+
await authorizeWrite(req);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
async function temporary() {
|
|
175
|
+
// Temporary downloads are not in any discovered Skill root and never execute.
|
|
176
|
+
const base = join(options.dataDir, "skill-previews");
|
|
177
|
+
await fs.mkdir(base, { recursive: true, mode: 0o700 });
|
|
178
|
+
const info = await fs.lstat(base);
|
|
179
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
180
|
+
throw new Error("invalid preview directory");
|
|
181
|
+
return fs.mkdtemp(join(base, "preview-"));
|
|
182
|
+
}
|
|
183
|
+
async function handle(req, res) {
|
|
184
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
185
|
+
if (url.pathname !== ROOT && !url.pathname.startsWith(`${ROOT}/`))
|
|
186
|
+
return false;
|
|
187
|
+
try {
|
|
188
|
+
const owner = await options.owner(req);
|
|
189
|
+
if (!owner)
|
|
190
|
+
throw new HubConfigurationError(401, "请先登录。");
|
|
191
|
+
if (req.method === "GET" && url.pathname === ROOT) {
|
|
192
|
+
json(res, 200, snapshot());
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (req.method === "GET" && url.pathname === `${ROOT}/detail`) {
|
|
196
|
+
const skill = listed(url.searchParams.get("name"));
|
|
197
|
+
const summary = snapshot().skills.find((entry) => entry.name === skill.name);
|
|
198
|
+
// Read-only external Skills use the scanner's already-discovered text.
|
|
199
|
+
const content = summary.editable
|
|
200
|
+
? readSkillBundle(dirname(skill.filePath)).content
|
|
201
|
+
: skill.content;
|
|
202
|
+
json(res, 200, {
|
|
203
|
+
name: skill.name,
|
|
204
|
+
content,
|
|
205
|
+
revision: summary.revision,
|
|
206
|
+
editable: summary.editable,
|
|
207
|
+
origin: summary.origin,
|
|
208
|
+
});
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
const body = await readBody(req);
|
|
212
|
+
if (url.pathname === `${ROOT}/local`) {
|
|
213
|
+
if (req.method === "POST") {
|
|
214
|
+
fields(body, ["name", "content"]);
|
|
215
|
+
const name = assertSafeSkillName(body.name);
|
|
216
|
+
const content = validateSkillMarkdown(body.content);
|
|
217
|
+
const stage = await temporary();
|
|
218
|
+
try {
|
|
219
|
+
await fs.writeFile(join(stage, "SKILL.md"), content, { mode: 0o600 });
|
|
220
|
+
await mutation(req, async () => {
|
|
221
|
+
if (scanSkills(options.cwd).some((entry) => entry.name === name))
|
|
222
|
+
throw new HubConfigurationError(409, "已有同名 Skill,请修改名称。");
|
|
223
|
+
const root = skillRoot("project", options.cwd, true);
|
|
224
|
+
const localStage = await stageSkillDirectory(stage, root);
|
|
225
|
+
try {
|
|
226
|
+
await authorizeWrite(req);
|
|
227
|
+
commitSkillDirectory(localStage, root, name);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
await fs.rm(localStage, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
json(res, 201, { ok: true, message: "Skill 已创建。", name });
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
await fs.rm(stage, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else if (req.method === "PUT" || req.method === "DELETE") {
|
|
240
|
+
fields(body, req.method === "PUT" ? ["name", "content", "revision"] : ["name", "revision"]);
|
|
241
|
+
const name = string(body.name, 256);
|
|
242
|
+
const revision = revisionOf(body.revision);
|
|
243
|
+
const content = req.method === "PUT" ? validateSkillMarkdown(body.content) : undefined;
|
|
244
|
+
await mutation(req, async () => {
|
|
245
|
+
await authorizeWrite(req);
|
|
246
|
+
const skill = owned(name);
|
|
247
|
+
if (content !== undefined)
|
|
248
|
+
editSkillMarkdown(skill.filePath, roots, content, revision);
|
|
249
|
+
else
|
|
250
|
+
removeOwnedSkill(skill.filePath, roots, revision);
|
|
251
|
+
});
|
|
252
|
+
json(res, 200, {
|
|
253
|
+
ok: true,
|
|
254
|
+
message: content !== undefined ? "Skill 已保存。" : "Skill 已移除。",
|
|
255
|
+
name,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
else
|
|
259
|
+
throw new HubConfigurationError(405, "不支持这个操作。");
|
|
260
|
+
}
|
|
261
|
+
else if (url.pathname === `${ROOT}/github/preview` && req.method === "POST") {
|
|
262
|
+
fields(body, ["url"]);
|
|
263
|
+
const inspection = await network(() => github.inspect(string(body.url, 8192), scanSkills(options.cwd).map((skill) => skill.name)));
|
|
264
|
+
if (!inspection.commit)
|
|
265
|
+
throw new Error("source preview is not pinned to a commit");
|
|
266
|
+
if (options.isAuthorized && !(await options.isAuthorized(req)))
|
|
267
|
+
throw new HubConfigurationError(401, "登录已失效。");
|
|
268
|
+
const expiresAt = now() + REVIEW_TTL;
|
|
269
|
+
const reviewToken = await issue({ kind: "install", owner, expiresAt, inspection });
|
|
270
|
+
json(res, 200, {
|
|
271
|
+
reviewToken,
|
|
272
|
+
expiresAt,
|
|
273
|
+
repoUrl: `https://github.com/${inspection.url.owner}/${inspection.url.repo}`,
|
|
274
|
+
commit: inspection.commit,
|
|
275
|
+
skills: inspection.skills,
|
|
276
|
+
warning: inspection.warning,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
else if (url.pathname === `${ROOT}/github/install` && req.method === "POST") {
|
|
280
|
+
fields(body, ["reviewToken", "pathInRepo", "name"]);
|
|
281
|
+
const reviewed = await reviewFor(body.reviewToken, owner, "install");
|
|
282
|
+
if (reviewed.kind !== "install")
|
|
283
|
+
throw new Error("invalid review kind");
|
|
284
|
+
const selected = reviewed.inspection.skills.find((skill) => skill.pathInRepo === body.pathInRepo);
|
|
285
|
+
if (!selected)
|
|
286
|
+
throw new HubConfigurationError(400, "只能导入刚刚预览过的 Skill。");
|
|
287
|
+
const name = assertSafeSkillName(body.name ?? selected.name);
|
|
288
|
+
const stage = await temporary();
|
|
289
|
+
try {
|
|
290
|
+
await network(() => github.download(reviewed.inspection.url, reviewed.inspection.commit, selected.dirInRepo, stage));
|
|
291
|
+
readSkillBundle(stage);
|
|
292
|
+
const meta = {
|
|
293
|
+
kind: "github",
|
|
294
|
+
owner: reviewed.inspection.url.owner,
|
|
295
|
+
repo: reviewed.inspection.url.repo,
|
|
296
|
+
ref: reviewed.inspection.url.ref || reviewed.inspection.defaultBranch,
|
|
297
|
+
dirInRepo: selected.dirInRepo,
|
|
298
|
+
commit: reviewed.inspection.commit,
|
|
299
|
+
installedAt: new Date(now()).toISOString(),
|
|
300
|
+
};
|
|
301
|
+
await fs.writeFile(join(stage, SKILL_META_FILE), JSON.stringify(meta, null, 2), {
|
|
302
|
+
mode: 0o600,
|
|
303
|
+
});
|
|
304
|
+
await mutation(req, async () => {
|
|
305
|
+
await reviewFor(body.reviewToken, owner, "install");
|
|
306
|
+
if (scanSkills(options.cwd).some((skill) => skill.name === name))
|
|
307
|
+
throw new HubConfigurationError(409, "已有同名 Skill,请选择其他名称。");
|
|
308
|
+
const root = skillRoot("project", options.cwd, true);
|
|
309
|
+
const localStage = await stageSkillDirectory(stage, root);
|
|
310
|
+
try {
|
|
311
|
+
await authorizeWrite(req);
|
|
312
|
+
commitSkillDirectory(localStage, root, name);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
await fs.rm(localStage, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
await discard(body.reviewToken);
|
|
318
|
+
});
|
|
319
|
+
json(res, 201, { ok: true, message: "Skill 及其脚本、资源已导入工作区。", name });
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
await fs.rm(stage, { recursive: true, force: true });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
else if (url.pathname === `${ROOT}/github/update-preview` && req.method === "POST") {
|
|
326
|
+
fields(body, ["name", "revision"]);
|
|
327
|
+
const skill = owned(body.name);
|
|
328
|
+
const revision = revisionOf(body.revision);
|
|
329
|
+
if (readSkillBundle(dirname(skill.filePath)).revision !== revision)
|
|
330
|
+
throw new SkillConflictError();
|
|
331
|
+
const meta = readMeta(skill.filePath);
|
|
332
|
+
if (!meta)
|
|
333
|
+
throw new HubConfigurationError(400, "这个 Skill 没有有效的 GitHub 来源记录。");
|
|
334
|
+
const info = { owner: meta.owner, repo: meta.repo };
|
|
335
|
+
const latestCommit = await network(() => github.commit(info, meta.ref));
|
|
336
|
+
if (latestCommit.toLowerCase() === meta.commit.toLowerCase()) {
|
|
337
|
+
json(res, 200, {
|
|
338
|
+
changed: false,
|
|
339
|
+
name: skill.name,
|
|
340
|
+
currentCommit: meta.commit,
|
|
341
|
+
latestCommit,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const stage = await temporary();
|
|
346
|
+
let retained = false;
|
|
347
|
+
try {
|
|
348
|
+
await network(() => github.download(info, latestCommit, meta.dirInRepo, stage));
|
|
349
|
+
const bundle = readSkillBundle(stage);
|
|
350
|
+
await fs.writeFile(join(stage, SKILL_META_FILE), JSON.stringify({ ...meta, commit: latestCommit, installedAt: new Date(now()).toISOString() }, null, 2), { mode: 0o600 });
|
|
351
|
+
if (options.isAuthorized && !(await options.isAuthorized(req)))
|
|
352
|
+
throw new HubConfigurationError(401, "登录已失效。");
|
|
353
|
+
const expiresAt = now() + REVIEW_TTL;
|
|
354
|
+
const reviewToken = await issue({
|
|
355
|
+
owner,
|
|
356
|
+
expiresAt,
|
|
357
|
+
kind: "update",
|
|
358
|
+
name: skill.name,
|
|
359
|
+
revision,
|
|
360
|
+
stage,
|
|
361
|
+
bytes: bundle.bytes,
|
|
362
|
+
currentCommit: meta.commit,
|
|
363
|
+
latestCommit,
|
|
364
|
+
});
|
|
365
|
+
retained = true;
|
|
366
|
+
json(res, 200, {
|
|
367
|
+
changed: true,
|
|
368
|
+
reviewToken,
|
|
369
|
+
expiresAt,
|
|
370
|
+
name: skill.name,
|
|
371
|
+
currentCommit: meta.commit,
|
|
372
|
+
latestCommit,
|
|
373
|
+
content: bundle.content,
|
|
374
|
+
files: bundle.files,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
if (!retained)
|
|
379
|
+
await fs.rm(stage, { recursive: true, force: true });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else if (url.pathname === `${ROOT}/github/update` && req.method === "POST") {
|
|
384
|
+
fields(body, ["reviewToken"]);
|
|
385
|
+
const reviewed = await reviewFor(body.reviewToken, owner, "update");
|
|
386
|
+
if (reviewed.kind !== "update")
|
|
387
|
+
throw new Error("invalid review kind");
|
|
388
|
+
await mutation(req, async () => {
|
|
389
|
+
await reviewFor(body.reviewToken, owner, "update");
|
|
390
|
+
const skill = owned(reviewed.name);
|
|
391
|
+
const dir = dirname(skill.filePath);
|
|
392
|
+
const stage = await stageSkillDirectory(reviewed.stage, dirname(dir));
|
|
393
|
+
try {
|
|
394
|
+
await authorizeWrite(req);
|
|
395
|
+
commitSkillDirectory(stage, dirname(dir), basename(dir), reviewed.revision);
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
await fs.rm(stage, { recursive: true, force: true });
|
|
399
|
+
}
|
|
400
|
+
await discard(body.reviewToken);
|
|
401
|
+
});
|
|
402
|
+
json(res, 200, { ok: true, name: reviewed.name, message: "Skill 已更新到预览的版本。" });
|
|
403
|
+
}
|
|
404
|
+
else
|
|
405
|
+
throw new HubConfigurationError(404, "找不到这个 Skills 接口。");
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
if (error instanceof HubConfigurationError)
|
|
409
|
+
json(res, error.status, { error: error.message });
|
|
410
|
+
else if (error instanceof SkillConflictError)
|
|
411
|
+
json(res, 409, { error: error.message });
|
|
412
|
+
else
|
|
413
|
+
json(res, 400, { error: safeMessage(error) });
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
async function close() {
|
|
418
|
+
closed = true;
|
|
419
|
+
clearInterval(sweep);
|
|
420
|
+
for (const token of [...reviews.keys()])
|
|
421
|
+
await discard(token);
|
|
422
|
+
}
|
|
423
|
+
return { handle, snapshot, close };
|
|
424
|
+
}
|
|
425
|
+
function fields(body, allowed) {
|
|
426
|
+
if (Object.keys(body).some((key) => !allowed.includes(key)))
|
|
427
|
+
throw new HubConfigurationError(400, "请求包含不支持的字段。");
|
|
428
|
+
}
|
|
429
|
+
function string(value, max) {
|
|
430
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || value.includes("\0"))
|
|
431
|
+
throw new HubConfigurationError(400, "请求字段无效。");
|
|
432
|
+
return value;
|
|
433
|
+
}
|
|
434
|
+
function revisionOf(value) {
|
|
435
|
+
const revision = string(value, 64);
|
|
436
|
+
if (!/^[a-f0-9]{64}$/.test(revision))
|
|
437
|
+
throw new HubConfigurationError(400, "请刷新 Skill 后重试。");
|
|
438
|
+
return revision;
|
|
439
|
+
}
|
|
440
|
+
function safeMessage(error) {
|
|
441
|
+
const message = error instanceof Error ? error.message : "";
|
|
442
|
+
// Forward authored validation/network messages, never local paths or arbitrary stack text.
|
|
443
|
+
return /[\u3400-\u9fff]/.test(message) && message.length < 400
|
|
444
|
+
? message
|
|
445
|
+
: "操作失败,原有 Skill 已保留。请检查来源或稍后重试。";
|
|
446
|
+
}
|
|
447
|
+
async function readBody(req) {
|
|
448
|
+
if (req.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json")
|
|
449
|
+
throw new HubConfigurationError(415, "需要 JSON 请求。");
|
|
450
|
+
const limit = 3 * 1024 * 1024;
|
|
451
|
+
if (Number(req.headers["content-length"] ?? 0) > limit)
|
|
452
|
+
throw new HubConfigurationError(413, "请求内容过大。");
|
|
453
|
+
const chunks = [];
|
|
454
|
+
let size = 0;
|
|
455
|
+
const timer = setTimeout(() => req.destroy(), 15_000);
|
|
456
|
+
timer.unref();
|
|
457
|
+
try {
|
|
458
|
+
for await (const chunk of req) {
|
|
459
|
+
const bytes = Buffer.from(chunk);
|
|
460
|
+
size += bytes.length;
|
|
461
|
+
if (size > limit)
|
|
462
|
+
throw new HubConfigurationError(413, "请求内容过大。");
|
|
463
|
+
chunks.push(bytes);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
471
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
472
|
+
throw new Error();
|
|
473
|
+
return value;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
throw new HubConfigurationError(400, "JSON 格式无效。");
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function json(res, status, value) {
|
|
480
|
+
if (res.headersSent || res.destroyed)
|
|
481
|
+
return;
|
|
482
|
+
res.writeHead(status, {
|
|
483
|
+
"content-type": "application/json; charset=utf-8",
|
|
484
|
+
"cache-control": "no-store",
|
|
485
|
+
});
|
|
486
|
+
res.end(JSON.stringify(value));
|
|
487
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type InputAttachmentMeta } from "../attachment-service.js";
|
|
3
|
+
export interface PreparedHubUploads {
|
|
4
|
+
attachments: InputAttachmentMeta[];
|
|
5
|
+
commit(): Promise<void>;
|
|
6
|
+
release(): void;
|
|
7
|
+
}
|
|
8
|
+
export declare function hubJson(res: ServerResponse, status: number, body: unknown): void;
|
|
9
|
+
/** Temporary, device-owned spools. Only the host can turn IDs into staged attachment paths. */
|
|
10
|
+
export declare class HubUploads {
|
|
11
|
+
private readonly root;
|
|
12
|
+
private readonly cwd;
|
|
13
|
+
private readonly uploads;
|
|
14
|
+
private readonly active;
|
|
15
|
+
private closed;
|
|
16
|
+
constructor(root: string, cwd: string);
|
|
17
|
+
ready(): Promise<void>;
|
|
18
|
+
accept(id: string, owner: string, req: IncomingMessage, res: ServerResponse, stillAuthorized: () => Promise<boolean>): Promise<void>;
|
|
19
|
+
prepare(ids: unknown, owner: string, sessionId: unknown): Promise<PreparedHubUploads>;
|
|
20
|
+
sweep(): Promise<void>;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|