omnicrawl-cli 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ OmniCrawl 启动器:按 `platform-arch` 选平台分包,默认启动**完整
14
14
  | `@omnicrawl/cli-win32-ia32` | 仅内核(32 位 Windows 缺运行期轮子) |
15
15
  | `@omnicrawl/cli-linux-arm-musl` | 仅内核(armv7 嵌入式目标) |
16
16
 
17
- 宿主载荷是 PyInstaller one-dir 产物(`host/`):CPython 运行时、依赖、代码与资源都在里面,
17
+ 宿主载荷是 Rust 二进制(`host/`):`omnicrawl-host` / `omnicrawl-tui` / `omnicrawl-api` / `omnicrawl-mcp-server` 与内核同架构,`host-meta.json` 里 `hostKind: "rust"`;
18
18
  用户机器不需要 Python 或 pip。没有宿主载荷的平台由启动器退回内核直连,并打印这一边界。
19
19
 
20
20
  **平台分包、`os`/`cpu` 与平台依赖只出现在发布产物里**(`dist/npm/`)。仓库内的 `packages/cli*/package.json`
@@ -33,19 +33,44 @@ omnicrawl --version 打印启动器、平台包、宿主与内核版本
33
33
 
34
34
  无交互式终端时宿主会直接给出改用 `api` 的提示并以 2 退出,不会停在协议等待状态。
35
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
+
36
51
  ## 本地开发
37
52
 
38
53
  ```bash
39
54
  cargo build --release -p omnicrawl-cli # 产物:rust/target/release/omnicrawl[.exe]
40
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 走
41
57
  node packages/cli/scripts/prepare.mjs # 暂存发布产物(缺的平台只警告)
42
58
  node packages/cli/scripts/host-smoke.mjs # 宿主冒烟:无终端提示 + 退出码
43
59
  node packages/cli/scripts/install-smoke.mjs # 安装冒烟:干净前缀里真装真跑
44
60
  npm test -w omnicrawl-cli # e2e:真二进制 + 协议 v1
45
61
  ```
46
62
 
47
- 宿主载荷只能与构建机同架构(里面有 CPython 与原生扩展),交叉构建不成立:
48
- 其它平台由 CI 在各自 runner 上构建(见 `.github/workflows/publish-npm.yml`)。
63
+ 宿主载荷是纯 Rust 产物,按构建机的目标构建即可(`omnicrawl-tts` 的 ONNX 运行时已改成可选
64
+ feature,所以 musl 静态宿主载荷在 Windows 上交叉编译也能过,本机实测见
65
+ `rust/docs/python-free-build.md` 第 7 节):Windows 侧用 `--target` 指到与内核相同的三元组目录,
66
+ Linux 侧目前仍按 runner 原生 gnu 目标构建(aarch64 musl 宿主在 arm runner 上还没验证过)。
67
+ 若目标无法在构建机上构建(或宿主没接线),启动器会退回协议直连内核——内核可以单独交叉编译成
68
+ musl 静态。
69
+
70
+ 平台包里的内核二进制声明成 `omnicrawl-kernel`,**不能**叫 `omnicrawl`:启动器自己也声明了
71
+ `omnicrawl`,而平台包在安装树里同样会生成 `node_modules/.bin` 条目,撞名会让平台包抢走
72
+ `.bin/omnicrawl`,本地安装与 `npx` 下 `omnicrawl` 就变成直连内核(只打印 `omnicrawl 0.0.1`)。
73
+ 安装冒烟里有断言守住 `.bin/omnicrawl` 必须指向启动器。
49
74
 
50
75
  两个逃生口:`OMNICRAWL_HOST` 直接指定宿主、`OMNICRAWL_BINARY` 直接指定内核(此时启动器是纯透传)。
51
76
 
package/bin/omnicrawl.mjs CHANGED
@@ -10,6 +10,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',
@@ -130,7 +133,7 @@ function run(executable, args) {
130
133
  })
131
134
  }
132
135
 
133
- function main() {
136
+ async function main() {
134
137
  let targets
135
138
  try {
136
139
  targets = resolveTargets()
@@ -147,6 +150,16 @@ function main() {
147
150
  if (args.includes('--version') || args.includes('-V')) return printVersion(targets)
148
151
  if (args.includes('--help') || args.includes('-h')) return printHelp()
149
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
+
150
163
  if (targets.host && existsSync(targets.host)) return run(targets.host, args)
151
164
 
152
165
  // 嵌入式目标(armv7 等)没有宿主载荷:退回内核直连,并说明这一平台的边界。
@@ -157,4 +170,4 @@ function main() {
157
170
  return run(targets.kernel, args)
158
171
  }
159
172
 
160
- 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.1",
3
+ "version": "0.2.3",
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.1",
29
- "@omnicrawl/cli-win32-ia32": "0.2.1",
30
- "@omnicrawl/cli-linux-x64": "0.2.1",
31
- "@omnicrawl/cli-linux-arm-musl": "0.2.1",
32
- "@omnicrawl/cli-linux-arm64-musl": "0.2.1"
28
+ "@omnicrawl/cli-win32-x64": "0.2.3",
29
+ "@omnicrawl/cli-win32-ia32": "0.2.3",
30
+ "@omnicrawl/cli-linux-x64": "0.2.3",
31
+ "@omnicrawl/cli-linux-arm-musl": "0.2.3",
32
+ "@omnicrawl/cli-linux-arm64-musl": "0.2.3"
33
33
  }
34
34
  }