glasspane-install 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +14 -0
- package/cli.js +996 -0
- package/package.json +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jingzhao-l
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# glasspane-install
|
|
2
|
+
|
|
3
|
+
GlassPane 一键安装器(零第三方依赖,Node >= 18,macOS 14+)。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx glasspane-install # 完整安装:编译 → 打包 → 后台服务 → 设置面板
|
|
7
|
+
npx glasspane-install --help # 全部选项
|
|
8
|
+
node cli.js --restore-launchd # 后台服务开机自启被停用后的一条恢复命令
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
安装过程只有两处需要人工:系统设置里的权限勾选(无程序化路径,面板有逐卡
|
|
12
|
+
引导与实测),以及对破坏性步骤(替换旧实例)的一次确认。其余检测、验证、
|
|
13
|
+
恢复全部机器完成。
|
|
14
|
+
License: MIT.
|
package/cli.js
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* glasspane-install — 一键安装 CLI(零第三方依赖,Node >= 18)。
|
|
4
|
+
*
|
|
5
|
+
* 流程:terminal onboarding 横幅 → 环境预检(node/swift/git/open)→ 定位项目
|
|
6
|
+
* 根目录 → npm install(workspaces)→ 编译 TS(kernel → mcp-shell)→ swift
|
|
7
|
+
* build -c release(engine)→ make-app.sh 打包**并签名**两个 .app → 安置到
|
|
8
|
+
* ~/Applications → 以 bundle 内可执行注册 launchd 自启并启动 daemon → 打开
|
|
9
|
+
* GlassPane.app 设置面板进入权限引导(授权对象是 daemon,见 P1 v1.2 §11)→
|
|
10
|
+
* 打印后续使用说明(含 MCP 客户端接入配置片段)。用户侧一键入口为仓库根
|
|
11
|
+
* install.sh(源码定位/clone 后委托本文件)。
|
|
12
|
+
*
|
|
13
|
+
* 所有辅助函数均具名导出,供 node:test 单测(test/cli.test.mjs),主流程通过
|
|
14
|
+
* import.meta.url 守卫隔离,测试导入不会触发安装动作。
|
|
15
|
+
*/
|
|
16
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
17
|
+
import { createInterface } from 'node:readline/promises'
|
|
18
|
+
import { pathToFileURL } from 'node:url'
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import process from 'node:process'
|
|
22
|
+
import net from 'node:net'
|
|
23
|
+
|
|
24
|
+
// 显式常量,避免散落魔法字符串。
|
|
25
|
+
export const MIN_NODE_MAJOR = 18
|
|
26
|
+
export const SOCKET_DIR_NAME = '.glasspane'
|
|
27
|
+
export const SOCKET_FILE_NAME = 'engine.sock'
|
|
28
|
+
export const DAEMON_LOG_NAME = 'installer-daemon.log'
|
|
29
|
+
export const LAUNCHD_LABEL = 'com.glasspane.daemon'
|
|
30
|
+
export const LAUNCHD_DIR_NAME = 'Library/LaunchAgents'
|
|
31
|
+
export const LAUNCHD_FILE_NAME = `${LAUNCHD_LABEL}.plist`
|
|
32
|
+
|
|
33
|
+
/** bundle 安置目录与产物名(P1 v1.2 §11.1:daemon 必须有 bundle 身份,系统设置
|
|
34
|
+
* 的权限列表才会显示可读名与图标,「+」选择器也才找得到它。安置在
|
|
35
|
+
* ~/Applications 而非 `.build` 隐藏目录,同一理由)。 */
|
|
36
|
+
export const APPS_DIR_NAME = 'Applications'
|
|
37
|
+
export const SETTINGS_APP_NAME = 'GlassPane.app'
|
|
38
|
+
export const DAEMON_APP_NAME = 'GlassPane Daemon.app'
|
|
39
|
+
export const DAEMON_EXE_NAME = 'glasspaned'
|
|
40
|
+
export const PANEL_EXE_NAME = 'glasspane-settings'
|
|
41
|
+
|
|
42
|
+
/** 源码仓库公共地址(install.sh clone 与"未找到仓库"指引共用同一真源)。 */
|
|
43
|
+
export const REPO_URL = 'https://github.com/jingzhao-l/GlassPane.git'
|
|
44
|
+
/** 一键安装入口脚本的 raw 地址(用户侧 curl 指引真源)。 */
|
|
45
|
+
export const INSTALL_SH_URL =
|
|
46
|
+
'https://raw.githubusercontent.com/jingzhao-l/GlassPane/main/install.sh'
|
|
47
|
+
|
|
48
|
+
const ANSI = {
|
|
49
|
+
reset: '\x1b[0m',
|
|
50
|
+
bold: '\x1b[1m',
|
|
51
|
+
dim: '\x1b[2m',
|
|
52
|
+
green: '\x1b[32m',
|
|
53
|
+
yellow: '\x1b[33m',
|
|
54
|
+
red: '\x1b[31m',
|
|
55
|
+
cyan: '\x1b[36m',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 简单着色工具(非 TTY 时自动降级为纯文本)。 */
|
|
59
|
+
export function paint(text, color, enabled = process.stdout.isTTY) {
|
|
60
|
+
return enabled ? `${ANSI[color] ?? ''}${text}${ANSI.reset}` : text
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// 纯逻辑层(可单测)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/** 解析 CLI 参数;未知/带值参数缺参时返回 { error }。 */
|
|
68
|
+
export function parseArgs(argv) {
|
|
69
|
+
const defaults = {
|
|
70
|
+
repoDir: null,
|
|
71
|
+
prompt: true,
|
|
72
|
+
gui: true,
|
|
73
|
+
daemon: true,
|
|
74
|
+
app: true,
|
|
75
|
+
launchd: true,
|
|
76
|
+
replaceDaemon: false,
|
|
77
|
+
skipBuild: false,
|
|
78
|
+
restoreLaunchd: false,
|
|
79
|
+
help: false,
|
|
80
|
+
}
|
|
81
|
+
const opts = { ...defaults }
|
|
82
|
+
const rest = [...argv]
|
|
83
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
84
|
+
const arg = rest[index]
|
|
85
|
+
switch (arg) {
|
|
86
|
+
case '--help':
|
|
87
|
+
case '-h':
|
|
88
|
+
opts.help = true
|
|
89
|
+
break
|
|
90
|
+
case '--repo':
|
|
91
|
+
index += 1
|
|
92
|
+
if (index >= rest.length || rest[index].startsWith('--')) {
|
|
93
|
+
return { options: defaults, error: '--repo 需要一个目录参数' }
|
|
94
|
+
}
|
|
95
|
+
opts.repoDir = rest[index]
|
|
96
|
+
break
|
|
97
|
+
case '--no-prompt':
|
|
98
|
+
opts.prompt = false
|
|
99
|
+
break
|
|
100
|
+
case '--no-gui':
|
|
101
|
+
opts.gui = false
|
|
102
|
+
break
|
|
103
|
+
case '--no-app':
|
|
104
|
+
opts.app = false
|
|
105
|
+
break
|
|
106
|
+
case '--no-launchd':
|
|
107
|
+
opts.launchd = false
|
|
108
|
+
break
|
|
109
|
+
case '--no-daemon':
|
|
110
|
+
opts.daemon = false
|
|
111
|
+
break
|
|
112
|
+
case '--replace-daemon':
|
|
113
|
+
opts.replaceDaemon = true
|
|
114
|
+
break
|
|
115
|
+
case '--skip-build':
|
|
116
|
+
opts.skipBuild = true
|
|
117
|
+
break
|
|
118
|
+
case '--restore-launchd':
|
|
119
|
+
opts.restoreLaunchd = true
|
|
120
|
+
break
|
|
121
|
+
default:
|
|
122
|
+
return { options: defaults, error: `未知参数:${arg}(--help 查看用法)` }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { options: opts, error: null }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 从起始目录向上逐级寻找含 engine/Package.swift 与 mcp-shell/package.json 的根目录。 */
|
|
129
|
+
export function resolveProjectRoot(startDir = process.cwd()) {
|
|
130
|
+
let dir = path.resolve(startDir)
|
|
131
|
+
while (true) {
|
|
132
|
+
const hasEngine = fs.existsSync(path.join(dir, 'engine', 'Package.swift'))
|
|
133
|
+
const hasShell = fs.existsSync(path.join(dir, 'mcp-shell', 'package.json'))
|
|
134
|
+
if (hasEngine && hasShell) {
|
|
135
|
+
return dir
|
|
136
|
+
}
|
|
137
|
+
const parent = path.dirname(dir)
|
|
138
|
+
if (parent === dir) {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
dir = parent
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 解析 `node --version` 输出的主版本号;不可解析/执行失败返回 0。 */
|
|
146
|
+
export function nodeMajorVersion(nodePath = process.execPath) {
|
|
147
|
+
try {
|
|
148
|
+
const result = spawnSync(nodePath, ['--version'], { encoding: 'utf8', timeout: 5000 })
|
|
149
|
+
if (result.status !== 0) {
|
|
150
|
+
return 0
|
|
151
|
+
}
|
|
152
|
+
const match = /^v(\d+)\./.exec((result.stdout ?? '').trim())
|
|
153
|
+
return match ? Number(match[1]) : 0
|
|
154
|
+
} catch {
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 检查命令是否存在:优先 `<bin> --version`,失败时退化检查 /usr/bin/<bin>
|
|
160
|
+
* 可执行文件(覆盖 open 等不支持 --version 的命令)。真实探测,不伪造。 */
|
|
161
|
+
export function commandAvailable(bin) {
|
|
162
|
+
const byVersion = (() => {
|
|
163
|
+
try {
|
|
164
|
+
const result = spawnSync(bin, ['--version'], { stdio: 'ignore', timeout: 5000 })
|
|
165
|
+
return result.status === 0
|
|
166
|
+
} catch {
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
})()
|
|
170
|
+
if (byVersion) {
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const absolute = path.join('/usr/bin', bin)
|
|
175
|
+
fs.accessSync(absolute, fs.constants.X_OK)
|
|
176
|
+
return true
|
|
177
|
+
} catch {
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 环境预检快照,返回每种依赖的可用状态(真实探测,不伪造)。 */
|
|
183
|
+
export function checkEnvironment({ nodePath = process.execPath } = {}) {
|
|
184
|
+
return {
|
|
185
|
+
nodeMajor: nodeMajorVersion(nodePath),
|
|
186
|
+
nodeOk: nodeMajorVersion(nodePath) >= MIN_NODE_MAJOR,
|
|
187
|
+
npm: commandAvailable('npm'),
|
|
188
|
+
swift: commandAvailable('swift'),
|
|
189
|
+
git: commandAvailable('git'),
|
|
190
|
+
open: commandAvailable('open'),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 汇总预检结果:缺项按严重程度排序返回可读描述(空数组 = 全部通过)。 */
|
|
195
|
+
export function preflightIssues(env) {
|
|
196
|
+
const issues = []
|
|
197
|
+
if (env.nodeMajor === 0) {
|
|
198
|
+
issues.push(`无法解析 Node.js 版本(${env.nodeMajor === 0 ? '执行失败' : ''})`)
|
|
199
|
+
} else if (!env.nodeOk) {
|
|
200
|
+
issues.push(`Node.js 需要 >= ${MIN_NODE_MAJOR},当前为 ${env.nodeMajor}`)
|
|
201
|
+
}
|
|
202
|
+
if (!env.npm) {
|
|
203
|
+
issues.push('未找到 npm')
|
|
204
|
+
}
|
|
205
|
+
if (!env.swift) {
|
|
206
|
+
issues.push('未找到 swift(需安装 Xcode Command Line Tools:xcode-select --install)')
|
|
207
|
+
}
|
|
208
|
+
if (!env.git) {
|
|
209
|
+
issues.push('未找到 git')
|
|
210
|
+
}
|
|
211
|
+
if (!env.open) {
|
|
212
|
+
issues.push('未找到 open(本安装器面向 macOS)')
|
|
213
|
+
}
|
|
214
|
+
return issues
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 探测 unix socket 是否已有 daemon 在监听;连接超时 800ms 视为不可达。 */
|
|
218
|
+
export function socketReachable(socketPath) {
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
const client = net.connect({ path: socketPath })
|
|
221
|
+
const onDone = (ok) => {
|
|
222
|
+
client.removeAllListeners()
|
|
223
|
+
client.destroy()
|
|
224
|
+
resolve(ok)
|
|
225
|
+
}
|
|
226
|
+
client.setTimeout(800, () => onDone(false))
|
|
227
|
+
client.on('connect', () => onDone(true))
|
|
228
|
+
client.on('timeout', () => onDone(false))
|
|
229
|
+
client.on('error', () => onDone(false))
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 等待 socket 重新可达(kill 之后 launchd 会按 ThrottleInterval 延迟重拉);
|
|
234
|
+
* 超时返回 false,调用方按"未接管"继续走手动启动分支。 */
|
|
235
|
+
export async function waitForSocket(
|
|
236
|
+
socketPath,
|
|
237
|
+
// 覆盖 launchd 起栈时间(kickstart 后仍需等 socket 真的可用)。
|
|
238
|
+
{ timeoutMs = 12000, intervalMs = 500, probe = socketReachable, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {},
|
|
239
|
+
) {
|
|
240
|
+
const deadline = Date.now() + timeoutMs
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
if (await probe(socketPath)) return true
|
|
243
|
+
await sleep(intervalMs)
|
|
244
|
+
}
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** bundle 安装计划(纯函数,P1 v1.2 §11.1):构建目录里的 .app → 安置到
|
|
249
|
+
* ~/Applications(可见、稳定、非隐藏),并给出 launchd/GUI 应使用的路径。
|
|
250
|
+
* `appsDir` 显式传入时覆盖默认(测试与非标准安装位置用)。 */
|
|
251
|
+
export function bundlePlan({ homeDir, buildDir, appsDir = null, appPackaged = true } = {}) {
|
|
252
|
+
const targetAppsDir = appsDir ?? path.join(homeDir ?? '', APPS_DIR_NAME)
|
|
253
|
+
const bundled = (name) => path.join(targetAppsDir, name)
|
|
254
|
+
return {
|
|
255
|
+
appsDir: targetAppsDir,
|
|
256
|
+
// 构建产物(make-app.sh 的输出位置)
|
|
257
|
+
builtSettingsApp: path.join(buildDir, SETTINGS_APP_NAME),
|
|
258
|
+
builtDaemonApp: path.join(buildDir, DAEMON_APP_NAME),
|
|
259
|
+
// 安置后的位置
|
|
260
|
+
settingsApp: appPackaged ? bundled(SETTINGS_APP_NAME) : null,
|
|
261
|
+
daemonApp: appPackaged ? bundled(DAEMON_APP_NAME) : null,
|
|
262
|
+
daemonExecutable: appPackaged
|
|
263
|
+
? path.join(bundled(DAEMON_APP_NAME), 'Contents', 'MacOS', DAEMON_EXE_NAME)
|
|
264
|
+
: null,
|
|
265
|
+
settingsExecutable: appPackaged
|
|
266
|
+
? path.join(bundled(SETTINGS_APP_NAME), 'Contents', 'MacOS', PANEL_EXE_NAME)
|
|
267
|
+
: null,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 选择 daemon 启动路径:bundle 就位 → 用 bundle 内可执行(权限条目才带名字
|
|
272
|
+
* 与图标);否则退回裸二进制并如实告警(`viaBundle=false`)。纯函数,
|
|
273
|
+
* 文件系统可达性由注入的 `exists` 决定,便于单测。 */
|
|
274
|
+
export function daemonLaunchPath({ plan, bareBin, exists = fs.existsSync } = {}) {
|
|
275
|
+
const bundled = plan?.daemonExecutable ?? null
|
|
276
|
+
if (bundled && exists(bundled)) {
|
|
277
|
+
return { path: bundled, viaBundle: true }
|
|
278
|
+
}
|
|
279
|
+
return { path: bareBin, viaBundle: false }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 设置面板启动路径:同上,优先 bundle。 */
|
|
283
|
+
export function settingsLaunchPath({ plan, bareBin, exists = fs.existsSync } = {}) {
|
|
284
|
+
const bundled = plan?.settingsExecutable ?? null
|
|
285
|
+
if (bundled && exists(bundled)) {
|
|
286
|
+
return { path: bundled, viaBundle: true }
|
|
287
|
+
}
|
|
288
|
+
return { path: bareBin, viaBundle: false }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 从 `ps -Ao pid=,comm=` 文本里挑出指定可执行名的进程 PID(纯函数)。
|
|
292
|
+
* TCC 席位按进程身份记账,多实例并存时"系统设置里看到的条目"与"实际服务
|
|
293
|
+
* socket 的实例"可能不是同一个进程——旧构建实例必须先收拢再授权。 */
|
|
294
|
+
export function pidsFromPs(psOutput, { exeName, selfPid = process.pid } = {}) {
|
|
295
|
+
const pids = []
|
|
296
|
+
for (const line of String(psOutput ?? '').split('\n')) {
|
|
297
|
+
const trimmed = line.trim()
|
|
298
|
+
if (!trimmed) continue
|
|
299
|
+
const separator = trimmed.indexOf(' ')
|
|
300
|
+
if (separator <= 0) continue
|
|
301
|
+
const pid = Number(trimmed.slice(0, separator))
|
|
302
|
+
const command = trimmed.slice(separator + 1).trim()
|
|
303
|
+
if (!Number.isInteger(pid) || pid <= 0) continue
|
|
304
|
+
if (pid === selfPid) continue
|
|
305
|
+
if (path.basename(command) === exeName) pids.push(pid)
|
|
306
|
+
}
|
|
307
|
+
return pids
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function daemonPidsFromPs(psOutput, { selfPid = process.pid } = {}) {
|
|
311
|
+
return pidsFromPs(psOutput, { exeName: DAEMON_EXE_NAME, selfPid })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function panelPidsFromPs(psOutput, { selfPid = process.pid } = {}) {
|
|
315
|
+
return pidsFromPs(psOutput, { exeName: PANEL_EXE_NAME, selfPid })
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 按可执行名列出在跑的进程 PID(副作用层:读 `ps`,解析交给纯函数)。 */
|
|
319
|
+
export function listRunningPids(exeName, { spawn = spawnSync } = {}) {
|
|
320
|
+
const result = spawn('ps', ['-Ao', 'pid=,comm='], { encoding: 'utf8' })
|
|
321
|
+
if (result.status !== 0) {
|
|
322
|
+
return []
|
|
323
|
+
}
|
|
324
|
+
return pidsFromPs(result.stdout, { exeName })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** launchd 主动接管(重启)已注册作业的参数(纯函数)。
|
|
328
|
+
* 为什么需要它而不是等 launchd 自动重拉:KeepAlive 采用 SuccessfulExit=false,
|
|
329
|
+
* 即"只有异常退出才重启";daemon 修好 SIGTERM 收尾后是 clean exit 0(P1-S15),
|
|
330
|
+
* 按该语义 launchd 本就不该复活它——收拢旧实例后要拿到 launchd 托管形态,
|
|
331
|
+
* 必须显式 kickstart。 */
|
|
332
|
+
export function launchdKickstartArgs({ label, uid }) {
|
|
333
|
+
return ['kickstart', '-k', `gui/${uid}/${label}`]
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 向指定 PID 发 SIGTERM(daemon 侧装有信号处理 → 清理 socket 后退出;
|
|
337
|
+
* launchd 托管时异常退出会按新 plist 重新拉起)。1s 后仍存活的补 SIGKILL:
|
|
338
|
+
* 实测存在收不到/不响应 SIGTERM 的旧 daemon 实例(主循环未跑起来时
|
|
339
|
+
* SIG_IGN 生效而 handler 永不执行),它们占着 socket 却是死身份,必须清掉。 */
|
|
340
|
+
export function terminatePids(pids, { spawn = spawnSync } = {}) {
|
|
341
|
+
const kill = (pid, signal) => spawn('kill', signal ? ['-' + signal, String(pid)] : [String(pid)])
|
|
342
|
+
for (const pid of pids) kill(pid)
|
|
343
|
+
spawn('sleep', ['1'])
|
|
344
|
+
const forced = []
|
|
345
|
+
for (const pid of pids) {
|
|
346
|
+
if (kill(pid, '0').status === 0) {
|
|
347
|
+
kill(pid, '9')
|
|
348
|
+
forced.push(pid)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { terminated: [...pids], forced }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 把 make-app.sh 的两个 bundle 安置到 ~/Applications(覆盖式,幂等)。
|
|
355
|
+
* 返回实际安置的条目;源缺失即跳过(--no-app 或构建产物不全时不报错)。 */
|
|
356
|
+
export function installBundles({ plan, exists = fs.existsSync, remove = fs.rmSync, copy = fs.cpSync, mkdir = fs.mkdirSync } = {}) {
|
|
357
|
+
const installed = []
|
|
358
|
+
if (!plan?.appsDir) return installed
|
|
359
|
+
mkdir(plan.appsDir, { recursive: true })
|
|
360
|
+
const pairs = [
|
|
361
|
+
[plan.builtSettingsApp, plan.settingsApp],
|
|
362
|
+
[plan.builtDaemonApp, plan.daemonApp],
|
|
363
|
+
]
|
|
364
|
+
for (const [source, target] of pairs) {
|
|
365
|
+
if (!source || !target || !exists(source)) continue
|
|
366
|
+
remove(target, { recursive: true, force: true })
|
|
367
|
+
copy(source, target, { recursive: true })
|
|
368
|
+
installed.push(target)
|
|
369
|
+
}
|
|
370
|
+
return installed
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 生成 launchd 用户代理 plist(纯函数,零副作用;XML 转义防路径注入)。
|
|
374
|
+
* KeepAlive 采用 SuccessfulExit=false:仅异常退出时由 launchd 重启,
|
|
375
|
+
* 避免正常 shutdown 也被反复拉起。 */
|
|
376
|
+
export function launchdPlistString({ label, daemonBin, socketPath, logPath }) {
|
|
377
|
+
const escapeXml = (value) =>
|
|
378
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
379
|
+
const argumentNode = (value) => ` <string>${escapeXml(value)}</string>`
|
|
380
|
+
return [
|
|
381
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
382
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
383
|
+
'<plist version="1.0">',
|
|
384
|
+
' <dict>',
|
|
385
|
+
` <key>Label</key>`,
|
|
386
|
+
` <string>${escapeXml(label)}</string>`,
|
|
387
|
+
' <key>ProgramArguments</key>',
|
|
388
|
+
' <array>',
|
|
389
|
+
argumentNode(daemonBin),
|
|
390
|
+
argumentNode('--socket-path'),
|
|
391
|
+
argumentNode(socketPath),
|
|
392
|
+
' </array>',
|
|
393
|
+
' <key>RunAtLoad</key>',
|
|
394
|
+
' <true/>',
|
|
395
|
+
' <key>KeepAlive</key>',
|
|
396
|
+
' <dict>',
|
|
397
|
+
' <key>SuccessfulExit</key>',
|
|
398
|
+
' <false/>',
|
|
399
|
+
' </dict>',
|
|
400
|
+
' <key>ThrottleInterval</key>',
|
|
401
|
+
' <integer>5</integer>',
|
|
402
|
+
' <key>StandardOutPath</key>',
|
|
403
|
+
` <string>${escapeXml(logPath)}</string>`,
|
|
404
|
+
' <key>StandardErrorPath</key>',
|
|
405
|
+
` <string>${escapeXml(logPath)}</string>`,
|
|
406
|
+
' </dict>',
|
|
407
|
+
'</plist>',
|
|
408
|
+
'',
|
|
409
|
+
].join('\n')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** 从 `launchctl print` 输出里取已加载作业的 `program`(纯函数,解析友好)。 */
|
|
413
|
+
export function loadedLaunchdProgram(printOutput) {
|
|
414
|
+
const match = /(?:^|\n)\s*program = (.*)/.exec(String(printOutput ?? ''))
|
|
415
|
+
return match ? match[1].trim() : null
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 是否需要重新注册(bootout + bootstrap)——比的是**已加载的作业定义**与本次要
|
|
419
|
+
* 安装的程序路径,而不是 plist 文件文本。
|
|
420
|
+
* launchd 缓存的是 **bootstrap 时读到的作业定义**:原地重写 plist(例如 daemon 从
|
|
421
|
+
* 裸二进制换成 bundle 内可执行)后,已加载作业仍按旧定义 spawn——真机表现为
|
|
422
|
+
* `launchctl print` 里 `last exit code = 78: EX_CONFIG`,kickstart 也永远起不来
|
|
423
|
+
* (socket 因此轮不到 launchd 接管)。只比 plist 文本不够:上一轮安装留下的陈旧
|
|
424
|
+
* 定义对本轮而言文本可能完全一致。因此比 `program` 路径,且读不到定义时一律重来。 */
|
|
425
|
+
export function launchdNeedsReregister({ alreadyLoaded, loadedProgram = null, desiredProgram }) {
|
|
426
|
+
if (!alreadyLoaded) return false
|
|
427
|
+
if (!loadedProgram) return true
|
|
428
|
+
return loadedProgram !== String(desiredProgram ?? '')
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 注册 launchd 用户代理(幂等):已加载则跳过,未加载则 bootstrap
|
|
432
|
+
* gui/<uid>。返回 { ok, already, message },失败携带真实 stderr。 */
|
|
433
|
+
export function launchctlBootstrap(label, plistPath, { desiredProgram = null } = {}) {
|
|
434
|
+
const uid = spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout.trim()
|
|
435
|
+
if (!uid) {
|
|
436
|
+
return { ok: false, already: false, message: '无法解析当前 uid(id -u 失败),跳过 launchd 注册' }
|
|
437
|
+
}
|
|
438
|
+
const domain = `gui/${uid}`
|
|
439
|
+
const probe = spawnSync('launchctl', ['print', domain + '/' + label], { encoding: 'utf8' })
|
|
440
|
+
const needsReregister = launchdNeedsReregister({
|
|
441
|
+
alreadyLoaded: probe.status === 0,
|
|
442
|
+
loadedProgram: loadedLaunchdProgram(probe.stdout),
|
|
443
|
+
desiredProgram,
|
|
444
|
+
})
|
|
445
|
+
if (probe.status === 0 && !needsReregister) {
|
|
446
|
+
return { ok: true, already: true, message: `launchd 已加载 ${label}(${domain}),跳过 bootstrap` }
|
|
447
|
+
}
|
|
448
|
+
if (probe.status === 0) {
|
|
449
|
+
// 定义变了:先卸掉旧作业,否则新 plist 不生效(真机 EX_CONFIG 的来路)。
|
|
450
|
+
spawnSync('launchctl', ['bootout', domain + '/' + label])
|
|
451
|
+
}
|
|
452
|
+
const boot = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8' })
|
|
453
|
+
const stderr = (boot.stderr ?? '').trim()
|
|
454
|
+
const status = boot.status
|
|
455
|
+
if (status === 0 || status === 5 || /already/i.test(stderr)) {
|
|
456
|
+
return {
|
|
457
|
+
ok: true,
|
|
458
|
+
already: status !== 0,
|
|
459
|
+
message: stderr || `launchctl bootstrap 完成(gui/${uid}/${label})`,
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
already: false,
|
|
465
|
+
message: stderr || `launchctl bootstrap 失败,退出码 ${status}(gui/${uid}/${label})`,
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 以 daemon 自身席位重启已加载的 launchd 作业(`kickstart -k`)。恢复流程里
|
|
470
|
+
* 只在"作业已加载但自报非 granted"时调用——此时 daemon 本就不可用,中断
|
|
471
|
+
* 面为零;TCC 判定按进程缓存,勾选授权后必须新进程才看得到(P1 v1.2 §11)。 */
|
|
472
|
+
export function launchctlKickstart(label) {
|
|
473
|
+
const uid = spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout.trim()
|
|
474
|
+
if (!uid) {
|
|
475
|
+
return { ok: false, message: '无法解析当前 uid(id -u 失败),跳过 kickstart' }
|
|
476
|
+
}
|
|
477
|
+
const kick = spawnSync('launchctl', ['kickstart', '-k', `gui/${uid}/${label}`], { encoding: 'utf8' })
|
|
478
|
+
const stderr = (kick.stderr ?? '').trim()
|
|
479
|
+
if (kick.status === 0) {
|
|
480
|
+
return { ok: true, message: `launchd 已重启作业(gui/${uid}/${label})` }
|
|
481
|
+
}
|
|
482
|
+
return { ok: false, message: stderr || `launchctl kickstart 失败,退出码 ${kick.status}` }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** 向 daemon socket 发一行 hello(NDJSON 单行协议,与 FrameCodec 对齐),返回
|
|
486
|
+
* 解析后的 result;不可达/超时/坏响应一律 null——恢复判定必须把"拿不到"
|
|
487
|
+
* 如实降级为未验证,不得冒充 granted。 */
|
|
488
|
+
export function socketHello(
|
|
489
|
+
socketPath,
|
|
490
|
+
{ timeoutMs = 3000, connect = (opts, onData) => { const c = net.createConnection(opts); c.on('data', onData); return c } } = {},
|
|
491
|
+
) {
|
|
492
|
+
return new Promise((resolve) => {
|
|
493
|
+
let settled = false
|
|
494
|
+
const finish = (value) => { if (!settled) { settled = true; try { client.destroy() } catch {} ; resolve(value) } }
|
|
495
|
+
let buffer = ''
|
|
496
|
+
const client = connect({ path: socketPath }, (chunk) => {
|
|
497
|
+
buffer += chunk.toString('utf8')
|
|
498
|
+
const newline = buffer.indexOf('\n')
|
|
499
|
+
if (newline < 0) return
|
|
500
|
+
try {
|
|
501
|
+
const frame = JSON.parse(buffer.slice(0, newline))
|
|
502
|
+
finish(frame.result ?? null)
|
|
503
|
+
} catch {
|
|
504
|
+
finish(null)
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
client.setTimeout(timeoutMs, () => finish(null))
|
|
508
|
+
client.on('error', () => finish(null))
|
|
509
|
+
client.on('connect', () => {
|
|
510
|
+
client.write('{"id":1,"method":"hello"}\n')
|
|
511
|
+
})
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 审计项④(launchd 恢复靠人 → 机器化):检测 + 恢复 + 校验一条龙,用户剩余
|
|
517
|
+
* 唯一动作 = 在系统设置里勾选(TCC 无程序化路径,P1 v1.2 §10.1)。
|
|
518
|
+
*
|
|
519
|
+
* 状态机(对 agent 可执行、对结果不撒谎):
|
|
520
|
+
* - plist 缺失 → 不猜,指回完整安装;
|
|
521
|
+
* - 作业未加载(booted-out,等授权期间常发生)→ bootstrap,新起的 daemon
|
|
522
|
+
* 自报即真值;
|
|
523
|
+
* - 作业已加载且自报 granted → 无需任何动作,如实报告;
|
|
524
|
+
* - 已加载但自报非 granted(含 permissions 缺失)→ TCC 按进程缓存,
|
|
525
|
+
* kickstart -k 换一个新判定进程再验——勾完框重跑本命令即可闭环。
|
|
526
|
+
*/
|
|
527
|
+
export async function restoreLaunchd({
|
|
528
|
+
label = LAUNCHD_LABEL,
|
|
529
|
+
plistPath = path.join(process.env.HOME ?? '', ...LAUNCHD_DIR_NAME.split('/'), LAUNCHD_FILE_NAME),
|
|
530
|
+
socketPath = path.join(process.env.HOME ?? '', SOCKET_DIR_NAME, SOCKET_FILE_NAME),
|
|
531
|
+
bootstrapFn = launchctlBootstrap,
|
|
532
|
+
kickstartFn = launchctlKickstart,
|
|
533
|
+
waitFn = waitForSocket,
|
|
534
|
+
helloFn = socketHello,
|
|
535
|
+
existsFn = (p) => fs.existsSync(p),
|
|
536
|
+
serveTimeoutMs = 10000,
|
|
537
|
+
} = {}) {
|
|
538
|
+
if (!existsFn(plistPath)) {
|
|
539
|
+
return {
|
|
540
|
+
ok: false,
|
|
541
|
+
action: 'missing-plist',
|
|
542
|
+
message: `launchd plist 不存在(${plistPath}):从未安装或已被删除,请重跑 install.sh 完整安装`,
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const boot = bootstrapFn(label, plistPath)
|
|
546
|
+
if (!boot.ok) {
|
|
547
|
+
return { ok: false, action: 'bootstrap-failed', message: `bootstrap 失败:${boot.message}` }
|
|
548
|
+
}
|
|
549
|
+
const verify = async () => {
|
|
550
|
+
if (!(await waitFn(socketPath, { timeoutMs: serveTimeoutMs }))) {
|
|
551
|
+
return { reachable: false, accessibility: null, pid: null, version: null }
|
|
552
|
+
}
|
|
553
|
+
const result = await helloFn(socketPath)
|
|
554
|
+
return {
|
|
555
|
+
reachable: result != null,
|
|
556
|
+
accessibility: result?.permissions?.accessibility ?? null,
|
|
557
|
+
pid: result?.pid ?? null,
|
|
558
|
+
version: result?.version ?? null,
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
let checked = await verify()
|
|
562
|
+
if (!checked.reachable) {
|
|
563
|
+
return {
|
|
564
|
+
ok: false,
|
|
565
|
+
action: boot.already ? 'already-loaded' : 'bootstrapped',
|
|
566
|
+
verified: checked,
|
|
567
|
+
message: `launchd 作业就位(${boot.message}),但 ${serveTimeoutMs}ms 内 daemon socket 未服务/未回 hello(${socketPath})`,
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
let restarted = false
|
|
571
|
+
if (checked.accessibility !== 'granted' && boot.already) {
|
|
572
|
+
// 已加载 + 非 granted:可能刚勾完框但旧进程缓存着未授权判定——换新进程再验。
|
|
573
|
+
const kick = kickstartFn(label)
|
|
574
|
+
if (kick.ok) {
|
|
575
|
+
restarted = true
|
|
576
|
+
checked = await verify()
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const granted = checked.accessibility === 'granted'
|
|
580
|
+
const action = restarted ? 'restarted-for-grant' : boot.already ? 'already-loaded' : 'bootstrapped'
|
|
581
|
+
return {
|
|
582
|
+
ok: true,
|
|
583
|
+
action,
|
|
584
|
+
verified: checked,
|
|
585
|
+
message: granted
|
|
586
|
+
? `daemon 已由 launchd 服务且自报辅助功能 granted(pid ${checked.pid},version ${checked.version})——无人工动作剩余`
|
|
587
|
+
: `daemon 已就位,自报辅助功能为 ${checked.accessibility ?? 'unknown'};唯一剩余人工动作 = 系统设置>隐私与安全性>辅助功能 勾选 daemon,然后重跑本命令复验(届时会自动换进程取新判定)`,
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
// 执行层
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
/** 前台运行命令并透传输出;非零退出码 → reject 携带清晰描述。 */
|
|
596
|
+
export function run(cmd, args, { cwd } = {}) {
|
|
597
|
+
return new Promise((resolve, reject) => {
|
|
598
|
+
const child = spawn(cmd, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
|
|
599
|
+
child.on('error', (error) => reject(new Error(`${cmd} 启动失败:${error.message}`)))
|
|
600
|
+
child.on('close', (code) => {
|
|
601
|
+
if (code === 0) {
|
|
602
|
+
resolve()
|
|
603
|
+
} else {
|
|
604
|
+
reject(new Error(`${cmd} 失败,退出码 ${code}`))
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 暂停询问;非 TTY 或 --no-prompt 时自动视为同意(不阻塞自动化)。 */
|
|
611
|
+
export async function confirm(question, { autoYes = false } = {}) {
|
|
612
|
+
if (autoYes || !process.stdin.isTTY) {
|
|
613
|
+
return true
|
|
614
|
+
}
|
|
615
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
616
|
+
try {
|
|
617
|
+
const answer = await rl.question(`${question} (Enter 继续 / n 跳过) `)
|
|
618
|
+
return !/^n/i.test(answer.trim())
|
|
619
|
+
} finally {
|
|
620
|
+
rl.close()
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** 后台分离启动长驻进程(daemon / GUI),日志追加写盘,返回 PID。 */
|
|
625
|
+
export function startDetached(binPath, args, logPath) {
|
|
626
|
+
const logStream = fs.openSync(logPath, 'a')
|
|
627
|
+
const child = spawn(binPath, args, {
|
|
628
|
+
detached: true,
|
|
629
|
+
stdio: ['ignore', logStream, logStream],
|
|
630
|
+
})
|
|
631
|
+
child.unref()
|
|
632
|
+
return child.pid
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** bundle 形态的 `open` 参数(纯函数)。
|
|
636
|
+
* 为什么走 `open -a`:把进程登记为那个 .app 的主进程(LaunchServices 里有
|
|
637
|
+
* 这条记录,系统设置的「+」选择器与拖拽才认它),并且
|
|
638
|
+
* `-g` = 不抢焦点(安装不该把窗口顶到用户面前),`-n` = 绕开"同 bundle 复用
|
|
639
|
+
* 已运行实例",确保起的是刚安置好的这份产物。
|
|
640
|
+
* 如实边界:条目在权限列表里的具体渲染(名字/图标)仍需真机目视确认,
|
|
641
|
+
* 本函数只保证启动路径与 bundle 身份一致。 */
|
|
642
|
+
export function bundleLaunchArgs({ appPath, args = [], background = true }) {
|
|
643
|
+
return [
|
|
644
|
+
...(background ? ['-g'] : []),
|
|
645
|
+
'-n',
|
|
646
|
+
'-a',
|
|
647
|
+
appPath,
|
|
648
|
+
...(args.length > 0 ? ['--args', ...args] : []),
|
|
649
|
+
]
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** daemon 启动命令(纯函数):bundle 形态经 open,裸二进制形态直接 exec。 */
|
|
653
|
+
export function daemonStartCommand({ daemonLaunch, daemonApp = null, socketPath }) {
|
|
654
|
+
const socketArgs = ['--socket-path', socketPath]
|
|
655
|
+
if (daemonLaunch.viaBundle && daemonApp) {
|
|
656
|
+
return {
|
|
657
|
+
launchPath: '/usr/bin/open',
|
|
658
|
+
args: bundleLaunchArgs({ appPath: daemonApp, args: socketArgs }),
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return { launchPath: daemonLaunch.path, args: socketArgs }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** 设置面板启动命令(纯函数),口径同 daemon。 */
|
|
665
|
+
export function guiStartCommand({ guiLaunch, settingsApp = null }) {
|
|
666
|
+
if (guiLaunch.viaBundle && settingsApp) {
|
|
667
|
+
return {
|
|
668
|
+
launchPath: '/usr/bin/open',
|
|
669
|
+
args: bundleLaunchArgs({ appPath: settingsApp }),
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return { launchPath: guiLaunch.path, args: [] }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** 打印安装历史步骤日志(消歧:与 daemon 日志文件区分)。 */
|
|
676
|
+
export function printStep(message, pending = false) {
|
|
677
|
+
const prefix = pending ? paint('⣾ ', 'cyan') : paint('✔ ', 'green')
|
|
678
|
+
process.stdout.write(`${prefix}${message}\n`)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** --help 用法文本(单一真源,主流程与 parseArgs 共用)。 */
|
|
682
|
+
export function usageText() {
|
|
683
|
+
return [
|
|
684
|
+
paint('GlassPane 一键安装器', 'bold'),
|
|
685
|
+
'',
|
|
686
|
+
'用法: node installer/cli.js [选项]',
|
|
687
|
+
'',
|
|
688
|
+
'选项:',
|
|
689
|
+
' --repo <目录> 指定项目根目录(默认自动向上查找)',
|
|
690
|
+
' --no-prompt 跳过每步确认(脚本/自动化场景)',
|
|
691
|
+
' --no-daemon 不启动 glasspaned',
|
|
692
|
+
' --replace-daemon 先结束已在跑的 glasspaned 实例(旧构建的 daemon 会继续占',
|
|
693
|
+
' socket,让系统设置里的授权条目与实际服务实例对不上)',
|
|
694
|
+
' --no-gui 不打开 glasspane-settings GUI',
|
|
695
|
+
' --no-app 不打包/安置 .app(仅裸二进制产物;权限条目将只显示文件',
|
|
696
|
+
' 名且无图标,见 P1 v1.2 §11.1)',
|
|
697
|
+
' --no-launchd 不注册开机自启(launchd bootstrap)',
|
|
698
|
+
' --restore-launchd 只做 launchd 恢复:检测作业被 bootout → bootstrap → hello',
|
|
699
|
+
' 校验 daemon 自报席位(勾完 TCC 框后重跑即自动换进程复验)',
|
|
700
|
+
' --skip-build 跳过 npm/tsc/swift 编译(已构建过时使用)',
|
|
701
|
+
' -h, --help 显示本帮助',
|
|
702
|
+
'',
|
|
703
|
+
].join('\n')
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** 生成 MCP 客户端接入配置片段(纯函数):mcp-shell 以仓库内构建产物直连,
|
|
707
|
+
* 与 §33.2 决策链一致——npx 发布形态挂 Phase B,本地安装即所得。
|
|
708
|
+
* socketPath 为 daemon 默认路径时仍显式写入 env,避免读者误以为不可配置。 */
|
|
709
|
+
export function mcpClientConfigSnippet({ rootDir, socketPath }) {
|
|
710
|
+
return JSON.stringify(
|
|
711
|
+
{
|
|
712
|
+
mcpServers: {
|
|
713
|
+
glasspane: {
|
|
714
|
+
command: 'node',
|
|
715
|
+
args: [path.join(rootDir, 'mcp-shell', 'dist', 'index.js')],
|
|
716
|
+
env: { GLASSPANE_ENGINE_SOCK: socketPath },
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
null,
|
|
721
|
+
2,
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** 安装完成后的使用说明(§34.2 步骤 8:mcp-shell 接入 + 授权/维护提示)。
|
|
726
|
+
* `daemon` 描述实际启动形态(P1 v1.2 §11.1):bundle 形态下系统设置权限列表里
|
|
727
|
+
* 出现带图标的「GlassPane Daemon」,裸二进制形态只出现文件名——文案必须与真实
|
|
728
|
+
* 形态一致,否则用户会在面板里找一个不存在的条目。 */
|
|
729
|
+
export function nextStepsText({
|
|
730
|
+
rootDir,
|
|
731
|
+
socketPath,
|
|
732
|
+
guiOpened,
|
|
733
|
+
daemon = { viaBundle: false, path: null },
|
|
734
|
+
settingsApp = null,
|
|
735
|
+
}) {
|
|
736
|
+
const settingsAppHint =
|
|
737
|
+
settingsApp ?? path.join(rootDir, 'engine', '.build', 'release', SETTINGS_APP_NAME)
|
|
738
|
+
const entryName = daemon.viaBundle ? 'GlassPane Daemon' : 'glasspaned'
|
|
739
|
+
return [
|
|
740
|
+
paint('后续使用说明', 'bold'),
|
|
741
|
+
'',
|
|
742
|
+
'1. MCP 客户端(Claude Desktop / Cursor / 任意 MCP host)接入配置片段:',
|
|
743
|
+
'',
|
|
744
|
+
mcpClientConfigSnippet({ rootDir, socketPath }),
|
|
745
|
+
'',
|
|
746
|
+
'2. TCC 权限(辅助功能/输入监控/屏幕录制/开发者工具):授权对象是 **daemon 进程**',
|
|
747
|
+
' 而不是设置窗口;授权勾选必须人工完成,无程序化路径(P1 v1.2 §10.1 / §11.2)。',
|
|
748
|
+
daemon.viaBundle
|
|
749
|
+
? ' 在系统设置的对应权限列表里应能看到「GlassPane Daemon」(带图标,可用「+」选择或从 Finder 拖入)。'
|
|
750
|
+
: ' 当前 daemon 是裸二进制形态,权限列表里只会显示文件名「glasspaned」(无图标,「+」选择器看不到隐藏目录);重跑安装(默认打包 .app)可获得带图标的条目。',
|
|
751
|
+
guiOpened
|
|
752
|
+
? ' 面板已打开:点权限卡「授权」会让 daemon 以自身身份发起申请并跳转到对应系统面板。'
|
|
753
|
+
: ` 打开面板:open "${settingsAppHint}"(点权限卡「授权」即由 daemon 自身发起申请)。`,
|
|
754
|
+
` 验证 daemon 自报的席位与主体:${daemon.path ?? path.join(rootDir, 'engine', '.build', 'release', DAEMON_EXE_NAME)} --permissions`,
|
|
755
|
+
' 口径提醒:daemon 必须由 launchd/登录项拉起,权限自报才等于真实席位;从终端或',
|
|
756
|
+
' GUI 手动起的实例会继承启动者 app 的判定(真机实测:同 bundle 两种起法读数不同)。',
|
|
757
|
+
'3. daemon 已注册 launchd 开机自启(--no-launchd 可跳过);维护命令:',
|
|
758
|
+
' glasspaned --approval-audit / --approval-verify / --prune-evidence / --evidence-stats',
|
|
759
|
+
` 作业被 bootout(等授权期间常发生)后的恢复**不需要手敲 launchctl**:`,
|
|
760
|
+
` node "${path.join(rootDir, 'installer', 'cli.js')}" --restore-launchd`,
|
|
761
|
+
` (检测→bootstrap→hello 校验自报席位;勾完 TCC 框重跑即自动换进程复验)`,
|
|
762
|
+
`4. entryName 核对:设置面板「Daemon 状态 → 主体」应与上面「${entryName}」一致;不一致说明连到了别的构建实例。`,
|
|
763
|
+
'5. registry 发布形态(npx glasspane-mcp)挂 kernel Phase B(P4 §33.2 决策链),当前以本仓库产物为准。',
|
|
764
|
+
'',
|
|
765
|
+
].join('\n')
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** 未定位到仓库时的指引文本(纯函数,可单测)。 */
|
|
769
|
+
export function repoMissingText() {
|
|
770
|
+
return [
|
|
771
|
+
'未定位到 GlassPane 项目根目录(需同时包含 engine/Package.swift 与 mcp-shell/package.json)。',
|
|
772
|
+
'可选路径:',
|
|
773
|
+
` a) 一键安装(自动 clone 源码后进入本流程):curl -fsSL ${INSTALL_SH_URL} | sh`,
|
|
774
|
+
` b) 手工 clone:git clone ${REPO_URL} && cd GlassPane && node installer/cli.js`,
|
|
775
|
+
' c) 已在本仓库其他位置:--repo <目录> 或 GLASSPANE_REPO 环境变量指定根目录。',
|
|
776
|
+
].join('\n')
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** 主安装流程;所有副作用步骤均记录真实执行结果,失败即抛错终止。 */
|
|
780
|
+
export async function install({ options = parseArgs([]).options, env = process.env } = {}) {
|
|
781
|
+
const explicitRoot = options.repoDir ? path.resolve(options.repoDir) : null
|
|
782
|
+
const envRoot = env.GLASSPANE_REPO ? path.resolve(env.GLASSPANE_REPO) : null
|
|
783
|
+
const rootDir = explicitRoot ?? envRoot ?? resolveProjectRoot()
|
|
784
|
+
|
|
785
|
+
if (!rootDir || !fs.existsSync(rootDir)) {
|
|
786
|
+
throw new Error(repoMissingText())
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const engineDir = path.join(rootDir, 'engine')
|
|
790
|
+
const buildDir = path.join(engineDir, '.build', 'release')
|
|
791
|
+
const socketPath = path.join(process.env.HOME ?? '', SOCKET_DIR_NAME, SOCKET_FILE_NAME)
|
|
792
|
+
const daemonBin = path.join(buildDir, 'glasspaned')
|
|
793
|
+
const settingsBin = path.join(buildDir, 'glasspane-settings')
|
|
794
|
+
const daemonLog = path.join(process.env.HOME ?? '', SOCKET_DIR_NAME, DAEMON_LOG_NAME)
|
|
795
|
+
// bundle 安置计划(P1 v1.2 §11.1):--no-app 时 appPackaged=false,后续一律
|
|
796
|
+
// 回退裸二进制并如实告警。
|
|
797
|
+
const plan = bundlePlan({
|
|
798
|
+
homeDir: process.env.HOME ?? '',
|
|
799
|
+
buildDir,
|
|
800
|
+
appPackaged: options.app,
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
printStep(`项目根目录: ${rootDir}`)
|
|
804
|
+
|
|
805
|
+
if (!options.skipBuild) {
|
|
806
|
+
printStep('安装 npm 依赖(workspaces: kernel / mcp-shell / installer)……')
|
|
807
|
+
await run('npm', ['install'], { cwd: rootDir })
|
|
808
|
+
|
|
809
|
+
printStep('编译 kernel……')
|
|
810
|
+
await run('npm', ['run', 'build', '--workspace', '@iterate/kernel'], { cwd: rootDir })
|
|
811
|
+
|
|
812
|
+
printStep('编译 mcp-shell……')
|
|
813
|
+
await run('npm', ['run', 'build', '--workspace', 'glasspane-mcp'], { cwd: rootDir })
|
|
814
|
+
|
|
815
|
+
printStep('编译 Swift engine(glasspaned + glasspane-settings,release)……')
|
|
816
|
+
await run('swift', ['build', '-c', 'release'], { cwd: engineDir })
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (options.app) {
|
|
820
|
+
const makeAppScript = path.join(engineDir, 'scripts', 'make-app.sh')
|
|
821
|
+
if (!fs.existsSync(makeAppScript)) {
|
|
822
|
+
throw new Error(`.app 打包脚本缺失:${makeAppScript}(仓库结构不完整)`)
|
|
823
|
+
}
|
|
824
|
+
if (!fs.existsSync(settingsBin)) {
|
|
825
|
+
throw new Error(`设置面板产物不存在:${settingsBin}(可用 --no-app 跳过 .app 打包)`)
|
|
826
|
+
}
|
|
827
|
+
printStep('打包并签名 .app(GlassPane.app + GlassPane Daemon.app)……')
|
|
828
|
+
await run('/bin/bash', [
|
|
829
|
+
makeAppScript,
|
|
830
|
+
'--settings-bin', settingsBin,
|
|
831
|
+
'--daemon-bin', daemonBin,
|
|
832
|
+
'--out', buildDir,
|
|
833
|
+
], { cwd: engineDir })
|
|
834
|
+
// 安置到 ~/Applications:TCC 条目要能被用户在系统设置的「+」选择器与
|
|
835
|
+
// Finder 里找到,隐藏目录 `.build` 里的产物做不到这一点(P1 v1.2 §11.1)。
|
|
836
|
+
const installed = installBundles({ plan })
|
|
837
|
+
for (const app of installed) {
|
|
838
|
+
printStep(`已安置:${app}`)
|
|
839
|
+
}
|
|
840
|
+
if (installed.length === 0) {
|
|
841
|
+
printStep('未安置任何 .app(make-app.sh 未产出 bundle),后续按裸二进制形态运行')
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// daemon 实际启动路径:bundle 优先,裸二进制兜底(两者授权主体不同,
|
|
846
|
+
// 混用会让用户在系统设置里找不到对应条目)。
|
|
847
|
+
const daemonLaunch = daemonLaunchPath({ plan, bareBin: daemonBin })
|
|
848
|
+
if (!daemonLaunch.viaBundle) {
|
|
849
|
+
printStep(
|
|
850
|
+
paint(
|
|
851
|
+
'提醒:daemon 以裸二进制运行——系统设置权限列表里只会显示文件名「glasspaned」,'
|
|
852
|
+
+ '无图标且「+」选择器看不到隐藏目录(去掉 --no-app 重跑可获得带图标的「GlassPane Daemon」)',
|
|
853
|
+
'yellow',
|
|
854
|
+
),
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// 开机自启先于手工启动注册:bootstrap 触发 RunAtLoad 会立即拉起 daemon,
|
|
859
|
+
// 随后手工启动步骤发现 socket 已可达即跳过——避免两个实例抢 socket。
|
|
860
|
+
if (options.launchd) {
|
|
861
|
+
const launchAgentsDir = path.join(process.env.HOME ?? '', ...LAUNCHD_DIR_NAME.split('/'))
|
|
862
|
+
const plistPath = path.join(launchAgentsDir, LAUNCHD_FILE_NAME)
|
|
863
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true })
|
|
864
|
+
if (!fs.existsSync(daemonLaunch.path)) {
|
|
865
|
+
throw new Error(`daemon 产物不存在:${daemonLaunch.path}(开机自启指向的可执行缺失)`)
|
|
866
|
+
}
|
|
867
|
+
const plistXml = launchdPlistString({
|
|
868
|
+
label: LAUNCHD_LABEL,
|
|
869
|
+
daemonBin: daemonLaunch.path,
|
|
870
|
+
socketPath,
|
|
871
|
+
logPath: daemonLog,
|
|
872
|
+
})
|
|
873
|
+
fs.writeFileSync(plistPath, plistXml, { mode: 0o644 })
|
|
874
|
+
const bootstrapResult = launchctlBootstrap(LAUNCHD_LABEL, plistPath, {
|
|
875
|
+
desiredProgram: daemonLaunch.path,
|
|
876
|
+
})
|
|
877
|
+
if (!bootstrapResult.ok) {
|
|
878
|
+
throw new Error(`launchd 注册失败:${bootstrapResult.message}`)
|
|
879
|
+
}
|
|
880
|
+
printStep(bootstrapResult.already ? bootstrapResult.message : `开机自启已注册(${plistPath})`)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (options.daemon) {
|
|
884
|
+
if (!fs.existsSync(daemonLaunch.path)) {
|
|
885
|
+
throw new Error(`daemon 产物不存在:${daemonLaunch.path}(请勿在未构建时使用 --skip-build 启动 daemon)`)
|
|
886
|
+
}
|
|
887
|
+
let relaunchedByLaunchd = false
|
|
888
|
+
const staleDaemons = listRunningPids(DAEMON_EXE_NAME)
|
|
889
|
+
const stalePanels = listRunningPids(PANEL_EXE_NAME)
|
|
890
|
+
if (staleDaemons.length > 0 || stalePanels.length > 0) {
|
|
891
|
+
const summary = `daemon ${staleDaemons.join(',') || '无'}|面板 ${stalePanels.join(',') || '无'}`
|
|
892
|
+
if (options.replaceDaemon) {
|
|
893
|
+
const result = terminatePids([...staleDaemons, ...stalePanels])
|
|
894
|
+
printStep(
|
|
895
|
+
result.forced.length > 0
|
|
896
|
+
? `既有实例已收拢(SIGTERM 未生效、已强杀:${result.forced.join(', ')})`
|
|
897
|
+
: `已结束既有实例:${result.terminated.join(', ')}`,
|
|
898
|
+
)
|
|
899
|
+
// 收拢旧实例后主动让 launchd 接管:TERM 现在是 clean exit 0,KeepAlive
|
|
900
|
+
// (SuccessfulExit=false) 语义下 launchd 不会自动复活,必须 kickstart。
|
|
901
|
+
// 不这么做就会退化成"手动实例持有 socket",开机自启那份配置形同虚设。
|
|
902
|
+
if (staleDaemons.length > 0 && options.launchd) {
|
|
903
|
+
const uid = String(spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout.trim())
|
|
904
|
+
if (uid) {
|
|
905
|
+
spawnSync('launchctl', launchdKickstartArgs({ label: LAUNCHD_LABEL, uid }))
|
|
906
|
+
relaunchedByLaunchd = await waitForSocket(socketPath)
|
|
907
|
+
printStep(relaunchedByLaunchd
|
|
908
|
+
? 'launchd 已按新配置接管 daemon(kickstart)'
|
|
909
|
+
: 'launchd 未在时限内接管,改由安装器直接启动')
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
} else {
|
|
913
|
+
printStep(
|
|
914
|
+
paint(
|
|
915
|
+
`检测到旧实例在跑(${summary}):多实例会让系统设置里的授权条目与实际服务实例对不上;`
|
|
916
|
+
+ '加 --replace-daemon 可一并收拢',
|
|
917
|
+
'yellow',
|
|
918
|
+
),
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (relaunchedByLaunchd || await socketReachable(socketPath)) {
|
|
923
|
+
printStep(`daemon 已在监听 ${socketPath},跳过启动(若为旧构建实例,用 --replace-daemon 重跑)`)
|
|
924
|
+
} else {
|
|
925
|
+
const start = daemonStartCommand({ daemonLaunch, daemonApp: plan.daemonApp, socketPath })
|
|
926
|
+
printStep(`启动 glasspaned(${daemonLaunch.viaBundle ? 'bundle 形态,经 open 登记主进程' : '裸二进制形态'},日志: ${daemonLog})……`)
|
|
927
|
+
const pid = startDetached(start.launchPath, start.args, daemonLog)
|
|
928
|
+
printStep(`glasspaned 启动命令已发出,PID ${pid}`)
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
let guiOpened = false
|
|
933
|
+
if (options.gui) {
|
|
934
|
+
const guiLaunch = settingsLaunchPath({ plan, bareBin: settingsBin })
|
|
935
|
+
if (!fs.existsSync(guiLaunch.path)) {
|
|
936
|
+
throw new Error(`设置面板产物不存在:${guiLaunch.path}`)
|
|
937
|
+
}
|
|
938
|
+
const guiStart = guiStartCommand({ guiLaunch, settingsApp: plan.settingsApp })
|
|
939
|
+
printStep(`打开设置面板(${guiLaunch.viaBundle ? 'GlassPane.app,经 open 登记主进程' : '裸二进制'},权限引导)……`)
|
|
940
|
+
startDetached(guiStart.launchPath, guiStart.args, daemonLog)
|
|
941
|
+
guiOpened = true
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
printStep('安装完成。')
|
|
945
|
+
process.stdout.write(`\n${nextStepsText({
|
|
946
|
+
rootDir,
|
|
947
|
+
socketPath,
|
|
948
|
+
guiOpened,
|
|
949
|
+
daemon: daemonLaunch,
|
|
950
|
+
settingsApp: plan.settingsApp ?? undefined,
|
|
951
|
+
})}\n`)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** 入口守卫:直接用 node 运行本文件时才走安装流程(测试导入仅取函数)。 */
|
|
955
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
956
|
+
|
|
957
|
+
if (isMain) {
|
|
958
|
+
const { options, error } = parseArgs(process.argv.slice(2))
|
|
959
|
+
if (error) {
|
|
960
|
+
process.stderr.write(`\n${paint(`参数错误:${error}`, 'red')}\n\n`)
|
|
961
|
+
process.stderr.write(usageText())
|
|
962
|
+
process.exit(2)
|
|
963
|
+
}
|
|
964
|
+
if (options.help) {
|
|
965
|
+
process.stdout.write(usageText())
|
|
966
|
+
process.exit(0)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (options.restoreLaunchd) {
|
|
970
|
+
// 审计项④:恢复 + 校验全机器化,退出码即判定(0=daemon 就位且已按实际
|
|
971
|
+
// 自报如实报告;1=作业/服务面失败),agent 可直接执行并按 message 续办。
|
|
972
|
+
const result = await restoreLaunchd()
|
|
973
|
+
process.stdout.write(`${result.message}\n`)
|
|
974
|
+
process.exit(result.ok ? 0 : 1)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const env = checkEnvironment()
|
|
978
|
+
const issues = preflightIssues(env)
|
|
979
|
+
if (issues.length > 0) {
|
|
980
|
+
process.stderr.write(paint('\n环境预检未通过:\n', 'red'))
|
|
981
|
+
for (const issue of issues) {
|
|
982
|
+
process.stderr.write(` - ${issue}\n`)
|
|
983
|
+
}
|
|
984
|
+
process.stderr.write('\n修复后重新运行即可。\n')
|
|
985
|
+
process.exit(1)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
install({ options })
|
|
989
|
+
.then(() => {
|
|
990
|
+
process.stdout.write('\n')
|
|
991
|
+
})
|
|
992
|
+
.catch((installError) => {
|
|
993
|
+
process.stderr.write(paint(`\n安装失败:${installError.message}\n`, 'red'))
|
|
994
|
+
process.exit(1)
|
|
995
|
+
})
|
|
996
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "glasspane-install",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "GlassPane one-command installer: terminal onboarding → install deps → build TS + Swift engine → start daemon → open settings GUI for permission guide",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "jingzhao-l",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jingzhao-l/GlassPane.git",
|
|
11
|
+
"directory": "installer"
|
|
12
|
+
},
|
|
13
|
+
"bugs": { "url": "https://github.com/jingzhao-l/GlassPane/issues" },
|
|
14
|
+
"homepage": "https://github.com/jingzhao-l/GlassPane/tree/main/installer#readme",
|
|
15
|
+
"keywords": ["macos", "installer", "glasspane", "cli"],
|
|
16
|
+
"main": "./cli.js",
|
|
17
|
+
"bin": {
|
|
18
|
+
"glasspane-install": "./cli.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"cli.js",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test test/*.test.mjs"
|
|
30
|
+
}
|
|
31
|
+
}
|