@rashidee/co2 1.2.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 (190) hide show
  1. package/dist/index.js +25449 -0
  2. package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
  3. package/drizzle/0001_wise_wolf_cub.sql +13 -0
  4. package/drizzle/0002_secret_spyke.sql +16 -0
  5. package/drizzle/0003_workspace_config_singleton.sql +16 -0
  6. package/drizzle/0004_terminal_claim_v120.sql +11 -0
  7. package/drizzle/0005_brainstorm_v124.sql +10 -0
  8. package/drizzle/0006_project_v140.sql +6 -0
  9. package/drizzle/0007_prd_wizard_v124.sql +14 -0
  10. package/drizzle/0008_workflow_artifact_v126.sql +24 -0
  11. package/drizzle/0009_project_import_v150.sql +7 -0
  12. package/drizzle/0010_quality_v151.sql +3 -0
  13. package/drizzle/meta/0000_snapshot.json +1335 -0
  14. package/drizzle/meta/0001_snapshot.json +1416 -0
  15. package/drizzle/meta/0002_snapshot.json +1510 -0
  16. package/drizzle/meta/_journal.json +83 -0
  17. package/package.json +41 -0
  18. package/plugin/.claude-plugin/marketplace.json +36 -0
  19. package/plugin/.claude-plugin/plugin.json +22 -0
  20. package/plugin/README.md +625 -0
  21. package/plugin/SKILLS.md +350 -0
  22. package/plugin/co2.svg +5 -0
  23. package/plugin/commands/brainstorm-loop.md +129 -0
  24. package/plugin/skills/conductor-defect/SKILL.md +898 -0
  25. package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
  26. package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
  27. package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
  28. package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
  29. package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
  30. package/plugin/skills/depgen-k8s/SKILL.md +468 -0
  31. package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
  32. package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
  33. package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
  34. package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
  35. package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
  36. package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
  37. package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
  38. package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
  39. package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
  40. package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
  41. package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
  42. package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
  43. package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
  44. package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
  45. package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
  46. package/plugin/skills/modelgen-relational/SKILL.md +707 -0
  47. package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
  48. package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
  49. package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
  50. package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
  51. package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
  52. package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
  53. package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
  54. package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
  55. package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
  56. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
  57. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
  58. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
  59. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
  60. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
  61. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
  62. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
  63. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
  64. package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
  65. package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
  66. package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
  67. package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
  68. package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
  69. package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
  70. package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
  71. package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
  72. package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
  73. package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
  74. package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
  75. package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
  76. package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
  77. package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
  78. package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
  79. package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
  80. package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
  81. package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
  82. package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
  83. package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
  84. package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
  85. package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
  86. package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
  87. package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
  88. package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
  89. package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
  90. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
  91. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
  92. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
  93. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
  94. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
  95. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
  96. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
  97. package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
  98. package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
  99. package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
  100. package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
  101. package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
  102. package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
  103. package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
  104. package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
  105. package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
  106. package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
  107. package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
  108. package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
  109. package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
  110. package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
  111. package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
  112. package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
  113. package/plugin/skills/testgen-functional/SKILL.md +1381 -0
  114. package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
  115. package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
  116. package/plugin/skills/util-projectinit/SKILL.md +307 -0
  117. package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
  118. package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
  119. package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
  120. package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
  121. package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
  122. package/plugin/skills/util-projectsync/SKILL.md +501 -0
  123. package/plugin/skills/util-updprd/SKILL.md +324 -0
  124. package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
  125. package/plugin/skills/util-ustagger/SKILL.md +154 -0
  126. package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
  127. package/static/assets/arc-DRKw7sjs.js +1 -0
  128. package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
  129. package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
  130. package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
  131. package/static/assets/channel-C2nVbuwv.js +1 -0
  132. package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
  133. package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
  134. package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
  135. package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
  136. package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
  137. package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
  138. package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
  139. package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
  140. package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
  141. package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
  142. package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
  143. package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
  144. package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
  145. package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
  146. package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
  147. package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
  148. package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
  149. package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  150. package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
  151. package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
  152. package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
  153. package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
  154. package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
  155. package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
  156. package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
  157. package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
  158. package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
  159. package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
  160. package/static/assets/graph-C9eacEi8.js +1 -0
  161. package/static/assets/index-LZSQkLE2.css +41 -0
  162. package/static/assets/index-y7XDSgMG.js +495 -0
  163. package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
  164. package/static/assets/init-Gi6I4Gst.js +1 -0
  165. package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
  166. package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
  167. package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
  168. package/static/assets/katex-C5jXJg4s.js +257 -0
  169. package/static/assets/layout-DEXfKzaS.js +1 -0
  170. package/static/assets/linear-CLO--ryk.js +1 -0
  171. package/static/assets/map-Czzmt4hB.js +1 -0
  172. package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
  173. package/static/assets/ordinal-Cboi1Yqb.js +1 -0
  174. package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
  175. package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
  176. package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
  177. package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
  178. package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
  179. package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
  180. package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
  181. package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
  182. package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
  183. package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
  184. package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
  185. package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
  186. package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
  187. package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
  188. package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
  189. package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
  190. package/static/index.html +24 -0
