@petercjl/codex-own 0.6.4 → 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
@@ -101,11 +101,23 @@ npm install --global --prefix "$HOME/.local" @petercjl/codex-own@latest
101
101
  "$HOME/.local/bin/codex-own" version
102
102
  ```
103
103
 
104
- 可选:需要使用模型时,再配置 API Key 并启动。将只包含一行 DashScope API Key 的文件放到下载目录,命名为 codex-own-api-key.txt;文件在其他位置时修改路径。配置前退出应用,已有可用配置可跳过配置命令。
104
+ **API Key 文件路径必须改为你实际下载的位置,不能直接复制占位路径。** 配置前先保存任务;命令会关闭 codex-own,完成配置后从 Mac“应用程序”打开 codex-own。
105
105
 
106
106
  ```bash
107
- "$HOME/.local/bin/codex-own" configure --api-key-file "$HOME/Downloads/codex-own-api-key.txt" --yes
108
- "$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
109
121
  ```
110
122
 
111
123
  以后更新:先退出 codex-own,再更新 npm 管理工具和桌面程序。配置与对话保存在 ~/Library/Application Support/codex-own-independent。
@@ -118,70 +130,88 @@ npm install --global --prefix "$HOME/.local" @petercjl/codex-own@latest
118
130
 
119
131
  ## 在 Windows 上通过 PowerShell 安装和测试
120
132
 
121
- 适用于运行 x64 Node.js 的 Windows。先从 Node.js 官网安装 Windows x64 的 LTS 版本(要求 Node.js 22 或以上,包含 npm),再重新打开普通用户 PowerShell。建议预留 10 GB 空间,用于下载、解压和备份。
133
+ 适用于 Windows x64,使用包含 npm 的官方 Node.js 22+ x64 发行包。可使用系统安装或用户目录的官方便携版。先核对实际 node.exe 位置;多套 Node、第三方 Agent 便携 Node 和 npm 的 .cmd 入口可能选择不同运行时。
122
134
 
123
- [Node.js 官方下载](https://nodejs.org/en/download)
124
-
125
- 安装验收只需完成前三步。无需 Agent、API Key 或模型登录。
135
+ **下列 $node 使用常见安装路径。请按实际 Node 安装位置修改后执行。** 普通用户 PowerShell 即可,无需改变脚本执行策略。建议首次安装预留至少 10 GB;更新还需保留旧程序和用户数据快照所需空间。
126
136
 
127
- 第一步:检查环境并安装 npm 管理工具。使用 .cmd 入口,无需修改 PowerShell 脚本执行策略。逐段执行,每一步成功后再继续。
137
+ 第一步:在当前窗口设置 UTF-8,并检查真正执行安装的 Node 和 npm:
128
138
 
129
139
  ```powershell
130
- node --version
131
- node -p "process.arch"
132
- npm.cmd --version
133
- 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 检查失败。" }
134
154
  ```
135
155
 
136
- process.arch 应输出 x64。第二步:解析命令路径并安装桌面程序。$codexOwn 只在当前窗口有效;打开新窗口后重新执行赋值行。
156
+ 第二步:通过同一个 Node 安装并调用管理工具:
137
157
 
138
158
  ```powershell
139
- $codexOwn = Join-Path (npm.cmd prefix --global) "codex-own.cmd"
140
- & $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 "桌面安装失败,请保留错误并先诊断。" }
141
166
  ```
142
167
 
143
- 管理工具和桌面资源全部从 registry.npmjs.org 下载。Windows 桌面资源约 1 GB,具体大小随版本变化。最多并行下载 4 个分片,安装时显示分片编号、下载百分比和已接收大小,然后显示校验、解压、安装。看到“安装完成”和 ok: true 才表示完成。
144
-
145
- 若已有手动安装的 codex-own,先退出应用,将第二步的安装命令替换为以下接管命令;原安装会被备份。
168
+ 第三步:验收结果。doctor 的 bootstrap 应指向所选 Node;installed 有版本,lock 为 false,pendingTransactions 为空;runtime doctor 验证独立 Node/npm/Python。先完成 API 配置,再从 Windows 开始菜单打开 codex-own。
146
169
 
147
170
  ```powershell
