discoclaw 0.1.0

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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,68 @@
1
+ import { TaskDiscordChannelType, } from './discord-types.js';
2
+ import { buildAppliedTagsWithStatus, buildTaskStarterContent, buildThreadName, taskIdToken, } from './thread-helpers.js';
3
+ // ---------------------------------------------------------------------------
4
+ // Forum channel resolution
5
+ // ---------------------------------------------------------------------------
6
+ /** Resolve a forum channel by name or ID in a specific guild (multi-guild safe). */
7
+ export async function resolveTasksForum(guild, nameOrId) {
8
+ // Fast path: cached by ID.
9
+ const byId = guild.channels.cache.get(nameOrId);
10
+ if (byId && byId.type === TaskDiscordChannelType.GuildForum)
11
+ return byId;
12
+ // If it's an ID, try fetching directly (covers cache misses).
13
+ try {
14
+ const fetched = await guild.channels.fetch(nameOrId);
15
+ if (fetched && fetched.type === TaskDiscordChannelType.GuildForum)
16
+ return fetched;
17
+ }
18
+ catch {
19
+ // Not an ID or fetch failed; fall through to name lookup.
20
+ }
21
+ const want = nameOrId.toLowerCase();
22
+ const ch = guild.channels.cache.find((c) => c.type === TaskDiscordChannelType.GuildForum && c.name.toLowerCase() === want);
23
+ return ch ?? null;
24
+ }
25
+ /** Create a new forum thread for a task. Returns the thread ID. */
26
+ export async function createTaskThread(forum, task, tagMap, mentionUserId) {
27
+ const name = buildThreadName(task.id, task.title, task.status);
28
+ // Resolve forum tag IDs from task labels.
29
+ const appliedTagIds = [];
30
+ for (const label of task.labels ?? []) {
31
+ // Try the label directly, then strip common prefixes (tag:, label:).
32
+ const cleaned = label.replace(/^(tag|label):/, '');
33
+ const tagId = tagMap[cleaned] ?? tagMap[label];
34
+ if (tagId)
35
+ appliedTagIds.push(tagId);
36
+ }
37
+ const uniqueTagIds = buildAppliedTagsWithStatus([...new Set(appliedTagIds)], task.status, tagMap);
38
+ const message = buildTaskStarterContent(task, mentionUserId).slice(0, 2000);
39
+ const thread = await forum.threads.create({
40
+ name,
41
+ message: {
42
+ content: message,
43
+ // Prevent accidental @everyone/@here from task descriptions.
44
+ allowedMentions: { parse: [], users: mentionUserId ? [mentionUserId] : [] },
45
+ },
46
+ appliedTags: uniqueTagIds,
47
+ });
48
+ return thread.id;
49
+ }
50
+ export async function findExistingThreadForTask(forum, taskId, opts) {
51
+ const token = taskIdToken(taskId);
52
+ const archivedLimit = Math.max(1, Math.min(100, opts?.archivedLimit ?? 100));
53
+ const active = await forum.threads.fetchActive();
54
+ const archived = await forum.threads.fetchArchived({ limit: archivedLimit, fetchAll: true });
55
+ const all = [...active.threads.values(), ...archived.threads.values()];
56
+ const matches = all.filter((t) => typeof t?.name === 'string' && t.name.includes(token));
57
+ if (matches.length === 0)
58
+ return null;
59
+ // Prefer active (non-archived) threads; among ties, pick the newest (highest snowflake ID).
60
+ const sorted = [...matches].sort((a, b) => {
61
+ const aActive = a.archived ? 0 : 1;
62
+ const bActive = b.archived ? 0 : 1;
63
+ if (aActive !== bActive)
64
+ return bActive - aActive;
65
+ return BigInt(b.id) > BigInt(a.id) ? 1 : -1;
66
+ });
67
+ return sorted[0].id;
68
+ }
@@ -0,0 +1,86 @@
1
+ import { STATUS_EMOJI, TASK_STATUSES } from './types.js';
2
+ const THREAD_NAME_MAX = 100;
3
+ /** Strip the project prefix from a task ID: `ws-001` → `001`. */
4
+ export function shortTaskId(id) {
5
+ const idx = id.indexOf('-');
6
+ return idx >= 0 ? id.slice(idx + 1) : id;
7
+ }
8
+ /** Build a thread name: `{emoji} [{shortId}] {title}`, capped at 100 chars. */
9
+ export function buildThreadName(taskId, title, status) {
10
+ const emoji = STATUS_EMOJI[status] ?? STATUS_EMOJI.open;
11
+ const prefix = `${emoji} [${shortTaskId(taskId)}] `;
12
+ const maxTitle = THREAD_NAME_MAX - prefix.length;
13
+ const trimmedTitle = title.length > maxTitle ? title.slice(0, maxTitle - 1) + '\u2026' : title;
14
+ return `${prefix}${trimmedTitle}`;
15
+ }
16
+ export function taskIdToken(taskId) {
17
+ return `[${shortTaskId(taskId)}]`;
18
+ }
19
+ const emojiPrefix = Object.values(STATUS_EMOJI).map(e => e.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')).join('|');
20
+ const TASK_THREAD_PATTERN = new RegExp(`^(?:${emojiPrefix})\\s*\\[(\\d+)\\]`);
21
+ /**
22
+ * Extract the short numeric ID from a thread name that starts with a
23
+ * recognised task status emoji followed by `[NNN]`.
24
+ * Returns the numeric string, or null if the name doesn't match.
25
+ */
26
+ export function extractShortIdFromThreadName(name) {
27
+ const m = TASK_THREAD_PATTERN.exec(name);
28
+ return m ? m[1] : null;
29
+ }
30
+ /**
31
+ * Extract the Discord thread ID from a task's external_ref field.
32
+ * Supports formats:
33
+ * - `discord:<threadId>`
34
+ * - raw numeric ID
35
+ */
36
+ export function getThreadIdFromTask(task) {
37
+ const ref = (task.external_ref ?? '').trim();
38
+ if (!ref)
39
+ return null;
40
+ if (ref.startsWith('discord:'))
41
+ return ref.slice('discord:'.length).trim() || null;
42
+ if (/^\d+$/.test(ref))
43
+ return ref;
44
+ return null;
45
+ }
46
+ /** Returns the set of Discord tag IDs that correspond to task statuses. */
47
+ export function getStatusTagIds(tagMap) {
48
+ const ids = new Set();
49
+ for (const status of TASK_STATUSES) {
50
+ const id = tagMap[status];
51
+ if (id)
52
+ ids.add(id);
53
+ }
54
+ return ids;
55
+ }
56
+ /**
57
+ * Strip old status tags, add the new one, preserve content tags.
58
+ * Status tag gets priority: up to 4 content tags + 1 status tag.
59
+ * If no status tag ID exists in tagMap, content tags get all 5 slots.
60
+ */
61
+ export function buildAppliedTagsWithStatus(currentTagIds, status, tagMap) {
62
+ const statusIds = getStatusTagIds(tagMap);
63
+ const uniqueContent = [...new Set(currentTagIds.filter(id => !statusIds.has(id)))];
64
+ const newStatusId = tagMap[status];
65
+ if (newStatusId) {
66
+ return [...uniqueContent.slice(0, 4), newStatusId];
67
+ }
68
+ return uniqueContent.slice(0, 5);
69
+ }
70
+ /** Build the starter message content for a task thread. */
71
+ export function buildTaskStarterContent(task, mentionUserId) {
72
+ const lines = [];
73
+ if (task.description)
74
+ lines.push(task.description);
75
+ lines.push('');
76
+ lines.push(`**ID:** \`${task.id}\``);
77
+ lines.push(`**Priority:** P${task.priority ?? 2}`);
78
+ lines.push(`**Status:** ${task.status}`);
79
+ if (task.owner)
80
+ lines.push(`**Owner:** ${task.owner}`);
81
+ if (mentionUserId) {
82
+ lines.push('');
83
+ lines.push(`<@${mentionUserId}>`);
84
+ }
85
+ return lines.join('\n');
86
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildAppliedTagsWithStatus, buildTaskStarterContent, buildThreadName, extractShortIdFromThreadName, getStatusTagIds, getThreadIdFromTask, shortTaskId, taskIdToken, } from './thread-helpers.js';
3
+ describe('thread-helpers', () => {
4
+ it('builds thread names and short-id tokens', () => {
5
+ expect(buildThreadName('ws-001', 'Fix login bug', 'open')).toContain('[001]');
6
+ expect(shortTaskId('ws-010')).toBe('010');
7
+ expect(taskIdToken('ws-010')).toBe('[010]');
8
+ });
9
+ it('extracts short ID from canonical thread names', () => {
10
+ expect(extractShortIdFromThreadName('\u{1F7E1} [042] Add feature')).toBe('042');
11
+ expect(extractShortIdFromThreadName('No match')).toBeNull();
12
+ });
13
+ it('extracts thread ID from external_ref', () => {
14
+ const task = { external_ref: 'discord:123456789' };
15
+ expect(getThreadIdFromTask(task)).toBe('123456789');
16
+ expect(getThreadIdFromTask({ external_ref: 'gh-123' })).toBeNull();
17
+ });
18
+ it('computes status tag IDs and applies status priority', () => {
19
+ const tagMap = { open: 's1', closed: 's2', feature: 'c1' };
20
+ expect(getStatusTagIds(tagMap)).toEqual(new Set(['s1', 's2']));
21
+ const updated = buildAppliedTagsWithStatus(['c1', 's1'], 'closed', tagMap);
22
+ expect(updated).toContain('s2');
23
+ expect(updated).not.toContain('s1');
24
+ });
25
+ it('builds starter content with optional mention', () => {
26
+ const task = { id: 'ws-001', status: 'open', priority: 2, description: 'A test task', owner: '' };
27
+ const plain = buildTaskStarterContent(task);
28
+ const mentioned = buildTaskStarterContent(task, '999888777');
29
+ expect(plain).toContain('**ID:** `ws-001`');
30
+ expect(plain).not.toContain('<@999888777>');
31
+ expect(mentioned).toContain('<@999888777>');
32
+ });
33
+ });
@@ -0,0 +1,144 @@
1
+ import { buildAppliedTagsWithStatus, buildTaskStarterContent, buildThreadName, getStatusTagIds, } from './thread-helpers.js';
2
+ import { fetchThreadChannel, tagsEqual } from './thread-ops-shared.js';
3
+ /** Post a close summary, rename with checkmark, and archive the thread. */
4
+ export async function closeTaskThread(client, threadId, task, tagMap, log) {
5
+ const thread = await fetchThreadChannel(client, threadId);
6
+ if (!thread)
7
+ return;
8
+ // Ensure the thread is modifiable even if it was archived previously.
9
+ try {
10
+ if (thread.archived)
11
+ await thread.setArchived(false);
12
+ }
13
+ catch {
14
+ // Ignore unarchive failures.
15
+ }
16
+ const closedName = buildThreadName(task.id, task.title, task.status);
17
+ // Remove mention from starter message to clear sidebar visibility.
18
+ try {
19
+ const starter = await thread.fetchStarterMessage();
20
+ if (starter && starter.author.id === client.user?.id) {
21
+ const cleanContent = buildTaskStarterContent(task);
22
+ if (starter.content !== cleanContent) {
23
+ await starter.edit({
24
+ content: cleanContent.slice(0, 2000),
25
+ allowedMentions: { parse: [], users: [] },
26
+ });
27
+ }
28
+ }
29
+ }
30
+ catch { /* ignore — close should still proceed */ }
31
+ const reason = task.close_reason || 'Closed';
32
+ try {
33
+ await thread.send({
34
+ content: `**Task Closed**\n${reason}`,
35
+ allowedMentions: { parse: [], users: [] },
36
+ });
37
+ }
38
+ catch {
39
+ // Ignore send failures (thread may already be archived).
40
+ }
41
+ const editPayload = { name: closedName };
42
+ if (tagMap) {
43
+ const current = thread.appliedTags ?? [];
44
+ const updated = buildAppliedTagsWithStatus(current, task.status, tagMap);
45
+ if (!tagsEqual(current, updated)) {
46
+ editPayload.appliedTags = updated;
47
+ }
48
+ }
49
+ try {
50
+ await thread.edit(editPayload);
51
+ }
52
+ catch (err) {
53
+ log?.warn({ err, taskId: task.id, threadId }, 'closeTaskThread: edit failed');
54
+ }
55
+ try {
56
+ await thread.setArchived(true);
57
+ }
58
+ catch {
59
+ // Ignore archive failures.
60
+ }
61
+ }
62
+ /** Check if a thread is archived. Returns true if the thread is archived or doesn't exist. */
63
+ export async function isThreadArchived(client, threadId) {
64
+ const thread = await fetchThreadChannel(client, threadId);
65
+ if (!thread)
66
+ return true; // Thread doesn't exist — treat as archived.
67
+ return thread.archived === true;
68
+ }
69
+ /** Check if a task thread is already in its final closed state (archived + correct name + correct tags). */
70
+ export async function isTaskThreadAlreadyClosed(client, threadId, task, tagMap) {
71
+ const thread = await fetchThreadChannel(client, threadId);
72
+ if (!thread)
73
+ return true; // Thread doesn't exist — nothing to close.
74
+ const closedName = buildThreadName(task.id, task.title, task.status);
75
+ if (thread.archived !== true || thread.name !== closedName)
76
+ return false;
77
+ // If tagMap provided, verify tags match expected closed state.
78
+ if (tagMap && getStatusTagIds(tagMap).size > 0) {
79
+ const current = thread.appliedTags ?? [];
80
+ const expected = buildAppliedTagsWithStatus(current, task.status, tagMap);
81
+ if (!tagsEqual(current, expected))
82
+ return false;
83
+ }
84
+ return true;
85
+ }
86
+ /** Update a thread's name to reflect current task state. */
87
+ export async function updateTaskThreadName(client, threadId, task) {
88
+ const thread = await fetchThreadChannel(client, threadId);
89
+ if (!thread)
90
+ return false;
91
+ const newName = buildThreadName(task.id, task.title, task.status);
92
+ const current = thread.name;
93
+ if (current === newName)
94
+ return false;
95
+ await thread.setName(newName);
96
+ return true;
97
+ }
98
+ /** Update a thread's starter message to reflect current task state. When mentionUserId is provided, the mention is included for sidebar visibility. */
99
+ export async function updateTaskStarterMessage(client, threadId, task, mentionUserId) {
100
+ const thread = await fetchThreadChannel(client, threadId);
101
+ if (!thread)
102
+ return false;
103
+ let starter;
104
+ try {
105
+ starter = await thread.fetchStarterMessage();
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ if (!starter)
111
+ return false;
112
+ // Only edit messages authored by the bot.
113
+ if (starter.author.id !== client.user?.id)
114
+ return false;
115
+ const newContent = buildTaskStarterContent(task, mentionUserId);
116
+ if (starter.content === newContent)
117
+ return false;
118
+ await starter.edit({
119
+ content: newContent.slice(0, 2000),
120
+ allowedMentions: { parse: [], users: mentionUserId ? [mentionUserId] : [] },
121
+ });
122
+ return true;
123
+ }
124
+ /** Update a thread's forum tags to reflect current task status. */
125
+ export async function updateTaskThreadTags(client, threadId, task, tagMap) {
126
+ const thread = await fetchThreadChannel(client, threadId);
127
+ if (!thread)
128
+ return false;
129
+ const current = thread.appliedTags ?? [];
130
+ const updated = buildAppliedTagsWithStatus(current, task.status, tagMap);
131
+ if (tagsEqual(current, updated))
132
+ return false;
133
+ await thread.edit({ appliedTags: updated });
134
+ return true;
135
+ }
136
+ /** Unarchive a thread if it's currently archived. */
137
+ export async function ensureUnarchived(client, threadId) {
138
+ const thread = await fetchThreadChannel(client, threadId);
139
+ if (!thread)
140
+ return;
141
+ if (thread.archived) {
142
+ await thread.setArchived(false);
143
+ }
144
+ }
@@ -0,0 +1,21 @@
1
+ export async function fetchThreadChannel(client, threadId) {
2
+ const cached = client.channels.cache.get(threadId);
3
+ if (cached && cached.isThread())
4
+ return cached;
5
+ try {
6
+ const fetched = await client.channels.fetch(threadId);
7
+ if (fetched && fetched.isThread())
8
+ return fetched;
9
+ return null;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ /** Order-insensitive comparison of two tag ID arrays. */
16
+ export function tagsEqual(a, b) {
17
+ if (a.length !== b.length)
18
+ return false;
19
+ const sorted = (arr) => [...arr].sort();
20
+ return sorted(a).every((v, i) => v === sorted(b)[i]);
21
+ }
@@ -0,0 +1,2 @@
1
+ export * from './thread-forum-ops.js';
2
+ export * from './thread-lifecycle-ops.js';
@@ -0,0 +1,20 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Task store types — in-process task data model.
3
+ // Replaces the external `bd` CLI dependency for the read/write path.
4
+ // ---------------------------------------------------------------------------
5
+ export const TASK_STATUSES = [
6
+ 'open',
7
+ 'in_progress',
8
+ 'blocked',
9
+ 'closed',
10
+ ];
11
+ export function isTaskStatus(s) {
12
+ return TASK_STATUSES.includes(s);
13
+ }
14
+ /** Status → emoji prefix for thread names. Widened to Record<string, string> for consumers that index with a plain string. */
15
+ export const STATUS_EMOJI = {
16
+ open: '\u{1F7E2}', // 🟢
17
+ in_progress: '\u{1F7E1}', // 🟡
18
+ blocked: '\u26A0\uFE0F', // ⚠️
19
+ closed: '\u2611\uFE0F', // ☑️
20
+ };
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { TASK_STATUSES, isTaskStatus, STATUS_EMOJI, } from './types.js';
3
+ describe('Task types', () => {
4
+ it('isTaskStatus accepts valid statuses', () => {
5
+ expect(isTaskStatus('open')).toBe(true);
6
+ expect(isTaskStatus('in_progress')).toBe(true);
7
+ expect(isTaskStatus('blocked')).toBe(true);
8
+ expect(isTaskStatus('closed')).toBe(true);
9
+ });
10
+ it('isTaskStatus rejects invalid statuses', () => {
11
+ expect(isTaskStatus('unknown')).toBe(false);
12
+ expect(isTaskStatus('')).toBe(false);
13
+ });
14
+ it('STATUS_EMOJI is indexable with a plain string', () => {
15
+ const status = 'open';
16
+ // This must compile and return a value (widened type check)
17
+ expect(STATUS_EMOJI[status]).toBe('\u{1F7E2}');
18
+ });
19
+ it('STATUS_EMOJI covers all TASK_STATUSES', () => {
20
+ for (const s of TASK_STATUSES) {
21
+ expect(STATUS_EMOJI[s]).toBeDefined();
22
+ }
23
+ });
24
+ // Type-level assertions for canonical task types.
25
+ // These are compile-time checks; if they compile the test passes at runtime.
26
+ it('Task types are assignable in expected shapes', () => {
27
+ const taskData = {
28
+ id: '1',
29
+ title: 'test',
30
+ status: 'open',
31
+ };
32
+ const taskData2 = taskData;
33
+ expect(taskData2).toBe(taskData);
34
+ const taskStatus = 'open';
35
+ expect(taskStatus).toBe('open');
36
+ const syncResult = {
37
+ threadsCreated: 0,
38
+ emojisUpdated: 0,
39
+ starterMessagesUpdated: 0,
40
+ threadsArchived: 0,
41
+ statusesUpdated: 0,
42
+ tagsUpdated: 0,
43
+ warnings: 0,
44
+ };
45
+ const syncResult2 = syncResult;
46
+ expect(syncResult2).toBe(syncResult);
47
+ const createParams = { title: 'x' };
48
+ const createParams2 = createParams;
49
+ expect(createParams2).toBe(createParams);
50
+ const updateParams = { title: 'y' };
51
+ const updateParams2 = updateParams;
52
+ expect(updateParams2).toBe(updateParams);
53
+ const closeParams = { reason: 'done' };
54
+ const closeParams2 = closeParams;
55
+ expect(closeParams2).toBe(closeParams);
56
+ const listParams = { status: 'open' };
57
+ const listParams2 = listParams;
58
+ expect(listParams2).toBe(listParams);
59
+ });
60
+ });
@@ -0,0 +1,11 @@
1
+ import { afterEach, vi } from 'vitest';
2
+ // After each test: clear accumulated mock call/result history to prevent
3
+ // monotonic memory growth across tests within a worker.
4
+ afterEach(() => {
5
+ vi.clearAllMocks();
6
+ vi.unstubAllEnvs();
7
+ vi.unstubAllGlobals();
8
+ // Safety net: restore real timers if a test used vi.useFakeTimers() but
9
+ // failed or threw before its own cleanup ran.
10
+ vi.useRealTimers();
11
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ // Verify that the global afterEach hooks registered in test-setup.ts
3
+ // actually clear mock state and env stubs between tests.
4
+ // Shared spy — persists across it() blocks within this describe so the second
5
+ // test can confirm the first test's call history was cleared by afterEach.
6
+ const sharedSpy = vi.fn();
7
+ describe('test-setup global afterEach hooks', () => {
8
+ describe('vi.clearAllMocks', () => {
9
+ it('populates shared spy call history in this test', () => {
10
+ sharedSpy('arg1');
11
+ sharedSpy('arg2');
12
+ expect(sharedSpy.mock.calls).toHaveLength(2);
13
+ // afterEach will call vi.clearAllMocks() — verified by the next test.
14
+ });
15
+ it('shared spy call history is empty after the previous test ran afterEach', () => {
16
+ // sharedSpy was called twice above; if clearAllMocks ran between tests
17
+ // its call history must now be empty.
18
+ expect(sharedSpy.mock.calls).toHaveLength(0);
19
+ });
20
+ });
21
+ describe('vi.unstubAllEnvs', () => {
22
+ it('stubs an env var in this test', () => {
23
+ vi.stubEnv('TEST_SETUP_SENTINEL', 'stubbed');
24
+ expect(process.env['TEST_SETUP_SENTINEL']).toBe('stubbed');
25
+ // afterEach will call vi.unstubAllEnvs().
26
+ });
27
+ it('env stub is restored after the previous test ran afterEach', () => {
28
+ expect(process.env['TEST_SETUP_SENTINEL']).toBeUndefined();
29
+ });
30
+ });
31
+ describe('vi.useRealTimers', () => {
32
+ it('installs fake timers in this test', () => {
33
+ vi.useFakeTimers();
34
+ // Confirm fake timers are active: setTimeout should be the vitest stub.
35
+ expect(vi.isFakeTimers()).toBe(true);
36
+ // afterEach will call vi.useRealTimers() as a safety net.
37
+ });
38
+ it('real timers are restored after the previous test ran afterEach', () => {
39
+ expect(vi.isFakeTimers()).toBe(false);
40
+ });
41
+ });
42
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared validators for Discord tokens and snowflake IDs.
3
+ * Used by scripts/doctor.ts and scripts/setup.ts.
4
+ */
5
+ /** Token: 3 dot-separated base64url segments. */
6
+ export function validateDiscordToken(token) {
7
+ if (!token)
8
+ return { valid: false, reason: 'Token is empty' };
9
+ const parts = token.split('.');
10
+ if (parts.length !== 3) {
11
+ return { valid: false, reason: `Expected 3 dot-separated segments, got ${parts.length}` };
12
+ }
13
+ // Each segment must be non-empty base64url (A-Z, a-z, 0-9, -, _)
14
+ const base64url = /^[A-Za-z0-9\-_]+$/;
15
+ for (let i = 0; i < parts.length; i++) {
16
+ if (!parts[i] || !base64url.test(parts[i])) {
17
+ return { valid: false, reason: `Segment ${i + 1} contains invalid characters` };
18
+ }
19
+ }
20
+ return { valid: true };
21
+ }
22
+ /**
23
+ * Snowflake: 17-20 digit numeric string.
24
+ *
25
+ * This is intentionally stricter than the runtime `isSnowflake()` in
26
+ * system-bootstrap.ts (which accepts 8+ digits for historical reasons).
27
+ * Real Discord snowflakes are always 17-20 digits. This stricter check
28
+ * is used in user-facing tools (doctor, setup wizard) to catch mistyped
29
+ * IDs early. Runtime code keeps the looser pattern for compatibility.
30
+ */
31
+ export function validateSnowflake(id) {
32
+ return /^\d{17,20}$/.test(id);
33
+ }
34
+ /** Comma/space-separated snowflake list. Returns invalid IDs if any. */
35
+ export function validateSnowflakes(raw) {
36
+ const ids = raw.split(/[,\s]+/).filter(Boolean);
37
+ if (ids.length === 0)
38
+ return { valid: false, invalidIds: [] };
39
+ const invalidIds = ids.filter((id) => !validateSnowflake(id));
40
+ return { valid: invalidIds.length === 0, invalidIds };
41
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateDiscordToken, validateSnowflake, validateSnowflakes } from './validate.js';
3
+ describe('validateDiscordToken', () => {
4
+ it('accepts a valid 3-segment base64url token', () => {
5
+ expect(validateDiscordToken('MTIzNDU2Nzg5.abc123.def456-_')).toEqual({ valid: true });
6
+ });
7
+ it('rejects empty string', () => {
8
+ const r = validateDiscordToken('');
9
+ expect(r.valid).toBe(false);
10
+ expect(r.reason).toMatch(/empty/i);
11
+ });
12
+ it('rejects token with wrong segment count (2 segments)', () => {
13
+ const r = validateDiscordToken('abc.def');
14
+ expect(r.valid).toBe(false);
15
+ expect(r.reason).toMatch(/3 dot-separated/);
16
+ });
17
+ it('rejects token with wrong segment count (4 segments)', () => {
18
+ const r = validateDiscordToken('a.b.c.d');
19
+ expect(r.valid).toBe(false);
20
+ expect(r.reason).toMatch(/3 dot-separated/);
21
+ });
22
+ it('rejects token with no dots', () => {
23
+ const r = validateDiscordToken('nodots');
24
+ expect(r.valid).toBe(false);
25
+ expect(r.reason).toMatch(/1$/);
26
+ });
27
+ it('rejects token with invalid base64 chars', () => {
28
+ const r = validateDiscordToken('abc.d e f.ghi');
29
+ expect(r.valid).toBe(false);
30
+ expect(r.reason).toMatch(/invalid characters/);
31
+ });
32
+ it('rejects token with empty segment', () => {
33
+ const r = validateDiscordToken('abc..ghi');
34
+ expect(r.valid).toBe(false);
35
+ expect(r.reason).toMatch(/invalid characters/);
36
+ });
37
+ it('accepts token with hyphens and underscores (base64url)', () => {
38
+ expect(validateDiscordToken('a-b.c_d.e-f')).toEqual({ valid: true });
39
+ });
40
+ });
41
+ describe('validateSnowflake', () => {
42
+ it('accepts 17-digit snowflake', () => {
43
+ expect(validateSnowflake('12345678901234567')).toBe(true);
44
+ });
45
+ it('accepts 18-digit snowflake', () => {
46
+ expect(validateSnowflake('123456789012345678')).toBe(true);
47
+ });
48
+ it('accepts 19-digit snowflake', () => {
49
+ expect(validateSnowflake('1234567890123456789')).toBe(true);
50
+ });
51
+ it('accepts 20-digit snowflake', () => {
52
+ expect(validateSnowflake('12345678901234567890')).toBe(true);
53
+ });
54
+ it('rejects 16-digit string (too short)', () => {
55
+ expect(validateSnowflake('1234567890123456')).toBe(false);
56
+ });
57
+ it('rejects 21-digit string (too long)', () => {
58
+ expect(validateSnowflake('123456789012345678901')).toBe(false);
59
+ });
60
+ it('rejects string with letters', () => {
61
+ expect(validateSnowflake('1234567890123456a')).toBe(false);
62
+ });
63
+ it('rejects empty string', () => {
64
+ expect(validateSnowflake('')).toBe(false);
65
+ });
66
+ it('rejects string with spaces', () => {
67
+ expect(validateSnowflake('1234567890 1234567')).toBe(false);
68
+ });
69
+ });
70
+ describe('validateSnowflakes', () => {
71
+ it('validates a single valid snowflake', () => {
72
+ expect(validateSnowflakes('12345678901234567')).toEqual({ valid: true, invalidIds: [] });
73
+ });
74
+ it('validates comma-separated snowflakes', () => {
75
+ expect(validateSnowflakes('12345678901234567,98765432109876543')).toEqual({ valid: true, invalidIds: [] });
76
+ });
77
+ it('validates space-separated snowflakes', () => {
78
+ expect(validateSnowflakes('12345678901234567 98765432109876543')).toEqual({ valid: true, invalidIds: [] });
79
+ });
80
+ it('validates mixed comma and space separation', () => {
81
+ expect(validateSnowflakes('12345678901234567, 98765432109876543')).toEqual({ valid: true, invalidIds: [] });
82
+ });
83
+ it('returns invalid IDs in a mixed list', () => {
84
+ const r = validateSnowflakes('12345678901234567,abc,98765432109876543,short');
85
+ expect(r.valid).toBe(false);
86
+ expect(r.invalidIds).toEqual(['abc', 'short']);
87
+ });
88
+ it('returns invalid when empty string is given', () => {
89
+ expect(validateSnowflakes('')).toEqual({ valid: false, invalidIds: [] });
90
+ });
91
+ it('returns invalid when only whitespace/commas', () => {
92
+ expect(validateSnowflakes(', , ')).toEqual({ valid: false, invalidIds: [] });
93
+ });
94
+ });
@@ -0,0 +1,15 @@
1
+ import { execa } from 'execa';
2
+ /**
3
+ * Returns the short git commit hash of the current HEAD, or null if git is
4
+ * unavailable or the working directory is not a git repository.
5
+ */
6
+ export async function getGitHash() {
7
+ try {
8
+ const result = await execa('git', ['rev-parse', '--short', 'HEAD']);
9
+ const hash = result.stdout.trim();
10
+ return hash || null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }