117 lines
5.2 KiB
TypeScript
117 lines
5.2 KiB
TypeScript
/*
|
|
* SPEC: PJ2026-010103 HWPOD 服务;PJ2026-010405 云端控制台。
|
|
* 责任: 为 Cloud API 与独立 HWPOD API 提供同一 Python 节点 artifact 合同。
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { HWPOD_NODE_READINESS_DEFINITIONS } from "../cloud/hwpod-topology-read-model.ts";
|
|
|
|
type Env = Record<string, string | undefined>;
|
|
|
|
export function buildHwlabNodeUpdatePayload(url: URL, env: Env = process.env) {
|
|
const currentVersion = cleanText(url.searchParams.get("current") || url.searchParams.get("version"));
|
|
const channel = cleanText(url.searchParams.get("channel")) || "stable";
|
|
const platform = cleanText(url.searchParams.get("platform")) || "unknown";
|
|
const bundled = hwlabNodeBundledArtifact(env);
|
|
const latestVersion = cleanText(env.HWLAB_NODE_PY_LATEST_VERSION) || bundled.version || "0.1.0";
|
|
const releaseNotesUrl = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES_URL) || null;
|
|
const downloadUrl = hwlabNodeDownloadUrl(env);
|
|
const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || bundled.sha256 || null;
|
|
const fileName = nodeArtifactFileName(downloadUrl) || "hwlab-node.py";
|
|
const sizeBytes = positiveInteger(env.HWLAB_NODE_PY_FILE_SIZE_BYTES, bundled.sizeBytes);
|
|
const newer = currentVersion ? compareVersionStrings(latestVersion, currentVersion) > 0 : Boolean(latestVersion);
|
|
const updateAvailable = Boolean(downloadUrl && newer);
|
|
return {
|
|
ok: true,
|
|
contractVersion: "hwlab-node-update-v1",
|
|
serviceId: "hwlab-node",
|
|
route: "/v1/hwlab-node/update",
|
|
channel,
|
|
platform,
|
|
currentVersion: currentVersion || null,
|
|
latestVersion,
|
|
updateAvailable,
|
|
downloadUrl: updateAvailable ? downloadUrl : null,
|
|
sha256: updateAvailable ? sha256 : null,
|
|
artifact: { version: latestVersion, fileName, sizeBytes, sha256, downloadUrl },
|
|
releaseNotes: hwlabNodeReleaseNotes(env),
|
|
releaseNotesUrl,
|
|
installConditions: [
|
|
{ id: "windows-python", label: "Windows 交互用户的原生 Python", required: true },
|
|
{ id: "tkinter", label: "Python tkinter 图形能力", required: true },
|
|
{ id: "yaml-managed-config", label: "owning YAML 声明 nodeId、工作区与凭据引用", required: true },
|
|
{ id: "outbound-websocket", label: "可主动出站连接 Cloud API WebSocket", required: true }
|
|
],
|
|
readinessStages: HWPOD_NODE_READINESS_DEFINITIONS.map((stage) => ({ ...stage, nextAction: { ...stage.nextAction } })),
|
|
manualDefault: true,
|
|
autoApplyDefault: false,
|
|
checkIntervalSeconds: positiveInteger(env.HWLAB_NODE_PY_UPDATE_INTERVAL_SECONDS, 300),
|
|
observedAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
export function hwlabNodeBundledArtifact(env: Env = process.env) {
|
|
const bundlePath = cleanText(env.HWLAB_NODE_PY_BUNDLE_PATH) || path.resolve(process.cwd(), "tools/hwlab-node.py");
|
|
try {
|
|
const content = readFileSync(bundlePath, "utf8");
|
|
const version = cleanText(content.match(/APP_VERSION\s*=\s*["']([^"']+)["']/u)?.[1]);
|
|
const sha256 = createHash("sha256").update(content, "utf8").digest("hex");
|
|
return { path: bundlePath, content, version, sha256, sizeBytes: Buffer.byteLength(content, "utf8") };
|
|
} catch {
|
|
return { path: bundlePath, content: "", version: "", sha256: "", sizeBytes: 0 };
|
|
}
|
|
}
|
|
|
|
function hwlabNodeDownloadUrl(env: Env) {
|
|
const explicit = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_URL);
|
|
if (explicit) return explicit;
|
|
const downloadPath = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_PATH) || "/v1/hwlab-node/download/hwlab-node.py";
|
|
if (/^https?:\/\//iu.test(downloadPath)) return downloadPath;
|
|
const publicEndpoint = cleanText(env.HWPOD_PUBLIC_BASE_URL) || cleanText(env.HWLAB_PUBLIC_ENDPOINT);
|
|
if (!publicEndpoint) return downloadPath.startsWith("/") ? downloadPath : `/${downloadPath}`;
|
|
try {
|
|
return new URL(downloadPath, publicEndpoint.endsWith("/") ? publicEndpoint : `${publicEndpoint}/`).toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function nodeArtifactFileName(downloadUrl: string | null) {
|
|
try { return cleanText(path.posix.basename(new URL(downloadUrl ?? "").pathname)); }
|
|
catch { return ""; }
|
|
}
|
|
|
|
function hwlabNodeReleaseNotes(env: Env) {
|
|
const configured = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES);
|
|
if (configured) return configured.split(/\r?\n|\|/u).map((item) => item.trim()).filter(Boolean).slice(0, 12);
|
|
return ["单文件 Python/tkinter 节点,提供主动出站 WebSocket、有界诊断和 SHA-256 校验自更新。"];
|
|
}
|
|
|
|
function compareVersionStrings(left: string, right: string) {
|
|
const a = versionParts(left);
|
|
const b = versionParts(right);
|
|
for (let index = 0; index < Math.max(a.length, b.length, 3); index += 1) {
|
|
const delta = (a[index] ?? 0) - (b[index] ?? 0);
|
|
if (delta !== 0) return delta > 0 ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function versionParts(value: string) {
|
|
return value.trim().replace(/^v/iu, "").split(/[.+-]/u).slice(0, 4)
|
|
.map((part) => Number.parseInt(part, 10))
|
|
.map((part) => (Number.isFinite(part) && part >= 0 ? part : 0));
|
|
}
|
|
|
|
function positiveInteger(value: unknown, fallback: number) {
|
|
const parsed = Number(value);
|
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function cleanText(value: unknown) {
|
|
const text = String(value ?? "").trim();
|
|
return text || "";
|
|
}
|