148
- & $codexOwn install --adopt --yes
171
+ & $node $codexOwn doctor --json
172
+ & $node $codexOwn version
173
+ & $node $codexOwn runtime doctor
149
174
  ```
150
175
 
151
- 第三步:检查安装结果。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。
152
179
 
153
180
  ```powershell
154
- & $codexOwn doctor
155
- & $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 配置失败。" }
156
187
  ```
157
188
 
158
- 程序目录:%LOCALAPPDATA%\Programs\codex-own\current。配置与对话目录:%LOCALAPPDATA%\codex-own-independent。二者独立于原版 Codex。
189
+ 日常优先使用应用内“更新”。需要手动更新时,退出应用,用同一 $node 和 $npmCli 更新 @petercjl/codex-own@latest,再执行 & $node $codexOwn update install --yes --progress stdout。保留配置与对话。
190
+
191
+ ## 安装失败与恢复
159
192
 
160
- 安装测试通过后即可停止;账号配置和模型对话属于后续使用测试。
193
+ 安装器直接读取 TAR/PAX/gzip/ZIP 归档中的 Unicode 名称、类型、大小及内容 SHA256,核对解压结果,最多重试 3 次。UNPACK_INCOMPLETE/UNPACK_FAILED 提供缺失样例;持续缺失同一批 DLL 或脚本时检查安全软件拦截记录和可信来源,按设备安全策略处理,安装器不会关闭防护。正常和失败安装都尝试清理本次 staging,被占用目录会报告并保留,已校验分片缓存仍可复用。
161
194
 
162
- 可选:需要使用模型时,将只包含一行 DashScope API Key 的文件放到下载目录,命名为 codex-own-api-key.txt。配置前先退出应用,文件位置不同则修改路径;已有可用配置可跳过配置命令。
195
+ Windows 先查看诊断及恢复计划:
163
196
 
164
197
  ```powershell
165
- & $codexOwn configure --api-key-file "$HOME\Downloads\codex-own-api-key.txt" --yes
166
- & $codexOwn launch
198
+ & $node $codexOwn doctor --json
199
+ & $node $codexOwn repair plan
167
200
  ```
168
201
 
169
- 以后更新:先退出 codex-own,再执行以下命令。更新保留独立配置和对话。
202
+ 确认 allowed: true 并同意回收失败安装的临时目录后:
170
203
 
171
204
  ```powershell
