opencastle 0.26.1 → 0.27.1

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 (226) hide show
  1. package/README.md +7 -1
  2. package/bin/cli.mjs +10 -0
  3. package/dist/cli/agents.d.ts +3 -0
  4. package/dist/cli/agents.d.ts.map +1 -0
  5. package/dist/cli/agents.js +161 -0
  6. package/dist/cli/agents.js.map +1 -0
  7. package/dist/cli/baselines.d.ts +3 -0
  8. package/dist/cli/baselines.d.ts.map +1 -0
  9. package/dist/cli/baselines.js +128 -0
  10. package/dist/cli/baselines.js.map +1 -0
  11. package/dist/cli/convoy/engine.d.ts +68 -2
  12. package/dist/cli/convoy/engine.d.ts.map +1 -1
  13. package/dist/cli/convoy/engine.js +2102 -26
  14. package/dist/cli/convoy/engine.js.map +1 -1
  15. package/dist/cli/convoy/engine.test.js +1572 -70
  16. package/dist/cli/convoy/engine.test.js.map +1 -1
  17. package/dist/cli/convoy/events.d.ts +4 -1
  18. package/dist/cli/convoy/events.d.ts.map +1 -1
  19. package/dist/cli/convoy/events.js +74 -13
  20. package/dist/cli/convoy/events.js.map +1 -1
  21. package/dist/cli/convoy/events.test.js +154 -27
  22. package/dist/cli/convoy/events.test.js.map +1 -1
  23. package/dist/cli/convoy/expertise.d.ts +16 -0
  24. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  25. package/dist/cli/convoy/expertise.js +121 -0
  26. package/dist/cli/convoy/expertise.js.map +1 -0
  27. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  28. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  29. package/dist/cli/convoy/expertise.test.js +96 -0
  30. package/dist/cli/convoy/expertise.test.js.map +1 -0
  31. package/dist/cli/convoy/export.test.js +1 -0
  32. package/dist/cli/convoy/export.test.js.map +1 -1
  33. package/dist/cli/convoy/formula.d.ts +19 -0
  34. package/dist/cli/convoy/formula.d.ts.map +1 -0
  35. package/dist/cli/convoy/formula.js +142 -0
  36. package/dist/cli/convoy/formula.js.map +1 -0
  37. package/dist/cli/convoy/formula.test.d.ts +2 -0
  38. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  39. package/dist/cli/convoy/formula.test.js +342 -0
  40. package/dist/cli/convoy/formula.test.js.map +1 -0
  41. package/dist/cli/convoy/gates.d.ts +128 -0
  42. package/dist/cli/convoy/gates.d.ts.map +1 -0
  43. package/dist/cli/convoy/gates.js +606 -0
  44. package/dist/cli/convoy/gates.js.map +1 -0
  45. package/dist/cli/convoy/gates.test.d.ts +2 -0
  46. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  47. package/dist/cli/convoy/gates.test.js +976 -0
  48. package/dist/cli/convoy/gates.test.js.map +1 -0
  49. package/dist/cli/convoy/health.d.ts +11 -0
  50. package/dist/cli/convoy/health.d.ts.map +1 -1
  51. package/dist/cli/convoy/health.js +54 -0
  52. package/dist/cli/convoy/health.js.map +1 -1
  53. package/dist/cli/convoy/health.test.js +56 -1
  54. package/dist/cli/convoy/health.test.js.map +1 -1
  55. package/dist/cli/convoy/issues.d.ts +8 -0
  56. package/dist/cli/convoy/issues.d.ts.map +1 -0
  57. package/dist/cli/convoy/issues.js +98 -0
  58. package/dist/cli/convoy/issues.js.map +1 -0
  59. package/dist/cli/convoy/issues.test.d.ts +2 -0
  60. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  61. package/dist/cli/convoy/issues.test.js +107 -0
  62. package/dist/cli/convoy/issues.test.js.map +1 -0
  63. package/dist/cli/convoy/knowledge.d.ts +5 -0
  64. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  65. package/dist/cli/convoy/knowledge.js +116 -0
  66. package/dist/cli/convoy/knowledge.js.map +1 -0
  67. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  68. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  69. package/dist/cli/convoy/knowledge.test.js +87 -0
  70. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  71. package/dist/cli/convoy/lessons.d.ts +17 -0
  72. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  73. package/dist/cli/convoy/lessons.js +149 -0
  74. package/dist/cli/convoy/lessons.js.map +1 -0
  75. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  76. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  77. package/dist/cli/convoy/lessons.test.js +135 -0
  78. package/dist/cli/convoy/lessons.test.js.map +1 -0
  79. package/dist/cli/convoy/lock.d.ts +13 -0
  80. package/dist/cli/convoy/lock.d.ts.map +1 -0
  81. package/dist/cli/convoy/lock.js +88 -0
  82. package/dist/cli/convoy/lock.js.map +1 -0
  83. package/dist/cli/convoy/lock.test.d.ts +2 -0
  84. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  85. package/dist/cli/convoy/lock.test.js +136 -0
  86. package/dist/cli/convoy/lock.test.js.map +1 -0
  87. package/dist/cli/convoy/merge.d.ts +4 -0
  88. package/dist/cli/convoy/merge.d.ts.map +1 -1
  89. package/dist/cli/convoy/merge.js +18 -1
  90. package/dist/cli/convoy/merge.js.map +1 -1
  91. package/dist/cli/convoy/merge.test.js +6 -7
  92. package/dist/cli/convoy/merge.test.js.map +1 -1
  93. package/dist/cli/convoy/partition.d.ts +51 -0
  94. package/dist/cli/convoy/partition.d.ts.map +1 -0
  95. package/dist/cli/convoy/partition.js +186 -0
  96. package/dist/cli/convoy/partition.js.map +1 -0
  97. package/dist/cli/convoy/partition.test.d.ts +2 -0
  98. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  99. package/dist/cli/convoy/partition.test.js +315 -0
  100. package/dist/cli/convoy/partition.test.js.map +1 -0
  101. package/dist/cli/convoy/pipeline.test.js +6 -0
  102. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  103. package/dist/cli/convoy/store.d.ts +47 -5
  104. package/dist/cli/convoy/store.d.ts.map +1 -1
  105. package/dist/cli/convoy/store.js +525 -19
  106. package/dist/cli/convoy/store.js.map +1 -1
  107. package/dist/cli/convoy/store.test.js +1345 -12
  108. package/dist/cli/convoy/store.test.js.map +1 -1
  109. package/dist/cli/convoy/types.d.ts +156 -2
  110. package/dist/cli/convoy/types.d.ts.map +1 -1
  111. package/dist/cli/destroy.d.ts +3 -0
  112. package/dist/cli/destroy.d.ts.map +1 -0
  113. package/dist/cli/destroy.js +69 -0
  114. package/dist/cli/destroy.js.map +1 -0
  115. package/dist/cli/destroy.test.d.ts +2 -0
  116. package/dist/cli/destroy.test.d.ts.map +1 -0
  117. package/dist/cli/destroy.test.js +116 -0
  118. package/dist/cli/destroy.test.js.map +1 -0
  119. package/dist/cli/gitignore.d.ts +9 -0
  120. package/dist/cli/gitignore.d.ts.map +1 -1
  121. package/dist/cli/gitignore.js +29 -0
  122. package/dist/cli/gitignore.js.map +1 -1
  123. package/dist/cli/plan.d.ts +3 -0
  124. package/dist/cli/plan.d.ts.map +1 -0
  125. package/dist/cli/plan.js +288 -0
  126. package/dist/cli/plan.js.map +1 -0
  127. package/dist/cli/run/adapters/claude.d.ts +2 -0
  128. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  129. package/dist/cli/run/adapters/claude.js +89 -49
  130. package/dist/cli/run/adapters/claude.js.map +1 -1
  131. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  132. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  133. package/dist/cli/run/adapters/claude.test.js +205 -0
  134. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  135. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  137. package/dist/cli/run/adapters/copilot.js +84 -46
  138. package/dist/cli/run/adapters/copilot.js.map +1 -1
  139. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  140. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  141. package/dist/cli/run/adapters/copilot.test.js +195 -0
  142. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  143. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  145. package/dist/cli/run/adapters/cursor.js +83 -47
  146. package/dist/cli/run/adapters/cursor.js.map +1 -1
  147. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  148. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  149. package/dist/cli/run/adapters/cursor.test.js +129 -0
  150. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  151. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  153. package/dist/cli/run/adapters/opencode.js +81 -47
  154. package/dist/cli/run/adapters/opencode.js.map +1 -1
  155. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  156. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  157. package/dist/cli/run/adapters/opencode.test.js +119 -0
  158. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  159. package/dist/cli/run/executor.js +1 -1
  160. package/dist/cli/run/executor.js.map +1 -1
  161. package/dist/cli/run/schema.d.ts.map +1 -1
  162. package/dist/cli/run/schema.js +245 -4
  163. package/dist/cli/run/schema.js.map +1 -1
  164. package/dist/cli/run/schema.test.js +669 -0
  165. package/dist/cli/run/schema.test.js.map +1 -1
  166. package/dist/cli/run.d.ts.map +1 -1
  167. package/dist/cli/run.js +362 -22
  168. package/dist/cli/run.js.map +1 -1
  169. package/dist/cli/types.d.ts +85 -2
  170. package/dist/cli/types.d.ts.map +1 -1
  171. package/dist/cli/types.js.map +1 -1
  172. package/dist/cli/watch.d.ts +15 -0
  173. package/dist/cli/watch.d.ts.map +1 -0
  174. package/dist/cli/watch.js +279 -0
  175. package/dist/cli/watch.js.map +1 -0
  176. package/package.json +1 -1
  177. package/src/cli/agents.ts +177 -0
  178. package/src/cli/baselines.ts +143 -0
  179. package/src/cli/convoy/engine.test.ts +1839 -70
  180. package/src/cli/convoy/engine.ts +2417 -38
  181. package/src/cli/convoy/events.test.ts +179 -38
  182. package/src/cli/convoy/events.ts +88 -16
  183. package/src/cli/convoy/expertise.test.ts +128 -0
  184. package/src/cli/convoy/expertise.ts +163 -0
  185. package/src/cli/convoy/export.test.ts +1 -0
  186. package/src/cli/convoy/formula.test.ts +405 -0
  187. package/src/cli/convoy/formula.ts +174 -0
  188. package/src/cli/convoy/gates.test.ts +1169 -0
  189. package/src/cli/convoy/gates.ts +774 -0
  190. package/src/cli/convoy/health.test.ts +64 -2
  191. package/src/cli/convoy/health.ts +80 -2
  192. package/src/cli/convoy/issues.test.ts +143 -0
  193. package/src/cli/convoy/issues.ts +136 -0
  194. package/src/cli/convoy/knowledge.test.ts +101 -0
  195. package/src/cli/convoy/knowledge.ts +132 -0
  196. package/src/cli/convoy/lessons.test.ts +188 -0
  197. package/src/cli/convoy/lessons.ts +164 -0
  198. package/src/cli/convoy/lock.test.ts +181 -0
  199. package/src/cli/convoy/lock.ts +103 -0
  200. package/src/cli/convoy/merge.test.ts +6 -7
  201. package/src/cli/convoy/merge.ts +19 -1
  202. package/src/cli/convoy/partition.test.ts +423 -0
  203. package/src/cli/convoy/partition.ts +232 -0
  204. package/src/cli/convoy/pipeline.test.ts +6 -0
  205. package/src/cli/convoy/store.test.ts +1512 -14
  206. package/src/cli/convoy/store.ts +676 -30
  207. package/src/cli/convoy/types.ts +170 -1
  208. package/src/cli/destroy.test.ts +141 -0
  209. package/src/cli/destroy.ts +88 -0
  210. package/src/cli/gitignore.ts +36 -0
  211. package/src/cli/plan.ts +316 -0
  212. package/src/cli/run/adapters/claude.test.ts +234 -0
  213. package/src/cli/run/adapters/claude.ts +45 -5
  214. package/src/cli/run/adapters/copilot.test.ts +224 -0
  215. package/src/cli/run/adapters/copilot.ts +34 -4
  216. package/src/cli/run/adapters/cursor.test.ts +144 -0
  217. package/src/cli/run/adapters/cursor.ts +33 -2
  218. package/src/cli/run/adapters/opencode.test.ts +135 -0
  219. package/src/cli/run/adapters/opencode.ts +30 -2
  220. package/src/cli/run/executor.ts +1 -1
  221. package/src/cli/run/schema.test.ts +758 -0
  222. package/src/cli/run/schema.ts +300 -25
  223. package/src/cli/run.ts +341 -21
  224. package/src/cli/types.ts +86 -1
  225. package/src/cli/watch.ts +298 -0
  226. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -1,4 +1,4 @@
