Merge pull request #1694 from pikasTech/feat/g14-wsl-hwpod-node
feat: 增加 G14 Windows HWPOD 节点受控部署
This commit is contained in:
@@ -0,0 +1,712 @@
|
||||
// 规格:PJ2026-010104 draft-2026-07-10-g14-wsl-python-hwpod-node。
|
||||
// 职责:通过 YAML-first 管理 trans 可达的 Windows Python HWPOD 节点部署和状态。
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import { repoRoot, rootPath } from "./config";
|
||||
import { runCommand, type CommandResult } from "./command";
|
||||
|
||||
type HwpodNodeAction = "plan" | "status" | "apply";
|
||||
type HwpodNodeComponent = "all" | "cloud-auth" | "desktop";
|
||||
|
||||
interface HwpodNodeOptions {
|
||||
action: HwpodNodeAction;
|
||||
component: HwpodNodeComponent;
|
||||
node: string;
|
||||
lane: string;
|
||||
confirm: boolean;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
interface SecretSpec {
|
||||
sourceRef: string;
|
||||
sourceKey: string;
|
||||
generateIfMissing: boolean;
|
||||
secretName: string;
|
||||
targetKey: string;
|
||||
}
|
||||
|
||||
interface SourceSpec {
|
||||
sourceId: string;
|
||||
projectId: string;
|
||||
hwpodId: string;
|
||||
nodeId: string;
|
||||
workspaceRootRef: string;
|
||||
mdtodoRootRef: string;
|
||||
capabilities: Record<string, boolean>;
|
||||
}
|
||||
|
||||
interface HwpodNodeSpec {
|
||||
configPath: string;
|
||||
node: string;
|
||||
lane: string;
|
||||
providerId: string;
|
||||
windowsRoute: string;
|
||||
nodeId: string;
|
||||
hwpodId: string;
|
||||
interactiveUser: string;
|
||||
publicOrigin: string;
|
||||
nodeOpsUrl: string;
|
||||
cloudKubeRoute: string;
|
||||
cloudNamespace: string;
|
||||
cloudSecret: SecretSpec;
|
||||
opsAuth: { sourceRef: string; sourceKey: string };
|
||||
python: {
|
||||
probeLauncherPath: string;
|
||||
guiLauncherPath: string;
|
||||
launcherArgs: string[];
|
||||
expectedVersionPrefix: string;
|
||||
};
|
||||
runtime: {
|
||||
root: string;
|
||||
scriptPath: string;
|
||||
configPath: string;
|
||||
credentialPath: string;
|
||||
logPath: string;
|
||||
};
|
||||
artifact: { metadataUrl: string; channel: string; platform: string };
|
||||
workspace: { allowedRoots: string[]; defaultRoot: string };
|
||||
desktopConfig: {
|
||||
serverUrl: string;
|
||||
autoConnect: boolean;
|
||||
requireCredential: boolean;
|
||||
updateEnabled: boolean;
|
||||
updateAutoApplyWhenIdle: boolean;
|
||||
};
|
||||
startup: { mode: string; runKeyName: string; initialLaunch: string; processPattern: string };
|
||||
sourceRef: string;
|
||||
source: SourceSpec;
|
||||
}
|
||||
|
||||
interface ArtifactBundle {
|
||||
latestVersion: string;
|
||||
downloadUrl: string;
|
||||
sha256: string;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
const CONFIG_PATH = "config/hwlab-hwpod-nodes.yaml";
|
||||
const SECRET_ROOT = rootPath(".state", "secrets");
|
||||
|
||||
export function hwlabHwpodNodeHelp(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "hwlab nodes hwpod-node",
|
||||
description: "通过 YAML 和 trans 部署、查询 Windows 原生 Python HWPOD 节点。",
|
||||
configPath: CONFIG_PATH,
|
||||
actions: {
|
||||
plan: "校验 YAML、SecretRef、发布文件元数据和 Windows 路由,不修改运行面。",
|
||||
status: "查询 Windows 文件、解释器、交互会话、启动项、进程和云端节点操作状态。",
|
||||
apply: "显式确认后同步云端认证 Secret 或部署 Windows Python 图形节点。",
|
||||
},
|
||||
components: {
|
||||
"cloud-auth": "生成缺失的 YAML 声明凭据,并同步 cloud-api 使用的 Kubernetes Secret。",
|
||||
desktop: "校验发布文件 SHA 后部署桌面节点、配置、凭据和 HKCU Run 启动项。",
|
||||
all: "按 cloud-auth、desktop 顺序执行。",
|
||||
},
|
||||
examples: [
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-node plan --node G14-WSL --lane v03",
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-node status --node G14-WSL --lane v03",
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-node apply --node G14-WSL --lane v03 --component cloud-auth --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-node apply --node G14-WSL --lane v03 --component desktop --confirm",
|
||||
],
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runHwlabHwpodNodeCommand(args: string[]): Promise<Record<string, unknown>> {
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return hwlabHwpodNodeHelp();
|
||||
const options = parseOptions(args);
|
||||
const spec = readSpec(options.node, options.lane);
|
||||
const artifact = await readArtifact(spec, false);
|
||||
const secretBefore = readSecretMaterial(spec.cloudSecret);
|
||||
const remoteBefore = remoteStatus(spec);
|
||||
const graph = configGraph(spec, secretBefore, artifact, remoteBefore);
|
||||
if (options.action === "plan") return resultEnvelope(options, spec, graph, null, null, null);
|
||||
const cloudBefore = await cloudProbe(spec);
|
||||
if (options.action === "status") return resultEnvelope(options, spec, graph, remoteBefore.payload, cloudBefore, null);
|
||||
if (options.dryRun) return resultEnvelope(options, spec, graph, remoteBefore.payload, cloudBefore, { status: "dry-run", component: options.component });
|
||||
if (!options.confirm) throw new Error("hwpod-node apply 必须显式使用 --confirm 或 --dry-run");
|
||||
let cloudApply: Record<string, unknown> | null = null;
|
||||
let desktopApply: Record<string, unknown> | null = null;
|
||||
if (options.component === "all" || options.component === "cloud-auth") cloudApply = applyCloudAuth(spec);
|
||||
if (options.component === "all" || options.component === "desktop") {
|
||||
const secret = readSecretMaterial(spec.cloudSecret);
|
||||
if (!secret.present || secret.value === null) throw new Error(`${spec.cloudSecret.sourceRef}.${spec.cloudSecret.sourceKey} 缺失;先应用 cloud-auth`);
|
||||
const fullArtifact = await readArtifact(spec, true);
|
||||
desktopApply = applyDesktop(spec, fullArtifact, secret.value);
|
||||
}
|
||||
await delay(2500);
|
||||
const remoteAfter = remoteStatus(spec);
|
||||
const cloudAfter = await cloudProbe(spec);
|
||||
const applyResult = { status: "applied", component: options.component, cloudAuth: cloudApply, desktop: desktopApply, valuesRedacted: true };
|
||||
return resultEnvelope(options, spec, configGraph(spec, readSecretMaterial(spec.cloudSecret), artifact, remoteAfter), remoteAfter.payload, cloudAfter, applyResult);
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): HwpodNodeOptions {
|
||||
const action = args[0];
|
||||
if (action !== "plan" && action !== "status" && action !== "apply") {
|
||||
throw new Error("hwpod-node 用法:plan|status|apply --node NODE --lane LANE [--component all|cloud-auth|desktop] [--confirm|--dry-run]");
|
||||
}
|
||||
const knownValue = new Set(["--node", "--lane", "--component"]);
|
||||
const knownFlag = new Set(["--confirm", "--dry-run", "--full"]);
|
||||
for (let index = 1; index < args.length; index += 1) {
|
||||
const item = args[index];
|
||||
if (knownValue.has(item)) {
|
||||
index += 1;
|
||||
if (args[index] === undefined) throw new Error(`${item} 缺少值`);
|
||||
continue;
|
||||
}
|
||||
if (!knownFlag.has(item)) throw new Error(`hwpod-node 不支持参数 ${item}`);
|
||||
}
|
||||
const node = option(args, "--node");
|
||||
const lane = option(args, "--lane");
|
||||
const componentRaw = option(args, "--component", "all");
|
||||
if (componentRaw !== "all" && componentRaw !== "cloud-auth" && componentRaw !== "desktop") throw new Error(`--component 不支持 ${componentRaw}`);
|
||||
const confirm = args.includes("--confirm");
|
||||
const dryRun = args.includes("--dry-run");
|
||||
if (confirm && dryRun) throw new Error("--confirm 与 --dry-run 不能同时使用");
|
||||
if (action !== "apply" && (confirm || dryRun)) throw new Error(`${action} 是只读动作,不接受 --confirm 或 --dry-run`);
|
||||
return { action, component: componentRaw, node, lane, confirm, dryRun };
|
||||
}
|
||||
|
||||
function option(args: string[], name: string, fallback?: string): string {
|
||||
const index = args.indexOf(name);
|
||||
if (index < 0) {
|
||||
if (fallback !== undefined) return fallback;
|
||||
throw new Error(`缺少 ${name}`);
|
||||
}
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`${name} 缺少值`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function readSpec(node: string, lane: string): HwpodNodeSpec {
|
||||
const config = yamlFile(CONFIG_PATH);
|
||||
const cloud = record(config.cloud, `${CONFIG_PATH}#cloud`);
|
||||
const targets = record(config.targets, `${CONFIG_PATH}#targets`);
|
||||
const target = record(targets[node], `${CONFIG_PATH}#targets.${node}`);
|
||||
if (target.enabled !== true) throw new Error(`${CONFIG_PATH}#targets.${node}.enabled 必须为 true`);
|
||||
const targetLane = text(target.lane, `targets.${node}.lane`);
|
||||
if (targetLane !== lane) throw new Error(`目标 ${node} 声明 lane=${targetLane},不是 ${lane}`);
|
||||
const python = record(target.python, `targets.${node}.python`);
|
||||
const runtime = record(target.runtime, `targets.${node}.runtime`);
|
||||
const artifact = record(target.artifact, `targets.${node}.artifact`);
|
||||
const workspace = record(target.workspace, `targets.${node}.workspace`);
|
||||
const desktopConfig = record(target.desktopConfig, `targets.${node}.desktopConfig`);
|
||||
const startup = record(target.startup, `targets.${node}.startup`);
|
||||
const nodeAuth = record(cloud.nodeAuth, "cloud.nodeAuth");
|
||||
const opsAuth = record(cloud.opsAuth, "cloud.opsAuth");
|
||||
const sourceRef = text(target.projectManagementSourceRef, `targets.${node}.projectManagementSourceRef`);
|
||||
const source = readSource(sourceRef);
|
||||
const allowedRoots = texts(workspace.allowedRoots, `targets.${node}.workspace.allowedRoots`);
|
||||
if (allowedRoots.length === 0) throw new Error(`targets.${node}.workspace.allowedRoots 不能为空`);
|
||||
const spec: HwpodNodeSpec = {
|
||||
configPath: CONFIG_PATH,
|
||||
node,
|
||||
lane,
|
||||
providerId: text(target.providerId, `targets.${node}.providerId`),
|
||||
windowsRoute: text(target.windowsRoute, `targets.${node}.windowsRoute`),
|
||||
nodeId: text(target.nodeId, `targets.${node}.nodeId`),
|
||||
hwpodId: text(target.hwpodId, `targets.${node}.hwpodId`),
|
||||
interactiveUser: text(target.interactiveUser, `targets.${node}.interactiveUser`),
|
||||
publicOrigin: text(cloud.publicOrigin, "cloud.publicOrigin"),
|
||||
nodeOpsUrl: text(cloud.nodeOpsUrl, "cloud.nodeOpsUrl"),
|
||||
cloudKubeRoute: text(cloud.kubeRoute, "cloud.kubeRoute"),
|
||||
cloudNamespace: text(cloud.namespace, "cloud.namespace"),
|
||||
cloudSecret: {
|
||||
sourceRef: text(nodeAuth.sourceRef, "cloud.nodeAuth.sourceRef"),
|
||||
sourceKey: text(nodeAuth.sourceKey, "cloud.nodeAuth.sourceKey"),
|
||||
generateIfMissing: bool(nodeAuth.generateIfMissing, "cloud.nodeAuth.generateIfMissing"),
|
||||
secretName: text(nodeAuth.secretName, "cloud.nodeAuth.secretName"),
|
||||
targetKey: text(nodeAuth.targetKey, "cloud.nodeAuth.targetKey"),
|
||||
},
|
||||
opsAuth: { sourceRef: text(opsAuth.sourceRef, "cloud.opsAuth.sourceRef"), sourceKey: text(opsAuth.sourceKey, "cloud.opsAuth.sourceKey") },
|
||||
python: {
|
||||
probeLauncherPath: text(python.probeLauncherPath, `targets.${node}.python.probeLauncherPath`),
|
||||
guiLauncherPath: text(python.guiLauncherPath, `targets.${node}.python.guiLauncherPath`),
|
||||
launcherArgs: texts(python.launcherArgs, `targets.${node}.python.launcherArgs`),
|
||||
expectedVersionPrefix: text(python.expectedVersionPrefix, `targets.${node}.python.expectedVersionPrefix`),
|
||||
},
|
||||
runtime: {
|
||||
root: text(runtime.root, `targets.${node}.runtime.root`),
|
||||
scriptPath: text(runtime.scriptPath, `targets.${node}.runtime.scriptPath`),
|
||||
configPath: text(runtime.configPath, `targets.${node}.runtime.configPath`),
|
||||
credentialPath: text(runtime.credentialPath, `targets.${node}.runtime.credentialPath`),
|
||||
logPath: text(runtime.logPath, `targets.${node}.runtime.logPath`),
|
||||
},
|
||||
artifact: {
|
||||
metadataUrl: text(artifact.metadataUrl, `targets.${node}.artifact.metadataUrl`),
|
||||
channel: text(artifact.channel, `targets.${node}.artifact.channel`),
|
||||
platform: text(artifact.platform, `targets.${node}.artifact.platform`),
|
||||
},
|
||||
workspace: { allowedRoots, defaultRoot: text(workspace.defaultRoot, `targets.${node}.workspace.defaultRoot`) },
|
||||
desktopConfig: {
|
||||
serverUrl: text(desktopConfig.serverUrl, `targets.${node}.desktopConfig.serverUrl`),
|
||||
autoConnect: bool(desktopConfig.autoConnect, `targets.${node}.desktopConfig.autoConnect`),
|
||||
requireCredential: bool(desktopConfig.requireCredential, `targets.${node}.desktopConfig.requireCredential`),
|
||||
updateEnabled: bool(desktopConfig.updateEnabled, `targets.${node}.desktopConfig.updateEnabled`),
|
||||
updateAutoApplyWhenIdle: bool(desktopConfig.updateAutoApplyWhenIdle, `targets.${node}.desktopConfig.updateAutoApplyWhenIdle`),
|
||||
},
|
||||
startup: {
|
||||
mode: text(startup.mode, `targets.${node}.startup.mode`),
|
||||
runKeyName: text(startup.runKeyName, `targets.${node}.startup.runKeyName`),
|
||||
initialLaunch: text(startup.initialLaunch, `targets.${node}.startup.initialLaunch`),
|
||||
processPattern: text(startup.processPattern, `targets.${node}.startup.processPattern`),
|
||||
},
|
||||
sourceRef,
|
||||
source,
|
||||
};
|
||||
validateCrossRefs(spec);
|
||||
return spec;
|
||||
}
|
||||
|
||||
function readSource(ref: string): SourceSpec {
|
||||
const parsed = configRef(ref);
|
||||
const doc = yamlFile(parsed.file);
|
||||
const value = pathValue(doc, parsed.path);
|
||||
const source = record(value, ref);
|
||||
const capabilitiesRaw = record(source.capabilities, `${ref}.capabilities`);
|
||||
return {
|
||||
sourceId: text(source.sourceId, `${ref}.sourceId`),
|
||||
projectId: text(source.projectId, `${ref}.projectId`),
|
||||
hwpodId: text(source.hwpodId, `${ref}.hwpodId`),
|
||||
nodeId: text(source.nodeId, `${ref}.nodeId`),
|
||||
workspaceRootRef: text(source.workspaceRootRef, `${ref}.workspaceRootRef`),
|
||||
mdtodoRootRef: text(source.mdtodoRootRef, `${ref}.mdtodoRootRef`),
|
||||
capabilities: Object.fromEntries(Object.entries(capabilitiesRaw).map(([key, value]) => [key, bool(value, `${ref}.capabilities.${key}`)])),
|
||||
};
|
||||
}
|
||||
|
||||
function validateCrossRefs(spec: HwpodNodeSpec): void {
|
||||
const conflicts: string[] = [];
|
||||
if (spec.source.nodeId !== spec.nodeId) conflicts.push(`source.nodeId=${spec.source.nodeId} != target.nodeId=${spec.nodeId}`);
|
||||
if (spec.source.hwpodId !== spec.hwpodId) conflicts.push(`source.hwpodId=${spec.source.hwpodId} != target.hwpodId=${spec.hwpodId}`);
|
||||
if (!spec.workspace.allowedRoots.includes(spec.workspace.defaultRoot)) conflicts.push("workspace.defaultRoot 不在 allowedRoots 中");
|
||||
if (spec.source.workspaceRootRef !== spec.workspace.defaultRoot) conflicts.push("source.workspaceRootRef 与 workspace.defaultRoot 不一致");
|
||||
if (spec.startup.mode !== "hkcu-run") conflicts.push(`startup.mode 仅支持 hkcu-run,实际为 ${spec.startup.mode}`);
|
||||
if (!spec.windowsRoute.endsWith(":win")) conflicts.push("windowsRoute 必须指向 :win plane");
|
||||
if (conflicts.length > 0) throw new Error(`HWPOD 节点配置冲突:${conflicts.join(";")}`);
|
||||
}
|
||||
|
||||
function configGraph(spec: HwpodNodeSpec, secret: ReturnType<typeof readSecretMaterial>, artifact: ArtifactBundle, remote: ReturnType<typeof remoteStatus>): Record<string, unknown> {
|
||||
return {
|
||||
ok: remote.reachable && artifact.sha256.length > 0 && (secret.present || spec.cloudSecret.generateIfMissing),
|
||||
configPath: spec.configPath,
|
||||
sourceRef: spec.sourceRef,
|
||||
target: {
|
||||
node: spec.node,
|
||||
lane: spec.lane,
|
||||
providerId: spec.providerId,
|
||||
windowsRoute: spec.windowsRoute,
|
||||
nodeId: spec.nodeId,
|
||||
hwpodId: spec.hwpodId,
|
||||
interactiveUser: spec.interactiveUser,
|
||||
workspaceRoots: spec.workspace.allowedRoots,
|
||||
mdtodoRootRef: spec.source.mdtodoRootRef,
|
||||
},
|
||||
cloudAuth: {
|
||||
sourceRef: spec.cloudSecret.sourceRef,
|
||||
sourceKey: spec.cloudSecret.sourceKey,
|
||||
sourcePresent: secret.present,
|
||||
fingerprint: secret.fingerprint,
|
||||
secretName: spec.cloudSecret.secretName,
|
||||
targetKey: spec.cloudSecret.targetKey,
|
||||
generateIfMissing: spec.cloudSecret.generateIfMissing,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
artifact: { latestVersion: artifact.latestVersion, downloadUrl: artifact.downloadUrl, sha256: artifact.sha256, bytes: artifact.content.length },
|
||||
route: { reachable: remote.reachable, exitCode: remote.result.exitCode, timedOut: remote.result.timedOut },
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resultEnvelope(
|
||||
options: HwpodNodeOptions,
|
||||
spec: HwpodNodeSpec,
|
||||
graph: Record<string, unknown>,
|
||||
remote: Record<string, unknown> | null,
|
||||
cloud: Record<string, unknown> | null,
|
||||
apply: Record<string, unknown> | null,
|
||||
): Record<string, unknown> {
|
||||
const remoteReady = remote?.installed === true && remote?.processReady === true && remote?.interactiveProcessReady === true;
|
||||
const cloudReady = cloud?.ok === true;
|
||||
const ok = options.action === "plan" ? graph.ok === true : options.action === "status" ? remoteReady && cloudReady : apply?.status === "applied" && (options.component === "cloud-auth" || (remoteReady && cloudReady));
|
||||
return {
|
||||
ok,
|
||||
command: `hwlab nodes hwpod-node ${options.action} --node ${spec.node} --lane ${spec.lane}${options.action === "apply" ? ` --component ${options.component}${options.confirm ? " --confirm" : " --dry-run"}` : ""}`,
|
||||
status: ok ? options.action === "apply" ? "applied" : "ready" : "blocked",
|
||||
graph,
|
||||
remote,
|
||||
cloud,
|
||||
apply,
|
||||
next: {
|
||||
plan: `bun scripts/cli.ts hwlab nodes hwpod-node plan --node ${spec.node} --lane ${spec.lane}`,
|
||||
status: `bun scripts/cli.ts hwlab nodes hwpod-node status --node ${spec.node} --lane ${spec.lane}`,
|
||||
cloudAuth: `bun scripts/cli.ts hwlab nodes hwpod-node apply --node ${spec.node} --lane ${spec.lane} --component cloud-auth --confirm`,
|
||||
desktop: `bun scripts/cli.ts hwlab nodes hwpod-node apply --node ${spec.node} --lane ${spec.lane} --component desktop --confirm`,
|
||||
},
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function readArtifact(spec: HwpodNodeSpec, includeContent: boolean): Promise<ArtifactBundle> {
|
||||
const metadataUrl = new URL(spec.artifact.metadataUrl);
|
||||
metadataUrl.searchParams.set("platform", spec.artifact.platform);
|
||||
metadataUrl.searchParams.set("channel", spec.artifact.channel);
|
||||
metadataUrl.searchParams.set("current", "0.0.0");
|
||||
const metadataResponse = await fetch(metadataUrl, { signal: AbortSignal.timeout(15000) });
|
||||
if (!metadataResponse.ok) throw new Error(`HWPOD 节点更新元数据返回 HTTP ${metadataResponse.status}`);
|
||||
const metadata = record(await metadataResponse.json(), "HWPOD 节点更新元数据");
|
||||
const downloadUrl = text(metadata.downloadUrl, "更新元数据.downloadUrl");
|
||||
const sha256 = text(metadata.sha256, "更新元数据.sha256").toLowerCase();
|
||||
const latestVersion = text(metadata.latestVersion, "更新元数据.latestVersion");
|
||||
const download = new URL(downloadUrl);
|
||||
if (download.hostname !== new URL(spec.publicOrigin).hostname || !download.pathname.endsWith(".py")) throw new Error("更新元数据下载地址不属于 YAML 声明的公共入口或不是 .py 文件");
|
||||
if (!includeContent) return { latestVersion, downloadUrl, sha256, content: Buffer.alloc(0) };
|
||||
const response = await fetch(download, { signal: AbortSignal.timeout(30000) });
|
||||
if (!response.ok) throw new Error(`HWPOD 节点文件下载返回 HTTP ${response.status}`);
|
||||
const content = Buffer.from(await response.arrayBuffer());
|
||||
const actual = createHash("sha256").update(content).digest("hex");
|
||||
if (actual !== sha256) throw new Error(`HWPOD 节点文件 SHA 不匹配:expected=${sha256} actual=${actual}`);
|
||||
return { latestVersion, downloadUrl, sha256, content };
|
||||
}
|
||||
|
||||
function readSecretMaterial(spec: { sourceRef: string; sourceKey: string }): { present: boolean; value: string | null; fingerprint: string | null; sourcePath: string } {
|
||||
const sourcePath = secretPath(spec.sourceRef);
|
||||
if (!existsSync(sourcePath)) return { present: false, value: null, fingerprint: null, sourcePath };
|
||||
const values = parseEnv(readFileSync(sourcePath, "utf8"));
|
||||
const value = values[spec.sourceKey] ?? null;
|
||||
return { present: value !== null && value.length > 0, value, fingerprint: value ? sha256(value) : null, sourcePath };
|
||||
}
|
||||
|
||||
function ensureSecretMaterial(spec: SecretSpec): { value: string; fingerprint: string; generated: boolean; sourcePath: string } {
|
||||
const current = readSecretMaterial(spec);
|
||||
if (current.present && current.value) return { value: current.value, fingerprint: current.fingerprint ?? sha256(current.value), generated: false, sourcePath: current.sourcePath };
|
||||
if (!spec.generateIfMissing) throw new Error(`${spec.sourceRef}.${spec.sourceKey} 缺失且 generateIfMissing=false`);
|
||||
const value = randomBytes(32).toString("base64url");
|
||||
const sourcePath = secretPath(spec.sourceRef);
|
||||
mkdirSync(dirname(sourcePath), { recursive: true });
|
||||
const existing = existsSync(sourcePath) ? parseEnv(readFileSync(sourcePath, "utf8")) : {};
|
||||
existing[spec.sourceKey] = value;
|
||||
writeFileSync(sourcePath, `${Object.entries(existing).map(([key, item]) => `${key}=${item}`).join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(sourcePath, 0o600);
|
||||
return { value, fingerprint: sha256(value), generated: true, sourcePath };
|
||||
}
|
||||
|
||||
function applyCloudAuth(spec: HwpodNodeSpec): Record<string, unknown> {
|
||||
const material = ensureSecretMaterial(spec.cloudSecret);
|
||||
const manifest = JSON.stringify({
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: { name: spec.cloudSecret.secretName, namespace: spec.cloudNamespace, labels: { "app.kubernetes.io/managed-by": "unidesk", "hwlab.pikastech.local/config": "hwpod-node-auth" } },
|
||||
type: "Opaque",
|
||||
data: { [spec.cloudSecret.targetKey]: Buffer.from(material.value, "utf8").toString("base64") },
|
||||
});
|
||||
const result = runCommand(["trans", spec.cloudKubeRoute, "kubectl", "-n", spec.cloudNamespace, "apply", "-f", "-"], repoRoot, { input: manifest, timeoutMs: 55000 });
|
||||
if (!commandSucceeded(result)) throw new Error(`同步 HWPOD 节点认证 Secret 失败:${compactFailure(result)}`);
|
||||
return {
|
||||
ok: true,
|
||||
status: "applied",
|
||||
generated: material.generated,
|
||||
sourceRef: spec.cloudSecret.sourceRef,
|
||||
sourceKey: spec.cloudSecret.sourceKey,
|
||||
fingerprint: material.fingerprint,
|
||||
secretName: spec.cloudSecret.secretName,
|
||||
targetKey: spec.cloudSecret.targetKey,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function applyDesktop(spec: HwpodNodeSpec, artifact: ArtifactBundle, credential: string): Record<string, unknown> {
|
||||
const config = {
|
||||
schemaVersion: 1,
|
||||
serverUrl: spec.desktopConfig.serverUrl,
|
||||
nodeId: spec.nodeId,
|
||||
autoConnect: spec.desktopConfig.autoConnect,
|
||||
requireCredential: spec.desktopConfig.requireCredential,
|
||||
credentialFile: spec.runtime.credentialPath,
|
||||
allowedWorkspaceRoots: spec.workspace.allowedRoots,
|
||||
defaultWorkspaceRoot: spec.workspace.defaultRoot,
|
||||
update: {
|
||||
enabled: spec.desktopConfig.updateEnabled,
|
||||
autoApplyWhenIdle: spec.desktopConfig.updateAutoApplyWhenIdle,
|
||||
channel: spec.artifact.channel,
|
||||
metadataUrl: spec.artifact.metadataUrl,
|
||||
downloadHostAllowlist: [new URL(spec.publicOrigin).hostname],
|
||||
},
|
||||
};
|
||||
const payload = {
|
||||
interactiveUser: spec.interactiveUser,
|
||||
runtimeRoot: spec.runtime.root,
|
||||
scriptPath: spec.runtime.scriptPath,
|
||||
configPath: spec.runtime.configPath,
|
||||
credentialPath: spec.runtime.credentialPath,
|
||||
probeLauncherPath: spec.python.probeLauncherPath,
|
||||
guiLauncherPath: spec.python.guiLauncherPath,
|
||||
launcherArgs: spec.python.launcherArgs,
|
||||
expectedVersionPrefix: spec.python.expectedVersionPrefix,
|
||||
runKeyName: spec.startup.runKeyName,
|
||||
artifactBase64: artifact.content.toString("base64"),
|
||||
artifactSha256: artifact.sha256,
|
||||
configBase64: Buffer.from(`${JSON.stringify(config, null, 2)}\n`, "utf8").toString("base64"),
|
||||
configSha256: sha256Hex(`${JSON.stringify(config, null, 2)}\n`),
|
||||
credentialBase64: Buffer.from(`${credential}\n`, "utf8").toString("base64"),
|
||||
};
|
||||
const result = runPowerShell(spec.windowsRoute, desktopApplyScript(payload), 55000);
|
||||
if (!commandSucceeded(result)) throw new Error(`部署 Windows HWPOD 节点失败:${compactFailure(result)}`);
|
||||
const parsed = parseJsonRecord(result.stdout);
|
||||
if (parsed?.ok !== true) throw new Error(`Windows HWPOD 节点部署未就绪:${JSON.stringify(parsed ?? { stdout: result.stdout.slice(-600) })}`);
|
||||
return { ...parsed, artifactVersion: artifact.latestVersion, artifactSha256: artifact.sha256, valuesRedacted: true };
|
||||
}
|
||||
|
||||
function remoteStatus(spec: HwpodNodeSpec): { reachable: boolean; payload: Record<string, unknown> | null; result: CommandResult } {
|
||||
const payload = {
|
||||
interactiveUser: spec.interactiveUser,
|
||||
runtimeRoot: spec.runtime.root,
|
||||
scriptPath: spec.runtime.scriptPath,
|
||||
configPath: spec.runtime.configPath,
|
||||
credentialPath: spec.runtime.credentialPath,
|
||||
logPath: spec.runtime.logPath,
|
||||
probeLauncherPath: spec.python.probeLauncherPath,
|
||||
launcherArgs: spec.python.launcherArgs,
|
||||
expectedVersionPrefix: spec.python.expectedVersionPrefix,
|
||||
runKeyName: spec.startup.runKeyName,
|
||||
};
|
||||
const result = runPowerShell(spec.windowsRoute, desktopStatusScript(payload), 45000);
|
||||
const reachable = commandSucceeded(result);
|
||||
return { reachable, payload: reachable ? parseJsonRecord(result.stdout) : null, result };
|
||||
}
|
||||
|
||||
async function cloudProbe(spec: HwpodNodeSpec): Promise<Record<string, unknown>> {
|
||||
const auth = readSecretMaterial(spec.opsAuth);
|
||||
if (!auth.present || !auth.value) return { ok: false, status: "blocked", blocker: "ops-auth-source-missing", sourceRef: spec.opsAuth.sourceRef, valuesRedacted: true };
|
||||
const body = {
|
||||
contractVersion: "hwpod-node-ops-v1",
|
||||
planId: `hwpod_status_${Date.now()}`,
|
||||
hwpodId: spec.hwpodId,
|
||||
nodeId: spec.nodeId,
|
||||
intent: "node.version",
|
||||
ops: [{ opId: "op_status", op: "node.version", args: {} }],
|
||||
};
|
||||
try {
|
||||
const response = await fetch(spec.nodeOpsUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${auth.value}`, "x-source-service-id": "unidesk-hwpod-node-cli" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const value = record(await response.json(), "node-ops status response");
|
||||
const result = Array.isArray(value.results) ? record(value.results[0], "node-ops result") : {};
|
||||
return {
|
||||
ok: response.ok && value.ok === true && result.ok === true,
|
||||
httpStatus: response.status,
|
||||
status: value.status ?? null,
|
||||
nodeId: spec.nodeId,
|
||||
nodeVersion: record(result.output ?? {}, "node-ops output").version ?? null,
|
||||
blocker: result.blocker ?? value.blocker ?? value.error ?? null,
|
||||
requestId: record(value.requestMeta ?? {}, "requestMeta").requestId ?? null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, status: "blocked", blocker: error instanceof Error ? error.message : String(error), valuesRedacted: true };
|
||||
}
|
||||
}
|
||||
|
||||
function desktopStatusScript(payload: Record<string, unknown>): string {
|
||||
return powerShellPayload(payload, `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expectedUser = [string]$payload.interactiveUser
|
||||
$scriptPath = [string]$payload.scriptPath
|
||||
$configPath = [string]$payload.configPath
|
||||
$credentialPath = [string]$payload.credentialPath
|
||||
$logPath = [string]$payload.logPath
|
||||
$probe = [string]$payload.probeLauncherPath
|
||||
$launcherArgs = @($payload.launcherArgs | ForEach-Object { [string]$_ })
|
||||
$versionOut = @(& $probe @launcherArgs -c "import sys;print(sys.version);print(sys.executable)" 2>&1)
|
||||
$versionExit = $LASTEXITCODE
|
||||
$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($scriptPath) })
|
||||
$explorerSessions = @(Get-Process explorer -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SessionId -Unique)
|
||||
$interactive = @($processes | Where-Object { $explorerSessions -contains $_.SessionId })
|
||||
$runValue = (Get-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name ([string]$payload.runKeyName) -ErrorAction SilentlyContinue).([string]$payload.runKeyName)
|
||||
function HashOrNull([string]$path) { if (Test-Path $path) { return 'sha256:' + (Get-FileHash -Algorithm SHA256 $path).Hash.ToLowerInvariant() }; return $null }
|
||||
$result = [ordered]@{
|
||||
ok = $true
|
||||
status = 'observed'
|
||||
user = $env:USERNAME
|
||||
expectedUser = $expectedUser
|
||||
userMatches = [bool]($env:USERNAME -ieq $expectedUser)
|
||||
pythonReady = [bool]((Test-Path $probe) -and ($versionExit -eq 0) -and (($versionOut -join "\`n") -like "*$($payload.expectedVersionPrefix)*"))
|
||||
pythonSummary = (($versionOut | Select-Object -First 2) -join ' | ')
|
||||
installed = [bool]((Test-Path $scriptPath) -and (Test-Path $configPath) -and (Test-Path $credentialPath))
|
||||
scriptSha256 = HashOrNull $scriptPath
|
||||
configSha256 = HashOrNull $configPath
|
||||
credentialPresent = [bool](Test-Path $credentialPath)
|
||||
credentialFingerprint = HashOrNull $credentialPath
|
||||
runKeyReady = [bool](-not [string]::IsNullOrWhiteSpace([string]$runValue))
|
||||
processCount = [int]$processes.Count
|
||||
processReady = [bool]($processes.Count -eq 1)
|
||||
interactiveProcessCount = [int]$interactive.Count
|
||||
interactiveProcessReady = [bool]($interactive.Count -eq 1)
|
||||
processIds = @($processes | ForEach-Object { [int]$_.ProcessId })
|
||||
explorerSessions = @($explorerSessions)
|
||||
logPresent = [bool](Test-Path $logPath)
|
||||
valuesRedacted = $true
|
||||
}
|
||||
$result | ConvertTo-Json -Compress -Depth 6
|
||||
`);
|
||||
}
|
||||
|
||||
function desktopApplyScript(payload: Record<string, unknown>): string {
|
||||
return powerShellPayload(payload, `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:USERNAME -ine [string]$payload.interactiveUser) { throw "interactive user mismatch expected=$($payload.interactiveUser) actual=$env:USERNAME" }
|
||||
$root = [string]$payload.runtimeRoot
|
||||
$scriptPath = [string]$payload.scriptPath
|
||||
$configPath = [string]$payload.configPath
|
||||
$credentialPath = [string]$payload.credentialPath
|
||||
$probe = [string]$payload.probeLauncherPath
|
||||
$gui = [string]$payload.guiLauncherPath
|
||||
$launcherArgs = @($payload.launcherArgs | ForEach-Object { [string]$_ })
|
||||
if (-not (Test-Path $probe)) { throw "Python probe launcher missing: $probe" }
|
||||
if (-not (Test-Path $gui)) { throw "Python GUI launcher missing: $gui" }
|
||||
$versionOut = @(& $probe @launcherArgs -c "import sys;import tkinter;print(sys.version);print(sys.executable);print(tkinter.TkVersion)" 2>&1)
|
||||
if ($LASTEXITCODE -ne 0 -or (($versionOut -join "\`n") -notlike "*$($payload.expectedVersionPrefix)*")) { throw "Python version mismatch: $($versionOut -join ' | ')" }
|
||||
New-Item -ItemType Directory -Force -Path $root, (Split-Path -Parent $configPath), (Split-Path -Parent $credentialPath) | Out-Null
|
||||
$old = @(Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($scriptPath) })
|
||||
foreach ($proc in $old) { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep -Milliseconds 500
|
||||
[IO.File]::WriteAllBytes($scriptPath, [Convert]::FromBase64String([string]$payload.artifactBase64))
|
||||
[IO.File]::WriteAllBytes($configPath, [Convert]::FromBase64String([string]$payload.configBase64))
|
||||
[IO.File]::WriteAllBytes($credentialPath, [Convert]::FromBase64String([string]$payload.credentialBase64))
|
||||
$artifactActual = (Get-FileHash -Algorithm SHA256 $scriptPath).Hash.ToLowerInvariant()
|
||||
$configActual = (Get-FileHash -Algorithm SHA256 $configPath).Hash.ToLowerInvariant()
|
||||
if ($artifactActual -ne [string]$payload.artifactSha256) { throw "artifact SHA mismatch" }
|
||||
if ($configActual -ne [string]$payload.configSha256) { throw "config SHA mismatch" }
|
||||
$null = & icacls $credentialPath /inheritance:r /grant:r "$env:USERNAME\`:(R,W)" 2>&1
|
||||
$argText = (($launcherArgs | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }) -join ' ')
|
||||
$runCommand = '"' + $gui + '" ' + $argText + ' "' + $scriptPath + '"'
|
||||
$null = New-Item -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Force
|
||||
Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name ([string]$payload.runKeyName) -Value $runCommand
|
||||
Start-Process -FilePath $gui -ArgumentList @($launcherArgs + @($scriptPath)) -WorkingDirectory $root
|
||||
Start-Sleep -Seconds 3
|
||||
$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($scriptPath) })
|
||||
$explorerSessions = @(Get-Process explorer -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SessionId -Unique)
|
||||
$interactive = @($processes | Where-Object { $explorerSessions -contains $_.SessionId })
|
||||
$result = [ordered]@{
|
||||
ok = [bool](($processes.Count -eq 1) -and ($interactive.Count -eq 1))
|
||||
status = $(if (($processes.Count -eq 1) -and ($interactive.Count -eq 1)) { 'applied' } else { 'installed-not-interactive' })
|
||||
pythonSummary = (($versionOut | Select-Object -First 3) -join ' | ')
|
||||
scriptSha256 = 'sha256:' + $artifactActual
|
||||
configSha256 = 'sha256:' + $configActual
|
||||
credentialPresent = $true
|
||||
processCount = [int]$processes.Count
|
||||
interactiveProcessCount = [int]$interactive.Count
|
||||
processIds = @($processes | ForEach-Object { [int]$_.ProcessId })
|
||||
runKeyReady = $true
|
||||
stoppedProcessIds = @($old | ForEach-Object { [int]$_.ProcessId })
|
||||
valuesRedacted = $true
|
||||
}
|
||||
$result | ConvertTo-Json -Compress -Depth 6
|
||||
`);
|
||||
}
|
||||
|
||||
function powerShellPayload(payload: Record<string, unknown>, body: string): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
return `$payload = ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')) | ConvertFrom-Json)\n${body.trim()}\n`;
|
||||
}
|
||||
|
||||
function runPowerShell(route: string, script: string, timeoutMs: number): CommandResult {
|
||||
return runCommand(["trans", route, "ps"], repoRoot, { input: script, timeoutMs });
|
||||
}
|
||||
|
||||
function yamlFile(path: string): Record<string, unknown> {
|
||||
const absolute = rootPath(path);
|
||||
if (!existsSync(absolute)) throw new Error(`${path} 不存在`);
|
||||
return record(Bun.YAML.parse(readFileSync(absolute, "utf8")) as unknown, path);
|
||||
}
|
||||
|
||||
function configRef(ref: string): { file: string; path: string } {
|
||||
const parts = ref.split("#");
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error(`配置引用必须使用 path.yaml#object.path:${ref}`);
|
||||
if (isAbsolute(parts[0]) || parts[0].includes("..")) throw new Error(`配置引用文件必须是仓库内相对路径:${ref}`);
|
||||
return { file: parts[0], path: parts[1] };
|
||||
}
|
||||
|
||||
function pathValue(input: unknown, path: string): unknown {
|
||||
let current = input;
|
||||
for (const raw of path.replace(/\[(\d+)\]/gu, ".$1").split(".").filter(Boolean)) {
|
||||
if (Array.isArray(current)) current = current[Number(raw)];
|
||||
else if (current && typeof current === "object") current = (current as Record<string, unknown>)[raw];
|
||||
else return undefined;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, any> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} 必须是对象`);
|
||||
return value as Record<string, any>;
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} 必须是非空字符串`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function texts(value: unknown, path: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) throw new Error(`${path} 必须是非空字符串数组`);
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
|
||||
function bool(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`${path} 必须是布尔值`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function secretPath(sourceRef: string): string {
|
||||
if (isAbsolute(sourceRef) || sourceRef.includes("..")) throw new Error(`Secret sourceRef 必须是相对路径且不能包含 ..:${sourceRef}`);
|
||||
return join(SECRET_ROOT, sourceRef);
|
||||
}
|
||||
|
||||
function parseEnv(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const raw of content.split(/\r?\n/u)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const index = line.indexOf("=");
|
||||
if (index <= 0) continue;
|
||||
result[line.slice(0, index).trim()] = line.slice(index + 1).trim().replace(/^['"]|['"]$/gu, "");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJsonRecord(textValue: string): Record<string, unknown> | null {
|
||||
const lines = textValue.trim().split(/\r?\n/u).filter(Boolean);
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
return record(JSON.parse(lines[index]) as unknown, "远端 JSON");
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function compactFailure(result: CommandResult): string {
|
||||
const reason = result.timedOut ? "command-timed-out" : "command-failed";
|
||||
return `${reason}; exit=${result.exitCode ?? "-"}; stderr=${result.stderr.trim().slice(-500)}`;
|
||||
}
|
||||
|
||||
function sha256(value: string | Buffer): string {
|
||||
return `sha256:${sha256Hex(value)}`;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string | Buffer): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function commandSucceeded(result: CommandResult): boolean {
|
||||
return result.exitCode === 0 && !result.timedOut;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function hwlabNodeHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-registry-volume --node JD01 --lane v03 --dry-run",
|
||||
"bun scripts/cli.ts hwlab nodes git-mirror status --node <node> --lane <lane>",
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node <node> --lane <lane> --dry-run",
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-node plan --node G14-WSL --lane v03",
|
||||
"bun scripts/cli.ts hwlab nodes fake-model-provider plan --node D518 --lane v03 --provider fake-echo",
|
||||
"bun scripts/cli.ts hwlab nodes secret status --node <node> --lane <lane> --name <secret>",
|
||||
"bun scripts/cli.ts hwlab nodes test-accounts status --node <node> --lane <lane>",
|
||||
@@ -29,6 +30,7 @@ export function hwlabNodeHelp(): Record<string, unknown> {
|
||||
"control-plane": "YAML-first node-local CI/CD, git-mirror, source-workspace sync, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention operations.",
|
||||
"git-mirror": "Inspect or operate the selected node/lane source mirror.",
|
||||
"hwpod-preinstall": "Render YAML-first HWPOD preinstall configRefs, runtime mount targets, PM MDTODO source, and gateway profile status.",
|
||||
"hwpod-node": "通过 YAML 和 trans 部署、查询 Windows 原生 Python HWPOD 节点。",
|
||||
"fake-model-provider": "Materialize and operate YAML-declared fake Responses model providers for HWLAB/AgentRun sentinel checks.",
|
||||
secret: "Inspect and sync YAML-declared runtime Secrets without printing secret values.",
|
||||
"test-accounts": "Prepare YAML-declared HWLAB admin/test account API keys with redacted sourceRef/fingerprint output.",
|
||||
|
||||
@@ -554,6 +554,10 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom
|
||||
const { runHwlabNodeHwpodPreinstallCommand } = await import("../hwlab-node-hwpod-preinstall");
|
||||
return runHwlabNodeHwpodPreinstallCommand(args.slice(1));
|
||||
}
|
||||
if (domain === "hwpod-node") {
|
||||
const { runHwlabHwpodNodeCommand } = await import("../hwlab-hwpod-node");
|
||||
return runHwlabHwpodNodeCommand(args.slice(1));
|
||||
}
|
||||
if (domain === "fake-model-provider") {
|
||||
return runHwlabFakeModelProviderCommand(_config, args.slice(1));
|
||||
}
|
||||
@@ -569,7 +573,7 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom
|
||||
return runNodeDelegatedDomain(_config, domain, args.slice(1));
|
||||
}
|
||||
if (domain !== "secret") {
|
||||
return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes hwpod-preinstall, hwlab nodes fake-model-provider, hwlab nodes observability, hwlab nodes secret, hwlab nodes test-accounts. web-probe moved to top-level: bun scripts/cli.ts web-probe --help" };
|
||||
return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes hwpod-preinstall, hwlab nodes hwpod-node, hwlab nodes fake-model-provider, hwlab nodes observability, hwlab nodes secret, hwlab nodes test-accounts. web-probe moved to top-level: bun scripts/cli.ts web-probe --help" };
|
||||
}
|
||||
const options = parseSecretOptions(args.slice(1));
|
||||
return runNodeSecret(options);
|
||||
|
||||
Reference in New Issue
Block a user