@adep/cli 0.4.4 → 0.4.6
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 +107 -11
- package/dist/index.js +33802 -27354
- package/dist/vite.js +247 -17
- package/dist/worker-entry.js +163 -1
- package/package.json +7 -6
- 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 +5 -1
- 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 +3 -0
- 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
|
@@ -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
|
+
}
|