Files
pikasTech-unidesk/scripts/src/tasktree.ts
T
2026-07-18 18:20:08 +02:00

231 lines
8.5 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { isAbsolute, join, relative } from "node:path";
import { rootPath } from "./config";
import { CliInputError } from "./output";
import { parseEnvFile, readYamlRecord } from "./platform-infra-ops-library";
interface TaskTreeRuntimeConfig {
defaults?: {
node?: unknown;
backup?: unknown;
};
targets?: unknown;
native?: {
targetRef?: unknown;
};
}
interface NodeLanesConfig {
lanes?: unknown;
}
interface SecretsDistributionConfig {
sources?: {
root?: unknown;
};
}
export function runTaskTreeCommand(args: string[]): number {
const runtime = readYamlRecord<TaskTreeRuntimeConfig>(
rootPath("config", "hwlab-tasktree.yaml"),
"hwlab-tasktree-runtime",
);
const targetName = nativeTargetName(runtime.native?.targetRef);
const target = recordField(recordField(runtime, "targets"), targetName);
const laneName = stringField(target, "lane", `targets.${targetName}.lane`);
const nodeName = stringValue(runtime.defaults?.node, "defaults.node");
const workspace = taskTreeWorkspace(laneName, nodeName);
const sourceRef = stringField(
recordField(target, "database", `targets.${targetName}.database`),
"sourceRef",
`targets.${targetName}.database.sourceRef`,
);
const secretRoot = taskTreeSecretRoot();
const secretPath = containedPath(secretRoot, sourceRef, "database.sourceRef");
if (!existsSync(secretPath)) {
throw new CliInputError("TaskTree 数据库 Secret source 不存在", {
code: "tasktree_database_secret_missing",
argument: sourceRef,
hint: "使用 bun scripts/cli.ts secrets sync/status 核对 YAML 声明的 Secret source。",
});
}
const databaseUrl = parseEnvFile(readFileSync(secretPath, "utf8")).DATABASE_URL;
if (!databaseUrl) {
throw new CliInputError("TaskTree 数据库 Secret source 缺少 DATABASE_URL", {
code: "tasktree_database_url_missing",
argument: sourceRef,
});
}
const cliPath = join(workspace, "tools", "hwlab-cli", "bin", "hwlab-cli.ts");
if (!existsSync(cliPath)) {
throw new CliInputError("YAML 选中的 HWLAB workspace 缺少 TaskTree CLI", {
code: "tasktree_cli_missing",
argument: cliPath,
});
}
const childEnv: Record<string, string | undefined> = {
...process.env,
TASKTREE_DATABASE_URL: databaseUrl,
};
const overapiEndpoint = optionalNestedString(target, ["runtime", "cli", "overapiEndpoint"]);
if (overapiEndpoint !== undefined) childEnv.HWLAB_TASKTREE_API_URL = overapiEndpoint;
if (args[0] === "backup") Object.assign(childEnv, taskTreeBackupEnv(runtime, target));
const result = Bun.spawnSync({
cmd: ["bun", cliPath, "tasktree", ...args],
cwd: workspace,
env: childEnv,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
return result.exitCode;
}
function nativeTargetName(value: unknown): string {
const targetRef = stringValue(value, "native.targetRef");
const match = /^targets\.([A-Za-z0-9_-]+)$/u.exec(targetRef);
if (match === null) {
throw new CliInputError("TaskTree native.targetRef 必须指向 targets.<name>", {
code: "tasktree_invalid_target_ref",
argument: targetRef,
});
}
return match[1]!;
}
function taskTreeWorkspace(laneName: string, nodeName: string): string {
const config = readYamlRecord<NodeLanesConfig>(
rootPath("config", "hwlab-node-lanes.yaml"),
"HwlabNodeLaneConfig",
);
const lane = recordField(recordField(config, "lanes"), laneName, `lanes.${laneName}`);
const targets = recordField(lane, "targets", `lanes.${laneName}.targets`);
const target = recordField(targets, nodeName, `lanes.${laneName}.targets.${nodeName}`);
const workspace = stringField(target, "workspace", `lanes.${laneName}.targets.${nodeName}.workspace`);
if (!isAbsolute(workspace)) {
throw new CliInputError("HWLAB workspace 必须是绝对路径", {
code: "tasktree_invalid_workspace",
argument: workspace,
});
}
return workspace;
}
function taskTreeSecretRoot(): string {
const config = readYamlRecord<SecretsDistributionConfig>(
rootPath("config", "secrets-distribution.yaml"),
"unidesk-secret-distribution",
);
const secretRoot = stringValue(config.sources?.root, "sources.root");
if (!isAbsolute(secretRoot)) {
throw new CliInputError("统一 Secret root 必须是绝对路径", {
code: "tasktree_invalid_secret_root",
argument: secretRoot,
});
}
return secretRoot;
}
function taskTreeBackupEnv(
runtime: TaskTreeRuntimeConfig,
target: Record<string, unknown>,
): Record<string, string> {
const backup = optionalRecord(runtime.defaults?.backup);
const targetBackup = optionalRecord(target.backup);
if (backup === undefined || targetBackup === undefined) {
throw new CliInputError("TaskTree owning YAML 尚未声明 native 备份配置", {
code: "tasktree_backup_config_missing",
hint: "先将 defaults.backup 与当前 target.backup 合入 config/hwlab-tasktree.yaml。",
});
}
return {
TASKTREE_BACKUP_ENABLED: String(booleanField(backup, "enabled", "defaults.backup.enabled")),
TASKTREE_BACKUP_REPO_SSH_URL: stringField(backup, "sshUrl", "defaults.backup.sshUrl"),
TASKTREE_BACKUP_BRANCH: stringField(backup, "branch", "defaults.backup.branch"),
TASKTREE_BACKUP_BASE_PATH: stringField(targetBackup, "basePath", "target.backup.basePath"),
TASKTREE_BACKUP_WORKTREE: stringField(backup, "worktreePath", "defaults.backup.worktreePath"),
TASKTREE_BACKUP_TARGET: stringField(targetBackup, "target", "target.backup.target"),
TASKTREE_BACKUP_INTERVAL_MS: String(durationMilliseconds(stringField(backup, "interval", "defaults.backup.interval"))),
TASKTREE_BACKUP_GIT_AUTHOR_NAME: stringField(recordField(backup, "author", "defaults.backup.author"), "name", "defaults.backup.author.name"),
TASKTREE_BACKUP_GIT_AUTHOR_EMAIL: stringField(recordField(backup, "author", "defaults.backup.author"), "email", "defaults.backup.author.email"),
TASKTREE_BACKUP_GIT_COMMIT_PREFIX: stringField(backup, "commitMessagePrefix", "defaults.backup.commitMessagePrefix"),
};
}
function containedPath(root: string, sourceRef: string, label: string): string {
if (isAbsolute(sourceRef)) {
throw new CliInputError(`${label} 必须相对统一 Secret root`, {
code: "tasktree_invalid_secret_source_ref",
argument: sourceRef,
});
}
const path = join(root, sourceRef);
const rel = relative(root, path);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new CliInputError(`${label} 超出统一 Secret root`, {
code: "tasktree_invalid_secret_source_ref",
argument: sourceRef,
});
}
return path;
}
function durationMilliseconds(value: string): number {
const match = /^(\d+)(ms|s|m|h)$/u.exec(value);
if (match === null) {
throw new CliInputError("TaskTree backup interval 格式无效", {
code: "tasktree_invalid_backup_interval",
argument: value,
});
}
const multiplier = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[match[2]!]!;
return Number(match[1]) * multiplier;
}
function optionalNestedString(record: Record<string, unknown>, path: string[]): string | undefined {
let value: unknown = record;
for (const key of path) {
const current = optionalRecord(value);
if (current === undefined) return undefined;
value = current[key];
}
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function optionalRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}
function recordField(value: unknown, key: string, label = key): Record<string, unknown> {
const record = optionalRecord(value);
const nested = record === undefined ? undefined : optionalRecord(record[key]);
if (nested === undefined) throw configError(label);
return nested;
}
function stringField(record: Record<string, unknown>, key: string, label: string): string {
return stringValue(record[key], label);
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) throw configError(label);
return value;
}
function booleanField(record: Record<string, unknown>, key: string, label: string): boolean {
if (typeof record[key] !== "boolean") throw configError(label);
return record[key];
}
function configError(label: string): CliInputError {
return new CliInputError(`TaskTree owning YAML 缺少 ${label}`, {
code: "tasktree_config_invalid",
argument: label,
});
}