elanous 0.2.7 → 0.2.9

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 (296) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/bFI5EEwg7T6aG88ibX4Zq/_buildManifest.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/7032-f4ef698518fba61c.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/8067-d976b441a104ab74.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/8519-5632377cce994f86.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/8630-cdfc61e82c653ffc.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/8894-e263b332208e3957.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/9454-139040d37c79dbc9.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/9740-8f5ff96c522b5f55.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/approvals/page-fb7c2d458f32fcad.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-35e2713597dd24d1.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/app/board/page-918b656f064577ba.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-624f7a2f89b60c8f.js → page-f972fc1c00a62e88.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/bots/{page-5a5fb385a4df0ffe.js → page-5f30e00693527ebd.js} +1 -1
  16. package/apps/pwa/out/_next/static/chunks/app/chat/{page-80497e93a878ddc5.js → page-7fbae173b3c103a7.js} +1 -1
  17. package/apps/pwa/out/_next/static/chunks/app/consult/{page-7457f52683562161.js → page-3b4edcce364062ea.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/control/page-c5794f484c251f44.js +1 -0
  19. package/apps/pwa/out/_next/static/chunks/app/design-check/page-1130d8eefcd87e76.js +1 -0
  20. package/apps/pwa/out/_next/static/chunks/app/editor/page-973996d584f008c1.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/exec/{page-b7dcf5d48a854b76.js → page-016e2bda04eecb1f.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/field/{page-51480757e7d68b92.js → page-b4d6d9a3b56f2940.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/intake/page-ec49a466121233ba.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/app/layout-f72db356744ceade.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/live/page-a6fd9b50d1b85378.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/app/market/page-8b0402a46a749a3d.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-07267f1391b96aba.js → page-037f83e2a1edc8e2.js} +1 -1
  28. package/apps/pwa/out/_next/static/chunks/app/morning/{page-55d50e9b0006a91d.js → page-adfbd2ba9598e59a.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/not-found-46fedff57d4bec67.js +1 -0
  30. package/apps/pwa/out/_next/static/chunks/app/observatory/page-0cf318e9dcee2bbf.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-899b327cb8b6845c.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-6c1e6ba743a29dfe.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/{page-a7e37af7d0399cb8.js → page-1a8e4807292f6b17.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/reflection/page-28deba6bcb834916.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-de68e2205794037d.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-15e5a15db84c56aa.js → page-c71c33a573230672.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-a2e572e4511f2a16.js → page-0df30e0c7e50148a.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/settings/page-4fc56790fe0d2682.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-59df2aebb38a7c1e.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/app/setup/page-4647be5dbcbd0795.js +1 -0
  41. package/apps/pwa/out/_next/static/chunks/app/share/page-e025892bda74b743.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/showroom/page-a0955de73b89f078.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/app/tasks/{page-c1c95739c0bff3b8.js → page-369846d2921af584.js} +1 -1
  44. package/apps/pwa/out/_next/static/chunks/app/term/page-72cbc1c8cdd0f65d.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/trace/{page-71d18eec33b9c667.js → page-77b28bfb0996d5ea.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/vault/page-3220c4934f49c6c2.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-2c4e59079ee357ac.js → page-90670589b87abea8.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/workflows/page-7df9243ed021a4cc.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-5d422f38c052967e.js → page-4498c3853f4f688d.js} +1 -1
  50. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-35bedde6d885f08f.js +1 -0
  51. package/apps/pwa/out/_next/static/css/f92f63a5df0ba229.css +1 -0
  52. package/apps/pwa/out/approvals/index.html +1 -1
  53. package/apps/pwa/out/approvals/index.txt +30 -29
  54. package/apps/pwa/out/autopilot/index.html +1 -1
  55. package/apps/pwa/out/autopilot/index.txt +30 -29
  56. package/apps/pwa/out/board/index.html +1 -1
  57. package/apps/pwa/out/board/index.txt +30 -29
  58. package/apps/pwa/out/botlab/index.html +1 -1
  59. package/apps/pwa/out/botlab/index.txt +27 -26
  60. package/apps/pwa/out/bots/index.html +1 -1
  61. package/apps/pwa/out/bots/index.txt +27 -26
  62. package/apps/pwa/out/chat/index.html +1 -1
  63. package/apps/pwa/out/chat/index.txt +27 -26
  64. package/apps/pwa/out/consult/index.html +1 -1
  65. package/apps/pwa/out/consult/index.txt +30 -29
  66. package/apps/pwa/out/control/index.html +1 -1
  67. package/apps/pwa/out/control/index.txt +30 -29
  68. package/apps/pwa/out/design-check/index.html +1 -1
  69. package/apps/pwa/out/design-check/index.txt +30 -29
  70. package/apps/pwa/out/editor/index.html +1 -1
  71. package/apps/pwa/out/editor/index.txt +30 -29
  72. package/apps/pwa/out/exec/index.html +1 -1
  73. package/apps/pwa/out/exec/index.txt +30 -29
  74. package/apps/pwa/out/field/index.html +1 -1
  75. package/apps/pwa/out/field/index.txt +30 -29
  76. package/apps/pwa/out/healthz +1 -1
  77. package/apps/pwa/out/index.html +1 -1
  78. package/apps/pwa/out/index.txt +27 -26
  79. package/apps/pwa/out/intake/index.html +1 -1
  80. package/apps/pwa/out/intake/index.txt +30 -29
  81. package/apps/pwa/out/live/index.html +1 -1
  82. package/apps/pwa/out/live/index.txt +30 -29
  83. package/apps/pwa/out/market/index.html +1 -1
  84. package/apps/pwa/out/market/index.txt +27 -26
  85. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  86. package/apps/pwa/out/missions/placeholder/index.txt +27 -26
  87. package/apps/pwa/out/morning/index.html +1 -1
  88. package/apps/pwa/out/morning/index.txt +30 -29
  89. package/apps/pwa/out/observatory/index.html +1 -1
  90. package/apps/pwa/out/observatory/index.txt +30 -29
  91. package/apps/pwa/out/ops/checklist/index.html +1 -0
  92. package/apps/pwa/out/ops/checklist/index.txt +30 -0
  93. package/apps/pwa/out/ops/release/index.html +1 -0
  94. package/apps/pwa/out/ops/release/index.txt +30 -0
  95. package/apps/pwa/out/reflection/index.html +1 -1
  96. package/apps/pwa/out/reflection/index.txt +28 -27
  97. package/apps/pwa/out/scheduler/index.html +1 -1
  98. package/apps/pwa/out/scheduler/index.txt +30 -29
  99. package/apps/pwa/out/sessions/index.html +1 -1
  100. package/apps/pwa/out/sessions/index.txt +27 -26
  101. package/apps/pwa/out/settings/devices/index.html +1 -1
  102. package/apps/pwa/out/settings/devices/index.txt +30 -29
  103. package/apps/pwa/out/settings/index.html +1 -1
  104. package/apps/pwa/out/settings/index.txt +30 -29
  105. package/apps/pwa/out/setup/done/index.html +1 -1
  106. package/apps/pwa/out/setup/done/index.txt +30 -29
  107. package/apps/pwa/out/setup/index.html +1 -1
  108. package/apps/pwa/out/setup/index.txt +30 -29
  109. package/apps/pwa/out/share/index.html +1 -1
  110. package/apps/pwa/out/share/index.txt +30 -29
  111. package/apps/pwa/out/showroom/index.html +1 -1
  112. package/apps/pwa/out/showroom/index.txt +27 -26
  113. package/apps/pwa/out/tasks/index.html +1 -1
  114. package/apps/pwa/out/tasks/index.txt +30 -29
  115. package/apps/pwa/out/term/index.html +1 -1
  116. package/apps/pwa/out/term/index.txt +30 -29
  117. package/apps/pwa/out/trace/index.html +1 -1
  118. package/apps/pwa/out/trace/index.txt +30 -29
  119. package/apps/pwa/out/vault/index.html +1 -1
  120. package/apps/pwa/out/vault/index.txt +30 -29
  121. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  122. package/apps/pwa/out/workflows/chat-ui/index.txt +30 -29
  123. package/apps/pwa/out/workflows/index.html +1 -1
  124. package/apps/pwa/out/workflows/index.txt +30 -29
  125. package/apps/pwa/out/workspace/index.html +1 -1
  126. package/apps/pwa/out/workspace/index.txt +30 -29
  127. package/apps/pwa/out/worktrees/index.html +1 -1
  128. package/apps/pwa/out/worktrees/index.txt +30 -29
  129. package/graphs/release/recipes.yaml +3 -0
  130. package/graphs/release/release-loop.yaml +3 -1
  131. package/graphs/test-diet/nightly-audit.yaml +18 -0
  132. package/graphs/test-diet/recipes.yaml +12 -0
  133. package/graphs/test-diet/test-diet.yaml +22 -0
  134. package/package.json +2 -1
  135. package/persona-presets/_index.yaml +10 -0
  136. package/persona-presets/_schema.json +146 -0
  137. package/persona-presets/dana.yaml +26 -0
  138. package/persona-presets/haru.yaml +27 -0
  139. package/persona-presets/kai.yaml +27 -0
  140. package/persona-presets/leo.yaml +27 -0
  141. package/persona-presets/mira.yaml +30 -0
  142. package/persona-presets/nari.yaml +27 -0
  143. package/persona-presets/sora.yaml +27 -0
  144. package/persona-presets/yuna.yaml +27 -0
  145. package/plugins/doc-draft/graphs/run-step.ts +1 -1
  146. package/plugins/geo-check/graphs/run-step.ts +34 -10
  147. package/scripts/field-feed/deliver-node.ts +8 -3
  148. package/scripts/field-feed/lib.ts +2 -0
  149. package/scripts/install.sh +20 -11
  150. package/scripts/persona-presets/lint.ts +79 -0
  151. package/scripts/release-loop/checklist-gate-node.ts +2 -2
  152. package/scripts/release-loop/cutoff.ts +20 -2
  153. package/scripts/release-loop/export-check-node.ts +85 -0
  154. package/scripts/release-loop/gate-node.ts +45 -7
  155. package/scripts/release-loop/node-verdict.ts +8 -1
  156. package/scripts/release-loop/npm-publish-node.ts +10 -4
  157. package/scripts/release-loop/ops-upgrade-node.ts +11 -3
  158. package/scripts/release-loop/publish-node.ts +23 -1
  159. package/scripts/release-loop/shard-plan.ts +17 -0
  160. package/scripts/storyboard/storyboard.ts +167 -0
  161. package/scripts/test-diet/lib.ts +89 -0
  162. package/scripts/test-diet/node.ts +144 -0
  163. package/src/acp/auth-methods.ts +30 -0
  164. package/src/acp/server.ts +5 -5
  165. package/src/auth/setup-link-tokens.fixture.ts +19 -0
  166. package/src/auth/setup-link-tokens.ts +153 -0
  167. package/src/budget/unified-usage.ts +75 -0
  168. package/src/channel-bot-token.ts +37 -10
  169. package/src/channel-import/detect.ts +191 -0
  170. package/src/channel-import/run.ts +111 -0
  171. package/src/chat/index.ts +9 -9
  172. package/src/cli/config-test-sync.ts +4 -0
  173. package/src/cli/decisions-cli.ts +13 -3
  174. package/src/cli/doctor-bundle.ts +128 -0
  175. package/src/cli/doctor-cli.ts +36 -1
  176. package/src/cli/doctor-local-llm.ts +95 -0
  177. package/src/cli/import-cli.ts +60 -0
  178. package/src/cli/mcp-login.ts +13 -4
  179. package/src/cli/op-proxy-sync.ts +83 -0
  180. package/src/cli/persona-cli.ts +116 -0
  181. package/src/cli/pr-cli.ts +6 -2
  182. package/src/cli/release-cli.ts +49 -8
  183. package/src/cli/skills-cli.ts +173 -0
  184. package/src/cli/start-cli.ts +13 -4
  185. package/src/codex/setup.ts +15 -13
  186. package/src/dashboard/dashboard-status-lines.ts +45 -0
  187. package/src/dashboard/first-screen-band.ts +35 -0
  188. package/src/dashboard/index.ts +87 -14
  189. package/src/dashboard/provider-slash-runtime.ts +28 -11
  190. package/src/dashboard/slash-runtime/dashboard-handlers.ts +12 -2
  191. package/src/dashboard/slash-runtime/help-from-registry.ts +4 -2
  192. package/src/dashboard/turn-stream-formatter.ts +1 -1
  193. package/src/decisions/decision-cards.ts +291 -0
  194. package/src/decisions/decision-ledger.ts +5 -2
  195. package/src/decisions/discord-decision-cards.ts +133 -0
  196. package/src/decisions/telegram-decision-cards.ts +100 -0
  197. package/src/directives/version-at.ts +77 -0
  198. package/src/discord.ts +17 -0
  199. package/src/domains/outbound-alert.ts +44 -1
  200. package/src/domains/repeated-failure.ts +1 -1
  201. package/src/error-report/report.ts +200 -0
  202. package/src/exec-requests/planner.ts +8 -2
  203. package/src/exec-requests/runner.ts +64 -24
  204. package/src/exec-requests/store.ts +2 -0
  205. package/src/field/field-feed-auto.ts +48 -0
  206. package/src/field/field-reel.ts +8 -1
  207. package/src/hooks/error-report.ts +210 -0
  208. package/src/hooks/receiver.ts +41 -1
  209. package/src/import/apply.ts +128 -0
  210. package/src/import/plan.ts +197 -0
  211. package/src/index.ts +98 -11
  212. package/src/input-core/text-entry.ts +13 -5
  213. package/src/intake-plane/seat-answer.ts +96 -0
  214. package/src/intake-plane/seat-doc-route.ts +17 -3
  215. package/src/maturity/feature-maturity.ts +2 -0
  216. package/src/nexus/api/consult-requests.ts +19 -8
  217. package/src/nexus/api/error-reports.ts +108 -0
  218. package/src/nexus/api/http-server.ts +41 -1
  219. package/src/nexus/api/meta-api.ts +27 -0
  220. package/src/nexus/api/operator.ts +61 -0
  221. package/src/nexus/api/ops-api.ts +139 -0
  222. package/src/nexus/api/ops-seats-sources.ts +65 -0
  223. package/src/nexus/api/ops-seats.ts +114 -0
  224. package/src/nexus/api/outbound-report.ts +7 -0
  225. package/src/nexus/api/public-routes.ts +14 -1
  226. package/src/nexus/api/running-runs-background.ts +68 -0
  227. package/src/nexus/api/running-runs-child.ts +6 -0
  228. package/src/nexus/api/setup-claim.ts +21 -0
  229. package/src/nexus/api/share-captures.ts +91 -0
  230. package/src/nexus/api/skill-problems.ts +56 -0
  231. package/src/nexus/api/terminals.ts +24 -8
  232. package/src/nexus/config/secrets/file-backend.ts +56 -18
  233. package/src/nexus/index.ts +25 -1
  234. package/src/nexus/setup-status.ts +7 -2
  235. package/src/oauth/browser-availability.ts +14 -0
  236. package/src/oauth/codex-quota-policy.ts +16 -0
  237. package/src/onboarding/continue-here.ts +13 -8
  238. package/src/onboarding/first-run-doctor.ts +54 -0
  239. package/src/onboarding/web-first.ts +8 -1
  240. package/src/onboarding.ts +66 -12
  241. package/src/persona/presets.ts +105 -0
  242. package/src/release-loop/checklist.ts +33 -69
  243. package/src/release-loop/feature-store.ts +327 -0
  244. package/src/release-loop/manifest.ts +59 -5
  245. package/src/seat-dispatch/ceo-commands.ts +65 -0
  246. package/src/self-dev/orchestrate.ts +18 -11
  247. package/src/shell-env-bootstrap.ts +44 -7
  248. package/src/skills/connect-claude-plugin.ts +142 -0
  249. package/src/skills/connect.ts +70 -0
  250. package/src/skills/index.ts +29 -1
  251. package/src/skills/repair.ts +124 -0
  252. package/src/skills/runner.ts +11 -2
  253. package/src/skills/tools/usage-slash.ts +8 -7
  254. package/src/skills/trigger-extract.ts +1 -1
  255. package/src/task-orchestrator/surfaces/pod-command-job.ts +16 -8
  256. package/src/telegram-commands.ts +29 -0
  257. package/src/telegram-run.ts +83 -13
  258. package/src/telegram.ts +34 -0
  259. package/src/thinking-line.ts +28 -20
  260. package/src/tui/chat/execution-badge.ts +1 -1
  261. package/src/user-config.ts +111 -13
  262. package/src/ux-sim/turn-states.ts +1 -1
  263. package/src/version/packed-revision.json +1 -1
  264. package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +0 -1
  265. package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/9454-a3e0953307216c58.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-10ed24f413ed66e9.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/board/page-211810b6c93ab66a.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/live/page-30533117beb53625.js +0 -1
  280. package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +0 -1
  281. package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +0 -1
  282. package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +0 -1
  284. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +0 -1
  285. package/apps/pwa/out/_next/static/chunks/app/settings/page-dd107bf1c7d52a90.js +0 -1
  286. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +0 -1
  287. package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +0 -1
  288. package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +0 -1
  289. package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +0 -1
  290. package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +0 -1
  291. package/apps/pwa/out/_next/static/chunks/app/vault/page-56adbe1a1f1379bd.js +0 -1
  292. package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +0 -1
  293. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +0 -1
  294. package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +0 -1
  295. package/apps/pwa/out/_next/static/keajW7DDyghEs1y8oJe6T/_buildManifest.js +0 -1
  296. /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → bFI5EEwg7T6aG88ibX4Zq}/_ssgManifest.js +0 -0
@@ -0,0 +1,153 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { dlopen, FFIType } from 'bun:ffi';
5
+ import { getElanousConfigDir } from '../elanous-config-dir.js';
6
+
7
+ export const SETUP_LINK_TTL_MS = 10 * 60_000;
8
+ const SETUP_BEARER_TTL_MS = 24 * 60 * 60_000;
9
+ const LINK_PREFIX = 'els_';
10
+ const BEARER_PREFIX = 'elsb_';
11
+ const HASH_PATTERN = /^[a-f0-9]{64}$/;
12
+ const LINK_PATTERN = /^els_[A-Za-z0-9_-]{43}$/;
13
+ const BEARER_PATTERN = /^elsb_[A-Za-z0-9_-]{43}$/;
14
+
15
+ interface SetupLinkEntry {
16
+ hash: string;
17
+ expiresAt: string;
18
+ usedAt?: string;
19
+ bearerHash?: string;
20
+ bearerExpiresAt?: string;
21
+ }
22
+
23
+ type ClaimResult =
24
+ | { ok: true; bearer: string; expiresAt: string }
25
+ | { ok: false; reason: 'unknown' | 'used' | 'expired' };
26
+
27
+ function storePath(dir: string): string { return join(dir, 'setup-link-tokens.json'); }
28
+ function hashOf(token: string): string { return createHash('sha256').update(token).digest('hex'); }
29
+ function mint(prefix: string): string { return `${prefix}${randomBytes(32).toString('base64url')}`; }
30
+
31
+ function readStore(dir: string): SetupLinkEntry[] {
32
+ let raw: string;
33
+ try {
34
+ const path = storePath(dir);
35
+ const st = lstatSync(path);
36
+ if (!st.isFile() || (st.mode & 0o077) !== 0) throw new Error('setup link store must be a private regular file (0600)');
37
+ raw = readFileSync(path, 'utf8');
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
40
+ throw error;
41
+ }
42
+ const parsed: unknown = JSON.parse(raw);
43
+ if (!parsed || typeof parsed !== 'object' || !('tokens' in parsed) || !Array.isArray(parsed.tokens)) {
44
+ throw new Error('setup link store is malformed');
45
+ }
46
+ const entries: unknown[] = parsed.tokens;
47
+ if (!entries.every((entry) => {
48
+ if (!entry || typeof entry !== 'object') return false;
49
+ const t = entry as Partial<SetupLinkEntry>;
50
+ return typeof t.hash === 'string' && HASH_PATTERN.test(t.hash)
51
+ && typeof t.expiresAt === 'string' && Number.isFinite(Date.parse(t.expiresAt))
52
+ && (t.usedAt === undefined || (typeof t.usedAt === 'string' && Number.isFinite(Date.parse(t.usedAt))))
53
+ && (t.bearerHash === undefined || (typeof t.bearerHash === 'string' && HASH_PATTERN.test(t.bearerHash)))
54
+ && (t.bearerExpiresAt === undefined || (typeof t.bearerExpiresAt === 'string' && Number.isFinite(Date.parse(t.bearerExpiresAt))))
55
+ && ((t.usedAt === undefined && t.bearerHash === undefined && t.bearerExpiresAt === undefined)
56
+ || (t.usedAt !== undefined && t.bearerHash !== undefined && t.bearerExpiresAt !== undefined));
57
+ })) throw new Error('setup link store is malformed');
58
+ return entries as SetupLinkEntry[];
59
+ }
60
+
61
+ function writeStore(dir: string, tokens: SetupLinkEntry[]): void {
62
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ const path = storePath(dir);
64
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
65
+ try {
66
+ writeFileSync(tmp, JSON.stringify({ tokens }, null, 2), { flag: 'wx', mode: 0o600 });
67
+ renameSync(tmp, path);
68
+ } finally {
69
+ try { unlinkSync(tmp); } catch (error) {
70
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
71
+ }
72
+ }
73
+ }
74
+
75
+ // A persistent inode and an OS advisory lock avoid stale owner files after an abrupt process exit.
76
+ // Never unlink this file: a replacement inode would let two processes hold different locks.
77
+ const libc = dlopen(process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6', {
78
+ flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
79
+ });
80
+ const LOCK_EX = 2;
81
+ const LOCK_NB = 4;
82
+ const LOCK_UN = 8;
83
+
84
+ function withLock<T>(dir: string, fn: () => T): T {
85
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
86
+ const lock = `${storePath(dir)}.lock`;
87
+ const fd = openSync(lock, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
88
+ try {
89
+ const stat = fstatSync(fd);
90
+ const current = lstatSync(lock);
91
+ if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.ino !== current.ino || stat.dev !== current.dev) {
92
+ throw new Error('setup link lock must be a private regular file (0600)');
93
+ }
94
+ const until = Date.now() + 3000;
95
+ while (libc.symbols.flock(fd, LOCK_EX | LOCK_NB) !== 0) {
96
+ if (Date.now() > until) throw new Error('setup link store is locked — try again');
97
+ Bun.sleepSync(20);
98
+ }
99
+ try {
100
+ const locked = lstatSync(lock);
101
+ if (stat.ino !== locked.ino || stat.dev !== locked.dev) throw new Error('setup link lock changed');
102
+ return fn();
103
+ } finally {
104
+ libc.symbols.flock(fd, LOCK_UN);
105
+ }
106
+ } finally {
107
+ closeSync(fd);
108
+ }
109
+ }
110
+
111
+ /** The plaintext link token is returned once; only its SHA-256 digest is persisted. */
112
+ export function issueSetupLinkToken(opts: { dir?: string; now?: number } = {}): { token: string; expiresAt: string } {
113
+ const dir = opts.dir ?? getElanousConfigDir();
114
+ const now = opts.now ?? Date.now();
115
+ const token = mint(LINK_PREFIX);
116
+ const expiresAt = new Date(now + SETUP_LINK_TTL_MS).toISOString();
117
+ withLock(dir, () => writeStore(dir, [...readStore(dir), { hash: hashOf(token), expiresAt }]));
118
+ return { token, expiresAt };
119
+ }
120
+
121
+ /** Claim exactly once under the same lock used by issuance. Expired and used links retain their diagnostic status. */
122
+ export function claimSetupLinkToken(token: string, opts: { dir?: string; now?: number } = {}): ClaimResult {
123
+ if (typeof token !== 'string' || !LINK_PATTERN.test(token)) return { ok: false, reason: 'unknown' };
124
+ const dir = opts.dir ?? getElanousConfigDir();
125
+ const now = opts.now ?? Date.now();
126
+ return withLock(dir, () => {
127
+ const entries = readStore(dir);
128
+ const digest = Buffer.from(hashOf(token), 'hex');
129
+ const hit = entries.find((entry) => timingSafeEqual(digest, Buffer.from(entry.hash, 'hex')));
130
+ if (!hit) return { ok: false, reason: 'unknown' };
131
+ if (hit.usedAt) return { ok: false, reason: 'used' };
132
+ if (Date.parse(hit.expiresAt) <= now) return { ok: false, reason: 'expired' };
133
+ const bearer = mint(BEARER_PREFIX);
134
+ const expiresAt = new Date(now + SETUP_BEARER_TTL_MS).toISOString();
135
+ hit.usedAt = new Date(now).toISOString();
136
+ hit.bearerHash = hashOf(bearer);
137
+ hit.bearerExpiresAt = expiresAt;
138
+ writeStore(dir, entries);
139
+ return { ok: true, bearer, expiresAt };
140
+ });
141
+ }
142
+
143
+ /** A setup bearer is not an owner or temp-owner token; validate it only on setup-allowed routes. */
144
+ export function matchSetupBearer(bearer: string, opts: { dir?: string; now?: number } = {}): { expiresAt: string } | null {
145
+ if (typeof bearer !== 'string' || !BEARER_PATTERN.test(bearer)) return null;
146
+ let entries: SetupLinkEntry[];
147
+ try { entries = readStore(opts.dir ?? getElanousConfigDir()); } catch { return null; }
148
+ const digest = Buffer.from(hashOf(bearer), 'hex');
149
+ const now = opts.now ?? Date.now();
150
+ const hit = entries.find((entry) => entry.bearerHash && entry.bearerExpiresAt
151
+ && timingSafeEqual(digest, Buffer.from(entry.bearerHash, 'hex')) && Date.parse(entry.bearerExpiresAt) > now);
152
+ return hit?.bearerExpiresAt ? { expiresAt: hit.bearerExpiresAt } : null;
153
+ }
@@ -18,6 +18,7 @@ import { listCodexAccountsInStore } from '../oauth/codex-account-store.js';
18
18
  import { loadTokens } from '../oauth/store.js';
19
19
  import { envLiteral } from '../platform/env-literal.js';
20
20
  import { describeResetCreditExpiry } from './codex-reset-credit-state.js';
21
+ import { visibleWidth } from '../tui.js';
21
22
  import { listCodexResetCredits, type CodexResetCreditsResult } from './codex-reset-credits.js';
22
23
  import type {
23
24
  AccountUsageRow,
@@ -268,6 +269,80 @@ async function collectOpenRouterRows(deps: UnifiedUsageDeps): Promise<AccountUsa
268
269
  }
269
270
  }
270
271
 
272
+ export function formatUsageCompact(report: UnifiedUsageReport, options: { width: number; nowMs: number }): string[] {
273
+ return report.rows.map((row) => {
274
+ const credits = row.credits;
275
+ const subscription = row.subscription;
276
+ const remaining = subscription.status === 'available' && Number.isFinite(subscription.remainingPercent)
277
+ ? subscription.remainingPercent
278
+ : credits.status === 'ok' && credits.usedPercent !== null && Number.isFinite(credits.usedPercent)
279
+ ? Math.round((100 - credits.usedPercent) * 10) / 10
280
+ : null;
281
+ const period = subscription.status === 'available'
282
+ ? subscription.windowKind
283
+ : credits.status === 'ok' ? credits.periodType : null;
284
+ const reset = subscription.status === 'available' ? subscription.resetsAt
285
+ : credits.status === 'ok' && credits.periodEnd ? Date.parse(credits.periodEnd) : Number.NaN;
286
+ const periodLabel = period ? `${formatCompactPeriod(period)} ` : '';
287
+ const when = period === 'prepaid-usd' ? '' : !isValidEpochMs(reset)
288
+ ? ` · ${periodLabel}시각 모름`
289
+ : reset <= options.nowMs
290
+ ? ` · ${periodLabel}재조회 필요`
291
+ : ` · ${periodLabel}${formatCompactReset(reset, options.nowMs)}에 다시 참`;
292
+ const balance = credits.status === 'ok' && credits.balance !== null && Number.isFinite(credits.balance)
293
+ ? ` · 크레딧 ${new Intl.NumberFormat('en-US', { maximumFractionDigits: credits.balance >= 1_000 ? 0 : 1 }).format(credits.balance)}` : '';
294
+ const prefix = `${row.provider} `;
295
+ const name = row.accountName.replace(/[\r\n\t\x00-\x1f\x7f]/g, ' ');
296
+ const remainingLabel = `남은 ${remaining === null ? '잔량 모름' : `${remaining}%`}`;
297
+ const width = Number.isFinite(options.width) ? Math.max(0, Math.floor(options.width)) : 120;
298
+ const suffix = ` · ${remainingLabel}${when}${balance}`;
299
+ const available = width - visibleWidth(prefix) - visibleWidth(suffix);
300
+ if (available >= 1) {
301
+ const account = visibleWidth(name) <= available ? name
302
+ : available === 1 ? '…' : `${compactSlice(name, available - 1)}…`;
303
+ return `${prefix}${account}${suffix}`;
304
+ }
305
+ // The fixed labels can exceed very narrow widths even with no account name.
306
+ // Keep the measured value when possible rather than slicing its digits.
307
+ const essential = `${row.provider} · ${remainingLabel}`;
308
+ if (visibleWidth(essential) <= width) return essential;
309
+ if (visibleWidth(remainingLabel) <= width) return remainingLabel;
310
+ const value = remaining === null ? '잔량 모름' : `${remaining}%`;
311
+ if (visibleWidth(value) <= width) return value;
312
+ return width > 0 ? '…' : '';
313
+ });
314
+ }
315
+
316
+ function formatCompactPeriod(value: string): string {
317
+ const period = value.replace(/^USAGE_PERIOD_TYPE_/, '').toLowerCase();
318
+ switch (period) {
319
+ case 'weekly': return '주간';
320
+ case 'monthly': return '월간';
321
+ case 'daily': return '일간';
322
+ default: return period;
323
+ }
324
+ }
325
+
326
+ function formatCompactReset(reset: number, nowMs: number): string {
327
+ const remaining = reset - nowMs;
328
+ if (remaining < 86_400_000) {
329
+ if (remaining < 3_600_000) return `${Math.ceil(remaining / 60_000)}분 뒤`;
330
+ return `${Math.ceil(remaining / 3_600_000)}시간 뒤`;
331
+ }
332
+ const date = new Date(reset);
333
+ const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
334
+ return `${date.getMonth() + 1}/${date.getDate()} (${weekdays[date.getDay()]}) ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
335
+ }
336
+
337
+ function compactSlice(text: string, width: number): string {
338
+ let result = '';
339
+ for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)) {
340
+ if (visibleWidth(result + segment) > width) break;
341
+ result += segment;
342
+ }
343
+ return result;
344
+ }
345
+
271
346
  export function formatUnifiedUsage(report: UnifiedUsageReport): string {
272
347
  const lines: string[] = [
273
348
  'usage — 계정마다 한 행 · 크레딧 축과 구독 축은 각각 칸',
@@ -1,35 +1,62 @@
1
1
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { debug } from './debug/log.js';
4
- import { getSecret, setSecretAsync } from './nexus/config/secrets/index.js';
5
- import { userConfigPath } from './nexus/config/paths.js';
4
+ import { getSecret, setSecretAsync, currentBackendId } from './nexus/config/secrets/index.js';
5
+ import { userConfigPath, secretsPath } from './nexus/config/paths.js';
6
6
  import { isSecretRef, makeSecretRef, secretIdFromRef } from './nexus/config/types.js';
7
7
  import { acquireLockAsync } from './storage/file-lock.js';
8
8
  import type { UserConfig } from './user-config.js';
9
9
 
10
10
  export type ChannelBotPlatform = 'telegram' | 'discord';
11
- export type ChannelBotTokenSource = 'tokenRef' | 'env' | 'plaintext';
11
+ export type ChannelBotTokenSource = 'tokenRef' | 'env' | 'botToken' | 'channels.main';
12
+ export type ChannelBotTokenResolution = { token: string; source: ChannelBotTokenSource; reason?: never }
13
+ | { token: ''; source?: never; reason: string };
12
14
 
13
15
  /** The tab reference is read from the same persistent config as the NEXUS switch. */
14
16
  export function resolveChannelBotToken(
15
17
  platform: ChannelBotPlatform,
16
18
  cfg: Pick<UserConfig, 'telegram' | 'discord'> & { tabs?: Record<string, { tokenRef?: unknown }> },
17
- ): { token: string; source: ChannelBotTokenSource } | undefined {
19
+ ): ChannelBotTokenResolution {
20
+ let reason: string | undefined;
18
21
  const ref = cfg.tabs?.[`${platform}:1`]?.tokenRef;
19
22
  if (isSecretRef(ref)) {
20
23
  const id = secretIdFromRef(ref);
21
- const token = id ? getSecret(id) : undefined;
22
- if (token) return { token, source: 'tokenRef' };
24
+ if (id) {
25
+ const backend = currentBackendId();
26
+ if (backend !== 'file') {
27
+ reason = `backend ${backend} not sync-readable`;
28
+ } else {
29
+ const token = getSecret(id);
30
+ if (token) {
31
+ debug.log('channel-bot.token', 'resolved', { kind: platform, source: 'tokenRef' });
32
+ return { token, source: 'tokenRef' };
33
+ }
34
+ reason = `ref-id-missing ${id} in ${secretsPath()}`;
35
+ }
36
+ }
23
37
  }
24
38
  const envName = platform === 'telegram' ? 'ELANOUS_TELEGRAM_BOT_TOKEN' : 'ELANOUS_DISCORD_BOT_TOKEN';
25
39
  const envToken = process.env[envName];
26
- if (envToken) return { token: envToken, source: 'env' };
40
+ if (envToken) {
41
+ debug.log('channel-bot.token', 'resolved', { kind: platform, source: 'env' });
42
+ return { token: envToken, source: 'env' };
43
+ }
27
44
  const token = cfg[platform]?.botToken;
28
45
  if (token) {
29
- debug.log('channel-bot.token', 'plaintext-fallback', { platform });
30
- return { token, source: 'plaintext' };
46
+ debug.log('channel-bot.token', 'resolved', { kind: platform, source: 'botToken' });
47
+ return { token, source: 'botToken' };
48
+ }
49
+ if (platform === 'telegram') {
50
+ const channels = cfg.telegram?.channels;
51
+ const channelToken = (channels?.find(c => c.name === 'main') ?? channels?.[0])?.botToken;
52
+ if (channelToken) {
53
+ debug.log('channel-bot.token', 'resolved', { kind: platform, source: 'channels.main' });
54
+ return { token: channelToken, source: 'channels.main' };
55
+ }
31
56
  }
32
- return undefined;
57
+ reason ??= 'no-source';
58
+ debug.log('channel-bot.token', 'unresolved', { kind: platform, reason });
59
+ return { token: '', reason };
33
60
  }
34
61
 
35
62
  /** Publish the secret before its reference, under the config lock. Other config keys survive unchanged. */
@@ -0,0 +1,191 @@
1
+ // EN13 — 셋업이 «이미 쓰던» 텔레그램·디스코드 설정을 찾아 온다(대표 10-01).
2
+ // 출처: OpenClaw(~/.openclaw/openclaw.json · tokenFile) · Hermes(~/.hermes/.env) · 이전 엘라누스(~/.monad/config.json).
3
+ // ⛔ 토큰 값은 이 모듈 밖으로 «문자열»로 나가지 않는다 — `BotToken` 이 감싸고, 문자열화·JSON·inspect 는 전부 가린다.
4
+ // 값이 필요한 곳(저장·연결 확인)만 `reveal()` 을 부른다. 미리보기는 «있음 · 끝 4자리 가림»만 낸다.
5
+ import { createHash } from 'node:crypto';
6
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ export type ImportPlatform = 'telegram' | 'discord';
11
+ export type ImportSource = 'openclaw' | 'hermes' | 'elanous-legacy';
12
+
13
+ const MASK = '있음 · 끝 4자리 가림';
14
+
15
+ /** A bot token that never prints itself. */
16
+ export class BotToken {
17
+ readonly #value: string;
18
+ constructor(value: string) { this.#value = value; }
19
+ reveal(): string { return this.#value; }
20
+ /** Same-token check without exposing either value. */
21
+ fingerprint(): string { return createHash('sha256').update(this.#value).digest('hex').slice(0, 8); }
22
+ toString(): string { return MASK; }
23
+ toJSON(): string { return MASK; }
24
+ [Symbol.for('nodejs.util.inspect.custom')](): string { return MASK; }
25
+ }
26
+
27
+ export interface ImportCandidate {
28
+ platform: ImportPlatform;
29
+ source: ImportSource;
30
+ /** `~`-relative file the value came from. */
31
+ file: string;
32
+ token: BotToken;
33
+ allowedUsers: string[];
34
+ /** Allowlist entries we could not use (not a numeric Telegram id, etc.). */
35
+ droppedUsers: number;
36
+ }
37
+
38
+ export const SOURCE_LABEL: Record<ImportSource, string> = {
39
+ openclaw: 'OpenClaw', hermes: 'Hermes', 'elanous-legacy': '이전 엘라누스(~/.monad)',
40
+ };
41
+
42
+ function tilde(path: string, home: string): string { return path.startsWith(home) ? `~${path.slice(home.length)}` : path; }
43
+ function obj(value: unknown): Record<string, unknown> | null {
44
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
45
+ }
46
+ function text(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; }
47
+
48
+ /** JSON with comments and trailing commas (openclaw.json is JSON5-ish). Strings are kept intact. */
49
+ export function parseLooseJson(raw: string): unknown {
50
+ try { return JSON.parse(raw); } catch { /* fall through */ }
51
+ let out = '';
52
+ let inString = false;
53
+ for (let i = 0; i < raw.length; i++) {
54
+ const ch = raw[i]!;
55
+ if (inString) {
56
+ out += ch;
57
+ if (ch === '\\') { out += raw[++i] ?? ''; continue; }
58
+ if (ch === '"') inString = false;
59
+ continue;
60
+ }
61
+ if (ch === '"') { inString = true; out += ch; continue; }
62
+ if (ch === '/' && raw[i + 1] === '/') { while (i < raw.length && raw[i] !== '\n') i++; out += '\n'; continue; }
63
+ if (ch === '/' && raw[i + 1] === '*') { i += 2; while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i++; i++; continue; }
64
+ out += ch;
65
+ }
66
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
67
+ }
68
+
69
+ /** `KEY=value` lines (quotes and `export ` allowed, `#` comments ignored). */
70
+ export function parseDotEnv(raw: string): Record<string, string> {
71
+ const out: Record<string, string> = {};
72
+ for (const line of raw.split(/\r?\n/)) {
73
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
74
+ if (!m) continue;
75
+ let value = m[2]!;
76
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
77
+ else value = value.replace(/\s+#.*$/, '');
78
+ out[m[1]!] = value.trim();
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** Telegram allowlists must be numeric user ids; `telegram:123` / `tg:123` prefixes are accepted. */
84
+ export function normalizeUsers(platform: ImportPlatform, raw: unknown): { users: string[]; dropped: number } {
85
+ const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : [];
86
+ const users: string[] = [];
87
+ let dropped = 0;
88
+ for (const entry of list) {
89
+ const value = String(entry).trim().replace(/^(?:telegram|tg|discord|user):/i, '');
90
+ if (!value || value === '*') { if (value === '*') dropped++; continue; }
91
+ const ok = platform === 'telegram' ? /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value) > 0 : /^\d{5,}$/.test(value);
92
+ if (ok && !users.includes(value)) users.push(value); else if (!ok) dropped++;
93
+ }
94
+ return { users, dropped };
95
+ }
96
+
97
+ function readRegularFile(path: string): string | null {
98
+ try { return lstatSync(path).isFile() ? readFileSync(path, 'utf8').trim() || null : null; } catch { return null; }
99
+ }
100
+
101
+ function candidate(platform: ImportPlatform, source: ImportSource, file: string, token: string | null, users: unknown): ImportCandidate | null {
102
+ if (!token) return null;
103
+ const { users: allowedUsers, dropped } = normalizeUsers(platform, users);
104
+ return { platform, source, file, token: new BotToken(token), allowedUsers, droppedUsers: dropped };
105
+ }
106
+
107
+ function fromOpenClaw(home: string): ImportCandidate[] {
108
+ const path = join(home, '.openclaw', 'openclaw.json');
109
+ if (!existsSync(path)) return [];
110
+ const cfg = obj(parseLooseJson(readFileSync(path, 'utf8')));
111
+ const channels = obj(cfg?.channels);
112
+ const file = tilde(path, home);
113
+ const out: ImportCandidate[] = [];
114
+ const telegram = obj(channels?.telegram);
115
+ if (telegram) {
116
+ const firstAccount = obj(Object.values(obj(telegram.accounts) ?? {})[0]);
117
+ const token = text(telegram.botToken) ?? (text(telegram.tokenFile) ? readRegularFile(text(telegram.tokenFile)!.replace(/^~(?=\/)/, home)) : null)
118
+ ?? text(firstAccount?.botToken);
119
+ const c = candidate('telegram', 'openclaw', file, token, telegram.allowFrom ?? firstAccount?.allowFrom);
120
+ if (c) out.push(c);
121
+ }
122
+ const discord = obj(channels?.discord);
123
+ if (discord) {
124
+ const firstAccount = obj(Object.values(obj(discord.accounts) ?? {})[0]);
125
+ const c = candidate('discord', 'openclaw', file, text(discord.token) ?? text(firstAccount?.token), discord.allowFrom ?? obj(discord.dm)?.allowFrom);
126
+ if (c) out.push(c);
127
+ }
128
+ return out;
129
+ }
130
+
131
+ function fromHermes(home: string): ImportCandidate[] {
132
+ const path = join(home, '.hermes', '.env');
133
+ if (!existsSync(path)) return [];
134
+ const env = parseDotEnv(readFileSync(path, 'utf8'));
135
+ const file = tilde(path, home);
136
+ return [
137
+ candidate('telegram', 'hermes', file, text(env.TELEGRAM_BOT_TOKEN), env.TELEGRAM_ALLOWED_USERS),
138
+ candidate('discord', 'hermes', file, text(env.DISCORD_BOT_TOKEN), env.DISCORD_ALLOWED_USERS),
139
+ ].filter((c): c is ImportCandidate => c !== null);
140
+ }
141
+
142
+ function fromElanousLegacy(home: string): ImportCandidate[] {
143
+ const path = join(home, '.monad', 'config.json');
144
+ if (!existsSync(path)) return [];
145
+ const cfg = obj(parseLooseJson(readFileSync(path, 'utf8')));
146
+ const file = tilde(path, home);
147
+ return (['telegram', 'discord'] as const)
148
+ .map((platform) => candidate(platform, 'elanous-legacy', file, text(obj(cfg?.[platform])?.botToken), obj(cfg?.[platform])?.allowedUsers))
149
+ .filter((c): c is ImportCandidate => c !== null);
150
+ }
151
+
152
+ export interface DetectResult { candidates: ImportCandidate[]; unreadable: { file: string; reason: string }[] }
153
+
154
+ /** Look in every known place; a broken file is reported by name (never by content) and the rest still count. */
155
+ export function detectChannelImports(home = homedir()): DetectResult {
156
+ const candidates: ImportCandidate[] = [];
157
+ const unreadable: DetectResult['unreadable'] = [];
158
+ const readers: [string, (h: string) => ImportCandidate[]][] = [
159
+ [join(home, '.monad', 'config.json'), fromElanousLegacy],
160
+ [join(home, '.openclaw', 'openclaw.json'), fromOpenClaw],
161
+ [join(home, '.hermes', '.env'), fromHermes],
162
+ ];
163
+ for (const [path, read] of readers) {
164
+ try { candidates.push(...read(home)); }
165
+ catch (error) {
166
+ // JSON parse errors can quote the offending text — keep only the error class.
167
+ unreadable.push({ file: tilde(path, home), reason: error instanceof SyntaxError ? '형식을 읽지 못했습니다' : '읽지 못했습니다' });
168
+ }
169
+ }
170
+ return { candidates, unreadable };
171
+ }
172
+
173
+ /** One per platform: the first source in priority order; the rest are named as «다른 토큰도 있음». */
174
+ export function pickPerPlatform(candidates: readonly ImportCandidate[]): { chosen: ImportCandidate[]; others: ImportCandidate[] } {
175
+ const chosen: ImportCandidate[] = [];
176
+ const others: ImportCandidate[] = [];
177
+ for (const c of candidates) {
178
+ const first = chosen.find((x) => x.platform === c.platform);
179
+ if (!first) chosen.push(c);
180
+ else if (first.token.fingerprint() !== c.token.fingerprint()) others.push(c);
181
+ else for (const user of c.allowedUsers) if (!first.allowedUsers.includes(user)) first.allowedUsers.push(user);
182
+ }
183
+ return { chosen, others };
184
+ }
185
+
186
+ export function previewLine(c: ImportCandidate): string {
187
+ const name = c.platform === 'telegram' ? '텔레그램' : '디스코드';
188
+ const users = c.allowedUsers.length ? `허용 사용자 ${c.allowedUsers.length}명` : '허용 사용자 없음(나중에 정해야 합니다)';
189
+ const dropped = c.droppedUsers ? ` · 쓸 수 없는 항목 ${c.droppedUsers}개 제외` : '';
190
+ return `${name} ← ${SOURCE_LABEL[c.source]} (${c.file}): 토큰 ${MASK} · ${users}${dropped}`;
191
+ }
@@ -0,0 +1,111 @@
1
+ // EN13 — 감지 → 미리보기 → 동의 → 시크릿 저장 → 연결 확인(읽기 1회). 토큰 값은 화면·로그·채널 어디에도 안 나간다.
2
+ import { homedir } from 'node:os';
3
+ import { debug } from '../debug/log.js';
4
+ import { detectChannelImports, pickPerPlatform, previewLine, SOURCE_LABEL, type ImportCandidate, type ImportPlatform } from './detect.js';
5
+
6
+ export interface ChannelImportDeps {
7
+ home?: string;
8
+ out?: { log: (s: string) => void; error: (s: string) => void };
9
+ /** Asks once; returns true for yes. Absent or non-interactive → no. */
10
+ ask?: (question: string) => Promise<boolean>;
11
+ yes?: boolean;
12
+ /** Current token for that platform (to say «already the same»), or null. */
13
+ currentToken?: (platform: ImportPlatform) => string | null | Promise<string | null>;
14
+ store?: (platform: ImportPlatform, token: string, allowedUsers: string[] | undefined) => Promise<void>;
15
+ probe?: (platform: ImportPlatform, token: string) => Promise<{ ok: boolean; botName?: string }>;
16
+ }
17
+
18
+ export interface ChannelImportOutcome {
19
+ exitCode: number;
20
+ imported: { platform: ImportPlatform; source: string; connected: boolean; botName?: string }[];
21
+ }
22
+
23
+ const NAME: Record<ImportPlatform, string> = { telegram: '텔레그램', discord: '디스코드' };
24
+
25
+ async function defaultStore(platform: ImportPlatform, token: string, allowedUsers: string[] | undefined): Promise<void> {
26
+ const { storeChannelBotToken } = await import('../channel-bot-token.js');
27
+ await storeChannelBotToken(platform, token, allowedUsers);
28
+ }
29
+
30
+ async function defaultProbe(platform: ImportPlatform, token: string): Promise<{ ok: boolean; botName?: string }> {
31
+ const { probeChannelBotToken } = await import('../channel-bot-token.js');
32
+ return probeChannelBotToken(platform, token);
33
+ }
34
+
35
+ async function defaultCurrentToken(platform: ImportPlatform): Promise<string | null> {
36
+ try {
37
+ const { resolveChannelBotToken } = await import('../channel-bot-token.js');
38
+ const { getUserConfig } = await import('../user-config.js');
39
+ return resolveChannelBotToken(platform, getUserConfig())?.token ?? null;
40
+ } catch { return null; }
41
+ }
42
+
43
+ export async function runChannelImport(deps: ChannelImportDeps = {}): Promise<ChannelImportOutcome> {
44
+ const out = deps.out ?? console;
45
+ const home = deps.home ?? homedir();
46
+ const { candidates, unreadable } = detectChannelImports(home);
47
+ for (const u of unreadable) out.log(`· ${u.file}: ${u.reason} — 건너뜁니다`);
48
+ const { chosen, others } = pickPerPlatform(candidates);
49
+ debug.log('channel-import', 'detected', {
50
+ sources: candidates.map((c) => `${c.source}:${c.platform}`), chosen: chosen.length, others: others.length, unreadable: unreadable.length,
51
+ });
52
+ if (!chosen.length) {
53
+ out.log('가져올 텔레그램·디스코드 설정을 찾지 못했습니다(OpenClaw · Hermes · 이전 엘라누스). 직접 넣으려면: elanous nexus channel-bot setup telegram');
54
+ return { exitCode: 0, imported: [] };
55
+ }
56
+
57
+ const current = deps.currentToken ?? defaultCurrentToken;
58
+ const todo: ImportCandidate[] = [];
59
+ out.log('이미 쓰던 채널 설정을 찾았습니다:');
60
+ for (const c of chosen) {
61
+ const existing = await current(c.platform);
62
+ if (existing && existing === c.token.reveal()) { out.log(`· ${previewLine(c)} — 이미 같은 토큰을 쓰고 있습니다`); continue; }
63
+ out.log(`· ${previewLine(c)}${existing ? ' — ⚠️ 지금 쓰는 토큰을 바꿉니다' : ''}`);
64
+ todo.push(c);
65
+ }
66
+ for (const c of others) out.log(` (${NAME[c.platform]} 토큰이 ${SOURCE_LABEL[c.source]}에도 있습니다 — 다른 토큰이라 위의 것만 가져옵니다)`);
67
+ if (!todo.length) return { exitCode: 0, imported: [] };
68
+
69
+ const question = `가져올까요? 토큰은 엘라누스 비밀 저장소에만 넣고 화면·로그에는 남기지 않습니다. [Y/n] `;
70
+ const agreed = deps.yes || (deps.ask ? await deps.ask(question) : false);
71
+ if (!agreed) {
72
+ out.log(deps.ask || deps.yes ? '가져오지 않았습니다.' : '가져오려면: elanous nexus channel-bot import --yes');
73
+ debug.log('channel-import', 'declined', { count: todo.length });
74
+ return { exitCode: 0, imported: [] };
75
+ }
76
+
77
+ const store = deps.store ?? defaultStore;
78
+ const probe = deps.probe ?? defaultProbe;
79
+ const imported: ChannelImportOutcome['imported'] = [];
80
+ let failed = 0;
81
+ for (const c of todo) {
82
+ try {
83
+ await store(c.platform, c.token.reveal(), c.allowedUsers.length ? c.allowedUsers : undefined);
84
+ } catch (error) {
85
+ failed++;
86
+ // Store errors are our own (lock, invalid ids) — print the class only, never the message, in case it carries input.
87
+ const reason = error instanceof Error && error.message === 'invalid-allowed-users' ? '허용 사용자 형식이 맞지 않습니다' : '저장하지 못했습니다';
88
+ out.error(`✗ ${NAME[c.platform]}: ${reason}`);
89
+ debug.log('channel-import', 'store-failed', { platform: c.platform, source: c.source }, { level: 'warn' });
90
+ continue;
91
+ }
92
+ const check = await probe(c.platform, c.token.reveal()).catch(() => ({ ok: false } as { ok: boolean; botName?: string }));
93
+ imported.push({ platform: c.platform, source: c.source, connected: check.ok, ...(check.botName ? { botName: check.botName } : {}) });
94
+ debug.log('channel-import', 'stored', { platform: c.platform, source: c.source, connected: check.ok });
95
+ out.log(check.ok
96
+ ? `✓ ${NAME[c.platform]}: 저장했고 연결을 확인했습니다 — @${check.botName}`
97
+ : `△ ${NAME[c.platform]}: 저장했지만 연결 확인에 실패했습니다 — 토큰이 만료됐거나 네트워크 문제일 수 있습니다(다시 넣기: elanous nexus channel-bot setup ${c.platform})`);
98
+ }
99
+ if (imported.length) out.log('봇은 다음 넥서스 시작 때 켜집니다.');
100
+ return { exitCode: failed ? 1 : 0, imported };
101
+ }
102
+
103
+ /** One line for onboarding — null when there is nothing to import. Never reveals a value. */
104
+ export function channelImportHint(home = homedir()): string | null {
105
+ try {
106
+ const { chosen } = pickPerPlatform(detectChannelImports(home).candidates);
107
+ if (!chosen.length) return null;
108
+ const what = chosen.map((c) => `${NAME[c.platform]}(${SOURCE_LABEL[c.source]})`).join(' · ');
109
+ return `쓰던 채널 설정 찾음: ${what} — 가져오려면: elanous nexus channel-bot import`;
110
+ } catch { return null; }
111
+ }