glasspane-install 1.22.0 → 1.24.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/cli.js +151 -31
- package/glasspane.js +28 -5
- package/package.json +1 -1
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.24.0'
|
|
74
74
|
/** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
|
|
75
75
|
export const REPO_REF = `v${RELEASE_VERSION}`
|
|
76
76
|
|
|
@@ -1571,7 +1571,15 @@ export async function registerAutoUpdate({
|
|
|
1571
1571
|
}
|
|
1572
1572
|
|
|
1573
1573
|
const registered = autoUpdate && !switchedOffByEnv
|
|
1574
|
-
|
|
1574
|
+
// 「每日几点」这句实话的唯一来源,是 updater 从**在册作业**里读回来的那个 interval
|
|
1575
|
+
// (`outcome.agentSchedule`,2026-10-27 finding C4)。从前这里直接拼 `launchd.DEFAULT_*` 那两个常量:
|
|
1576
|
+
// 装过 `updater enable --hour 3` 的机器被重装之后,作业照旧 3:00 跑(`enable` 故意保住在册时刻,
|
|
1577
|
+
// updater/cli.js:583),而安装日志与 nextSteps 交给读者的那句却写成"每日 12:00"——一个默认值
|
|
1578
|
+
// 冒充了一次实测,而面板与 gp_diagnose 的读者信的那句就是这个。读不回来时不猜,改口说清。
|
|
1579
|
+
const held = outcome.agentSchedule ?? null
|
|
1580
|
+
const scheduleMeasured = !!held && Number.isInteger(held.hour) && Number.isInteger(held.minute)
|
|
1581
|
+
const defaultSchedule = `${launchd.DEFAULT_HOUR}:${String(launchd.DEFAULT_MINUTE).padStart(2, '0')}`
|
|
1582
|
+
const schedule = scheduleMeasured ? `${held.hour}:${String(held.minute).padStart(2, '0')}` : defaultSchedule
|
|
1575
1583
|
const summary = {
|
|
1576
1584
|
ok: true,
|
|
1577
1585
|
registered,
|
|
@@ -1579,6 +1587,9 @@ export async function registerAutoUpdate({
|
|
|
1579
1587
|
reason: switchedOffByEnv ? 'env' : (autoUpdate ? null : 'flag'),
|
|
1580
1588
|
agentLabel,
|
|
1581
1589
|
schedule,
|
|
1590
|
+
// 「这个数是读回来的还是默认值」本身要成为数据:只把 schedule 交给读者的话,两种来源在下游
|
|
1591
|
+
// 长成同一个样子,而这两种说法对人是完全不同的。
|
|
1592
|
+
scheduleMeasured,
|
|
1582
1593
|
stateRoot,
|
|
1583
1594
|
updaterCli: tree.cli,
|
|
1584
1595
|
pointer: written.pointer,
|
|
@@ -1593,7 +1604,7 @@ export async function registerAutoUpdate({
|
|
|
1593
1604
|
caRoots: outcome.state && outcome.state.caRoots ? outcome.state.caRoots : null,
|
|
1594
1605
|
}
|
|
1595
1606
|
if (registered) {
|
|
1596
|
-
say(`每日自动更新已注册(${agentLabel}
|
|
1607
|
+
say(`每日自动更新已注册(${agentLabel},${summary.scheduleMeasured ? `在册作业跑在 ${schedule}` : `默认 ${schedule}——这一档没能从在册作业读回实际时刻,所以下一句是默认值而不是实测`},状态根 ${stateRoot};指针 ${written.path})——一键关:node "${tree.cli}" disable`)
|
|
1597
1608
|
// "已注册"这三个字的证据是 updater 把在册作业读回来核对过。launchd 装载成功但读不回在册作业时,
|
|
1598
1609
|
// 这句话只能算一半成立,而那另一半(原话)必须跟着说出来——否则这台机器看起来有每日作业,实际
|
|
1599
1610
|
// 有没有人跑没人知道。措辞仍来自唯一作者 outcome.message,这里只转述。
|
|
@@ -1650,7 +1661,7 @@ export function updateGuidanceText(update) {
|
|
|
1650
1661
|
: ` ⚠ 本机没能导出 node 要用的系统根证书束(${ca.status});HTTPS 被中间人替换的机器上,每日检查会在第一道校验上失败,`
|
|
1651
1662
|
+ '面板与 gp_diagnose 会一直显示这句话,修复办法是它给的那条。'
|
|
1652
1663
|
return [
|
|
1653
|
-
`6. 自动更新:默认开。launchd 每日 ${update.schedule} 跑 "${update.updaterCli}" check && apply`,
|
|
1664
|
+
`6. 自动更新:默认开。launchd 每日 ${update.schedule}${update.scheduleMeasured ? '' : '(默认值——在册作业没读回实际时刻,这一句不是实测)'} 跑 "${update.updaterCli}" check && apply`,
|
|
1654
1665
|
` (作业 ${update.agentLabel},RunAtLoad=false,日志 ${update.stateRoot}/update.log);`,
|
|
1655
1666
|
' 只在 daemon 空闲应答探针、且规格 §1 八道校验全过时才换版,major 永远等人点按钮。',
|
|
1656
1667
|
...(caLine ? [caLine] : []),
|
|
@@ -2353,7 +2364,15 @@ export function bootstrapPlan({
|
|
|
2353
2364
|
* · 干净但不是那个 tag ⇒ 前进(fetch + detached checkout —— 这是移动**产品自己的 clone**,
|
|
2354
2365
|
* 不是移动任何人的工作树);
|
|
2355
2366
|
* · 脏 ⇒ 拒绝,点名前 10 个挡路的路径,并给一条不销毁任何东西的出路(换一个空目录);
|
|
2356
|
-
* ·
|
|
2367
|
+
* · HEAD 读不出 ⇒ 拒绝。"判不出来"永远不许变成"那就照用/那就切过去"。
|
|
2368
|
+
*
|
|
2369
|
+
* **"该 tag 本地读不出"不是一条拒绝**,2026-10-27 在本机被实测推翻的正是这一格。旧写法把它和
|
|
2370
|
+
* "HEAD 读不出"并成同一句"判不出来就拒",于是 `~/glasspane`(v1.5.0 的 clone,本地 tag 只到
|
|
2371
|
+
* v1.2.0)在 npm 那条入口上**永远**走到 refuse:`bootstrapPlan` 先问判据、判据先拒,而那一步
|
|
2372
|
+
* 之后**会 fetch 这个 tag 的代码(`advanceExistingClone`)根本轮不到执行**。`install.sh` 那一支
|
|
2373
|
+
* 是对的(先 fetch 再判),两条一键入口对同一棵树给出两种答案——finding 10 那族的老形状。
|
|
2374
|
+
* 现在 tag 缺席归到"前进"里:fetch 不成仍由 `advanceExistingClone` 拒,并交出 git 的原话;
|
|
2375
|
+
* HEAD 读不出照旧是拒。前者是能补的事实缺口,后者是判据本身没了。
|
|
2357
2376
|
*/
|
|
2358
2377
|
export function existingCloneDecision({ targetDir, ref, head = null, tagCommit = null, blocking = [] }) {
|
|
2359
2378
|
const dirty = blocking.map((line) => String(line).trim()).filter((line) => line !== '')
|
|
@@ -2366,25 +2385,41 @@ export function existingCloneDecision({ targetDir, ref, head = null, tagCommit =
|
|
|
2366
2385
|
+ shown.map((line) => ` ${line}`).join('\n')
|
|
2367
2386
|
+ (dirty.length > shown.length ? `\n …另有 ${dirty.length - shown.length} 处` : '')
|
|
2368
2387
|
+ `\n下一步(不销毁任何东西,两条都做得动):`
|
|
2369
|
-
|
|
2388
|
+
// 建议的目录必须从**被拒的那棵树**推出来,不能写死 `~/glasspane-<ref>`:GLASSPANE_INSTALL_DIR
|
|
2389
|
+
// 指到 /Volumes/src/gp 时被拒的是那棵树,而句子里给的是家目录里另一条从没存在过的路径——
|
|
2390
|
+
// 两条一键入口同源的那条规矩(判据不许指向一个与本次无关的位置)。路径带空格时必须加引号,
|
|
2391
|
+
// 否则这句照抄进去的是两个词,clone 落在 `/Users/John` 而 `Smith/…` 变成一条命令。
|
|
2392
|
+
+ `\n a) 换一处干净的家再跑一键安装:GLASSPANE_INSTALL_DIR="${String(targetDir)}-${String(ref).replace(/[^A-Za-z0-9._+-]/g, '_')}" `
|
|
2370
2393
|
+ `sh install.sh(或 npx glasspane-install);`
|
|
2371
2394
|
+ `\n b) 自己收着这些改动:在 ${targetDir} 里把它们提交或转移到别处,再重跑本命令——`
|
|
2372
2395
|
+ '安装器不替你决定这些文件去哪。',
|
|
2373
2396
|
}
|
|
2374
2397
|
}
|
|
2375
|
-
if (!head
|
|
2398
|
+
if (!head) {
|
|
2376
2399
|
return {
|
|
2377
2400
|
action: 'refuse',
|
|
2378
2401
|
blocking: [],
|
|
2379
|
-
text: `${targetDir} 读不出 HEAD
|
|
2380
|
-
+ '
|
|
2381
|
-
+ `\n
|
|
2382
|
-
+ 'GLASSPANE_INSTALL_DIR=<空目录> 重跑一键安装。',
|
|
2402
|
+
text: `${targetDir} 读不出 HEAD(不是 git 仓库、那目录没有一次提交,或 git 本身答不了):`
|
|
2403
|
+
+ '连"这棵树在哪"都判不出来,就不动它,也不装它。'
|
|
2404
|
+
+ `\n下一步:确认 ${targetDir} 是一棵 git 仓库(git -C "${targetDir}" rev-parse HEAD 能不能答话);`
|
|
2405
|
+
+ '要一份全新的 clone 就 GLASSPANE_INSTALL_DIR=<空目录> 重跑一键安装。',
|
|
2383
2406
|
}
|
|
2384
2407
|
}
|
|
2385
|
-
if (head === tagCommit) {
|
|
2408
|
+
if (tagCommit && head === tagCommit) {
|
|
2386
2409
|
return { action: 'use', blocking: [], head, text: `${targetDir} 已经在 ${ref}(${String(head).slice(0, 7)}),直接用它构建。` }
|
|
2387
2410
|
}
|
|
2411
|
+
if (!tagCommit) {
|
|
2412
|
+
return {
|
|
2413
|
+
action: 'advance',
|
|
2414
|
+
blocking: [],
|
|
2415
|
+
head,
|
|
2416
|
+
tagCommit: null,
|
|
2417
|
+
needsFetch: true,
|
|
2418
|
+
text: `${targetDir} 是干净的 clone(HEAD 在 ${String(head).slice(0, 7)}),但本地没有 ${ref} 这个 tag`
|
|
2419
|
+
+ '(旧 clone 的 tag 表停在更早的一版,浅历史也会这样):前进那一步先 fetch 它再切。'
|
|
2420
|
+
+ `fetch 不成就是拒——${targetDir} 原地不动,本轮不构建。`,
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2388
2423
|
return {
|
|
2389
2424
|
action: 'advance',
|
|
2390
2425
|
blocking: [],
|
|
@@ -2396,66 +2431,151 @@ export function existingCloneDecision({ targetDir, ref, head = null, tagCommit =
|
|
|
2396
2431
|
}
|
|
2397
2432
|
|
|
2398
2433
|
/**
|
|
2399
|
-
* 一棵既有 clone
|
|
2400
|
-
* `git status --porcelain`
|
|
2401
|
-
*
|
|
2434
|
+
* 一棵既有 clone 的四份事实(IO 读,不判定):HEAD 指向的 commit、`ref^{commit}`、
|
|
2435
|
+
* `git status --porcelain` 的行,以及这份 clone 是不是浅仓。判据一律在 `existingCloneDecision`,
|
|
2436
|
+
* 两处调用(bootstrap 的纯计划、前进这一步的复判)共用同一份读数,避免出现第二个"什么算脏"的说法。
|
|
2437
|
+
*
|
|
2438
|
+
* `shallow` 是给 fetch 挑形状的:`--depth 1` 会把一棵**完整**的 clone 变成浅仓(`.git/shallow`
|
|
2439
|
+
* 一旦落下,之后的遍历都撞上人为边界,要恢复得 `git fetch --unshallow`)。旧写法对任何树都带
|
|
2440
|
+
* `--depth 1`,而注释写的是"只往 .git 里加对象"——那句话对完整 clone 不成立,它加的是一个边界。
|
|
2441
|
+
* 只有 git 亲口答 `true` 才用 `--depth 1`;答不出来(老 git、不是仓库)按完整仓对待,宁可多取
|
|
2442
|
+
* 一点也不给人家的历史加边界。
|
|
2402
2443
|
*/
|
|
2403
|
-
export function readCloneFacts({ targetDir, ref, spawn = spawnSync } = {}) {
|
|
2444
|
+
export function readCloneFacts({ targetDir, ref, spawn = spawnSync, fetchHead = false } = {}) {
|
|
2404
2445
|
const read = (args) => spawn('git', ['-C', targetDir, ...args], { encoding: 'utf8' })
|
|
2405
2446
|
const sha = (what) => {
|
|
2406
2447
|
const res = read(['rev-parse', '--verify', what])
|
|
2407
2448
|
return res.status === 0 ? String(res.stdout ?? '').trim() : null
|
|
2408
2449
|
}
|
|
2409
2450
|
const status = read(['status', '--porcelain'])
|
|
2451
|
+
const shallow = read(['rev-parse', '--is-shallow-repository'])
|
|
2452
|
+
// 按 tag 建的浅 clone 里**根本没有 refs/heads/**(`git clone --branch v1.23.0` 只配了那一个 tag
|
|
2453
|
+
// 的 refspec)。所以对 `GLASSPANE_REF=main` 这种追主干的用法,`main^{commit}` 在本地永远读不出,
|
|
2454
|
+
// 而紧跟其后的 `git fetch --tags origin main` **退 0 却不落下名为 main 的 ref**——它只写 FETCH_HEAD。
|
|
2455
|
+
// 实测(真 git,一棵按 tag 建的浅 clone):fetch 前后 `rev-parse --verify main^{commit}` 都回
|
|
2456
|
+
// `fatal: Needed a single revision`,而 `checkout --detach main` 回
|
|
2457
|
+
// `fatal: git checkout: --detach does not take a path argument 'main'`。旧代码把这两句都当成
|
|
2458
|
+
// "网络或该 tag 有问题",于是文档里写明的追主干口径在这两条一键入口上是**永久死路**。
|
|
2459
|
+
// `fetchHead` 只在"这一趟自己刚 fetch 过那个 ref"之后打开:FETCH_HEAD 就是那次 fetch 的答案,
|
|
2460
|
+
// 拿它当 ref 的落点比猜一个不存在的 ref 诚实;不由判据那一侧默认打开,否则上一趟别人 fetch 的
|
|
2461
|
+
// 残留会被读成"这一版就在它指的地方"。
|
|
2462
|
+
const refCommit = sha(`${ref}^{commit}`) ?? (fetchHead ? sha('FETCH_HEAD^{commit}') : null)
|
|
2410
2463
|
return {
|
|
2411
2464
|
head: sha('HEAD'),
|
|
2412
|
-
tagCommit:
|
|
2465
|
+
tagCommit: refCommit,
|
|
2413
2466
|
// `git status` 答非零时这里得到空数组,读起来是"干净"——那棵树随后会因为 `head` 读不出而被拒,
|
|
2414
2467
|
// 所以"判不出来"走的是拒绝那一支,不是"那就切过去"。
|
|
2415
2468
|
blocking: status.status === 0 ? String(status.stdout ?? '').split('\n').filter((line) => line !== '') : [],
|
|
2469
|
+
shallow: shallow.status === 0 ? String(shallow.stdout ?? '').trim() === 'true' : null,
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/**
|
|
2474
|
+
* 这棵 clone 该从哪个远端取(纯判定,可单测)。
|
|
2475
|
+
*
|
|
2476
|
+
* 旧写法把 `origin` 写死在 fetch 与诊断两处。一棵用 `git clone --origin up` 建的树、或把 origin
|
|
2477
|
+
* 改指镜像的树,走到的是同一条拒绝,而那句拒绝让人去 `git ls-remote --tags origin <ref>`——**同一条
|
|
2478
|
+
* 用错名字的命令**,照抄也只会再失败一次。判据只读事实:有 origin 用它;只有一个远端就用那一个;
|
|
2479
|
+
* 一个都没有、或多个而没有 origin,都是"答不出该从哪取",不猜。
|
|
2480
|
+
*/
|
|
2481
|
+
export function resolveRemoteName({ remotes = [] } = {}) {
|
|
2482
|
+
const list = remotes.map((name) => String(name).trim()).filter((name) => name !== '')
|
|
2483
|
+
if (list.includes('origin')) return { remote: 'origin' }
|
|
2484
|
+
if (list.length === 1) return { remote: list[0] }
|
|
2485
|
+
if (list.length === 0) {
|
|
2486
|
+
return { error: '这棵树里一个远端都没有(git remote 答空):fetch 无处可取,这里也不替它猜一个名字。' }
|
|
2487
|
+
}
|
|
2488
|
+
return {
|
|
2489
|
+
error: `这棵树里有 ${list.length} 个远端(${list.join('、')})而没有名为 origin 的那个:`
|
|
2490
|
+
+ '不猜该从哪一个取。下一步:把要用的那个远端改名成 origin,或直接 git fetch <那个远端> <ref> 之后重跑。',
|
|
2416
2491
|
}
|
|
2417
2492
|
}
|
|
2418
2493
|
|
|
2419
2494
|
/**
|
|
2420
2495
|
* 把既有 clone 前进到钉住的 ref(IO 步;判据全在 `existingCloneDecision` 里)。
|
|
2421
2496
|
*
|
|
2422
|
-
*
|
|
2423
|
-
*
|
|
2424
|
-
* `
|
|
2497
|
+
* 只有这几种 git 动作:`status --porcelain`、两次 `rev-parse --verify`、`rev-parse
|
|
2498
|
+
* --is-shallow-repository`(全是读),`fetch --tags [origin <ref>]`(只往 .git 里加对象;
|
|
2499
|
+
* 带不带 `--depth 1` 由 git 自己答的那句"是不是浅仓"决定,见 `readCloneFacts`),以及
|
|
2500
|
+
* `checkout --detach <ref>`(只在树干净时执行)。没有 `-f`、没有 `stash`、没有 `clean`、
|
|
2501
|
+
* 没有 `reset --hard`:一个 installer 不该有能把别人的未提交工作抹掉的那一格。
|
|
2502
|
+
*/
|
|
2503
|
+
/**
|
|
2504
|
+
* 把既有 clone 前进到钉住的 ref(IO 步;判据全在 `existingCloneDecision` 里)。
|
|
2505
|
+
*
|
|
2506
|
+
* 只有这几种 git 动作:`status --porcelain`、`remote`、几次 `rev-parse --verify`、`rev-parse
|
|
2507
|
+
* --is-shallow-repository`(全是读),`fetch --tags [remote <ref>]`(只往 .git 里加对象;
|
|
2508
|
+
* 带不带 `--depth 1` 由 git 自己答的那句"是不是浅仓"决定,见 `readCloneFacts`),以及
|
|
2509
|
+
* `checkout --detach <那个 commit 的 sha>`(只在树干净时执行)。没有 `-f`、没有 `stash`、没有
|
|
2510
|
+
* `clean`、没有 `reset --hard`:一个 installer 不该有能把别人的未提交工作抹掉的那一格。
|
|
2511
|
+
*
|
|
2512
|
+
* 切的是 **sha 而不是那个名字**:按 tag 建的 clone 里没有 refs/heads/*,`main` 这个名字 fetch 之后
|
|
2513
|
+
* 仍然不是一个 ref,`git checkout --detach main` 被 git 以"--detach does not take a path argument"
|
|
2514
|
+
* 拒绝;而那次 fetch 的落点(FETCH_HEAD)就是一个 sha,切它才是这一句本来的意思。
|
|
2425
2515
|
*/
|
|
2426
2516
|
export function advanceExistingClone({ repoDir, ref, spawn = spawnSync } = {}) {
|
|
2427
2517
|
const read = (args) => spawn('git', ['-C', repoDir, ...args], { encoding: 'utf8' })
|
|
2518
|
+
const remoteNames = () => {
|
|
2519
|
+
const res = read(['remote'])
|
|
2520
|
+
return res.status === 0 ? String(res.stdout ?? '').split('\n').filter((line) => line !== '') : null
|
|
2521
|
+
}
|
|
2428
2522
|
let facts = readCloneFacts({ targetDir: repoDir, ref, spawn })
|
|
2429
2523
|
if (facts.blocking.length === 0 && facts.head && !facts.tagCommit) {
|
|
2430
|
-
// tag
|
|
2431
|
-
|
|
2524
|
+
// 该 ref 在本地读不出(旧 clone 的 tag 表停在更早一版、浅 clone、按 tag 建的 clone 里没有
|
|
2525
|
+
// refs/heads/*):先把它取回来,再判。fetch 失败不改任何东西。
|
|
2526
|
+
const remotes = remoteNames()
|
|
2527
|
+
const remote = remotes === null ? null : resolveRemoteName({ remotes })
|
|
2528
|
+
if (!remote || !remote.remote) {
|
|
2529
|
+
return {
|
|
2530
|
+
action: 'refuse',
|
|
2531
|
+
blocking: [],
|
|
2532
|
+
text: `${repoDir} 里没有 ${ref} 这个引用,而这一趟连"该从哪个远端取"都答不出来:`
|
|
2533
|
+
+ `${remote?.error ?? 'git remote 本身没答话。'}\n`
|
|
2534
|
+
+ `${repoDir} 原地不动,没有 fetch、没有切分支、没有构建任何东西。`,
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
const fetchArgs = facts.shallow === true
|
|
2538
|
+
? ['fetch', '--tags', '--depth', '1', remote.remote, ref]
|
|
2539
|
+
: ['fetch', '--tags', remote.remote, ref]
|
|
2540
|
+
const fetched = read(fetchArgs)
|
|
2432
2541
|
if (fetched.status !== 0) {
|
|
2433
2542
|
return {
|
|
2434
2543
|
action: 'refuse',
|
|
2435
2544
|
blocking: [],
|
|
2436
|
-
|
|
2437
|
-
|
|
2545
|
+
fetchArgs,
|
|
2546
|
+
remote: remote.remote,
|
|
2547
|
+
text: `${repoDir} 里没有 ${ref} 这个引用,而 `
|
|
2548
|
+
+ `git -C "${repoDir}" ${fetchArgs.join(' ')} 退出 ${String(fetched.status ?? 'null')}:`
|
|
2438
2549
|
+ `${String(fetched.stderr ?? '').trim().split('\n')[0] || '(git 没有留下 stderr)'}。\n`
|
|
2439
|
-
+ '没有 clone
|
|
2440
|
-
+ `git ls-remote
|
|
2550
|
+
+ '没有 clone、没有切分支、没有写入任何东西。下一步:确认网络与该引用存在('
|
|
2551
|
+
+ `git ls-remote ${remote.remote} ${ref}),或 GLASSPANE_INSTALL_DIR=<空目录> 换一处全新的 clone。`,
|
|
2441
2552
|
}
|
|
2442
2553
|
}
|
|
2443
|
-
facts = readCloneFacts({ targetDir: repoDir, ref, spawn })
|
|
2554
|
+
facts = readCloneFacts({ targetDir: repoDir, ref, spawn, fetchHead: true })
|
|
2444
2555
|
}
|
|
2445
2556
|
let decision = existingCloneDecision({ targetDir: repoDir, ref, ...facts })
|
|
2446
2557
|
if (decision.action !== 'advance') return decision
|
|
2447
|
-
const
|
|
2558
|
+
const targetCommit = facts.tagCommit
|
|
2559
|
+
if (!targetCommit) {
|
|
2560
|
+
// 这一支不是"生产会走到的判断",而是一条**构造性的护栏**:git 在这台机器上对一个远端不存在
|
|
2561
|
+
// 的引用答的是退出 128(本机实测:`fatal: couldn't find remote ref nosuchbranch`),所以
|
|
2562
|
+
// fetch 退 0 而落点读不出这种形状没人给过。但 `checkout --detach` 的实参只许是一个读到的
|
|
2563
|
+
// commit —— 把 null 拼进 argv 会让 git 报一条与本轮结论无关的错,而那正是"切了个不存在的东西"
|
|
2564
|
+
// 的另一种写法。判据照旧是"不切就停下",由上面那条 decision 说话。
|
|
2565
|
+
return decision
|
|
2566
|
+
}
|
|
2567
|
+
const out = read(['checkout', '--detach', targetCommit])
|
|
2448
2568
|
if (out.status !== 0) {
|
|
2449
2569
|
return {
|
|
2450
2570
|
action: 'refuse',
|
|
2451
|
-
blocking: readCloneFacts({ targetDir: repoDir, ref, spawn }).blocking,
|
|
2452
|
-
text: `切到 ${ref}
|
|
2571
|
+
blocking: readCloneFacts({ targetDir: repoDir, ref, spawn, fetchHead: true }).blocking,
|
|
2572
|
+
text: `切到 ${ref}(${String(targetCommit).slice(0, 7)})没成(git checkout --detach 退出 ${String(out.status ?? 'null')}):`
|
|
2453
2573
|
+ `${String(out.stderr ?? '').trim().split('\n')[0] || '(git 没有留下 stderr)'}。\n`
|
|
2454
2574
|
+ `${repoDir} 仍在 ${String(facts.head ?? '读不出').slice(0, 7)},本轮没有构建任何东西。`,
|
|
2455
2575
|
}
|
|
2456
2576
|
}
|
|
2457
2577
|
// 切完再读一次:判据要落在**切换之后**的事实上,不是落在命令的退出码上。
|
|
2458
|
-
const after = readCloneFacts({ targetDir: repoDir, ref, spawn })
|
|
2578
|
+
const after = readCloneFacts({ targetDir: repoDir, ref, spawn, fetchHead: true })
|
|
2459
2579
|
const recheck = existingCloneDecision({ targetDir: repoDir, ref, ...after })
|
|
2460
2580
|
if (recheck.action !== 'use') {
|
|
2461
2581
|
return {
|
package/glasspane.js
CHANGED
|
@@ -29,9 +29,12 @@ import fs from 'node:fs'
|
|
|
29
29
|
import path from 'node:path'
|
|
30
30
|
import os from 'node:os'
|
|
31
31
|
import process from 'node:process'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
32
33
|
import { spawnSync } from 'node:child_process'
|
|
33
34
|
|
|
34
35
|
export const VERBS = Object.freeze(['update', 'doctor', 'status', 'help'])
|
|
36
|
+
/** 用法里的同义写法:读不出"同一条命令的两种问法"就没有可比性,所以别名是一张明面上的表。 */
|
|
37
|
+
export const VERB_ALIASES = Object.freeze({ '-h': 'help', '--help': 'help' })
|
|
35
38
|
export const DAEMON_JOB_LABEL = 'com.glasspane.daemon'
|
|
36
39
|
export const NPM_PACKAGES = Object.freeze(['glasspane-mcp', 'glasspane-install'])
|
|
37
40
|
/** The three answers a read may give. `unverified` is the one that used to be missing everywhere. */
|
|
@@ -268,7 +271,7 @@ export function usageText() {
|
|
|
268
271
|
' update 问一次有没有新版本,有就装(走这台机器已经装着的那个更新器)',
|
|
269
272
|
' status 把更新器自己的那一行结论原样打出来',
|
|
270
273
|
' doctor 读这台机器的安装状态(安装指针、更新器、daemon 作业、socket、全局 npm 包)——只读,不写',
|
|
271
|
-
' help
|
|
274
|
+
' help 这一页(`-h`、`--help` 同义)',
|
|
272
275
|
'',
|
|
273
276
|
'退出码:0 = 成/都在位;2 = 用法错;3 = 被拒或有一格不在位(doctor 用它表示"有东西要看上面那几行");',
|
|
274
277
|
'4/5 来自 update(换版后回退 / 回退也没成),与更新器本身的口径一致。',
|
|
@@ -279,8 +282,12 @@ export function usageText() {
|
|
|
279
282
|
export function parseVerb(argv) {
|
|
280
283
|
const first = String(argv?.[0] ?? '')
|
|
281
284
|
if (argv.length === 0) return { verb: null, error: '没给命令' }
|
|
282
|
-
|
|
283
|
-
|
|
285
|
+
// `-h` / `--help` 与 `help` 同义:这条命令是本产品给 agent 的入口,而 agent 的第一反应就是
|
|
286
|
+
// `--help`。2026-10-27 用发布物实测:`/usr/local/bin/glasspane --help` 回"不认得命令「--help」"
|
|
287
|
+
// 且退出 2——同产品的 `installer/cli.js` 认 `--help`,两条入口对同一种问法给两种答案。
|
|
288
|
+
const verb = VERB_ALIASES[first] ?? first
|
|
289
|
+
if (!VERBS.includes(verb)) return { verb: null, error: `不认得命令「${first}」` }
|
|
290
|
+
return { verb, error: null, rest: argv.slice(1) }
|
|
284
291
|
}
|
|
285
292
|
|
|
286
293
|
/**
|
|
@@ -353,8 +360,24 @@ export async function main({ argv = process.argv.slice(2), env = process.env, sp
|
|
|
353
360
|
return result.exitCode
|
|
354
361
|
}
|
|
355
362
|
|
|
356
|
-
|
|
357
|
-
|
|
363
|
+
// Both sides must be realpath-resolved, not merely resolved against the cwd, and the comparison is
|
|
364
|
+
// wrapped because `realpathSync` throws for paths that do not exist yet — this runs at import time, so
|
|
365
|
+
// an uncomparable pair means "not invoked as a command", never "crash the entry". The URL is decoded
|
|
366
|
+
// with `fileURLToPath`, not read through `.pathname`: `.pathname` leaves a path containing a space or a
|
|
367
|
+
// CJK character percent-encoded (`/Users/John%20Smith/…`), which names no existing file, so the guard
|
|
368
|
+
// threw and `glasspane <任何动词>` died with a stack trace for every account whose home needs encoding.
|
|
369
|
+
// `updater/agent-entry.js` carries the same rule; this file was the face that never got it.
|
|
370
|
+
function invokedAsCommand() {
|
|
371
|
+
const entry = process.argv[1]
|
|
372
|
+
if (entry === undefined) return false
|
|
373
|
+
try {
|
|
374
|
+
return fs.realpathSync(entry) === fs.realpathSync(fileURLToPath(import.meta.url))
|
|
375
|
+
} catch {
|
|
376
|
+
return false
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (invokedAsCommand()) {
|
|
358
381
|
main().then((code) => {
|
|
359
382
|
process.exitCode = code
|
|
360
383
|
}, (error) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glasspane-install",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.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",
|