@geoly-ai/skills-hub 0.1.0 → 0.3.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/src/cli.mjs CHANGED
@@ -14,10 +14,10 @@ import { Output } from './commands/output.mjs';
14
14
  import { createCacheRegistry } from './commands/registry.mjs';
15
15
  import { EXIT, classify, UsageError } from './exit-codes.mjs';
16
16
 
17
- const HELP = `skills-hub —— geoly skill 分发(M1)
17
+ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
18
18
 
19
19
  命令:
20
- install <spec>… 装制品(direct;pack 与 --all 在 M2)
20
+ install <spec>… | install --all 装制品(skill / pack:;--all 见 09-cli.md §3)
21
21
  list [--packs|--installed|--outdated]
22
22
  search <kw>…
23
23
  check 两阶段校验(字节 / 现在还该不该用)
@@ -25,6 +25,8 @@ const HELP = `skills-hub —— geoly skill 分发(M1)
25
25
  sync-lock 在 repo 锁下重算 geoly-skills.lock.json
26
26
  recover [--continue|--rollback|--reinstall] [--from-generation N [--only x]…]
27
27
  [--reset-generation N] [--resume-cleanup]
28
+ vendor <pack-spec> --out <dir> [--layout flat]
29
+ 把 pack 与全部成员物化成一棵目录树(不走安装账本)
28
30
 
29
31
  stats [--json] [--export <file>] 本地埋点报表
30
32
  telemetry <status|flush> 埋点/上报开关与队列
@@ -40,11 +42,22 @@ const HELP = `skills-hub —— geoly skill 分发(M1)
40
42
  --allow-yanked 仅取证;大声告警并写进账本(不放行 degraded)
41
43
  --replace <name> 点名替换未被账本认领的同名目录
42
44
  --no-bundled / --pre / --json / --yes
45
+ --yes-i-really-want-everything 仅 --all 在非交互下使用(--yes 不够)
43
46
  --keep-generations <N> attic 保留代数,默认 3
44
47
 
45
48
  🔴 没有 --no-verify、--insecure、--force、--force-unlock、--assume-idle。
46
49
  验签与摘要校验不可关闭;替换必须点名;陈旧/yank/全量各有独立开关。
47
50
 
51
+ 埋点(docs/telemetry/00-spec.md):
52
+ 🔴 上报**默认开**(有内置默认端点)。**install 成功收尾后**会静默发一次
53
+ (24 小时最多一次,超时 1 秒,失败不影响安装);别的命令只写本地,
54
+ 也可以随时 \`telemetry flush\` 手动发。
55
+ 采集面是穷举白名单 —— 不含路径、目录清单、文件内容、用户名。
56
+ GEOLY_TELEMETRY=0 完全关闭:本地一个字节都不写
57
+ GEOLY_TELEMETRY_UPLOAD=0 只留本地统计,不上报
58
+ GEOLY_TELEMETRY_ENDPOINT 改上报端点(必须 https;空值视为配置错误)
59
+ --offline 单次命令禁止一切网络出口,埋点上报同样被否决
60
+
48
61
  退出码(§6):0 成功 · 1 用法 · 2 完整性 · 3 冲突 · 4 部分失败 · 5 需 recover/锁忙
49
62
  6 网络 · 7 认证 · 8 陈旧 · 9 平台/文件系统 · 10 不可写 · 11 CLI 版本过低
50
63
  `;
@@ -58,6 +71,7 @@ const COMMANDS = {
58
71
  check: () => import('./commands/check.mjs').then((m) => m.cmdCheck),
59
72
  'sync-lock': () => import('./commands/sync-lock.mjs').then((m) => m.cmdSyncLock),
60
73
  recover: () => import('./commands/recover.mjs').then((m) => m.cmdRecover),
74
+ vendor: () => import('./commands/vendor.mjs').then((m) => m.cmdVendor),
61
75
  };
62
76
 
