1102 lines
57 KiB
TypeScript
1102 lines
57 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { resolve } from "node:path";
|
|
import { type UniDeskConfig, repoRoot } from "./config";
|
|
import { runCommand } from "./command";
|
|
import { resolveCliChildJsonCommandResult } from "./cli-child-json-recovery";
|
|
import { coreInternalFetch } from "./microservices";
|
|
import { capture, compactCapture, parseJsonOutput, shQuote } from "./platform-infra-ops-library";
|
|
|
|
type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "internal_goal" | "blocker" | "debt" | "experiment";
|
|
type RequirementRecordType = Exclude<DecisionRecordType, "meeting">;
|
|
type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none";
|
|
type DecisionRecordStatus = "active" | "blocked" | "parked" | "done";
|
|
type DecisionDocumentType = "DCSN" | "GOAL" | "PLAN" | "RPRT" | "ACTN" | "ISSU" | "RETR" | "RQST" | "RESP" | "MINS";
|
|
type DecisionDocumentPriority = "P0" | "P1" | "P2" | "P3";
|
|
|
|
const serviceId = "decision-center";
|
|
const hostK8sConfigPath = "config/unidesk-host-k8s.yaml";
|
|
const typeValues = new Set<DecisionRecordType>(["meeting", "decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
|
|
const requirementTypeValues = new Set<RequirementRecordType>(["decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
|
|
const levelValues = new Set<DecisionRecordLevel>(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]);
|
|
const statusValues = new Set<DecisionRecordStatus>(["active", "blocked", "parked", "done"]);
|
|
const documentTypeValues = new Set<DecisionDocumentType>(["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"]);
|
|
const documentPriorityValues = new Set<DecisionDocumentPriority>(["P0", "P1", "P2", "P3"]);
|
|
|
|
function optionValue(args: string[], names: string[]): string | undefined {
|
|
for (const name of names) {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) continue;
|
|
const raw = args[index + 1];
|
|
if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${name} requires a non-empty value`);
|
|
return raw;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function hasFlag(args: string[], name: string): boolean {
|
|
return args.includes(name);
|
|
}
|
|
|
|
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = obj[key];
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
function numberField(obj: Record<string, unknown>, key: string, path: string): number {
|
|
const value = obj[key];
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${path}.${key} must be a positive number`);
|
|
return value;
|
|
}
|
|
|
|
function readYamlConfig(path = hostK8sConfigPath): Record<string, unknown> {
|
|
return asRecord(Bun.YAML.parse(readFileSync(resolve(repoRoot, path), "utf8")) as unknown, path);
|
|
}
|
|
|
|
function sha256Short(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
function secretSourceSummary(sourcePath: string, key: string): Record<string, unknown> {
|
|
const absolute = resolve(repoRoot, sourcePath);
|
|
if (!existsSync(absolute)) return { sourceRef: sourcePath, key, present: false, fingerprint: null };
|
|
const text = readFileSync(absolute, "utf8");
|
|
const line = text.split(/\r?\n/u).find((item) => item.startsWith(`${key}=`)) ?? "";
|
|
const value = line.length > key.length + 1 ? line.slice(key.length + 1) : "";
|
|
return {
|
|
sourceRef: sourcePath,
|
|
key,
|
|
present: value.length > 0,
|
|
fingerprint: value.length > 0 ? `sha256:${sha256Short(`${key}=${value}`)}` : null,
|
|
};
|
|
}
|
|
|
|
function fileSourceSummary(sourcePath: string): Record<string, unknown> {
|
|
const absolute = sourcePath.startsWith("/") ? sourcePath : resolve(repoRoot, sourcePath);
|
|
if (!existsSync(absolute)) return { sourceRef: sourcePath, present: false, fingerprint: null };
|
|
const value = readFileSync(absolute, "utf8");
|
|
return {
|
|
sourceRef: sourcePath,
|
|
present: value.length > 0,
|
|
fingerprint: value.length > 0 ? `sha256:${sha256Short(value)}` : null,
|
|
};
|
|
}
|
|
|
|
function decisionDeploymentConfigSummary(configPath = hostK8sConfigPath): Record<string, unknown> {
|
|
const parsed = readYamlConfig(configPath);
|
|
const target = asRecord(parsed.target, `${configPath}.target`);
|
|
const runtime = asRecord(parsed.runtime, `${configPath}.runtime`);
|
|
const images = asRecord(parsed.images, `${configPath}.images`);
|
|
const services = asRecord(parsed.services, `${configPath}.services`);
|
|
const decision = asRecord(services.decisionCenter, `${configPath}.services.decisionCenter`);
|
|
const database = asRecord(decision.database, `${configPath}.services.decisionCenter.database`);
|
|
const sourceRef = asRecord(database.sourceRef, `${configPath}.services.decisionCenter.database.sourceRef`);
|
|
const storage = asRecord(decision.storage, `${configPath}.services.decisionCenter.storage`);
|
|
const github = asRecord(storage.github, `${configPath}.services.decisionCenter.storage.github`);
|
|
const ssh = asRecord(github.ssh, `${configPath}.services.decisionCenter.storage.github.ssh`);
|
|
const privateKey = asRecord(ssh.privateKey, `${configPath}.services.decisionCenter.storage.github.ssh.privateKey`);
|
|
const knownHosts = asRecord(ssh.knownHosts, `${configPath}.services.decisionCenter.storage.github.ssh.knownHosts`);
|
|
const privateKeySourceRef = asRecord(privateKey.sourceRef, `${configPath}.services.decisionCenter.storage.github.ssh.privateKey.sourceRef`);
|
|
const knownHostsSourceRef = asRecord(knownHosts.sourceRef, `${configPath}.services.decisionCenter.storage.github.ssh.knownHosts.sourceRef`);
|
|
return {
|
|
configPath,
|
|
target: {
|
|
node: stringField(target, "id", `${configPath}.target`),
|
|
route: `${stringField(target, "id", `${configPath}.target`)}:k3s`,
|
|
namespace: stringField(target, "namespace", `${configPath}.target`),
|
|
},
|
|
runtime: {
|
|
deployRef: stringField(runtime, "deployRef", `${configPath}.runtime`),
|
|
repository: stringField(runtime, "repository", `${configPath}.runtime`),
|
|
commit: stringField(runtime, "commit", `${configPath}.runtime`),
|
|
},
|
|
image: stringField(images, "decisionCenter", `${configPath}.images`),
|
|
workload: {
|
|
deployment: stringField(decision, "deploymentName", `${configPath}.services.decisionCenter`),
|
|
service: stringField(decision, "serviceName", `${configPath}.services.decisionCenter`),
|
|
container: stringField(decision, "containerName", `${configPath}.services.decisionCenter`),
|
|
port: numberField(decision, "containerPort", `${configPath}.services.decisionCenter`),
|
|
healthPath: stringField(decision, "healthPath", `${configPath}.services.decisionCenter`),
|
|
},
|
|
database: {
|
|
secret: stringField(database, "secretName", `${configPath}.services.decisionCenter.database`),
|
|
key: stringField(database, "secretKey", `${configPath}.services.decisionCenter.database`),
|
|
source: secretSourceSummary(
|
|
stringField(sourceRef, "path", `${configPath}.services.decisionCenter.database.sourceRef`),
|
|
stringField(sourceRef, "key", `${configPath}.services.decisionCenter.database.sourceRef`),
|
|
),
|
|
},
|
|
storage: {
|
|
primary: stringField(storage, "primary", `${configPath}.services.decisionCenter.storage`),
|
|
github: {
|
|
repo: stringField(github, "repo", `${configPath}.services.decisionCenter.storage.github`),
|
|
sshUrl: stringField(github, "sshUrl", `${configPath}.services.decisionCenter.storage.github`),
|
|
branch: stringField(github, "branch", `${configPath}.services.decisionCenter.storage.github`),
|
|
basePath: stringField(github, "basePath", `${configPath}.services.decisionCenter.storage.github`),
|
|
worktreePath: stringField(github, "worktreePath", `${configPath}.services.decisionCenter.storage.github`),
|
|
sshSecret: stringField(ssh, "secretName", `${configPath}.services.decisionCenter.storage.github.ssh`),
|
|
privateKey: fileSourceSummary(stringField(privateKeySourceRef, "path", `${configPath}.services.decisionCenter.storage.github.ssh.privateKey.sourceRef`)),
|
|
knownHosts: fileSourceSummary(stringField(knownHostsSourceRef, "path", `${configPath}.services.decisionCenter.storage.github.ssh.knownHosts.sourceRef`)),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderDecisionDeploymentManifest(configPath = hostK8sConfigPath): string {
|
|
const result = runCommand(["bun", "scripts/native/deploy/render-unidesk-host-k8s.mjs", configPath], repoRoot, { timeoutMs: 30_000 });
|
|
if (result.exitCode !== 0) throw new Error(`render ${configPath} failed: ${result.stderr || result.stdout}`);
|
|
return result.stdout;
|
|
}
|
|
|
|
function manifestObjects(manifest: string): Array<Record<string, string>> {
|
|
return manifest
|
|
.split(/^---$/mu)
|
|
.map((doc) => ({
|
|
kind: doc.match(/^kind:\s*(\S+)/mu)?.[1] ?? "",
|
|
name: doc.match(/^metadata:\n(?:.*\n)*?\s{2}name:\s*(\S+)/mu)?.[1] ?? "",
|
|
namespace: doc.match(/^metadata:\n(?:.*\n)*?\s{2}namespace:\s*(\S+)/mu)?.[1] ?? "",
|
|
}))
|
|
.filter((item) => item.kind.length > 0 && item.name.length > 0);
|
|
}
|
|
|
|
function redactSecretManifestValues(manifest: string): string {
|
|
return manifest.split(/^---$/mu).map((doc) => {
|
|
if (!/^kind:\s*Secret\s*$/mu.test(doc)) return doc;
|
|
const lines = doc.split("\n");
|
|
let inStringData = false;
|
|
return lines.map((line) => {
|
|
if (/^stringData:\s*$/u.test(line)) {
|
|
inStringData = true;
|
|
return line;
|
|
}
|
|
if (inStringData && /^\S/u.test(line)) inStringData = false;
|
|
if (inStringData && /^ [A-Za-z0-9_.-]+:\s*/u.test(line)) return line.replace(/:\s*.*$/u, ': "<redacted>"');
|
|
return line;
|
|
}).join("\n");
|
|
}).join("---");
|
|
}
|
|
|
|
function deploymentOptions(args: string[]): { configPath: string; confirm: boolean; dryRun: boolean; wait: boolean; full: boolean; raw: boolean } {
|
|
return {
|
|
configPath: optionValue(args, ["--config"]) ?? hostK8sConfigPath,
|
|
confirm: hasFlag(args, "--confirm"),
|
|
dryRun: hasFlag(args, "--dry-run"),
|
|
wait: !hasFlag(args, "--no-wait"),
|
|
full: hasFlag(args, "--full"),
|
|
raw: hasFlag(args, "--raw"),
|
|
};
|
|
}
|
|
|
|
function deploymentPlan(args: string[]): Record<string, unknown> {
|
|
const options = deploymentOptions(args);
|
|
const summary = decisionDeploymentConfigSummary(options.configPath);
|
|
let render: Record<string, unknown>;
|
|
try {
|
|
const manifest = renderDecisionDeploymentManifest(options.configPath);
|
|
render = {
|
|
ok: true,
|
|
manifestSha: `sha256:${sha256Short(manifest)}`,
|
|
objects: manifestObjects(manifest),
|
|
};
|
|
} catch (error) {
|
|
render = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
return {
|
|
ok: render.ok === true,
|
|
action: "decision-center-deployment-plan",
|
|
mutation: false,
|
|
config: summary,
|
|
render,
|
|
next: {
|
|
dryRun: `bun scripts/cli.ts decision deployment apply --config ${options.configPath} --dry-run`,
|
|
apply: `bun scripts/cli.ts decision deployment apply --config ${options.configPath} --confirm`,
|
|
status: `bun scripts/cli.ts decision deployment status --config ${options.configPath}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function deploymentRender(args: string[]): Record<string, unknown> | string {
|
|
const options = deploymentOptions(args);
|
|
const manifest = renderDecisionDeploymentManifest(options.configPath);
|
|
if (options.raw || options.full) return redactSecretManifestValues(manifest);
|
|
return {
|
|
ok: true,
|
|
action: "decision-center-deployment-render",
|
|
mutation: false,
|
|
config: decisionDeploymentConfigSummary(options.configPath),
|
|
manifestSha: `sha256:${sha256Short(manifest)}`,
|
|
objects: manifestObjects(manifest),
|
|
disclosure: "Use --raw or --full to print the full manifest.",
|
|
};
|
|
}
|
|
|
|
function applyScript(namespace: string, manifest: string, dryRun: boolean, wait: boolean): string {
|
|
const manifestB64 = Buffer.from(manifest, "utf8").toString("base64");
|
|
const applyArgs = dryRun ? "apply --dry-run=server -f" : "apply -f";
|
|
return `set -eu
|
|
ns=${shQuote(namespace)}
|
|
tmp="$(mktemp /tmp/unidesk-decision-center.XXXXXX.yaml)"
|
|
cleanup() { rm -f "$tmp"; }
|
|
trap cleanup EXIT
|
|
printf %s ${shQuote(manifestB64)} | base64 -d > "$tmp"
|
|
kubectl ${applyArgs} "$tmp"
|
|
if [ ${dryRun ? "1" : "0"} -eq 0 ] && [ ${wait ? "1" : "0"} -eq 1 ]; then
|
|
kubectl -n "$ns" rollout status deployment/backend-core --timeout=45s
|
|
kubectl -n "$ns" rollout status deployment/frontend --timeout=45s
|
|
kubectl -n "$ns" rollout status deployment/decision-center --timeout=45s
|
|
fi
|
|
python3 - <<'PY'
|
|
import json
|
|
print(json.dumps({"ok": True, "applied": ${dryRun ? "False" : "True"}, "dryRun": ${dryRun ? "True" : "False"}}))
|
|
PY`;
|
|
}
|
|
|
|
async function deploymentApply(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
|
const options = deploymentOptions(args);
|
|
if (!options.confirm && !options.dryRun) throw new Error("decision deployment apply requires --confirm or --dry-run");
|
|
const summary = decisionDeploymentConfigSummary(options.configPath);
|
|
const target = asRecord(summary.target, "deployment target");
|
|
const manifest = renderDecisionDeploymentManifest(options.configPath);
|
|
const result = await capture(config, stringField(target, "route", "deployment.target"), ["sh"], applyScript(stringField(target, "namespace", "deployment.target"), manifest, options.dryRun, options.wait));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && (parsed === null || parsed.ok === true),
|
|
action: "decision-center-deployment-apply",
|
|
mutation: !options.dryRun,
|
|
mode: options.dryRun ? "dry-run" : "confirmed",
|
|
config: summary,
|
|
manifestSha: `sha256:${sha256Short(manifest)}`,
|
|
remote: parsed ?? compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
next: {
|
|
status: `bun scripts/cli.ts decision deployment status --config ${options.configPath}`,
|
|
health: "bun scripts/cli.ts decision health",
|
|
},
|
|
};
|
|
}
|
|
|
|
function statusScript(namespace: string, serviceName: string, port: number, healthPath: string): string {
|
|
return `set -eu
|
|
ns=${shQuote(namespace)}
|
|
svc=${shQuote(serviceName)}
|
|
port=${shQuote(String(port))}
|
|
health_path=${shQuote(healthPath)}
|
|
tmp="$(mktemp -d /tmp/unidesk-decision-center-status.XXXXXX)"
|
|
cleanup() { rm -rf "$tmp"; }
|
|
trap cleanup EXIT
|
|
kubectl -n "$ns" get deploy decision-center -o json > "$tmp/deploy.json"
|
|
kubectl -n "$ns" get svc "$svc" -o json > "$tmp/service.json"
|
|
kubectl -n "$ns" get pods -l app.kubernetes.io/name=decision-center -o json > "$tmp/pods.json"
|
|
kubectl get --raw "/api/v1/namespaces/$ns/services/$svc:$port/proxy$health_path" > "$tmp/health.json" 2>"$tmp/health.err" || true
|
|
python3 - "$tmp" <<'PY'
|
|
import json, pathlib, sys
|
|
base = pathlib.Path(sys.argv[1])
|
|
def load(name, fallback=None):
|
|
try:
|
|
return json.loads((base / name).read_text())
|
|
except Exception:
|
|
return fallback
|
|
deploy = load("deploy.json", {})
|
|
svc = load("service.json", {})
|
|
pods = load("pods.json", {"items": []})
|
|
health = load("health.json", None)
|
|
conditions = {item.get("type"): item.get("status") for item in deploy.get("status", {}).get("conditions", [])}
|
|
ready = deploy.get("status", {}).get("readyReplicas", 0) == deploy.get("spec", {}).get("replicas", 1)
|
|
print(json.dumps({
|
|
"ok": bool(ready and isinstance(health, dict) and health.get("ok") is True),
|
|
"ready": bool(ready),
|
|
"deployment": {
|
|
"name": deploy.get("metadata", {}).get("name"),
|
|
"image": (((deploy.get("spec", {}).get("template", {}).get("spec", {}).get("containers") or [{}])[0]).get("image")),
|
|
"readyReplicas": deploy.get("status", {}).get("readyReplicas", 0),
|
|
"replicas": deploy.get("spec", {}).get("replicas", 1),
|
|
"available": conditions.get("Available"),
|
|
},
|
|
"service": {"name": svc.get("metadata", {}).get("name"), "clusterIP": svc.get("spec", {}).get("clusterIP")},
|
|
"pods": [{"name": p.get("metadata", {}).get("name"), "phase": p.get("status", {}).get("phase")} for p in pods.get("items", [])],
|
|
"health": health,
|
|
}))
|
|
PY`;
|
|
}
|
|
|
|
async function deploymentStatus(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
|
const options = deploymentOptions(args);
|
|
const summary = decisionDeploymentConfigSummary(options.configPath);
|
|
const target = asRecord(summary.target, "deployment target");
|
|
const workload = asRecord(summary.workload, "deployment workload");
|
|
const result = await capture(
|
|
config,
|
|
stringField(target, "route", "deployment.target"),
|
|
["sh"],
|
|
statusScript(
|
|
stringField(target, "namespace", "deployment.target"),
|
|
stringField(workload, "service", "deployment.workload"),
|
|
numberField(workload, "port", "deployment.workload"),
|
|
stringField(workload, "healthPath", "deployment.workload"),
|
|
),
|
|
);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: "decision-center-deployment-status",
|
|
mutation: false,
|
|
config: summary,
|
|
remote: options.raw || options.full ? parsed ?? compactCapture(result, { full: true }) : parsed ?? compactCapture(result, { full: result.exitCode !== 0 }),
|
|
next: {
|
|
plan: `bun scripts/cli.ts decision deployment plan --config ${options.configPath}`,
|
|
apply: `bun scripts/cli.ts decision deployment apply --config ${options.configPath} --confirm`,
|
|
health: "bun scripts/cli.ts decision health",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runDeploymentCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
|
|
const [action = "plan"] = args;
|
|
if (action === "plan") return deploymentPlan(args.slice(1));
|
|
if (action === "render") return deploymentRender(args.slice(1));
|
|
if (action === "apply") return deploymentApply(config, args.slice(1));
|
|
if (action === "status") return deploymentStatus(config, args.slice(1));
|
|
throw new Error("decision deployment command must be one of: plan, render, apply, status");
|
|
}
|
|
|
|
function storageOptions(args: string[]): { configPath: string; confirm: boolean; dryRun: boolean } {
|
|
return {
|
|
configPath: optionValue(args, ["--config"]) ?? hostK8sConfigPath,
|
|
confirm: hasFlag(args, "--confirm"),
|
|
dryRun: hasFlag(args, "--dry-run"),
|
|
};
|
|
}
|
|
|
|
function storagePlan(args: string[]): Record<string, unknown> {
|
|
const options = storageOptions(args);
|
|
const summary = decisionDeploymentConfigSummary(options.configPath);
|
|
return {
|
|
ok: true,
|
|
action: "decision-center-storage-plan",
|
|
mutation: false,
|
|
config: {
|
|
configPath: options.configPath,
|
|
target: asRecord(summary.target, "summary.target"),
|
|
storage: asRecord(summary.storage, "summary.storage"),
|
|
database: asRecord(summary.database, "summary.database"),
|
|
},
|
|
next: {
|
|
servicePlan: "bun scripts/cli.ts decision storage status",
|
|
migrateDryRun: "bun scripts/cli.ts decision storage migrate --dry-run",
|
|
migrateConfirm: "bun scripts/cli.ts decision storage migrate --confirm",
|
|
verify: "bun scripts/cli.ts decision storage verify",
|
|
},
|
|
};
|
|
}
|
|
|
|
function storageProxy(path: string, init?: { method?: string; body?: unknown }): unknown {
|
|
return unwrapProxyResponse(decisionProxy(path, init));
|
|
}
|
|
|
|
async function storageProxyAsync(fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>, path: string, init?: { method?: string; body?: unknown }): Promise<unknown> {
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, path, init));
|
|
}
|
|
|
|
async function runStorageCommand(args: string[], fetcher?: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
const [action = "plan"] = args;
|
|
if (action === "plan") return storagePlan(args.slice(1));
|
|
if (action === "status") {
|
|
return fetcher === undefined ? storageProxy("/api/storage/status") : storageProxyAsync(fetcher, "/api/storage/status");
|
|
}
|
|
if (action === "migrate") {
|
|
const options = storageOptions(args.slice(1));
|
|
if (!options.confirm && !options.dryRun) throw new Error("decision storage migrate requires --dry-run or --confirm");
|
|
const init = { method: "POST", body: { confirm: options.confirm } };
|
|
return fetcher === undefined ? storageProxy("/api/storage/migrate", init) : storageProxyAsync(fetcher, "/api/storage/migrate", init);
|
|
}
|
|
if (action === "verify") {
|
|
return fetcher === undefined ? storageProxy("/api/storage/verify") : storageProxyAsync(fetcher, "/api/storage/verify");
|
|
}
|
|
throw new Error("decision storage command must be one of: plan, status, migrate, verify");
|
|
}
|
|
|
|
function optionValues(args: string[], names: string[]): string[] {
|
|
const values: string[] = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
if (!names.includes(args[index] ?? "")) continue;
|
|
const raw = args[index + 1];
|
|
if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${args[index]} requires a non-empty value`);
|
|
values.push(raw);
|
|
index += 1;
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function positionalArgs(args: string[]): string[] {
|
|
const positions: string[] = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const value = args[index] ?? "";
|
|
if (value.startsWith("--")) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
positions.push(value);
|
|
}
|
|
return positions;
|
|
}
|
|
|
|
function parseType(raw: string | undefined, fallback: DecisionRecordType): DecisionRecordType {
|
|
const value = raw || fallback;
|
|
if (!typeValues.has(value as DecisionRecordType)) throw new Error(`--type must be one of: ${Array.from(typeValues).join(", ")}`);
|
|
return value as DecisionRecordType;
|
|
}
|
|
|
|
function parseRequirementType(raw: string | undefined, fallback: RequirementRecordType): RequirementRecordType {
|
|
const value = raw || fallback;
|
|
if (!requirementTypeValues.has(value as RequirementRecordType)) throw new Error(`--type must be one of: ${Array.from(requirementTypeValues).join(", ")}`);
|
|
return value as RequirementRecordType;
|
|
}
|
|
|
|
function parseLevel(raw: string | undefined, fallback: DecisionRecordLevel): DecisionRecordLevel {
|
|
const value = raw || fallback;
|
|
if (!levelValues.has(value as DecisionRecordLevel)) throw new Error(`--level must be one of: ${Array.from(levelValues).join(", ")}`);
|
|
return value as DecisionRecordLevel;
|
|
}
|
|
|
|
function parseStatus(raw: string | undefined, fallback: DecisionRecordStatus): DecisionRecordStatus {
|
|
const value = raw || fallback;
|
|
if (!statusValues.has(value as DecisionRecordStatus)) throw new Error(`--status must be one of: ${Array.from(statusValues).join(", ")}`);
|
|
return value as DecisionRecordStatus;
|
|
}
|
|
|
|
function parseDocumentNo(raw: string | undefined): string | undefined {
|
|
if (raw === undefined) return undefined;
|
|
const value = raw.trim().toUpperCase();
|
|
if (!/^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-P[0-3]-\d{4}-\d{1,9}$/u.test(value)) {
|
|
throw new Error("--doc-no must match DC-<TYPE>-<PRIORITY>-<YEAR>-<SEQ>, for example DC-GOAL-P0-2026-001");
|
|
}
|
|
const [prefix, docType, docPriority, docYear, docSeq] = value.split("-");
|
|
return `${prefix}-${docType}-${docPriority}-${docYear}-${String(Number(docSeq)).padStart(3, "0")}`;
|
|
}
|
|
|
|
function parseDocumentType(raw: string | undefined): DecisionDocumentType | undefined {
|
|
if (raw === undefined) return undefined;
|
|
const value = raw.toUpperCase();
|
|
if (!documentTypeValues.has(value as DecisionDocumentType)) throw new Error(`--doc-type must be one of: ${Array.from(documentTypeValues).join(", ")}`);
|
|
return value as DecisionDocumentType;
|
|
}
|
|
|
|
function parseDocumentPriority(raw: string | undefined): DecisionDocumentPriority | undefined {
|
|
if (raw === undefined) return undefined;
|
|
const value = raw.toUpperCase();
|
|
if (!documentPriorityValues.has(value as DecisionDocumentPriority)) throw new Error(`--doc-priority must be one of: ${Array.from(documentPriorityValues).join(", ")}`);
|
|
return value as DecisionDocumentPriority;
|
|
}
|
|
|
|
function parseDocumentYear(raw: string | undefined): number | undefined {
|
|
if (raw === undefined) return undefined;
|
|
const value = Number(raw);
|
|
if (!/^\d{4}$/u.test(raw) || !Number.isInteger(value) || value < 1970 || value > 2100) throw new Error("--doc-year must be a four-digit year between 1970 and 2100");
|
|
return value;
|
|
}
|
|
|
|
function parseDocumentSeq(raw: string | undefined): number | undefined {
|
|
if (raw === undefined) return undefined;
|
|
const value = Number(raw);
|
|
if (!/^\d+$/u.test(raw) || !Number.isInteger(value) || value <= 0) throw new Error("--doc-seq must be a positive integer");
|
|
return value;
|
|
}
|
|
|
|
function addDocumentPayloadFields(payload: Record<string, unknown>, args: string[]): void {
|
|
const docNo = parseDocumentNo(optionValue(args, ["--doc-no", "--docNo", "--document-no", "--documentNo"]));
|
|
const docType = parseDocumentType(optionValue(args, ["--doc-type", "--docType"]));
|
|
const docPriority = parseDocumentPriority(optionValue(args, ["--doc-priority", "--docPriority"]));
|
|
const docYear = parseDocumentYear(optionValue(args, ["--doc-year", "--docYear", "--year"]));
|
|
const docSeq = parseDocumentSeq(optionValue(args, ["--doc-seq", "--docSeq"]));
|
|
const signer = optionValue(args, ["--signer"]);
|
|
const issuedAt = optionValue(args, ["--issued-at", "--issuedAt"]);
|
|
const effectiveScope = optionValue(args, ["--effective-scope", "--effectiveScope"]);
|
|
const supersedes = splitList(optionValues(args, ["--supersedes"]));
|
|
const supersededBy = splitList(optionValues(args, ["--superseded-by", "--supersededBy"]));
|
|
if (docNo !== undefined) payload.docNo = docNo;
|
|
if (docType !== undefined) payload.docType = docType;
|
|
if (docPriority !== undefined) payload.docPriority = docPriority;
|
|
if (docYear !== undefined) payload.docYear = docYear;
|
|
if (docSeq !== undefined) payload.docSeq = docSeq;
|
|
if (signer !== undefined) payload.signer = signer;
|
|
if (issuedAt !== undefined) payload.issuedAt = issuedAt;
|
|
if (effectiveScope !== undefined) payload.effectiveScope = effectiveScope;
|
|
if (supersedes.length > 0) payload.supersedes = supersedes;
|
|
if (supersededBy.length > 0) payload.supersededBy = supersededBy;
|
|
}
|
|
|
|
function splitList(values: string[]): string[] {
|
|
return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
|
|
}
|
|
|
|
function readMarkdownFile(path: string): { absolutePath: string; markdown: string } {
|
|
const absolutePath = resolve(repoRoot, path);
|
|
const markdown = readFileSync(absolutePath, "utf8");
|
|
if (markdown.trim().length === 0) throw new Error(`markdown file is empty: ${absolutePath}`);
|
|
if (markdown.length > 4_500_000) throw new Error(`markdown file is too large: ${absolutePath}`);
|
|
return { absolutePath, markdown };
|
|
}
|
|
|
|
function optionalBodyFromArgs(args: string[], command: string): { body: string | undefined; bodySource: Record<string, string> } {
|
|
const body = optionValue(args, ["--body"]);
|
|
const bodyFile = optionValue(args, ["--body-file", "--markdown-file"]);
|
|
const markdownFile = optionValue(args, ["--file"]);
|
|
const sources = [body !== undefined, bodyFile !== undefined, markdownFile !== undefined].filter(Boolean).length;
|
|
if (sources > 1) throw new Error(`${command} accepts only one of --body, --body-file, or --file`);
|
|
if (body !== undefined) return { body, bodySource: { kind: "inline" } };
|
|
const file = bodyFile ?? markdownFile;
|
|
if (file !== undefined) {
|
|
const { absolutePath, markdown } = readMarkdownFile(file);
|
|
return { body: markdown, bodySource: { kind: "file", path: absolutePath } };
|
|
}
|
|
return { body: undefined, bodySource: { kind: "none" } };
|
|
}
|
|
|
|
function bodyFromArgs(args: string[], command: string): { body: string; bodySource: Record<string, string> } {
|
|
const { body, bodySource } = optionalBodyFromArgs(args, command);
|
|
if (body !== undefined) return { body, bodySource };
|
|
throw new Error(`${command} requires --body text or --body-file path`);
|
|
}
|
|
|
|
function decisionProxy(path: string, init?: { method?: string; body?: unknown }): unknown {
|
|
return coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${path}`, init);
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
function decisionK8sPath(path: string): string {
|
|
const proxyPrefix = `/api/microservices/${encodeURIComponent(serviceId)}/proxy`;
|
|
if (path.startsWith(proxyPrefix)) return path.slice(proxyPrefix.length) || "/";
|
|
if (path === `/api/microservices/${encodeURIComponent(serviceId)}/health`) return "/health";
|
|
return path;
|
|
}
|
|
|
|
function decisionK8sTarget(): { node: string; namespace: string } {
|
|
const raw = readFileSync(resolve(repoRoot, "config", "unidesk-host-k8s.yaml"), "utf8");
|
|
const parsed = Bun.YAML.parse(raw) as unknown;
|
|
const record = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
const target = typeof record.target === "object" && record.target !== null && !Array.isArray(record.target) ? record.target as Record<string, unknown> : {};
|
|
const node = typeof target.id === "string" && target.id.length > 0 ? target.id : "NC01";
|
|
const namespace = typeof target.namespace === "string" && target.namespace.length > 0 ? target.namespace : "unidesk";
|
|
return { node, namespace };
|
|
}
|
|
|
|
async function nc01K8sDecisionFetch(path: string, init?: { method?: string; body?: unknown }): Promise<unknown> {
|
|
const target = decisionK8sTarget();
|
|
const payload = {
|
|
path: decisionK8sPath(path),
|
|
method: init?.method ?? "GET",
|
|
body: init?.body,
|
|
namespace: target.namespace,
|
|
};
|
|
const requestB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
const remoteScript = `
|
|
set -eu
|
|
POD=$(kubectl -n ${shellQuote(target.namespace)} get pod -l app.kubernetes.io/name=decision-center -o jsonpath='{.items[0].metadata.name}')
|
|
REQ_B64=${shellQuote(requestB64)}
|
|
kubectl -n ${shellQuote(target.namespace)} exec "$POD" -- env REQ_B64="$REQ_B64" bun -e '
|
|
const req = JSON.parse(Buffer.from(process.env.REQ_B64, "base64").toString("utf8"));
|
|
const headers = {};
|
|
const init = { method: req.method || "GET", headers };
|
|
if (req.body !== undefined) {
|
|
headers["content-type"] = "application/json";
|
|
init.body = JSON.stringify(req.body);
|
|
}
|
|
const res = await fetch("http://127.0.0.1:4277" + req.path, init);
|
|
const text = await res.text();
|
|
let body;
|
|
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
|
if (!String(req.path).includes("includeBody=true") && body && typeof body === "object") {
|
|
for (const key of ["records", "entries", "requirements"]) {
|
|
if (Array.isArray(body[key])) {
|
|
body[key] = body[key].map((item) => item && typeof item === "object" ? { ...item, body: "" } : item);
|
|
}
|
|
}
|
|
}
|
|
let output = JSON.stringify({ ok: res.ok, status: res.status, body });
|
|
if (output.length > 8000 && body && typeof body === "object") {
|
|
for (const key of ["records", "entries", "requirements"]) {
|
|
if (Array.isArray(body[key])) {
|
|
const totalReturned = body[key].length;
|
|
body[key] = body[key].slice(0, 5).map((item) => {
|
|
if (!item || typeof item !== "object") return item;
|
|
return {
|
|
id: item.id,
|
|
docNo: item.docNo,
|
|
date: item.date,
|
|
title: item.title,
|
|
type: item.type,
|
|
status: item.status,
|
|
level: item.level,
|
|
sourceFile: item.sourceFile,
|
|
updatedAt: item.updatedAt,
|
|
};
|
|
});
|
|
body.totalReturned = totalReturned;
|
|
body[key + "Omitted"] = Math.max(0, totalReturned - body[key].length);
|
|
body.outputPolicy = { bounded: true, reason: "ssh-preview-budget", shown: body[key].length, totalReturned };
|
|
}
|
|
}
|
|
output = JSON.stringify({ ok: res.ok, status: res.status, body });
|
|
}
|
|
console.log(output);
|
|
'
|
|
`;
|
|
const result = runCommand(["bun", "scripts/cli.ts", "ssh", target.node, "sh", "--", remoteScript], repoRoot, { timeoutMs: 30_000 });
|
|
if (result.exitCode !== 0) {
|
|
return {
|
|
ok: false,
|
|
status: 502,
|
|
error: "NC01 decision-center k8s proxy failed",
|
|
commandExitCode: result.exitCode,
|
|
stdoutTail: result.stdout.slice(-1000),
|
|
stderrTail: result.stderr.slice(-1000),
|
|
};
|
|
}
|
|
const resolved = resolveCliChildJsonCommandResult({
|
|
result,
|
|
requestedStdoutType: "NC01 decision-center k8s proxy JSON",
|
|
acceptParsed: (value) => typeof value.ok === "boolean" && typeof value.status === "number",
|
|
});
|
|
if (resolved.parsed !== null) return resolved.parsed;
|
|
return {
|
|
ok: false,
|
|
status: 502,
|
|
error: "NC01 decision-center k8s proxy returned unparseable JSON",
|
|
transport: {
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
stdoutBytes: Buffer.byteLength(result.stdout),
|
|
stderrBytes: Buffer.byteLength(result.stderr),
|
|
},
|
|
diagnostics: resolved.diagnostics,
|
|
};
|
|
}
|
|
|
|
async function decisionProxyAsync(
|
|
fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>,
|
|
path: string,
|
|
init?: { method?: string; body?: unknown },
|
|
): Promise<unknown> {
|
|
return await fetcher(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${path}`, init);
|
|
}
|
|
|
|
function unwrapProxyResponse(response: unknown): unknown {
|
|
const record = typeof response === "object" && response !== null && !Array.isArray(response) ? response as Record<string, unknown> : {};
|
|
if (record.ok !== true) return response;
|
|
const body = record.body;
|
|
return { upstream: { ok: record.ok, status: record.status }, body };
|
|
}
|
|
|
|
function uploadMeeting(args: string[]): unknown {
|
|
const file = positionalArgs(args)[0];
|
|
if (!file) throw new Error("decision upload requires markdown file");
|
|
const { absolutePath, markdown } = readMarkdownFile(file);
|
|
const type = parseType(optionValue(args, ["--type"]), "meeting");
|
|
const payload = {
|
|
markdown,
|
|
title: optionValue(args, ["--title"]),
|
|
type,
|
|
level: parseLevel(optionValue(args, ["--level", "--priority"]), "none"),
|
|
status: parseStatus(optionValue(args, ["--status"]), "active"),
|
|
linkedGoalId: optionValue(args, ["--linked-goal-id", "--linkedGoalId"]),
|
|
tags: splitList(optionValues(args, ["--tag", "--tags"])),
|
|
evidenceLinks: splitList(optionValues(args, ["--evidence", "--evidence-link", "--evidenceLinks"])),
|
|
source: optionValue(args, ["--source"]),
|
|
sourceSession: optionValue(args, ["--source-session", "--sourceSession"]),
|
|
issueId: optionValue(args, ["--issue", "--issue-id", "--issueId", "--linked-issue", "--linked-task"]),
|
|
taskId: optionValue(args, ["--task-id", "--taskId"]),
|
|
commitId: optionValue(args, ["--commit-id", "--commitId"]),
|
|
};
|
|
addDocumentPayloadFields(payload, args);
|
|
const endpoint = type === "meeting" ? "/api/meetings/import" : "/api/records";
|
|
const body = type === "meeting" ? payload : { ...payload, body: markdown };
|
|
return { file: absolutePath, result: unwrapProxyResponse(decisionProxy(endpoint, { method: "POST", body })) };
|
|
}
|
|
|
|
async function uploadMeetingAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
const file = positionalArgs(args)[0];
|
|
if (!file) throw new Error("decision upload requires markdown file");
|
|
const { absolutePath, markdown } = readMarkdownFile(file);
|
|
const type = parseType(optionValue(args, ["--type"]), "meeting");
|
|
const payload = {
|
|
markdown,
|
|
title: optionValue(args, ["--title"]),
|
|
type,
|
|
level: parseLevel(optionValue(args, ["--level", "--priority"]), "none"),
|
|
status: parseStatus(optionValue(args, ["--status"]), "active"),
|
|
linkedGoalId: optionValue(args, ["--linked-goal-id", "--linkedGoalId"]),
|
|
tags: splitList(optionValues(args, ["--tag", "--tags"])),
|
|
evidenceLinks: splitList(optionValues(args, ["--evidence", "--evidence-link", "--evidenceLinks"])),
|
|
source: optionValue(args, ["--source"]),
|
|
sourceSession: optionValue(args, ["--source-session", "--sourceSession"]),
|
|
issueId: optionValue(args, ["--issue", "--issue-id", "--issueId", "--linked-issue", "--linked-task"]),
|
|
taskId: optionValue(args, ["--task-id", "--taskId"]),
|
|
commitId: optionValue(args, ["--commit-id", "--commitId"]),
|
|
};
|
|
addDocumentPayloadFields(payload, args);
|
|
const endpoint = type === "meeting" ? "/api/meetings/import" : "/api/records";
|
|
const body = type === "meeting" ? payload : { ...payload, body: markdown };
|
|
return { file: absolutePath, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, endpoint, { method: "POST", body })) };
|
|
}
|
|
|
|
function summarizeDiaryImportResult(result: unknown, includeEntries: boolean): unknown {
|
|
const body = typeof result === "object" && result !== null && !Array.isArray(result) ? result as Record<string, unknown> : {};
|
|
const entries = Array.isArray(body.entries) ? body.entries : [];
|
|
const summaryEntries = entries.map((item) => {
|
|
const record = typeof item === "object" && item !== null && !Array.isArray(item) ? item as Record<string, unknown> : {};
|
|
return {
|
|
id: record.id,
|
|
date: record.date,
|
|
month: record.month,
|
|
title: record.title,
|
|
markdownPath: record.markdownPath,
|
|
summary: record.summary,
|
|
sourceFile: record.sourceFile,
|
|
updatedAt: record.updatedAt,
|
|
};
|
|
});
|
|
return {
|
|
...body,
|
|
entries: includeEntries ? summaryEntries : summaryEntries.slice(0, 20),
|
|
entriesOmitted: includeEntries ? 0 : Math.max(0, summaryEntries.length - 20),
|
|
outputPolicy: includeEntries
|
|
? { bounded: false, entries: summaryEntries.length }
|
|
: { bounded: true, entriesShown: Math.min(summaryEntries.length, 20), fullCommand: "Re-run with --include-entries to show every imported day summary." },
|
|
};
|
|
}
|
|
|
|
function diaryImportPayload(args: string[]): { absolutePath: string; payload: Record<string, unknown> } {
|
|
const file = positionalArgs(args)[0];
|
|
if (!file) throw new Error("decision diary import requires markdown file");
|
|
const { absolutePath, markdown } = readMarkdownFile(file);
|
|
return {
|
|
absolutePath,
|
|
payload: {
|
|
markdown,
|
|
sourceFile: optionValue(args, ["--source-file", "--source-path", "--source"]) ?? absolutePath,
|
|
tags: splitList(optionValues(args, ["--tag", "--tags"])),
|
|
},
|
|
};
|
|
}
|
|
|
|
function importDiary(args: string[]): unknown {
|
|
const { absolutePath, payload } = diaryImportPayload(args);
|
|
const result = unwrapProxyResponse(decisionProxy("/api/diary/import", { method: "POST", body: payload }));
|
|
const body = typeof result === "object" && result !== null && !Array.isArray(result) && "body" in result
|
|
? (result as { body?: unknown }).body
|
|
: result;
|
|
return { file: absolutePath, result: { ...(typeof result === "object" && result !== null && !Array.isArray(result) && "upstream" in result ? { upstream: (result as { upstream?: unknown }).upstream } : {}), body: summarizeDiaryImportResult(body, args.includes("--include-entries")) } };
|
|
}
|
|
|
|
async function importDiaryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
const { absolutePath, payload } = diaryImportPayload(args);
|
|
const result = unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/diary/import", { method: "POST", body: payload }));
|
|
const body = typeof result === "object" && result !== null && !Array.isArray(result) && "body" in result
|
|
? (result as { body?: unknown }).body
|
|
: result;
|
|
return { file: absolutePath, result: { ...(typeof result === "object" && result !== null && !Array.isArray(result) && "upstream" in result ? { upstream: (result as { upstream?: unknown }).upstream } : {}), body: summarizeDiaryImportResult(body, args.includes("--include-entries")) } };
|
|
}
|
|
|
|
function listRecords(args: string[]): unknown {
|
|
const query = recordQuery(args);
|
|
return unwrapProxyResponse(decisionProxy(`/api/records${query ? `?${query}` : ""}`));
|
|
}
|
|
|
|
async function listRecordsAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
const query = recordQuery(args);
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records${query ? `?${query}` : ""}`));
|
|
}
|
|
|
|
function recordQuery(args: string[], options: { requirementOnly?: boolean } = {}): string {
|
|
const params = new URLSearchParams();
|
|
const type = optionValue(args, ["--type"]);
|
|
const status = optionValue(args, ["--status"]);
|
|
const level = optionValue(args, ["--level", "--priority"]);
|
|
const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]);
|
|
const source = optionValue(args, ["--source"]);
|
|
const issueId = optionValue(args, ["--issue", "--issue-id", "--issueId", "--linked-issue", "--linked-task"]);
|
|
const tag = optionValue(args, ["--tag", "--tags"]);
|
|
const queryText = optionValue(args, ["--query", "--q"]);
|
|
const limit = optionValue(args, ["--limit"]);
|
|
const docNo = parseDocumentNo(optionValue(args, ["--doc-no", "--docNo", "--document-no", "--documentNo"]));
|
|
const docType = parseDocumentType(optionValue(args, ["--doc-type", "--docType"]));
|
|
const docPriority = parseDocumentPriority(optionValue(args, ["--doc-priority", "--docPriority"]));
|
|
const docYear = parseDocumentYear(optionValue(args, ["--doc-year", "--docYear", "--year"]));
|
|
if (docNo !== undefined) params.set("docNo", docNo);
|
|
if (docType !== undefined) params.set("docType", docType);
|
|
if (docPriority !== undefined) params.set("docPriority", docPriority);
|
|
if (docYear !== undefined) params.set("docYear", String(docYear));
|
|
if (type !== undefined) params.set("type", options.requirementOnly === true ? parseRequirementType(type, "goal") : parseType(type, "meeting"));
|
|
if (status !== undefined) params.set("status", parseStatus(status, "active"));
|
|
if (level !== undefined) params.set("level", parseLevel(level, "none"));
|
|
if (linkedGoalId !== undefined) params.set("linkedGoalId", linkedGoalId);
|
|
if (source !== undefined) params.set("source", source);
|
|
if (issueId !== undefined) params.set("issueId", issueId);
|
|
if (tag !== undefined) params.set("tag", tag);
|
|
if (queryText !== undefined) params.set("q", queryText);
|
|
if (limit !== undefined) params.set("limit", limit);
|
|
if (args.includes("--include-body")) params.set("includeBody", "true");
|
|
return params.toString();
|
|
}
|
|
|
|
function diaryQuery(args: string[]): string {
|
|
const params = new URLSearchParams();
|
|
const month = optionValue(args, ["--month"]);
|
|
const from = optionValue(args, ["--from"]);
|
|
const to = optionValue(args, ["--to"]);
|
|
const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]);
|
|
const limit = optionValue(args, ["--limit"]);
|
|
if (month !== undefined) params.set("month", month);
|
|
if (from !== undefined) params.set("from", from);
|
|
if (to !== undefined) params.set("to", to);
|
|
if (sourceFile !== undefined) params.set("sourceFile", sourceFile);
|
|
if (limit !== undefined) params.set("limit", limit);
|
|
if (args.includes("--include-body")) params.set("includeBody", "true");
|
|
const query = params.toString();
|
|
return query ? `?${query}` : "";
|
|
}
|
|
|
|
function listDiary(args: string[]): unknown {
|
|
return unwrapProxyResponse(decisionProxy(`/api/diary/entries${diaryQuery(args)}`));
|
|
}
|
|
|
|
async function listDiaryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries${diaryQuery(args)}`));
|
|
}
|
|
|
|
function diaryHistory(args: string[]): unknown {
|
|
return unwrapProxyResponse(decisionProxy(`/api/diary/history${diaryQuery(args)}`));
|
|
}
|
|
|
|
async function diaryHistoryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/history${diaryQuery(args)}`));
|
|
}
|
|
|
|
function listDiaryMonths(): unknown {
|
|
return unwrapProxyResponse(decisionProxy("/api/diary/months"));
|
|
}
|
|
|
|
async function listDiaryMonthsAsync(fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/diary/months"));
|
|
}
|
|
|
|
function diaryShowQuery(args: string[]): string {
|
|
const params = new URLSearchParams();
|
|
const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]);
|
|
if (sourceFile !== undefined) params.set("sourceFile", sourceFile);
|
|
const query = params.toString();
|
|
return query ? `?${query}` : "";
|
|
}
|
|
|
|
function showDiary(key: string | undefined, args: string[] = []): unknown {
|
|
if (!key) throw new Error("decision diary show requires entry id or YYYY-MM-DD date");
|
|
return unwrapProxyResponse(decisionProxy(`/api/diary/entries/${encodeURIComponent(key)}${diaryShowQuery(args)}`));
|
|
}
|
|
|
|
async function showDiaryAsync(key: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>, args: string[] = []): Promise<unknown> {
|
|
if (!key) throw new Error("decision diary show requires entry id or YYYY-MM-DD date");
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries/${encodeURIComponent(key)}${diaryShowQuery(args)}`));
|
|
}
|
|
|
|
function todayDiary(): unknown {
|
|
return unwrapProxyResponse(decisionProxy("/api/diary/today"));
|
|
}
|
|
|
|
async function todayDiaryAsync(fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/diary/today"));
|
|
}
|
|
|
|
function diaryEditPayload(args: string[], command: string): { key: string; payload: Record<string, unknown>; bodySource: Record<string, string> } {
|
|
const key = positionalArgs(args)[0];
|
|
if (!key) throw new Error(`${command} requires entry id or YYYY-MM-DD date`);
|
|
const { body, bodySource } = optionalBodyFromArgs(args, command);
|
|
const payload: Record<string, unknown> = {};
|
|
const title = optionValue(args, ["--title"]);
|
|
const summary = optionValue(args, ["--summary"]);
|
|
const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]);
|
|
if (body !== undefined) payload.body = body;
|
|
if (title !== undefined) payload.title = title;
|
|
if (summary !== undefined) payload.summary = summary;
|
|
if (sourceFile !== undefined) payload.sourceFile = sourceFile;
|
|
const tags = splitList(optionValues(args, ["--tag", "--tags"]));
|
|
if (tags.length > 0) payload.tags = tags;
|
|
if (Object.keys(payload).length === 0) throw new Error(`${command} requires --body text, --body-file path, --summary, --title, --source-file, or --tag`);
|
|
return { key, payload, bodySource };
|
|
}
|
|
|
|
function editDiary(args: string[]): unknown {
|
|
const { key, payload, bodySource } = diaryEditPayload(args, "decision diary edit");
|
|
return { key, bodySource, result: unwrapProxyResponse(decisionProxy(`/api/diary/entries/${encodeURIComponent(key)}`, { method: "PUT", body: payload })) };
|
|
}
|
|
|
|
async function editDiaryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
const { key, payload, bodySource } = diaryEditPayload(args, "decision diary edit");
|
|
return { key, bodySource, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries/${encodeURIComponent(key)}`, { method: "PUT", body: payload })) };
|
|
}
|
|
|
|
function editTodayDiary(args: string[]): unknown {
|
|
const { body, bodySource } = optionalBodyFromArgs(args, "decision diary today --edit");
|
|
const payload: Record<string, unknown> = {};
|
|
const title = optionValue(args, ["--title"]);
|
|
const summary = optionValue(args, ["--summary"]);
|
|
const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]);
|
|
if (body !== undefined) payload.body = body;
|
|
if (title !== undefined) payload.title = title;
|
|
if (summary !== undefined) payload.summary = summary;
|
|
if (sourceFile !== undefined) payload.sourceFile = sourceFile;
|
|
const tags = splitList(optionValues(args, ["--tag", "--tags"]));
|
|
if (tags.length > 0) payload.tags = tags;
|
|
if (Object.keys(payload).length === 0) throw new Error("decision diary today --edit requires --body text, --body-file path, --summary, --title, --source-file, or --tag");
|
|
return { bodySource, result: unwrapProxyResponse(decisionProxy("/api/diary/today", { method: "PUT", body: payload })) };
|
|
}
|
|
|
|
async function editTodayDiaryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
const { body, bodySource } = optionalBodyFromArgs(args, "decision diary today --edit");
|
|
const payload: Record<string, unknown> = {};
|
|
const title = optionValue(args, ["--title"]);
|
|
const summary = optionValue(args, ["--summary"]);
|
|
const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]);
|
|
if (body !== undefined) payload.body = body;
|
|
if (title !== undefined) payload.title = title;
|
|
if (summary !== undefined) payload.summary = summary;
|
|
if (sourceFile !== undefined) payload.sourceFile = sourceFile;
|
|
const tags = splitList(optionValues(args, ["--tag", "--tags"]));
|
|
if (tags.length > 0) payload.tags = tags;
|
|
if (Object.keys(payload).length === 0) throw new Error("decision diary today --edit requires --body text, --body-file path, --summary, --title, --source-file, or --tag");
|
|
return { bodySource, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/diary/today", { method: "PUT", body: payload })) };
|
|
}
|
|
|
|
function showRecord(id: string | undefined): unknown {
|
|
if (!id) throw new Error("decision show requires record id");
|
|
return unwrapProxyResponse(decisionProxy(`/api/records/${encodeURIComponent(id)}`));
|
|
}
|
|
|
|
async function showRecordAsync(id: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
if (!id) throw new Error("decision show requires record id");
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records/${encodeURIComponent(id)}`));
|
|
}
|
|
|
|
function requirementPayload(args: string[], command: string, options: { partial?: boolean; includeId?: boolean } = {}): Record<string, unknown> {
|
|
const title = optionValue(args, ["--title"]);
|
|
const bodyArg = optionValue(args, ["--body"]);
|
|
const bodyFile = optionValue(args, ["--body-file", "--markdown-file", "--file"]);
|
|
const body = bodyArg !== undefined ? bodyArg : bodyFile !== undefined ? readMarkdownFile(bodyFile).markdown : undefined;
|
|
const payload: Record<string, unknown> = {};
|
|
const id = optionValue(args, ["--id"]);
|
|
const type = optionValue(args, ["--type"]);
|
|
const level = optionValue(args, ["--level", "--priority"]);
|
|
const status = optionValue(args, ["--status"]);
|
|
const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]);
|
|
const source = optionValue(args, ["--source"]);
|
|
const sourceSession = optionValue(args, ["--source-session", "--sourceSession"]);
|
|
const issueId = optionValue(args, ["--issue", "--issue-id", "--issueId", "--linked-issue", "--linked-task"]);
|
|
const taskId = optionValue(args, ["--task-id", "--taskId"]);
|
|
const commitId = optionValue(args, ["--commit-id", "--commitId"]);
|
|
const tags = splitList(optionValues(args, ["--tag", "--tags"]));
|
|
const evidenceLinks = splitList(optionValues(args, ["--evidence", "--evidence-link", "--evidenceLinks"]));
|
|
if (options.includeId !== false && id !== undefined) payload.id = id;
|
|
if (title !== undefined) payload.title = title;
|
|
if (body !== undefined) payload.body = body;
|
|
if (type !== undefined || options.partial !== true) payload.type = parseRequirementType(type, "external_goal");
|
|
if (level !== undefined || options.partial !== true) payload.level = parseLevel(level, "none");
|
|
if (status !== undefined || options.partial !== true) payload.status = parseStatus(status, "active");
|
|
if (linkedGoalId !== undefined) payload.linkedGoalId = linkedGoalId;
|
|
if (tags.length > 0 || options.partial !== true) payload.tags = tags;
|
|
if (evidenceLinks.length > 0 || options.partial !== true) payload.evidenceLinks = evidenceLinks;
|
|
if (source !== undefined) payload.source = source;
|
|
if (sourceSession !== undefined) payload.sourceSession = sourceSession;
|
|
if (issueId !== undefined) payload.issueId = issueId;
|
|
if (taskId !== undefined) payload.taskId = taskId;
|
|
if (commitId !== undefined) payload.commitId = commitId;
|
|
addDocumentPayloadFields(payload, args);
|
|
if (Object.keys(payload).length === 0) throw new Error(`${command} requires at least one field to write`);
|
|
if (options.partial !== true && title === undefined && body === undefined) throw new Error(`${command} requires --title or --body-file/--body`);
|
|
return payload;
|
|
}
|
|
|
|
function createRequirement(args: string[]): unknown {
|
|
return unwrapProxyResponse(decisionProxy("/api/requirements", { method: "POST", body: requirementPayload(args, "decision requirement create") }));
|
|
}
|
|
|
|
async function createRequirementAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/requirements", { method: "POST", body: requirementPayload(args, "decision requirement create") }));
|
|
}
|
|
|
|
function upsertRequirement(args: string[]): unknown {
|
|
return unwrapProxyResponse(decisionProxy("/api/requirements", { method: "PUT", body: requirementPayload(args, "decision requirement upsert") }));
|
|
}
|
|
|
|
async function upsertRequirementAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/requirements", { method: "PUT", body: requirementPayload(args, "decision requirement upsert") }));
|
|
}
|
|
|
|
function showRequirement(id: string | undefined): unknown {
|
|
if (!id) throw new Error("decision requirement show requires record id or docNo");
|
|
return unwrapProxyResponse(decisionProxy(`/api/requirements/${encodeURIComponent(id)}`));
|
|
}
|
|
|
|
async function showRequirementAsync(id: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
if (!id) throw new Error("decision requirement show requires record id or docNo");
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements/${encodeURIComponent(id)}`));
|
|
}
|
|
|
|
function updateRequirement(id: string | undefined, args: string[]): unknown {
|
|
if (!id) throw new Error("decision requirement update requires record id or docNo");
|
|
return unwrapProxyResponse(decisionProxy(`/api/requirements/${encodeURIComponent(id)}`, { method: "PUT", body: requirementPayload(args, "decision requirement update", { partial: true, includeId: false }) }));
|
|
}
|
|
|
|
async function updateRequirementAsync(id: string | undefined, args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
|
if (!id) throw new Error("decision requirement update requires record id or docNo");
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements/${encodeURIComponent(id)}`, { method: "PUT", body: requirementPayload(args, "decision requirement update", { partial: true, includeId: false }) }));
|
|
}
|
|
|
|
export async function runDecisionCenterCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
|
|
return runDecisionCenterCommandAsync(_config, args, nc01K8sDecisionFetch);
|
|
}
|
|
|
|
export async function runDecisionCenterCommandAsync(
|
|
_config: UniDeskConfig,
|
|
args: string[],
|
|
fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>,
|
|
): Promise<unknown> {
|
|
const [action = "list", id] = args;
|
|
if (action === "deployment" || action === "deploy") return runDeploymentCommand(_config, args.slice(1));
|
|
if (action === "storage") return runStorageCommand(args.slice(1), fetcher);
|
|
if (action === "diary") {
|
|
const [diaryAction = "list", diaryId] = args.slice(1);
|
|
if (diaryAction === "import") return importDiaryAsync(args.slice(2), fetcher);
|
|
if (diaryAction === "list") return listDiaryAsync(args.slice(2), fetcher);
|
|
if (diaryAction === "history") return diaryHistoryAsync(args.slice(2), fetcher);
|
|
if (diaryAction === "months") return listDiaryMonthsAsync(fetcher);
|
|
if (diaryAction === "today") return args.includes("--edit") ? editTodayDiaryAsync(args.slice(2).filter((arg) => arg !== "--edit"), fetcher) : todayDiaryAsync(fetcher);
|
|
if (diaryAction === "show") return showDiaryAsync(diaryId, fetcher, args.slice(3));
|
|
if (diaryAction === "edit" || diaryAction === "upsert") return editDiaryAsync(args.slice(2), fetcher);
|
|
throw new Error("decision diary command must be one of: import, list, history, months, today, show, edit, upsert");
|
|
}
|
|
if (action === "requirement" || action === "requirements") {
|
|
const [requirementAction = "list", requirementId] = args.slice(1);
|
|
if (requirementAction === "list") {
|
|
const query = recordQuery(args.slice(2), { requirementOnly: true });
|
|
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements${query ? `?${query}` : ""}`));
|
|
}
|
|
if (requirementAction === "create") return createRequirementAsync(args.slice(2), fetcher);
|
|
if (requirementAction === "upsert") return upsertRequirementAsync(args.slice(2), fetcher);
|
|
if (requirementAction === "show") return showRequirementAsync(requirementId, fetcher);
|
|
if (requirementAction === "update") return updateRequirementAsync(requirementId, args.slice(3), fetcher);
|
|
throw new Error("decision requirement command must be one of: list, create, show, update, upsert");
|
|
}
|
|
if (action === "upload") return uploadMeetingAsync(args.slice(1), fetcher);
|
|
if (action === "list") return listRecordsAsync(args.slice(1), fetcher);
|
|
if (action === "show") return showRecordAsync(id, fetcher);
|
|
if (action === "health") return unwrapProxyResponse(await fetcher(`/api/microservices/${encodeURIComponent(serviceId)}/health`));
|
|
throw new Error("decision command must be one of: upload, list, show, health, requirement, diary, deployment, storage");
|
|
}
|