feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s
TallyNote release / linux-x64 (push) Failing after 2m41s
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { openDatabase } from "../db/index.js";
|
||||
import { acquireInstanceLock, loadConfig, prepareDataDirectories } from "../config.js";
|
||||
import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword } from "../security.js";
|
||||
import { writeAudit } from "../audit.js";
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
async function readSecret(prompt: string): Promise<string> {
|
||||
if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码");
|
||||
output.write(prompt);
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
let value = "";
|
||||
const wasRaw = Boolean(input.isRaw);
|
||||
const onData = (chunk: Buffer) => {
|
||||
const text = chunk.toString("utf8");
|
||||
if (text === "\u0003") {
|
||||
cleanup();
|
||||
reject(new Error("已取消"));
|
||||
} else if (text === "\r" || text === "\n") {
|
||||
cleanup();
|
||||
output.write("\n");
|
||||
resolve(value);
|
||||
} else if (text === "\u007f") {
|
||||
value = value.slice(0, -1);
|
||||
} else if (!text.includes("\u001b")) {
|
||||
value += text;
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
input.off("data", onData);
|
||||
input.setRawMode?.(wasRaw);
|
||||
input.pause();
|
||||
};
|
||||
input.resume();
|
||||
input.setRawMode?.(true);
|
||||
input.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
prepareDataDirectories(config);
|
||||
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
||||
const release = acquireInstanceLock(config);
|
||||
const database = openDatabase(config);
|
||||
try {
|
||||
const existing = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
|
||||
if (existing.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
|
||||
const username = arg("--username") ?? (await readSecret("用户名: "));
|
||||
const displayName = arg("--display-name") ?? (await readSecret("显示名称: "));
|
||||
const generate = process.argv.includes("--generate");
|
||||
let password = generate ? temporaryPassword() : await readSecret("密码(至少 12 个字符): ");
|
||||
if (!generate) {
|
||||
const confirmation = await readSecret("再次输入密码: ");
|
||||
if (password !== confirmation) throw new Error("两次密码输入不一致");
|
||||
}
|
||||
const policyError = validateNewPassword(password);
|
||||
if (policyError) throw new Error(policyError);
|
||||
const normalized = normalizeUsername(username);
|
||||
if ([...normalized].length < 3) throw new Error("用户名至少需要 3 个字符");
|
||||
const passwordHash = await hashPassword(password);
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
database.sqlite.transaction(() => {
|
||||
const current = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
|
||||
if (current.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
|
||||
database.sqlite.prepare(`
|
||||
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
|
||||
must_change_password, auth_version, version, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?)
|
||||
`).run(id, username.normalize("NFKC").trim(), normalized, displayName.trim(), passwordHash, now);
|
||||
writeAudit(database.sqlite, {
|
||||
requestId: `cli:${randomUUID()}`,
|
||||
actorUsername: "cli",
|
||||
action: "admin.initialized",
|
||||
targetType: "admin",
|
||||
targetId: id,
|
||||
after: { username: normalized, displayName: displayName.trim(), status: "active" },
|
||||
});
|
||||
})();
|
||||
console.log(generate ? `已创建首位管理员。一次性密码:${password}` : "已创建首位管理员。");
|
||||
} finally {
|
||||
database.sqlite.close();
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type Database from "better-sqlite3";
|
||||
import { z } from "zod";
|
||||
import { acquireInstanceLock, loadConfig, prepareDataDirectories, type AppConfig } from "../config.js";
|
||||
import { openDatabase } from "../db/index.js";
|
||||
import { writeAudit } from "../audit.js";
|
||||
import {
|
||||
atomicSwitchDirectory,
|
||||
atomicSwitchRelease,
|
||||
compareSemver,
|
||||
createSafeArchive,
|
||||
detectPlatform,
|
||||
downloadReleaseAsset,
|
||||
extractSafeArchive,
|
||||
fetchReleaseMetadata,
|
||||
isNewerVersion,
|
||||
normalizeReleasePermissions,
|
||||
parseSemver,
|
||||
selectReleaseAsset,
|
||||
sanitizeAssetName,
|
||||
validateHttpsUrl,
|
||||
type ReleaseAsset,
|
||||
type ReleaseMetadata,
|
||||
type UrlPolicy,
|
||||
} from "../update.js";
|
||||
import { attachSidecarHash } from "../update-service.js";
|
||||
import type { UpdateJobStatus } from "../../shared/contracts.js";
|
||||
|
||||
const updateRequestFileSchema = z.object({
|
||||
jobId: z.string().uuid(),
|
||||
version: z.string().regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
|
||||
metadataUrl: z.string().url(),
|
||||
assetUrl: z.string().url(),
|
||||
assetName: z.string().min(1).max(200),
|
||||
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/i),
|
||||
requestedAt: z.number().int().positive(),
|
||||
currentLink: z.string().min(1),
|
||||
releasesDir: z.string().min(1),
|
||||
dataDir: z.string().min(1),
|
||||
}).strict();
|
||||
|
||||
export type UpdateRequestFile = z.infer<typeof updateRequestFileSchema>;
|
||||
|
||||
/** Validate the hand-off from the unprivileged web process. URL and path
|
||||
* fields are treated as untrusted data even though the file is local: the
|
||||
* privileged runner must bind them to its own configuration before using it.
|
||||
*/
|
||||
export function validateUpdateRequest(requestValue: unknown, config: AppConfig): UpdateRequestFile {
|
||||
const request = updateRequestFileSchema.parse(requestValue);
|
||||
if (path.resolve(request.dataDir) !== path.resolve(config.dataDir)
|
||||
|| path.resolve(request.currentLink) !== path.resolve(config.currentLink)
|
||||
|| path.resolve(request.releasesDir) !== path.resolve(config.releasesDir)) {
|
||||
throw new Error("更新请求目录与服务配置不一致");
|
||||
}
|
||||
const configuredMetadataUrl = validateHttpsUrl(config.updateMetadataUrl, {
|
||||
allowedHosts: config.updateAllowedHosts,
|
||||
baseUrl: config.updateMetadataUrl,
|
||||
}).toString();
|
||||
const requestedMetadataUrl = validateHttpsUrl(request.metadataUrl, {
|
||||
allowedHosts: config.updateAllowedHosts,
|
||||
baseUrl: config.updateMetadataUrl,
|
||||
}).toString();
|
||||
if (requestedMetadataUrl !== configuredMetadataUrl) throw new Error("更新请求源与服务配置不一致");
|
||||
const requestAge = Date.now() - request.requestedAt;
|
||||
if (requestAge > 24 * 60 * 60 * 1000 || requestAge < -5 * 60 * 1000) throw new Error("更新请求已过期");
|
||||
return request;
|
||||
}
|
||||
|
||||
export type UpdateRunOptions = UrlPolicy & {
|
||||
sqlite?: Database.Database;
|
||||
metadataUrl?: string | undefined;
|
||||
assetUrl?: string | undefined;
|
||||
assetName?: string | undefined;
|
||||
version?: string | undefined;
|
||||
expectedSha256?: string | undefined;
|
||||
currentVersion?: string | undefined;
|
||||
currentDir: string;
|
||||
stagingDir: string;
|
||||
backupArchivePath?: string | undefined;
|
||||
dataBackupArchivePath?: string | undefined;
|
||||
dataBackupSource?: string | undefined;
|
||||
backupDir?: string | undefined;
|
||||
releasesDir?: string | undefined;
|
||||
currentLink?: string | undefined;
|
||||
adminId?: string | undefined;
|
||||
sessionHash?: string | undefined;
|
||||
requestId?: string | undefined;
|
||||
deferCompletion?: boolean | undefined;
|
||||
maxBytes?: number | undefined;
|
||||
dataBackupMaxBytes?: number | undefined;
|
||||
fetchImpl?: typeof fetch;
|
||||
platform?: ReturnType<typeof detectPlatform> | undefined;
|
||||
jobId?: string | undefined;
|
||||
publicKey?: string | undefined;
|
||||
requireSignature?: boolean | undefined;
|
||||
};
|
||||
|
||||
export type UpdateRunResult = {
|
||||
jobId: string;
|
||||
version: string;
|
||||
asset: ReleaseAsset;
|
||||
archivePath: string;
|
||||
backupArchivePath?: string;
|
||||
backupDir?: string;
|
||||
};
|
||||
|
||||
function safeErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return "更新失败";
|
||||
const message = error.message;
|
||||
if (message.length > 200 || /https?:\/\//i.test(message) || /authorization|token|secret|password|cookie|apikey/i.test(message)) return "更新失败";
|
||||
return message || "更新失败";
|
||||
}
|
||||
|
||||
function normalizedSha256(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const normalized = value.trim().replace(/^sha256:/i, "").toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(normalized)) throw new Error("SHA-256 校验值无效");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function writeJob(sqlite: Database.Database | undefined, jobId: string, values: {
|
||||
status: UpdateJobStatus;
|
||||
version: string;
|
||||
platform: string;
|
||||
releaseUrl?: string | undefined;
|
||||
assetName?: string | undefined;
|
||||
assetUrl: string;
|
||||
expectedSha256?: string | undefined;
|
||||
actualSha256?: string | undefined;
|
||||
downloadPath?: string | undefined;
|
||||
backupPath?: string | undefined;
|
||||
sizeBytes?: number | undefined;
|
||||
errorMessage?: string | undefined;
|
||||
completedAt?: number | undefined;
|
||||
adminId?: string | undefined;
|
||||
sessionHash?: string | undefined;
|
||||
requestId?: string | undefined;
|
||||
requestedAt?: number | undefined;
|
||||
startedAt?: number | undefined;
|
||||
}): void {
|
||||
if (!sqlite) return;
|
||||
const now = Date.now();
|
||||
sqlite.prepare(`
|
||||
INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, started_at,
|
||||
status, version, platform, release_url, asset_name, asset_url,
|
||||
expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message,
|
||||
created_at, updated_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id),
|
||||
session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash),
|
||||
request_id=COALESCE(excluded.request_id, update_jobs.request_id),
|
||||
requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at),
|
||||
started_at=COALESCE(excluded.started_at, update_jobs.started_at),
|
||||
status=excluded.status, version=excluded.version, platform=excluded.platform,
|
||||
release_url=COALESCE(excluded.release_url, update_jobs.release_url),
|
||||
asset_name=COALESCE(excluded.asset_name, update_jobs.asset_name),
|
||||
asset_url=excluded.asset_url,
|
||||
expected_sha256=COALESCE(excluded.expected_sha256, update_jobs.expected_sha256),
|
||||
actual_sha256=COALESCE(excluded.actual_sha256, update_jobs.actual_sha256),
|
||||
download_path=COALESCE(excluded.download_path, update_jobs.download_path),
|
||||
backup_path=COALESCE(excluded.backup_path, update_jobs.backup_path),
|
||||
size_bytes=COALESCE(excluded.size_bytes, update_jobs.size_bytes),
|
||||
error_message=COALESCE(excluded.error_message, update_jobs.error_message),
|
||||
updated_at=excluded.updated_at,
|
||||
completed_at=COALESCE(excluded.completed_at, update_jobs.completed_at)
|
||||
`).run(
|
||||
jobId,
|
||||
values.adminId ?? null,
|
||||
values.sessionHash ?? null,
|
||||
values.requestId ?? null,
|
||||
values.requestedAt ?? null,
|
||||
values.startedAt ?? null,
|
||||
values.status,
|
||||
values.version,
|
||||
values.platform,
|
||||
values.releaseUrl ?? null,
|
||||
values.assetName ?? null,
|
||||
values.assetUrl,
|
||||
values.expectedSha256 ?? null,
|
||||
values.actualSha256 ?? null,
|
||||
values.downloadPath ?? null,
|
||||
values.backupPath ?? null,
|
||||
values.sizeBytes ?? null,
|
||||
values.errorMessage ?? null,
|
||||
now,
|
||||
now,
|
||||
values.completedAt ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function updateJob(sqlite: Database.Database | undefined, jobId: string, values: Parameters<typeof writeJob>[2]): void {
|
||||
writeJob(sqlite, jobId, values);
|
||||
}
|
||||
|
||||
function clearTransientJobPath(sqlite: Database.Database | undefined, jobId: string): void {
|
||||
if (!sqlite) return;
|
||||
sqlite.prepare("UPDATE update_jobs SET download_path=NULL, updated_at=? WHERE id=?").run(Date.now(), jobId);
|
||||
}
|
||||
|
||||
async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<typeof detectPlatform>): Promise<{ release?: ReleaseMetadata; asset: ReleaseAsset; version: string; releaseUrl?: string }> {
|
||||
if (options.metadataUrl) {
|
||||
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
|
||||
const release = await fetchReleaseMetadata(metadataUrl, options);
|
||||
let asset = options.assetUrl && !options.requireSignature
|
||||
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
|
||||
: selectReleaseAsset(release, platform);
|
||||
if (!asset) throw new Error("没有匹配当前平台的更新文件");
|
||||
const integrity = await attachSidecarHash(release, asset, {
|
||||
allowedHosts: options.allowedHosts ?? [],
|
||||
baseUrl: metadataUrl.toString(),
|
||||
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
|
||||
publicKey: options.publicKey,
|
||||
requireSignature: options.requireSignature,
|
||||
});
|
||||
asset = integrity.asset;
|
||||
if (options.requireSignature && !integrity.signatureVerified) throw new Error("更新发布签名校验失败");
|
||||
if (options.version && compareSemver(options.version, release.version) !== 0) throw new Error("更新版本与发布信息不一致");
|
||||
return { release, asset: { ...asset, name: sanitizeAssetName(asset.name) }, version: release.version, releaseUrl: metadataUrl.toString() };
|
||||
}
|
||||
if (!options.assetUrl || !options.version) throw new Error("必须提供 metadata URL,或同时提供更新文件地址和版本号");
|
||||
const assetUrl = validateHttpsUrl(options.assetUrl, options);
|
||||
parseSemver(options.version);
|
||||
return { asset: { name: sanitizeAssetName(options.assetName ?? path.basename(assetUrl.pathname)), url: assetUrl.toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }, version: options.version };
|
||||
}
|
||||
|
||||
async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
|
||||
const resolved = path.resolve(directory);
|
||||
await mkdir(resolved, { recursive: true, mode: 0o700 });
|
||||
const info = await lstat(resolved).catch(() => null);
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0 || uid !== 0) {
|
||||
throw new Error("更新工作目录必须是 root 拥有且权限为 0700");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunResult> {
|
||||
const platform = options.platform ?? detectPlatform();
|
||||
const jobId = options.jobId ?? randomUUID();
|
||||
let resolved: Awaited<ReturnType<typeof resolveRelease>> | undefined;
|
||||
try {
|
||||
resolved = await resolveRelease(options, platform);
|
||||
const suppliedSha256 = normalizedSha256(options.expectedSha256);
|
||||
const expectedSha256 = normalizedSha256(options.requireSignature && options.metadataUrl ? resolved.asset.sha256 : suppliedSha256 ?? resolved.asset.sha256);
|
||||
if (options.requireSignature && options.metadataUrl && suppliedSha256 && suppliedSha256 !== expectedSha256) throw new Error("更新校验值与发布信息不一致");
|
||||
if (!expectedSha256) throw new Error("发布信息缺少 SHA-256 校验值");
|
||||
if (options.currentVersion && !isNewerVersion(options.currentVersion, resolved.version)) throw new Error("更新版本不是较新版本");
|
||||
writeJob(options.sqlite, jobId, {
|
||||
status: "queued", version: resolved.version, platform: platform.target,
|
||||
releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url,
|
||||
expectedSha256, adminId: options.adminId, sessionHash: options.sessionHash,
|
||||
requestId: options.requestId, requestedAt: Date.now(),
|
||||
});
|
||||
|
||||
await mkdir(options.stagingDir, { recursive: true, mode: 0o700 });
|
||||
const workspace = await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
|
||||
const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`);
|
||||
try {
|
||||
updateJob(options.sqlite, jobId, { status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
|
||||
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, options);
|
||||
if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败");
|
||||
updateJob(options.sqlite, jobId, { status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
|
||||
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
|
||||
const stagedDir = path.join(workspace, "payload");
|
||||
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
|
||||
await normalizeReleasePermissions(stagedDir);
|
||||
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
|
||||
updateJob(options.sqlite, jobId, { status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath });
|
||||
|
||||
let backupArchivePath: string | undefined;
|
||||
if (options.dataBackupArchivePath && options.dataBackupSource) {
|
||||
updateJob(options.sqlite, jobId, { status: "backing_up", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: options.dataBackupArchivePath });
|
||||
await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, {
|
||||
maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
if (options.backupArchivePath) {
|
||||
updateJob(options.sqlite, jobId, { status: "backing_up", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath, backupPath: options.backupArchivePath });
|
||||
const backupSource = await realpath(options.currentDir).catch(() => options.currentDir);
|
||||
await createSafeArchive(backupSource, options.backupArchivePath, {
|
||||
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
|
||||
});
|
||||
backupArchivePath = options.backupArchivePath;
|
||||
}
|
||||
updateJob(options.sqlite, jobId, { status: "applying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath, backupPath: options.backupArchivePath });
|
||||
const switchedBackup = options.releasesDir && options.currentLink
|
||||
? (await atomicSwitchRelease(stagedDir, options.currentLink, options.releasesDir, resolved.version)).previousTarget
|
||||
: await atomicSwitchDirectory(stagedDir, options.currentDir, options.backupDir);
|
||||
const completedAt = Date.now();
|
||||
updateJob(options.sqlite, jobId, { status: options.deferCompletion ? "applying" : "completed", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: switchedBackup ?? backupArchivePath, ...(options.deferCompletion ? {} : { completedAt }) });
|
||||
return { jobId, version: resolved.version, asset: resolved.asset, archivePath, ...(backupArchivePath ? { backupArchivePath } : {}), ...(switchedBackup ? { backupDir: switchedBackup } : {}) };
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
clearTransientJobPath(options.sqlite, jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
const fallbackVersion = resolved?.version ?? options.version ?? "0.0.0";
|
||||
const fallbackAsset = resolved?.asset ?? { name: options.assetName ?? "unknown", url: options.assetUrl ?? "https://invalid.invalid/unknown" };
|
||||
updateJob(options.sqlite, jobId, { status: "failed", version: fallbackVersion, platform: platform.target, releaseUrl: resolved?.releaseUrl, assetName: fallbackAsset.name, assetUrl: fallbackAsset.url, expectedSha256: options.expectedSha256 ?? fallbackAsset.sha256, errorMessage: safeErrorMessage(error) });
|
||||
throw new Error(safeErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export function finalizeUpdateJob(
|
||||
sqlite: Database.Database,
|
||||
jobId: string,
|
||||
status: "completed" | "failed",
|
||||
message?: string,
|
||||
): void {
|
||||
const row = sqlite.prepare(`
|
||||
SELECT id, status, version, platform, admin_id AS adminId,
|
||||
request_id AS requestId, session_hash AS sessionHash
|
||||
FROM update_jobs WHERE id=?
|
||||
`).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined;
|
||||
if (!row) throw new Error("更新任务不存在");
|
||||
if (row.status !== "applying" && row.status !== "completed" && row.status !== "failed") throw new Error("更新任务状态不允许完成");
|
||||
const now = Date.now();
|
||||
const safeFailureMessage = status === "failed" ? "新版本健康检查失败,已恢复上一版本" : null;
|
||||
sqlite.transaction(() => {
|
||||
sqlite.prepare("UPDATE update_jobs SET status=?, error_message=?, completed_at=?, updated_at=? WHERE id=?").run(status, safeFailureMessage, now, now, jobId);
|
||||
writeAudit(sqlite, {
|
||||
requestId: row.requestId || randomUUID(),
|
||||
actorAdminId: row.adminId,
|
||||
action: status === "completed" ? "update.completed" : "update.failed",
|
||||
targetType: "update",
|
||||
targetId: jobId,
|
||||
outcome: status === "completed" ? "success" : "failure",
|
||||
after: { status, version: row.version, platform: row.platform, ...(status === "failed" ? { reason: "health_check_failed" } : {}) },
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
const finalizeJobId = arg("--finalize-job");
|
||||
if (finalizeJobId) {
|
||||
const finalStatus = arg("--finalize-status");
|
||||
if (finalStatus !== "completed" && finalStatus !== "failed") throw new Error("更新完成状态无效");
|
||||
prepareDataDirectories(config);
|
||||
const database = openDatabase(config);
|
||||
try {
|
||||
finalizeUpdateJob(database.sqlite, finalizeJobId, finalStatus, arg("--message"));
|
||||
} finally {
|
||||
database.sqlite.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const requestPath = arg("--request-file");
|
||||
const metadataUrl = arg("--metadata-url");
|
||||
const assetUrl = arg("--asset-url");
|
||||
const version = arg("--version");
|
||||
let request: UpdateRequestFile | undefined;
|
||||
if (requestPath) {
|
||||
if (path.resolve(requestPath) !== path.resolve(config.updateRequestPath)) throw new Error("更新请求文件路径无效");
|
||||
try {
|
||||
const requestInfo = await lstat(requestPath);
|
||||
if (!requestInfo.isFile() || requestInfo.isSymbolicLink() || (requestInfo.mode & 0o077) !== 0) throw new Error("权限");
|
||||
request = validateUpdateRequest(JSON.parse(await readFile(requestPath, "utf8")), config);
|
||||
} catch { throw new Error("更新请求文件无效"); }
|
||||
}
|
||||
const effectiveMetadataUrl = request ? config.updateMetadataUrl : metadataUrl;
|
||||
const effectiveAssetUrl = request ? undefined : assetUrl;
|
||||
const effectiveVersion = request?.version ?? version;
|
||||
const deferCompletion = request ? process.argv.includes("--defer-completion") : false;
|
||||
const currentDir = request?.currentLink ?? arg("--current-dir") ?? config.projectRoot;
|
||||
const stagingDir = arg("--staging-dir") ?? (request ? config.updateWorkspaceDir : config.stagingDir);
|
||||
const backupArchive = arg("--backup-archive") ?? (request ? path.join(config.dataDir, "backups", `update-${request.jobId}.tar.gz`) : undefined);
|
||||
const dataBackupArchive = arg("--data-backup") ?? (request ? path.join(path.dirname(config.dataDir), "tallynote-backups", `data-${request.jobId}.tar.gz`) : undefined);
|
||||
const allowedHosts = process.argv.flatMap((value, index) => value === "--allow-host" && process.argv[index + 1] ? [process.argv[index + 1]!] : []);
|
||||
prepareDataDirectories(config);
|
||||
if (request) await ensurePrivilegedWorkspace(stagingDir);
|
||||
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
|
||||
const release = acquireInstanceLock(config);
|
||||
const database = openDatabase(config);
|
||||
try {
|
||||
const result = await runUpdate({
|
||||
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
|
||||
...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}),
|
||||
...(effectiveVersion ? { version: effectiveVersion } : {}),
|
||||
...((request ? undefined : arg("--sha256")) ? { expectedSha256: arg("--sha256") } : {}),
|
||||
currentDir,
|
||||
stagingDir,
|
||||
...(request ? { currentLink: request.currentLink, releasesDir: request.releasesDir } : {}),
|
||||
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
|
||||
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive, dataBackupSource: config.dataDir } : {}),
|
||||
...((arg("--backup-dir")) ? { backupDir: arg("--backup-dir") } : {}),
|
||||
allowedHosts: allowedHosts.length ? allowedHosts : config.updateAllowedHosts,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
currentVersion: config.appVersion,
|
||||
...(deferCompletion ? { deferCompletion: true } : {}),
|
||||
...(request ? { jobId: request.jobId } : {}),
|
||||
publicKey: config.updatePublicKey,
|
||||
requireSignature: request ? true : config.updateRequireSignature,
|
||||
sqlite: database.sqlite,
|
||||
});
|
||||
console.log(`更新完成:${result.version}`);
|
||||
} finally {
|
||||
database.sqlite.close();
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main().catch((error) => {
|
||||
console.error(safeErrorMessage(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user