release: 1.3.4
This commit is contained in:
+4
-22
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
||||
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";
|
||||
@@ -10,7 +10,6 @@ import { writeAudit } from "../audit.js";
|
||||
import {
|
||||
atomicSwitchDirectory,
|
||||
atomicSwitchRelease,
|
||||
applicationUpdateRuntimeHash,
|
||||
compareSemver,
|
||||
createSafeArchive,
|
||||
detectPlatform,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
isNewerVersion,
|
||||
normalizeReleasePermissions,
|
||||
parseSemver,
|
||||
runtimeHashFromLockfile,
|
||||
selectReleaseAsset,
|
||||
sanitizeAssetName,
|
||||
validateHttpsUrl,
|
||||
@@ -223,16 +221,9 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
|
||||
if (options.metadataUrl) {
|
||||
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
|
||||
const release = await fetchReleaseMetadata(metadataUrl, options);
|
||||
let runtimeHash: string | undefined;
|
||||
try {
|
||||
runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml")));
|
||||
} catch {
|
||||
// Fall back to the full archive when the current installation predates
|
||||
// runtime fingerprints or is missing deployment provenance.
|
||||
}
|
||||
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, runtimeHash);
|
||||
: selectReleaseAsset(release, platform);
|
||||
if (!asset) throw new Error("没有匹配当前平台的更新文件");
|
||||
const integrity = await attachSidecarHash(release, asset, {
|
||||
allowedHosts: options.allowedHosts ?? [],
|
||||
@@ -339,17 +330,8 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
|
||||
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 });
|
||||
if (applicationUpdateRuntimeHash(resolved.asset.name)) {
|
||||
const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); });
|
||||
const currentInfo = await lstat(currentRelease).catch(() => null);
|
||||
if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
|
||||
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
|
||||
const source = path.join(currentRelease, entry);
|
||||
const sourceInfo = await lstat(source).catch(() => null);
|
||||
if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新");
|
||||
await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false });
|
||||
}
|
||||
}
|
||||
const embeddedRuntime = await lstat(path.join(stagedDir, "runtime")).catch(() => null);
|
||||
if (embeddedRuntime) throw new Error("发布包不应包含 Node.js runtime");
|
||||
await normalizeReleasePermissions(stagedDir);
|
||||
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
|
||||
|
||||
+11
-30
@@ -1,5 +1,5 @@
|
||||
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
isNewerVersion,
|
||||
normalizeReleasePermissions,
|
||||
parseSemver,
|
||||
runtimeHashFromLockfile,
|
||||
sanitizeAssetName,
|
||||
selectReleaseAsset,
|
||||
validateHttpsUrl,
|
||||
@@ -211,14 +210,9 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
||||
} catch {
|
||||
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
|
||||
}
|
||||
let runtimeHash: string | undefined;
|
||||
try {
|
||||
runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml")));
|
||||
} catch {
|
||||
// Legacy or source installations may not contain the lockfile. They stay
|
||||
// on the full release asset instead of risking an incompatible runtime.
|
||||
}
|
||||
// Force choosing the full standalone archive so users always get a real, visible streaming download
|
||||
// Always select the complete production archive. The host Node.js runtime
|
||||
// is reused, while the application package remains self-contained and
|
||||
// identical for first installs and in-place updates.
|
||||
let asset = selectReleaseAsset(metadata, platform, undefined);
|
||||
let signatureVerified = false;
|
||||
if (asset) {
|
||||
@@ -688,8 +682,9 @@ export function cancelUpdateJob(
|
||||
* The root runner only needs to apply (stop/backup/switch/restart) afterwards.
|
||||
*
|
||||
* This function runs asynchronously outside the request lifecycle. It updates
|
||||
* the job row in the database so the frontend can poll progress. On success it
|
||||
* writes an apply request file so the systemd path unit triggers the runner.
|
||||
* the job row in the database so the frontend can poll progress. A successful
|
||||
* download only becomes staged; applying it is a separate, explicit action
|
||||
* that the administrator submits after reviewing the verification result.
|
||||
*/
|
||||
export async function downloadAndStageUpdate(
|
||||
database: Database.Database,
|
||||
@@ -756,8 +751,10 @@ export async function downloadAndStageUpdate(
|
||||
await extractSafeArchive(archivePath, payloadDir);
|
||||
await normalizeReleasePermissions(payloadDir);
|
||||
|
||||
const embeddedRuntime = await lstat(path.join(payloadDir, "runtime")).catch(() => null);
|
||||
if (embeddedRuntime) throw new Error("发布包不应包含 Node.js runtime");
|
||||
|
||||
// Verify payload contains dist directory
|
||||
const { lstat } = await import("node:fs/promises");
|
||||
const payloadInfo = await lstat(path.join(payloadDir, "dist")).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) {
|
||||
throw new Error("发布包缺少 dist 目录");
|
||||
@@ -765,26 +762,10 @@ export async function downloadAndStageUpdate(
|
||||
|
||||
// Transition to staged
|
||||
const staged = database.prepare(
|
||||
"UPDATE update_jobs SET status='staged', operation='apply', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')",
|
||||
"UPDATE update_jobs SET status='staged', operation='download', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')",
|
||||
).run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId);
|
||||
if (staged.changes !== 1) return; // cancelled
|
||||
|
||||
// Write apply request file for the root runner
|
||||
await writeUpdateRequest(config, {
|
||||
jobId,
|
||||
operation: "apply",
|
||||
version,
|
||||
metadataUrl,
|
||||
assetUrl,
|
||||
assetName,
|
||||
expectedSha256,
|
||||
requestedAt: Date.now(),
|
||||
currentLink: config.currentLink,
|
||||
releasesDir: config.releasesDir,
|
||||
dataDir: config.dataDir,
|
||||
stagedPath: workspace,
|
||||
});
|
||||
|
||||
writeAudit(database, {
|
||||
requestId: `download:${jobId}`,
|
||||
actorAdminId: adminId,
|
||||
|
||||
+1
-1
@@ -1000,7 +1000,7 @@ export async function normalizeReleasePermissions(rootPath: string): Promise<voi
|
||||
await walk(target);
|
||||
} else if (entry.isFile()) {
|
||||
const relative = path.relative(root, target).split(path.sep).join("/");
|
||||
const executable = relative.startsWith("bin/") || relative.startsWith("scripts/") || relative.startsWith("runtime/bin/");
|
||||
const executable = relative.startsWith("bin/") || relative.startsWith("scripts/");
|
||||
await chmod(target, executable ? 0o755 : 0o644);
|
||||
} else {
|
||||
throw new Error("发布包包含不受支持的文件类型");
|
||||
|
||||
Reference in New Issue
Block a user