@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.
- package/dist/index.js +25449 -0
- package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
- package/drizzle/0001_wise_wolf_cub.sql +13 -0
- package/drizzle/0002_secret_spyke.sql +16 -0
- package/drizzle/0003_workspace_config_singleton.sql +16 -0
- package/drizzle/0004_terminal_claim_v120.sql +11 -0
- package/drizzle/0005_brainstorm_v124.sql +10 -0
- package/drizzle/0006_project_v140.sql +6 -0
- package/drizzle/0007_prd_wizard_v124.sql +14 -0
- package/drizzle/0008_workflow_artifact_v126.sql +24 -0
- package/drizzle/0009_project_import_v150.sql +7 -0
- package/drizzle/0010_quality_v151.sql +3 -0
- package/drizzle/meta/0000_snapshot.json +1335 -0
- package/drizzle/meta/0001_snapshot.json +1416 -0
- package/drizzle/meta/0002_snapshot.json +1510 -0
- package/drizzle/meta/_journal.json +83 -0
- package/package.json +41 -0
- package/plugin/.claude-plugin/marketplace.json +36 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/README.md +625 -0
- package/plugin/SKILLS.md +350 -0
- package/plugin/co2.svg +5 -0
- package/plugin/commands/brainstorm-loop.md +129 -0
- package/plugin/skills/conductor-defect/SKILL.md +898 -0
- package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
- package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
- package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
- package/plugin/skills/depgen-k8s/SKILL.md +468 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
- package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
- package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
- package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
- package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
- package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
- package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
- package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
- package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
- package/plugin/skills/modelgen-relational/SKILL.md +707 -0
- package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
- package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
- package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
- package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
- package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
- package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
- package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
- package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
- package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
- package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
- package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
- package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
- package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
- package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
- package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
- package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
- package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
- package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
- package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
- package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
- package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
- package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
- package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
- package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
- package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
- package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
- package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
- package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
- package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
- package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
- package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
- package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
- package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
- package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
- package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
- package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
- package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
- package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
- package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
- package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
- package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
- package/plugin/skills/testgen-functional/SKILL.md +1381 -0
- package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
- package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
- package/plugin/skills/util-projectinit/SKILL.md +307 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
- package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
- package/plugin/skills/util-projectsync/SKILL.md +501 -0
- package/plugin/skills/util-updprd/SKILL.md +324 -0
- package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
- package/plugin/skills/util-ustagger/SKILL.md +154 -0
- package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
- package/static/assets/arc-DRKw7sjs.js +1 -0
- package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
- package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
- package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
- package/static/assets/channel-C2nVbuwv.js +1 -0
- package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
- package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
- package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
- package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
- package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
- package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
- package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
- package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
- package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
- package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
- package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
- package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
- package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
- package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
- package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
- package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
- package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
- package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
- package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
- package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
- package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
- package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
- package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
- package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
- package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
- package/static/assets/graph-C9eacEi8.js +1 -0
- package/static/assets/index-LZSQkLE2.css +41 -0
- package/static/assets/index-y7XDSgMG.js +495 -0
- package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
- package/static/assets/init-Gi6I4Gst.js +1 -0
- package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
- package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
- package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
- package/static/assets/katex-C5jXJg4s.js +257 -0
- package/static/assets/layout-DEXfKzaS.js +1 -0
- package/static/assets/linear-CLO--ryk.js +1 -0
- package/static/assets/map-Czzmt4hB.js +1 -0
- package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
- package/static/assets/ordinal-Cboi1Yqb.js +1 -0
- package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
- package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
- package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
- package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
- package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
- package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
- package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
- package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
- package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
- package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
- package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
- package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
- package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
- package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
- package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
- package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
- 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
|
+
```
|