@petercjl/codex-own 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,20 @@ Windows 桌面在启动 Agent 会话时自动检测并验证系统安装的 Git
44
44
 
45
45
  日常应用内更新使用随 App 交付的更新组件,无需手动更新全局 npm 管理工具。API Key、配置、对话和语言偏好会保留。断网时当前 App 保持可用;下载失败可以重试,已校验分片会复用;已准备好的更新会在下次打开时恢复。旧版本首次升级到带更新组件的版本,以及故障恢复,可以使用下文的终端更新命令。
46
46
 
47
+ ## 更新后的自动清理
48
+
49
+ 从桌面 0.6.4 起,主界面加载并持续正常运行 30 秒后,codex-own 会确认当前受管理版本,再自动清理已成功安装的完整归档和已退出的更新助手。升级前的程序及配置/对话快照成套保留最近一次完整回滚,更早且有成功事务记录的备份会回收。当前应用、密钥、配置、对话、独立 Node/npm/Python 运行时及手动备份保持完整。
50
+
51
+ 已校验分片缓存默认上限为 2 GiB,优先回收长期未使用的分片;待安装或失败更新需要的分片受保护,期间可能暂时超过上限。无法识别的目录、未完成事务、被占用文件和不完整的回滚资料会保留。清理异常不影响当前应用及正常更新;“更新”窗口会显示本次启动回收的空间。清理计划、结果和启动确认记录各保留最近 20 条。
52
+
53
+ 可直接发给 Agent:
54
+
55
+ > 请检查 codex-own 的更新文件占用,并预览自动清理范围和保留的回滚备份。
56
+
57
+ > 请通过 @petercjl/codex-own 清理已用完的更新文件和更旧的受管理备份,保留最近一次完整回滚。
58
+
59
+ 开发者入口:`codex-own cleanup plan` 只读预览;`codex-own cleanup run --yes` 按相同策略执行。`--cache-limit-mib <0..16384>` 可指定本次清理的缓存上限。清理需要当前桌面已记录成功启动;仅升级 npm 管理工具不会产生这份启动证明。清理使用安装操作锁,原版 Codex 和系统/npm 全局缓存不在范围内。
60
+
47
61
  ## 增量下载
48
62
 
49
63
  从 0.4.0 起,更新前会扫描已安装的 codex-own 程序,复用与新版相同的内容块,只从 npm 下载缺少的块。支持跳过版本;首次安装仍下载完整资源。进度会显示“复用多少块、需要下载多少 MiB、全量多少 MiB”,结果中的 `transfer` 保留这些统计。少量代码改动通常只影响部分块;上游引擎、大型二进制或签名资源变化时,下载量会相应增加。
@@ -87,11 +101,23 @@ npm install --global --prefix "$HOME/.local" @petercjl/codex-own@latest
87
101
  "$HOME/.local/bin/codex-own" version
88
102
  ```
89
103
 
90
- 可选:需要使用模型时,再配置 API Key 并启动。将只包含一行 DashScope API Key 的文件放到下载目录,命名为 codex-own-api-key.txt;文件在其他位置时修改路径。配置前退出应用,已有可用配置可跳过配置命令。
104
+ **API Key 文件路径必须改为你实际下载的位置,不能直接复制占位路径。** 配置前先保存任务;命令会关闭 codex-own,完成配置后从 Mac“应用程序”打开 codex-own。
91
105
 
92
106
  ```bash
93
- "$HOME/.local/bin/codex-own" configure --api-key-file "$HOME/Downloads/codex-own-api-key.txt" --yes
94
- "$HOME/.local/bin/codex-own" launch
107
+ apiKeyFile="【填写 API Key 文件的实际完整路径】"
108
+ if [ -f "$apiKeyFile" ]; then
109
+ ownPattern='^/Applications/codex-own[.]app/Contents/'
110
+ pkill -TERM -f "$ownPattern" || true
111
+ for i in {1..10}; do
112
+ pgrep -f "$ownPattern" >/dev/null || break
113
+ sleep 1
114
+ done
115
+ pkill -KILL -f "$ownPattern" || true
116
+ "$HOME/.local/bin/codex-own" configure --api-key-file "$apiKeyFile" --yes &&
117
+ "$HOME/.local/bin/codex-own" doctor
118
+ else
119
+ printf '%s\n' "请先填写 apiKeyFile,确认文件路径和文件名正确。"
120
+ fi
95
121
  ```
96
122
 
97
123
  以后更新:先退出 codex-own,再更新 npm 管理工具和桌面程序。配置与对话保存在 ~/Library/Application Support/codex-own-independent。
@@ -104,70 +130,88 @@ npm install --global --prefix "$HOME/.local" @petercjl/codex-own@latest
104
130
 
105
131
  ## 在 Windows 上通过 PowerShell 安装和测试
106
132
 
107
- 适用于运行 x64 Node.js 的 Windows。先从 Node.js 官网安装 Windows x64 的 LTS 版本(要求 Node.js 22 或以上,包含 npm),再重新打开普通用户 PowerShell。建议预留 10 GB 空间,用于下载、解压和备份。
108
-
109
- [Node.js 官方下载](https://nodejs.org/en/download)
133
+ 适用于 Windows x64,使用包含 npm 的官方 Node.js 22+ x64 发行包。可使用系统安装或用户目录的官方便携版。先核对实际 node.exe 位置;多套 Node、第三方 Agent 便携 Node 和 npm 的 .cmd 入口可能选择不同运行时。
110
134
 
111
- 安装验收只需完成前三步。无需 Agent、API Key 或模型登录。
135
+ **下列 $node 使用常见安装路径。请按实际 Node 安装位置修改后执行。** 普通用户 PowerShell 即可,无需改变脚本执行策略。建议首次安装预留至少 10 GB;更新还需保留旧程序和用户数据快照所需空间。
112
136
 
113
- 第一步:检查环境并安装 npm 管理工具。使用 .cmd 入口,无需修改 PowerShell 脚本执行策略。逐段执行,每一步成功后再继续。
137
+ 第一步:在当前窗口设置 UTF-8,并检查真正执行安装的 Node 和 npm:
114
138
 
115
139
  ```powershell
116
- node --version
117
- node -p "process.arch"
118
- npm.cmd --version
119
- npm.cmd install --global @petercjl/codex-own@latest
140
+ $ErrorActionPreference = "Stop"
141
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
142
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
143
+ $OutputEncoding = [Console]::OutputEncoding
144
+ chcp.com 65001 > $null
145
+ $node = "C:\Program Files\nodejs\node.exe"
146
+ if (-not (Test-Path -LiteralPath $node -PathType Leaf)) { throw "请填写实际的 Node.js 22+ x64 可执行文件路径。" }
147
+ $bootstrap = (& $node -p 'JSON.stringify({version:process.versions.node,arch:process.arch,executable:process.execPath})') | ConvertFrom-Json
148
+ if ($LASTEXITCODE -ne 0 -or [int]($bootstrap.version.Split('.')[0]) -lt 22 -or $bootstrap.arch -ne "x64") { throw "安装引导要求 Node.js 22+ x64,请核对实际路径。" }
149
+ $bootstrap | Format-List
150
+ $npmCli = Join-Path (Split-Path -Parent $node) "node_modules\npm\bin\npm-cli.js"
151
+ if (-not (Test-Path -LiteralPath $npmCli -PathType Leaf)) { throw "所选 Node 目录缺少 npm,请使用包含 npm 的官方 Node.js 发行包。" }
152
+ & $node $npmCli --version
153
+ if ($LASTEXITCODE -ne 0) { throw "npm 检查失败。" }
120
154
  ```
121
155
 
122
- process.arch 应输出 x64。第二步:解析命令路径并安装桌面程序。$codexOwn 只在当前窗口有效;打开新窗口后重新执行赋值行。
156
+ 第二步:通过同一个 Node 安装并调用管理工具:
123
157
 
124
158
  ```powershell
