@logictan/dsh-config-manager 0.1.60

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 (429) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/cordis.patch.yml +21 -0
  4. package/lib/adapters/agent-instructions.d.ts +13 -0
  5. package/lib/adapters/agent-instructions.js +46 -0
  6. package/lib/adapters/agent-presets.d.ts +13 -0
  7. package/lib/adapters/agent-presets.js +14 -0
  8. package/lib/adapters/credentials.d.ts +37 -0
  9. package/lib/adapters/credentials.js +122 -0
  10. package/lib/adapters/file-collection.d.ts +15 -0
  11. package/lib/adapters/file-collection.js +127 -0
  12. package/lib/adapters/index.d.ts +57 -0
  13. package/lib/adapters/index.js +55 -0
  14. package/lib/adapters/link-report.d.ts +16 -0
  15. package/lib/adapters/link-report.js +47 -0
  16. package/lib/adapters/mcp.d.ts +28 -0
  17. package/lib/adapters/mcp.js +178 -0
  18. package/lib/adapters/plugin-files.d.ts +18 -0
  19. package/lib/adapters/plugin-files.js +142 -0
  20. package/lib/adapters/plugins.d.ts +41 -0
  21. package/lib/adapters/plugins.js +603 -0
  22. package/lib/adapters/pnpm-workspace.d.ts +26 -0
  23. package/lib/adapters/pnpm-workspace.js +147 -0
  24. package/lib/adapters/prompts.d.ts +30 -0
  25. package/lib/adapters/prompts.js +231 -0
  26. package/lib/adapters/providers.d.ts +34 -0
  27. package/lib/adapters/providers.js +155 -0
  28. package/lib/adapters/self.d.ts +17 -0
  29. package/lib/adapters/self.js +71 -0
  30. package/lib/adapters/sessions.d.ts +14 -0
  31. package/lib/adapters/sessions.js +15 -0
  32. package/lib/adapters/settings.d.ts +28 -0
  33. package/lib/adapters/settings.js +165 -0
  34. package/lib/adapters/skills.d.ts +13 -0
  35. package/lib/adapters/skills.js +14 -0
  36. package/lib/adapters/test-helpers.d.ts +125 -0
  37. package/lib/adapters/test-helpers.js +236 -0
  38. package/lib/adapters/ui.d.ts +27 -0
  39. package/lib/adapters/ui.js +89 -0
  40. package/lib/adapters/workspaces.d.ts +13 -0
  41. package/lib/adapters/workspaces.js +106 -0
  42. package/lib/client.d.ts +1026 -0
  43. package/lib/client.js +10047 -0
  44. package/lib/core/analyzer.d.ts +99 -0
  45. package/lib/core/analyzer.js +934 -0
  46. package/lib/core/backup.d.ts +138 -0
  47. package/lib/core/backup.js +513 -0
  48. package/lib/core/boot-rescue.d.ts +146 -0
  49. package/lib/core/boot-rescue.js +618 -0
  50. package/lib/core/cache-cleaner.d.ts +31 -0
  51. package/lib/core/cache-cleaner.js +95 -0
  52. package/lib/core/config-lifecycle.d.ts +180 -0
  53. package/lib/core/config-lifecycle.js +481 -0
  54. package/lib/core/config-snapshot.d.ts +135 -0
  55. package/lib/core/config-snapshot.js +424 -0
  56. package/lib/core/config-state.d.ts +70 -0
  57. package/lib/core/config-state.js +177 -0
  58. package/lib/core/consult-source.d.ts +58 -0
  59. package/lib/core/consult-source.js +260 -0
  60. package/lib/core/crash-report.d.ts +84 -0
  61. package/lib/core/crash-report.js +317 -0
  62. package/lib/core/exporter.d.ts +68 -0
  63. package/lib/core/exporter.js +375 -0
  64. package/lib/core/ghost-sweep.d.ts +19 -0
  65. package/lib/core/ghost-sweep.js +52 -0
  66. package/lib/core/importer.d.ts +60 -0
  67. package/lib/core/importer.js +29 -0
  68. package/lib/core/index.d.ts +14 -0
  69. package/lib/core/index.js +14 -0
  70. package/lib/core/journal.d.ts +197 -0
  71. package/lib/core/journal.js +418 -0
  72. package/lib/core/local-plugin-host.d.ts +35 -0
  73. package/lib/core/local-plugin-host.js +120 -0
  74. package/lib/core/local-plugin-pack.d.ts +135 -0
  75. package/lib/core/local-plugin-pack.js +278 -0
  76. package/lib/core/messages.d.ts +310 -0
  77. package/lib/core/messages.js +621 -0
  78. package/lib/core/migration-consult.d.ts +117 -0
  79. package/lib/core/migration-consult.js +363 -0
  80. package/lib/core/migration-history.d.ts +153 -0
  81. package/lib/core/migration-history.js +411 -0
  82. package/lib/core/model-tools.d.ts +50 -0
  83. package/lib/core/model-tools.js +148 -0
  84. package/lib/core/msg-types.d.ts +8 -0
  85. package/lib/core/msg-types.js +6 -0
  86. package/lib/core/phase3-child-crash.d.ts +1 -0
  87. package/lib/core/phase3-child-crash.js +87 -0
  88. package/lib/core/phase3-host.d.ts +138 -0
  89. package/lib/core/phase3-host.js +359 -0
  90. package/lib/core/phase3-prod-child.d.ts +1 -0
  91. package/lib/core/phase3-prod-child.js +35 -0
  92. package/lib/core/phase4-crash-child.d.ts +1 -0
  93. package/lib/core/phase4-crash-child.js +103 -0
  94. package/lib/core/plugin-cli.d.ts +150 -0
  95. package/lib/core/plugin-cli.js +497 -0
  96. package/lib/core/reconcile.d.ts +120 -0
  97. package/lib/core/reconcile.js +389 -0
  98. package/lib/core/recovery-orchestrator.d.ts +94 -0
  99. package/lib/core/recovery-orchestrator.js +327 -0
  100. package/lib/core/restore.d.ts +162 -0
  101. package/lib/core/restore.js +703 -0
  102. package/lib/core/rollback.d.ts +12 -0
  103. package/lib/core/rollback.js +145 -0
  104. package/lib/core/run-registry.d.ts +85 -0
  105. package/lib/core/run-registry.js +157 -0
  106. package/lib/core/startup-barrier.d.ts +53 -0
  107. package/lib/core/startup-barrier.js +77 -0
  108. package/lib/core/types.d.ts +515 -0
  109. package/lib/core/types.js +27 -0
  110. package/lib/core/undo.d.ts +73 -0
  111. package/lib/core/undo.js +87 -0
  112. package/lib/core/validator.d.ts +18 -0
  113. package/lib/core/validator.js +77 -0
  114. package/lib/core/verify-recovery.d.ts +21 -0
  115. package/lib/core/verify-recovery.js +256 -0
  116. package/lib/core/watcher.d.ts +101 -0
  117. package/lib/core/watcher.js +219 -0
  118. package/lib/index.d.ts +238 -0
  119. package/lib/index.js +2764 -0
  120. package/lib/migrations/index.d.ts +29 -0
  121. package/lib/migrations/index.js +56 -0
  122. package/lib/migrations/v1-to-v2.d.ts +9 -0
  123. package/lib/migrations/v1-to-v2.js +10 -0
  124. package/lib/profiles/index.d.ts +4 -0
  125. package/lib/profiles/index.js +5 -0
  126. package/lib/profiles/profile-manager.d.ts +106 -0
  127. package/lib/profiles/profile-manager.js +543 -0
  128. package/lib/schema/config.d.ts +28 -0
  129. package/lib/schema/config.js +165 -0
  130. package/lib/schema/index.d.ts +24 -0
  131. package/lib/schema/index.js +26 -0
  132. package/lib/schema/manifest.d.ts +28 -0
  133. package/lib/schema/manifest.js +109 -0
  134. package/lib/schema/tombstones.d.ts +46 -0
  135. package/lib/schema/tombstones.js +64 -0
  136. package/lib/schema/types.d.ts +232 -0
  137. package/lib/schema/types.js +8 -0
  138. package/lib/schema/versions.d.ts +27 -0
  139. package/lib/schema/versions.js +50 -0
  140. package/lib/security/encryption.d.ts +85 -0
  141. package/lib/security/encryption.js +279 -0
  142. package/lib/security/index.d.ts +16 -0
  143. package/lib/security/index.js +17 -0
  144. package/lib/security/integrity.d.ts +30 -0
  145. package/lib/security/integrity.js +78 -0
  146. package/lib/security/redaction.d.ts +26 -0
  147. package/lib/security/redaction.js +95 -0
  148. package/lib/security/secret-scanner.d.ts +86 -0
  149. package/lib/security/secret-scanner.js +404 -0
  150. package/lib/security/vault.d.ts +59 -0
  151. package/lib/security/vault.js +151 -0
  152. package/lib/security/zip-security.d.ts +27 -0
  153. package/lib/security/zip-security.js +154 -0
  154. package/lib/sync/autosync-config.d.ts +39 -0
  155. package/lib/sync/autosync-config.js +151 -0
  156. package/lib/sync/autosync-scheduler.d.ts +139 -0
  157. package/lib/sync/autosync-scheduler.js +564 -0
  158. package/lib/sync/fs.d.ts +18 -0
  159. package/lib/sync/fs.js +49 -0
  160. package/lib/sync/git/git-transport.d.ts +100 -0
  161. package/lib/sync/git/git-transport.js +437 -0
  162. package/lib/sync/github-auth.d.ts +131 -0
  163. package/lib/sync/github-auth.js +264 -0
  164. package/lib/sync/layout.d.ts +43 -0
  165. package/lib/sync/layout.js +203 -0
  166. package/lib/sync/snapshot-json.d.ts +9 -0
  167. package/lib/sync/snapshot-json.js +107 -0
  168. package/lib/sync/sync-config.d.ts +81 -0
  169. package/lib/sync/sync-config.js +281 -0
  170. package/lib/sync/sync-engine.d.ts +269 -0
  171. package/lib/sync/sync-engine.js +591 -0
  172. package/lib/sync/sync-history.d.ts +42 -0
  173. package/lib/sync/sync-history.js +78 -0
  174. package/lib/sync/sync-selection.d.ts +29 -0
  175. package/lib/sync/sync-selection.js +127 -0
  176. package/lib/sync/sync-session.d.ts +40 -0
  177. package/lib/sync/sync-session.js +54 -0
  178. package/lib/sync/sync-state.d.ts +37 -0
  179. package/lib/sync/sync-state.js +108 -0
  180. package/lib/sync/transport.d.ts +75 -0
  181. package/lib/sync/transport.js +55 -0
  182. package/lib/sync/ui-prefs.d.ts +34 -0
  183. package/lib/sync/ui-prefs.js +109 -0
  184. package/lib/sync/webdav/webdav-transport.d.ts +77 -0
  185. package/lib/sync/webdav/webdav-transport.js +451 -0
  186. package/lib/ui/errors.d.ts +19 -0
  187. package/lib/ui/errors.js +81 -0
  188. package/lib/ui/export-flow.d.ts +82 -0
  189. package/lib/ui/export-flow.js +98 -0
  190. package/lib/ui/i18n.d.ts +289 -0
  191. package/lib/ui/i18n.js +581 -0
  192. package/lib/ui/migration-consult-view.d.ts +91 -0
  193. package/lib/ui/migration-consult-view.js +115 -0
  194. package/lib/ui/progress.d.ts +38 -0
  195. package/lib/ui/progress.js +87 -0
  196. package/lib/ui/report.d.ts +23 -0
  197. package/lib/ui/report.js +153 -0
  198. package/lib/ui/test-helpers.d.ts +69 -0
  199. package/lib/ui/test-helpers.js +154 -0
  200. package/lib/ui/types.d.ts +257 -0
  201. package/lib/ui/types.js +21 -0
  202. package/lib/utils/atomic-write.d.ts +88 -0
  203. package/lib/utils/atomic-write.js +399 -0
  204. package/lib/utils/env-lock.d.ts +393 -0
  205. package/lib/utils/env-lock.js +930 -0
  206. package/lib/utils/hashing.d.ts +17 -0
  207. package/lib/utils/hashing.js +41 -0
  208. package/lib/utils/json.d.ts +24 -0
  209. package/lib/utils/json.js +76 -0
  210. package/lib/utils/logger.d.ts +39 -0
  211. package/lib/utils/logger.js +82 -0
  212. package/lib/utils/paths.d.ts +42 -0
  213. package/lib/utils/paths.js +189 -0
  214. package/lib/utils/proxy.d.ts +69 -0
  215. package/lib/utils/proxy.js +455 -0
  216. package/lib/utils/recursive-walk.d.ts +24 -0
  217. package/lib/utils/recursive-walk.js +134 -0
  218. package/lib/utils/zip.d.ts +54 -0
  219. package/lib/utils/zip.js +285 -0
  220. package/package.json +188 -0
  221. package/src/adapters/agent-instructions.ts +48 -0
  222. package/src/adapters/agent-presets.ts +14 -0
  223. package/src/adapters/credentials.test.ts +90 -0
  224. package/src/adapters/credentials.ts +143 -0
  225. package/src/adapters/file-collection.ts +137 -0
  226. package/src/adapters/files.test.ts +353 -0
  227. package/src/adapters/index.ts +94 -0
  228. package/src/adapters/link-report.test.ts +65 -0
  229. package/src/adapters/link-report.ts +61 -0
  230. package/src/adapters/mcp.test.ts +96 -0
  231. package/src/adapters/mcp.ts +185 -0
  232. package/src/adapters/plugin-files.ts +146 -0
  233. package/src/adapters/plugins.test.ts +326 -0
  234. package/src/adapters/plugins.ts +615 -0
  235. package/src/adapters/pnpm-workspace.test.ts +87 -0
  236. package/src/adapters/pnpm-workspace.ts +150 -0
  237. package/src/adapters/prompts.test.ts +110 -0
  238. package/src/adapters/prompts.ts +230 -0
  239. package/src/adapters/providers.test.ts +126 -0
  240. package/src/adapters/providers.ts +173 -0
  241. package/src/adapters/roundtrip.test.ts +190 -0
  242. package/src/adapters/self.test.ts +92 -0
  243. package/src/adapters/self.ts +75 -0
  244. package/src/adapters/sessions.ts +15 -0
  245. package/src/adapters/settings.test.ts +100 -0
  246. package/src/adapters/settings.ts +187 -0
  247. package/src/adapters/skills.ts +14 -0
  248. package/src/adapters/test-helpers.ts +248 -0
  249. package/src/adapters/ui.test.ts +57 -0
  250. package/src/adapters/ui.ts +108 -0
  251. package/src/adapters/workspaces.test.ts +106 -0
  252. package/src/adapters/workspaces.ts +109 -0
  253. package/src/client/ConfigManagerSection.tsx +90 -0
  254. package/src/client/api.ts +66 -0
  255. package/src/client/client-types.ts +18 -0
  256. package/src/client/common/ErrorBanner.tsx +73 -0
  257. package/src/client/common/Icon.tsx +129 -0
  258. package/src/client/common/Modal.tsx +173 -0
  259. package/src/client/common/ToastViewport.tsx +73 -0
  260. package/src/client/common/toast-store.test.ts +192 -0
  261. package/src/client/common/toast-store.ts +181 -0
  262. package/src/client/common/toast-types.ts +7 -0
  263. package/src/client/common/ui.tsx +342 -0
  264. package/src/client/config-manager.module.css +2971 -0
  265. package/src/client/css-modules.d.ts +8 -0
  266. package/src/client/index.ts +65 -0
  267. package/src/client/locales.ts +56 -0
  268. package/src/client/lucide-icons.d.ts +38 -0
  269. package/src/client/run-store.test.ts +223 -0
  270. package/src/client/run-store.ts +372 -0
  271. package/src/client/sync/SyncConfirmView.tsx +379 -0
  272. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  273. package/src/client/sync/SyncHistoryView.tsx +220 -0
  274. package/src/client/sync/SyncSettingsView.tsx +1535 -0
  275. package/src/client/sync/history-model.test.ts +194 -0
  276. package/src/client/sync/history-model.ts +195 -0
  277. package/src/client/sync/sync-api.test.ts +585 -0
  278. package/src/client/sync/sync-api.ts +543 -0
  279. package/src/client/sync/sync-locales.ts +416 -0
  280. package/src/client/sync/sync-push-preview.test.ts +52 -0
  281. package/src/client/sync/sync-view-v2.test.ts +213 -0
  282. package/src/client/sync/sync-view.test.ts +487 -0
  283. package/src/client/sync/sync-view.ts +759 -0
  284. package/src/core/analyzer.ts +1068 -0
  285. package/src/core/backup.ts +605 -0
  286. package/src/core/boot-rescue.test.ts +847 -0
  287. package/src/core/boot-rescue.ts +737 -0
  288. package/src/core/cache-cleaner.test.ts +169 -0
  289. package/src/core/cache-cleaner.ts +121 -0
  290. package/src/core/config-lifecycle.test.ts +727 -0
  291. package/src/core/config-lifecycle.ts +573 -0
  292. package/src/core/config-snapshot.test.ts +369 -0
  293. package/src/core/config-snapshot.ts +522 -0
  294. package/src/core/config-state.test.ts +381 -0
  295. package/src/core/config-state.ts +207 -0
  296. package/src/core/consult-source.test.ts +199 -0
  297. package/src/core/consult-source.ts +293 -0
  298. package/src/core/crash-report.test.ts +380 -0
  299. package/src/core/crash-report.ts +369 -0
  300. package/src/core/exporter.ts +417 -0
  301. package/src/core/ghost-sweep.test.ts +94 -0
  302. package/src/core/ghost-sweep.ts +67 -0
  303. package/src/core/importer.ts +83 -0
  304. package/src/core/index.ts +56 -0
  305. package/src/core/journal.test.ts +257 -0
  306. package/src/core/journal.ts +518 -0
  307. package/src/core/local-plugin-host.ts +152 -0
  308. package/src/core/local-plugin-pack.test.ts +359 -0
  309. package/src/core/local-plugin-pack.ts +363 -0
  310. package/src/core/messages.test.ts +54 -0
  311. package/src/core/messages.ts +664 -0
  312. package/src/core/migration-consult.test.ts +315 -0
  313. package/src/core/migration-consult.ts +494 -0
  314. package/src/core/migration-history.test.ts +319 -0
  315. package/src/core/migration-history.ts +484 -0
  316. package/src/core/model-tools.test.ts +190 -0
  317. package/src/core/model-tools.ts +208 -0
  318. package/src/core/msg-types.ts +10 -0
  319. package/src/core/phase3-child-crash.ts +95 -0
  320. package/src/core/phase3-host.ts +411 -0
  321. package/src/core/phase3-p1.test.ts +212 -0
  322. package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
  323. package/src/core/phase3-prod-child.ts +36 -0
  324. package/src/core/phase3-production-integration.test.ts +286 -0
  325. package/src/core/phase4-crash-child.ts +101 -0
  326. package/src/core/phase4-crash-injection.test.ts +166 -0
  327. package/src/core/plugin-cli.fs.test.ts +184 -0
  328. package/src/core/plugin-cli.test.ts +137 -0
  329. package/src/core/plugin-cli.ts +563 -0
  330. package/src/core/reconcile.test.ts +392 -0
  331. package/src/core/reconcile.ts +498 -0
  332. package/src/core/recovery-orchestrator.test.ts +145 -0
  333. package/src/core/recovery-orchestrator.ts +366 -0
  334. package/src/core/restore-manage.test.ts +94 -0
  335. package/src/core/restore.ts +849 -0
  336. package/src/core/rollback.ts +151 -0
  337. package/src/core/run-progress.test.ts +237 -0
  338. package/src/core/run-registry.test.ts +232 -0
  339. package/src/core/run-registry.ts +209 -0
  340. package/src/core/smoke.test.ts +842 -0
  341. package/src/core/startup-barrier.ts +99 -0
  342. package/src/core/types.ts +542 -0
  343. package/src/core/undo.ts +116 -0
  344. package/src/core/validator.ts +84 -0
  345. package/src/core/verify-recovery.ts +265 -0
  346. package/src/core/watcher.test.ts +326 -0
  347. package/src/core/watcher.ts +263 -0
  348. package/src/index.facade.test.ts +265 -0
  349. package/src/index.sync.test.ts +217 -0
  350. package/src/index.ts +2975 -0
  351. package/src/migrations/index.ts +71 -0
  352. package/src/migrations/v1-to-v2.ts +17 -0
  353. package/src/profiles/index.ts +8 -0
  354. package/src/profiles/profile-manager.test.ts +282 -0
  355. package/src/profiles/profile-manager.ts +630 -0
  356. package/src/schema/config.ts +165 -0
  357. package/src/schema/index.ts +71 -0
  358. package/src/schema/manifest-schema.test.ts +519 -0
  359. package/src/schema/manifest.ts +124 -0
  360. package/src/schema/tombstones.test.ts +131 -0
  361. package/src/schema/tombstones.ts +89 -0
  362. package/src/schema/types.ts +245 -0
  363. package/src/schema/versions.ts +55 -0
  364. package/src/security/encryption.ts +335 -0
  365. package/src/security/index.ts +16 -0
  366. package/src/security/integrity.ts +102 -0
  367. package/src/security/redaction.ts +101 -0
  368. package/src/security/secret-scanner.ts +445 -0
  369. package/src/security/security.test.ts +1215 -0
  370. package/src/security/vault.test.ts +164 -0
  371. package/src/security/vault.ts +198 -0
  372. package/src/security/zip-security.ts +180 -0
  373. package/src/sync/autosync-config.test.ts +147 -0
  374. package/src/sync/autosync-config.ts +179 -0
  375. package/src/sync/autosync-scheduler.test.ts +490 -0
  376. package/src/sync/autosync-scheduler.ts +646 -0
  377. package/src/sync/fs.ts +48 -0
  378. package/src/sync/git/git-transport.test.ts +673 -0
  379. package/src/sync/git/git-transport.ts +491 -0
  380. package/src/sync/github-auth.test.ts +280 -0
  381. package/src/sync/github-auth.ts +356 -0
  382. package/src/sync/layout.test.ts +270 -0
  383. package/src/sync/layout.ts +222 -0
  384. package/src/sync/snapshot-json.test.ts +119 -0
  385. package/src/sync/snapshot-json.ts +128 -0
  386. package/src/sync/sync-config.test.ts +310 -0
  387. package/src/sync/sync-config.ts +299 -0
  388. package/src/sync/sync-engine.test.ts +1074 -0
  389. package/src/sync/sync-engine.ts +754 -0
  390. package/src/sync/sync-history.test.ts +116 -0
  391. package/src/sync/sync-history.ts +120 -0
  392. package/src/sync/sync-selection.test.ts +186 -0
  393. package/src/sync/sync-selection.ts +143 -0
  394. package/src/sync/sync-session.test.ts +143 -0
  395. package/src/sync/sync-session.ts +77 -0
  396. package/src/sync/sync-state.test.ts +164 -0
  397. package/src/sync/sync-state.ts +131 -0
  398. package/src/sync/transport.test.ts +111 -0
  399. package/src/sync/transport.ts +118 -0
  400. package/src/sync/ui-prefs.test.ts +152 -0
  401. package/src/sync/ui-prefs.ts +135 -0
  402. package/src/sync/webdav/webdav-transport.test.ts +871 -0
  403. package/src/sync/webdav/webdav-transport.ts +535 -0
  404. package/src/ui/errors.test.ts +47 -0
  405. package/src/ui/errors.ts +100 -0
  406. package/src/ui/export-flow.test.ts +87 -0
  407. package/src/ui/export-flow.ts +156 -0
  408. package/src/ui/i18n.ts +590 -0
  409. package/src/ui/migration-consult-view.test.ts +151 -0
  410. package/src/ui/migration-consult-view.ts +178 -0
  411. package/src/ui/progress.test.ts +47 -0
  412. package/src/ui/progress.ts +102 -0
  413. package/src/ui/report.test.ts +101 -0
  414. package/src/ui/report.ts +146 -0
  415. package/src/ui/test-helpers.ts +184 -0
  416. package/src/ui/types.ts +325 -0
  417. package/src/utils/atomic-write.test.ts +312 -0
  418. package/src/utils/atomic-write.ts +422 -0
  419. package/src/utils/env-lock.test.ts +999 -0
  420. package/src/utils/env-lock.ts +1086 -0
  421. package/src/utils/hashing.ts +53 -0
  422. package/src/utils/json.ts +77 -0
  423. package/src/utils/logger.ts +114 -0
  424. package/src/utils/paths.ts +176 -0
  425. package/src/utils/proxy.test.ts +465 -0
  426. package/src/utils/proxy.ts +493 -0
  427. package/src/utils/recursive-walk.test.ts +148 -0
  428. package/src/utils/recursive-walk.ts +145 -0
  429. package/src/utils/zip.ts +332 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * m-github-oauth:GitHub OAuth device flow 认证模块测试(TDD:先红后绿)。
