sanook-cli 0.5.1 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -0,0 +1,456 @@
1
+ import { BRAND } from '../brand.js';
2
+ import { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTeamsConfig, resolveTelegramConfig, resolveWebhookConfig, resolveWhatsAppConfig, } from './config.js';
3
+ import { listTasks } from './ledger.js';
4
+ const CHAT_INBOUND_CHANNELS = new Set([
5
+ 'telegram',
6
+ 'discord',
7
+ 'slack',
8
+ 'mattermost',
9
+ 'line',
10
+ 'signal',
11
+ 'whatsapp',
12
+ 'matrix',
13
+ 'googlechat',
14
+ 'bluebubbles',
15
+ 'teams',
16
+ 'sms',
17
+ 'email',
18
+ 'ntfy',
19
+ ]);
20
+ function check(id, channel, status, message, details) {
21
+ return { id, channel, status, message, details };
22
+ }
23
+ function isHttpUrl(raw, opts = {}) {
24
+ const value = raw?.trim();
25
+ if (!value)
26
+ return false;
27
+ try {
28
+ const url = new URL(value);
29
+ if (opts.requireHttps && url.protocol !== 'https:')
30
+ return false;
31
+ return url.protocol === 'http:' || url.protocol === 'https:';
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ async function probeOk(fetchImpl, url, init, predicate) {
38
+ try {
39
+ const response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(8_000) });
40
+ const body = await response.json().catch(() => ({}));
41
+ if (predicate ? predicate(response, body) : response.ok)
42
+ return { ok: true };
43
+ return { ok: false, detail: `HTTP ${response.status}` };
44
+ }
45
+ catch (error) {
46
+ return { ok: false, detail: error.message || 'request failed' };
47
+ }
48
+ }
49
+ function allowlistDetail(items, emptyLabel) {
50
+ return items.length ? items.map(String) : [emptyLabel];
51
+ }
52
+ async function checkTelegram(config, env, fetchImpl, skipNetwork) {
53
+ const resolved = resolveTelegramConfig(config, env);
54
+ if (!resolved.token)
55
+ return [check('telegram.configured', 'telegram', 'skip', 'not configured')];
56
+ const checks = [
57
+ check('telegram.token', 'telegram', 'pass', `bot token set (${resolved.source})`),
58
+ ];
59
+ if (!resolved.allowedChatIds.length) {
60
+ checks.push(check('telegram.allowlist', 'telegram', 'fail', 'allowed chat ids empty — inbound fail-closed', [
61
+ `รัน: ${BRAND.cliName} gateway setup telegram --allowed-chats <id>`,
62
+ ]));
63
+ }
64
+ else {
65
+ checks.push(check('telegram.allowlist', 'telegram', 'pass', `${resolved.allowedChatIds.length} allowed chat id(s)`, allowlistDetail(resolved.allowedChatIds, '(none)')));
66
+ }
67
+ if (!skipNetwork) {
68
+ const probe = await probeOk(fetchImpl, `https://api.telegram.org/bot${resolved.token}/getMe`, undefined, (r, body) => {
69
+ const parsed = body;
70
+ return r.ok && parsed.ok === true;
71
+ });
72
+ checks.push(check('telegram.token.live', 'telegram', probe.ok ? 'pass' : 'fail', probe.ok ? 'getMe OK' : `getMe failed${probe.detail ? `: ${probe.detail}` : ''}`));
73
+ }
74
+ return checks;
75
+ }
76
+ async function checkDiscord(config, env, fetchImpl, skipNetwork) {
77
+ const resolved = resolveDiscordConfig(config, env);
78
+ if (!resolved.token)
79
+ return [check('discord.configured', 'discord', 'skip', 'not configured')];
80
+ const checks = [check('discord.token', 'discord', 'pass', `bot token set (${resolved.source})`)];
81
+ if (!resolved.defaultChannelId && !resolved.allowedChannelIds.length) {
82
+ checks.push(check('discord.allowlist', 'discord', 'warn', 'no default channel or allowed channels — outbound may fail'));
83
+ }
84
+ else {
85
+ checks.push(check('discord.allowlist', 'discord', 'pass', 'delivery targets configured', [
86
+ resolved.defaultChannelId ? `default: ${resolved.defaultChannelId}` : '(no default)',
87
+ ...allowlistDetail(resolved.allowedChannelIds, '(no explicit allowlist)'),
88
+ ]));
89
+ }
90
+ if (!skipNetwork) {
91
+ const probe = await probeOk(fetchImpl, 'https://discord.com/api/v10/users/@me', { headers: { authorization: `Bot ${resolved.token}` } }, (r) => r.ok);
92
+ checks.push(check('discord.token.live', 'discord', probe.ok ? 'pass' : 'fail', probe.ok ? 'users/@me OK' : `token probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
93
+ }
94
+ return checks;
95
+ }
96
+ async function checkSlack(config, env, fetchImpl, skipNetwork) {
97
+ const resolved = resolveSlackConfig(config, env);
98
+ if (!resolved.botToken)
99
+ return [check('slack.configured', 'slack', 'skip', 'not configured')];
100
+ const checks = [check('slack.token', 'slack', 'pass', `bot token set (${resolved.source})`)];
101
+ if (!resolved.appToken) {
102
+ checks.push(check('slack.app_token', 'slack', 'warn', 'app token missing — Socket Mode inbound unavailable'));
103
+ }
104
+ else {
105
+ checks.push(check('slack.app_token', 'slack', 'pass', 'app token set'));
106
+ }
107
+ if (!resolved.defaultChannelId && !resolved.allowedChannelIds.length) {
108
+ checks.push(check('slack.allowlist', 'slack', 'warn', 'no default channel or allowed channels'));
109
+ }
110
+ else {
111
+ checks.push(check('slack.allowlist', 'slack', 'pass', 'delivery targets configured'));
112
+ }
113
+ if (!skipNetwork) {
114
+ const probe = await probeOk(fetchImpl, 'https://slack.com/api/auth.test', {
115
+ method: 'POST',
116
+ headers: { authorization: `Bearer ${resolved.botToken}`, 'content-type': 'application/x-www-form-urlencoded' },
117
+ }, (r, body) => {
118
+ const parsed = body;
119
+ return r.ok && parsed.ok === true;
120
+ });
121
+ checks.push(check('slack.token.live', 'slack', probe.ok ? 'pass' : 'fail', probe.ok ? 'auth.test OK' : `auth.test failed${probe.detail ? `: ${probe.detail}` : ''}`));
122
+ }
123
+ return checks;
124
+ }
125
+ async function checkMattermost(config, env, fetchImpl, skipNetwork) {
126
+ const resolved = resolveMattermostConfig(config, env);
127
+ if (!resolved.serverUrl && !resolved.token)
128
+ return [check('mattermost.configured', 'mattermost', 'skip', 'not configured')];
129
+ const checks = [];
130
+ if (!resolved.serverUrl)
131
+ checks.push(check('mattermost.url', 'mattermost', 'fail', 'server URL missing'));
132
+ else
133
+ checks.push(check('mattermost.url', 'mattermost', isHttpUrl(resolved.serverUrl) ? 'pass' : 'fail', resolved.serverUrl));
134
+ if (!resolved.token)
135
+ checks.push(check('mattermost.token', 'mattermost', 'fail', 'token missing'));
136
+ else
137
+ checks.push(check('mattermost.token', 'mattermost', 'pass', 'token set'));
138
+ const hasAllow = resolved.allowAllUsers ||
139
+ resolved.homeChannel ||
140
+ resolved.allowedChannels.length ||
141
+ resolved.allowedUsers.length;
142
+ checks.push(check('mattermost.allowlist', 'mattermost', hasAllow ? 'pass' : 'fail', hasAllow ? 'inbound/outbound allow rules configured' : 'no home channel, allowed users/channels, or allow-all'));
143
+ if (!skipNetwork && resolved.serverUrl && resolved.token) {
144
+ const probe = await probeOk(fetchImpl, `${resolved.serverUrl}/api/v4/users/me`, { headers: { authorization: `Bearer ${resolved.token}` } }, (r) => r.ok);
145
+ checks.push(check('mattermost.token.live', 'mattermost', probe.ok ? 'pass' : 'fail', probe.ok ? 'users/me OK' : `token probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
146
+ }
147
+ return checks;
148
+ }
149
+ function checkHomeAssistant(config, env) {
150
+ const resolved = resolveHomeAssistantConfig(config, env);
151
+ const configured = Boolean(resolved.token || resolved.url !== 'http://homeassistant.local:8123');
152
+ if (!configured)
153
+ return [check('homeassistant.configured', 'homeassistant', 'skip', 'not configured')];
154
+ const checks = [
155
+ check('homeassistant.url', 'homeassistant', isHttpUrl(resolved.url) ? 'pass' : 'fail', resolved.url),
156
+ ];
157
+ if (!resolved.token)
158
+ checks.push(check('homeassistant.token', 'homeassistant', 'fail', 'token missing'));
159
+ else
160
+ checks.push(check('homeassistant.token', 'homeassistant', 'pass', 'token set'));
161
+ if (!resolved.watchAll && !resolved.watchDomains.length && !resolved.watchEntities.length) {
162
+ checks.push(check('homeassistant.watch', 'homeassistant', 'warn', 'no watch domains/entities — events may be sparse'));
163
+ }
164
+ return checks;
165
+ }
166
+ function checkEmail(config, env) {
167
+ const resolved = resolveEmailConfig(config, env);
168
+ if (!resolved.address)
169
+ return [check('email.configured', 'email', 'skip', 'not configured')];
170
+ const checks = [check('email.address', 'email', 'pass', resolved.address)];
171
+ if (!resolved.password)
172
+ checks.push(check('email.password', 'email', 'fail', 'password missing'));
173
+ if (!resolved.imapHost || !resolved.smtpHost)
174
+ checks.push(check('email.hosts', 'email', 'fail', 'IMAP/SMTP hosts incomplete'));
175
+ if (!resolved.allowAllUsers && !resolved.allowedUsers.length) {
176
+ checks.push(check('email.allowlist', 'email', 'fail', 'allowed senders empty — inbound fail-closed'));
177
+ }
178
+ else {
179
+ checks.push(check('email.allowlist', 'email', 'pass', resolved.allowAllUsers ? 'allow all senders' : `${resolved.allowedUsers.length} allowed sender(s)`));
180
+ }
181
+ return checks;
182
+ }
183
+ async function checkLine(config, env, fetchImpl, skipNetwork) {
184
+ const resolved = resolveLineConfig(config, env);
185
+ if (!resolved.channelAccessToken)
186
+ return [check('line.configured', 'line', 'skip', 'not configured')];
187
+ const checks = [check('line.token', 'line', 'pass', `channel access token set (${resolved.source})`)];
188
+ if (!resolved.channelSecret)
189
+ checks.push(check('line.secret', 'line', 'warn', 'channel secret missing — webhook signature verification disabled'));
190
+ if (resolved.publicUrl) {
191
+ checks.push(check('line.public_url', 'line', isHttpUrl(resolved.publicUrl, { requireHttps: true }) ? 'pass' : 'fail', resolved.publicUrl));
192
+ }
193
+ else {
194
+ checks.push(check('line.public_url', 'line', 'warn', 'public URL not set — webhook inbound needs a reachable URL'));
195
+ }
196
+ const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length || resolved.allowedGroups.length || resolved.allowedRooms.length;
197
+ checks.push(check('line.allowlist', 'line', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed targets'));
198
+ if (!skipNetwork) {
199
+ const probe = await probeOk(fetchImpl, 'https://api.line.me/v2/bot/info', { headers: { authorization: `Bearer ${resolved.channelAccessToken}` } }, (r) => r.ok);
200
+ checks.push(check('line.token.live', 'line', probe.ok ? 'pass' : 'fail', probe.ok ? 'bot/info OK' : `token probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
201
+ }
202
+ return checks;
203
+ }
204
+ async function checkSms(config, env, fetchImpl, skipNetwork) {
205
+ const resolved = resolveSmsConfig(config, env);
206
+ if (!resolved.accountSid && !resolved.authToken && !resolved.phoneNumber)
207
+ return [check('sms.configured', 'sms', 'skip', 'not configured')];
208
+ const checks = [];
209
+ if (!resolved.accountSid || !resolved.authToken || !resolved.phoneNumber) {
210
+ checks.push(check('sms.credentials', 'sms', 'fail', 'Twilio accountSid/authToken/phoneNumber incomplete'));
211
+ }
212
+ else {
213
+ checks.push(check('sms.credentials', 'sms', 'pass', 'Twilio credentials set'));
214
+ }
215
+ if (resolved.webhookUrl) {
216
+ checks.push(check('sms.webhook_url', 'sms', isHttpUrl(resolved.webhookUrl, { requireHttps: true }) ? 'pass' : 'fail', resolved.webhookUrl));
217
+ }
218
+ else if (!resolved.insecureNoSignature) {
219
+ checks.push(check('sms.webhook_url', 'sms', 'warn', 'webhook URL not set — inbound SMS webhook unavailable'));
220
+ }
221
+ const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length;
222
+ checks.push(check('sms.allowlist', 'sms', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed users'));
223
+ if (!skipNetwork && resolved.accountSid && resolved.authToken) {
224
+ const auth = Buffer.from(`${resolved.accountSid}:${resolved.authToken}`, 'utf8').toString('base64');
225
+ const probe = await probeOk(fetchImpl, `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(resolved.accountSid)}.json`, { headers: { authorization: `Basic ${auth}` } }, (r) => r.ok);
226
+ checks.push(check('sms.token.live', 'sms', probe.ok ? 'pass' : 'fail', probe.ok ? 'Twilio account OK' : `credential probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
227
+ }
228
+ return checks;
229
+ }
230
+ function checkNtfy(config, env) {
231
+ const resolved = resolveNtfyConfig(config, env);
232
+ if (!resolved.topic && !resolved.token)
233
+ return [check('ntfy.configured', 'ntfy', 'skip', 'not configured')];
234
+ const checks = [
235
+ check('ntfy.server', 'ntfy', isHttpUrl(resolved.serverUrl) ? 'pass' : 'fail', resolved.serverUrl),
236
+ ];
237
+ if (!resolved.topic)
238
+ checks.push(check('ntfy.topic', 'ntfy', 'fail', 'topic missing'));
239
+ else
240
+ checks.push(check('ntfy.topic', 'ntfy', 'pass', resolved.topic));
241
+ const hasAllow = resolved.allowAllUsers || resolved.topic || resolved.homeChannel || resolved.allowedUsers.length;
242
+ checks.push(check('ntfy.allowlist', 'ntfy', hasAllow ? 'pass' : 'fail', hasAllow ? 'topics configured' : 'no allowed topics'));
243
+ return checks;
244
+ }
245
+ async function checkSignal(config, env, fetchImpl, skipNetwork) {
246
+ const resolved = resolveSignalConfig(config, env);
247
+ if (!resolved.account)
248
+ return [check('signal.configured', 'signal', 'skip', 'not configured')];
249
+ const checks = [
250
+ check('signal.http_url', 'signal', isHttpUrl(resolved.httpUrl) ? 'pass' : 'fail', resolved.httpUrl),
251
+ check('signal.account', 'signal', 'pass', 'account configured'),
252
+ ];
253
+ const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length || resolved.groupAllowedUsers.length;
254
+ checks.push(check('signal.allowlist', 'signal', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed users/groups'));
255
+ if (!skipNetwork) {
256
+ const probe = await probeOk(fetchImpl, `${resolved.httpUrl}/v1/about`, undefined, (r) => r.ok);
257
+ checks.push(check('signal.reachable', 'signal', probe.ok ? 'pass' : 'warn', probe.ok ? 'signal-cli HTTP reachable' : `signal-cli HTTP probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
258
+ }
259
+ return checks;
260
+ }
261
+ function checkWhatsApp(config, env) {
262
+ const resolved = resolveWhatsAppConfig(config, env);
263
+ if (!resolved.phoneNumberId && !resolved.accessToken)
264
+ return [check('whatsapp.configured', 'whatsapp', 'skip', 'not configured')];
265
+ const checks = [];
266
+ if (!resolved.phoneNumberId || !resolved.accessToken)
267
+ checks.push(check('whatsapp.credentials', 'whatsapp', 'fail', 'phoneNumberId/accessToken incomplete'));
268
+ else
269
+ checks.push(check('whatsapp.credentials', 'whatsapp', 'pass', 'Cloud API credentials set'));
270
+ if (!resolved.appSecret)
271
+ checks.push(check('whatsapp.app_secret', 'whatsapp', 'warn', 'app secret missing — webhook signature verification disabled'));
272
+ if (resolved.publicUrl) {
273
+ checks.push(check('whatsapp.public_url', 'whatsapp', isHttpUrl(resolved.publicUrl, { requireHttps: true }) ? 'pass' : 'fail', resolved.publicUrl));
274
+ }
275
+ else {
276
+ checks.push(check('whatsapp.public_url', 'whatsapp', 'warn', 'public URL not set — Meta webhook needs a reachable URL'));
277
+ }
278
+ const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length;
279
+ checks.push(check('whatsapp.allowlist', 'whatsapp', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed users'));
280
+ return checks;
281
+ }
282
+ async function checkMatrix(config, env, fetchImpl, skipNetwork) {
283
+ const resolved = resolveMatrixConfig(config, env);
284
+ if (!resolved.homeserver && !resolved.accessToken && !resolved.userId)
285
+ return [check('matrix.configured', 'matrix', 'skip', 'not configured')];
286
+ const checks = [];
287
+ if (!resolved.homeserver)
288
+ checks.push(check('matrix.homeserver', 'matrix', 'fail', 'homeserver missing'));
289
+ else
290
+ checks.push(check('matrix.homeserver', 'matrix', isHttpUrl(resolved.homeserver) ? 'pass' : 'fail', resolved.homeserver));
291
+ if (!resolved.accessToken && !(resolved.userId && resolved.password)) {
292
+ checks.push(check('matrix.auth', 'matrix', 'fail', 'access token or userId/password required'));
293
+ }
294
+ else {
295
+ checks.push(check('matrix.auth', 'matrix', 'pass', resolved.accessToken ? 'access token set' : 'password auth configured'));
296
+ }
297
+ const hasAllow = resolved.allowAllUsers || resolved.homeRoom || resolved.allowedRooms.length || resolved.allowedUsers.length;
298
+ checks.push(check('matrix.allowlist', 'matrix', hasAllow ? 'pass' : 'fail', hasAllow ? 'rooms configured' : 'no home/allowed rooms'));
299
+ if (!skipNetwork && resolved.homeserver && resolved.accessToken) {
300
+ const probe = await probeOk(fetchImpl, `${resolved.homeserver}/_matrix/client/v3/account/whoami`, { headers: { authorization: `Bearer ${resolved.accessToken}` } }, (r) => r.ok);
301
+ checks.push(check('matrix.token.live', 'matrix', probe.ok ? 'pass' : 'fail', probe.ok ? 'whoami OK' : `token probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
302
+ }
303
+ return checks;
304
+ }
305
+ function checkGoogleChat(config, env) {
306
+ const resolved = resolveGoogleChatConfig(config, env);
307
+ if (!resolved.serviceAccountJson && !resolved.incomingWebhookUrl)
308
+ return [check('googlechat.configured', 'googlechat', 'skip', 'not configured')];
309
+ const checks = [];
310
+ if (resolved.incomingWebhookUrl) {
311
+ checks.push(check('googlechat.webhook_url', 'googlechat', isHttpUrl(resolved.incomingWebhookUrl, { requireHttps: true }) ? 'pass' : 'fail', 'incoming webhook configured'));
312
+ }
313
+ if (resolved.serviceAccountJson)
314
+ checks.push(check('googlechat.service_account', 'googlechat', 'pass', 'service account configured'));
315
+ if (!resolved.incomingWebhookUrl && !resolved.serviceAccountJson) {
316
+ checks.push(check('googlechat.delivery', 'googlechat', 'fail', 'no webhook or Chat API credentials'));
317
+ }
318
+ const hasAllow = resolved.allowAllSpaces || resolved.homeChannel || resolved.allowedSpaces.length;
319
+ checks.push(check('googlechat.allowlist', 'googlechat', hasAllow ? 'pass' : 'fail', hasAllow ? 'spaces configured' : 'no home/allowed spaces'));
320
+ return checks;
321
+ }
322
+ function checkBlueBubbles(config, env) {
323
+ const resolved = resolveBlueBubblesConfig(config, env);
324
+ if (!resolved.serverUrl && !resolved.password)
325
+ return [check('bluebubbles.configured', 'bluebubbles', 'skip', 'not configured')];
326
+ const checks = [];
327
+ if (!resolved.serverUrl)
328
+ checks.push(check('bluebubbles.server', 'bluebubbles', 'fail', 'server URL missing'));
329
+ else
330
+ checks.push(check('bluebubbles.server', 'bluebubbles', isHttpUrl(resolved.serverUrl) ? 'pass' : 'fail', resolved.serverUrl));
331
+ if (!resolved.password)
332
+ checks.push(check('bluebubbles.password', 'bluebubbles', 'fail', 'password missing'));
333
+ else
334
+ checks.push(check('bluebubbles.password', 'bluebubbles', 'pass', 'password set'));
335
+ checks.push(check('bluebubbles.webhook', 'bluebubbles', 'pass', `local webhook ${resolved.webhookHost}:${resolved.webhookPort}${resolved.webhookPath}`));
336
+ const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length;
337
+ checks.push(check('bluebubbles.allowlist', 'bluebubbles', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed users'));
338
+ return checks;
339
+ }
340
+ function checkTeams(config, env) {
341
+ const resolved = resolveTeamsConfig(config, env);
342
+ if (!resolved.incomingWebhookUrl && !resolved.graphAccessToken && !resolved.clientId) {
343
+ return [check('teams.configured', 'teams', 'skip', 'not configured')];
344
+ }
345
+ const checks = [check('teams.mode', 'teams', 'pass', `delivery mode: ${resolved.deliveryMode}`)];
346
+ if (resolved.incomingWebhookUrl) {
347
+ checks.push(check('teams.webhook_url', 'teams', isHttpUrl(resolved.incomingWebhookUrl, { requireHttps: true }) ? 'pass' : 'fail', 'incoming webhook configured'));
348
+ }
349
+ if (resolved.deliveryMode === 'graph' && !resolved.graphAccessToken && !(resolved.clientId && resolved.clientSecret && resolved.tenantId)) {
350
+ checks.push(check('teams.graph', 'teams', 'fail', 'graph mode needs access token or client credentials'));
351
+ }
352
+ const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length || resolved.incomingWebhookUrl || resolved.chatId;
353
+ checks.push(check('teams.allowlist', 'teams', hasAllow ? 'pass' : 'warn', hasAllow ? 'delivery target configured' : 'no home/chat/webhook target'));
354
+ return checks;
355
+ }
356
+ function checkWebhooks(config, env) {
357
+ const resolved = resolveWebhookConfig(config, env);
358
+ if (!resolved.enabled && resolved.source === 'none')
359
+ return [check('webhooks.configured', 'webhooks', 'skip', 'not enabled')];
360
+ const checks = [check('webhooks.enabled', 'webhooks', resolved.enabled ? 'pass' : 'warn', resolved.enabled ? 'enabled' : 'disabled in config')];
361
+ if (resolved.publicUrl) {
362
+ checks.push(check('webhooks.public_url', 'webhooks', isHttpUrl(resolved.publicUrl, { requireHttps: true }) ? 'pass' : 'fail', resolved.publicUrl));
363
+ }
364
+ else {
365
+ checks.push(check('webhooks.public_url', 'webhooks', 'warn', 'public URL not set — external systems cannot reach routes'));
366
+ }
367
+ const routeNames = Object.keys(resolved.routes);
368
+ if (!routeNames.length)
369
+ checks.push(check('webhooks.routes', 'webhooks', 'warn', 'no routes configured'));
370
+ else {
371
+ const missingDeliver = routeNames.filter((name) => !resolved.routes[name]?.deliver);
372
+ checks.push(check('webhooks.routes', 'webhooks', missingDeliver.length ? 'warn' : 'pass', `${routeNames.length} route(s)`, missingDeliver.length ? missingDeliver.map((name) => `${name}: deliver target missing`) : undefined));
373
+ }
374
+ if (!resolved.secret)
375
+ checks.push(check('webhooks.secret', 'webhooks', 'warn', 'global webhook secret not set'));
376
+ return checks;
377
+ }
378
+ export async function checkGateway(options = {}) {
379
+ const config = options.config ?? (await readGatewayConfig());
380
+ const env = options.env ?? process.env;
381
+ const skipNetwork = options.skipNetwork === true;
382
+ const fetchImpl = options.fetchImpl ?? fetch;
383
+ const groups = await Promise.all([
384
+ checkTelegram(config, env, fetchImpl, skipNetwork),
385
+ checkDiscord(config, env, fetchImpl, skipNetwork),
386
+ checkSlack(config, env, fetchImpl, skipNetwork),
387
+ checkMattermost(config, env, fetchImpl, skipNetwork),
388
+ Promise.resolve(checkHomeAssistant(config, env)),
389
+ Promise.resolve(checkEmail(config, env)),
390
+ checkLine(config, env, fetchImpl, skipNetwork),
391
+ checkSms(config, env, fetchImpl, skipNetwork),
392
+ Promise.resolve(checkNtfy(config, env)),
393
+ checkSignal(config, env, fetchImpl, skipNetwork),
394
+ Promise.resolve(checkWhatsApp(config, env)),
395
+ checkMatrix(config, env, fetchImpl, skipNetwork),
396
+ Promise.resolve(checkGoogleChat(config, env)),
397
+ Promise.resolve(checkBlueBubbles(config, env)),
398
+ Promise.resolve(checkTeams(config, env)),
399
+ Promise.resolve(checkWebhooks(config, env)),
400
+ ]);
401
+ const checks = groups.flat();
402
+ const configured = checks.some((item) => item.status !== 'skip');
403
+ if (!configured) {
404
+ checks.unshift(check('gateway.configured', 'gateway', 'warn', 'no gateway channels configured'));
405
+ }
406
+ return { ok: !checks.some((item) => item.status === 'fail'), checks };
407
+ }
408
+ export function summarizeChannelHealth(checks) {
409
+ const byChannel = new Map();
410
+ const rank = { fail: 4, warn: 3, pass: 2, skip: 1 };
411
+ for (const item of checks) {
412
+ if (item.channel === 'gateway')
413
+ continue;
414
+ const current = byChannel.get(item.channel);
415
+ if (!current || rank[item.status] > rank[current])
416
+ byChannel.set(item.channel, item.status);
417
+ }
418
+ return [...byChannel.entries()]
419
+ .map(([channel, status]) => ({ channel, status }))
420
+ .sort((a, b) => a.channel.localeCompare(b.channel));
421
+ }
422
+ export async function listPendingCronJobs(now = Date.now()) {
423
+ return (await listTasks())
424
+ .filter((task) => task.kind === 'cron' && task.status === 'queued')
425
+ .sort((a, b) => a.runAt - b.runAt || a.createdAt - b.createdAt);
426
+ }
427
+ export async function listRecentDeliveryFailures(limit = 5) {
428
+ return (await listTasks())
429
+ .filter((task) => Boolean(task.deliver?.trim() && task.lastError?.trim()))
430
+ .sort((a, b) => (b.lastRun ?? b.createdAt) - (a.lastRun ?? a.createdAt))
431
+ .slice(0, limit)
432
+ .map((task) => ({
433
+ taskId: task.id,
434
+ deliver: task.deliver.trim(),
435
+ spec: task.spec,
436
+ error: task.lastError.trim(),
437
+ lastRun: task.lastRun,
438
+ status: task.status,
439
+ }));
440
+ }
441
+ export function formatGatewayDoctorStatus(status) {
442
+ return status.toUpperCase().padEnd(4);
443
+ }
444
+ export function formatGatewayDoctorReport(report) {
445
+ const lines = [`${BRAND.productName} gateway doctor`, ''];
446
+ for (const item of report.checks) {
447
+ lines.push(`[${formatGatewayDoctorStatus(item.status)}] ${item.channel}/${item.id} — ${item.message}`);
448
+ for (const detail of item.details ?? [])
449
+ lines.push(` - ${detail}`);
450
+ }
451
+ lines.push('', report.ok ? 'OK — no failing checks' : 'FAIL — fix failing checks above');
452
+ return lines.join('\n');
453
+ }
454
+ export function isInboundChatChannel(channel) {
455
+ return CHAT_INBOUND_CHANNELS.has(channel);
456
+ }