修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)

一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。

二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。

三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
This commit is contained in:
admin committed 2026-10-08 22:29:52 +08:00
1 parent e03465c398
commit 237a09a5b0
161 files changed
+19429 -2

No files matched your search

@@ -0,0 +1,96 @@
import { randomBytes } from 'node:crypto';
import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadManifest, nativeBackend, PublicError, validateManifest, type Manifest } from './config.ts';
import { loadPage, validateFields, validatePageUI } from './page.ts';
async function writeConfiguration(file: string, configuration: object) {
await mkdir(path.dirname(file), { recursive: true });
const temporary = file + '.' + randomBytes(8).toString('hex') + '.tmp';
try {
const handle = await open(temporary, 'wx', 0o600);
try { await handle.writeFile(JSON.stringify(configuration, null, 2) + '\n', 'utf8'); await handle.sync(); }
finally { await handle.close(); }
await rename(temporary, file);
} finally { await unlink(temporary).catch(() => {}); }
}
export async function configurePage(file: string, manifests: string[], options: Record<string, string>, dryRun = false) {
if (Object.keys(options).some(key => !['title', 'label', 'save-label'].includes(key))) throw new PublicError('存在不支持的页面配置项。');
let existing: { manifests: string[]; ui?: Record<string, string> } | undefined;
try {
const stat = await lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new PublicError('页面配置必须是独立的普通文件。');
await loadPage(file);
existing = JSON.parse(await readFile(file, 'utf8'));
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
// 命令参数按当前目录解析;写入页面文件后统一保存为相对该文件的路径。
const paths = manifests.length ? manifests.map(item => path.relative(path.dirname(file), path.resolve(item)).split(path.sep).join('/')) : existing?.manifests ?? [];
validateFields(await Promise.all(paths.map(item => loadManifest(path.resolve(path.dirname(file), item)))));
const ui = { ...existing?.ui };
for (const [flag, key] of [['title', 'title'], ['label', 'label'], ['save-label', 'saveLabel']])
if (options[flag] !== undefined) ui[key] = options[flag];
const configuration = { version: 1, manifests: paths, ui: validatePageUI(ui) };
if (!dryRun) await writeConfiguration(file, configuration);
return { status: dryRun ? 'preview' : 'configured', page: file, configuration };
}
export async function configureManifest(file: string, options: Record<string, string>, dryRun = false) {
const allowed = ['id', 'label', 'credential', 'title', 'placeholder', 'save-label'];
if (Object.keys(options).some(k => !allowed.includes(k))) throw new PublicError('存在不支持的配置项。');
let existing: Manifest | undefined;
try {
const stat = await lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new PublicError('声明必须是独立的普通文件。');
existing = await loadManifest(file);
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
if (existing && ((options.id && options.id !== existing.id) || (options.credential && options.credential !== existing.credential)))
throw new PublicError('已有声明的身份和凭据引用不可改写。请为新凭据指定新的 --manifest 文件。');
const draft: Record<string, unknown> = { version: 1, ...existing };
for (const key of ['id', 'label', 'credential']) if (options[key] !== undefined) draft[key] = options[key];
const ui = { ...existing?.ui };
for (const [flag, key] of [['title', 'title'], ['placeholder', 'placeholder'], ['save-label', 'saveLabel']] as const)
if (options[flag] !== undefined) ui[key] = options[flag];
if (Object.keys(ui).length) draft.ui = ui;
const manifest = validateManifest(draft);
if (!dryRun) await writeConfiguration(file, manifest);
return { status: dryRun ? 'preview' : 'configured', manifest: file, configuration: manifest };
}
async function main() {
const [command, ...args] = process.argv.slice(2);
if (!command || command === '--help') {
process.stdout.write('configure [--manifest 文件] --id 标识 --label 用途 --credential 引用 [--title 标题] [--placeholder 占位文字] [--save-label 按钮文字] [--dry-run]\nconfigure-page --page 文件 [--manifest 声明(可重复,替换整组)] [--title 标题] [--label 用途] [--save-label 按钮文字] [--dry-run]\nstatus [--manifest 文件]\n');
return;
}
const flags: Record<string, string> = {};
const manifests: string[] = [];
let dryRun = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--dry-run' && ['configure', 'configure-page'].includes(command)) { dryRun = true; continue; }
if (command === 'configure-page' && args[i] === '--manifest' && args[i + 1] && !args[i + 1].startsWith('--')) { manifests.push(args[++i]); continue; }
const name = args[i].replace(/^--/, '');
if (!args[i].startsWith('--') || !args[i + 1] || args[i + 1].startsWith('--') || Object.hasOwn(flags, name))
throw new PublicError('命令参数不正确。');
flags[name] = args[++i];
}
if (command === 'configure-page') {
if (!flags.page) throw new PublicError('请指定 --page 配置文件。');
const file = path.resolve(flags.page); delete flags.page;
process.stdout.write(JSON.stringify(await configurePage(file, manifests, flags, dryRun)) + '\n'); return;
}
const file = path.resolve(flags.manifest ?? fileURLToPath(new URL('../manifests/default.json', import.meta.url)));
delete flags.manifest;
if (command === 'configure') process.stdout.write(JSON.stringify(await configureManifest(file, flags, dryRun)) + '\n');
else if (command === 'status' && !Object.keys(flags).length) {
const m = await loadManifest(file);
const backend = await nativeBackend(m.credential);
const configured = (await backend.get()) !== undefined;
process.stdout.write(JSON.stringify({ id: m.id, credential: m.credential, configured, storage: backend.name }) + '\n');
if (!configured) process.exitCode = 2;
} else throw new PublicError('命令不支持,请使用 --help。');
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
main().catch(error => {
process.stderr.write((error instanceof PublicError ? error.message : '配置操作未完成,请检查文件位置与权限。') + '\n');
process.exitCode = 1;
});
@@ -0,0 +1,103 @@
import { createHmac, randomBytes } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { execFile, spawn } from 'node:child_process';
export class PublicError extends Error {
status: number;
constructor(message: string, status = 400) { super(message); this.status = status; }
}
export type Manifest = {
version: 1; id: string; label: string; credential: string;
ui?: { title?: string; placeholder?: string; saveLabel?: string };
};
export const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
export async function loadManifest(file: string): Promise<Manifest> {
let m: unknown;
try { m = JSON.parse(await readFile(file, 'utf8')); } catch { throw new PublicError('无法读取配置声明。'); }
return validateManifest(m);
}
export function validateManifest(m: unknown): Manifest {
if (!object(m) || m.version !== 1 || typeof m.id !== 'string' || !/^[a-z0-9-]{1,80}$/.test(m.id)
|| typeof m.label !== 'string' || !m.label.trim() || m.label.length > 120
|| typeof m.credential !== 'string' || !/^[a-z0-9][a-z0-9/_.-]{0,150}$/.test(m.credential)
|| Object.keys(m).some(k => !['version', 'id', 'label', 'credential', 'ui'].includes(k))) throw new PublicError('配置声明不合法。');
if (m.ui !== undefined && (!object(m.ui) || Object.entries(m.ui).some(([k, v]) =>
!['title', 'placeholder', 'saveLabel'].includes(k) || typeof v !== 'string' || !v.trim() || v.length > 80)))
throw new PublicError('页面配置不合法。');
return m as Manifest;
}
export interface CredentialBackend {
name: string;
get(): Promise<string | undefined>;
set(value: string): Promise<void>;
delete(): Promise<void>;
}
export const service = 'org.oiloil.skill-credentials';
const backendError = () => new PublicError('无法访问系统凭据库,请解锁或检查系统凭据服务后重试。', 503);
// Linux 显式使用 Secret Service,不走可能回退到临时 keyutils 的默认绑定。
function linuxBackend(credential: string): CredentialBackend {
const attributes = ['service', service, 'account', credential];
const run = (args: string[]): Promise<string | undefined> => new Promise((resolve, reject) => {
execFile('secret-tool', args, { timeout: 15_000, maxBuffer: 32_768, encoding: 'utf8' }, (error, stdout, stderr) => {
if (error) {
if (args[0] === 'lookup' && error.code === 1 && !stderr.trim()) resolve(undefined);
else reject(backendError());
} else resolve(stdout.replace(/\r?\n$/, ''));
});
});
return { name: 'Linux Secret Service', get: () => run(['lookup', ...attributes]),
set: value => new Promise((resolve, reject) => {
const child = spawn('secret-tool', ['store', '--label=Skill 凭据', ...attributes], { stdio: ['pipe', 'ignore', 'ignore'], timeout: 15_000, shell: false });
child.once('error', () => reject(backendError()));
child.stdin.on('error', () => reject(backendError()));
child.once('close', code => code === 0 ? resolve() : reject(backendError()));
child.stdin.end(value + '\n');
}),
delete: async () => { await run(['clear', ...attributes]); }
};
}
export async function nativeBackend(credential: string): Promise<CredentialBackend> {
if (process.platform === 'linux') return linuxBackend(credential);
if (!['darwin', 'win32'].includes(process.platform)) throw new PublicError('当前系统暂不支持凭据保存。', 503);
try {
const { AsyncEntry } = await import('@napi-rs/keyring');
const entry = new AsyncEntry(service, credential);
return { name: process.platform === 'darwin' ? 'macOS 钥匙串' : 'Windows 凭据管理器',
// 原生绑定的空结果在实际运行中可能为 null,统一为接口约定的 undefined。
get: async () => { try { return (await entry.getPassword()) ?? undefined; } catch { throw backendError(); } },
set: async value => { try { await entry.setPassword(value); } catch { throw backendError(); } },
delete: async () => { try { await entry.deleteCredential(); } catch { throw backendError(); } }
};
} catch { throw backendError(); }
}
export function createStore(manifest: Manifest, backend: CredentialBackend) {
const salt = randomBytes(32);
let busy = false;
async function status() {
let value: string | undefined;
try { value = await backend.get(); } catch { throw backendError(); }
const revision = createHmac('sha256', salt).update(value === undefined ? 'missing:' : 'exists:').update(value ?? '').digest('hex');
return { revision, configured: value !== undefined, storage: backend.name };
}
async function validate(input: unknown) {
if (!object(input) || typeof input.value !== 'string' || !input.value.trim() || input.value.length > 2500
|| /[\r\n\0]/.test(input.value) || typeof input.revision !== 'string'
|| Object.keys(input).some(k => !['value', 'revision', 'replaceExisting'].includes(k))) throw new PublicError('请填写有效的单行密钥。');
const current = await status();
if (current.revision !== input.revision) throw new PublicError('凭据已发生变化,请刷新后重试。', 409);
if (current.configured && input.replaceExisting !== true) throw new PublicError('已有凭据,请确认替换后保存。', 409);
return { value: input.value.trim() };
}
async function save(input: unknown) {
if (busy) throw new PublicError('正在保存,请稍后。', 409);
busy = true;
try {
const checked = await validate(input);
await backend.set(checked.value);
return { status: 'saved', skill: manifest.id, credential: manifest.credential, configured: true };
} catch (error) { if (error instanceof PublicError) throw error; throw backendError(); }
finally { busy = false; }
}
return { status, save, validate };
}
@@ -0,0 +1,27 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { loadManifest, object, PublicError, validateManifest, type Manifest } from './config.ts';
export type PageUI = { title?: string; label?: string; saveLabel?: string };
export function validatePageUI(ui: unknown): PageUI {
if (!object(ui) || Object.entries(ui).some(([key, value]) =>
!['title', 'label', 'saveLabel'].includes(key) || typeof value !== 'string' || !value.trim() || value.length > 120))
throw new PublicError('页面文案配置不合法。');
return ui as PageUI;
}
export function validateFields(fields: Manifest[]): Manifest[] {
if (!Array.isArray(fields) || fields.length < 1 || fields.length > 16) throw new PublicError('每页需要 1 至 16 项凭据声明。');
fields.forEach(validateManifest);
if (new Set(fields.map(field => field.credential)).size !== fields.length) throw new PublicError('同一页不能重复引用相同凭据。');
return fields;
}
export async function loadPage(file: string) {
let value: unknown;
try { value = JSON.parse(await readFile(file, 'utf8')); } catch { throw new PublicError('无法读取页面配置。'); }
if (!object(value) || value.version !== 1 || Object.keys(value).some(key => !['version', 'manifests', 'ui'].includes(key))
|| !Array.isArray(value.manifests) || !value.manifests.length || value.manifests.length > 16
|| value.manifests.some(item => typeof item !== 'string' || !item.trim())) throw new PublicError('页面配置不合法。');
const ui = validatePageUI(value.ui ?? {});
const manifests = validateFields(await Promise.all(value.manifests.map(item => loadManifest(path.resolve(path.dirname(file), item)))));
return { manifests, ui };
}
@@ -0,0 +1,79 @@
import { readFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadManifest, nativeBackend, object, PublicError } from './config.ts';
import { validateFields } from './page.ts';
import { parseBindings, prepareCommand } from './run.ts';
import { startServer } from './server.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
type Binding = { manifest: string; env: string };
export async function loadProfile(name: string, directory = root): Promise<Binding[]> {
let value: unknown;
try { value = JSON.parse(await readFile(path.join(directory, 'manifests/profiles.json'), 'utf8')); }
catch { throw new PublicError('未找到业务凭据配置。'); }
if (!/^[a-z0-9-]+$/.test(name) || !object(value) || value.version !== 1 || !object(value.profiles)
|| !Object.hasOwn(value.profiles, name)) throw new PublicError('请选择已声明的业务配置。');
const items = value.profiles[name];
if (!Array.isArray(items) || !items.length || items.length > 16) throw new PublicError('业务凭据配置不合法。');
const bindings: Binding[] = items.map(item => {
if (!object(item) || Object.keys(item).some(k => !['manifest', 'env'].includes(k))
|| typeof item.manifest !== 'string' || !/^[a-z0-9-]+\.json$/.test(item.manifest)
|| typeof item.env !== 'string') throw new PublicError('业务凭据绑定不合法。');
return { manifest: path.join(directory, 'manifests', item.manifest), env: item.env };
});
parseBindings([...bindingArgs(bindings), '--', 'check']);
validateFields(await Promise.all(bindings.map(b => loadManifest(b.manifest))));
return bindings;
}
function bindingArgs(bindings: Binding[]) {
return bindings.flatMap(b => ['--manifest', b.manifest, '--env', b.env]);
}
export async function profileStatus(bindings: Binding[], environment = process.env,
read = async (ref: string) => (await nativeBackend(ref)).get()) {
const fields = [];
for (const b of bindings) {
const m = await loadManifest(b.manifest);
const fromEnv = Boolean(environment[b.env]?.trim());
const configured = fromEnv || Boolean(await read(m.credential));
fields.push({ credential: m.credential, configured, source: fromEnv ? 'environment' : 'system-store' });
}
return { configured: fields.every(f => f.configured), fields };
}
export async function prepareProfile(bindings: Binding[], command: string[], environment = process.env,
read = async (ref: string) => (await nativeBackend(ref)).get()) {
if (!command.length) throw new PublicError('请指定真实业务程序。');
// 环境注入优先;本次只读取所选业务需要的凭据。
const missing = bindings.filter(b => !environment[b.env]?.trim());
if (!missing.length) return { command: command[0], args: command.slice(1), env: { ...environment } };
return prepareCommand([...bindingArgs(missing), '--', ...command], read, environment);
}
async function main() {
const [action, name, ...args] = process.argv.slice(2);
if (!['status', 'setup', 'run'].includes(action) || !name || (action !== 'run' && args.length)
|| (action === 'run' && (args[0] !== '--' || args.length < 2)))
throw new PublicError('用法:node src/profile.ts status|setup 配置名;node src/profile.ts run 配置名 -- 程序 参数');
const bindings = await loadProfile(name);
if (action === 'status') {
const status = await profileStatus(bindings);
process.stdout.write(JSON.stringify(status) + '\n');
if (!status.configured) process.exitCode = 2;
} else if (action === 'setup') {
const manifests = await Promise.all(bindings.map(b => loadManifest(b.manifest)));
const app = await startServer({ manifests, onComplete: result => process.stdout.write(JSON.stringify(result) + '\n') });
process.stdout.write(`本机配置页面(由用户亲自填写,30 分钟内有效):\n${app.url}\n`);
process.once('SIGINT', app.close); process.once('SIGTERM', app.close);
} else {
const plan = await prepareProfile(bindings, args.slice(1));
const child = spawn(plan.command, plan.args, { env: plan.env, stdio: 'inherit', shell: false });
for (const b of bindings) delete plan.env[b.env];
child.once('error', () => { process.stderr.write('业务程序启动失败。\n'); process.exitCode = 1; });
child.once('exit', code => { process.exitCode = code ?? 1; });
}
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
main().catch(error => {
process.stderr.write((error instanceof PublicError ? error.message : '配置未完成,请检查依赖与系统凭据服务。') + '\n');
process.exitCode = 1;
});
@@ -0,0 +1,61 @@
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import { loadManifest, nativeBackend, PublicError } from './config.ts';
type Binding = { file: string; variable: string };
const defaultManifest = fileURLToPath(new URL('../manifests/default.json', import.meta.url));
const reserved = new Set(['PATH', 'HOME', 'SHELL', 'NODE_OPTIONS', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES', 'PYTHONPATH', 'PYTHONSTARTUP', 'COMSPEC', 'SYSTEMROOT']);
export function parseBindings(args: string[]) {
const split = args.indexOf('--');
if (split < 0 || !args[split + 1]) throw new PublicError('请指定要启动的程序。');
const bindings: Binding[] = [];
const variables = new Set<string>();
let pending: string | undefined;
for (let i = 0; i < split; i += 2) {
const value = args[i + 1];
if (i + 1 >= split || !value || value.startsWith('--')) throw new PublicError('凭据绑定参数不完整。');
if (args[i] === '--manifest' && pending === undefined) pending = value;
else if (args[i] === '--env') {
if (!/^[A-Z][A-Z0-9_]*$/.test(value) || reserved.has(value) || variables.has(value))
throw new PublicError('环境变量名重复或不适合注入凭据。');
bindings.push({ file: pending ?? defaultManifest, variable: value });
variables.add(value); pending = undefined;
} else throw new PublicError('凭据绑定参数不正确。');
}
if (pending !== undefined || !bindings.length || bindings.length > 16) throw new PublicError('请提供一至十六组完整的凭据绑定。');
return { bindings, command: args[split + 1], args: args.slice(split + 2) };
}
export async function prepareCommand(
args: string[],
readCredential: (ref: string) => Promise<string | undefined> = async ref => (await nativeBackend(ref)).get(),
baseEnv: NodeJS.ProcessEnv = process.env,
) {
const plan = parseBindings(args);
const manifests = await Promise.all(plan.bindings.map(binding => loadManifest(binding.file)));
const env = { ...baseEnv };
try {
for (let i = 0; i < plan.bindings.length; i++) {
const value = await readCredential(manifests[i].credential);
if (!value) throw new PublicError('有凭据尚未配置,任务未启动。');
env[plan.bindings[i].variable] = value;
}
return { ...plan, env };
} catch (error) {
for (const binding of plan.bindings) delete env[binding.variable];
if (error instanceof PublicError) throw error;
throw new PublicError('凭据读取未完成,任务未启动。');
}
}
async function main() {
const prepared = await prepareCommand(process.argv.slice(2));
const child = spawn(prepared.command, prepared.args, { env: prepared.env, stdio: 'inherit', shell: false });
for (const binding of prepared.bindings) delete prepared.env[binding.variable];
child.once('error', () => { process.stderr.write('无法启动目标程序。\n'); process.exitCode = 1; });
child.once('exit', code => { process.exitCode = code ?? 1; });
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
main().catch(error => {
process.stderr.write((error instanceof PublicError ? error.message : '无法读取凭据或启动参数不正确。') + '\n');
process.exitCode = 1;
});
@@ -0,0 +1,179 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import { createStore, loadManifest, nativeBackend, object, PublicError, type Manifest, type CredentialBackend } from './config.ts';
import { loadPage, validateFields, validatePageUI, type PageUI } from './page.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
function equal(a: string, b: string) { const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); }
export async function startServer(options: { manifest?: Manifest; manifests?: Manifest[]; ui?: PageUI; backend?: CredentialBackend; backends?: CredentialBackend[]; port?: number; ttlMs?: number; onComplete?: (result: object) => void }) {
if (options.manifest && options.manifests) throw new PublicError('不能同时指定单项与多项声明。');
const fields = validateFields(options.manifests ?? (options.manifest ? [options.manifest] : []));
const ui = validatePageUI(options.ui ?? {});
if ((options.backends && options.backends.length !== fields.length) || (options.backend && (fields.length !== 1 || options.backends)))
throw new PublicError('凭据后端与字段数量不匹配。');
const stores = await Promise.all(fields.map(async (field, index) => createStore(field,
options.backends?.[index] ?? options.backend ?? await nativeBackend(field.credential))));
const store = stores[0];
const identity = fields.length === 1 ? { skill: fields[0].id, credential: fields[0].credential }
: { credentials: fields.map(field => ({ skill: field.id, credential: field.credential })) };
let lastResult: object | undefined;
const bootstrap = randomBytes(32).toString('hex');
const session = randomBytes(32).toString('hex');
// 同一主机上的不同端口共享 cookie 命名空间,因此每个会话使用独立名称。
const cookieName = 'credentials_session_' + randomBytes(12).toString('hex');
let origin = '';
let outcome = 'waiting';
let saving = false;
let completedTimer: ReturnType<typeof setTimeout> | undefined;
const assets: Record<string, [string, string]> = {
'/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/style.css': ['style.css', 'text/css; charset=utf-8'], '/favicon.svg': ['favicon.svg', 'image/svg+xml']
};
function json(res: ServerResponse, code: number, body: object) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); }
async function body(req: IncomingMessage) {
if (req.headers['content-type'] !== 'application/json') throw new PublicError('请求格式不支持。', 415);
const parts: Buffer[] = []; let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 256 * 1024) throw new PublicError('请求内容过大。', 413);
parts.push(chunk);
}
try { return JSON.parse(Buffer.concat(parts).toString('utf8')) as unknown; }
catch { throw new PublicError('请求格式不正确。'); }
}
const server = createServer(async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
try {
if (req.headers.host !== new URL(origin).host) throw new PublicError('请求来源不正确。', 403);
const url = new URL(req.url ?? '/', origin);
if (url.search) throw new PublicError('不接受 URL 查询参数。');
if (req.headers.origin && req.headers.origin !== origin) throw new PublicError('不接受跨站请求。', 403);
if (req.method === 'GET' && Object.hasOwn(assets, url.pathname)) {
const [file, type] = assets[url.pathname];
const content = await readFile(path.join(root, 'public', file));
res.writeHead(200, { 'Content-Type': type }); res.end(content); return;
}
if (url.pathname === '/agent/status' && req.method === 'GET') {
if (!equal(req.headers.authorization ?? '', `Bearer ${bootstrap}`)) throw new PublicError('无权访问。', 401);
json(res, 200, { ...identity, status: outcome, result: lastResult }); return;
}
if (req.method === 'POST' && (req.headers.origin !== origin || req.headers['x-local-request'] !== '1'))
throw new PublicError('请从本地配置页面提交。', 403);
if (url.pathname === '/api/session' && req.method === 'POST') {
if (!equal(req.headers.authorization ?? '', `Bearer ${bootstrap}`)) throw new PublicError('入口已失效,请重新打开工具提供的链接。', 401);
res.setHeader('Set-Cookie', `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.ceil((options.ttlMs ?? 30 * 60_000) / 1000)}`);
json(res, 200, { status: 'ready' }); return;
}
const cookies = (req.headers.cookie ?? '').split(';').map(c => c.trim());
if (!cookies.some(c => equal(c, `${cookieName}=${session}`))) throw new PublicError('请重新打开工具提供的配置链接。', 401);
if (url.pathname === '/api/meta' && req.method === 'GET') {
const metadata = await Promise.all(fields.map(async (field, index) => ({ ...field, ...await stores[index].status() })));
json(res, 200, { ...metadata[0], fields: metadata, page: ui, outcome }); return;
}
if (url.pathname === '/api/save' && req.method === 'POST') {
if (!['waiting', 'partial'].includes(outcome) || saving) throw new PublicError('配置正在保存或已结束,请稍后确认状态。', 409);
saving = true;
try {
const input = await body(req);
let result;
if (fields.length === 1 && object(input) && !Object.hasOwn(input, 'entries')) result = await store.save(input);
else {
if (!object(input) || Object.keys(input).some(key => key !== 'entries') || !Array.isArray(input.entries)
|| !input.entries.length || input.entries.length > fields.length) throw new PublicError('提交字段不合法。');
const seen = new Set<string>();
const entries = input.entries.map(entry => {
if (!object(entry) || typeof entry.credential !== 'string' || seen.has(entry.credential)) throw new PublicError('提交凭据重复或不合法。');
seen.add(entry.credential);
const index = fields.findIndex(field => field.credential === entry.credential);
if (index === -1) throw new PublicError('提交了页面之外的凭据。');
const { credential, ...payload } = entry;
return { index, payload };
});
// 所有格式、替换授权和版本先验证;系统存储不提供跨项事务。
await Promise.all([
...entries.map(entry => stores[entry.index].validate(entry.payload)),
...fields.map(async (field, index) => {
if (!seen.has(field.credential) && !(await stores[index].status()).configured)
throw new PublicError('请填写所有尚未配置的密钥。');
}),
]);
const results: { credential: string; status: string }[] = [];
let failed = false;
for (const entry of entries) {
let status = 'not_attempted';
if (!failed) {
try { await stores[entry.index].save(entry.payload); status = 'saved'; }
catch { status = 'failed'; failed = true; }
}
results.push({ credential: fields[entry.index].credential, status });
}
result = { ...identity, status: failed ? 'partial' : 'saved', results };
}
outcome = result.status;
lastResult = result;
json(res, 200, result);
options.onComplete?.(result);
if (outcome === 'saved') { completedTimer = setTimeout(close, 90_000); completedTimer.unref(); }
return;
} finally { saving = false; }
}
if (url.pathname === '/api/cancel' && req.method === 'POST') {
if (!['waiting', 'partial'].includes(outcome) || saving) throw new PublicError('配置正在保存或已结束。', 409);
outcome = 'cancelled'; json(res, 200, { status: outcome });
options.onComplete?.({ ...identity, status: outcome });
completedTimer = setTimeout(close, 500); completedTimer.unref(); return;
}
throw new PublicError('接口不存在。', 404);
} catch (error) {
if (!res.headersSent) json(res, error instanceof PublicError ? error.status : 500,
{ error: error instanceof PublicError ? error.message : '操作未完成,请检查系统凭据服务。' });
else res.end();
}
});
server.requestTimeout = 15_000;
server.headersTimeout = 10_000;
server.maxHeadersCount = 30;
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', () => resolve()); });
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('无法启动本机服务。');
origin = `http://127.0.0.1:${addr.port}`;
const expiry = setTimeout(() => {
if (['waiting', 'partial'].includes(outcome)) options.onComplete?.({ ...identity, status: 'expired', result: lastResult });
close();
}, options.ttlMs ?? 30 * 60_000);
expiry.unref();
function close() { clearTimeout(expiry); clearTimeout(completedTimer); server.close(); server.closeAllConnections(); }
return { origin, url: `${origin}/#${bootstrap}`, bootstrap, close };
}
async function main() {
const args = process.argv.slice(2);
const manifestFiles: string[] = [];
let pageFile: string | undefined;
let port = 0;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--manifest' && args[i + 1]) manifestFiles.push(path.resolve(args[++i]));
else if (args[i] === '--page' && args[i + 1] && !pageFile) pageFile = path.resolve(args[++i]);
else if (args[i] === '--port' && /^\d+$/.test(args[i + 1] ?? '')) port = Number(args[++i]);
else throw new PublicError('用法:npm start -- [--manifest 声明路径(可重复) | --page 页面配置] [--port 端口]');
}
if (port > 65535) throw new PublicError('端口不合法。');
if (pageFile && manifestFiles.length) throw new PublicError('--page 与 --manifest 不能同时使用。');
const page = pageFile ? await loadPage(pageFile) : {
manifests: await Promise.all((manifestFiles.length ? manifestFiles : [path.join(root, 'manifests', 'default.json')]).map(loadManifest))
};
const app = await startServer({ ...page, port,
onComplete: result => process.stdout.write(JSON.stringify(result) + '\n') });
process.stdout.write(`本机配置页面(30 分钟内有效):\n${app.url}\n`);
process.once('SIGINT', app.close); process.once('SIGTERM', app.close);
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
main().catch(() => { process.stderr.write('启动失败,请检查 Node.js 版本、声明文件和端口。\n'); process.exitCode = 1; });
}