glasspane-install 1.18.0 → 1.20.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.
Files changed (3) hide show
  1. package/README.md +3 -2
  2. package/cli.js +160 -49
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,8 +10,9 @@ macOS 14+.
10
10
  ## Channels / 安装渠道
11
11
 
12
12
  ```bash
13
- # 1) One-liner (推荐): fetches install.sh, which clones the pinned release tag
14
- curl -fsSL https://raw.githubusercontent.com/jingzhao-l/GlassPane/main/install.sh | sh
13
+ # 1) Fetch, then run (recommended): install.sh clones the pinned release tag. Two steps on
14
+ # purpose — piped into `sh`, a curl that fails leaves stdin empty and the pipeline exits 0.
15
+ curl -fsSL https://raw.githubusercontent.com/jingzhao-l/GlassPane/main/install.sh -o /tmp/gp-install.sh && sh /tmp/gp-install.sh
15
16
 
16
17
  # 2) This package via npx — also clones the pinned release tag when no local
17
18
  # repo is found, then runs the same flow
package/cli.js CHANGED
@@ -70,7 +70,7 @@ export const INSTALL_SH_URL =
70
70
  /** 发布线锚点(版本真源 = 根 package.json,由 scripts/set-version.mjs 统一改写,
71
71
  * 勿手改):npx 形态下本地没有仓库时,引导 clone 的就是这个 tag,与 install.sh
72
72
  * 的 `GLASSPANE_RELEASE` 同值——两条一键入口必须拿到同一份源码。 */
73
- export const RELEASE_VERSION = '1.18.0'
73
+ export const RELEASE_VERSION = '1.20.0'
74
74
  /** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
75
75
  export const REPO_REF = `v${RELEASE_VERSION}`
76
76
 
@@ -277,50 +277,137 @@ export const SHIM_PROBE_COMMANDS = {
277
277
  git: { cmd: 'git', args: ['--version'] },
278
278
  }
279
279
 
280
- /** 检查命令是否存在:优先 `<bin> --version`;shim 易感工具(swift/git)改由
281
- * SHIM_PROBE_COMMANDS 里那条探测命令的退出码决定;其余(open 等不支持 --version)
282
- * 才退化到 /usr/bin 下的可执行文件。真实探测,不伪造。
283
- * `spawn`/`access` 是测试接缝:这一条判据的正反两面都得由假探测跑出来,真机上
284
- * "装了 Xcode 的机器"永远只能给出正面一半。 */
285
- export function commandAvailable(bin, {
280
+ /** 探测一次命令:返回三档判定,而不是把"没答话"和"不在"混成一件事。
281
+ *
282
+ * 2026-10-23(缺陷 2)实测到的形状:整套件并发跑时 `git --version` 在 5 秒内没回来,旧实现把
283
+ * `status !== 0`(超时是 `null`)当成"未找到",于是①对用户说"未找到 git(xcode-select --install)"
284
+ * ——这台机器上 git 好好的;②`installer/test/review-2026-10-09.test.mjs` 那条按"预检挡了"跳过的
285
+ * 真机用例静默不跑,整条正向分支(退出码 0 + "安装完成" + "实测校验通过")在本机一次也没被测过。
286
+ * 单独跑这条又恒过——所以红看不见、跳过也看不见。
287
+ *
288
+ * 三档:`available`(探测命令自己退 0)/ `absent`(ENOENT,或答了但退非 0:CLT shim 就是这一档,
289
+ * 此时"装它"这条建议仍然成立)/ `unverified`(探测没走完:超时或 spawn 起了但没结果——这一档
290
+ * **不能**说成"未找到")。 */
291
+ export const PROBE_STATES = Object.freeze({ available: 'available', absent: 'absent', unverified: 'unverified' })
292
+
293
+ /** 探测预算。第一次 5s;只有"没答话"才补一次宽的(这是区分"没答"与"答了不要",不是给计时假红加超时)。 */
294
+ export const PROBE_TIMEOUT_MS = 5000
295
+ export const PROBE_RETRY_TIMEOUT_MS = 20000
296
+
297
+ function probeOnce(spawn, cmd, args, timeoutMs) {
298
+ try {
299
+ return spawn(cmd, args, { stdio: 'ignore', timeout: timeoutMs })
300
+ } catch {
301
+ return { status: null, error: { code: 'EPARSE_SPAWN_THROW' } }
302
+ }
303
+ }
304
+
305
+ /** 一次判定的三档结果;`access` 只用于不支持 `--version` 的命令(open)。 */
306
+ export function commandProbe(bin, {
286
307
  spawn = spawnSync,
287
308
  access = (file) => fs.accessSync(file, fs.constants.X_OK),
309
+ timeoutMs = PROBE_TIMEOUT_MS,
310
+ retryTimeoutMs = PROBE_RETRY_TIMEOUT_MS,
288
311
  } = {}) {
289
- const succeeds = (cmd, args) => {
290
- try {
291
- return spawn(cmd, args, { stdio: 'ignore', timeout: 5000 }).status === 0
292
- } catch {
293
- return false
294
- }
295
- }
296
- if (succeeds(bin, ['--version'])) {
297
- return true
298
- }
312
+ const judge = (cmd, args, budget) => {
313
+ const res = probeOnce(spawn, cmd, args, budget)
314
+ if (res.status === 0) return PROBE_STATES.available
315
+ const code = res.error && res.error.code
316
+ if (code === 'ENOENT') return PROBE_STATES.absent
317
+ // 夹具里注入的假 spawn 只给 {status}:非 0 退出=它答过了,答案是"不行"。
318
+ if (code === undefined || code === null) return PROBE_STATES.absent
319
+ return PROBE_STATES.unverified
320
+ }
321
+ const first = judge(bin, ['--version'], timeoutMs)
322
+ if (first === PROBE_STATES.available) return first
323
+ const second = first === PROBE_STATES.unverified
324
+ ? judge(bin, ['--version'], retryTimeoutMs)
325
+ : first
326
+ if (second !== PROBE_STATES.absent) return second
299
327
  const probe = SHIM_PROBE_COMMANDS[bin]
300
328
  if (probe) {
301
329
  // 到这一支为止:文件存在对 shim 易感工具不构成证据(见 SHIM_PROBE_COMMANDS 注释)。
302
- return succeeds(probe.cmd, probe.args)
330
+ const shim = judge(probe.cmd, probe.args, timeoutMs)
331
+ if (shim === PROBE_STATES.available) return shim
332
+ return shim === PROBE_STATES.unverified
333
+ ? judge(probe.cmd, probe.args, retryTimeoutMs)
334
+ : shim
303
335
  }
304
336
  try {
305
337
  access(path.join('/usr/bin', bin))
306
- return true
338
+ return PROBE_STATES.available
307
339
  } catch {
308
- return false
340
+ return PROBE_STATES.absent
309
341
  }
310
342
  }
311
343
 
312
- /** 环境预检快照,返回每种依赖的可用状态(真实探测,不伪造)。 */
313
- export function checkEnvironment({ nodePath = process.execPath } = {}) {
344
+ /** 检查命令是否存在:优先 `<bin> --version`;shim 易感工具(swift/git)改由
345
+ * SHIM_PROBE_COMMANDS 里那条探测命令的退出码决定;其余(open 等不支持 --version)
346
+ * 才退化到 /usr/bin 下的可执行文件。真实探测,不伪造。
347
+ * `spawn`/`access` 是测试接缝:这一条判据的正反两面都得由假探测跑出来,真机上
348
+ * "装了 Xcode 的机器"永远只能给出正面一半。
349
+ * 布尔只是三档判定的"可用"那一格;预检的**文案**要区分"没有"与"没确认",见 `checkEnvironment`。 */
350
+ export function commandAvailable(bin, opts = {}) {
351
+ return commandProbe(bin, opts) === PROBE_STATES.available
352
+ }
353
+
354
+
355
+ /**
356
+ * 预检**真的**去问主机的那些命令。这一行是文档侧那句数量主张的真源:
357
+ * `README.md` / `README.zh-CN.md` 的「它确实探测的这五项」由 `scripts/check-doc-links.mjs`
358
+ * 从这里的数组字面量算出来比对(从前那句写的是"四行",而表里一共五行、代码探五项——
359
+ * 三个数字各说各话,没人核过;2026-10-22 那轮实测到 npm/swift/launchd 真的在开发者 HOME 上
360
+ * 跑起来之后才发现文档与代码已经漂了两个刻度)。
361
+ *
362
+ * 它同时是 `checkEnvironment()` 的探测面清单:新增一项却不加探测,由
363
+ * `installer/test/daily-20261022-preflight-claim.test.mjs` 的那张映射表抓红。
364
+ */
365
+ export const PREFLIGHT_PROBED_COMMANDS = Object.freeze(['node', 'npm', 'swift', 'git', 'open'])
366
+
367
+ /** 环境预检快照,返回每种依赖的可用状态(真实探测,不伪造)。
368
+ *
369
+ * `unverified` 记的是"探测没走完"那几项命令名(见 `commandProbe` 的三档)。它不是一个新命令,
370
+ * 而是同一批读数的第二种答案:`git: false` 说的是"这次没确认成",`unverified` 说得出**为什么**
371
+ * 不能写成"未找到"。预检的文案靠它分流,拒绝本身照旧发生——确认不了的依赖不假装齐。 */
372
+ export function checkEnvironment({ nodePath = process.execPath, probe = commandProbe } = {}) {
373
+ const states = {
374
+ npm: probe('npm'),
375
+ swift: probe('swift'),
376
+ git: probe('git'),
377
+ open: probe('open'),
378
+ }
314
379
  return {
315
380
  nodeMajor: nodeMajorVersion(nodePath),
316
381
  nodeOk: nodeMajorVersion(nodePath) >= MIN_NODE_MAJOR,
317
- npm: commandAvailable('npm'),
318
- swift: commandAvailable('swift'),
319
- git: commandAvailable('git'),
320
- open: commandAvailable('open'),
382
+ npm: states.npm === PROBE_STATES.available,
383
+ swift: states.swift === PROBE_STATES.available,
384
+ git: states.git === PROBE_STATES.available,
385
+ open: states.open === PROBE_STATES.available,
386
+ unverified: Object.entries(states)
387
+ .filter(([, state]) => state === PROBE_STATES.unverified)
388
+ .map(([name]) => name),
321
389
  }
322
390
  }
323
391
 
392
+ /**
393
+ * 这台主机能不能装 GlassPane——判据是 node 向主机问来的 `process.platform`,不是 PATH 上
394
+ * 任何一个可以被换掉的程序。
395
+ *
396
+ * 为什么这一条必须住在 cli.js 里:`install.sh` 的 macOS 闸从前只挡"要 clone 的那一趟",
397
+ * 源码在手边(指定仓库、当前目录、既有 clone)时根本不经过它,而它的注释写着"交给 installer
398
+ * 自己去说平台"——installer 里当时一个字都没有。实测代价(2026-10-22):把 `uname` 桩成 Linux
399
+ * 再从仓库根跑真装,npm ci、tsc、swift build 与 launchd 注册全部真的发生,HOME 是开发者自己的。
400
+ * 一条依赖机器状态、且会**改**机器状态的"拒绝分支"不是护栏。
401
+ *
402
+ * 纯函数 + 可注入的 platform,为的是这条判据能被测试指到,而不是只在某台 runner 上碰巧走到。
403
+ */
404
+ export function platformRefusal(platform = process.platform) {
405
+ if (platform === 'darwin') return null
406
+ return '这个产品只支持 macOS(本机 platform 不是 darwin):Swift/XCTest、launchd 与 TCC 席位都在 macOS 这一侧。'
407
+ + '本轮没有跑 npm ci、没有 swift build、没有注册 launchd 作业,也没有写进任何应用目录。'
408
+ + '要在别的系统上用 GlassPane,等它把引擎搬到那一侧再说——现在这一步没有可绕的旗标。'
409
+ }
410
+
324
411
  /** 汇总预检结果:缺项按严重程度排序返回可读描述(空数组 = 全部通过)。 */
325
412
  export function preflightIssues(env) {
326
413
  const issues = []
@@ -331,21 +418,27 @@ export function preflightIssues(env) {
331
418
  } else if (!env.nodeOk) {
332
419
  issues.push(`Node.js 需要 >= ${MIN_NODE_MAJOR},当前为 ${env.nodeMajor}:换够格的 Node 版本再重跑(brew upgrade node 或 nvm use 22)`)
333
420
  }
334
- if (!env.npm) {
335
- issues.push('未找到 npm(它随 Node 一起装:brew install node)')
336
- }
337
- if (!env.swift) {
338
- issues.push('未找到 swift(需安装 Xcode Command Line Tools:xcode-select --install)')
339
- }
340
- if (!env.git) {
341
- issues.push('未找到 git(xcode-select --install 会带上它,或 brew install git)')
342
- }
343
- if (!env.open) {
344
- issues.push('未找到 open(本安装器面向 macOS:Linux/Windows 上没有可装的东西,请在 macOS 14+ 上重跑)')
421
+ // "没答话"不等于"没有"。这两句话给的动作不一样(一个是装它,一个是看它卡在哪),
422
+ // 写成一句就是把读者支去 `xcode-select --install` 而机器本来就是好的。
423
+ const absentLine = {
424
+ npm: '未找到 npm(它随 Node 一起装:brew install node)',
425
+ swift: '未找到 swift(需安装 Xcode Command Line Tools:xcode-select --install)',
426
+ git: '未找到 git(xcode-select --install 会带上它,或 brew install git)',
427
+ open: '未找到 open(本安装器面向 macOS:Linux/Windows 上没有可装的东西,请在 macOS 14+ 上重跑)',
428
+ }
429
+ for (const name of ['npm', 'swift', 'git', 'open']) {
430
+ if (env[name]) continue
431
+ if (Array.isArray(env.unverified) && env.unverified.includes(name)) {
432
+ issues.push(`${name} 未能确认:探测命令(\`${name} --version\`)在 ${PROBE_TIMEOUT_MS / 1000}s、补一次的 ${PROBE_RETRY_TIMEOUT_MS / 1000}s 内都没有答话。`
433
+ + `这不是"未安装"——先手动跑 \`${name} --version\` 看它卡在哪;若只是本机负载高,重跑一次安装即可。`)
434
+ continue
435
+ }
436
+ issues.push(absentLine[name])
345
437
  }
