glasspane-install 1.20.2 → 1.22.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 (4) hide show
  1. package/README.md +36 -5
  2. package/cli.js +350 -38
  3. package/glasspane.js +364 -0
  4. package/package.json +4 -2
package/README.md CHANGED
@@ -10,14 +10,15 @@ macOS 14+.
10
10
  ## Channels / 安装渠道
11
11
 
12
12
  ```bash
13
- # 1) Fetch, then run (recommended): install.sh clones the pinned release tag. Two steps on
13
+ # 1) This package via npx (recommended) — also clones the pinned release tag when no local
14
+ # repo is found, then runs the same flow. It is also the only channel that leaves the
15
+ # `glasspane` command on PATH; `npm install -g glasspane-install` keeps it there permanently.
16
+ npx glasspane-install
17
+
18
+ # 2) Fetch, then run: install.sh clones the pinned release tag. Two steps on
14
19
  # purpose — piped into `sh`, a curl that fails leaves stdin empty and the pipeline exits 0.
15
20
  curl -fsSL https://raw.githubusercontent.com/jingzhao-l/GlassPane/main/install.sh -o /tmp/gp-install.sh && sh /tmp/gp-install.sh
16
21
 
17
- # 2) This package via npx — also clones the pinned release tag when no local
18
- # repo is found, then runs the same flow
19
- npx glasspane-install
20
-
21
22
  # 3) From an existing checkout
22
23
  node installer/cli.js
23
24
  ```
@@ -27,9 +28,39 @@ channels clone the **same pinned tag** — never a moving branch — so a fresh
27
28
  is reproducible. Override with `GLASSPANE_REF=<tag>` (`main` is available but is not
28
29
  a fixed artifact).
29
30
 
