feat: add deterministic daily ops SOP and verified dev lifecycle
This commit is contained in:
1 parent
dac4af53e0
commit
2aafb6f92f
16 files changed
+935
-5
No files matched your search
@@ -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 用户确认)
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ src/
|
||||
docs/ 方案与验收记录
|
||||
```
|
||||
|
||||
## 日常操作与新会话
|
||||
|
||||
已打开本仓库后,可直接说“拉取代码”“运行 dev”“启动最新代码”“重启 dev”“停止 dev”“构建”“打包”“查看状态”。助手按 [日常操作 SOP](docs/协作/日常操作SOP.md) 调用固定入口,不先跑全量测试;新会话尚未打开仓库时,先提供项目路径。SOP 内含可复制的首条消息、动作表与阻塞处理规则。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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. 先选目录和版本
|
||||
|
||||
@@ -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
|
||||
<动作>:<状态>(<code>)。
|
||||
目录:<root>;版本:<SHA>;本地修改:<有/无/未知>。
|
||||
<dev URL、PID 与窗口验证范围,或实际产物路径>。
|
||||
日志:<实际位置/无>。<若失败:阻塞原因及需要用户决定的下一步>。
|
||||
```
|
||||
|
||||
## 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);业务开发与独立验收仍按原有协作流程,日常操作成功不替代产品验收。
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'}};
|
||||
}
|
||||
@@ -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;i<args.length;i++) {
|
||||
const key=args[i];
|
||||
if (seen.has(key)) throw new OpsError('INVALID_ARGUMENT', `Duplicate option: ${key}`);
|
||||
seen.add(key);
|
||||
if(key==='--json') options.json=true;
|
||||
else if(key==='--root') {
|
||||
const value=args[++i];
|
||||
if(!value || !path.isAbsolute(value) || value.includes('\0')) throw new OpsError('INVALID_ROOT','--root requires an absolute directory.');
|
||||
options.root=value;
|
||||
} else if(key==='--timeout-ms') {
|
||||
const value=args[++i];
|
||||
if(!/^\d+$/.test(value || '') || Number(value)<1000 || Number(value)>3600000) 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.');
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
|
||||
@@ -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*<!doctype html/i.test(body);
|
||||
}
|
||||
export function verifyAttestation(attestation, state, health, repoRoot, now = Date.now()) {
|
||||
if (!attestation || !state.runId || !health.healthy) return false;
|
||||
const checkedAt = attestation.checkedAt;
|
||||
return attestation.ready === true && attestation.runId === state.runId &&
|
||||
attestation.pid === health.windowPid && attestation.rendererPid === health.rendererPid &&
|
||||
Number.isFinite(checkedAt) && checkedAt >= 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 } : {}) });
|
||||
}
|
||||
}
|
||||
@@ -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('<!doctype html><html>')), false);
|
||||
assert.equal(await moduleMatches('http://localhost:5197/', source, async url => url.search ? fakeFetch(url) : new Response('<!doctype html>')), 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);
|
||||
});
|
||||
@@ -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}};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);});
|
||||
@@ -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<void> = Promise.resolve()
|
||||
const invalidate = (): void => { generation++; void publish(false).catch(() => undefined) }
|
||||
function publish(ready: boolean): Promise<void> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user