From 2aafb6f92f483de24cf6e17277a2f5385c7c9fc5 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 16:50:11 +0900 Subject: [PATCH] feat: add deterministic daily ops SOP and verified dev lifecycle --- AGENTS.md | 11 +- README.md | 4 + docs/协作/Dev启动SOP.md | 2 + docs/协作/日常操作SOP.md | 86 ++++++++++++++ scripts/ops.mjs | 58 ++++++++++ scripts/ops/build.mjs | 58 ++++++++++ scripts/ops/core.mjs | 83 ++++++++++++++ scripts/ops/dev-host.mjs | 35 ++++++ scripts/ops/dev-processes.ps1 | 34 ++++++ scripts/ops/dev.mjs | 204 ++++++++++++++++++++++++++++++++++ scripts/ops/dev.test.mjs | 68 ++++++++++++ scripts/ops/git.mjs | 35 ++++++ scripts/ops/health.test.mjs | 109 ++++++++++++++++++ scripts/ops/ops.test.mjs | 91 +++++++++++++++ src/main/dev-ops-health.ts | 60 ++++++++++ src/main/index.ts | 2 + 16 files changed, 935 insertions(+), 5 deletions(-) create mode 100644 docs/协作/日常操作SOP.md create mode 100644 scripts/ops.mjs create mode 100644 scripts/ops/build.mjs create mode 100644 scripts/ops/core.mjs create mode 100644 scripts/ops/dev-host.mjs create mode 100644 scripts/ops/dev-processes.ps1 create mode 100644 scripts/ops/dev.mjs create mode 100644 scripts/ops/dev.test.mjs create mode 100644 scripts/ops/git.mjs create mode 100644 scripts/ops/health.test.mjs create mode 100644 scripts/ops/ops.test.mjs create mode 100644 src/main/dev-ops-health.ts diff --git a/AGENTS.md b/AGENTS.md index d3ab3e3..2dd260b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,12 @@ # YCode 项目约定 -## 启动 dev +## 日常操作:固定入口优先 -- 用户要求启动、重启、启动当前/最新代码或用子代理启动 dev 时,先读并执行 `docs/协作/Dev启动SOP.md`。 -- 首个执行动作是检查目标目录、Git 版本和已有 dev;不要先做业务调研、REA、全量测试或依赖升级。 -- 当前代码保留本地状态;最新代码显式同步 `gitea/master`。不猜目录、不覆盖脏工作区、不复用固定版本验收目录。 -- 设置 `YCODE_SKIP_KERNEL_DATA_RESET=1` 后在同一进程运行 dev,保留 exec session;以实际端口、对应 renderer 与可响应窗口作为成功依据。 +- 用户说“拉取代码/运行 dev/启动当前代码/启动最新代码/重启 dev/停止 dev/构建/打包/查看状态”时,执行 [日常操作 SOP](docs/协作/日常操作SOP.md),唯一入口为 `node scripts/ops.mjs <动作> --json`;重启动作名为 `restart-dev`,停止为 `stop-dev`。模型只映射意图、启动操作一次、续读同一执行会话至结构化终态,不自由拼接 Git/npm/进程命令。 +- 首先确认本次已打开的项目根目录;未知时只询问路径。新会话须已打开本仓库或由用户提供一次路径,不假定跨会话全局规则、旧电脑路径或历史验收目录仍适用。 +- 不先做业务调研、REA、全量测试、方案设计或依赖升级。当前代码保留本地状态;只有 `pull`、`dev-latest` 同步 `gitea/master`。 +- 自动恢复只限入口脚本内已定义、有边界的处理。非成功结果直接报告 `code`、摘要、日志与所需下一步;不循环重试、不擅自改源码、stash/reset、强推、换检出、修改凭据或杀未知进程。审批与安全限制照常生效。 +- dev 的日志、端口、PID 不等于真实窗口验证;仅按结果中的证据报告。详细窗口验收及历史人工操作说明见 [Dev 启动 SOP](docs/协作/Dev启动SOP.md),不作为失败后绕过固定入口的捷径。 ## 当前目标(2026-10-15 用户确认) diff --git a/README.md b/README.md index f8b6cf1..4ec6429 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ src/ docs/ 方案与验收记录 ``` +## 日常操作与新会话 + +已打开本仓库后,可直接说“拉取代码”“运行 dev”“启动最新代码”“重启 dev”“停止 dev”“构建”“打包”“查看状态”。助手按 [日常操作 SOP](docs/协作/日常操作SOP.md) 调用固定入口,不先跑全量测试;新会话尚未打开仓库时,先提供项目路径。SOP 内含可复制的首条消息、动作表与阻塞处理规则。 + ## 开发 ```bash diff --git a/docs/协作/Dev启动SOP.md b/docs/协作/Dev启动SOP.md index 455ec33..5e7caf9 100644 --- a/docs/协作/Dev启动SOP.md +++ b/docs/协作/Dev启动SOP.md @@ -1,5 +1,7 @@ # YCode Dev 启动 SOP +> **现行自动执行入口(2026-10-21):** 新会话及普通 dev 操作先遵循 [日常操作 SOP](日常操作SOP.md),调用 `node scripts/ops.mjs dev --json`、`dev-latest` 或 `restart-dev`;明确停止使用 `stop-dev`,仅作用于归属已验证的受管实例。Windows 实机流程仍需在目标机器验收,离线检查不作实机证明。下文保留详细验收标准与历史人工命令,供明确授权的诊断参考,不得在入口阻塞后自行照抄绕过。历史绝对路径、PID、端口和旧会话目录不是默认值;缺少项目路径时询问。脏工作区、依赖缺失、凭据失败等由入口结构化报告,不自动换检出、安装依赖、修复代码或循环启动。脚本报告未完成窗口核验时,不能使用第 6 节的“窗口正常响应”成功措辞。 + 适用:用户说“启动 dev”“启动当前代码”“启动最新代码”“使用子代理启动”。这是执行任务,目标是让正确版本的真实窗口可用;不要先做业务调研、REA、方案设计、全量测试或依赖升级。 ## 0. 先选目录和版本 diff --git a/docs/协作/日常操作SOP.md b/docs/协作/日常操作SOP.md new file mode 100644 index 0000000..6c4b759 --- /dev/null +++ b/docs/协作/日常操作SOP.md @@ -0,0 +1,86 @@ +# YCode 日常操作 SOP + +目的:在新对话里用一句“拉取代码”“运行 dev”准确执行已知流程,而不是重新研究项目或临时拼命令。本制度是**仓库内约定 + 固定脚本入口**,不是跨对话全局记忆,也不自动选择模型、开新任务或保证额度消耗。 + +## 1. 新会话只需定位一次 + +- 已在工具中打开本仓库:读取 `AGENTS.md`,使用已确认的仓库根目录。 +- 尚未定位项目:用户给出当前机器的绝对路径;只问这一个缺失信息,不扫描整台机器、不猜历史目录。 +- 默认根目录取当前 cwd 所属的 Git 仓库,并校验项目身份;也可显式传 `--root "<已确认绝对路径>"`。本入口没有跨机器路径注册表,不自动发现或切换其他检出。 +- 首次使用需该检出已包含 `scripts/ops.mjs`。如果缺失,报告入口尚未安装,不能假称已启用或自行拼命令替代。 + +可复制的新对话首条消息(替换尖括号内容): + +```text +项目是 <本机 YCode 仓库绝对路径>。请读 AGENTS.md,按 docs/协作/日常操作SOP.md 执行。现在运行 dev(当前代码,不拉取);遇到阻塞只报告错误码和日志,不自行修复。 +``` + +之后在该上下文中直接说下面动作即可。另一台电脑或全新未打开仓库的会话仍须提供路径。 + +## 2. 意图 → 唯一固定动作 + +从已确认的仓库根目录调用,以下命令适用于 shell 与 PowerShell: + +| 用户说 | 固定调用 | 边界 | +| --- | --- | --- | +| 拉取代码/同步最新代码 | `node scripts/ops.mjs pull --json` | 同步 `gitea/master`;不启动 dev | +| 运行 dev/启动 dev/启动当前代码 | `node scripts/ops.mjs dev --json` | 保留当前代码与本地修改;不拉取 | +| 拉取并启动/启动最新代码 | `node scripts/ops.mjs dev-latest --json` | 同步成功才进入 dev;同步失败不称“最新” | +| 重启 dev | `node scripts/ops.mjs restart-dev --json` | 重启当前检出的可确认归属实例;不拉取 | +| 停止 dev | `node scripts/ops.mjs stop-dev --json` | 仅停止当前目录中归属已验证的受管 dev;未知进程不杀 | +| 构建 | `node scripts/ops.mjs build --json` | 当前代码构建,含项目 build 定义的类型检查;不是全量验收 | +| 打包/生成 Windows 安装包 | `node scripts/ops.mjs package --json` | Windows x64 的安装包流程;不发布、不签名保证、不自动安装 | +| 查看状态/dev 在运行吗 | `node scripts/ops.mjs status --json` | 检查仓库与运行证据;不启动或同步 | + +`doctor` 是明确要求环境诊断时的固定只读入口,不在每次操作前额外执行。除非用户指定限制,使用脚本默认超时;需要时可用 `--timeout-ms`(1000–3600000)。不将任意用户文本转成 shell 命令。 + +## 3. 助手执行纪律 + +1. 确认目录和意图后,直接调用对应入口**一次**;不要先跑全量测试、REA、业务研究、依赖升级或拆子任务。动作自身的检查由脚本执行。 +2. 读取结构化终态。build/package 等可能超过工具首次等待时间;工具返回 session ID 时,允许续读同一执行会话至脚本终态或既定时间预算,不另起重复操作、不重新调用 ops。首次空输出不代表 PASS;不要把等待输出误作重新执行授权。 +3. `PASS` 只表示该动作在输出证据范围内成功。dev 的端口开放、HTTP 响应或进程存活不等于真实窗口可用;窗口未核验时明确说“窗口未验证”,不宣称完整启动验收。 +4. `BLOCKED`、`FAIL`、`UNSUPPORTED`:报告错误码、原因、日志与所需下一步,停止依赖该结果的操作。只允许脚本内部已编码、有超时/次数上限的恢复;模型不得自行循环重试、升级依赖、修代码或扩大检查。 +5. 更复杂故障交给明确授权的诊断/修复任务;保留本次 JSON 和日志作为交接。不要借“快速执行”绕过网络、文件、进程、安装或其他审批。 + +## 4. 不可变安全边界 + +- `pull`/`dev-latest` 只接受脚本校验通过的仓库、远端和 `master`,在干净工作区快进到此次 fetch 的 `gitea/master`。不以缓存 SHA 证明最新。 +- 脏工作区、分支不匹配、分叉、远端不匹配、凭据缺失或网络失败均按结构化阻塞处理;不 stash、reset、强推、改凭据、换仓库或另建 worktree。当前代码模式可保留本地修改,须如实报告 dirty。 +- 不按进程名批量杀 node/Electron;仅脚本验证归属的受管实例可被重启。无法确认归属就保留并报告。 +- dev 保留 `YCODE_SKIP_KERNEL_DATA_RESET=1`,不主动切换生产/测试数据目录,不发模型消息验证启动。 +- 当前 dev 活跃或归属未知时,`pull`、`dev-latest`、`build`、`package` 可能阻塞,以避免覆盖正在运行的文件。用户明确要求停止后使用 `stop-dev`,再按授权执行原动作;不自动停止。未知进程不得为解除阻塞而杀掉。 +- 工作区操作锁冲突不自动解锁。超时后若子进程可能仍写入,锁可能保留;不要删除锁或 PID 状态文件来绕过另一个任务。 +- 打包不等于发布、上传、安装、签名或产品功能验收,这些需单独授权及证据。 + +## 5. 统一结果与回复 + +入口 JSON 的稳定顶层字段:`schemaVersion`、`action`、`status`、`ok`、`code`、`summary`、`root`、`git`、`evidence`、`logs`。取得 Git 信息后位于 `git.sha`、`git.dirty`、`git.branch`;早期失败可能没有 Git 信息。动作专属证据位于 `evidence`,日志也可能在 `evidence.logPath`。退出码:`PASS=0`、`BLOCKED=2`、`FAIL=3`、`UNSUPPORTED=4`。 + +回复保留以下信息;不适用或未取得的字段明确标注,不补猜值: + +| 信息 | 要求 | +| --- | --- | +| 目录/版本 | 实际 `root`、SHA、dirty;本地修改存在时不称“纯提交版本” | +| 动作/结果 | `action`、`status`、`code`、简短摘要;失败不能省略错误码 | +| 日志 | JSON 返回的实际日志位置或“无日志” | +| dev | 实际 URL/端口、PID、复用或新启、窗口验证范围;不能固定写 5173 或历史 PID | +| 构建/打包 | 返回的实际产物路径;未生成就写未生成,存在旧文件不等于本次成功 | + +推荐短回复: + +```text +<动作>:<状态>()。 +目录:;版本:;本地修改:<有/无/未知>。 +。 +日志:<实际位置/无>。<若失败:阻塞原因及需要用户决定的下一步>。 +``` + +## 6. dev 机器检查与验收边界 + +- dev/重启/停止的原生生命周期当前面向 Windows;其他平台依输出明确标为 `UNSUPPORTED`,不以后台 HTTP 服务冒充 Electron 桌面启动。Windows 实际启动、停止、重启与窗口行为仍需目标机器验收,离线自测通过不等于已获 Windows 实机证明。 +- dev 成功判定包含目标进程归属、响应窗口、renderer 应用目录、实际端口和仓库组件响应,并要求本次运行的可见应用 DOM 布局心跳。开发钩子 `src/main/dev-ops-health.ts` 仅在非打包且设置 ops run UUID 时启用;心跳在 `.tmp/ops/renderer-health.json`,绑定本次运行及进程,不增加网络/CDP 通道。 +- 机器检查能防止“只有进程或 Local URL 就报成功”,不证明业务功能、模型调用或完整 UI 行为正确。陈述成功仍须限定为实际返回的证据。 +- dev 状态、日志和心跳在本检出的 `.tmp/ops/`;调度器操作锁和日志在 Git 私有目录的 `ycode-ops/`。报告实际输出路径,不将其复制成其他机器的固定路径。 +- `UNTRACKED_DEV` 不自动收编或删除;启动超时保留单个可追踪进程,不反复新建。重启和停止仅作用于 PID、创建时间及命令行归属已验证的进程树。缺少依赖按阻塞报告,不自动重装。 + +真实 dev 窗口的详细验收标准见 [Dev 启动 SOP](Dev启动SOP.md);业务开发与独立验收仍按原有协作流程,日常操作成功不替代产品验收。 diff --git a/scripts/ops.mjs b/scripts/ops.mjs new file mode 100644 index 0000000..8ccd4b9 --- /dev/null +++ b/scripts/ops.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {parseArgs,OpsError,withLock,run,npmInvocation,publicResult} from './ops/core.mjs'; +import {resolveContext,gitState,pull} from './ops/git.mjs'; +import {build} from './ops/build.mjs'; + +async function dev(ctx, action) { + const {runDev}=await import('./ops/dev.mjs'); + const result=await runDev(action,{repoRoot:ctx.root,timeoutMs:Math.min(ctx.timeoutMs,120000),stateDir:ctx.stateDir,logDir:ctx.logDir,prepare:false}); + if(result.code==='TIMEOUT'||result.evidence?.possibleOrphans)ctx.keepLock=true; + return result; +} +export async function execute(args) { + let options,ctx; + try { + options=parseArgs(args);ctx=await resolveContext(options); + const initial=await gitState(ctx); + let result; + if(options.action==='status'||options.action==='doctor') { + let lock=null;try{lock=JSON.parse(await fs.readFile(path.join(ctx.stateDir,'workspace.lock'),'utf8'));}catch(error){if(error.code!=='ENOENT')lock={unreadable:true};} + const runtime=await dev(ctx,'status'); + result={status:'PASS',code:'STATUS',summary:'Read-only workspace status; no start, fetch or repair attempted.',git:initial,evidence:{lock,dev:runtime}}; + if(options.action==='doctor') { + let npm;try {const invocation=await npmInvocation();npm=await run(invocation.command,[...invocation.prefix,'--version'],{cwd:ctx.root,timeoutMs:10000});}catch(error){npm={error:error.message};} + const checks={node:process.version,platform:process.platform,arch:process.arch,npm:npm.stdout?.trim()||null,npmError:npm.error||null,dependencies:await fs.access(path.join(ctx.root,'node_modules','electron-vite')).then(()=>true,()=>false),dev:runtime}; + result={...result,status:npm.exitCode===0&&checks.dependencies?'PASS':'BLOCKED',code:npm.exitCode===0&&checks.dependencies?'DOCTOR_OK':'PREREQUISITES_MISSING',summary:'Read-only prerequisite checks; no installation or repair attempted.',evidence:{lock,...checks}}; + } + } else result=await withLock(ctx,options.action,async()=>{ + if(['pull','build','package'].includes(options.action)&&process.platform==='win32') { + const running=await dev(ctx,'status'); + if(running.status!=='BLOCKED'||running.code!=='DEV_NOT_RUNNING')throw new OpsError('DEV_STATE_BLOCKS_MUTATION','Target dev is running or its ownership is unverifiable; stop it explicitly before changing shared build/worktree files.',{dev:running}); + } + if(options.action==='pull')return pull(ctx); + if(options.action==='build'||options.action==='package')return build(ctx,options.action); + let sync; + if(options.action==='dev-latest') { + if(process.platform!=='win32')throw new OpsError('WINDOWS_REQUIRED','Native dev-latest requires Windows; no fetch or checkout change was attempted.',{},'UNSUPPORTED'); + // Do not rewrite a checkout underneath an existing or unverifiable dev process. + const running=await dev(ctx,'status'); + if(running.status!=='BLOCKED' || running.code!=='DEV_NOT_RUNNING')throw new OpsError('DEV_STATE_BLOCKS_SYNC','Stop the verified dev instance before synchronizing latest; no implicit process termination.',{dev:running}); + sync=await pull(ctx); + } + const started=await dev(ctx,options.action==='restart-dev'?'restart':options.action==='stop-dev'?'stop':'dev'); + return {...started,git:await gitState(ctx),...(sync?{sync:sync.evidence}:{})}; + }); + return {schemaVersion:1,action:options.action,root:ctx.root,...result,ok:result.status==='PASS',exitCode:{PASS:0,BLOCKED:2,FAIL:3,UNSUPPORTED:4}[result.status]??3}; + } catch(error) { + const status=error instanceof OpsError?error.status:'FAIL'; + return {schemaVersion:1,action:options?.action||args[0]||null,root:ctx?.root||null,status,ok:false,code:error.code||'INTERNAL_ERROR',summary:error.message,evidence:error.evidence||{},exitCode:{BLOCKED:2,FAIL:3,UNSUPPORTED:4}[status]||3}; + } +} +if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href) { + const result=publicResult(await execute(process.argv.slice(2))); + console.log(process.argv.includes('--json')?JSON.stringify(result):`${result.status} ${result.code}: ${result.summary}\n${JSON.stringify(result,null,2)}`); + process.exitCode=result.exitCode; +} diff --git a/scripts/ops/build.mjs b/scripts/ops/build.mjs new file mode 100644 index 0000000..73fb54b --- /dev/null +++ b/scripts/ops/build.mjs @@ -0,0 +1,58 @@ +import fs from 'node:fs/promises'; +import {createReadStream} from 'node:fs'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {run,OpsError,requireSuccess,npmInvocation} from './core.mjs'; +import {gitState,git} from './git.mjs'; +async function hashFile(file) {const hash=createHash('sha256');for await (const chunk of createReadStream(file))hash.update(chunk);return hash.digest('hex');} +export async function snapshot(directory, filter=()=>true) { + const results={}; + async function visit(dir) { + for(const entry of await fs.readdir(dir,{withFileTypes:true}).catch(error=>{if(error.code==='ENOENT')return [];throw error;})) { + const file=path.join(dir,entry.name); + if(entry.isSymbolicLink())continue; + if(entry.isDirectory())await visit(file); + else if(entry.isFile()&&filter(file)) {const stat=await fs.stat(file);const hash=await hashFile(file);results[path.relative(directory,file)]={size:stat.size,mtimeMs:stat.mtimeMs,sha256:hash};} + } + } + await visit(directory);return results; +} +export function freshArtifacts(before, after, startedAt) { + return Object.entries(after).filter(([name,value])=>value.size>0 && value.mtimeMs>=startedAt-2000 && (!before[name] || before[name].sha256!==value.sha256 || before[name].mtimeMs!==value.mtimeMs)).map(([name,value])=>({name,...value})); +} +export async function sourceFingerprint(ctx) { + const listing=await git(ctx,['ls-files','--cached','--others','--exclude-standard','-z'],{maxOutput:16*1024*1024}); + requireSuccess(listing,'SOURCE_SCAN_FAILED','Cannot enumerate source files.'); + if(listing.outputTruncated)throw new OpsError('SOURCE_SCAN_TOO_LARGE','Source listing exceeds the evidence limit.'); + const digest=createHash('sha256'); + for(const name of [...new Set(listing.stdout.split('\0').filter(Boolean))].sort()) { + digest.update(name+'\0'); + const file=path.join(ctx.root,name); + try{const stat=await fs.lstat(file);if(stat.isSymbolicLink())digest.update(await fs.readlink(file));else if(stat.isFile())digest.update(await hashFile(file));else digest.update('[non-file]');}catch(error){if(error.code==='ENOENT')digest.update('[deleted]');else throw error;} + } + return digest.digest('hex'); +} +export async function build(ctx, action) { + if(action==='package' && (process.platform!=='win32'||process.arch!=='x64'))throw new OpsError('WINDOWS_X64_REQUIRED','Installer packaging is supported only on Windows x64.',{},'UNSUPPORTED'); + const expected=action==='package'?'npm run runtimes:prepare && npm run build && electron-builder --win nsis --x64':'npm run typecheck && electron-vite build'; + const script=action==='package'?'dist:installer':'build'; + if(ctx.manifest.scripts?.[script]!==expected)throw new OpsError('SCRIPT_CONTRACT_CHANGED',`${script} script changed; review its safety before using this fixed operation.`); + if(ctx.manifest.scripts?.build!=='npm run typecheck && electron-vite build' || ctx.manifest.scripts?.typecheck!=='tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json' || (action==='package'&&ctx.manifest.scripts?.['runtimes:prepare']!=='node scripts/prepare-runtime-bundle.mjs'))throw new OpsError('SCRIPT_CONTRACT_CHANGED','Nested typecheck/runtime preparation script changed; review before execution.'); + const directory=path.join(ctx.root,action==='package'?'release':'out'); + const filter=action==='package'?file=>path.basename(file)===`YCode-${ctx.manifest.version}-Setup-x64.exe`:file=>/[/\\](?:main[/\\]index\.mjs|preload[/\\]index\.(?:js|mjs|cjs)|renderer[/\\]index\.html)$/.test(file); + for(const hook of ['prebuild','postbuild','predist:installer','postdist:installer','pretypecheck','posttypecheck','preruntimes:prepare','postruntimes:prepare'])if(ctx.manifest.scripts?.[hook])throw new OpsError('LIFECYCLE_HOOK_BLOCKED',`Unexpected ${hook} lifecycle hook needs review.`); + const before=await snapshot(directory,filter), initial=await gitState(ctx), fingerprint=await sourceFingerprint(ctx), startedAt=Date.now(); + const npm=await npmInvocation(); + const env={...process.env,CSC_IDENTITY_AUTO_DISCOVERY:'false'}; + // This action creates local unsigned artifacts only; it never inherits signing credentials. + for(const key of Object.keys(env))if(/^(?:CSC_|WIN_CSC_|APPLE_|GH_TOKEN$|GITHUB_TOKEN$|AZURE_|YCODE_.*SIGN)/i.test(key)&&key.toUpperCase()!=='CSC_IDENTITY_AUTO_DISCOVERY')delete env[key]; + const result=await run(npm.command,[...npm.prefix,'run',script,...(action==='package'?['--','--publish','never']:[])],{cwd:ctx.root,timeoutMs:ctx.timeoutMs,logDir:ctx.logDir,env}); + if(result.timedOut)ctx.keepLock=true; // Descendant toolchain processes may remain; never allow an automatic overlapping retry. + requireSuccess(result,'BUILD_FAILED',`${action} failed; no source changes or dependency upgrades were attempted.`); + const after=await snapshot(directory,filter), artifacts=freshArtifacts(before,after,startedAt), current=await gitState(ctx); + const verified=action==='package'?artifacts.length>0:['main/index.mjs','renderer/index.html'].every(name=>artifacts.some(a=>a.name.split(path.sep).join('/')===name)) && artifacts.some(a=>/^preload\/index\.(?:js|mjs|cjs)$/.test(a.name.split(path.sep).join('/'))); + if(!verified)throw new OpsError('ARTIFACTS_UNVERIFIED','Command exited successfully but expected fresh artifacts are missing.',{logPath:result.logPath,freshArtifacts:artifacts},'FAIL'); + if(await sourceFingerprint(ctx)!==fingerprint)throw new OpsError('SOURCE_CHANGED','Source content changed during the operation; artifacts are not certified.',{before:initial,current,logPath:result.logPath},'FAIL'); + if(current.sha!==initial.sha)throw new OpsError('VERSION_CHANGED','HEAD changed during the operation; artifact version cannot be certified.',{before:initial,current},'FAIL'); + return {status:'PASS',code:action==='package'?'PACKAGED':'BUILT',summary:action==='package'?'Fresh unsigned Windows x64 installer created locally; not published.':'Build completed with fresh main, preload and renderer entry artifacts.',git:current,evidence:{startedAt:new Date(startedAt).toISOString(),logPath:result.logPath,artifactDirectory:directory,artifacts:artifacts.slice(0,10),sourceFingerprint:fingerprint,sourceDirty:initial.dirty,sourceIdentity:(initial.dirty||current.dirty)?'HEAD plus local modifications; not a pristine commit build':'HEAD'}}; +} diff --git a/scripts/ops/core.mjs b/scripts/ops/core.mjs new file mode 100644 index 0000000..77b0e7a --- /dev/null +++ b/scripts/ops/core.mjs @@ -0,0 +1,83 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; + +export class OpsError extends Error { + constructor(code, message, evidence = {}, status = 'BLOCKED') { super(message); Object.assign(this, {code, evidence, status}); } +} +export function redact(value) { + let text=String(value).replace(/(https?:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi,'$1[REDACTED]@').replace(/((?:authorization|token|password|secret)\s*[:=]\s*)(?:Bearer\s+)?[^\s,;]+/gi,'$1[REDACTED]'); + for(const [key,secret] of Object.entries(process.env)) if(/TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY/i.test(key)&&secret?.length>=8)text=text.split(secret).join('[REDACTED]'); + return text; +} +export function redactObject(value) { + if(typeof value==='string')return redact(value); + if(Array.isArray(value))return value.map(redactObject); + if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([key,item])=>[key,redactObject(item)])); + return value; +} +export function publicResult(value) { + const result=redactObject(value); + for(const key of ['action','code','summary','root'])if(typeof result[key]==='string')result[key]=result[key].slice(0,key==='summary'?4000:8192); + if(Buffer.byteLength(JSON.stringify(result),'utf8')>65536) { + result.evidence={truncated:true,reason:'Evidence exceeds 64 KiB JSON limit; inspect local operation logs.'}; + if(result.git)result.git={...result.git,changes:result.git.changes?.slice(0,5).map(s=>s.slice(0,1000)),changesTruncated:true}; + delete result.sync; + } + return result; +} +export const actions = new Set(['status','pull','dev','dev-latest','restart-dev','stop-dev','build','package','doctor']); +export function parseArgs(args) { + const options = {action: args[0], json: false, timeoutMs: 600000}; + if (!actions.has(options.action)) throw new OpsError('INVALID_ACTION', 'Use status, pull, dev, dev-latest, restart-dev, stop-dev, build, package or doctor.'); + const seen = new Set(); + for (let i=1;i3600000) throw new OpsError('INVALID_TIMEOUT','Timeout must be 1000..3600000 milliseconds.'); + options.timeoutMs=Number(value); + } else throw new OpsError('INVALID_ARGUMENT', `Unknown option: ${key}`); + } + return options; +} +// Never uses a shell. Logs are private local evidence; JSON contains bounded tails only. +export async function run(command, args, {cwd, timeoutMs=30000, logDir, env=process.env,maxOutput=65536}={}) { + let logPath, handle; + if(logDir) { await fs.mkdir(logDir,{recursive:true}); logPath=path.join(logDir,`${Date.now()}-${randomUUID()}.log`); handle=await fs.open(logPath,'wx',0o600); } + return new Promise(resolve=>{ + let stdout='',stderr='',log='',outputTruncated=false,timedOut=false, finished=false; + const child=spawn(command,args,{cwd,env,shell:false,windowsHide:true,stdio:['ignore','pipe','pipe']}); + const append=(key,data)=>{ const value=data.toString(); if((key==='stdout'?stdout.length:stderr.length)+value.length>maxOutput)outputTruncated=true; if(key==='stdout') stdout=(stdout+value).slice(-maxOutput); else stderr=(stderr+value).slice(-maxOutput); if(handle) log=(log+value).slice(-8*1024*1024); }; + child.stdout.on('data',data=>append('stdout',data)); child.stderr.on('data',data=>append('stderr',data)); + const timer=setTimeout(()=>{timedOut=true; child.kill(); setTimeout(()=>{ if(!finished) {child.kill('SIGKILL'); finish(null,'TIMEOUT');} },1500).unref();},timeoutMs); + const finish=async(exitCode,error)=>{ if(finished)return; finished=true;clearTimeout(timer);if(timedOut){child.stdout.destroy();child.stderr.destroy();child.unref();}if(handle){await handle.writeFile(redact(log)).catch(()=>{});await handle.close().catch(()=>{});}resolve({exitCode,stdout,stderr:redact(stderr),timedOut,outputTruncated,logPath,error:error?redact(error):undefined}); }; + child.on('error',error=>finish(null,error.message));child.on('close',code=>finish(code)); + }); +} +export function requireSuccess(result,code,summary) { + if(result.exitCode!==0 || result.timedOut) throw new OpsError(result.timedOut?'TIMEOUT':code,summary,{logPath:result.logPath,exitCode:result.exitCode,stderr:result.stderr.slice(-4000),error:result.error},'FAIL'); + return result.stdout.trim(); +} +export async function withLock(ctx, action, fn) { + await fs.mkdir(ctx.stateDir,{recursive:true}); + const file=path.join(ctx.stateDir,'workspace.lock'); + let handle; + try {handle=await fs.open(file,'wx',0o600);} catch(error) {if(error.code!=='EEXIST')throw error; throw new OpsError('WORKSPACE_LOCKED','Another operation owns this workspace. Inspect the lock; never automatically remove it.',{lockPath:file});} + const token=randomUUID();await handle.writeFile(JSON.stringify({pid:process.pid,token,action,root:ctx.root,startedAt:new Date().toISOString()}));await handle.close(); + try{return await fn();} finally {try {const owner=JSON.parse(await fs.readFile(file,'utf8'));if(owner.token===token && !ctx.keepLock)await fs.unlink(file);}catch{}} +} +export async function npmInvocation() { + if(process.platform!=='win32') return {command:'npm', prefix:[]}; + const candidates=[process.env.npm_execpath,path.join(path.dirname(process.execPath),'node_modules/npm/bin/npm-cli.js')].filter(Boolean); + for(const file of candidates) if(path.basename(file)==='npm-cli.js') {try{await fs.access(file);return {command:process.execPath,prefix:[file]};}catch{}} + throw new OpsError('NPM_NOT_FOUND','Run through npm run ops, or use a Node installation containing npm-cli.js.'); +} diff --git a/scripts/ops/dev-host.mjs b/scripts/ops/dev-host.mjs new file mode 100644 index 0000000..d97e755 --- /dev/null +++ b/scripts/ops/dev-host.mjs @@ -0,0 +1,35 @@ +// Detached Windows lifetime owner: no services, registry changes or test profile. +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { redact } from './core.mjs'; +const [repoRoot, resultFile] = process.argv.slice(2); +if (!repoRoot || !resultFile || process.platform !== 'win32') process.exit(2); +const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'npm run dev'], { + cwd: repoRoot, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, YCODE_SKIP_KERNEL_DATA_RESET: '1' } +}); +// Line buffering avoids leaking credentials split across stream chunks. Oversized +// lines are omitted, rather than persisting partial unredacted secrets. +for (const stream of [child.stdout, child.stderr]) { + stream.setEncoding('utf8'); + let pending = '', dropping = false; + stream.on('data', chunk => { + for (const part of chunk.match(/[^\n]*\n|[^\n]+$/g) || []) { + if (!dropping) pending += part; + if (pending.length > 65536) { pending = ''; dropping = true; } + if (part.endsWith('\n')) { + process.stdout.write(dropping ? '[oversized log line omitted]\n' : redact(pending)); + pending = ''; dropping = false; + } + } + }); + stream.on('end', () => { if (pending && !dropping) process.stdout.write(redact(pending)); }); +} +child.on('error', error => { + writeFileSync(resultFile, JSON.stringify({ code: 'SPAWN_FAILED', message: redact(error.message) })); + process.exitCode = 1; +}); +child.on('exit', (code, signal) => { + writeFileSync(resultFile, JSON.stringify({ code: 'DEV_EXITED', exitCode: code, signal })); + process.exitCode = code ?? 1; +}); diff --git a/scripts/ops/dev-processes.ps1 b/scripts/ops/dev-processes.ps1 new file mode 100644 index 0000000..67b1ff8 --- /dev/null +++ b/scripts/ops/dev-processes.ps1 @@ -0,0 +1,34 @@ +# Read-only snapshot or identity-checked termination. Input is JSON over stdin; +# repository paths/commands are never evaluated as PowerShell source. +$ErrorActionPreference = 'Stop' +$request = [Console]::In.ReadToEnd() | ConvertFrom-Json +function Identity($p) { + [PSCustomObject]@{ pid = [int]$p.ProcessId; parentPid = [int]$p.ParentProcessId; name = $p.Name; commandLine = [string]$p.CommandLine; created = $(if ($null -ne $p.CreationDate) { $p.CreationDate.ToUniversalTime().ToString('o') } else { '' }) } +} +if ($request.action -eq 'stop') { + $stopped = @() + foreach ($expected in $request.processes) { + $p = Get-CimInstance Win32_Process -Filter ('ProcessId = ' + [int]$expected.pid) + if ($null -eq $p) { continue } + $actual = Identity $p + if (-not $expected.created -or -not $expected.commandLine -or $actual.created -ne $expected.created -or $actual.commandLine -cne $expected.commandLine) { throw ('PROCESS_IDENTITY_CHANGED:' + $expected.pid) } + # Open a handle before the final identity check so termination cannot select a reused PID. + $handle = [System.Diagnostics.Process]::GetProcessById([int]$expected.pid) + $null = $handle.Handle + $again = Get-CimInstance Win32_Process -Filter ('ProcessId = ' + [int]$expected.pid) + if ($null -eq $again) { $handle.Dispose(); continue } + $identity = Identity $again + if ($identity.created -ne $expected.created -or $identity.commandLine -cne $expected.commandLine) { $handle.Dispose(); throw ('PROCESS_IDENTITY_CHANGED:' + $expected.pid) } + $handle.Kill() + $handle.Dispose() + $stopped += [int]$expected.pid + } + @{ stopped = $stopped } | ConvertTo-Json -Compress + exit +} +$processes = @(Get-CimInstance Win32_Process | ForEach-Object { Identity $_ }) +$windows = @(Get-Process electron -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { + @{ pid = $_.Id; title = $_.MainWindowTitle; responding = $_.Responding } +}) +$listeners = @(Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { @{ pid = $_.OwningProcess; port = $_.LocalPort; address = $_.LocalAddress } }) +@{ processes = $processes; windows = $windows; listeners = $listeners } | ConvertTo-Json -Depth 5 -Compress diff --git a/scripts/ops/dev.mjs b/scripts/ops/dev.mjs new file mode 100644 index 0000000..2ea512f --- /dev/null +++ b/scripts/ops/dev.mjs @@ -0,0 +1,204 @@ +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { createHash, randomUUID } from 'node:crypto'; +import { readFile, writeFile, mkdir, access, open, rename } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const exec = promisify(execFile); +const here = path.dirname(fileURLToPath(import.meta.url)); +import { redact } from './core.mjs'; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const result = (status, code, summary, evidence = {}) => ({ status, code, summary, evidence }); +const normalize = value => value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase(); +export function sameIdentity(a, b) { + return !!a && !!b && a.pid === b.pid && !!a.created && a.created === b.created && !!a.commandLine && a.commandLine === b.commandLine; +} +export function ownedProcesses(snapshot, owner) { + const root = snapshot.processes.find(p => sameIdentity(p, owner)); + if (!root) return []; + const owned = [root]; + for (let changed = true; changed;) { + changed = false; + for (const p of snapshot.processes) { + if (!owned.some(x => x.pid === p.pid) && owned.some(x => x.pid === p.parentPid && Date.parse(p.created) >= Date.parse(x.created))) { + owned.push(p); changed = true; + } + } + } + return owned; +} +export function rendererMatches(commandLine, repoRoot) { + const match = commandLine.match(/(?:^|\s)--app-path=(?:"([^"]+)"|([^\s]+))/i); + return /(?:^|\s)--type=renderer(?:\s|$)/i.test(commandLine) && !!match && normalize(match[1] || match[2]) === normalize(repoRoot); +} +export function localUrls(log) { + return [...new Set([...log.replace(/\x1b\[[0-9;]*m/g, '').matchAll(/Local:\s+(http:\/\/(?:localhost|127\.0\.0\.1):\d+\/)/g)].map(m => m[1]))].reverse(); +} +export async function moduleMatches(url, source, fetchImpl = fetch) { + const response = await fetchImpl(new URL('/src/components/ChatPane.tsx?raw', url), { signal: AbortSignal.timeout(3500), redirect: 'error' }); + if (!response.ok) return false; + const text = await response.text(); + const match = text.match(/^\s*export default\s+("(?:[^"\\]|\\.)*")\s*;?/s); + if (!match || JSON.parse(match[1]) !== source) return false; + const module = await fetchImpl(new URL('/src/components/ChatPane.tsx', url), { signal: AbortSignal.timeout(3500), redirect: 'error' }); + const body = await module.text(); + return module.ok && /(?:javascript|ecmascript)/i.test(module.headers.get('content-type') || '') && !/^\s*= Date.parse(state.startedAt) && checkedAt <= now + 2000 && now - checkedAt <= 5000; +} +async function verifiedHealth(snapshot, state, repoRoot, source, log) { + const health = await inspectHealth(snapshot, state, repoRoot, source, log); + const attestation = state.readyPath ? await readJson(state.readyPath) : null; + const rendererReady = verifyAttestation(attestation, state, health, repoRoot); + return { ...health, processWindowHttpHealthy: health.healthy, healthy: health.healthy && rendererReady, + rendererReady, rendererUI: rendererReady ? 'live-visible-app-shell' : 'unverified', + verificationScope: rendererReady ? 'process-window-http-source-and-visible-renderer' : health.verificationScope }; +} +async function powershell(request) { + return new Promise((resolve, reject) => { + const child = spawn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-File', path.join(here, 'dev-processes.ps1')], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + const timer = setTimeout(() => { child.kill(); reject(new Error('PROCESS_QUERY_TIMEOUT')); }, 15000); + child.stdout.on('data', chunk => { stdout += chunk; if (stdout.length > 16_000_000) { child.kill(); reject(new Error('PROCESS_QUERY_TOO_LARGE')); } }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', error => { clearTimeout(timer); reject(error); }); + child.on('close', code => { clearTimeout(timer); if (code !== 0) reject(new Error(`PROCESS_QUERY_FAILED: ${stderr.slice(0, 1000)}`)); else { try { resolve(JSON.parse(stdout.replace(/^\uFEFF/, ''))); } catch { reject(new Error('INVALID_PROCESS_SNAPSHOT')); } } }); + child.stdin.end(JSON.stringify(request)); + }); +} +async function gitIdentity(repoRoot) { + const git = async args => (await exec('git', ['-C', repoRoot, ...args], { timeout: 15000, maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' })).stdout; + const sha = (await git(['rev-parse', 'HEAD'])).trim(); + const status = await git(['status', '--porcelain=v1', '-z', '--untracked-files=all']); + const hash = createHash('sha256').update(sha).update(status).update(await git(['diff', 'HEAD', '--binary'])); + const untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean); + for (const file of untracked) hash.update(file).update(await readFile(path.join(repoRoot, file))); + return { sha, dirty: !!status, fingerprint: hash.digest('hex') }; +} +async function readJson(file) { + try { return JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } +} +async function save(file, value) { + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, JSON.stringify(value, null, 2)); await rename(tmp, file); +} +function targetCandidates(snapshot, repoRoot) { + const root = normalize(repoRoot); + return snapshot.processes.filter(p => { + const cmd = normalize(p.commandLine || ''); + return (cmd.includes(root + '\\') || rendererMatches(p.commandLine || '', repoRoot)) && /electron-vite|dev-host\.mjs|--type=renderer/i.test(cmd); + }); +} +export async function inspectHealth(snapshot, state, repoRoot, source, log, fetchImpl = fetch) { + const owned = ownedProcesses(snapshot, state.owner); + const renderer = owned.find(p => rendererMatches(p.commandLine, repoRoot)); + const window = snapshot.windows.find(w => w.responding && /ycode/i.test(w.title) && owned.some(p => p.pid === w.pid && /^electron\.exe$/i.test(p.name) && !/--type=/.test(p.commandLine))); + const ports = snapshot.listeners.filter(l => owned.some(p => p.pid === l.pid && /^node\.exe$/i.test(p.name) && /electron-vite/i.test(p.commandLine))).map(l => l.port); + const urls = [...new Set([...localUrls(log), ...(state.url && /^http:\/\/(?:localhost|127\.0\.0\.1):\d+\/$/.test(state.url) ? [state.url] : [])])]; + for (const url of urls) { + if (!ports.includes(Number(new URL(url).port))) continue; + if (renderer && window && await moduleMatches(url, source, fetchImpl).catch(() => false)) return { healthy: true, ownerAlive: true, url, port: Number(new URL(url).port), windowPid: window.pid, rendererPid: renderer.pid, ownerPid: state.owner.pid, verificationScope: 'process-window-and-http-source', rendererUI: 'unverified' }; + } + return { healthy: false, ownerAlive: owned.length > 0, renderer: !!renderer, responsiveWindow: !!window, listeningPorts: ports, discoveredUrls: urls }; +} +export async function runDev(action, options = {}) { + const repoRoot = path.resolve(options.repoRoot || options.root || process.cwd()); + if (process.platform !== 'win32') return result('UNSUPPORTED', 'WINDOWS_REQUIRED', 'Native dev requires Windows; no GUI readiness is claimed.', { repoRoot, platform: process.platform }); + if (!['dev', 'status', 'restart', 'stop'].includes(action)) return result('BLOCKED', 'INVALID_ACTION', 'Use dev, status, restart or stop.'); + const stateDir = path.join(repoRoot, '.tmp', 'ops'); + const stateFile = path.join(stateDir, 'dev-state.json'); + const timeoutMs = Math.min(180000, Math.max(5000, options.timeoutMs || 90000)); + let state; + let spawnedPid; + let launchPersisted = false; + try { + const unsafe = [...new Set(['YCODE_USER_DATA_DIR', 'ELECTRON_RUN_AS_NODE', 'ELECTRON_NO_ATTACH_CONSOLE', 'NODE_OPTIONS', ...Object.keys(process.env).filter(key => key.startsWith('YCODE_QA_'))])].filter(key => process.env[key]); + if (unsafe.length && action !== 'stop') return result('BLOCKED', 'UNSAFE_PREVIEW_ENV', 'Inherited test/profile environment must be reviewed before normal user preview.', { variables: unsafe }); + const identity = await gitIdentity(repoRoot); + state = await readJson(stateFile); + if (state && (state.schema !== 1 || normalize(state.repoRoot) !== normalize(repoRoot))) return result('BLOCKED', 'INVALID_STATE', 'Dev state belongs to a different repository or schema; inspect it before continuing.', { stateFile }); + if (state && (!state.owner || !state.identity || !state.logPath || + path.dirname(path.resolve(state.logPath)) !== stateDir || + (state.readyPath && path.resolve(state.readyPath) !== path.join(stateDir, 'renderer-health.json')))) { + return result('BLOCKED', 'INVALID_STATE', 'Dev state has invalid identity or log/health paths; inspect it before continuing.', { stateFile }); + } + let snapshot = await powershell({ action: 'snapshot' }); + const owned = state ? ownedProcesses(snapshot, state.owner) : []; + const candidates = targetCandidates(snapshot, repoRoot); + const unknown = candidates.filter(p => !owned.some(o => o.pid === p.pid)); + if (unknown.length) return result('BLOCKED', 'UNTRACKED_DEV', 'Untracked target dev detected. Its version/ownership cannot be safely adopted; inspect it manually. No process was stopped.', { repoRoot, pids: unknown.map(p => p.pid), identity }); + if (action === 'stop') { + if (!owned.length) return result('PASS', 'DEV_STOPPED', 'No tracked target dev is running; no process was stopped.', { identity, stateFile }); + await powershell({ action: 'stop', processes: [...owned].reverse() }); + await sleep(500); + snapshot = await powershell({ action: 'snapshot' }); + const survivors = snapshot.processes.filter(p => owned.some(o => sameIdentity(p, o))); + if (survivors.length || targetCandidates(snapshot, repoRoot).length) return result('BLOCKED', 'STOP_INCOMPLETE', 'Target processes remain after identity-checked stop. Inspect before another mutation.', { pids: survivors.map(p => p.pid), stateFile }); + return result('PASS', 'DEV_STOPPED', 'Only the verified target dev process tree was stopped; user data was preserved.', { identity, stoppedPids: owned.map(p => p.pid), stateFile }); + } + const source = await readFile(path.join(repoRoot, 'src/renderer/src/components/ChatPane.tsx'), 'utf8'); + if (owned.length) { + const log = await readFile(state.logPath, 'utf8').catch(() => ''); + const health = await verifiedHealth(snapshot, state, repoRoot, source, log); + const matching = identity.fingerprint === state.identity.fingerprint; + if (action !== 'restart') { + if (!matching) return result('BLOCKED', 'VERSION_CHANGED', 'Running dev was started from different source contents. Use restart-dev to restart only its verified process tree.', { identity, startedIdentity: state.identity, health, stateFile }); + if (health.healthy) { + const current = await gitIdentity(repoRoot); + if (current.fingerprint !== identity.fingerprint) return result('BLOCKED', 'VERSION_CHANGED_DURING_CHECK', 'Source changed while checking the existing dev; retry status or restart.', { identity, current, stateFile }); + return result('PASS', 'DEV_READY_REUSED', 'Existing target dev is healthy and reused.', { ...health, identity, logPath: state.logPath, stateFile }); + } + return result('BLOCKED', 'DEV_NOT_READY', 'Target dev exists but is not fully healthy. Inspect its log or explicitly restart-dev; no duplicate was launched.', { identity, health, logPath: state.logPath, stateFile }); + } + // Stop only the captured tree; PowerShell rechecks creation time and full command line per handle. + await powershell({ action: 'stop', processes: [...owned].reverse() }); + await sleep(500); + snapshot = await powershell({ action: 'snapshot' }); + if (targetCandidates(snapshot, repoRoot).length || ownedProcesses(snapshot, state.owner).length) return result('BLOCKED', 'STOP_INCOMPLETE', 'Target processes remain after identity-checked stop; no new dev was launched.', { stateFile }); + } else if (action === 'status') return result('BLOCKED', 'DEV_NOT_RUNNING', 'No tracked target dev is running.', { identity, stateFile }); + try { await access(path.join(repoRoot, 'node_modules/.bin/electron-vite.cmd')); await access(path.join(repoRoot, 'node_modules/electron/dist/electron.exe')); } + catch { return result('BLOCKED', 'DEPENDENCIES_MISSING', 'Missing Windows dev dependencies. Run explicit prepare, then retry dev; dependencies are never reinstalled on each launch.', { nextCommand: 'npm ci --legacy-peer-deps --no-audit --no-fund', electronRepair: 'If Electron alone is absent after install: node node_modules/electron/install.js' }); } + const pkg = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')); + if (pkg.scripts?.dev !== 'electron-vite dev' || pkg.scripts?.predev || pkg.scripts?.postdev) return result('BLOCKED', 'DEV_SCRIPT_CHANGED', 'Expected npm run dev to be exactly electron-vite dev with no predev/postdev hooks; review changed scripts.'); + await mkdir(stateDir, { recursive: true }); + const stamp = `${Date.now()}-${process.pid}`; + const logPath = path.join(stateDir, `dev-${stamp}.log`); + const exitPath = path.join(stateDir, `dev-${stamp}-exit.json`); + const runId = randomUUID(); + const readyPath = path.join(stateDir, 'renderer-health.json'); + const log = await open(logPath, 'a'); + const host = spawn(process.execPath, [path.join(here, 'dev-host.mjs'), repoRoot, exitPath], { cwd: repoRoot, detached: true, windowsHide: true, stdio: ['ignore', log.fd, log.fd], env: { ...process.env, YCODE_SKIP_KERNEL_DATA_RESET: '1', YCODE_OPS_RUN_ID: runId } }); + await new Promise((resolve, reject) => { host.once('spawn', resolve); host.once('error', reject); }); + spawnedPid = host.pid; + host.unref(); await log.close(); + snapshot = await powershell({ action: 'snapshot' }); + const owner = snapshot.processes.find(p => p.pid === host.pid && p.commandLine.includes(path.join(here, 'dev-host.mjs')) && p.commandLine.includes(exitPath)); + if (!owner) return result('BLOCKED', 'START_IDENTITY_UNCONFIRMED', 'Launch owner could not be captured. Inspect logs/processes before retry; do not start another instance.', { logPath, exitPath, pid: host.pid, possibleOrphans: true }); + state = { schema: 1, repoRoot, identity, owner, logPath, exitPath, runId, readyPath, startedAt: new Date().toISOString() }; + await save(stateFile, state); + launchPersisted = true; + const deadline = Date.now() + timeoutMs; + let health; + do { + snapshot = await powershell({ action: 'snapshot' }); + health = await verifiedHealth(snapshot, state, repoRoot, source, await readFile(logPath, 'utf8')); + if (health.healthy) { + const current = await gitIdentity(repoRoot); + if (current.fingerprint !== identity.fingerprint) return result('BLOCKED', 'VERSION_CHANGED_DURING_START', 'Source changed during launch; restart to verify the exact version.', { identity, current, logPath, stateFile }); + state.url = health.url; state.verifiedAt = new Date().toISOString(); await save(stateFile, state); + return result('PASS', 'DEV_READY', 'Target dev started; owned window responds, renderer shell is visible, and served module matches this repository.', { ...health, identity, logPath, stateFile }); + } + const exit = await readJson(exitPath); + if (exit || !health.ownerAlive) return result('FAIL', 'DEV_EXITED', 'Dev exited before full readiness. Inspect the startup log; no automatic reinstall or relaunch was attempted.', { exit, health, identity, logPath, stateFile }); + if (Date.now() < deadline) await sleep(1000); + } while (Date.now() < deadline); + return result('BLOCKED', 'DEV_READY_TIMEOUT', 'Readiness deadline reached. The single tracked dev is retained for diagnosis; no GUI success is claimed.', { health, identity, logPath, stateFile }); + } catch (error) { + return result('FAIL', 'DEV_CHECK_FAILED', redact(error.message), { repoRoot, stateFile, ...(spawnedPid && !launchPersisted ? { possibleOrphans: true, spawnedPid } : {}), ...(state?.logPath ? { logPath: state.logPath } : {}) }); + } +} diff --git a/scripts/ops/dev.test.mjs b/scripts/ops/dev.test.mjs new file mode 100644 index 0000000..acf5f20 --- /dev/null +++ b/scripts/ops/dev.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { sameIdentity, ownedProcesses, rendererMatches, localUrls, moduleMatches, inspectHealth, verifyAttestation, runDev } from './dev.mjs'; +const root = 'C:\\Users\\A Name\\YCode'; +const p = (pid, parentPid, name, commandLine, created = '2026-10-21T00:00:01.000Z') => ({ pid, parentPid, name, commandLine, created }); +const owner = p(10, 1, 'node.exe', `node "${root}\\scripts\\ops\\dev-host.mjs"`); +const vite = p(11, 10, 'node.exe', `node "${root}\\node_modules\\electron-vite\\bin\\electron-vite.js" dev`); +const main = p(12, 11, 'electron.exe', 'electron.exe .'); +const renderer = p(13, 12, 'electron.exe', `electron.exe --type=renderer --app-path="${root}" --other`); +const snapshot = () => ({ processes: [owner, vite, main, renderer], windows: [{ pid: 12, title: 'YCode', responding: true }], listeners: [{ pid: 11, port: 5197 }] }); +const state = { owner }; +const source = 'export function ChatPane() { return "测试" }\n'; +const fakeFetch = async url => new Response(url.search === '?raw' ? `export default ${JSON.stringify(source)};\n//# sourceURL=foo` : 'export function ChatPane() {}', { headers: { 'Content-Type': 'text/javascript' } }); +test('identity rejects PID reuse, command changes, opaque timestamps', () => { + assert.equal(sameIdentity(owner, { ...owner }), true); + assert.equal(sameIdentity(owner, { ...owner, created: '2026-10-22T00:00:01.000Z' }), false); + assert.equal(sameIdentity(owner, { ...owner, commandLine: 'node other.js' }), false); + assert.equal(sameIdentity({ ...owner, created: '' }, { ...owner, created: '' }), false); +}); +test('ownership excludes unrelated siblings and pre-parent timestamp PID artifacts', () => { + const snap = snapshot(); + snap.processes.push(p(88, 1, 'node.exe', 'other repo'), p(89, 10, 'node.exe', 'old', '2026-10-20T00:00:00Z'), p(90, 10, 'system', '', '')); + assert.deepEqual(ownedProcesses(snap, owner).map(p => p.pid), [10, 11, 12, 13]); + assert.deepEqual(ownedProcesses(snap, { ...owner, created: '2025-01-01T00:00:00Z' }), []); +}); +test('renderer app path is an exact Windows path, not a substring', () => { + assert.equal(rendererMatches(renderer.commandLine, root.toUpperCase()), true); + assert.equal(rendererMatches(renderer.commandLine.replace('YCode"', 'YCode-other"'), root), false); + assert.equal(rendererMatches(`electron.exe --type=renderer --app-path=C:/repo`, 'C:\\repo'), true); + assert.equal(rendererMatches(`electron.exe --app-path="${root}"`, root), false); +}); +test('actual Local URL parses ANSI and variable ports without accepting remote hosts', () => { + assert.deepEqual(localUrls('\x1b[32mLocal: http://localhost:5197/\x1b[0m\nLocal: http://localhost:5198/\nLocal: http://evil:5173/'), ['http://localhost:5198/', 'http://localhost:5197/']); +}); +test('HTTP requires exact source and real transformed JS, not generic 200 HTML', async () => { + assert.equal(await moduleMatches('http://localhost:5197/', source, fakeFetch), true); + assert.equal(await moduleMatches('http://localhost:5197/', source + '// changed', fakeFetch), false); + assert.equal(await moduleMatches('http://localhost:5197/', source, async () => new Response('')), false); + assert.equal(await moduleMatches('http://localhost:5197/', source, async url => url.search ? fakeFetch(url) : new Response('')), false); +}); +test('health requires correct port ownership, target renderer, responsive owned main', async () => { + const check = snap => inspectHealth(snap, state, root, source, 'Local: http://localhost:5197/', fakeFetch); + assert.equal((await check(snapshot())).healthy, true); + assert.equal((await check(snapshot())).ownerAlive, true); + for (const change of [s => { s.windows[0].responding = false; }, s => { s.windows[0].pid = 99; }, s => { s.listeners[0].pid = 99; }, s => { s.listeners[0].port = 5173; }, s => { s.processes = s.processes.filter(p => p.pid !== 13); }, s => { s.processes[3] = { ...renderer, commandLine: renderer.commandLine.replace('YCode', 'Other') }; }]) { + const snap = snapshot(); snap.windows = snap.windows.map(x => ({ ...x })); snap.listeners = snap.listeners.map(x => ({ ...x })); + change(snap); assert.equal((await check(snap)).healthy, false); + } +}); +test('non-Windows native dev is explicitly unsupported, never GUI PASS', { skip: process.platform === 'win32' }, async () => { + for (const action of ['dev', 'restart', 'status', 'stop']) { + const response = await runDev(action, { repoRoot: '/nonexistent/no-side-effects' }); + assert.equal(response.status, 'UNSUPPORTED'); assert.equal(response.code, 'WINDOWS_REQUIRED'); + } +}); + +test('renderer attestation requires fresh UUID-bound visible UI and exact process IDs', () => { + const now = Date.now(); + const tracked = { runId: 'nonce', startedAt: new Date(now - 3000).toISOString() }; + const health = { healthy: true, windowPid: 12, rendererPid: 13 }; + const proof = { runId: 'nonce', ready: true, checkedAt: now - 500, pid: 12, rendererPid: 13 }; + assert.equal(verifyAttestation(proof, tracked, health, root, now), true); + for (const change of [{ ready: false }, { runId: 'old-run' }, { pid: 99 }, { rendererPid: 99 }, { checkedAt: now - 6000 }, { checkedAt: now + 5000 }, { checkedAt: NaN }]) { + assert.equal(verifyAttestation({ ...proof, ...change }, tracked, health, root, now), false); + } + assert.equal(verifyAttestation(null, tracked, health, root, now), false); + assert.equal(verifyAttestation(proof, tracked, { ...health, healthy: false }, root, now), false); +}); diff --git a/scripts/ops/git.mjs b/scripts/ops/git.mjs new file mode 100644 index 0000000..80c4fdd --- /dev/null +++ b/scripts/ops/git.mjs @@ -0,0 +1,35 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {run,OpsError,requireSuccess} from './core.mjs'; +export const EXPECTED_REMOTE='https://gitea.iiiii.run/admin/ycode.git'; +export async function git(ctx,args,options={}) {const result=await run('git',['-C',ctx.root,...args],{cwd:ctx.root,timeoutMs:30000,env:{...process.env,GIT_TERMINAL_PROMPT:'0',GIT_OPTIONAL_LOCKS:'0',GCM_INTERACTIVE:'Never',GIT_ASKPASS:'',SSH_ASKPASS:''},...options});if(result.timedOut)ctx.keepLock=true;return result;} +export async function resolveContext(options) { + const requested=await fs.realpath(options.root || process.cwd()).catch(()=>{throw new OpsError('ROOT_NOT_FOUND','Target directory does not exist.');}); + const root=await fs.realpath(requireSuccess(await git({root:requested},['rev-parse','--show-toplevel']),'NOT_REPOSITORY','Target is not a Git working tree.')); + if(options.root && root!==requested)throw new OpsError('ROOT_NOT_TOPLEVEL','Explicit --root must be the repository top-level.',{requested,root}); + let manifest;try{manifest=JSON.parse(await fs.readFile(path.join(root,'package.json'),'utf8'));}catch{throw new OpsError('WRONG_REPOSITORY','Missing YCode package.json.');} + if(manifest.name!=='ycode')throw new OpsError('WRONG_REPOSITORY','Target package is not ycode.'); + const gitDir=requireSuccess(await git({root},['rev-parse','--absolute-git-dir']),'GIT_FAILED','Cannot resolve private Git directory.'); + return {...options,root,manifest,stateDir:path.join(gitDir,'ycode-ops'),logDir:path.join(gitDir,'ycode-ops','logs')}; +} +export async function gitState(ctx) { + const sha=requireSuccess(await git(ctx,['rev-parse','HEAD']),'GIT_FAILED','Cannot read HEAD.'); + const status=requireSuccess(await git(ctx,['status','--porcelain=v1','--untracked-files=normal']),'GIT_FAILED','Cannot read worktree status.'); + const branch=(await git(ctx,['symbolic-ref','--quiet','--short','HEAD'])).stdout.trim() || null; + return {sha,dirty:!!status,branch,changes:status.split('\n').filter(Boolean).slice(0,30),changesTruncated:status.split('\n').length>30}; +} +export async function pull(ctx) { + const before=await gitState(ctx); + if(before.dirty)throw new OpsError('DIRTY_WORKTREE','Local changes preserved; latest requires a clean checkout.',{git:before}); + if(before.branch!=='master')throw new OpsError('WRONG_BRANCH','Pull requires checked-out master; no automatic branch switching.',{git:before}); + const remote=requireSuccess(await git(ctx,['remote','get-url','--all','gitea']),'REMOTE_MISSING','Expected gitea remote is missing.'); + if(remote!==EXPECTED_REMOTE)throw new OpsError('REMOTE_MISMATCH','gitea URL does not match the approved repository.'); + requireSuccess(await git(ctx,['fetch','--no-tags','gitea','refs/heads/master:refs/remotes/gitea/master'],{timeoutMs:ctx.timeoutMs,logDir:ctx.logDir}),'FETCH_FAILED','Fetch failed; cached refs are not latest evidence.'); + const target=requireSuccess(await git(ctx,['rev-parse','refs/remotes/gitea/master']),'GIT_FAILED','Cannot resolve fetched master.'); + // An ahead-only checkout also fails: latest means exact equality with fetched master. + if((await git(ctx,['merge-base','--is-ancestor',before.sha,target])).exitCode!==0)throw new OpsError('NOT_FAST_FORWARD','Local history is ahead or divergent; no reset, stash or merge commit permitted.',{before:before.sha,target}); + const now=await gitState(ctx);if(now.dirty||now.sha!==before.sha)throw new OpsError('WORKTREE_CHANGED','Workspace changed during fetch; synchronization stopped.'); + requireSuccess(await git(ctx,['-c','core.hooksPath='+path.join(ctx.stateDir,'disabled-hooks'),'merge','--ff-only','--quiet',target],{logDir:ctx.logDir}),'FAST_FORWARD_FAILED','Fast-forward failed; local state was not forcibly overwritten.'); + const after=await gitState(ctx);if(after.sha!==target||after.dirty)throw new OpsError('SYNC_UNVERIFIED','Post-sync state does not match fetched clean master.',{git:after,target},'FAIL'); + return {status:'PASS',code:'SYNCED',summary:'Clean master matches this fetch of gitea/master.',git:after,evidence:{before:before.sha,fetchedSha:target,remote:EXPECTED_REMOTE}}; +} diff --git a/scripts/ops/health.test.mjs b/scripts/ops/health.test.mjs new file mode 100644 index 0000000..b706c65 --- /dev/null +++ b/scripts/ops/health.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import vm from 'node:vm'; +import { transform } from 'esbuild'; + +// Compile the real producer; Electron, disk, timers and renderer are isolated mocks. +// No GUI, application profile or repository runtime files are touched. +const source = await readFile(new URL('../../src/main/dev-ops-health.ts', import.meta.url), 'utf8'); +const compiled = (await transform(source, { loader: 'ts', format: 'cjs', target: 'node20' })).code; +const runId = '12345678-1234-1234-1234-123456789abc'; +const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); }; +function fixture(options = {}) { + const files = new Map(), writes = [], renames = [], intervals = new Set(); + const directory = path.join(path.resolve('/fixture/ycode'), '.tmp', 'ops'); + const destination = path.join(directory, 'renderer-health.json'); + const window = new EventEmitter(), contents = new EventEmitter(); + let sequence = 0, releaseWrite, hold = !!options.holdFirstWrite; + const dom = { complete: true, shell: true, button: true, width: 800, height: 600, display: 'block', visibility: 'visible' }; + const fs = { + realpath: async value => options.linkedDirectory ? `${value}-elsewhere` : value, + writeFile: async (file, data, flags) => { + writes.push({ file, data: JSON.parse(data), flags }); + if (hold) { hold = false; await new Promise(resolve => { releaseWrite = resolve; }); } + assert.equal(flags.flag, 'wx'); assert.equal(flags.mode, 0o600); + assert.equal(files.has(file), false); files.set(file, data); + }, + rename: async (from, to) => { assert.equal(files.has(from), true); files.set(to, files.get(from)); files.delete(from); renames.push(JSON.parse(files.get(to))); }, + rm: async file => { files.delete(file); } + }; + Object.assign(contents, { + isDestroyed: () => false, getOSProcessId: () => 5678, + isLoadingMainFrame: () => !!options.loading, + executeJavaScript: async code => { + if (options.rejectProbe) throw new Error('renderer unavailable'); + const shell = { querySelector: () => dom.button ? {} : null, getBoundingClientRect: () => ({ width: dom.width, height: dom.height }) }; + return vm.runInNewContext(code, { + document: { readyState: dom.complete ? 'complete' : 'loading', querySelector: () => dom.shell ? shell : null }, + getComputedStyle: () => ({ display: dom.display, visibility: dom.visibility }) + }); + } + }); + Object.assign(window, { webContents: contents, isDestroyed: () => false }); + const module = { exports: {} }; + vm.runInNewContext(compiled, { + module, exports: module.exports, + require: name => { + if (name === 'electron') return { app: { isPackaged: !!options.packaged } }; + if (name === 'node:fs') return { promises: fs }; + if (name === 'node:path') return path; + if (name === 'node:crypto') return { randomUUID: () => `mock-${++sequence}` }; + throw new Error(`Unexpected dependency: ${name}`); + }, + process: { env: { YCODE_OPS_RUN_ID: options.id === undefined ? runId : options.id }, cwd: () => '/fixture/ycode', pid: 1234 }, + setInterval: callback => { const timer = { callback, unref() {} }; intervals.add(timer); return timer; }, + clearInterval: timer => intervals.delete(timer), Date + }); + return { window, contents, dom, files, writes, renames, intervals, destination, + attach: () => module.exports.attachDevOpsHealth(window), + tick: () => { for (const timer of intervals) timer.callback(); }, + release: () => { assert.ok(releaseWrite, 'expected delayed write'); releaseWrite(); }, + proof: () => files.has(destination) ? JSON.parse(files.get(destination)) : null }; +} + +test('producer opt-in gates packaged, missing and invalid run IDs without timers or writes', async () => { + for (const options of [{ packaged: true }, { id: '' }, { id: 'not-a-uuid' }]) { + const f = fixture(options); f.attach(); await flush(); + assert.equal(f.writes.length, 0); assert.equal(f.intervals.size, 0); + } +}); +test('producer writes fresh run and native PID proof only for a complete visible shell with button', async () => { + const f = fixture(); f.attach(); await flush(); + const proof = f.proof(); + assert.equal(proof.ready, true); assert.equal(proof.runId, runId); + assert.equal(proof.pid, 1234); assert.equal(proof.rendererPid, 5678); + assert.ok(Math.abs(Date.now() - proof.checkedAt) < 5000); + assert.equal(f.files.size, 1); assert.equal(f.files.has(f.destination), true); + for (const [key, value] of [['complete', false], ['shell', false], ['button', false], ['width', 0], ['height', 0], ['display', 'none'], ['visibility', 'hidden']]) { + const prior = f.dom[key]; f.dom[key] = value; f.tick(); await flush(); + assert.equal(f.proof().ready, false, `${key} must fail readiness`); f.dom[key] = prior; + } + f.tick(); await flush(); assert.equal(f.proof().ready, true); +}); +test('loading, rejected renderer and linked output directory cannot produce ready proof', async () => { + for (const options of [{ loading: true }, { rejectProbe: true }, { linkedDirectory: true }]) { + const f = fixture(options); f.attach(); await flush(); + assert.notEqual(f.proof()?.ready, true); + if (options.linkedDirectory) assert.equal(f.writes.length, 0); + } +}); +test('invalidation while a true proof write is pending cannot leave a stale true result', async () => { + const f = fixture({ holdFirstWrite: true }); f.attach(); await flush(); + assert.equal(f.writes[0].data.ready, true); + f.contents.emit('did-start-loading'); await flush(); + f.release(); await flush(); + assert.equal(f.proof().ready, false); + // Once invalidation is published, no older queued true may replace it. + const firstFalse = f.renames.findIndex(proof => proof.ready === false); + assert.ok(firstFalse >= 0); assert.ok(f.renames.slice(firstFalse).every(proof => proof.ready === false)); +}); +test('unresponsive and closed windows invalidate proof; close cancels periodic probes', async () => { + const f = fixture(); f.attach(); await flush(); + f.window.emit('unresponsive'); await flush(); assert.equal(f.proof().ready, false); + f.tick(); await flush(); assert.equal(f.proof().ready, false); + f.window.emit('responsive'); await flush(); assert.equal(f.proof().ready, true); + f.window.emit('closed'); await flush(); assert.equal(f.proof().ready, false); assert.equal(f.intervals.size, 0); +}); diff --git a/scripts/ops/ops.test.mjs b/scripts/ops/ops.test.mjs new file mode 100644 index 0000000..ea7f7fe --- /dev/null +++ b/scripts/ops/ops.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {parseArgs,run,withLock,redact,redactObject,publicResult} from './core.mjs'; +import {resolveContext,gitState,pull,EXPECTED_REMOTE} from './git.mjs'; +import {freshArtifacts,snapshot,build,sourceFingerprint} from './build.mjs'; +import {execute} from '../ops.mjs'; +async function fixture(t) { + const root=await fs.mkdtemp(path.join(os.tmpdir(),'ycode-ops-fixture-')); + t.after(()=>fs.rm(root,{recursive:true,force:true})); + const git=(...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim(); + git('init','-b','master');git('config','user.name','Ops Fixture');git('config','user.email','ops@example.invalid'); + await fs.writeFile(path.join(root,'package.json'),JSON.stringify({name:'ycode'}));git('add','.');git('commit','-m','fixture'); + return {root,git,ctx:await resolveContext({root,timeoutMs:10000})}; +} +test('strict parser accepts only fixed action and bounded flags',()=>{ + assert.equal(parseArgs(['dev','--json']).action,'dev');assert.equal(parseArgs(['stop-dev']).action,'stop-dev'); + for(const args of [['dev;touch','/tmp/pwn'],['dev','--','echo'],['dev','--root','relative'],['build','--timeout-ms','1'],['status','--json','--json'],['dev','--root','/tmp','--command','x']])assert.throws(()=>parseArgs(args)); +}); +test('shell metacharacters remain literal argv, not commands',async t=>{ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'ops-argv-'));t.after(()=>fs.rm(root,{recursive:true,force:true})); + const value='; touch '+path.join(root,'injected'); + const result=await run(process.execPath,['-e','process.stdout.write(process.argv[1])',value],{cwd:root}); + assert.equal(result.stdout,value);await assert.rejects(fs.access(path.join(root,'injected'))); +}); +test('explicit root never silently picks parent or another repository',async t=>{ + const {root}=await fixture(t);await fs.mkdir(path.join(root,'nested')); + await assert.rejects(resolveContext({root:path.join(root,'nested')}),e=>e.code==='ROOT_NOT_TOPLEVEL'); + await fs.writeFile(path.join(root,'package.json'),'{"name":"other"}');await assert.rejects(resolveContext({root}),e=>e.code==='WRONG_REPOSITORY'); +}); +test('dirty checkout blocks pull without discarding changes',async t=>{ + const {root,ctx}=await fixture(t);await fs.writeFile(path.join(root,'local.txt'),'preserve'); + await assert.rejects(pull(ctx),e=>e.code==='DIRTY_WORKTREE');assert.equal(await fs.readFile(path.join(root,'local.txt'),'utf8'),'preserve');assert.equal((await gitState(ctx)).dirty,true); +}); +test('pull refuses wrong branch and remote before network access',async t=>{ + const {ctx,git}=await fixture(t);git('checkout','-b','other');await assert.rejects(pull(ctx),e=>e.code==='WRONG_BRANCH'); + git('checkout','master');git('remote','add','gitea','https://example.invalid/other.git');await assert.rejects(pull(ctx),e=>e.code==='REMOTE_MISMATCH'); +}); +test('workspace lock excludes concurrent mutations and preserves unknown lock',async t=>{ + const {ctx}=await fixture(t); + await withLock(ctx,'test',async()=>{await assert.rejects(withLock(ctx,'test',async()=>{}),e=>e.code==='WORKSPACE_LOCKED');}); + await withLock(ctx,'test',async()=>{ctx.keepLock=true;}); + await assert.rejects(withLock(ctx,'test',async()=>{}),e=>e.code==='WORKSPACE_LOCKED'); +}); +test('timeout is bounded and records actual timeout',async()=>{ + const result=await run(process.execPath,['-e','setInterval(()=>{},1000)'],{timeoutMs:50});assert.equal(result.timedOut,true); +}); +test('artifact freshness rejects stale outputs and accepts rewritten identical bytes',async t=>{ + const {root}=await fixture(t);await fs.writeFile(path.join(root,'artifact'),'data');const before=await snapshot(root,f=>f.endsWith('artifact')); + assert.equal(freshArtifacts(before,before,Date.now()).length,0); + const after={artifact:{...before.artifact,mtimeMs:Date.now()+5}};assert.equal(freshArtifacts(before,after,Date.now()).length,1); +}); +test('logs redact credentials and JSON errors are machine-readable',async t=>{ + assert.equal(redact('https://user:password@example.invalid/token'), 'https://[REDACTED]@example.invalid/token'); + const {root}=await fixture(t);const result=await run(process.execPath,['-e','console.error("token=fixture-secret")'],{logDir:path.join(root,'logs')}); + assert.doesNotMatch(await fs.readFile(result.logPath,'utf8'),/fixture-secret/); + const failure=await execute(['dev','--command','bad']);assert.equal(failure.exitCode,2);assert.equal(failure.schemaVersion,1); +}); +test('package never invokes installer on unsupported platforms',async t=>{ + if(process.platform==='win32'&&process.arch==='x64')return t.skip('native packaging covered by Windows smoke'); + const {ctx}=await fixture(t);await assert.rejects(build(ctx,'package'),e=>e.code==='WINDOWS_X64_REQUIRED'); +}); +test('fresh local fetch fast-forwards exactly; ahead/divergent and fetch failure preserve checkout',async t=>{ + if(process.platform==='win32')return t.skip('POSIX git shim fixture; Windows covered by native smoke'); + const {root,ctx,git}=await fixture(t); + const realGit=execFileSync('which',['git'],{encoding:'utf8'}).trim(); + const remote=path.join(root,'.git','fixture-remote.git');execFileSync(realGit,['clone','--bare',root,remote],{stdio:'ignore'}); + git('remote','add','gitea',EXPECTED_REMOTE); + // Wrapper substitutes ONLY the fetch transport with a local bare fixture; remote identity remains production contract. + const bin=path.join(root,'.git','fixture-bin');await fs.mkdir(bin); + const shim=path.join(bin,'git');await fs.writeFile(shim,`#!/usr/bin/env node\nconst {spawnSync}=require('node:child_process'); const a=process.argv.slice(2);const i=a.indexOf('fetch');if(i>=0){if(process.env.OPS_FIXTURE_FETCH_FAIL==='1')process.exit(8);a[a.indexOf('gitea',i)]=process.env.OPS_FIXTURE_REMOTE;}const r=spawnSync(process.env.OPS_FIXTURE_GIT,a,{stdio:'inherit'});process.exit(r.status??1);\n`,{mode:0o755}); + const saved={PATH:process.env.PATH,OPS_FIXTURE_REMOTE:process.env.OPS_FIXTURE_REMOTE,OPS_FIXTURE_GIT:process.env.OPS_FIXTURE_GIT}; + Object.assign(process.env,{PATH:bin+path.delimiter+process.env.PATH,OPS_FIXTURE_REMOTE:remote,OPS_FIXTURE_GIT:realGit}); + t.after(()=>{for(const [k,v]of Object.entries(saved))if(v===undefined)delete process.env[k];else process.env[k]=v;delete process.env.OPS_FIXTURE_FETCH_FAIL;}); + const old=git('rev-parse','HEAD');await fs.writeFile(path.join(root,'next'),'next');git('add','.');git('commit','-m','remote next');const latest=git('rev-parse','HEAD');git('push',remote,'master');git('checkout','--detach',old);git('branch','-f','master',old);git('checkout','master'); + const synced=await pull(ctx);assert.equal(synced.evidence.fetchedSha,latest);assert.equal(git('rev-parse','HEAD'),latest); + await fs.writeFile(path.join(root,'ahead'),'ahead');git('add','.');git('commit','-m','local ahead');const ahead=git('rev-parse','HEAD'); + await assert.rejects(pull(ctx),e=>e.code==='NOT_FAST_FORWARD');assert.equal(git('rev-parse','HEAD'),ahead); + process.env.OPS_FIXTURE_FETCH_FAIL='1';await assert.rejects(pull(ctx),e=>e.code==='FETCH_FAILED');assert.equal(git('rev-parse','HEAD'),ahead); +}); + +test('source fingerprint notices content changes even when HEAD and dirty boolean do not change',async t=>{const {ctx,root}=await fixture(t);const file=path.join(root,'local');await fs.writeFile(file,'one');const before=await sourceFingerprint(ctx);await fs.writeFile(file,'two');assert.notEqual(await sourceFingerprint(ctx),before);}); + +test('redaction preserves JSON syntax for embedded token error text',()=>{const encoded=JSON.stringify(redactObject({summary:'token=secret-value',next:1}));assert.equal(JSON.parse(encoded).next,1);assert.doesNotMatch(encoded,/secret-value/);}); + +test('dev-latest is unsupported before any Git mutation on non-Windows',async t=>{if(process.platform==='win32')return t.skip('non-Windows contract');const {root,git}=await fixture(t);const before=git('rev-parse','HEAD');const result=await execute(['dev-latest','--root',root]);assert.equal(result.status,'UNSUPPORTED');assert.equal(result.code,'WINDOWS_REQUIRED');assert.equal(git('rev-parse','HEAD'),before);}); + +test('oversized evidence is bounded for small-model JSON consumption',()=>{const result=publicResult({status:'FAIL',summary:'bounded',evidence:{data:'x'.repeat(100000)}});assert.equal(result.evidence.truncated,true);assert.ok(JSON.stringify(result).length<65536);}); diff --git a/src/main/dev-ops-health.ts b/src/main/dev-ops-health.ts new file mode 100644 index 0000000..3937e0b --- /dev/null +++ b/src/main/dev-ops-health.ts @@ -0,0 +1,60 @@ +/** Opt-in development readiness only. Never exposes a network/debug endpoint. */ +import { app, type BrowserWindow } from 'electron' +import { promises as fs } from 'node:fs' +import { join, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' + +export function attachDevOpsHealth(window: BrowserWindow): void { + const runId = process.env.YCODE_OPS_RUN_ID + if (app.isPackaged || !runId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(runId)) return + const root = resolve(process.cwd()), directory = join(root, '.tmp', 'ops') + const destination = join(directory, 'renderer-health.json') + let busy = false, stopped = false, responsive = true, generation = 0 + let writes: Promise = Promise.resolve() + const invalidate = (): void => { generation++; void publish(false).catch(() => undefined) } + function publish(ready: boolean): Promise { + const expectedGeneration = generation + const operation = writes.then(async () => { + if (expectedGeneration !== generation) return + // The controller owns this directory. Do not create or follow an arbitrary linked path. + const actualDirectory = await fs.realpath(directory) + const canonical = (value: string): string => process.platform === 'win32' ? value.toLowerCase() : value + if (canonical(actualDirectory) !== canonical(directory)) return + const temporary = join(directory, `.health-${process.pid}-${randomUUID()}.tmp`) + try { + await fs.writeFile(temporary, JSON.stringify({ runId, pid: process.pid, + rendererPid: window.webContents.isDestroyed() ? 0 : window.webContents.getOSProcessId(), + checkedAt: Date.now(), ready }), { flag: 'wx', mode: 0o600 }) + if (expectedGeneration === generation) await fs.rename(temporary, destination) + } finally { await fs.rm(temporary, { force: true }).catch(() => undefined) } + }) + writes = operation.catch(() => undefined) + return operation + } + async function probe(): Promise { + if (busy || stopped) return + busy = true + const current = generation + try { + let ready = false + if (responsive && !window.isDestroyed() && !window.webContents.isDestroyed() && !window.webContents.isLoadingMainFrame()) { + ready = await window.webContents.executeJavaScript(`(() => { + const shell = document.querySelector('.app-shell'); + if (document.readyState !== 'complete' || !shell || !shell.querySelector('button')) return false; + const rect = shell.getBoundingClientRect(), style = getComputedStyle(shell); + return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; + })()`) + } + if (!stopped && current === generation) await publish(ready === true) + } catch { if (!stopped && current === generation) await publish(false).catch(() => undefined) } + finally { busy = false } + } + window.webContents.on('did-start-loading', invalidate) + window.webContents.on('render-process-gone', invalidate) + window.on('unresponsive', () => { responsive = false; invalidate() }) + window.on('responsive', () => { responsive = true; void probe() }) + const timer = setInterval(() => { void probe() }, 1000) + timer.unref() + window.on('closed', () => { stopped = true; clearInterval(timer); invalidate() }) + void probe() +} diff --git a/src/main/index.ts b/src/main/index.ts index 769fdb4..710fa12 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,4 @@ +import { attachDevOpsHealth } from './dev-ops-health' /** * Electron 主进程入口。 * @@ -109,6 +110,7 @@ function createWindow(): void { } }) + attachDevOpsHealth(mainWindow) mainWindow.on('ready-to-show', () => mainWindow?.show()) mainWindow.on('closed', () => { mainWindow = null