@geoly-ai/skills-hub 0.3.0 → 0.3.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.
@@ -19,6 +19,24 @@ process.emit = function (name, data, ...rest) {
19
19
  for (const k of Object.keys(process.env)) {
20
20
  if (k.startsWith('GEOLY_FAULT')) delete process.env[k];
21
21
  }
22
+ // 🔴 认 HTTP_PROXY / HTTPS_PROXY / NO_PROXY —— **必须在第一次 fetch 之前**。
23
+ //
24
+ // Node 的内建 fetch(undici)**默认不认代理环境变量**,而 curl / npm / git 都认。
25
+ // 后果不是「慢一点」:在企业代理后面,`install` 会以 `UND_ERR_CONNECT_TIMEOUT`
26
+ // 直接失败,而同一台机器上 `curl` 同一个地址是通的 —— 于是看起来像我们的
27
+ // registry 挂了。2026-09-03 在开发机上实测到,绕了两圈才找到。
28
+ //
29
+ // ⚠️ `NODE_USE_ENV_PROXY` 是 Node **24** 引入的;22.x 上这一行**无效**,
30
+ // 代理后面的 22.x 用户仍然连不上。这是已知缺口,不要写成已解决。
31
+ //
32
+ // 📌 代理不削弱安全性:HTTPS 走 CONNECT 隧道,TLS 仍是端到端;
33
+ // 而且我们对取回的字节做的是**签名验证**,一个恶意代理改了字节只会验签失败。
34
+ //
35
+ // 🔴 只在**用户没有显式表态**时设置:已经设过(哪怕设成 '0')就尊重用户的选择。
36
+ if (process.env.NODE_USE_ENV_PROXY === undefined) {
37
+ process.env.NODE_USE_ENV_PROXY = '1';
38
+ }
39
+
22
40
  const { lockdown } = await import('../src/fault-inject.mjs');
23
41
  lockdown();
24
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoly-ai/skills-hub",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "geoly-ai 的 skill 分发中心 —— 安装、校验、审计",
5
5
  "type": "module",
6
6
  "bin": {
@@ -176,6 +176,32 @@ export function uintArg(name, val) {
176
176
  * @param {object} globals parseGlobals 的产物
177
177
  * @param {object} deps 🔴 只从 `main(argv, deps)` 的第二个形参来,argv/env 到不了这里
178
178
  */
179
+ /**
180
+ * 本 CLI 自己的版本 —— 🔴 **从 package.json 读,不要硬编码。**
181
+ *
182
+ * ⚠️ 这里原本是字面量 `'0.0.0-m1'`。后果不是「显示得不好看」:
183
+ * `timestamp.min_cli_version` 是按真实版本号比对的策略门,
184
+ * 而 `bin/skills-hub.mjs` 一个 dep 都不传 —— 于是**发布出去的 CLI
185
+ * 自报 0.0.0-m1,会被自己的 min_cli_version 当场挡死**。
186
+ * 2026-09-03 首次端到端安装时撞到:timestamp 写着 0.3.0,
187
+ * 而刚从 npm 装下来的 0.3.0 报的是 0.0.0-m1 → E_MIN_CLI_VERSION。
188
+ *
189
+ * 🔴 读不到就**抛**,不要退回一个假版本号:一个编出来的版本号会让
190
+ * 版本门做出错误判定,而那正是这道门要防的事。
191
+ */
192
+ let cachedVersion;
193
+ export function ownVersion() {
194
+ if (cachedVersion === undefined) {
195
+ const p = new URL('../../package.json', import.meta.url);
196
+ const v = JSON.parse(readFileSync(p, 'utf8')).version;
197
+ if (typeof v !== 'string' || v === '') {
198
+ throw new Error('读不出本 CLI 的版本号(package.json 的 version 不是非空字符串)');
199
+ }
200
+ cachedVersion = v;
201
+ }
202
+ return cachedVersion;
203
+ }
204
+
179
205
  export function makeContext(globals, deps = {}) {
180
206
  const env = deps.env ?? process.env;
181
207
  const home = deps.home ?? homedir();
@@ -213,7 +239,7 @@ export function makeContext(globals, deps = {}) {
213
239
  cacheDir,
214
240
  /** 🔴 时间只从这里取:canonical JSON 要求严格 `YYYY-MM-DDTHH:MM:SSZ`,测试要能定死它 */
215
241
  now: deps.now ?? (() => new Date()),
216
- cliVersion: deps.cliVersion ?? env.GEOLY_CLI_VERSION ?? '0.0.0-m1',
242
+ cliVersion: deps.cliVersion ?? env.GEOLY_CLI_VERSION ?? ownVersion(),
217
243
  /**
218
244
  * 🔴 验签器**没有逃生口**:`deps.verifier` 只有 `main(argv, deps)` 的调用方能给,
219
245
  * 而生产入口 `bin/skills-hub.mjs` 一个 dep 都不传。
@@ -16,8 +16,10 @@
16
16
  import { existsSync } from 'node:fs';
17
17
  import { resolveCurrent } from '../snapshot.mjs';
18
18
  import { readTrustFloor, resolveStateDir } from '../trust.mjs';
19
+ import { mkdirChainFsync } from '../atomic-fs.mjs';
19
20
  import { preheatAssets, preheatMetadata, promoteMetadata, discard, newBudget } from '../preheat.mjs';
20
21
  import { createCacheRegistry } from './registry.mjs';
22
+ import { getVerifier } from './snapshot-access.mjs';
21
23
 
22
24
  /**
23
25
  * 联网刷新一次 metadata(timestamp + 当前快照)到本地缓存。
@@ -28,6 +30,16 @@ export async function preheatOnce({
28
30
  cacheDir, stateDir, verifier, cliVersion, now, fetchImpl, timeoutMs,
29
31
  budget = newBudget(),
30
32
  }) {
33
+ // 🔴 preheat 现在是**第一个**碰这两个目录的人。
34
+ // 在它之前,建目录那一步藏在 `resolveSnapshotForCommand()` 里面 ——
35
+ // 而 preheat 排在它前面,于是干净 home 上第一次安装直接 ENOENT。
36
+ // 2026-09-03 端到端撞到:`lstat '<home>/.local'`。
37
+ // ⚠️ 顺序要紧:`resolveStateDir()` 内部是 `realpathSync`,**要求目录已存在**。
38
+ // 我第一版写成 `mkdirChainFsync(resolveStateDir(stateDir))` —— 先解析后创建,
39
+ // 在干净 home 上必然 ENOENT,而且报的是 `lstat '<home>/.local'`,
40
+ // 看起来完全不像「目录还没建」。
41
+ mkdirChainFsync(cacheDir);
42
+ mkdirChainFsync(stateDir);
31
43
  const { stagingDir, n } = await preheatMetadata({ cacheDir, fetchImpl, timeoutMs, budget });
32
44
  try {
33
45
  // 🔴 registry 指向 **staging**,不是 cache —— 验的必须是刚下回来的那份。
@@ -70,10 +82,23 @@ export async function preheatForInstall(ctx, out) {
70
82
  if (ctx.registryFactory) return { refreshed: false, reason: 'custom-registry' };
71
83
  const haveCache = existsSync(`${ctx.cacheDir}/timestamp.json`);
72
84
  try {
85
+ // 🔴 `ctx.verifier` 的缺省值是 **null**(不是 undefined)——
86
+ // 直接传会得到 `E_VERIFIER_MISSING`。命令面统一走 `getVerifier()`,
87
+ // 它在没注入时落到**真验签器**(内置信任根)。
88
+ // ⚠️ 与 fetchImpl 是**同一个形状**:注入点缺省为 null,而下游按
89
+ // 「没给就用默认」写。一处栽了就该全仓找同形状的——这是第二处。
73
90
  const r = await preheatOnce({
74
91
  cacheDir: ctx.cacheDir, stateDir: ctx.stateDir,
75
- verifier: ctx.verifier, cliVersion: ctx.cliVersion, now: ctx.now,
76
- fetchImpl: ctx.fetchImpl,
92
+ verifier: await getVerifier(ctx),
93
+ cliVersion: ctx.cliVersion,
94
+ // 🔴 `ctx.now` 是**函数**(`() => new Date()`),而 `resolveCurrent` 要的是
95
+ // **毫秒数**。直接传函数会一路走到 `makeFloor` 里 `new Date(fn).toISOString()`
96
+ // → `RangeError: Invalid time value`,报出来的话完全看不出是这儿。
97
+ // 命令面原本就写着 `now: ctx.now().getTime()`(snapshot-access.mjs:54)——
98
+ // ⚠️ 这是我今天**第三次**把 ctx 字段原样传下去而没看形状
99
+ // (前两次:fetchImpl 与 verifier 的缺省是 null)。
100
+ now: ctx.now().getTime(),
101
+ fetchImpl: ctx.fetchImpl ?? undefined,
77
102
  });
78
103
  if (r.refreshed) out?.note?.(`已刷新到快照 ${r.n}`);
79
104
  return r;
package/src/download.mjs CHANGED
@@ -135,11 +135,18 @@ export async function download(url, {
135
135
  redirectHosts = REDIRECT_HOSTS,
136
136
  maxRedirects = MAX_REDIRECTS,
137
137
  cap = MAX_DOWNLOAD_BYTES,
138
- fetchImpl = globalThis.fetch,
138
+ fetchImpl,
139
139
  timeoutMs = DEFAULT_TIMEOUT_MS,
140
140
  what = url,
141
141
  } = {}) {
142
142
  assertDownloadUrl(url, host);
143
+ // 🔴 用 `??` 而不是默认参数:**默认参数只对 `undefined` 生效**。
144
+ // `commands/context.mjs` 里 `fetchImpl` 的缺省值是 **`null`**(与 verifier
145
+ // 等注入点一致),null 会原样穿过默认参数,于是生产路径上拿到的是 null。
146
+ // ⚠️ 单元测试**永远抓不到这一条** —— 它们每次都注入替身,走不到缺省分支。
147
+ // 2026-09-03 干净 home 端到端首次安装时才炸出来:
148
+ // 「当前 Node 没有内建 fetch」 —— 而 Node 25 明明有。
149
+ fetchImpl = fetchImpl ?? globalThis.fetch;
143
150
  if (typeof fetchImpl !== 'function') {
144
151
  throw new NetworkError('当前 Node 没有内建 fetch,且调用方没有注入 fetchImpl');
145
152
  }
@@ -164,7 +171,18 @@ export async function download(url, {
164
171
  });
165
172
  } catch (e) {
166
173
  if (ac.signal.aborted) throw new NetworkError(`${what} 下载超时(${timeoutMs} ms)`);
167
- throw new NetworkError(`${what} 下载失败:${e.message}`);
174
+ // 🔴 **必须带上 `e.cause`。** undici 抛的永远是 `TypeError: fetch failed`,
175
+ // 真因(ETIMEDOUT / UND_ERR_CONNECT_TIMEOUT / ENOTFOUND / 证书错误…)
176
+ // 全在 `cause` 里。只报 message 的话用户看到的就是一句
177
+ // 「fetch failed」—— 什么都定位不了。
178
+ // 2026-09-03 我自己被这句话挡了两轮,最后是手写探针才挖出
179
+ // UND_ERR_CONNECT_TIMEOUT,进而发现是代理没被认。
180
+ const why = e.cause?.code ?? e.cause?.message ?? e.code ?? '';
181
+ const hint = /TIMEOUT|ETIMEDOUT|ECONNREFUSED/.test(String(why))
182
+ ? '\n 连不上。若你在代理后面:本 CLI 认 HTTPS_PROXY / NO_PROXY,'
183
+ + '但那需要 Node ≥ 24(当前 ' + process.version + ')。'
184
+ : '';
185
+ throw new NetworkError(`${what} 下载失败:${e.message}${why ? `(${why})` : ''}${hint}`);
168
186
  }
169
187
 
170
188
  if (res.status >= 300 && res.status < 400) {