@blacklake-tech-cn/castor-cli 0.1.0-beta.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.
- package/README.md +258 -0
- package/checksums.txt +6 -0
- package/package.json +36 -0
- package/scripts/install.js +218 -0
- package/scripts/run.js +37 -0
- package/scripts/verify-publish.js +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# castor-cli
|
|
2
|
+
|
|
3
|
+
面向人和 Agent 的 Castor 命令行客户端。工程分层、安全存储和 Skill 组织参考 `hhzz-cli`,Castor 的路由、认证协议和 OpenAPI 表单合同均来自本工作区的 `castor` 与 `bf-constant-code` 源码。
|
|
4
|
+
|
|
5
|
+
## npm 安装
|
|
6
|
+
|
|
7
|
+
正式分享方式与 `hhzz-cli` 一致:npm 包安装平台启动器和对应的 Go 二进制,Skills 使用固定版本的 `npx skills` 独立安装。
|
|
8
|
+
|
|
9
|
+
当前首个预发布版本为 `0.1.0-beta.1`。OSS 和 npm 发布完成后,其他人执行:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @blacklake-tech-cn/castor-cli@beta
|
|
13
|
+
|
|
14
|
+
npx -y skills@1.5.18 add \
|
|
15
|
+
https://bl-v3-cli.oss-cn-shanghai.aliyuncs.com/castor-cli/releases/v0.1.0-beta.1/skills \
|
|
16
|
+
--global -y
|
|
17
|
+
|
|
18
|
+
castor-cli version
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`npm install` 会根据操作系统和 CPU 下载对应的 GoReleaser 产物,并使用 npm 包内的 `checksums.txt` 做 SHA-256 校验。支持 macOS、Linux 和 Windows 的 amd64/arm64。
|
|
22
|
+
|
|
23
|
+
用户安装步骤见 [Castor CLI 安装指南](docs/castor-cli-installation-guide.md),发布前的本地构建、OSS 目录和 npm 发布顺序见 [npm 分发指南](docs/npm-distribution.md)。
|
|
24
|
+
|
|
25
|
+
## 当前能力
|
|
26
|
+
|
|
27
|
+
- `feature`、`test` 与自定义多环境 Profile。
|
|
28
|
+
- 账号密码登录;密码只用于登录请求,不落盘,返回的 Token 存入本机安全存储。
|
|
29
|
+
- 所有认证请求使用 Castor 的 `X-AUTH` 请求头。
|
|
30
|
+
- 查询 OpenAPI 应用、分类、发布版本、许可项、Swagger URL 和 CustomFields 配置。
|
|
31
|
+
- 输出 OpenAPI registration 的机器可读 schema。
|
|
32
|
+
- 对单个接口或批量清单执行本地规范化和 plan。
|
|
33
|
+
- 按名称、版本、叶子分类和状态搜索已有登记,读取详情并导出可编辑的新登记 manifest。
|
|
34
|
+
- 单条登记支持显式 `--dry-run`、`--confirm`,创建后使用返回 ID 查询详情并校验业务键。
|
|
35
|
+
- 单条 OpenAPI 变动发布支持显式 `--dry-run`、`--confirm`,发布前校验状态和版本,发布后读回状态。
|
|
36
|
+
- 单条已发布 OpenAPI 支持同步到 Castor 动态提供的目标环境,写入前解析环境代码,写入后核对同步确认和源记录。
|
|
37
|
+
- 六个 Agent Skill:环境认证、OpenAPI 登记、复制现有登记、安全发布、跨环境同步、清单模板转换。
|
|
38
|
+
- 稳定 JSON 输出、HTTP 超时、响应大小限制、错误正文截断和 User-Agent。
|
|
39
|
+
|
|
40
|
+
批量登记、批量发布和批量同步暂未开放,避免后端逐条执行时产生部分成功。单条写命令均要求 dry-run、明确确认和操作后的稳定校验。
|
|
41
|
+
|
|
42
|
+
## 目录结构
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
cmd/castor-cli/ 进程入口
|
|
46
|
+
internal/cli/ Cobra 命令、参数校验和展示
|
|
47
|
+
internal/client/ HTTP、X-AUTH 和 Castor API 调用
|
|
48
|
+
internal/config/ Profile 配置读取与持久化
|
|
49
|
+
internal/secret/ Token 安全存储
|
|
50
|
+
internal/openapi/ 登记 manifest 规范化与校验
|
|
51
|
+
internal/output/ 稳定 JSON 输出
|
|
52
|
+
skills/ Agent 工作流
|
|
53
|
+
examples/ 非敏感示例 manifest
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 构建
|
|
57
|
+
|
|
58
|
+
项目要求 Go 1.26.4。
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
make check
|
|
62
|
+
make build
|
|
63
|
+
./bin/castor-cli --help
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
生成包含 CLI、示例清单和全部 Agent Skills 的当前平台离线试用包:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
make package VERSION=0.1.0-beta.1
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
可通过 `GOOS`、`GOARCH` 交叉编译,例如:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
GOOS=linux GOARCH=amd64 make package VERSION=0.1.0-beta.1
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
产物和 SHA-256 校验文件位于 `dist/packages/`。打包过程不会包含本机 `~/.castor` 配置、密码或 Token。
|
|
79
|
+
|
|
80
|
+
## 环境和登录
|
|
81
|
+
|
|
82
|
+
首次初始化会提供两个 Profile:
|
|
83
|
+
|
|
84
|
+
- `feature`:`https://castor-web-v3-feature.blacklake.tech`
|
|
85
|
+
- `test`:`https://castor-web-v3-test.blacklake.tech`
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
./bin/castor-cli config init
|
|
89
|
+
./bin/castor-cli profile list
|
|
90
|
+
./bin/castor-cli auth login --profile feature --username <username>
|
|
91
|
+
./bin/castor-cli auth status --profile feature
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
如果内部环境暂时使用 Kubernetes Ingress 默认自签名证书,可仅对对应 Profile 显式关闭 TLS 证书和主机名校验,然后再登录:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
./bin/castor-cli config set tls-skip-verify true --profile feature
|
|
98
|
+
./bin/castor-cli auth login --profile feature --username <username>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
该开关默认关闭,并会降低中间人攻击防护。服务端配置有效证书后应立即恢复:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
./bin/castor-cli config set tls-skip-verify false --profile feature
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
交互式登录会隐藏密码。Agent 或脚本应通过标准输入提供密码,不能把密码放进参数:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
printf '%s\n' "$CASTOR_PASSWORD" | ./bin/castor-cli auth login \
|
|
111
|
+
--profile feature \
|
|
112
|
+
--username <username> \
|
|
113
|
+
--password-stdin
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
配置文件只保存 Profile、Endpoint、用户名和 Token 引用。非 Windows 平台的 Token 使用本机随机主密钥进行 AES-GCM 加密,Windows 使用当前用户的 DPAPI。也可用 `CASTOR_AUTH_TOKEN` 临时覆盖安全存储中的 Token。
|
|
117
|
+
|
|
118
|
+
## 生成 OpenAPI 登记计划
|
|
119
|
+
|
|
120
|
+
先查询表单候选项:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
./bin/castor-cli openapi applications --profile feature
|
|
124
|
+
./bin/castor-cli openapi categories --profile feature
|
|
125
|
+
./bin/castor-cli openapi versions --profile feature
|
|
126
|
+
./bin/castor-cli openapi licenses --profile feature
|
|
127
|
+
./bin/castor-cli openapi urls --profile feature \
|
|
128
|
+
--application material-domain \
|
|
129
|
+
--category 物料
|
|
130
|
+
./bin/castor-cli openapi custom-fields --profile feature \
|
|
131
|
+
--application material-domain \
|
|
132
|
+
--url /web/v1/material/_create
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`applications` 直接返回 `bf-constant-code` 新建 API 表单使用的 27 个本地应用名称;前端该下拉框本身是静态清单,因此 CLI 不调用 `_applications` 接口。其他候选项仍从当前 Castor Profile 实时查询。
|
|
136
|
+
|
|
137
|
+
再获取合同并校验清单:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
./bin/castor-cli openapi registration schema
|
|
141
|
+
./bin/castor-cli openapi registration plan \
|
|
142
|
+
--file examples/openapi-registration.yaml
|
|
143
|
+
|
|
144
|
+
./bin/castor-cli openapi registration resolve \
|
|
145
|
+
--profile feature \
|
|
146
|
+
--file examples/openapi-registration.yaml
|
|
147
|
+
|
|
148
|
+
./bin/castor-cli openapi registration create \
|
|
149
|
+
--file examples/openapi-registration.yaml \
|
|
150
|
+
--dry-run
|
|
151
|
+
|
|
152
|
+
./bin/castor-cli openapi registration create \
|
|
153
|
+
--file examples/openapi-registration.yaml \
|
|
154
|
+
--confirm
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
表单映射规则:
|
|
158
|
+
|
|
159
|
+
- manifest 优先填写页面展示路径,例如 `apiCategoryPath: 生产 / 生产执行 / 生产工单`;CLI 会沿分类树解析出 `apiCategoryId` 和 `apiCategory`。
|
|
160
|
+
- `apiVersion` 是 Castor 发布计划版本,不是接口路径中的 `v1`。
|
|
161
|
+
- `modifyType`:1 新增、2 删除、3 更新;新增可填写页面名称 `licenseNames`,CLI 会解析成提交所需的 `licenseCode`。
|
|
162
|
+
- CustomFields 使用 `fieldPath + reqOrRes` 精确匹配;`null` 表示未配置,只有明确无效时使用 `InvalidCustomFields`。
|
|
163
|
+
- 后端按当前组织上下文、`applicationName`、`connectUrl`、`apiVersion` 判断重复;plan 会先拦截清单内部的重复业务键。
|
|
164
|
+
- 创建成功响应的 `data` 是登记记录 ID;CLI 会调用 `_detail` 读回并核对 ID 与业务键。
|
|
165
|
+
|
|
166
|
+
## 复制现有 OpenAPI 登记
|
|
167
|
+
|
|
168
|
+
先通过与页面一致的筛选接口查找源记录。名称使用包含查询;多个候选必须根据 ID、应用、URL、版本和状态人工确认:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
./bin/castor-cli openapi registration search \
|
|
172
|
+
--profile feature \
|
|
173
|
+
--name 工艺路线自定义字段详 \
|
|
174
|
+
--page 1 \
|
|
175
|
+
--size 20
|
|
176
|
+
|
|
177
|
+
./bin/castor-cli openapi registration detail \
|
|
178
|
+
--profile feature \
|
|
179
|
+
--id 1785866611428562
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
将详情转换为新的 registration manifest:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
./bin/castor-cli openapi registration export \
|
|
186
|
+
--profile feature \
|
|
187
|
+
--id 1785866611428562 \
|
|
188
|
+
--name 工艺路线自定义字段详情_copy
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
导出结果位于 `data.manifest`,不会包含源 ID、状态、创建人等服务端字段。CLI 会把详情中的 `categoryInfo` 还原为完整分类路径,并将 `fieldConfig` 解析成 `customFields`。修改 manifest 后继续使用既有的 `plan → resolve → create --dry-run → create --confirm` 流程。
|
|
192
|
+
|
|
193
|
+
如果用许可项页面名称覆盖原许可编码,应删除 `licenses` 并填写 `licenseNames`。若新 manifest 的 `applicationName + connectUrl + apiVersion` 与源记录完全一致,应先处理业务键重复问题。
|
|
194
|
+
|
|
195
|
+
## 发布 OpenAPI 登记记录
|
|
196
|
+
|
|
197
|
+
发布只处理 Castor 登记记录的 `待发布 → 已发布`,不执行 test、预发或生产环境同步。必须先 dry-run,再由用户确认:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
./bin/castor-cli openapi release \
|
|
201
|
+
--profile feature \
|
|
202
|
+
--id 1785866611428562 \
|
|
203
|
+
--dry-run
|
|
204
|
+
|
|
205
|
+
./bin/castor-cli openapi release \
|
|
206
|
+
--profile feature \
|
|
207
|
+
--id 1785866611428562 \
|
|
208
|
+
--confirm
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
发布前要求记录 `status=1` 且版本仍存在于当前 Profile 的发布计划;发布后按同一 ID 读回,核对业务键不变且 `status=2`。
|
|
212
|
+
|
|
213
|
+
## 同步已发布的 OpenAPI
|
|
214
|
+
|
|
215
|
+
同步来源固定为 `feature`。`--target` 表示 Castor 后端维护的同步目标,不是登录 Profile;可以先读取页面同源的动态环境列表:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
./bin/castor-cli openapi sync-targets --profile feature
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
单条同步必须先 dry-run,再由用户确认:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
./bin/castor-cli openapi sync \
|
|
225
|
+
--profile feature \
|
|
226
|
+
--id 1785866611428562 \
|
|
227
|
+
--target test \
|
|
228
|
+
--dry-run
|
|
229
|
+
|
|
230
|
+
./bin/castor-cli openapi sync \
|
|
231
|
+
--profile feature \
|
|
232
|
+
--id 1785866611428562 \
|
|
233
|
+
--target test \
|
|
234
|
+
--confirm
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
CLI 会把 `test`、目标数字代码或页面中的精确环境名称解析为实时环境列表中的代码。同步前要求源记录 `status=2` 且版本仍在发布计划中;每次只向批量接口提交一个 ID。
|
|
238
|
+
|
|
239
|
+
Castor 同步接口是同步调用:只有目标 OpenAPI 服务返回成功时 Castor 才返回成功。CLI 随后回读 feature 源记录,核对 ID、应用、URL、版本及已发布状态。当前后端没有提供目标环境的独立查询接口,因此输出会明确标记 `independent_target_readback=false`,不会把源记录回读描述成目标库二次查询。
|
|
240
|
+
|
|
241
|
+
配置默认从可执行文件同目录的 `config.yaml` 和 `~/.castor/config.yaml` 读取。仓库根目录的 `./config.yaml` 不会被隐式加载,可用 `--config` 指定其他文件。
|
|
242
|
+
|
|
243
|
+
## Skills
|
|
244
|
+
|
|
245
|
+
项目内 Skill 位于 `skills/`:
|
|
246
|
+
|
|
247
|
+
- `castor-shared`
|
|
248
|
+
- `castor-openapi-register`
|
|
249
|
+
- `castor-openapi-copy`
|
|
250
|
+
- `castor-openapi-release`
|
|
251
|
+
- `castor-openapi-sync`
|
|
252
|
+
- `castor-import-template`
|
|
253
|
+
|
|
254
|
+
部署 CLI 时,将所需 Skill 安装或链接到 Codex 可发现的 Skills 目录。Skill 通过 `castor-cli --help` 和 `openapi registration schema` 发现命令合同,不复制整套参数与 DTO。
|
|
255
|
+
|
|
256
|
+
## 发布
|
|
257
|
+
|
|
258
|
+
`.goreleaser.yaml` 已配置 Linux、macOS 和 Windows 的 amd64/arm64 构建。正式发布前仍需确定代码托管地址、制品仓库、版本策略和安装渠道。
|
package/checksums.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
7e3bcc8b0c17b613a339f2c10275a2a6259a4e14a7119b1eb2dc800e601a6468 castor-cli_0.1.0-beta.1_darwin_amd64.tar.gz
|
|
2
|
+
f2e5d91860eb084364119a08d7b5e94598dc7a999a5d0da5052e82054db12bac castor-cli_0.1.0-beta.1_darwin_arm64.tar.gz
|
|
3
|
+
a398e5ab6d224130a8a5aefea981829f5e4e3488d3227b2d17efd1f8fb4fa0a4 castor-cli_0.1.0-beta.1_linux_amd64.tar.gz
|
|
4
|
+
80c1574e44242e6dc1611c7ae99e132e29c0447790c91e96d1a88ba75f448306 castor-cli_0.1.0-beta.1_linux_arm64.tar.gz
|
|
5
|
+
c348c33392752ca135d5c8224267b8ccfd2a7750d67206de24c4d6c30d71bdac castor-cli_0.1.0-beta.1_windows_amd64.zip
|
|
6
|
+
9f08950887ba917099ebaf16a9b19d2bde89189a2478f8d100f9415d4cc9981c castor-cli_0.1.0-beta.1_windows_arm64.zip
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blacklake-tech-cn/castor-cli",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "Castor OpenAPI command-line client for people and AI agents",
|
|
5
|
+
"bin": {
|
|
6
|
+
"castor-cli": "scripts/run.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"postinstall": "node scripts/install.js",
|
|
10
|
+
"prepublishOnly": "node scripts/verify-publish.js",
|
|
11
|
+
"test": "node --check scripts/install.js && node --check scripts/run.js && node --check scripts/verify-publish.js && node --check scripts/verify-isolation.js && node scripts/npm-smoke-test.js && node scripts/verify-isolation.js"
|
|
12
|
+
},
|
|
13
|
+
"os": [
|
|
14
|
+
"darwin",
|
|
15
|
+
"linux",
|
|
16
|
+
"win32"
|
|
17
|
+
],
|
|
18
|
+
"cpu": [
|
|
19
|
+
"x64",
|
|
20
|
+
"arm64"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"registry": "https://registry.npmjs.org/"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"scripts/install.js",
|
|
32
|
+
"scripts/run.js",
|
|
33
|
+
"scripts/verify-publish.js",
|
|
34
|
+
"checksums.txt"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const http = require("http");
|
|
6
|
+
const https = require("https");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { execFileSync } = require("child_process");
|
|
10
|
+
|
|
11
|
+
const NAME = "castor-cli";
|
|
12
|
+
const VERSION = require("../package.json").version;
|
|
13
|
+
|
|
14
|
+
const PLATFORM_MAP = {
|
|
15
|
+
darwin: "darwin",
|
|
16
|
+
linux: "linux",
|
|
17
|
+
win32: "windows",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ARCH_MAP = {
|
|
21
|
+
x64: "amd64",
|
|
22
|
+
arm64: "arm64",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function resolveTarget(nodePlatform = process.platform, nodeArch = process.arch, version = VERSION) {
|
|
26
|
+
const platform = PLATFORM_MAP[nodePlatform];
|
|
27
|
+
const arch = ARCH_MAP[nodeArch];
|
|
28
|
+
if (!platform || !arch) {
|
|
29
|
+
throw new Error(`Unsupported platform: ${nodePlatform}-${nodeArch}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ext = platform === "windows" ? ".zip" : ".tar.gz";
|
|
33
|
+
const archiveName = `${NAME}_${version}_${platform}_${arch}${ext}`;
|
|
34
|
+
const binaryName = NAME + (platform === "windows" ? ".exe" : "");
|
|
35
|
+
return { platform, arch, archiveName, binaryName };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function binaryBaseUrl(env = process.env, version = VERSION) {
|
|
39
|
+
return (
|
|
40
|
+
env.CASTOR_CLI_BINARY_BASE_URL ||
|
|
41
|
+
env.npm_config_castor_cli_binary_base_url ||
|
|
42
|
+
`https://bl-v3-cli.oss-cn-shanghai.aliyuncs.com/castor-cli/releases/v${version}`
|
|
43
|
+
).replace(/\/+$/, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function downloadUrl(env = process.env) {
|
|
47
|
+
const target = resolveTarget();
|
|
48
|
+
return `${binaryBaseUrl(env)}/${target.archiveName}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function download(url, destPath, redirectCount = 0) {
|
|
52
|
+
if (redirectCount > 3) {
|
|
53
|
+
return Promise.reject(new Error(`Too many redirects while downloading ${url}`));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const parsed = new URL(url);
|
|
58
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
59
|
+
reject(new Error(`Unsupported download protocol: ${parsed.protocol}`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const client = parsed.protocol === "http:" ? http : https;
|
|
64
|
+
const request = client.get(parsed, (response) => {
|
|
65
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
|
|
66
|
+
response.resume();
|
|
67
|
+
const location = response.headers.location;
|
|
68
|
+
if (!location) {
|
|
69
|
+
reject(new Error(`Redirect from ${url} did not include a location header`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
download(new URL(location, url).toString(), destPath, redirectCount + 1).then(
|
|
73
|
+
resolve,
|
|
74
|
+
reject
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
80
|
+
response.resume();
|
|
81
|
+
reject(new Error(`Download failed with HTTP ${response.statusCode}: ${url}`));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const file = fs.createWriteStream(destPath);
|
|
86
|
+
response.pipe(file);
|
|
87
|
+
file.on("finish", () => file.close(resolve));
|
|
88
|
+
file.on("error", reject);
|
|
89
|
+
});
|
|
90
|
+
request.on("error", reject);
|
|
91
|
+
request.setTimeout(120000, () => {
|
|
92
|
+
request.destroy(new Error(`Download timed out: ${url}`));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractZipWindows(archivePath, destDir) {
|
|
98
|
+
const env = {
|
|
99
|
+
...process.env,
|
|
100
|
+
CASTOR_CLI_ARCHIVE: archivePath,
|
|
101
|
+
CASTOR_CLI_DEST: destDir,
|
|
102
|
+
};
|
|
103
|
+
const args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"];
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const dotnet =
|
|
107
|
+
"$ErrorActionPreference='Stop';" +
|
|
108
|
+
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
|
|
109
|
+
"[System.IO.Compression.ZipFile]::ExtractToDirectory($env:CASTOR_CLI_ARCHIVE,$env:CASTOR_CLI_DEST)";
|
|
110
|
+
execFileSync("powershell.exe", [...args, dotnet], { stdio: "inherit", env });
|
|
111
|
+
} catch (_) {
|
|
112
|
+
const cmdlet =
|
|
113
|
+
"$ErrorActionPreference='Stop';" +
|
|
114
|
+
"Expand-Archive -LiteralPath $env:CASTOR_CLI_ARCHIVE -DestinationPath $env:CASTOR_CLI_DEST -Force";
|
|
115
|
+
execFileSync("powershell.exe", [...args, cmdlet], { stdio: "inherit", env });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function extractArchive(archivePath, destDir, platform = process.platform) {
|
|
120
|
+
if (platform === "win32") {
|
|
121
|
+
extractZipWindows(archivePath, destDir);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
execFileSync("tar", ["-xzf", archivePath, "-C", destDir], { stdio: "inherit" });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getExpectedChecksum(archiveName, checksumsDir = path.join(__dirname, "..")) {
|
|
128
|
+
const checksumsPath = path.join(checksumsDir, "checksums.txt");
|
|
129
|
+
if (!fs.existsSync(checksumsPath)) {
|
|
130
|
+
throw new Error("checksums.txt is missing from the npm package");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const content = fs.readFileSync(checksumsPath, "utf8");
|
|
134
|
+
for (const line of content.split(/\r?\n/)) {
|
|
135
|
+
const parts = line.trim().split(/\s+/);
|
|
136
|
+
if (parts.length >= 2 && parts[1] === archiveName) {
|
|
137
|
+
return parts[0];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`Checksum entry not found for ${archiveName}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sha256(filePath) {
|
|
144
|
+
const hash = crypto.createHash("sha256");
|
|
145
|
+
const fd = fs.openSync(filePath, "r");
|
|
146
|
+
try {
|
|
147
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
148
|
+
let bytesRead;
|
|
149
|
+
while ((bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
|
|
150
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
fs.closeSync(fd);
|
|
154
|
+
}
|
|
155
|
+
return hash.digest("hex");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function verifyChecksum(archivePath, expectedHash) {
|
|
159
|
+
const actual = sha256(archivePath);
|
|
160
|
+
if (actual.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`[SECURITY] Checksum mismatch for ${path.basename(archivePath)}: expected ${expectedHash} but got ${actual}`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function install() {
|
|
168
|
+
const target = resolveTarget();
|
|
169
|
+
const binDir = path.join(__dirname, "..", "bin");
|
|
170
|
+
const dest = path.join(binDir, target.binaryName);
|
|
171
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `${NAME}-`));
|
|
174
|
+
const archivePath = path.join(tmpDir, target.archiveName);
|
|
175
|
+
try {
|
|
176
|
+
const url = downloadUrl();
|
|
177
|
+
await download(url, archivePath);
|
|
178
|
+
verifyChecksum(archivePath, getExpectedChecksum(target.archiveName));
|
|
179
|
+
extractArchive(archivePath, tmpDir);
|
|
180
|
+
|
|
181
|
+
const extractedBinary = path.join(tmpDir, target.binaryName);
|
|
182
|
+
if (!fs.existsSync(extractedBinary)) {
|
|
183
|
+
throw new Error(`Archive did not contain ${target.binaryName} at its root`);
|
|
184
|
+
}
|
|
185
|
+
fs.copyFileSync(extractedBinary, dest);
|
|
186
|
+
fs.chmodSync(dest, 0o755);
|
|
187
|
+
console.log(`${NAME} v${VERSION} installed successfully`);
|
|
188
|
+
} finally {
|
|
189
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (require.main === module) {
|
|
194
|
+
const isNpxPostinstall = process.env.npm_command === "exec" && !process.env.CASTOR_CLI_RUN;
|
|
195
|
+
if (isNpxPostinstall) {
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
install().catch((err) => {
|
|
200
|
+
console.error(`Failed to install ${NAME}: ${err.message}`);
|
|
201
|
+
console.error(
|
|
202
|
+
"\nIf release assets are hosted on another mirror, set:\n" +
|
|
203
|
+
" CASTOR_CLI_BINARY_BASE_URL=https://your-host/path/to/release/assets\n"
|
|
204
|
+
);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
ARCH_MAP,
|
|
211
|
+
PLATFORM_MAP,
|
|
212
|
+
binaryBaseUrl,
|
|
213
|
+
downloadUrl,
|
|
214
|
+
getExpectedChecksum,
|
|
215
|
+
install,
|
|
216
|
+
resolveTarget,
|
|
217
|
+
verifyChecksum,
|
|
218
|
+
};
|
package/scripts/run.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { execFileSync } = require("child_process");
|
|
6
|
+
|
|
7
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
8
|
+
const bin = path.join(__dirname, "..", "bin", "castor-cli" + ext);
|
|
9
|
+
|
|
10
|
+
function main(args = process.argv.slice(2)) {
|
|
11
|
+
if (!fs.existsSync(bin)) {
|
|
12
|
+
try {
|
|
13
|
+
execFileSync(process.execPath, [path.join(__dirname, "install.js")], {
|
|
14
|
+
stdio: "inherit",
|
|
15
|
+
env: { ...process.env, CASTOR_CLI_RUN: "true" },
|
|
16
|
+
});
|
|
17
|
+
} catch (_) {
|
|
18
|
+
console.error(
|
|
19
|
+
"\nFailed to auto-install castor-cli binary.\n" +
|
|
20
|
+
"If you use an internal release host, set CASTOR_CLI_BINARY_BASE_URL and retry.\n"
|
|
21
|
+
);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
execFileSync(bin, args, { stdio: "inherit" });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
process.exit(err.status || 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (require.main === module) {
|
|
34
|
+
main();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { bin, main };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const NAME = "castor-cli";
|
|
7
|
+
const VERSION = require("../package.json").version;
|
|
8
|
+
const EXPECTED_ARCHIVES = [
|
|
9
|
+
`${NAME}_${VERSION}_darwin_amd64.tar.gz`,
|
|
10
|
+
`${NAME}_${VERSION}_darwin_arm64.tar.gz`,
|
|
11
|
+
`${NAME}_${VERSION}_linux_amd64.tar.gz`,
|
|
12
|
+
`${NAME}_${VERSION}_linux_arm64.tar.gz`,
|
|
13
|
+
`${NAME}_${VERSION}_windows_amd64.zip`,
|
|
14
|
+
`${NAME}_${VERSION}_windows_arm64.zip`,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function verifyChecksums(options = {}) {
|
|
18
|
+
const checksumsPath = options.checksumsPath || path.join(__dirname, "..", "checksums.txt");
|
|
19
|
+
if (!fs.existsSync(checksumsPath)) {
|
|
20
|
+
throw new Error("checksums.txt is required before publishing the npm package");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const content = fs.readFileSync(checksumsPath, "utf8");
|
|
24
|
+
const missing = EXPECTED_ARCHIVES.filter((archiveName) => !content.includes(` ${archiveName}`));
|
|
25
|
+
if (missing.length > 0) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`checksums.txt does not match package version ${VERSION}. Missing: ${missing.join(", ")}`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (require.main === module) {
|
|
33
|
+
try {
|
|
34
|
+
verifyChecksums();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`Publish verification failed: ${err.message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { EXPECTED_ARCHIVES, verifyChecksums };
|