31
+ An existing clone at the install directory is **advanced**, not ignored and not
32
+ clobbered: if `git status --porcelain` is empty the installer fetches the pinned tag
33
+ and `checkout --detach`s onto it; if it is dirty it refuses, names the files in the
34
+ way, and offers a different `GLASSPANE_INSTALL_DIR`. No `git stash`, no `checkout -f`,
35
+ no `clean` — an installer has no business destroying somebody's uncommitted work
36
+ (that refusal is what replaced the 2026-10-26 shape, where one channel printed "the
37
+ existing clone will not be switched" and then built from a three-versions-old tree
38
+ while the other channel died on git's "already exists and is not an empty directory").
39
+
30
40
  三条渠道最终都走同一份 `installer/cli.js`,且两条一键渠道 clone 的是同一个**钉住的发布 tag**(不是移动的分支),
31
41
  所以同一天两次安装拿到的是同一份源码。
32
42
 
43
+ 安装目录里已经有一棵 clone 时,这一趟**把它前进**到钉住的那个 tag,而不是照用旧树、也不是删掉重来:
44
+ `git status --porcelain` 是空的就 fetch + `checkout --detach`;不空就**拒绝**,点名挡路的那些文件,
45
+ 并给出换一个 `GLASSPANE_INSTALL_DIR` 的走法。没有 `git stash`、没有 `checkout -f`、没有 `clean`——
46
+ 安装器不该有能把别人的未提交工作抹掉的那一格。
47
+
48
+ ## The `glasspane` command / `glasspane` 这条命令
49
+
50
+ 这个包同时交付 `glasspane`(`bin` 里两个名字,见 `package.json`),回答的是"面板按钮失败了,
51
+ 而我不知道自己装到了哪里":
52
+
53
+ | 命令 | 做什么 | 退出码 |
54
+ |---|---|---|
55
+ | `glasspane doctor` | 只读这台机器:状态根、安装指针、更新器状态、在册 daemon 作业、socket、两个全局 npm 包;每格答 available / absent / unverified | 0 全在位;3 有格子不在位或答不了 |
56
+ | `glasspane update` | 先 `check`,再装通过校验的那一份;检查停住就不进安装 | 沿用更新器自己的那一档(0/3/4/5) |
57
+ | `glasspane status` | 把更新器那一行结论原样打出来 | 同上 |
58
+
59
+ 它只按 `~/.glasspane/update-install.json` 找那份更新器(安装器写的、面板读的同一份),不从当前目录猜;
60
+ 不 sudo、不删东西,需要人做的那一步只点名工具与目录。npm 那一格的补法给的是
61
+ `npm install -g glasspane-mcp@<版本> glasspane-install@<版本>`——那取的是 registry 的发布物,
62
+ 与更新器从已校验暂存树里现场打包的那一份是两种来源,这句话也一起说。
63
+
33
64
  ## Flags / 选项
34
65
 
35
66
  | 选项 | 作用 |
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.20.2'
73
+ export const RELEASE_VERSION = '1.22.0'
74
74
  /** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
75
75
  export const REPO_REF = `v${RELEASE_VERSION}`
76
76
 
@@ -684,25 +684,74 @@ export function bundlesToReplace({ plan, exists = fs.existsSync } = {}) {
684
684
  .map(([, target]) => target)
685
685
  }
686
686
 
687
+ /** 安置一份 bundle 时那条"没落地"的拒绝:说清动到哪一步、旧的一份还在不在、下一步跑什么。
688
+ * finding C9(2026-10-24):从前这里只把 `cpSync` 的 errno 原样抛出去——读的人既不知道
689
+ * 旧 bundle 是否还在(旧实现是先 `rm -rf` 再拷,中途失败就是"旧的没了、新的是半棵"),
690
+ * 也没有一句下一步。现在每一种失败各自点名"原件在不在",并给一条真能跑的命令。 */
691
+ function bundlePlacementFailureText({ source, target, staged, retired, error, step, stagedRemoved = true }) {
692
+ const raw = String(error?.message ?? error)
693
+ const errno = error?.code && !raw.startsWith(String(error.code)) ? `${error.code}: ${raw}` : raw
694
+ const head = `安置 ${target} 失败(来源 ${source}):${errno}`
695
+ const dir = JSON.stringify(path.dirname(target))
696
+ const q = (p) => JSON.stringify(p)
697
+ if (step === 'copy' || step === 'retire') {
698
+ return `${head}\n`
699
+ + `新的一份只走到暂存位置就断了,原件还在原位——${target} 仍是上一轮那份**完整**的 bundle,`
700
+ + `本轮没有任何一步动过它的内容。下一步:空间不够看 \`df -h ${dir}\`,权限/旗标看 `
701
+ + `\`ls -ld ${dir}\` 与 \`ls -lO ${q(target)}\`(macOS 的 immutable 旗标 uchg 会让改名与覆盖都失败),`
702
+ + `腾出来或修好之后重跑 install.sh。`
703
+ + (stagedRemoved
704
+ ? `本轮的暂存副本 ${staged} 已删,不需要手工清理。`
705
+ : `本轮的暂存副本 ${staged} 没能删掉(同一个错误也挡住了它),重跑前手工 \`rm -rf ${q(staged)}\` 即可。`)
706
+ }
707
+ if (step === 'restore') {
708
+ return `${head}\n`
709
+ + `这一步之后本机已经没有 ${target} 了:新的一份在 ${staged},上一轮那份完整的备份在 ${retired},`
710
+ + `而把它们放回原位的改名也被同一个错误挡住。这一步归人(安装器不发放提权命令):`
711
+ + `先 \`mv ${q(retired)} ${q(target)}\` 把旧的一份放回原位,或 \`mv ${q(staged)} ${q(target)}\` 用本轮新的一份;`
712
+ + `两个都跑不通就是 ${path.dirname(target)} 的权限问题,看 \`ls -ld ${dir}\`,修好后重跑 install.sh。`
713
+ }
714
+ // step === 'place':新的一份没能落位,旧的一份已经被挪回原位——机器上此刻仍是完整的一版。
715
+ return `${head}\n`
716
+ + `${target} 已经放回上一轮那一份(先把备份 ${retired} 挪回原位再报错),本轮新的一份留在 ${staged}——`
717
+ + `本机现在不是"半棵",是旧的那版在位。下一步:\`ls -ld ${dir}\` 看这个目录当前账号能不能写`
718
+ + `(EACCES 通常是目录属主不是您,或这卷不保存权限位),修好后重跑 install.sh;`
719
+ + `两处残留 ${staged} 与 ${retired} 想清就 \`rm -rf\`,留着也不影响下一轮。`
720
+ }
721
+
687
722
  /** 把 make-app.sh 的两个 bundle 安置到 ~/Applications(覆盖式,幂等)。
688
- * 返回 `{ installed, declined }`(finding 11):
723
+ * 返回 `{ installed, declined, leftovers }`(finding 11):
689
724
  * - `installed` = 本轮真的写进去的条目;
690
725
  * - `declined` = 目标**已存在**而 `allowReplace=false`(人回答 n,或非交互环境没给
691
726
  * `--no-prompt`)因此没有覆盖的条目——留着的是上一轮的旧 bundle,daemon 启动路径
692
727
  * 会照旧选中它。这不是"无所谓"的分支:收尾那次版本核对正是靠它拦住"装了新版、
693
728
  * 跑的还是旧版"。
694
- * 源缺失即跳过(--no-app 或构建产物不全时不报错)。 */
729
+ * - `leftovers` = 没能删干净的暂存/备份目录(磁盘动作失败不影响安置结论,但要如实报出来)。
730
+ * 源缺失即跳过(--no-app 或构建产物不全时不报错)。
731
+ *
732
+ * finding C9(2026-10-24)的**顺序**就是修复本身。从前是 `remove(target)` → `copy(source,
733
+ * target)`:`cpSync` 在中途 ENOSPC/EACCES 就留下"旧的没了、新的是半棵"——一个既不能跑也
734
+ * 没法回滚的目录,而报错只有 errno。现在四步,每一步失败时机器上的 bundle 都是完整的:
735
+ * 1. 新的一份整体拷到 `${target}.tmp-<token>`(旧目标还好好在原位);
736
+ * 2. 旧的一份**改名**到 `${target}.old-<token>`(不是删除,所以随时能放回原位);
737
+ * 3. 把暂存的一份**改名**到目标——同目录内的 rename 是原子的,目标要么整个是旧的、
738
+ * 要么整个是新的,"半棵"这一档压根不存在;
739
+ * 4. 删掉备份位(这一步失败只是留个残骸,进 `leftovers`,不算安置失败)。
740
+ * 第 2/3 步抛出时先把旧的一份放回原位,再带着"动到哪一步 + 原件在不在 + 下一步跑什么"报出去。 */
695
741
  export function installBundles({
696
742
  plan,
697
743
  exists = fs.existsSync,
698
744
  remove = fs.rmSync,
699
745
  copy = fs.cpSync,
700
746
  mkdir = fs.mkdirSync,
747
+ rename = fs.renameSync,
748
+ token = `${process.pid}-${Date.now().toString(36)}`,
701
749
  allowReplace = true,
702
750
  } = {}) {
703
751
  const installed = []
704
752
  const declined = []
705
- if (!plan?.appsDir) return { installed, declined }
753
+ const leftovers = []
754
+ if (!plan?.appsDir) return { installed, declined, leftovers }
706
755
  mkdir(plan.appsDir, { recursive: true })
707
756
  const pairs = [
708
757
  [plan.builtSettingsApp, plan.settingsApp],
@@ -714,11 +763,51 @@ export function installBundles({
714
763
  declined.push(target)
715
764
  continue
716
765
  }
717
- remove(target, { recursive: true, force: true })
718
- copy(source, target, { recursive: true })
719
- installed.push(target)
766
+ const staged = `${target}.tmp-${token}`
767
+ const retired = `${target}.old-${token}`
768
+ let step = 'copy'
769
+ let stagedRemovalFailed = false
770
+ try {
771
+ // 上一轮被打断留下的同名暂存位先清掉,否则本轮的拷贝会落在一棵半截树上面。
772
+ if (exists(staged)) remove(staged, { recursive: true, force: true })
773
+ copy(source, staged, { recursive: true })
774
+ if (exists(target)) {
775
+ step = 'retire'
776
+ rename(target, retired)
777
+ }
778
+ step = 'place'
779
+ rename(staged, target)
780
+ step = 'cleanup'
781
+ try {
782
+ remove(retired, { recursive: true, force: true })
783
+ } catch {
784
+ leftovers.push(retired)
785
+ }
786
+ installed.push(target)
787
+ } catch (error) {
788
+ if (step === 'place' || step === 'retire') {
789
+ // 目标此刻是空的(旧的挪走了、新的没落位)——把旧的一份放回原位。
790
+ try {
791
+ if (exists(retired) && !exists(target)) rename(retired, target)
792
+ } catch (restoreError) {
793
+ throw new Error(bundlePlacementFailureText({
794
+ source, target, staged, retired, error: restoreError, step: 'restore',
795
+ }), { cause: restoreError })
796
+ }
797
+ } else {
798
+ try {
799
+ remove(staged, { recursive: true, force: true })
800
+ } catch {
801
+ leftovers.push(staged)
802
+ stagedRemovalFailed = true
803
+ }
804
+ }
805
+ throw new Error(bundlePlacementFailureText({
806
+ source, target, staged, retired, error, step, stagedRemoved: !stagedRemovalFailed,
807
+ }), { cause: error })
808
+ }
720
809
  }
721
- return { installed, declined }
810
+ return { installed, declined, leftovers }
722
811
  }
723
812
 
724
813
  /** 生成 launchd 用户代理 plist(纯函数,零副作用;XML 转义防路径注入)。
@@ -1030,6 +1119,16 @@ export function socketHello(
1030
1119
  * - 作业已加载且自报 granted → 无需任何动作,如实报告;
1031
1120
  * - 已加载但自报非 granted(含 permissions 缺失)→ TCC 按进程缓存,
1032
1121
  * kickstart -k 换一个新判定进程再验——勾完框重跑本命令即可闭环。
1122
+ *
1123
+ * finding C3(2026-10-24):上面那条"作业已加载但定义变了"的路要 **bootout** 一个
1124
+ * 已加载作业,而 install() 对同一个动作是有 `confirm` 门的(autoYes 只由 `--no-prompt`
1125
+ * 决定)。本命令从前调 `bootstrapFn(label, resolvedPlist)` 时不传 `allowBootout`,默认
1126
+ * true ——于是 `--restore-launchd`(产品交给 agent 的"daemon 不可达"补救命令,
1127
+ * `mcp-shell/src/tools.ts` 甚至专门叮嘱过"这一步别重启东西,在飞的 act 会被打断")
1128
+ * 可以在用户应用正在 act 中途卸掉一个健康作业且一句不问。现在复用同一道门:
1129
+ * 先按"不许 bootout"探一次(`launchctlBootstrap` 的 `bootoutDeclined` 那一支就是为此
1130
+ * 存在的只读答案,一次 launchctl 都不多写),真需要 bootout 才问;非交互且没给
1131
+ * `--no-prompt` 就回拒,且磁盘与 launchd 都没被动过。
1033
1132
  */
1034
1133
  export async function restoreLaunchd({
1035
1134
  label = LAUNCHD_LABEL,
@@ -1042,6 +1141,8 @@ export async function restoreLaunchd({
1042
1141
  waitFn = waitForSocket,
1043
1142
  helloFn = socketHello,
1044
1143
  existsFn = (p) => fs.existsSync(p),
1144
+ confirmFn = confirm,
1145
+ autoYes = false,
1045
1146
  serveTimeoutMs = 10000,
1046
1147
  } = {}) {
1047
1148
  // finding 4:这条路径也是拿 HOME 拼出来的(旧代码在参数默认值里读
@@ -1066,8 +1167,38 @@ export async function restoreLaunchd({
1066
1167
  message: `launchd plist 不存在(${resolvedPlist}):从未安装或已被删除,请重跑 install.sh 完整安装`,
1067
1168
  }
1068
1169
  }
1069
- const boot = bootstrapFn(label, resolvedPlist)
1170
+ // 第一趟一律按"不许 bootout"问:需要卸掉已加载作业时它回 `bootoutDeclined` 且一个字节
1171
+ // 都不动,这就是"要不要问人"的那个只读答案;不需要 bootout 的常规路径(未加载 →
1172
+ // bootstrap,或已加载且定义一致 → already)照旧一次问都不问。
1173
+ let boot = bootstrapFn(label, resolvedPlist, { allowBootout: false })
1174
+ let bootoutDeclined = false
1175
+ if (boot.ok !== true && boot.bootoutDeclined === true) {
1176
+ const allowBootout = await confirmFn(
1177
+ `${label} 已加载且跑的是旧定义,而 ${resolvedPlist} 写的是另一份——卸载它才能换新定义,`
1178
+ + '这会打断 daemon 正在替用户执行的 act',
1179
+ { autoYes },
1180
+ )
1181
+ if (allowBootout) {
1182
+ boot = bootstrapFn(label, resolvedPlist, { allowBootout: true })
1183
+ } else {
1184
+ bootoutDeclined = true
1185
+ }
1186
+ }
1070
1187
  if (!boot.ok) {
1188
+ if (bootoutDeclined) {
1189
+ return {
1190
+ ok: false,
1191
+ action: 'bootout-declined',
1192
+ verified: null,
1193
+ message: `没有卸载已加载的 ${label}(本轮未获得确认),因此也没有 bootstrap——launchd 与磁盘都没被动过,什么都没改。`
1194
+ + `现况:${boot.message}\n`
1195
+ + '下一步(三选一,没有一项会静动别人的作业):① 在自己的交互终端里重跑 '
1196
+ + 'node installer/cli.js --restore-launchd 并回答继续;'
1197
+ + '② 脚本/自动化里显式带上放行旗标:node installer/cli.js --restore-launchd --no-prompt'
1198
+ + '(带上就等于允许这一步 bootout 已加载的作业);'
1199
+ + `③ 不想换新定义就什么都不用做——launchd 仍在按它手里那份旧定义服务,${resolvedPlist} 也没被改写。`,
1200
+ }
1201
+ }
1071
1202
  // 两种"没注册上"要分开说:bootout 失败意味着旧作业还在跑旧定义(agent 下一步是把它卸掉),
1072
1203
  // bootstrap 失败才是"卸干净了但装不上"。合成一句会把人支到错的那条命令上去。
1073
1204
  return {
@@ -1612,9 +1743,12 @@ export function run(cmd, args, { cwd } = {}) {
1612
1743
  * - 交互终端:问一句,回车继续、`n` 跳过;
1613
1744
  * - 非交互又没给 flag:**回 false**,不阻塞、也不"替用户同意"。
1614
1745
  * 旧实现在这里把非 TTY 当成"自动同意",于是 `curl | sh`(管道里没有终端)替每一条
1615
- * 破坏性动作点了"是"——而 `install.sh` 正是文档里那条命令。现在由入口脚本在非 TTY 时
1616
- * 显式补 `--no-prompt`(install.sh 的 SILENT),放行这件事变成一条写在脚本里的、可被读到的
1617
- * 决定,而不是一个默认值。`interactive`/`ask` 是测试接缝:没有它们这三档只能靠真终端验。 */
1746
+ * 破坏性动作点了"是"——而 `install.sh` 正是文档里那条命令。现在入口脚本**不代答**:
1747
+ * 它在非 TTY 时只说明"这一趟会跳过什么、怎么放行"(`sh -s -- --no-prompt`),放行这件事
1748
+ * 是一条写在调用行里的、可被读到的决定,而不是一个藏在脚本里的默认值。
1749
+ * (从前这里写的是"由入口脚本在非 TTY 时显式补 `--no-prompt`(install.sh 的 SILENT)"——
1750
+ * 那个代答在 2026-10-20 被废,SILENT 变量也已经由 review-2026-10-09.test.mjs 钉死不许回来。
1751
+ * `interactive`/`ask` 是测试接缝:没有它们这三档只能靠真终端验。) */
1618
1752
  export async function confirm(question, {
1619
1753
  autoYes = false,
1620
1754
  interactive = Boolean(process.stdin.isTTY),
@@ -1849,12 +1983,14 @@ export function usageText() {
1849
1983
  '选项:',
1850
1984
  ' --repo <目录> 指定项目根目录(默认自动向上查找)',
1851
1985
  ' --no-prompt 跳过破坏性步骤的确认(脚本/自动化场景)。被它拦住的是三件:',
1852
- ' ① bootout 已加载的 launchd daemon 作业(会打断正在跑的 act);',
1986
+ ' ① bootout 已加载的 launchd daemon 作业(会打断正在跑的 act;',
1987
+ ' install 与 --restore-launchd 走的是同一道门);',
1853
1988
  ' ② 覆盖 ~/Applications 里已存在的 .app;③ --replace-daemon 结束',
1854
1989
  ' 已在跑的实例。交互终端会逐条问;非交互(stdin 不是终端)**且没给**本 flag 时',
1855
1990
  ' 这三步一律不执行(不阻塞等待、也不默认同意)并打印说明——',
1856
- ' 所以 CI / e2e / `curl | sh` 这类调用必须显式带 --no-prompt',
1857
- ' (install.sh 在 stdin 非 TTY 时已经自动补上)。',
1991
+ ' 所以 CI / e2e / `curl | sh` 这类调用必须显式带 --no-prompt。',
1992
+ ' 入口脚本 install.sh 不代答:stdin 不是终端时它只说明这一趟会跳过',
1993
+ ' 什么、怎么放行,把决定留给敲命令的人。',
1858
1994
  ' --no-daemon 不启动 glasspaned',
1859
1995
  ' --replace-daemon 先结束已在跑的 glasspaned 实例(旧构建的 daemon 会继续占',
1860
1996
  ' socket,让系统设置里的授权条目与实际服务实例对不上)',
@@ -1867,7 +2003,8 @@ export function usageText() {
1867
2003
  ' 检查";手动 check/apply 仍可用。等价的环境开关由 updater 自己',
1868
2004
  ' 判定(本安装器不重复解读那个环境变量)',
1869
2005
  ' --restore-launchd 只做 launchd 恢复:检测作业被 bootout → bootstrap → hello',
1870
- ' 校验 daemon 自报席位(勾完 TCC 框后重跑即自动换进程复验)',
2006
+ ' 校验 daemon 自报席位(勾完 TCC 框后重跑即自动换进程复验);',
2007
+ ' 恢复要换的是**已加载**作业的定义时,它和 install 一样先过 ① 那道门',
1871
2008
  ' --skip-build 跳过 npm/tsc/swift 编译(已构建过时使用)',
1872
2009
  ' --no-bootstrap 本地找不到仓库时不自动 clone,改为打印定位指引',
1873
2010
  ' -h, --help 显示本帮助',
@@ -2167,6 +2304,8 @@ export function bootstrapPlan({
2167
2304
  repoUrl = REPO_URL,
2168
2305
  ref = REPO_REF,
2169
2306
  installDir = null,
2307
+ // 只读探针:注入它,这条纯函数才能被"目录已经存在且是一棵 GlassPane 仓库"这一支单测覆盖。
2308
+ readCloneState = null,
2170
2309
  } = {}) {
2171
2310
  if (!homeDir) {
2172
2311
  return { error: '无法确定用户主目录(HOME 为空),不知道把源码 clone 到哪里' }
@@ -2177,6 +2316,18 @@ export function bootstrapPlan({
2177
2316
  error: `目标目录已存在且不是 GlassPane 仓库:${targetDir}(用 --repo 指定仓库,或清理该目录后重试)`,
2178
2317
  }
2179
2318
  }
2319
+ // 目录存在**且是一棵 GlassPane 仓库**:这一支从前直接往下走 `git clone`,于是产品自己的安装位置
2320
+ // (`~/glasspane`)里那份旧 clone 换来的是 git 的 "destination path … already exists and is not an
2321
+ // empty directory"——读者按提示"换个目录"重跑,下一次又落在同一个地方。现在它前进那棵树,或说清
2322
+ // 为什么不动它(判据见 `existingCloneDecision`)。
2323
+ if (fs.existsSync(targetDir) && typeof readCloneState === 'function') {
2324
+ const state = readCloneState({ targetDir, ref })
2325
+ const decision = existingCloneDecision({ targetDir, ref, ...state })
2326
+ if (decision.action !== 'advance') {
2327
+ return { use: decision.action === 'use', targetDir, refuse: decision.action === 'refuse' ? decision : null, decision }
2328
+ }
2329
+ return { advance: { targetDir, ref, decision } }
2330
+ }
2180
2331
  // 三个值都会进 git argv:ref 来自 GLASSPANE_REF、目录来自 GLASSPANE_INSTALL_DIR,
2181
2332
  // 都是文档里写明的覆盖口子。判据与 install.sh 的 assert_ref/assert_repo_url 同源。
2182
2333
  const clone = safeGitCloneArgs({ repoUrl, ref, targetDir })
@@ -2188,6 +2339,140 @@ export function bootstrapPlan({
2188
2339
  }
2189
2340
  }
2190
2341
 
2342
+ /**
2343
+ * 既有 clone 的结论(纯判定,可单测)。
2344
+ *
2345
+ * 为什么这一格不能像以前那样"提示一句就照用":`~/glasspane` 是产品自己的安装位置,`install.sh` 与
2346
+ * `npx glasspane-install` 都往那里 clone。上一版在这里只打印"既有 clone 不会被自动切到 v1.21.0",
2347
+ * 然后**照用那棵树**去构建——2026-10-26 这台机器上那是 v1.5.0 的 clone,于是"一键安装"把一个 1.5.0
2348
+ * 的产物当成新装交出去,而 `npx` 那条路更糟:它直接对已存在目录跑 `git clone`,被 git 以
2349
+ * "destination path … already exists and is not an empty directory" 拒掉,读者只剩"换目录"这一条死路。
2350
+ *
2351
+ * 判据按那个人 2026-10-26 选的形状:
2352
+ * · 干净(`git status --porcelain` 空)且 HEAD 已经是那个 tag ⇒ 就用;
2353
+ * · 干净但不是那个 tag ⇒ 前进(fetch + detached checkout —— 这是移动**产品自己的 clone**,
2354
+ * 不是移动任何人的工作树);
2355
+ * · 脏 ⇒ 拒绝,点名前 10 个挡路的路径,并给一条不销毁任何东西的出路(换一个空目录);
2356
+ * · 读不出 HEAD 或 tag ⇒ 也拒绝。"判不出来"永远不许变成"那就照用/那就切过去"。
2357
+ */
2358
+ export function existingCloneDecision({ targetDir, ref, head = null, tagCommit = null, blocking = [] }) {
2359
+ const dirty = blocking.map((line) => String(line).trim()).filter((line) => line !== '')
2360
+ if (dirty.length > 0) {
2361
+ const shown = dirty.slice(0, 10)
2362
+ return {
2363
+ action: 'refuse',
2364
+ blocking: dirty,
2365
+ text: `${targetDir} 有 ${dirty.length} 处未提交改动,安装器不会切走它(切走会把别人的工作当成构建源码):\n`
2366
+ + shown.map((line) => ` ${line}`).join('\n')
2367
+ + (dirty.length > shown.length ? `\n …另有 ${dirty.length - shown.length} 处` : '')
2368
+ + `\n下一步(不销毁任何东西,两条都做得动):`
2369
+ + `\n a) 换一处干净的家再跑一键安装:GLASSPANE_INSTALL_DIR=~/glasspane-${String(ref).replace(/[^A-Za-z0-9._+-]/g, '_')} `
2370
+ + `sh install.sh(或 npx glasspane-install);`
2371
+ + `\n b) 自己收着这些改动:在 ${targetDir} 里把它们提交或转移到别处,再重跑本命令——`
2372
+ + '安装器不替你决定这些文件去哪。',
2373
+ }
2374
+ }
2375
+ if (!head || !tagCommit) {
2376
+ return {
2377
+ action: 'refuse',
2378
+ blocking: [],
2379
+ text: `${targetDir} 读不出 HEAD 或 ${ref} 指向的 commit(不是 git 仓库、浅历史里没有这个 tag,或 git 本身答不了):`
2380
+ + '判不出"这棵树是不是被签的那一份"就不动它,也不装它。'
2381
+ + `\n下一步:git -C "${targetDir}" fetch --tags --depth 1 后重跑;要一份全新的 clone 就 `
2382
+ + 'GLASSPANE_INSTALL_DIR=<空目录> 重跑一键安装。',
2383
+ }
2384
+ }
2385
+ if (head === tagCommit) {
2386
+ return { action: 'use', blocking: [], head, text: `${targetDir} 已经在 ${ref}(${String(head).slice(0, 7)}),直接用它构建。` }
2387
+ }
2388
+ return {
2389
+ action: 'advance',
2390
+ blocking: [],
2391
+ head,
2392
+ tagCommit,
2393
+ text: `${targetDir} 是干净的 clone,HEAD 在 ${String(head).slice(0, 7)} 而要装的是 ${ref}(${String(tagCommit).slice(0, 7)})——`
2394
+ + '把它前进到那一个 tag 再构建。',
2395
+ }
2396
+ }
2397
+
2398
+ /**
2399
+ * 一棵既有 clone 的三份事实(IO 读,不判定):HEAD 指向的 commit、`ref^{commit}`、以及
2400
+ * `git status --porcelain` 的行。判据一律在 `existingCloneDecision`,两处调用(bootstrap 的纯计划、
2401
+ * 前进这一步的复判)共用同一份读数,避免出现第二个"什么算脏"的说法。
2402
+ */
2403
+ export function readCloneFacts({ targetDir, ref, spawn = spawnSync } = {}) {
2404
+ const read = (args) => spawn('git', ['-C', targetDir, ...args], { encoding: 'utf8' })
2405
+ const sha = (what) => {
2406
+ const res = read(['rev-parse', '--verify', what])
2407
+ return res.status === 0 ? String(res.stdout ?? '').trim() : null
2408
+ }
2409
+ const status = read(['status', '--porcelain'])
2410
+ return {
2411
+ head: sha('HEAD'),
2412
+ tagCommit: sha(`${ref}^{commit}`),
2413
+ // `git status` 答非零时这里得到空数组,读起来是"干净"——那棵树随后会因为 `head` 读不出而被拒,
2414
+ // 所以"判不出来"走的是拒绝那一支,不是"那就切过去"。
2415
+ blocking: status.status === 0 ? String(status.stdout ?? '').split('\n').filter((line) => line !== '') : [],
2416
+ }
2417
+ }
2418
+
2419
+ /**
2420
+ * 把既有 clone 前进到钉住的 ref(IO 步;判据全在 `existingCloneDecision` 里)。
2421
+ *
2422
+ * 只有三条 git 动作:`status --porcelain` 与两次 `rev-parse`(读)、`fetch --tags --depth 1`(只往
2423
+ * .git 里加对象)、`checkout --detach <ref>`(只在树干净时执行)。没有 `-f`、没有 `stash`、没有
2424
+ * `clean`:一个 installer 不该有能把别人的未提交工作抹掉的那一格。
2425
+ */
2426
+ export function advanceExistingClone({ repoDir, ref, spawn = spawnSync } = {}) {
2427
+ const read = (args) => spawn('git', ['-C', repoDir, ...args], { encoding: 'utf8' })
2428
+ let facts = readCloneFacts({ targetDir: repoDir, ref, spawn })
2429
+ if (facts.blocking.length === 0 && facts.head && !facts.tagCommit) {
2430
+ // tag 不在这份浅历史里:先把它取回来,再判。fetch 失败不改任何东西。
2431
+ const fetched = read(['fetch', '--tags', '--depth', '1', 'origin', ref])
2432
+ if (fetched.status !== 0) {
2433
+ return {
2434
+ action: 'refuse',
2435
+ blocking: [],
2436
+ text: `${repoDir} 里没有 ${ref} 这个 tag,而 `
2437
+ + `git -C "${repoDir}" fetch --tags --depth 1 origin ${ref} 退出 ${String(fetched.status ?? 'null')}:`
2438
+ + `${String(fetched.stderr ?? '').trim().split('\n')[0] || '(git 没有留下 stderr)'}。\n`
2439
+ + '没有 clone、没有切分支、没有写入任何东西。下一步:确认网络与该 tag 存在('
2440
+ + `git ls-remote --tags origin ${ref}),或 GLASSPANE_INSTALL_DIR=<空目录> 换一处全新的 clone。`,
2441
+ }
2442
+ }
2443
+ facts = readCloneFacts({ targetDir: repoDir, ref, spawn })
2444
+ }
2445
+ let decision = existingCloneDecision({ targetDir: repoDir, ref, ...facts })
2446
+ if (decision.action !== 'advance') return decision
2447
+ const out = read(['checkout', '--detach', ref])
2448
+ if (out.status !== 0) {
2449
+ return {
2450
+ action: 'refuse',
2451
+ blocking: readCloneFacts({ targetDir: repoDir, ref, spawn }).blocking,
2452
+ text: `切到 ${ref} 没成(git checkout --detach 退出 ${String(out.status ?? 'null')}):`
2453
+ + `${String(out.stderr ?? '').trim().split('\n')[0] || '(git 没有留下 stderr)'}。\n`
2454
+ + `${repoDir} 仍在 ${String(facts.head ?? '读不出').slice(0, 7)},本轮没有构建任何东西。`,
2455
+ }
2456
+ }
2457
+ // 切完再读一次:判据要落在**切换之后**的事实上,不是落在命令的退出码上。
2458
+ const after = readCloneFacts({ targetDir: repoDir, ref, spawn })
2459
+ const recheck = existingCloneDecision({ targetDir: repoDir, ref, ...after })
2460
+ if (recheck.action !== 'use') {
2461
+ return {
2462
+ action: 'refuse',
2463
+ blocking: after.blocking,
2464
+ text: `切完之后 ${repoDir} 还不是 ${ref}(HEAD=${String(after.head ?? '读不出').slice(0, 7)},`
2465
+ + `tag=${String(after.tagCommit ?? '读不出').slice(0, 7)},另有 ${after.blocking.length} 处改动):本轮不拿它构建。`,
2466
+ }
2467
+ }
2468
+ return {
2469
+ ...recheck,
2470
+ advanced: true,
2471
+ from: facts.head,
2472
+ text: `${repoDir} 从 ${String(facts.head ?? '读不出').slice(0, 7)} 前进到 ${ref}(${String(after.head).slice(0, 7)}),用它构建。`,
2473
+ }
2474
+ }
2475
+
2191
2476
  /** 未定位到仓库时的指引文本(纯函数,可单测)。 */
2192
2477
  export function repoMissingText() {
2193
2478
  return [
@@ -2394,31 +2679,50 @@ export async function install({
2394
2679
  installDir: env.GLASSPANE_INSTALL_DIR ?? null,
2395
2680
  repoUrl: env.GLASSPANE_REPO_URL ?? REPO_URL,
2396
2681
  ref: pinRef,
2682
+ readCloneState: ({ targetDir, ref }) => readCloneFacts({ targetDir, ref }),
2397
2683
  })
2398
2684
  if (plan.error) {
2399
2685
  throw new Error(`${plan.error}\n\n${repoMissingText()}`)
2400
2686
  }
2401
- printStep(`未定位到本地仓库,引导 clone:${plan.targetDir}(ref: ${plan.ref})……`)
2402
- // stderr 要留着归因(finding 9):任何非零都算"网络不可达或 tag 不存在"会把
2403
- // "目标目录已存在且非空"这类本地故障说成网络问题,读者据此一直重试同一条死路。
2404
- const clone = spawnSync('git', plan.gitArgs, {
2405
- stdio: ['inherit', 'inherit', 'pipe'],
2406
- encoding: 'utf8',
2407
- })
2408
- if (clone.status !== 0) {
2409
- throw new Error(
2410
- `${cloneFailureText({
2411
- ref: plan.ref,
2412
- status: clone.status,
2413
- stderr: clone.stderr ?? clone.error?.message ?? '',
2414
- targetDir: plan.targetDir,
2415
- })}\n\n${repoMissingText()}`,
2416
- )
2417
- }
2418
- if (resolveProjectRoot(plan.targetDir) === null) {
2419
- throw new Error(`clone 完成但目录结构不完整:${plan.targetDir}\n\n${repoMissingText()}`)
2687
+ // 目录里已经有一棵 GlassPane 仓库:要么用它(已经在钉的那个 tag 上),要么前进它,要么说清为什么
2688
+ // 不动它。三条都是**结论**,没有一条是"那就 clone 试试,让 git 去报错"。
2689
+ if (plan.refuse) {
2690
+ throw new Error(`${plan.refuse.text}\n\n${repoMissingText()}`)
2691
+ }
2692
+ if (plan.use) {
2693
+ printStep(plan.decision.text)
2694
+ rootDir = plan.targetDir
2695
+ } else if (plan.advance) {
2696
+ printStep(plan.advance.decision.text)
2697
+ const advanced = advanceExistingClone({ repoDir: plan.advance.targetDir, ref: plan.advance.ref })
2698
+ if (advanced.action !== 'use') {
2699
+ throw new Error(`${advanced.text}\n\n${repoMissingText()}`)
2700
+ }
2701
+ printStep(advanced.text)
2702
+ rootDir = advanced.targetDir ?? plan.advance.targetDir
2703
+ } else {
2704
+ printStep(`未定位到本地仓库,引导 clone:${plan.targetDir}(ref: ${plan.ref})……`)
2705
+ // stderr 要留着归因(finding 9):任何非零都算"网络不可达或 tag 不存在"会把
2706
+ // "目标目录已存在且非空"这类本地故障说成网络问题,读者据此一直重试同一条死路。
2707
+ const clone = spawnSync('git', plan.gitArgs, {
2708
+ stdio: ['inherit', 'inherit', 'pipe'],
2709
+ encoding: 'utf8',
2710
+ })
2711
+ if (clone.status !== 0) {
2712
+ throw new Error(
2713
+ `${cloneFailureText({
2714
+ ref: plan.ref,
2715
+ status: clone.status,
2716
+ stderr: clone.stderr ?? clone.error?.message ?? '',
2717
+ targetDir: plan.targetDir,
2718
+ })}\n\n${repoMissingText()}`,
2719
+ )
2720
+ }
2721
+ if (resolveProjectRoot(plan.targetDir) === null) {
2722
+ throw new Error(`clone 完成但目录结构不完整:${plan.targetDir}\n\n${repoMissingText()}`)
2723
+ }
2724
+ rootDir = plan.targetDir
2420
2725
  }
2421
- rootDir = plan.targetDir
2422
2726
  }
2423
2727
 
2424
2728
  // 发布 tag GPG 验签:先过"这次到底验不验"的门(形状/存在性),再判动作,两条判据都是纯函数。
@@ -2512,7 +2816,8 @@ export async function install({
2512
2816
  // (另有 bootout 已加载作业、结束在跑的实例)。`--no-prompt` 过去是**惰性**的——
2513
2817
  // confirm() 没有任何调用方,README 与 --help 却写着"跳过每步确认"。现在它真的有
2514
2818
  // 一档可跳过:交互终端里会问一句;`--no-prompt` 直接放行;非交互又没给 flag 则不覆盖
2515
- // (install.sh 在 stdin 非 TTY 时本来就会补 --no-prompt,CI/e2e 与 `curl | sh` 行为不变)。
2819
+ // (install.sh 在 stdin 非 TTY 时**不代答**,它只把"这一趟会跳过什么"说出来——CI / e2e /
2820
+ // `curl | sh` 要放行就自己带上 --no-prompt,见 install.sh 末节与 install-sh 那组用例)。
2516
2821
  const replaceTargets = bundlesToReplace({ plan })
2517
2822
  let allowReplace = true
2518
2823
  if (replaceTargets.length > 0) {
@@ -2532,6 +2837,11 @@ export async function install({
2532
2837
  for (const app of bundles.declined) {
2533
2838
  printStep(paint(`未覆盖(回答跳过):${app}——里面还是上一轮的产物`, 'yellow'))
2534
2839
  }
2840
+ // 备份位删不掉不是安置失败(新的一份已经在位),但也不能不说:它是个看得见、
2841
+ // 没人认领的目录,留在 ~/Applications 里下一次安装还得再挪一次。
2842
+ for (const leftover of bundles.leftovers ?? []) {
2843
+ printStep(paint(`已安置,但备份没能删净:${leftover}——手工 \`rm -rf\` 掉它即可,本轮结论不受影响`, 'yellow'))
2844
+ }
2535
2845
  if (bundles.installed.length === 0 && bundles.declined.length === 0) {
2536
2846
  printStep('未安置任何 .app(make-app.sh 未产出 bundle),后续按裸二进制形态运行')
2537
2847
  }
@@ -2846,7 +3156,9 @@ if (isMain) {
2846
3156
  } else if (options.restoreLaunchd) {
2847
3157
  // 审计项④:恢复 + 校验全机器化,退出码即判定(0=daemon 就位且已按实际
2848
3158
  // 自报如实报告;1=作业/服务面失败),agent 可直接执行并按 message 续办。
2849
- const result = await restoreLaunchd()
3159
+ // finding C3:这一支也可能 bootout 一个已加载作业,所以它读同一个同意门——
3160
+ // `--no-prompt` 是唯一的显式放行,交互终端问一句,非交互又没给旗标就拒且零改动。
3161
+ const result = await restoreLaunchd({ autoYes: options.prompt === false })
2850
3162
  process.stdout.write(`${result.message}\n`)
2851
3163
  process.exitCode = result.ok ? 0 : 1
2852
3164
  } else {
package/glasspane.js ADDED
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `glasspane` — the one command an installed machine should need.
4
+ *
5
+ * Why this file exists. Until 2026-10-26 the machine had two ways to update itself and neither was a
6
+ * command a person would remember: the settings panel's button (which runs the updater as the *GUI's*
7
+ * child, and whose failure this package could not distinguish from the daemon's refusal to be restarted),
8
+ * and `node <那个 checkout>/updater/cli.js apply` (which needs the install root, which is exactly the
9
+ * thing people lose track of). The other complaint measured the same day: `npx glasspane-install` re-ran
10
+ * `git clone` into `~/glasspane`, git said "destination path … already exists", and the reader was told
11
+ * to change directory — forever, because the next run would look in the same place.
12
+ *
13
+ * So this is a *thin* bin, added to the same package that already owns the one-command install (adding a
14
+ * third bare-name package would be a third thing to version, sign and keep in step):
15
+ *
16
+ * glasspane update ask for an update and run it, through the updater this machine is already
17
+ * installed with — never through a copy guessed from the current directory
18
+ * glasspane doctor read the machine: install pointer, updater state, the loaded daemon job, the
19
+ * socket, and the two global npm packages. Reads only; every line answers
20
+ * available / absent / unverified, because "I could not look" is not "there is
21
+ * nothing there" (`installer/cli.js`'s PROBE_STATES is the same rule)
22
+ * glasspane status the updater's own one-line summary, verbatim
23
+ *
24
+ * What it deliberately does not do: no `sudo`, no npm escalation, no deleting anything. Where a repair
25
+ * needs a person, it prints the tool and the two directories involved, the same way the updater's remedy
26
+ * sentences do (SECURITY.md §3: a remedy may name a tool, it may not hand out an executable escalation).
27
+ */
28
+ import fs from 'node:fs'
29
+ import path from 'node:path'
30
+ import os from 'node:os'
31
+ import process from 'node:process'
32
+ import { spawnSync } from 'node:child_process'
33
+
34
+ export const VERBS = Object.freeze(['update', 'doctor', 'status', 'help'])
35
+ export const DAEMON_JOB_LABEL = 'com.glasspane.daemon'
36
+ export const NPM_PACKAGES = Object.freeze(['glasspane-mcp', 'glasspane-install'])
37
+ /** The three answers a read may give. `unverified` is the one that used to be missing everywhere. */
38
+ export const READS = Object.freeze({ available: 'available', absent: 'absent', unverified: 'unverified' })
39
+
40
+ const POINTER_NAME = 'update-install.json'
41
+ const STATE_NAME = 'update-state.json'
42
+
43
+ /** Where this machine keeps GlassPane's state, and who said so. */
44
+ export function stateRootOf({ env = process.env, homeDir = null } = {}) {
45
+ const named = String(env.GLASSPANE_STATE_DIR ?? '').trim()
46
+ if (named !== '') return { stateRoot: path.resolve(named), source: 'GLASSPANE_STATE_DIR' }
47
+ const home = String(homeDir ?? (typeof env.HOME === 'string' && env.HOME !== '' ? env.HOME : safeHomedir()) ?? '').trim()
48
+ if (home === '') return { stateRoot: null, source: '读不到家目录(HOME 未设置,os.homedir() 也没答)' }
49
+ return { stateRoot: path.join(home, '.glasspane'), source: '默认(<HOME>/.glasspane)' }
50
+ }
51
+
52
+ function safeHomedir() {
53
+ try {
54
+ return os.homedir()
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The install pointer: the one file that says which updater this machine is installed with. Three states,
62
+ * because the remedy differs: absent (never installed / a state root that isn't ours), unreadable (a file
63
+ * that exists and does not parse — a person's problem, not a re-install's), and a pointer whose
64
+ * `updaterCli` no longer exists on disk (a moved or deleted checkout — §11's whole reason the pointer
65
+ * exists, and the case where "run the installer again" is the honest answer).
66
+ */
67
+ export function readInstallPointer({ stateRoot, readFile = (p) => fs.readFileSync(p, 'utf8'), exists = (p) => fs.existsSync(p) } = {}) {
68
+ if (!stateRoot) return { read: READS.unverified, reason: '没有状态根可读', pointerPath: null }
69
+ const pointerPath = path.join(stateRoot, POINTER_NAME)
70
+ let text = null
71
+ try {
72
+ text = readFile(pointerPath)
73
+ } catch (error) {
74
+ const missing = error?.code === 'ENOENT'
75
+ return {
76
+ read: missing ? READS.absent : READS.unverified,
77
+ reason: missing ? `${pointerPath} 不存在` : `${pointerPath} 读不出来(${error?.message ?? String(error)})`,
78
+ pointerPath,
79
+ }
80
+ }
81
+ let pointer = null
82
+ try {
83
+ pointer = JSON.parse(text)
84
+ } catch (error) {
85
+ return { read: READS.unverified, reason: `${pointerPath} 不是合法 JSON(${error.message})`, pointerPath }
86
+ }
87
+ const updaterCli = typeof pointer?.updaterCli === 'string' && pointer.updaterCli !== '' ? pointer.updaterCli : null
88
+ if (updaterCli === null) return { read: READS.unverified, reason: `${pointerPath} 里没有 updaterCli 这一项`, pointerPath, pointer }
89
+ if (!exists(updaterCli)) {
90
+ return {
91
+ read: READS.absent,
92
+ reason: `指针指向的更新脚本不在盘上:${updaterCli}(那份 checkout 被移动或删除了)`,
93
+ remedy: '重跑一次安装程序(node installer/cli.js 或 curl -fsSL <安装脚本> | sh),它会重写指针并把代理指回稳定入口。',
94
+ pointerPath,
95
+ pointer,
96
+ updaterCli,
97
+ }
98
+ }
99
+ return { read: READS.available, reason: null, pointerPath, pointer, updaterCli, installRoot: pointer?.installRoot ?? null }
100
+ }
101
+
102
+ /** One child process, answered as data: did it run, what did it print, what did it exit with. */
103
+ export function spawnJson(bin, args, { spawn = spawnSync, timeoutMs = 120_000 } = {}) {
104
+ const res = spawn(bin, args, { encoding: 'utf8', timeout: timeoutMs })
105
+ const status = typeof res?.status === 'number' ? res.status : null
106
+ const stdout = String(res?.stdout ?? '')
107
+ const stderr = String(res?.stderr ?? '').trim()
108
+ // Two documents, two shapes, and getting this wrong reads a healthy machine as a silent one:
109
+ // the updater CLI answers **one line** of JSON (its contract with the panel and `gp_diagnose`), while
110
+ // `npm ls -g --json` answers **pretty-printed multi-line JSON** (measured here against npm 11). A
111
+ // reader that took the last line got `}` and reported "npm 答的不是那份 JSON" about a prefix that
112
+ // named every package on the machine. So: the whole document first, the last line only as a fallback
113
+ // for a stream that carried a log line before the answer.
114
+ const parse = (text) => {
115
+ const trimmed = String(text ?? '').trim()
116
+ if (trimmed === '') return null
117
+ try {
118
+ return JSON.parse(trimmed)
119
+ } catch {
120
+ /* not the whole document */
121
+ }
122
+ const last = trimmed.split('\n').filter((line) => line !== '').pop()
123
+ try {
124
+ return JSON.parse(last)
125
+ } catch {
126
+ return null
127
+ }
128
+ }
129
+ const json = parse(stdout)
130
+ return {
131
+ read: status === null ? READS.unverified : READS.available,
132
+ status,
133
+ json,
134
+ stdout,
135
+ stderr,
136
+ reason: status === null
137
+ ? `${bin} 没答话(${res?.error?.message ?? String(res?.error ?? '没有退出码')})`
138
+ : json === null && stdout.trim() !== ''
139
+ ? `${bin} 答的不是那份 JSON(前 120 字:${stdout.trim().slice(0, 120)})`
140
+ : null,
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The doctor's six readings. Each returns `{ name, read, detail, remedy }`, and `remedy` is only ever the
146
+ * command this tool can already see working — never an invented path, never an escalation.
147
+ */
148
+ export function doctorReadings({
149
+ env = process.env,
150
+ homeDir = null,
151
+ readFile = (p) => fs.readFileSync(p, 'utf8'),
152
+ exists = (p) => fs.existsSync(p),
153
+ spawn = spawnSync,
154
+ execPath = process.execPath,
155
+ uid = null,
156
+ runLaunchctl = null,
157
+ } = {}) {
158
+ const { stateRoot, source } = stateRootOf({ env, homeDir })
159
+ const out = []
160
+
161
+ out.push(stateRoot === null
162
+ ? { name: '状态根', read: READS.unverified, detail: source, remedy: 'export HOME=/Users/<你> 后重跑(这个产品把状态放在家目录下是刻意的)。' }
163
+ : { name: '状态根', read: READS.available, detail: `${stateRoot}(${source})`, remedy: null })
164
+
165
+ const pointer = readInstallPointer({ stateRoot, readFile, exists })
166
+ out.push({
167
+ name: '安装指针',
168
+ read: pointer.read,
169
+ detail: pointer.read === READS.available
170
+ ? `${pointer.updaterCli}(安装根 ${pointer.installRoot ?? '未记'})`
171
+ : pointer.reason,
172
+ remedy: pointer.remedy ?? (pointer.read === READS.unverified ? '这一份是安装器写的 JSON,读不出来通常意味着它被手改过:重跑安装程序覆盖它。' : null),
173
+ })
174
+
175
+ if (pointer.read !== READS.available) {
176
+ out.push({ name: '更新器状态', read: READS.unverified, detail: '没有可跑的更新脚本,这一格没问', remedy: '先把上面那一格修好。' })
177
+ out.push({ name: '定时代理', read: READS.unverified, detail: '同上', remedy: null })
178
+ } else {
179
+ const status = spawnJson(execPath, [pointer.updaterCli, 'status', '--json', '--state-dir', stateRoot], { spawn })
180
+ // `status --json` 的那一份文档把状态**整体**放在 `state` 里(顶层只有 command/status/code/
181
+ // message/exitCode)。在本机量过一次:按顶层读 `current` 得到的是空,于是 doctor 对着一台装着
182
+ // 1.6.2、最新 1.21.0 的机器答"装着=?"——一个读错字段的读数比不读更容易骗人。
183
+ const doc = status.json?.state ?? status.json ?? {}
184
+ out.push(status.read === READS.available && status.json
185
+ ? {
186
+ name: '更新器状态',
187
+ read: READS.available,
188
+ detail: `status=${status.json.status ?? '?'} 装着=${doc.current ?? '?'} 最新=${doc.latest ?? '未查'} `
189
+ + `待装=${doc.staged?.version ?? '没有'} code=${status.json.code ?? '无'}`,
190
+ // 状态里挂着的代号是这台机器**现在**的降级原因(`npm-not-refreshed`、`runtime-stale` 这一类)。
191
+ // 只把 message 摘要抄过来是不够的:读 doctor 的人要先知道是哪一格,才有办法决定去不去读原话。
192
+ remedy: status.json.code
193
+ ? `这一格挂在状态里的代号是 ${status.json.code}:${String(status.json.message ?? status.json.lastError?.message ?? '(没给原话)').slice(0, 200)}`
194
+ : null,
195
+ }
196
+ : { name: '更新器状态', read: READS.unverified, detail: status.reason ?? `${pointer.updaterCli} status 退出 ${status.status}:${status.stderr.slice(0, 160)}`, remedy: null })
197
+
198
+ const launchctl = runLaunchctl ?? ((args) => {
199
+ const res = spawn('launchctl', args, { encoding: 'utf8', timeout: 15_000 })
200
+ return { status: typeof res?.status === 'number' ? res.status : null, stdout: res?.stdout ?? '', stderr: String(res?.stderr ?? '') + (res?.error ? ` ${res.error.message ?? res.error}` : '') }
201
+ })
202
+ const who = uid ?? (typeof process.getuid === 'function' ? String(process.getuid()) : null)
203
+ const job = who === null
204
+ ? { read: READS.unverified, detail: '读不到 uid(id -u 没答),所以没问 launchd' }
205
+ : (() => {
206
+ const res = launchctl(['print', `gui/${who}/${DAEMON_JOB_LABEL}`])
207
+ const text = String(res?.stdout ?? '')
208
+ if (res?.status === 0) {
209
+ const state = /(?:^|\n)\s*state\s*=\s*(\S+)/.exec(text)
210
+ return { read: READS.available, detail: `在册作业装载着,state=${state ? state[1] : '未给出'}` }
211
+ }
212
+ if (res?.status === 113 || /could not find service|does not exist/i.test(String(res?.stderr ?? ''))) {
213
+ return { read: READS.absent, detail: `launchd 说它没有 gui/${who}/${DAEMON_JOB_LABEL} 这个作业`, remedy: '重跑安装程序(node installer/cli.js)——它会重新注册 daemon 作业,并在动手前问一句。' }
214
+ }
215
+ // 答不了就是答不了:这一支从前把 launchd 的沉默写成"没这个作业",于是 daemon 明明在册、
216
+ // doctor 却说它缺席——缺席与不知道是两件事,补救也不同(一个是重装,一个是先让这一问能问出去)。
217
+ return { read: READS.unverified, detail: `launchctl print 退出 ${String(res?.status ?? 'null')}:${String(res?.stderr ?? '').trim().slice(0, 160) || '没有 stderr'}` }
218
+ })()
219
+ out.push({ name: '定时代理', read: job.read, detail: job.detail, remedy: job.remedy ?? null })
220
+ }
221
+
222
+ const socketPath = stateRoot ? path.join(stateRoot, 'engine.sock') : null
223
+ const socket = socketPath === null
224
+ ? { read: READS.unverified, detail: '没有状态根,socket 路径无从得出' }
225
+ : exists(socketPath)
226
+ ? { read: READS.available, detail: socketPath }
227
+ : { read: READS.absent, detail: `${socketPath} 不在:daemon 没在跑(或这台机器上它注册在别的状态根)` }
228
+ out.push({ name: '引擎 socket', read: socket.read, detail: socket.detail, remedy: socket.read === READS.absent ? '启动 daemon:open "GlassPane Daemon.app",或重跑安装程序最后那一支。' : null })
229
+
230
+ const npm = spawnJson('npm', ['ls', '-g', '--depth=0', '--json'], { spawn })
231
+ if (npm.read !== READS.available || npm.json === null) {
232
+ out.push({
233
+ name: '全局 npm 包',
234
+ read: READS.unverified,
235
+ detail: npm.reason ?? `npm ls -g 退出 ${npm.status}:${npm.stderr.slice(0, 120) || '没有 stderr'}`,
236
+ remedy: '够不着 npm 时,这一格只能答"不知道";先确认这个账号能跑到 npm。',
237
+ })
238
+ } else {
239
+ const deps = npm.json.dependencies ?? {}
240
+ const seen = NPM_PACKAGES.map((name) => `${name}=${deps[name]?.version ?? '没装'}`)
241
+ const missing = NPM_PACKAGES.filter((name) => !deps[name])
242
+ out.push({
243
+ name: '全局 npm 包',
244
+ read: missing.length === 0 ? READS.available : READS.absent,
245
+ detail: seen.join(' '),
246
+ remedy: missing.length === 0 ? null
247
+ : `补法(不需要权限,取的是 registry 的发布物):npm install -g ${NPM_PACKAGES.map((n) => `${n}@<版本>`).join(' ')}`,
248
+ })
249
+ }
250
+
251
+ return out
252
+ }
253
+
254
+ /** The shape of `glasspane doctor`'s answer: one line per reading, plus the exit-code rule. */
255
+ export function doctorReport(readings) {
256
+ const lines = readings.map((one) => `· ${one.name}:${one.detail}${one.remedy ? `\n 下一步:${one.remedy}` : ''}`)
257
+ const unhealthy = readings.filter((one) => one.read !== READS.available)
258
+ const verdict = unhealthy.length === 0
259
+ ? '六格都答了话且都在位。'
260
+ : `${unhealthy.length}/${readings.length} 格不在位或答不了话:${unhealthy.map((one) => `${one.name}(${one.read})`).join('、')}`
261
+ return { text: `${lines.join('\n')}\n${verdict}`, ok: unhealthy.length === 0 }
262
+ }
263
+
264
+ export function usageText() {
265
+ return [
266
+ '用法:glasspane <命令>',
267
+ '',
268
+ ' update 问一次有没有新版本,有就装(走这台机器已经装着的那个更新器)',
269
+ ' status 把更新器自己的那一行结论原样打出来',
270
+ ' doctor 读这台机器的安装状态(安装指针、更新器、daemon 作业、socket、全局 npm 包)——只读,不写',
271
+ ' help 这一页',
272
+ '',
273
+ '退出码:0 = 成/都在位;2 = 用法错;3 = 被拒或有一格不在位(doctor 用它表示"有东西要看上面那几行");',
274
+ '4/5 来自 update(换版后回退 / 回退也没成),与更新器本身的口径一致。',
275
+ '本命令不 sudo、不删东西:需要人做的那一步只会把工具名与目录写出来,不会给成可照抄的升权命令。',
276
+ ].join('\n')
277
+ }
278
+
279
+ export function parseVerb(argv) {
280
+ const first = String(argv?.[0] ?? '')
281
+ if (argv.length === 0) return { verb: null, error: '没给命令' }
282
+ if (!VERBS.includes(first)) return { verb: null, error: `不认得命令「${first}」` }
283
+ return { verb: first, error: null, rest: argv.slice(1) }
284
+ }
285
+
286
+ /**
287
+ * `update` = the updater's own two steps, in its own words. It runs `check` first (an update has to be
288
+ * staged before `apply` will move anything), and hands the *updater's* exit code back: 3 from `check`
289
+ * means "nothing staged / unreachable", which is a refusal, not a failure — and this command has no
290
+ * business re-interpreting that as its own verdict.
291
+ */
292
+ export async function runUpdate({ execPath = process.execPath, updaterCli, stateRoot, spawn = spawnSync, write = (line) => process.stdout.write(`${line}\n`) } = {}) {
293
+ const check = spawnJson(execPath, [updaterCli, 'check', '--json', '--state-dir', stateRoot], { spawn })
294
+ if (check.read !== READS.available || check.json === null) {
295
+ write(`检查没答话:${check.reason ?? `退出 ${check.status}:${check.stderr.slice(0, 200)}`}`)
296
+ return { exitCode: check.status ?? 3, stage: 'check' }
297
+ }
298
+ write(`检查:${check.json.message ?? check.json.status}`)
299
+ if (check.json.status === 'up-to-date') {
300
+ write('已经是最新,没有要装的东西。')
301
+ return { exitCode: 0, stage: 'check' }
302
+ }
303
+ // 检查这一格的非零退出就是"这一趟停在这里":`deferred`/`needs-consent`/`check-failed` 都带着它自己的
304
+ // 原因与代号。从前这里读的是一个不存在的字段(`exitCode`,而子进程答的是 `status`),于是条件恒假,
305
+ // 一次"daemon 正忙"的拒绝被接着送去 apply,屏幕上就多出一条没做过的安装。
306
+ if (check.status !== 0) {
307
+ write(`检查停在 ${check.json.status}(code=${check.json.code ?? '未给出'},退出码 ${check.status}),没有进入安装。`)
308
+ return { exitCode: check.status ?? 3, stage: 'check', json: check.json }
309
+ }
310
+ const apply = spawnJson(execPath, [updaterCli, 'apply', '--json', '--state-dir', stateRoot], { spawn })
311
+ if (apply.read !== READS.available || apply.json === null) {
312
+ write(`安装没答话:${apply.reason ?? `退出 ${apply.status}:${apply.stderr.slice(0, 200)}`}`)
313
+ return { exitCode: apply.status ?? 3, stage: 'apply' }
314
+ }
315
+ write(`安装:${apply.json.message ?? apply.json.status}`)
316
+ if (apply.json.code) write(`(这一格的代号:${apply.json.code}——它挂在状态里,面板与 gp_diagnose 也读得到。)`)
317
+ return { exitCode: apply.status ?? 0, stage: 'apply', json: apply.json }
318
+ }
319
+
320
+ export async function main({ argv = process.argv.slice(2), env = process.env, spawn = spawnSync, write = (line) => process.stdout.write(`${line}\n`), fail = (line) => process.stderr.write(`${line}\n`) } = {}) {
321
+ const parsed = parseVerb(argv)
322
+ if (parsed.error) {
323
+ fail(`${parsed.error}。\n\n${usageText()}`)
324
+ return 2
325
+ }
326
+ if (parsed.verb === 'help') {
327
+ write(usageText())
328
+ return 0
329
+ }
330
+ const { stateRoot } = stateRootOf({ env })
331
+ const pointer = readInstallPointer({ stateRoot, readFile: (p) => fs.readFileSync(p, 'utf8'), exists: (p) => fs.existsSync(p) })
332
+ if (parsed.verb === 'doctor') {
333
+ const report = doctorReport(doctorReadings({ env, spawn }))
334
+ write(report.text)
335
+ return report.ok ? 0 : 3
336
+ }
337
+ if (pointer.read !== READS.available) {
338
+ fail(`这台机器的安装指针读不出可用的一份更新器:${pointer.reason}\n${pointer.remedy ?? ''}`)
339
+ return 3
340
+ }
341
+ if (parsed.verb === 'status') {
342
+ const status = spawnJson(process.execPath, [pointer.updaterCli, 'status', '--json', '--state-dir', stateRoot], { spawn })
343
+ if (status.read !== READS.available || status.json === null) {
344
+ fail(`更新器没答话:${status.reason ?? status.stderr.slice(0, 200)}`)
345
+ return 3
346
+ }
347
+ const doc = status.json.state ?? status.json
348
+ write(`${status.json.status}|装着 ${doc.current ?? '?'}|最新 ${doc.latest ?? '未查'}|待装 ${doc.staged?.version ?? '没有'}|代号 ${status.json.code ?? '无'}`)
349
+ if (status.json.message) write(status.json.message)
350
+ return status.status ?? 0
351
+ }
352
+ const result = await runUpdate({ updaterCli: pointer.updaterCli, stateRoot, spawn, write })
353
+ return result.exitCode
354
+ }
355
+
356
+ const invokedDirectly = process.argv[1] !== undefined && fs.realpathSync(process.argv[1]) === fs.realpathSync(new URL(import.meta.url).pathname)
357
+ if (invokedDirectly) {
358
+ main().then((code) => {
359
+ process.exitCode = code
360
+ }, (error) => {
361
+ process.stderr.write(`glasspane 自己出了错:${error?.message ?? String(error)}\n`)
362
+ process.exitCode = 3
363
+ })
364
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-install",
3
- "version": "1.20.2",
3
+ "version": "1.22.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",
@@ -15,10 +15,12 @@
15
15
  "keywords": ["macos", "installer", "glasspane", "cli"],
16
16
  "main": "./cli.js",
17
17
  "bin": {
18
- "glasspane-install": "./cli.js"
18
+ "glasspane-install": "./cli.js",
19
+ "glasspane": "./glasspane.js"
19
20
  },
20
21
  "files": [
21
22
  "cli.js",
23
+ "glasspane.js",
22
24
  "LICENSE",
23
25
  "README.md"
24
26
  ],