1362 lines
57 KiB
TypeScript
1362 lines
57 KiB
TypeScript
// 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 { repoRoot, rootPath } from "./config";
|
|
import { runCommand, type CommandResult } from "./command";
|
|
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" | "gateway-plan" | "gateway-status" | "gateway-apply";
|
|
type HwpodPreinstallConfigRefKey = "preinstall" | "projectManagementSource" | "gatewayProfile";
|
|
|
|
interface HwpodPreinstallOptions {
|
|
readonly action: HwpodPreinstallAction;
|
|
readonly node: string;
|
|
readonly lane: string;
|
|
readonly dryRun: boolean;
|
|
readonly confirm: boolean;
|
|
}
|
|
|
|
interface HwpodPreinstallRoot {
|
|
readonly present: boolean;
|
|
readonly enabled: boolean;
|
|
readonly rootPath: string;
|
|
readonly configRefs: Partial<Record<HwpodPreinstallConfigRefKey, string>>;
|
|
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<string, string>;
|
|
readonly valuesRedacted: true;
|
|
}
|
|
|
|
interface HwpodGatewayPlan {
|
|
readonly ok: boolean;
|
|
readonly command: string;
|
|
readonly status: "ready" | "blocked" | "applied";
|
|
readonly node: string;
|
|
readonly lane: string;
|
|
readonly runtimeRoot: string;
|
|
readonly profile: HwpodGatewayProfileSummary | null;
|
|
readonly files: readonly HwpodGatewayFilePlan[];
|
|
readonly legacyRetire: readonly string[];
|
|
readonly blockers: readonly string[];
|
|
readonly remoteStatus: HwpodGatewayRemoteStatus | null;
|
|
readonly remoteApply: HwpodGatewayApplyResult | null;
|
|
readonly next: Record<string, string>;
|
|
readonly valuesRedacted: true;
|
|
}
|
|
|
|
interface HwpodGatewayProfileSummary {
|
|
readonly hwlabNode: string;
|
|
readonly lane: string;
|
|
readonly cloudUrl: string;
|
|
readonly websocketUrl: string;
|
|
readonly hwpodId: string;
|
|
readonly nodeId: string;
|
|
readonly gatewayId: string;
|
|
readonly sessionId: string;
|
|
readonly resourceId: string;
|
|
readonly capabilityId: string;
|
|
readonly taskName: string;
|
|
readonly periodicTaskName: string;
|
|
readonly runtimeRoot: string;
|
|
readonly bunPath: string;
|
|
readonly runKeyName: string;
|
|
readonly startCommand: string;
|
|
readonly statusCommand: string;
|
|
readonly processPattern: string;
|
|
readonly reconnectBaseMs: number;
|
|
readonly reconnectMaxMs: number;
|
|
}
|
|
|
|
interface HwpodGatewayFilePlan {
|
|
readonly path: string;
|
|
readonly kind: "source" | "runner";
|
|
readonly bytes: number;
|
|
readonly sha256: string;
|
|
}
|
|
|
|
interface HwpodGatewayRemoteStatus {
|
|
readonly ok: boolean;
|
|
readonly status: string;
|
|
readonly rootExists: boolean;
|
|
readonly expectedProcessCount: number;
|
|
readonly legacyProcessCount: number;
|
|
readonly taskStates: Record<string, string>;
|
|
readonly fileStates: Record<string, string>;
|
|
readonly legacyProcessCommands: readonly string[];
|
|
readonly valuesRedacted?: boolean;
|
|
}
|
|
|
|
interface HwpodGatewayApplyResult {
|
|
readonly ok: boolean;
|
|
readonly status: string;
|
|
readonly writtenFiles: readonly string[];
|
|
readonly stoppedLegacyPids: readonly number[];
|
|
readonly taskName: string;
|
|
readonly periodicTaskName: string;
|
|
readonly runTaskExitCode: number | null;
|
|
readonly failureDetail?: string;
|
|
readonly valuesRedacted?: boolean;
|
|
}
|
|
|
|
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<HwpodPreinstallConfigRefKey, RequiredTargetShape> = {
|
|
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",
|
|
"node",
|
|
"lane",
|
|
"gatewayId",
|
|
"sessionId",
|
|
"resourceId",
|
|
"capabilityId",
|
|
"hwpodId",
|
|
"nodeId",
|
|
"nodeOps.serviceUrl",
|
|
"nodeOps.publicUrl",
|
|
"nodeOps.websocketUrl",
|
|
"managedRun.mode",
|
|
"managedRun.runtimeRoot",
|
|
"managedRun.bunPath",
|
|
"managedRun.taskName",
|
|
"managedRun.periodicTaskName",
|
|
"managedRun.runKeyName",
|
|
"managedRun.startCommand",
|
|
"managedRun.statusCommand",
|
|
"managedRun.processPattern",
|
|
"managedRun.reconnectBaseMs",
|
|
"managedRun.reconnectMaxMs",
|
|
"secretRefs[0].sourceRef",
|
|
],
|
|
},
|
|
};
|
|
|
|
export function hwlabNodeHwpodPreinstallHelp(): Record<string, unknown> {
|
|
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",
|
|
"bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-plan --node D601 --lane v03 --dry-run",
|
|
"bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-status --node D601 --lane v03",
|
|
"bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-apply --node D601 --lane v03 --dry-run",
|
|
],
|
|
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.",
|
|
"gateway-plan": "Render the v0.3+ Windows HWPOD node/gateway runner files and legacy-retire policy without touching runtime.",
|
|
"gateway-status": "Read D601 Windows task/process/file state and classify old v0.2 direct-url runners as legacy drift.",
|
|
"gateway-apply": "Sync v0.3 source tools, overwrite the Windows runner from YAML, stop legacy v0.2 runners, and recreate managed tasks.",
|
|
},
|
|
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.",
|
|
"v0.2 is not a compatibility target for this feature; gateway commands only support v0.3+ selected lanes.",
|
|
"Runtime apply/rollout is a later P3 step; plan/status are P2 visibility and friction-removal commands.",
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function runHwlabNodeHwpodPreinstallCommand(args: string[]): Promise<RenderedCliResult | Record<string, unknown>> {
|
|
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);
|
|
if (options.action === "plan" || options.action === "status") {
|
|
return withHwpodPreinstallRendered(hwpodPreinstallConfigPlan(spec, options.action, options.dryRun));
|
|
}
|
|
return withHwpodGatewayRendered(await hwpodGatewayPlan(spec, options));
|
|
}
|
|
|
|
function parseHwpodPreinstallOptions(args: string[]): HwpodPreinstallOptions {
|
|
const [actionRaw] = args;
|
|
if (
|
|
actionRaw !== "plan"
|
|
&& actionRaw !== "status"
|
|
&& actionRaw !== "gateway-plan"
|
|
&& actionRaw !== "gateway-status"
|
|
&& actionRaw !== "gateway-apply"
|
|
) {
|
|
throw new Error("hwpod-preinstall usage: hwpod-preinstall plan|status|gateway-plan|gateway-status|gateway-apply --node NODE --lane vNN [--dry-run|--confirm]");
|
|
}
|
|
assertKnownOptions(args, new Set(["--node", "--lane"]), new Set(["--dry-run", "--confirm", "--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}`);
|
|
const confirm = args.includes("--confirm");
|
|
const dryRun = args.includes("--dry-run");
|
|
if (confirm && dryRun) throw new Error("hwpod-preinstall accepts only one of --confirm or --dry-run");
|
|
if (actionRaw === "gateway-apply" && !confirm && !dryRun) throw new Error("gateway-apply requires --dry-run or --confirm");
|
|
if (actionRaw !== "gateway-apply" && confirm) throw new Error(`${actionRaw} is read-only and does not accept --confirm`);
|
|
return { action: actionRaw, node, lane, dryRun, confirm };
|
|
}
|
|
|
|
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 withHwpodGatewayRendered(value: HwpodGatewayPlan): RenderedCliResult {
|
|
return {
|
|
ok: value.ok,
|
|
command: value.command,
|
|
contentType: "text/plain",
|
|
renderedText: renderHwpodGatewayPlan(value),
|
|
};
|
|
}
|
|
|
|
async function hwpodGatewayPlan(spec: HwlabRuntimeLaneSpec, options: HwpodPreinstallOptions): Promise<HwpodGatewayPlan> {
|
|
const root = readHwpodPreinstallRoot(spec);
|
|
const refs = root.present && root.enabled ? CONFIG_REF_KEYS.map((key) => readConfigRef(key, root.configRefs[key] ?? "")) : [];
|
|
const graph = hwpodPreinstallConfigPlan(spec, "plan", true);
|
|
const gateway = recordTarget(new Map(refs.map((ref) => [ref.key, ref])).get("gatewayProfile"));
|
|
const profile = gatewayProfileSummary(gateway);
|
|
const unsupportedLegacyLane = spec.lane === "v02" || spec.version === "v0.2";
|
|
const blockers = [
|
|
...(graph.ok ? [] : ["preinstall configRef graph is not ready; run hwpod-preinstall plan first"]),
|
|
...(profile === null ? ["gatewayProfile configRef target is missing or invalid"] : []),
|
|
...(unsupportedLegacyLane ? ["v0.2 is legacy-retire for 71-FREQ HWPOD gateway; no compatibility or fallback is retained"] : []),
|
|
];
|
|
const files = profile === null ? [] : gatewayRunnerFiles(profile).map((file) => ({
|
|
path: file.path,
|
|
kind: "runner" as const,
|
|
bytes: Buffer.byteLength(file.content, "utf8"),
|
|
sha256: sha256Fingerprint(file.content),
|
|
}));
|
|
let remoteStatus: HwpodGatewayRemoteStatus | null = null;
|
|
let remoteApply: HwpodGatewayApplyResult | null = null;
|
|
if (profile !== null && !unsupportedLegacyLane && (options.action === "gateway-status" || options.action === "gateway-apply")) {
|
|
remoteStatus = readGatewayRemoteStatus(spec, profile);
|
|
}
|
|
if (profile !== null && !unsupportedLegacyLane && options.action === "gateway-apply" && options.confirm && blockers.length === 0) {
|
|
const sourceFiles = readGatewaySourceFiles(spec);
|
|
remoteApply = applyGatewayRuntime(spec, profile, sourceFiles, gatewayRunnerFiles(profile));
|
|
remoteStatus = readGatewayRemoteStatus(spec, profile);
|
|
}
|
|
const remoteOk = remoteStatus === null || remoteStatus.ok;
|
|
const applyOk = remoteApply === null || remoteApply.ok;
|
|
const ok = blockers.length === 0 && remoteOk && applyOk;
|
|
return {
|
|
ok,
|
|
command: `hwlab nodes hwpod-preinstall ${options.action} --node ${spec.nodeId} --lane ${spec.lane}${options.dryRun ? " --dry-run" : ""}${options.confirm ? " --confirm" : ""}`,
|
|
status: remoteApply?.ok ? "applied" : ok ? "ready" : "blocked",
|
|
node: spec.nodeId,
|
|
lane: spec.lane,
|
|
runtimeRoot: profile?.runtimeRoot ?? "-",
|
|
profile,
|
|
files,
|
|
legacyRetire: [
|
|
"v0.2 direct-url hwpod-node is not a compatibility target",
|
|
"apply stops hwpod-node.ts connect processes for the selected node before starting the YAML-rendered v0.3 runner",
|
|
"old .device-pod profiles and old cloud URLs are drift evidence only",
|
|
],
|
|
blockers,
|
|
remoteStatus,
|
|
remoteApply,
|
|
next: {
|
|
plan: `bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-plan --node ${spec.nodeId} --lane ${spec.lane} --dry-run`,
|
|
status: `bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-status --node ${spec.nodeId} --lane ${spec.lane}`,
|
|
apply: `bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-apply --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
|
|
},
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
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<Record<HwpodPreinstallConfigRefKey, string>> = {};
|
|
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 gatewayProfileSummary(gateway: Record<string, unknown> | null): HwpodGatewayProfileSummary | null {
|
|
if (gateway === null) return null;
|
|
const hwlabNode = stringAt(gateway, "node");
|
|
const lane = stringAt(gateway, "lane");
|
|
const cloudUrl = stringAt(gateway, "cloudUrl");
|
|
const websocketUrl = stringAt(gateway, "nodeOps.websocketUrl");
|
|
const hwpodId = stringAt(gateway, "hwpodId");
|
|
const nodeId = stringAt(gateway, "nodeId");
|
|
const gatewayId = stringAt(gateway, "gatewayId");
|
|
const sessionId = stringAt(gateway, "sessionId");
|
|
const resourceId = stringAt(gateway, "resourceId");
|
|
const capabilityId = stringAt(gateway, "capabilityId");
|
|
const runtimeRoot = stringAt(gateway, "managedRun.runtimeRoot");
|
|
const bunPath = stringAt(gateway, "managedRun.bunPath");
|
|
const taskName = stringAt(gateway, "managedRun.taskName");
|
|
const periodicTaskName = stringAt(gateway, "managedRun.periodicTaskName");
|
|
const runKeyName = stringAt(gateway, "managedRun.runKeyName");
|
|
const startCommand = stringAt(gateway, "managedRun.startCommand");
|
|
const statusCommand = stringAt(gateway, "managedRun.statusCommand");
|
|
const processPattern = stringAt(gateway, "managedRun.processPattern");
|
|
const reconnectBaseMs = numberAt(gateway, "managedRun.reconnectBaseMs");
|
|
const reconnectMaxMs = numberAt(gateway, "managedRun.reconnectMaxMs");
|
|
if (
|
|
hwlabNode === null
|
|
|| lane === null
|
|
|| cloudUrl === null
|
|
|| websocketUrl === null
|
|
|| hwpodId === null
|
|
|| nodeId === null
|
|
|| gatewayId === null
|
|
|| sessionId === null
|
|
|| resourceId === null
|
|
|| capabilityId === null
|
|
|| runtimeRoot === null
|
|
|| bunPath === null
|
|
|| taskName === null
|
|
|| periodicTaskName === null
|
|
|| runKeyName === null
|
|
|| startCommand === null
|
|
|| statusCommand === null
|
|
|| processPattern === null
|
|
|| reconnectBaseMs === null
|
|
|| reconnectMaxMs === null
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
hwlabNode,
|
|
lane,
|
|
cloudUrl,
|
|
websocketUrl,
|
|
hwpodId,
|
|
nodeId,
|
|
gatewayId,
|
|
sessionId,
|
|
resourceId,
|
|
capabilityId,
|
|
runtimeRoot,
|
|
bunPath,
|
|
taskName,
|
|
periodicTaskName,
|
|
runKeyName,
|
|
startCommand,
|
|
statusCommand,
|
|
processPattern,
|
|
reconnectBaseMs,
|
|
reconnectMaxMs,
|
|
};
|
|
}
|
|
|
|
function gatewayRunnerFiles(profile: HwpodGatewayProfileSummary): { readonly path: string; readonly content: string }[] {
|
|
return [
|
|
{ path: "run-node-ws-detached.cmd", content: gatewayRunDetachedCmd(profile) },
|
|
{ path: "start-node-ws.cmd", content: gatewayStartCmd(profile) },
|
|
{ path: "status-node-ws.cmd", content: gatewayStatusCmd(profile) },
|
|
{ path: "install-node-ws.cmd", content: gatewayInstallCmd(profile) },
|
|
{ path: "run-node-ws.vbs", content: gatewayRunVbs(profile) },
|
|
{ path: "ensure-node-ws.vbs", content: gatewayEnsureVbs(profile) },
|
|
];
|
|
}
|
|
|
|
function readGatewayRemoteStatus(spec: HwlabRuntimeLaneSpec, profile: HwpodGatewayProfileSummary): HwpodGatewayRemoteStatus {
|
|
const result = runTrans(spec, "win", ["ps"], gatewayRemoteStatusScript(profile), 30_000);
|
|
if (result.exitCode !== 0) {
|
|
return {
|
|
ok: false,
|
|
status: "capture-failed",
|
|
rootExists: false,
|
|
expectedProcessCount: 0,
|
|
legacyProcessCount: 0,
|
|
taskStates: {},
|
|
fileStates: {},
|
|
legacyProcessCommands: [compactResultTail(result)],
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
const parsed = parseJsonFromStdout(result.stdout);
|
|
if (parsed === null) {
|
|
return {
|
|
ok: false,
|
|
status: "invalid-status-json",
|
|
rootExists: false,
|
|
expectedProcessCount: 0,
|
|
legacyProcessCount: 0,
|
|
taskStates: {},
|
|
fileStates: {},
|
|
legacyProcessCommands: [short(result.stdout, 500)],
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
return {
|
|
ok: parsed.ok === true,
|
|
status: typeof parsed.status === "string" ? parsed.status : "unknown",
|
|
rootExists: parsed.rootExists === true,
|
|
expectedProcessCount: numberFromUnknown(parsed.expectedProcessCount),
|
|
legacyProcessCount: numberFromUnknown(parsed.legacyProcessCount),
|
|
taskStates: stringRecord(parsed.taskStates),
|
|
fileStates: stringRecord(parsed.fileStates),
|
|
legacyProcessCommands: stringArray(parsed.legacyProcessCommands).map((item) => short(item, 180)),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function readGatewaySourceFiles(spec: HwlabRuntimeLaneSpec): { readonly path: string; readonly base64: string; readonly sha256: string; readonly bytes: number }[] {
|
|
const result = runTrans(spec, spec.workspace, ["sh", "--", gatewaySourceBundleScript()], "", 30_000);
|
|
if (result.exitCode !== 0) throw new Error(`failed to read HWLAB v0.3 hwpod-node source files from ${spec.nodeId}:${spec.workspace}: ${compactResultTail(result)}`);
|
|
const parsed = parseJsonFromStdout(result.stdout);
|
|
if (parsed === null || parsed.ok !== true || !Array.isArray(parsed.files)) throw new Error(`invalid hwpod-node source bundle output: ${short(result.stdout, 500)}`);
|
|
return parsed.files.map((item) => {
|
|
if (!isRecord(item)) throw new Error("invalid source file record");
|
|
const path = stringAt(item, "path");
|
|
const base64 = stringAt(item, "base64");
|
|
const sha256 = stringAt(item, "sha256");
|
|
const bytes = numberFromUnknown(item.bytes);
|
|
if (path === null || base64 === null || sha256 === null || bytes <= 0) throw new Error("invalid source file fields");
|
|
return { path, base64, sha256, bytes };
|
|
});
|
|
}
|
|
|
|
function applyGatewayRuntime(
|
|
spec: HwlabRuntimeLaneSpec,
|
|
profile: HwpodGatewayProfileSummary,
|
|
sourceFiles: readonly { readonly path: string; readonly base64: string; readonly sha256: string; readonly bytes: number }[],
|
|
runnerFiles: readonly { readonly path: string; readonly content: string }[],
|
|
): HwpodGatewayApplyResult {
|
|
const payload = {
|
|
root: profile.runtimeRoot,
|
|
profile,
|
|
sourceFiles,
|
|
runnerFiles: runnerFiles.map((file) => ({
|
|
path: file.path,
|
|
base64: Buffer.from(file.content, "utf8").toString("base64"),
|
|
sha256: sha256Fingerprint(file.content),
|
|
bytes: Buffer.byteLength(file.content, "utf8"),
|
|
})),
|
|
};
|
|
const result = runTrans(spec, "win", ["ps"], gatewayApplyScript(payload), 60_000);
|
|
if (result.exitCode !== 0) {
|
|
return {
|
|
ok: false,
|
|
status: "apply-failed",
|
|
writtenFiles: [],
|
|
stoppedLegacyPids: [],
|
|
taskName: profile.taskName,
|
|
periodicTaskName: profile.periodicTaskName,
|
|
runTaskExitCode: null,
|
|
failureDetail: compactResultTail(result),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
const parsed = parseJsonFromStdout(result.stdout);
|
|
if (parsed === null) {
|
|
return {
|
|
ok: false,
|
|
status: "invalid-apply-json",
|
|
writtenFiles: [],
|
|
stoppedLegacyPids: [],
|
|
taskName: profile.taskName,
|
|
periodicTaskName: profile.periodicTaskName,
|
|
runTaskExitCode: null,
|
|
failureDetail: short(result.stdout, 500),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
return {
|
|
ok: parsed.ok === true,
|
|
status: typeof parsed.status === "string" ? parsed.status : "unknown",
|
|
writtenFiles: stringArray(parsed.writtenFiles),
|
|
stoppedLegacyPids: numberArray(parsed.stoppedLegacyPids),
|
|
taskName: profile.taskName,
|
|
periodicTaskName: profile.periodicTaskName,
|
|
runTaskExitCode: typeof parsed.runTaskExitCode === "number" ? parsed.runTaskExitCode : null,
|
|
failureDetail: typeof parsed.failureDetail === "string" && parsed.failureDetail.length > 0 ? short(parsed.failureDetail, 500) : undefined,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function renderHwpodGatewayPlan(value: HwpodGatewayPlan): string {
|
|
const profileRows = value.profile === null
|
|
? [["profile", "missing"]]
|
|
: [
|
|
["cloudUrl", value.profile.cloudUrl],
|
|
["websocketUrl", value.profile.websocketUrl],
|
|
["hwpodId", value.profile.hwpodId],
|
|
["nodeId", value.profile.nodeId],
|
|
["resourceId", value.profile.resourceId],
|
|
["capabilityId", value.profile.capabilityId],
|
|
["taskName", value.profile.taskName],
|
|
["periodicTaskName", value.profile.periodicTaskName],
|
|
["runKeyName", value.profile.runKeyName],
|
|
["bunPath", value.profile.bunPath],
|
|
["reconnectBaseMs", value.profile.reconnectBaseMs],
|
|
["reconnectMaxMs", value.profile.reconnectMaxMs],
|
|
["runtimeRoot", value.runtimeRoot],
|
|
];
|
|
const remoteRows = value.remoteStatus === null
|
|
? [["remote", "not-read"]]
|
|
: [
|
|
["status", value.remoteStatus.status],
|
|
["rootExists", value.remoteStatus.rootExists],
|
|
["expectedProcessCount", value.remoteStatus.expectedProcessCount],
|
|
["legacyProcessCount", value.remoteStatus.legacyProcessCount],
|
|
["taskStates", Object.entries(value.remoteStatus.taskStates).map(([key, state]) => `${key}=${state}`).join(",") || "-"],
|
|
["fileStates", Object.entries(value.remoteStatus.fileStates).map(([key, state]) => `${key}=${state}`).join(",") || "-"],
|
|
];
|
|
const applyRows = value.remoteApply === null
|
|
? [["apply", "not-run"]]
|
|
: [
|
|
["status", value.remoteApply.status],
|
|
["writtenFiles", value.remoteApply.writtenFiles.length],
|
|
["stoppedLegacyPids", value.remoteApply.stoppedLegacyPids.join(",") || "-"],
|
|
["runTaskExitCode", value.remoteApply.runTaskExitCode ?? "-"],
|
|
["failureDetail", value.remoteApply.failureDetail ?? "-"],
|
|
];
|
|
return [
|
|
`hwlab nodes hwpod-preinstall ${commandAction(value.command)} (${value.status})`,
|
|
"",
|
|
hwpodTable(["NODE", "LANE", "OK", "RUNTIME"], [[value.node, value.lane, value.ok, value.runtimeRoot]]),
|
|
"",
|
|
"PROFILE",
|
|
hwpodTable(["FIELD", "VALUE"], profileRows.map(([key, item]) => [key, short(String(item), 140)])),
|
|
"",
|
|
"RUNNER FILES",
|
|
value.files.length === 0
|
|
? " none"
|
|
: hwpodTable(["PATH", "KIND", "BYTES", "HASH"], value.files.map((file) => [file.path, file.kind, file.bytes, `${file.sha256.slice(0, 19)}...`])),
|
|
"",
|
|
"REMOTE STATUS",
|
|
hwpodTable(["FIELD", "VALUE"], remoteRows.map(([key, item]) => [key, short(String(item), 180)])),
|
|
"",
|
|
"REMOTE APPLY",
|
|
hwpodTable(["FIELD", "VALUE"], applyRows.map(([key, item]) => [key, short(String(item), 180)])),
|
|
"",
|
|
"LEGACY RETIRE",
|
|
...value.legacyRetire.map((item) => ` - ${item}`),
|
|
...(value.remoteStatus !== null && value.remoteStatus.legacyProcessCommands.length > 0 ? [" legacyProcessCommands:", ...value.remoteStatus.legacyProcessCommands.map((item) => ` ${item}`)] : []),
|
|
...(value.blockers.length === 0 ? [] : ["", "BLOCKERS", ...value.blockers.map((item) => ` - ${item}`)]),
|
|
"",
|
|
"NEXT",
|
|
` plan: ${value.next.plan}`,
|
|
` status: ${value.next.status}`,
|
|
` apply: ${value.next.apply}`,
|
|
"DISCLOSURE",
|
|
" valuesRedacted=true; secret values, raw logs, full process dumps, and complete file content are not printed.",
|
|
].join("\n");
|
|
}
|
|
|
|
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<string, string> {
|
|
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 numberAt(value: unknown, path: string): number | null {
|
|
const found = valueAtPath(value, path);
|
|
return typeof found === "number" && Number.isFinite(found) ? 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<string, unknown> | null {
|
|
return ref !== undefined && isRecord(ref.target) ? ref.target : null;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
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 {
|
|
for (const action of ["gateway-apply", "gateway-status", "gateway-plan", "status", "plan"]) {
|
|
if (command.includes(` ${action} `) || command.endsWith(` ${action}`)) return action;
|
|
}
|
|
return "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;
|
|
}
|
|
|
|
function sha256Fingerprint(value: string | Buffer): string {
|
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
}
|
|
|
|
function runTrans(spec: HwlabRuntimeLaneSpec, planeOrWorkspace: string, args: string[], input: string, timeoutMs: number): CommandResult {
|
|
const route = planeOrWorkspace === "win" ? `${spec.nodeId}:win` : `${spec.nodeId}:${planeOrWorkspace}`;
|
|
return runCommand(["trans", route, ...args], repoRoot, { input, timeoutMs });
|
|
}
|
|
|
|
function gatewaySourceBundleScript(): string {
|
|
const evalScript = [
|
|
"const fs=require('fs');",
|
|
"const crypto=require('crypto');",
|
|
"const paths=['tools/hwpod-node.ts','tools/src/hwpod-node-lib.ts','tools/src/hwpod-node-ops-contract.ts'];",
|
|
"const files=paths.map((path)=>{const buf=fs.readFileSync(path);return {path,bytes:buf.length,sha256:'sha256:'+crypto.createHash('sha256').update(buf).digest('hex'),base64:buf.toString('base64')}});",
|
|
"console.log(JSON.stringify({ok:true,files,valuesRedacted:true}));",
|
|
].join("");
|
|
return `bun --eval ${shQuote(evalScript)}`;
|
|
}
|
|
|
|
function gatewayRemoteStatusScript(profile: HwpodGatewayProfileSummary): string {
|
|
return `
|
|
$ErrorActionPreference = 'Stop'
|
|
$Root = ${psQuote(profile.runtimeRoot)}
|
|
$CloudUrl = ${psQuote(profile.cloudUrl)}
|
|
$WsUrl = ${psQuote(profile.websocketUrl)}
|
|
$NodeId = ${psQuote(profile.nodeId)}
|
|
$TaskName = ${psQuote(profile.taskName)}
|
|
$PeriodicTaskName = ${psQuote(profile.periodicTaskName)}
|
|
$Files = @('tools\\hwpod-node.ts','tools\\src\\hwpod-node-lib.ts','tools\\src\\hwpod-node-ops-contract.ts','run-node-ws-detached.cmd','start-node-ws.cmd','status-node-ws.cmd','install-node-ws.cmd','run-node-ws.vbs','ensure-node-ws.vbs')
|
|
function TaskState([string]$Name) {
|
|
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
|
if ($null -eq $task) { return 'missing' }
|
|
return [string]$task.State
|
|
}
|
|
$fileStates = [ordered]@{}
|
|
foreach ($rel in $Files) {
|
|
$path = Join-Path $Root $rel
|
|
if (Test-Path $path) {
|
|
$hash = (Get-FileHash -Algorithm SHA256 $path).Hash.ToLowerInvariant()
|
|
$fileStates[$rel] = 'sha256:' + $hash
|
|
} else {
|
|
$fileStates[$rel] = 'missing'
|
|
}
|
|
}
|
|
$processes = @(Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | Where-Object { ($_.CommandLine -match 'hwpod-node\\.ts\\s+connect') -and ($_.CommandLine -match [regex]::Escape($NodeId)) })
|
|
$expected = @($processes | Where-Object { ($_.CommandLine -match [regex]::Escape($CloudUrl)) -and ($_.CommandLine -match [regex]::Escape($WsUrl)) })
|
|
$legacy = @($processes | Where-Object { -not (($_.CommandLine -match [regex]::Escape($CloudUrl)) -and ($_.CommandLine -match [regex]::Escape($WsUrl))) })
|
|
$taskStates = [ordered]@{
|
|
$TaskName = TaskState $TaskName
|
|
$PeriodicTaskName = TaskState $PeriodicTaskName
|
|
}
|
|
$rootExists = Test-Path $Root
|
|
$filesReady = -not ($fileStates.Values | Where-Object { $_ -eq 'missing' } | Select-Object -First 1)
|
|
$tasksReady = -not ($taskStates.Values | Where-Object { $_ -eq 'missing' } | Select-Object -First 1)
|
|
$ok = $rootExists -and $filesReady -and $tasksReady -and ($expected.Count -gt 0) -and ($legacy.Count -eq 0)
|
|
$result = [ordered]@{
|
|
ok = [bool]$ok
|
|
status = $(if ($ok) { 'ready' } elseif ($legacy.Count -gt 0) { 'legacy-drift' } else { 'blocked' })
|
|
rootExists = [bool]$rootExists
|
|
expectedProcessCount = [int]$expected.Count
|
|
legacyProcessCount = [int]$legacy.Count
|
|
taskStates = $taskStates
|
|
fileStates = $fileStates
|
|
legacyProcessCommands = @($legacy | ForEach-Object { $_.CommandLine })
|
|
valuesRedacted = $true
|
|
}
|
|
$result | ConvertTo-Json -Compress -Depth 8
|
|
`.trim();
|
|
}
|
|
|
|
function gatewayApplyScript(payload: Record<string, unknown>): string {
|
|
const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
return `
|
|
$ErrorActionPreference = 'Stop'
|
|
$payloadJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(${psQuote(payloadB64)}))
|
|
$payload = $payloadJson | ConvertFrom-Json
|
|
$Root = [string]$payload.root
|
|
$CloudUrl = [string]$payload.profile.cloudUrl
|
|
$WsUrl = [string]$payload.profile.websocketUrl
|
|
$NodeId = [string]$payload.profile.nodeId
|
|
$TaskName = [string]$payload.profile.taskName
|
|
$PeriodicTaskName = [string]$payload.profile.periodicTaskName
|
|
$RunKeyName = [string]$payload.profile.runKeyName
|
|
$RunVbs = Join-Path $Root 'run-node-ws.vbs'
|
|
$EnsureVbs = Join-Path $Root 'ensure-node-ws.vbs'
|
|
$RunTask = 'wscript.exe //B //Nologo "' + $RunVbs + '"'
|
|
$EnsureTask = 'wscript.exe //B //Nologo "' + $EnsureVbs + '"'
|
|
New-Item -ItemType Directory -Force -Path $Root, (Join-Path $Root 'logs'), (Join-Path $Root '.state'), (Join-Path $Root 'tools'), (Join-Path $Root 'tools\\src') | Out-Null
|
|
function RunNativeCapture([scriptblock]$Block) {
|
|
$previous = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
try {
|
|
$output = @(& $Block 2>&1)
|
|
$exitCode = $global:LASTEXITCODE
|
|
return [pscustomobject]@{
|
|
exitCode = [int]$exitCode
|
|
output = (($output | ForEach-Object { [string]$_ }) -join "\`n")
|
|
}
|
|
} finally {
|
|
$ErrorActionPreference = $previous
|
|
}
|
|
}
|
|
function EndTaskIfPresent([string]$Name) {
|
|
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
|
if ($null -eq $task) { return }
|
|
if ([string]$task.State -eq 'Running') {
|
|
$null = RunNativeCapture { schtasks /End /TN $Name }
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
}
|
|
function WritePayloadFile($item) {
|
|
$target = Join-Path $Root ([string]$item.path)
|
|
$parent = Split-Path -Parent $target
|
|
if (-not [string]::IsNullOrWhiteSpace($parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
|
|
$bytes = [System.Convert]::FromBase64String([string]$item.base64)
|
|
[System.IO.File]::WriteAllBytes($target, $bytes)
|
|
$actual = 'sha256:' + (Get-FileHash -Algorithm SHA256 $target).Hash.ToLowerInvariant()
|
|
if ($actual -ne [string]$item.sha256) { throw "sha mismatch for $target expected=$($item.sha256) actual=$actual" }
|
|
return [string]$item.path
|
|
}
|
|
$stopped = @()
|
|
EndTaskIfPresent $PeriodicTaskName
|
|
EndTaskIfPresent $TaskName
|
|
$processes = @(Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | Where-Object { ($_.CommandLine -match 'hwpod-node\\.ts\\s+connect') -and ($_.CommandLine -match [regex]::Escape($NodeId)) })
|
|
foreach ($proc in $processes) {
|
|
$stopped += [int]$proc.ProcessId
|
|
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
|
|
}
|
|
$written = @()
|
|
foreach ($item in @($payload.sourceFiles)) { $written += WritePayloadFile $item }
|
|
foreach ($item in @($payload.runnerFiles)) { $written += WritePayloadFile $item }
|
|
$runCreate = RunNativeCapture { schtasks /Create /TN $TaskName /SC ONCE /ST 00:00 /TR $RunTask /F }
|
|
if ($runCreate.exitCode -ne 0) { throw "run task create failed: $($runCreate.output)" }
|
|
$periodicCreate = RunNativeCapture { schtasks /Create /TN $PeriodicTaskName /SC MINUTE /MO 5 /TR $EnsureTask /F }
|
|
if ($periodicCreate.exitCode -ne 0) { throw "periodic task create failed: $($periodicCreate.output)" }
|
|
$runKey = RunNativeCapture { reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v $RunKeyName /t REG_SZ /d $EnsureTask /f }
|
|
if ($runKey.exitCode -ne 0) { throw "HKCU Run update failed: $($runKey.output)" }
|
|
$runOut = RunNativeCapture { schtasks /Run /TN $TaskName }
|
|
$runExit = $runOut.exitCode
|
|
Start-Sleep -Seconds 3
|
|
$expected = @(Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | Where-Object { ($_.CommandLine -match 'hwpod-node\\.ts\\s+connect') -and ($_.CommandLine -match [regex]::Escape($NodeId)) -and ($_.CommandLine -match [regex]::Escape($CloudUrl)) -and ($_.CommandLine -match [regex]::Escape($WsUrl)) })
|
|
$result = [ordered]@{
|
|
ok = [bool](($runExit -eq 0) -and ($expected.Count -gt 0))
|
|
status = $(if (($runExit -eq 0) -and ($expected.Count -gt 0)) { 'applied' } else { 'started-pending' })
|
|
writtenFiles = $written
|
|
stoppedLegacyPids = $stopped
|
|
taskName = $TaskName
|
|
periodicTaskName = $PeriodicTaskName
|
|
runTaskExitCode = $runExit
|
|
failureDetail = $(if (($runExit -eq 0) -and ($expected.Count -gt 0)) { $null } else { "runOutput=$($runOut.output)" })
|
|
valuesRedacted = $true
|
|
}
|
|
$result | ConvertTo-Json -Compress -Depth 8
|
|
`.trim();
|
|
}
|
|
|
|
function gatewayRunDetachedCmd(profile: HwpodGatewayProfileSummary): string {
|
|
return crlf([
|
|
"@echo off",
|
|
"setlocal EnableExtensions",
|
|
"chcp 65001 >nul",
|
|
`set "ROOT=${profile.runtimeRoot}"`,
|
|
`set "HWLAB_GATEWAY_ID=${profile.gatewayId}"`,
|
|
`set "HWLAB_GATEWAY_SESSION_ID=${profile.sessionId}"`,
|
|
`set "HWLAB_GATEWAY_RESOURCE_ID=${profile.resourceId}"`,
|
|
`set "HWLAB_GATEWAY_CMD_CAPABILITY_ID=${profile.capabilityId}"`,
|
|
`set "HWLAB_HWPOD_ID=${profile.hwpodId}"`,
|
|
`set "HWLAB_HWPOD_NODE_ID=${profile.nodeId}"`,
|
|
`set "HWLAB_HWPOD_NODE_WS_URL=${profile.websocketUrl}"`,
|
|
`set "HWLAB_HWPOD_NODE_CLOUD_URL=${profile.cloudUrl}"`,
|
|
`set "BUN_EXE=${profile.bunPath}"`,
|
|
"cd /d \"%ROOT%\" || exit /b 2",
|
|
"if not exist logs mkdir logs",
|
|
"if not exist .state mkdir .state",
|
|
"echo {\"ok\":true,\"action\":\"hwpod-node.ws.v03.runner\",\"status\":\"starting\"}>>\"%ROOT%\\logs\\hwpod-node-ws.lifecycle.log\"",
|
|
`"${"%BUN_EXE%"}" tools\\hwpod-node.ts connect --ws-url ${profile.websocketUrl} --cloud-url ${profile.cloudUrl} --node-id ${profile.nodeId} --reconnect-base-ms ${profile.reconnectBaseMs} --reconnect-max-ms ${profile.reconnectMaxMs} 1>>"%ROOT%\\logs\\hwpod-node-ws.stdout.log" 2>>"%ROOT%\\logs\\hwpod-node-ws.stderr.log"`,
|
|
"set \"EXIT_CODE=%ERRORLEVEL%\"",
|
|
"echo {\"ok\":false,\"action\":\"hwpod-node.ws.v03.runner\",\"status\":\"exited\",\"exitCode\":%EXIT_CODE%}>>\"%ROOT%\\logs\\hwpod-node-ws.lifecycle.log\"",
|
|
"exit /b %EXIT_CODE%",
|
|
]);
|
|
}
|
|
|
|
function gatewayStartCmd(profile: HwpodGatewayProfileSummary): string {
|
|
return crlf([
|
|
"@echo off",
|
|
"setlocal EnableExtensions",
|
|
"chcp 65001 >nul",
|
|
`set "ROOT=${profile.runtimeRoot}"`,
|
|
`set "TASK_NAME=${profile.taskName}"`,
|
|
"cd /d \"%ROOT%\" || (",
|
|
" echo {\"ok\":false,\"action\":\"hwpod-node.ws.v03.start\",\"status\":\"blocked\",\"message\":\"runtime root not found\"}",
|
|
" exit /b 2",
|
|
")",
|
|
"schtasks /Run /TN \"%TASK_NAME%\" > .state\\hwpod-node-ws.schtasks-run.txt 2>&1",
|
|
"set \"RUN_EXIT=%ERRORLEVEL%\"",
|
|
"ping -n 4 127.0.0.1 >nul",
|
|
`echo {"ok":true,"action":"hwpod-node.ws.v03.start","status":"submitted","taskName":"${jsonEscape(profile.taskName)}","cloudUrl":"${jsonEscape(profile.cloudUrl)}","nodeId":"${jsonEscape(profile.nodeId)}","runExit":%RUN_EXIT%}`,
|
|
"exit /b %RUN_EXIT%",
|
|
]);
|
|
}
|
|
|
|
function gatewayStatusCmd(profile: HwpodGatewayProfileSummary): string {
|
|
return crlf([
|
|
"@echo off",
|
|
"setlocal EnableExtensions",
|
|
"chcp 65001 >nul",
|
|
`echo {"ok":true,"action":"hwpod-node.ws.v03.status","root":"${jsonEscape(profile.runtimeRoot)}","cloudUrl":"${jsonEscape(profile.cloudUrl)}","websocketUrl":"${jsonEscape(profile.websocketUrl)}","nodeId":"${jsonEscape(profile.nodeId)}","statusCommand":"bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-status --node ${jsonEscape(profile.hwlabNode)} --lane ${jsonEscape(profile.lane)}","valuesRedacted":true}`,
|
|
]);
|
|
}
|
|
|
|
function gatewayInstallCmd(profile: HwpodGatewayProfileSummary): string {
|
|
return crlf([
|
|
"@echo off",
|
|
"setlocal EnableExtensions",
|
|
"chcp 65001 >nul",
|
|
`set "ROOT=${profile.runtimeRoot}"`,
|
|
`set "TASK_NAME=${profile.taskName}"`,
|
|
`set "PERIODIC_TASK_NAME=${profile.periodicTaskName}"`,
|
|
`set "RUN_KEY_NAME=${profile.runKeyName}"`,
|
|
"set \"RUN_TASK=wscript.exe //B //Nologo %ROOT%\\run-node-ws.vbs\"",
|
|
"set \"ENSURE_TASK=wscript.exe //B //Nologo %ROOT%\\ensure-node-ws.vbs\"",
|
|
"cd /d \"%ROOT%\" || exit /b 2",
|
|
"schtasks /Create /TN \"%TASK_NAME%\" /SC ONCE /ST 00:00 /TR \"%RUN_TASK%\" /F || exit /b 2",
|
|
"schtasks /Create /TN \"%PERIODIC_TASK_NAME%\" /SC MINUTE /MO 5 /TR \"%ENSURE_TASK%\" /F || exit /b 2",
|
|
"reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v \"%RUN_KEY_NAME%\" /t REG_SZ /d \"%ENSURE_TASK%\" /f || exit /b 2",
|
|
`echo {"ok":true,"action":"hwpod-node.ws.v03.install","status":"installed","taskName":"${jsonEscape(profile.taskName)}","periodicTaskName":"${jsonEscape(profile.periodicTaskName)}","valuesRedacted":true}`,
|
|
"exit /b 0",
|
|
]);
|
|
}
|
|
|
|
function gatewayRunVbs(profile: HwpodGatewayProfileSummary): string {
|
|
return crlf([
|
|
"Set sh = CreateObject(\"WScript.Shell\")",
|
|
`sh.CurrentDirectory = "${vbsEscape(profile.runtimeRoot)}"`,
|
|
`sh.Run "cmd.exe /c ""${vbsEscape(`${profile.runtimeRoot}\\run-node-ws-detached.cmd`)}""", 0, False`,
|
|
]);
|
|
}
|
|
|
|
function gatewayEnsureVbs(profile: HwpodGatewayProfileSummary): string {
|
|
return crlf([
|
|
"Set sh = CreateObject(\"WScript.Shell\")",
|
|
`sh.CurrentDirectory = "${vbsEscape(profile.runtimeRoot)}"`,
|
|
`sh.Run "cmd.exe /c ""${vbsEscape(`${profile.runtimeRoot}\\start-node-ws.cmd`)}""", 0, False`,
|
|
]);
|
|
}
|
|
|
|
function parseJsonFromStdout(stdout: string): Record<string, unknown> | null {
|
|
const start = stdout.indexOf("{");
|
|
const end = stdout.lastIndexOf("}");
|
|
if (start === -1 || end <= start) return null;
|
|
try {
|
|
const parsed = JSON.parse(stdout.slice(start, end + 1)) as unknown;
|
|
return isRecord(parsed) ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function compactResultTail(result: CommandResult): string {
|
|
return short(`exit=${result.exitCode} timedOut=${result.timedOut} stdout=${result.stdout.slice(-400)} stderr=${result.stderr.slice(-400)}`, 900);
|
|
}
|
|
|
|
function numberFromUnknown(value: unknown): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
function numberArray(value: unknown): number[] {
|
|
return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number" && Number.isFinite(item)) : [];
|
|
}
|
|
|
|
function stringArray(value: unknown): string[] {
|
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
}
|
|
|
|
function stringRecord(value: unknown): Record<string, string> {
|
|
if (!isRecord(value)) return {};
|
|
const result: Record<string, string> = {};
|
|
for (const [key, item] of Object.entries(value)) result[key] = typeof item === "string" ? item : String(item);
|
|
return result;
|
|
}
|
|
|
|
function shQuote(value: string): string {
|
|
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
}
|
|
|
|
function psQuote(value: string): string {
|
|
return `'${value.replace(/'/gu, "''")}'`;
|
|
}
|
|
|
|
function crlf(lines: readonly string[]): string {
|
|
return `${lines.join("\r\n")}\r\n`;
|
|
}
|
|
|
|
function jsonEscape(value: string): string {
|
|
return value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"");
|
|
}
|
|
|
|
function vbsEscape(value: string): string {
|
|
return value.replace(/"/gu, "\"\"");
|
|
}
|