omnicrawl-cli 0.2.0 → 0.2.2
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 +78 -59
- package/bin/omnicrawl.mjs +28 -3
- package/bin/update.mjs +290 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,59 +1,78 @@
|
|
|
1
|
-
# omnicrawl-cli
|
|
2
|
-
|
|
3
|
-
OmniCrawl 启动器:按 `platform-arch` 选平台分包,默认启动**完整宿主**(工作台 / `api` / 插件 CLI);
|
|
4
|
-
`kernel` 子命令把 stdio 交给内核二进制(协议调试与 e2e)。
|
|
5
|
-
|
|
6
|
-
## 分发形态
|
|
7
|
-
|
|
8
|
-
| npm 包 | 内容 |
|
|
9
|
-
| --- | --- |
|
|
10
|
-
| `omnicrawl-cli` | JS 启动器(`bin/omnicrawl.mjs`) |
|
|
11
|
-
| `@omnicrawl/cli-win32-x64` | 内核二进制 + 宿主载荷(Windows x64) |
|
|
12
|
-
| `@omnicrawl/cli-linux-x64` | 内核二进制 + 宿主载荷(Linux x64,内核为 musl 静态) |
|
|
13
|
-
| `@omnicrawl/cli-linux-arm64-musl` | 内核二进制 + 宿主载荷(Linux arm64) |
|
|
14
|
-
| `@omnicrawl/cli-win32-ia32` | 仅内核(32 位 Windows 缺运行期轮子) |
|
|
15
|
-
| `@omnicrawl/cli-linux-arm-musl` | 仅内核(armv7 嵌入式目标) |
|
|
16
|
-
|
|
17
|
-
宿主载荷是 PyInstaller one-dir 产物(`host/`):CPython 运行时、依赖、代码与资源都在里面,
|
|
18
|
-
用户机器不需要 Python 或 pip。没有宿主载荷的平台由启动器退回内核直连,并打印这一边界。
|
|
19
|
-
|
|
20
|
-
**平台分包、`os`/`cpu` 与平台依赖只出现在发布产物里**(`dist/npm/`)。仓库内的 `packages/cli*/package.json`
|
|
21
|
-
刻意不带 `os`/`cpu`、也不声明平台分包依赖:npm 安装时会校验工作区成员的 `os`/`cpu`,带上就直接
|
|
22
|
-
`notsup` 失败。发布产物由 `scripts/prepare.mjs` 生成。
|
|
23
|
-
|
|
24
|
-
## 命令面
|
|
25
|
-
|
|
26
|
-
```
|
|
27
|
-
omnicrawl 启动工作台(TUI)
|
|
28
|
-
omnicrawl api 启动本地 HTTP 服务(无头/服务器场景)
|
|
29
|
-
omnicrawl plugin ... 插件管理
|
|
30
|
-
omnicrawl kernel ... 直连内核进程(协议调试)
|
|
31
|
-
omnicrawl --version 打印启动器、平台包、宿主与内核版本
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
无交互式终端时宿主会直接给出改用 `api` 的提示并以 2 退出,不会停在协议等待状态。
|
|
35
|
-
|
|
36
|
-
##
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
1
|
+
# omnicrawl-cli
|
|
2
|
+
|
|
3
|
+
OmniCrawl 启动器:按 `platform-arch` 选平台分包,默认启动**完整宿主**(工作台 / `api` / 插件 CLI);
|
|
4
|
+
`kernel` 子命令把 stdio 交给内核二进制(协议调试与 e2e)。
|
|
5
|
+
|
|
6
|
+
## 分发形态
|
|
7
|
+
|
|
8
|
+
| npm 包 | 内容 |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `omnicrawl-cli` | JS 启动器(`bin/omnicrawl.mjs`) |
|
|
11
|
+
| `@omnicrawl/cli-win32-x64` | 内核二进制 + 宿主载荷(Windows x64) |
|
|
12
|
+
| `@omnicrawl/cli-linux-x64` | 内核二进制 + 宿主载荷(Linux x64,内核为 musl 静态) |
|
|
13
|
+
| `@omnicrawl/cli-linux-arm64-musl` | 内核二进制 + 宿主载荷(Linux arm64) |
|
|
14
|
+
| `@omnicrawl/cli-win32-ia32` | 仅内核(32 位 Windows 缺运行期轮子) |
|
|
15
|
+
| `@omnicrawl/cli-linux-arm-musl` | 仅内核(armv7 嵌入式目标) |
|
|
16
|
+
|
|
17
|
+
宿主载荷是 PyInstaller one-dir 产物(`host/`):CPython 运行时、依赖、代码与资源都在里面,
|
|
18
|
+
用户机器不需要 Python 或 pip。没有宿主载荷的平台由启动器退回内核直连,并打印这一边界。
|
|
19
|
+
|
|
20
|
+
**平台分包、`os`/`cpu` 与平台依赖只出现在发布产物里**(`dist/npm/`)。仓库内的 `packages/cli*/package.json`
|
|
21
|
+
刻意不带 `os`/`cpu`、也不声明平台分包依赖:npm 安装时会校验工作区成员的 `os`/`cpu`,带上就直接
|
|
22
|
+
`notsup` 失败。发布产物由 `scripts/prepare.mjs` 生成。
|
|
23
|
+
|
|
24
|
+
## 命令面
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
omnicrawl 启动工作台(TUI)
|
|
28
|
+
omnicrawl api 启动本地 HTTP 服务(无头/服务器场景)
|
|
29
|
+
omnicrawl plugin ... 插件管理
|
|
30
|
+
omnicrawl kernel ... 直连内核进程(协议调试)
|
|
31
|
+
omnicrawl --version 打印启动器、平台包、宿主与内核版本
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
无交互式终端时宿主会直接给出改用 `api` 的提示并以 2 退出,不会停在协议等待状态。
|
|
35
|
+
|
|
36
|
+
## 启动自动更新
|
|
37
|
+
|
|
38
|
+
默认工作台路径(无子命令)启动时,启动器会检查 npm registry 上 `omnicrawl-cli` 的最新版:
|
|
39
|
+
落后就执行 `npm install -g omnicrawl-cli@<latest>`,成功后用原参数重新拉起启动器,
|
|
40
|
+
沿用新进程的退出码。语义与旧 Python 的 `maintenance/updater.py` 一路对齐,只是版本源换成
|
|
41
|
+
npm registry、升级方式换成 `npm install -g`。
|
|
42
|
+
|
|
43
|
+
- 版本结果缓存 24 小时(`~/.OmniCrawl/npm-version-check.json`),过期才请求 registry(3 秒超时)。
|
|
44
|
+
- `[update].enabled = false`(`~/.OmniCrawl/config.toml`)可关闭;缺省开启。
|
|
45
|
+
- 护栏:源码检出(祖先目录含 `.git`)、`OMNICRAWL_HOST` / `OMNICRAWL_BINARY` 逃生口、
|
|
46
|
+
已知子命令(`api` / `kernel` / `plugin`)都不做更新检查。
|
|
47
|
+
- `OMNICRAWL_SKIP_AUTO_UPDATE=1` 彻底关闭;升级尝试后写 `OMNICRAWL_AUTO_UPDATE_ATTEMPTED=1`
|
|
48
|
+
让重启的子进程不再重复尝试,避免升级失败时无限重启。
|
|
49
|
+
- 任何失败(离线、npm 不可用、安装报错)只打印原因与手动升级命令,继续用当前版本启动。
|
|
50
|
+
|
|
51
|
+
## 本地开发
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cargo build --release -p omnicrawl-cli # 产物:rust/target/release/omnicrawl[.exe]
|
|
55
|
+
node packages/cli/scripts/build-host.mjs # 宿主载荷:dist/host/<platform>/payload
|
|
56
|
+
node packages/cli/scripts/build-host.mjs --target x86_64-pc-windows-msvc # 产物目录跟 --target 走
|
|
57
|
+
node packages/cli/scripts/prepare.mjs # 暂存发布产物(缺的平台只警告)
|
|
58
|
+
node packages/cli/scripts/host-smoke.mjs # 宿主冒烟:无终端提示 + 退出码
|
|
59
|
+
node packages/cli/scripts/install-smoke.mjs # 安装冒烟:干净前缀里真装真跑
|
|
60
|
+
npm test -w omnicrawl-cli # e2e:真二进制 + 协议 v1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
宿主载荷是纯 Rust 产物,但仍按构建机的原生目标构建(里面没有 CPython,交叉构建在大多数
|
|
64
|
+
情况下也不再受限于 Python):Windows 侧用 `--target` 指到与内核相同的三元组目录;
|
|
65
|
+
Linux 侧的宿主载荷仍走原生 gnu 目标,因为 `omnicrawl-tts → ort-sys` 没有 musl 预编译库
|
|
66
|
+
(详见 `rust/docs/python-free-build.md` 第 7 节)。若目标无法在构建机上构建(或宿主没接线),
|
|
67
|
+
启动器会退回协议直连内核——内核可以单独交叉编译成 musl 静态。
|
|
68
|
+
|
|
69
|
+
两个逃生口:`OMNICRAWL_HOST` 直接指定宿主、`OMNICRAWL_BINARY` 直接指定内核(此时启动器是纯透传)。
|
|
70
|
+
|
|
71
|
+
## 发布
|
|
72
|
+
|
|
73
|
+
1. 升 `packages/cli/package.json` 版本(平台包与启动器同版本)。
|
|
74
|
+
2. 推 `npm-v<版本>` tag(CI 会校验 tag 与包版本一致)或手动触发 workflow:
|
|
75
|
+
矩阵各平台构建内核(+ 宿主)→ 宿主冒烟 → 暂存并上传产物;
|
|
76
|
+
发布任务下载全部产物 → 暂存启动器 → 安装冒烟 → 平台包在前、启动器在后依次发布。
|
|
77
|
+
|
|
78
|
+
发布前自检:`cargo test --workspace`、`npm test --workspaces`、`prepare.mjs --require-all --require-host`。
|
package/bin/omnicrawl.mjs
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
// OMNICRAWL_HOST=<可执行文件> 直接指定宿主,跳过平台分包
|
|
7
7
|
// OMNICRAWL_BINARY=<可执行文件> 直接指定内核;此时启动器退化为纯透传,便于协议测试
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { chmodSync, existsSync, readFileSync, statSync } from 'node:fs'
|
|
10
10
|
import { createRequire } from 'node:module'
|
|
11
11
|
import { dirname, join } from 'node:path'
|
|
12
12
|
import process from 'node:process'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
import { runStartupUpdateIfDue, shouldCheckUpdate } from './update.mjs'
|
|
13
16
|
|
|
14
17
|
const PLATFORM_PACKAGES = {
|
|
15
18
|
'linux-x64': '@omnicrawl/cli-linux-x64',
|
|
@@ -97,11 +100,23 @@ function printHelp() {
|
|
|
97
100
|
)
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
/** npm 打包会把未在 bin 里声明的文件权限位归一化成 0644,POSIX 下按需补回可执行位。 */
|
|
104
|
+
function ensureExecutable(path) {
|
|
105
|
+
if (IS_WINDOWS) return
|
|
106
|
+
try {
|
|
107
|
+
const mode = statSync(path).mode
|
|
108
|
+
if ((mode & 0o111) === 0) chmodSync(path, mode | 0o755)
|
|
109
|
+
} catch {
|
|
110
|
+
// 权限改不动(只读文件系统等)时交给 spawn 的错误说明原因。
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
/** 把 stdio 交给目标可执行文件,并把它的退出码/信号作为本次退出状态。 */
|
|
101
115
|
function run(executable, args) {
|
|
102
116
|
if (!executable || !existsSync(executable)) {
|
|
103
117
|
fail(`omnicrawl:找不到可执行文件 ${executable ?? '(未解析到)'}。请重装对应的平台包。`)
|
|
104
118
|
}
|
|
119
|
+
ensureExecutable(executable)
|
|
105
120
|
const child = spawn(executable, args, { stdio: 'inherit' })
|
|
106
121
|
|
|
107
122
|
child.on('error', (error) => {
|
|
@@ -118,7 +133,7 @@ function run(executable, args) {
|
|
|
118
133
|
})
|
|
119
134
|
}
|
|
120
135
|
|
|
121
|
-
function main() {
|
|
136
|
+
async function main() {
|
|
122
137
|
let targets
|
|
123
138
|
try {
|
|
124
139
|
targets = resolveTargets()
|
|
@@ -135,6 +150,16 @@ function main() {
|
|
|
135
150
|
if (args.includes('--version') || args.includes('-V')) return printVersion(targets)
|
|
136
151
|
if (args.includes('--help') || args.includes('-h')) return printHelp()
|
|
137
152
|
|
|
153
|
+
// 启动自动更新只走默认工作台路径;升级成功后重新拉起并沿用子进程退出码。
|
|
154
|
+
if (shouldCheckUpdate(args, process.env)) {
|
|
155
|
+
const code = await runStartupUpdateIfDue({
|
|
156
|
+
argv: args,
|
|
157
|
+
currentVersion: launcherVersion,
|
|
158
|
+
launcherPath: fileURLToPath(import.meta.url),
|
|
159
|
+
})
|
|
160
|
+
if (code !== null) process.exit(code)
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
if (targets.host && existsSync(targets.host)) return run(targets.host, args)
|
|
139
164
|
|
|
140
165
|
// 嵌入式目标(armv7 等)没有宿主载荷:退回内核直连,并说明这一平台的边界。
|
|
@@ -145,4 +170,4 @@ function main() {
|
|
|
145
170
|
return run(targets.kernel, args)
|
|
146
171
|
}
|
|
147
172
|
|
|
148
|
-
main()
|
|
173
|
+
main().catch((error) => fail(`omnicrawl:${error.message}`))
|
package/bin/update.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// 启动自动更新:检测 npm registry 上的 `omnicrawl-cli` 最新版,落后时全局升级并重启。
|
|
2
|
+
//
|
|
3
|
+
// 语义与 Python `omnicrawl/maintenance/{version_check,updater}.py` 一路对齐,差别只在版本源与
|
|
4
|
+
// 升级方式:版本源从 PyPI RSS 换成 npm registry 的 `latest`,升级方式从
|
|
5
|
+
// `pip install --upgrade` 换成 `npm install -g`。
|
|
6
|
+
//
|
|
7
|
+
// 流程(仅默认工作台路径,非逃生口):
|
|
8
|
+
//
|
|
9
|
+
// 1. 读取 `[update].enabled`(缺省启用),并检查环境变量跳过开关;
|
|
10
|
+
// 2. 读 24h 本地缓存,过期或缺失才请求 registry(3s 超时);
|
|
11
|
+
// 3. 本地版本落后时打印升级说明,执行 `npm install -g omnicrawl-cli@<latest>`;
|
|
12
|
+
// 4. 升级成功后重新拉起启动器(`node <launcher> <原参数>`),把子进程退出码作为本次退出码;
|
|
13
|
+
// 5. 任何失败(离线、npm 不可用、安装报错)都只打印原因与手动升级命令,继续用当前版本启动,
|
|
14
|
+
// 绝不让更新流程阻塞工具可用性。
|
|
15
|
+
//
|
|
16
|
+
// 安全护栏:
|
|
17
|
+
// - 源码检出 / 开发环境(祖先目录里出现 `.git`)直接跳过;
|
|
18
|
+
// - `OMNICRAWL_SKIP_AUTO_UPDATE=1` 可彻底关闭;
|
|
19
|
+
// - 逃生口 `OMNICRAWL_HOST` / `OMNICRAWL_BINARY`(协议测试与开发)不走更新检查;
|
|
20
|
+
// - 执行过一次升级尝试后写入 `OMNICRAWL_AUTO_UPDATE_ATTEMPTED=1`,重启后的子进程继承该标记
|
|
21
|
+
// 不再重复尝试,避免升级失败/版本探测异常时无限重启循环。
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { dirname, join, resolve } from 'node:path'
|
|
26
|
+
import process from 'node:process'
|
|
27
|
+
|
|
28
|
+
export const PACKAGE_NAME = 'omnicrawl-cli'
|
|
29
|
+
/** 彻底关闭自动更新(用户 / CI / 故障排查用)。 */
|
|
30
|
+
export const SKIP_AUTO_UPDATE_ENV = 'OMNICRAWL_SKIP_AUTO_UPDATE'
|
|
31
|
+
/** 标记「本次升级已经尝试过」,重启后的子进程据此不再重复尝试。 */
|
|
32
|
+
export const AUTO_UPDATE_ATTEMPTED_ENV = 'OMNICRAWL_AUTO_UPDATE_ATTEMPTED'
|
|
33
|
+
export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
|
|
34
|
+
export const VERSION_CHECK_TIMEOUT_MS = 3000
|
|
35
|
+
export const VERSION_CHECK_CACHE_TTL_SECONDS = 24 * 60 * 60
|
|
36
|
+
export const VERSION_CHECK_CACHE_FILENAME = 'npm-version-check.json'
|
|
37
|
+
export const USER_CONFIG_DIRNAME = '.OmniCrawl'
|
|
38
|
+
export const CONFIG_FILENAME = 'config.toml'
|
|
39
|
+
|
|
40
|
+
/** 已识别的子命令:这些路径不做启动更新(工作台才是自动更新的入口)。 */
|
|
41
|
+
const DISPATCH_COMMANDS = new Set(['api', 'kernel', 'plugin'])
|
|
42
|
+
/** `npm install -g` 的附加参数(减少噪音与交互)。 */
|
|
43
|
+
const NPM_QUIET_ARGS = ['--no-fund', '--no-audit']
|
|
44
|
+
|
|
45
|
+
const VERSION_PATTERN =
|
|
46
|
+
/^v?(?<release>\d+(?:\.\d+)*)(?:-(?<pre>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
|
|
47
|
+
|
|
48
|
+
/** 解析常见 semver(`1.2.3`、`1.2.3-rc.1`、`v1.2.3+build`);非法值返回 null。 */
|
|
49
|
+
export function parseVersion(value) {
|
|
50
|
+
const text = String(value ?? '').trim()
|
|
51
|
+
const match = VERSION_PATTERN.exec(text)
|
|
52
|
+
if (!match) return null
|
|
53
|
+
const release = match.groups.release.split('.').map((part) => Number.parseInt(part, 10))
|
|
54
|
+
if (release.some((part) => !Number.isFinite(part))) return null
|
|
55
|
+
const pre = match.groups.pre
|
|
56
|
+
return { release, pre: pre ? pre.split('.') : null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** semver 预发布标识比较:数字段按数值,字母段按字典序,数字段低于字母段。 */
|
|
60
|
+
function comparePre(a, b) {
|
|
61
|
+
if (a === null && b === null) return 0
|
|
62
|
+
if (a === null) return 1 // 无预发布高于有预发布
|
|
63
|
+
if (b === null) return -1
|
|
64
|
+
const width = Math.max(a.length, b.length)
|
|
65
|
+
for (let index = 0; index < width; index += 1) {
|
|
66
|
+
const left = a[index]
|
|
67
|
+
const right = b[index]
|
|
68
|
+
if (left === undefined) return -1
|
|
69
|
+
if (right === undefined) return 1
|
|
70
|
+
const leftNumber = /^\d+$/.test(left) ? Number.parseInt(left, 10) : null
|
|
71
|
+
const rightNumber = /^\d+$/.test(right) ? Number.parseInt(right, 10) : null
|
|
72
|
+
if (leftNumber !== null && rightNumber !== null) {
|
|
73
|
+
if (leftNumber !== rightNumber) return leftNumber < rightNumber ? -1 : 1
|
|
74
|
+
} else if (leftNumber !== null) {
|
|
75
|
+
return -1
|
|
76
|
+
} else if (rightNumber !== null) {
|
|
77
|
+
return 1
|
|
78
|
+
} else if (left !== right) {
|
|
79
|
+
return left < right ? -1 : 1
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 比较版本;任一侧非法(异常值)都按「不可升级」处理(Python `is_newer_version` 同口径)。 */
|
|
86
|
+
export function isNewerVersion(candidate, current) {
|
|
87
|
+
const candidateKey = parseVersion(candidate)
|
|
88
|
+
const currentKey = parseVersion(current)
|
|
89
|
+
if (!candidateKey || !currentKey) return false
|
|
90
|
+
|
|
91
|
+
const width = Math.max(candidateKey.release.length, currentKey.release.length)
|
|
92
|
+
for (let index = 0; index < width; index += 1) {
|
|
93
|
+
const left = candidateKey.release[index] ?? 0
|
|
94
|
+
const right = currentKey.release[index] ?? 0
|
|
95
|
+
if (left !== right) return left > right
|
|
96
|
+
}
|
|
97
|
+
return comparePre(candidateKey.pre, currentKey.pre) > 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 从 registry 的 `latest` 响应体解析版本号;解析失败抛错(调用方降级)。 */
|
|
101
|
+
export function parseRegistryVersion(payload) {
|
|
102
|
+
const body = typeof payload === 'string' ? JSON.parse(payload) : payload
|
|
103
|
+
const version = String(body?.version ?? '').trim()
|
|
104
|
+
if (!version || !parseVersion(version)) {
|
|
105
|
+
throw new Error('npm registry 的 latest 响应里没有有效版本。')
|
|
106
|
+
}
|
|
107
|
+
return version
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 默认取版本方式:直连 npm registry,带 3s 超时,失败抛错。 */
|
|
111
|
+
export async function fetchRegistryLatest(url = REGISTRY_URL) {
|
|
112
|
+
const response = await fetch(url, {
|
|
113
|
+
headers: { Accept: 'application/json', 'User-Agent': `${PACKAGE_NAME} version-check` },
|
|
114
|
+
signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS),
|
|
115
|
+
})
|
|
116
|
+
if (!response.ok) throw new Error(`npm registry 返回 ${response.status}`)
|
|
117
|
+
return parseRegistryVersion(await response.json())
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function cachePath(home) {
|
|
121
|
+
return join(home, USER_CONFIG_DIRNAME, VERSION_CHECK_CACHE_FILENAME)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readFreshCache(home, now) {
|
|
125
|
+
try {
|
|
126
|
+
const data = JSON.parse(readFileSync(cachePath(home), 'utf8'))
|
|
127
|
+
const checkedAt = Number(data.checked_at)
|
|
128
|
+
const latest = String(data.latest_version ?? '').trim()
|
|
129
|
+
const age = Math.max(0, now / 1000 - checkedAt)
|
|
130
|
+
if (age < VERSION_CHECK_CACHE_TTL_SECONDS && parseVersion(latest)) return latest
|
|
131
|
+
} catch {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeCache(home, now, latestVersion) {
|
|
138
|
+
const target = cachePath(home)
|
|
139
|
+
const temp = `${target}.${process.pid}.tmp`
|
|
140
|
+
try {
|
|
141
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
142
|
+
writeFileSync(
|
|
143
|
+
temp,
|
|
144
|
+
JSON.stringify({ checked_at: now / 1000, latest_version: latestVersion }),
|
|
145
|
+
'utf8',
|
|
146
|
+
)
|
|
147
|
+
renameSync(temp, target)
|
|
148
|
+
} catch {
|
|
149
|
+
// 缓存失败不应抹掉已经取得的在线检查结果。
|
|
150
|
+
} finally {
|
|
151
|
+
rmSync(temp, { force: true })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 读取 `[update].enabled`;缺省开启,配置异常也按开启处理(保底默认)。 */
|
|
156
|
+
export function loadUpdateEnabled(home = homedir()) {
|
|
157
|
+
try {
|
|
158
|
+
const text = readFileSync(join(home, USER_CONFIG_DIRNAME, CONFIG_FILENAME), 'utf8')
|
|
159
|
+
const section = text.match(/(?:^|\n)\[update\]\s*\n(?<body>[\s\S]*?)(?:\n\[|$)/)
|
|
160
|
+
if (!section) return true
|
|
161
|
+
const match = section.groups.body.match(/^\s*enabled\s*=\s*(true|false)\s*$/m)
|
|
162
|
+
if (!match) return true
|
|
163
|
+
return match[1] === 'true'
|
|
164
|
+
} catch {
|
|
165
|
+
return true // 更新功能不能因配置问题影响启动
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 判断是否从源码检出 / 开发环境运行(此类环境不自动更新)。
|
|
171
|
+
*
|
|
172
|
+
* npm 全局安装的启动器会落在 `node_modules/@omnicrawl/cli/bin` 这类目录里,祖先里没有 `.git`;
|
|
173
|
+
* 仓库内开发时祖先里有 `.git`。与 Python `is_source_checkout`(`pyproject.toml + .git`)同义。
|
|
174
|
+
*/
|
|
175
|
+
export function isSourceCheckout(launcherPath, maxDepth = 8) {
|
|
176
|
+
let current = dirname(resolve(launcherPath))
|
|
177
|
+
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
178
|
+
if (existsSync(join(current, '.git'))) return true
|
|
179
|
+
const parent = dirname(current)
|
|
180
|
+
if (parent === current) break
|
|
181
|
+
current = parent
|
|
182
|
+
}
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 是否需要为这组参数做启动更新:只认工作台路径,已知子命令与逃生口都跳过。 */
|
|
187
|
+
export function shouldCheckUpdate(args, env = process.env) {
|
|
188
|
+
if (env.OMNICRAWL_HOST || env.OMNICRAWL_BINARY) return false
|
|
189
|
+
const first = args[0]
|
|
190
|
+
if (first !== undefined && DISPATCH_COMMANDS.has(first)) return false
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildInstallCommand(latestVersion) {
|
|
195
|
+
const command = process.env.npm_execpath
|
|
196
|
+
return command
|
|
197
|
+
? [process.execPath, command, 'install', '-g', ...NPM_QUIET_ARGS, `${PACKAGE_NAME}@${latestVersion}`]
|
|
198
|
+
: ['npm', 'install', '-g', ...NPM_QUIET_ARGS, `${PACKAGE_NAME}@${latestVersion}`]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function runDefault(command) {
|
|
202
|
+
const child = spawn(command[0], command.slice(1), { stdio: 'inherit', shell: false })
|
|
203
|
+
return new Promise((resolveExit) => {
|
|
204
|
+
child.on('error', () => resolveExit(1))
|
|
205
|
+
child.on('exit', (code) => resolveExit(code ?? 1))
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function relaunchDefault(launcherPath, args) {
|
|
210
|
+
const child = spawn(process.execPath, [launcherPath, ...args], { stdio: 'inherit' })
|
|
211
|
+
return new Promise((resolveExit) => {
|
|
212
|
+
child.on('error', () => resolveExit(null))
|
|
213
|
+
child.on('exit', (code) => resolveExit(code ?? 0))
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function manualInstallHint(latestVersion) {
|
|
218
|
+
return `可手动执行升级:npm install -g ${PACKAGE_NAME}@${latestVersion}`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 启动阶段自动更新入口。
|
|
223
|
+
*
|
|
224
|
+
* @returns `null`:无需更新 / 已跳过 / 更新失败按用户策略继续用当前版本启动;
|
|
225
|
+
* 其他整数:已升级并重启,返回新进程的退出码,调用方应直接退出。
|
|
226
|
+
*/
|
|
227
|
+
export async function runStartupUpdateIfDue({
|
|
228
|
+
argv = [],
|
|
229
|
+
currentVersion,
|
|
230
|
+
launcherPath = new URL(import.meta.url).pathname,
|
|
231
|
+
env = process.env,
|
|
232
|
+
home = homedir(),
|
|
233
|
+
print = (line) => console.error(line),
|
|
234
|
+
now = Date.now,
|
|
235
|
+
latestChecker = null,
|
|
236
|
+
installer = null,
|
|
237
|
+
relauncher = null,
|
|
238
|
+
sourceCheckout = null,
|
|
239
|
+
configEnabled = null,
|
|
240
|
+
} = {}) {
|
|
241
|
+
if (env[SKIP_AUTO_UPDATE_ENV] === '1') return null
|
|
242
|
+
if (env[AUTO_UPDATE_ATTEMPTED_ENV] === '1') return null
|
|
243
|
+
if (env.OMNICRAWL_HOST || env.OMNICRAWL_BINARY) return null
|
|
244
|
+
if ((sourceCheckout ?? isSourceCheckout)(launcherPath)) return null
|
|
245
|
+
if (!(configEnabled ?? loadUpdateEnabled)(home)) return null
|
|
246
|
+
|
|
247
|
+
const installed = String(currentVersion ?? '').trim()
|
|
248
|
+
let latest = null
|
|
249
|
+
try {
|
|
250
|
+
latest = readFreshCache(home, now())
|
|
251
|
+
if (latest === null) {
|
|
252
|
+
latest = latestChecker
|
|
253
|
+
? await latestChecker()
|
|
254
|
+
: await fetchRegistryLatest()
|
|
255
|
+
if (latest) writeCache(home, now(), latest)
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
// 版本检查不能改变应用可用性;离线、代理和缓存损坏均只隐藏更新提示。
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
if (!latest || !isNewerVersion(latest, installed)) return null
|
|
262
|
+
|
|
263
|
+
// 先标记尝试,避免子进程(继承环境变量)在升级后因探测不一致再次升级。
|
|
264
|
+
env[AUTO_UPDATE_ATTEMPTED_ENV] = '1'
|
|
265
|
+
print(`发现新版本 ${latest}(当前 ${installed}),正在自动升级…`)
|
|
266
|
+
|
|
267
|
+
let installedOk = false
|
|
268
|
+
try {
|
|
269
|
+
installedOk = (await (installer
|
|
270
|
+
? installer(latest)
|
|
271
|
+
: runDefault(buildInstallCommand(latest)))) === 0
|
|
272
|
+
} catch {
|
|
273
|
+
installedOk = false
|
|
274
|
+
}
|
|
275
|
+
if (!installedOk) {
|
|
276
|
+
print(`自动升级失败,本次继续使用当前版本 ${installed} 启动。`)
|
|
277
|
+
print(manualInstallHint(latest))
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
print(`升级完成(${installed} → ${latest}),正在重新启动工作台…`)
|
|
282
|
+
const code = await (relauncher
|
|
283
|
+
? relauncher(launcherPath, argv)
|
|
284
|
+
: relaunchDefault(launcherPath, argv))
|
|
285
|
+
if (code === null) {
|
|
286
|
+
print('重新启动工作台失败,请手动执行 omnicrawl 重新进入。')
|
|
287
|
+
return 0
|
|
288
|
+
}
|
|
289
|
+
return code
|
|
290
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omnicrawl-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "OmniCrawl 启动器:按平台启动完整宿主(工作台 / api / 插件)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"optionalDependencies": {
|
|
28
|
-
"@omnicrawl/cli-win32-x64": "0.2.
|
|
29
|
-
"@omnicrawl/cli-win32-ia32": "0.2.
|
|
30
|
-
"@omnicrawl/cli-linux-x64": "0.2.
|
|
31
|
-
"@omnicrawl/cli-linux-arm-musl": "0.2.
|
|
32
|
-
"@omnicrawl/cli-linux-arm64-musl": "0.2.
|
|
28
|
+
"@omnicrawl/cli-win32-x64": "0.2.2",
|
|
29
|
+
"@omnicrawl/cli-win32-ia32": "0.2.2",
|
|
30
|
+
"@omnicrawl/cli-linux-x64": "0.2.2",
|
|
31
|
+
"@omnicrawl/cli-linux-arm-musl": "0.2.2",
|
|
32
|
+
"@omnicrawl/cli-linux-arm64-musl": "0.2.2"
|
|
33
33
|
}
|
|
34
34
|
}
|