Merge pull request #256 from pikasTech/fix/check-scripts-typecheck-visibility
fix: 让 scripts typecheck 长命令持续可见
This commit is contained in:
@@ -26,7 +26,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动
|
||||
- 每个 CLI 命名空间必须支持 `help`、`--help` 或 `-h` 并返回 JSON,不得为了打印帮助而访问 runtime 服务、拉起交互会话或执行长时任务。
|
||||
- `--main-server-ip <ip> <command>` 默认通过公网 frontend 登录态调用主 server 的同源 API 代理,不要求计算节点持有主 server SSH key;显式提供 `--main-server-key` 或 `--main-server-transport ssh` 时才使用旧 SSH 传输。远程 frontend 传输下的 `ssh <route> ...` 必须复用同一套结构化 route parser,支持 `D601`、`G14`、host workspace、`D601:win`、`D601:win/c/test`、`D601:k3s` 和 `D601:k3s:<namespace>:<workload>` 这类定位路径;它不向调用容器下发 provider token,也不要求调用容器能解析 backend-core 内网 DNS。
|
||||
- `config show` 读取并校验根目录 `config.json`,不从环境变量、默认值或隐藏文件静默补配置。
|
||||
- `check` 默认只执行轻量配置校验、Bun 版本检查和 Bun Transpiler 语法解析(覆盖 CLI 入口、主要 `scripts/` 模块和核心组件入口,不做类型推导);关键文件存在性、`scripts/` TypeScript 类型检查、GitHub CLI contract、`src/components/` TypeScript 类型检查、Docker Compose config、日志轮转策略扫描和 D601 recovery guardrails 默认不启用,分别通过 `--files`、`--scripts-typecheck`、`--gh-contracts`、`--components`、`--compose`、`--logs`、`--recovery-guardrails` 开启,或用 `--full` 一次性开启。`--scripts-typecheck` 只表示 scripts TypeScript 与本地脚本合同,不应触发 GitHub issue/PR live contract 这类可能受网络/API 时延影响的检查;需要验证 GitHub CLI 时显式加 `--gh-contracts`,并以该 profile 的结果判定 GitHub CLI 变更。长命令项必须在 stderr 输出 `unidesk.check.progress` JSON lines,stdout 保持最终 JSON 结果,避免 post-task 或人工运行时长时间无可见进度。`check recovery-guardrails` 是同一诊断的低噪声直接入口,报告 malformed `/proc/mounts`、kubelet validation risk、stale CRI sandbox count、Code Queue worktree/symlink、Code Queue/MDTODO hostPath 和 `ContainerCreating` 分类;它不得重启 k3s、删除 CRI sandbox、修改 hostPath、deploy/rollout 或 prune/reset。`--rust` 只允许在 D601 CI/dev execution 中配合 `UNIDESK_D601_RUST_CHECK=1` 使用,长期规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。
|
||||
- `check` 默认只执行轻量配置校验、Bun 版本检查和 Bun Transpiler 语法解析(覆盖 CLI 入口、主要 `scripts/` 模块和核心组件入口,不做类型推导);关键文件存在性、`scripts/` TypeScript 类型检查、GitHub CLI contract、`src/components/` TypeScript 类型检查、Docker Compose config、日志轮转策略扫描和 D601 recovery guardrails 默认不启用,分别通过 `--files`、`--scripts-typecheck`、`--gh-contracts`、`--components`、`--compose`、`--logs`、`--recovery-guardrails` 开启,或用 `--full` 一次性开启。`--scripts-typecheck` 只表示 scripts TypeScript 与本地脚本合同,不应触发 GitHub issue/PR live contract 这类可能受网络/API 时延影响的检查;需要验证 GitHub CLI 时显式加 `--gh-contracts`,并以该 profile 的结果判定 GitHub CLI 变更。长命令项必须在 stderr 输出 `unidesk.check.progress` JSON lines,stdout 保持最终 JSON 结果,避免 post-task 或人工运行时长时间无可见进度。`typescript:scripts` 固定通过 `bun --bun tsc -p scripts/tsconfig.json --noEmit --pretty false` 执行,默认 `--scripts-typecheck-timeout-ms 120000`,可按目标运行面显式调小或调大但 CLI 会封顶;`--check-heartbeat-ms` 控制运行中心跳间隔,默认 `15000`。所有命令项的最终 item detail 必须包含 `durationMs`、`timeoutMs`、`heartbeatMs`、`exitCode`、`signal`、`timedOut`、stdout/stderr byte count、truncation flag 和有界 tail;超时必须返回 `timedOut=true`,不得只留下被外层命令杀死的空输出。`check recovery-guardrails` 是同一诊断的低噪声直接入口,报告 malformed `/proc/mounts`、kubelet validation risk、stale CRI sandbox count、Code Queue worktree/symlink、Code Queue/MDTODO hostPath 和 `ContainerCreating` 分类;它不得重启 k3s、删除 CRI sandbox、修改 hostPath、deploy/rollout 或 prune/reset。`--rust` 只允许在 D601 CI/dev execution 中配合 `UNIDESK_D601_RUST_CHECK=1` 使用,长期规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。
|
||||
- `server start` 创建异步 job,在后台执行 Docker 构建和启动;命令本身只负责返回 job id、日志路径和启动命令。
|
||||
- `server stop` 创建异步 job,在后台停止固定 Compose project 中的全部 UniDesk 服务。
|
||||
- `server status` 查询公开端口、受限宿主端口、内部端口、主机 swap 摘要、Compose 容器、core/frontend/dev-frontend/provider/database 健康检查和访问 URL;D601 Code Queue 使用的 PostgreSQL/OA Event Flow host mapping 必须出现在受限宿主端口而不是无条件公开入口中。低内存主 server 上 `swap.warning` 非空时,先执行 `server swap status` 或 `server swap ensure`。
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { runCommandObserved, type CommandProgress } from "./src/command";
|
||||
import { repoRoot } from "./src/config";
|
||||
|
||||
const progressEvents: CommandProgress[] = [];
|
||||
const startedAt = Date.now();
|
||||
const result = await runCommandObserved(
|
||||
[
|
||||
"bun",
|
||||
"--eval",
|
||||
"setTimeout(() => {}, 10_000)",
|
||||
],
|
||||
repoRoot,
|
||||
{
|
||||
timeoutMs: 250,
|
||||
heartbeatMs: 50,
|
||||
killAfterMs: 100,
|
||||
onProgress: (progress) => progressEvents.push(progress),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.timedOut, true);
|
||||
assert.notEqual(result.signal, null);
|
||||
assert.ok(result.durationMs !== undefined && result.durationMs < 2_000, `expected bounded duration, got ${result.durationMs}`);
|
||||
assert.ok(Date.now() - startedAt < 2_000, "silent child should not block beyond timeout");
|
||||
assert.ok(progressEvents.length > 0, "silent child must still emit heartbeat progress");
|
||||
assert.ok(progressEvents.some((event) => event.elapsedMs > 0 && event.timeoutMs === 250), "heartbeat must expose elapsed and timeout");
|
||||
assert.equal(result.stdoutBytes, 0);
|
||||
assert.equal(result.stderrBytes, 0);
|
||||
assert.equal(result.stdoutTruncated, false);
|
||||
assert.equal(result.stderrTruncated, false);
|
||||
|
||||
const noisy = await runCommandObserved(
|
||||
[
|
||||
"bun",
|
||||
"--eval",
|
||||
"console.log('x'.repeat(2048)); console.error('y'.repeat(2048));",
|
||||
],
|
||||
repoRoot,
|
||||
{ timeoutMs: 5_000, maxCaptureChars: 128 },
|
||||
);
|
||||
|
||||
assert.equal(noisy.exitCode, 0);
|
||||
assert.ok(noisy.stdout.length <= 128);
|
||||
assert.ok(noisy.stderr.length <= 128);
|
||||
assert.equal(noisy.stdoutTruncated, true);
|
||||
assert.equal(noisy.stderrTruncated, true);
|
||||
|
||||
console.log("check command progress contract ok");
|
||||
@@ -4,6 +4,8 @@ import { parseCheckOptions, runChecks } from "./src/check";
|
||||
const scriptsOnly = parseCheckOptions(["--scripts-typecheck"]);
|
||||
assert.equal(scriptsOnly.scriptsTypecheck, true);
|
||||
assert.equal(scriptsOnly.ghContracts, false);
|
||||
assert.equal(scriptsOnly.scriptsTypecheckTimeoutMs, 120_000);
|
||||
assert.equal(scriptsOnly.checkHeartbeatMs, 15_000);
|
||||
|
||||
const full = parseCheckOptions(["--full"]);
|
||||
assert.equal(full.scriptsTypecheck, true);
|
||||
@@ -13,11 +15,16 @@ const explicit = parseCheckOptions(["--gh-contracts"]);
|
||||
assert.equal(explicit.scriptsTypecheck, false);
|
||||
assert.equal(explicit.ghContracts, true);
|
||||
|
||||
const tunedVisibility = parseCheckOptions(["--scripts-typecheck", "--scripts-typecheck-timeout-ms", "1000", "--check-heartbeat-ms", "200"]);
|
||||
assert.equal(tunedVisibility.scriptsTypecheck, true);
|
||||
assert.equal(tunedVisibility.scriptsTypecheckTimeoutMs, 1000);
|
||||
assert.equal(tunedVisibility.checkHeartbeatMs, 200);
|
||||
|
||||
const minimalConfig = {
|
||||
project: { name: "contract" },
|
||||
runtime: "contract",
|
||||
} as never;
|
||||
const result = runChecks(minimalConfig, { ...scriptsOnly, scriptsTypecheck: false });
|
||||
const result = await runChecks(minimalConfig, { ...scriptsOnly, scriptsTypecheck: false });
|
||||
const issueGuard = result.items.find((item) => item.name === "gh:issue-guard-contract");
|
||||
const prFiles = result.items.find((item) => item.name === "gh:pr-files-contract");
|
||||
const pr = result.items.find((item) => item.name === "gh:pr-contract");
|
||||
|
||||
+1
-1
@@ -363,7 +363,7 @@ async function main(): Promise<void> {
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const result = runChecks(config, parseCheckOptions(args.slice(1)));
|
||||
const result = await runChecks(config, parseCheckOptions(args.slice(1)));
|
||||
emitJson(commandName, result, result.ok);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
|
||||
+140
-79
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
import { runCommand } from "./command";
|
||||
import { runCommandObserved, type CommandProgress } from "./command";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { composeConfig } from "./docker";
|
||||
import { compactD601RecoveryGuardrails, runD601RecoveryGuardrails } from "./recovery-guardrails";
|
||||
@@ -19,6 +19,7 @@ const syntaxFiles = [
|
||||
"scripts/platform-infra-sub2api-codex-routing-contract-test.ts",
|
||||
"scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts",
|
||||
"scripts/platform-infra-sub2api-http-upstream-contract-test.ts",
|
||||
"scripts/check-command-progress-contract-test.ts",
|
||||
"scripts/check-gh-contract-scope-contract-test.ts",
|
||||
"scripts/src/playwright-cli.ts",
|
||||
"scripts/src/check.ts",
|
||||
@@ -89,8 +90,13 @@ export interface CheckOptions {
|
||||
recoveryGuardrails: boolean;
|
||||
ghContracts: boolean;
|
||||
rust: boolean;
|
||||
scriptsTypecheckTimeoutMs: number;
|
||||
checkHeartbeatMs: number;
|
||||
}
|
||||
|
||||
const defaultScriptsTypecheckTimeoutMs = 120_000;
|
||||
const defaultCheckHeartbeatMs = 15_000;
|
||||
|
||||
const defaultCheckOptions: CheckOptions = {
|
||||
full: false,
|
||||
files: false,
|
||||
@@ -101,14 +107,24 @@ const defaultCheckOptions: CheckOptions = {
|
||||
recoveryGuardrails: false,
|
||||
ghContracts: false,
|
||||
rust: false,
|
||||
scriptsTypecheckTimeoutMs: defaultScriptsTypecheckTimeoutMs,
|
||||
checkHeartbeatMs: defaultCheckHeartbeatMs,
|
||||
};
|
||||
|
||||
interface CheckRunResult {
|
||||
ok: boolean;
|
||||
mode: string;
|
||||
options: CheckOptions;
|
||||
summary: { total: number; passed: number; failed: number; failedItems: string[] };
|
||||
items: CheckItem[];
|
||||
}
|
||||
|
||||
export function checkHelp(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "check",
|
||||
usage: [
|
||||
"bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--gh-contracts|--components|--compose|--logs|--recovery-guardrails|--rust]",
|
||||
"bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--gh-contracts|--components|--compose|--logs|--recovery-guardrails|--rust]",
|
||||
"bun scripts/cli.ts check recovery-guardrails",
|
||||
],
|
||||
defaultMode: "syntax/config only; Rust is never compiled on the master server by default",
|
||||
@@ -116,7 +132,9 @@ export function checkHelp(): Record<string, unknown> {
|
||||
{ name: "--syntax-only|--basic", description: "Run only config validation, Bun version and TypeScript syntax transpile." },
|
||||
{ name: "--full", description: "Enable all non-Rust checks, including explicit GitHub CLI contracts." },
|
||||
{ name: "--files", description: "Verify required entrypoint files, including backend-core Cargo files." },
|
||||
{ name: "--scripts-typecheck", description: "Run scripts TypeScript typecheck." },
|
||||
{ name: "--scripts-typecheck", description: "Run scripts TypeScript typecheck through the observed checker." },
|
||||
{ name: "--scripts-typecheck-timeout-ms N", description: `Bound scripts TypeScript typecheck duration; default ${defaultScriptsTypecheckTimeoutMs}.` },
|
||||
{ name: "--check-heartbeat-ms N", description: `Emit unidesk.check.progress JSON lines for running command checks; default ${defaultCheckHeartbeatMs}.` },
|
||||
{ name: "--gh-contracts", description: "Run slower GitHub CLI contract tests; intentionally separate from generic scripts typecheck." },
|
||||
{ name: "--components", description: "Run component TypeScript typecheck." },
|
||||
{ name: "--compose", description: "Render Docker Compose config." },
|
||||
@@ -138,7 +156,8 @@ export function checkHelp(): Record<string, unknown> {
|
||||
|
||||
export function parseCheckOptions(args: string[]): CheckOptions {
|
||||
const options = { ...defaultCheckOptions };
|
||||
for (const arg of args) {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--full") {
|
||||
options.full = true;
|
||||
options.files = true;
|
||||
@@ -148,6 +167,12 @@ export function parseCheckOptions(args: string[]): CheckOptions {
|
||||
options.logs = true;
|
||||
options.recoveryGuardrails = true;
|
||||
options.ghContracts = true;
|
||||
} else if (arg === "--scripts-typecheck-timeout-ms") {
|
||||
options.scriptsTypecheckTimeoutMs = positiveIntegerOption(arg, args[index + 1], 600_000);
|
||||
index += 1;
|
||||
} else if (arg === "--check-heartbeat-ms") {
|
||||
options.checkHeartbeatMs = positiveIntegerOption(arg, args[index + 1], 60_000);
|
||||
index += 1;
|
||||
} else if (arg === "--files") {
|
||||
options.files = true;
|
||||
} else if (arg === "--scripts-typecheck") {
|
||||
@@ -173,6 +198,12 @@ export function parseCheckOptions(args: string[]): CheckOptions {
|
||||
return options;
|
||||
}
|
||||
|
||||
function positiveIntegerOption(name: string, raw: string | undefined, max: number): number {
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
||||
return Math.min(value, max);
|
||||
}
|
||||
|
||||
function fileItem(path: string): CheckItem {
|
||||
const absolute = rootPath(path);
|
||||
return { name: `file:${path}`, ok: existsSync(absolute), detail: absolute };
|
||||
@@ -182,13 +213,18 @@ function emitCheckProgress(detail: Record<string, unknown>): void {
|
||||
console.error(JSON.stringify({ event: "unidesk.check.progress", ...detail }));
|
||||
}
|
||||
|
||||
function commandItem(name: string, command: string[], timeoutMs = 30_000, env: NodeJS.ProcessEnv = process.env): CheckItem {
|
||||
async function commandItem(name: string, command: string[], timeoutMs = 30_000, env: NodeJS.ProcessEnv = process.env, heartbeatMs = defaultCheckHeartbeatMs): Promise<CheckItem> {
|
||||
const startedAt = Date.now();
|
||||
emitCheckProgress({ phase: "started", name, command, timeoutMs });
|
||||
const result = runCommand(command, repoRoot, { timeoutMs, env });
|
||||
const durationMs = Date.now() - startedAt;
|
||||
emitCheckProgress({ phase: "started", name, command, timeoutMs, heartbeatMs });
|
||||
const result = await runCommandObserved(command, repoRoot, {
|
||||
timeoutMs,
|
||||
env,
|
||||
heartbeatMs,
|
||||
onProgress: (progress) => emitCommandHeartbeat(name, command, progress),
|
||||
});
|
||||
const durationMs = result.durationMs ?? Date.now() - startedAt;
|
||||
emitCheckProgress({
|
||||
phase: result.exitCode === 0 ? "succeeded" : "failed",
|
||||
phase: result.timedOut ? "timed-out" : result.exitCode === 0 ? "succeeded" : "failed",
|
||||
name,
|
||||
durationMs,
|
||||
exitCode: result.exitCode,
|
||||
@@ -204,12 +240,32 @@ function commandItem(name: string, command: string[], timeoutMs = 30_000, env: N
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
timeoutMs,
|
||||
heartbeatMs,
|
||||
stdoutBytes: result.stdoutBytes,
|
||||
stderrBytes: result.stderrBytes,
|
||||
stdoutTruncated: result.stdoutTruncated,
|
||||
stderrTruncated: result.stderrTruncated,
|
||||
stdoutTail: result.stdout.slice(-4000),
|
||||
stderrTail: result.stderr.slice(-4000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emitCommandHeartbeat(name: string, command: string[], progress: CommandProgress): void {
|
||||
emitCheckProgress({
|
||||
phase: "running",
|
||||
name,
|
||||
command,
|
||||
elapsedMs: progress.elapsedMs,
|
||||
timeoutMs: progress.timeoutMs,
|
||||
pid: progress.pid,
|
||||
stdoutBytes: progress.stdoutBytes,
|
||||
stderrBytes: progress.stderrBytes,
|
||||
lastOutputAgeMs: progress.lastOutputAgeMs,
|
||||
});
|
||||
}
|
||||
|
||||
function syntaxItem(): CheckItem {
|
||||
const failures: Array<{ path: string; error: string }> = [];
|
||||
const checked: string[] = [];
|
||||
@@ -307,24 +363,26 @@ function codeQueueMgrHealthcheckItem(): CheckItem {
|
||||
};
|
||||
}
|
||||
|
||||
function rustCheckItem(): CheckItem {
|
||||
function skippedRustCheckItem(): CheckItem {
|
||||
return {
|
||||
name: "rust:backend-core",
|
||||
ok: false,
|
||||
detail: {
|
||||
skipped: true,
|
||||
reason: "Rust compilation is intentionally not allowed on the master server; run it from an approved native k3s CI/dev execution plane.",
|
||||
enableOnNativeK3s: "UNIDESK_NATIVE_K3S_RUST_CHECK=1 bun scripts/cli.ts check --rust",
|
||||
legacyEnableOnD601: "UNIDESK_D601_RUST_CHECK=1 bun scripts/cli.ts check --rust",
|
||||
deployPath: "bun scripts/cli.ts deploy apply --env dev --service backend-core",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runRustCheckItem(heartbeatMs: number): Promise<CheckItem> {
|
||||
const rustCheckAllowed = process.env.UNIDESK_D601_RUST_CHECK === "1" || process.env.UNIDESK_NATIVE_K3S_RUST_CHECK === "1";
|
||||
if (!rustCheckAllowed) {
|
||||
return {
|
||||
name: "rust:backend-core",
|
||||
ok: false,
|
||||
detail: {
|
||||
skipped: true,
|
||||
reason: "Rust compilation is intentionally not allowed on the master server; run it from an approved native k3s CI/dev execution plane.",
|
||||
enableOnNativeK3s: "UNIDESK_NATIVE_K3S_RUST_CHECK=1 bun scripts/cli.ts check --rust",
|
||||
legacyEnableOnD601: "UNIDESK_D601_RUST_CHECK=1 bun scripts/cli.ts check --rust",
|
||||
deployPath: "bun scripts/cli.ts deploy apply --env dev --service backend-core",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!rustCheckAllowed) return skippedRustCheckItem();
|
||||
const envPath = process.env.HOME ? `${process.env.HOME}/.cargo/bin:${process.env.PATH ?? ""}` : process.env.PATH;
|
||||
const env = envPath ? { ...process.env, PATH: envPath } : process.env;
|
||||
return commandItem("rust:backend-core", ["cargo", "check", "--manifest-path", "src/components/backend-core/Cargo.toml"], 180_000, env);
|
||||
return commandItem("rust:backend-core", ["cargo", "check", "--manifest-path", "src/components/backend-core/Cargo.toml"], 180_000, env, heartbeatMs);
|
||||
}
|
||||
|
||||
function skippedItem(name: string, reason: string, enableWith: string): CheckItem {
|
||||
@@ -335,13 +393,13 @@ export function runRecoveryGuardrailsCheck(config: UniDeskConfig): ReturnType<ty
|
||||
return compactD601RecoveryGuardrails(runD601RecoveryGuardrails(config));
|
||||
}
|
||||
|
||||
export function runChecks(config: UniDeskConfig, options: CheckOptions = defaultCheckOptions): { ok: boolean; mode: string; options: CheckOptions; summary: { total: number; passed: number; failed: number; failedItems: string[] }; items: CheckItem[] } {
|
||||
export async function runChecks(config: UniDeskConfig, options: CheckOptions = defaultCheckOptions): Promise<CheckRunResult> {
|
||||
const items: CheckItem[] = [
|
||||
{ name: "config:validated", ok: true, detail: { project: config.project.name, runtime: config.runtime } },
|
||||
commandItem("bun:version", ["bun", "--version"]),
|
||||
syntaxItem(),
|
||||
codeQueueMgrHealthcheckItem(),
|
||||
];
|
||||
items.push(await commandItem("bun:version", ["bun", "--version"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(syntaxItem());
|
||||
items.push(codeQueueMgrHealthcheckItem());
|
||||
if (options.files) {
|
||||
items.push(
|
||||
fileItem("scripts/cli.ts"),
|
||||
@@ -405,6 +463,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
fileItem("scripts/deploy-artifact-matrix-contract-test.ts"),
|
||||
fileItem("scripts/decision-center-desired-state-contract-test.ts"),
|
||||
fileItem("scripts/code-queue-prompt-observation-test.ts"),
|
||||
fileItem("scripts/check-command-progress-contract-test.ts"),
|
||||
fileItem("scripts/check-gh-contract-scope-contract-test.ts"),
|
||||
fileItem("scripts/gh-cli-issue-guard-contract-test.ts"),
|
||||
fileItem("scripts/gh-cli-pr-files-contract-test.ts"),
|
||||
@@ -436,54 +495,56 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(skippedItem("files:required-entrypoints", "required file presence scan is opt-in", "--files or --full"));
|
||||
}
|
||||
if (options.scriptsTypecheck) {
|
||||
items.push(commandItem("typescript:scripts", ["bunx", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"], 120_000));
|
||||
items.push(commandItem("code-queue:prompt-observation-contract", ["bun", "scripts/code-queue-prompt-observation-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:issue3-diagnostics-and-image-preflight", ["bun", "scripts/code-queue-issue3-regression-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:cli-disclosure-contract", ["bun", "scripts/code-queue-cli-disclosure-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:prompt-lint-contract", ["bun", "scripts/code-queue-prompt-lint-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:cli-steer-contract", ["bun", "scripts/code-queue-cli-steer-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:steer-confirmation-contract", ["bun", "scripts/code-queue-steer-confirmation-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:resume-contract", ["bun", "scripts/code-queue-resume-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:read-terminal-contract", ["bun", "scripts/code-queue-cli-read-terminal-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:submit-execution-mode-contract", ["bun", "scripts/code-queue-submit-execution-mode-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:submit-summary-contract", ["bun", "scripts/code-queue-submit-summary-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:gh-auth-redaction-contract", ["bun", "scripts/code-queue-gh-auth-redaction-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:queues-shape-contract", ["bun", "scripts/code-queue-queues-shape-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:supervisor-disclosure-contract", ["bun", "scripts/code-queue-supervisor-disclosure-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:commander-view-contract", ["bun", "scripts/code-queue-commander-view-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:postgres-rotation-contract", ["bun", "scripts/code-queue-postgres-rotation-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("host-codex-commander:prompt-lint-contract", ["bun", "scripts/host-codex-commander-prompt-lint-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("ssh:argv-guidance-contract", ["bun", "scripts/ssh-argv-guidance-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 90_000));
|
||||
items.push(commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:active-run-heartbeat-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:active-run-heartbeat-visible"], 30_000));
|
||||
items.push(commandItem("code-queue:trace-gap-not-stale", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:trace-gap-not-stale"], 30_000));
|
||||
items.push(commandItem("code-queue:stale-active-owner-expired", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:stale-active-owner-expired"], 30_000));
|
||||
items.push(commandItem("code-queue:control-plane-split-brain-diagnostics", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:control-plane-split-brain-diagnostics"], 30_000));
|
||||
items.push(commandItem("code-queue:oa-publisher-degraded-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:oa-publisher-degraded-visible"], 30_000));
|
||||
items.push(commandItem("baidu-netdisk:artifact-guard-contract", ["bun", "scripts/baidu-netdisk-artifact-guard-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("artifact-registry:direct-compose-dry-run-matrix", ["bun", "scripts/artifact-consumer-dry-run-matrix-test.ts"], 30_000));
|
||||
items.push(commandItem("schedule:cli-contract", ["bun", "scripts/schedule-cli-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("server:cleanup-plan-contract", ["bun", "scripts/server-cleanup-plan-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("check:gh-contract-scope-contract", ["bun", "scripts/check-gh-contract-scope-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("playwright:cli-wrapper-contract", ["bun", "scripts/playwright-cli-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("platform-infra:sub2api-codex-local-config-contract", ["bun", "scripts/platform-infra-sub2api-codex-local-config-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("platform-infra:sub2api-codex-routing-contract", ["bun", "scripts/platform-infra-sub2api-codex-routing-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("platform-infra:sub2api-codex-temp-unsched-contract", ["bun", "scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("platform-infra:sub2api-http-upstream-contract", ["bun", "scripts/platform-infra-sub2api-http-upstream-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("d601:recovery-guardrails-contract", ["bun", "scripts/d601-recovery-guardrails-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("hwlab:cd-wrapper-contract", ["bun", "scripts/hwlab-cd-wrapper-contract-test.ts"], 30_000));
|
||||
items.push(await commandItem("typescript:scripts", ["bun", "--bun", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"], options.scriptsTypecheckTimeoutMs, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("check:command-progress-contract", ["bun", "scripts/check-command-progress-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:prompt-observation-contract", ["bun", "scripts/code-queue-prompt-observation-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:issue3-diagnostics-and-image-preflight", ["bun", "scripts/code-queue-issue3-regression-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:cli-disclosure-contract", ["bun", "scripts/code-queue-cli-disclosure-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:prompt-lint-contract", ["bun", "scripts/code-queue-prompt-lint-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:cli-steer-contract", ["bun", "scripts/code-queue-cli-steer-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:steer-confirmation-contract", ["bun", "scripts/code-queue-steer-confirmation-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:resume-contract", ["bun", "scripts/code-queue-resume-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:read-terminal-contract", ["bun", "scripts/code-queue-cli-read-terminal-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:submit-execution-mode-contract", ["bun", "scripts/code-queue-submit-execution-mode-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:submit-summary-contract", ["bun", "scripts/code-queue-submit-summary-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:gh-auth-redaction-contract", ["bun", "scripts/code-queue-gh-auth-redaction-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:queues-shape-contract", ["bun", "scripts/code-queue-queues-shape-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:supervisor-disclosure-contract", ["bun", "scripts/code-queue-supervisor-disclosure-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:commander-view-contract", ["bun", "scripts/code-queue-commander-view-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:postgres-rotation-contract", ["bun", "scripts/code-queue-postgres-rotation-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("host-codex-commander:prompt-lint-contract", ["bun", "scripts/host-codex-commander-prompt-lint-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("ssh:argv-guidance-contract", ["bun", "scripts/ssh-argv-guidance-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 90_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:active-run-heartbeat-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:active-run-heartbeat-visible"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:trace-gap-not-stale", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:trace-gap-not-stale"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:stale-active-owner-expired", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:stale-active-owner-expired"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:control-plane-split-brain-diagnostics", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:control-plane-split-brain-diagnostics"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("code-queue:oa-publisher-degraded-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:oa-publisher-degraded-visible"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("baidu-netdisk:artifact-guard-contract", ["bun", "scripts/baidu-netdisk-artifact-guard-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("artifact-registry:direct-compose-dry-run-matrix", ["bun", "scripts/artifact-consumer-dry-run-matrix-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("schedule:cli-contract", ["bun", "scripts/schedule-cli-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("server:cleanup-plan-contract", ["bun", "scripts/server-cleanup-plan-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("check:gh-contract-scope-contract", ["bun", "scripts/check-gh-contract-scope-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("playwright:cli-wrapper-contract", ["bun", "scripts/playwright-cli-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("platform-infra:sub2api-codex-local-config-contract", ["bun", "scripts/platform-infra-sub2api-codex-local-config-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("platform-infra:sub2api-codex-routing-contract", ["bun", "scripts/platform-infra-sub2api-codex-routing-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("platform-infra:sub2api-codex-temp-unsched-contract", ["bun", "scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("platform-infra:sub2api-http-upstream-contract", ["bun", "scripts/platform-infra-sub2api-http-upstream-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("d601:recovery-guardrails-contract", ["bun", "scripts/d601-recovery-guardrails-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("hwlab:cd-wrapper-contract", ["bun", "scripts/hwlab-cd-wrapper-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
} else {
|
||||
items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("check:command-progress-contract", "observed command progress contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:prompt-observation-contract", "prompt observation contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:issue3-diagnostics-and-image-preflight", "Code Queue issue #3 regression fixtures are opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:trace-summary-contract", "Code Queue trace summary contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
@@ -528,9 +589,9 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(skippedItem("logs:unified-hourly-rotation", "policy scan is opt-in", "--logs or --full"));
|
||||
}
|
||||
if (options.ghContracts) {
|
||||
items.push(commandItem("gh:issue-guard-contract", ["bun", "scripts/gh-cli-issue-guard-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("gh:pr-files-contract", ["bun", "scripts/gh-cli-pr-files-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("gh:pr-contract", ["bun", "scripts/gh-cli-pr-contract-test.ts"], 30_000));
|
||||
items.push(await commandItem("gh:issue-guard-contract", ["bun", "scripts/gh-cli-issue-guard-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("gh:pr-files-contract", ["bun", "scripts/gh-cli-pr-files-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
items.push(await commandItem("gh:pr-contract", ["bun", "scripts/gh-cli-pr-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs));
|
||||
} else {
|
||||
items.push(skippedItem("gh:issue-guard-contract", "GitHub issue CLI contract is opt-in because it can be slower than generic scripts typecheck", "--gh-contracts or --full"));
|
||||
items.push(skippedItem("gh:pr-files-contract", "GitHub PR files/stat contract is opt-in because it can be slower than generic scripts typecheck", "--gh-contracts or --full"));
|
||||
@@ -547,7 +608,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(skippedItem("d601:recovery-guardrails", "D601 reboot recovery diagnostics are opt-in and read-only", "--recovery-guardrails or --full"));
|
||||
}
|
||||
if (options.components) {
|
||||
items.push(commandItem("typescript:components", ["bunx", "tsc", "-p", "src/tsconfig.check.json", "--pretty", "false"], 180_000));
|
||||
items.push(await commandItem("typescript:components", ["bunx", "tsc", "-p", "src/tsconfig.check.json", "--pretty", "false"], 180_000, process.env, options.checkHeartbeatMs));
|
||||
} else {
|
||||
items.push(skippedItem("typescript:components", "component TypeScript check is opt-in", "--components or --full"));
|
||||
}
|
||||
@@ -570,7 +631,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(skippedItem("docker-compose:config", "Docker Compose config rendering is opt-in", "--compose or --full"));
|
||||
}
|
||||
if (options.rust) {
|
||||
items.push(rustCheckItem());
|
||||
items.push(await runRustCheckItem(options.checkHeartbeatMs));
|
||||
} else {
|
||||
items.push(skippedItem("rust:backend-core", "Rust check/build must run through an approved native k3s CI artifact publication, not on the master server or CD runtime target", "--rust inside native k3s CI with UNIDESK_NATIVE_K3S_RUST_CHECK=1"));
|
||||
}
|
||||
|
||||
+148
-1
@@ -1,4 +1,4 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { closeSync, createWriteStream, existsSync, openSync, readSync, statSync } from "node:fs";
|
||||
|
||||
export interface CommandResult {
|
||||
@@ -9,6 +9,30 @@ export interface CommandResult {
|
||||
stderr: string;
|
||||
signal: NodeJS.Signals | null;
|
||||
timedOut: boolean;
|
||||
durationMs?: number;
|
||||
stdoutBytes?: number;
|
||||
stderrBytes?: number;
|
||||
stdoutTruncated?: boolean;
|
||||
stderrTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandProgress {
|
||||
elapsedMs: number;
|
||||
timeoutMs: number | null;
|
||||
pid: number | null;
|
||||
stdoutBytes: number;
|
||||
stderrBytes: number;
|
||||
lastOutputAgeMs: number | null;
|
||||
}
|
||||
|
||||
interface ObservedCommandOptions {
|
||||
timeoutMs?: number;
|
||||
heartbeatMs?: number;
|
||||
killAfterMs?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
input?: string;
|
||||
maxCaptureChars?: number;
|
||||
onProgress?: (progress: CommandProgress) => void;
|
||||
}
|
||||
|
||||
export function runCommand(command: string[], cwd: string, options: { timeoutMs?: number; env?: NodeJS.ProcessEnv; input?: string } = {}): CommandResult {
|
||||
@@ -33,6 +57,129 @@ export function runCommand(command: string[], cwd: string, options: { timeoutMs?
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCommandObserved(command: string[], cwd: string, options: ObservedCommandOptions = {}): Promise<CommandResult> {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs;
|
||||
const heartbeatMs = Math.max(0, options.heartbeatMs ?? 0);
|
||||
const maxCaptureChars = Math.max(1, options.maxCaptureChars ?? 1024 * 1024);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
let lastOutputAt: number | null = null;
|
||||
let timedOut = false;
|
||||
let timeoutTimer: NodeJS.Timeout | undefined;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
let heartbeatTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const child = spawn(command[0], command.slice(1), {
|
||||
cwd,
|
||||
env: options.env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
stdoutBytes += Buffer.byteLength(text);
|
||||
lastOutputAt = Date.now();
|
||||
const captured = appendBounded(stdout, text, maxCaptureChars);
|
||||
stdout = captured.text;
|
||||
stdoutTruncated = stdoutTruncated || captured.truncated;
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
stderrBytes += Buffer.byteLength(text);
|
||||
lastOutputAt = Date.now();
|
||||
const captured = appendBounded(stderr, text, maxCaptureChars);
|
||||
stderr = captured.text;
|
||||
stderrTruncated = stderrTruncated || captured.truncated;
|
||||
});
|
||||
|
||||
child.stdin.end(options.input ?? "");
|
||||
|
||||
if (heartbeatMs > 0) {
|
||||
heartbeatTimer = setInterval(() => {
|
||||
options.onProgress?.({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
timeoutMs: timeoutMs ?? null,
|
||||
pid: child.pid ?? null,
|
||||
stdoutBytes,
|
||||
stderrBytes,
|
||||
lastOutputAgeMs: lastOutputAt === null ? null : Date.now() - lastOutputAt,
|
||||
});
|
||||
}, heartbeatMs);
|
||||
}
|
||||
|
||||
if (timeoutMs !== undefined) {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killProcessTree(child, "SIGTERM");
|
||||
killTimer = setTimeout(() => {
|
||||
killProcessTree(child, "SIGKILL");
|
||||
}, Math.max(100, options.killAfterMs ?? 5_000));
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const completion = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null; error?: Error }>((resolve) => {
|
||||
let resolved = false;
|
||||
const finish = (result: { exitCode: number | null; signal: NodeJS.Signals | null; error?: Error }) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
};
|
||||
child.on("error", (error) => finish({ exitCode: 127, signal: null, error }));
|
||||
child.on("close", (code, signal) => finish({ exitCode: code, signal }));
|
||||
});
|
||||
|
||||
if (timeoutTimer !== undefined) clearTimeout(timeoutTimer);
|
||||
if (killTimer !== undefined) clearTimeout(killTimer);
|
||||
if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer);
|
||||
|
||||
if (completion.error !== undefined && stderr.length === 0) {
|
||||
stderr = completion.error.message;
|
||||
stderrBytes = Buffer.byteLength(stderr);
|
||||
}
|
||||
|
||||
return {
|
||||
command,
|
||||
cwd,
|
||||
exitCode: completion.exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
signal: completion.signal,
|
||||
timedOut,
|
||||
durationMs: Date.now() - startedAt,
|
||||
stdoutBytes,
|
||||
stderrBytes,
|
||||
stdoutTruncated,
|
||||
stderrTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
function appendBounded(current: string, next: string, maxChars: number): { text: string; truncated: boolean } {
|
||||
const combined = `${current}${next}`;
|
||||
if (combined.length <= maxChars) return { text: combined, truncated: false };
|
||||
return { text: combined.slice(-maxChars), truncated: true };
|
||||
}
|
||||
|
||||
function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean {
|
||||
if (child.pid === undefined) return false;
|
||||
try {
|
||||
if (process.platform === "win32") return child.kill(signal);
|
||||
process.kill(-child.pid, signal);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
return child.kill(signal);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function commandOk(command: string[], cwd: string): boolean {
|
||||
return runCommand(command, cwd).exitCode === 0;
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "help", description: "List supported commands." },
|
||||
{ command: "--main-server-ip <ip> <command>", description: "Run selected commands through the public frontend API; use --main-server-key only for legacy SSH transport." },
|
||||
{ command: "config show", description: "Validate and print config.json as the single source of truth." },
|
||||
{ command: "check [--full|--files|--scripts-typecheck|--components|--compose|--logs|--recovery-guardrails|--rust] | check recovery-guardrails", description: "Run the lightweight default syntax/config gate or the low-noise read-only D601 recovery guardrails; check --rust stays D601-guarded, while backend-core main-server online uses the separate constrained rebuild path." },
|
||||
{ command: "check [--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--components|--compose|--logs|--recovery-guardrails|--rust] | check recovery-guardrails", description: "Run the lightweight default syntax/config gate or the low-noise read-only D601 recovery guardrails; long command checks emit progress heartbeats and bounded timeout details." },
|
||||
{ command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, provider gateway, and managed main-server user services." },
|
||||
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
|
||||
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
|
||||
|
||||
Reference in New Issue
Block a user