@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 +69 -39
- package/bin/codex-own.mjs +4 -41
- package/capabilities.json +44 -4
- package/lib/archive-inventory.mjs +40 -0
- package/lib/cli.mjs +47 -0
- package/lib/extraction.mjs +42 -0
- package/lib/file-operations.mjs +22 -0
- package/lib/maintenance.mjs +54 -0
- package/lib/manager.mjs +21 -18
- package/lib/network.mjs +3 -1
- package/lib/tool-runtime.mjs +6 -3
- package/package.json +449 -608
- package/release.json +446 -607
- package/skills/codex-own/SKILL.md +8 -0
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
|
-
|
|
104
|
+
**API Key 文件路径必须改为你实际下载的位置,不能直接复制占位路径。** 配置前先保存任务;命令会关闭 codex-own,完成配置后从 Mac“应用程序”打开 codex-own。
|
|
105
105
|
|
|
106
106
|
```bash
|
|
107
|
-
"
|
|
108
|
-
"$
|
|
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
|
-
|
|
133
|
+
适用于 Windows x64,使用包含 npm 的官方 Node.js 22+ x64 发行包。可使用系统安装或用户目录的官方便携版。先核对实际 node.exe 位置;多套 Node、第三方 Agent 便携 Node 和 npm 的 .cmd 入口可能选择不同运行时。
|
|
122
134
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
安装验收只需完成前三步。无需 Agent、API Key 或模型登录。
|
|
135
|
+
**下列 $node 使用常见安装路径。请按实际 Node 安装位置修改后执行。** 普通用户 PowerShell 即可,无需改变脚本执行策略。建议首次安装预留至少 10 GB;更新还需保留旧程序和用户数据快照所需空间。
|
|
126
136
|
|
|
127
|
-
|
|
137
|
+
第一步:在当前窗口设置 UTF-8,并检查真正执行安装的 Node 和 npm:
|
|
128
138
|
|
|
129
139
|
```powershell
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
156
|
+
第二步:通过同一个 Node 安装并调用管理工具:
|
|
137
157
|
|
|
138
158
|
```powershell
|
|
139
|
-
$
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
171
|
+
& $node $codexOwn doctor --json
|
|
172
|
+
& $node $codexOwn version
|
|
173
|
+
& $node $codexOwn runtime doctor
|
|
149
174
|
```
|
|
150
175
|
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
Windows 先查看诊断及恢复计划:
|
|
163
196
|
|
|
164
197
|
```powershell
|
|
165
|
-
& $codexOwn
|
|
166
|
-
& $codexOwn
|
|
198
|
+
& $node $codexOwn doctor --json
|
|
199
|
+
& $node $codexOwn repair plan
|
|
167
200
|
```
|
|
168
201
|
|
|
169
|
-
|
|
202
|
+
确认 allowed: true 并同意回收失败安装的临时目录后:
|
|
170
203
|
|
|
171
204
|
```powershell
|
|
172
|
-
|
|
173
|
-
$codexOwn
|
|
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
|
-
|
|
209
|
+
Mac 使用 "$HOME/.local/bin/codex-own" doctor --json、repair plan 和 repair run --yes。恢复备份并移走确认死亡进程的 operation.lock,清理 inactive stage 和有所有权回执的 next 副本;保留应用、配置、密钥、对话、缓存及回滚。活动或未知锁、recovery.lock 和未完成事务需要维护者检查,不能通过删文件强行继续。
|
|
179
210
|
|
|
180
|
-
|
|
211
|
+
默认 stdout 仅为 JSON,进度走 stderr;--progress stdout 是交互终端显示模式,与 --json 互斥;--quiet 关闭进度。CLI 入口在加载管理器前验证 Node 22+。doctor.bootstrap 返回实际可执行路径、版本及架构。
|
|
181
212
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
```
|
|
213
|
+
可发送给 Agent:
|
|
214
|
+
|
|
215
|
+
> 请检查 codex-own 的安装环境和失败残留,使用 @petercjl/codex-own 的恢复计划诊断,保留配置与对话。
|
|
186
216
|
|
|
187
|
-
|
|
217
|
+
> 请通过 @petercjl/codex-own 修复已确认可清理的安装残留,然后重试安装。
|
package/bin/codex-own.mjs
CHANGED
|
@@ -1,42 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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": [
|
|
38
|
-
|
|
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": [
|
|
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
|
+
}
|