346
438
  return issues
347
439
  }
348
440
 
441
+
349
442
  /** 探测 unix socket 是否已有 daemon 在监听;连接超时 800ms 视为不可达。 */
350
443
  export function socketReachable(socketPath) {
351
444
  return new Promise((resolve) => {
@@ -483,7 +576,7 @@ export function classifyKillResult({ status, stderr = '', error = null } = {}) {
483
576
  }
484
577
 
485
578
  /** 这个 pid 是不是**僵尸**(已死、等父进程回收):`kill -0` 对僵尸回 0,光靠信号探测会把
486
- * 一个已经不服务 socket 的进程报成"还活着、请你 sudo kill"。状态读不出来时保守回 false
579
+ * 一个已经不服务 socket 的进程报成"还活着、请你去收尾"。状态读不出来时保守回 false
487
580
  * (宁可多报一个幸存者,也不放走真的还在跑的进程)。 */
488
581
  export function isZombiePid(pid, { spawn = spawnSync } = {}) {
489
582
  try {
@@ -536,13 +629,13 @@ export function terminatePids(pids, { spawn = spawnSync, sleepMs = 1000, reprobe
536
629
  let after = state(pid)
537
630
  if (after !== 'gone') {
538
631
  // 刚被 SIGKILL 的进程要等它的父进程回收(父进程正 `wait` 着的话是毫秒级);
539
- // 立刻探一次会把它读成"还活着",于是红字让人去 sudo kill 一个已经没了的 pid。
632
+ // 立刻探一次会把它读成"还活着",于是红字让人去收尾一个已经没了的 pid。
540
633
  // 给一次**有界**的再探窗口,只有窗口过后还在的才算幸存者。
541
634
  if (sleep) sleep(reprobeMs)
542
635
  else spawn('sleep', [String(reprobeMs / 1000)], { encoding: 'utf8' })
543
636
  after = state(pid)
544
637
  // 还"活着"但状态是 Z = 它已经死了,只差父进程 wait() 收尸:这种东西不持有 socket、
545
- // 不需要人去 sudo kill,报成幸存者就是把人支到一条没用的命令上。
638
+ // 不需要人去做任何收尾动作,报成幸存者就是把人支到一条没用的命令上。
546
639
  if (after === 'alive' && isZombiePid(pid, { spawn })) after = 'gone'
547
640
  }
548
641
  if (after === 'gone') {
@@ -556,11 +649,15 @@ export function terminatePids(pids, { spawn = spawnSync, sleepMs = 1000, reprobe
556
649
  return { terminated, forced, survivors }
557
650
  }
558
651
 
559
- /** 幸存者(安装器发不动信号的进程)的红字(finding 5,纯函数)。
560
- * 这里给的必须是**人能直接执行**的动作,而不是"请检查权限":安装器没有 root 权限,
561
- * 所以只有坐在键盘前的这个人能 `sudo kill -9`;他知道 pid、知道为什么必须他做、
562
- * 也知道做完要重跑什么。socket 还挂在这些 pid 上,所以本轮的产物从未接管——
563
- * 收尾那次版本核对会把这件事再钉一次。 */
652
+ /** 幸存者(安装器收不掉的进程)的红字(finding 5,纯函数)。
653
+ * 这里给的必须是**能直接执行**的动作,而不是"请检查权限";而本产品的红线是:
654
+ * 补救文案一律不发提权命令。发信号这条本来就分两档,两档都如实写出来——
655
+ * ① 当前账号自己就能做的 `/bin/kill -9 <pid>`(安装器已经试过 TERM 与 KILL,
656
+ * 再报一次只是给人一个能手敲的等价动作,它不越权);
657
+ * ② 它被 EPERM 拒掉 = 这些 pid 不属于当前账号,那一步**只有坐在键盘前的这个人
658
+ * 能做**。这一步归人,不归脚本:本产品保留给真人的就是这种物理动作。
659
+ * socket 还挂在这些 pid 上,所以本轮的产物从未接管——收尾那次版本核对会把这件
660
+ * 事再钉一次。退出码契约不变(调用方仍按失败说)。 */
564
661
  export function survivorText(survivors, { socketPath } = {}) {
565
662
  const pids = survivors.map((entry) => entry.pid).join(' ')
566
663
  return [
@@ -568,8 +665,10 @@ export function survivorText(survivors, { socketPath } = {}) {
568
665
  + survivors.map((entry) => `pid ${entry.pid} — ${entry.reason}`).join(';'),
569
666
  `安装器无权向这些进程发信号(它们不属于当前账号,典型是 root 拉起的旧 daemon)。`
570
667
  + `上面"已结束"那句只对确实已经不在的 pid 成立,不含这些。`,
571
- `由人来收尾:先看清是谁起的 \`ps -o pid=,user=,lstart=,command= -p ${pids}\`,`
572
- + `再 \`sudo kill -9 ${pids}\`,然后重跑本安装器并加 --replace-daemon。`,
668
+ `先试当前账号自己就能做的那条(不提权):\`/bin/kill -9 ${pids}\`。`
669
+ + `它若仍被拒,就只能由坐在键盘前的人来结束这些进程——本安装器不发放提权命令,`
670
+ + `这一步归人。先看清是谁起的 \`ps -o pid=,user=,lstart=,command= -p ${pids}\`,`
671
+ + `由本人结束它们,然后重跑本安装器并加 --replace-daemon。`,
573
672
  ].join('\n')
574
673
  }
575
674
 
@@ -1990,7 +2089,10 @@ export function installGuard({ home = '', uid = null } = {}) {
1990
2089
  '当前是 uid 0(root / sudo):GlassPane 是**用户级** launchd 代理与 TCC 授权,整棵安装会以 root 身份'
1991
2090
  + '落进 /var/root(产物、socket、plist 都不是你登录账号的那一份),辅助功能授权主体也永远对不上。'
1992
2091
  + '本安装不需要 sudo。补救:去掉 sudo 直接重跑同一条命令'
1993
- + '(curl -fsSL https://raw.githubusercontent.com/jingzhao-l/GlassPane/main/install.sh | sh,'
2092
+ // 这条补救话里的 URL **必须是那个常量的插值**。写第二份字面量就是仓库改名/换 org 时
2093
+ // 唯一不会被任何闸发现的复制品:`installer/test/install-sh.test.mjs` 按这条断言,
2094
+ // 而它测的是这段拒绝文本真的带着 INSTALL_SH_URL。
2095
+ + `(curl -fsSL ${INSTALL_SH_URL} | sh,`
1994
2096
  + '或 node installer/cli.js)。',
1995
2097
  }
1996
2098
  }
@@ -2725,7 +2827,16 @@ if (isMain) {
2725
2827
  // 从这里现形的(那台机器没有 swift,预检拒绝必然走到,而测试原本指望走到 install
2726
2828
  // 的拒绝分支——一条依赖机器状态的断言)。
2727
2829
  const { options, error } = parseArgs(process.argv.slice(2))
2728
- if (error) {
2830
+ // 平台判据住在 cli.js 这一侧才是权威的:`uname` 是 PATH 上一个可以被换掉的程序(本轮实测:
2831
+ // 桩一个 `uname` 说 Linux,install.sh 那一道只管挡"要 clone 的那一趟",源码在手边时一路走到
2832
+ // `exec node cli.js`,npm ci / tsc / swift build / launchd 注册全部真的发生),而
2833
+ // `process.platform` 是 node 向主机问来的,桩不掉。放在 --help / 参数错误 / --restore-launchd
2834
+ // 之后判:CI 的 install-gate 那一格跑在非 macOS 上,它读的正是 usage 与预检那几句。
2835
+ const refusal = (error || options.help || options.restoreLaunchd) ? null : platformRefusal()
2836
+ if (refusal) {
2837
+ process.stderr.write(paint(`\n${refusal}\n`, 'red'))
2838
+ process.exitCode = 1
2839
+ } else if (error) {
2729
2840
  process.stderr.write(`\n${paint(`参数错误:${error}`, 'red')}\n\n`)
2730
2841
  process.stderr.write(usageText())
2731
2842
  process.exitCode = 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-install",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "GlassPane one-command installer: terminal onboarding → install deps → build TS + Swift engine → start daemon → open settings GUI for permission guide",
5
5
  "type": "module",
6
6
  "license": "MIT",