1
- export type ConvoyStatus = 'pending' | 'running' | 'done' | 'failed' | 'gate-failed'
1
+ export type ConvoyStatus = 'pending' | 'running' | 'done' | 'failed' | 'gate-failed' | 'hook-failed'
2
2
 
3
3
  export type ConvoyTaskStatus =
4
4
  | 'pending'
@@ -6,8 +6,13 @@ export type ConvoyTaskStatus =
6
6
  | 'running'
7
7
  | 'done'
8
8
  | 'failed'
9
+ | 'gate-failed'
10
+ | 'review-blocked'
9
11
  | 'timed-out'
10
12
  | 'skipped'
13
+ | 'hook-failed'
14
+ | 'disputed'
15
+ | 'wait-for-input'
11
16
 
12
17
  export type WorkerStatus = 'spawned' | 'running' | 'done' | 'failed' | 'killed'
13
18
 
@@ -26,6 +31,9 @@ export interface ConvoyRecord {
26
31
  total_tokens: number | null
27
32
  total_cost_usd: string | null
28
33
  pipeline_id: string | null
34
+ circuit_state: string | null
35
+ review_tokens_total: number | null
36
+ review_budget: number | null
29
37
  }
30
38
 
31
39
  export interface TaskRecord {
@@ -52,6 +60,24 @@ export interface TaskRecord {
52
60
  completion_tokens: number | null
53
61
  total_tokens: number | null
54
62
  cost_usd: string | null
63
+ gates: string | null
64
+ on_exhausted: 'dlq' | 'skip' | 'stop'
65
+ injected: number
66
+ provenance: string | null
67
+ idempotency_key: string | null
68
+ current_step: number | null
69
+ total_steps: number | null
70
+ review_level: string | null
71
+ review_verdict: string | null
72
+ review_tokens: number | null
73
+ review_model: string | null
74
+ panel_attempts: number
75
+ dispute_id: string | null
76
+ drift_score: number | null
77
+ drift_retried: number
78
+ outputs?: string | null // JSON array of TaskOutput
79
+ inputs?: string | null // JSON array of TaskInput
80
+ discovered_issues?: string | null // JSON array
55
81
  }
56
82
 
57
83
  export interface WorkerRecord {
@@ -90,3 +116,146 @@ export interface PipelineRecord {
90
116
  total_tokens: number | null
91
117
  total_cost_usd: string | null
92
118
  }
119
+
120
+ export interface BuiltInGatesConfig {
121
+ secret_scan?: boolean
122
+ blast_radius?: boolean
123
+ dependency_audit?: 'auto' | boolean
124
+ regression_test?: 'auto' | boolean
125
+ browser_test?: 'auto' | boolean
126
+ gate_timeout?: number
127
+ }
128
+
129
+
130
+ export interface BrowserTestConfig {
131
+ urls: string[]
132
+ check_console_errors?: boolean
133
+ visual_diff_threshold?: number
134
+ a11y?: boolean
135
+ severity_threshold?: 'critical' | 'serious' | 'moderate' | 'minor'
136
+ baselines_dir?: string
137
+ }
138
+ export interface GuardConfig {
139
+ enabled?: boolean // default: true
140
+ agent?: string // optional agent name (e.g. 'session-guard')
141
+ checks?: string[] // e.g. ['observability', 'cleanup', 'cost-report']
142
+ }
143
+
144
+ export interface DlqRecord {
145
+ id: string
146
+ convoy_id: string
147
+ task_id: string
148
+ agent: string
149
+ failure_type: string
150
+ error_output: string | null
151
+ attempts: number
152
+ tokens_spent: number | null
153
+ escalation_task_id: string | null
154
+ resolved: number
155
+ resolution: string | null
156
+ created_at: string
157
+ resolved_at: string | null
158
+ }
159
+
160
+ export interface CircuitBreakerConfig {
161
+ threshold?: number // failures before Open (default: 3)
162
+ cooldown_ms?: number // ms in Open before Half-Open (default: 300000 = 5min)
163
+ fallback_agent?: string // reassign pending tasks when circuit opens
164
+ }
165
+
166
+ export interface TaskOutput {
167
+ name: string
168
+ type: 'file' | 'summary' | 'json'
169
+ description?: string
170
+ }
171
+
172
+ export interface TaskInput {
173
+ from: string
174
+ name: string
175
+ as?: string
176
+ }
177
+
178
+ export interface ArtifactRecord {
179
+ id: string
180
+ convoy_id: string
181
+ task_id: string
182
+ name: string
183
+ type: 'file' | 'summary' | 'json'
184
+ content: string
185
+ created_at: string
186
+ }
187
+
188
+ export interface AgentIdentityRecord {
189
+ id: string
190
+ agent: string
191
+ convoy_id: string
192
+ task_id: string
193
+ summary: string
194
+ created_at: string
195
+ retention_days: number
196
+ }
197
+
198
+ export interface StepCondition {
199
+ step: string // reference previous step by id
200
+ exitCode?: { eq?: number; ne?: number; gt?: number; lt?: number }
201
+ fileExists?: { path: string }
202
+ }
203
+
204
+ export interface TaskStep {
205
+ id?: string
206
+ prompt: string
207
+ gates?: string[]
208
+ max_retries?: number // inherits from task if omitted
209
+ if?: StepCondition
210
+ }
211
+
212
+ export interface Hook {
213
+ type: 'review' | 'guard' | 'agent' | 'command' | 'validate'
214
+ name?: string
215
+ prompt?: string // for agent hooks
216
+ command?: string // for command hooks
217
+ on?: 'pre_task' | 'post_task' | 'post_convoy'
218
+ }
219
+
220
+ export interface TaskStepRecord {
221
+ id: number
222
+ task_id: string
223
+ step_index: number
224
+ prompt: string
225
+ gates: string | null
226
+ status: string
227
+ exit_code: number | null
228
+ output: string | null
229
+ started_at: string | null
230
+ finished_at: string | null
231
+ }
232
+
233
+ export interface WatchTrigger {
234
+ type: 'file-change' | 'cron' | 'git-push'
235
+ glob?: string // for file-change: glob pattern to watch
236
+ schedule?: string // for cron: 5-field cron expression
237
+ branch?: string // for git-push: branch name pattern
238
+ debounce_ms?: number // file-change debounce (default: 500ms)
239
+ }
240
+
241
+ export interface WatchConfig {
242
+ triggers: WatchTrigger[]
243
+ clear_scratchpad?: boolean // clear scratchpad on watch start
244
+ scratchpad_retention_days?: number // auto-clear scratchpad entries older than N days
245
+ }
246
+
247
+ export interface ScratchpadRecord {
248
+ key: string
249
+ value: string
250
+ updated_at: string
251
+ }
252
+
253
+ export interface MCPServerConfig {
254
+ name: string
255
+ type: string
256
+ local?: boolean
257
+ command?: string
258
+ args?: string[]
259
+ url?: string
260
+ config?: Record<string, unknown>
261
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { existsSync } from 'node:fs'
6
+
7
+ vi.mock('./prompt.js', () => ({
8
+ confirm: vi.fn().mockResolvedValue(true),
9
+ closePrompts: vi.fn(),
10
+ c: {
11
+ green: (s: string) => s,
12
+ dim: (s: string) => s,
13
+ bold: (s: string) => s,
14
+ red: (s: string) => s,
15
+ cyan: (s: string) => s,
16
+ yellow: (s: string) => s,
17
+ magenta: (s: string) => s,
18
+ },
19
+ }))
20
+
21
+ import destroy from './destroy.js'
22
+ import { confirm } from './prompt.js'
23
+ import type { Manifest } from './types.js'
24
+
25
+ const START_MARKER = '# >>> OpenCastle managed (do not edit) >>>'
26
+ const END_MARKER = '# <<< OpenCastle managed <<<'
27
+
28
+ async function writeManifestFile(dir: string, manifest: Partial<Manifest> = {}): Promise<void> {
29
+ await mkdir(join(dir, '.opencastle'), { recursive: true })
30
+ const full: Manifest = {
31
+ version: '1.0.0',
32
+ ide: 'vscode',
33
+ ides: ['vscode'],
34
+ installedAt: new Date().toISOString(),
35
+ updatedAt: new Date().toISOString(),
36
+ managedPaths: { framework: [], customizable: [] },
37
+ ...manifest,
38
+ }
39
+ await writeFile(join(dir, '.opencastle', 'manifest.json'), JSON.stringify(full, null, 2))
40
+ }
41
+
42
+ async function writeGitignoreWithBlock(dir: string, userEntries = 'node_modules\n'): Promise<void> {
43
+ const block = [userEntries, '', START_MARKER, '.github/', '!.github/customizations/', END_MARKER, ''].join('\n')
44
+ await writeFile(join(dir, '.gitignore'), block)
45
+ }
46
+
47
+ // ── Tests ──────────────────────────────────────────────────────
48
+
49
+ describe('destroy', () => {
50
+ let tmpDir: string
51
+ let cwdSpy: ReturnType<typeof vi.spyOn>
52
+ let exitSpy: ReturnType<typeof vi.spyOn>
53
+
54
+ beforeEach(async () => {
55
+ tmpDir = await mkdtemp(join(tmpdir(), 'oc-destroy-'))
56
+ cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
57
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
58
+ throw new Error('process.exit called')
59
+ })
60
+ vi.mocked(confirm).mockResolvedValue(true)
61
+ })
62
+
63
+ afterEach(async () => {
64
+ cwdSpy.mockRestore()
65
+ exitSpy.mockRestore()
66
+ await rm(tmpDir, { recursive: true, force: true })
67
+ })
68
+
69
+ it('removes all managed framework files', async () => {
70
+ await writeManifestFile(tmpDir, {
71
+ managedPaths: {
72
+ framework: ['.github/instructions/general.instructions.md', '.github/copilot-instructions.md'],
73
+ customizable: [],
74
+ },
75
+ })
76
+ await mkdir(join(tmpDir, '.github', 'instructions'), { recursive: true })
77
+ await writeFile(join(tmpDir, '.github', 'instructions', 'general.instructions.md'), 'content')
78
+ await writeFile(join(tmpDir, '.github', 'copilot-instructions.md'), 'content')
79
+
80
+ await destroy({ pkgRoot: tmpDir, args: [] })
81
+
82
+ expect(existsSync(join(tmpDir, '.github', 'instructions', 'general.instructions.md'))).toBe(false)
83
+ expect(existsSync(join(tmpDir, '.github', 'copilot-instructions.md'))).toBe(false)
84
+ })
85
+
86
+ it('removes .opencastle/ directory', async () => {
87
+ await writeManifestFile(tmpDir)
88
+ expect(existsSync(join(tmpDir, '.opencastle'))).toBe(true)
89
+
90
+ await destroy({ pkgRoot: tmpDir, args: [] })
91
+
92
+ expect(existsSync(join(tmpDir, '.opencastle'))).toBe(false)
93
+ })
94
+
95
+ it('removes legacy .opencastle.json manifest', async () => {
96
+ await writeManifestFile(tmpDir)
97
+ const legacyPath = join(tmpDir, '.opencastle.json')
98
+ await writeFile(legacyPath, JSON.stringify({ version: '0.1.0', ide: 'vscode', installedAt: '', updatedAt: '' }))
99
+
100
+ await destroy({ pkgRoot: tmpDir, args: [] })
101
+
102
+ expect(existsSync(legacyPath)).toBe(false)
103
+ })
104
+
105
+ it('cleans the gitignore block but keeps user entries', async () => {
106
+ await writeManifestFile(tmpDir)
107
+ await writeGitignoreWithBlock(tmpDir, 'node_modules\ndist\n')
108
+
109
+ await destroy({ pkgRoot: tmpDir, args: [] })
110
+
111
+ const gitignorePath = join(tmpDir, '.gitignore')
112
+ expect(existsSync(gitignorePath)).toBe(true)
113
+ const { readFile } = await import('node:fs/promises')
114
+ const content = await readFile(gitignorePath, 'utf8')
115
+ expect(content).not.toContain(START_MARKER)
116
+ expect(content).not.toContain(END_MARKER)
117
+ expect(content).toContain('node_modules')
118
+ expect(content).toContain('dist')
119
+ })
120
+
121
+ it('dry-run makes no changes', async () => {
122
+ await writeManifestFile(tmpDir, {
123
+ managedPaths: { framework: ['some-file.md'], customizable: [] },
124
+ })
125
+ await writeFile(join(tmpDir, 'some-file.md'), 'content')
126
+ await writeGitignoreWithBlock(tmpDir)
127
+
128
+ await destroy({ pkgRoot: tmpDir, args: ['--dry-run'] })
129
+
130
+ expect(existsSync(join(tmpDir, 'some-file.md'))).toBe(true)
131
+ expect(existsSync(join(tmpDir, '.opencastle'))).toBe(true)
132
+ const { readFile } = await import('node:fs/promises')
133
+ const gitignore = await readFile(join(tmpDir, '.gitignore'), 'utf8')
134
+ expect(gitignore).toContain(START_MARKER)
135
+ })
136
+
137
+ it('exits with error when no manifest found', async () => {
138
+ await expect(destroy({ pkgRoot: tmpDir, args: [] })).rejects.toThrow('process.exit called')
139
+ expect(exitSpy).toHaveBeenCalledWith(1)
140
+ })
141
+ })
@@ -0,0 +1,88 @@
1
+ import { resolve } from 'node:path'
2
+ import { unlink } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
4
+ import { readManifest } from './manifest.js'
5
+ import { removeDirIfExists } from './copy.js'
6
+ import { removeGitignoreBlock } from './gitignore.js'
7
+ import { confirm, closePrompts, c } from './prompt.js'
8
+ import type { CliContext } from './types.js'
9
+
10
+ export default async function destroy({
11
+ pkgRoot: _pkgRoot,
12
+ args,
13
+ }: CliContext): Promise<void> {
14
+ const projectRoot = process.cwd()
15
+ const dryRun = args.includes('--dry-run') || args.includes('--dryRun')
16
+
17
+ const manifest = await readManifest(projectRoot)
18
+ if (!manifest) {
19
+ console.error(' ✗ No OpenCastle installation found.')
20
+ process.exit(1)
21
+ }
22
+
23
+ const frameworkPaths = manifest.managedPaths?.framework ?? []
24
+ const customizablePaths = manifest.managedPaths?.customizable ?? []
25
+ const legacyManifestPath = resolve(projectRoot, '.opencastle.json')
26
+ const hasLegacy = existsSync(legacyManifestPath)
27
+
28
+ console.log(`\n 🏰 OpenCastle destroy\n`)
29
+ console.log(' This will permanently remove:\n')
30
+
31
+ for (const p of frameworkPaths) {
32
+ console.log(` ${c.dim(p)}`)
33
+ }
34
+ for (const p of customizablePaths) {
35
+ console.log(` ${c.dim(p)}`)
36
+ }
37
+ console.log(` ${c.dim('.opencastle/')}`)
38
+ if (hasLegacy) {
39
+ console.log(` ${c.dim('.opencastle.json')}`)
40
+ }
41
+ console.log(` ${c.dim('.gitignore block')}\n`)
42
+
43
+ if (dryRun) {
44
+ console.log(' [dry-run] No files were changed.\n')
45
+ return
46
+ }
47
+
48
+ const proceed = await confirm(
49
+ 'This will permanently delete all OpenCastle files. Continue?',
50
+ false
51
+ )
52
+ if (!proceed) {
53
+ console.log(' Aborted.')
54
+ closePrompts()
55
+ return
56
+ }
57
+
58
+ let removed = 0
59
+
60
+ for (const p of [...frameworkPaths, ...customizablePaths]) {
61
+ if (p.endsWith('/')) {
62
+ const dir = resolve(projectRoot, p)
63
+ await removeDirIfExists(dir)
64
+ removed++
65
+ } else {
66
+ const file = resolve(projectRoot, p)
67
+ if (existsSync(file)) {
68
+ await unlink(file)
69
+ removed++
70
+ }
71
+ }
72
+ }
73
+
74
+ await removeDirIfExists(resolve(projectRoot, '.opencastle'))
75
+ removed++
76
+
77
+ if (hasLegacy) {
78
+ await unlink(legacyManifestPath)
79
+ removed++
80
+ }
81
+
82
+ const gitignoreResult = await removeGitignoreBlock(projectRoot)
83
+
84
+ console.log(`\n ${c.green('✓')} Removed ${removed} path(s)${gitignoreResult === 'removed' ? ' + .gitignore block' : ''}.`)
85
+ console.log(` You can uninstall: ${c.bold('npm uninstall opencastle')}\n`)
86
+
87
+ closePrompts()
88
+ }
@@ -72,3 +72,39 @@ export async function updateGitignore(
72
72
  await writeFile(gitignorePath, existing + separator + block + '\n', 'utf8')
73
73
  return 'updated'
74
74
  }
75
+
76
+ /**
77
+ * Remove the OpenCastle managed block from `.gitignore`.
78
+ *
79
+ * - No-op if no `.gitignore` exists or no block is present.
80
+ * - Cleans up resulting double blank lines.
81
+ * - Deletes `.gitignore` if the file becomes empty after removal.
82
+ * - Returns 'removed' or 'unchanged'.
83
+ */
84
+ export async function removeGitignoreBlock(
85
+ projectRoot: string
86
+ ): Promise<'removed' | 'unchanged'> {
87
+ const gitignorePath = resolve(projectRoot, '.gitignore')
88
+ if (!existsSync(gitignorePath)) return 'unchanged'
89
+
90
+ const existing = await readFile(gitignorePath, 'utf8')
91
+ const startIdx = existing.indexOf(START_MARKER)
92
+ const endIdx = existing.indexOf(END_MARKER)
93
+
94
+ if (startIdx === -1 || endIdx === -1) return 'unchanged'
95
+
96
+ const before = existing.slice(0, startIdx)
97
+ const after = existing.slice(endIdx + END_MARKER.length)
98
+
99
+ // Collapse consecutive blank lines left by removal
100
+ const updated = (before + after).replace(/\n{3,}/g, '\n\n').trimEnd()
101
+
102
+ if (!updated) {
103
+ const { unlink } = await import('node:fs/promises')
104
+ await unlink(gitignorePath)
105
+ return 'removed'
106
+ }
107
+
108
+ await writeFile(gitignorePath, updated + '\n', 'utf8')
109
+ return 'removed'
110
+ }