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.
- package/README.md +57 -7
- package/cli.js +298 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,64 @@
|
|
|
1
1
|
# glasspane-install
|
|
2
2
|
|
|
3
|
-
GlassPane
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
/**
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
753
|
-
|
|
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
|
-
|
|
763
|
-
'5. registry
|
|
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
|
-
|
|
968
|
+
let rootDir = explicitRoot ?? envRoot ?? resolveProjectRoot()
|
|
784
969
|
|
|
785
970
|
if (!rootDir || !fs.existsSync(rootDir)) {
|
|
786
|
-
|
|
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
|
|
928
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|