658 行
20 KiB
JavaScript
658 行
20 KiB
JavaScript
#!/usr/bin/env node
|
|
require("dotenv/config");
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { Pool } = require("pg");
|
|
|
|
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
|
const ROLE_VALUES = new Set([
|
|
"employee",
|
|
"manager",
|
|
"ceo",
|
|
"finance",
|
|
"finance_mgr",
|
|
"administration",
|
|
"hr",
|
|
"admin",
|
|
]);
|
|
|
|
const DEFAULT_TARGETS = [
|
|
{ key: "admin", identifierEnv: "LIVE_SMOKE_ADMIN_IDENTIFIER", roles: ["admin", "employee"] },
|
|
{ key: "employee", identifierEnv: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER", roles: ["employee"] },
|
|
{ key: "manager", identifierEnv: "LIVE_SMOKE_MANAGER_IDENTIFIER", roles: ["manager", "employee"] },
|
|
{ key: "ceo", identifierEnv: "LIVE_SMOKE_CEO_IDENTIFIER", roles: ["ceo", "employee"] },
|
|
{ key: "finance", identifierEnv: "LIVE_SMOKE_FINANCE_IDENTIFIER", roles: ["finance", "employee"] },
|
|
{ key: "finance_mgr", identifierEnv: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", roles: ["finance", "finance_mgr", "employee"] },
|
|
{ key: "hr", identifierEnv: "LIVE_SMOKE_HR_IDENTIFIER", roles: ["hr", "employee"] },
|
|
];
|
|
|
|
function timestamp() {
|
|
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
}
|
|
|
|
function printHelp() {
|
|
process.stdout.write(`Usage: node src/scripts/repair-oa-only-smoke-accounts.js [options]
|
|
|
|
治理 OA-only smoke 账号。默认 dry-run;真实修复必须同时指定 --apply 且设置 OA_ONLY_SMOKE_REPAIR_APPLY=1。
|
|
|
|
Options:
|
|
--dry-run 只生成计划,不写数据库,默认。
|
|
--apply 执行受控 OA DB 修复,需 OA_ONLY_SMOKE_REPAIR_APPLY=1。
|
|
--format=text|json 输出格式,默认 text。
|
|
--report-dir=<path> 输出目录,默认 artifacts/qa-flow/oa-only-smoke-repair-<timestamp>。
|
|
--target=<key>:<identifier>:<role+role>
|
|
追加一个目标;identifier 可为 employee_no/email/phone。
|
|
--help 显示帮助。
|
|
|
|
环境变量:
|
|
LIVE_SMOKE_*_IDENTIFIER 默认目标来源;不会读取或输出密码。
|
|
OA_ONLY_SMOKE_TARGETS_JSON 可选 JSON 数组,元素形如 {"key":"admin","identifier":"...","roles":["admin","employee"]}。
|
|
|
|
隐私边界:
|
|
输出只包含目标 key、脱敏标识、员工号、状态、角色和动作。
|
|
不输出手机号、邮箱、密码哈希、密码、token、secret、完整本地路径或原始 payload。
|
|
`);
|
|
}
|
|
|
|
function splitList(value) {
|
|
return String(value ?? "")
|
|
.split(/[,+\s]+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeRoleList(roles) {
|
|
const values = Array.isArray(roles) ? roles : splitList(roles);
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const raw of values) {
|
|
const role = String(raw ?? "").trim();
|
|
if (!ROLE_VALUES.has(role) || seen.has(role)) continue;
|
|
seen.add(role);
|
|
result.push(role);
|
|
}
|
|
return result.length ? result : ["employee"];
|
|
}
|
|
|
|
function normalizeIdentifier(value) {
|
|
return String(value ?? "").trim().slice(0, 256);
|
|
}
|
|
|
|
function normalizeKey(value) {
|
|
return String(value ?? "").trim().slice(0, 64) || "target";
|
|
}
|
|
|
|
function maskIdentifier(value) {
|
|
const text = normalizeIdentifier(value);
|
|
if (!text) return null;
|
|
if (text.includes("@")) {
|
|
const [local, domain] = text.split("@");
|
|
const safeLocal = local.length <= 2 ? `${local[0] || "*"}***` : `${local.slice(0, 2)}***`;
|
|
const domainParts = String(domain || "").split(".");
|
|
const safeDomain = domainParts.length > 1 ? `***.${domainParts.at(-1)}` : "***";
|
|
return `${safeLocal}@${safeDomain}`;
|
|
}
|
|
if (text.length <= 4) return "***";
|
|
return `${text.slice(0, 2)}***${text.slice(-2)}`;
|
|
}
|
|
|
|
function sanitizeEmployeeNo(value) {
|
|
return String(value ?? "").trim().slice(0, 64);
|
|
}
|
|
|
|
function sanitizeRoles(roles) {
|
|
return Array.isArray(roles)
|
|
? roles.filter((role) => typeof role === "string").sort()
|
|
: [];
|
|
}
|
|
|
|
function isSmokeLikeValue(value) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) return false;
|
|
const lower = text.toLowerCase();
|
|
const emailLocal = lower.includes("@") ? lower.split("@")[0] : lower;
|
|
return (
|
|
/^smoke[-_.]/.test(emailLocal)
|
|
|| /^oa[-_.]?smoke[-_.]/.test(emailLocal)
|
|
|| /^(SMOKE|OSMOKE|WFULL|PRELAUNCH|LAUNCHAUTO|HRWDTEST)[A-Z0-9_-]*/i.test(text)
|
|
|| /测试|试运行/.test(text)
|
|
);
|
|
}
|
|
|
|
function isSafeOaOnlySmokeTarget(target, row) {
|
|
return [
|
|
target?.identifier,
|
|
row?.employeeNo,
|
|
row?.email,
|
|
row?.name,
|
|
].some(isSmokeLikeValue);
|
|
}
|
|
|
|
function sanitizeErrorMessage(value) {
|
|
return String(value ?? "")
|
|
.replace(/access_token=[^&\s]+/gi, "access_token=***")
|
|
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
|
|
.slice(0, 300);
|
|
}
|
|
|
|
function parseTargetArg(value) {
|
|
const parts = String(value ?? "").split(":");
|
|
if (parts.length < 2) {
|
|
throw new Error("--target must be <key>:<identifier>:<role+role>");
|
|
}
|
|
const [key, identifier, rolesRaw = "employee"] = parts;
|
|
const normalizedIdentifier = normalizeIdentifier(identifier);
|
|
if (!normalizedIdentifier) {
|
|
throw new Error("--target identifier is empty");
|
|
}
|
|
return {
|
|
key: normalizeKey(key),
|
|
identifier: normalizedIdentifier,
|
|
roles: normalizeRoleList(rolesRaw),
|
|
source: "cli",
|
|
};
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
mode: "dry_run",
|
|
format: "text",
|
|
reportDir: process.env.OA_ONLY_SMOKE_REPAIR_REPORT_DIR
|
|
|| path.join(DEFAULT_REPORT_ROOT, `oa-only-smoke-repair-${timestamp()}`),
|
|
targets: [],
|
|
help: false,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--help" || arg === "-h") {
|
|
options.help = true;
|
|
} else if (arg === "--dry-run") {
|
|
options.mode = "dry_run";
|
|
} else if (arg === "--apply") {
|
|
options.mode = "apply";
|
|
} else if (arg === "--format") {
|
|
options.format = argv[index + 1] || options.format;
|
|
index += 1;
|
|
} else if (arg.startsWith("--format=")) {
|
|
options.format = arg.slice("--format=".length);
|
|
} else if (arg === "--report-dir") {
|
|
options.reportDir = argv[index + 1] || options.reportDir;
|
|
index += 1;
|
|
} else if (arg.startsWith("--report-dir=")) {
|
|
options.reportDir = arg.slice("--report-dir=".length);
|
|
} else if (arg === "--target") {
|
|
options.targets.push(parseTargetArg(argv[index + 1] || ""));
|
|
index += 1;
|
|
} else if (arg.startsWith("--target=")) {
|
|
options.targets.push(parseTargetArg(arg.slice("--target=".length)));
|
|
} else {
|
|
throw new Error(`Unsupported argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!["text", "json"].includes(options.format)) {
|
|
throw new Error(`Unsupported --format: ${options.format}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function parseJsonTargets() {
|
|
const raw = String(process.env.OA_ONLY_SMOKE_TARGETS_JSON ?? "").trim();
|
|
if (!raw) return [];
|
|
let parsed = null;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error(`Invalid OA_ONLY_SMOKE_TARGETS_JSON: ${error.message}`);
|
|
}
|
|
if (!Array.isArray(parsed)) {
|
|
throw new Error("OA_ONLY_SMOKE_TARGETS_JSON must be a JSON array");
|
|
}
|
|
return parsed.map((item, index) => {
|
|
const identifier = normalizeIdentifier(item?.identifier);
|
|
if (!identifier) {
|
|
throw new Error(`OA_ONLY_SMOKE_TARGETS_JSON[${index}].identifier is empty`);
|
|
}
|
|
return {
|
|
key: normalizeKey(item?.key || `json_${index + 1}`),
|
|
identifier,
|
|
roles: normalizeRoleList(item?.roles),
|
|
source: "env_json",
|
|
};
|
|
});
|
|
}
|
|
|
|
function loadTargets(options) {
|
|
const defaults = DEFAULT_TARGETS
|
|
.map((target) => {
|
|
const identifier = normalizeIdentifier(process.env[target.identifierEnv]);
|
|
if (!identifier) return null;
|
|
return {
|
|
key: target.key,
|
|
identifier,
|
|
roles: target.roles,
|
|
source: target.identifierEnv,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const merged = [...defaults, ...parseJsonTargets(), ...options.targets];
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const target of merged) {
|
|
const identityKey = `${target.key}:${target.identifier.toLowerCase()}`;
|
|
if (seen.has(identityKey)) continue;
|
|
seen.add(identityKey);
|
|
result.push({
|
|
key: target.key,
|
|
identifier: target.identifier,
|
|
maskedIdentifier: maskIdentifier(target.identifier),
|
|
roles: normalizeRoleList(target.roles),
|
|
source: target.source,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function assertApplyAllowed(options) {
|
|
if (options.mode !== "apply") return;
|
|
if (process.env.OA_ONLY_SMOKE_REPAIR_APPLY !== "1") {
|
|
throw new Error("Refusing apply without OA_ONLY_SMOKE_REPAIR_APPLY=1");
|
|
}
|
|
}
|
|
|
|
async function queryTarget(client, identifier) {
|
|
const result = await client.query(
|
|
`
|
|
SELECT
|
|
e.id::text,
|
|
e.employee_no,
|
|
e.name,
|
|
e.email,
|
|
e.phone,
|
|
e.status::text,
|
|
e.employment_status::text,
|
|
(e.archived_at IS NOT NULL) AS archived,
|
|
e.login_failed_count,
|
|
e.login_failed_first_at IS NOT NULL AS has_login_failed_first_at,
|
|
e.login_locked_until,
|
|
COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles,
|
|
(
|
|
SELECT COUNT(*)::int
|
|
FROM auth_sessions s
|
|
WHERE s.employee_id = e.id
|
|
AND s.revoked_at IS NULL
|
|
) AS active_auth_sessions
|
|
FROM employees e
|
|
LEFT JOIN employee_roles er ON er.employee_id = e.id
|
|
WHERE e.employee_no = $1
|
|
OR lower(e.email) = lower($1)
|
|
OR e.phone = $1
|
|
GROUP BY e.id
|
|
ORDER BY
|
|
CASE WHEN e.status = 'active' AND e.archived_at IS NULL THEN 0 ELSE 1 END,
|
|
e.employee_no ASC
|
|
LIMIT 1
|
|
`,
|
|
[identifier],
|
|
);
|
|
const row = result.rows[0];
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
employeeNo: sanitizeEmployeeNo(row.employee_no),
|
|
name: row.name || "",
|
|
email: row.email || "",
|
|
phone: row.phone || "",
|
|
status: row.status,
|
|
employmentStatus: row.employment_status,
|
|
archived: Boolean(row.archived),
|
|
loginFailedCount: Number(row.login_failed_count ?? 0),
|
|
hasLoginFailedFirstAt: Boolean(row.has_login_failed_first_at),
|
|
loginLocked: row.login_locked_until ? new Date(row.login_locked_until) > new Date() : false,
|
|
roles: sanitizeRoles(row.roles),
|
|
activeAuthSessions: Number(row.active_auth_sessions ?? 0),
|
|
};
|
|
}
|
|
|
|
function planForTarget(target, row) {
|
|
if (!row) {
|
|
return {
|
|
key: target.key,
|
|
maskedIdentifier: target.maskedIdentifier,
|
|
expectedRoles: target.roles,
|
|
employeeNo: null,
|
|
status: "SKIP",
|
|
reason: "employee_not_found",
|
|
actions: [],
|
|
before: null,
|
|
};
|
|
}
|
|
|
|
if (!isSafeOaOnlySmokeTarget(target, row)) {
|
|
return {
|
|
key: target.key,
|
|
maskedIdentifier: target.maskedIdentifier,
|
|
expectedRoles: target.roles,
|
|
employeeNo: row.employeeNo,
|
|
status: "BLOCKED",
|
|
reason: "unsafe_non_smoke_target",
|
|
actions: [],
|
|
missingRoles: [],
|
|
before: {
|
|
status: row.status,
|
|
employmentStatus: row.employmentStatus,
|
|
archived: row.archived,
|
|
roles: row.roles,
|
|
loginFailedCount: row.loginFailedCount,
|
|
loginLocked: row.loginLocked,
|
|
activeAuthSessions: row.activeAuthSessions,
|
|
safeOaOnlySmoke: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
const actions = [];
|
|
if (row.status !== "active" || row.employmentStatus !== "active" || row.archived) {
|
|
actions.push("restore_active_employee");
|
|
}
|
|
if (row.loginFailedCount > 0 || row.hasLoginFailedFirstAt || row.loginLocked) {
|
|
actions.push("clear_login_lock");
|
|
}
|
|
const missingRoles = target.roles.filter((role) => !row.roles.includes(role));
|
|
if (missingRoles.length) {
|
|
actions.push("add_missing_roles");
|
|
}
|
|
|
|
return {
|
|
key: target.key,
|
|
maskedIdentifier: target.maskedIdentifier,
|
|
expectedRoles: target.roles,
|
|
employeeNo: row.employeeNo,
|
|
status: actions.length ? "PLANNED" : "OK",
|
|
reason: actions.length ? "needs_repair" : "already_ready",
|
|
actions,
|
|
missingRoles,
|
|
before: {
|
|
status: row.status,
|
|
employmentStatus: row.employmentStatus,
|
|
archived: row.archived,
|
|
roles: row.roles,
|
|
loginFailedCount: row.loginFailedCount,
|
|
loginLocked: row.loginLocked,
|
|
activeAuthSessions: row.activeAuthSessions,
|
|
safeOaOnlySmoke: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function applyTarget(client, target, plan, now) {
|
|
if (!plan.employeeNo || !plan.actions.length) {
|
|
return { ...plan, result: plan.status === "OK" ? "noop" : "skipped" };
|
|
}
|
|
|
|
const row = await queryTarget(client, target.identifier);
|
|
if (!row?.id) {
|
|
return { ...plan, result: "skipped", reason: "employee_not_found_at_apply" };
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE employees
|
|
SET
|
|
status = 'active',
|
|
employment_status = 'active',
|
|
archived_at = NULL,
|
|
login_failed_count = 0,
|
|
login_failed_first_at = NULL,
|
|
login_locked_until = NULL,
|
|
token_version = token_version + 1,
|
|
updated_at = $2::timestamptz,
|
|
updated_by = NULL
|
|
WHERE id = $1::uuid
|
|
`,
|
|
[row.id, now.toISOString()],
|
|
);
|
|
|
|
for (const role of target.roles) {
|
|
await client.query(
|
|
`
|
|
INSERT INTO employee_roles (employee_id, role_code, created_at, created_by)
|
|
VALUES ($1::uuid, $2::"RoleCode", $3::timestamptz, NULL)
|
|
ON CONFLICT (employee_id, role_code) DO NOTHING
|
|
`,
|
|
[row.id, role, now.toISOString()],
|
|
);
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
INSERT INTO employee_lifecycle_events (id, employee_id, event_type, effective_date, payload_json, created_at, created_by)
|
|
VALUES (gen_random_uuid(), $1::uuid, 'transfer', $2::date, $3::jsonb, $4::timestamptz, NULL)
|
|
`,
|
|
[
|
|
row.id,
|
|
now.toISOString().slice(0, 10),
|
|
JSON.stringify({
|
|
source: "OSMOKE1",
|
|
reason: "oa_only_smoke_account_repair",
|
|
actions: plan.actions,
|
|
expectedRoles: target.roles,
|
|
}),
|
|
now.toISOString(),
|
|
],
|
|
);
|
|
|
|
const after = await queryTarget(client, target.identifier);
|
|
return {
|
|
...plan,
|
|
result: "applied",
|
|
after: after ? {
|
|
status: after.status,
|
|
employmentStatus: after.employmentStatus,
|
|
archived: after.archived,
|
|
roles: after.roles,
|
|
loginFailedCount: after.loginFailedCount,
|
|
loginLocked: after.loginLocked,
|
|
activeAuthSessions: after.activeAuthSessions,
|
|
} : null,
|
|
};
|
|
}
|
|
|
|
function buildNoDatabaseReport(targets) {
|
|
return {
|
|
mode: "dry_run",
|
|
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
|
generatedAt: new Date().toISOString(),
|
|
privacy: "target key/masked identifier/role only; no password/token/secret/full email/full phone",
|
|
reason: "missing_DATABASE_URL",
|
|
targets: targets.map((target) => ({
|
|
key: target.key,
|
|
maskedIdentifier: target.maskedIdentifier,
|
|
expectedRoles: target.roles,
|
|
source: target.source,
|
|
})),
|
|
totals: {
|
|
targets: targets.length,
|
|
plannedRepairs: 0,
|
|
appliedRepairs: 0,
|
|
missingTargets: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildDatabaseUnavailableReport(targets, error) {
|
|
return {
|
|
...buildNoDatabaseReport(targets),
|
|
reason: "database_unavailable",
|
|
details: {
|
|
message: sanitizeErrorMessage(error?.message || error),
|
|
name: error?.name || null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function summarizeReport({ mode, targets, planned, generatedAt }) {
|
|
const plannedRepairs = planned.filter((item) => item.status === "PLANNED").length;
|
|
const missingTargets = planned.filter((item) => item.reason === "employee_not_found").length;
|
|
const blockedTargets = planned.filter((item) => item.status === "BLOCKED").length;
|
|
const appliedRepairs = planned.filter((item) => item.result === "applied").length;
|
|
const blockers = missingTargets;
|
|
let status = "PASS_OA_ONLY_SMOKE_READY";
|
|
if (blockedTargets > 0) {
|
|
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
|
} else if (!targets.length) {
|
|
status = "PAUSED_MANUAL_CONFIRMATION";
|
|
} else if (mode === "apply") {
|
|
status = blockers ? "PASS_WITH_FINDINGS" : "APPLIED_OA_ONLY_SMOKE_REPAIR";
|
|
} else if (plannedRepairs > 0 || missingTargets > 0) {
|
|
status = "PASS_WITH_REPAIR_PLAN";
|
|
}
|
|
|
|
return {
|
|
mode,
|
|
status,
|
|
generatedAt,
|
|
privacy: "target key/masked identifier/employee_no/status/roles/actions only; no password/token/secret/full email/full phone",
|
|
scope: {
|
|
accountClass: "oa_only_smoke",
|
|
wecomWrites: 0,
|
|
passwordReset: false,
|
|
realEmployees: "not_targeted",
|
|
},
|
|
totals: {
|
|
targets: targets.length,
|
|
plannedRepairs,
|
|
appliedRepairs,
|
|
missingTargets,
|
|
blockedTargets,
|
|
readyTargets: planned.filter((item) => item.status === "OK").length,
|
|
},
|
|
targets: planned,
|
|
manualGate: mode === "dry_run" && plannedRepairs > 0
|
|
? "A:按 dry-run 结果恢复现有 OA-only smoke;B:另建新账号;C:暂停自动化。"
|
|
: null,
|
|
};
|
|
}
|
|
|
|
async function execute(options) {
|
|
const targets = loadTargets(options);
|
|
assertApplyAllowed(options);
|
|
if (!targets.length) {
|
|
return summarizeReport({
|
|
mode: options.mode,
|
|
targets,
|
|
planned: [],
|
|
generatedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
if (!process.env.DATABASE_URL) {
|
|
return buildNoDatabaseReport(targets);
|
|
}
|
|
|
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
let client;
|
|
try {
|
|
client = await pool.connect();
|
|
} catch (error) {
|
|
await pool.end();
|
|
if (options.mode !== "apply") {
|
|
return buildDatabaseUnavailableReport(targets, error);
|
|
}
|
|
throw error;
|
|
}
|
|
const generatedAt = new Date().toISOString();
|
|
const now = new Date();
|
|
try {
|
|
await client.query(options.mode === "apply" ? "BEGIN" : "BEGIN READ ONLY");
|
|
const planned = [];
|
|
for (const target of targets) {
|
|
const row = await queryTarget(client, target.identifier);
|
|
const plan = planForTarget(target, row);
|
|
planned.push(plan);
|
|
}
|
|
if (options.mode === "apply" && planned.some((item) => item.status === "BLOCKED")) {
|
|
await client.query("ROLLBACK");
|
|
return summarizeReport({ mode: options.mode, targets, planned, generatedAt });
|
|
}
|
|
if (options.mode === "apply") {
|
|
const applied = [];
|
|
for (let index = 0; index < targets.length; index += 1) {
|
|
applied.push(await applyTarget(client, targets[index], planned[index], now));
|
|
}
|
|
planned.splice(0, planned.length, ...applied);
|
|
}
|
|
await client.query("COMMIT");
|
|
return summarizeReport({ mode: options.mode, targets, planned, generatedAt });
|
|
} catch (error) {
|
|
try {
|
|
await client.query("ROLLBACK");
|
|
} catch {
|
|
// Keep original error.
|
|
}
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
function ensureReportDir(reportDir) {
|
|
fs.mkdirSync(reportDir, { recursive: true });
|
|
}
|
|
|
|
function writeReport(reportDir, report) {
|
|
ensureReportDir(reportDir);
|
|
const filePath = path.join(reportDir, "summary.json");
|
|
fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
return filePath;
|
|
}
|
|
|
|
function printText(report, reportPath) {
|
|
process.stdout.write(`[oa-only-smoke-repair] status=${report.status} mode=${report.mode}\n`);
|
|
process.stdout.write(`[oa-only-smoke-repair] report=${reportPath}\n`);
|
|
process.stdout.write(`[oa-only-smoke-repair] totals=${JSON.stringify(report.totals)}\n`);
|
|
if (report.manualGate) {
|
|
process.stdout.write(`[oa-only-smoke-repair] manualGate=${report.manualGate}\n`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
const report = await execute(options);
|
|
const reportPath = writeReport(options.reportDir, report);
|
|
if (options.format === "json") {
|
|
process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`);
|
|
} else {
|
|
printText(report, reportPath);
|
|
}
|
|
if (report.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
process.stderr.write(`[oa-only-smoke-repair][FAIL] ${sanitizeErrorMessage(error?.message || error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_TARGETS,
|
|
applyTarget,
|
|
execute,
|
|
isSafeOaOnlySmokeTarget,
|
|
isSmokeLikeValue,
|
|
loadTargets,
|
|
normalizeRoleList,
|
|
parseArgs,
|
|
planForTarget,
|
|
};
|