63
77
  /**
@@ -107,6 +121,17 @@ export async function main(argv, deps = {}) {
107
121
  const out = new Output({ json: globals.json, stdout, stderr });
108
122
  const cmd = rest[0];
109
123
 
124
+ // 🔴 首次运行的埋点告知。上报默认开(2026-09-01 拍板),所以**必须**在用户的
125
+ // 第一条命令上把「收什么 / 发到哪 / 怎么关」说清楚,且只说一次。
126
+ // · 走 stderr:`--json` 下 stdout 只能有一个 JSON 对象(§7 输出契约)。
127
+ // · 放在这里而不是各命令里:它要覆盖**所有**命令,漏一条就有人被绕过告知。
128
+ // · 整个过程不许影响主命令(T-5),所以异常一律吞掉。
129
+ // 🔴 **它必须先于任何一次自动上报**(§5.1.1)——「先发了再告诉你」比不告知
130
+ // 更糟。这里的位置(命令分发之前)是第一道保证;第二道在 maybeAutoUpload
131
+ // 里:告知的标记文件不存在就不发。两道都在,是因为第一道只是**调用顺序**,
132
+ // 挪一行代码就能悄悄反过来而不会有任何东西变红。
133
+ await noticeOnce(stderr);
134
+
110
135
  if (cmd === undefined || cmd === '-h' || cmd === '--help' || cmd === 'help') {
111
136
  if (globals.json) return out.emit('help', { help: HELP }, EXIT.OK);
112
137
  stdout.write(HELP);
@@ -131,13 +156,70 @@ export async function main(argv, deps = {}) {
131
156
  const record = base.record ?? (await import('./telemetry.mjs')).record;
132
157
  const ctx = Object.freeze({ ...base, record, registry });
133
158
  const run = await load();
134
- return await run(ctx, rest.slice(1), out);
159
+ const code = await run(ctx, rest.slice(1), out);
160
+ // 🔴 自动上报的**唯一**触发点(规格 §5.1.1,2026-09-01 拍板)。
161
+ //
162
+ // 判据两条,缺一不可:
163
+ // · `cmd === 'install'` —— 只有安装,别的命令一律不出网;
164
+ // · `code === EXIT.OK` —— 而且必须是**完全成功**。
165
+ // ⚠️ `EXIT.PARTIAL`(4,部分 target 失败)**不算**:一次没装干净的
166
+ // 安装不该再替用户付一次网络代价。那批事件不会丢,留在本地等下一次
167
+ // 成功的 install 或用户手动 flush(§5.2.2)。
168
+ // ⚠️ 抛错的路径根本走不到这里(下面的 catch 接住了),所以「失败不发」
169
+ // 不是靠记得写 if,而是靠控制流。
170
+ //
171
+ // 🔴 这里 `await`,不是发了就不管:进程一退,在途的 POST 就没了。
172
+ // ⚠️ 代价是**这一次 install 会等它**。网络那一段有 1 秒上界
173
+ // (AUTO_UPLOAD_TIMEOUT_MS),但**整段没有**:认领戳的 fsync、上报锁、
174
+ // stage/reap 都是超时之外的同步 I/O。别把「最多多 1 秒」说成整体保证
175
+ // (规格 §5.1.1 已按实话改写)。
176
+ //
177
+ // 🔴 返回值**故意丢弃**:不打印、不改退出码。一次成功的 install 绝不能
178
+ // 因为埋点失败而看起来像失败了。
179
+ // 🔴 走 `autoUploadQuietly` 而不是在这里直接 `await import(...)`:
180
+ // 这一段在 try 里,一个失败的动态 import 会被下面的 catch 接住、
181
+ // 走 emitError —— 于是**一次成功的 install 会被埋点变成一次失败的输出**。
182
+ // 正是本次拍板里那条硬要求要防的事。所以吞错要在调用点里面,不在外面。
183
+ if (cmd === 'install' && code === EXIT.OK) await autoUploadQuietly();
184
+ return code;
135
185
  } catch (err) {
136
186
  const cls = classify(err);
137
187
  return out.emitError(cmd, cls, err);
138
188
  }
139
189
  }
140
190
 
191
+ /**
192
+ * install 成功收尾后的自动上报(规格 §5.1.1)。**整段完全静默。**
193
+ *
194
+ * 🔴 连 `import` 都包在 try 里:`maybeAutoUpload` 自己不抛,但把它取进来这一步
195
+ * 会(模块求值失败、文件被删)。差别不是理论上的 —— 调用点在 `main` 的 try 里,
196
+ * 漏出去的异常会被顶层 catch 变成 `emitError`,用户看到的是一次**失败的 install**,
197
+ * 而磁盘上其实一切正常。
198
+ */
199
+ async function autoUploadQuietly() {
200
+ try {
201
+ const { maybeAutoUpload } = await import('./upload.mjs');
202
+ await maybeAutoUpload(); // 返回值故意不看:不打印、不改退出码
203
+ } catch { /* 埋点的任何问题都不许冒泡到主命令(T-5) */ }
204
+ }
205
+
206
+ /**
207
+ * 首次运行时打印埋点告知(只打一次)。任何失败都不得影响主命令。
208
+ *
209
+ * ⚠️ 端点在这里解析而不是在 telemetry.mjs 里:upload.mjs 依赖 telemetry.mjs,
210
+ * 反过来 import 会成环。
211
+ */
212
+ async function noticeOnce(stderr) {
213
+ try {
214
+ const tm = await import('./telemetry.mjs');
215
+ if (!tm.enabled() || !tm.uploadEnabled()) return; // 没有出网就没什么可告知的
216
+ const { endpoint } = await import('./upload.mjs');
217
+ let url = null;
218
+ try { url = endpoint(); } catch { return; } // 端点配坏了 = 不会出网
219
+ tm.maybeNoticeUpload((s) => stderr.write(s), url);
220
+ } catch { /* 告知不是主命令的一部分(T-5) */ }
221
+ }
222
+
141
223
  // ── 内部探针 ─────────────────────────────────────────────────────────────────
