glasspane-install 1.21.0 → 1.23.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/README.md +36 -5
- package/cli.js +222 -21
- package/glasspane.js +370 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -10,14 +10,15 @@ macOS 14+.
|
|
|
10
10
|
## Channels / 安装渠道
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
# 1)
|
|
13
|
+
# 1) This package via npx (recommended) — also clones the pinned release tag when no local
|
|
14
|
+
# repo is found, then runs the same flow. It is also the only channel that leaves the
|
|
15
|
+
# `glasspane` command on PATH; `npm install -g glasspane-install` keeps it there permanently.
|
|
16
|
+
npx glasspane-install
|
|
17
|
+
|
|
18
|
+
# 2) Fetch, then run: install.sh clones the pinned release tag. Two steps on
|
|
14
19
|
# purpose — piped into `sh`, a curl that fails leaves stdin empty and the pipeline exits 0.
|
|
15
20
|
curl -fsSL https://raw.githubusercontent.com/jingzhao-l/GlassPane/main/install.sh -o /tmp/gp-install.sh && sh /tmp/gp-install.sh
|
|
16
21
|
|
|
17
|
-
# 2) This package via npx — also clones the pinned release tag when no local
|
|
18
|
-
# repo is found, then runs the same flow
|
|
19
|
-
npx glasspane-install
|
|
20
|
-
|
|
21
22
|
# 3) From an existing checkout
|
|
22
23
|
node installer/cli.js
|
|
23
24
|
```
|
|
@@ -27,9 +28,39 @@ channels clone the **same pinned tag** — never a moving branch — so a fresh
|
|
|
27
28
|
is reproducible. Override with `GLASSPANE_REF=<tag>` (`main` is available but is not
|
|
28
29
|
a fixed artifact).
|
|
29
30
|
|
|
31
|
+
An existing clone at the install directory is **advanced**, not ignored and not
|
|
32
|
+
clobbered: if `git status --porcelain` is empty the installer fetches the pinned tag
|
|
33
|
+
and `checkout --detach`s onto it; if it is dirty it refuses, names the files in the
|
|
34
|
+
way, and offers a different `GLASSPANE_INSTALL_DIR`. No `git stash`, no `checkout -f`,
|
|
35
|
+
no `clean` — an installer has no business destroying somebody's uncommitted work
|
|
36
|
+
(that refusal is what replaced the 2026-10-26 shape, where one channel printed "the
|
|
37
|
+
existing clone will not be switched" and then built from a three-versions-old tree
|
|
38
|
+
while the other channel died on git's "already exists and is not an empty directory").
|
|
39
|
+
|
|
30
40
|
三条渠道最终都走同一份 `installer/cli.js`,且两条一键渠道 clone 的是同一个**钉住的发布 tag**(不是移动的分支),
|
|
31
41
|
所以同一天两次安装拿到的是同一份源码。
|
|
32
42
|
|
|
43
|
+
安装目录里已经有一棵 clone 时,这一趟**把它前进**到钉住的那个 tag,而不是照用旧树、也不是删掉重来:
|
|
44
|
+
`git status --porcelain` 是空的就 fetch + `checkout --detach`;不空就**拒绝**,点名挡路的那些文件,
|
|
45
|
+
并给出换一个 `GLASSPANE_INSTALL_DIR` 的走法。没有 `git stash`、没有 `checkout -f`、没有 `clean`——
|
|
46
|
+
安装器不该有能把别人的未提交工作抹掉的那一格。
|
|
47
|
+
|
|
48
|
+
## The `glasspane` command / `glasspane` 这条命令
|
|
49
|
+
|
|
50
|
+
这个包同时交付 `glasspane`(`bin` 里两个名字,见 `package.json`),回答的是"面板按钮失败了,
|
|
51
|
+
而我不知道自己装到了哪里":
|
|
52
|
+
|
|
53
|
+
| 命令 | 做什么 | 退出码 |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `glasspane doctor` | 只读这台机器:状态根、安装指针、更新器状态、在册 daemon 作业、socket、两个全局 npm 包;每格答 available / absent / unverified | 0 全在位;3 有格子不在位或答不了 |
|
|
56
|
+
| `glasspane update` | 先 `check`,再装通过校验的那一份;检查停住就不进安装 | 沿用更新器自己的那一档(0/3/4/5) |
|
|
57
|
+
| `glasspane status` | 把更新器那一行结论原样打出来 | 同上 |
|
|
58
|
+
|
|
59
|
+
它只按 `~/.glasspane/update-install.json` 找那份更新器(安装器写的、面板读的同一份),不从当前目录猜;
|
|
60
|
+
不 sudo、不删东西,需要人做的那一步只点名工具与目录。npm 那一格的补法给的是
|
|
61
|
+
`npm install -g glasspane-mcp@<版本> glasspane-install@<版本>`——那取的是 registry 的发布物,
|
|
62
|
+
与更新器从已校验暂存树里现场打包的那一份是两种来源,这句话也一起说。
|
|
63
|
+
|
|
33
64
|
## Flags / 选项
|
|
34
65
|
|
|
35
66
|
| 选项 | 作用 |
|
package/cli.js
CHANGED
|
@@ -70,7 +70,7 @@ export const INSTALL_SH_URL =
|
|
|
70
70
|
/** 发布线锚点(版本真源 = 根 package.json,由 scripts/set-version.mjs 统一改写,
|
|
71
71
|
* 勿手改):npx 形态下本地没有仓库时,引导 clone 的就是这个 tag,与 install.sh
|
|
72
72
|
* 的 `GLASSPANE_RELEASE` 同值——两条一键入口必须拿到同一份源码。 */
|
|
73
|
-
export const RELEASE_VERSION = '1.
|
|
73
|
+
export const RELEASE_VERSION = '1.23.0'
|
|
74
74
|
/** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
|
|
75
75
|
export const REPO_REF = `v${RELEASE_VERSION}`
|
|
76
76
|
|
|
@@ -2304,6 +2304,8 @@ export function bootstrapPlan({
|
|
|
2304
2304
|
repoUrl = REPO_URL,
|
|
2305
2305
|
ref = REPO_REF,
|
|
2306
2306
|
installDir = null,
|
|
2307
|
+
// 只读探针:注入它,这条纯函数才能被"目录已经存在且是一棵 GlassPane 仓库"这一支单测覆盖。
|
|
2308
|
+
readCloneState = null,
|
|
2307
2309
|
} = {}) {
|
|
2308
2310
|
if (!homeDir) {
|
|
2309
2311
|
return { error: '无法确定用户主目录(HOME 为空),不知道把源码 clone 到哪里' }
|
|
@@ -2314,6 +2316,18 @@ export function bootstrapPlan({
|
|
|
2314
2316
|
error: `目标目录已存在且不是 GlassPane 仓库:${targetDir}(用 --repo 指定仓库,或清理该目录后重试)`,
|
|
2315
2317
|
}
|
|
2316
2318
|
}
|
|
2319
|
+
// 目录存在**且是一棵 GlassPane 仓库**:这一支从前直接往下走 `git clone`,于是产品自己的安装位置
|
|
2320
|
+
// (`~/glasspane`)里那份旧 clone 换来的是 git 的 "destination path … already exists and is not an
|
|
2321
|
+
// empty directory"——读者按提示"换个目录"重跑,下一次又落在同一个地方。现在它前进那棵树,或说清
|
|
2322
|
+
// 为什么不动它(判据见 `existingCloneDecision`)。
|
|
2323
|
+
if (fs.existsSync(targetDir) && typeof readCloneState === 'function') {
|
|
2324
|
+
const state = readCloneState({ targetDir, ref })
|
|
2325
|
+
const decision = existingCloneDecision({ targetDir, ref, ...state })
|
|
2326
|
+
if (decision.action !== 'advance') {
|
|
2327
|
+
return { use: decision.action === 'use', targetDir, refuse: decision.action === 'refuse' ? decision : null, decision }
|
|
2328
|
+
}
|
|
2329
|
+
return { advance: { targetDir, ref, decision } }
|
|
2330
|
+
}
|
|
2317
2331
|
// 三个值都会进 git argv:ref 来自 GLASSPANE_REF、目录来自 GLASSPANE_INSTALL_DIR,
|
|
2318
2332
|
// 都是文档里写明的覆盖口子。判据与 install.sh 的 assert_ref/assert_repo_url 同源。
|
|
2319
2333
|
const clone = safeGitCloneArgs({ repoUrl, ref, targetDir })
|
|
@@ -2325,6 +2339,174 @@ export function bootstrapPlan({
|
|
|
2325
2339
|
}
|
|
2326
2340
|
}
|
|
2327
2341
|
|
|
2342
|
+
/**
|
|
2343
|
+
* 既有 clone 的结论(纯判定,可单测)。
|
|
2344
|
+
*
|
|
2345
|
+
* 为什么这一格不能像以前那样"提示一句就照用":`~/glasspane` 是产品自己的安装位置,`install.sh` 与
|
|
2346
|
+
* `npx glasspane-install` 都往那里 clone。上一版在这里只打印"既有 clone 不会被自动切到 v1.21.0",
|
|
2347
|
+
* 然后**照用那棵树**去构建——2026-10-26 这台机器上那是 v1.5.0 的 clone,于是"一键安装"把一个 1.5.0
|
|
2348
|
+
* 的产物当成新装交出去,而 `npx` 那条路更糟:它直接对已存在目录跑 `git clone`,被 git 以
|
|
2349
|
+
* "destination path … already exists and is not an empty directory" 拒掉,读者只剩"换目录"这一条死路。
|
|
2350
|
+
*
|
|
2351
|
+
* 判据按那个人 2026-10-26 选的形状:
|
|
2352
|
+
* · 干净(`git status --porcelain` 空)且 HEAD 已经是那个 tag ⇒ 就用;
|
|
2353
|
+
* · 干净但不是那个 tag ⇒ 前进(fetch + detached checkout —— 这是移动**产品自己的 clone**,
|
|
2354
|
+
* 不是移动任何人的工作树);
|
|
2355
|
+
* · 脏 ⇒ 拒绝,点名前 10 个挡路的路径,并给一条不销毁任何东西的出路(换一个空目录);
|
|
2356
|
+
* · HEAD 读不出 ⇒ 拒绝。"判不出来"永远不许变成"那就照用/那就切过去"。
|
|
2357
|
+
*
|
|
2358
|
+
* **"该 tag 本地读不出"不是一条拒绝**,2026-10-27 在本机被实测推翻的正是这一格。旧写法把它和
|
|
2359
|
+
* "HEAD 读不出"并成同一句"判不出来就拒",于是 `~/glasspane`(v1.5.0 的 clone,本地 tag 只到
|
|
2360
|
+
* v1.2.0)在 npm 那条入口上**永远**走到 refuse:`bootstrapPlan` 先问判据、判据先拒,而那一步
|
|
2361
|
+
* 之后**会 fetch 这个 tag 的代码(`advanceExistingClone`)根本轮不到执行**。`install.sh` 那一支
|
|
2362
|
+
* 是对的(先 fetch 再判),两条一键入口对同一棵树给出两种答案——finding 10 那族的老形状。
|
|
2363
|
+
* 现在 tag 缺席归到"前进"里:fetch 不成仍由 `advanceExistingClone` 拒,并交出 git 的原话;
|
|
2364
|
+
* HEAD 读不出照旧是拒。前者是能补的事实缺口,后者是判据本身没了。
|
|
2365
|
+
*/
|
|
2366
|
+
export function existingCloneDecision({ targetDir, ref, head = null, tagCommit = null, blocking = [] }) {
|
|
2367
|
+
const dirty = blocking.map((line) => String(line).trim()).filter((line) => line !== '')
|
|
2368
|
+
if (dirty.length > 0) {
|
|
2369
|
+
const shown = dirty.slice(0, 10)
|
|
2370
|
+
return {
|
|
2371
|
+
action: 'refuse',
|
|
2372
|
+
blocking: dirty,
|
|
2373
|
+
text: `${targetDir} 有 ${dirty.length} 处未提交改动,安装器不会切走它(切走会把别人的工作当成构建源码):\n`
|
|
2374
|
+
+ shown.map((line) => ` ${line}`).join('\n')
|
|
2375
|
+
+ (dirty.length > shown.length ? `\n …另有 ${dirty.length - shown.length} 处` : '')
|
|
2376
|
+
+ `\n下一步(不销毁任何东西,两条都做得动):`
|
|
2377
|
+
+ `\n a) 换一处干净的家再跑一键安装:GLASSPANE_INSTALL_DIR=~/glasspane-${String(ref).replace(/[^A-Za-z0-9._+-]/g, '_')} `
|
|
2378
|
+
+ `sh install.sh(或 npx glasspane-install);`
|
|
2379
|
+
+ `\n b) 自己收着这些改动:在 ${targetDir} 里把它们提交或转移到别处,再重跑本命令——`
|
|
2380
|
+
+ '安装器不替你决定这些文件去哪。',
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
if (!head) {
|
|
2384
|
+
return {
|
|
2385
|
+
action: 'refuse',
|
|
2386
|
+
blocking: [],
|
|
2387
|
+
text: `${targetDir} 读不出 HEAD(不是 git 仓库、那目录没有一次提交,或 git 本身答不了):`
|
|
2388
|
+
+ '连"这棵树在哪"都判不出来,就不动它,也不装它。'
|
|
2389
|
+
+ `\n下一步:确认 ${targetDir} 是一棵 git 仓库(git -C "${targetDir}" rev-parse HEAD 能不能答话);`
|
|
2390
|
+
+ '要一份全新的 clone 就 GLASSPANE_INSTALL_DIR=<空目录> 重跑一键安装。',
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
if (tagCommit && head === tagCommit) {
|
|
2394
|
+
return { action: 'use', blocking: [], head, text: `${targetDir} 已经在 ${ref}(${String(head).slice(0, 7)}),直接用它构建。` }
|
|
2395
|
+
}
|
|
2396
|
+
if (!tagCommit) {
|
|
2397
|
+
return {
|
|
2398
|
+
action: 'advance',
|
|
2399
|
+
blocking: [],
|
|
2400
|
+
head,
|
|
2401
|
+
tagCommit: null,
|
|
2402
|
+
needsFetch: true,
|
|
2403
|
+
text: `${targetDir} 是干净的 clone(HEAD 在 ${String(head).slice(0, 7)}),但本地没有 ${ref} 这个 tag`
|
|
2404
|
+
+ '(旧 clone 的 tag 表停在更早的一版,浅历史也会这样):前进那一步先 fetch 它再切。'
|
|
2405
|
+
+ `fetch 不成就是拒——${targetDir} 原地不动,本轮不构建。`,
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
return {
|
|
2409
|
+
action: 'advance',
|
|
2410
|
+
blocking: [],
|
|
2411
|
+
head,
|
|
2412
|
+
tagCommit,
|
|
2413
|
+
text: `${targetDir} 是干净的 clone,HEAD 在 ${String(head).slice(0, 7)} 而要装的是 ${ref}(${String(tagCommit).slice(0, 7)})——`
|
|
2414
|
+
+ '把它前进到那一个 tag 再构建。',
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
/**
|
|
2419
|
+
* 一棵既有 clone 的四份事实(IO 读,不判定):HEAD 指向的 commit、`ref^{commit}`、
|
|
2420
|
+
* `git status --porcelain` 的行,以及这份 clone 是不是浅仓。判据一律在 `existingCloneDecision`,
|
|
2421
|
+
* 两处调用(bootstrap 的纯计划、前进这一步的复判)共用同一份读数,避免出现第二个"什么算脏"的说法。
|
|
2422
|
+
*
|
|
2423
|
+
* `shallow` 是给 fetch 挑形状的:`--depth 1` 会把一棵**完整**的 clone 变成浅仓(`.git/shallow`
|
|
2424
|
+
* 一旦落下,之后的遍历都撞上人为边界,要恢复得 `git fetch --unshallow`)。旧写法对任何树都带
|
|
2425
|
+
* `--depth 1`,而注释写的是"只往 .git 里加对象"——那句话对完整 clone 不成立,它加的是一个边界。
|
|
2426
|
+
* 只有 git 亲口答 `true` 才用 `--depth 1`;答不出来(老 git、不是仓库)按完整仓对待,宁可多取
|
|
2427
|
+
* 一点也不给人家的历史加边界。
|
|
2428
|
+
*/
|
|
2429
|
+
export function readCloneFacts({ targetDir, ref, spawn = spawnSync } = {}) {
|
|
2430
|
+
const read = (args) => spawn('git', ['-C', targetDir, ...args], { encoding: 'utf8' })
|
|
2431
|
+
const sha = (what) => {
|
|
2432
|
+
const res = read(['rev-parse', '--verify', what])
|
|
2433
|
+
return res.status === 0 ? String(res.stdout ?? '').trim() : null
|
|
2434
|
+
}
|
|
2435
|
+
const status = read(['status', '--porcelain'])
|
|
2436
|
+
const shallow = read(['rev-parse', '--is-shallow-repository'])
|
|
2437
|
+
return {
|
|
2438
|
+
head: sha('HEAD'),
|
|
2439
|
+
tagCommit: sha(`${ref}^{commit}`),
|
|
2440
|
+
// `git status` 答非零时这里得到空数组,读起来是"干净"——那棵树随后会因为 `head` 读不出而被拒,
|
|
2441
|
+
// 所以"判不出来"走的是拒绝那一支,不是"那就切过去"。
|
|
2442
|
+
blocking: status.status === 0 ? String(status.stdout ?? '').split('\n').filter((line) => line !== '') : [],
|
|
2443
|
+
shallow: shallow.status === 0 ? String(shallow.stdout ?? '').trim() === 'true' : null,
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/**
|
|
2448
|
+
* 把既有 clone 前进到钉住的 ref(IO 步;判据全在 `existingCloneDecision` 里)。
|
|
2449
|
+
*
|
|
2450
|
+
* 只有这几种 git 动作:`status --porcelain`、两次 `rev-parse --verify`、`rev-parse
|
|
2451
|
+
* --is-shallow-repository`(全是读),`fetch --tags [origin <ref>]`(只往 .git 里加对象;
|
|
2452
|
+
* 带不带 `--depth 1` 由 git 自己答的那句"是不是浅仓"决定,见 `readCloneFacts`),以及
|
|
2453
|
+
* `checkout --detach <ref>`(只在树干净时执行)。没有 `-f`、没有 `stash`、没有 `clean`、
|
|
2454
|
+
* 没有 `reset --hard`:一个 installer 不该有能把别人的未提交工作抹掉的那一格。
|
|
2455
|
+
*/
|
|
2456
|
+
export function advanceExistingClone({ repoDir, ref, spawn = spawnSync } = {}) {
|
|
2457
|
+
const read = (args) => spawn('git', ['-C', repoDir, ...args], { encoding: 'utf8' })
|
|
2458
|
+
let facts = readCloneFacts({ targetDir: repoDir, ref, spawn })
|
|
2459
|
+
if (facts.blocking.length === 0 && facts.head && !facts.tagCommit) {
|
|
2460
|
+
// tag 不在这份历史里(旧 clone / 浅 clone 都会这样):先把它取回来,再判。fetch 失败不改任何东西。
|
|
2461
|
+
const fetchArgs = facts.shallow === true
|
|
2462
|
+
? ['fetch', '--tags', '--depth', '1', 'origin', ref]
|
|
2463
|
+
: ['fetch', '--tags', 'origin', ref]
|
|
2464
|
+
const fetched = read(fetchArgs)
|
|
2465
|
+
if (fetched.status !== 0) {
|
|
2466
|
+
return {
|
|
2467
|
+
action: 'refuse',
|
|
2468
|
+
blocking: [],
|
|
2469
|
+
fetchArgs,
|
|
2470
|
+
text: `${repoDir} 里没有 ${ref} 这个 tag,而 `
|
|
2471
|
+
+ `git -C "${repoDir}" ${fetchArgs.join(' ')} 退出 ${String(fetched.status ?? 'null')}:`
|
|
2472
|
+
+ `${String(fetched.stderr ?? '').trim().split('\n')[0] || '(git 没有留下 stderr)'}。\n`
|
|
2473
|
+
+ '没有 clone、没有切分支、没有写入任何东西。下一步:确认网络与该 tag 存在('
|
|
2474
|
+
+ `git ls-remote --tags origin ${ref}),或 GLASSPANE_INSTALL_DIR=<空目录> 换一处全新的 clone。`,
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
facts = readCloneFacts({ targetDir: repoDir, ref, spawn })
|
|
2478
|
+
}
|
|
2479
|
+
let decision = existingCloneDecision({ targetDir: repoDir, ref, ...facts })
|
|
2480
|
+
if (decision.action !== 'advance') return decision
|
|
2481
|
+
const out = read(['checkout', '--detach', ref])
|
|
2482
|
+
if (out.status !== 0) {
|
|
2483
|
+
return {
|
|
2484
|
+
action: 'refuse',
|
|
2485
|
+
blocking: readCloneFacts({ targetDir: repoDir, ref, spawn }).blocking,
|
|
2486
|
+
text: `切到 ${ref} 没成(git checkout --detach 退出 ${String(out.status ?? 'null')}):`
|
|
2487
|
+
+ `${String(out.stderr ?? '').trim().split('\n')[0] || '(git 没有留下 stderr)'}。\n`
|
|
2488
|
+
+ `${repoDir} 仍在 ${String(facts.head ?? '读不出').slice(0, 7)},本轮没有构建任何东西。`,
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
// 切完再读一次:判据要落在**切换之后**的事实上,不是落在命令的退出码上。
|
|
2492
|
+
const after = readCloneFacts({ targetDir: repoDir, ref, spawn })
|
|
2493
|
+
const recheck = existingCloneDecision({ targetDir: repoDir, ref, ...after })
|
|
2494
|
+
if (recheck.action !== 'use') {
|
|
2495
|
+
return {
|
|
2496
|
+
action: 'refuse',
|
|
2497
|
+
blocking: after.blocking,
|
|
2498
|
+
text: `切完之后 ${repoDir} 还不是 ${ref}(HEAD=${String(after.head ?? '读不出').slice(0, 7)},`
|
|
2499
|
+
+ `tag=${String(after.tagCommit ?? '读不出').slice(0, 7)},另有 ${after.blocking.length} 处改动):本轮不拿它构建。`,
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
return {
|
|
2503
|
+
...recheck,
|
|
2504
|
+
advanced: true,
|
|
2505
|
+
from: facts.head,
|
|
2506
|
+
text: `${repoDir} 从 ${String(facts.head ?? '读不出').slice(0, 7)} 前进到 ${ref}(${String(after.head).slice(0, 7)}),用它构建。`,
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2328
2510
|
/** 未定位到仓库时的指引文本(纯函数,可单测)。 */
|
|
2329
2511
|
export function repoMissingText() {
|
|
2330
2512
|
return [
|
|
@@ -2531,31 +2713,50 @@ export async function install({
|
|
|
2531
2713
|
installDir: env.GLASSPANE_INSTALL_DIR ?? null,
|
|
2532
2714
|
repoUrl: env.GLASSPANE_REPO_URL ?? REPO_URL,
|
|
2533
2715
|
ref: pinRef,
|
|
2716
|
+
readCloneState: ({ targetDir, ref }) => readCloneFacts({ targetDir, ref }),
|
|
2534
2717
|
})
|
|
2535
2718
|
if (plan.error) {
|
|
2536
2719
|
throw new Error(`${plan.error}\n\n${repoMissingText()}`)
|
|
2537
2720
|
}
|
|
2538
|
-
|
|
2539
|
-
//
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
}
|
|
2555
|
-
|
|
2556
|
-
|
|
2721
|
+
// 目录里已经有一棵 GlassPane 仓库:要么用它(已经在钉的那个 tag 上),要么前进它,要么说清为什么
|
|
2722
|
+
// 不动它。三条都是**结论**,没有一条是"那就 clone 试试,让 git 去报错"。
|
|
2723
|
+
if (plan.refuse) {
|
|
2724
|
+
throw new Error(`${plan.refuse.text}\n\n${repoMissingText()}`)
|
|
2725
|
+
}
|
|
2726
|
+
if (plan.use) {
|
|
2727
|
+
printStep(plan.decision.text)
|
|
2728
|
+
rootDir = plan.targetDir
|
|
2729
|
+
} else if (plan.advance) {
|
|
2730
|
+
printStep(plan.advance.decision.text)
|
|
2731
|
+
const advanced = advanceExistingClone({ repoDir: plan.advance.targetDir, ref: plan.advance.ref })
|
|
2732
|
+
if (advanced.action !== 'use') {
|
|
2733
|
+
throw new Error(`${advanced.text}\n\n${repoMissingText()}`)
|
|
2734
|
+
}
|
|
2735
|
+
printStep(advanced.text)
|
|
2736
|
+
rootDir = advanced.targetDir ?? plan.advance.targetDir
|
|
2737
|
+
} else {
|
|
2738
|
+
printStep(`未定位到本地仓库,引导 clone:${plan.targetDir}(ref: ${plan.ref})……`)
|
|
2739
|
+
// stderr 要留着归因(finding 9):任何非零都算"网络不可达或 tag 不存在"会把
|
|
2740
|
+
// "目标目录已存在且非空"这类本地故障说成网络问题,读者据此一直重试同一条死路。
|
|
2741
|
+
const clone = spawnSync('git', plan.gitArgs, {
|
|
2742
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
2743
|
+
encoding: 'utf8',
|
|
2744
|
+
})
|
|
2745
|
+
if (clone.status !== 0) {
|
|
2746
|
+
throw new Error(
|
|
2747
|
+
`${cloneFailureText({
|
|
2748
|
+
ref: plan.ref,
|
|
2749
|
+
status: clone.status,
|
|
2750
|
+
stderr: clone.stderr ?? clone.error?.message ?? '',
|
|
2751
|
+
targetDir: plan.targetDir,
|
|
2752
|
+
})}\n\n${repoMissingText()}`,
|
|
2753
|
+
)
|
|
2754
|
+
}
|
|
2755
|
+
if (resolveProjectRoot(plan.targetDir) === null) {
|
|
2756
|
+
throw new Error(`clone 完成但目录结构不完整:${plan.targetDir}\n\n${repoMissingText()}`)
|
|
2757
|
+
}
|
|
2758
|
+
rootDir = plan.targetDir
|
|
2557
2759
|
}
|
|
2558
|
-
rootDir = plan.targetDir
|
|
2559
2760
|
}
|
|
2560
2761
|
|
|
2561
2762
|
// 发布 tag GPG 验签:先过"这次到底验不验"的门(形状/存在性),再判动作,两条判据都是纯函数。
|
package/glasspane.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `glasspane` — the one command an installed machine should need.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists. Until 2026-10-26 the machine had two ways to update itself and neither was a
|
|
6
|
+
* command a person would remember: the settings panel's button (which runs the updater as the *GUI's*
|
|
7
|
+
* child, and whose failure this package could not distinguish from the daemon's refusal to be restarted),
|
|
8
|
+
* and `node <那个 checkout>/updater/cli.js apply` (which needs the install root, which is exactly the
|
|
9
|
+
* thing people lose track of). The other complaint measured the same day: `npx glasspane-install` re-ran
|
|
10
|
+
* `git clone` into `~/glasspane`, git said "destination path … already exists", and the reader was told
|
|
11
|
+
* to change directory — forever, because the next run would look in the same place.
|
|
12
|
+
*
|
|
13
|
+
* So this is a *thin* bin, added to the same package that already owns the one-command install (adding a
|
|
14
|
+
* third bare-name package would be a third thing to version, sign and keep in step):
|
|
15
|
+
*
|
|
16
|
+
* glasspane update ask for an update and run it, through the updater this machine is already
|
|
17
|
+
* installed with — never through a copy guessed from the current directory
|
|
18
|
+
* glasspane doctor read the machine: install pointer, updater state, the loaded daemon job, the
|
|
19
|
+
* socket, and the two global npm packages. Reads only; every line answers
|
|
20
|
+
* available / absent / unverified, because "I could not look" is not "there is
|
|
21
|
+
* nothing there" (`installer/cli.js`'s PROBE_STATES is the same rule)
|
|
22
|
+
* glasspane status the updater's own one-line summary, verbatim
|
|
23
|
+
*
|
|
24
|
+
* What it deliberately does not do: no `sudo`, no npm escalation, no deleting anything. Where a repair
|
|
25
|
+
* needs a person, it prints the tool and the two directories involved, the same way the updater's remedy
|
|
26
|
+
* sentences do (SECURITY.md §3: a remedy may name a tool, it may not hand out an executable escalation).
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import os from 'node:os'
|
|
31
|
+
import process from 'node:process'
|
|
32
|
+
import { spawnSync } from 'node:child_process'
|
|
33
|
+
|
|
34
|
+
export const VERBS = Object.freeze(['update', 'doctor', 'status', 'help'])
|
|
35
|
+
/** 用法里的同义写法:读不出"同一条命令的两种问法"就没有可比性,所以别名是一张明面上的表。 */
|
|
36
|
+
export const VERB_ALIASES = Object.freeze({ '-h': 'help', '--help': 'help' })
|
|
37
|
+
export const DAEMON_JOB_LABEL = 'com.glasspane.daemon'
|
|
38
|
+
export const NPM_PACKAGES = Object.freeze(['glasspane-mcp', 'glasspane-install'])
|
|
39
|
+
/** The three answers a read may give. `unverified` is the one that used to be missing everywhere. */
|
|
40
|
+
export const READS = Object.freeze({ available: 'available', absent: 'absent', unverified: 'unverified' })
|
|
41
|
+
|
|
42
|
+
const POINTER_NAME = 'update-install.json'
|
|
43
|
+
const STATE_NAME = 'update-state.json'
|
|
44
|
+
|
|
45
|
+
/** Where this machine keeps GlassPane's state, and who said so. */
|
|
46
|
+
export function stateRootOf({ env = process.env, homeDir = null } = {}) {
|
|
47
|
+
const named = String(env.GLASSPANE_STATE_DIR ?? '').trim()
|
|
48
|
+
if (named !== '') return { stateRoot: path.resolve(named), source: 'GLASSPANE_STATE_DIR' }
|
|
49
|
+
const home = String(homeDir ?? (typeof env.HOME === 'string' && env.HOME !== '' ? env.HOME : safeHomedir()) ?? '').trim()
|
|
50
|
+
if (home === '') return { stateRoot: null, source: '读不到家目录(HOME 未设置,os.homedir() 也没答)' }
|
|
51
|
+
return { stateRoot: path.join(home, '.glasspane'), source: '默认(<HOME>/.glasspane)' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safeHomedir() {
|
|
55
|
+
try {
|
|
56
|
+
return os.homedir()
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The install pointer: the one file that says which updater this machine is installed with. Three states,
|
|
64
|
+
* because the remedy differs: absent (never installed / a state root that isn't ours), unreadable (a file
|
|
65
|
+
* that exists and does not parse — a person's problem, not a re-install's), and a pointer whose
|
|
66
|
+
* `updaterCli` no longer exists on disk (a moved or deleted checkout — §11's whole reason the pointer
|
|
67
|
+
* exists, and the case where "run the installer again" is the honest answer).
|
|
68
|
+
*/
|
|
69
|
+
export function readInstallPointer({ stateRoot, readFile = (p) => fs.readFileSync(p, 'utf8'), exists = (p) => fs.existsSync(p) } = {}) {
|
|
70
|
+
if (!stateRoot) return { read: READS.unverified, reason: '没有状态根可读', pointerPath: null }
|
|
71
|
+
const pointerPath = path.join(stateRoot, POINTER_NAME)
|
|
72
|
+
let text = null
|
|
73
|
+
try {
|
|
74
|
+
text = readFile(pointerPath)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const missing = error?.code === 'ENOENT'
|
|
77
|
+
return {
|
|
78
|
+
read: missing ? READS.absent : READS.unverified,
|
|
79
|
+
reason: missing ? `${pointerPath} 不存在` : `${pointerPath} 读不出来(${error?.message ?? String(error)})`,
|
|
80
|
+
pointerPath,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let pointer = null
|
|
84
|
+
try {
|
|
85
|
+
pointer = JSON.parse(text)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return { read: READS.unverified, reason: `${pointerPath} 不是合法 JSON(${error.message})`, pointerPath }
|
|
88
|
+
}
|
|
89
|
+
const updaterCli = typeof pointer?.updaterCli === 'string' && pointer.updaterCli !== '' ? pointer.updaterCli : null
|
|
90
|
+
if (updaterCli === null) return { read: READS.unverified, reason: `${pointerPath} 里没有 updaterCli 这一项`, pointerPath, pointer }
|
|
91
|
+
if (!exists(updaterCli)) {
|
|
92
|
+
return {
|
|
93
|
+
read: READS.absent,
|
|
94
|
+
reason: `指针指向的更新脚本不在盘上:${updaterCli}(那份 checkout 被移动或删除了)`,
|
|
95
|
+
remedy: '重跑一次安装程序(node installer/cli.js 或 curl -fsSL <安装脚本> | sh),它会重写指针并把代理指回稳定入口。',
|
|
96
|
+
pointerPath,
|
|
97
|
+
pointer,
|
|
98
|
+
updaterCli,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { read: READS.available, reason: null, pointerPath, pointer, updaterCli, installRoot: pointer?.installRoot ?? null }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One child process, answered as data: did it run, what did it print, what did it exit with. */
|
|
105
|
+
export function spawnJson(bin, args, { spawn = spawnSync, timeoutMs = 120_000 } = {}) {
|
|
106
|
+
const res = spawn(bin, args, { encoding: 'utf8', timeout: timeoutMs })
|
|
107
|
+
const status = typeof res?.status === 'number' ? res.status : null
|
|
108
|
+
const stdout = String(res?.stdout ?? '')
|
|
109
|
+
const stderr = String(res?.stderr ?? '').trim()
|
|
110
|
+
// Two documents, two shapes, and getting this wrong reads a healthy machine as a silent one:
|
|
111
|
+
// the updater CLI answers **one line** of JSON (its contract with the panel and `gp_diagnose`), while
|
|
112
|
+
// `npm ls -g --json` answers **pretty-printed multi-line JSON** (measured here against npm 11). A
|
|
113
|
+
// reader that took the last line got `}` and reported "npm 答的不是那份 JSON" about a prefix that
|
|
114
|
+
// named every package on the machine. So: the whole document first, the last line only as a fallback
|
|
115
|
+
// for a stream that carried a log line before the answer.
|
|
116
|
+
const parse = (text) => {
|
|
117
|
+
const trimmed = String(text ?? '').trim()
|
|
118
|
+
if (trimmed === '') return null
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(trimmed)
|
|
121
|
+
} catch {
|
|
122
|
+
/* not the whole document */
|
|
123
|
+
}
|
|
124
|
+
const last = trimmed.split('\n').filter((line) => line !== '').pop()
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(last)
|
|
127
|
+
} catch {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const json = parse(stdout)
|
|
132
|
+
return {
|
|
133
|
+
read: status === null ? READS.unverified : READS.available,
|
|
134
|
+
status,
|
|
135
|
+
json,
|
|
136
|
+
stdout,
|
|
137
|
+
stderr,
|
|
138
|
+
reason: status === null
|
|
139
|
+
? `${bin} 没答话(${res?.error?.message ?? String(res?.error ?? '没有退出码')})`
|
|
140
|
+
: json === null && stdout.trim() !== ''
|
|
141
|
+
? `${bin} 答的不是那份 JSON(前 120 字:${stdout.trim().slice(0, 120)})`
|
|
142
|
+
: null,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The doctor's six readings. Each returns `{ name, read, detail, remedy }`, and `remedy` is only ever the
|
|
148
|
+
* command this tool can already see working — never an invented path, never an escalation.
|
|
149
|
+
*/
|
|
150
|
+
export function doctorReadings({
|
|
151
|
+
env = process.env,
|
|
152
|
+
homeDir = null,
|
|
153
|
+
readFile = (p) => fs.readFileSync(p, 'utf8'),
|
|
154
|
+
exists = (p) => fs.existsSync(p),
|
|
155
|
+
spawn = spawnSync,
|
|
156
|
+
execPath = process.execPath,
|
|
157
|
+
uid = null,
|
|
158
|
+
runLaunchctl = null,
|
|
159
|
+
} = {}) {
|
|
160
|
+
const { stateRoot, source } = stateRootOf({ env, homeDir })
|
|
161
|
+
const out = []
|
|
162
|
+
|
|
163
|
+
out.push(stateRoot === null
|
|
164
|
+
? { name: '状态根', read: READS.unverified, detail: source, remedy: 'export HOME=/Users/<你> 后重跑(这个产品把状态放在家目录下是刻意的)。' }
|
|
165
|
+
: { name: '状态根', read: READS.available, detail: `${stateRoot}(${source})`, remedy: null })
|
|
166
|
+
|
|
167
|
+
const pointer = readInstallPointer({ stateRoot, readFile, exists })
|
|
168
|
+
out.push({
|
|
169
|
+
name: '安装指针',
|
|
170
|
+
read: pointer.read,
|
|
171
|
+
detail: pointer.read === READS.available
|
|
172
|
+
? `${pointer.updaterCli}(安装根 ${pointer.installRoot ?? '未记'})`
|
|
173
|
+
: pointer.reason,
|
|
174
|
+
remedy: pointer.remedy ?? (pointer.read === READS.unverified ? '这一份是安装器写的 JSON,读不出来通常意味着它被手改过:重跑安装程序覆盖它。' : null),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if (pointer.read !== READS.available) {
|
|
178
|
+
out.push({ name: '更新器状态', read: READS.unverified, detail: '没有可跑的更新脚本,这一格没问', remedy: '先把上面那一格修好。' })
|
|
179
|
+
out.push({ name: '定时代理', read: READS.unverified, detail: '同上', remedy: null })
|
|
180
|
+
} else {
|
|
181
|
+
const status = spawnJson(execPath, [pointer.updaterCli, 'status', '--json', '--state-dir', stateRoot], { spawn })
|
|
182
|
+
// `status --json` 的那一份文档把状态**整体**放在 `state` 里(顶层只有 command/status/code/
|
|
183
|
+
// message/exitCode)。在本机量过一次:按顶层读 `current` 得到的是空,于是 doctor 对着一台装着
|
|
184
|
+
// 1.6.2、最新 1.21.0 的机器答"装着=?"——一个读错字段的读数比不读更容易骗人。
|
|
185
|
+
const doc = status.json?.state ?? status.json ?? {}
|
|
186
|
+
out.push(status.read === READS.available && status.json
|
|
187
|
+
? {
|
|
188
|
+
name: '更新器状态',
|
|
189
|
+
read: READS.available,
|
|
190
|
+
detail: `status=${status.json.status ?? '?'} 装着=${doc.current ?? '?'} 最新=${doc.latest ?? '未查'} `
|
|
191
|
+
+ `待装=${doc.staged?.version ?? '没有'} code=${status.json.code ?? '无'}`,
|
|
192
|
+
// 状态里挂着的代号是这台机器**现在**的降级原因(`npm-not-refreshed`、`runtime-stale` 这一类)。
|
|
193
|
+
// 只把 message 摘要抄过来是不够的:读 doctor 的人要先知道是哪一格,才有办法决定去不去读原话。
|
|
194
|
+
remedy: status.json.code
|
|
195
|
+
? `这一格挂在状态里的代号是 ${status.json.code}:${String(status.json.message ?? status.json.lastError?.message ?? '(没给原话)').slice(0, 200)}`
|
|
196
|
+
: null,
|
|
197
|
+
}
|
|
198
|
+
: { name: '更新器状态', read: READS.unverified, detail: status.reason ?? `${pointer.updaterCli} status 退出 ${status.status}:${status.stderr.slice(0, 160)}`, remedy: null })
|
|
199
|
+
|
|
200
|
+
const launchctl = runLaunchctl ?? ((args) => {
|
|
201
|
+
const res = spawn('launchctl', args, { encoding: 'utf8', timeout: 15_000 })
|
|
202
|
+
return { status: typeof res?.status === 'number' ? res.status : null, stdout: res?.stdout ?? '', stderr: String(res?.stderr ?? '') + (res?.error ? ` ${res.error.message ?? res.error}` : '') }
|
|
203
|
+
})
|
|
204
|
+
const who = uid ?? (typeof process.getuid === 'function' ? String(process.getuid()) : null)
|
|
205
|
+
const job = who === null
|
|
206
|
+
? { read: READS.unverified, detail: '读不到 uid(id -u 没答),所以没问 launchd' }
|
|
207
|
+
: (() => {
|
|
208
|
+
const res = launchctl(['print', `gui/${who}/${DAEMON_JOB_LABEL}`])
|
|
209
|
+
const text = String(res?.stdout ?? '')
|
|
210
|
+
if (res?.status === 0) {
|
|
211
|
+
const state = /(?:^|\n)\s*state\s*=\s*(\S+)/.exec(text)
|
|
212
|
+
return { read: READS.available, detail: `在册作业装载着,state=${state ? state[1] : '未给出'}` }
|
|
213
|
+
}
|
|
214
|
+
if (res?.status === 113 || /could not find service|does not exist/i.test(String(res?.stderr ?? ''))) {
|
|
215
|
+
return { read: READS.absent, detail: `launchd 说它没有 gui/${who}/${DAEMON_JOB_LABEL} 这个作业`, remedy: '重跑安装程序(node installer/cli.js)——它会重新注册 daemon 作业,并在动手前问一句。' }
|
|
216
|
+
}
|
|
217
|
+
// 答不了就是答不了:这一支从前把 launchd 的沉默写成"没这个作业",于是 daemon 明明在册、
|
|
218
|
+
// doctor 却说它缺席——缺席与不知道是两件事,补救也不同(一个是重装,一个是先让这一问能问出去)。
|
|
219
|
+
return { read: READS.unverified, detail: `launchctl print 退出 ${String(res?.status ?? 'null')}:${String(res?.stderr ?? '').trim().slice(0, 160) || '没有 stderr'}` }
|
|
220
|
+
})()
|
|
221
|
+
out.push({ name: '定时代理', read: job.read, detail: job.detail, remedy: job.remedy ?? null })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const socketPath = stateRoot ? path.join(stateRoot, 'engine.sock') : null
|
|
225
|
+
const socket = socketPath === null
|
|
226
|
+
? { read: READS.unverified, detail: '没有状态根,socket 路径无从得出' }
|
|
227
|
+
: exists(socketPath)
|
|
228
|
+
? { read: READS.available, detail: socketPath }
|
|
229
|
+
: { read: READS.absent, detail: `${socketPath} 不在:daemon 没在跑(或这台机器上它注册在别的状态根)` }
|
|
230
|
+
out.push({ name: '引擎 socket', read: socket.read, detail: socket.detail, remedy: socket.read === READS.absent ? '启动 daemon:open "GlassPane Daemon.app",或重跑安装程序最后那一支。' : null })
|
|
231
|
+
|
|
232
|
+
const npm = spawnJson('npm', ['ls', '-g', '--depth=0', '--json'], { spawn })
|
|
233
|
+
if (npm.read !== READS.available || npm.json === null) {
|
|
234
|
+
out.push({
|
|
235
|
+
name: '全局 npm 包',
|
|
236
|
+
read: READS.unverified,
|
|
237
|
+
detail: npm.reason ?? `npm ls -g 退出 ${npm.status}:${npm.stderr.slice(0, 120) || '没有 stderr'}`,
|
|
238
|
+
remedy: '够不着 npm 时,这一格只能答"不知道";先确认这个账号能跑到 npm。',
|
|
239
|
+
})
|
|
240
|
+
} else {
|
|
241
|
+
const deps = npm.json.dependencies ?? {}
|
|
242
|
+
const seen = NPM_PACKAGES.map((name) => `${name}=${deps[name]?.version ?? '没装'}`)
|
|
243
|
+
const missing = NPM_PACKAGES.filter((name) => !deps[name])
|
|
244
|
+
out.push({
|
|
245
|
+
name: '全局 npm 包',
|
|
246
|
+
read: missing.length === 0 ? READS.available : READS.absent,
|
|
247
|
+
detail: seen.join(' '),
|
|
248
|
+
remedy: missing.length === 0 ? null
|
|
249
|
+
: `补法(不需要权限,取的是 registry 的发布物):npm install -g ${NPM_PACKAGES.map((n) => `${n}@<版本>`).join(' ')}`,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return out
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The shape of `glasspane doctor`'s answer: one line per reading, plus the exit-code rule. */
|
|
257
|
+
export function doctorReport(readings) {
|
|
258
|
+
const lines = readings.map((one) => `· ${one.name}:${one.detail}${one.remedy ? `\n 下一步:${one.remedy}` : ''}`)
|
|
259
|
+
const unhealthy = readings.filter((one) => one.read !== READS.available)
|
|
260
|
+
const verdict = unhealthy.length === 0
|
|
261
|
+
? '六格都答了话且都在位。'
|
|
262
|
+
: `${unhealthy.length}/${readings.length} 格不在位或答不了话:${unhealthy.map((one) => `${one.name}(${one.read})`).join('、')}`
|
|
263
|
+
return { text: `${lines.join('\n')}\n${verdict}`, ok: unhealthy.length === 0 }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function usageText() {
|
|
267
|
+
return [
|
|
268
|
+
'用法:glasspane <命令>',
|
|
269
|
+
'',
|
|
270
|
+
' update 问一次有没有新版本,有就装(走这台机器已经装着的那个更新器)',
|
|
271
|
+
' status 把更新器自己的那一行结论原样打出来',
|
|
272
|
+
' doctor 读这台机器的安装状态(安装指针、更新器、daemon 作业、socket、全局 npm 包)——只读,不写',
|
|
273
|
+
' help 这一页(`-h`、`--help` 同义)',
|
|
274
|
+
'',
|
|
275
|
+
'退出码:0 = 成/都在位;2 = 用法错;3 = 被拒或有一格不在位(doctor 用它表示"有东西要看上面那几行");',
|
|
276
|
+
'4/5 来自 update(换版后回退 / 回退也没成),与更新器本身的口径一致。',
|
|
277
|
+
'本命令不 sudo、不删东西:需要人做的那一步只会把工具名与目录写出来,不会给成可照抄的升权命令。',
|
|
278
|
+
].join('\n')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function parseVerb(argv) {
|
|
282
|
+
const first = String(argv?.[0] ?? '')
|
|
283
|
+
if (argv.length === 0) return { verb: null, error: '没给命令' }
|
|
284
|
+
// `-h` / `--help` 与 `help` 同义:这条命令是本产品给 agent 的入口,而 agent 的第一反应就是
|
|
285
|
+
// `--help`。2026-10-27 用发布物实测:`/usr/local/bin/glasspane --help` 回"不认得命令「--help」"
|
|
286
|
+
// 且退出 2——同产品的 `installer/cli.js` 认 `--help`,两条入口对同一种问法给两种答案。
|
|
287
|
+
const verb = VERB_ALIASES[first] ?? first
|
|
288
|
+
if (!VERBS.includes(verb)) return { verb: null, error: `不认得命令「${first}」` }
|
|
289
|
+
return { verb, error: null, rest: argv.slice(1) }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* `update` = the updater's own two steps, in its own words. It runs `check` first (an update has to be
|
|
294
|
+
* staged before `apply` will move anything), and hands the *updater's* exit code back: 3 from `check`
|
|
295
|
+
* means "nothing staged / unreachable", which is a refusal, not a failure — and this command has no
|
|
296
|
+
* business re-interpreting that as its own verdict.
|
|
297
|
+
*/
|
|
298
|
+
export async function runUpdate({ execPath = process.execPath, updaterCli, stateRoot, spawn = spawnSync, write = (line) => process.stdout.write(`${line}\n`) } = {}) {
|
|
299
|
+
const check = spawnJson(execPath, [updaterCli, 'check', '--json', '--state-dir', stateRoot], { spawn })
|
|
300
|
+
if (check.read !== READS.available || check.json === null) {
|
|
301
|
+
write(`检查没答话:${check.reason ?? `退出 ${check.status}:${check.stderr.slice(0, 200)}`}`)
|
|
302
|
+
return { exitCode: check.status ?? 3, stage: 'check' }
|
|
303
|
+
}
|
|
304
|
+
write(`检查:${check.json.message ?? check.json.status}`)
|
|
305
|
+
if (check.json.status === 'up-to-date') {
|
|
306
|
+
write('已经是最新,没有要装的东西。')
|
|
307
|
+
return { exitCode: 0, stage: 'check' }
|
|
308
|
+
}
|
|
309
|
+
// 检查这一格的非零退出就是"这一趟停在这里":`deferred`/`needs-consent`/`check-failed` 都带着它自己的
|
|
310
|
+
// 原因与代号。从前这里读的是一个不存在的字段(`exitCode`,而子进程答的是 `status`),于是条件恒假,
|
|
311
|
+
// 一次"daemon 正忙"的拒绝被接着送去 apply,屏幕上就多出一条没做过的安装。
|
|
312
|
+
if (check.status !== 0) {
|
|
313
|
+
write(`检查停在 ${check.json.status}(code=${check.json.code ?? '未给出'},退出码 ${check.status}),没有进入安装。`)
|
|
314
|
+
return { exitCode: check.status ?? 3, stage: 'check', json: check.json }
|
|
315
|
+
}
|
|
316
|
+
const apply = spawnJson(execPath, [updaterCli, 'apply', '--json', '--state-dir', stateRoot], { spawn })
|
|
317
|
+
if (apply.read !== READS.available || apply.json === null) {
|
|
318
|
+
write(`安装没答话:${apply.reason ?? `退出 ${apply.status}:${apply.stderr.slice(0, 200)}`}`)
|
|
319
|
+
return { exitCode: apply.status ?? 3, stage: 'apply' }
|
|
320
|
+
}
|
|
321
|
+
write(`安装:${apply.json.message ?? apply.json.status}`)
|
|
322
|
+
if (apply.json.code) write(`(这一格的代号:${apply.json.code}——它挂在状态里,面板与 gp_diagnose 也读得到。)`)
|
|
323
|
+
return { exitCode: apply.status ?? 0, stage: 'apply', json: apply.json }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function main({ argv = process.argv.slice(2), env = process.env, spawn = spawnSync, write = (line) => process.stdout.write(`${line}\n`), fail = (line) => process.stderr.write(`${line}\n`) } = {}) {
|
|
327
|
+
const parsed = parseVerb(argv)
|
|
328
|
+
if (parsed.error) {
|
|
329
|
+
fail(`${parsed.error}。\n\n${usageText()}`)
|
|
330
|
+
return 2
|
|
331
|
+
}
|
|
332
|
+
if (parsed.verb === 'help') {
|
|
333
|
+
write(usageText())
|
|
334
|
+
return 0
|
|
335
|
+
}
|
|
336
|
+
const { stateRoot } = stateRootOf({ env })
|
|
337
|
+
const pointer = readInstallPointer({ stateRoot, readFile: (p) => fs.readFileSync(p, 'utf8'), exists: (p) => fs.existsSync(p) })
|
|
338
|
+
if (parsed.verb === 'doctor') {
|
|
339
|
+
const report = doctorReport(doctorReadings({ env, spawn }))
|
|
340
|
+
write(report.text)
|
|
341
|
+
return report.ok ? 0 : 3
|
|
342
|
+
}
|
|
343
|
+
if (pointer.read !== READS.available) {
|
|
344
|
+
fail(`这台机器的安装指针读不出可用的一份更新器:${pointer.reason}\n${pointer.remedy ?? ''}`)
|
|
345
|
+
return 3
|
|
346
|
+
}
|
|
347
|
+
if (parsed.verb === 'status') {
|
|
348
|
+
const status = spawnJson(process.execPath, [pointer.updaterCli, 'status', '--json', '--state-dir', stateRoot], { spawn })
|
|
349
|
+
if (status.read !== READS.available || status.json === null) {
|
|
350
|
+
fail(`更新器没答话:${status.reason ?? status.stderr.slice(0, 200)}`)
|
|
351
|
+
return 3
|
|
352
|
+
}
|
|
353
|
+
const doc = status.json.state ?? status.json
|
|
354
|
+
write(`${status.json.status}|装着 ${doc.current ?? '?'}|最新 ${doc.latest ?? '未查'}|待装 ${doc.staged?.version ?? '没有'}|代号 ${status.json.code ?? '无'}`)
|
|
355
|
+
if (status.json.message) write(status.json.message)
|
|
356
|
+
return status.status ?? 0
|
|
357
|
+
}
|
|
358
|
+
const result = await runUpdate({ updaterCli: pointer.updaterCli, stateRoot, spawn, write })
|
|
359
|
+
return result.exitCode
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const invokedDirectly = process.argv[1] !== undefined && fs.realpathSync(process.argv[1]) === fs.realpathSync(new URL(import.meta.url).pathname)
|
|
363
|
+
if (invokedDirectly) {
|
|
364
|
+
main().then((code) => {
|
|
365
|
+
process.exitCode = code
|
|
366
|
+
}, (error) => {
|
|
367
|
+
process.stderr.write(`glasspane 自己出了错:${error?.message ?? String(error)}\n`)
|
|
368
|
+
process.exitCode = 3
|
|
369
|
+
})
|
|
370
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glasspane-install",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"description": "GlassPane one-command installer: terminal onboarding → install deps → build TS + Swift engine → start daemon → open settings GUI for permission guide",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
"keywords": ["macos", "installer", "glasspane", "cli"],
|
|
16
16
|
"main": "./cli.js",
|
|
17
17
|
"bin": {
|
|
18
|
-
"glasspane-install": "./cli.js"
|
|
18
|
+
"glasspane-install": "./cli.js",
|
|
19
|
+
"glasspane": "./glasspane.js"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"cli.js",
|
|
23
|
+
"glasspane.js",
|
|
22
24
|
"LICENSE",
|
|
23
25
|
"README.md"
|
|
24
26
|
],
|