@adep/cli 0.4.4 → 0.4.5

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 (46) hide show
  1. package/README.md +107 -11
  2. package/dist/index.js +33409 -27038
  3. package/dist/vite.js +209 -15
  4. package/dist/worker-entry.js +163 -1
  5. package/package.json +5 -4
  6. package/templates/app-desktop/README.md +67 -2
  7. package/templates/app-desktop/identity-store.mjs +309 -0
  8. package/templates/app-desktop/identity.mjs +517 -0
  9. package/templates/app-desktop/main.cjs +17 -0
  10. package/templates/app-desktop/main.mjs +218 -2
  11. package/templates/app-desktop/offline-runtime.mjs +72 -3
  12. package/templates/app-desktop/package.json +10 -6
  13. package/templates/app-desktop/recovery-ui.mjs +300 -0
  14. package/templates/app-desktop/sync-loop.mjs +137 -0
  15. package/templates/app-mobile/README.md +47 -9
  16. package/templates/app-mobile/package.json +2 -1
  17. package/templates/app-mobile/plugins/adep-bridge/Package.swift +5 -1
  18. package/templates/app-mobile/plugins/adep-bridge/android/build.gradle +34 -3
  19. package/templates/app-mobile/plugins/adep-bridge/android/src/main/AndroidManifest.xml +12 -5
  20. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepBridge.kt +198 -12
  21. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepIdentity.kt +342 -0
  22. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepKeystore.kt +56 -2
  23. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqlite.kt +506 -0
  24. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqliteLogic.kt +235 -0
  25. package/templates/app-mobile/plugins/adep-bridge/dist/plugin.js +30 -0
  26. package/templates/app-mobile/plugins/adep-bridge/ios/AdepBridge.podspec +3 -0
  27. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepBridge.swift +221 -9
  28. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepIdentity.swift +500 -0
  29. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepKeychain.swift +69 -17
  30. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepPermissionGate.swift +25 -4
  31. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepShellConfig.swift +54 -0
  32. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepSqlite.swift +614 -0
  33. package/templates/app-mobile/plugins/adep-bridge/package.json +10 -0
  34. package/templates/app-mobile/template.json +12 -3
  35. package/templates/app-mobile/web/adep-bridge.js +103 -9
  36. package/templates/app-mobile/web/offline-runtime.js +230 -0
  37. package/templates/app-mobile-xtool/AdepShell-Info.plist +24 -0
  38. package/templates/app-mobile-xtool/Package.swift +59 -0
  39. package/templates/app-mobile-xtool/README.md +81 -0
  40. package/templates/app-mobile-xtool/Sources/AdepShell/App.swift +36 -0
  41. package/templates/app-mobile-xtool/Sources/AdepShell/ShellViewController.swift +223 -0
  42. package/templates/app-mobile-xtool/adep.permissions.json +4 -0
  43. package/templates/app-mobile-xtool/app/site/.gitkeep +0 -0
  44. package/templates/app-mobile-xtool/template.json +15 -0
  45. package/templates/app-mobile-xtool/xtool.yml +35 -0
  46. package/templates/app-mobile/plugins/adep-bridge/capacitor.plugin.json +0 -10
