231 lines
8.5 KiB
TypeScript
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,
|
|
});
|
|
}
|