glasspane-install 1.23.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 +104 -18
- package/glasspane.js +19 -2
- 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] : []),
|
|
@@ -2374,7 +2385,11 @@ export function existingCloneDecision({ targetDir, ref, head = null, tagCommit =
|
|
|
2374
2385
|
+ shown.map((line) => ` ${line}`).join('\n')
|
|
2375
2386
|
+ (dirty.length > shown.length ? `\n …另有 ${dirty.length - shown.length} 处` : '')
|
|
2376
2387
|
+ `\n下一步(不销毁任何东西,两条都做得动):`
|
|
2377
|
-
|
|
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, '_')}" `
|
|
2378
2393
|
+ `sh install.sh(或 npx glasspane-install);`
|
|
2379
2394
|
+ `\n b) 自己收着这些改动:在 ${targetDir} 里把它们提交或转移到别处,再重跑本命令——`
|
|
2380
2395
|
+ '安装器不替你决定这些文件去哪。',
|
|
@@ -2426,7 +2441,7 @@ export function existingCloneDecision({ targetDir, ref, head = null, tagCommit =
|
|
|
2426
2441
|
* 只有 git 亲口答 `true` 才用 `--depth 1`;答不出来(老 git、不是仓库)按完整仓对待,宁可多取
|
|
2427
2442
|
* 一点也不给人家的历史加边界。
|
|
2428
2443
|
*/
|
|
2429
|
-
export function readCloneFacts({ targetDir, ref, spawn = spawnSync } = {}) {
|
|
2444
|
+
export function readCloneFacts({ targetDir, ref, spawn = spawnSync, fetchHead = false } = {}) {
|
|
2430
2445
|
const read = (args) => spawn('git', ['-C', targetDir, ...args], { encoding: 'utf8' })
|
|
2431
2446
|
const sha = (what) => {
|
|
2432
2447
|
const res = read(['rev-parse', '--verify', what])
|
|
@@ -2434,9 +2449,20 @@ export function readCloneFacts({ targetDir, ref, spawn = spawnSync } = {}) {
|
|
|
2434
2449
|
}
|
|
2435
2450
|
const status = read(['status', '--porcelain'])
|
|
2436
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)
|
|
2437
2463
|
return {
|
|
2438
2464
|
head: sha('HEAD'),
|
|
2439
|
-
tagCommit:
|
|
2465
|
+
tagCommit: refCommit,
|
|
2440
2466
|
// `git status` 答非零时这里得到空数组,读起来是"干净"——那棵树随后会因为 `head` 读不出而被拒,
|
|
2441
2467
|
// 所以"判不出来"走的是拒绝那一支,不是"那就切过去"。
|
|
2442
2468
|
blocking: status.status === 0 ? String(status.stdout ?? '').split('\n').filter((line) => line !== '') : [],
|
|
@@ -2444,6 +2470,27 @@ export function readCloneFacts({ targetDir, ref, spawn = spawnSync } = {}) {
|
|
|
2444
2470
|
}
|
|
2445
2471
|
}
|
|
2446
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> 之后重跑。',
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2447
2494
|
/**
|
|
2448
2495
|
* 把既有 clone 前进到钉住的 ref(IO 步;判据全在 `existingCloneDecision` 里)。
|
|
2449
2496
|
*
|
|
@@ -2453,43 +2500,82 @@ export function readCloneFacts({ targetDir, ref, spawn = spawnSync } = {}) {
|
|
|
2453
2500
|
* `checkout --detach <ref>`(只在树干净时执行)。没有 `-f`、没有 `stash`、没有 `clean`、
|
|
2454
2501
|
* 没有 `reset --hard`:一个 installer 不该有能把别人的未提交工作抹掉的那一格。
|
|
2455
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,切它才是这一句本来的意思。
|
|
2515
|
+
*/
|
|
2456
2516
|
export function advanceExistingClone({ repoDir, ref, spawn = spawnSync } = {}) {
|
|
2457
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
|
+
}
|
|
2458
2522
|
let facts = readCloneFacts({ targetDir: repoDir, ref, spawn })
|
|
2459
2523
|
if (facts.blocking.length === 0 && facts.head && !facts.tagCommit) {
|
|
2460
|
-
// tag
|
|
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
|
+
}
|
|
2461
2537
|
const fetchArgs = facts.shallow === true
|
|
2462
|
-
? ['fetch', '--tags', '--depth', '1',
|
|
2463
|
-
: ['fetch', '--tags',
|
|
2538
|
+
? ['fetch', '--tags', '--depth', '1', remote.remote, ref]
|
|
2539
|
+
: ['fetch', '--tags', remote.remote, ref]
|
|
2464
2540
|
const fetched = read(fetchArgs)
|
|
2465
2541
|
if (fetched.status !== 0) {
|
|
2466
2542
|
return {
|
|
2467
2543
|
action: 'refuse',
|
|
2468
2544
|
blocking: [],
|
|
2469
2545
|
fetchArgs,
|
|
2470
|
-
|
|
2546
|
+
remote: remote.remote,
|
|
2547
|
+
text: `${repoDir} 里没有 ${ref} 这个引用,而 `
|
|
2471
2548
|
+ `git -C "${repoDir}" ${fetchArgs.join(' ')} 退出 ${String(fetched.status ?? 'null')}:`
|
|
2472
2549
|
+ `${String(fetched.stderr ?? '').trim().split('\n')[0] || '(git 没有留下 stderr)'}。\n`
|
|
2473
|
-
+ '没有 clone
|
|
2474
|
-
+ `git ls-remote
|
|
2550
|
+
+ '没有 clone、没有切分支、没有写入任何东西。下一步:确认网络与该引用存在('
|
|
2551
|
+
+ `git ls-remote ${remote.remote} ${ref}),或 GLASSPANE_INSTALL_DIR=<空目录> 换一处全新的 clone。`,
|
|
2475
2552
|
}
|
|
2476
2553
|
}
|
|
2477
|
-
facts = readCloneFacts({ targetDir: repoDir, ref, spawn })
|
|
2554
|
+
facts = readCloneFacts({ targetDir: repoDir, ref, spawn, fetchHead: true })
|
|
2478
2555
|
}
|
|
2479
2556
|
let decision = existingCloneDecision({ targetDir: repoDir, ref, ...facts })
|
|
2480
2557
|
if (decision.action !== 'advance') return decision
|
|
2481
|
-
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])
|
|
2482
2568
|
if (out.status !== 0) {
|
|
2483
2569
|
return {
|
|
2484
2570
|
action: 'refuse',
|
|
2485
|
-
blocking: readCloneFacts({ targetDir: repoDir, ref, spawn }).blocking,
|
|
2486
|
-
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')}):`
|
|
2487
2573
|
+ `${String(out.stderr ?? '').trim().split('\n')[0] || '(git 没有留下 stderr)'}。\n`
|
|
2488
2574
|
+ `${repoDir} 仍在 ${String(facts.head ?? '读不出').slice(0, 7)},本轮没有构建任何东西。`,
|
|
2489
2575
|
}
|
|
2490
2576
|
}
|
|
2491
2577
|
// 切完再读一次:判据要落在**切换之后**的事实上,不是落在命令的退出码上。
|
|
2492
|
-
const after = readCloneFacts({ targetDir: repoDir, ref, spawn })
|
|
2578
|
+
const after = readCloneFacts({ targetDir: repoDir, ref, spawn, fetchHead: true })
|
|
2493
2579
|
const recheck = existingCloneDecision({ targetDir: repoDir, ref, ...after })
|
|
2494
2580
|
if (recheck.action !== 'use') {
|
|
2495
2581
|
return {
|
package/glasspane.js
CHANGED
|
@@ -29,6 +29,7 @@ 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'])
|
|
@@ -359,8 +360,24 @@ export async function main({ argv = process.argv.slice(2), env = process.env, sp
|
|
|
359
360
|
return result.exitCode
|
|
360
361
|
}
|
|
361
362
|
|
|
362
|
-
|
|
363
|
-
|
|
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()) {
|
|
364
381
|
main().then((code) => {
|
|
365
382
|
process.exitCode = code
|
|
366
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",
|