glasspane-install 1.9.0 → 1.11.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 +159 -11
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -69,7 +69,7 @@ export const INSTALL_SH_URL =
|
|
|
69
69
|
/** 发布线锚点(版本真源 = 根 package.json,由 scripts/set-version.mjs 统一改写,
|
|
70
70
|
* 勿手改):npx 形态下本地没有仓库时,引导 clone 的就是这个 tag,与 install.sh
|
|
71
71
|
* 的 `GLASSPANE_RELEASE` 同值——两条一键入口必须拿到同一份源码。 */
|
|
72
|
-
export const RELEASE_VERSION = '1.
|
|
72
|
+
export const RELEASE_VERSION = '1.11.0'
|
|
73
73
|
/** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
|
|
74
74
|
export const REPO_REF = `v${RELEASE_VERSION}`
|
|
75
75
|
|
|
@@ -762,11 +762,95 @@ export function classifyTagVerify({ status, stdout = '', stderr = '' } = {}) {
|
|
|
762
762
|
return { status: 'invalid', detail: `git verify-tag 失败(退出码 ${status}),签名无法确证` }
|
|
763
763
|
}
|
|
764
764
|
|
|
765
|
+
/**
|
|
766
|
+
* 验签结论怎么落成安装动作。三种结局各说各话:
|
|
767
|
+
* - `verified` 绿字继续;
|
|
768
|
+
* - `unsigned` / `unavailable` 黄字继续——缺的是来源证明的**增量**:被验的 tag 与
|
|
769
|
+
* 装进构建的源码是同一个 ref,HTTPS + tag 钉版本已经保证内容完整;
|
|
770
|
+
* - `invalid` 签名在、验不过,那说的不是"缺证明"而是"这份字节不是发布者签过的
|
|
771
|
+
* 那一份",必须**停下**。自动更新那一面对它是硬拒且 `--consent` 洗不掉
|
|
772
|
+
* (updater/lib/check.js 的门 8);安装这一面从前只黄字一句"继续安装"就往下跑
|
|
773
|
+
* npm install、swift build 并注册 launchd——同一件事在两条路上按两种诚实度处理,
|
|
774
|
+
* 而安装是更不可逆的那条。
|
|
775
|
+
* 判据放在纯函数里:这一段从前埋在 main() 的 if 里,没有一条测试能指到它。
|
|
776
|
+
*/
|
|
777
|
+
export function tagVerifyDecision({ sig, pinRef, repoDir }) {
|
|
778
|
+
if (sig.status === 'verified') {
|
|
779
|
+
return { action: 'pass', text: `发布 tag ${pinRef} GPG 签名验证通过` }
|
|
780
|
+
}
|
|
781
|
+
if (sig.status === 'invalid') {
|
|
782
|
+
return {
|
|
783
|
+
action: 'refuse',
|
|
784
|
+
text: `发布 tag ${pinRef} 带着 GPG 签名,但签名验不过:${sig.detail}\n`
|
|
785
|
+
+ '这不是"没签名"。签名在而校验失败,说明眼前这份 tag 指向的字节不是发布者签过的那一份。'
|
|
786
|
+
+ '安装到此为止:没有跑 npm install,没有 swift build,没有注册 launchd 作业。\n'
|
|
787
|
+
+ `下一步:核对远端与这个 tag(git -C "${repoDir}" remote -v;git -C "${repoDir}" tag -v ${pinRef});`
|
|
788
|
+
+ '确认仓库与发布者公钥都无误后改用更新的发布 tag(GLASSPANE_REF=v<版本> 重跑)。'
|
|
789
|
+
+ '要装开发主线用 GLASSPANE_REF=main(那条路不做 tag 验签,会如实告警)。',
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (sig.status === 'unsigned' || sig.status === 'unavailable') {
|
|
793
|
+
return {
|
|
794
|
+
action: 'warn',
|
|
795
|
+
text: `发布 tag ${pinRef} 没有可核验的 GPG 签名(${sig.status}:${sig.detail})`
|
|
796
|
+
+ '——继续安装:缺的是来源证明的增量,不是篡改迹象',
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// 没见过的新判据一律停下:把"不认识"当成"没签名"放过,等于每加一种失败模式
|
|
800
|
+
// 就自动退回成"继续安装"。
|
|
801
|
+
return {
|
|
802
|
+
action: 'refuse',
|
|
803
|
+
text: `发布 tag ${pinRef} 的验签回了一个安装器不认识的判据(${sig.status}:${sig.detail})。`
|
|
804
|
+
+ '安装到此为止:不认识的判据不许当成"没签名"放过。\n'
|
|
805
|
+
+ `下一步:手动核对(git -C "${repoDir}" tag -v ${pinRef}),确认无误后重新安装。`,
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
765
809
|
/** 仓库里是否存在指定 tag ref(IO 探测,纯判定)。 */
|
|
766
810
|
export function repoHasRef(repoDir, ref, { spawn = spawnSync } = {}) {
|
|
767
811
|
return spawn('git', ['-C', repoDir, 'rev-parse', '--verify', `refs/tags/${ref}`], { stdio: 'ignore' }).status === 0
|
|
768
812
|
}
|
|
769
813
|
|
|
814
|
+
/**
|
|
815
|
+
* 要构建的这棵树,是不是被验签的那个 tag 本身(IO 探测,纯判定)。
|
|
816
|
+
*
|
|
817
|
+
* 存在的理由:`rootDir` 可以来自 `--repo`、`GLASSPANE_REPO`、当前目录或既有 clone,而
|
|
818
|
+
* `install.sh` 自己就写着"既有 clone 不会被自动切到 $REF"。那条绿字说的若是
|
|
819
|
+
* "签名验证通过",它证明的却只是 *tag 对象* 的签名——签的不是 HEAD 加一堆本地改动。
|
|
820
|
+
* 签名与产物之间这层对应关系一旦断,绿字就成了对没签过的字节作保。
|
|
821
|
+
*/
|
|
822
|
+
export function tagTreeAgreement({ repoDir, ref, spawn = spawnSync } = {}) {
|
|
823
|
+
const head = spawn('git', ['-C', repoDir, 'rev-parse', '--verify', 'HEAD'], { encoding: 'utf8' })
|
|
824
|
+
const tagged = spawn('git', ['-C', repoDir, 'rev-parse', '--verify', `${ref}^{commit}`], { encoding: 'utf8' })
|
|
825
|
+
if (head.status !== 0 || tagged.status !== 0) {
|
|
826
|
+
return { ok: false, same: false, changed: null, head: null, tagCommit: null }
|
|
827
|
+
}
|
|
828
|
+
const work = spawn('git', ['-C', repoDir, 'status', '--porcelain'], { encoding: 'utf8' })
|
|
829
|
+
const changed = String(work.stdout ?? '').split('\n').filter((line) => line !== '').length
|
|
830
|
+
const headSha = String(head.stdout ?? '').trim()
|
|
831
|
+
const tagSha = String(tagged.stdout ?? '').trim()
|
|
832
|
+
return { ok: true, same: headSha === tagSha && changed === 0, changed, head: headSha, tagCommit: tagSha }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* "签名有效" 与 "要装的就是被签的那棵树" 是两件事,绿字必须两件事都说得出。
|
|
837
|
+
* 树对不上时不拒装(装开发主线本来就合法),但绝不能沿用那句"验证通过"。
|
|
838
|
+
*/
|
|
839
|
+
export function tagVerifyAgainstTree({ verdict, tree, pinRef, repoDir }) {
|
|
840
|
+
if (verdict.action !== 'pass') return verdict
|
|
841
|
+
if (tree.ok && tree.same) return verdict
|
|
842
|
+
const why = !tree.ok
|
|
843
|
+
? '读不出这棵树的 HEAD 与该 tag 指向的 commit'
|
|
844
|
+
: `HEAD 是 ${String(tree.head ?? '').slice(0, 7)},tag 指向 ${String(tree.tagCommit ?? '').slice(0, 7)},另有 ${tree.changed} 处未提交改动`
|
|
845
|
+
return {
|
|
846
|
+
action: 'warn',
|
|
847
|
+
text: `发布 tag ${pinRef} 的 GPG 签名有效,但它签的不是本次要装的这棵树:${why}。\n`
|
|
848
|
+
+ '安装继续,装出来的是这棵树的内容,不是那个已签名发布的内容——这句不说清楚,屏幕上那行绿字就是替没验过的字节作保。\n'
|
|
849
|
+
+ `下一步(要装被签的那一版):git -C "${repoDir}" checkout ${pinRef} 后重跑,或用一键安装脚本从发布 tag 另起一份:`
|
|
850
|
+
+ `curl -fsSL ${INSTALL_SH_URL} | sh`,
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
770
854
|
/** 对 clone 下来的发布 tag 做 GPG 验签(best-effort,不抛错)。
|
|
771
855
|
* 隔离 GNUPGHOME 并临时导入发布公钥,`git verify-tag` 只看退出码与 stderr;不动
|
|
772
856
|
* 用户钥匙环、无网络依赖。返回 classifyTagVerify 的口径(verified/unsigned/invalid/
|
|
@@ -1474,6 +1558,55 @@ export function installOutcome({ verified = false, daemonExpected = true } = {})
|
|
|
1474
1558
|
return { ok, exitCode: ok ? 0 : 1, verified: Boolean(verified), daemonExpected }
|
|
1475
1559
|
}
|
|
1476
1560
|
|
|
1561
|
+
/**
|
|
1562
|
+
* 收尾那句"实测校验通过"到底能说什么,取决于两件事:有没有 daemon 应答,以及应答的那一份
|
|
1563
|
+
* **是不是本轮刚构建的产物**。从前这里只看 `hello?.version` 有没有值——旧实例占着 socket 时
|
|
1564
|
+
* (上面那条"daemon 已在监听…跳过启动"分支)应答的是上一版构建,新构建从未被启动也从未被验证,
|
|
1565
|
+
* 安装器却打印"实测校验通过…安装完成"并退 0,而 `install.sh` 是 exec 委托:按退出码办事的
|
|
1566
|
+
* agent 只看到 0。
|
|
1567
|
+
*/
|
|
1568
|
+
export function installVerification({
|
|
1569
|
+
arrived = false,
|
|
1570
|
+
helloVersion = null,
|
|
1571
|
+
builtVersion = null,
|
|
1572
|
+
servedByEarlierInstance = false,
|
|
1573
|
+
} = {}) {
|
|
1574
|
+
if (!arrived || !helloVersion) return { verified: false, note: null }
|
|
1575
|
+
if (!builtVersion) {
|
|
1576
|
+
return {
|
|
1577
|
+
verified: false,
|
|
1578
|
+
note: '读不出本轮构建产物的版本(`glasspaned --version` 没给出可解析的一行),'
|
|
1579
|
+
+ '所以无法证明应答 hello 的那一份就是本轮构建——本次不算校验通过。',
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
if (helloVersion !== builtVersion) {
|
|
1583
|
+
return {
|
|
1584
|
+
verified: false,
|
|
1585
|
+
note: `应答 hello 的 daemon 自报 ${helloVersion},本轮构建产物是 ${builtVersion}:socket 被旧实例占着,`
|
|
1586
|
+
+ '这一轮的构建从未被验证。用 --replace-daemon 重跑(它会先收拢在跑的实例)。',
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
if (servedByEarlierInstance) {
|
|
1590
|
+
return {
|
|
1591
|
+
verified: true,
|
|
1592
|
+
note: `版本号一致(${builtVersion}),但应答的实例在本次安装之前就已经在服务:同一版本号只能说明它是`
|
|
1593
|
+
+ '同一版本,不能证明它是本轮构建出来的那一份。要确证,用 --replace-daemon 重跑一次。',
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
return { verified: true, note: null }
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/** 本轮构建产物自报的版本(`glasspaned --version` 那一行);读不出返回 null,不猜。 */
|
|
1600
|
+
export function builtDaemonVersion({ daemonBin, spawn = spawnSync } = {}) {
|
|
1601
|
+
const res = spawn(daemonBin, ['--version'], { encoding: 'utf8' })
|
|
1602
|
+
if (res.error || res.status !== 0) return null
|
|
1603
|
+
const line = String(res.stdout ?? '')
|
|
1604
|
+
.split('\n')
|
|
1605
|
+
.map((text) => text.trim())
|
|
1606
|
+
.find((text) => /^\d+\.\d+\.\d+/.test(text))
|
|
1607
|
+
return line ? line.split(/\s+/)[0] : null
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1477
1610
|
/** 未通过校验时的提示行(纯函数,可单测)。必须自带**用户真能执行**的补救命令,
|
|
1478
1611
|
* 并点明退出码非零——只说"安装未完成"的话,脚本调用方什么也拿不到。 */
|
|
1479
1612
|
export function verificationFailureText({
|
|
@@ -1526,17 +1659,19 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1526
1659
|
rootDir = plan.targetDir
|
|
1527
1660
|
}
|
|
1528
1661
|
|
|
1529
|
-
// 发布 tag GPG
|
|
1530
|
-
// 公钥验签其签名。未签名/不可用只告警不阻断——HTTPS clone + tag 钉版本已保证内容
|
|
1531
|
-
// 完整性,GPG 是来源证明的增量(见 verifyCloneTagGpg)。
|
|
1662
|
+
// 发布 tag GPG 验签:动作怎么落由 `tagVerifyDecision` 判(纯函数,有单测)。
|
|
1532
1663
|
const pinRef = env.GLASSPANE_REF || REPO_REF
|
|
1533
1664
|
if (pinRef !== 'main' && repoHasRef(rootDir, pinRef)) {
|
|
1534
1665
|
const sig = verifyCloneTagGpg({ repoDir: rootDir, ref: pinRef })
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1666
|
+
// 先判"签名对不对",再判"要装的是不是被签的那棵树"——两问分开,绿字才只在对得上时出现。
|
|
1667
|
+
const verdict = tagVerifyAgainstTree({
|
|
1668
|
+
verdict: tagVerifyDecision({ sig, pinRef, repoDir: rootDir }),
|
|
1669
|
+
tree: tagTreeAgreement({ repoDir: rootDir, ref: pinRef }),
|
|
1670
|
+
pinRef,
|
|
1671
|
+
repoDir: rootDir,
|
|
1672
|
+
})
|
|
1673
|
+
if (verdict.action === 'refuse') throw new Error(verdict.text)
|
|
1674
|
+
printStep(paint(verdict.text, verdict.action === 'pass' ? 'green' : 'yellow'))
|
|
1540
1675
|
}
|
|
1541
1676
|
|
|
1542
1677
|
const engineDir = path.join(rootDir, 'engine')
|
|
@@ -1545,6 +1680,8 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1545
1680
|
const daemonBin = path.join(buildDir, 'glasspaned')
|
|
1546
1681
|
const settingsBin = path.join(buildDir, 'glasspane-settings')
|
|
1547
1682
|
const daemonLog = path.join(process.env.HOME ?? '', SOCKET_DIR_NAME, DAEMON_LOG_NAME)
|
|
1683
|
+
// 收尾那句"实测校验通过"要说实话,就得知道应答的实例是不是本次安装启动/重启的那一份。
|
|
1684
|
+
let servedByEarlierInstance = false
|
|
1548
1685
|
// bundle 安置计划(P1 v1.2 §11.1):--no-app 时 appPackaged=false,后续一律
|
|
1549
1686
|
// 回退裸二进制并如实告警。
|
|
1550
1687
|
const plan = bundlePlan({
|
|
@@ -1704,6 +1841,9 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1704
1841
|
}
|
|
1705
1842
|
}
|
|
1706
1843
|
if (relaunchedByLaunchd || await socketReachable(socketPath)) {
|
|
1844
|
+
// launchd 刚 kickstart 起来的那一份算"本次重启的";纯粹是 socket 早就有人听着,
|
|
1845
|
+
// 就是别人(很可能是上一版)在服务。
|
|
1846
|
+
servedByEarlierInstance = !relaunchedByLaunchd
|
|
1707
1847
|
printStep(`daemon 已在监听 ${socketPath},跳过启动(若为旧构建实例,用 --replace-daemon 重跑)`)
|
|
1708
1848
|
} else {
|
|
1709
1849
|
const start = daemonStartCommand({ daemonLaunch, daemonApp: plan.daemonApp, socketPath })
|
|
@@ -1754,12 +1894,20 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1754
1894
|
const daemonExpected = Boolean(options.daemon || options.launchd)
|
|
1755
1895
|
const arrived = await waitForSocket(socketPath, { timeoutMs: daemonExpected ? 12000 : 1 })
|
|
1756
1896
|
const hello = arrived ? await socketHello(socketPath) : null
|
|
1757
|
-
const
|
|
1897
|
+
const builtVersion = builtDaemonVersion({ daemonBin })
|
|
1898
|
+
const { verified, note } = installVerification({
|
|
1899
|
+
arrived,
|
|
1900
|
+
helloVersion: hello?.version ?? null,
|
|
1901
|
+
builtVersion,
|
|
1902
|
+
servedByEarlierInstance,
|
|
1903
|
+
})
|
|
1758
1904
|
const outcome = installOutcome({ verified, daemonExpected })
|
|
1759
1905
|
if (verified) {
|
|
1760
|
-
printStep(`实测校验通过:daemon 在 ${socketPath} 应答 hello(version=${hello.version}${hello.pid ? `,pid=${hello.pid}` : ''})`)
|
|
1906
|
+
printStep(`实测校验通过:daemon 在 ${socketPath} 应答 hello(version=${hello.version}${hello.pid ? `,pid=${hello.pid}` : ''},本轮构建产物=${builtVersion})`)
|
|
1907
|
+
if (note) printStep(paint(note, 'yellow'))
|
|
1761
1908
|
printStep(`安装完成(退出码 ${outcome.exitCode})。`)
|
|
1762
1909
|
} else if (daemonExpected) {
|
|
1910
|
+
if (note) printStep(paint(note, 'red'))
|
|
1763
1911
|
for (const line of verificationFailureText({
|
|
1764
1912
|
arrived,
|
|
1765
1913
|
socketPath,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glasspane-install",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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",
|