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.
Files changed (2) hide show
  1. package/cli.js +177 -49
  2. 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.6.2'
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
+ * 顺序要紧:先解 </>,最后才解 &,否则 `<` 会被多解一层。 */
534
+ function unescapeXml(value) {
535
+ return String(value)
536
+ .replace(/&lt;/g, '<')
537
+ .replace(/&gt;/g, '>')
538
+ .replace(/&quot;/g, '"')
539
+ .replace(/&apos;/g, "'")
540
+ .replace(/&amp;/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
- export function launchctlBootstrap(label, plistPath, { desiredProgram = null } = {}) {
535
- const uid = spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout.trim()
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 = spawnSync('launchctl', ['print', domain + '/' + label], { encoding: 'utf8' })
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
- spawnSync('launchctl', ['bootout', domain + '/' + label])
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 = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8' })
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
- return { ok: false, action: 'bootstrap-failed', message: `bootstrap 失败:${boot.message}` }
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
- mkdir(dir, { recursive: true })
1067
- return { ok: true, dir, error: null }
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
- let logStream
1100
- try {
1101
- logStream = fs.openSync(logPath, 'a')
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.exit(2)
1679
- }
1680
- if (options.help) {
1806
+ process.exitCode = 2
1807
+ } else if (options.help) {
1681
1808
  process.stdout.write(usageText())
1682
- process.exit(0)
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.exit(result.ok ? 0 : 1)
1691
- }
1692
-
1693
- const env = checkEnvironment()
1694
- const issues = preflightIssues(env)
1695
- if (issues.length > 0) {
1696
- process.stderr.write(paint('\n环境预检未通过:\n', 'red'))
1697
- for (const issue of issues) {
1698
- process.stderr.write(` - ${issue}\n`)
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.6.2",
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",