@@ -0,0 +1,1101 @@
1
+ # Async Patterns — Polling, Batch Processing, and Background Daemon
2
+
3
+ This reference covers three patterns for CLI commands that run for longer than a single
4
+ request-response cycle. Include the relevant sections in Section 17 of the generated
5
+ specification, based on which patterns are detected.
6
+
7
+ ---
8
+
9
+ ## Pattern Decision Guide
10
+
11
+ ```
12
+ ┌──────────────────────────────────────────────────────────────────────────┐
13
+ │ "How does this command work over time?" │
14
+ │ │
15
+ │ Polling — command waits for an external condition to change │
16
+ │ e.g. deploy --watch, pipeline --wait, build --follow │
17
+ │ Lifecycle: start → loop(check → sleep) → done / timeout │
18
+ │ │
19
+ │ Inline Batch — command processes many items in one run │
20
+ │ e.g. sync --all, import records.csv, migrate --run │
21
+ │ Lifecycle: load items → chunk → process → report │
22
+ │ │
23
+ │ Background Daemon — persistent process managed by the CLI │
24
+ │ e.g. daemon start/stop/status, worker start, watcher start │
25
+ │ Lifecycle: start (detach) → run loop → stop (signal) │
26
+ └──────────────────────────────────────────────────────────────────────────┘
27
+ ```
28
+
29
+ All three patterns share two requirements:
30
+ 1. **SIGINT handling** — Ctrl+C must produce a clean exit with a human-readable message,
31
+ not a Node.js stack trace
32
+ 2. **Progress visibility** — the user must see that something is happening during any
33
+ wait longer than ~300ms
34
+
35
+ ---
36
+
37
+ ## Shared Utility: SIGINT Handler
38
+
39
+ Every command that runs a loop or long operation must register a SIGINT handler
40
+ before entering the loop. This is the canonical pattern used by all three async patterns.
41
+
42
+ ```typescript
43
+ // src/utils/signal.ts
44
+
45
+ export type CleanupFn = () => void | Promise<void>
46
+
47
+ /**
48
+ * Register a cleanup function to run on SIGINT (Ctrl+C) or SIGTERM.
49
+ * Returns a disposal function that removes the listeners.
50
+ *
51
+ * Usage:
52
+ * const dispose = onSignal(async () => {
53
+ * spinner.stop()
54
+ * await db.close()
55
+ * })
56
+ * // ... do work ...
57
+ * dispose() // remove listeners when done normally
58
+ */
59
+ export function onSignal(cleanup: CleanupFn): () => void {
60
+ let handled = false
61
+
62
+ const handler = async (signal: NodeJS.Signals): Promise<void> => {
63
+ if (handled) return
64
+ handled = true
65
+
66
+ process.stderr.write('\n') // move past the ^C characters
67
+ try {
68
+ await cleanup()
69
+ } finally {
70
+ // Use the signal's natural exit code convention
71
+ process.exit(signal === 'SIGTERM' ? 0 : 130)
72
+ }
73
+ }
74
+
75
+ process.once('SIGINT', () => { void handler('SIGINT') })
76
+ process.once('SIGTERM', () => { void handler('SIGTERM') })
77
+
78
+ return () => {
79
+ process.off('SIGINT', handler as never)
80
+ process.off('SIGTERM', handler as never)
81
+ }
82
+ }
83
+ ```
84
+
85
+ **Exit code 130** is the POSIX convention for "terminated by Ctrl+C" (`128 + SIGINT(2)`).
86
+ Shells and CI systems interpret this correctly as user-initiated cancellation, not a
87
+ program error.
88
+
89
+ ---
90
+
91
+ ## Pattern 1: Polling
92
+
93
+ ### When to use
94
+
95
+ A command fires an operation (e.g. trigger a deployment, start a pipeline) and then
96
+ waits for the resulting status to reach a terminal state (`success`, `failed`, `cancelled`).
97
+ The user wants to see live status updates rather than polling manually with repeated invocations.
98
+
99
+ ### Trigger flags
100
+
101
+ ```
102
+ my-tool deploy --watch # deploy and poll until done
103
+ my-tool pipeline run --wait # start pipeline and follow it
104
+ my-tool build trigger --follow # trigger and tail status
105
+ ```
106
+
107
+ Non-watching behaviour (fire-and-forget) must still work when the flag is absent:
108
+ ```
109
+ my-tool deploy # triggers deploy, prints job ID, exits 0 immediately
110
+ ```
111
+
112
+ ### `src/utils/poll.ts`
113
+
114
+ ```typescript
115
+ import type { Ora } from 'ora'
116
+ import { createSpinner } from '../ui/spinner.js'
117
+ import { CliError, ExitCodes } from '../errors.js'
118
+ import { onSignal } from './signal.js'
119
+
120
+ export interface PollOptions<T> {
121
+ /** Human-readable label shown in the spinner while polling */
122
+ label: string
123
+ /** Called repeatedly. Return non-null when the terminal state is reached. */
124
+ check: () => Promise<T | null>
125
+ /** Milliseconds between checks. Default: 3000 */
126
+ intervalMs?: number
127
+ /** Maximum total wait time in milliseconds. Default: 300_000 (5 min) */
128
+ timeoutMs?: number
129
+ /** Optional: update spinner text on each tick */
130
+ onTick?: (spinner: Ora, elapsed: number) => void
131
+ }
132
+
133
+ export interface PollResult<T> {
134
+ value: T
135
+ elapsedMs: number
136
+ }
137
+
138
+ /**
139
+ * Poll a condition function at a fixed interval until it returns a non-null
140
+ * value or the timeout expires. Shows an ora spinner throughout.
141
+ *
142
+ * Registers a SIGINT handler so Ctrl+C exits cleanly instead of throwing.
143
+ */
144
+ export async function poll<T>(options: PollOptions<T>): Promise<PollResult<T>> {
145
+ const {
146
+ label,
147
+ check,
148
+ intervalMs = 3_000,
149
+ timeoutMs = 300_000,
150
+ onTick,
151
+ } = options
152
+
153
+ const spinner = createSpinner(label)
154
+ const startedAt = Date.now()
155
+
156
+ spinner.start()
157
+
158
+ const disposeSignal = onSignal(() => {
159
+ spinner.warn('Polling stopped — operation continues in the background')
160
+ })
161
+
162
+ try {
163
+ while (true) {
164
+ const elapsed = Date.now() - startedAt
165
+
166
+ if (elapsed >= timeoutMs) {
167
+ spinner.fail(`Timed out after ${Math.round(elapsed / 1000).toString()}s`)
168
+ throw new CliError(
169
+ `Polling timed out after ${Math.round(timeoutMs / 1000).toString()}s`,
170
+ 'POLL_TIMEOUT',
171
+ ExitCodes.GENERAL_ERROR,
172
+ )
173
+ }
174
+
175
+ const result = await check()
176
+
177
+ if (result !== null) {
178
+ spinner.succeed(`${label} — done in ${(elapsed / 1000).toFixed(1)}s`)
179
+ return { value: result, elapsedMs: elapsed }
180
+ }
181
+
182
+ if (onTick) {
183
+ onTick(spinner, elapsed)
184
+ } else {
185
+ spinner.text = `${label} (${Math.round(elapsed / 1000).toString()}s elapsed)`
186
+ }
187
+
188
+ await sleep(intervalMs)
189
+ }
190
+ } finally {
191
+ disposeSignal()
192
+ if (spinner.isSpinning) spinner.stop()
193
+ }
194
+ }
195
+
196
+ function sleep(ms: number): Promise<void> {
197
+ return new Promise(resolve => setTimeout(resolve, ms))
198
+ }
199
+ ```
200
+
201
+ ### Command integration
202
+
203
+ ```typescript
204
+ // src/commands/deploy/index.ts (watch variant)
205
+ import type { Command } from 'commander'
206
+ import { handleError } from '../../errors.js'
207
+ import { output } from '../../ui/output.js'
208
+ import { logger } from '../../ui/logger.js'
209
+ import { poll } from '../../utils/poll.js'
210
+ import { DeployService } from '../../services/deploy.service.js'
211
+ import type { DeployResult } from '../../services/deploy.service.js'
212
+
213
+ export function registerDeployCommand(program: Command): void {
214
+ program
215
+ .command('deploy')
216
+ .description('Trigger a deployment')
217
+ .argument('<environment>', 'target environment')
218
+ .option('-w, --watch', 'wait for deployment to complete', false)
219
+ .option('--interval <seconds>', 'polling interval in seconds', '3')
220
+ .option('--timeout <seconds>', 'maximum wait time in seconds', '300')
221
+ .action(handleError(async (env: string, opts: DeployOptions) => {
222
+ const service = new DeployService()
223
+
224
+ // Step 1 — always: trigger the deployment
225
+ const { deploymentId } = await service.trigger(env)
226
+ logger.info(`Deployment started: ${deploymentId}`)
227
+
228
+ if (!opts.watch) {
229
+ // Fire-and-forget: print the ID and exit
230
+ output.success({ deploymentId, environment: env }, `Deployment ${deploymentId} triggered`)
231
+ return
232
+ }
233
+
234
+ // Step 2 — only when --watch: poll until terminal status
235
+ const { value: finalStatus } = await poll<DeployResult>({
236
+ label: `Waiting for deployment ${deploymentId}`,
237
+ intervalMs: Number(opts.interval) * 1000,
238
+ timeoutMs: Number(opts.timeout) * 1000,
239
+ check: async () => {
240
+ const status = await service.getStatus(deploymentId)
241
+ // Return non-null only when a terminal state is reached
242
+ if (status.status === 'success' || status.status === 'failed') {
243
+ return status
244
+ }
245
+ return null // keep polling
246
+ },
247
+ onTick: (spinner, elapsed) => {
248
+ spinner.text = `Deploying to ${env} (${Math.round(elapsed / 1000).toString()}s)…`
249
+ },
250
+ })
251
+
252
+ if (finalStatus.status === 'failed') {
253
+ output.failure('DEPLOY_FAILED', `Deployment failed: ${finalStatus.error ?? 'unknown reason'}`)
254
+ process.exit(1)
255
+ }
256
+
257
+ output.success(finalStatus, `Deployed to ${env}: ${finalStatus.url}`)
258
+ }))
259
+ }
260
+
261
+ interface DeployOptions {
262
+ watch: boolean
263
+ interval: string
264
+ timeout: string
265
+ verbose: boolean
266
+ json: boolean
267
+ }
268
+ ```
269
+
270
+ ### Output contract
271
+
272
+ **Human output (--watch, success):**
273
+ ```
274
+ ℹ Deployment started: dep-abc123
275
+ ⠸ Deploying to production (12s)…
276
+ ✔ Waiting for deployment dep-abc123 — done in 14.2s
277
+ ✔ Deployed to production: https://app.example.com
278
+ ```
279
+
280
+ **--json output (--watch, success):**
281
+ ```json
282
+ {
283
+ "success": true,
284
+ "data": {
285
+ "deploymentId": "dep-abc123",
286
+ "environment": "production",
287
+ "status": "success",
288
+ "url": "https://app.example.com",
289
+ "durationMs": 14200
290
+ }
291
+ }
292
+ ```
293
+
294
+ **Ctrl+C during polling:**
295
+ ```
296
+ ⚠ Polling stopped — operation continues in the background
297
+ ```
298
+ Exit code: `130`
299
+
300
+ **Timeout:**
301
+ ```
302
+ ✖ Timed out after 300s
303
+ ✖ Polling timed out after 300s
304
+ ```
305
+ Exit code: `1`
306
+
307
+ ---
308
+
309
+ ## Pattern 2: Inline Batch Processing
310
+
311
+ ### When to use
312
+
313
+ A command must process a potentially large collection of items (records, files, API
314
+ resources) within a single invocation. Key requirements:
315
+
316
+ - Show live progress (items processed / total, percentage, ETA)
317
+ - Support configurable concurrency (`--concurrency N`)
318
+ - Support fail-fast vs skip-on-error mode (`--bail` vs `--continue-on-error`)
319
+ - Support resume from last checkpoint (when combined with Local Database)
320
+ - Exit code reflects partial failure (processed some, failed some)
321
+
322
+ ### `src/utils/batch.ts`
323
+
324
+ ```typescript
325
+ import { CliError, ExitCodes } from '../errors.js'
326
+ import { logger } from '../ui/logger.js'
327
+ import { onSignal } from './signal.js'
328
+
329
+ export interface BatchOptions<TInput, TOutput> {
330
+ /** Total item count (for progress display) */
331
+ total: number
332
+ /** Source of items to process (async generator or array) */
333
+ items: AsyncIterable<TInput> | TInput[]
334
+ /** Process a single item. Throw to signal failure. */
335
+ process: (item: TInput) => Promise<TOutput>
336
+ /** Number of items to process concurrently. Default: 1 */
337
+ concurrency?: number
338
+ /** If true, stop immediately on first error. Default: false */
339
+ bail?: boolean
340
+ /** Called after each item completes (for progress updates) */
341
+ onProgress?: (progress: BatchProgress) => void
342
+ }
343
+
344
+ export interface BatchProgress {
345
+ processed: number
346
+ succeeded: number
347
+ failed: number
348
+ total: number
349
+ percent: number
350
+ etaMs: number | null
351
+ startedAt: Date
352
+ }
353
+
354
+ export interface BatchSummary<TOutput> {
355
+ results: TOutput[]
356
+ errors: Array<{ item: unknown; error: Error }>
357
+ processed: number
358
+ succeeded: number
359
+ failed: number
360
+ durationMs: number
361
+ }
362
+
363
+ /**
364
+ * Process items in configurable concurrency batches with progress tracking.
365
+ * Registers SIGINT so Ctrl+C stops after the current chunk completes.
366
+ */
367
+ export async function runBatch<TInput, TOutput>(
368
+ options: BatchOptions<TInput, TOutput>,
369
+ ): Promise<BatchSummary<TOutput>> {
370
+ const { total, process: processFn, concurrency = 1, bail = false, onProgress } = options
371
+
372
+ const results: TOutput[] = []
373
+ const errors: BatchSummary<TOutput>['errors'] = []
374
+ const startedAt = new Date()
375
+ let processed = 0
376
+ let aborted = false
377
+
378
+ const disposeSignal = onSignal(() => {
379
+ aborted = true
380
+ logger.warn('Batch interrupted — stopping after current chunk')
381
+ })
382
+
383
+ const itemsArray: TInput[] = []
384
+ for await (const item of options.items) {
385
+ itemsArray.push(item)
386
+ if (aborted) break
387
+ }
388
+
389
+ // Process in chunks of `concurrency`
390
+ for (let i = 0; i < itemsArray.length; i += concurrency) {
391
+ if (aborted) break
392
+
393
+ const chunk = itemsArray.slice(i, i + concurrency)
394
+
395
+ const chunkResults = await Promise.allSettled(
396
+ chunk.map(item => processFn(item))
397
+ )
398
+
399
+ for (let j = 0; j < chunkResults.length; j++) {
400
+ processed++
401
+ const result = chunkResults[j]!
402
+
403
+ if (result.status === 'fulfilled') {
404
+ results.push(result.value)
405
+ } else {
406
+ const error = result.reason instanceof Error
407
+ ? result.reason
408
+ : new Error(String(result.reason))
409
+ errors.push({ item: chunk[j], error })
410
+ logger.debug(`Item failed: ${error.message}`)
411
+
412
+ if (bail) {
413
+ aborted = true
414
+ break
415
+ }
416
+ }
417
+
418
+ if (onProgress) {
419
+ const elapsed = Date.now() - startedAt.getTime()
420
+ const rate = processed / (elapsed / 1000)
421
+ const etaMs = rate > 0 ? ((total - processed) / rate) * 1000 : null
422
+
423
+ onProgress({
424
+ processed,
425
+ succeeded: results.length,
426
+ failed: errors.length,
427
+ total,
428
+ percent: Math.round((processed / total) * 100),
429
+ etaMs,
430
+ startedAt,
431
+ })
432
+ }
433
+ }
434
+
435
+ if (aborted && bail) break
436
+ }
437
+
438
+ disposeSignal()
439
+
440
+ return {
441
+ results,
442
+ errors,
443
+ processed,
444
+ succeeded: results.length,
445
+ failed: errors.length,
446
+ durationMs: Date.now() - startedAt.getTime(),
447
+ }
448
+ }
449
+ ```
450
+
451
+ ### Progress bar helper
452
+
453
+ ```typescript
454
+ // src/ui/progress.ts
455
+ import { createSpinner } from './spinner.js'
456
+ import type { BatchProgress } from '../utils/batch.js'
457
+ import type { Ora } from 'ora'
458
+
459
+ export class ProgressBar {
460
+ private spinner: Ora
461
+
462
+ constructor(label: string) {
463
+ this.spinner = createSpinner(label)
464
+ this.spinner.start()
465
+ }
466
+
467
+ update(progress: BatchProgress): void {
468
+ const { processed, total, percent, failed, etaMs } = progress
469
+ const etaText = etaMs !== null ? ` — ETA ${formatDuration(etaMs)}` : ''
470
+ const failText = failed > 0 ? ` (${failed.toString()} failed)` : ''
471
+ this.spinner.text =
472
+ `[${percent.toString().padStart(3)}%] ${processed.toString()}/${total.toString()}${failText}${etaText}`
473
+ }
474
+
475
+ succeed(message: string): void { this.spinner.succeed(message) }
476
+ fail(message: string): void { this.spinner.fail(message) }
477
+ stop(): void { this.spinner.stop() }
478
+ }
479
+
480
+ function formatDuration(ms: number): string {
481
+ if (ms < 60_000) return `${Math.round(ms / 1000).toString()}s`
482
+ return `${Math.round(ms / 60_000).toString()}m`
483
+ }
484
+ ```
485
+
486
+ ### Command integration
487
+
488
+ ```typescript
489
+ // src/commands/sync/index.ts
490
+ import type { Command } from 'commander'
491
+ import { handleError } from '../../errors.js'
492
+ import { output } from '../../ui/output.js'
493
+ import { logger } from '../../ui/logger.js'
494
+ import { ProgressBar } from '../../ui/progress.js'
495
+ import { runBatch } from '../../utils/batch.js'
496
+ import { SyncService } from '../../services/sync.service.js'
497
+
498
+ export function registerSyncCommand(program: Command): void {
499
+ program
500
+ .command('sync')
501
+ .description('Synchronise all remote resources to local cache')
502
+ .option('-c, --concurrency <n>', 'parallel workers', '5')
503
+ .option('--bail', 'stop on first error', false)
504
+ .option('--dry-run', 'preview without writing', false)
505
+ .action(handleError(async (opts: SyncOptions) => {
506
+ const service = new SyncService()
507
+
508
+ // Fetch the item list (IDs only — full fetch happens per-item in process())
509
+ const items = await service.listRemoteIds()
510
+ const total = items.length
511
+ const bar = new ProgressBar(`Syncing ${total.toString()} resources`)
512
+
513
+ const summary = await runBatch({
514
+ total,
515
+ items,
516
+ concurrency: Number(opts.concurrency),
517
+ bail: opts.bail,
518
+ process: async (id) => {
519
+ return service.syncOne(id, { dryRun: opts.dryRun })
520
+ },
521
+ onProgress: (p) => bar.update(p),
522
+ })
523
+
524
+ if (summary.failed === 0) {
525
+ bar.succeed(`Synced ${summary.succeeded.toString()} resources in ${(summary.durationMs / 1000).toFixed(1)}s`)
526
+ } else {
527
+ bar.fail(`Completed with ${summary.failed.toString()} error(s)`)
528
+ }
529
+
530
+ output.success(
531
+ {
532
+ succeeded: summary.succeeded,
533
+ failed: summary.failed,
534
+ durationMs: summary.durationMs,
535
+ errors: summary.errors.map(e => ({ error: e.error.message })),
536
+ },
537
+ `Sync complete: ${summary.succeeded.toString()} succeeded, ${summary.failed.toString()} failed`,
538
+ )
539
+
540
+ // Non-zero exit if any items failed
541
+ if (summary.failed > 0 && !opts.bail) {
542
+ process.exit(1)
543
+ }
544
+ }))
545
+ }
546
+
547
+ interface SyncOptions {
548
+ concurrency: string
549
+ bail: boolean
550
+ dryRun: boolean
551
+ verbose: boolean
552
+ json: boolean
553
+ }
554
+ ```
555
+
556
+ ### Resume support (with Local Database)
557
+
558
+ When `Local Database = yes`, the batch command should support resuming a previous
559
+ incomplete run. The checkpoint pattern stores processed item IDs in SQLite:
560
+
561
+ ```typescript
562
+ // src/services/sync.service.ts — resume-aware variant
563
+
564
+ export class SyncService {
565
+ constructor(
566
+ private readonly http = getHttpClient(),
567
+ private readonly syncRepo = new SyncRepository(getDb()),
568
+ ) {}
569
+
570
+ async listRemoteIds(): Promise<string[]> {
571
+ const all = await this.http.get('resources').json<string[]>()
572
+ const processed = this.syncRepo.listProcessedIds()
573
+ const processedSet = new Set(processed)
574
+
575
+ // Filter out already-processed items to support resume
576
+ return all.filter(id => !processedSet.has(id))
577
+ }
578
+
579
+ async syncOne(id: string, opts: { dryRun: boolean }): Promise<SyncRecord> {
580
+ const resource = await this.http.get(`resources/${id}`).json<Resource>()
581
+
582
+ if (!opts.dryRun) {
583
+ this.syncRepo.markProcessed(id, resource)
584
+ }
585
+
586
+ return { id, synced: !opts.dryRun, name: resource.name }
587
+ }
588
+ }
589
+ ```
590
+
591
+ **Output contract (human):**
592
+ ```
593
+ ℹ Fetching resource list… 432 items
594
+ [ 0%] 0/432
595
+ [ 23%] 99/432 — ETA 18s
596
+ [100%] 432/432
597
+ ✔ Synced 430 resources in 24.1s
598
+ ✖ Completed with 2 error(s)
599
+ ```
600
+
601
+ **--json output:**
602
+ ```json
603
+ {
604
+ "success": true,
605
+ "data": {
606
+ "succeeded": 430,
607
+ "failed": 2,
608
+ "durationMs": 24100,
609
+ "errors": [
610
+ { "error": "Resource res-007 not found" },
611
+ { "error": "Timeout on res-099" }
612
+ ]
613
+ }
614
+ }
615
+ ```
616
+
617
+ **Exit codes:**
618
+ | Scenario | Exit Code |
619
+ |---------------------------|-----------|
620
+ | All items succeeded | `0` |
621
+ | Some items failed (no --bail) | `1` |
622
+ | --bail triggered | `1` |
623
+ | Ctrl+C | `130` |
624
+
625
+ ---
626
+
627
+ ## Pattern 3: Background Daemon
628
+
629
+ ### When to use
630
+
631
+ The CLI manages a persistent background process (watcher, sync agent, local proxy, job
632
+ queue worker). The daemon runs indefinitely and the CLI provides `start`/`stop`/`status`
633
+ commands to manage it.
634
+
635
+ **Architecture:**
636
+ ```
637
+ my-tool daemon start → spawns detached child (same binary, special entrypoint flag)
638
+ writes PID to conf store
639
+ returns immediately (exit 0)
640
+
641
+ my-tool daemon stop → reads PID from conf, sends SIGTERM, waits for exit
642
+ removes PID from conf
643
+
644
+ my-tool daemon status → reads PID, checks if process is alive
645
+ shows uptime if running
646
+ ```
647
+
648
+ ### Daemon entry point detection
649
+
650
+ The daemon process is the **same binary** launched with a special hidden flag. This avoids
651
+ shipping a separate binary and keeps the package surface small:
652
+
653
+ ```typescript
654
+ // src/cli.ts — detect daemon mode before registering user-facing commands
655
+
656
+ if (process.env['MY_TOOL_DAEMON_MODE'] === '1') {
657
+ // Import and run the daemon loop directly — bypass Commander entirely
658
+ const { runDaemon } = await import('./daemon/runner.js')
659
+ await runDaemon()
660
+ process.exit(0)
661
+ }
662
+
663
+ // Normal mode: register user-facing commands
664
+ registerDaemonCommand(program)
665
+ // ...
666
+ ```
667
+
668
+ ### `src/commands/daemon/index.ts`
669
+
670
+ ```typescript
671
+ import type { Command } from 'commander'
672
+ import { handleError } from '../../errors.js'
673
+ import { CliError } from '../../errors.js'
674
+ import { output } from '../../ui/output.js'
675
+ import { logger } from '../../ui/logger.js'
676
+ import { DaemonManager } from '../../services/daemon.service.js'
677
+
678
+ export function registerDaemonCommand(program: Command): void {
679
+ const daemonCmd = program
680
+ .command('daemon')
681
+ .description('Manage the background daemon process')
682
+
683
+ daemonCmd
684
+ .command('start')
685
+ .description('Start the background daemon')
686
+ .option('--foreground', 'run in foreground (do not detach)', false)
687
+ .action(handleError(async (opts: { foreground: boolean }) => {
688
+ const manager = new DaemonManager()
689
+
690
+ if (await manager.isRunning()) {
691
+ logger.warn('Daemon is already running')
692
+ output.success(await manager.getStatus(), 'Daemon already running')
693
+ return
694
+ }
695
+
696
+ if (opts.foreground) {
697
+ logger.info('Starting daemon in foreground (Ctrl+C to stop)')
698
+ const { runDaemon } = await import('../../daemon/runner.js')
699
+ await runDaemon()
700
+ return
701
+ }
702
+
703
+ const pid = await manager.start()
704
+ output.success({ pid, status: 'started' }, `Daemon started (PID ${pid.toString()})`)
705
+ }))
706
+
707
+ daemonCmd
708
+ .command('stop')
709
+ .description('Stop the background daemon')
710
+ .option('--timeout <seconds>', 'wait timeout before force-kill', '10')
711
+ .action(handleError(async (opts: { timeout: string }) => {
712
+ const manager = new DaemonManager()
713
+
714
+ if (!await manager.isRunning()) {
715
+ logger.warn('Daemon is not running')
716
+ return
717
+ }
718
+
719
+ await manager.stop(Number(opts.timeout) * 1000)
720
+ output.success({ status: 'stopped' }, 'Daemon stopped')
721
+ }))
722
+
723
+ daemonCmd
724
+ .command('status')
725
+ .description('Show daemon status')
726
+ .action(handleError(async () => {
727
+ const manager = new DaemonManager()
728
+ const status = await manager.getStatus()
729
+ output.success(status, status.running
730
+ ? `Daemon running (PID ${status.pid?.toString() ?? '?'}, uptime ${formatUptime(status.uptimeMs ?? 0)})`
731
+ : 'Daemon is not running',
732
+ )
733
+ }))
734
+
735
+ daemonCmd
736
+ .command('logs')
737
+ .description('Show recent daemon logs')
738
+ .option('-n, --lines <n>', 'number of lines to show', '50')
739
+ .action(handleError(async (opts: { lines: string }) => {
740
+ const manager = new DaemonManager()
741
+ const lines = await manager.getRecentLogs(Number(opts.lines))
742
+ for (const line of lines) {
743
+ output.raw(line)
744
+ }
745
+ }))
746
+ }
747
+
748
+ function formatUptime(ms: number): string {
749
+ const s = Math.floor(ms / 1000)
750
+ if (s < 60) return `${s.toString()}s`
751
+ if (s < 3600) return `${Math.floor(s / 60).toString()}m`
752
+ return `${Math.floor(s / 3600).toString()}h ${Math.floor((s % 3600) / 60).toString()}m`
753
+ }
754
+ ```
755
+
756
+ ### `src/services/daemon.service.ts`
757
+
758
+ ```typescript
759
+ import { spawn } from 'node:child_process'
760
+ import { fileURLToPath } from 'node:url'
761
+ import { dirname, join } from 'node:path'
762
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
763
+ import { getUserConfig } from '../config/user.config.js'
764
+ import { CliError } from '../errors.js'
765
+
766
+ const __filename = fileURLToPath(import.meta.url)
767
+ const __dirname = dirname(__filename)
768
+
769
+ export interface DaemonStatus {
770
+ running: boolean
771
+ pid?: number
772
+ startedAt?: Date
773
+ uptimeMs?: number
774
+ }
775
+
776
+ export class DaemonManager {
777
+ private readonly config = getUserConfig()
778
+
779
+ async isRunning(): Promise<boolean> {
780
+ const pid = this.config.get('daemonPid') as number | undefined
781
+ if (!pid) return false
782
+ try {
783
+ // Signal 0 tests if the process exists without sending a real signal
784
+ process.kill(pid, 0)
785
+ return true
786
+ } catch {
787
+ // ESRCH = no such process; EPERM = process exists but we can't signal it
788
+ this.clearPid()
789
+ return false
790
+ }
791
+ }
792
+
793
+ async start(): Promise<number> {
794
+ // Spawn the same binary with the daemon mode env var set
795
+ const binaryPath = join(__dirname, '../../dist/cli.js')
796
+
797
+ const child = spawn(process.execPath, [binaryPath], {
798
+ detached: true,
799
+ stdio: 'ignore', // detach stdio completely
800
+ env: {
801
+ ...process.env,
802
+ MY_TOOL_DAEMON_MODE: '1',
803
+ },
804
+ })
805
+
806
+ child.unref() // allow parent to exit without waiting for child
807
+
808
+ const pid = child.pid
809
+ if (!pid) throw new CliError('Failed to start daemon', 'DAEMON_START_FAILED')
810
+
811
+ this.config.set('daemonPid', pid as never)
812
+ this.config.set('daemonStartedAt', new Date().toISOString() as never)
813
+
814
+ return pid
815
+ }
816
+
817
+ async stop(timeoutMs = 10_000): Promise<void> {
818
+ const pid = this.config.get('daemonPid') as number | undefined
819
+ if (!pid) return
820
+
821
+ process.kill(pid, 'SIGTERM')
822
+
823
+ // Wait for the process to exit
824
+ const deadline = Date.now() + timeoutMs
825
+ while (Date.now() < deadline) {
826
+ await sleep(200)
827
+ if (!await this.isRunning()) {
828
+ this.clearPid()
829
+ return
830
+ }
831
+ }
832
+
833
+ // Force-kill if SIGTERM didn't work in time
834
+ try {
835
+ process.kill(pid, 'SIGKILL')
836
+ } catch {
837
+ // Process already gone
838
+ }
839
+ this.clearPid()
840
+ }
841
+
842
+ async getStatus(): Promise<DaemonStatus> {
843
+ const pid = this.config.get('daemonPid') as number | undefined
844
+ const startedAt = this.config.get('daemonStartedAt') as string | undefined
845
+ const running = await this.isRunning()
846
+
847
+ if (!running || !pid) return { running: false }
848
+
849
+ const startDate = startedAt ? new Date(startedAt) : undefined
850
+ return {
851
+ running: true,
852
+ pid,
853
+ startedAt: startDate,
854
+ uptimeMs: startDate ? Date.now() - startDate.getTime() : undefined,
855
+ }
856
+ }
857
+
858
+ async getRecentLogs(lines: number): Promise<string[]> {
859
+ // Daemon writes to a log file in the OS data directory
860
+ // (see env-paths in database-patterns.md for path resolution)
861
+ const logPath = join(getDaemonLogPath(), 'daemon.log')
862
+ if (!existsSync(logPath)) return []
863
+
864
+ const content = readFileSync(logPath, 'utf-8')
865
+ return content.split('\n').filter(Boolean).slice(-lines)
866
+ }
867
+
868
+ private clearPid(): void {
869
+ this.config.delete('daemonPid' as never)
870
+ this.config.delete('daemonStartedAt' as never)
871
+ }
872
+ }
873
+
874
+ function sleep(ms: number): Promise<void> {
875
+ return new Promise(resolve => setTimeout(resolve, ms))
876
+ }
877
+
878
+ function getDaemonLogPath(): string {
879
+ // Import and use the same env-paths helper from database-patterns.md
880
+ // Placed here inline for clarity — reference src/db/path.ts in real impl
881
+ import envPaths from 'env-paths'
882
+ const paths = envPaths('{{BINARY_NAME}}', { suffix: '' })
883
+ return paths.log
884
+ }
885
+ ```
886
+
887
+ ### `src/daemon/runner.ts` — the daemon event loop
888
+
889
+ ```typescript
890
+ import { onSignal } from '../utils/signal.js'
891
+ import { logger } from '../ui/logger.js'
892
+
893
+ /**
894
+ * The daemon's main loop. Called when the process is launched with
895
+ * MY_TOOL_DAEMON_MODE=1. Runs until SIGTERM is received.
896
+ */
897
+ export async function runDaemon(): Promise<void> {
898
+ logger.info('Daemon starting…')
899
+
900
+ let running = true
901
+
902
+ const disposeSignal = onSignal(() => {
903
+ logger.info('Daemon received stop signal, shutting down…')
904
+ running = false
905
+ })
906
+
907
+ // Replace with the actual daemon work derived from NFRs
908
+ while (running) {
909
+ try {
910
+ await tick()
911
+ } catch (err: unknown) {
912
+ logger.error(`Daemon tick error: ${err instanceof Error ? err.message : String(err)}`)
913
+ }
914
+
915
+ await sleep(TICK_INTERVAL_MS)
916
+ }
917
+
918
+ disposeSignal()
919
+ logger.info('Daemon stopped')
920
+ }
921
+
922
+ /** One unit of daemon work — implement per NFRs */
923
+ async function tick(): Promise<void> {
924
+ // e.g. poll an API, process a queue, sync data, run a scheduled task
925
+ logger.debug('Daemon tick')
926
+ }
927
+
928
+ const TICK_INTERVAL_MS = 30_000
929
+
930
+ function sleep(ms: number): Promise<void> {
931
+ return new Promise(resolve => setTimeout(resolve, ms))
932
+ }
933
+ ```
934
+
935
+ ### User Config keys required for daemon
936
+
937
+ When `Background Daemon = yes`, add these keys to the `conf` schema in
938
+ `src/config/user.config.ts`:
939
+
940
+ ```typescript
941
+ // Additional keys in UserConfigSchema
942
+ daemonPid: z.number().int().positive().optional(),
943
+ daemonStartedAt: z.string().optional(), // ISO 8601 string
944
+ ```
945
+
946
+ ---
947
+
948
+ ## Testing Async Patterns
949
+
950
+ ### Testing polling
951
+
952
+ Use `vi.useFakeTimers()` to control time without actual waits:
953
+
954
+ ```typescript
955
+ // test/unit/utils/poll.test.ts
956
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
957
+ import { poll } from '../../../src/utils/poll.js'
958
+
959
+ describe('poll', () => {
960
+ beforeEach(() => { vi.useFakeTimers() })
961
+ afterEach(() => { vi.useRealTimers() })
962
+
963
+ it('resolves when check returns non-null', async () => {
964
+ let calls = 0
965
+ const pollPromise = poll({
966
+ label: 'Waiting',
967
+ intervalMs: 1_000,
968
+ check: async () => {
969
+ calls++
970
+ return calls >= 3 ? { status: 'success' } : null
971
+ },
972
+ })
973
+
974
+ // Advance time through 3 intervals
975
+ await vi.advanceTimersByTimeAsync(3_000)
976
+
977
+ const result = await pollPromise
978
+ expect(result.value).toEqual({ status: 'success' })
979
+ expect(calls).toBe(3)
980
+ })
981
+
982
+ it('throws CliError on timeout', async () => {
983
+ const pollPromise = poll({
984
+ label: 'Waiting',
985
+ timeoutMs: 5_000,
986
+ check: async () => null,
987
+ })
988
+
989
+ await vi.advanceTimersByTimeAsync(6_000)
990
+
991
+ await expect(pollPromise).rejects.toThrow('Polling timed out')
992
+ })
993
+ })
994
+ ```
995
+
996
+ ### Testing batch processing
997
+
998
+ ```typescript
999
+ // test/unit/utils/batch.test.ts
1000
+ import { describe, it, expect, vi } from 'vitest'
1001
+ import { runBatch } from '../../../src/utils/batch.js'
1002
+
1003
+ describe('runBatch', () => {
1004
+ it('processes all items and returns results', async () => {
1005
+ const items = [1, 2, 3, 4, 5]
1006
+ const summary = await runBatch({
1007
+ total: items.length,
1008
+ items,
1009
+ process: async (n) => n * 2,
1010
+ })
1011
+
1012
+ expect(summary.succeeded).toBe(5)
1013
+ expect(summary.failed).toBe(0)
1014
+ expect(summary.results).toEqual([2, 4, 6, 8, 10])
1015
+ })
1016
+
1017
+ it('collects errors without stopping when bail = false', async () => {
1018
+ const items = [1, 2, 3]
1019
+ const summary = await runBatch({
1020
+ total: items.length,
1021
+ items,
1022
+ bail: false,
1023
+ process: async (n) => {
1024
+ if (n === 2) throw new Error('item 2 failed')
1025
+ return n
1026
+ },
1027
+ })
1028
+
1029
+ expect(summary.succeeded).toBe(2)
1030
+ expect(summary.failed).toBe(1)
1031
+ expect(summary.errors[0]?.error.message).toBe('item 2 failed')
1032
+ })
1033
+
1034
+ it('stops after first error when bail = true', async () => {
1035
+ const processed: number[] = []
1036
+ await runBatch({
1037
+ total: 5,
1038
+ items: [1, 2, 3, 4, 5],
1039
+ bail: true,
1040
+ process: async (n) => {
1041
+ processed.push(n)
1042
+ if (n === 2) throw new Error('bail!')
1043
+ return n
1044
+ },
1045
+ })
1046
+
1047
+ // Should stop at or near item 2 (exact cutoff depends on concurrency chunk)
1048
+ expect(processed.length).toBeLessThan(5)
1049
+ })
1050
+
1051
+ it('reports progress via onProgress callback', async () => {
1052
+ const progressCalls: number[] = []
1053
+ await runBatch({
1054
+ total: 3,
1055
+ items: [1, 2, 3],
1056
+ process: async (n) => n,
1057
+ onProgress: (p) => progressCalls.push(p.processed),
1058
+ })
1059
+
1060
+ expect(progressCalls).toEqual([1, 2, 3])
1061
+ })
1062
+ })
1063
+ ```
1064
+
1065
+ ### Testing daemon commands (DaemonManager)
1066
+
1067
+ Mock `process.kill` and `getUserConfig` to avoid spawning real processes:
1068
+
1069
+ ```typescript
1070
+ // test/unit/services/daemon.service.test.ts
1071
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
1072
+ import { DaemonManager } from '../../../src/services/daemon.service.js'
1073
+
1074
+ vi.mock('../../../src/config/user.config.js', () => {
1075
+ const store = new Map<string, unknown>()
1076
+ return {
1077
+ getUserConfig: () => ({
1078
+ get: (k: string) => store.get(k),
1079
+ set: (k: string, v: unknown) => store.set(k, v),
1080
+ delete: (k: string) => store.delete(k),
1081
+ }),
1082
+ }
1083
+ })
1084
+
1085
+ describe('DaemonManager.isRunning', () => {
1086
+ it('returns false when no PID is stored', async () => {
1087
+ const manager = new DaemonManager()
1088
+ expect(await manager.isRunning()).toBe(false)
1089
+ })
1090
+
1091
+ it('returns true when stored PID is a live process', async () => {
1092
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true)
1093
+ const config = (await import('../../../src/config/user.config.js')).getUserConfig()
1094
+ config.set('daemonPid', process.pid) // use our own PID — definitely alive
1095
+
1096
+ const manager = new DaemonManager()
1097
+ expect(await manager.isRunning()).toBe(true)
1098
+ killSpy.mockRestore()
1099
+ })
1100
+ })
1101
+ ```