@adep/cli 0.4.1 → 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.
- package/README.md +140 -11
- package/dist/index.js +34568 -27795
- package/dist/vite.js +279 -19
- package/dist/worker-entry.js +201 -2
- package/package.json +6 -5
- package/templates/app-desktop/README.md +67 -2
- package/templates/app-desktop/identity-store.mjs +309 -0
- package/templates/app-desktop/identity.mjs +517 -0
- package/templates/app-desktop/main.cjs +17 -0
- package/templates/app-desktop/main.mjs +218 -2
- package/templates/app-desktop/offline-runtime.mjs +72 -3
- package/templates/app-desktop/package.json +10 -6
- package/templates/app-desktop/recovery-ui.mjs +300 -0
- package/templates/app-desktop/sync-loop.mjs +137 -0
- package/templates/app-mobile/README.md +47 -9
- package/templates/app-mobile/package.json +2 -1
- package/templates/app-mobile/plugins/adep-bridge/Package.swift +52 -0
- package/templates/app-mobile/plugins/adep-bridge/android/build.gradle +34 -3
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/AndroidManifest.xml +12 -5
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepBridge.kt +198 -12
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepIdentity.kt +342 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepKeystore.kt +56 -2
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqlite.kt +506 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqliteLogic.kt +235 -0
- package/templates/app-mobile/plugins/adep-bridge/dist/plugin.js +30 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/AdepBridge.podspec +7 -1
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepBridge.swift +221 -9
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepIdentity.swift +500 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepKeychain.swift +69 -17
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepPermissionGate.swift +25 -4
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepShellConfig.swift +54 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepSqlite.swift +614 -0
- package/templates/app-mobile/plugins/adep-bridge/package.json +10 -0
- package/templates/app-mobile/template.json +12 -3
- package/templates/app-mobile/web/adep-bridge.js +103 -9
- package/templates/app-mobile/web/offline-runtime.js +230 -0
- package/templates/app-mobile-xtool/AdepShell-Info.plist +24 -0
- package/templates/app-mobile-xtool/Package.swift +59 -0
- package/templates/app-mobile-xtool/README.md +81 -0
- package/templates/app-mobile-xtool/Sources/AdepShell/App.swift +36 -0
- package/templates/app-mobile-xtool/Sources/AdepShell/ShellViewController.swift +223 -0
- package/templates/app-mobile-xtool/adep.permissions.json +4 -0
- package/templates/app-mobile-xtool/app/site/.gitkeep +0 -0
- package/templates/app-mobile-xtool/template.json +15 -0
- package/templates/app-mobile-xtool/xtool.yml +35 -0
- package/templates/app-mobile/plugins/adep-bridge/capacitor.plugin.json +0 -10
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"homepage": "https://adep.top",
|
|
9
9
|
"bugs": "https://gitcode.com/yunqiang_wu/ai-yunke/issues",
|
|
10
|
-
"version": "0.4.
|
|
10
|
+
"version": "0.4.5",
|
|
11
11
|
"type": "module",
|
|
12
12
|
"description": "`adep` 命令行工具包位:登录 / 初始化 / 本地调试 / 部署 / 数据库维护 / 云存储 / 静态托管 / 微前端组件(widget),实现见任务单 CLI-001 ~ CLI-003 与 FE-002;子路径 `@adep/cli/vite` 提供云函数 vite 插件(CLI-014)",
|
|
13
13
|
"bin": {
|
|
@@ -29,14 +29,15 @@
|
|
|
29
29
|
"commander": "14.0.2",
|
|
30
30
|
"esbuild": "0.25.12",
|
|
31
31
|
"yaml": "2.9.1",
|
|
32
|
-
"zod": "^4.
|
|
33
|
-
"@adep/cf-compat": "0.1.
|
|
34
|
-
"@adep/runtime": "0.
|
|
32
|
+
"zod": "^4.6.6",
|
|
33
|
+
"@adep/cf-compat": "0.1.4",
|
|
34
|
+
"@adep/app-runtime": "0.1.1",
|
|
35
|
+
"@adep/runtime": "0.3.5",
|
|
35
36
|
"@adep/crypto": "0.1.1",
|
|
36
37
|
"@adep/vite-plugin": "0.1.9"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
|
-
"@adep/types": "0.3.
|
|
40
|
+
"@adep/types": "0.3.2"
|
|
40
41
|
},
|
|
41
42
|
"scripts": {
|
|
42
43
|
"test": "vitest run",
|
|
@@ -18,7 +18,7 @@ GUI(前端产物)随包本地加载,云函数经 HTTP 在服务端执行
|
|
|
18
18
|
|
|
19
19
|
1. 把平台项目的前端构建产物放进 `app/site/`(`adep app build` 会自动拉取已发布产物);
|
|
20
20
|
2. `npm install`;
|
|
21
|
-
3. `npm start
|
|
21
|
+
3. `npm start`(或仓库根的 `adep app open`,等价——直接调壳工程内的 electron;配置从 `adep.app.json` 读取,无需环境变量)。
|
|
22
22
|
|
|
23
23
|
## 页面侧调用云函数(在线运行时)
|
|
24
24
|
|
|
@@ -31,12 +31,77 @@ const client = createAdepClient({ baseUrl: window.__ADEP_APP__.platformUrl })
|
|
|
31
31
|
const result = await client.invokeFunction('hello', { name: 'world' })
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
## 设备身份与同步(离线构建之后)
|
|
35
|
+
|
|
36
|
+
离线构建把设备端运行时注入 `app/offline/`,首次启动时在 `data/offline.sqlite` 建本地库:
|
|
37
|
+
白名单函数在本地执行,写 owned 表时同时追加变更流。
|
|
38
|
+
|
|
39
|
+
**设备身份是首启自动签发的**(APP-033 / PRD §2.11.5):主进程启动后在后台调一次
|
|
40
|
+
`POST /api/v1/projects/<slug>/app-installations`(`platform` 按**宿主 OS** 映射为
|
|
41
|
+
`macos` / `windows` / `linux`——服务端没有 `desktop` 这个取值),拿到 `ownerKey` +
|
|
42
|
+
一次性恢复码后写进受保护存储;此后每次启动只读不发请求。
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
identity.mjs 判定层(配置 / 端点 / 形态 / 响应解析 / 平台映射)+ 自举流程
|
|
46
|
+
identity-store.mjs 受保护存储:macOS 钥匙串 → Linux Secret Service → 0600 文件(明说降级)
|
|
47
|
+
recovery-ui.mjs 恢复码呈现与导出(APP-034;全在主进程,页面侧拿不到)
|
|
48
|
+
sync-loop.mjs 后台同步驱动(首轮延迟 → 每 5 分钟一轮;不重叠、失败不致命)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
存储后端:
|
|
52
|
+
|
|
53
|
+
- macOS:`security add-generic-password`(登录钥匙串);
|
|
54
|
+
- Linux:`secret-tool`(Secret Service);
|
|
55
|
+
- **Windows 与「系统库不可用」**:退化到 `data/identity/` 下的 0600 文件,并在日志与
|
|
56
|
+
`adep app doctor` 里**明说「非系统凭据」**——不会静默假装是钥匙串。
|
|
57
|
+
|
|
58
|
+
身份状态(**非机密**:只有存储类型 / 原因码 / 安装 id / 时间)写在
|
|
59
|
+
`data/identity/status.json`,`adep app doctor` 的 `identity` 项读它:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
adep app doctor # 已签发(系统凭据库 keychain) / 非系统凭据(file) / 首启签发失败(原因码)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
⚠️ **页面侧拿不到 `ownerKey`**(与移动壳同一条边界):同步跑在主进程,渲染进程不需要这枚
|
|
66
|
+
bearer 凭据,给它等于「任意 XSS 都能劫持该设备的数据分区」。
|
|
67
|
+
|
|
68
|
+
## 恢复码(APP-034:呈现 / 再次查看 / 导出)
|
|
69
|
+
|
|
70
|
+
签发响应里的恢复码**只出现一次**(服务端只存摘要、任何端点不重发),壳负责把它递到用户手里:
|
|
71
|
+
|
|
72
|
+
- **首次呈现**:自举成功后自动弹一次原生对话框(码可读且没呈现过才弹——老安装升级上来
|
|
73
|
+
同样有机会看到)。此后不再主动弹,标记写在 `data/identity/recovery-code-shown`;
|
|
74
|
+
- **再次查看 / 导出**:应用菜单 **「凭据」→「查看恢复码…」/「导出恢复码…」**(导出文件
|
|
75
|
+
头部写明「服务端无法重发,丢了码等于放弃认领」);
|
|
76
|
+
- **复制到剪贴板**:两个对话框里都有一键复制。
|
|
77
|
+
|
|
78
|
+
呈现与导出**全在主进程**(应用菜单 + 原生对话框):不开 IPC、不改 preload,恢复码与
|
|
79
|
+
`ownerKey` 一样不进渲染进程。码不可读时(未签发 / 存储失败)对话框会说明原因并指向
|
|
80
|
+
`adep app doctor`,不是静默空窗。
|
|
81
|
+
|
|
82
|
+
身份就绪后主进程自动把同步挂到运行时上:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
const runtime = await createOfflineRuntime() // 页面先可用
|
|
86
|
+
const sync = runtime.attachSync({ ownerKey }) // 身份到位后再挂(不重开库)
|
|
87
|
+
await sync.push() // 本地变更流 → outbox → 云端
|
|
88
|
+
await sync.pull() // 云端增量 → 本地库(直写驱动,不回灌 outbox)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
手动同步(开发期 / 排障)仍用 CLI:`adep app sync --push`(身份取 `--owner-key` 或
|
|
92
|
+
`ADEP_OWNER_KEY`)。**不要**在 `adep.app.json` 或环境变量里编一个 key:本地自造会写出一个
|
|
93
|
+
「看起来成功」的空分区(PRD §2.11.5 明确「本地自造判违规」)。
|
|
94
|
+
|
|
95
|
+
⚠️ 已知限制(已登记):打包形态下 `data/` 仍在应用目录内(`.app` / Program Files),
|
|
96
|
+
写入可能被系统拒绝——此时 `identity` 项会显示 `IDENTITY_STORE_FAILED`,属于**响亮失败**。
|
|
97
|
+
把本地数据目录迁到 `app.getPath('userData')` 是独立的一批(含 `data/offline.sqlite`)。
|
|
98
|
+
|
|
34
99
|
## 打安装包
|
|
35
100
|
|
|
36
101
|
推荐用 CLI(会自动拉取**已发布**的前端产物、回写产物版本、产出构建报告):
|
|
37
102
|
|
|
38
103
|
```bash
|
|
39
|
-
adep app build # 当前平台、走 package.json build 段的 target(mac dmg / win nsis / linux AppImage)
|
|
104
|
+
adep app build # 当前平台、走 package.json build 段的 target(mac dmg / win nsis+zip / linux AppImage+deb)
|
|
40
105
|
adep app build --target dir # 只出未压缩目录(macOS 即 .app),本地快速验证
|
|
41
106
|
adep app build --prepare-only # 只拉产物 + 注入 + 出构建报告,不调 electron-builder
|
|
42
107
|
```
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 桌面端受保护存储(APP-033;PRD §2.11.5「桌面端存系统凭据/受保护存储」)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么不是「写个 0600 文件就完事」
|
|
5
|
+
*
|
|
6
|
+
* 0600 文件只挡同机其他用户,**挡不住以该用户身份运行的其他进程**(同用户读得到)。
|
|
7
|
+
* 系统凭据库(macOS Keychain / Linux Secret Service)多一层「条目归属 + 访问控制」,
|
|
8
|
+
* 这是 PRD 要求它的原因。所以本模块的顺序是:**先试系统凭据库,失败才退化**。
|
|
9
|
+
*
|
|
10
|
+
* ## 退化必须是响亮的
|
|
11
|
+
*
|
|
12
|
+
* 退化形态(`data/identity/*` 0600 文件)与系统凭据在调用侧**返回值完全同形**——
|
|
13
|
+
* 差别只在 `backend` 这一个字段。所以:
|
|
14
|
+
* - `backend` 是**读写**属性(不是构造期快照):系统库写失败当场改成 `file`,
|
|
15
|
+
* 调用方读到的永远是「实际生效的那一个」,不会出现「报告说 Keychain、实际在明文文件里」;
|
|
16
|
+
* - 退化时打 `IDENTITY_STORE_FALLBACK` 码 + 一句人话,状态文件里也写 `backend: "file"`,
|
|
17
|
+
* `adep app doctor` 据此显示「非系统凭据」——**不静默假装是 Keychain**(验收标准第 4 条)。
|
|
18
|
+
*
|
|
19
|
+
* ## Windows 的实况(如实登记,不假装)
|
|
20
|
+
*
|
|
21
|
+
* 本批只落了 macOS(`security`)与 Linux(`secret-tool`)。Windows 的 DPAPI / Credential
|
|
22
|
+
* Manager 需要 PowerShell 或原生模块,**本机无法验证**(与 SQLCipher 真机证据同口径)⇒
|
|
23
|
+
* 不写一段跑不到、也没人验过的 PowerShell:Windows 上直接走退化文件并打同一个 fallback 码。
|
|
24
|
+
* 缺口登记在 APP-033 的归档卡「超出范围」里,不在这里假装已支持。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** 受保护存储后端标识(进状态文件与 doctor 读数,**不要**改字面值)。 */
|
|
28
|
+
export const STORE_BACKENDS = {
|
|
29
|
+
/** macOS 登录钥匙串(`security` 命令行)。 */
|
|
30
|
+
keychain: 'keychain',
|
|
31
|
+
/** Linux Secret Service(`secret-tool`,GNOME Keyring / KWallet 的通用前端)。 */
|
|
32
|
+
secretService: 'secret-service',
|
|
33
|
+
/** 退化形态:`data/identity/**` 下 0600 文件。 */
|
|
34
|
+
file: 'file',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 身份文件目录(相对壳工程根)。doctor 读同一个目录 ⇒ 改这里要同步改 `app-doctor.ts`。 */
|
|
38
|
+
export const IDENTITY_DIR = 'data/identity'
|
|
39
|
+
/** ownerKey 文件名(0600)。 */
|
|
40
|
+
export const OWNER_KEY_FILE = 'owner-key'
|
|
41
|
+
/** 恢复码文件名(0600)。 */
|
|
42
|
+
export const RECOVERY_CODE_FILE = 'recovery-code'
|
|
43
|
+
/** 身份状态文件名(**非机密**:backend / 码 / installationId / 时间)。 */
|
|
44
|
+
export const STATUS_FILE = 'status.json'
|
|
45
|
+
|
|
46
|
+
/** 退化原因码(日志与状态文件都带它,排障时先看码)。 */
|
|
47
|
+
export const STORE_CODES = {
|
|
48
|
+
fallback: 'IDENTITY_STORE_FALLBACK',
|
|
49
|
+
unavailable: 'IDENTITY_STORE_UNAVAILABLE',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 后端选择:看宿主 OS,不看配置。
|
|
54
|
+
*
|
|
55
|
+
* @param {string} platform `process.platform` 取值
|
|
56
|
+
* @returns {'keychain'|'secret-service'|null} null 表示该平台没有系统凭据库实现(Windows 属此形态)
|
|
57
|
+
*/
|
|
58
|
+
export function systemBackendFor(platform) {
|
|
59
|
+
if (platform === 'darwin') return STORE_BACKENDS.keychain
|
|
60
|
+
if (platform === 'linux') return STORE_BACKENDS.secretService
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 缺省执行器:`execFile` + stdin 注入,失败折成 `{ ok: false }`(不 throw,调用方按 ok 分支)。
|
|
66
|
+
*
|
|
67
|
+
* 注入点存在的理由:`app-desktop-identity.test.ts` 要能造「系统库不可用 / 写成功但读回空 /
|
|
68
|
+
* 命令超时」这些形态,而它们在本机(macOS)要么做不到、要么会污染真实钥匙串。
|
|
69
|
+
*
|
|
70
|
+
* @param {string} command
|
|
71
|
+
* @param {string[]} args
|
|
72
|
+
* @param {string} [input] 写进子进程 stdin 的内容(`secret-tool store` 从 stdin 读密文)
|
|
73
|
+
* @returns {Promise<{ ok: boolean, stdout: string, error: string | null }>}
|
|
74
|
+
*/
|
|
75
|
+
export function createExecFileRunner() {
|
|
76
|
+
return async (command, args, input) => {
|
|
77
|
+
const { execFile } = await import('node:child_process')
|
|
78
|
+
return await new Promise((resolve) => {
|
|
79
|
+
const child = execFile(
|
|
80
|
+
command,
|
|
81
|
+
args,
|
|
82
|
+
{ timeout: 15_000, windowsHide: true },
|
|
83
|
+
(error, stdout) => {
|
|
84
|
+
resolve({
|
|
85
|
+
ok: error === null,
|
|
86
|
+
stdout: typeof stdout === 'string' ? stdout : String(stdout),
|
|
87
|
+
error: error === null ? null : error.message,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
if (input !== undefined && child.stdin !== null) {
|
|
92
|
+
child.stdin.end(input)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 值形态守卫:换行 / NUL 会让「写进去的」与「读回来的」不是同一个串
|
|
100
|
+
* (`security -w` 会把后续行当新参数,`secret-tool` 走 stdin 时换行直接截断)。
|
|
101
|
+
*
|
|
102
|
+
* @param {string} account
|
|
103
|
+
* @param {string} value
|
|
104
|
+
*/
|
|
105
|
+
function assertStorableValue(account, value) {
|
|
106
|
+
if (typeof value !== 'string' || value === '') {
|
|
107
|
+
throw new Error(`${account} 不能为空`)
|
|
108
|
+
}
|
|
109
|
+
if (value.includes('\n') || value.includes('\r') || value.includes('\u0000')) {
|
|
110
|
+
throw new Error(`${account} 不能包含换行或 NUL(换行会被凭据命令截断)`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @typedef {Object} adepStoreOptions
|
|
116
|
+
* @property {string} root 壳工程根(data/identity/ 落在这里)
|
|
117
|
+
* @property {string} service 凭据条目的 service 串(如 `adep-app:<projectSlug>`)
|
|
118
|
+
* @property {string} [platform] 宿主平台(缺省 process.platform)
|
|
119
|
+
* @property {(command: string, args: string[], input?: string) => Promise<{ok: boolean, stdout: string, error: string | null}>} [exec] 凭据命令执行器(测试注入)
|
|
120
|
+
* @property {(line: string) => void} [log] 降级 / 失败日志
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 造一个受保护存储。
|
|
125
|
+
*
|
|
126
|
+
* @param {adepStoreOptions} options
|
|
127
|
+
*/
|
|
128
|
+
export function createProtectedStore(options) {
|
|
129
|
+
const {
|
|
130
|
+
root,
|
|
131
|
+
service,
|
|
132
|
+
platform = process.platform,
|
|
133
|
+
exec = createExecFileRunner(),
|
|
134
|
+
log = () => {},
|
|
135
|
+
} = options
|
|
136
|
+
const system = systemBackendFor(platform)
|
|
137
|
+
/** @type {string} 实际生效的后端(系统库写失败会当场降级) */
|
|
138
|
+
let effective = system ?? STORE_BACKENDS.file
|
|
139
|
+
const dir = joinPath(root, IDENTITY_DIR)
|
|
140
|
+
|
|
141
|
+
// ⚠️ 命令按**期望后端**(`system`)选,不按 `effective`:降级之后 `effective` 已是 `file`,
|
|
142
|
+
// 若拿它选命令,macOS 上会去调 `secret-tool`(不存在)——写失败被判成「系统库不可用」,
|
|
143
|
+
// 而真正的 `security` 一次都没试过。
|
|
144
|
+
const commandFor = (/** @type {string} */ action, /** @type {string} */ account, /** @type {string|undefined} */ value) => {
|
|
145
|
+
if (system === STORE_BACKENDS.keychain) {
|
|
146
|
+
if (action === 'read') {
|
|
147
|
+
return { command: 'security', args: ['find-generic-password', '-s', service, '-a', account, '-w'] }
|
|
148
|
+
}
|
|
149
|
+
if (action === 'write') {
|
|
150
|
+
// -U:同 service/account 已存在时原地更新(不 -U 会报 duplicate item)。
|
|
151
|
+
return { command: 'security', args: ['add-generic-password', '-U', '-s', service, '-a', account, '-w', String(value)] }
|
|
152
|
+
}
|
|
153
|
+
return { command: 'security', args: ['delete-generic-password', '-s', service, '-a', account] }
|
|
154
|
+
}
|
|
155
|
+
if (action === 'read') {
|
|
156
|
+
return { command: 'secret-tool', args: ['lookup', 'service', service, 'account', account] }
|
|
157
|
+
}
|
|
158
|
+
if (action === 'write') {
|
|
159
|
+
return {
|
|
160
|
+
command: 'secret-tool',
|
|
161
|
+
args: ['store', `--label=${service} ${account}`, 'service', service, 'account', account],
|
|
162
|
+
input: String(value),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { command: 'secret-tool', args: ['clear', 'service', service, 'account', account] }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 系统凭据库读:不存在 / 命令失败都当「没有」,交给退化路径兜。 */
|
|
169
|
+
const readSystem = async (/** @type {string} */ account) => {
|
|
170
|
+
if (system === null) return null
|
|
171
|
+
const { command, args } = commandFor('read', account, undefined)
|
|
172
|
+
const result = await exec(command, args)
|
|
173
|
+
if (!result.ok) return null
|
|
174
|
+
const value = result.stdout.trim()
|
|
175
|
+
return value === '' ? null : value
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 退化快照:文件不存在返回 null(**不**把「读失败」当成空值写回)。 */
|
|
179
|
+
const readFileValue = async (/** @type {string} */ name) => {
|
|
180
|
+
const { readFile } = await import('node:fs/promises')
|
|
181
|
+
try {
|
|
182
|
+
const text = await readFile(joinPath(dir, name), 'utf8')
|
|
183
|
+
return text === '' ? null : text
|
|
184
|
+
} catch {
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 退化写:目录 0700、文件 0600(先建后 chmod,旧文件已存在时 mode 参数不生效)。 */
|
|
190
|
+
const writeFileValue = async (/** @type {string} */ name, /** @type {string} */ value) => {
|
|
191
|
+
const { chmod, mkdir, writeFile } = await import('node:fs/promises')
|
|
192
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
193
|
+
const file = joinPath(dir, name)
|
|
194
|
+
await writeFile(file, value, { encoding: 'utf8', mode: 0o600 })
|
|
195
|
+
// mode 在文件已存在时被忽略 ⇒ 显式再 chmod 一次(0600 是这条路径唯一的保护)。
|
|
196
|
+
await chmod(file, 0o600)
|
|
197
|
+
return file
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 写系统凭据库;失败返回 false(由调用方降级)。 */
|
|
201
|
+
const writeSystem = async (/** @type {string} */ account, /** @type {string} */ value) => {
|
|
202
|
+
if (system === null) return false
|
|
203
|
+
const { command, args, input } = commandFor('write', account, value)
|
|
204
|
+
const result = await exec(command, args, input)
|
|
205
|
+
if (!result.ok) {
|
|
206
|
+
log(
|
|
207
|
+
`[adep-identity] ${STORE_CODES.fallback}: 系统凭据库写入失败(${system}:${result.error ?? '未知原因'})`
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
return result.ok
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const read = async (/** @type {string} */ account, /** @type {string} */ file) => {
|
|
214
|
+
const fromSystem = await readSystem(account)
|
|
215
|
+
if (fromSystem !== null) return fromSystem
|
|
216
|
+
return await readFileValue(file)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const write = async (
|
|
220
|
+
/** @type {string} */ account,
|
|
221
|
+
/** @type {string} */ file,
|
|
222
|
+
/** @type {string} */ value
|
|
223
|
+
) => {
|
|
224
|
+
assertStorableValue(account, value)
|
|
225
|
+
if (await writeSystem(account, value)) {
|
|
226
|
+
// 系统库成功 ⇒ 清掉退化副本(同一枚凭据只留一份,避免「哪份是活的」歧义)。
|
|
227
|
+
await removeFileQuietly(joinPath(dir, file))
|
|
228
|
+
effective = system ?? STORE_BACKENDS.file
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (effective !== STORE_BACKENDS.file) {
|
|
232
|
+
effective = STORE_BACKENDS.file
|
|
233
|
+
log(
|
|
234
|
+
`[adep-identity] ${STORE_CODES.fallback}: ${account} 落到 data/identity/${file}(0600)——**非系统凭据**,同机同用户进程可读`
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
await writeFileValue(file, value)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
/** 实际生效的后端(退化后变成 `file`)。 */
|
|
242
|
+
get backend() {
|
|
243
|
+
return effective
|
|
244
|
+
},
|
|
245
|
+
/** 期望后端(诊断用:`file` 表示这台机器从一开始就没有系统凭据库)。 */
|
|
246
|
+
get preferredBackend() {
|
|
247
|
+
return system ?? STORE_BACKENDS.file
|
|
248
|
+
},
|
|
249
|
+
readOwnerKey: () => read('ownerKey', OWNER_KEY_FILE),
|
|
250
|
+
writeOwnerKey: (/** @type {string} */ value) => write('ownerKey', OWNER_KEY_FILE, value),
|
|
251
|
+
readRecoveryCode: () => read('recoveryCode', RECOVERY_CODE_FILE),
|
|
252
|
+
writeRecoveryCode: (/** @type {string} */ value) => write('recoveryCode', RECOVERY_CODE_FILE, value),
|
|
253
|
+
/** 吊销 / 换发时清空两把(本批未接 UI,留给轮换路径用)。 */
|
|
254
|
+
async clear() {
|
|
255
|
+
const targets = [
|
|
256
|
+
['ownerKey', OWNER_KEY_FILE],
|
|
257
|
+
['recoveryCode', RECOVERY_CODE_FILE],
|
|
258
|
+
]
|
|
259
|
+
await Promise.all(
|
|
260
|
+
targets.map(async ([account, file]) => {
|
|
261
|
+
if (system !== null) {
|
|
262
|
+
const { command, args } = commandFor('delete', String(account), undefined)
|
|
263
|
+
await exec(command, args)
|
|
264
|
+
}
|
|
265
|
+
await removeFileQuietly(joinPath(dir, String(file)))
|
|
266
|
+
})
|
|
267
|
+
)
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 写身份状态(**非机密**,0600 + 原子替换)。
|
|
274
|
+
*
|
|
275
|
+
* 为什么要有这个文件:桌面壳是 GUI,日志(stderr)用户看不到;身份到底签没签、用的哪种
|
|
276
|
+
* 存储,必须有一条**可判读**的读数路径。`adep app doctor` 读的就是它(APP-030 的教训:
|
|
277
|
+
* 读数存在但读不出来等于没有)。
|
|
278
|
+
*
|
|
279
|
+
* @param {string} root 壳工程根
|
|
280
|
+
* @param {Record<string, unknown>} record
|
|
281
|
+
* @returns {Promise<string>} 状态文件绝对路径
|
|
282
|
+
*/
|
|
283
|
+
export async function writeIdentityStatus(root, record) {
|
|
284
|
+
const { chmod, mkdir, rename, writeFile } = await import('node:fs/promises')
|
|
285
|
+
const dir = joinPath(root, IDENTITY_DIR)
|
|
286
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
287
|
+
const file = joinPath(dir, STATUS_FILE)
|
|
288
|
+
// 原子替换:doctor 可能正好在写的时候读,读到半截 JSON 会被判成「损坏」。
|
|
289
|
+
const temp = `${file}.tmp-${process.pid}`
|
|
290
|
+
await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
291
|
+
await rename(temp, file)
|
|
292
|
+
await chmod(file, 0o600)
|
|
293
|
+
return file
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 最小路径拼接(避免为了一个 join 引 node:path 的 ESM 互操作差异)。 */
|
|
297
|
+
function joinPath(/** @type {string} */ base, /** @type {string} */ rel) {
|
|
298
|
+
return `${base.replace(/[\\/]+$/, '')}/${rel}`
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 删文件(不存在 / 无权限都静默:它只是「清副本」,失败不改变主路径结果)。 */
|
|
302
|
+
async function removeFileQuietly(/** @type {string} */ file) {
|
|
303
|
+
const { rm } = await import('node:fs/promises')
|
|
304
|
+
try {
|
|
305
|
+
await rm(file, { force: true })
|
|
306
|
+
} catch {
|
|
307
|
+
// 清不掉退化副本不是致命错:主凭据已经写进系统库,这里只影响整洁度。
|
|
308
|
+
}
|
|
309
|
+
}
|