172
- npm.cmd install --global @petercjl/codex-own@latest
173
- $codexOwn = Join-Path (npm.cmd prefix --global) "codex-own.cmd"
174
- & $codexOwn update check
175
- & $codexOwn update install --yes
205
+ & $node $codexOwn repair run --yes
206
+ & $node $codexOwn doctor --json
176
207
  ```
177
208
 
178
- 下载失败:连续 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 和未完成事务需要维护者检查,不能通过删文件强行继续。
179
210
 
180
- npm 安装出现缓存 ENOENT 或缓存损坏提示时,先检查缓存,再重新安装 npm 工具:
211
+ 默认 stdout 仅为 JSON,进度走 stderr;--progress stdout 是交互终端显示模式,与 --json 互斥;--quiet 关闭进度。CLI 入口在加载管理器前验证 Node 22+。doctor.bootstrap 返回实际可执行路径、版本及架构。
181
212
 
182
- ```powershell
183
- npm.cmd cache verify
184
- npm.cmd install --global @petercjl/codex-own@latest
185
- ```
213
+ 可发送给 Agent:
214
+
215
+ > 请检查 codex-own 的安装环境和失败残留,使用 @petercjl/codex-own 的恢复计划诊断,保留配置与对话。
186
216
 
187
- 管理工具与桌面版本分别报告。API Key 和用户数据保存在 npm 包之外。更多命令见 `codex-own help`。
217
+ > 请通过 @petercjl/codex-own 修复已确认可清理的安装残留,然后重试安装。
package/bin/codex-own.mjs CHANGED
@@ -1,42 +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
- import {planCleanup,runCleanup} from '../lib/cleanup.mjs';
10
- const args=process.argv.slice(2),[command,sub]=args;
11
- const flag=n=>args.includes('--'+n),value=n=>{const i=args.indexOf('--'+n);return i<0?undefined:args[i+1];};
12
- const yes=()=>{if(!flag('yes'))fail('CONFIRMATION_REQUIRED','Mutating commands require --yes');};
13
- 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');}
14
- 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};}
15
- async function main(){
16
- 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','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:'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.'};
17
- if(command==='version'||command==='--version')return {manager:pkg.version,desktop:state()?.version||null,desktopIdentity:state()?.desktop?.identity||null,runtime:toolEnv.active()?.version||null};
18
- if(command==='capabilities')return read(path.join(packageRoot,'capabilities.json'));
19
- if(command==='doctor')return {...doctor(),runtime:runtimeDoctor()};
20
- if(command==='cleanup'){
21
- 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;}
22
- if(sub==='plan')return planCleanup(options);
23
- if(sub==='run'){yes();return runCleanup(options);}
24
- fail('INVALID_COMMAND','cleanup plan or cleanup run --yes');
25
- }
26
- if(command==='runtime'){
27
- if(sub==='doctor')return runtimeDoctor();
28
- 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)))};}
29
- if(sub==='run'){const i=args.indexOf('--');process.exitCode=runRuntime(i<0?args.slice(2):args.slice(i+1));return;}
30
- 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'});}
31
- if(sub==='rollback'){yes();if(toolEnv.active()?.previous)toolSelectionGate();return rollbackRuntime();}
32
- return {commands:['runtime install --yes','runtime update --yes','runtime doctor','runtime env','runtime run -- <node|npm|npx|python|python3|pip> [args]','runtime rollback --yes']};
33
- }
34
- if(command==='launch')return launch();
35
- if(command==='skill')return skill();
36
- if(command==='configure'){yes();return configure(value('api-key-file'));}
37
- if(command==='install'){yes();return install({adopt:flag('adopt'),version:value('version'),archive:value('archive'),manifest:value('manifest')?read(value('manifest')):undefined});}
38
- 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});}}
39
- if(command==='rollback'){yes();return rollback({restoreData:flag('restore-data')});}
40
- fail('INVALID_COMMAND','See codex-own help');
41
- }
42
- 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
@@ -20,7 +20,9 @@
20
20
  "runtime doctor",
21
21
  "runtime env",
22
22
  "runtime run",
23
- "runtime rollback"
23
+ "runtime rollback",
24
+ "repair plan",
25
+ "repair run"
24
26
  ],
25
27
  "platforms": {
26
28
  "darwin-arm64": "implemented",
@@ -34,13 +36,31 @@
34
36
  "trigger": "owned main interface loaded and alive for 30 seconds; installed version and component identity must match",
35
37
  "defaultCacheLimitMiB": 2048,
36
38
  "rollbackCopies": 1,
37
- "reclaims": ["applied desktop archives", "completed inactive helpers", "superseded complete app/data backup pairs", "least recently used unpinned blocks"],
38
- "preserves": ["live app and user data", "private tool versions", "latest complete rollback", "pending/failed/unknown updates", "active processes", "manual recovery backups"],
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
+ ],
39
53
  "temporaryCacheExcess": "pending or unknown receipts pin required blocks; locked files remain available for inspection",
40
54
  "diagnosticRecordsPerCategory": 20,
41
55
  "preview": "cleanup plan",
42
56
  "execute": "cleanup run --yes [--cache-limit-mib <0..16384>]",
43
- "results": ["reclaimedBytes", "rollbackRetained", "warnings", "cacheLimitExceeded", "report"]
57
+ "results": [
58
+ "reclaimedBytes",
59
+ "rollbackRetained",
60
+ "warnings",
61
+ "cacheLimitExceeded",
62
+ "report"
63
+ ]
44
64
  },
45
65
  "windowsAgentShell": {
46
66
  "contract": 1,
@@ -118,5 +138,25 @@
118
138
  },
119
139
  "sameDesktop": "zero desktop download and activation; app may remain open",
120
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
121
161
  }
122
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
+ }
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;}
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {execFileSync} from 'node:child_process';
4
+ import {archiveInventory,validateArchiveName,extractedHash} from './archive-inventory.mjs';
5
+
6
+ const error=(code,message,extra={})=>Object.assign(new Error(message),{code,...extra});
7
+ export function archiveNames(text){
8
+ const names=text.split('\n').map(n=>n.replace(/\r$/,'')).filter(Boolean);
9
+ if(!names.length||names.length>200000)throw error('UNSAFE_ARCHIVE','Empty or excessive archive inventory');
10
+ for(const n of names){const normalized=n.replaceAll('\\','/');if(/^[a-zA-Z]:|^\/|^\\|[\0\r\n]/.test(n)||normalized.split('/').includes('..'))throw error('UNSAFE_ARCHIVE','Unsafe archive entry');}
11
+ return [...new Set(names)];
12
+ }
13
+ function inspectTree(root){
14
+ function walk(dir){for(const x of fs.readdirSync(dir,{withFileTypes:true})){
15
+ const f=path.join(dir,x.name),s=fs.lstatSync(f);
16
+ if(s.isSymbolicLink()){const link=fs.readlinkSync(f),resolved=path.resolve(path.dirname(f),link);if(path.isAbsolute(link)||!resolved.startsWith(root+path.sep))throw error('UNSAFE_ARCHIVE','Escaping archive symlink');}
17
+ else if(s.isDirectory())walk(f);else if(!s.isFile())throw error('UNSAFE_ARCHIVE','Unsupported archive file type');
18
+ }}walk(root);
19
+ }
20
+ export function extractChecked(archive,dest,{tar,attempts=3,report=()=>{},inventory=archiveInventory,execute=execFileSync,delay=ms=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms)}={}){
21
+ if(!Number.isInteger(attempts)||attempts<1||attempts>3)throw error('INVALID_ARGUMENT','Extraction attempts must be 1..3');
22
+ const cwd=path.dirname(path.resolve(archive)),source=path.basename(archive),root=path.resolve(dest),target=path.relative(cwd,root);
23
+ const entries=inventory(archive);for(const e of entries)validateArchiveName(e.name);const names=entries.map(e=>e.name);
24
+ fs.mkdirSync(root);let missing=names,lastError;
25
+ const absent=()=>entries.filter(e=>{try{const file=path.join(root,e.name),s=fs.lstatSync(file);if(e.type==='Directory')return !s.isDirectory();if(e.type==='SymbolicLink')return !s.isSymbolicLink()||e.link!==undefined&&fs.readlinkSync(file)!==e.link;if(!s.isFile())return true;return e.size!==undefined&&e.type!=='Link'&&s.size!==e.size||!!e.sha256&&extractedHash(file)!==e.sha256;}catch(e){if(['ENOENT','ENOTDIR','EPERM','EBUSY','EACCES'].includes(e.code))return true;throw e;}}).map(e=>e.name);
26
+ for(let attempt=1;attempt<=attempts;attempt++){
27
+ // A previous partial extraction must not provide an escaping link for retry.
28
+ inspectTree(root);
29
+ try{
30
+ if(attempt===1||!missing.length||missing.length>32||missing.some(n=>/[^\x20-\x7e]/.test(n)))execute(tar,['-xf',source,'-C',target],{cwd,stdio:'pipe'});
31
+ else{
32
+ // One entry at a time also keeps Windows argv bounded for long inventories.
33
+ for(const n of missing)execute(tar,['-xf',source,'-C',target,'--',n],{cwd,stdio:'pipe'});
34
+ }
35
+ lastError=null;
36
+ }catch(e){lastError=e;}
37
+ inspectTree(root);report('正在核对解压文件大小与内容校验和');missing=absent();
38
+ if(!missing.length&&!lastError)return {entries:names.length,attempts:attempt};
39
+ if(attempt<attempts){report(`解压验证未完成:${missing.length} 项缺失或内容不符,重试 ${attempt}/${attempts-1}`);delay(attempt*500);}
40
+ }
41
+ throw error(missing.length?'UNPACK_INCOMPLETE':'UNPACK_FAILED',`解压验证失败,${missing.length} 项缺失或内容不符;可能被占用或被安全软件拦截。示例:${missing.slice(0,5).join(', ')||'tar 未正常退出'}`,{missing:missing.slice(0,30),missingCount:missing.length,stage:root,tarExit:lastError?.status??null});
42
+ }
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Reserve a new directory atomically, then copy its children. This avoids the
5
+ // Windows Node 24.22 strict cp(existing empty directory) incompatibility while
6
+ // still refusing an existing destination and every existing child.
7
+ export function copyNewDirectory(source,target,{onCreate=()=>{},copy=fs.cpSync}={}){
8
+ fs.mkdirSync(target);
9
+ try{onCreate();for(const name of fs.readdirSync(source))copy(path.join(source,name),path.join(target,name),{recursive:true,force:false,errorOnExist:true,dereference:false,verbatimSymlinks:true});}
10
+ catch(e){try{removeNewTreeSync(target);}catch{e.partialTarget=target;}throw e;}
11
+ }
12
+ // Do not invoke native recursive rmSync: older Windows runtimes can terminate
13
+ // the process on sharing violations instead of returning a JavaScript error.
14
+ export function removeNewTreeSync(file){
15
+ const st=fs.lstatSync(file);
16
+ if(st.isDirectory()&&!st.isSymbolicLink()){for(const n of fs.readdirSync(file))removeNewTreeSync(path.join(file,n));fs.rmdirSync(file);}
17
+ else fs.unlinkSync(file);
18
+ }
19
+ export async function removeTemporary(file,{report=()=>{}}={}){
20
+ try{await fs.promises.rm(file,{recursive:true,force:true,maxRetries:3,retryDelay:200});return true;}
21
+ catch(e){report(`临时目录被占用,保留供 repair 检查:${file}`);return false;}
22
+ }
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import {paths,locked,rejectLinks,running,fail} from './manager.mjs';
5
+
6
+ const uuid='[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}';
7
+ function readJSON(f){try{return JSON.parse(fs.readFileSync(f,'utf8'));}catch{return null;}}
8
+ export function lockStatus(p,{alive=pid=>{try{process.kill(pid,0);return true;}catch(e){return e.code==='ESRCH'?false:null;}}}={}){
9
+ const file=path.join(p.manager,'operation.lock');
10
+ if(!fs.existsSync(file))return {exists:false};
11
+ rejectLinks(file);const s=fs.statSync(file),v=readJSON(file),valid=Number.isSafeInteger(v?.pid)&&v.pid>0;
12
+ return {exists:true,pid:valid?v.pid:null,time:typeof v?.time==='string'?v.time:null,alive:valid?alive(v.pid):null,fingerprint:{ino:s.ino,size:s.size,mtimeMs:s.mtimeMs}};
13
+ }
14
+ export function incompleteTransactions(p){return fs.existsSync(p.manager)?fs.readdirSync(p.manager).filter(n=>/^transaction-.*\.json$/.test(n)&&!n.endsWith('.complete.json')&&!fs.existsSync(path.join(p.manager,n.replace('.json','.complete.json')))):[];}
15
+ function size(file){const s=fs.lstatSync(file);if(s.isSymbolicLink())return s.size;if(s.isFile())return s.size;if(!s.isDirectory())return 0;return fs.readdirSync(file).reduce((n,x)=>n+size(path.join(file,x)),0);}
16
+ export function temporaryStages(p){
17
+ const items=[];
18
+ for(const dir of [p.manager,path.join(p.root,'runtimes')]){
19
+ rejectLinks(dir);if(!fs.existsSync(dir))continue;
20
+ for(const n of fs.readdirSync(dir).filter(n=>/^stage-[A-Za-z0-9_-]+$/.test(n))){const f=path.join(dir,n);const st=fs.lstatSync(f);if(st.isDirectory()&&!st.isSymbolicLink())items.push({path:f,bytes:size(f),kind:'stage'});}
21
+ }
22
+ if(fs.existsSync(p.manager))for(const n of fs.readdirSync(p.manager).filter(n=>new RegExp('^temporary-'+uuid+'\\.json$').test(n))){
23
+ const receipt=path.join(p.manager,n);rejectLinks(receipt);const r=readJSON(receipt),id=n.slice(10,-5),expected=path.join(path.dirname(p.target),'.codex-own-next-'+id);
24
+ if(r?.package!=='@petercjl/codex-own'||r.kind!=='next'||r.path!==expected||r.target!==p.target||!fs.existsSync(expected))continue;
25
+ rejectLinks(expected);items.push({path:expected,bytes:size(expected),kind:'next',receipt});
26
+ }
27
+ return items;
28
+ }
29
+ export function repairPlan({p=paths(),isRunning=running}={}){
30
+ const lock=lockStatus(p),pending=incompleteTransactions(p),active=isRunning(p);
31
+ const reason=pending.length?'INCOMPLETE_TRANSACTION':active.length?'APP_RUNNING':lock.exists&&lock.alive!==false?'LOCK_OWNER_UNCERTAIN_OR_ALIVE':null;
32
+ return {ok:true,allowed:!reason,reason,lock,pendingTransactions:pending,orphanStages:temporaryStages(p),policy:'only inactive staging and receipted next copies; preserve applications, credentials, conversations, caches and rollback'};
33
+ }
34
+ export async function repairRun({p=paths(),isRunning=running}={}){
35
+ rejectLinks(p.root);fs.mkdirSync(p.manager,{recursive:true,mode:0o700});
36
+ const guard=path.join(p.manager,'recovery.lock');rejectLinks(guard);let fd;
37
+ try{fd=fs.openSync(guard,'wx',0o600);}catch{fail('BUSY','Another recovery is active; inspect recovery.lock before recovery');}
38
+ try{
39
+ fs.writeSync(fd,JSON.stringify({pid:process.pid,time:new Date().toISOString()}));const plan=repairPlan({p,isRunning});
40
+ if(!plan.allowed)fail(plan.reason,'Recovery is deferred; preserve the lock and transaction evidence');
41
+ let recoveredLock=null;
42
+ if(plan.lock.exists){const f=path.join(p.manager,'operation.lock'),now=lockStatus(p);if(JSON.stringify(now.fingerprint)!==JSON.stringify(plan.lock.fingerprint)||now.alive!==false)fail('BUSY','Lock changed during recovery');
43
+ recoveredLock=path.join(p.manager,'recovered-lock-'+crypto.randomUUID()+'.json');fs.renameSync(f,recoveredLock);
44
+ }
45
+ return await locked(p,async()=>{
46
+ const removed=[],warnings=[];let reclaimedBytes=0;
47
+ for(const item of temporaryStages(p)){
48
+ rejectLinks(item.path);try{await fs.promises.rm(item.path,{recursive:true,maxRetries:3,retryDelay:200});if(item.receipt)fs.unlinkSync(item.receipt);removed.push(item.path);reclaimedBytes+=item.bytes;}
49
+ catch(e){warnings.push({path:item.path,code:e.code||'CLEANUP_FAILED'});}
50
+ }
51
+ return {ok:true,recoveredLock,removed,reclaimedBytes,warnings};
52
+ },{recovery:true});
53
+ }finally{fs.closeSync(fd);fs.unlinkSync(guard);}
54
+ }