142
224
 
143
225
  async function warnProbe(stdout) {
@@ -190,7 +272,9 @@ async function cmdTelemetry(args, stdout, stderr) {
190
272
  : '关(GEOLY_TELEMETRY_UPLOAD=0)';
191
273
  stdout.write(`埋点 ${tm.enabled() ? '开' : '关(GEOLY_TELEMETRY=0)'}\n`);
192
274
  stdout.write(`上报 ${tm.uploadEnabled() ? '开' : uploadOff}\n`);
193
- stdout.write(`端点 ${ep ?? '未配置 —— 纯本地'}\n`);
275
+ // 🔴 端点默认开(2026-09-01 拍板),所以「这是内置默认值」必须写在脸上:
276
+ // 用户有权知道数据默认发去哪,而不是去翻源码才发现有个默认端点。
277
+ stdout.write(`端点 ${ep ?? '无'}${ep && up.isDefaultEndpoint() ? '(内置默认值 —— 未配 GEOLY_TELEMETRY_ENDPOINT)' : ''}\n`);
194
278
  stdout.write(`待上报 ${tm.readAll().length} 条\n`);
195
279
  stdout.write(`报表历史 ${tm.readHistory().length} 条(上报不消费它)\n`);
196
280
  stdout.write(`状态目录 ${tm.stateDir()}\n`);
@@ -198,8 +282,14 @@ async function cmdTelemetry(args, stdout, stderr) {
198
282
  }
199
283
  case 'flush': {
200
284
  const r = await up.flush();
201
- stdout.write(r.skipped ? `未上报:${r.reason}\n` : `已上报 ${r.sent} 条\n`);
202
- return r.skipped && r.reason?.startsWith('error:') ? EXIT.USAGE : EXIT.OK;
285
+ stdout.write(r.skipped
286
+ ? `未上报:${r.reason}${r.detail ? `(${r.detail})` : ''}\n`
287
+ : `已上报 ${r.sent} 条\n`);
288
+ // 🔴 `bad-endpoint` 也要非零退出:它是**配置错误**,不是「这次没什么可发的」。
289
+ // 退 0 意味着脚本永远发现不了「端点变量填空了、这台机器从此不上报」。
290
+ // ⚠️ `offline` / `upload-disabled` / `empty` / `busy` 都是正常状态,退 0。
291
+ const bad = r.skipped && (r.reason === 'bad-endpoint' || r.reason?.startsWith('error:'));
292
+ return bad ? EXIT.USAGE : EXIT.OK;
203
293
  }
204
294
  default:
205
295
  stderr.write('用法:skills-hub telemetry <status|flush>\n');
@@ -222,8 +222,21 @@ export function makeContext(globals, deps = {}) {
222
222
  verifier: deps.verifier ?? null,
223
223
  /** 注入点:测试用内存 registry,生产用 `registry.mjs` 的缓存适配器 */
224
224
  registryFactory: deps.registryFactory ?? null,
225
+ /**
226
+ * 注入点:出网。生产走内建 `fetch`(`src/download.mjs` 的默认值)。
227
+ * 🔴 与 `verifier` 一样**没有逃生口**:不给就是真出网,
228
+ * 不存在「测试忘了注入所以静默不出网」这种状态 ——
229
+ * 那会让「--offline 有没有被绕过」这个问题看起来被回答了,其实没有。
230
+ */
231
+ fetchImpl: deps.fetchImpl ?? null,
225
232
  /** 注入点:埋点。生产是 `telemetry.record` */
226
233
  record: deps.record ?? null,
234
+ /**
235
+ * 注入点:标准输入。**只有 `install --all` 的 §3 全量确认用它**。
236
+ * 🔴 交互与否的判据是 `isTTY`,不是「有没有 stdin」—— 管道进来的 stdin
237
+ * 照样可读,但那不是「用户正坐在终端前逐条看名单」。
238
+ */
239
+ stdin: deps.stdin ?? process.stdin,
227
240
  });
228
241
  return ctx;
229
242
  }