@@ -0,0 +1,517 @@
1
+ /**
2
+ * 桌面壳设备身份自举(APP-033;PRD §2.11.5「设备身份与恢复」/ ADR-0008 第 2 条)。
3
+ *
4
+ * ## 这一层解决什么问题
5
+ *
6
+ * `ownerKey` 必须由**服务端**签发(本地自造 / 构建期预置都判违规)。移动壳在 APP-027
7
+ * 已经有完整自举(原生侧首启签发 → Keychain / Keystore),而桌面壳**一次都没调过签发端点**:
8
+ * `main.mjs` 不读不存、`preload.cjs` 也不注入。后果是 APP-032 的同步 transport 只能在开发机
9
+ * 上靠 `adep app sync --owner-key` 手传——装机到用户机器上「功能齐了,钥匙没有」。
10
+ *
11
+ * ## 三条刻意的边界(与移动壳同一条口径)
12
+ *
13
+ * 1. **不做在页面侧**:`preload.cjs` 不暴露 ownerKey,页面拿不到这枚 bearer 凭据。
14
+ * 同步跑在主进程(transport 在 `offline-runtime.mjs` 里装配),页面不需要它;
15
+ * 反之,把凭据交给渲染进程等于「页面里任何一个 XSS 都能劫持该设备的数据分区」。
16
+ * 身份**状态**(backend / 是否已签发)由 `adep app doctor` 读 `data/identity/status.json`
17
+ * 呈现——那是既有的体检读数面,不新开一条 IPC 通道。
18
+ * 2. **失败不改变可用性**:断网 / 5xx / 响应形态不符 ⇒ 不写空值、不覆盖既有值,只留带
19
+ * **原因码**的日志与状态文件,下次启动重试。首启失败不得阻止 app 启动。
20
+ * 3. **幂等**:本地已有合法 ownerKey ⇒ 不发请求(一枚安装只签发一次)。
21
+ *
22
+ * ## 为什么这个文件不 import electron
23
+ *
24
+ * 判定(配置 / URL / 形态 / 响应解析 / 平台映射)与副作用(存储、传输、日志)全部拆开,
25
+ * 判定层是纯函数 ⇒ `app-desktop-identity.test.ts` 能把**被测源码原样** import 进来真跑,
26
+ * 不做镜像实现(APP-027 用 swiftc/kotlinc 编原文件,这里是同一思路的 Node 版)。
27
+ * `main.mjs` 才是唯一碰 electron 的地方。
28
+ */
29
+
30
+ /**
31
+ * 自举配置(`adep.app.json` 的投影;完整校验归 CLI 侧,这里只取两个字段)。
32
+ *
33
+ * @typedef {Object} adepIdentityConfig
34
+ * @property {string} platformUrl 平台地址(http(s) 绝对地址)
35
+ * @property {string} projectSlug 项目 slug(会拼进签发端点路径)
36
+ */
37
+
38
+ /**
39
+ * 签发响应的投影(三个字段都通过形态校验后才存在这个类型里)。
40
+ *
41
+ * @typedef {Object} adepIssueResult
42
+ * @property {string} ownerKey 设备凭据(`adep_ok_` + 64 位小写 hex)
43
+ * @property {string} installationId 安装行主键
44
+ * @property {string} recoveryCode 一次性恢复码(明文只在签发响应里出现一次)
45
+ */
46
+
47
+ /**
48
+ * 自举依赖(全部注入:存储 / 配置 / 传输 / 日志 / 状态落盘)。
49
+ *
50
+ * @typedef {Object} adepBootstrapDependencies
51
+ * @property {() => unknown} readConfig 读壳根 adep.app.json(返回 JSON.parse 结果)
52
+ * @property {adepProtectedStore} store 受保护存储
53
+ * @property {(url: string, body: string, timeoutMs: number) => Promise<{status: number, body: string | null, error: string | null}>} transport 签发传输
54
+ * @property {string} platform 服务端平台枚举值(APP_PLATFORMS 之一,没有 desktop)
55
+ * @property {number} [timeoutMs] 签发请求超时(缺省 10s)
56
+ * @property {(line: string) => void} [log] 日志(每条带原因码)
57
+ * @property {(status: Record<string, unknown>) => void | Promise<void>} [recordStatus] 状态落盘(非机密读数面)
58
+ */
59
+
60
+ /**
61
+ * 受保护存储面(identity-store.mjs 的返回形态;测试用假实现同样满足)。
62
+ *
63
+ * @typedef {Object} adepProtectedStore
64
+ * @property {string} backend 实际生效的后端(退化后是 file)
65
+ * @property {() => Promise<string|null>} readOwnerKey
66
+ * @property {(value: string) => Promise<void>} writeOwnerKey
67
+ * @property {() => Promise<string|null>} readRecoveryCode
68
+ * @property {(value: string) => Promise<void>} writeRecoveryCode
69
+ */
70
+
71
+ /** 自举结果码。日志与状态文件里必须出现码本身(排障时先看码,再看文案)。 */
72
+ export const ADEP_IDENTITY_CODES = {
73
+ /** 壳根没有 `adep.app.json`(未 init 或文件被删)。 */
74
+ configMissing: 'IDENTITY_CONFIG_MISSING',
75
+ /** 配置在但字段非法(platformUrl 不是 http(s) / projectSlug 形态不符)。 */
76
+ configInvalid: 'IDENTITY_CONFIG_INVALID',
77
+ /** 传输层失败或非 2xx(断网 / 超时 / 5xx / 429)。 */
78
+ requestFailed: 'IDENTITY_REQUEST_FAILED',
79
+ /** 2xx 但响应形态不符(不是 JSON / 缺 installation / ownerKey 形态不对)。 */
80
+ responseInvalid: 'IDENTITY_RESPONSE_INVALID',
81
+ /** 拿到合法凭据但落受保护存储失败(含写后读回不一致)。 */
82
+ storeFailed: 'IDENTITY_STORE_FAILED',
83
+ /** 已签发(跳过,未发请求)。 */
84
+ alreadyIssued: 'IDENTITY_ALREADY_ISSUED',
85
+ /** 同一实例重复 start(跳过,未发请求)。 */
86
+ alreadyStarted: 'IDENTITY_ALREADY_STARTED',
87
+ }
88
+
89
+ /** ownerKey 明文前缀(与 `server/domains/appbuild/identity/contract.ts` 的 `OWNER_KEY_PREFIX` 逐字对应)。 */
90
+ export const OWNER_KEY_PREFIX = 'adep_ok_'
91
+ /** ownerKey 随机段 hex 长度(服务端 `OWNER_KEY_BYTES = 32` ⇒ 64 位 hex,256 位熵)。 */
92
+ export const OWNER_KEY_HEX_LENGTH = 64
93
+ /**
94
+ * 签发端点路径(服务端 `sync-routes.ts` 的 `/v1/projects/:projectId/app-installations`,
95
+ * 平台侧挂在 `/api` 前缀下;`:projectId` 接受 **id 或 slug**)。
96
+ */
97
+ export const ISSUE_PATH_PREFIX = '/api/v1/projects/'
98
+ export const ISSUE_PATH_SUFFIX = '/app-installations'
99
+
100
+ /**
101
+ * 宿主 OS → 服务端 `APP_PLATFORMS`(`server/domains/appbuild/manifest/schema.ts`)。
102
+ *
103
+ * ⚠️ 这里**没有** `desktop` 这个取值——APP-032 的 e2e 就栽在照抄 CLI 的
104
+ * `--platform desktop|mobile` 上(那是「哪种壳」,不是「哪个系统」),签发端直接
105
+ * `400 APP_INSTALL_INPUT_INVALID`。两套枚举同名不同域,别互相代入。
106
+ */
107
+ const HOST_PLATFORM = { darwin: 'macos', win32: 'windows' }
108
+
109
+ /**
110
+ * 宿主 OS → 服务端平台枚举。
111
+ *
112
+ * @param {string} hostPlatform `process.platform` 的取值
113
+ * @returns {'macos'|'windows'|'linux'}
114
+ */
115
+ export function platformForHost(hostPlatform) {
116
+ return HOST_PLATFORM[hostPlatform] ?? 'linux'
117
+ }
118
+
119
+ /**
120
+ * ownerKey 形态:前缀 + 64 位**小写** hex(与服务端正则完全同形)。
121
+ *
122
+ * 刻意严格(不宽松地接受大写):宽松会让「服务端换了编码」这类契约漂移在客户端静默通过,
123
+ * 然后在同步侧 401——错误在最远的地方爆。
124
+ *
125
+ * @param {unknown} value
126
+ * @returns {boolean}
127
+ */
128
+ export function isOwnerKey(value) {
129
+ if (typeof value !== 'string' || !value.startsWith(OWNER_KEY_PREFIX)) return false
130
+ const hex = value.slice(OWNER_KEY_PREFIX.length)
131
+ if (hex.length !== OWNER_KEY_HEX_LENGTH) return false
132
+ return /^[0-9a-f]+$/.test(hex)
133
+ }
134
+
135
+ /**
136
+ * projectSlug 形态(与 `adep.app.json` schema 的 `^[a-z][a-z0-9-]{0,62}$` 同形)。
137
+ *
138
+ * 在客户端再判一次的理由:拼 URL 时 slug 会成为**路径段**,形态失守就是拼出一个别的端点。
139
+ *
140
+ * @param {unknown} value
141
+ * @returns {boolean}
142
+ */
143
+ export function isProjectSlug(value) {
144
+ return typeof value === 'string' && /^[a-z][a-z0-9-]{0,62}$/.test(value)
145
+ }
146
+
147
+ /** 身份层错误(带原因码;`message` 会被写进日志与状态文件,必须能直接排障)。 */
148
+ export class IdentityError extends Error {
149
+ /**
150
+ * @param {string} code
151
+ * @param {string} message
152
+ */
153
+ constructor(code, message) {
154
+ super(message)
155
+ this.name = 'IdentityError'
156
+ this.code = code
157
+ }
158
+ }
159
+
160
+ /**
161
+ * 解析壳根 `adep.app.json` 字节里的自举配置。
162
+ *
163
+ * 只取自举需要的两个字段,其余不校验:壳配置的完整校验在 CLI 侧 `app-config.ts`,
164
+ * 客户端再抄一份 schema 只会漂移。
165
+ *
166
+ * @param {unknown} raw 已经 JSON.parse 过的对象
167
+ * @returns {adepIdentityConfig} 自举所需的最小字段集
168
+ */
169
+ export function parseIdentityConfig(raw) {
170
+ if (raw === null || typeof raw !== 'object') {
171
+ throw new IdentityError(ADEP_IDENTITY_CODES.configInvalid, 'adep.app.json 不是 JSON 对象')
172
+ }
173
+ const platformUrl = /** @type {Record<string, unknown>} */ (raw).platformUrl
174
+ if (typeof platformUrl !== 'string') {
175
+ throw new IdentityError(ADEP_IDENTITY_CODES.configInvalid, 'adep.app.json 缺 platformUrl')
176
+ }
177
+ /** @type {URL} */
178
+ let url
179
+ try {
180
+ url = new URL(platformUrl)
181
+ } catch {
182
+ throw new IdentityError(
183
+ ADEP_IDENTITY_CODES.configInvalid,
184
+ `platformUrl 不是合法 URL:${platformUrl}`
185
+ )
186
+ }
187
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.hostname === '') {
188
+ throw new IdentityError(
189
+ ADEP_IDENTITY_CODES.configInvalid,
190
+ `platformUrl 必须是 http(s) 绝对地址:${platformUrl}`
191
+ )
192
+ }
193
+ const projectSlug = /** @type {Record<string, unknown>} */ (raw).projectSlug
194
+ if (!isProjectSlug(projectSlug)) {
195
+ throw new IdentityError(
196
+ ADEP_IDENTITY_CODES.configInvalid,
197
+ `projectSlug 形态不符(需小写字母开头,仅小写字母/数字/连字符):${String(projectSlug)}`
198
+ )
199
+ }
200
+ return { platformUrl, projectSlug }
201
+ }
202
+
203
+ /**
204
+ * 组装签发端点 URL。`platformUrl` 常带尾斜杠(`https://adep.top/`)⇒ 先归一,
205
+ * 否则拼出 `//api/v1/...`(服务端 404,症状像「端点没部署」)。
206
+ *
207
+ * @param {adepIdentityConfig} config
208
+ * @returns {string}
209
+ */
210
+ export function issueUrl(config) {
211
+ const url = new URL(config.platformUrl)
212
+ let base = url.pathname
213
+ while (base.endsWith('/')) base = base.slice(0, -1)
214
+ url.pathname = `${base}${ISSUE_PATH_PREFIX}${config.projectSlug}${ISSUE_PATH_SUFFIX}`
215
+ // 平台地址上的 query / fragment 对端点无意义,带上只会污染请求。
216
+ url.search = ''
217
+ url.hash = ''
218
+ return url.toString()
219
+ }
220
+
221
+ /**
222
+ * 解析签发响应:`{ installation: { installationId, ownerKey, recoveryCode, partition, platform } }`。
223
+ *
224
+ * **形态不符一律抛**(不写「尽量取到的那部分」):ownerKey 是 bearer 凭据,把一个空串 /
225
+ * 半截值写进受保护存储,等于把「设备身份」变成「永远 401」的垃圾状态,而且**看起来像已签发**
226
+ * (下次启动不再重试)——比直接失败难查得多。
227
+ *
228
+ * @param {unknown} raw 已经 JSON.parse 过的响应体
229
+ * @returns {adepIssueResult} 三个字段都已通过形态校验
230
+ */
231
+ export function parseIssueResponse(raw) {
232
+ const invalid = (/** @type {string} */ message) =>
233
+ new IdentityError(ADEP_IDENTITY_CODES.responseInvalid, message)
234
+ if (raw === null || typeof raw !== 'object') throw invalid('签发响应不是 JSON 对象')
235
+ const installation = /** @type {Record<string, unknown>} */ (raw).installation
236
+ if (installation === null || typeof installation !== 'object') {
237
+ throw invalid('签发响应缺 installation 对象')
238
+ }
239
+ const fields = /** @type {Record<string, unknown>} */ (installation)
240
+ if (!isOwnerKey(fields.ownerKey)) {
241
+ throw invalid(
242
+ `installation.ownerKey 形态不符(期望 ${OWNER_KEY_PREFIX} + ${OWNER_KEY_HEX_LENGTH} 位小写 hex)`
243
+ )
244
+ }
245
+ const installationId = fields.installationId
246
+ if (typeof installationId !== 'string' || installationId === '') {
247
+ throw invalid('installation.installationId 缺失或为空')
248
+ }
249
+ const recoveryCode = fields.recoveryCode
250
+ if (typeof recoveryCode !== 'string' || recoveryCode === '') {
251
+ throw invalid('installation.recoveryCode 缺失或为空')
252
+ }
253
+ return { ownerKey: fields.ownerKey, installationId, recoveryCode }
254
+ }
255
+
256
+ /**
257
+ * 恢复码脱敏(日志用)。恢复码形态 `<installationId>.<secret>`:**installationId 不是机密**
258
+ * (它就是安装行主键),secret 段才是凭据 ⇒ 保留 installationId + secret 前 4 位 + 总长度
259
+ * (长度能看出「码被截断」这类畸形)。
260
+ *
261
+ * @param {string} value
262
+ * @param {string} installationId
263
+ * @returns {string}
264
+ */
265
+ export function maskRecoveryCode(value, installationId) {
266
+ const head = installationId === '' ? '' : `${installationId}.`
267
+ const secret = value.startsWith(head) ? value.slice(head.length) : value
268
+ return `${head}${secret.slice(0, 4)}…(len ${value.length})`
269
+ }
270
+
271
+ /**
272
+ * 生产传输:`fetch` + `AbortSignal.timeout`。
273
+ *
274
+ * 返回形态与注入面一致(`status === 0` 表示**没拿到 HTTP 响应**),这样失败分支在测试里
275
+ * 能被同一组断言覆盖,不必分辨「真网络」与「假传输」。
276
+ *
277
+ * @param {number} timeoutMs
278
+ * @returns {(url: string, body: string, timeoutMs: number) => Promise<{ status: number, body: string | null, error: string | null }>}
279
+ */
280
+ export function createFetchTransport(timeoutMs) {
281
+ return async (url, body) => {
282
+ try {
283
+ const response = await fetch(url, {
284
+ method: 'POST',
285
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
286
+ body,
287
+ signal: AbortSignal.timeout(timeoutMs),
288
+ })
289
+ return { status: response.status, body: await response.text(), error: null }
290
+ } catch (error) {
291
+ return {
292
+ status: 0,
293
+ body: null,
294
+ error: error instanceof Error ? error.message : String(error),
295
+ }
296
+ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * 首启自举:查本地 →(缺则)签发 → 落受保护存储 → 通知调用方。
302
+ *
303
+ * 依赖全部注入(存储 / 配置 / 传输 / 日志 / 状态落盘)——「要不要请求」「写不写」「写什么」
304
+ * 三条判定都能真跑,而不是靠读源码猜。
305
+ *
306
+ * 顺序即不变量:**本地有 ownerKey ⇒ 本地必有与它同源的恢复码**。所以先写恢复码并读回校验,
307
+ * 再写 ownerKey。反过来会出现「app 已带着这枚身份跑,用户却永远拿不到恢复码」——明文只在
308
+ * 这一次响应里存在(服务端只存摘要,任何端点都不重发),丢了就是该分区永久不可认领。
309
+ */
310
+ export class IdentityBootstrap {
311
+ /**
312
+ * @param {adepBootstrapDependencies} dependencies 全部注入(判定与副作用分离的关键)
313
+ */
314
+ constructor(dependencies) {
315
+ this.dependencies = dependencies
316
+ this.started = false
317
+ }
318
+
319
+ /**
320
+ * 启动一次自举。**幂等**:同一实例重复调用只发一次请求。
321
+ *
322
+ * 永不 throw——失败一律折成 `{ status: 'failed', code, detail }`,调用方(主进程)
323
+ * 不得因为身份没签到而阻止窗口创建。
324
+ *
325
+ * @returns {Promise<{ status: 'issued'|'skipped'|'failed', code: string, ownerKey?: string, recoveryCode?: string, backend: string, installationId?: string, detail?: string }>}
326
+ */
327
+ async start() {
328
+ const { log = () => {}, store } = this.dependencies
329
+ if (this.started) {
330
+ log(`[adep-identity] ${ADEP_IDENTITY_CODES.alreadyStarted}: 已启动过,忽略重复调用`)
331
+ return this.#skip(ADEP_IDENTITY_CODES.alreadyStarted)
332
+ }
333
+ this.started = true
334
+
335
+ // ① 已有合法 ownerKey ⇒ 一枚安装只签发一次(分母断言:这条路径下请求数必须为 0)。
336
+ const existing = await store.readOwnerKey()
337
+ if (typeof existing === 'string' && existing !== '') {
338
+ if (isOwnerKey(existing)) {
339
+ const masked = `${existing.slice(0, OWNER_KEY_PREFIX.length + 6)}…`
340
+ log(`[adep-identity] ${ADEP_IDENTITY_CODES.alreadyIssued}: 本地已有 ownerKey(${masked}),不重复签发`)
341
+ return this.#skip(ADEP_IDENTITY_CODES.alreadyIssued, existing)
342
+ }
343
+ // 形态不符(历史垃圾值 / 手写调试值)不是可用身份 ⇒ 重新签发覆盖它。
344
+ // 不静默当成「已签发」——那会让 app 永远带着一枚服务端不认的凭据。
345
+ log(`[adep-identity] 本地 ownerKey 形态不符(长度 ${existing.length}),将重新签发`)
346
+ }
347
+
348
+ // ② 配置(壳根 adep.app.json)。缺失与非法分开报:前者是打包问题,后者是配置写错。
349
+ /** @type {adepIdentityConfig} */
350
+ let config
351
+ try {
352
+ config = parseIdentityConfig(this.dependencies.readConfig())
353
+ } catch (error) {
354
+ return this.#fail(error)
355
+ }
356
+ let url = ''
357
+ try {
358
+ url = issueUrl(config)
359
+ } catch (error) {
360
+ return this.#fail(error)
361
+ }
362
+
363
+ // ③ 请求。非 2xx 与传输层失败都落 requestFailed,但 detail 区分(status 0 = 没拿到响应)。
364
+ const body = JSON.stringify({ platform: this.dependencies.platform })
365
+ const result = await this.dependencies.transport(url, body, this.dependencies.timeoutMs ?? 10_000)
366
+ if (result.status < 200 || result.status >= 300) {
367
+ const detail =
368
+ result.error === null
369
+ ? `HTTP ${result.status}(url: ${url};响应:${(result.body ?? '').slice(0, 200)})`
370
+ : `传输层失败:${result.error}(url: ${url})`
371
+ return this.#fail(new IdentityError(ADEP_IDENTITY_CODES.requestFailed, detail))
372
+ }
373
+ if (result.body === null || result.body === '') {
374
+ return this.#fail(
375
+ new IdentityError(ADEP_IDENTITY_CODES.responseInvalid, `HTTP ${result.status} 但响应体为空`)
376
+ )
377
+ }
378
+
379
+ // ④ 解析 + 落库。
380
+ /** @type {adepIssueResult} */
381
+ let issue
382
+ try {
383
+ /** @type {unknown} */
384
+ let parsed
385
+ try {
386
+ parsed = JSON.parse(result.body)
387
+ } catch {
388
+ throw new IdentityError(ADEP_IDENTITY_CODES.responseInvalid, '签发响应不是合法 JSON')
389
+ }
390
+ issue = parseIssueResponse(parsed)
391
+ } catch (error) {
392
+ return this.#fail(error)
393
+ }
394
+
395
+ // ⑤ 恢复码先落库并读回校验(APP-028 的顺序)。
396
+ try {
397
+ await store.writeRecoveryCode(issue.recoveryCode)
398
+ } catch (error) {
399
+ return this.#fail(
400
+ new IdentityError(
401
+ ADEP_IDENTITY_CODES.storeFailed,
402
+ `恢复码写入受保护存储失败(installationId: ${issue.installationId}):${describe(error)}——本次不落 ownerKey,下次启动重新签发`
403
+ )
404
+ )
405
+ }
406
+ // 读回校验:写接口报成功但读不出是真实故障形态(条目被策略清掉 / backend 回退写到了
407
+ // 另一处)。只看写返回成功会把「码已丢」当成功——正是 APP-028 要消灭的静默。
408
+ if ((await store.readRecoveryCode()) !== issue.recoveryCode) {
409
+ return this.#fail(
410
+ new IdentityError(
411
+ ADEP_IDENTITY_CODES.storeFailed,
412
+ `恢复码写入后读回不一致(installationId: ${issue.installationId})——本次不落 ownerKey`
413
+ )
414
+ )
415
+ }
416
+
417
+ // ⑥ ownerKey 落库(写不进去也算失败:下次启动还会再签发一次,多一枚孤儿安装行)。
418
+ try {
419
+ await store.writeOwnerKey(issue.ownerKey)
420
+ } catch (error) {
421
+ return this.#fail(
422
+ new IdentityError(
423
+ ADEP_IDENTITY_CODES.storeFailed,
424
+ `ownerKey 写入受保护存储失败(installationId: ${issue.installationId}):${describe(error)}`
425
+ )
426
+ )
427
+ }
428
+
429
+ log(
430
+ `[adep-identity] 签发成功(installationId: ${issue.installationId}, platform: ${this.dependencies.platform}, ` +
431
+ `storage: ${store.backend}, recoveryCode: ${maskRecoveryCode(issue.recoveryCode, issue.installationId)})`
432
+ )
433
+ await this.#record({
434
+ state: 'issued',
435
+ code: 'IDENTITY_ISSUED',
436
+ backend: store.backend,
437
+ installationId: issue.installationId,
438
+ platform: this.dependencies.platform,
439
+ })
440
+ return {
441
+ status: 'issued',
442
+ code: 'IDENTITY_ISSUED',
443
+ ownerKey: issue.ownerKey,
444
+ recoveryCode: issue.recoveryCode,
445
+ backend: store.backend,
446
+ installationId: issue.installationId,
447
+ }
448
+ }
449
+
450
+ /**
451
+ * 跳过收口(未发请求)。状态文件照写:doctor 要能区分「已签发(正常)」与「还没跑过」。
452
+ *
453
+ * @param {string} code
454
+ * @param {string} [ownerKey]
455
+ */
456
+ async #skip(code, ownerKey) {
457
+ const { store } = this.dependencies
458
+ await this.#record({
459
+ state: 'issued',
460
+ code,
461
+ backend: store.backend,
462
+ platform: this.dependencies.platform,
463
+ })
464
+ return {
465
+ status: 'skipped',
466
+ code,
467
+ ...(typeof ownerKey === 'string' ? { ownerKey } : {}),
468
+ backend: store.backend,
469
+ }
470
+ }
471
+
472
+ /**
473
+ * 失败收口:日志带**原因码**(排障顺序:先看码,再看详情),状态文件同步落一份。
474
+ * 失败时**不写任何凭据**——只写状态(state=failed + code),下次启动据此重试。
475
+ *
476
+ * @param {unknown} error
477
+ */
478
+ async #fail(error) {
479
+ const { log = () => {} } = this.dependencies
480
+ const code = error instanceof IdentityError ? error.code : ADEP_IDENTITY_CODES.requestFailed
481
+ const detail = describe(error)
482
+ log(`[adep-identity] ${code}: ${detail}`)
483
+ await this.#record({ state: 'failed', code, backend: this.dependencies.store.backend, detail })
484
+ return {
485
+ status: 'failed',
486
+ code,
487
+ detail,
488
+ backend: this.dependencies.store.backend,
489
+ }
490
+ }
491
+
492
+ /**
493
+ * 状态落盘(非机密:只有 backend / 码 / installationId)。写失败不算自举失败——
494
+ * 状态文件是**读数面**,不是身份本身;丢一份读数不该让已经落库的凭据变成无效。
495
+ *
496
+ * @param {Record<string, unknown>} status
497
+ */
498
+ async #record(status) {
499
+ const record = this.dependencies.recordStatus
500
+ if (record === undefined) return
501
+ try {
502
+ await record({ ...status, at: new Date().toISOString() })
503
+ } catch (error) {
504
+ this.dependencies.log?.(`[adep-identity] 状态文件写入失败(不影响身份):${describe(error)}`)
505
+ }
506
+ }
507
+ }
508
+
509
+ /**
510
+ * 可读错误文本(`Error` 取 message,其余 String 化)。
511
+ *
512
+ * @param {unknown} error
513
+ * @returns {string}
514
+ */
515
+ export function describe(error) {
516
+ return error instanceof Error ? error.message : String(error)
517
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ESM 主进程入口的 CJS 包装(APP-036)。
3
+ *
4
+ * ## 为什么入口是 .cjs 而不是直接用 main.mjs
5
+ *
6
+ * 2026-10-23 视觉验收实测(macOS 27.0.1 / Electron 44.9.3):ESM 主进程(`main: main.mjs`)
7
+ * 的 top-level `await app.whenReady()` **永不 resolve**——主线程停在 libuv 的 io poll 上、
8
+ * 无任何错误输出、窗口不出现(TRACE 打点钉死卡点;对照实验:同一二进制跑 CJS 入口,
9
+ * whenReady 47ms 即 resolve)。CJS 入口一切正常,故模板入口换成本包装,`main.mjs`
10
+ * 保持 ESM 语义不动(import 链 / top-level await 都不变,只是由本文件动态 import)。
11
+ *
12
+ * 失败响亮:动态 import 失败(模板缺文件 / 语法错)不能静默——打日志并置非零退出码。
13
+ */
14
+ import('./main.mjs').catch((error) => {
15
+ console.error('[adep-app] 主进程加载失败:', error)
16
+ process.exitCode = 1
17
+ })