3
+ *
4
+ * 覆盖:
5
+ * - startDeviceFlow:请求构造(URL/method/表单/scope)与响应解析;GitHub 错误 → GitHubAuthError
6
+ * - exchangeDeviceCode:单次 token 交换(成功 / 错误码 / 非 JSON / 网络失败)
7
+ * - pollForToken:pending/slow_down/denied/expired/未知错误 → 类型化轮询结果
8
+ * - DeviceFlowStore:内存登记/过期清理/删除(device_code 只存宿主侧,不回浏览器)
9
+ *
10
+ * 全部通过注入 fetcher mock,不碰真实网络。
11
+ */
12
+ import test from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+
15
+ import {
16
+ GITHUB_ACCESS_TOKEN_URL, GITHUB_DEVICE_CODE_URL, GITHUB_DEVICE_FLOW_SCOPE,
17
+ DeviceFlowStore, GitHubAuthClient, GitHubAuthError,
18
+ } from './github-auth.ts';
19
+ import type { DeviceFlowEntry } from './github-auth.ts';
20
+
21
+ /* ---------------------------------------------------------------- mock 基础设施 */
22
+
23
+ interface FetchCall {
24
+ url: string;
25
+ init?: RequestInit;
26
+ }
27
+
28
+ function installFetcher(handler: (call: FetchCall) => Response): GitHubAuthClient {
29
+ const fetcher = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
30
+ return handler({ url: String(input), init });
31
+ }) as typeof fetch;
32
+ return new GitHubAuthClient({ fetcher });
33
+ }
34
+
35
+ function jsonResponse(status: number, body: unknown): Response {
36
+ return new Response(JSON.stringify(body), {
37
+ status,
38
+ headers: { 'content-type': 'application/json' },
39
+ });
40
+ }
41
+
42
+ function bodyOf(call: FetchCall): string {
43
+ return typeof call.init?.body === 'string' ? call.init.body : '';
44
+ }
45
+
46
+ /** 标准 device/code 成功响应体 */
47
+ function deviceCodeBody(): Record<string, unknown> {
48
+ return {
49
+ device_code: 'device-abc-123',
50
+ user_code: 'ABCD-EFGH',
51
+ verification_uri: 'https://github.com/login/device',
52
+ expires_in: 900,
53
+ interval: 5,
54
+ };
55
+ }
56
+
57
+ /* ---------------------------------------------------------------- startDeviceFlow */
58
+
59
+ test('github-auth: startDeviceFlow 成功 → 请求构造正确(URL/POST/表单/scope)并解析响应', async () => {
60
+ const calls: FetchCall[] = [];
61
+ const client = installFetcher((call) => {
62
+ calls.push(call);
63
+ return jsonResponse(200, deviceCodeBody());
64
+ });
65
+
66
+ const result = await client.startDeviceFlow('client-123');
67
+ assert.equal(result.deviceCode, 'device-abc-123');
68
+ assert.equal(result.userCode, 'ABCD-EFGH');
69
+ assert.equal(result.verificationUri, 'https://github.com/login/device');
70
+ assert.equal(result.expiresIn, 900);
71
+ assert.equal(result.interval, 5);
72
+
73
+ assert.equal(calls.length, 1);
74
+ assert.equal(calls[0]?.url, GITHUB_DEVICE_CODE_URL);
75
+ assert.equal(calls[0]?.init?.method, 'POST');
76
+ const headers = new Headers(calls[0]?.init?.headers);
77
+ assert.equal(headers.get('accept'), 'application/json');
78
+ const body = bodyOf(calls[0]!);
79
+ assert.ok(body.includes('client_id=client-123'), '表单必须携带 client_id');
80
+ assert.ok(body.includes(`scope=${GITHUB_DEVICE_FLOW_SCOPE}`), `缺省 scope 必须为 ${GITHUB_DEVICE_FLOW_SCOPE}`);
81
+ });
82
+
83
+ test('github-auth: startDeviceFlow 自定义 scope 覆盖缺省', async () => {
84
+ const calls: FetchCall[] = [];
85
+ const client = installFetcher((call) => {
86
+ calls.push(call);
87
+ return jsonResponse(200, deviceCodeBody());
88
+ });
89
+
90
+ await client.startDeviceFlow('client-123', 'repo gist');
91
+ assert.ok(bodyOf(calls[0]!).includes('scope=repo%20gist') || bodyOf(calls[0]!).includes('scope=repo+gist'),
92
+ '表单必须携带自定义 scope(URL 编码空格)');
93
+ });
94
+
95
+ test('github-auth: startDeviceFlow GitHub 错误 JSON → GitHubAuthError(含 code 与描述,不含任何秘密)', async () => {
96
+ const client = installFetcher(() =>
97
+ jsonResponse(200, { error: 'incorrect_client_credentials', error_description: 'The client_id is invalid' }));
98
+ await assert.rejects(client.startDeviceFlow('bad-client'), (err: unknown) => {
99
+ assert.ok(err instanceof GitHubAuthError, '必须是 GitHubAuthError');
100
+ assert.equal(err.code, 'incorrect_client_credentials');
101
+ assert.match(err.message, /client_id/);
102
+ assert.ok(!err.message.includes('bad-client'), '错误消息不泄露 client_id');
103
+ return true;
104
+ });
105
+ });
106
+
107
+ test('github-auth: startDeviceFlow 非 2xx HTTP → GitHubAuthError', async () => {
108
+ const client = installFetcher(() => jsonResponse(500, { error: 'boom' }));
109
+ await assert.rejects(client.startDeviceFlow('client-123'), (err: unknown) => {
110
+ assert.ok(err instanceof GitHubAuthError);
111
+ assert.equal(err.status, 500);
112
+ return true;
113
+ });
114
+ });
115
+
116
+ test('github-auth: startDeviceFlow 非 JSON 响应 → GitHubAuthError', async () => {
117
+ const client = installFetcher(() => new Response('<html>oops</html>', { status: 200 }));
118
+ await assert.rejects(client.startDeviceFlow('client-123'), (err: unknown) => {
119
+ assert.ok(err instanceof GitHubAuthError);
120
+ return true;
121
+ });
122
+ });
123
+
124
+ test('github-auth: startDeviceFlow 网络失败 → GitHubAuthError(原始错误消息保留)', async () => {
125
+ const client = installFetcher(() => {
126
+ throw new Error('ECONNREFUSED github.com');
127
+ });
128
+ await assert.rejects(client.startDeviceFlow('client-123'), (err: unknown) => {
129
+ assert.ok(err instanceof GitHubAuthError);
130
+ assert.match(err.message, /ECONNREFUSED/);
131
+ return true;
132
+ });
133
+ });
134
+
135
+ /* ---------------------------------------------------------------- exchangeDeviceCode */
136
+
137
+ test('github-auth: exchangeDeviceCode 成功 → 返回 access token,请求构造正确(含 grant_type)', async () => {
138
+ const calls: FetchCall[] = [];
139
+ const client = installFetcher((call) => {
140
+ calls.push(call);
141
+ return jsonResponse(200, { access_token: 'gho_secret_123', token_type: 'bearer', scope: 'repo' });
142
+ });
143
+
144
+ const result = await client.exchangeDeviceCode({
145
+ clientId: 'client-123', deviceCode: 'device-abc-123',
146
+ });
147
+ assert.equal(result.ok, true);
148
+ if (result.ok) assert.equal(result.accessToken, 'gho_secret_123');
149
+
150
+ assert.equal(calls[0]?.url, GITHUB_ACCESS_TOKEN_URL);
151
+ assert.equal(calls[0]?.init?.method, 'POST');
152
+ const body = bodyOf(calls[0]!);
153
+ assert.ok(body.includes('client_id=client-123'));
154
+ assert.ok(body.includes('device_code=device-abc-123'));
155
+ assert.ok(body.includes('grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code'));
156
+ assert.ok(!body.includes('client_secret'), '未提供 client_secret 时表单不得携带该字段');
157
+ });
158
+
159
+ test('github-auth: exchangeDeviceCode 携带 client_secret(confidential app)', async () => {
160
+ const calls: FetchCall[] = [];
161
+ const client = installFetcher((call) => {
162
+ calls.push(call);
163
+ return jsonResponse(200, { access_token: 'gho_secret_123' });
164
+ });
165
+
166
+ const result = await client.exchangeDeviceCode({
167
+ clientId: 'client-123', deviceCode: 'device-abc-123', clientSecret: 'super-secret',
168
+ });
169
+ assert.equal(result.ok, true);
170
+ assert.ok(bodyOf(calls[0]!).includes('client_secret=super-secret'), '提供 client_secret 时必须随表单发送');
171
+ });
172
+
173
+ test('github-auth: exchangeDeviceCode GitHub 错误码(authorization_pending)→ ok:false 原样返回', async () => {
174
+ const client = installFetcher(() =>
175
+ jsonResponse(200, { error: 'authorization_pending', error_description: 'user has not yet approved' }));
176
+ const result = await client.exchangeDeviceCode({ clientId: 'c', deviceCode: 'd' });
177
+ assert.equal(result.ok, false);
178
+ if (!result.ok) {
179
+ assert.equal(result.error, 'authorization_pending');
180
+ assert.match(result.errorDescription ?? '', /approved/);
181
+ }
182
+ });
183
+
184
+ test('github-auth: exchangeDeviceCode 非 JSON → GitHubAuthError(token 不进错误消息)', async () => {
185
+ const client = installFetcher(() => new Response('gateway timeout', { status: 502 }));
186
+ await assert.rejects(client.exchangeDeviceCode({ clientId: 'c', deviceCode: 'd' }), (err: unknown) => {
187
+ assert.ok(err instanceof GitHubAuthError);
188
+ assert.ok(!err.message.includes('gho_'), '错误消息不得含 token 形态内容');
189
+ return true;
190
+ });
191
+ });
192
+
193
+ /* ---------------------------------------------------------------- pollForToken */
194
+
195
+ test('github-auth: pollForToken 成功 → status success + access token', async () => {
196
+ const client = installFetcher(() =>
197
+ jsonResponse(200, { access_token: 'gho_ok_token', token_type: 'bearer', scope: 'repo' }));
198
+ const result = await client.pollForToken({ clientId: 'c', deviceCode: 'd' });
199
+ assert.equal(result.status, 'success');
200
+ assert.equal(result.accessToken, 'gho_ok_token');
201
+ });
202
+
203
+ test('github-auth: pollForToken authorization_pending → pending + 按 interval 计算下次轮询延迟', async () => {
204
+ const client = installFetcher(() =>
205
+ jsonResponse(200, { error: 'authorization_pending', error_description: 'not yet' }));
206
+ const result = await client.pollForToken({ clientId: 'c', deviceCode: 'd', interval: 5 });
207
+ assert.equal(result.status, 'pending');
208
+ assert.equal(result.pollDelayMs, 5000);
209
+ });
210
+
211
+ test('github-auth: pollForToken slow_down → pending + 延迟在 interval 基础上加 5 秒(RFC 8628)', async () => {
212
+ const client = installFetcher(() =>
213
+ jsonResponse(200, { error: 'slow_down', error_description: 'too frequent' }));
214
+ const result = await client.pollForToken({ clientId: 'c', deviceCode: 'd', interval: 5 });
215
+ assert.equal(result.status, 'pending');
216
+ assert.equal(result.pollDelayMs, 10_000);
217
+ });
218
+
219
+ test('github-auth: pollForToken expired_token → status expired', async () => {
220
+ const client = installFetcher(() =>
221
+ jsonResponse(200, { error: 'expired_token', error_description: 'device code expired' }));
222
+ const result = await client.pollForToken({ clientId: 'c', deviceCode: 'd' });
223
+ assert.equal(result.status, 'expired');
224
+ });
225
+
226
+ test('github-auth: pollForToken access_denied → status denied', async () => {
227
+ const client = installFetcher(() =>
228
+ jsonResponse(200, { error: 'access_denied', error_description: 'user denied' }));
229
+ const result = await client.pollForToken({ clientId: 'c', deviceCode: 'd' });
230
+ assert.equal(result.status, 'denied');
231
+ });
232
+
233
+ test('github-auth: pollForToken 未知错误 → status error + errorCode + 可展示描述', async () => {
234
+ const client = installFetcher(() =>
235
+ jsonResponse(200, { error: 'incorrect_device_code', error_description: 'device code mismatch' }));
236
+ const result = await client.pollForToken({ clientId: 'c', deviceCode: 'd' });
237
+ assert.equal(result.status, 'error');
238
+ assert.equal(result.errorCode, 'incorrect_device_code');
239
+ assert.match(result.message ?? '', /mismatch/);
240
+ assert.equal(result.accessToken, undefined, '失败结果绝不携带 access token');
241
+ });
242
+
243
+ /* ---------------------------------------------------------------- DeviceFlowStore */
244
+
245
+ function entry(overrides: Partial<DeviceFlowEntry> = {}): DeviceFlowEntry {
246
+ return {
247
+ deviceCode: 'device-abc-123',
248
+ clientId: 'client-123',
249
+ interval: 5,
250
+ expiresAt: 1_000_000,
251
+ ...overrides,
252
+ };
253
+ }
254
+
255
+ test('github-auth: DeviceFlowStore set/get 往返;缺失 flowId → undefined', () => {
256
+ const store = new DeviceFlowStore(() => 0);
257
+ store.set('flow-1', entry());
258
+ const got = store.get('flow-1');
259
+ assert.equal(got?.deviceCode, 'device-abc-123');
260
+ assert.equal(store.get('missing'), undefined);
261
+ });
262
+
263
+ test('github-auth: DeviceFlowStore 过期条目 → get 返回 undefined 并清理(不残留)', () => {
264
+ let now = 0;
265
+ const store = new DeviceFlowStore(() => now);
266
+ store.set('flow-1', entry({ expiresAt: 100 }));
267
+ now = 200; // 超过 expiresAt
268
+ assert.equal(store.get('flow-1'), undefined);
269
+ assert.equal(store.size, 0, '过期条目必须被清除');
270
+ });
271
+
272
+ test('github-auth: DeviceFlowStore delete 移除条目;未过期条目可正常取出', () => {
273
+ const store = new DeviceFlowStore(() => 0);
274
+ store.set('flow-1', entry({ expiresAt: 1000 }));
275
+ store.delete('flow-1');
276
+ assert.equal(store.get('flow-1'), undefined);
277
+ assert.equal(store.size, 0);
278
+ store.set('flow-2', entry({ expiresAt: 1000 }));
279
+ assert.equal(store.get('flow-2')?.clientId, 'client-123');
280
+ });
@@ -0,0 +1,356 @@
1
+ /**
2
+ * m-github-oauth:GitHub OAuth device flow(设备码流程)认证模块。
3
+ *
4
+ * 插件场景选择 device flow 而非授权码+回调:无需注册回调 URL,天然适合本地 GUI
5
+ * 插件 —— 用户点「使用 GitHub 登录」→ 宿主向 GitHub 请求设备码 → UI 展示一次性
6
+ * 用户码 + 授权页 URL → 用户在浏览器确认 → 宿主轮询换取 access token → 存入
7
+ * DSH credentials(SYNC_CREDENTIAL_REF,与手动 token 同一凭据槽位)→ git 通道
8
+ * 的 getToken provider 从那里读取,token 永不进 argv / repoUrl / 日志 / 浏览器。
9
+ *
10
+ * 安全不变量:
11
+ * - 本模块不接触任何秘密的持久化:startDeviceFlow 产出设备码(宿主侧内存登记),
12
+ * pollForToken 只返回 access token(由宿主立即写入 credentials,绝不回传 UI);
13
+ * - 错误消息只携带 GitHub 返回的 error/error_description,绝不内嵌 token 值;
14
+ * - fetcher / now 可注入:测试全程 mock,不碰真实网络。
15
+ *
16
+ * 流程(GitHub 官方 device flow):
17
+ * 1. POST https://github.com/login/device/code { client_id, scope }
18
+ * → { device_code, user_code, verification_uri, expires_in, interval }
19
+ * 2. 用户访问 verification_uri 输入 user_code 授权
20
+ * 3. POST https://github.com/login/oauth/access_token
21
+ * { client_id, device_code, grant_type=urn:ietf:params:oauth:grant-type:device_code
22
+ * [, client_secret] } → { access_token } 或 { error, error_description }
23
+ * error ∈ authorization_pending | slow_down | expired_token | access_denied | ...
24
+ */
25
+ import { randomUUID } from 'node:crypto';
26
+ import { defaultFetcher } from '../utils/proxy.ts';
27
+
28
+ /** GitHub device flow 第一步:请求设备码 */
29
+ export const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code';
30
+ /** GitHub device flow 第三步:轮询换取 access token */
31
+ export const GITHUB_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
32
+ /** 缺省 scope:repo(读写私有仓库,同步通道需要) */
33
+ export const GITHUB_DEVICE_FLOW_SCOPE = 'repo';
34
+ /** GitHub 缺省轮询间隔(秒) */
35
+ export const GITHUB_DEFAULT_INTERVAL = 5;
36
+ /** slow_down 时按 RFC 8628 额外增加的等待秒数 */
37
+ export const SLOW_DOWN_EXTRA_SECONDS = 5;
38
+ /** 设备码过期秒数上限兜底(GitHub 默认 900s) */
39
+ const DEVICE_CODE_MAX_EXPIRES_IN = 3600;
40
+
41
+ export interface GitHubAuthOptions {
42
+ /** 可注入 fetcher(测试 mock 用);缺省 = global fetch */
43
+ fetcher?: typeof fetch;
44
+ /** 可注入时钟(epoch ms;测试过期清理用);缺省 = Date.now */
45
+ now?: () => number;
46
+ }
47
+
48
+ /** startDeviceFlow 产出:UI 展示用户码 + 授权页,宿主侧登记设备码 */
49
+ export interface DeviceFlowStartResult {
50
+ deviceCode: string;
51
+ userCode: string;
52
+ verificationUri: string;
53
+ /** 设备码过期秒数(GitHub 默认 900) */
54
+ expiresIn: number;
55
+ /** GitHub 建议的轮询间隔秒数(缺省 5) */
56
+ interval: number;
57
+ }
58
+
59
+ /** 轮询结果状态:success 携带 token(宿主立即入库);pending 继续等;其余为终止态 */
60
+ export type DeviceFlowPollStatus = 'pending' | 'success' | 'denied' | 'expired' | 'error';
61
+
62
+ export interface DeviceFlowPollResult {
63
+ status: DeviceFlowPollStatus;
64
+ /** status=success 时:access token(调用方负责立即写入 credentials,绝不落日志/回传 UI) */
65
+ accessToken?: string;
66
+ /** status=pending 时:下次轮询应等待的毫秒数(按 GitHub interval;slow_down 额外 +5s) */
67
+ pollDelayMs?: number;
68
+ /** status=error 时:GitHub 返回的 error code */
69
+ errorCode?: string;
70
+ /** 可展示的错误描述(来自 GitHub error_description,不含 token) */
71
+ message?: string;
72
+ }
73
+
74
+ /** GitHub OAuth 可预期错误:code = GitHub error / 固定内部码,status = HTTP 状态(网络层无) */
75
+ export class GitHubAuthError extends Error {
76
+ readonly code: string;
77
+ readonly status: number | undefined;
78
+
79
+ constructor(message: string, code: string, status?: number) {
80
+ super(message);
81
+ this.name = 'GitHubAuthError';
82
+ this.code = code;
83
+ this.status = status;
84
+ }
85
+ }
86
+
87
+ /** exchangeDeviceCode 入参(client_secret 仅 confidential OAuth app 需要,缺省不发送) */
88
+ export interface ExchangeDeviceCodeParams {
89
+ clientId: string;
90
+ deviceCode: string;
91
+ clientSecret?: string;
92
+ /** 轮询间隔秒数(pollForToken 计算下次延迟用;缺省 5) */
93
+ interval?: number;
94
+ }
95
+
96
+ /** 单次 token 交换结果:成功 = access token;失败 = GitHub error code + 描述 */
97
+ export type ExchangeDeviceCodeResult =
98
+ | { ok: true; accessToken: string }
99
+ | { ok: false; error: string; errorDescription?: string };
100
+
101
+ /** 解析 GitHub 表单/JSON 混合响应:优先 JSON(Accept: application/json 时 GitHub 返回 JSON) */
102
+ async function parseGitHubJson(response: Response, context: string): Promise<Record<string, unknown>> {
103
+ const text = await response.text();
104
+ if (text.trim() === '') {
105
+ throw new GitHubAuthError(`${context}: GitHub 返回空响应(HTTP ${response.status})`, 'empty_response', response.status);
106
+ }
107
+ try {
108
+ const parsed: unknown = JSON.parse(text);
109
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
110
+ throw new Error('not a JSON object');
111
+ }
112
+ return parsed as Record<string, unknown>;
113
+ } catch {
114
+ throw new GitHubAuthError(
115
+ `${context}: GitHub 返回了无法解析的响应(HTTP ${response.status}),请稍后重试`,
116
+ 'invalid_json',
117
+ response.status,
118
+ );
119
+ }
120
+ }
121
+
122
+ /**
123
+ * GitHub OAuth device flow 客户端。
124
+ * 无状态(设备码登记由宿主侧的 DeviceFlowStore 负责),fetch 可注入。
125
+ */
126
+ export class GitHubAuthClient {
127
+ private readonly fetcher: typeof fetch;
128
+ private readonly now: () => number;
129
+
130
+ constructor(options: GitHubAuthOptions = {}) {
131
+ this.fetcher = options.fetcher ?? defaultFetcher();
132
+ this.now = options.now ?? (() => Date.now());
133
+ }
134
+
135
+ /**
136
+ * 用给定 token 验证身份并取回当前用户(GET /user)。
137
+ *
138
+ * 供 /sync/github/validate 判定「已存 token 是否仍有效」:401 → `unauthorized`,
139
+ * 其余错误按 `http_error` 抛出(调用方据此区分「未登录」与「真实故障」,不误判登出)。
140
+ * token 只作为本次请求的 Authorization 头,绝不回传、不落盘、不进日志。
141
+ */
142
+ async getUser(token: string): Promise<{ login: string; id: number }> {
143
+ if (typeof token !== 'string' || token === '') {
144
+ throw new GitHubAuthError('token 必须是非空字符串', 'no_token');
145
+ }
146
+ let response: Response;
147
+ try {
148
+ response = await this.fetcher('https://api.github.com/user', {
149
+ method: 'GET',
150
+ headers: {
151
+ accept: 'application/vnd.github+json',
152
+ authorization: `Bearer ${token}`,
153
+ 'user-agent': 'dsh-config-manager',
154
+ },
155
+ });
156
+ } catch (err) {
157
+ throw new GitHubAuthError(
158
+ `校验 GitHub token 失败:${err instanceof Error ? err.message : String(err)}`,
159
+ 'network_error',
160
+ );
161
+ }
162
+ if (response.status === 401) {
163
+ throw new GitHubAuthError('GitHub token 无效或已过期', 'unauthorized', 401);
164
+ }
165
+ if (!response.ok) {
166
+ throw new GitHubAuthError(`GitHub 返回 HTTP ${response.status}`, 'http_error', response.status);
167
+ }
168
+ const parsed = await parseGitHubJson(response, '校验 token');
169
+ const login = parsed['login'];
170
+ const id = parsed['id'];
171
+ if (typeof login !== 'string' || login === '' || typeof id !== 'number') {
172
+ throw new GitHubAuthError('GitHub /user 响应缺少 login/id', 'invalid_response', response.status);
173
+ }
174
+ return { login, id };
175
+ }
176
+
177
+ /**
178
+ * 第一步:请求设备码。产出一次性 user_code + 授权页 URL + device_code
179
+ * (device_code 由宿主登记,只在本模块与宿主间流转,绝不发给浏览器)。
180
+ */
181
+ async startDeviceFlow(clientId: string, scope: string = GITHUB_DEVICE_FLOW_SCOPE): Promise<DeviceFlowStartResult> {
182
+ if (typeof clientId !== 'string' || clientId === '') {
183
+ throw new GitHubAuthError('client_id 必须是非空字符串', 'invalid_client_id');
184
+ }
185
+ const body = new URLSearchParams();
186
+ body.set('client_id', clientId);
187
+ body.set('scope', scope);
188
+
189
+ let response: Response;
190
+ try {
191
+ response = await this.fetcher(GITHUB_DEVICE_CODE_URL, {
192
+ method: 'POST',
193
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
194
+ body: body.toString(),
195
+ });
196
+ } catch (err) {
197
+ throw new GitHubAuthError(
198
+ `请求 GitHub 设备码失败:${err instanceof Error ? err.message : String(err)}`,
199
+ 'network_error',
200
+ );
201
+ }
202
+
203
+ const parsed = await parseGitHubJson(response, '请求设备码');
204
+ const ghError = parsed['error'];
205
+ if (typeof ghError === 'string' && ghError !== '') {
206
+ const desc = parsed['error_description'];
207
+ throw new GitHubAuthError(
208
+ `GitHub 拒绝了设备码请求:${typeof desc === 'string' && desc !== '' ? desc : ghError}`,
209
+ ghError,
210
+ response.status,
211
+ );
212
+ }
213
+ const deviceCode = parsed['device_code'];
214
+ const userCode = parsed['user_code'];
215
+ const verificationUri = parsed['verification_uri'];
216
+ if (typeof deviceCode !== 'string' || deviceCode === ''
217
+ || typeof userCode !== 'string' || userCode === ''
218
+ || typeof verificationUri !== 'string' || verificationUri === '') {
219
+ throw new GitHubAuthError('GitHub 设备码响应缺少必要字段(device_code/user_code/verification_uri)', 'invalid_response', response.status);
220
+ }
221
+ const expiresIn = typeof parsed['expires_in'] === 'number' && Number.isFinite(parsed['expires_in'])
222
+ ? Math.min(Math.max(Math.floor(parsed['expires_in']), 1), DEVICE_CODE_MAX_EXPIRES_IN)
223
+ : 900;
224
+ const interval = typeof parsed['interval'] === 'number' && Number.isFinite(parsed['interval'])
225
+ ? Math.max(Math.floor(parsed['interval']), 1)
226
+ : GITHUB_DEFAULT_INTERVAL;
227
+ return { deviceCode, userCode, verificationUri, expiresIn, interval };
228
+ }
229
+
230
+ /**
231
+ * 单次 token 交换(device flow 第三步的原子操作)。
232
+ * 返回类型化结果:成功 = access token;失败 = GitHub error code(pending/slow_down
233
+ * 等由 pollForToken 归并为轮询状态)。
234
+ */
235
+ async exchangeDeviceCode(params: ExchangeDeviceCodeParams): Promise<ExchangeDeviceCodeResult> {
236
+ const body = new URLSearchParams();
237
+ body.set('client_id', params.clientId);
238
+ body.set('device_code', params.deviceCode);
239
+ body.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
240
+ if (params.clientSecret !== undefined && params.clientSecret !== '') {
241
+ body.set('client_secret', params.clientSecret);
242
+ }
243
+
244
+ let response: Response;
245
+ try {
246
+ response = await this.fetcher(GITHUB_ACCESS_TOKEN_URL, {
247
+ method: 'POST',
248
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
249
+ body: body.toString(),
250
+ });
251
+ } catch (err) {
252
+ throw new GitHubAuthError(
253
+ `交换 GitHub token 失败:${err instanceof Error ? err.message : String(err)}`,
254
+ 'network_error',
255
+ );
256
+ }
257
+
258
+ const parsed = await parseGitHubJson(response, '交换 token');
259
+ const accessToken = parsed['access_token'];
260
+ if (typeof accessToken === 'string' && accessToken !== '') {
261
+ return { ok: true, accessToken };
262
+ }
263
+ const ghError = parsed['error'];
264
+ const desc = parsed['error_description'];
265
+ return {
266
+ ok: false,
267
+ error: typeof ghError === 'string' && ghError !== '' ? ghError : 'unknown_error',
268
+ errorDescription: typeof desc === 'string' && desc !== '' ? desc : undefined,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * 轮询换取 access token,把 GitHub 错误码归并为 UI 可消费的轮询状态:
274
+ * - authorization_pending → pending(按 interval 继续等)
275
+ * - slow_down → pending(interval + 5s,RFC 8628)
276
+ * - expired_token → expired(终止,重新开始)
277
+ * - access_denied → denied(终止)
278
+ * - 其他 error → error(终止,携带 errorCode + 描述)
279
+ */
280
+ async pollForToken(params: ExchangeDeviceCodeParams): Promise<DeviceFlowPollResult> {
281
+ const result = await this.exchangeDeviceCode(params);
282
+ if (result.ok) {
283
+ return { status: 'success', accessToken: result.accessToken };
284
+ }
285
+ const intervalMs = Math.max(params.interval ?? GITHUB_DEFAULT_INTERVAL, 1) * 1000;
286
+ switch (result.error) {
287
+ case 'authorization_pending':
288
+ return { status: 'pending', pollDelayMs: intervalMs };
289
+ case 'slow_down':
290
+ return { status: 'pending', pollDelayMs: intervalMs + SLOW_DOWN_EXTRA_SECONDS * 1000 };
291
+ case 'expired_token':
292
+ return { status: 'expired' };
293
+ case 'access_denied':
294
+ return { status: 'denied' };
295
+ default:
296
+ return { status: 'error', errorCode: result.error, message: result.errorDescription };
297
+ }
298
+ }
299
+ }
300
+
301
+ /* ---------------------------------------------------------------- 宿主侧设备码登记 */
302
+
303
+ /** DeviceFlowStore 登记的条目(device_code 只存宿主内存,绝不发往浏览器) */
304
+ export interface DeviceFlowEntry {
305
+ deviceCode: string;
306
+ clientId: string;
307
+ clientSecret?: string;
308
+ /** 轮询间隔秒数(来自 start 响应,pollForToken 计算下次延迟用) */
309
+ interval: number;
310
+ /** 过期时刻(epoch ms):now > expiresAt 视为已过期,get 返回 undefined 并清理 */
311
+ expiresAt: number;
312
+ }
313
+
314
+ /**
315
+ * 宿主内存设备码登记表(进程生命周期,跨请求共享 —— 与 RunRegistry 同模式)。
316
+ * device_code 的持有者是宿主:UI 只拿 flowId(随机 id),轮询时宿主凭 flowId 取回
317
+ * device_code 去 GitHub 换 token。过期条目惰性清理,防止无限增长。
318
+ */
319
+ export class DeviceFlowStore {
320
+ private readonly flows = new Map<string, DeviceFlowEntry>();
321
+ private readonly now: () => number;
322
+
323
+ constructor(now?: () => number) {
324
+ this.now = now ?? (() => Date.now());
325
+ }
326
+
327
+ get size(): number {
328
+ return this.flows.size;
329
+ }
330
+
331
+ /** 登记一个设备码流;flowId 由调用方生成(随机,不可猜) */
332
+ set(flowId: string, entry: DeviceFlowEntry): void {
333
+ this.flows.set(flowId, entry);
334
+ }
335
+
336
+ /** 取回设备码流;过期条目视为不存在并立即清理 */
337
+ get(flowId: string): DeviceFlowEntry | undefined {
338
+ const entry = this.flows.get(flowId);
339
+ if (entry === undefined) return undefined;
340
+ if (this.now() > entry.expiresAt) {
341
+ this.flows.delete(flowId);
342
+ return undefined;
343
+ }
344
+ return entry;
345
+ }
346
+
347
+ /** 主动移除(取消/完成后清理) */
348
+ delete(flowId: string): void {
349
+ this.flows.delete(flowId);
350
+ }
351
+
352
+ /** 生成不可猜的 flowId(路由层用) */
353
+ static newFlowId(): string {
354
+ return randomUUID();
355
+ }
356
+ }