diff --git a/CHANGELOG.md b/CHANGELOG.md index 726f537..43689f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added +- **Relay 部署契约**:新增默认只读的 `sync_relay_scripts.mjs --check` 与显式 Human-gated `--apply`;manifest 覆盖入口脚本及本地依赖,部署时 staging、逐文件备份、复检并在失败时回滚,Orchestrator automatic/manual relay 路径在推送前强制检查但绝不自动部署 - **Digest/relay 契约**:`orchestrator` 新增 `--digest`,默认读取 frontmatter `summary`,并在自动/手动 relay 推送命令中显式传给 `create_wechat_draft.mjs` - **发布证据持久化**:草稿 create/update + `draft/get` 后自动写入 `audit.log` 与 `push-result.json`;relay 路径把证据带回文章 `publish/vN/` - **CATALOG Backlink**:Orchestrator 可发现或显式接收 CATALOG/slug,成功后回写当前 id、`pushed-draft`、audit 指针,并保留被替换的旧 id diff --git a/README.md b/README.md index 1ffffce..528ef20 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,31 @@ node scripts/create_wechat_draft.mjs \ **方式 B:通过跳板机推送**(本地 IP 不在白名单时) +正式推送前先验证 relay 共享脚本与当前仓库一致: + +```bash +npm run relay:check +# 等价命令:node scripts/sync_relay_scripts.mjs --check --json +``` + +这个检查覆盖 `create_wechat_draft.mjs` 及其仓库内本地依赖、逐文件 +SHA-256、Node 语法和 `--digest` / `--source-path` / `--audit-out` / +`--push-result-out` / `--json` 参数面。Orchestrator 的自动与手动 relay +路径都会先执行该检查;发现漂移时直接非零退出,不会边推文章边改共享脚本。 + +只有获得 live mutation 授权后,才执行部署: + +```bash +node scripts/sync_relay_scripts.mjs --apply --json +``` + +`--apply` 会先把完整 manifest 上传到 staging 路径,为每个现有文件创建 +`.bak-` 备份,再执行 staged promotion、hash/语法/参数复检;安装 +失败会从同批备份恢复。需要可复现的备份名时可显式传 +`--backup-tag `。relay 主机和共享目录只从 `.env` 的 +`WECHAT_RELAY_HOST` / `WECHAT_RELAY_SCRIPTS_DIR` 读取,结构化输出不会打印 +host 值或凭据。 + 推荐直接用 Orchestrator 的 `--auto-push`: ```bash @@ -262,7 +287,9 @@ node scripts/orchestrator.mjs \ --auto-push ``` -它会自动完成:创建远程目录 → SCP 上传 bundle(`.env` 单独传输)→ SSH 远程执行 `create_wechat_draft.mjs`。手动分步命令详见 SKILL.md Step 3。 +它会自动完成:只读校验 relay manifest → 创建远程目录 → SCP 上传 bundle +(`.env` 单独传输)→ SSH 远程执行 `create_wechat_draft.mjs`。它不会自动 +执行 `--apply`。手动分步命令详见 SKILL.md Step 3。 验证通过后,Orchestrator 必须把两份证据带回文章的同一 `publish/vN/`: diff --git a/SKILL.md b/SKILL.md index 9e6d9e1..20d0f3e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -22,16 +22,12 @@ metadata: → Step 3:推送 → Step 4:回检 → Step 5:核查 ``` ---- - ## Step 0:改写(khazix-writer) `Skill: "khazix-writer"`。 ⚠️ 删尾部模板「投稿或爆料…」整行,替换身份。正文 > 800 字至少 1 张图。 详细规则:`references/gotchas.md` #G01–#G05、#G34 + `references/writing-rules.md`。 ---- - ## Step 0.5:Orchestrator(推荐) ```bash @@ -44,8 +40,6 @@ node ${PIPELINE_HOME}/scripts/orchestrator.mjs \ ⚠️ 发布优先用 Orchestrator,不要手动拆 render/bundle/push。`--digest` 不传时会读取 frontmatter `summary` 并传到 relay;`--qr` 必须传绝对路径,避免 footer QR 在 render/preflight 间被拼成错误相对路径。 ---- - ## Step 1:渲染 ```bash @@ -58,16 +52,12 @@ node ${PIPELINE_HOME}/scripts/render_wechat_editorial.mjs \ ⚠️ `--env` 和 `--lint-report-out` 必须指定。`--footer-qr` 一律使用绝对路径。禁止绕路用 inline import 替换 CLI。 Gotchas:`references/gotchas.md` #G06–#G10、#G35–#G36。 ---- - ## Step 2:图片 封面图必须有(≤ 2MB)。生成 → 压缩 → 覆盖原文件(不要改名)。 提示词模板:`references/cover-prompts.md`。 Gotchas:`references/gotchas.md` #G11–#G14。 ---- - ## Step 2.5:Bundle ```bash @@ -78,10 +68,17 @@ node ${PIPELINE_HOME}/scripts/bundle_wechat_article.mjs \ ⚠️ `.env` 是隐藏文件,必须单独 `scp`。 Gotchas:`references/gotchas.md` #G15–#G17。 ---- - ## Step 3:推送 +升级或正式推送前先验 relay 共享脚本;默认只读,漂移即阻断: +```bash +node ${PIPELINE_HOME}/scripts/sync_relay_scripts.mjs --check --json +# 仅在 live mutation 明确获批后: +node ${PIPELINE_HOME}/scripts/sync_relay_scripts.mjs --apply --json +``` +`--apply` 会 staged upload → `.bak-` → promote → hash/语法/参数复检, +失败则按 manifest 回滚。Orchestrator manual/auto 只自动执行 `--check`,绝不 auto-apply。 + ```bash ssh relay "cd && node create_wechat_draft.mjs \ --html article.html --thumb-image cover.png \ @@ -93,9 +90,7 @@ relay 的 `audit.log` 和 `push-result.json` 带回文章 `publish/vN/`,并在 唯一定位时回写 CATALOG。本机直推只有在确认当前 IP 已进微信白名单时才可用。 证据或 Backlink 不完整时即使微信已收稿也必须报非零。裁剪参数必须从 preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。 -Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33、#G37–#G38。 - ---- +Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33、#G37–#G39。 ## Step 4:回检 @@ -111,8 +106,6 @@ node ${PIPELINE_HOME}/scripts/reconcile_wechat_drafts.mjs \ 对账默认只读且经 relay;草稿消失只标 `published-or-deleted`,封面空值单列。 Gotchas:`references/gotchas.md` #G22–#G24、#G31–#G33。 ---- - ## Step 5:核查(不得跳过) ```bash @@ -121,8 +114,5 @@ Skill: "skill-compliance-harness" 输出自查结果:`[回检] img: N✅ / CDN: N✅ / ...` Gotchas:`references/gotchas.md` #G25–#G26。 ---- - - 所有历史踩坑在 `references/gotchas.md`,按 Step 分组。 新增 Gotcha 必须同步更新 `references/gotchas.md` 和 `.spec.md` 第 3 节。 diff --git a/harness/test-orchestrator-command-contract.mjs b/harness/test-orchestrator-command-contract.mjs index a52166b..0001598 100644 --- a/harness/test-orchestrator-command-contract.mjs +++ b/harness/test-orchestrator-command-contract.mjs @@ -5,8 +5,10 @@ import os from "node:os"; import path from "node:path"; import { buildManualRelayCommand, + buildRelayDeploymentCheckCommand, extractSummaryFromMarkdown, resolvePipelinePaths, + runRelayDeploymentCheck, } from "../scripts/orchestrator.mjs"; const result = buildManualRelayCommand({ @@ -29,9 +31,15 @@ const result = buildManualRelayCommand({ thumbImage: "/tmp/cover.png", cropSpec: "0_0.0035_1_0.9965", envInBundle: true, + envPath: "/tmp/md2wechat config/.env", }); assert.equal(result.envReminder, true); +assert.equal( + result.relayDeploymentCheck, + buildRelayDeploymentCheckCommand({ envPath: "/tmp/md2wechat config/.env" }), +); +assert.match(result.command, /^node .*sync_relay_scripts\.mjs.*--check.*--env.*--json && \\\nssh/); assert.match(result.command, /ssh 'relay-host'/); assert.match(result.command, /scp '\/tmp\/wechat bundle'\/\*/); assert.match(result.command, /scp '\/tmp\/wechat bundle\/\.env'/); @@ -47,6 +55,21 @@ assert.match(result.command, /audit\.log/); assert.match(result.command, /update_wechat_catalog\.mjs/); assert.doesNotMatch(result.command, /\\scp|\\ssh|\\ node/); +let admissionCall = null; +const blockedAdmission = runRelayDeploymentCheck({ + envPath: "/tmp/md2wechat config/.env", + spawn(command, args, options) { + admissionCall = { command, args, options }; + return { status: 2, stdout: '{"ok":false,"completion_status":"relay-scripts-drift"}' }; + }, +}); +assert.equal(blockedAdmission.ok, false); +assert.equal(blockedAdmission.exitCode, 2); +assert.match(blockedAdmission.resultPreview, /relay-scripts-drift/); +assert.ok(admissionCall.args.includes("--check")); +assert.ok(admissionCall.args.includes("--json")); +assert.ok(admissionCall.args.includes("/tmp/md2wechat config/.env")); + assert.equal( extractSummaryFromMarkdown("---\ntitle: Test\nsummary: \"frontmatter 摘要\"\n---\n# H1\n"), "frontmatter 摘要", diff --git a/harness/test-relay-script-sync-contract.mjs b/harness/test-relay-script-sync-contract.mjs new file mode 100644 index 0000000..f453a49 --- /dev/null +++ b/harness/test-relay-script-sync-contract.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + RELAY_DEPLOYMENT_MANIFEST, + REQUIRED_ENTRY_FLAGS, + buildLocalManifest, + buildRemoteInstallCommand, + buildRemotePreparationCommand, + buildRemoteProbeCommand, + buildRemoteUploadCommand, + evaluateRemoteProbe, + parseCliArgs, + parseRemoteProbeOutput, +} from "../scripts/sync_relay_scripts.mjs"; + +const harnessDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(harnessDir, ".."); + +assert.equal(parseCliArgs([]).mode, "check"); +assert.equal(parseCliArgs(["--check", "--json"]).json, true); +assert.equal(parseCliArgs(["--apply", "--backup-tag", "proof-tag"]).mode, "apply"); +assert.throws(() => parseCliArgs(["--check", "--apply"]), /mutually exclusive/); +assert.throws(() => parseCliArgs(["--apply", "--backup-tag", "unsafe tag"]), /backup-tag/); + +const localManifest = buildLocalManifest({ repoRoot }); +assert.equal(localManifest.length, RELAY_DEPLOYMENT_MANIFEST.length); +assert.ok(localManifest.every((entry) => /^[a-f0-9]{64}$/.test(entry.sha256))); + +const entrySourcePath = path.join(repoRoot, RELAY_DEPLOYMENT_MANIFEST[0].localPath); +const entrySource = fs.readFileSync(entrySourcePath, "utf8"); +const localImports = [...entrySource.matchAll(/from\s+["'](\.\/[^"']+)["']/g)].map((match) => + path.relative(repoRoot, path.resolve(path.dirname(entrySourcePath), match[1])), +); +for (const importedPath of localImports) { + assert.ok( + RELAY_DEPLOYMENT_MANIFEST.some((entry) => entry.localPath === importedPath), + `relay manifest must include local import: ${importedPath}`, + ); +} + +const probeCommand = buildRemoteProbeCommand({ + remoteDir: "/relay/shared/scripts", + manifest: localManifest, +}); +assert.match(probeCommand, /create_wechat_draft\.mjs/); +assert.match(probeCommand, /lib\/memory-lib\.mjs/); +for (const flag of REQUIRED_ENTRY_FLAGS) assert.ok(probeCommand.includes(flag)); +assert.match(probeCommand, /node --check/); + +const alignedOutput = [ + ...localManifest.flatMap((entry) => [ + `FILE\t${entry.remotePath}\t${entry.sha256}`, + `SYNTAX\t${entry.remotePath}\t1`, + ]), + ...REQUIRED_ENTRY_FLAGS.map((flag) => `FLAG\t${flag}\t1`), +].join("\n"); +const aligned = evaluateRemoteProbe({ + localManifest, + remoteProbe: parseRemoteProbeOutput(alignedOutput), +}); +assert.equal(aligned.ok, true); +assert.ok(aligned.files.every((entry) => entry.status === "aligned")); + +const driftedOutput = alignedOutput.replace(localManifest[0].sha256, "0".repeat(64)); +const drifted = evaluateRemoteProbe({ + localManifest, + remoteProbe: parseRemoteProbeOutput(driftedOutput), +}); +assert.equal(drifted.ok, false); +assert.equal(drifted.files[0].status, "drift"); + +const missingFlagOutput = alignedOutput.replace("FLAG\t--audit-out\t1", "FLAG\t--audit-out\t0"); +assert.equal( + evaluateRemoteProbe({ + localManifest, + remoteProbe: parseRemoteProbeOutput(missingFlagOutput), + }).ok, + false, +); + +const preparation = buildRemotePreparationCommand({ + remoteDir: "/relay/shared/scripts", + localManifest, + backupTag: "proof-tag", +}); +assert.equal(preparation.stagingPaths.length, localManifest.length); +assert.match(preparation.command, /mkdir -p/); +assert.match(preparation.command, /\.staged-proof-tag/); +assert.equal( + buildRemoteUploadCommand("/relay/shared scripts/writer.staged-proof-tag"), + "cat > '/relay/shared scripts/writer.staged-proof-tag'", +); + +const installCommand = buildRemoteInstallCommand({ + remoteDir: "/relay/shared/scripts", + localManifest, + backupTag: "proof-tag", +}); +assert.match(installCommand, /\.bak-proof-tag/); +assert.match(installCommand, /\.staged-proof-tag/); +assert.match(installCommand, /cp -p/); +assert.match(installCommand, /mv /); +assert.match(installCommand, /rollback_ok/); +assert.match(installCommand, /exit 51/); +assert.match(installCommand, /exit 52/); +for (const entry of localManifest) assert.ok(installCommand.includes(entry.sha256)); +for (const flag of REQUIRED_ENTRY_FLAGS) assert.ok(installCommand.includes(flag)); + +function sha256Text(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function writeInstallFixture(root, { tag, entryContent, dependencyContent }) { + const entryTarget = path.join(root, "create_wechat_draft.mjs"); + const dependencyTarget = path.join(root, "lib", "memory-lib.mjs"); + fs.mkdirSync(path.dirname(dependencyTarget), { recursive: true }); + fs.writeFileSync(entryTarget, "console.log('old entry');\n", "utf8"); + fs.writeFileSync(dependencyTarget, "export const oldDependency = true;\n", "utf8"); + fs.writeFileSync(`${entryTarget}.staged-${tag}`, entryContent, "utf8"); + fs.writeFileSync(`${dependencyTarget}.staged-${tag}`, dependencyContent, "utf8"); + return { + manifest: [ + { + localPath: "scripts/create_wechat_draft.mjs", + remotePath: "create_wechat_draft.mjs", + sha256: sha256Text(entryContent), + }, + { + localPath: "scripts/lib/memory-lib.mjs", + remotePath: "lib/memory-lib.mjs", + sha256: sha256Text(dependencyContent), + }, + ], + entryTarget, + dependencyTarget, + }; +} + +const installTmp = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-relay-install-")); +try { + const requiredFlag = "--required-flag"; + const successTag = "success-tag"; + const successEntry = `if (process.argv.includes("--help")) console.log("${requiredFlag}");\n`; + const successDependency = "export const deployedDependency = true;\n"; + const success = writeInstallFixture(installTmp, { + tag: successTag, + entryContent: successEntry, + dependencyContent: successDependency, + }); + const successCommand = buildRemoteInstallCommand({ + remoteDir: installTmp, + localManifest: success.manifest, + backupTag: successTag, + requiredFlags: [requiredFlag], + }); + const successRun = spawnSync("sh", ["-c", successCommand], { encoding: "utf8" }); + assert.equal(successRun.status, 0, successRun.stderr); + assert.equal(fs.readFileSync(success.entryTarget, "utf8"), successEntry); + assert.equal(fs.readFileSync(success.dependencyTarget, "utf8"), successDependency); + assert.equal(fs.readFileSync(`${success.entryTarget}.bak-${successTag}`, "utf8"), "console.log('old entry');\n"); + + const rollbackRoot = path.join(installTmp, "rollback fixture"); + const rollbackTag = "rollback-tag"; + const badEntry = "console.log('help without required flag');\n"; + const rollback = writeInstallFixture(rollbackRoot, { + tag: rollbackTag, + entryContent: badEntry, + dependencyContent: successDependency, + }); + const rollbackCommand = buildRemoteInstallCommand({ + remoteDir: rollbackRoot, + localManifest: rollback.manifest, + backupTag: rollbackTag, + requiredFlags: [requiredFlag], + }); + const rollbackRun = spawnSync("sh", ["-c", rollbackCommand], { encoding: "utf8" }); + assert.equal(rollbackRun.status, 51, rollbackRun.stderr); + assert.equal(fs.readFileSync(rollback.entryTarget, "utf8"), "console.log('old entry');\n"); + assert.equal(fs.readFileSync(rollback.dependencyTarget, "utf8"), "export const oldDependency = true;\n"); +} finally { + fs.rmSync(installTmp, { recursive: true, force: true }); +} + +console.log("Relay script sync contract passed."); diff --git a/md2wechat.spec.md b/md2wechat.spec.md index 40ee74b..04da4d4 100644 --- a/md2wechat.spec.md +++ b/md2wechat.spec.md @@ -82,6 +82,8 @@ md2wechat/ - [ ] bundle 目录是 single source of truth,推送时只传这个目录里的文件 ### Step 3(推送) +- [ ] 正式 relay 推送前必须运行共享脚本 manifest `--check`;漂移时阻断,不能边推文章边 auto-apply +- [ ] relay 部署必须显式 `--apply`,并具备 staging、逐文件备份、hash/语法/参数复检和失败回滚 - [ ] 裁剪参数必须从 preflight 输出中复制,不能猜测 - [ ] 标题 ≤ 21 个中文字(微信 64 字节限制) - [ ] digest(summary)≤ 120 字(微信 digest 限制) diff --git a/package.json b/package.json index a822c89..5f9877f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "scripts": { "render": "node scripts/render_wechat_editorial.mjs", "publish": "node scripts/create_wechat_draft.mjs", - "check": "find scripts harness -name '*.mjs' -print | sort | xargs -n1 node --check && node harness/test-code-generator-contract.mjs && node harness/test-preflight-fixtures.mjs && node harness/test-orchestrator-command-contract.mjs && node harness/test-publish-evidence-contract.mjs && node harness/test-reconcile-wechat-drafts.mjs && node harness/test-self-report-no-write.mjs && node harness/run-generated-check-tests.mjs" + "relay:check": "node scripts/sync_relay_scripts.mjs --check --json", + "check": "find scripts harness -name '*.mjs' -print | sort | xargs -n1 node --check && node harness/test-code-generator-contract.mjs && node harness/test-preflight-fixtures.mjs && node harness/test-orchestrator-command-contract.mjs && node harness/test-relay-script-sync-contract.mjs && node harness/test-publish-evidence-contract.mjs && node harness/test-reconcile-wechat-drafts.mjs && node harness/test-self-report-no-write.mjs && node harness/run-generated-check-tests.mjs" }, "engines": { "node": ">=18.0.0" diff --git a/references/gotchas.md b/references/gotchas.md index d1c0191..8dc988c 100644 --- a/references/gotchas.md +++ b/references/gotchas.md @@ -75,6 +75,8 @@ **Gotcha #G38**: relay 推送必须显式传 `--digest` — 不传时低层脚本会退回正文前 54 字,可能覆盖精心写好的摘要。Orchestrator 会从 `--digest` 或 frontmatter `summary` 读取并传到 relay。 +**Gotcha #G39**: article bundle 上传不等于 relay 共享脚本部署 — `--auto-push` 只调用已安装的 writer。正式推送前必须让 `sync_relay_scripts.mjs --check` 通过;漂移时停止并单独申请 `--apply`,不要让普通文章推送隐式修改 relay runtime。 + --- ## Step 4:回检验证 diff --git a/scripts/orchestrator.mjs b/scripts/orchestrator.mjs index e8efa9d..6a613aa 100644 --- a/scripts/orchestrator.mjs +++ b/scripts/orchestrator.mjs @@ -276,6 +276,7 @@ export function buildManualRelayCommand({ const localArchiveDir = archiveDir || path.dirname(outDir); const auditOut = path.join(localArchiveDir, "audit.log"); const pushResultOut = path.join(localArchiveDir, "push-result.json"); + const relayDeploymentCheck = buildRelayDeploymentCheckCommand({ envPath }); const remoteDraftCmd = [ `cd ${shellQuote(remoteDir)} && node ${shellQuote(manualScript)}`, `--html ${shellQuote(path.basename(renderOut))}`, @@ -294,6 +295,7 @@ export function buildManualRelayCommand({ ].join(" "); const pushCommands = [ + relayDeploymentCheck, `ssh ${shellQuote(relayHost)} ${shellQuote(`mkdir -p ${shellQuote(remoteDir)}`)}`, `scp ${shellQuote(outDir)}/* ${shellQuote(`${relayHost}:${remoteDir}/`)}`, ...(envInBundle ? [`scp ${shellQuote(path.join(outDir, ".env"))} ${shellQuote(`${relayHost}:${remoteDir}/.env`)}`] : []), @@ -319,10 +321,33 @@ export function buildManualRelayCommand({ return { command: pushCommands.join(" && \\\n"), remoteDraftCmd, + relayDeploymentCheck, envReminder: Boolean(envInBundle), }; } +export function buildRelayDeploymentCheckCommand({ envPath = "" } = {}) { + return [ + `node ${shellQuote(path.join(PIPELINE_HOME, "scripts", "sync_relay_scripts.mjs"))}`, + "--check", + ...(envPath ? [`--env ${shellQuote(envPath)}`] : []), + "--json", + ].join(" "); +} + +export function runRelayDeploymentCheck({ envPath, spawn = spawnSync }) { + const result = spawn( + NODE, + [path.join(PIPELINE_HOME, "scripts", "sync_relay_scripts.mjs"), "--check", "--env", envPath, "--json"], + { encoding: "utf8", stdio: "pipe", cwd: PIPELINE_HOME }, + ); + return { + ok: result.status === 0, + exitCode: result.status, + resultPreview: (result.stdout || "").slice(0, 1000), + }; +} + function collectRelayEvidence({ relayHost, remoteDir, auditOut, pushResultOut }) { fs.mkdirSync(path.dirname(auditOut), { recursive: true }); const pushResultCopy = spawnSync( @@ -907,6 +932,21 @@ function main() { step(3, "Push to WeChat Draft (AUTO)"); info(`Relay host: ${relayHost}`); + info("Checking relay script deployment contract..."); + const relayDeploymentCheck = runRelayDeploymentCheck({ envPath }); + if (!relayDeploymentCheck.ok) { + err("Relay shared scripts do not match the local deployment manifest. Run sync_relay_scripts.mjs --check and request an authorized --apply before pushing."); + logger.record("relay_deployment_check", "failed", { + reason: "relay_script_manifest_drift", + exit_code: relayDeploymentCheck.exitCode, + result_preview: relayDeploymentCheck.resultPreview, + }); + return 6; + } + logger.record("relay_deployment_check", "success", { + completion_status: "relay-scripts-aligned", + }); + // 查询已有版本号并递增 info("Checking existing versions on relay..."); const articleDir = `${relayRoot}/${account}/${dateStamp}_${slug}`; diff --git a/scripts/sync_relay_scripts.mjs b/scripts/sync_relay_scripts.mjs new file mode 100644 index 0000000..59d7684 --- /dev/null +++ b/scripts/sync_relay_scripts.mjs @@ -0,0 +1,436 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const REPO_ROOT = path.resolve(path.dirname(SCRIPT_PATH), ".."); +const DEFAULT_ENV_PATH = path.join(REPO_ROOT, ".env"); +const SSH_OPTIONS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]; + +export const RELAY_DEPLOYMENT_MANIFEST = Object.freeze([ + Object.freeze({ localPath: "scripts/create_wechat_draft.mjs", remotePath: "create_wechat_draft.mjs" }), + Object.freeze({ localPath: "scripts/lib/memory-lib.mjs", remotePath: "lib/memory-lib.mjs" }), +]); + +export const REQUIRED_ENTRY_FLAGS = Object.freeze([ + "--digest", + "--source-path", + "--audit-out", + "--push-result-out", + "--json", +]); + +export function shellQuote(value) { + return `'${String(value).replace(/'/g, `'"'"'`)}'`; +} + +export function formatBackupTag(date = new Date()) { + return date.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15); +} + +export function parseCliArgs(argv) { + const result = { + mode: "check", + envPath: DEFAULT_ENV_PATH, + remoteDir: "", + backupTag: "", + json: false, + help: false, + }; + let explicitMode = ""; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--check" || arg === "--apply") { + const nextMode = arg.slice(2); + if (explicitMode && explicitMode !== nextMode) { + throw new Error("--check and --apply are mutually exclusive"); + } + explicitMode = nextMode; + result.mode = nextMode; + } else if (arg === "--env" || arg === "--remote-dir" || arg === "--backup-tag") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`); + index += 1; + if (arg === "--env") result.envPath = path.resolve(value); + if (arg === "--remote-dir") result.remoteDir = value; + if (arg === "--backup-tag") result.backupTag = value; + } else if (arg === "--json") { + result.json = true; + } else if (arg === "--help" || arg === "-h") { + result.help = true; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + if (result.backupTag && !/^[A-Za-z0-9._-]+$/.test(result.backupTag)) { + throw new Error("--backup-tag may contain only letters, numbers, dot, underscore, and dash"); + } + return result; +} + +export function readEnvFile(envPath) { + if (!fs.existsSync(envPath)) return {}; + const result = {}; + for (const rawLine of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const separator = line.indexOf("="); + if (separator === -1) continue; + const key = line.slice(0, separator).trim(); + let value = line.slice(separator + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + result[key] = value; + } + return result; +} + +function sha256File(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +export function buildLocalManifest({ repoRoot = REPO_ROOT, manifest = RELAY_DEPLOYMENT_MANIFEST } = {}) { + return manifest.map((entry) => { + const absolutePath = path.resolve(repoRoot, entry.localPath); + if (!fs.existsSync(absolutePath)) throw new Error(`manifest source missing: ${entry.localPath}`); + return { + ...entry, + absolutePath, + sha256: sha256File(absolutePath), + }; + }); +} + +export function buildRemoteProbeCommand({ remoteDir, manifest = RELAY_DEPLOYMENT_MANIFEST, requiredFlags = REQUIRED_ENTRY_FLAGS }) { + const lines = ["set -eu"]; + for (const entry of manifest) { + const target = path.posix.join(remoteDir, entry.remotePath); + lines.push( + `if [ -f ${shellQuote(target)} ]; then ` + + `remote_hash=$(sha256sum ${shellQuote(target)} | awk '{print $1}'); ` + + `printf 'FILE\\t%s\\t%s\\n' ${shellQuote(entry.remotePath)} "$remote_hash"; ` + + `if node --check ${shellQuote(target)} >/dev/null 2>&1; then ` + + `printf 'SYNTAX\\t%s\\t1\\n' ${shellQuote(entry.remotePath)}; ` + + `else printf 'SYNTAX\\t%s\\t0\\n' ${shellQuote(entry.remotePath)}; fi; ` + + `else ` + + `printf 'FILE\\t%s\\tMISSING\\n' ${shellQuote(entry.remotePath)}; ` + + `printf 'SYNTAX\\t%s\\t0\\n' ${shellQuote(entry.remotePath)}; ` + + `fi`, + ); + } + + const entryTarget = path.posix.join(remoteDir, manifest[0].remotePath); + lines.push( + `if [ -f ${shellQuote(entryTarget)} ]; then ` + + `help_output=$(node ${shellQuote(entryTarget)} --help 2>&1 || true); ` + + `else help_output=''; fi`, + ); + for (const flag of requiredFlags) { + lines.push( + `if printf '%s' "$help_output" | grep -Fq -- ${shellQuote(flag)}; then ` + + `printf 'FLAG\\t%s\\t1\\n' ${shellQuote(flag)}; ` + + `else printf 'FLAG\\t%s\\t0\\n' ${shellQuote(flag)}; fi`, + ); + } + return lines.join("; "); +} + +export function parseRemoteProbeOutput(output) { + const result = { files: {}, syntax: {}, flags: {} }; + for (const line of String(output || "").split(/\r?\n/)) { + const [kind, name, value] = line.split("\t"); + if (kind === "FILE" && name) result.files[name] = value || "MISSING"; + if (kind === "SYNTAX" && name) result.syntax[name] = value === "1"; + if (kind === "FLAG" && name) result.flags[name] = value === "1"; + } + return result; +} + +export function evaluateRemoteProbe({ localManifest, remoteProbe, requiredFlags = REQUIRED_ENTRY_FLAGS }) { + const files = localManifest.map((entry) => { + const remoteSha256 = remoteProbe.files[entry.remotePath] || ""; + let status = "aligned"; + if (!remoteSha256 || remoteSha256 === "MISSING") status = "missing"; + else if (remoteSha256 !== entry.sha256) status = "drift"; + else if (!remoteProbe.syntax[entry.remotePath]) status = "syntax-invalid"; + return { + local_path: entry.localPath, + remote_path: entry.remotePath, + local_sha256: entry.sha256, + remote_sha256: remoteSha256 === "MISSING" ? "" : remoteSha256, + syntax_ok: Boolean(remoteProbe.syntax[entry.remotePath]), + status, + }; + }); + const flags = requiredFlags.map((flag) => ({ flag, present: Boolean(remoteProbe.flags[flag]) })); + return { + ok: files.every((entry) => entry.status === "aligned") && flags.every((entry) => entry.present), + files, + flags, + }; +} + +export function buildRemotePreparationCommand({ remoteDir, localManifest, backupTag }) { + const directories = new Set([remoteDir]); + for (const entry of localManifest) { + directories.add(path.posix.dirname(path.posix.join(remoteDir, entry.remotePath))); + } + const stagingPaths = localManifest.map((entry) => + path.posix.join(remoteDir, `${entry.remotePath}.staged-${backupTag}`), + ); + return { + command: `mkdir -p ${[...directories].map(shellQuote).join(" ")} && rm -f ${stagingPaths.map(shellQuote).join(" ")}`, + stagingPaths, + }; +} + +export function buildRemoteUploadCommand(stagingPath) { + return `cat > ${shellQuote(stagingPath)}`; +} + +export function buildRemoteInstallCommand({ + remoteDir, + localManifest, + backupTag, + requiredFlags = REQUIRED_ENTRY_FLAGS, +}) { + const records = localManifest.map((entry) => { + const target = path.posix.join(remoteDir, entry.remotePath); + return { + ...entry, + target, + backup: `${target}.bak-${backupTag}`, + staging: `${target}.staged-${backupTag}`, + }; + }); + const lines = ["set -u", "backup_ok=1"]; + + for (const entry of records) { + lines.push(`if [ -e ${shellQuote(entry.backup)} ]; then backup_ok=0; fi`); + } + lines.push("if [ \"$backup_ok\" -ne 1 ]; then exit 50; fi"); + for (const entry of records) { + lines.push( + `if [ -f ${shellQuote(entry.target)} ]; then ` + + `cp -p ${shellQuote(entry.target)} ${shellQuote(entry.backup)} || backup_ok=0; ` + + `fi`, + ); + } + lines.push( + `if [ "$backup_ok" -ne 1 ]; then rm -f ${records.map((entry) => shellQuote(entry.staging)).join(" ")}; exit 50; fi`, + "install_ok=1", + ); + for (const entry of records) { + lines.push( + `mv ${shellQuote(entry.staging)} ${shellQuote(entry.target)} || install_ok=0`, + `chmod 0644 ${shellQuote(entry.target)} || install_ok=0`, + `test "$(sha256sum ${shellQuote(entry.target)} | awk '{print $1}')" = ${shellQuote(entry.sha256)} || install_ok=0`, + `node --check ${shellQuote(entry.target)} >/dev/null 2>&1 || install_ok=0`, + ); + } + const entryTarget = records[0].target; + lines.push(`help_output=$(node ${shellQuote(entryTarget)} --help 2>&1 || true)`); + for (const flag of requiredFlags) { + lines.push(`printf '%s' "$help_output" | grep -Fq -- ${shellQuote(flag)} || install_ok=0`); + } + lines.push("if [ \"$install_ok\" -ne 1 ]; then rollback_ok=1"); + for (const entry of records) { + lines.push( + `if [ -f ${shellQuote(entry.backup)} ]; then ` + + `cp -p ${shellQuote(entry.backup)} ${shellQuote(entry.target)} || rollback_ok=0; ` + + `else rm -f ${shellQuote(entry.target)} || rollback_ok=0; fi`, + ); + } + lines.push( + `rm -f ${records.map((entry) => shellQuote(entry.staging)).join(" ")}`, + "if [ \"$rollback_ok\" -eq 1 ]; then exit 51; else exit 52; fi", + "fi", + "printf 'APPLY\\tOK\\n'", + ); + return lines.join("; "); +} + +function runSsh({ relayHost, command, timeoutMs = 20000 }) { + return spawnSync("ssh", [...SSH_OPTIONS, relayHost, command], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, + }); +} + +function uploadStagedFile({ relayHost, sourcePath, stagingPath, timeoutMs = 20000 }) { + return spawnSync("ssh", [...SSH_OPTIONS, relayHost, buildRemoteUploadCommand(stagingPath)], { + encoding: "utf8", + input: fs.readFileSync(sourcePath), + stdio: ["pipe", "pipe", "pipe"], + timeout: timeoutMs, + }); +} + +function runProbe({ relayHost, remoteDir, localManifest }) { + const probe = runSsh({ + relayHost, + command: buildRemoteProbeCommand({ remoteDir, manifest: localManifest }), + }); + if (probe.status !== 0) { + return { + ok: false, + transport_ok: false, + transport_status: probe.status, + files: [], + flags: [], + }; + } + const evaluated = evaluateRemoteProbe({ + localManifest, + remoteProbe: parseRemoteProbeOutput(probe.stdout), + }); + return { ...evaluated, transport_ok: true, transport_status: 0 }; +} + +function resolveRuntimeConfig(args) { + const env = readEnvFile(args.envPath); + const relayHost = env.WECHAT_RELAY_HOST || ""; + const remoteDir = args.remoteDir || env.WECHAT_RELAY_SCRIPTS_DIR || ""; + if (!relayHost) throw new Error("WECHAT_RELAY_HOST is not configured"); + if (!remoteDir) throw new Error("WECHAT_RELAY_SCRIPTS_DIR is not configured"); + return { relayHost, remoteDir }; +} + +function publicCheckResult({ mode, remoteDir, probe, backupTag = "", applied = false }) { + return { + schema_version: "md2wechat-relay-deployment/v1", + mode, + ok: probe.ok, + completion_status: probe.ok ? (applied ? "relay-scripts-deployed" : "relay-scripts-aligned") : "relay-scripts-drift", + remote: { + configured: true, + scripts_dir: remoteDir, + host_redacted: true, + transport_ok: probe.transport_ok, + }, + manifest: probe.files, + required_flags: probe.flags, + applied, + backup_tag: backupTag, + }; +} + +function printHelp() { + console.log(`Usage: node scripts/sync_relay_scripts.mjs [--check | --apply] [options] + +Default mode is --check and never mutates the relay. + +Options: + --check Compare local manifest hashes/flags with relay (default) + --apply Stage, back up, promote, verify, and rollback on failure + --env Env file containing WECHAT_RELAY_HOST and WECHAT_RELAY_SCRIPTS_DIR + --remote-dir Override the configured relay scripts directory + --backup-tag Backup suffix for --apply (default: UTC timestamp) + --json Emit structured JSON with the relay host redacted + --help Show this help + +Live --apply requires explicit operator approval. Routine article pushes only run --check.`); +} + +function emit(result, json) { + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + const label = result.ok ? "PASS" : "FAIL"; + console.log(`[relay-deployment] ${label}: ${result.completion_status}`); + for (const entry of result.manifest || []) { + console.log(` ${entry.remote_path}: ${entry.status}`); + } + for (const entry of result.required_flags || []) { + console.log(` ${entry.flag}: ${entry.present ? "present" : "missing"}`); + } +} + +export function main(argv = process.argv.slice(2)) { + let args; + try { + args = parseCliArgs(argv); + if (args.help) { + printHelp(); + return 0; + } + const { relayHost, remoteDir } = resolveRuntimeConfig(args); + const localManifest = buildLocalManifest(); + const initialProbe = runProbe({ relayHost, remoteDir, localManifest }); + + if (!initialProbe.transport_ok) throw new Error("relay read-only probe failed"); + + if (args.mode === "check" || initialProbe.ok) { + const result = publicCheckResult({ mode: args.mode, remoteDir, probe: initialProbe }); + emit(result, args.json); + return result.ok ? 0 : 2; + } + + const backupTag = args.backupTag || formatBackupTag(); + const preparation = buildRemotePreparationCommand({ remoteDir, localManifest, backupTag }); + const prepareResult = runSsh({ relayHost, command: preparation.command }); + if (prepareResult.status !== 0) throw new Error("relay staging preparation failed"); + + for (let index = 0; index < localManifest.length; index += 1) { + const entry = localManifest[index]; + const copy = uploadStagedFile({ + relayHost, + sourcePath: entry.absolutePath, + stagingPath: preparation.stagingPaths[index], + }); + if (copy.status !== 0) { + runSsh({ + relayHost, + command: `rm -f ${preparation.stagingPaths.map(shellQuote).join(" ")}`, + }); + throw new Error("relay staging copy failed"); + } + } + + const install = runSsh({ + relayHost, + command: buildRemoteInstallCommand({ remoteDir, localManifest, backupTag }), + }); + if (install.status !== 0) { + throw new Error(install.status === 51 ? "relay install failed and rollback completed" : "relay install or rollback failed"); + } + + const finalProbe = runProbe({ relayHost, remoteDir, localManifest }); + const result = publicCheckResult({ + mode: "apply", + remoteDir, + probe: finalProbe, + backupTag, + applied: true, + }); + emit(result, args.json); + return result.ok ? 0 : 4; + } catch (error) { + const result = { + schema_version: "md2wechat-relay-deployment/v1", + mode: args?.mode || "check", + ok: false, + completion_status: "relay-deployment-error", + error: error.message, + remote: { host_redacted: true }, + }; + emit(result, Boolean(args?.json)); + return 3; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) { + process.exitCode = main(); +}