glasspane-install 1.0.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +57 -7
  2. package/cli.js +298 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,14 +1,64 @@
1
1
  # glasspane-install
2
2
 
3
- GlassPane 一键安装器(零第三方依赖,Node >= 18,macOS 14+)。
3
+ One-command installer for [GlassPane](https://github.com/jingzhao-l/GlassPane): builds the Swift
4
+ engine and the MCP shell, packages and signs the `.app` bundles, registers the launchd job, and
5
+ opens the settings panel where you grant permissions. Zero third-party dependencies, Node ≥ 18,
6
+ macOS 14+.
7
+
8
+ 无第三方依赖的一键安装器:编译引擎与 MCP 层、打包并签名 `.app`、注册 launchd 自启、打开权限引导面板。
9
+
10
+ ## Channels / 安装渠道
4
11
 
5
12
  ```bash
6
- npx glasspane-install # 完整安装:编译 → 打包 → 后台服务 → 设置面板
7
- npx glasspane-install --help # 全部选项
8
- node cli.js --restore-launchd # 后台服务开机自启被停用后的一条恢复命令
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
15
+
16
+ # 2) This package via npx — also clones the pinned release tag when no local
17
+ # repo is found, then runs the same flow
18
+ npx glasspane-install
19
+
20
+ # 3) From an existing checkout
21
+ node installer/cli.js
9
22
  ```
10
23
 
11
- 安装过程只有两处需要人工:系统设置里的权限勾选(无程序化路径,面板有逐卡
12
- 引导与实测),以及对破坏性步骤(替换旧实例)的一次确认。其余检测、验证、
13
- 恢复全部机器完成。
24
+ All three end up in the same code path (`installer/cli.js`), and both one-command
25
+ channels clone the **same pinned tag** — never a moving branch — so a fresh install
26
+ is reproducible. Override with `GLASSPANE_REF=<tag>` (`main` is available but is not
27
+ a fixed artifact).
28
+
29
+ 三条渠道最终都走同一份 `installer/cli.js`,且两条一键渠道 clone 的是同一个**钉住的发布 tag**(不是移动的分支),
30
+ 所以同一天两次安装拿到的是同一份源码。
31
+
32
+ ## Flags / 选项
33
+
34
+ | 选项 | 作用 |
35
+ |---|---|
36
+ | `--repo <dir>` | 指定项目根目录(默认自动向上查找) |
37
+ | `--no-bootstrap` | 找不到本地仓库时不自动 clone,只打印定位指引 |
38
+ | `--no-prompt` | 跳过逐步确认(脚本/自动化场景) |
39
+ | `--no-daemon` / `--no-gui` | 不启动 daemon / 不打开设置面板 |
40
+ | `--no-app` | 不打包 `.app`(daemon 将以裸二进制运行,系统设置里只显示文件名) |
41
+ | `--no-launchd` | 不注册开机自启 |
42
+ | `--replace-daemon` | 先收拢已在跑的旧实例(旧构建会占住 socket) |
43
+ | `--skip-build` | 跳过 npm/tsc/swift 编译 |
44
+ | `--restore-launchd` | 只做 launchd 恢复 + 校验 daemon 自报席位 |
45
+ | `-h, --help` | 完整用法 |
46
+
47
+ ## What still needs a human / 仍需人工的两件事
48
+
49
+ 1. **TCC permissions.** Accessibility, Input Monitoring and Screen Recording must be ticked in
50
+ System Settings, by the person, for **`GlassPane Daemon`** (the daemon binary itself) — not for
51
+ a terminal and not for the settings window. There is no programmatic path; the panel walks you
52
+ through each card and re-measures after you tick it.
53
+ 2. **First launch past Gatekeeper.** Builds are ad-hoc signed (no Developer ID / notarization
54
+ yet), so on a machine other than the author's, macOS will refuse the app on first launch until
55
+ you allow it in System Settings → Privacy & Security. This is a real caveat, not a guess:
56
+ notarization is a pending owner decision (cost-gated).
57
+
58
+ Everything else — detection, building, launching, verifying, repairing — is done by the installer.
59
+
60
+ ## Errors / 出错时
61
+
62
+ 失败信息里带可直接执行的补救命令;按提示执行即可,不需要翻文档。安装日志:`~/.glasspane/installer-daemon.log`。
63
+
14
64
  License: MIT.
package/cli.js CHANGED
@@ -12,6 +12,10 @@
12
12
  *
13
13
  * 所有辅助函数均具名导出,供 node:test 单测(test/cli.test.mjs),主流程通过
14
14
  * import.meta.url 守卫隔离,测试导入不会触发安装动作。
15
+ *
16
+ * 对外契约:退出码是脚本/agent 唯一机器可读的结论——0 = 产物就位**且**daemon 实测
17
+ * 应答过 hello(或本轮按要求压根不该起 daemon),1 = 未通过校验或构建/注册失败,
18
+ * 2 = 参数错误。install.sh 以 exec 委托,退出码原样透传。口径详见 usageText。
15
19
  */
16
20
  import { spawn, spawnSync } from 'node:child_process'
17
21
  import { createInterface } from 'node:readline/promises'
@@ -30,6 +34,13 @@ export const LAUNCHD_LABEL = 'com.glasspane.daemon'
30
34
  export const LAUNCHD_DIR_NAME = 'Library/LaunchAgents'
31
35
  export const LAUNCHD_FILE_NAME = `${LAUNCHD_LABEL}.plist`
32
36
 
37
+ /** 分离启动后的**有界观察窗口**:spawn 失败(ENOENT、参数被拒)以异步 error 事件
38
+ * 落地,`/usr/bin/open` 目标缺失则以非零退出落地,两者都发生在启动后的几百毫秒
39
+ * 内,所以窗口要给足才能真的看见失败(旧值 150ms 常常在 `open` 退出前就收工,
40
+ * 等于那道退出码分支没有触发路径)。窗口外的崩溃仍然看不到——存活判定的真源
41
+ * 始终是收尾那次 socket hello 校验,不是这里。 */
42
+ export const LAUNCH_SETTLE_MS = 600
43
+
33
44
  /** bundle 安置目录与产物名(P1 v1.2 §11.1:daemon 必须有 bundle 身份,系统设置
34
45
  * 的权限列表才会显示可读名与图标,「+」选择器也才找得到它。安置在
35
46
  * ~/Applications 而非 `.build` 隐藏目录,同一理由)。 */
@@ -44,6 +55,12 @@ export const REPO_URL = 'https://github.com/jingzhao-l/GlassPane.git'
44
55
  /** 一键安装入口脚本的 raw 地址(用户侧 curl 指引真源)。 */
45
56
  export const INSTALL_SH_URL =
46
57
  'https://raw.githubusercontent.com/jingzhao-l/GlassPane/main/install.sh'
58
+ /** 发布线锚点(版本真源 = 根 package.json,由 scripts/set-version.mjs 统一改写,
59
+ * 勿手改):npx 形态下本地没有仓库时,引导 clone 的就是这个 tag,与 install.sh
60
+ * 的 `GLASSPANE_RELEASE` 同值——两条一键入口必须拿到同一份源码。 */
61
+ export const RELEASE_VERSION = '1.1.2'
62
+ /** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
63
+ export const REPO_REF = `v${RELEASE_VERSION}`
47
64
 
48
65
  const ANSI = {
49
66
  reset: '\x1b[0m',
@@ -76,6 +93,7 @@ export function parseArgs(argv) {
76
93
  replaceDaemon: false,
77
94
  skipBuild: false,
78
95
  restoreLaunchd: false,
96
+ bootstrap: true,
79
97
  help: false,
80
98
  }
81
99
  const opts = { ...defaults }
@@ -115,6 +133,10 @@ export function parseArgs(argv) {
115
133
  case '--skip-build':
116
134
  opts.skipBuild = true
117
135
  break
136
+ case '--no-bootstrap':
137
+ // 无仓库时不自动 clone,直接给三条路径指引(离线/内网场景)。
138
+ opts.bootstrap = false
139
+ break
118
140
  case '--restore-launchd':
119
141
  opts.restoreLaunchd = true
120
142
  break
@@ -621,15 +643,90 @@ export async function confirm(question, { autoYes = false } = {}) {
621
643
  }
622
644
  }
623
645
 
624
- /** 后台分离启动长驻进程(daemon / GUI),日志追加写盘,返回 PID。 */
625
- export function startDetached(binPath, args, logPath) {
626
- const logStream = fs.openSync(logPath, 'a')
646
+ /** 创建日志/socket 目录(`~/.glasspane`),一次性、幂等(A-8 的后半段)。
647
+ *
648
+ * 为什么必须由安装器在**写 plist 之前**做,而不是等 startDetached 顺手 mkdir:
649
+ * `~/.glasspane` 同时是三样东西的落点——socket、launchd plist 的
650
+ * StandardOutPath/StandardErrorPath、手动启动的日志。默认注册 launchd
651
+ * (parseArgs 的 launchd 默认 true)时 bootstrap 的 RunAtLoad 会在安装器自己
652
+ * 那次 startDetached **之前**就把 daemon 拉起来,而后面"socket 已可达即跳过手动
653
+ * 启动"那条分支让手动分支根本不执行——于是全新机器上第一个进场的 spawner 是
654
+ * launchd,而它要写的目录还不存在。startDetached 里的 mkdirSync 救不了这一枪。
655
+ *
656
+ * 不改变任何文件位置:只建目录,路径口径与 install() 里算出来的完全一致。 */
657
+ export function ensureLogDir(logPath, { mkdir = fs.mkdirSync } = {}) {
658
+ const dir = path.dirname(logPath)
659
+ try {
660
+ mkdir(dir, { recursive: true })
661
+ return { ok: true, dir, error: null }
662
+ } catch (error) {
663
+ return { ok: false, dir, error: `日志/socket 目录不可用:${dir}(${error.message})` }
664
+ }
665
+ }
666
+
667
+ /** 后台分离启动长驻进程(daemon / GUI),日志追加写盘。
668
+ *
669
+ * 返回 `{ ok, pid, error, exit }` 而不是裸 PID:spawn 的失败是**异步** error 事件,
670
+ * 此前函数只回 `child.pid`,于是"日志目录不存在 / 启动器路径不存在"这类
671
+ * 故障会先打印一句 `PID undefined`,再抛未捕获异常——用户看到的是一次成功
672
+ * 输出加一个崩溃栈。这里等一小段时间把 error 事件收进来。
673
+ *
674
+ * 窗口之后还有一件确定可观测的事:子进程是否**已经退出**。`child.on('error')`
675
+ * 只把失败写进一个没人读的变量,而秒退的子进程(参数被拒、bundle 校验失败、
676
+ * 日志写不进去)在窗口内收不到任何 error 事件,旧实现于是带着一个已经作废的
677
+ * PID 返回 ok:true。现在读 `child.exitCode`/`child.signalCode`:非零退出或被
678
+ * 信号终止 → ok:false 并给出原因;退出码 0 仍算命令被受理(`/usr/bin/open`
679
+ * 就是"提交给 LaunchServices 后立刻 0 退出"的形态,把它判成失败是过度修复),
680
+ * 但窗口内观察到的退出状态会通过 `exit` 字段如实回传,调用方不得再说"进程在跑"。
681
+ *
682
+ * 如实边界:这是一次**有界观察**,不是存活证明——窗口外的崩溃、窗口之后才到的
683
+ * error 事件都看不见;daemon 到底活没活,只有收尾那次 socket hello 校验说了算。
684
+ *
685
+ * 日志目录(`~/.glasspane`)由 install() 在建 plist、bootstrap 之前一次性创建
686
+ * (见 ensureLogDir);这里的 mkdirSync 只是给直接调用方与单测留的兜底。 */
687
+ export async function startDetached(binPath, args, logPath, { settleMs = LAUNCH_SETTLE_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
688
+ try {
689
+ fs.mkdirSync(path.dirname(logPath), { recursive: true })
690
+ } catch (error) {
691
+ return { ok: false, pid: null, error: `日志目录不可用:${error.message}`, exit: null }
692
+ }
693
+ let logStream
694
+ try {
695
+ logStream = fs.openSync(logPath, 'a')
696
+ } catch (error) {
697
+ return { ok: false, pid: null, error: `日志文件打不开:${error.message}`, exit: null }
698
+ }
627
699
  const child = spawn(binPath, args, {
628
700
  detached: true,
629
701
  stdio: ['ignore', logStream, logStream],
630
702
  })
703
+ let failure = null
704
+ child.on('error', (error) => { failure = `${error.message}` })
631
705
  child.unref()
632
- return child.pid
706
+ // 给 spawn 的 error 事件一个落地窗口(ENOENT 在下一 tick 才到)。
707
+ await sleep(settleMs)
708
+ if (child.pid == null) {
709
+ return { ok: false, pid: null, error: failure ?? '子进程未起来(拿不到 PID)', exit: null }
710
+ }
711
+ // 'exit' 事件会在窗口内把 exitCode/signalCode 填上;两者都为 null 才是"窗口
712
+ // 结束时仍在运行"。(detached + unref 不影响 exitCode 的记账。)
713
+ const exit = child.exitCode !== null || child.signalCode !== null
714
+ ? { code: child.exitCode, signal: child.signalCode }
715
+ : null
716
+ if (failure) {
717
+ return { ok: false, pid: null, error: `${failure}`, exit }
718
+ }
719
+ if (exit && (exit.code !== 0 || exit.signal !== null)) {
720
+ const how = exit.signal ? `被信号 ${exit.signal} 终止` : `以退出码 ${exit.code} 结束`
721
+ return {
722
+ ok: false,
723
+ pid: null,
724
+ error: `子进程 ${child.pid} 在 ${settleMs}ms 观察窗口内就${how},回传的是作废的 PID。`
725
+ + `注意:窗口外的崩溃与 error 事件看不到,本结论是有界观察而非存活证明。`,
726
+ exit,
727
+ }
728
+ }
729
+ return { ok: true, pid: child.pid, error: null, exit }
633
730
  }
634
731
 
635
732
  /** bundle 形态的 `open` 参数(纯函数)。
@@ -698,8 +795,17 @@ export function usageText() {
698
795
  ' --restore-launchd 只做 launchd 恢复:检测作业被 bootout → bootstrap → hello',
699
796
  ' 校验 daemon 自报席位(勾完 TCC 框后重跑即自动换进程复验)',
700
797
  ' --skip-build 跳过 npm/tsc/swift 编译(已构建过时使用)',
798
+ ' --no-bootstrap 本地找不到仓库时不自动 clone,改为打印定位指引',
701
799
  ' -h, --help 显示本帮助',
702
800
  '',
801
+ '退出码(脚本/agent 调用方读这个,别读屏幕文案):',
802
+ ' 0 安装完成且 daemon 实测应答 hello;或本轮按要求压根不该有 daemon',
803
+ ' (--no-daemon 且 --no-launchd)',
804
+ ' 1 收尾未通过 hello 校验(daemon 没起来/不应答),或构建/产物/launchd 出错',
805
+ ' 2 命令行参数错误',
806
+ ' 注:install.sh 用 exec 委托本文件,退出码原样透传,所以 `curl | sh` 的',
807
+ ' $? 就是这里的结论。',
808
+ '',
703
809
  ].join('\n')
704
810
  }
705
811
 
@@ -730,6 +836,7 @@ export function nextStepsText({
730
836
  rootDir,
731
837
  socketPath,
732
838
  guiOpened,
839
+ daemonVerified = true,
733
840
  daemon = { viaBundle: false, path: null },
734
841
  settingsApp = null,
735
842
  }) {
@@ -739,6 +846,14 @@ export function nextStepsText({
739
846
  return [
740
847
  paint('后续使用说明', 'bold'),
741
848
  '',
849
+ // daemon 没实测应答时,后面所有"已装好"的措辞都不成立——把这条摆在最前面,
850
+ // 而不是让人照着说明接完 MCP 才发现连不上。补救命令必须是**真路径**(本函数
851
+ // 已经拿到 rootDir),印 "<仓库目录>" 占位符等于没给可执行的东西。
852
+ ...(daemonVerified ? [] : [
853
+ paint('⚠ 后台服务未通过 hello 校验:下面第 1 步接好的 MCP 客户端会连不上 daemon。', 'red'),
854
+ ` 先修这一步:node "${path.join(rootDir, 'installer', 'cli.js')}" --restore-launchd`,
855
+ '',
856
+ ]),
742
857
  '1. MCP 客户端(Claude Desktop / Cursor / 任意 MCP host)接入配置片段:',
743
858
  '',
744
859
  mcpClientConfigSnippet({ rootDir, socketPath }),
@@ -748,9 +863,17 @@ export function nextStepsText({
748
863
  daemon.viaBundle
749
864
  ? ' 在系统设置的对应权限列表里应能看到「GlassPane Daemon」(带图标,可用「+」选择或从 Finder 拖入)。'
750
865
  : ' 当前 daemon 是裸二进制形态,权限列表里只会显示文件名「glasspaned」(无图标,「+」选择器看不到隐藏目录);重跑安装(默认打包 .app)可获得带图标的条目。',
751
- guiOpened
752
- ? ' 面板已打开:点权限卡「授权」会让 daemon 以自身身份发起申请并跳转到对应系统面板。'
753
- : ` 打开面板:open "${settingsAppHint}"(点权限卡「授权」即由 daemon 自身发起申请)。`,
866
+ // guiOpened 的口径 = 启动命令被受理且未报错退出(bundle 形态经 `/usr/bin/open`,
867
+ // 目标缺失/被 LaunchServices 拒绝时它返回非零 → 走下面那条手工指引)。窗口是否
868
+ // 真出现在屏幕上没实测过,而且 `-g` 本就不抢焦点,得把这层边界写在话里。
869
+ ...(guiOpened ? [
870
+ ' 面板已打开(口径:启动命令已发出且在观察窗口内未报错退出——bundle 形态走的',
871
+ ' `open` 在目标 bundle 缺失时会非零退出,未报错即产物确实在;窗口外的失败看不',
872
+ ` 到,所以没看到窗口属正常(-g 不抢焦点),再起一次:open "${settingsAppHint}")。`,
873
+ ' 点权限卡「授权」会让 daemon 以自身身份发起申请并跳转到对应系统面板。',
874
+ ] : [
875
+ ` 打开面板:open "${settingsAppHint}"(点权限卡「授权」即由 daemon 自身发起申请)。`,
876
+ ]),
754
877
  ` 验证 daemon 自报的席位与主体:${daemon.path ?? path.join(rootDir, 'engine', '.build', 'release', DAEMON_EXE_NAME)} --permissions`,
755
878
  ' 口径提醒:daemon 必须由 launchd/登录项拉起,权限自报才等于真实席位;从终端或',
756
879
  ' GUI 手动起的实例会继承启动者 app 的判定(真机实测:同 bundle 两种起法读数不同)。',
@@ -759,31 +882,115 @@ export function nextStepsText({
759
882
  ` 作业被 bootout(等授权期间常发生)后的恢复**不需要手敲 launchctl**:`,
760
883
  ` node "${path.join(rootDir, 'installer', 'cli.js')}" --restore-launchd`,
761
884
  ` (检测→bootstrap→hello 校验自报席位;勾完 TCC 框重跑即自动换进程复验)`,
762
- `4. entryName 核对:设置面板「Daemon 状态 → 主体」应与上面「${entryName}」一致;不一致说明连到了别的构建实例。`,
763
- '5. registry 发布形态(npx glasspane-mcp)挂 kernel Phase B(P4 §33.2 决策链),当前以本仓库产物为准。',
885
+ `4. entryName 核对:设置面板「Daemon 状态 → 主体」应与上面「${entryName}」一致;不一致说明连到了别的构建实例。`,
886
+ '5. registry 发布形态:MCP 服务器已可独立安装(`npm i -g glasspane-mcp`,命令名',
887
+ ' glasspane-mcp);但它只是转发层,daemon 与权限仍由本次安装产出,缺 daemon 时',
888
+ ' 工具调用会返回带补救步骤的结构化错误。',
764
889
  '',
765
890
  ].join('\n')
766
891
  }
767
892
 
893
+ /**
894
+ * 无本地仓库时的源码引导计划(纯函数,可单测)。与 install.sh 的源码定位策略
895
+ * 同构:clone 发布 tag 到 ~/glasspane,两条一键入口拿到同一份源码。
896
+ * npx glasspane-install 靠它兑现"一条命令完整安装"的承诺——此前该命令在没有
897
+ * 仓库时直接报错,README 却写着"完整安装"。
898
+ */
899
+ export function bootstrapPlan({
900
+ homeDir = '',
901
+ repoUrl = REPO_URL,
902
+ ref = REPO_REF,
903
+ installDir = null,
904
+ } = {}) {
905
+ if (!homeDir) {
906
+ return { error: '无法确定用户主目录(HOME 为空),不知道把源码 clone 到哪里' }
907
+ }
908
+ const targetDir = installDir || path.join(homeDir, 'glasspane')
909
+ if (fs.existsSync(targetDir) && resolveProjectRoot(targetDir) === null) {
910
+ return {
911
+ error: `目标目录已存在且不是 GlassPane 仓库:${targetDir}(用 --repo 指定仓库,或清理该目录后重试)`,
912
+ }
913
+ }
914
+ return {
915
+ targetDir,
916
+ ref,
917
+ gitArgs: ['clone', '--branch', ref, '--depth', '1', repoUrl, targetDir],
918
+ }
919
+ }
920
+
768
921
  /** 未定位到仓库时的指引文本(纯函数,可单测)。 */
769
922
  export function repoMissingText() {
770
923
  return [
771
924
  '未定位到 GlassPane 项目根目录(需同时包含 engine/Package.swift 与 mcp-shell/package.json)。',
772
- '可选路径:',
925
+ `本应自动引导 clone 源码(ref: ${REPO_REF}),但该路径不可用。可选路径:`,
773
926
  ` a) 一键安装(自动 clone 源码后进入本流程):curl -fsSL ${INSTALL_SH_URL} | sh`,
774
- ` b) 手工 clone:git clone ${REPO_URL} && cd GlassPane && node installer/cli.js`,
927
+ ` b) 手工 clone:git clone --branch ${REPO_REF} ${REPO_URL} && cd GlassPane && node installer/cli.js`,
775
928
  ' c) 已在本仓库其他位置:--repo <目录> 或 GLASSPANE_REPO 环境变量指定根目录。',
929
+ ` d) 追主干而非发布版:GLASSPANE_REF=main 重跑(不推荐,主干非固定产物)。`,
930
+ ].join('\n')
931
+ }
932
+
933
+ /** 收尾判定 → 对外结论(纯函数)。
934
+ * 退出码是本 CLI 唯一**机器可读**的结论:install.sh 是 `exec node cli.js` 委托,
935
+ * `curl | sh` 与按退出码办事的 agent 都只看到它,看不到屏幕上那句"安装未完成"。
936
+ * 所以 daemon 没实测应答 hello 就必须非零,否则半成品安装会被报成成功。
937
+ * 例外:本次安装本就不该留下 daemon(--no-daemon 且 --no-launchd)时,"没验到"
938
+ * 是用户的选择而不是故障,退出码保持 0(告警文案照给,nextStepsText 也照警告)。 */
939
+ export function installOutcome({ verified = false, daemonExpected = true } = {}) {
940
+ const ok = Boolean(verified) || !daemonExpected
941
+ return { ok, exitCode: ok ? 0 : 1, verified: Boolean(verified), daemonExpected }
942
+ }
943
+
944
+ /** 未通过校验时的提示行(纯函数,可单测)。必须自带**用户真能执行**的补救命令,
945
+ * 并点明退出码非零——只说"安装未完成"的话,脚本调用方什么也拿不到。 */
946
+ export function verificationFailureText({
947
+ arrived = false,
948
+ socketPath,
949
+ daemonLog,
950
+ cliPath,
951
+ exitCode = 1,
952
+ } = {}) {
953
+ return [
954
+ arrived
955
+ ? `安装未完成:socket 在(${socketPath}),但 daemon 没有正确应答 hello——见 ${daemonLog}`
956
+ : `安装未完成:没有等到 daemon 在 ${socketPath} 应答 hello——见 ${daemonLog}`,
957
+ `本命令退出码 ${exitCode}:install.sh 与调用它的脚本/agent 按安装失败处理,先别接 MCP。`,
958
+ `补救(不需要手敲 launchctl):node "${cliPath}" --restore-launchd`,
959
+ '修好后重跑安装器即可(它会覆盖式重建产物与 plist)。',
776
960
  ].join('\n')
777
961
  }
778
962
 
779
- /** 主安装流程;所有副作用步骤均记录真实执行结果,失败即抛错终止。 */
963
+ /** 主安装流程;所有副作用步骤均记录真实执行结果,失败即抛错终止。
964
+ * 返回值是收尾校验结论(`installOutcome`),入口守卫据此设退出码。 */
780
965
  export async function install({ options = parseArgs([]).options, env = process.env } = {}) {
781
966
  const explicitRoot = options.repoDir ? path.resolve(options.repoDir) : null
782
967
  const envRoot = env.GLASSPANE_REPO ? path.resolve(env.GLASSPANE_REPO) : null
783
- const rootDir = explicitRoot ?? envRoot ?? resolveProjectRoot()
968
+ let rootDir = explicitRoot ?? envRoot ?? resolveProjectRoot()
784
969
 
785
970
  if (!rootDir || !fs.existsSync(rootDir)) {
786
- throw new Error(repoMissingText())
971
+ if (options.bootstrap === false) {
972
+ throw new Error(repoMissingText())
973
+ }
974
+ const plan = bootstrapPlan({
975
+ homeDir: env.HOME ?? process.env.HOME ?? '',
976
+ installDir: env.GLASSPANE_INSTALL_DIR ?? null,
977
+ ref: env.GLASSPANE_REF || REPO_REF,
978
+ })
979
+ if (plan.error) {
980
+ throw new Error(`${plan.error}\n\n${repoMissingText()}`)
981
+ }
982
+ printStep(`未定位到本地仓库,引导 clone:${plan.targetDir}(ref: ${plan.ref})……`)
983
+ const clone = spawnSync('git', plan.gitArgs, { stdio: 'inherit' })
984
+ if (clone.status !== 0) {
985
+ throw new Error(
986
+ `git clone 失败(ref '${plan.ref}',退出码 ${clone.status ?? 'null'}):`
987
+ + '网络不可达或该发布 tag 不存在。\n\n' + repoMissingText(),
988
+ )
989
+ }
990
+ if (resolveProjectRoot(plan.targetDir) === null) {
991
+ throw new Error(`clone 完成但目录结构不完整:${plan.targetDir}\n\n${repoMissingText()}`)
992
+ }
993
+ rootDir = plan.targetDir
787
994
  }
788
995
 
789
996
  const engineDir = path.join(rootDir, 'engine')
@@ -802,6 +1009,19 @@ export async function install({ options = parseArgs([]).options, env = process.e
802
1009
 
803
1010
  printStep(`项目根目录: ${rootDir}`)
804
1011
 
1012
+ // 审计 A-8 的后半段:`~/.glasspane` 在全新机器上并不存在,而它同时是 socket、
1013
+ // launchd plist 的 StandardOutPath/StandardErrorPath 与手动启动日志三者的父目录。
1014
+ // 必须**在这里**建——路径刚定、plist 还没写、还没 bootstrap。默认就注册 launchd
1015
+ // (options.launchd 默认 true),bootstrap 的 RunAtLoad 会先于安装器自己那次
1016
+ // startDetached 把 daemon 拉起来,之后"socket 已可达即跳过手动启动",所以靠
1017
+ // startDetached 内部 mkdir 永远慢一步:首跑真正要进这个目录的 spawner 是 launchd。
1018
+ // 只建目录,不改变任何文件的落点。
1019
+ const logDir = ensureLogDir(daemonLog)
1020
+ if (!logDir.ok) {
1021
+ throw new Error(`${logDir.error}——plist 与产物均未写入,安装在此中止`)
1022
+ }
1023
+ printStep(`日志与 socket 目录就位:${logDir.dir}`)
1024
+
805
1025
  if (!options.skipBuild) {
806
1026
  printStep('安装 npm 依赖(workspaces: kernel / mcp-shell / installer)……')
807
1027
  await run('npm', ['install'], { cwd: rootDir })
@@ -857,6 +1077,9 @@ export async function install({ options = parseArgs([]).options, env = process.e
857
1077
 
858
1078
  // 开机自启先于手工启动注册:bootstrap 触发 RunAtLoad 会立即拉起 daemon,
859
1079
  // 随后手工启动步骤发现 socket 已可达即跳过——避免两个实例抢 socket。
1080
+ // 前置条件:daemonLog 的父目录(= socket 目录)已在上面 ensureLogDir 建好——
1081
+ // 下面这份 plist 的 StandardOutPath/StandardErrorPath 就写在那个目录里,而
1082
+ // RunAtLoad 让 launchd 成为第一个往那里写的 spawner(全新机器上目录原本不存在)。
860
1083
  if (options.launchd) {
861
1084
  const launchAgentsDir = path.join(process.env.HOME ?? '', ...LAUNCHD_DIR_NAME.split('/'))
862
1085
  const plistPath = path.join(launchAgentsDir, LAUNCHD_FILE_NAME)
@@ -924,8 +1147,21 @@ export async function install({ options = parseArgs([]).options, env = process.e
924
1147
  } else {
925
1148
  const start = daemonStartCommand({ daemonLaunch, daemonApp: plan.daemonApp, socketPath })
926
1149
  printStep(`启动 glasspaned(${daemonLaunch.viaBundle ? 'bundle 形态,经 open 登记主进程' : '裸二进制形态'},日志: ${daemonLog})……`)
927
- const pid = startDetached(start.launchPath, start.args, daemonLog)
928
- printStep(`glasspaned 启动命令已发出,PID ${pid}`)
1150
+ const started = await startDetached(start.launchPath, start.args, daemonLog)
1151
+ if (!started.ok) {
1152
+ printStep(paint(`glasspaned 启动命令失败:${started.error}`, 'red'))
1153
+ } else if (started.exit) {
1154
+ // 窗口内启动命令就已退出 0:经 open 时是"已提交给 LaunchServices"的正常
1155
+ // 形态,裸二进制形态则意味着 daemon 自己秒退。两种情况都不能说"进程在跑"
1156
+ // ——那只有下面收尾的 socket hello 校验才配说。
1157
+ printStep(paint(
1158
+ `glasspaned 启动命令已执行完毕并退出(码 ${started.exit.code},PID ${started.pid});`
1159
+ + '这不代表 daemon 在跑,存活与否以下面的 hello 校验为准',
1160
+ 'yellow',
1161
+ ))
1162
+ } else {
1163
+ printStep(`glasspaned 启动命令已发出且观察窗口内未退出,PID ${started.pid}`)
1164
+ }
929
1165
  }
930
1166
  }
931
1167
 
@@ -937,18 +1173,57 @@ export async function install({ options = parseArgs([]).options, env = process.e
937
1173
  }
938
1174
  const guiStart = guiStartCommand({ guiLaunch, settingsApp: plan.settingsApp })
939
1175
  printStep(`打开设置面板(${guiLaunch.viaBundle ? 'GlassPane.app,经 open 登记主进程' : '裸二进制'},权限引导)……`)
940
- startDetached(guiStart.launchPath, guiStart.args, daemonLog)
941
- guiOpened = true
1176
+ const gui = await startDetached(guiStart.launchPath, guiStart.args, daemonLog)
1177
+ // "面板已打开"只能是实测结论。startDetached 现在会把 `open` 的非零退出(目标
1178
+ // bundle 缺失、被 LaunchServices 拒绝)与窗口内被信号终止都判成 ok:false,
1179
+ // 所以 gui.ok 的口径是"启动命令被受理且未报错退出",不是"窗口已在屏幕上"
1180
+ // (bundle 形态带 -g,本就不抢焦点)——文案里把这层边界写明,见 nextStepsText。
1181
+ guiOpened = gui.ok
1182
+ if (!gui.ok) {
1183
+ printStep(paint(`设置面板没能打开:${gui.error}`, 'yellow'))
1184
+ }
942
1185
  }
943
1186
 
944
- printStep('安装完成。')
1187
+ // 收尾校验:socket 到位 + 一次 hello 真握手。安装器此前无条件打印"安装完成。",
1188
+ // 而且**无论校没校验过都退出 0**——install.sh 是 exec 委托,`curl | sh` 和按退出
1189
+ // 码办事的 agent 只看到 0,于是半成品安装被报成成功。现在结论同时进文案与退出码。
1190
+ // 本轮是否**应该**留下一个在跑的 daemon:--no-daemon 但注册了 launchd 时,
1191
+ // bootstrap 的 RunAtLoad 已经把它拉起来了,同样要等要验(旧代码只看 options.daemon,
1192
+ // 于是这种组合下 timeoutMs=1ms,必然"没验到")。
1193
+ const daemonExpected = Boolean(options.daemon || options.launchd)
1194
+ const arrived = await waitForSocket(socketPath, { timeoutMs: daemonExpected ? 12000 : 1 })
1195
+ const hello = arrived ? await socketHello(socketPath) : null
1196
+ const verified = Boolean(arrived && hello?.version)
1197
+ const outcome = installOutcome({ verified, daemonExpected })
1198
+ if (verified) {
1199
+ printStep(`实测校验通过:daemon 在 ${socketPath} 应答 hello(version=${hello.version}${hello.pid ? `,pid=${hello.pid}` : ''})`)
1200
+ printStep(`安装完成(退出码 ${outcome.exitCode})。`)
1201
+ } else if (daemonExpected) {
1202
+ for (const line of verificationFailureText({
1203
+ arrived,
1204
+ socketPath,
1205
+ daemonLog,
1206
+ cliPath: path.join(rootDir, 'installer', 'cli.js'),
1207
+ exitCode: outcome.exitCode,
1208
+ }).split('\n')) {
1209
+ printStep(paint(line, 'red'))
1210
+ }
1211
+ } else {
1212
+ printStep(paint(
1213
+ `本次按要求没有起 daemon(--no-daemon 且 --no-launchd),未做 hello 校验,退出码 ${outcome.exitCode};`
1214
+ + '接 MCP 前先自行启动 daemon,否则客户端连不上。',
1215
+ 'yellow',
1216
+ ))
1217
+ }
945
1218
  process.stdout.write(`\n${nextStepsText({
946
1219
  rootDir,
947
1220
  socketPath,
948
1221
  guiOpened,
1222
+ daemonVerified: verified,
949
1223
  daemon: daemonLaunch,
950
1224
  settingsApp: plan.settingsApp ?? undefined,
951
1225
  })}\n`)
1226
+ return outcome
952
1227
  }
953
1228
 
954
1229
  /** 入口守卫:直接用 node 运行本文件时才走安装流程(测试导入仅取函数)。 */
@@ -986,8 +1261,11 @@ if (isMain) {
986
1261
  }
987
1262
 
988
1263
  install({ options })
989
- .then(() => {
1264
+ .then((outcome) => {
990
1265
  process.stdout.write('\n')
1266
+ // 退出码 = 收尾校验结论。用 process.exitCode 而不是 process.exit():让上面的
1267
+ // 说明文本先冲干净(`curl | sh` 常接管道,截断会把"下一步怎么办"整段吃掉)。
1268
+ process.exitCode = outcome.exitCode
991
1269
  })
992
1270
  .catch((installError) => {
993
1271
  process.stderr.write(paint(`\n安装失败:${installError.message}\n`, 'red'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-install",
3
- "version": "1.0.0",
3
+ "version": "1.1.2",
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",