125
- $codexOwn = Join-Path (npm.cmd prefix --global) "codex-own.cmd"
126
- & $codexOwn install --yes
159
+ & $node $npmCli install --global @petercjl/codex-own@latest --registry=https://registry.npmjs.org
160
+ if ($LASTEXITCODE -ne 0) { throw "管理工具安装失败。" }
161
+ $npmRoot = (& $node $npmCli root --global).Trim()
162
+ if ($LASTEXITCODE -ne 0) { throw "无法取得 npm 全局目录。" }
163
+ $codexOwn = Join-Path $npmRoot "@petercjl\codex-own\bin\codex-own.mjs"
164
+ & $node $codexOwn install --yes --progress stdout
165
+ if ($LASTEXITCODE -ne 0) { throw "桌面安装失败,请保留错误并先诊断。" }
127
166
  ```
128
167
 
129
- 管理工具和桌面资源全部从 registry.npmjs.org 下载。Windows 桌面资源约 1 GB,具体大小随版本变化。最多并行下载 4 个分片,安装时显示分片编号、下载百分比和已接收大小,然后显示校验、解压、安装。看到“安装完成”和 ok: true 才表示完成。
130
-
131
- 若已有手动安装的 codex-own,先退出应用,将第二步的安装命令替换为以下接管命令;原安装会被备份。
168
+ 第三步:验收结果。doctor 的 bootstrap 应指向所选 Node;installed 有版本,lock 为 false,pendingTransactions 为空;runtime doctor 验证独立 Node/npm/Python。先完成 API 配置,再从 Windows 开始菜单打开 codex-own。
132
169
 
133
170
  ```powershell
134
- & $codexOwn install --adopt --yes
171
+ & $node $codexOwn doctor --json
172
+ & $node $codexOwn version
173
+ & $node $codexOwn runtime doctor
135
174
  ```
136
175
 
137
- 第三步:检查安装结果。doctor 中 installed 应有版本、lock 为 false,pendingTransactions 为空;开始菜单出现 codex-own。仅检查安装时,configured: false 是正常状态。
176
+ $node、$npmCli、$codexOwn 仅对当前窗口有效;换窗口后按前两步重新赋值。已有未受管理的独立安装可用 install --adopt --yes 接管并保留备份。程序位于 %LOCALAPPDATA%\Programs\codex-own\current,配置和对话位于 %LOCALAPPDATA%\codex-own-independent。
177
+
178
+ **API Key 文件路径必须改为你实际下载的位置,不能直接复制占位路径。** 配置前先保存任务并退出 codex-own;已有可用配置可跳过。安装验收无需 API Key。
138
179
 
139
180
  ```powershell
140
- & $codexOwn doctor
141
- & $codexOwn version
181
+ $apiKeyFile = "【填写 API Key 文件的实际完整路径】"
182
+ if (-not (Test-Path -LiteralPath $apiKeyFile -PathType Leaf)) { throw "请先填写实际 Key 文件路径。" }
183
+ Get-Process -Name "codex-own" -ErrorAction SilentlyContinue | Stop-Process -Force
184
+ if (Get-Process -Name "codex-own" -ErrorAction SilentlyContinue) { throw "应用尚未完全退出。" }
185
+ & $node $codexOwn configure --api-key-file $apiKeyFile --yes
186
+ if ($LASTEXITCODE -ne 0) { throw "API 配置失败。" }
142
187
  ```
143
188
 
144
- 程序目录:%LOCALAPPDATA%\Programs\codex-own\current。配置与对话目录:%LOCALAPPDATA%\codex-own-independent。二者独立于原版 Codex。
189
+ 日常优先使用应用内“更新”。需要手动更新时,退出应用,用同一 $node 和 $npmCli 更新 @petercjl/codex-own@latest,再执行 & $node $codexOwn update install --yes --progress stdout。保留配置与对话。
145
190
 
146
- 安装测试通过后即可停止;账号配置和模型对话属于后续使用测试。
191
+ ## 安装失败与恢复
147
192
 
148
- 可选:需要使用模型时,将只包含一行 DashScope API Key 的文件放到下载目录,命名为 codex-own-api-key.txt。配置前先退出应用,文件位置不同则修改路径;已有可用配置可跳过配置命令。
193
+ 安装器直接读取 TAR/PAX/gzip/ZIP 归档中的 Unicode 名称、类型、大小及内容 SHA256,核对解压结果,最多重试 3 次。UNPACK_INCOMPLETE/UNPACK_FAILED 提供缺失样例;持续缺失同一批 DLL 或脚本时检查安全软件拦截记录和可信来源,按设备安全策略处理,安装器不会关闭防护。正常和失败安装都尝试清理本次 staging,被占用目录会报告并保留,已校验分片缓存仍可复用。
194
+
195
+ Windows 先查看诊断及恢复计划:
149
196
 
150
197
  ```powershell
151
- & $codexOwn configure --api-key-file "$HOME\Downloads\codex-own-api-key.txt" --yes
152
- & $codexOwn launch
198
+ & $node $codexOwn doctor --json
199
+ & $node $codexOwn repair plan
153
200
  ```
154
201
 
155
- 以后更新:先退出 codex-own,再执行以下命令。更新保留独立配置和对话。
202
+ 确认 allowed: true 并同意回收失败安装的临时目录后:
156
203
 
157
204
  ```powershell
