@inventec/dsh-copilot-auth 1.2.3 → 1.2.4
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/README.md +3 -1
- package/lib/client.js +31 -9
- package/package.json +1 -1
- package/src/catalog.mjs +44 -2
- package/src/host.mjs +57 -18
- package/src/refresh-flow.mjs +8 -3
package/README.md
CHANGED
|
@@ -81,6 +81,7 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 ge
|
|
|
81
81
|
- **数据源与回退**:账号可用模型现场拉取(失败回退凭证缓存);目录数据从 npm 拉取最新 pi-ai tarball(强制 `dist.integrity` 校验、超时与大小上限、tar 严格解析),失败时可改用内置覆盖层(0.85.1 收割,integrity `sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==`)出 diff。
|
|
82
82
|
- **只增不更新**:合并只补充本机从未见过的模型条目;上游对已有 id 的元数据修正(api 归属、contextWindow 等)不会同步——这是「永不覆盖」语义的代价(ADR 0001)。
|
|
83
83
|
- **digest 绑定**:preview 与 apply 之间以输入 digest(settings 完整配置 / 可用模型 / 本地目录 / 目录来源)绑定,任何漂移即 409 并要求重新预览确认。
|
|
84
|
+
- **可写性门禁(v1.2.4 起,ADR 0002)**:dsh-desktop(Electron)把整个运行时树打包进只读的 `resources/app.asar`,pi-ai 是其中唯一副本——读一切正常、写必被拒。插件在写盘前对目标做可写性分类(asar 路径识别 + 探针文件实测):不可写时 preview/`/status` 暴露 `catalogWritable:false`、UI 置灰刷新入口并给出解释文案,apply 在 write-ahead **之前**以 400 `catalog-not-writable` 结构化快速失败(绝不留下 prepared journal);boot 序列遇到只读目标同样不重试写盘,journal/overlay 原样保留,目标变可写(如桌面版改为解包 pi-ai)后恢复/自愈自动完成。Web/服务部署形态(pi-ai 在真实磁盘)行为不变。
|
|
84
85
|
|
|
85
86
|
## 工作原理
|
|
86
87
|
|
|
@@ -95,7 +96,7 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 ge
|
|
|
95
96
|
|
|
96
97
|
```bash
|
|
97
98
|
npm install
|
|
98
|
-
npm test # node:test:patch 结构 + host
|
|
99
|
+
npm test # node:test:patch 结构 + host/目录/状态机/启动序列/可写性门禁共 114 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
|
|
99
100
|
npm run build # esbuild 打包 client 到 lib/client.js(__ModuleLoader__ 信封)
|
|
100
101
|
npm pack --dry-run
|
|
101
102
|
```
|
|
@@ -125,6 +126,7 @@ npm pack --dry-run
|
|
|
125
126
|
- 模型目录只在「尚不存在」时由登录成功兜底填充一次,填充后归用户所有:账号新增的模型不会自动出现——用 GHC 设置页的「**刷新可用模型目录**」同步(见上节),或在 Models 页手动添加
|
|
126
127
|
- 模型目录只写入 pi-ai 内置目录已描述的模型:目录快照外的新模型需先经「刷新可用模型目录」补入目录条目(数据级补丁,pi-ai 版本不变)才会出现在可选列表
|
|
127
128
|
- DSH 升级后自愈仅在 pi-ai 基线版本不变(0.84.4)时重放;跨版本且条目未原生存在时上报 `self-heal-incompatible`,不修改安装树(重新执行一次手动刷新即可在新基线上激活)
|
|
129
|
+
- **dsh-desktop(Electron 桌面版)无法刷新模型目录**:运行时树(含 pi-ai 唯一副本)打包在只读的 `app.asar` 内,数据级目录补丁物理不可写。v1.2.4 起该形态被显式识别:刷新入口置灰 + `catalog-not-writable` 结构化报错(v1.2.3 及之前会抛 `ENOENT ... not found in ...app.asar` 天书并遗留 prepared journal)。登录/注销/settings 模型目录镜像不受影响;目录刷新请用 Web/服务部署形态(ADR 0002)
|
|
128
130
|
- 并发保护为宿主单进程内 mutex,**不支持多实例/多进程并发刷新**
|
|
129
131
|
- 状态落盘后的目录 fsync 仅在 POSIX 执行:Windows 无目录 fsync 语义(对目录句柄 fsync 必然 `EPERM`),win32 直接跳过、不再刷告警,目录项一致性由 NTFS 元数据日志保证(v1.2.3 起,issue #1);POSIX 行为不变
|
|
130
132
|
|
package/lib/client.js
CHANGED
|
@@ -44,7 +44,7 @@ var routes = () => ({
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
// src/refresh-flow.mjs
|
|
47
|
-
var initial = Object.freeze({ name: "idle" });
|
|
47
|
+
var initial = Object.freeze({ name: "idle", catalogWritable: null });
|
|
48
48
|
function reduce(state, event) {
|
|
49
49
|
switch (state.name) {
|
|
50
50
|
case "idle":
|
|
@@ -59,8 +59,11 @@ function reduce(state, event) {
|
|
|
59
59
|
const refresh = event.status?.refresh;
|
|
60
60
|
if (!event.status) return [state, null];
|
|
61
61
|
if (refresh?.lastError === "state-corrupt") return [{ name: "failed", error: "state-corrupt" }, null];
|
|
62
|
-
if (refresh?.pendingRestart === true)
|
|
63
|
-
|
|
62
|
+
if (refresh?.pendingRestart === true) {
|
|
63
|
+
return [{ name: "restartNeeded", catalogWritable: refresh?.catalogWritable ?? null }, null];
|
|
64
|
+
}
|
|
65
|
+
const writable = refresh && "catalogWritable" in refresh ? refresh.catalogWritable : state.catalogWritable ?? null;
|
|
66
|
+
return [{ ...state, catalogWritable: writable }, null];
|
|
64
67
|
}
|
|
65
68
|
return [state, null];
|
|
66
69
|
}
|
|
@@ -192,7 +195,8 @@ var DICTS = {
|
|
|
192
195
|
srcLatest: "Catalog source: latest pi-ai from npm",
|
|
193
196
|
srcLocal: "Catalog source: local only (npm fetch failed)",
|
|
194
197
|
srcOverlay: "Catalog source: bundled overlay (offline bootstrap)",
|
|
195
|
-
none: "(none)"
|
|
198
|
+
none: "(none)",
|
|
199
|
+
notWritable: "This DSH installation packages the model catalog read-only (inside app.asar) \u2014 the refresh cannot be written. Use a web/service deployment for catalog refresh, or wait for a desktop build that unpacks pi-ai."
|
|
196
200
|
},
|
|
197
201
|
zh: {
|
|
198
202
|
nav: "GHC\u8BBE\u7F6E",
|
|
@@ -235,7 +239,8 @@ var DICTS = {
|
|
|
235
239
|
srcLatest: "\u76EE\u5F55\u6765\u6E90\uFF1Anpm \u6700\u65B0 pi-ai",
|
|
236
240
|
srcLocal: "\u76EE\u5F55\u6765\u6E90\uFF1A\u4EC5\u672C\u5730\u76EE\u5F55\uFF08npm \u62C9\u53D6\u5931\u8D25\uFF09",
|
|
237
241
|
srcOverlay: "\u76EE\u5F55\u6765\u6E90\uFF1A\u5185\u7F6E\u8986\u76D6\u5C42\uFF08\u79BB\u7EBF bootstrap\uFF09",
|
|
238
|
-
none: "\uFF08\u65E0\uFF09"
|
|
242
|
+
none: "\uFF08\u65E0\uFF09",
|
|
243
|
+
notWritable: "\u5F53\u524D DSH \u5B89\u88C5\u628A\u6A21\u578B\u76EE\u5F55\u6253\u5305\u5728\u53EA\u8BFB\u7684 app.asar \u5185\uFF0C\u5237\u65B0\u65E0\u6CD5\u5199\u5165\u3002\u8BF7\u5728 Web/\u670D\u52A1\u90E8\u7F72\u5F62\u6001\u4E0B\u5237\u65B0\u76EE\u5F55\uFF0C\u6216\u7B49\u5F85\u684C\u9762\u7248\u63D0\u4F9B\u53EF\u5199\u5E03\u5C40\u540E\u91CD\u8BD5\u3002"
|
|
239
244
|
}
|
|
240
245
|
};
|
|
241
246
|
var NAV_TEXTS = Object.keys(DICTS).map((locale) => DICTS[locale].nav);
|
|
@@ -424,10 +429,23 @@ function RefreshModal({ t, flow, onConfirm, onCancel, onOverlay }) {
|
|
|
424
429
|
"\u26A0 ",
|
|
425
430
|
t("riskRestart")
|
|
426
431
|
] }),
|
|
432
|
+
p.catalogWritable === false && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.error, children: [
|
|
433
|
+
"\u26A0 ",
|
|
434
|
+
t("notWritable")
|
|
435
|
+
] }),
|
|
427
436
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.modalActions, children: [
|
|
428
437
|
p.catalogSource === "local" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary, marginRight: "auto" }, onClick: onOverlay, children: t("overlayBtn") }),
|
|
429
438
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary }, onClick: onCancel, children: t("cancel") }),
|
|
430
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
439
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
440
|
+
"button",
|
|
441
|
+
{
|
|
442
|
+
type: "button",
|
|
443
|
+
style: { ...styles.button, ...styles.primary, ...p.catalogWritable === false ? { opacity: 0.5, cursor: "not-allowed" } : null },
|
|
444
|
+
disabled: p.catalogWritable === false,
|
|
445
|
+
onClick: onConfirm,
|
|
446
|
+
children: t("confirmRefresh")
|
|
447
|
+
}
|
|
448
|
+
)
|
|
431
449
|
] })
|
|
432
450
|
] }) });
|
|
433
451
|
}
|
|
@@ -551,18 +569,22 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
|
|
|
551
569
|
"button",
|
|
552
570
|
{
|
|
553
571
|
type: "button",
|
|
554
|
-
style: { ...styles.button, ...styles.primary },
|
|
555
|
-
disabled: flow.name === "previewing" || flow.name === "applying",
|
|
572
|
+
style: { ...styles.button, ...styles.primary, ...flow.catalogWritable === false ? { opacity: 0.5, cursor: "not-allowed" } : null },
|
|
573
|
+
disabled: flow.catalogWritable === false || flow.name === "previewing" || flow.name === "applying",
|
|
556
574
|
onClick: () => drive({ type: "start" }),
|
|
557
575
|
children: flow.name === "previewing" ? t("refreshing") : flow.name === "applying" ? t("applying") : t("refreshNow")
|
|
558
576
|
}
|
|
559
577
|
)
|
|
560
578
|
] }),
|
|
579
|
+
page === "authorized" && flow.catalogWritable === false && flow.name !== "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
|
|
580
|
+
"\u26A0 ",
|
|
581
|
+
t("notWritable")
|
|
582
|
+
] }),
|
|
561
583
|
page === "authorized" && flow.name === "restartNeeded" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
|
|
562
584
|
"\u26A0 ",
|
|
563
585
|
t("restartNeeded")
|
|
564
586
|
] }),
|
|
565
|
-
page === "authorized" && flow.name === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.error, children: flow.error === "state-corrupt" ? t("stateCorrupt") : flow.error }),
|
|
587
|
+
page === "authorized" && flow.name === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.error, children: flow.error === "state-corrupt" ? t("stateCorrupt") : flow.error === "catalog-not-writable" ? t("notWritable") : flow.error }),
|
|
566
588
|
flow.name === "confirming" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
567
589
|
RefreshModal,
|
|
568
590
|
{
|
package/package.json
CHANGED
package/src/catalog.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// catalog.mjs — 数据核心:tgz 严格解析、条目校验、只增不更新合并、diff、digest、
|
|
2
2
|
// pi-ai 安装定位。合并语义「只增不更新」(ADR 0001):已有 id 的元数据永不覆盖。
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { closeSync, existsSync, openSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { gunzipSync } from "node:zlib";
|
|
7
7
|
|
|
@@ -154,3 +154,45 @@ export function findPiAiInstallation({ startPath } = {}) {
|
|
|
154
154
|
} catch { /* 安装树结构变化 → null(调用方降级) */ }
|
|
155
155
|
return null;
|
|
156
156
|
}
|
|
157
|
+
|
|
158
|
+
// ==================== 安装目标可写性分类(desktop asar 只读问题,ADR 0002) ====================
|
|
159
|
+
// dsh-desktop(Electron)把整个运行时树打进 resources/app.asar,pi-ai 是其中唯一副本:
|
|
160
|
+
// asar 虚拟文件系统读没问题、创建/写入必被拒(ENOENT "... not found in <archive>")。
|
|
161
|
+
// 数据级目录补丁在写盘前必须先分类目标,把"物理不可写"变成结构化错误而不是天书 ENOENT。
|
|
162
|
+
|
|
163
|
+
// Electron 的 asar 拦截按"路径中任一段以 .asar 结尾"判定(大小写不敏感,分隔符 / 与 \ 都认,
|
|
164
|
+
// Win 路径在 POSIX 主机上测试时也要能识别)。.asar.unpacked 不以 .asar 结尾,不会误判。
|
|
165
|
+
export function isAsarPath(p) {
|
|
166
|
+
const s = String(p ?? "");
|
|
167
|
+
if (/\.asar$/i.test(s)) return true;
|
|
168
|
+
return s.split(/[\\/]/).some((seg) => /\.asar$/i.test(seg));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 功能性可写探测:在目录内排他创建 + 删除一个探针文件。覆盖 asar 之外的不可写形态
|
|
172
|
+
// (只读卷、根属主安装、ACL 拒绝——win32 chmod 弱语义下唯一可靠的通用手段)。
|
|
173
|
+
// 探针文件名带 UUID,排他创建,任何失败路径都尝试清理并返回 false。
|
|
174
|
+
export function probeWritable(dir) {
|
|
175
|
+
const probe = join(dir, `.copilot-auth-write-probe-${randomUUID()}`);
|
|
176
|
+
let fd;
|
|
177
|
+
try {
|
|
178
|
+
fd = openSync(probe, "wx");
|
|
179
|
+
closeSync(fd);
|
|
180
|
+
fd = undefined;
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
} finally {
|
|
185
|
+
if (fd !== undefined) {
|
|
186
|
+
try { closeSync(fd); } catch { /* 已关闭 */ }
|
|
187
|
+
}
|
|
188
|
+
try { unlinkSync(probe); } catch { /* 探针可能未创建成功 */ }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 分类结果平铺进安装对象:writable: boolean;不可写时 unwritableReason: "asar" | "probe-failed"。
|
|
193
|
+
// writableProbe 仅作测试注入钩子(生产恒用默认实现)。
|
|
194
|
+
export function classifyCatalogTarget(catalogFile, writableProbe = probeWritable) {
|
|
195
|
+
if (isAsarPath(catalogFile)) return { writable: false, unwritableReason: "asar" };
|
|
196
|
+
if (!writableProbe(dirname(catalogFile))) return { writable: false, unwritableReason: "probe-failed" };
|
|
197
|
+
return { writable: true, unwritableReason: null };
|
|
198
|
+
}
|
package/src/host.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// host 半区:cordis 插件。注册 6 条 exact 路由,把内置 github-copilot 的
|
|
2
2
|
// OAuth 设备码流(ctx.authorization)暴露给 Web client,并提供「手动刷新可用
|
|
3
3
|
// 模型目录」(数据级目录补丁,pi-ai 代码版本不动;只读 GET /models 适配显式
|
|
4
|
-
// 耦合 pi-ai 0.84.4,详见
|
|
4
|
+
// 耦合 pi-ai 0.84.4,详见 GLOSSARY.md 与 ADR 0001)。
|
|
5
5
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { CREDENTIAL_KEY, emptyState, routes } from "./shared.mjs";
|
|
10
10
|
import { readJson, writeJsonAtomic, createMutex } from "./atomic-json.mjs";
|
|
11
|
-
import { mergeCatalog, diffModels, digest, findPiAiInstallation } from "./catalog.mjs";
|
|
11
|
+
import { mergeCatalog, diffModels, digest, findPiAiInstallation, classifyCatalogTarget } from "./catalog.mjs";
|
|
12
12
|
import { fetchLatestCatalog } from "./catalog-fetch.mjs";
|
|
13
13
|
import { fetchLiveAvailableModelIds } from "./copilot-models.mjs";
|
|
14
14
|
import { loadState, saveState, freshState, createJournal, advanceJournal, restartMarker, setLastError, clearLastError } from "./state.mjs";
|
|
@@ -163,22 +163,37 @@ function readBody(req) {
|
|
|
163
163
|
// 版本与 catalog 同根解析(R3-8):注入 catalogFile 时 version 取其所在根的
|
|
164
164
|
// package.json(opts.piAiVersion 为测试钩子);生产走 findPiAiInstallation()。
|
|
165
165
|
function resolveInstall(opts) {
|
|
166
|
+
let install;
|
|
166
167
|
if (opts.catalogFile) {
|
|
167
|
-
if (opts.piAiVersion)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
if (opts.piAiVersion) {
|
|
169
|
+
install = { catalogFile: opts.catalogFile, version: opts.piAiVersion };
|
|
170
|
+
} else {
|
|
171
|
+
const root = dirname(dirname(dirname(dirname(opts.catalogFile))));
|
|
172
|
+
const packageJsonFile = join(root, "package.json");
|
|
173
|
+
const pkg = JSON.parse(readFileSync(packageJsonFile, "utf8"));
|
|
174
|
+
install = { catalogFile: opts.catalogFile, packageJsonFile, version: pkg.version };
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
const found = findPiAiInstallation();
|
|
178
|
+
if (!found) throw new Error("pi-ai installation not found");
|
|
179
|
+
install = found;
|
|
172
180
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
181
|
+
// 可写性门禁(ADR 0002):desktop asar 打包形态下 catalogFile 在只读归档内——读一切
|
|
182
|
+
// 正常、写必被拒。这里统一分类,apply/boot 据此结构化快速失败而非抛天书 ENOENT。
|
|
183
|
+
// opts.writableProbe 为测试注入钩子(生产恒用默认探测实现)。
|
|
184
|
+
return { ...install, ...classifyCatalogTarget(install.catalogFile, opts.writableProbe) };
|
|
176
185
|
}
|
|
177
186
|
|
|
178
187
|
function readLocalCatalog(opts) {
|
|
179
|
-
const
|
|
180
|
-
const bytes = readFileSync(
|
|
181
|
-
return {
|
|
188
|
+
const install = resolveInstall(opts);
|
|
189
|
+
const bytes = readFileSync(install.catalogFile);
|
|
190
|
+
return {
|
|
191
|
+
file: install.catalogFile,
|
|
192
|
+
bytes,
|
|
193
|
+
catalog: JSON.parse(bytes.toString("utf8")),
|
|
194
|
+
writable: install.writable,
|
|
195
|
+
unwritableReason: install.unwritableReason,
|
|
196
|
+
};
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
// 账号可用模型:现场拉取(显式耦合 0.84.4)→ 任何失败回退凭证缓存 availableModelIds
|
|
@@ -299,6 +314,13 @@ async function bootRefresh(ctx, opts) {
|
|
|
299
314
|
}
|
|
300
315
|
const replay = mergeCatalog(readDiskCatalog(), state.journal.pendingOverlay);
|
|
301
316
|
if (replay.added.length > 0) {
|
|
317
|
+
// 可写性门禁(ADR 0002):不可写目标不重试写盘——journal 原样保留,安装将来
|
|
318
|
+
// 变可写(如桌面版改为解包 pi-ai)后本恢复自动完成。零写入、可 grep 的 lastError。
|
|
319
|
+
if (!install.writable) {
|
|
320
|
+
state = setLastError(state, `catalog-not-writable (${install.unwritableReason}): pi-ai catalog is read-only; prepared journal kept, refresh will complete automatically once the target becomes writable`);
|
|
321
|
+
saveState(stateFile, state);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
302
324
|
writeJsonAtomic(install.catalogFile, replay.merged);
|
|
303
325
|
bootPatched = true;
|
|
304
326
|
}
|
|
@@ -327,11 +349,18 @@ async function bootRefresh(ctx, opts) {
|
|
|
327
349
|
} else if (install.version === baseline) {
|
|
328
350
|
const replay = mergeCatalog(readDiskCatalog(), state.appliedOverlay);
|
|
329
351
|
if (replay.added.length > 0) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
352
|
+
// 可写性门禁(ADR 0002):自愈同理不重试写盘——appliedOverlay 保留,
|
|
353
|
+
// 目标可写后下一 boot 重放;不抛错、不阻断启动序列。
|
|
354
|
+
if (!install.writable) {
|
|
355
|
+
state = setLastError(state, `catalog-not-writable (${install.unwritableReason}): pi-ai catalog is read-only; self-heal deferred, overlay entries stay pending`);
|
|
356
|
+
saveState(stateFile, state);
|
|
357
|
+
} else {
|
|
358
|
+
writeJsonAtomic(install.catalogFile, replay.merged); // 原子重放
|
|
359
|
+
bootPatched = true;
|
|
360
|
+
state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
|
|
361
|
+
state = clearLastError(state);
|
|
362
|
+
saveState(stateFile, state);
|
|
363
|
+
}
|
|
335
364
|
}
|
|
336
365
|
} else {
|
|
337
366
|
const missing = overlayIds.filter((id) => !diskIds(readDiskCatalog()).has(id));
|
|
@@ -566,6 +595,7 @@ export function apply(ctx, opts = {}) {
|
|
|
566
595
|
piAiVersion: install?.version ?? null,
|
|
567
596
|
catalogDigest,
|
|
568
597
|
catalogFile: install?.catalogFile ?? null,
|
|
598
|
+
catalogWritable: install ? install.writable === true : null,
|
|
569
599
|
};
|
|
570
600
|
json(res, 200, { configured, syncError: lastSyncError, refresh });
|
|
571
601
|
},
|
|
@@ -615,6 +645,7 @@ export function apply(ctx, opts = {}) {
|
|
|
615
645
|
source: inputs.source,
|
|
616
646
|
catalogSource: inputs.resolved.catalogSource,
|
|
617
647
|
catalogError: inputs.resolved.catalogError,
|
|
648
|
+
catalogWritable: inputs.local.writable,
|
|
618
649
|
skipped: inputs.merged.skipped,
|
|
619
650
|
added: inputs.diff.added,
|
|
620
651
|
removed: inputs.diff.removed,
|
|
@@ -665,6 +696,14 @@ export function apply(ctx, opts = {}) {
|
|
|
665
696
|
}
|
|
666
697
|
// digest 全部一致后才解析版本(写入失败点上移,500 不留 journal)
|
|
667
698
|
const install = resolveInstall(opts);
|
|
699
|
+
// 可写性门禁(ADR 0002):不可写目标在 write-ahead 之前快速失败——绝不留
|
|
700
|
+
// prepared journal(desktop asar 形态下那会让每个 boot 都重试写归档)。
|
|
701
|
+
if (!install.writable) {
|
|
702
|
+
return {
|
|
703
|
+
code: 400,
|
|
704
|
+
payload: { ok: false, error: "catalog-not-writable", reason: install.unwritableReason, catalogFile: install.catalogFile },
|
|
705
|
+
};
|
|
706
|
+
}
|
|
668
707
|
const stateFile = opts.stateFile ?? defaultStateFile();
|
|
669
708
|
const patchedBytes = Buffer.from(JSON.stringify(inputs.merged.merged, null, 2) + "\n", "utf8");
|
|
670
709
|
let state = loadState(stateFile);
|
package/src/refresh-flow.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// POST preview)→ 成功进 confirming(staleNotice);restartNeeded 可由 /status 水合。
|
|
7
7
|
import { routes } from "./shared.mjs";
|
|
8
8
|
|
|
9
|
-
export const initial = Object.freeze({ name: "idle" });
|
|
9
|
+
export const initial = Object.freeze({ name: "idle", catalogWritable: null });
|
|
10
10
|
|
|
11
11
|
// reduce(state, event) → [nextState, effect | null]
|
|
12
12
|
// effect 为数据化指令:{type:"preview",mode} | {type:"apply",mode,digests} | {type:"status"}
|
|
@@ -24,8 +24,13 @@ export function reduce(state, event) {
|
|
|
24
24
|
const refresh = event.status?.refresh;
|
|
25
25
|
if (!event.status) return [state, null]; // status 拉取失败不硬失败
|
|
26
26
|
if (refresh?.lastError === "state-corrupt") return [{ name: "failed", error: "state-corrupt" }, null];
|
|
27
|
-
if (refresh?.pendingRestart === true)
|
|
28
|
-
|
|
27
|
+
if (refresh?.pendingRestart === true) {
|
|
28
|
+
return [{ name: "restartNeeded", catalogWritable: refresh?.catalogWritable ?? null }, null];
|
|
29
|
+
}
|
|
30
|
+
// 干净水合:catalogWritable 随 /status 落态(desktop asar 只读形态 → false,
|
|
31
|
+
// UI 置灰刷新入口);旧宿主不携带该字段时保持现值不回退。
|
|
32
|
+
const writable = refresh && "catalogWritable" in refresh ? refresh.catalogWritable : state.catalogWritable ?? null;
|
|
33
|
+
return [{ ...state, catalogWritable: writable }, null];
|
|
29
34
|
}
|
|
30
35
|
return [state, null];
|
|
31
36
|
}
|