@jeffusion/tianshu 0.1.0 → 0.1.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tianshu",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "author": {
5
5
  "name": "Jeffusion"
6
6
  },
@@ -3,8 +3,9 @@
3
3
  "tianshu": {
4
4
  "command": "node",
5
5
  "args": [
6
- "${PLUGIN_ROOT}/dist/server.mjs"
7
- ]
6
+ "./dist/server.mjs"
7
+ ],
8
+ "cwd": "."
8
9
  }
9
10
  }
10
11
  }
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  面向长期目标的智能协作工作台,以 Codex 本地插件交付。天枢将任务台账、执行聊天和验收证据连接起来,让跨聊天工作有持续的状态和明确的授权边界。
7
7
 
8
- **当前阶段:0.1.0,早期开发。** 已实现任务中心、MCP 工具和协调 Skill;完整自主规划、后台常驻执行和多客户端协作尚未实现。
8
+ **当前阶段:0.1.1,早期开发。** 已实现任务中心、MCP 工具和协调 Skill;完整自主规划、后台常驻执行和多客户端协作尚未实现。
9
9
 
10
10
  ## 已实现
11
11
 
@@ -15,19 +15,25 @@
15
15
  - 删除任务保留独立验收归档;聊天空闲、回合结束和业务验收分别记录。
16
16
  - 明暗主题、只读浏览器预览、隔离测试与内容哈希构建标识。
17
17
 
18
- ## npm 安装(正式渠道)
18
+ ## 安装
19
+
20
+ 发布物是 npm 包 `@jeffusion/tianshu`,由 Codex 原生插件机制下载和安装,无需全局安装天枢、克隆仓库或编译。
19
21
 
20
22
  ```sh
21
- npm install -g @jeffusion/tianshu
22
- tianshu install
23
- tianshu doctor
23
+ codex plugin marketplace add jeffusion/tianshu
24
+ codex plugin add tianshu@jeffusion
24
25
  ```
25
26
 
26
- 命令安装后即可使用,无需克隆和构建源码;仍需本机 Node.js 24–26、Python 3.12+ 与 Codex CLI。npm 安装没有 postinstall 副作用,显式运行 install 才配置 Codex。安装后重开客户端、启用天枢并发送“@天枢 打开任务中心”。
27
+ 需要本机 Node.js 24–26、npm、Python 3.12+ 和支持 npm 插件源的 Codex。首次使用显式创建业务台账:
28
+
29
+ ```sh
30
+ npx --yes @jeffusion/tianshu@0.1.1 init
31
+ npx --yes @jeffusion/tianshu@0.1.1 doctor
32
+ ```
27
33
 
28
- 已有台账使用 `tianshu install --config /absolute/path/to/config.json`,不会搬动或清空数据。升级命令、回滚与卸载见 [npm 分发说明](docs/npm-distribution.md)。安装标识 `tianshu@tianshu`,市场显示名“天枢”,Skill 为 `tianshu:coordinator`,MCP 服务名 `tianshu`。
34
+ 重开客户端、启用“天枢”,发送“@天枢 打开任务中心”。已有任务先按[配置说明](docs/configuration.md)接入既有台账;不要用另一份空库替代。市场标识 `jeffusion`,插件 `tianshu`,Skill `tianshu:coordinator`,MCP 服务名 `tianshu`。升级、卸载和版本发布见 [npm 分发说明](docs/npm-distribution.md)。
29
35
 
30
- 源码克隆、npm ci、npm run demo 仅供开发测试。构建和测试不会更新当前 Codex 安装,也不会发布 npm 版本。
36
+ 源码安装只用于开发测试。构建和测试不会更新当前 Codex 安装,也不会自动发布 npm。
31
37
 
32
38
  ## 工作方式与边界
33
39
 
@@ -55,8 +61,9 @@ npm run check
55
61
  npm run build
