@geoly-ai/skills-hub 0.1.0 → 0.2.0

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
@@ -6,18 +6,66 @@
6
6
  `geoly-ai` 的 skill 分发中心:一条命令装单个 skill、装矩阵包,
7
7
  并支持外部投稿与过审。
8
8
 
9
- > ⚠️ **M0 规格已封版,M1 实现刚起步** —— 目前只有基础模块和埋点子系统,
10
- > 装 skill 的主流程还没接通。
9
+ ## 安装
10
+
11
+ ```sh
12
+ npm i -g @geoly-ai/skills-hub # 或 npx @geoly-ai/skills-hub <命令>
13
+ skills-hub --help
14
+ ```
15
+
16
+ **已发布**:[`@geoly-ai/skills-hub@0.1.0`](https://www.npmjs.com/package/@geoly-ai/skills-hub)
17
+ (46 个文件,带 npm provenance;发布 workflow 会用**本包自带的验签器 + 内置信任根**
18
+ 自验一遍它自己签的 tarball)。
19
+
20
+ 平台:**macOS / Linux / WSL**,**Node ≥ 22.13**。
21
+
22
+ ### 当前能装到哪几端
23
+
24
+ | client | 全局 | 项目级 | 说明 |
25
+ |---|:--:|:--:|---|
26
+ | `claude` | ✅ | ✅ | |
27
+ | `codex` | ✅ | ✅ | |
28
+ | `agents` | ✅ | ✅ | **present-only**:`.agents` 已存在才加入,**不会被创建** |
29
+ | `cursor` | ❌ | ❌ | 无运行时证据,且静态读其加载器**预判会失败**(R-8) |
30
+
31
+ ⚠️ `codex` 与 `agents` 同时装时,同一个 skill 会在 codex 的 catalog 里出现两次 ——
32
+ 这两个位置本身重叠,CLI 会告警但不拦截。
11
33
 
12
34
  ## 现在在哪一步
13
35
 
14
36
  | 阶段 | 状态 |
15
37
  |---|---|
16
- | **M0 · 制品与信任模型** | ✅ **已通过**(v45,2026-08-25) |
17
- | M1 · 只读分发(单 skill resolve / install / list / check | 🚧 进行中 —— 信任链与 adapter 已就绪,事务内核在做 |
18
- | M2 · pack 与受控 catalog | |
38
+ | **M0 · 制品与信任模型** | ✅ 已通过(v45,2026-08-25) |
39
+ | **M1 · 只读分发** | **已完成并发布 0.1.0** —— resolve / install / recover / check / list-search-why / sync-lock |
40
+ | **M2 · pack 与受控 catalog** | 🚧 进行中 —— 命令面已齐(`vendor` / `install pack:` / `install --all`);promotion 的**派生**那一半已就绪(`scripts/build-snapshot.mjs`),元数据来源待 M3 |
19
41
  | M3 · 投稿与审核 | — |
20
- | M4 · update / remove / 正式发包 | — |
42
+ | M4 · update / remove | — |
43
+
44
+ **930 个测试**在 Node 22.13.0 / 24.19.0 双版本全绿;穷举崩溃注入(真内核 51 个注入点
45
+ 逐个反向命中)是 CI 的合并门。
46
+
47
+ ### 🔴 0.1.0 明确**没有**做到的
48
+
49
+ 不写清楚就等于默认承诺了,所以逐条列出:
50
+
51
+ - **制品(skill tar.gz)与快照的签发链不存在** —— packer 是 M2。
52
+ 这一版签的是 **npm 包本身**(provenance + cosign 对 `.tgz` 的签名),不是 skill 制品。
53
+ ⚠️ M2 进行中:`scripts/build-snapshot.mjs` 已能从 `artifacts/**` 确定性地**构建**
54
+ 快照与资产(打包、摘要、pack 的 clients 交集 / capabilities 并集、`degraded` 重算、
55
+ `latest` 投影),但**它不签名** —— 签名仍是 release workflow 的事,尚未接上。
56
+ 且 record 必填的 `owner` / `review`(以及 pack 的 `provenance`,`pack.json` 里没有
57
+ 这个字段)目前由显式的 `--inputs` 提供,M3 的投稿流水线接上后才自动产出。
58
+ - **registry 是纯缓存,没有网络客户端** —— `resolveCurrent()` 是同步的,接不进 `fetch`。
59
+ - **`--from-generation` 只做到编译计划**,接成正向事务的入口还没写。
60
+ - **`--release-frozen` 如实拒绝**(没有按 label 解冻 attic 的导出),不提供假装成功的路径。
61
+ - `cursor` 未验证;`search` 搜不了 description(快照 record 里没有这个字段)。
62
+
63
+ 已知且**明确接受**的残余风险见 [`docs/m1/01-residual-risks.md`](docs/m1/01-residual-risks.md)(R-1 … R-11)
64
+ 与 [`docs/m2/01-residual-risks.md`](docs/m2/01-residual-risks.md)(R-12 … R-16),
65
+ M0 正文的勘误见 [`docs/m0/ERRATA.md`](docs/m0/ERRATA.md)(E-1 … E-8)。
66
+
67
+ M2 交出了什么、**明确没做到什么**、以及三条待拍板项,见
68
+ [`docs/m2/00-delivery.md`](docs/m2/00-delivery.md)。
21
69
 
22
70
  ## 从哪读起
23
71
 
@@ -79,13 +127,23 @@ client 生成,`test/adapters.test.mjs` 用真 git 仓库验证过它确实忽
79
127
 
80
128
  ## 埋点与面板
81
129
 
82
- 规格:[`docs/telemetry/00-spec.md`](docs/telemetry/00-spec.md)(v2,已过 Codex 评审)。
130
+ 规格:[`docs/telemetry/00-spec.md`](docs/telemetry/00-spec.md)(v6,已过六轮 Codex 评审)。
131
+ 端点实现见 [`server/`](server/)。
132
+
133
+ 🔴 **上报默认开**(2026-09-01 起,规格 §4.2)—— CLI 有内置默认端点。
134
+ 首次运行会打印一次告知(收什么、发到哪、怎么关),**这段告知一定先于第一次出网**。
135
+ 🔴 **一次 `install` 成功收尾后会静默上报一次**(规格 §5.1.1,2026-09-01 起):
136
+ 24 小时最多一次,网络那一段超时 1 秒,发不出去就留在本地等下次,不影响安装结果、
137
+ 也不改退出码。`install` 失败(含部分失败)不发;`check` / `list` / `stats` 等命令
138
+ 只写本地,不出网。也可以随时 `skills-hub telemetry flush` 手动发。
83
139
 
84
- 🔴 **默认不向任何地方发数据** —— 没有内置端点,不配 `GEOLY_TELEMETRY_ENDPOINT`
85
- 就是纯本地。事件只含制品坐标、客户端、操作、结果、耗时,**不含路径、目录清单、
86
- 文件内容、用户名**;这条契约由 `assertValidEvent()` 在落盘/读回/上报/导出四个边界执行。
140
+ 事件只含制品坐标、客户端、操作、结果、耗时、CLI/OS/Node 版本和一个本机随机 ID,
141
+ **不含路径、目录清单、文件内容、用户名、命令行原文、异常栈**;
142
+ 这条契约由 `assertValidEvent()` 在落盘/读回/上报/导出四个边界执行,
143
+ **端点侧跑的是同一个校验器**(不另写一份,两份必然分叉)。
87
144
 
88
145
  - 关掉:`GEOLY_TELEMETRY=0` 只留本地:`GEOLY_TELEMETRY_UPLOAD=0` 断网:`--offline`
146
+ (⚠️ `GEOLY_TELEMETRY_ENDPOINT=` 空值是**配置错误**,不是关闭开关)
89
147
  - 面板:[`docs/dashboard/`](docs/dashboard/)(零依赖静态页,
90
148
  `skills-hub stats --export` 出的 JSON 拖进去即可)
91
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoly-ai/skills-hub",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "geoly-ai 的 skill 分发中心 —— 安装、校验、审计",
5
5
  "type": "module",
6
6
  "bin": {
package/src/artifact.mjs CHANGED
@@ -190,6 +190,44 @@ const PACK_MANIFEST_KEYS = {
190
190
  };
191
191
 
192
192
  /** 最小 YAML frontmatter 子集:`---` 包围、单行 `key: value`。其余一律拒绝。 */
193
+ /**
194
+ * SKILL.md frontmatter 的全部检查 —— **投稿门与建快照必须调同一个**。
195
+ *
196
+ * 🔴 2026-09-02:投稿侧的结构门**根本没解析过 frontmatter**,于是 11 个投稿
197
+ * 全绿合并进 main,promote 建快照时才红在 `E_FRONTMATTER`(10 个用了 YAML
198
+ * 折叠标量 `>`,而这里的解析器是刻意最小化的、只认单行 `key: value`)。
199
+ * 那正是本仓库反复警告的「**PR 时绿、promote 时红**」——
200
+ * 投稿已经在 main 上了,改起来要走一整轮。
201
+ *
202
+ * ⚠️ 所以这段逻辑抽成一个函数、两处调用,而**不是**在投稿门里另写一份:
203
+ * 另写一份就是又一处会分叉的实现。
204
+ *
205
+ * @param {object} a
206
+ * @param {string} a.payloadDir
207
+ * @param {string} a.name 期望的 name(来自 record / 目录名)
208
+ * @param {(code:string,msg:string)=>void} a.viol 报错回调
209
+ * @returns {object|null} 解析出来的 frontmatter
210
+ */
211
+ export function assertSkillFrontmatter({ payloadDir, name, viol }) {
212
+ const sp = join(payloadDir, 'SKILL.md');
213
+ if (!existsSync(sp) || !statSync(sp).isFile()) {
214
+ viol('E_MANIFEST_MISSING', '载荷根缺少 SKILL.md(§5.1)');
215
+ return null;
216
+ }
217
+ const frontmatter = parseFrontmatter(readFileSync(sp, 'utf8'));
218
+ if (frontmatter.name !== name) {
219
+ viol('E_MANIFEST_BINDING', `⑦ SKILL.md frontmatter 的 name 是 ${JSON.stringify(frontmatter.name)},应为 ${name}`);
220
+ }
221
+ if (typeof frontmatter.description !== 'string' || frontmatter.description === '') {
222
+ viol('E_MANIFEST_BINDING', 'SKILL.md frontmatter 缺少 description(§5.1)');
223
+ }
224
+ // 🔴 版本只放 skill.json;SKILL.md frontmatter 只承担运行时语义(§5.1 末段)
225
+ if (Object.hasOwn(frontmatter, 'version')) {
226
+ viol('E_MANIFEST_BINDING', 'SKILL.md frontmatter 不得带 version —— 版本只放 skill.json(§5.1)');
227
+ }
228
+ return frontmatter;
229
+ }
230
+
193
231
  export function parseFrontmatter(text) {
194
232
  if (!text.startsWith('---\n')) throw new WireError('E_FRONTMATTER', 'SKILL.md 必须以 --- 开头的 YAML frontmatter 起始');
195
233
  const end = text.indexOf('\n---\n', 3);
@@ -301,21 +339,7 @@ export function assertManifestBinding(record, payloadDir) {
301
339
 
302
340
  // ⑦ skill 的第七项:SKILL.md frontmatter 的 name
303
341
  let frontmatter = null;
304
- if (isSkill) {
305
- const sp = join(payloadDir, 'SKILL.md');
306
- if (!existsSync(sp) || !statSync(sp).isFile()) viol('E_MANIFEST_MISSING', '载荷根缺少 SKILL.md(§5.1)');
307
- frontmatter = parseFrontmatter(readFileSync(sp, 'utf8'));
308
- if (frontmatter.name !== record.name) {
309
- viol('E_MANIFEST_BINDING', `⑦ SKILL.md frontmatter 的 name 是 ${JSON.stringify(frontmatter.name)},应为 ${record.name}`);
310
- }
311
- if (typeof frontmatter.description !== 'string' || frontmatter.description === '') {
312
- viol('E_MANIFEST_BINDING', 'SKILL.md frontmatter 缺少 description(§5.1)');
313
- }
314
- // 🔴 版本只放 skill.json;SKILL.md frontmatter 只承担运行时语义(§5.1 末段)
315
- if (Object.hasOwn(frontmatter, 'version')) {
316
- viol('E_MANIFEST_BINDING', 'SKILL.md frontmatter 不得带 version —— 版本只放 skill.json(§5.1)');
317
- }
318
- }
342
+ if (isSkill) frontmatter = assertSkillFrontmatter({ payloadDir, name: record.name, viol });
319
343
 
320
344
  return { manifest: doc, frontmatter };
321
345
  }
@@ -14,6 +14,34 @@ import {
14
14
  export const DSSE_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
15
15
  export const PREDICATE_TYPE = 'https://geoly.ai/skills-hub/release/v1';
16
16
  export const BUILD_TYPE = 'geoly-skills/release/v1';
17
+ /**
18
+ * 🔴 **两个精确值的枚举,不是前缀、不是正则、不是「未知版本放行」。**
19
+ *
20
+ * 2026-09-02 第一次真跑 release(dry_run)时,`check-attestation-bundle.mjs`
21
+ * 解析 cosign **实际写出来的** envelope,报 `_type` 是 `Statement/v0.1` 而不是 v1。
22
+ * ⚠️ 那道双重检查正是为此设的:`build-attestation.mjs` 的自检用的是我们自己拼的
23
+ * **占位** envelope,它证明不了 cosign 最后产出的东西也满足契约。它抓到了。
24
+ *
25
+ * **不存在能让 cosign v2.4.3 输出 v1 的参数**(Codex 2026-09-02 核实):
26
+ * `--type https://geoly.ai/...` 设的是 `predicateType`,**不是 `_type`**;
27
+ * cosign 自己包装 Statement,固定 v0.1。
28
+ *
29
+ * **为什么接受 v0.1 的安全代价很低**:`_type` 位于 DSSE payload **内部**,
30
+ * 已被 PAE 签名覆盖 —— 攻击者不能把一份已签的 v0.1 改标成 v1。
31
+ * 我们真正依赖的属性一条都没松:DSSE payloadType、固定 predicateType、
32
+ * 唯一 subject、唯一 sha256 digest、snapshot 交叉绑定、固定 sourceRepo、
33
+ * 以及 E-2 绑定(workflowRef 钉的 sha === sourceCommit)。
34
+ * **放宽的是「允许的证据方言」,不是「允许的证据内容」。**
35
+ *
36
+ * ⚠️ 代价是互操作性:只认 v1 的外部工具会拒绝我们(cosign v2)的实际产物。
37
+ * 📌 当前 producer 输出 v0.1;本验证器接受 v0.1 / v1 两个**精确字符串**。
38
+ * 🔴 不要改成前缀匹配或正则 —— 那等于给未来任何一个没审过的版本发通行证。
39
+ */
40
+ export const STATEMENT_TYPES = Object.freeze([
41
+ 'https://in-toto.io/Statement/v0.1',
42
+ 'https://in-toto.io/Statement/v1',
43
+ ]);
44
+ /** @deprecated 保留给只需要「我们首选哪个」的调用方;判定一律用 STATEMENT_TYPES。 */
17
45
  export const STATEMENT_TYPE = 'https://in-toto.io/Statement/v1';
18
46
 
19
47
  const RE_COMMIT = /^[0-9a-f]{40}$/;
@@ -72,8 +100,9 @@ export function parseAttestationForForensics(bytes, { expectSnapshotSha256, expe
72
100
  const payload = decodeB64Strict(env.payload, 'attestation.payload');
73
101
  const stmt = parseWireJson(payload, 'attestation.payload');
74
102
  assertExactKeys(stmt, STATEMENT_KEYS, 'attestation.payload');
75
- if (stmt._type !== STATEMENT_TYPE) {
76
- throw new WireError('E_STATEMENT_TYPE', `_type 必须是 ${STATEMENT_TYPE},得到 ${JSON.stringify(stmt._type)}`);
103
+ if (!STATEMENT_TYPES.includes(stmt._type)) {
104
+ throw new WireError('E_STATEMENT_TYPE',
105
+ `_type 必须是 ${STATEMENT_TYPES.join(' 或 ')},得到 ${JSON.stringify(stmt._type)}`);
77
106
  }
78
107
  if (stmt.predicateType !== PREDICATE_TYPE) {
79
108
  throw new WireError('E_PREDICATE_TYPE', `predicateType 必须是固定字符串 ${PREDICATE_TYPE}(变更即升版本)`);
package/src/auth.mjs ADDED
@@ -0,0 +1,290 @@
1
+ // `publish` 的 token 存储与来源判定 —— 06-submission.md §9。
2
+ //
3
+ // §9 的四条:
4
+ // · `login`:GitHub device flow,scope 只要 `public_repo`;
5
+ // · 存储:**优先 OS keychain**;不可用时落 `~/.local/state/geoly-skills/auth.json`,
6
+ // `0600`,父目录 `0700`;
7
+ // · token 只在 `publish` / `logout` / `status` 三条命令里读;
8
+ // · 🔴 CLI 以 `npx github:` 运行时,`login` / `publish` **拒绝执行**。
9
+ //
10
+ // ── 🔴 最后那一条挡的是什么 ─────────────────────────────────────────────
11
+ // `npx github:<owner>/<repo>` 从**一个 git ref** 装并直接跑,没有版本号、
12
+ // 没有 registry 的不可变性、也没有签名 —— ref 指向的内容随时可以被换掉。
13
+ // 让这种形态的进程拿到用户的 GitHub token,等于把「谁能改那个 ref」
14
+ // 变成「谁能拿到 token」。装 skill(只读)容忍这种形态,
15
+ // **发凭据不容忍**。
16
+ //
17
+ // ⚠️ 这是一条**尽力**的门,不是安全边界:能改 ref 的人也能改这段判定。
18
+ // 它挡的是「用户自己图省事用 npx github: 跑 publish」,
19
+ // 不是「攻击者已经控制了代码」——后者早就赢了。
20
+
21
+ import {
22
+ readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync, existsSync, statSync,
23
+ } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join, dirname, sep } from 'node:path';
26
+
27
+ export class AuthError extends Error {
28
+ constructor(code, msg) { super(msg); this.name = 'AuthError'; this.code = code; }
29
+ }
30
+ const bad = (code, msg) => { throw new AuthError(code, msg); };
31
+
32
+ /** §9:`login` 只要这一个 scope。 */
33
+ export const REQUIRED_SCOPE = 'public_repo';
34
+
35
+ /**
36
+ * 🔴 **`login` 时必须把权限面说清楚**(10-open-questions Q4 的落点):
37
+ * `public_repo` 能改用户**所有**公开仓,远大于「给 skills-hub 投稿」所需。
38
+ * Q4 的收窄方案(GitHub App)没赶上 M3,按 Q4 自己写的兜底走:
39
+ * 用 `public_repo` 上线,但**说清楚**,并列为已知残余风险。
40
+ */
41
+ export const SCOPE_DISCLOSURE = `⚠️ 这次授权的 scope 是 \`${REQUIRED_SCOPE}\`。
42
+
43
+ 它的权限面**大于**你要做的事:
44
+ 你要做的:往 geoly-ai/skills-hub 开一张投稿 PR。
45
+ 它实际能做的:读写你**所有**公开仓库。
46
+
47
+ 之所以还是它:GitHub 的 device flow 没有更细的 scope 可选;
48
+ 收窄方案(GitHub App,权限细到单仓)还没做完 —— 见 10-open-questions.md Q4。
49
+
50
+ 不想给这个权限的话,**不用 \`login\`**:
51
+ fork + 手动开 PR 走的是同一条流水线,一模一样的门,只是要你自己点几下。`;
52
+
53
+ // ── 存放位置 ───────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * `~/.local/state/geoly-skills/auth.json`。
57
+ * 🔴 认 `XDG_STATE_HOME`:用户把 state 挪到别处(加密卷、tmpfs)是常见做法,
58
+ * 忽略它等于把凭据写回一个用户以为不会有凭据的地方。
59
+ */
60
+ export function authFilePath({ env = process.env, home = homedir() } = {}) {
61
+ const base = env.XDG_STATE_HOME && env.XDG_STATE_HOME.startsWith('/')
62
+ ? env.XDG_STATE_HOME
63
+ : join(home, '.local', 'state');
64
+ return join(base, 'geoly-skills', 'auth.json');
65
+ }
66
+
67
+ /**
68
+ * 落盘:文件 `0600`、父目录 `0700`(§9 明写)。
69
+ *
70
+ * 🔴 **先建目录并 chmod,再写文件**。反过来的话,文件在一个 `0755` 的目录里
71
+ * 短暂存在过 —— 同机器上的其他用户在那个窗口内能读到它。
72
+ * 🔴 **写之前先 chmod 到 0600**:`writeFileSync` 的 mode 参数只在**新建**时生效,
73
+ * 文件已存在时它一声不吭地沿用旧权限。一个之前被 chmod 成 0644 的
74
+ * auth.json 会一直是 0644。
75
+ */
76
+ export function writeTokenFile(token, {
77
+ path = authFilePath(), now = () => new Date(), warn = null,
78
+ } = {}) {
79
+ const dir = dirname(path);
80
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
81
+ // 🔴 目录已存在时 mkdir 的 mode 不生效,所以要显式 chmod。
82
+ // ⚠️ 但**不能因为 chmod 失败就不存 token**:目录可能不归我们所有
83
+ // (用户把路径指到了一个共享目录),那时 chmod 抛 EPERM。
84
+ // 文件本身的 0600 才是保护内容的那一道 —— 目录权限管的是能不能列目录。
85
+ // 所以这里降级成告警。
86
+ try {
87
+ chmodSync(dir, 0o700);
88
+ } catch (e) {
89
+ if (warn !== null) {
90
+ warn(`⚠️ 收紧 ${dir} 的权限失败(${e.code})—— 它可能不归你所有。\n`
91
+ + ' token 文件本身仍然是 0600,别人读不到内容,但能看到这个文件存在。');
92
+ }
93
+ }
94
+ const body = `${JSON.stringify({
95
+ schema: 'geoly.skills.auth/1',
96
+ token,
97
+ scope: REQUIRED_SCOPE,
98
+ created_at: now().toISOString(),
99
+ }, null, 2)}\n`;
100
+ writeFileSync(path, body, { mode: 0o600 });
101
+ // 🔴 文件已存在时 writeFileSync 的 mode 不生效 —— 这一处**不降级**:
102
+ // 收紧不了文件权限就等于把明文 token 摊开,宁可失败。
103
+ chmodSync(path, 0o600);
104
+ return path;
105
+ }
106
+
107
+ /** @returns {{token:string, scope:string, created_at:string}|null} */
108
+ export function readTokenFile({ path = authFilePath(), warn = null } = {}) {
109
+ if (!existsSync(path)) return null;
110
+ // 🔴 权限变宽了要**说出来**,但不要因此拒绝读 —— 用户此刻多半正在
111
+ // `logout`,而拒绝读会让他连撤销都做不了。
112
+ const mode = statSync(path).mode & 0o777;
113
+ if (mode !== 0o600 && warn !== null) {
114
+ warn(`⚠️ ${path} 的权限是 ${mode.toString(8)},应为 600 —— 同机器上的其他用户可能读得到。`
115
+ + '\n 建议 `geoly-skills logout` 之后重新 login。');
116
+ }
117
+ let doc;
118
+ try { doc = JSON.parse(readFileSync(path, 'utf8')); } catch (e) {
119
+ bad('E_AUTH_CORRUPT', `${path} 读不出来:${e.message}\n 删掉它再 login。`);
120
+ }
121
+ if (doc === null || typeof doc !== 'object' || typeof doc.token !== 'string' || doc.token === '') {
122
+ bad('E_AUTH_CORRUPT', `${path} 里没有 token —— 删掉它再 login。`);
123
+ }
124
+ return doc;
125
+ }
126
+
127
+ export function deleteTokenFile({ path = authFilePath() } = {}) {
128
+ if (!existsSync(path)) return false;
129
+ rmSync(path, { force: true });
130
+ return true;
131
+ }
132
+
133
+ // ── npx github: 判定 ───────────────────────────────────────────────────────
134
+
135
+ // npm ≥ 7 的 npx 把包装进 `~/.npm/_npx/<hash>/node_modules/<name>`,
136
+ // 并在 `~/.npm/_npx/<hash>/package.json` 里记下**用户当初写的那个 spec**。
137
+ // 那份 spec 才是判据 —— 装完之后的目录长得都一样。
138
+ const NPX_DIR = `${sep}_npx${sep}`;
139
+
140
+ /** 一个依赖 spec 是不是「从 git ref 装」。 */
141
+ export function isGitSpec(spec) {
142
+ if (typeof spec !== 'string') return false;
143
+ const s = spec.trim();
144
+ return /^(github|gitlab|bitbucket|gist):/i.test(s)
145
+ || /^git(\+(ssh|https?|file))?:/i.test(s)
146
+ || /^(https?:\/\/|git@)[^\s]*\.git($|#)/i.test(s)
147
+ // `npx owner/repo` 这种裸写法 npm 也当 GitHub 处理
148
+ || /^[\w.-]+\/[\w.-]+(#.*)?$/.test(s);
149
+ }
150
+
151
+ /**
152
+ * 本进程是不是「`npx <git spec>` 跑起来的」。
153
+ *
154
+ * @param {string} moduleDir 本模块所在目录(生产上传 `import.meta.dirname`)
155
+ * @returns {{isNpxGit:boolean, spec:string|null, manifest:string|null}}
156
+ */
157
+ export function detectNpxGit(moduleDir) {
158
+ const idx = moduleDir.indexOf(NPX_DIR);
159
+ if (idx === -1) return { isNpxGit: false, spec: null, manifest: null };
160
+ // `<...>/_npx/<hash>/` —— hash 那一层就是 npx 的临时安装根
161
+ const rest = moduleDir.slice(idx + NPX_DIR.length);
162
+ const hash = rest.split(sep)[0];
163
+ if (!hash) return { isNpxGit: false, spec: null, manifest: null };
164
+ const manifest = join(moduleDir.slice(0, idx), '_npx', hash, 'package.json');
165
+ if (!existsSync(manifest)) return { isNpxGit: false, spec: null, manifest };
166
+ let doc;
167
+ try { doc = JSON.parse(readFileSync(manifest, 'utf8')); } catch {
168
+ // 读不出来就**不**断言它是 git spec —— 这道门是尽力性质的,
169
+ // 误拒一个正常的 `npx skills-hub` 比放过一个 `npx github:` 更常见。
170
+ return { isNpxGit: false, spec: null, manifest };
171
+ }
172
+ for (const spec of Object.values(doc?.dependencies ?? {})) {
173
+ if (isGitSpec(spec)) return { isNpxGit: true, spec, manifest };
174
+ }
175
+ return { isNpxGit: false, spec: null, manifest };
176
+ }
177
+
178
+ /**
179
+ * §9 最后一条的断言形态。`login` / `publish` 开头就调它。
180
+ * 退出码 7(认证),见 09-cli.md §6。
181
+ */
182
+ export function assertNotNpxGit(moduleDir, command) {
183
+ const d = detectNpxGit(moduleDir);
184
+ if (!d.isNpxGit) return false;
185
+ bad('E_NPX_GIT',
186
+ `\`${command}\` 拒绝在 \`npx ${d.spec}\` 下运行(06-submission.md §9)。\n`
187
+ + ' 🔴 从一个 git ref 装并直接跑:没有版本号、没有 registry 的不可变性、\n'
188
+ + ' 也没有签名 —— ref 指向的内容随时可以被换掉。\n'
189
+ + ' 让这种形态的进程拿到你的 GitHub token,等于把「谁能改那个 ref」\n'
190
+ + ' 变成「谁能拿到你的 token」。装 skill(只读)容忍它,发凭据不容忍。\n'
191
+ + ` 改用装好的 CLI:\`npm i -g skills-hub && geoly-skills ${command}\`。`);
192
+ }
193
+
194
+ // ── OS keychain(§9:**优先** keychain,不可用才落文件)────────────────────
195
+
196
+ /**
197
+ * 每个平台的 keychain 命令。
198
+ *
199
+ * 🔴 **token 一律走 stdin,不进 argv。** 进程的命令行参数在同机器上是可见的
200
+ * (Linux 的 `/proc/<pid>/cmdline` 全用户可读;macOS 上同用户可见),
201
+ * 把 token 写进 argv 等于让它出现在任何一次 `ps` 里,
202
+ * 还会进 shell 历史。这是「用 keychain 存」这件事的第一个前提 ——
203
+ * 存得再安全,取的路上漏了也白搭。
204
+ *
205
+ * ⚠️ macOS 的 `security add-generic-password` **没有**从 stdin 读密码的选项,
206
+ * 只有 `-w <value>`。所以那一条只能走 argv —— 这是平台限制,不是选择。
207
+ * 缓解:macOS 上非 root 用户看不到**别的用户**的进程参数;
208
+ * 同用户的进程本来就能直接读 keychain。**如实记在这里,不假装没有。**
209
+ */
210
+ const KEYCHAIN = {
211
+ darwin: {
212
+ // -U:已存在就更新,否则 add 会以 45 号错误失败
213
+ set: (service, account, token) =>
214
+ ({ cmd: 'security', args: ['add-generic-password', '-U', '-s', service, '-a', account, '-w', token] }),
215
+ get: (service, account) =>
216
+ ({ cmd: 'security', args: ['find-generic-password', '-s', service, '-a', account, '-w'] }),
217
+ del: (service, account) =>
218
+ ({ cmd: 'security', args: ['delete-generic-password', '-s', service, '-a', account] }),
219
+ },
220
+ linux: {
221
+ // secret-tool 从 stdin 读值 —— token 不进 argv
222
+ set: (service, account) =>
223
+ ({ cmd: 'secret-tool', args: ['store', '--label', service, 'service', service, 'account', account], stdin: true }),
224
+ get: (service, account) =>
225
+ ({ cmd: 'secret-tool', args: ['lookup', 'service', service, 'account', account] }),
226
+ del: (service, account) =>
227
+ ({ cmd: 'secret-tool', args: ['clear', 'service', service, 'account', account] }),
228
+ },
229
+ };
230
+
231
+ export const KEYCHAIN_SERVICE = 'geoly-skills';
232
+ export const KEYCHAIN_ACCOUNT = 'github-token';
233
+
234
+ /**
235
+ * 统一的存取。**keychain 优先,失败就落文件** —— 两条路都要能用:
236
+ * CI、容器、没有 keyring 的服务器上 keychain 根本不存在。
237
+ *
238
+ * 🔴 **keychain 不可用是「换一条路」,不是「报错」。** 但要**说出来** ——
239
+ * 用户有权知道自己的 token 是躺在 keychain 里还是躺在一个文件里。
240
+ *
241
+ * @param {object} deps
242
+ * @param {(cmd:string, args:string[], input:string|null) => {status:number, stdout:string, stderr:string}} deps.run
243
+ */
244
+ export function makeAuthStore({ run, platform = process.platform, path = null, warn = null } = {}) {
245
+ const kc = KEYCHAIN[platform] ?? null;
246
+ const file = path ?? authFilePath();
247
+ const note = (s) => { if (warn !== null) warn(s); };
248
+
249
+ const tryKeychain = (make, input = null) => {
250
+ if (kc === null || typeof run !== 'function') return null;
251
+ const spec = make(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, input);
252
+ let r;
253
+ try { r = run(spec.cmd, spec.args, spec.stdin ? input : null); } catch { return null; }
254
+ // 命令不存在 / 没有 keyring daemon:status 非 0 或抛错,一律当「不可用」
255
+ if (r === null || r === undefined || r.status !== 0) return null;
256
+ return r;
257
+ };
258
+
259
+ return {
260
+ /** @returns {'keychain'|'file'} 实际存到了哪儿 */
261
+ save(token) {
262
+ if (tryKeychain((s, a) => kc.set(s, a, token), token) !== null) return 'keychain';
263
+ note(`⚠️ 系统 keychain 不可用,token 落在 ${file}(0600)。`
264
+ + '\n 它是一个明文文件 —— 这台机器上能读它的人就能用你的身份投稿。');
265
+ writeTokenFile(token, { path: file, warn });
266
+ return 'file';
267
+ },
268
+ /** @returns {{token:string, from:'keychain'|'file'}|null} */
269
+ load() {
270
+ const r = tryKeychain((s, a) => kc.get(s, a));
271
+ if (r !== null) {
272
+ const token = r.stdout.replace(/\n$/, '');
273
+ if (token !== '') return { token, from: 'keychain' };
274
+ }
275
+ const d = readTokenFile({ path: file, warn });
276
+ return d === null ? null : { token: d.token, from: 'file' };
277
+ },
278
+ /**
279
+ * 🔴 **两处都要清。** 只清 keychain 的话,一个早先落过盘的 auth.json
280
+ * 会留在原地 —— 用户以为 logout 了,token 还躺在那儿。
281
+ * @returns {string[]} 实际清掉了哪几处
282
+ */
283
+ clear() {
284
+ const cleared = [];
285
+ if (tryKeychain((s, a) => kc.del(s, a)) !== null) cleared.push('keychain');
286
+ if (deleteTokenFile({ path: file })) cleared.push('file');
287
+ return cleared;
288
+ },
289
+ };
290
+ }