From 050cba102be069ddd2276447c56560587fa22741 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 26 Jun 2026 02:19:27 +0000 Subject: [PATCH] feat: render 71freq hwpod preinstall config --- .../constart-71freq-d601-v03.yaml | 40 ++ .../constart-71freq-c.yaml | 113 ++++ config/hwlab-node-lanes.yaml | 6 + .../constart-71freq-mdtodo.yaml | 40 ++ scripts/cli.ts | 8 +- scripts/src/help.ts | 10 +- scripts/src/hwlab-node-help.ts | 2 + scripts/src/hwlab-node-hwpod-preinstall.ts | 612 ++++++++++++++++++ scripts/src/hwlab-node/entry.ts | 6 +- 9 files changed, 832 insertions(+), 5 deletions(-) create mode 100644 config/hwlab-gateway/constart-71freq-d601-v03.yaml create mode 100644 config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml create mode 100644 config/hwlab-project-management/constart-71freq-mdtodo.yaml create mode 100644 scripts/src/hwlab-node-hwpod-preinstall.ts diff --git a/config/hwlab-gateway/constart-71freq-d601-v03.yaml b/config/hwlab-gateway/constart-71freq-d601-v03.yaml new file mode 100644 index 00000000..1a9ebe54 --- /dev/null +++ b/config/hwlab-gateway/constart-71freq-d601-v03.yaml @@ -0,0 +1,40 @@ +version: 1 +kind: HwlabGatewayProfileConfig +metadata: + name: constart-71freq-d601-v03 + spec: PJ2026-01010305 + implementationRef: draft-2026-06-26-71freq-v03-hwpod-preinstall + +gateway: + profile: + id: constart-71freq-d601-v03 + node: D601 + lane: v03 + cloudUrl: https://hwlab.pikapython.com + apiUrl: https://hwlab.pikapython.com + gatewayId: gw-d601-constart-71freq + sessionId: gws_D601_71_FREQ + resourceId: constart-71freq-c + capabilityId: cap_constart_71freq_hwpod + hwpodId: constart-71freq-c + nodeId: node-d601-f103-v2 + nodeOps: + route: /v1/hwpod-node-ops + serviceUrl: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops + publicUrl: https://hwlab.pikapython.com/v1/hwpod-node-ops + websocketUrl: wss://hwlab.pikapython.com/v1/hwpod-node/ws + managedRun: + mode: windows-scheduled-task + runtimeRoot: "C:\\Users\\liang\\hwpod-node-runtime" + taskName: HWLAB-HWPOD-Node-WS + periodicTaskName: hwpod-node-ws-periodic + startCommand: "C:\\Users\\liang\\hwpod-node-runtime\\start-node-ws.cmd" + statusCommand: "C:\\Users\\liang\\hwpod-node-runtime\\status-node-ws.cmd" + processPattern: "tools\\hwpod-node.ts connect" + secretRefs: + - purpose: gatewayAuthToken + sourceRef: hwlab/d601-v03-gateway.env + sourceKey: HWLAB_GATEWAY_TOKEN + targetKey: HWLAB_GATEWAY_TOKEN + migratedFrom: + - "F:\\Work\\ConStart\\.device-pod\\.runtime\\D601-71-FREQ.json" diff --git a/config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml b/config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml new file mode 100644 index 00000000..bd3bf9ee --- /dev/null +++ b/config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml @@ -0,0 +1,113 @@ +version: 1 +kind: HwlabHwpodPreinstallConfig +metadata: + name: constart-71freq-c + spec: PJ2026-01010305 + implementationRef: draft-2026-06-26-71freq-v03-hwpod-preinstall + +hwpodPreinstall: + hwpodId: constart-71freq-c + sourceRef: + spec: config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml#hwpodPreinstall.specDocument + metadata: config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml#hwpodPreinstall.metadataSidecar + metadataRef: config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml#hwpodPreinstall.metadataSidecar + targetDevice: + board: ConStart 71-FREQ Controller + mcu: STM32H723ZGTx + flashBase: "0x08000000" + nodeBinding: + hwlabNode: D601 + lane: v03 + nodeId: node-d601-f103-v2 + nodeType: pc-host + workspaceRootRef: "F:\\Work\\ConStart" + projectRoot: projects/71-00075-11 + toolchain: + name: keil-mdk + keilProject: projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx + keilTarget: FREQ_Controller_FW + hexPath: projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.hex + mapPath: projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.map + keilCliPath: "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py" + uv4Path: "C:\\Keil_v5\\UV4\\UV4.exe" + debugProbe: + id: debug-probe + type: cmsis-dap + adapter: keil + probeUid: 3FD750C63E342E24 + probeName: MicroLink CMSIS-DAP + programBackend: keil-headless + autoBindUvoptx: true + uart: + id: uart/1 + scope: external + port: COM4 + baudRate: 921600 + encoding: utf8 + boardComm: + endpoints: + - id: hwpod-node-ops + kind: hwpod-node-ops + endpointRef: config/hwlab-gateway/constart-71freq-d601-v03.yaml#gateway.profile.nodeOps.publicUrl + ioProbe: + uart: + id: uart/1 + port: COM4 + baudrate: 921600 + runtimeMount: + namespace: hwlab-v03 + configMapName: hwlab-v03-hwpod-preinstalled-specs + specKey: constart-71freq-c.yaml + metadataKey: constart-71freq-c.meta.json + mountPath: /etc/hwlab/hwpod-specs + envKey: HWLAB_HWPOD_SPEC_REGISTRY_DIRS + rolloutTarget: + kind: Deployment + name: hwlab-cloud-api + container: hwlab-cloud-api + specDocument: + apiVersion: hwlab.dev/v0alpha1 + kind: Hwpod + metadata: + uid: CONSTART-71FREQ-C + name: constart-71freq-c + spec: + targetDevice: + board: ConStart 71-FREQ Controller + mcu: STM32H723ZGTx + flashBase: "0x08000000" + workspace: + path: "F:\\Work\\ConStart" + toolchain: keil-mdk + projectRoot: projects/71-00075-11 + keilProject: projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx + keilTarget: FREQ_Controller_FW + hexPath: projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.hex + mapPath: projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.map + keilCliPath: "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py" + debugProbe: + type: cmsis-dap + adapter: keil + probeUid: 3FD750C63E342E24 + probeName: MicroLink CMSIS-DAP + programBackend: keil-headless + autoBindUvoptx: true + ioProbe: + uart: + id: uart/1 + port: COM4 + baudrate: 921600 + nodeBinding: + nodeId: node-d601-f103-v2 + nodeType: pc-host + metadataSidecar: + contractVersion: hwpod-spec-registry-v1 + source: + kind: preinstalled-yaml-first-spec + migratedFrom: + - "F:\\Work\\ConStart\\.device-pod\\.runtime\\D601-71-FREQ.json" + - "F:\\Work\\ConStart\\projects\\71-00075-11\\.device-pod\\device-pod-71-00075-11.json" + workspaceRoot: "F:\\Work\\ConStart" + projectRoot: projects/71-00075-11 + verificationIssue: pikasTech/HWLAB#2183 + verifiedAt: 2026-06-26 diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index a73628fb..413d5b24 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -150,6 +150,12 @@ lanes: queryRetryMaxAttempts: 5 queryRetryInitialDelayMs: 250 queryRetryMaxDelayMs: 5000 + hwpodPreinstall: + enabled: true + configRefs: + preinstall: config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml#hwpodPreinstall + projectManagementSource: config/hwlab-project-management/constart-71freq-mdtodo.yaml#projectManagement.sources[0] + gatewayProfile: config/hwlab-gateway/constart-71freq-d601-v03.yaml#gateway.profile webProbe: browserProxyMode: direct defaultOrigin: diff --git a/config/hwlab-project-management/constart-71freq-mdtodo.yaml b/config/hwlab-project-management/constart-71freq-mdtodo.yaml new file mode 100644 index 00000000..aba96829 --- /dev/null +++ b/config/hwlab-project-management/constart-71freq-mdtodo.yaml @@ -0,0 +1,40 @@ +version: 1 +kind: HwlabProjectManagementSourceConfig +metadata: + name: constart-71freq-mdtodo + spec: PJ2026-010404 + implementationRef: draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source + +projectManagement: + sources: + - sourceId: constart-71freq-mdtodo + sourceKind: hwpod-workspace + displayName: 71-FREQ MDTODO + projectId: project_constart_71freq + hwpodId: constart-71freq-c + nodeId: node-d601-f103-v2 + workspaceRootRef: "F:\\Work\\ConStart" + mdtodoRootRef: docs/MDTODO + maxFiles: 300 + focusFiles: + - 20260419_频率判断.md + - 20260609_频率判断_用户反馈.md + - details/ + capabilities: + read: true + write: true + reindex: true + launchWorkbench: true + hwpodNodeOpsUrlConfigRef: config/hwlab-gateway/constart-71freq-d601-v03.yaml#gateway.profile.nodeOps.serviceUrl + runtimeEnv: + envKey: HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL + targetServiceId: hwlab-project-management + rolloutTarget: + kind: Deployment + namespace: hwlab-v03 + name: hwlab-project-management + container: hwlab-project-management + redaction: + rawMarkdown: true + hostPath: true + valuesRedacted: true diff --git a/scripts/cli.ts b/scripts/cli.ts index f26254c4..0fcb995c 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -200,11 +200,17 @@ async function main(): Promise { } const [top, sub, third, fourth] = args; - if (top === undefined || top === "help" || top === "--help" || top === "-h") { + if (top === undefined || top === "--help" || top === "-h") { emitJson(commandName, rootHelp()); return; } + if (top === "help") { + const namespaceHelp = args.length > 1 ? await staticNamespaceHelp([...args.slice(1), "--help"]) : null; + emitJson(commandName, namespaceHelp ?? rootHelp()); + return; + } + if (top === "ssh" && (sub === undefined || isHelpToken(sub) || (isHelpToken(third) && args.length === 3))) { emitJson(commandName, sshHelp()); return; diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 49b4fe43..1d69c5a3 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -60,7 +60,7 @@ export function rootHelp(): unknown { { command: "git github-push-fallback [--repo owner/name] [--branch branch] [--host-name host-or-ip] [--confirm]", description: "Plan or execute a one-shot GitHub push through ssh.github.com:443 without editing remotes; use only for reviewed DNS/port-22 push fallback." }, { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run approval preview without live bridges or message sends." }, { command: "web-probe run|script|observe|sentinel --node --lane ", description: "Run YAML-selected HWLAB Web probes, long observe/analyze sessions, project-management MDTODO commands, and Web sentinel control through the single top-level web-probe entrypoint." }, - { command: "hwlab nodes control-plane|git-mirror|secret|test-accounts --node --lane ", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared k3s infra/tools-image/Argo bootstrap, redacted test-account preparation, and G14 v0.3+ runtime lanes, with the node identity passed as data." }, + { command: "hwlab nodes control-plane|git-mirror|hwpod-preinstall|secret|test-accounts --node --lane ", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared k3s infra/tools-image/Argo bootstrap, HWPOD preinstall configRefs, redacted test-account preparation, and G14 v0.3+ runtime lanes, with the node identity passed as data." }, { command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." }, { command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." }, { command: "platform-infra sub2api|langbot|n8n|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot and n8n, manage YAML-controlled public FRP/Caddy exposure and WeChat archive workflows, and inspect status/logs without printing secrets." }, @@ -663,7 +663,7 @@ function platformInfraHelpSummary(): unknown { function hwlabNodeHelpSummary(): unknown { return { - command: "hwlab nodes control-plane|git-mirror|observability|secret|test-accounts --node --lane ", + command: "hwlab nodes control-plane|git-mirror|hwpod-preinstall|observability|secret|test-accounts --node --lane ", output: "json", usage: [ "bun scripts/cli.ts hwlab nodes control-plane infra plan --node D601 --lane v03", @@ -672,12 +672,13 @@ function hwlabNodeHelpSummary(): unknown { "bun scripts/cli.ts hwlab nodes control-plane infra argo status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03", "bun scripts/cli.ts hwlab nodes git-mirror status --node G14 --lane v03", + "bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node D601 --lane v03 --dry-run", "bun scripts/cli.ts hwlab nodes observability performance-summary --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name ", "bun scripts/cli.ts hwlab nodes test-accounts status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes test-accounts sync --node D601 --lane v03 --confirm", ], - description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD, git-mirror, public Dockerfile tools image, and declarative Argo CD prerequisites for D601 v03 while keeping cross-node work semi-automatic; observability reads runtime metrics and authenticated Web Performance summaries; test-accounts prepares UniDesk YAML-declared admin/test account API keys with redacted sourceRef/fingerprint output. Web probe commands moved to top-level `bun scripts/cli.ts web-probe`.", + description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD, git-mirror, public Dockerfile tools image, and declarative Argo CD prerequisites for D601 v03 while keeping cross-node work semi-automatic; hwpod-preinstall renders D601/v03 71-FREQ HWPOD/MDTODO/gateway configRefs without runtime mutation; observability reads runtime metrics and authenticated Web Performance summaries; test-accounts prepares UniDesk YAML-declared admin/test account API keys with redacted sourceRef/fingerprint output. Web probe commands moved to top-level `bun scripts/cli.ts web-probe`.", }; } @@ -771,6 +772,9 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./hwlab-test-accounts")).hwlabTestAccountsHelp(), hwlabNodeHelpSummary()); } + if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "hwpod-preinstall") { + return loadHelp(async () => (await import("./hwlab-node-hwpod-preinstall")).hwlabNodeHwpodPreinstallHelp(), hwlabNodeHelpSummary()); + } if (top === "hwlab" && (sub === "node" || sub === "nodes")) return loadHelp(async () => (await import("./hwlab-node")).hwlabNodeHelp(), hwlabNodeHelpSummary()); if (top === "hwlab" && sub === "g14") return loadHelp(async () => (await import("./hwlab-g14")).hwlabG14Help(), hwlabG14HelpSummary()); if (top === "hwlab") return loadHelp(async () => (await import("./hwlab-cd")).hwlabHelp(), hwlabHelpSummary()); diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 6a4d037a..1165a3c9 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -12,6 +12,7 @@ export function hwlabNodeHelp(): Record { "bun scripts/cli.ts hwlab nodes control-plane infra plan --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes git-mirror status --node G14 --lane v03", + "bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node D601 --lane v03 --dry-run", "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name ", "bun scripts/cli.ts hwlab nodes test-accounts status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes observability performance-summary --node D601 --lane v03", @@ -20,6 +21,7 @@ export function hwlabNodeHelp(): Record { actions: { "control-plane": "YAML-first node-local CI/CD, git-mirror, public exposure, runtime-image, Argo and PipelineRun 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.", 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.", observability: "Read runtime metrics and authenticated Web Performance summaries.", diff --git a/scripts/src/hwlab-node-hwpod-preinstall.ts b/scripts/src/hwlab-node-hwpod-preinstall.ts new file mode 100644 index 00000000..98e3ac23 --- /dev/null +++ b/scripts/src/hwlab-node-hwpod-preinstall.ts @@ -0,0 +1,612 @@ +// SPEC: PJ2026-01010305 71FREQ预装 draft-2026-06-26-71freq-v03-hwpod-preinstall. +// Responsibility: Redacted YAML configRef graph and plan/status rendering for D601 71-FREQ HWPOD preinstall. +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { rootPath } from "./config"; +import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; +import { assertLane, assertNodeId, requiredOption } from "./hwlab-node/utils"; +import { assertKnownOptions } from "./hwlab-node/web-probe-observe"; +import type { RenderedCliResult } from "./output"; + +type HwpodPreinstallAction = "plan" | "status"; +type HwpodPreinstallConfigRefKey = "preinstall" | "projectManagementSource" | "gatewayProfile"; + +interface HwpodPreinstallOptions { + readonly action: HwpodPreinstallAction; + readonly node: string; + readonly lane: string; + readonly dryRun: boolean; +} + +interface HwpodPreinstallRoot { + readonly present: boolean; + readonly enabled: boolean; + readonly rootPath: string; + readonly configRefs: Partial>; + readonly error: string | null; +} + +interface HwpodPreinstallConfigPlan { + readonly ok: boolean; + readonly command: string; + readonly status: "ready" | "blocked" | "disabled"; + readonly node: string; + readonly lane: string; + readonly rootPath: string; + readonly enabled: boolean; + readonly refs: readonly HwpodPreinstallConfigRefStatus[]; + readonly conflicts: readonly string[]; + readonly artifacts: readonly HwpodPreinstallArtifactRow[]; + readonly next: Record; + readonly valuesRedacted: true; +} + +interface HwpodPreinstallConfigRefStatus { + readonly key: HwpodPreinstallConfigRefKey; + readonly ref: string; + readonly file: string; + readonly path: string; + readonly present: boolean; + readonly targetPresent: boolean; + readonly targetKind: "object" | "array" | "scalar" | "null" | "missing"; + readonly sha256: string | null; + readonly byteCount: number | null; + readonly missingFields: readonly string[]; + readonly conflicts: readonly string[]; + readonly summary: string; + readonly error: string | null; +} + +interface InternalConfigRefStatus extends HwpodPreinstallConfigRefStatus { + readonly target: unknown; +} + +interface RequiredTargetShape { + readonly kind: "object"; + readonly requiredPaths: readonly string[]; +} + +interface HwpodPreinstallArtifactRow { + readonly kind: string; + readonly name: string; + readonly target: string; + readonly detail: string; +} + +const CONFIG_REF_KEYS = ["preinstall", "projectManagementSource", "gatewayProfile"] as const satisfies readonly HwpodPreinstallConfigRefKey[]; + +const REQUIRED_TARGET_SHAPES: Record = { + preinstall: { + kind: "object", + requiredPaths: [ + "hwpodId", + "sourceRef.spec", + "metadataRef", + "targetDevice.board", + "targetDevice.mcu", + "nodeBinding.hwlabNode", + "nodeBinding.lane", + "nodeBinding.nodeId", + "workspaceRootRef", + "projectRoot", + "toolchain.name", + "toolchain.keilProject", + "toolchain.keilTarget", + "toolchain.keilCliPath", + "debugProbe.type", + "debugProbe.probeUid", + "uart.id", + "uart.port", + "uart.baudRate", + "boardComm.endpoints[0].id", + "ioProbe.uart.id", + "runtimeMount.namespace", + "runtimeMount.configMapName", + "runtimeMount.mountPath", + "runtimeMount.envKey", + "runtimeMount.rolloutTarget.name", + "specDocument.metadata.name", + "specDocument.spec.workspace.keilProject", + "metadataSidecar.contractVersion", + ], + }, + projectManagementSource: { + kind: "object", + requiredPaths: [ + "sourceId", + "sourceKind", + "projectId", + "hwpodId", + "nodeId", + "workspaceRootRef", + "mdtodoRootRef", + "focusFiles[0]", + "hwpodNodeOpsUrlConfigRef", + "runtimeEnv.envKey", + "runtimeEnv.rolloutTarget.name", + ], + }, + gatewayProfile: { + kind: "object", + requiredPaths: [ + "cloudUrl", + "gatewayId", + "sessionId", + "resourceId", + "capabilityId", + "hwpodId", + "nodeId", + "nodeOps.serviceUrl", + "nodeOps.publicUrl", + "managedRun.mode", + "managedRun.statusCommand", + "secretRefs[0].sourceRef", + ], + }, +}; + +export function hwlabNodeHwpodPreinstallHelp(): Record { + return { + ok: true, + command: "hwlab nodes hwpod-preinstall", + description: "Render the YAML-first HWPOD preinstall configRef graph for a selected node/lane without mutating runtime.", + configPath: hwlabRuntimeLaneConfigPath(), + examples: [ + "bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node D601 --lane v03 --dry-run", + "bun scripts/cli.ts hwlab nodes hwpod-preinstall status --node D601 --lane v03", + ], + actions: { + plan: "Validate D601/v03 hwpodPreinstall configRefs, cross-check ids, and render runtime artifact targets.", + status: "Render the same redacted graph for the selected target as a read-only status view.", + }, + notes: [ + "Root hwpodPreinstall must be declared under the selected lane target; this command does not use global defaults.", + "Secret values, full YAML objects, host file content, and raw Markdown are not printed.", + "Runtime apply/rollout is a later P3 step; plan/status are P2 visibility and friction-removal commands.", + ], + }; +} + +export async function runHwlabNodeHwpodPreinstallCommand(args: string[]): Promise> { + if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return hwlabNodeHwpodPreinstallHelp(); + const options = parseHwpodPreinstallOptions(args); + const spec = hwlabRuntimeLaneSpecForNode(options.lane, options.node); + return withHwpodPreinstallRendered(hwpodPreinstallConfigPlan(spec, options.action, options.dryRun)); +} + +function parseHwpodPreinstallOptions(args: string[]): HwpodPreinstallOptions { + const [actionRaw] = args; + if (actionRaw !== "plan" && actionRaw !== "status") { + throw new Error("hwpod-preinstall usage: hwpod-preinstall plan|status --node NODE --lane vNN [--dry-run]"); + } + assertKnownOptions(args, new Set(["--node", "--lane"]), new Set(["--dry-run", "--full"])); + const node = requiredOption(args, "--node"); + assertNodeId(node); + const lane = requiredOption(args, "--lane"); + assertLane(lane); + if (!isHwlabRuntimeLane(lane)) throw new Error(`hwpod-preinstall only supports HWLAB runtime lanes, got ${lane}`); + return { action: actionRaw, node, lane, dryRun: args.includes("--dry-run") }; +} + +function hwpodPreinstallConfigPlan(spec: HwlabRuntimeLaneSpec, action: HwpodPreinstallAction, dryRun: boolean): HwpodPreinstallConfigPlan { + const root = readHwpodPreinstallRoot(spec); + const command = `hwlab nodes hwpod-preinstall ${action} --node ${spec.nodeId} --lane ${spec.lane}${dryRun ? " --dry-run" : ""}`; + const refs = root.present && root.enabled ? CONFIG_REF_KEYS.map((key) => readConfigRef(key, root.configRefs[key] ?? "")) : []; + const rootConflicts = root.error === null ? [] : [root.error]; + const conflicts = root.enabled ? [...rootConflicts, ...crossReferenceConflicts(spec, refs)] : rootConflicts; + const refBlocked = refs.some((ref) => !ref.present || !ref.targetPresent || ref.missingFields.length > 0 || ref.conflicts.length > 0 || ref.error !== null); + const ok = root.present && root.enabled && !refBlocked && conflicts.length === 0; + return { + ok, + command, + status: root.enabled ? ok ? "ready" : "blocked" : "disabled", + node: spec.nodeId, + lane: spec.lane, + rootPath: root.rootPath, + enabled: root.enabled, + refs: refs.map(stripInternalTarget), + conflicts, + artifacts: runtimeArtifactRows(refs), + next: nextCommands(spec.nodeId, spec.lane), + valuesRedacted: true, + }; +} + +function withHwpodPreinstallRendered(value: HwpodPreinstallConfigPlan): RenderedCliResult { + return { + ok: value.ok, + command: value.command, + contentType: "text/plain", + renderedText: renderHwpodPreinstallPlan(value), + }; +} + +function readHwpodPreinstallRoot(spec: HwlabRuntimeLaneSpec): HwpodPreinstallRoot { + const rootFragment = `lanes.${spec.lane}.targets.${spec.nodeId}.hwpodPreinstall`; + const root = `config/hwlab-node-lanes.yaml#${rootFragment}`; + try { + const text = readFileSync(rootPath(hwlabRuntimeLaneConfigPath()), "utf8"); + const doc = Bun.YAML.parse(text) as unknown; + const target = valueAtPath(doc, rootFragment); + if (!isRecord(target)) { + return { present: false, enabled: false, rootPath: root, configRefs: {}, error: `${root} must be declared on the selected node/lane target` }; + } + const enabled = target.enabled === true; + const refs = isRecord(target.configRefs) ? target.configRefs : {}; + const configRefs: Partial> = {}; + for (const key of CONFIG_REF_KEYS) { + const value = refs[key]; + if (typeof value === "string" && value.length > 0) configRefs[key] = value; + } + const missingKeys = CONFIG_REF_KEYS.filter((key) => configRefs[key] === undefined); + return { + present: true, + enabled, + rootPath: root, + configRefs, + error: missingKeys.length === 0 ? null : `${root}.configRefs missing ${missingKeys.join(",")}`, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { present: false, enabled: false, rootPath: root, configRefs: {}, error: message }; + } +} + +function readConfigRef(key: HwpodPreinstallConfigRefKey, ref: string): InternalConfigRefStatus { + const parsed = parseConfigRef(ref); + if (parsed.error !== null) return emptyRefStatus(key, ref, parsed.file, parsed.path, parsed.error); + const absPath = rootPath(parsed.file); + if (!existsSync(absPath)) return emptyRefStatus(key, ref, parsed.file, parsed.path, `${parsed.file} does not exist`); + try { + const text = readFileSync(absPath, "utf8"); + const sha256 = `sha256:${createHash("sha256").update(text).digest("hex")}`; + const doc = Bun.YAML.parse(text) as unknown; + const target = valueAtPath(doc, parsed.path); + const targetKind = target === undefined ? "missing" : targetKindOf(target); + const missingFields = target === undefined ? ["target"] : missingFieldsForTarget(key, target); + return { + key, + ref, + file: parsed.file, + path: parsed.path, + present: true, + targetPresent: target !== undefined, + targetKind, + sha256, + byteCount: Buffer.byteLength(text), + missingFields, + conflicts: [], + summary: summarizeTarget(key, target), + error: null, + target, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return emptyRefStatus(key, ref, parsed.file, parsed.path, message); + } +} + +function parseConfigRef(ref: string): { readonly file: string; readonly path: string; readonly error: string | null } { + const [file, path, extra] = ref.split("#"); + if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) { + return { file: file ?? "", path: path ?? "", error: "configRef must use path/to/file.yaml#object.path" }; + } + if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) { + return { file, path, error: "configRef file must be repo-relative config/*.yaml without .." }; + } + return { file, path, error: null }; +} + +function emptyRefStatus(key: HwpodPreinstallConfigRefKey, ref: string, file: string, path: string, error: string): InternalConfigRefStatus { + return { + key, + ref, + file, + path, + present: false, + targetPresent: false, + targetKind: "missing", + sha256: null, + byteCount: null, + missingFields: ["target"], + conflicts: [], + summary: "-", + error, + target: undefined, + }; +} + +function missingFieldsForTarget(key: HwpodPreinstallConfigRefKey, target: unknown): string[] { + const shape = REQUIRED_TARGET_SHAPES[key]; + if (!isRecord(target)) return [`expected ${shape.kind}`]; + return shape.requiredPaths.filter((path) => valueAtPath(target, path) === undefined); +} + +function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly InternalConfigRefStatus[]): string[] { + const byKey = new Map(refs.map((ref) => [ref.key, ref])); + const preinstall = recordTarget(byKey.get("preinstall")); + const pm = recordTarget(byKey.get("projectManagementSource")); + const gateway = recordTarget(byKey.get("gatewayProfile")); + const conflicts: string[] = []; + + if (preinstall !== null) { + requireEquals(conflicts, byKey.get("preinstall"), "nodeBinding.hwlabNode", spec.nodeId, `selected node ${spec.nodeId}`); + requireEquals(conflicts, byKey.get("preinstall"), "nodeBinding.lane", spec.lane, `selected lane ${spec.lane}`); + requireEquals(conflicts, byKey.get("preinstall"), "runtimeMount.namespace", spec.runtimeNamespace, `selected namespace ${spec.runtimeNamespace}`); + requireSelfEquals(conflicts, byKey.get("preinstall"), "hwpodId", "specDocument.metadata.name"); + requireSelfEquals(conflicts, byKey.get("preinstall"), "workspaceRootRef", "specDocument.spec.workspace.path"); + requireSelfEquals(conflicts, byKey.get("preinstall"), "toolchain.keilProject", "specDocument.spec.workspace.keilProject"); + requireSelfEquals(conflicts, byKey.get("preinstall"), "toolchain.keilTarget", "specDocument.spec.workspace.keilTarget"); + requireSelfEquals(conflicts, byKey.get("preinstall"), "nodeBinding.nodeId", "specDocument.spec.nodeBinding.nodeId"); + } + + const hwpodId = stringAt(preinstall, "hwpodId"); + const nodeId = stringAt(preinstall, "nodeBinding.nodeId"); + const workspaceRootRef = stringAt(preinstall, "workspaceRootRef"); + if (pm !== null) { + requireEquals(conflicts, byKey.get("projectManagementSource"), "sourceKind", "hwpod-workspace", "required sourceKind hwpod-workspace"); + if (hwpodId !== null) requireEquals(conflicts, byKey.get("projectManagementSource"), "hwpodId", hwpodId, `preinstall hwpodId ${hwpodId}`); + if (nodeId !== null) requireEquals(conflicts, byKey.get("projectManagementSource"), "nodeId", nodeId, `preinstall nodeId ${nodeId}`); + if (workspaceRootRef !== null) requireEquals(conflicts, byKey.get("projectManagementSource"), "workspaceRootRef", workspaceRootRef, "preinstall workspaceRootRef"); + } + if (gateway !== null) { + requireEquals(conflicts, byKey.get("gatewayProfile"), "node", spec.nodeId, `selected node ${spec.nodeId}`); + requireEquals(conflicts, byKey.get("gatewayProfile"), "lane", spec.lane, `selected lane ${spec.lane}`); + if (hwpodId !== null) { + requireEquals(conflicts, byKey.get("gatewayProfile"), "hwpodId", hwpodId, `preinstall hwpodId ${hwpodId}`); + requireEquals(conflicts, byKey.get("gatewayProfile"), "resourceId", hwpodId, `preinstall hwpodId ${hwpodId}`); + } + if (nodeId !== null) requireEquals(conflicts, byKey.get("gatewayProfile"), "nodeId", nodeId, `preinstall nodeId ${nodeId}`); + } + const pmOpsRef = stringAt(pm, "hwpodNodeOpsUrlConfigRef"); + const gatewayRef = byKey.get("gatewayProfile"); + if (pmOpsRef !== null && gatewayRef !== undefined) { + const expected = `${gatewayRef.file}#${gatewayRef.path}.nodeOps.serviceUrl`; + if (pmOpsRef !== expected) { + conflicts.push(`${byKey.get("projectManagementSource")?.file}#${byKey.get("projectManagementSource")?.path}.hwpodNodeOpsUrlConfigRef=${pmOpsRef} does not match gateway nodeOps serviceUrl ref ${expected}`); + } + } + return conflicts; +} + +function runtimeArtifactRows(refs: readonly InternalConfigRefStatus[]): HwpodPreinstallArtifactRow[] { + const byKey = new Map(refs.map((ref) => [ref.key, ref])); + const preinstall = recordTarget(byKey.get("preinstall")); + const pm = recordTarget(byKey.get("projectManagementSource")); + const gateway = recordTarget(byKey.get("gatewayProfile")); + return [ + ...(preinstall === null + ? [] + : [ + { + kind: "ConfigMap", + name: textAt(preinstall, "runtimeMount.configMapName"), + target: textAt(preinstall, "runtimeMount.namespace"), + detail: `mount=${textAt(preinstall, "runtimeMount.mountPath")} env=${textAt(preinstall, "runtimeMount.envKey")} rollout=${textAt(preinstall, "runtimeMount.rolloutTarget.name")}`, + }, + { + kind: "HWPOD", + name: textAt(preinstall, "hwpodId"), + target: textAt(preinstall, "nodeBinding.nodeId"), + detail: `keil=${textAt(preinstall, "toolchain.keilTarget")} uart=${textAt(preinstall, "uart.port")}/${textAt(preinstall, "uart.baudRate")}`, + }, + ]), + ...(pm === null + ? [] + : [ + { + kind: "PM source", + name: textAt(pm, "sourceId"), + target: textAt(pm, "projectId"), + detail: `root=${textAt(pm, "mdtodoRootRef")} env=${textAt(pm, "runtimeEnv.envKey")} focusFiles=${arrayAt(pm, "focusFiles").length}`, + }, + ]), + ...(gateway === null + ? [] + : [ + { + kind: "Gateway", + name: textAt(gateway, "gatewayId"), + target: textAt(gateway, "resourceId"), + detail: `mode=${textAt(gateway, "managedRun.mode")} status=${lastPathSegment(textAt(gateway, "managedRun.statusCommand"))}`, + }, + ]), + ]; +} + +function requireEquals(conflicts: string[], ref: InternalConfigRefStatus | undefined, path: string, expected: string, expectedLabel: string): void { + if (ref === undefined) return; + const actual = stringAt(ref.target, path); + if (actual !== null && actual !== expected) { + conflicts.push(`${ref.file}#${ref.path}.${path}=${actual} does not match ${expectedLabel}`); + } +} + +function requireSelfEquals(conflicts: string[], ref: InternalConfigRefStatus | undefined, firstPath: string, secondPath: string): void { + if (ref === undefined) return; + const first = stringAt(ref.target, firstPath); + const second = stringAt(ref.target, secondPath); + if (first !== null && second !== null && first !== second) { + conflicts.push(`${ref.file}#${ref.path}.${firstPath}=${first} does not match ${secondPath}=${second}`); + } +} + +function stripInternalTarget(ref: InternalConfigRefStatus): HwpodPreinstallConfigRefStatus { + return { + key: ref.key, + ref: ref.ref, + file: ref.file, + path: ref.path, + present: ref.present, + targetPresent: ref.targetPresent, + targetKind: ref.targetKind, + sha256: ref.sha256, + byteCount: ref.byteCount, + missingFields: ref.missingFields, + conflicts: ref.conflicts, + summary: ref.summary, + error: ref.error, + }; +} + +function summarizeTarget(key: HwpodPreinstallConfigRefKey, target: unknown): string { + if (target === undefined) return "target=missing"; + if (!isRecord(target)) return `kind=${targetKindOf(target)}`; + if (key === "preinstall") { + return `hwpod=${textAt(target, "hwpodId")} keil=${textAt(target, "toolchain.keilTarget")} uart=${textAt(target, "uart.port")}/${textAt(target, "uart.baudRate")} board=${textAt(target, "targetDevice.board")}`; + } + if (key === "projectManagementSource") { + return `source=${textAt(target, "sourceId")} project=${textAt(target, "projectId")} root=${textAt(target, "mdtodoRootRef")} focusFiles=${arrayAt(target, "focusFiles").length}`; + } + if (key === "gatewayProfile") { + return `gateway=${textAt(target, "gatewayId")} resource=${textAt(target, "resourceId")} mode=${textAt(target, "managedRun.mode")} cloud=${textAt(target, "cloudUrl")}`; + } + return `keys=${Object.keys(target).length}`; +} + +function renderHwpodPreinstallPlan(value: HwpodPreinstallConfigPlan): string { + const refSummary = value.refs.length === 0 + ? ["CONFIG REFS", " none (root disabled or not declared)"] + : [ + hwpodTable( + ["KEY", "PRESENT", "TARGET", "TYPE", "HASH", "MISSING", "SUMMARY"], + value.refs.map((ref) => [ + ref.key, + ref.present, + ref.targetPresent, + ref.targetKind, + ref.sha256 === null ? "-" : `${ref.sha256.slice(0, 19)}...`, + ref.missingFields.length === 0 ? "-" : short(ref.missingFields.join(","), 52), + short(ref.summary, 120), + ]), + ), + "", + hwpodTable( + ["KEY", "FILE", "PATH", "BYTES"], + value.refs.map((ref) => [ref.key, ref.file, ref.path, ref.byteCount ?? "-"]), + ), + ]; + const artifactSummary = value.artifacts.length === 0 + ? ["ARTIFACTS", " none"] + : [ + hwpodTable( + ["KIND", "NAME", "TARGET", "DETAIL"], + value.artifacts.map((item) => [item.kind, item.name, item.target, short(item.detail, 120)]), + ), + ]; + const blocked = value.ok ? [] : [ + "", + "Blocked detail:", + hwpodTable(["KIND", "VALUE"], [ + ...value.conflicts.map((item) => ["conflict", short(item, 150)]), + ...value.refs.flatMap((ref) => [ + ...(ref.error === null ? [] : [[`${ref.key}.error`, short(ref.error, 150)]]), + ...(ref.missingFields.length === 0 ? [] : [[`${ref.key}.missing`, short(ref.missingFields.join(","), 150)]]), + ...(ref.conflicts.length === 0 ? [] : [[`${ref.key}.conflict`, short(ref.conflicts.join(" | "), 150)]]), + ]), + ]), + ]; + return [ + `hwlab nodes hwpod-preinstall ${commandAction(value.command)} (${value.status})`, + "", + hwpodTable(["NODE", "LANE", "ENABLED", "OK", "ROOT"], [[value.node, value.lane, value.enabled, value.ok, value.rootPath]]), + "", + ...refSummary, + "", + ...artifactSummary, + ...blocked, + "", + "NEXT", + ` plan: ${value.next.plan}`, + ` status: ${value.next.status}`, + ` deploy: ${value.next.deploy}`, + "DISCLOSURE", + " valuesRedacted=true; secret values, raw Markdown, host file content, and full YAML objects are not printed.", + ].join("\n"); +} + +function nextCommands(node: string, lane: string): Record { + return { + plan: `bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node ${node} --lane ${lane} --dry-run`, + status: `bun scripts/cli.ts hwlab nodes hwpod-preinstall status --node ${node} --lane ${lane}`, + deploy: `P3: apply the rendered ConfigMap/env/gateway profile through controlled node/lane rollout for ${node}/${lane}`, + }; +} + +function valueAtPath(value: unknown, path: string): unknown { + let current: unknown = value; + for (const segment of path.split(".")) { + if (segment.length === 0) return undefined; + const match = /^(?:([A-Za-z0-9_-]+))?(?:\[(\d+)\])?$/u.exec(segment); + if (match === null) return undefined; + if (match[1] !== undefined) { + if (!isRecord(current)) return undefined; + current = current[match[1]]; + } + if (match[2] !== undefined) { + if (!Array.isArray(current)) return undefined; + current = current[Number(match[2])]; + } + } + return current; +} + +function stringAt(value: unknown, path: string): string | null { + const found = valueAtPath(value, path); + return typeof found === "string" && found.length > 0 ? found : null; +} + +function textAt(value: unknown, path: string): string { + const found = valueAtPath(value, path); + if (typeof found === "string") return found; + if (typeof found === "number" || typeof found === "boolean") return String(found); + return "-"; +} + +function arrayAt(value: unknown, path: string): unknown[] { + const found = valueAtPath(value, path); + return Array.isArray(found) ? found : []; +} + +function recordTarget(ref: InternalConfigRefStatus | undefined): Record | null { + return ref !== undefined && isRecord(ref.target) ? ref.target : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function targetKindOf(value: unknown): "object" | "array" | "scalar" | "null" { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (isRecord(value)) return "object"; + return "scalar"; +} + +function commandAction(command: string): string { + return command.includes(" status ") ? "status" : "plan"; +} + +function hwpodTable(headers: string[], rows: unknown[][]): string { + const normalized = [headers, ...rows.map((row) => row.map((cell) => hwpodText(cell)))]; + const widths = headers.map((_, index) => Math.max(...normalized.map((row) => hwpodText(row[index] ?? "").length))); + return normalized.map((row) => row.map((cell, index) => hwpodText(cell).padEnd(widths[index])).join(" ").trimEnd()).join("\n"); +} + +function hwpodText(value: unknown): string { + if (value === null || value === undefined || value === "") return "-"; + if (typeof value === "boolean") return value ? "true" : "false"; + return String(value).replace(/\s+/gu, " ").trim(); +} + +function short(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + if (maxLength <= 1) return value.slice(0, maxLength); + return `${value.slice(0, maxLength - 1)}~`; +} + +function lastPathSegment(value: string): string { + const normalized = value.replace(/\\/gu, "/"); + const segment = normalized.split("/").filter(Boolean).pop(); + return segment ?? value; +} diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index f64ebf3e..7087db72 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -419,6 +419,10 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom const { runHwlabTestAccountsCommand } = await import("../hwlab-test-accounts"); return runHwlabTestAccountsCommand(args.slice(1)); } + if (domain === "hwpod-preinstall") { + const { runHwlabNodeHwpodPreinstallCommand } = await import("../hwlab-node-hwpod-preinstall"); + return runHwlabNodeHwpodPreinstallCommand(args.slice(1)); + } if (domain === "web-probe") { return legacyHwlabNodeWebProbeUnsupported(args.slice(1)); } @@ -431,7 +435,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 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 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);