571 lines
22 KiB
TypeScript
571 lines
22 KiB
TypeScript
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
|
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
|
|
// Responsibility: Redacted YAML configRef graph for web-probe sentinel plan/status.
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { rootPath } from "./config";
|
|
import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey } from "./hwlab-node-lanes";
|
|
import { resolveWebProbeSentinel, webProbeSentinelRegistryRows, type WebProbeSentinelRegistryRow } from "./hwlab-node-web-sentinel-resolver";
|
|
import type { RenderedCliResult } from "./output";
|
|
|
|
export type WebProbeSentinelConfigAction = "plan" | "status";
|
|
|
|
export interface WebProbeSentinelConfigPlan {
|
|
readonly ok: boolean;
|
|
readonly command: string;
|
|
readonly status: "ready" | "blocked" | "disabled";
|
|
readonly node: string;
|
|
readonly lane: string;
|
|
readonly rootPath: string;
|
|
readonly sentinelId: string | null;
|
|
readonly enabled: boolean;
|
|
readonly sentinels: readonly WebProbeSentinelRegistryRow[];
|
|
readonly refs: readonly WebProbeSentinelConfigRefStatus[];
|
|
readonly conflicts: readonly string[];
|
|
readonly next: Record<string, string>;
|
|
readonly valuesRedacted: true;
|
|
}
|
|
|
|
export interface WebProbeSentinelConfigRefStatus {
|
|
readonly key: HwlabRuntimeWebProbeSentinelConfigRefKey;
|
|
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 WebProbeSentinelConfigRefStatus {
|
|
readonly target: unknown;
|
|
}
|
|
|
|
interface RequiredTargetShape {
|
|
readonly kind: "object" | "array";
|
|
readonly requiredPaths: readonly string[];
|
|
}
|
|
|
|
const REQUIRED_TARGET_SHAPES: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, RequiredTargetShape> = {
|
|
runtime: {
|
|
kind: "object",
|
|
requiredPaths: [
|
|
"target.node",
|
|
"target.lane",
|
|
"target.publicOriginRef",
|
|
"target.observeWrapperRef",
|
|
"namespace",
|
|
"serviceAccountName",
|
|
"deploymentName",
|
|
"serviceName",
|
|
"listenHost",
|
|
"servicePort",
|
|
"pvcName",
|
|
"pvcStorage",
|
|
"stateRoot",
|
|
"imageRef",
|
|
"replicas",
|
|
"healthPath",
|
|
"metricsPath",
|
|
"scheduler.intervalMs",
|
|
"scheduler.heartbeatStaleSeconds",
|
|
"scheduler.maxConcurrentRuns",
|
|
"sqlite.path",
|
|
"sqlite.busyTimeoutMs",
|
|
],
|
|
},
|
|
scenarios: {
|
|
kind: "array",
|
|
requiredPaths: [
|
|
"id",
|
|
"enabled",
|
|
"cadence",
|
|
"observeTargetPath",
|
|
"sampleIntervalMs",
|
|
"screenshotIntervalMs",
|
|
"maxRunSeconds",
|
|
"providerProfile",
|
|
"providerProfileMode",
|
|
"promptSetRef",
|
|
"reportViewRef",
|
|
"commandSequence[0].type",
|
|
],
|
|
},
|
|
promptSet: {
|
|
kind: "object",
|
|
requiredPaths: ["id", "providerProfile", "providerProfileMode", "promptSourceRef", "promptSourceKey", "promptCount", "redaction"],
|
|
},
|
|
reportViews: {
|
|
kind: "object",
|
|
requiredPaths: ["defaultView", "views[0]", "pageSize", "maxPageSize", "rawAccess", "redaction.prompt", "redaction.secrets"],
|
|
},
|
|
publicExposure: {
|
|
kind: "object",
|
|
requiredPaths: [
|
|
"enabled",
|
|
"mode",
|
|
"publicBaseUrl",
|
|
"hostname",
|
|
"expectedA",
|
|
"frpc.deploymentName",
|
|
"frpc.image",
|
|
"frpc.serverAddr",
|
|
"frpc.serverPort",
|
|
"frpc.tokenSourceRef",
|
|
"frpc.tokenSourceKey",
|
|
"frpc.secretName",
|
|
"frpc.secretKey",
|
|
"frpc.httpProxy.name",
|
|
"frpc.httpProxy.localIP",
|
|
"frpc.httpProxy.localPort",
|
|
"caddy.route",
|
|
"caddy.configPath",
|
|
"caddy.serviceName",
|
|
"caddy.managedBlockOwner",
|
|
],
|
|
},
|
|
cicd: {
|
|
kind: "object",
|
|
requiredPaths: [
|
|
"controlPlaneConfigRef",
|
|
"source.repository",
|
|
"source.branch",
|
|
"source.gitMirrorReadUrl",
|
|
"source.buildContext",
|
|
"source.entrypoint",
|
|
"gitopsPath",
|
|
"argo.namespace",
|
|
"argo.projectName",
|
|
"argo.applicationName",
|
|
"argo.repoURL",
|
|
"argo.targetRevision",
|
|
"image.repository",
|
|
"image.tagSource",
|
|
"image.baseImageRef",
|
|
"image.envRecipeRef",
|
|
"maintenance.startCommand",
|
|
"maintenance.stopCommand",
|
|
"targetValidation.scenarioId",
|
|
"targetValidation.maxSeconds",
|
|
"targetValidation.serviceUnavailablePolicy",
|
|
],
|
|
},
|
|
secrets: {
|
|
kind: "object",
|
|
requiredPaths: [
|
|
"sources[0].purpose",
|
|
"sources[0].sourceRef",
|
|
"sources[0].sourceKey",
|
|
"runtimeSecrets[0].name",
|
|
"runtimeSecrets[0].namespace",
|
|
"runtimeSecrets[0].data[0].sourcePurpose",
|
|
"runtimeSecrets[0].data[0].targetKey",
|
|
],
|
|
},
|
|
};
|
|
|
|
export function webProbeSentinelConfigPlan(spec: HwlabRuntimeLaneSpec, action: WebProbeSentinelConfigAction, sentinelId: string | null = null): WebProbeSentinelConfigPlan {
|
|
const command = `web-probe sentinel ${action} --node ${spec.nodeId} --lane ${spec.lane}${sentinelId === null ? "" : ` --sentinel ${sentinelId}`}`;
|
|
const registry = webProbeSentinelRegistryRows(spec);
|
|
const registryPath = `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels`;
|
|
if (sentinelId === null && registry.length > 1) {
|
|
const enabled = registry.some((item) => item.enabled);
|
|
return {
|
|
ok: enabled,
|
|
command,
|
|
status: enabled ? "ready" : "disabled",
|
|
node: spec.nodeId,
|
|
lane: spec.lane,
|
|
rootPath: registryPath,
|
|
sentinelId: null,
|
|
enabled,
|
|
sentinels: registry,
|
|
refs: [],
|
|
conflicts: [],
|
|
next: sentinelNext(spec.nodeId, spec.lane, registry[0]?.id ?? null),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
if (registry.length === 0) {
|
|
return {
|
|
ok: false,
|
|
command,
|
|
status: "blocked",
|
|
node: spec.nodeId,
|
|
lane: spec.lane,
|
|
rootPath: registryPath,
|
|
sentinelId,
|
|
enabled: false,
|
|
sentinels: [],
|
|
refs: [],
|
|
conflicts: [`${registryPath} is missing`],
|
|
next: sentinelNext(spec.nodeId, spec.lane, sentinelId),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
const selected = resolveWebProbeSentinel(spec, sentinelId);
|
|
const refs = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => readSentinelConfigRef(key, selected.configRefs[key]));
|
|
const conflicts = selected.enabled ? crossReferenceConflicts(spec, refs) : [];
|
|
const refBlocked = refs.some((ref) => !ref.present || !ref.targetPresent || ref.missingFields.length > 0 || ref.conflicts.length > 0 || ref.error !== null);
|
|
const ok = selected.enabled && !refBlocked && conflicts.length === 0;
|
|
return {
|
|
ok,
|
|
command,
|
|
status: selected.enabled ? ok ? "ready" : "blocked" : "disabled",
|
|
node: spec.nodeId,
|
|
lane: spec.lane,
|
|
rootPath: selected.rootPath,
|
|
sentinelId: selected.id,
|
|
enabled: selected.enabled,
|
|
sentinels: registry,
|
|
refs: refs.map(stripInternalTarget),
|
|
conflicts,
|
|
next: sentinelNext(spec.nodeId, spec.lane, selected.id),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
export function withWebProbeSentinelConfigRendered(value: WebProbeSentinelConfigPlan): RenderedCliResult {
|
|
return {
|
|
ok: value.ok,
|
|
command: value.command,
|
|
contentType: "text/plain",
|
|
renderedText: renderWebProbeSentinelConfigPlan(value),
|
|
};
|
|
}
|
|
|
|
function readSentinelConfigRef(key: HwlabRuntimeWebProbeSentinelConfigRefKey, 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: HwlabRuntimeWebProbeSentinelConfigRefKey, 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: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string[] {
|
|
const shape = REQUIRED_TARGET_SHAPES[key];
|
|
if (shape.kind === "array") {
|
|
const items = key === "scenarios" && isRecord(target) ? [target] : Array.isArray(target) ? target : null;
|
|
if (items === null) return [`expected ${shape.kind}`];
|
|
if (items.length === 0) return ["[0]"];
|
|
return items.flatMap((item, index) => shape.requiredPaths
|
|
.filter((path) => valueAtPath(item, path) === undefined)
|
|
.map((path) => `[${index}].${path}`));
|
|
}
|
|
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 conflicts: string[] = [];
|
|
const runtime = recordTarget(byKey.get("runtime"));
|
|
const scenarios = scenarioTargets(byKey.get("scenarios"));
|
|
const promptSet = recordTarget(byKey.get("promptSet"));
|
|
const cicd = recordTarget(byKey.get("cicd"));
|
|
const secrets = recordTarget(byKey.get("secrets"));
|
|
|
|
if (runtime !== null) {
|
|
requireEquals(conflicts, byKey.get("runtime"), "target.node", spec.nodeId, `selected node ${spec.nodeId}`);
|
|
requireEquals(conflicts, byKey.get("runtime"), "target.lane", spec.lane, `selected lane ${spec.lane}`);
|
|
requireEquals(conflicts, byKey.get("runtime"), "namespace", spec.runtimeNamespace, `selected namespace ${spec.runtimeNamespace}`);
|
|
}
|
|
|
|
const promptSetRef = byKey.get("promptSet")?.ref ?? null;
|
|
const reportViewsRef = byKey.get("reportViews")?.ref ?? null;
|
|
const scenarioIds = new Set<string>();
|
|
const scenarioProviders = new Set<string>();
|
|
for (const [index, scenario] of scenarios.entries()) {
|
|
const id = stringAt(scenario, "id");
|
|
if (id !== null) scenarioIds.add(id);
|
|
const provider = stringAt(scenario, "providerProfile");
|
|
if (provider !== null) scenarioProviders.add(provider);
|
|
if (promptSetRef !== null) requireEquals(conflicts, byKey.get("scenarios"), `[${index}].promptSetRef`, promptSetRef, "promptSet configRef");
|
|
if (reportViewsRef !== null) requireEquals(conflicts, byKey.get("scenarios"), `[${index}].reportViewRef`, reportViewsRef, "reportViews configRef");
|
|
}
|
|
|
|
const promptProvider = promptSet === null ? null : stringAt(promptSet, "providerProfile");
|
|
if (promptProvider !== null && scenarioProviders.size > 0 && !scenarioProviders.has(promptProvider)) {
|
|
conflicts.push(`${byKey.get("promptSet")?.file}#${byKey.get("promptSet")?.path}.providerProfile=${promptProvider} does not match scenario providerProfile set ${Array.from(scenarioProviders).join(",")}`);
|
|
}
|
|
|
|
const validationScenarioId = cicd === null ? null : stringAt(cicd, "targetValidation.scenarioId");
|
|
if (validationScenarioId !== null && !scenarioIds.has(validationScenarioId)) {
|
|
conflicts.push(`${byKey.get("cicd")?.file}#${byKey.get("cicd")?.path}.targetValidation.scenarioId=${validationScenarioId} is not declared in scenarios`);
|
|
}
|
|
|
|
const runtimeNamespace = runtime === null ? null : stringAt(runtime, "namespace");
|
|
const runtimeSecrets = secrets === null ? [] : arrayAt(secrets, "runtimeSecrets");
|
|
if (runtimeNamespace !== null) {
|
|
for (const [index, item] of runtimeSecrets.entries()) {
|
|
const namespace = stringAt(item, "namespace");
|
|
if (namespace !== null && namespace !== runtimeNamespace) {
|
|
conflicts.push(`${byKey.get("secrets")?.file}#${byKey.get("secrets")?.path}.runtimeSecrets[${index}].namespace=${namespace} does not match runtime namespace ${runtimeNamespace}`);
|
|
}
|
|
}
|
|
}
|
|
return conflicts;
|
|
}
|
|
|
|
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 stripInternalTarget(ref: InternalConfigRefStatus): WebProbeSentinelConfigRefStatus {
|
|
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: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string {
|
|
if (target === undefined) return "target=missing";
|
|
if (key === "scenarios") {
|
|
const items = isRecord(target) ? [target] : Array.isArray(target) ? target : [];
|
|
const ids = items.map((item) => stringAt(item, "id")).filter((item): item is string => item !== null).slice(0, 4);
|
|
const cadences = items.map((item) => stringAt(item, "cadence")).filter((item): item is string => item !== null).slice(0, 4);
|
|
const checks = items.flatMap((item) => arrayAt(item, "sessionInvarianceChecks"));
|
|
const afterRounds = checks
|
|
.map((item) => {
|
|
const value = isRecord(item) ? item.afterRound : null;
|
|
return typeof value === "number" ? String(value) : null;
|
|
})
|
|
.filter((item): item is string => item !== null)
|
|
.slice(0, 8);
|
|
return `items=${items.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"} sessionInvarianceChecks=${checks.length} afterRound=${afterRounds.join(",") || "-"}`;
|
|
}
|
|
if (!isRecord(target)) return `kind=${targetKindOf(target)}`;
|
|
if (key === "runtime") return `namespace=${textAt(target, "namespace")} service=${textAt(target, "serviceName")} image=${short(textAt(target, "imageRef"), 48)}`;
|
|
if (key === "promptSet") return `id=${textAt(target, "id")} provider=${textAt(target, "providerProfile")} prompts=${textAt(target, "promptCount")} markers=${arrayAt(target, "expectedMarkers").slice(0, 12).join(",") || "-"} source=${textAt(target, "promptSourceRef")}:${textAt(target, "promptSourceKey")}`;
|
|
if (key === "reportViews") return `default=${textAt(target, "defaultView")} views=${arrayAt(target, "views").length}`;
|
|
if (key === "publicExposure") return `enabled=${textAt(target, "enabled")} mode=${textAt(target, "mode")} url=${textAt(target, "publicBaseUrl")}`;
|
|
if (key === "cicd") return `gitops=${textAt(target, "gitopsPath")} image=${textAt(target, "image.repository")}:${textAt(target, "image.tagSource")}`;
|
|
if (key === "secrets") return `sources=${arrayAt(target, "sources").length} runtimeSecrets=${arrayAt(target, "runtimeSecrets").length}`;
|
|
return `keys=${Object.keys(target).length}`;
|
|
}
|
|
|
|
function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): string {
|
|
const blocked = value.ok ? [] : [
|
|
"",
|
|
"Blocked detail:",
|
|
sentinelTable(["KIND", "VALUE"], [
|
|
...value.conflicts.map((item) => ["conflict", short(item, 140)]),
|
|
...value.refs.flatMap((ref) => [
|
|
...(ref.error === null ? [] : [[`${ref.key}.error`, short(ref.error, 140)]]),
|
|
...(ref.missingFields.length === 0 ? [] : [[`${ref.key}.missing`, short(ref.missingFields.join(","), 140)]]),
|
|
...(ref.conflicts.length === 0 ? [] : [[`${ref.key}.conflict`, short(ref.conflicts.join(" | "), 140)]]),
|
|
]),
|
|
]),
|
|
];
|
|
return [
|
|
`web-probe sentinel ${commandAction(value.command)} (${value.status})`,
|
|
"",
|
|
sentinelTable(["NODE", "LANE", "SENTINEL", "ENABLED", "OK", "ROOT"], [[value.node, value.lane, value.sentinelId ?? "registry", value.enabled, value.ok, value.rootPath]]),
|
|
...(value.sentinels.length === 0 ? [] : [
|
|
"",
|
|
sentinelTable(
|
|
["SENTINEL", "ENABLED", "CONFIG_REF"],
|
|
value.sentinels.map((item) => [item.id, item.enabled, short(item.configRef, 110)]),
|
|
),
|
|
]),
|
|
...(value.refs.length === 0 ? [
|
|
"",
|
|
"DRILL_DOWN",
|
|
...value.sentinels.map((item) => ` ${item.id}: bun scripts/cli.ts web-probe sentinel ${commandAction(value.command)} --node ${value.node} --lane ${value.lane} --sentinel ${item.id}`),
|
|
] : [
|
|
"",
|
|
sentinelTable(
|
|
["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, 90),
|
|
]),
|
|
),
|
|
"",
|
|
sentinelTable(
|
|
["KEY", "FILE", "PATH", "BYTES"],
|
|
value.refs.map((ref) => [ref.key, ref.file, ref.path, ref.byteCount ?? "-"]),
|
|
),
|
|
]),
|
|
...blocked,
|
|
"",
|
|
"NEXT",
|
|
` plan: ${value.next.plan}`,
|
|
` status: ${value.next.status}`,
|
|
"DISCLOSURE",
|
|
" valuesRedacted=true; secret values and full YAML objects are not printed.",
|
|
].join("\n");
|
|
}
|
|
|
|
function sentinelNext(node: string, lane: string, sentinelId: string | null): Record<string, string> {
|
|
const suffix = sentinelId === null ? "" : ` --sentinel ${sentinelId}`;
|
|
return {
|
|
plan: `bun scripts/cli.ts web-probe sentinel plan --node ${node} --lane ${lane}${suffix} --dry-run`,
|
|
status: `bun scripts/cli.ts web-probe sentinel status --node ${node} --lane ${lane}${suffix}`,
|
|
};
|
|
}
|
|
|
|
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<string, unknown> | null {
|
|
return ref !== undefined && isRecord(ref.target) ? ref.target : null;
|
|
}
|
|
|
|
function arrayTarget(ref: InternalConfigRefStatus | undefined): Record<string, unknown>[] {
|
|
return ref !== undefined && Array.isArray(ref.target) ? ref.target.filter(isRecord) : [];
|
|
}
|
|
|
|
function scenarioTargets(ref: InternalConfigRefStatus | undefined): Record<string, unknown>[] {
|
|
if (ref === undefined) return [];
|
|
if (Array.isArray(ref.target)) return ref.target.filter(isRecord);
|
|
return isRecord(ref.target) ? [ref.target] : [];
|
|
}
|
|
|
|
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 {
|
|
return command.includes(" status ") ? "status" : "plan";
|
|
}
|
|
|
|
function sentinelTable(headers: string[], rows: unknown[][]): string {
|
|
const normalized = [headers, ...rows.map((row) => row.map((cell) => sentinelText(cell)))];
|
|
const widths = headers.map((_, index) => Math.max(...normalized.map((row) => sentinelText(row[index] ?? "").length)));
|
|
return normalized.map((row) => row.map((cell, index) => sentinelText(cell).padEnd(widths[index])).join(" ").trimEnd()).join("\n");
|
|
}
|
|
|
|
function sentinelText(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)}~`;
|
|
}
|