158
- npm.cmd install --global @petercjl/codex-own@latest
159
- $codexOwn = Join-Path (npm.cmd prefix --global) "codex-own.cmd"
160
- & $codexOwn update check
161
- & $codexOwn update install --yes
205
+ & $node $codexOwn repair run --yes
206
+ & $node $codexOwn doctor --json
162
207
  ```
163
208
 
164
- 下载失败:连续 30 秒无数据会重试当前分片,最多 3 次。出现 DOWNLOAD_FAILED 时检查 npm 网络连接,再执行同一安装命令;已校验分片会复用。下载期间按 Ctrl+C 可以取消。遇到 BUSY 时先确认是否有另一安装进程,保留错误信息,不直接删除锁文件。
209
+ Mac 使用 "$HOME/.local/bin/codex-own" doctor --json、repair plan 和 repair run --yes。恢复备份并移走确认死亡进程的 operation.lock,清理 inactive stage 和有所有权回执的 next 副本;保留应用、配置、密钥、对话、缓存及回滚。活动或未知锁、recovery.lock 和未完成事务需要维护者检查,不能通过删文件强行继续。
165
210
 
166
- npm 安装出现缓存 ENOENT 或缓存损坏提示时,先检查缓存,再重新安装 npm 工具:
211
+ 默认 stdout 仅为 JSON,进度走 stderr;--progress stdout 是交互终端显示模式,与 --json 互斥;--quiet 关闭进度。CLI 入口在加载管理器前验证 Node 22+。doctor.bootstrap 返回实际可执行路径、版本及架构。
167
212
 
168
- ```powershell
169
- npm.cmd cache verify
170
- npm.cmd install --global @petercjl/codex-own@latest
171
- ```
213
+ 可发送给 Agent:
214
+
215
+ > 请检查 codex-own 的安装环境和失败残留,使用 @petercjl/codex-own 的恢复计划诊断,保留配置与对话。
172
216
 
173
- 管理工具与桌面版本分别报告。API Key 和用户数据保存在 npm 包之外。更多命令见 `codex-own help`。
217
+ > 请通过 @petercjl/codex-own 修复已确认可清理的安装残留,然后重试安装。
package/bin/codex-own.mjs CHANGED
@@ -1,35 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import os from 'node:os';
5
- import {pkg,packageRoot,read,fail,paths,doctor,latest,state,install,launch,rollback,hash,rejectLinks,configure} from '../lib/manager.mjs';
6
- import {manifestFor} from '../lib/manager.mjs';
7
- import {installRuntime,runtimeDoctor,rollbackRuntime,runRuntime,toolEnv} from '../lib/tool-runtime.mjs';
8
- import {components} from '../lib/binding.mjs';
9
- const args=process.argv.slice(2),[command,sub]=args;
10
- const flag=n=>args.includes('--'+n),value=n=>{const i=args.indexOf('--'+n);return i<0?undefined:args[i+1];};
11
- const yes=()=>{if(!flag('yes'))fail('CONFIRMATION_REQUIRED','Mutating commands require --yes');};
12
- function toolSelectionGate(){const p=paths();if(!state(p))return;const file=path.join(p.target,process.platform==='darwin'?'Contents/Resources':'resources','own-updater','desktop-version.json'),meta=fs.existsSync(file)?read(file):null;if(meta?.updaterContract!==2||meta.runtimeContract!==1)fail('INCOMPATIBLE_BINDING','Upgrade the desktop to component contract 2 before independently changing its tools');}
13
- async function skill(){const source=path.join(packageRoot,'skills','codex-own');if(sub==='source')return {ok:true,source,version:pkg.version};const agent=value('agent')||'codex';if(!['codex','sealseek'].includes(agent))fail('UNSUPPORTED_AGENT',agent);const base=value('target-root')||(agent==='codex'?path.join(os.homedir(),'.codex','skills'):null);if(!base)fail('TARGET_REQUIRED','Provide the configured Agent skill root with --target-root');const dest=path.join(base,'codex-own'),marker=path.join(dest,'.codex-own-managed.json');rejectLinks(base);rejectLinks(dest);const exists=fs.existsSync(dest);const owned=exists&&fs.existsSync(marker)&&read(marker).package===pkg.name;const srcHash=hash(fs.readFileSync(path.join(source,'SKILL.md')));if(sub==='status')return {ok:true,source,destination:dest,installed:exists,managed:owned,current:owned&&hash(fs.readFileSync(path.join(dest,'SKILL.md')))===srcHash};if(!['install','update'].includes(sub))fail('INVALID_COMMAND','skill source/status/install/update');yes();if(exists&&!owned)fail('UNMANAGED_TARGET','Existing skill is not managed');let backup=null;if(exists){backup=dest+'.backup-'+Date.now();fs.cpSync(dest,backup,{recursive:true,force:false,errorOnExist:true});}fs.mkdirSync(dest,{recursive:true});const ui=path.join(dest,'agents','openai.yaml'),customUI=fs.existsSync(ui)?fs.readFileSync(ui):null;fs.cpSync(source,dest,{recursive:true});if(customUI)fs.writeFileSync(ui,customUI);fs.writeFileSync(marker,JSON.stringify({package:pkg.name,version:pkg.version,hash:srcHash}));if(hash(fs.readFileSync(path.join(dest,'SKILL.md')))!==srcHash)fail('VERIFY_FAILED','Skill copy mismatch');return {ok:true,source,destination:dest,backup,version:pkg.version};}
14
- async function main(){
15
- if(!command||command==='help'||command==='--help')return {commands:['version','capabilities','doctor','install --yes [--adopt]','configure --api-key-file <file> --yes','update check','update install --yes','runtime install|update|doctor|env|run|rollback','launch','rollback --restore-data --yes','skill source|status|install|update'],note:'Application data and private tools remain outside npm manager packages. Quit the app before desktop updates. Manager, desktop and tool runtime versions are reported separately.'};
16
- if(command==='version'||command==='--version')return {manager:pkg.version,desktop:state()?.version||null,desktopIdentity:state()?.desktop?.identity||null,runtime:toolEnv.active()?.version||null};
17
- if(command==='capabilities')return read(path.join(packageRoot,'capabilities.json'));
18
- if(command==='doctor')return {...doctor(),runtime:runtimeDoctor()};
19
- if(command==='runtime'){
20
- if(sub==='doctor')return runtimeDoctor();
21
- if(sub==='env'){const env=toolEnv.environment();if(!env)fail('RUNTIME_NOT_INSTALLED','Install private tools first');return {ok:true,contract:1,root:toolEnv.runtimeRoot(),environment:Object.fromEntries(Object.entries(env).filter(([k])=>['PATH','ZDOTDIR','BASH_ENV','CODEX_OWN_NODE','CODEX_OWN_PYTHON','CODEX_OWN_RUNTIME_VERSION','AGENT_WORKBENCH_NPM_CLI'].includes(k)||/^(NPM_CONFIG_|PYTHON|PIP_)/.test(k)))};}
22
- if(sub==='run'){const i=args.indexOf('--');process.exitCode=runRuntime(i<0?args.slice(2):args.slice(i+1));return;}
23
- if(sub==='install'||sub==='update'){yes();const m=await manifestFor(value('version')||await latest());if(toolEnv.active()?.version!==m.runtimes.version)toolSelectionGate();return installRuntime(m.runtimes,{selection:'user-update'});}
24
- if(sub==='rollback'){yes();if(toolEnv.active()?.previous)toolSelectionGate();return rollbackRuntime();}
25
- return {commands:['runtime install --yes','runtime update --yes','runtime doctor','runtime env','runtime run -- <node|npm|npx|python|python3|pip> [args]','runtime rollback --yes']};
26
- }
27
- if(command==='launch')return launch();
28
- if(command==='skill')return skill();
29
- if(command==='configure'){yes();return configure(value('api-key-file'));}
30
- if(command==='install'){yes();return install({adopt:flag('adopt'),version:value('version'),archive:value('archive'),manifest:value('manifest')?read(value('manifest')):undefined});}
31
- if(command==='update'){const v=await latest();if(sub==='check'){const m=await manifestFor(v),c=components(state(),m,paths().platform,toolEnv.active()?.version);return {ok:true,manager:pkg.version,desktop:state()?.version||null,available:v,...c,managerUpdate:pkg.version!==v,updateAvailable:c.desktopUpdate||c.runtimeUpdate};}if(sub==='install'){yes();return install({version:v});}}
32
- if(command==='rollback'){yes();return rollback({restoreData:flag('restore-data')});}
33
- fail('INVALID_COMMAND','See codex-own help');
34
- }
35
- try{const result=await main();if(result!==undefined){console.log(JSON.stringify(result,null,2));if(result.ok===false)process.exitCode=1;}}catch(e){console.error(JSON.stringify({ok:false,code:e.code||'OPERATION_FAILED',message:e.message}));process.exitCode=1;}
2
+ // Check before importing modules that require supported Node.
3
+ if(Number(process.versions.node.split('.')[0])<22){
4
+ console.error(JSON.stringify({ok:false,code:'NODE_VERSION_UNSUPPORTED',message:'codex-own 需要 Node.js 22 或以上,请核对入口实际使用的 Node。',nodeVersion:process.version,executable:process.execPath}));process.exitCode=1;
5
+ }else await import('../lib/cli.mjs');
package/capabilities.json CHANGED
@@ -6,6 +6,8 @@
6
6
  "configure",
7
7
  "update check",
8
8
  "update install",
9
+ "cleanup plan",
10
+ "cleanup run",
9
11
  "launch",
10
12
  "rollback",
11
13
  "doctor",
@@ -18,7 +20,9 @@
18
20
  "runtime doctor",
19
21
  "runtime env",
20
22
  "runtime run",
21
- "runtime rollback"
23
+ "runtime rollback",
24
+ "repair plan",
25
+ "repair run"
22
26
  ],
23
27
  "platforms": {
24
28
  "darwin-arm64": "implemented",
@@ -27,6 +31,37 @@
27
31
  "dataPolicy": "independent external directories; preserve credentials and conversations",
28
32
  "updatePolicy": "desktop checks npm latest at startup and every six hours; explicit download and restart/install; pinned verified build; backup before switch; CLI remains available for recovery",
29
33
  "authentication": "npm install does not grant model access; existing independent credentials remain local",
34
+ "cleanupPolicy": {
35
+ "contract": 1,
36
+ "trigger": "owned main interface loaded and alive for 30 seconds; installed version and component identity must match",
37
+ "defaultCacheLimitMiB": 2048,
38
+ "rollbackCopies": 1,
39
+ "reclaims": [
40
+ "applied desktop archives",
41
+ "completed inactive helpers",
42
+ "superseded complete app/data backup pairs",
43
+ "least recently used unpinned blocks"
44
+ ],
45
+ "preserves": [
46
+ "live app and user data",
47
+ "private tool versions",
48
+ "latest complete rollback",
49
+ "pending/failed/unknown updates",
50
+ "active processes",
51
+ "manual recovery backups"
52
+ ],
53
+ "temporaryCacheExcess": "pending or unknown receipts pin required blocks; locked files remain available for inspection",
54
+ "diagnosticRecordsPerCategory": 20,
55
+ "preview": "cleanup plan",
56
+ "execute": "cleanup run --yes [--cache-limit-mib <0..16384>]",
57
+ "results": [
58
+ "reclaimedBytes",
59
+ "rollbackRetained",
60
+ "warnings",
61
+ "cacheLimitExceeded",
62
+ "report"
63
+ ]
64
+ },
30
65
  "windowsAgentShell": {
31
66
  "contract": 1,
32
67
  "preferred": "verified system Git Bash; explicit exec_command shell selection",
@@ -103,5 +138,25 @@
103
138
  },
104
139
  "sameDesktop": "zero desktop download and activation; app may remain open",
105
140
  "legacy": "one bridge desktop upgrade required before ordinary manager-only reuse"
141
+ },
142
+ "installationRecovery": {
143
+ "contract": 1,
144
+ "bootstrapMinimumNode": 22,
145
+ "diagnostics": [
146
+ "bootstrap",
147
+ "lockDetails",
148
+ "orphanStages",
149
+ "recoveryLock"
150
+ ],
151
+ "extractAttempts": 3,
152
+ "inventory": "direct Unicode TAR/PAX/gzip/ZIP inventory; entry types, sizes and SHA256; path/link safety; tar errors retained",
153
+ "failedStaging": "finally cleanup; occupied files reported",
154
+ "recovery": "explicit --yes; refuse live/unknown locks, active apps and incomplete transactions",
155
+ "progress": {
156
+ "default": "stderr; stdout JSON",
157
+ "human": "--progress stdout",
158
+ "quiet": "--quiet"
159
+ },
160
+ "runtimeSelectionBackupLimit": 20
106
161
  }
107
162
  }
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import {list} from 'tar';
5
+ import AdmZip from 'adm-zip';
6
+
7
+ const unsafe=message=>Object.assign(new Error(message),{code:'UNSAFE_ARCHIVE'});
8
+ const digest=data=>crypto.createHash('sha256').update(data).digest('hex');
9
+ export function validateArchiveName(name){
10
+ if(typeof name!=='string'||!name||/^[a-zA-Z]:|^\/|^\\|[\0\r\n]/.test(name)||name.replaceAll('\\','/').split('/').includes('..'))throw unsafe('Unsafe archive entry');
11
+ }
12
+ // Parse archive bytes rather than Windows tar's locale-dependent console text.
13
+ // Inventory hashes also detect truncated or silently modified extracted files.
14
+ export function archiveInventory(file){
15
+ const fd=fs.openSync(file,'r'),magic=Buffer.alloc(4);try{fs.readSync(fd,magic,0,4,0);}finally{fs.closeSync(fd);}
16
+ const result=[];let total=0;
17
+ function add(entry){validateArchiveName(entry.name);if(!Number.isSafeInteger(entry.size)||entry.size<0||entry.size>8*1024**3||result.length>=200000||(total+=entry.size)>32*1024**3)throw unsafe('Excessive archive inventory');result.push(entry);return entry;}
18
+ if(magic.readUInt32LE(0)===0x04034b50||magic.readUInt32LE(0)===0x06054b50){
19
+ if(fs.statSync(file).size>128*1024**2)throw unsafe('Runtime ZIP exceeds supported size');
20
+ for(const e of new AdmZip(file).getEntries()){
21
+ const link=((e.header.attr>>>16)&0xf000)===0xa000,type=e.isDirectory?'Directory':link?'SymbolicLink':'File';
22
+ const item=add({name:e.entryName,type,size:e.header.size});
23
+ if(type==='File')item.sha256=digest(e.getData());else if(link)item.link=e.getData().toString('utf8');
24
+ }
25
+ }else{
26
+ list({file,sync:true,strict:true,preservePaths:true,onReadEntry:e=>{
27
+ if(['ExtendedHeader','GlobalExtendedHeader','NextFileHasLongPath','NextFileHasLongLinkpath'].includes(e.type))return;
28
+ if(!['File','OldFile','ContiguousFile','Directory','SymbolicLink','Link'].includes(e.type))throw unsafe('Unsupported archive entry type');
29
+ const item=add({name:e.path,type:e.type,size:e.size,link:e.linkpath});
30
+ if(['File','OldFile','ContiguousFile'].includes(e.type)){const hash=crypto.createHash('sha256');e.on('data',b=>hash.update(b));e.on('end',()=>{item.sha256=hash.digest('hex');});}
31
+ }});
32
+ }
33
+ if(!result.length)throw unsafe('Empty archive');
34
+ for(const e of result){if(['File','OldFile','ContiguousFile'].includes(e.type)&&!e.sha256)throw unsafe('Incomplete archive content');if(e.link){if(/^[a-zA-Z]:|^\/|^\\|[\0\r\n]/.test(e.link))throw unsafe('Unsafe archive link');const base=e.type==='Link'?'':path.posix.dirname(e.name.replaceAll('\\','/'));const normalized=path.posix.normalize(path.posix.join(base,e.link.replaceAll('\\','/')));if(normalized==='..'||normalized.startsWith('../'))throw unsafe('Escaping archive link');}}
35
+ return result;
36
+ }
37
+ export function extractedHash(file){
38
+ const hash=crypto.createHash('sha256'),buffer=Buffer.allocUnsafe(256*1024),fd=fs.openSync(file,'r');
39
+ try{let count;while((count=fs.readSync(fd,buffer,0,buffer.length,null))>0)hash.update(buffer.subarray(0,count));return hash.digest('hex');}finally{fs.closeSync(fd);}
40
+ }
@@ -0,0 +1,177 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import {execFileSync} from 'node:child_process';
5
+ import {paths,state,locked,rejectLinks,exclusive,appIdentity,validateManifest,fail} from './manager.mjs';
6
+
7
+ export const CLEANUP_POLICY=Object.freeze({contract:1,cacheLimitBytes:2*1024**3,rollbackCopies:1,reportLimit:20,healthyStartupSeconds:30});
8
+ const PACKAGE='@petercjl/codex-own',UUID='[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}';
9
+ const json=file=>{rejectLinks(file);try{return JSON.parse(fs.readFileSync(file,'utf8'));}catch{return null;}};
10
+ const norm=file=>process.platform==='win32'?path.resolve(file).toLowerCase():path.resolve(file);
11
+ const same=(a,b)=>typeof a==='string'&&typeof b==='string'&&norm(a)===norm(b);
12
+ const contains=(base,file)=>same(base,file)||norm(file).startsWith(norm(base)+path.sep);
13
+ const version=v=>/^\d+\.\d+\.\d+$/.test(v||'');
14
+ function notNewer(a,b){if(!version(a)||!version(b))return false;const aa=a.split('.').map(Number),bb=b.split('.').map(Number);for(let i=0;i<3;i++){if(aa[i]!==bb[i])return aa[i]<bb[i];}return true;}
15
+ function exists(file){return fs.existsSync(file);}
16
+ function regular(file){rejectLinks(file);return exists(file)&&fs.lstatSync(file).isFile();}
17
+ function names(dir){rejectLinks(dir);return exists(dir)?fs.readdirSync(dir):[];}
18
+ function bytes(file){const s=fs.lstatSync(file);if(s.isSymbolicLink())return s.size;if(s.isDirectory())return fs.readdirSync(file).reduce((n,name)=>n+bytes(path.join(file,name)),0);return s.size;}
19
+ function fingerprint(file){const s=fs.lstatSync(file);return {size:s.size,mtimeMs:s.mtimeMs,ino:s.ino};}
20
+ function unchanged(file,expected){try{const s=fingerprint(file);return s.size===expected.size&&s.mtimeMs===expected.mtimeMs&&s.ino===expected.ino;}catch{return false;}}
21
+ function currentInstall(p,identify){
22
+ rejectLinks(p.manager);rejectLinks(p.target);
23
+ regular(path.join(p.manager,'state.json'));
24
+ const s=state(p),meta=path.join(p.target,p.platform.startsWith('darwin-')?'Contents/Resources':'resources','own-updater','desktop-version.json');
25
+ if(s?.identity!=='local.codex-own'||!same(s.target,p.target)||!version(s.version)||!s.desktop||!/^[a-f0-9]{64}$/.test(s.desktop.identity)||s.desktop.identity!==s.releaseManifest?.platforms?.[p.platform]?.sha256||identify(p.target)!=='local.codex-own'||json(meta)?.version!==s.version)
26
+ fail('UNMANAGED_TARGET','Cleanup requires the matching managed desktop and component identity');
27
+ return s;
28
+ }
29
+ function pendingTransactions(p){return names(p.manager).filter(n=>new RegExp('^transaction-'+UUID+'\\.json$').test(n)&&json(path.join(p.manager,n.replace('.json','.complete.json')))?.ok!==true);}
30
+ function healthFor(p,s){
31
+ const root=path.join(p.manager,'health');
32
+ return names(root).filter(n=>new RegExp('^startup-'+UUID+'\\.json$').test(n)).map(n=>({file:path.join(root,n),value:json(path.join(root,n))})).filter(({value:h})=>h?.schema===1&&h.package===PACKAGE&&h.version===s.version&&h.desktopIdentity===s.desktop.identity&&same(h.target,p.target)).sort((a,b)=>String(b.value.readyAt).localeCompare(String(a.value.readyAt)))[0]||null;
33
+ }
34
+
35
+ // Only return matched owned roots, never persist unrelated process arguments.
36
+ export function activeCleanupRoots(roots){
37
+ if(!roots.length)return [];
38
+ if(process.platform==='win32'){
39
+ const quoted=roots.map(r=>"'"+r.replaceAll("'","''")+"'").join(',');
40
+ const ps=`$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);$roots=@(${quoted});@(Get-CimInstance Win32_Process | ForEach-Object { $f=$_.ExecutablePath;$c=$_.CommandLine; foreach($r in $roots){$prefix=$r+'\\';if(($f -and ($f.Equals($r,[StringComparison]::OrdinalIgnoreCase) -or $f.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase))) -or ($c -and ($c.IndexOf($prefix,[StringComparison]::OrdinalIgnoreCase) -ge 0))){$r}} }) | ConvertTo-Json -Compress`;
41
+ const text=execFileSync('powershell.exe',['-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(ps,'utf16le').toString('base64')],{encoding:'utf8',windowsHide:true,timeout:30000});
42
+ const result=text.trim()?JSON.parse(text):[];return [...new Set(Array.isArray(result)?result:[result])];
43
+ }
44
+ const lines=execFileSync('ps',['-axo','command='],{encoding:'utf8',timeout:10000}).split('\n');
45
+ return roots.filter(r=>lines.some(line=>line.includes(r+path.sep)));
46
+ }
47
+ function manifestParts(m,platform){
48
+ validateManifest(m,platform);
49
+ return [m.platforms[platform],...Object.values(m.runtimes?.platforms?.[platform]||{})].flatMap(a=>a.parts||[]).map(a=>a.sha256);
50
+ }
51
+ function helperResult(dir,p){
52
+ const runtime=path.join(dir,'runtime'),meta=json(path.join(runtime,'desktop-version.json')),ready=json(path.join(runtime,'handoff-ready.json'));
53
+ if(meta?.source!=='https://registry.npmjs.org/@petercjl%2fcodex-own/latest'||meta.updaterContract!==2||!ready?.ready)return null;
54
+ if(ready.schema===2){
55
+ if(!Number.isSafeInteger(ready.pid)||ready.pid<1||ready.current!==meta.version||!new RegExp('^update-result-'+UUID+'\\.json$').test(path.basename(ready.resultFile||''))||!same(path.dirname(ready.resultFile),p.manager))return null;
56
+ try{process.kill(ready.pid,0);return null;}catch(e){if(e.code!=='ESRCH')return null;}
57
+ if(typeof ready.receipt!=='string'||path.basename(ready.receipt)!=='receipt.json'||!/^update-[a-zA-Z0-9]+$/.test(path.basename(path.dirname(ready.receipt)))||!same(path.dirname(path.dirname(ready.receipt)),path.join(p.manager,'prepared')))return null;
58
+ const result=json(ready.resultFile),receipt=json(ready.receipt),applied=json(ready.receipt+'.applied.json');
59
+ return result?.version===receipt?.version&&applied?.ok===true&&applied.version===receipt?.version?result:null;
60
+ }
61
+ // Legacy helpers have no PID/result receipt. A completed log plus an owned
62
+ // executable-process scan is required before reclaiming their copied runtime.
63
+ const log=path.join(dir,'install.log');if(!regular(log))return null;
64
+ const fd=fs.openSync(log,'r'),size=fs.fstatSync(fd).size,b=Buffer.alloc(Math.min(size,131072));
65
+ try{fs.readSync(fd,b,0,b.length,Math.max(0,size-b.length));}finally{fs.closeSync(fd);}
66
+ for(const line of b.toString('utf8').split('\n').reverse()){try{const r=JSON.parse(line).result;if(r)return r;}catch{}}
67
+ return null;
68
+ }
69
+
70
+ export function planCleanup({p=paths(),cacheLimitBytes=CLEANUP_POLICY.cacheLimitBytes,identify=appIdentity,processRoots=activeCleanupRoots,lockHeld=false}={}){
71
+ if(!Number.isSafeInteger(cacheLimitBytes)||cacheLimitBytes<0||cacheLimitBytes>16*1024**3)fail('INVALID_ARGUMENT','Cache limit must be between 0 and 16 GiB');
72
+ const result={ok:true,contract:1,version:null,cacheLimitBytes,actions:[],preserved:[],plannedBytes:0};
73
+ const defer=reason=>({...result,actions:[],plannedBytes:0,deferred:true,reason});
74
+ if(!lockHeld&&exists(path.join(p.manager,'operation.lock')))return defer('BUSY');
75
+ let s;try{s=currentInstall(p,identify);}catch(e){if(e.code==='UNSAFE_PATH')throw e;return defer('UNMANAGED_TARGET');}result.version=s.version;
76
+ if(pendingTransactions(p).length)return defer('INCOMPLETE_TRANSACTION');
77
+ const health=healthFor(p,s);if(!health)return defer('HEALTH_NOT_CONFIRMED');
78
+ if(s.previous){
79
+ const id=typeof s.previous.app==='string'?path.basename(s.previous.app).match(new RegExp('^\\.codex-own-backup-('+UUID+')$'))?.[1]:null;
80
+ if(!id||!same(s.previous.app,path.join(path.dirname(p.target),'.codex-own-backup-'+id))||!same(s.previous.data,path.join(p.manager,'backups',id,'data')))return defer('UNKNOWN_ROLLBACK');
81
+ rejectLinks(s.previous.app);rejectLinks(s.previous.data);
82
+ const j=json(path.join(p.manager,'transaction-'+id+'.json')),done=json(path.join(p.manager,'transaction-'+id+'.complete.json'));
83
+ if(done?.ok!==true||j?.version!==s.version||!same(j?.target,p.target)||!same(j?.old,s.previous.app)||!same(j?.backup,path.dirname(s.previous.data)))return defer('ROLLBACK_INCOMPLETE');
84
+ if(!exists(s.previous.app)||!exists(s.previous.data)||identify(s.previous.app)!=='local.codex-own')return defer('ROLLBACK_INCOMPLETE');
85
+ }
86
+ result.rollbackRetained=!!s.previous;
87
+ const protectedPaths=[p.target,p.core,p.desktop,s.previous?.app,s.previous?.data].filter(Boolean);
88
+ const protects=f=>protectedPaths.some(q=>contains(f,q)||contains(q,f));
89
+ const action=(kind,files,reason)=>{for(const f of files){rejectLinks(f);if(protects(f))fail('UNSAFE_PATH','Cleanup candidate intersects protected application data');}result.actions.push({kind,files,reason,bytes:files.reduce((n,f)=>n+bytes(f),0),fingerprints:files.map(fingerprint)});};
90
+ const pinned=new Set();let pinAll=false;
91
+ const prepared=path.join(p.manager,'prepared');
92
+ for(const name of names(prepared)){
93
+ if(!/^update-[a-zA-Z0-9]+$/.test(name))continue;
94
+ const dir=path.join(prepared,name),receipt=path.join(dir,'receipt.json');rejectLinks(dir);
95
+ const r=regular(receipt)?json(receipt):null,applied=regular(receipt+'.applied.json')?json(receipt+'.applied.json'):null;
96
+ let valid=false;
97
+ try{valid=!!r&&[1,2].includes(r.schema)&&r.platform===p.platform&&r.version===r.manifest?.version&&version(r.version);if(valid)manifestParts(r.manifest,p.platform);}catch{valid=false;}
98
+ const completed=valid&&applied?.ok===true&&applied.version===r.version&&notNewer(r.version,s.version);
99
+ if(!completed){
100
+ if(valid)for(const hash of manifestParts(r.manifest,p.platform))pinned.add(hash);else pinAll=true;
101
+ result.preserved.push({path:dir,reason:valid?'PENDING_OR_FAILED_UPDATE':'UNKNOWN_UPDATE_RECEIPT'});continue;
102
+ }
103
+ const archive=path.join(dir,'desktop.tar.gz');if(regular(archive))action('applied-archive',[archive],'APPLIED_UPDATE_AND_HEALTHY_DESKTOP');
104
+ }
105
+ const backups=[];
106
+ for(const name of names(p.manager)){
107
+ const match=name.match(new RegExp('^transaction-('+UUID+')\\.json$'));if(!match)continue;
108
+ const j=json(path.join(p.manager,name)),done=json(path.join(p.manager,name.replace('.json','.complete.json'))),id=match[1];
109
+ const old=path.join(path.dirname(p.target),'.codex-own-backup-'+id),backup=path.join(p.manager,'backups',id);
110
+ if(done?.ok!==true||!notNewer(j?.version,s.version)||!same(j?.target,p.target)||!same(j?.old,old)||!same(j?.backup,backup))continue;
111
+ const files=[old,backup].filter(exists);if(!files.length)continue;
112
+ if(files.some(protects)){result.preserved.push({path:backup,reason:'LATEST_COMPLETE_ROLLBACK'});continue;}
113
+ for(const f of files)rejectLinks(f);
114
+ let owned=true;try{if(exists(old))owned=identify(old)==='local.codex-own';}catch{owned=false;}
115
+ if(!owned){result.preserved.push({path:old,reason:'UNKNOWN_APP_IDENTITY'});continue;}
116
+ backups.push({old,files});
117
+ }
118
+ const handoffs=[];const helperBase=path.join(p.manager,'handoffs');
119
+ for(const name of names(helperBase)){
120
+ if(!/^apply-[a-zA-Z0-9]+$/.test(name))continue;
121
+ const dir=path.join(helperBase,name);rejectLinks(dir);const r=helperResult(dir,p);
122
+ if(r?.ok===true&&same(r.target,p.target)&&notNewer(r.version,s.version))handoffs.push(dir);
123
+ else result.preserved.push({path:dir,reason:'ACTIVE_FAILED_OR_UNKNOWN_HELPER'});
124
+ }
125
+ let active;try{active=processRoots([...backups.map(b=>b.old),...handoffs]);}catch{return defer('PROCESS_INSPECTION_UNAVAILABLE');}
126
+ for(const b of backups){if(active.some(a=>same(a,b.old))){result.preserved.push({path:b.old,reason:'ACTIVE_OLD_APP'});continue;}action('old-rollback',b.files,'SUPERSEDED_COMPLETE_TRANSACTION');}
127
+ for(const dir of handoffs){if(active.some(a=>same(a,dir))){result.preserved.push({path:dir,reason:'ACTIVE_UPDATE_HELPER'});continue;}action('completed-helper',[dir],'COMPLETED_HELPER_AND_HEALTHY_DESKTOP');}
128
+ const cache=path.join(p.manager,'npm-assets'),blocks=[];
129
+ for(const name of names(cache)){if(!/^[a-f0-9]{64}\.bin$/.test(name))continue;const f=path.join(cache,name);if(!regular(f))continue;const st=fs.statSync(f);blocks.push({file:f,hash:name.slice(0,64),bytes:st.size,used:st.mtimeMs});}
130
+ let total=blocks.reduce((n,b)=>n+b.bytes,0);result.cacheBytesBefore=total;
131
+ for(const b of blocks.sort((a,b)=>a.used-b.used||a.hash.localeCompare(b.hash))){
132
+ if(total<=cacheLimitBytes)break;if(pinAll||pinned.has(b.hash))continue;
133
+ action('cache-block',[b.file],'LEAST_RECENTLY_USED_UNPINNED_BLOCK');total-=b.bytes;
134
+ }
135
+ result.cacheBytesAfter=total;result.cacheLimitExceeded=total>cacheLimitBytes;result.cacheProtection=pinAll?'UNKNOWN_RECEIPT':pinned.size?'PENDING_UPDATE':null;
136
+ for(const [folder,prefix,keep] of [['cleanup-reports','report-',CLEANUP_POLICY.reportLimit-1],['cleanup-intents','intent-',CLEANUP_POLICY.reportLimit-1],['health','startup-',CLEANUP_POLICY.reportLimit]]){
137
+ const dir=path.join(p.manager,folder),records=names(dir).filter(n=>new RegExp('^'+prefix+UUID+'\\.json$').test(n)).map(n=>path.join(dir,n)).filter(regular).filter(f=>{const r=json(f);return r?.schema===1&&r.package===PACKAGE;}).sort((a,b)=>fs.statSync(b).mtimeMs-fs.statSync(a).mtimeMs);
138
+ for(const file of records.slice(keep))if(!same(file,health.file))action('old-report',[file],'BOUNDED_DIAGNOSTIC_HISTORY');
139
+ }
140
+ result.plannedBytes=result.actions.reduce((n,a)=>n+a.bytes,0);return result;
141
+ }
142
+
143
+ async function performCleanup(options){
144
+ const p=options.p||paths(),plan=planCleanup({...options,p,lockHeld:true});
145
+ if(plan.deferred)return {...plan,reclaimedBytes:0};
146
+ const intents=path.join(p.manager,'cleanup-intents');rejectLinks(intents);fs.mkdirSync(intents,{recursive:true,mode:0o700});
147
+ exclusive(path.join(intents,'intent-'+crypto.randomUUID()+'.json'),{schema:1,package:PACKAGE,version:plan.version,target:p.target,plannedAt:new Date().toISOString(),actions:plan.actions});
148
+ const removed=[],warnings=[];let reclaimedBytes=0;
149
+ for(const a of plan.actions){
150
+ for(let i=0;i<a.files.length;i++){
151
+ const file=a.files[i];let before;
152
+ try{
153
+ rejectLinks(file);if(!unchanged(file,a.fingerprints[i])){warnings.push({path:file,code:'CANDIDATE_CHANGED'});break;}
154
+ before=bytes(file);await fs.promises.rm(file,{recursive:true,maxRetries:2,retryDelay:100});reclaimedBytes+=before;removed.push({kind:a.kind,path:file,bytes:before});
155
+ }catch(e){let remaining;try{remaining=bytes(file);}catch{}if(Number.isSafeInteger(before)&&Number.isSafeInteger(remaining))reclaimedBytes+=Math.max(0,before-remaining);warnings.push({path:file,code:e.code||'CLEANUP_FAILED'});break;}
156
+ }
157
+ }
158
+ const cache=path.join(p.manager,'npm-assets'),cacheBytesAfter=names(cache).filter(n=>/^[a-f0-9]{64}\.bin$/.test(n)).map(n=>path.join(cache,n)).filter(regular).reduce((n,f)=>n+fs.statSync(f).size,0);
159
+ const result={schema:1,package:PACKAGE,ok:true,version:plan.version,completedAt:new Date().toISOString(),reclaimedBytes,removed,warnings,cacheLimitBytes:plan.cacheLimitBytes,cacheBytesAfter,cacheLimitExceeded:cacheBytesAfter>plan.cacheLimitBytes,cacheProtection:plan.cacheProtection,rollbackCopies:CLEANUP_POLICY.rollbackCopies,rollbackRetained:plan.rollbackRetained};
160
+ const dir=path.join(p.manager,'cleanup-reports');rejectLinks(dir);fs.mkdirSync(dir,{recursive:true,mode:0o700});
161
+ const report=path.join(dir,'report-'+crypto.randomUUID()+'.json');exclusive(report,result);
162
+ return {...result,report};
163
+ }
164
+ export async function runCleanup(options={}){const p=options.p||paths();return locked(p,()=>performCleanup({...options,p}));}
165
+
166
+ // Called only by the desktop worker after the controller's main-window health
167
+ // gate. A matching installed component is mandatory; spawning an app is not proof.
168
+ export async function cleanupAfterHealthyStartup(current,options={}){
169
+ const p=options.p||paths(),identify=options.identify||appIdentity;
170
+ return locked(p,()=>{
171
+ const s=currentInstall(p,identify);if(s.version!==current)fail('INSTALLATION_CHANGED','The running desktop differs from its managed installation');
172
+ if(pendingTransactions(p).length)return {ok:true,deferred:true,reason:'INCOMPLETE_TRANSACTION',reclaimedBytes:0};
173
+ const dir=path.join(p.manager,'health');rejectLinks(dir);fs.mkdirSync(dir,{recursive:true,mode:0o700});
174
+ exclusive(path.join(dir,'startup-'+crypto.randomUUID()+'.json'),{schema:1,package:PACKAGE,version:current,target:p.target,desktopIdentity:s.desktop.identity,readyAt:new Date().toISOString(),evidence:'main-window-loaded-and-alive-30s'});
175
+ return performCleanup({...options,p});
176
+ });
177
+ }
package/lib/cli.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import {pkg,packageRoot,read,fail,paths,doctor,latest,state,install,launch,rollback,hash,rejectLinks,configure} from '../lib/manager.mjs';
5
+ import {manifestFor} from '../lib/manager.mjs';
6
+ import {installRuntime,runtimeDoctor,rollbackRuntime,runRuntime,toolEnv} from '../lib/tool-runtime.mjs';
7
+ import {components} from '../lib/binding.mjs';
8
+ import {planCleanup,runCleanup} from '../lib/cleanup.mjs';
9
+ import {repairPlan,repairRun} from './maintenance.mjs';
10
+ import {setProgressMode} from './network.mjs';
11
+ const args=process.argv.slice(2),[command,sub]=args;
12
+ const flag=n=>args.includes('--'+n),value=n=>{const i=args.indexOf('--'+n);return i<0?undefined:args[i+1];};
13
+ const yes=()=>{if(!flag('yes'))fail('CONFIRMATION_REQUIRED','Mutating commands require --yes');};
14
+ function toolSelectionGate(){const p=paths();if(!state(p))return;const file=path.join(p.target,process.platform==='darwin'?'Contents/Resources':'resources','own-updater','desktop-version.json'),meta=fs.existsSync(file)?read(file):null;if(meta?.updaterContract!==2||meta.runtimeContract!==1)fail('INCOMPATIBLE_BINDING','Upgrade the desktop to component contract 2 before independently changing its tools');}
15
+ async function skill(){const source=path.join(packageRoot,'skills','codex-own');if(sub==='source')return {ok:true,source,version:pkg.version};const agent=value('agent')||'codex';if(!['codex','sealseek'].includes(agent))fail('UNSUPPORTED_AGENT',agent);const base=value('target-root')||(agent==='codex'?path.join(os.homedir(),'.codex','skills'):null);if(!base)fail('TARGET_REQUIRED','Provide the configured Agent skill root with --target-root');const dest=path.join(base,'codex-own'),marker=path.join(dest,'.codex-own-managed.json');rejectLinks(base);rejectLinks(dest);const exists=fs.existsSync(dest);const owned=exists&&fs.existsSync(marker)&&read(marker).package===pkg.name;const srcHash=hash(fs.readFileSync(path.join(source,'SKILL.md')));if(sub==='status')return {ok:true,source,destination:dest,installed:exists,managed:owned,current:owned&&hash(fs.readFileSync(path.join(dest,'SKILL.md')))===srcHash};if(!['install','update'].includes(sub))fail('INVALID_COMMAND','skill source/status/install/update');yes();if(exists&&!owned)fail('UNMANAGED_TARGET','Existing skill is not managed');let backup=null;if(exists){backup=dest+'.backup-'+Date.now();fs.cpSync(dest,backup,{recursive:true,force:false,errorOnExist:true});}fs.mkdirSync(dest,{recursive:true});const ui=path.join(dest,'agents','openai.yaml'),customUI=fs.existsSync(ui)?fs.readFileSync(ui):null;fs.cpSync(source,dest,{recursive:true});if(customUI)fs.writeFileSync(ui,customUI);fs.writeFileSync(marker,JSON.stringify({package:pkg.name,version:pkg.version,hash:srcHash}));if(hash(fs.readFileSync(path.join(dest,'SKILL.md')))!==srcHash)fail('VERIFY_FAILED','Skill copy mismatch');return {ok:true,source,destination:dest,backup,version:pkg.version};}
16
+ async function main(){
17
+ if(flag('progress')&&!value('progress'))fail('INVALID_ARGUMENT','Provide --progress stderr|stdout|quiet');
18
+ if(flag('json')&&value('progress')==='stdout')fail('INVALID_ARGUMENT','JSON mode requires progress on stderr or quiet');
19
+ setProgressMode(flag('quiet')?'quiet':value('progress')||'stderr');
20
+ if(command==='repair'){if(sub==='plan')return repairPlan();if(sub==='run'){yes();return repairRun();}fail('INVALID_COMMAND','repair plan or repair run --yes');}
21
+ if(!command||command==='help'||command==='--help')return {commands:['version','capabilities','doctor','repair plan','repair run --yes','install --yes [--adopt]','configure --api-key-file <file> --yes','update check','update install --yes','cleanup plan [--cache-limit-mib <0..16384>]','cleanup run --yes [--cache-limit-mib <0..16384>]','runtime install|update|doctor|env|run|rollback','launch','rollback --restore-data --yes','skill source|status|install|update'],note:'Node >=22; doctor.bootstrap reports the actual executable. Default stdout is JSON; --progress stdout is for human terminals; --quiet disables progress. Repair preserves application/user data and refuses active or incomplete transactions.'};
22
+ if(command==='version'||command==='--version')return {manager:pkg.version,desktop:state()?.version||null,desktopIdentity:state()?.desktop?.identity||null,runtime:toolEnv.active()?.version||null};
23
+ if(command==='capabilities')return read(path.join(packageRoot,'capabilities.json'));
24
+ if(command==='doctor')return {...doctor(),runtime:runtimeDoctor()};
25
+ if(command==='cleanup'){
26
+ const limit=value('cache-limit-mib'),options={};if(flag('cache-limit-mib')&&limit===undefined)fail('INVALID_ARGUMENT','Provide --cache-limit-mib <0..16384>');if(limit!==undefined){if(!/^\d+$/.test(limit)||Number(limit)>16384)fail('INVALID_ARGUMENT','Cache limit must be an integer from 0 to 16384 MiB');options.cacheLimitBytes=Number(limit)*1048576;}
27
+ if(sub==='plan')return planCleanup(options);
28
+ if(sub==='run'){yes();return runCleanup(options);}
29
+ fail('INVALID_COMMAND','cleanup plan or cleanup run --yes');
30
+ }
31
+ if(command==='runtime'){
32
+ if(sub==='doctor')return runtimeDoctor();
33
+ if(sub==='env'){const env=toolEnv.environment();if(!env)fail('RUNTIME_NOT_INSTALLED','Install private tools first');return {ok:true,contract:1,root:toolEnv.runtimeRoot(),environment:Object.fromEntries(Object.entries(env).filter(([k])=>['PATH','ZDOTDIR','BASH_ENV','CODEX_OWN_NODE','CODEX_OWN_PYTHON','CODEX_OWN_RUNTIME_VERSION','AGENT_WORKBENCH_NPM_CLI'].includes(k)||/^(NPM_CONFIG_|PYTHON|PIP_)/.test(k)))};}
34
+ if(sub==='run'){const i=args.indexOf('--');process.exitCode=runRuntime(i<0?args.slice(2):args.slice(i+1));return;}
35
+ if(sub==='install'||sub==='update'){yes();const m=await manifestFor(value('version')||await latest());if(toolEnv.active()?.version!==m.runtimes.version)toolSelectionGate();return installRuntime(m.runtimes,{selection:'user-update'});}
36
+ if(sub==='rollback'){yes();if(toolEnv.active()?.previous)toolSelectionGate();return rollbackRuntime();}
37
+ return {commands:['runtime install --yes','runtime update --yes','runtime doctor','runtime env','runtime run -- <node|npm|npx|python|python3|pip> [args]','runtime rollback --yes']};
38
+ }
39
+ if(command==='launch')return launch();
40
+ if(command==='skill')return skill();
41
+ if(command==='configure'){yes();return configure(value('api-key-file'));}
42
+ if(command==='install'){yes();return install({adopt:flag('adopt'),version:value('version'),archive:value('archive'),manifest:value('manifest')?read(value('manifest')):undefined});}
43
+ if(command==='update'){const v=await latest();if(sub==='check'){const m=await manifestFor(v),c=components(state(),m,paths().platform,toolEnv.active()?.version);return {ok:true,manager:pkg.version,desktop:state()?.version||null,available:v,...c,managerUpdate:pkg.version!==v,updateAvailable:c.desktopUpdate||c.runtimeUpdate};}if(sub==='install'){yes();return install({version:v});}}
44
+ if(command==='rollback'){yes();return rollback({restoreData:flag('restore-data')});}
45
+ fail('INVALID_COMMAND','See codex-own help');
46
+ }
47
+ try{const result=await main();if(result!==undefined){console.log(JSON.stringify(result,null,2));if(result.ok===false)process.exitCode=1;}}catch(e){console.error(JSON.stringify({ok:false,code:e.code||'OPERATION_FAILED',message:e.message,...Object.fromEntries(['missing','missingCount','stage','partialTarget','tarExit'].filter(k=>e[k]!==undefined).map(k=>[k,e[k]]))}));process.exitCode=1;}