glasspane-install 1.6.2 → 1.8.1
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 +177 -49
- 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.8.1'
|
|
73
73
|
/** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
|
|
74
74
|
export const REPO_REF = `v${RELEASE_VERSION}`
|
|
75
75
|
|
|
@@ -529,28 +529,80 @@ export function launchdNeedsReregister({ alreadyLoaded, loadedProgram = null, de
|
|
|
529
529
|
return loadedProgram !== String(desiredProgram ?? '')
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
+
/** 把 plist 文本里的转义还原回真实路径(与 launchdPlistString 的 escapeXml 反向)。
|
|
533
|
+
* 顺序要紧:先解 </>,最后才解 &,否则 `&lt;` 会被多解一层。 */
|
|
534
|
+
function unescapeXml(value) {
|
|
535
|
+
return String(value)
|
|
536
|
+
.replace(/</g, '<')
|
|
537
|
+
.replace(/>/g, '>')
|
|
538
|
+
.replace(/"/g, '"')
|
|
539
|
+
.replace(/'/g, "'")
|
|
540
|
+
.replace(/&/g, '&')
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** 从 plist 文本取本次要装的程序路径:`Program` 单值,或 `ProgramArguments` 的首项。
|
|
544
|
+
* `launchctl print` 报的 `program =` 就是这一个值,所以两边必须同源于渲染出来的 plist——
|
|
545
|
+
* restore 路径手上只有 plist 文件,拿不到"我想装什么"。 */
|
|
546
|
+
export function launchdProgramFromPlistText(xml) {
|
|
547
|
+
const text = String(xml ?? '')
|
|
548
|
+
const single = /<key>Program<\/key>\s*<string>([\s\S]*?)<\/string>/.exec(text)
|
|
549
|
+
if (single) return unescapeXml(single[1]).trim()
|
|
550
|
+
const args = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)
|
|
551
|
+
if (!args) return null
|
|
552
|
+
const first = /<string>([\s\S]*?)<\/string>/.exec(args[1])
|
|
553
|
+
return first ? unescapeXml(first[1]).trim() : null
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** plist 读不回东西时回 null:`launchdNeedsReregister` 把"读不到定义"一律判成要重来,这是
|
|
557
|
+
* 保守方向(宁可重注册,也不要在旧定义上假装成功),所以这里不许编一个路径出来。 */
|
|
558
|
+
function readPlistProgram(readFile, plistPath) {
|
|
559
|
+
try {
|
|
560
|
+
return launchdProgramFromPlistText(readFile(plistPath))
|
|
561
|
+
} catch {
|
|
562
|
+
return null
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
532
566
|
/** 注册 launchd 用户代理(幂等):已加载则跳过,未加载则 bootstrap
|
|
533
|
-
* gui/<uid>。返回 { ok, already, message },失败携带真实 stderr。
|
|
534
|
-
|
|
535
|
-
|
|
567
|
+
* gui/<uid>。返回 { ok, already, message },失败携带真实 stderr。
|
|
568
|
+
*
|
|
569
|
+
* `desiredProgram` 缺失时**自己从 plist 读**:调用方之一是 `--restore-launchd`,它手里只有
|
|
570
|
+
* plist 路径,而它偏偏是安装器交给 agent 的"daemon 不可达"补救命令。以前不传就等于跟 ''
|
|
571
|
+
* 比,任何已加载作业都被判成"定义变了"→ bootout 一个健康作业(打断正在跑的 act)。 */
|
|
572
|
+
export function launchctlBootstrap(label, plistPath, { desiredProgram = null, spawn = spawnSync, readFile = (file) => fs.readFileSync(file, 'utf8') } = {}) {
|
|
573
|
+
const uid = spawn('id', ['-u'], { encoding: 'utf8' }).stdout.trim()
|
|
536
574
|
if (!uid) {
|
|
537
575
|
return { ok: false, already: false, message: '无法解析当前 uid(id -u 失败),跳过 launchd 注册' }
|
|
538
576
|
}
|
|
539
577
|
const domain = `gui/${uid}`
|
|
540
|
-
const probe =
|
|
578
|
+
const probe = spawn('launchctl', ['print', domain + '/' + label], { encoding: 'utf8' })
|
|
579
|
+
const wantProgram = desiredProgram ?? readPlistProgram(readFile, plistPath)
|
|
541
580
|
const needsReregister = launchdNeedsReregister({
|
|
542
581
|
alreadyLoaded: probe.status === 0,
|
|
543
582
|
loadedProgram: loadedLaunchdProgram(probe.stdout),
|
|
544
|
-
desiredProgram,
|
|
583
|
+
desiredProgram: wantProgram,
|
|
545
584
|
})
|
|
546
585
|
if (probe.status === 0 && !needsReregister) {
|
|
547
586
|
return { ok: true, already: true, message: `launchd 已加载 ${label}(${domain}),跳过 bootstrap` }
|
|
548
587
|
}
|
|
549
588
|
if (probe.status === 0) {
|
|
550
589
|
// 定义变了:先卸掉旧作业,否则新 plist 不生效(真机 EX_CONFIG 的来路)。
|
|
551
|
-
|
|
590
|
+
// bootout 的退出码必须判:它失败时紧随的 bootstrap 只会回 5 / "already loaded",
|
|
591
|
+
// 而那一支被下面映射成 ok:true —— 于是安装器打印"开机自启已注册",launchd 却还在
|
|
592
|
+
// 按旧定义 spawn(正是 L519-525 那段注释说这次设计要消灭的 EX_CONFIG 陷阱)。
|
|
593
|
+
const out = spawn('launchctl', ['bootout', domain + '/' + label], { encoding: 'utf8' })
|
|
594
|
+
const outStatus = out?.status ?? null
|
|
595
|
+
const outError = String(out?.stderr ?? '').trim()
|
|
596
|
+
if (outStatus !== 0) {
|
|
597
|
+
return {
|
|
598
|
+
ok: false,
|
|
599
|
+
already: false,
|
|
600
|
+
bootoutFailed: true,
|
|
601
|
+
message: `launchctl bootout ${domain}/${label} 未成功(退出码 ${outStatus}${outError ? `:${outError}` : ''}),旧作业仍挂在 launchd 上,因此没有继续 bootstrap——此刻在跑的仍是旧定义 ${loadedLaunchdProgram(probe.stdout) ?? '(未读到)'},而新 plist 写的是 ${wantProgram ?? '(读不到)'}。请按顺序手工执行:launchctl bootout ${domain}/${label}(仍失败就换 launchctl remove ${label}),确认 launchctl print ${domain}/${label} 报 Could not find service,然后重跑 install.sh(或 node installer/cli.js --restore-launchd)。`,
|
|
602
|
+
}
|
|
603
|
+
}
|
|
552
604
|
}
|
|
553
|
-
const boot =
|
|
605
|
+
const boot = spawn('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8' })
|
|
554
606
|
const stderr = (boot.stderr ?? '').trim()
|
|
555
607
|
const status = boot.status
|
|
556
608
|
if (status === 0 || status === 5 || /already/i.test(stderr)) {
|
|
@@ -645,7 +697,13 @@ export async function restoreLaunchd({
|
|
|
645
697
|
}
|
|
646
698
|
const boot = bootstrapFn(label, plistPath)
|
|
647
699
|
if (!boot.ok) {
|
|
648
|
-
|
|
700
|
+
// 两种"没注册上"要分开说:bootout 失败意味着旧作业还在跑旧定义(agent 下一步是把它卸掉),
|
|
701
|
+
// bootstrap 失败才是"卸干净了但装不上"。合成一句会把人支到错的那条命令上去。
|
|
702
|
+
return {
|
|
703
|
+
ok: false,
|
|
704
|
+
action: boot.bootoutFailed ? 'bootout-failed' : 'bootstrap-failed',
|
|
705
|
+
message: boot.bootoutFailed ? boot.message : `bootstrap 失败:${boot.message}`,
|
|
706
|
+
}
|
|
649
707
|
}
|
|
650
708
|
const verify = async () => {
|
|
651
709
|
if (!(await waitFn(socketPath, { timeoutMs: serveTimeoutMs }))) {
|
|
@@ -1049,6 +1107,17 @@ export async function confirm(question, { autoYes = false } = {}) {
|
|
|
1049
1107
|
}
|
|
1050
1108
|
}
|
|
1051
1109
|
|
|
1110
|
+
/** 状态根/日志目录与其里那份日志的模式(与 updater 的 fsutil 同一组数:0700 / 0600)。
|
|
1111
|
+
* installer 是零依赖单文件,所以这里自己写死,不去 import updater/lib/fsutil.js。 */
|
|
1112
|
+
export const STATE_DIR_MODE = 0o700
|
|
1113
|
+
export const STATE_FILE_MODE = 0o600
|
|
1114
|
+
|
|
1115
|
+
/** 把人话版的八进制模式写全前导零:0o755 打出来是 `0755` 而不是 `755`,拒绝里出现的数
|
|
1116
|
+
* 要能被直接拿去和 ls -l 的答案对。 */
|
|
1117
|
+
export function formatMode(mode) {
|
|
1118
|
+
return `0${(Number(mode ?? 0) & 0o777).toString(8).padStart(3, '0')}`
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1052
1121
|
/** 创建日志/socket 目录(`~/.glasspane`),一次性、幂等(A-8 的后半段)。
|
|
1053
1122
|
*
|
|
1054
1123
|
* 为什么必须由安装器在**写 plist 之前**做,而不是等 startDetached 顺手 mkdir:
|
|
@@ -1060,16 +1129,68 @@ export async function confirm(question, { autoYes = false } = {}) {
|
|
|
1060
1129
|
* launchd,而它要写的目录还不存在。startDetached 里的 mkdirSync 救不了这一枪。
|
|
1061
1130
|
*
|
|
1062
1131
|
* 不改变任何文件位置:只建目录,路径口径与 install() 里算出来的完全一致。 */
|
|
1063
|
-
export function ensureLogDir(logPath, { mkdir = fs.mkdirSync } = {}) {
|
|
1132
|
+
export function ensureLogDir(logPath, { mkdir = fs.mkdirSync, chmod = fs.chmodSync, statMode = (p) => fs.statSync(p).mode & 0o777 } = {}) {
|
|
1064
1133
|
const dir = path.dirname(logPath)
|
|
1065
1134
|
try {
|
|
1066
|
-
|
|
1067
|
-
|
|
1135
|
+
// mode 只作用在**新建**的那一层、还要被 umask 削一刀;目录已经存在时(上一轮安装留下的
|
|
1136
|
+
// 0755)mkdirSync 一个字都不改。这个目录里放着 socket、审批记录、证据和 installer-daemon.log,
|
|
1137
|
+
// 所以 0700 是要求而不是建议:补一次 chmod,再把模式读回来。
|
|
1138
|
+
mkdir(dir, { recursive: true, mode: STATE_DIR_MODE })
|
|
1139
|
+
chmod(dir, STATE_DIR_MODE)
|
|
1140
|
+
const mode = statMode(dir)
|
|
1141
|
+
if (mode !== STATE_DIR_MODE) {
|
|
1142
|
+
// 没落住的 chmod 按**写失败**报告,而不是"我们至少试过了"——与 updater/fsutil.tightenMode
|
|
1143
|
+
// 同一个判据(卷不保存权限位时,owner-only 这件事在这里就做不到)。
|
|
1144
|
+
return {
|
|
1145
|
+
ok: false,
|
|
1146
|
+
dir,
|
|
1147
|
+
mode,
|
|
1148
|
+
error: `日志/socket 目录 ${dir} 在 chmod(0700) 之后仍是 ${formatMode(mode)}:这台机器的卷不保存权限位,owner-only 的 socket 目录做不到,安装在这里中止(不是继续写进去)`,
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return { ok: true, dir, mode, error: null }
|
|
1068
1152
|
} catch (error) {
|
|
1069
1153
|
return { ok: false, dir, error: `日志/socket 目录不可用:${dir}(${error.message})` }
|
|
1070
1154
|
}
|
|
1071
1155
|
}
|
|
1072
1156
|
|
|
1157
|
+
/** 以 owner-only(0600) 打开或创建 daemon 日志,并把模式**读回来**。
|
|
1158
|
+
*
|
|
1159
|
+
* 为什么不能只 `openSync(logPath, 'a')`:那条口令既没有 mode(新建文件因此拿到 0644 & ~umask),
|
|
1160
|
+
* 也不改**已存在**文件的模式(上一轮安装留下的 0644 就这么留着)。这份日志里是审批与证据线索,
|
|
1161
|
+
* 而 daemon 的启动清扫只收紧 registry/approvals/evidence(engine StateRoot.swift 的那一段),
|
|
1162
|
+
* 从不碰它——SECURITY.md 记的正是这条"量到而未修"。chmod 没落住按写失败报告。 */
|
|
1163
|
+
export function openPrivateLog(logPath, {
|
|
1164
|
+
open = (file) => fs.openSync(file, 'a', STATE_FILE_MODE),
|
|
1165
|
+
chmod = fs.fchmodSync,
|
|
1166
|
+
fstat = fs.fstatSync,
|
|
1167
|
+
close = fs.closeSync,
|
|
1168
|
+
} = {}) {
|
|
1169
|
+
let fd
|
|
1170
|
+
try {
|
|
1171
|
+
fd = open(logPath)
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
return { ok: false, fd: null, mode: null, error: `日志文件打不开:${error.message}` }
|
|
1174
|
+
}
|
|
1175
|
+
try {
|
|
1176
|
+
chmod(fd, STATE_FILE_MODE)
|
|
1177
|
+
const mode = Number(fstat(fd).mode ?? 0) & 0o777
|
|
1178
|
+
if (mode !== STATE_FILE_MODE) {
|
|
1179
|
+
try { close(fd) } catch { /* 已经决定失败了 */ }
|
|
1180
|
+
return {
|
|
1181
|
+
ok: false,
|
|
1182
|
+
fd: null,
|
|
1183
|
+
mode,
|
|
1184
|
+
error: `日志文件 ${logPath} 在 chmod(0600) 之后仍是 ${formatMode(mode)}:这台机器的卷不保存权限位,owner-only 的日志做不到(同一次写按失败算,不当成功报告)`,
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return { ok: true, fd, mode, error: null }
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
try { close(fd) } catch { /* 已经决定失败了 */ }
|
|
1190
|
+
return { ok: false, fd: null, mode: null, error: `日志文件打不开:${error.message}` }
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1073
1194
|
/** 后台分离启动长驻进程(daemon / GUI),日志追加写盘。
|
|
1074
1195
|
*
|
|
1075
1196
|
* 返回 `{ ok, pid, error, exit }` 而不是裸 PID:spawn 的失败是**异步** error 事件,
|
|
@@ -1090,18 +1211,18 @@ export function ensureLogDir(logPath, { mkdir = fs.mkdirSync } = {}) {
|
|
|
1090
1211
|
*
|
|
1091
1212
|
* 日志目录(`~/.glasspane`)由 install() 在建 plist、bootstrap 之前一次性创建
|
|
1092
1213
|
* (见 ensureLogDir);这里的 mkdirSync 只是给直接调用方与单测留的兜底。 */
|
|
1093
|
-
export async function startDetached(binPath, args, logPath, { settleMs = LAUNCH_SETTLE_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
1214
|
+
export async function startDetached(binPath, args, logPath, { settleMs = LAUNCH_SETTLE_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), openLog = openPrivateLog } = {}) {
|
|
1094
1215
|
try {
|
|
1095
|
-
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
|
1216
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true, mode: STATE_DIR_MODE })
|
|
1217
|
+
fs.chmodSync(path.dirname(logPath), STATE_DIR_MODE)
|
|
1096
1218
|
} catch (error) {
|
|
1097
1219
|
return { ok: false, pid: null, error: `日志目录不可用:${error.message}`, exit: null }
|
|
1098
1220
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
} catch (error) {
|
|
1103
|
-
return { ok: false, pid: null, error: `日志文件打不开:${error.message}`, exit: null }
|
|
1221
|
+
const opened = openLog(logPath)
|
|
1222
|
+
if (!opened.ok) {
|
|
1223
|
+
return { ok: false, pid: null, error: opened.error, exit: null }
|
|
1104
1224
|
}
|
|
1225
|
+
const logStream = opened.fd
|
|
1105
1226
|
const child = spawn(binPath, args, {
|
|
1106
1227
|
detached: true,
|
|
1107
1228
|
stdio: ['ignore', logStream, logStream],
|
|
@@ -1671,45 +1792,52 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1671
1792
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
1672
1793
|
|
|
1673
1794
|
if (isMain) {
|
|
1795
|
+
// 入口的**每一条**退出都交给事件循环,没有例外。`curl | sh` 是文档里的调用形态,
|
|
1796
|
+
// stdout 接的是管道:`process.exit()` 抢在 flush 之前退,会把刚写出去的那几行
|
|
1797
|
+
// (usage、预检清单、restore 的判定句)连根截掉——而失败时用户能读的正是这几行。
|
|
1798
|
+
// 上一轮只把 install 的 .catch 改成了 `process.exitCode`,参数错误/--help/
|
|
1799
|
+
// --restore-launchd/环境预检这四条还留着 `process.exit()`;Linux runner 上就是
|
|
1800
|
+
// 从这里现形的(那台机器没有 swift,预检拒绝必然走到,而测试原本指望走到 install
|
|
1801
|
+
// 的拒绝分支——一条依赖机器状态的断言)。
|
|
1674
1802
|
const { options, error } = parseArgs(process.argv.slice(2))
|
|
1675
1803
|
if (error) {
|
|
1676
1804
|
process.stderr.write(`\n${paint(`参数错误:${error}`, 'red')}\n\n`)
|
|
1677
1805
|
process.stderr.write(usageText())
|
|
1678
|
-
process.
|
|
1679
|
-
}
|
|
1680
|
-
if (options.help) {
|
|
1806
|
+
process.exitCode = 2
|
|
1807
|
+
} else if (options.help) {
|
|
1681
1808
|
process.stdout.write(usageText())
|
|
1682
|
-
process.
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
if (options.restoreLaunchd) {
|
|
1809
|
+
process.exitCode = 0
|
|
1810
|
+
} else if (options.restoreLaunchd) {
|
|
1686
1811
|
// 审计项④:恢复 + 校验全机器化,退出码即判定(0=daemon 就位且已按实际
|
|
1687
1812
|
// 自报如实报告;1=作业/服务面失败),agent 可直接执行并按 message 续办。
|
|
1688
1813
|
const result = await restoreLaunchd()
|
|
1689
1814
|
process.stdout.write(`${result.message}\n`)
|
|
1690
|
-
process.
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1815
|
+
process.exitCode = result.ok ? 0 : 1
|
|
1816
|
+
} else {
|
|
1817
|
+
const env = checkEnvironment()
|
|
1818
|
+
const issues = preflightIssues(env)
|
|
1819
|
+
if (issues.length > 0) {
|
|
1820
|
+
process.stderr.write(paint('\n环境预检未通过:\n', 'red'))
|
|
1821
|
+
for (const issue of issues) {
|
|
1822
|
+
process.stderr.write(` - ${issue}\n`)
|
|
1823
|
+
}
|
|
1824
|
+
process.stderr.write('\n修复后重新运行即可。\n')
|
|
1825
|
+
process.exitCode = 1
|
|
1826
|
+
} else {
|
|
1827
|
+
install({ options })
|
|
1828
|
+
.then((outcome) => {
|
|
1829
|
+
process.stdout.write('\n')
|
|
1830
|
+
// 退出码 = 收尾校验结论。用 process.exitCode 而不是 process.exit():让上面的
|
|
1831
|
+
// 说明文本先冲干净(`curl | sh` 常接管道,截断会把"下一步怎么办"整段吃掉)。
|
|
1832
|
+
process.exitCode = outcome.exitCode
|
|
1833
|
+
})
|
|
1834
|
+
.catch((installError) => {
|
|
1835
|
+
process.stderr.write(paint(`\n安装失败:${installError.message}\n`, 'red'))
|
|
1836
|
+
// 与成功路径同一套:用 process.exitCode,不用 process.exit()。`curl | sh` 是文档里的调用
|
|
1837
|
+
// 形态,stdout 接的是管道——exit() 会把还排在队列里的步骤输出连根截掉,而那正是用户
|
|
1838
|
+
// 唯一需要读的一段(哪一步炸的、下一步跑什么)。
|
|
1839
|
+
process.exitCode = 1
|
|
1840
|
+
})
|
|
1699
1841
|
}
|
|
1700
|
-
process.stderr.write('\n修复后重新运行即可。\n')
|
|
1701
|
-
process.exit(1)
|
|
1702
1842
|
}
|
|
1703
|
-
|
|
1704
|
-
install({ options })
|
|
1705
|
-
.then((outcome) => {
|
|
1706
|
-
process.stdout.write('\n')
|
|
1707
|
-
// 退出码 = 收尾校验结论。用 process.exitCode 而不是 process.exit():让上面的
|
|
1708
|
-
// 说明文本先冲干净(`curl | sh` 常接管道,截断会把"下一步怎么办"整段吃掉)。
|
|
1709
|
-
process.exitCode = outcome.exitCode
|
|
1710
|
-
})
|
|
1711
|
-
.catch((installError) => {
|
|
1712
|
-
process.stderr.write(paint(`\n安装失败:${installError.message}\n`, 'red'))
|
|
1713
|
-
process.exit(1)
|
|
1714
|
-
})
|
|
1715
1843
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glasspane-install",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
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",
|