56
62
  ```
57
63
 
58
- `check` 包含 TypeScript、生产构建、Node 协议测试、Python 台账测试和真实 npm 包生命周期测试,无需 API key、模型服务或业务数据库。构建产物位于 `dist/plugin` 和 `dist/marketplace`,`dist/build-info.json` 包含内容指纹。版本保持 `0.1.0`;本地测试不递增版本,不自动生成 ZIP、创建 release 或发布 npm。
64
+ `check` 包含 TypeScript、生产构建、Node 协议测试、Python 台账测试和真实 npm tarball 回归,无需 API key、模型服务或业务数据库。可发布包位于 `dist/package`;插件清单、MCP、Skill 和运行产物直接位于包根目录。`build-info.json` 记录版本和内容指纹。
59
65
 
66
+ 本地测试不递增版本、不生成独立安装器。正式 npm 版本不可覆盖;本次 `0.1.1` 修正已发布 `0.1.0` 的安装结构。
60
67
  ## 工程结构
61
68
 
62
69
  | 目录 | 内容 |
@@ -66,7 +73,7 @@ npm run build
66
73
  | `src/contracts` | 产品元数据、任务和附件契约 |
67
74
  | `ledger` | 配置解析、只读投影、事务台账 |
68
75
  | `skills/coordinator` | 跨聊天协调规则与命令参考 |
69
- | `scripts` | 演示、开发、构建、本地同步 |
76
+ | `scripts` | 演示、开发、构建、原生安装验收 |
70
77
  | `tests` | 独立 fixture 和回归测试 |
71
78
 
72
79
  参见[开发与验证](docs/development.md)、[贡献指南](CONTRIBUTING.md)、[安全说明](SECURITY.md)。私人配置、任务数据库、历史验收材料和安装产物不进入仓库。
@@ -1731,7 +1731,7 @@ SOFTWARE.
1731
1731
  SOFTWARE
1732
1732
 
1733
1733
 
1734
- ===== @typescript/typescript-darwin-arm64@7.0.2 (Apache-2.0) =====
1734
+ ===== @typescript/typescript-linux-x64@7.0.2 (Apache-2.0) =====
1735
1735
 
1736
1736
 
1737
1737
  --- LICENSE ---
@@ -3540,34 +3540,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
3540
3540
  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
3541
3541
 
3542
3542
 
3543
- ===== fsevents@2.3.3 (MIT) =====
3544
-
3545
-
3546
- --- LICENSE ---
3547
- MIT License
3548
- -----------
3549
-
3550
- Copyright (C) 2010-2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller
3551
-
3552
- Permission is hereby granted, free of charge, to any person obtaining a copy
3553
- of this software and associated documentation files (the "Software"), to deal
3554
- in the Software without restriction, including without limitation the rights
3555
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
3556
- copies of the Software, and to permit persons to whom the Software is
3557
- furnished to do so, subject to the following conditions:
3558
-
3559
- The above copyright notice and this permission notice shall be included in
3560
- all copies or substantial portions of the Software.
3561
-
3562
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
3563
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
3564
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
3565
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
3566
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
3567
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
3568
- THE SOFTWARE.
3569
-
3570
-
3571
3543
  ===== function-bind@1.1.2 (MIT) =====
3572
3544
 
3573
3545
 
@@ -4452,7 +4424,7 @@ This Source Code Form is "Incompatible With Secondary Licenses", as
4452
4424
  defined by the Mozilla Public License, v. 2.0.
4453
4425
 
4454
4426
 
4455
- ===== lightningcss-darwin-arm64@1.33.0 (MPL-2.0) =====
4427
+ ===== lightningcss-linux-x64-gnu@1.33.0 (MPL-2.0) =====
4456
4428
 
4457
4429
 
4458
4430
  --- LICENSE ---
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "tianshu",
3
+ "version": "0.1.1",
4
+ "build_id": "e2f64d01ba77022f91e4"
5
+ }
package/cli/main.mjs CHANGED
@@ -1,193 +1,140 @@
1
- import {existsSync,readFileSync,writeFileSync,mkdirSync,cpSync,renameSync,rmSync,readdirSync,lstatSync,realpathSync} from 'node:fs';
2
- import {resolve,join,dirname,relative,isAbsolute} from 'node:path';
3
- import {homedir} from 'node:os';
4
- import {fileURLToPath} from 'node:url';
5
- import {execFileSync,spawn} from 'node:child_process';
6
- import {createHash,randomUUID} from 'node:crypto';
7
- import {probe} from './probe.mjs';
8
- const packageRoot=fileURLToPath(new URL('../',import.meta.url));
9
- const pkg=JSON.parse(readFileSync(join(packageRoot,'package.json'),'utf8'));
10
- const read=p=>JSON.parse(readFileSync(p,'utf8'));
11
- const print=value=>console.log(JSON.stringify(value,null,2));
12
- const help=`天枢 Tianshu ${pkg.version}\n\n tianshu init 初始化独立台账(已有配置不覆盖)\n tianshu install 将已编译插件安装到 Codex\n tianshu doctor 检查依赖、配置和已安装 MCP\n tianshu upgrade 将当前 npm 包同步到插件,保留上一份\n tianshu rollback 恢复上一份插件/未完成安装,保留台账\n tianshu uninstall 移除 Codex 插件和市场,保留用户数据\n\n选项:--config <绝对路径>,--help,--version\n环境:TIANSHU_HOME、TIANSHU_PYTHON、TIANSHU_CODEX、CODEX_HOME\n安装 npm 包不会自动修改 Codex。需要 Node.js 24–26、Python 3.12+、Codex CLI。`;
1
+ // cli/main.mjs
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, lstatSync } from "node:fs";
3
+ import { join as join2, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { execFileSync } from "node:child_process";
13
6
 
14
- function atomic(path,value){const temp=path+'.'+randomUUID()+'.tmp';writeFileSync(temp,JSON.stringify(value,null,2)+'\n',{mode:0o600,flag:'wx'});renameSync(temp,path);}
15
- function run(command,args,options={}){return execFileSync(command,args,{encoding:'utf8',timeout:30000,maxBuffer:4*1024*1024,stdio:['pipe','pipe','pipe'],env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'},...options});}
16
- function canonical(path){const value=resolve(path);return existsSync(value)?realpathSync(value):value;}
17
- function defaultHome(){return process.platform==='darwin'?join(homedir(),'Library/Application Support/Tianshu'):join(process.env.XDG_DATA_HOME||join(homedir(),'.local/share'),'tianshu');}
18
- function environment(options){
19
- const requestedHome=resolve(process.env.TIANSHU_HOME||defaultHome());
20
- if(existsSync(requestedHome)&&lstatSync(requestedHome).isSymbolicLink())throw new Error('TIANSHU_HOME must not be a symbolic link');
21
- const home=canonical(requestedHome);
22
- const codexHome=canonical(process.env.CODEX_HOME||join(homedir(),'.codex'));
23
- const context={home,codexHome,codex:process.env.TIANSHU_CODEX||'codex',python:process.env.TIANSHU_PYTHON||'python3',statePath:join(home,'installation.json'),journal:join(home,'operation.json'),market:join(home,'marketplace'),active:join(home,'marketplace/plugins/tianshu')};
24
- if(existsSync(home)&&lstatSync(home).isSymbolicLink())throw new Error('TIANSHU_HOME must not be a symbolic link');
25
- const state=existsSync(context.statePath)?read(context.statePath):null;
26
- if(state&&(state.schema!==1||state.codex_home!==codexHome))throw new Error('Installation state belongs to a different CODEX_HOME or unsupported schema');
27
- context.state=state;
28
- const selected=options.config||process.env.TIANSHU_CONFIG||state?.config||join(home,'config.json');
29
- if(!isAbsolute(selected))throw new Error('Config path must be absolute');
30
- context.config=selected;context.explicitConfig=Boolean(options.config||process.env.TIANSHU_CONFIG);
31
- return context;
7
+ // cli/probe.mjs
8
+ import { spawn } from "node:child_process";
9
+ function probe(server, python, config) {
10
+ return new Promise((resolve, reject) => {
11
+ const child = spawn(process.execPath, [server], { env: { ...process.env, TIANSHU_PYTHON: python, TIANSHU_CONFIG: config, PYTHONDONTWRITEBYTECODE: "1" }, stdio: ["pipe", "pipe", "pipe"] });
12
+ let buffer = "", bytes = 0, finished = false;
13
+ const timer = setTimeout(() => finish(new Error("MCP health check timed out")), 12e3);
14
+ function finish(error, value) {
15
+ if (finished) return;
16
+ finished = true;
17
+ clearTimeout(timer);
18
+ child.kill();
19
+ const force = setTimeout(() => child.kill("SIGKILL"), 1e3);
20
+ force.unref();
21
+ child.once("close", () => clearTimeout(force));
22
+ error ? reject(error) : resolve(value);
23
+ }
24
+ const send = (value) => child.stdin.write(JSON.stringify(value) + "\n");
25
+ child.on("error", (error) => finish(error));
26
+ child.on("exit", () => {
27
+ if (!finished) finish(new Error("MCP server exited before health check completed"));
28
+ });
29
+ child.stdin.on("error", (error) => finish(error));
30
+ child.stderr.resume();
31
+ child.stdout.on("data", (chunk) => {
32
+ bytes += chunk.length;
33
+ if (bytes > 4 * 1024 * 1024) return finish(new Error("MCP response exceeds health check limit"));
34
+ buffer += chunk.toString();
35
+ let newline;
36
+ while ((newline = buffer.indexOf("\n")) >= 0) {
37
+ const line = buffer.slice(0, newline);
38
+ buffer = buffer.slice(newline + 1);
39
+ if (!line.trim()) continue;
40
+ try {
41
+ const response = JSON.parse(line);
42
+ if (response.error) throw new Error("MCP health request rejected");
43
+ if (response.id === 1) {
44
+ if (response.result?.serverInfo?.name !== "tianshu") throw new Error("Unexpected MCP server identity");
45
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
46
+ send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
47
+ } else if (response.id === 2) {
48
+ const tools = (response.result?.tools || []).map((t) => t.name).sort();
49
+ if (JSON.stringify(tools) !== JSON.stringify(["delete_task", "get_task", "list_tasks", "open_workspace"])) throw new Error("Unexpected MCP tool inventory");
50
+ send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "list_tasks", arguments: {} } });
51
+ } else if (response.id === 3) {
52
+ const data = response.result?.structuredContent;
53
+ if (response.result?.isError || !data?.readonly || !data.sources?.length || data.sources.some((s) => !s.ok)) throw new Error("Configured ledger is not healthy/readable");
54
+ finish(null, { build: data.build, task_count: data.tasks.length, tools: 4 });
55
+ }
56
+ } catch (error) {
57
+ finish(error);
58
+ }
59
+ }
60
+ });
61
+ send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "tianshu-doctor", version: "1" } } });
62
+ });
32
63
  }
33
- function owned(c,path){
34
- const rel=relative(c.home,path);if(!rel||rel.startsWith('..')||isAbsolute(rel))throw new Error('Refusing operation outside managed home');
35
- let current=c.home;
36
- for(const part of rel.split(/[\\/]/)){current=join(current,part);if(existsSync(current)&&lstatSync(current).isSymbolicLink())throw new Error('Managed path must not contain symbolic links');}
64
+
65
+ // src/contracts/config-path.mjs
66
+ import { homedir } from "node:os";
67
+ import { isAbsolute, join } from "node:path";
68
+ function dataHome() {
69
+ return process.env.TIANSHU_HOME || (process.platform === "darwin" ? join(homedir(), "Library/Application Support/Tianshu") : join(process.env.XDG_DATA_HOME || join(homedir(), ".local/share"), "tianshu"));
70
+ }
71
+ function configPath(explicit) {
72
+ const path = explicit || process.env.TIANSHU_CONFIG || join(dataHome(), "config.json");
73
+ if (!isAbsolute(path)) throw new Error("Config path must be absolute");
37
74
  return path;
38
75
  }
39
- function stateWrite(c,state){owned(c,c.statePath);if(state)atomic(c.statePath,state);else rmSync(c.statePath,{force:true});}
40
- function preflight(c,needCodex=true){
41
- if(process.platform==='win32')throw new Error('This release supports macOS/Linux; Windows installation is not validated');
42
- const node=Number(process.versions.node.split('.')[0]);if(node<24||node>26)throw new Error('Node.js 24–26 is required');
43
- const python=JSON.parse(run(c.python,['-c','import sys,json;print(json.dumps({"version":list(sys.version_info[:2]),"path":sys.executable}))']));
44
- if(python.version[0]!==3||python.version[1]<12)throw new Error('Python 3.12+ is required');c.python=python.path;
45
- if(needCodex)run(c.codex,['--version'],{env:{...process.env,CODEX_HOME:c.codexHome}});
76
+
77
+ // cli/main.mjs
78
+ var packageRoot = fileURLToPath(new URL("../", import.meta.url));
79
+ var pkg = JSON.parse(readFileSync(join2(packageRoot, "package.json"), "utf8"));
80
+ var print = (value) => console.log(JSON.stringify(value, null, 2));
81
+ var help = `\u5929\u67A2 Tianshu ${pkg.version}
82
+
83
+ tianshu init \u521D\u59CB\u5316\u72EC\u7ACB\u53F0\u8D26\uFF1B\u5DF2\u6709\u914D\u7F6E\u53EA\u6821\u9A8C\uFF0C\u4E0D\u8986\u76D6
84
+ tianshu doctor \u68C0\u67E5\u672C\u5305\u4F9D\u8D56\u3001\u914D\u7F6E\u548C\u53EA\u8BFB MCP \u67E5\u8BE2
85
+
86
+ \u9009\u9879\uFF1A--config <\u7EDD\u5BF9\u8DEF\u5F84>\uFF0C--help\uFF0C--version
87
+ \u73AF\u5883\uFF1ATIANSHU_CONFIG\u3001TIANSHU_HOME\u3001TIANSHU_PYTHON
88
+ \u63D2\u4EF6\u5B89\u88C5\u548C\u5378\u8F7D\u8BF7\u4F7F\u7528 Codex \u539F\u751F\u547D\u4EE4\uFF1B\u672C\u547D\u4EE4\u4E0D\u7BA1\u7406\u63D2\u4EF6\u3002`;
89
+ function run(python, args) {
90
+ return execFileSync(python, args, { encoding: "utf8", timeout: 3e4, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" } });
46
91
  }
47
- function codex(c,args){return JSON.parse(run(c.codex,args,{env:{...process.env,CODEX_HOME:c.codexHome}}));}
48
- function registeredMarket(c){const result=codex(c,['plugin','marketplace','list','--json']);const list=Array.isArray(result)?result:result.marketplaces;if(!Array.isArray(list))throw new Error('Unsupported Codex marketplace listing');return list.find(m=>m.name==='tianshu')||null;}
49
- function register(c,path){
50
- if(registeredMarket(c))codex(c,['plugin','marketplace','remove','tianshu','--json']);
51
- codex(c,['plugin','marketplace','add',path,'--json']);
52
- const result=codex(c,['plugin','add','tianshu@tianshu','--json']);
53
- return result.installedPath;
92
+ function pythonRuntime() {
93
+ const node = Number(process.versions.node.split(".")[0]);
94
+ if (node < 24 || node > 26) throw new Error("Node.js 24\u201326 is required");
95
+ const executable = process.env.TIANSHU_PYTHON || "python3";
96
+ const info = JSON.parse(run(executable, ["-c", 'import sys,json;print(json.dumps({"version":list(sys.version_info[:2]),"path":sys.executable}))']));
97
+ if (info.version[0] !== 3 || info.version[1] < 12) throw new Error("Python 3.12+ is required");
98
+ return info.path;
54
99
  }
55
- function verifyBundle(plugin,manifest){
56
- if(lstatSync(plugin).isSymbolicLink())throw new Error('Plugin root must not be a symlink');
57
- const actual=[];
58
- function walk(dir){for(const e of readdirSync(dir,{withFileTypes:true})){const p=join(dir,e.name);if(e.isSymbolicLink())throw new Error('Plugin payload contains symlinks');if(e.isDirectory())walk(p);else if(e.isFile())actual.push(relative(plugin,p).split('\\').join('/'));else throw new Error('Unsupported payload entry');}}
59
- walk(plugin);const expected=Object.keys(manifest.files||{}).sort();
60
- if(!expected.length||JSON.stringify(actual.sort())!==JSON.stringify(expected))throw new Error('Plugin payload inventory mismatch');
61
- for(const path of expected){if(isAbsolute(path)||path.split(/[\\/]/).includes('..'))throw new Error('Invalid integrity path');const hash=createHash('sha256').update(readFileSync(join(plugin,path))).digest('hex');if(hash!==manifest.files[path])throw new Error('Plugin integrity check failed: '+path);}
62
- const info=read(join(plugin,'build-info.json'));
63
- if(info.name!=='tianshu'||!/^[0-9a-f]{20}$/.test(info.build_id)||!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(info.version))throw new Error('Invalid plugin identity');
64
- return info;
100
+ function validate(python, config) {
101
+ const result = JSON.parse(run(python, [join2(packageRoot, "ledger/read_ledger.py"), "--config", config]));
102
+ if (!result.sources?.length || result.sources.some((s) => !s.ok)) throw new Error("Configured ledger is missing or unhealthy; no replacement database was created");
65
103
  }
66
- function archive(c){
67
- const source=join(packageRoot,'dist/plugin'),integrity=read(join(packageRoot,'dist/build-info.json'));
68
- const info=verifyBundle(source,integrity),id=info.version+'-'+info.build_id;
69
- const directory=owned(c,join(c.home,'releases',id));mkdirSync(owned(c,join(c.home,'releases')),{recursive:true,mode:0o700});
70
- if(!existsSync(directory)){
71
- const stage=owned(c,join(c.home,'releases','.stage-'+randomUUID()));mkdirSync(stage,{mode:0o700});
72
- try{cpSync(source,join(stage,'plugin'),{recursive:true});atomic(join(stage,'integrity.json'),integrity);renameSync(stage,directory);}finally{rmSync(stage,{recursive:true,force:true});}
104
+ async function main(argv = process.argv.slice(2)) {
105
+ if (!argv.length || argv.includes("--help")) return console.log(help);
106
+ if (argv.length === 1 && argv[0] === "--version") return console.log(pkg.version);
107
+ const [command, ...args] = argv;
108
+ if (!["init", "doctor"].includes(command)) throw new Error("Unsupported command. Use Codex native plugin commands for installation and removal.");
109
+ let explicit;
110
+ for (let i = 0; i < args.length; i++) {
111
+ if (args[i] === "--config" && args[i + 1] && !explicit) explicit = args[++i];
112
+ else throw new Error("Unknown option: " + args[i]);
73
113
  }
74
- if(JSON.stringify(read(join(directory,'integrity.json')).files)!==JSON.stringify(integrity.files))throw new Error('Build identity collision; existing release was preserved');
75
- verifyBundle(owned(c,join(directory,'plugin')),read(owned(c,join(directory,'integrity.json'))));
76
- return {id,directory,info};
77
- }
78
- function previousRelease(c,id){
79
- if(!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?-[0-9a-f]{20}$/.test(id||''))throw new Error('No previous managed plugin is available');
80
- const directory=owned(c,join(c.home,'releases',id)),info=verifyBundle(owned(c,join(directory,'plugin')),read(owned(c,join(directory,'integrity.json'))));
81
- return {id,directory,info};
82
- }
83
- function validateConfig(c){
84
- const result=JSON.parse(run(c.python,[join(packageRoot,'dist/plugin/ledger/read_ledger.py'),'--config',c.config]));
85
- if(!result.sources?.length||result.sources.some(s=>!s.ok))throw new Error('Configured ledger is missing or unhealthy; no replacement database was created');
86
- return result;
87
- }
88
- function initialize(c){
89
- if(existsSync(c.config)){validateConfig(c);return false;}
90
- if(c.explicitConfig)throw new Error('Explicit config does not exist; configure an existing ledger or run init without --config');
91
- const data=owned(c,join(c.home,'data'));mkdirSync(data,{recursive:true,mode:0o700});
92
- const db=owned(c,join(data,'coordinator.db'));
93
- if(!existsSync(db))run(c.python,[join(packageRoot,'dist/plugin/ledger/coordinator.py'),'--db',db,'init']);
94
- const temp=owned(c,join(c.home,'.config-'+randomUUID()+'.json'));
95
- const config={sources:[{id:'daily',label:'日常任务',path:db}]};
96
- try{atomic(temp,config);const candidate={...c,config:temp};validateConfig(candidate);writeFileSync(owned(c,c.config),JSON.stringify(config,null,2)+'\n',{flag:'wx',mode:0o600});}finally{rmSync(temp,{force:true});}
97
- return true;
98
- }
99
- function marketplaceFile(c){
100
- mkdirSync(owned(c,join(c.market,'.agents/plugins')),{recursive:true,mode:0o700});
101
- atomic(join(c.market,'.agents/plugins/marketplace.json'),{name:'tianshu',interface:{displayName:'天枢'},plugins:[{name:'tianshu',source:{source:'local',path:'./plugins/tianshu'},policy:{installation:'AVAILABLE',authentication:'ON_INSTALL'},category:'Productivity'}]});
102
- }
103
- async function recover(c){
104
- const journal=read(owned(c,c.journal));
105
- if(journal.codex_home!==c.codexHome||!/^\.backup-[0-9a-f-]+$/.test(journal.backup))throw new Error('Invalid recovery record');
106
- const backup=owned(c,join(c.home,journal.backup));
107
- if(existsSync(backup)){rmSync(owned(c,c.active),{recursive:true,force:true});mkdirSync(dirname(c.active),{recursive:true});renameSync(backup,c.active);}
108
- else if(!journal.had_active)rmSync(owned(c,c.active),{recursive:true,force:true});
109
- if(journal.before_market){register(c,journal.before_market);}
110
- else{codex(c,['plugin','remove','tianshu@tianshu','--json']);if(registeredMarket(c))codex(c,['plugin','marketplace','remove','tianshu','--json']);}
111
- stateWrite(c,journal.before_state);rmSync(c.journal);return {recovered:true,data_preserved:true};
112
- }
113
- async function install(c,release){
114
- const beforeMarket=registeredMarket(c);
115
- if(beforeMarket&&canonical(beforeMarket.root)!==canonical(c.market)&&!c.explicitConfig&&!c.state)throw new Error('An existing Tianshu installation was found. Use --config with its existing configuration to preserve tasks.');
116
- initialize(c);validateConfig(c);
117
- const stage=owned(c,join(c.home,'.stage-'+randomUUID())),backup='.backup-'+randomUUID(),backupPath=owned(c,join(c.home,backup));
118
- mkdirSync(stage,{mode:0o700});
119
- try{
120
- cpSync(join(release.directory,'plugin'),stage,{recursive:true});
121
- atomic(join(stage,'runtime-config.json'),{config_path:c.config});
122
- const cache=join(c.codexHome,'plugins/cache/tianshu/tianshu',release.info.version);
123
- atomic(join(stage,'.mcp.json'),{mcpServers:{tianshu:{command:process.execPath,args:[join(cache,'dist/server.mjs')],env:{TIANSHU_PYTHON:c.python,TIANSHU_CONFIG:c.config,PYTHONDONTWRITEBYTECODE:'1'}}}});
124
- await probe(join(stage,'dist/server.mjs'),c.python,c.config);
125
- const journal={codex_home:c.codexHome,backup,had_active:existsSync(c.active),before_state:c.state,before_market:beforeMarket?.root||null};
126
- atomic(owned(c,c.journal),journal);
127
- try{
128
- mkdirSync(owned(c,dirname(c.active)),{recursive:true,mode:0o700});
129
- if(journal.had_active)renameSync(c.active,backupPath);renameSync(stage,c.active);marketplaceFile(c);
130
- const installed=register(c,c.market);if(!installed||canonical(installed)!==canonical(cache))throw new Error('Unexpected Codex installed path: '+installed);
131
- const health=await probe(join(cache,'dist/server.mjs'),c.python,c.config);
132
- if(health.build?.build_id!==release.info.build_id)throw new Error('Installed build does not match requested build');
133
- const previous=c.state?.active===release.id?c.state.previous:c.state?.active||null;
134
- stateWrite(c,{schema:1,codex_home:c.codexHome,active:release.id,previous,config:c.config,installed:true,installed_at:new Date().toISOString(),adopted_marketplace:c.state?.adopted_marketplace||(beforeMarket?.root!==c.market?beforeMarket?.root:null)});
135
- rmSync(c.journal);rmSync(backupPath,{recursive:true,force:true});
136
- return {installed:true,plugin:'tianshu@tianshu',...release.info,health,data_preserved:true,reload_client:true};
137
- }catch(error){
138
- try{await recover(c);}catch{throw new Error('Install failed and recovery is incomplete. Run tianshu rollback after checking Codex availability; user data was preserved.');}
139
- throw new Error('Install failed; previous plugin restored: '+error.message);
140
- }
141
- }finally{rmSync(stage,{recursive:true,force:true});}
142
- }
143
- async function lock(c){
144
- mkdirSync(c.home,{recursive:true,mode:0o700});const path=owned(c,join(c.home,'lifecycle.lock'));
145
- // Kernel flock is released on exit/crash. Never unlink the stable lock inode.
146
- const code='import os,sys,fcntl; fd=os.open(sys.argv[1],os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600); fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB); print("LOCKED",flush=True); sys.stdin.buffer.read()';
147
- const child=spawn(c.python,['-c',code,path],{stdio:['pipe','pipe','pipe']});child.stderr.resume();
148
- await new Promise((resolve,reject)=>{
149
- const timer=setTimeout(()=>{child.kill();reject(new Error('Lifecycle lock timed out'));},5000);
150
- child.once('error',error=>{clearTimeout(timer);reject(error);});
151
- child.stdout.once('data',data=>{clearTimeout(timer);data.toString().trim()==='LOCKED'?resolve():reject(new Error('Lifecycle lock failed'));});
152
- child.once('exit',()=>{clearTimeout(timer);reject(new Error('Another lifecycle operation is running or lock acquisition failed'));});
153
- });
154
- return ()=>new Promise(resolve=>{const timer=setTimeout(()=>child.kill('SIGKILL'),1000);child.once('close',()=>{clearTimeout(timer);resolve();});child.stdin.end();});
155
- }
156
-
157
- export async function main(argv=process.argv.slice(2)){
158
- if(!argv.length||argv.includes('--help'))return console.log(help);
159
- if(argv.length===1&&argv[0]==='--version')return console.log(pkg.version);
160
- const command=argv.shift(),options={};
161
- for(let i=0;i<argv.length;i++){if(argv[i]==='--config'&&argv[i+1])options.config=argv[++i];else throw new Error('Unknown option: '+argv[i]);}
162
- if(!['init','install','upgrade','rollback','uninstall','doctor'].includes(command))throw new Error('Unknown command: '+command);
163
- const c=environment(options);preflight(c,command!=='init');
164
- if(command==='doctor'){
165
- if(existsSync(c.journal))throw new Error('An interrupted lifecycle operation requires tianshu rollback');
166
- if(!c.state?.installed)throw new Error('Plugin is not installed by this CLI; run tianshu install');
167
- validateConfig(c);const release=previousRelease(c,c.state.active),cache=join(c.codexHome,'plugins/cache/tianshu/tianshu',release.info.version);
168
- const market=registeredMarket(c);if(!market||canonical(market.root)!==canonical(c.market))throw new Error('Codex marketplace does not match this installation');
169
- const health=await probe(join(cache,'dist/server.mjs'),c.python,c.config);if(health.build?.build_id!==release.info.build_id)throw new Error('Installed build drift detected');
170
- return print({ok:true,home:c.home,config:c.config,health,host_ui_verified:false});
114
+ const python = pythonRuntime(), config = configPath(explicit);
115
+ if (command === "doctor") {
116
+ validate(python, config);
117
+ const health = await probe(join2(packageRoot, "dist/server.mjs"), python, config);
118
+ return print({ ok: true, config, health, scope: "this_package", host_ui_verified: false });
171
119
  }
172
- if(command==='install')mkdirSync(c.codexHome,{recursive:true,mode:0o700});
173
- const unlock=await lock(c);
174
- try{
175
- // Re-read mutable installation state only after acquiring the lifecycle lock.
176
- const python=c.python;Object.assign(c,environment(options),{python});
177
- if(existsSync(c.journal)){if(command!=='rollback')throw new Error('An interrupted operation requires tianshu rollback');return print(await recover(c));}
178
- if(command==='init'){initialize(c);return print({initialized:true,config:c.config});}
179
- if(command==='uninstall'){
180
- const market=registeredMarket(c);if(market&&canonical(market.root)!==canonical(c.market))throw new Error('Refusing to remove an installation managed elsewhere');
181
- if(market&&!c.state?.installed)throw new Error('Managed installation state is missing or inconsistent; reinstall before uninstalling');
182
- if(c.state?.installed){
183
- atomic(owned(c,c.journal),{codex_home:c.codexHome,backup:'.backup-'+randomUUID(),had_active:existsSync(c.active),before_state:c.state,before_market:market?.root||null});
184
- try{codex(c,['plugin','remove','tianshu@tianshu','--json']);if(market)codex(c,['plugin','marketplace','remove','tianshu','--json']);stateWrite(c,{...c.state,installed:false});rmSync(c.journal);}
185
- catch(error){try{await recover(c);}catch{throw new Error('Uninstall recovery is incomplete; run tianshu rollback. User data was preserved.');}throw new Error('Uninstall failed; previous plugin restored: '+error.message);}
186
- }
187
- return print({uninstalled:true,data_preserved:true,home:c.home});
188
- }
189
- if(command==='upgrade'&&!c.state?.installed)throw new Error('No managed installation; run tianshu install first');
190
- const release=command==='rollback'?previousRelease(c,c.state?.previous):archive(c);
191
- return print(await install(c,release));
192
- }finally{await unlock();}
120
+ if (existsSync(config)) {
121
+ validate(python, config);
122
+ return print({ initialized: false, config, data_preserved: true });
123
+ }
124
+ if (explicit || process.env.TIANSHU_CONFIG) throw new Error("Explicit config does not exist; init never creates a replacement for a specified configuration");
125
+ const directory = dirname(config);
126
+ if (existsSync(directory) && lstatSync(directory).isSymbolicLink()) throw new Error("Data home must not be a symbolic link");
127
+ mkdirSync(directory, { recursive: true, mode: 448 });
128
+ const data = join2(directory, "data");
129
+ if (existsSync(data) && lstatSync(data).isSymbolicLink()) throw new Error("Data directory must not be a symbolic link");
130
+ mkdirSync(data, { recursive: true, mode: 448 });
131
+ const db = join2(data, "coordinator.db");
132
+ if (!existsSync(db)) run(python, [join2(packageRoot, "ledger/coordinator.py"), "--db", db, "init"]);
133
+ const value = { sources: [{ id: "daily", label: "\u65E5\u5E38\u4EFB\u52A1", path: db }] };
134
+ run(python, ["-c", 'import sys;sys.path.insert(0,sys.argv[1]);from read_ledger import read_source;read_source({"id":"daily","label":"\u65E5\u5E38\u4EFB\u52A1","path":sys.argv[2]})', join2(packageRoot, "ledger"), db]);
135
+ writeFileSync(config, JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 384 });
136
+ return print({ initialized: true, config, data_preserved: true });
193
137
  }
138
+ export {
139
+ main
140
+ };