@build-astron-co/nimbus 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (313) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +628 -0
  3. package/bin/nimbus +38 -0
  4. package/package.json +80 -0
  5. package/src/__tests__/app.test.ts +76 -0
  6. package/src/__tests__/audit.test.ts +877 -0
  7. package/src/__tests__/circuit-breaker.test.ts +116 -0
  8. package/src/__tests__/cli-run.test.ts +115 -0
  9. package/src/__tests__/context-manager.test.ts +502 -0
  10. package/src/__tests__/context.test.ts +242 -0
  11. package/src/__tests__/enterprise.test.ts +401 -0
  12. package/src/__tests__/generator.test.ts +433 -0
  13. package/src/__tests__/hooks.test.ts +582 -0
  14. package/src/__tests__/init.test.ts +436 -0
  15. package/src/__tests__/intent-parser.test.ts +229 -0
  16. package/src/__tests__/llm-router.test.ts +209 -0
  17. package/src/__tests__/lsp.test.ts +293 -0
  18. package/src/__tests__/modes.test.ts +336 -0
  19. package/src/__tests__/permissions.test.ts +338 -0
  20. package/src/__tests__/serve.test.ts +275 -0
  21. package/src/__tests__/sessions.test.ts +227 -0
  22. package/src/__tests__/sharing.test.ts +288 -0
  23. package/src/__tests__/snapshots.test.ts +581 -0
  24. package/src/__tests__/state-db.test.ts +334 -0
  25. package/src/__tests__/stream-with-tools.test.ts +732 -0
  26. package/src/__tests__/subagents.test.ts +176 -0
  27. package/src/__tests__/system-prompt.test.ts +169 -0
  28. package/src/__tests__/tool-converter.test.ts +256 -0
  29. package/src/__tests__/tool-schemas.test.ts +397 -0
  30. package/src/__tests__/tools.test.ts +143 -0
  31. package/src/__tests__/version.test.ts +49 -0
  32. package/src/agent/compaction-agent.ts +227 -0
  33. package/src/agent/context-manager.ts +435 -0
  34. package/src/agent/context.ts +427 -0
  35. package/src/agent/deploy-preview.ts +426 -0
  36. package/src/agent/index.ts +68 -0
  37. package/src/agent/loop.ts +717 -0
  38. package/src/agent/modes.ts +429 -0
  39. package/src/agent/permissions.ts +466 -0
  40. package/src/agent/subagents/base.ts +116 -0
  41. package/src/agent/subagents/cost.ts +51 -0
  42. package/src/agent/subagents/explore.ts +42 -0
  43. package/src/agent/subagents/general.ts +54 -0
  44. package/src/agent/subagents/index.ts +102 -0
  45. package/src/agent/subagents/infra.ts +59 -0
  46. package/src/agent/subagents/security.ts +69 -0
  47. package/src/agent/system-prompt.ts +436 -0
  48. package/src/app.ts +122 -0
  49. package/src/audit/activity-log.ts +290 -0
  50. package/src/audit/compliance-checker.ts +540 -0
  51. package/src/audit/cost-tracker.ts +318 -0
  52. package/src/audit/index.ts +23 -0
  53. package/src/audit/security-scanner.ts +596 -0
  54. package/src/auth/guard.ts +75 -0
  55. package/src/auth/index.ts +56 -0
  56. package/src/auth/oauth.ts +455 -0
  57. package/src/auth/providers.ts +470 -0
  58. package/src/auth/sso.ts +113 -0
  59. package/src/auth/store.ts +505 -0
  60. package/src/auth/types.ts +187 -0
  61. package/src/build.ts +141 -0
  62. package/src/cli/index.ts +16 -0
  63. package/src/cli/init.ts +854 -0
  64. package/src/cli/openapi-spec.ts +356 -0
  65. package/src/cli/run.ts +237 -0
  66. package/src/cli/serve-auth.ts +80 -0
  67. package/src/cli/serve.ts +462 -0
  68. package/src/cli/web.ts +67 -0
  69. package/src/cli.ts +1417 -0
  70. package/src/clients/core-engine-client.ts +227 -0
  71. package/src/clients/enterprise-client.ts +334 -0
  72. package/src/clients/generator-client.ts +351 -0
  73. package/src/clients/git-client.ts +627 -0
  74. package/src/clients/github-client.ts +410 -0
  75. package/src/clients/helm-client.ts +504 -0
  76. package/src/clients/index.ts +80 -0
  77. package/src/clients/k8s-client.ts +497 -0
  78. package/src/clients/llm-client.ts +161 -0
  79. package/src/clients/rest-client.ts +130 -0
  80. package/src/clients/service-discovery.ts +33 -0
  81. package/src/clients/terraform-client.ts +482 -0
  82. package/src/clients/tools-client.ts +1843 -0
  83. package/src/clients/ws-client.ts +115 -0
  84. package/src/commands/analyze/index.ts +352 -0
  85. package/src/commands/apply/helm.ts +473 -0
  86. package/src/commands/apply/index.ts +213 -0
  87. package/src/commands/apply/k8s.ts +454 -0
  88. package/src/commands/apply/terraform.ts +582 -0
  89. package/src/commands/ask.ts +167 -0
  90. package/src/commands/audit/index.ts +238 -0
  91. package/src/commands/auth-cloud.ts +294 -0
  92. package/src/commands/auth-list.ts +134 -0
  93. package/src/commands/auth-profile.ts +121 -0
  94. package/src/commands/auth-status.ts +141 -0
  95. package/src/commands/aws/ec2.ts +501 -0
  96. package/src/commands/aws/iam.ts +397 -0
  97. package/src/commands/aws/index.ts +133 -0
  98. package/src/commands/aws/lambda.ts +396 -0
  99. package/src/commands/aws/rds.ts +439 -0
  100. package/src/commands/aws/s3.ts +439 -0
  101. package/src/commands/aws/vpc.ts +393 -0
  102. package/src/commands/aws-discover.ts +649 -0
  103. package/src/commands/aws-terraform.ts +805 -0
  104. package/src/commands/azure/aks.ts +376 -0
  105. package/src/commands/azure/functions.ts +253 -0
  106. package/src/commands/azure/index.ts +116 -0
  107. package/src/commands/azure/storage.ts +478 -0
  108. package/src/commands/azure/vm.ts +355 -0
  109. package/src/commands/billing/index.ts +256 -0
  110. package/src/commands/chat.ts +314 -0
  111. package/src/commands/config.ts +346 -0
  112. package/src/commands/cost/cloud-cost-estimator.ts +266 -0
  113. package/src/commands/cost/estimator.ts +79 -0
  114. package/src/commands/cost/index.ts +594 -0
  115. package/src/commands/cost/parsers/terraform.ts +273 -0
  116. package/src/commands/cost/parsers/types.ts +25 -0
  117. package/src/commands/cost/pricing/aws.ts +544 -0
  118. package/src/commands/cost/pricing/azure.ts +499 -0
  119. package/src/commands/cost/pricing/gcp.ts +396 -0
  120. package/src/commands/cost/pricing/index.ts +40 -0
  121. package/src/commands/demo.ts +250 -0
  122. package/src/commands/doctor.ts +794 -0
  123. package/src/commands/drift/index.ts +439 -0
  124. package/src/commands/explain.ts +277 -0
  125. package/src/commands/feedback.ts +389 -0
  126. package/src/commands/fix.ts +324 -0
  127. package/src/commands/fs/index.ts +402 -0
  128. package/src/commands/gcp/compute.ts +325 -0
  129. package/src/commands/gcp/functions.ts +271 -0
  130. package/src/commands/gcp/gke.ts +438 -0
  131. package/src/commands/gcp/iam.ts +344 -0
  132. package/src/commands/gcp/index.ts +129 -0
  133. package/src/commands/gcp/storage.ts +284 -0
  134. package/src/commands/generate-helm.ts +1249 -0
  135. package/src/commands/generate-k8s.ts +1560 -0
  136. package/src/commands/generate-terraform.ts +1460 -0
  137. package/src/commands/gh/index.ts +863 -0
  138. package/src/commands/git/index.ts +1343 -0
  139. package/src/commands/helm/index.ts +1126 -0
  140. package/src/commands/help.ts +539 -0
  141. package/src/commands/history.ts +142 -0
  142. package/src/commands/import.ts +868 -0
  143. package/src/commands/index.ts +367 -0
  144. package/src/commands/init.ts +1046 -0
  145. package/src/commands/k8s/index.ts +1137 -0
  146. package/src/commands/login.ts +631 -0
  147. package/src/commands/logout.ts +83 -0
  148. package/src/commands/onboarding.ts +228 -0
  149. package/src/commands/plan/display.ts +279 -0
  150. package/src/commands/plan/index.ts +599 -0
  151. package/src/commands/preview.ts +452 -0
  152. package/src/commands/questionnaire.ts +1270 -0
  153. package/src/commands/resume.ts +55 -0
  154. package/src/commands/team/index.ts +346 -0
  155. package/src/commands/template.ts +232 -0
  156. package/src/commands/tf/index.ts +1034 -0
  157. package/src/commands/upgrade.ts +550 -0
  158. package/src/commands/usage/index.ts +134 -0
  159. package/src/commands/version.ts +170 -0
  160. package/src/compat/index.ts +2 -0
  161. package/src/compat/runtime.ts +12 -0
  162. package/src/compat/sqlite.ts +107 -0
  163. package/src/config/index.ts +17 -0
  164. package/src/config/manager.ts +530 -0
  165. package/src/config/safety-policy.ts +358 -0
  166. package/src/config/schema.ts +125 -0
  167. package/src/config/types.ts +527 -0
  168. package/src/context/context-db.ts +199 -0
  169. package/src/demo/index.ts +349 -0
  170. package/src/demo/scenarios/full-journey.ts +229 -0
  171. package/src/demo/scenarios/getting-started.ts +127 -0
  172. package/src/demo/scenarios/helm-release.ts +341 -0
  173. package/src/demo/scenarios/k8s-deployment.ts +194 -0
  174. package/src/demo/scenarios/terraform-vpc.ts +170 -0
  175. package/src/demo/types.ts +92 -0
  176. package/src/engine/cost-estimator.ts +438 -0
  177. package/src/engine/diagram-generator.ts +256 -0
  178. package/src/engine/drift-detector.ts +902 -0
  179. package/src/engine/executor.ts +1035 -0
  180. package/src/engine/index.ts +76 -0
  181. package/src/engine/orchestrator.ts +636 -0
  182. package/src/engine/planner.ts +720 -0
  183. package/src/engine/safety.ts +743 -0
  184. package/src/engine/verifier.ts +770 -0
  185. package/src/enterprise/audit.ts +348 -0
  186. package/src/enterprise/auth.ts +270 -0
  187. package/src/enterprise/billing.ts +822 -0
  188. package/src/enterprise/index.ts +17 -0
  189. package/src/enterprise/teams.ts +443 -0
  190. package/src/generator/best-practices.ts +1608 -0
  191. package/src/generator/helm.ts +630 -0
  192. package/src/generator/index.ts +37 -0
  193. package/src/generator/intent-parser.ts +514 -0
  194. package/src/generator/kubernetes.ts +976 -0
  195. package/src/generator/terraform.ts +1867 -0
  196. package/src/history/index.ts +8 -0
  197. package/src/history/manager.ts +322 -0
  198. package/src/history/types.ts +34 -0
  199. package/src/hooks/config.ts +432 -0
  200. package/src/hooks/engine.ts +391 -0
  201. package/src/hooks/index.ts +4 -0
  202. package/src/llm/auth-bridge.ts +198 -0
  203. package/src/llm/circuit-breaker.ts +140 -0
  204. package/src/llm/config-loader.ts +201 -0
  205. package/src/llm/cost-calculator.ts +171 -0
  206. package/src/llm/index.ts +8 -0
  207. package/src/llm/model-aliases.ts +115 -0
  208. package/src/llm/provider-registry.ts +63 -0
  209. package/src/llm/providers/anthropic.ts +433 -0
  210. package/src/llm/providers/bedrock.ts +477 -0
  211. package/src/llm/providers/google.ts +405 -0
  212. package/src/llm/providers/ollama.ts +767 -0
  213. package/src/llm/providers/openai-compatible.ts +340 -0
  214. package/src/llm/providers/openai.ts +328 -0
  215. package/src/llm/providers/openrouter.ts +338 -0
  216. package/src/llm/router.ts +1035 -0
  217. package/src/llm/types.ts +232 -0
  218. package/src/lsp/client.ts +298 -0
  219. package/src/lsp/languages.ts +116 -0
  220. package/src/lsp/manager.ts +278 -0
  221. package/src/mcp/client.ts +402 -0
  222. package/src/mcp/index.ts +5 -0
  223. package/src/mcp/manager.ts +133 -0
  224. package/src/nimbus.ts +214 -0
  225. package/src/plugins/index.ts +27 -0
  226. package/src/plugins/loader.ts +334 -0
  227. package/src/plugins/manager.ts +376 -0
  228. package/src/plugins/types.ts +284 -0
  229. package/src/scanners/cicd-scanner.ts +258 -0
  230. package/src/scanners/cloud-scanner.ts +466 -0
  231. package/src/scanners/framework-scanner.ts +469 -0
  232. package/src/scanners/iac-scanner.ts +388 -0
  233. package/src/scanners/index.ts +539 -0
  234. package/src/scanners/language-scanner.ts +276 -0
  235. package/src/scanners/package-manager-scanner.ts +277 -0
  236. package/src/scanners/types.ts +172 -0
  237. package/src/sessions/manager.ts +365 -0
  238. package/src/sessions/types.ts +44 -0
  239. package/src/sharing/sync.ts +296 -0
  240. package/src/sharing/viewer.ts +97 -0
  241. package/src/snapshots/index.ts +2 -0
  242. package/src/snapshots/manager.ts +530 -0
  243. package/src/state/artifacts.ts +147 -0
  244. package/src/state/audit.ts +137 -0
  245. package/src/state/billing.ts +240 -0
  246. package/src/state/checkpoints.ts +117 -0
  247. package/src/state/config.ts +67 -0
  248. package/src/state/conversations.ts +14 -0
  249. package/src/state/credentials.ts +154 -0
  250. package/src/state/db.ts +58 -0
  251. package/src/state/index.ts +26 -0
  252. package/src/state/messages.ts +115 -0
  253. package/src/state/projects.ts +123 -0
  254. package/src/state/schema.ts +236 -0
  255. package/src/state/sessions.ts +147 -0
  256. package/src/state/teams.ts +200 -0
  257. package/src/telemetry.ts +108 -0
  258. package/src/tools/aws-ops.ts +952 -0
  259. package/src/tools/azure-ops.ts +579 -0
  260. package/src/tools/file-ops.ts +593 -0
  261. package/src/tools/gcp-ops.ts +625 -0
  262. package/src/tools/git-ops.ts +773 -0
  263. package/src/tools/github-ops.ts +799 -0
  264. package/src/tools/helm-ops.ts +943 -0
  265. package/src/tools/index.ts +17 -0
  266. package/src/tools/k8s-ops.ts +819 -0
  267. package/src/tools/schemas/converter.ts +184 -0
  268. package/src/tools/schemas/devops.ts +612 -0
  269. package/src/tools/schemas/index.ts +73 -0
  270. package/src/tools/schemas/standard.ts +1144 -0
  271. package/src/tools/schemas/types.ts +705 -0
  272. package/src/tools/terraform-ops.ts +862 -0
  273. package/src/types/ambient.d.ts +193 -0
  274. package/src/types/config.ts +83 -0
  275. package/src/types/drift.ts +116 -0
  276. package/src/types/enterprise.ts +335 -0
  277. package/src/types/index.ts +20 -0
  278. package/src/types/plan.ts +44 -0
  279. package/src/types/request.ts +65 -0
  280. package/src/types/response.ts +54 -0
  281. package/src/types/service.ts +51 -0
  282. package/src/ui/App.tsx +997 -0
  283. package/src/ui/DeployPreview.tsx +169 -0
  284. package/src/ui/Header.tsx +68 -0
  285. package/src/ui/InputBox.tsx +350 -0
  286. package/src/ui/MessageList.tsx +585 -0
  287. package/src/ui/PermissionPrompt.tsx +151 -0
  288. package/src/ui/StatusBar.tsx +158 -0
  289. package/src/ui/ToolCallDisplay.tsx +409 -0
  290. package/src/ui/chat-ui.ts +853 -0
  291. package/src/ui/index.ts +33 -0
  292. package/src/ui/ink/index.ts +711 -0
  293. package/src/ui/streaming.ts +176 -0
  294. package/src/ui/types.ts +57 -0
  295. package/src/utils/analytics.ts +72 -0
  296. package/src/utils/cost-warning.ts +27 -0
  297. package/src/utils/env.ts +46 -0
  298. package/src/utils/errors.ts +69 -0
  299. package/src/utils/event-bus.ts +38 -0
  300. package/src/utils/index.ts +24 -0
  301. package/src/utils/logger.ts +171 -0
  302. package/src/utils/rate-limiter.ts +121 -0
  303. package/src/utils/service-auth.ts +49 -0
  304. package/src/utils/validation.ts +53 -0
  305. package/src/version.ts +4 -0
  306. package/src/watcher/index.ts +163 -0
  307. package/src/wizard/approval.ts +383 -0
  308. package/src/wizard/index.ts +25 -0
  309. package/src/wizard/prompts.ts +338 -0
  310. package/src/wizard/types.ts +171 -0
  311. package/src/wizard/ui.ts +556 -0
  312. package/src/wizard/wizard.ts +304 -0
  313. package/tsconfig.json +24 -0
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Snapshot Manager Tests
3
+ *
4
+ * Validates the SnapshotManager for both git-based and non-git (filesystem)
5
+ * projects. Tests cover snapshot capture/restore, undo/redo, history tracking,
6
+ * cleanup of old snapshots, and the static `shouldSnapshot` decision logic.
7
+ *
8
+ * Git-based tests use real temporary git repositories to exercise the actual
9
+ * git write-tree / read-tree / checkout-index workflow.
10
+ */
11
+
12
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import * as os from 'node:os';
16
+ import { execSync } from 'node:child_process';
17
+
18
+ import { SnapshotManager } from '../snapshots/manager';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** Create a temporary directory. */
25
+ function createTempDir(): string {
26
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'nimbus-snap-test-'));
27
+ }
28
+
29
+ /** Remove a temporary directory and all its contents. */
30
+ function removeTempDir(dir: string): void {
31
+ try {
32
+ fs.rmSync(dir, { recursive: true, force: true });
33
+ } catch {
34
+ // Best effort
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Initialize a temporary directory as a git repository with an initial commit.
40
+ * Returns the directory path.
41
+ */
42
+ function createTempGitRepo(): string {
43
+ const tmpDir = createTempDir();
44
+ execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
45
+ execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
46
+ execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
47
+ fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
48
+ execSync('git add -A && git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
49
+ return tmpDir;
50
+ }
51
+
52
+ /** Common snapshot params for testing. */
53
+ function snapshotParams(desc: string) {
54
+ return {
55
+ sessionId: 'test-session',
56
+ messageId: 'msg-001',
57
+ toolCallId: 'tc-001',
58
+ description: desc,
59
+ };
60
+ }
61
+
62
+ // ===========================================================================
63
+ // Constructor — project detection
64
+ // ===========================================================================
65
+
66
+ describe('SnapshotManager constructor', () => {
67
+ let gitDir: string;
68
+ let nonGitDir: string;
69
+
70
+ beforeEach(() => {
71
+ gitDir = createTempGitRepo();
72
+ nonGitDir = createTempDir();
73
+ });
74
+
75
+ afterEach(() => {
76
+ removeTempDir(gitDir);
77
+ removeTempDir(nonGitDir);
78
+ });
79
+
80
+ test('detects git project', () => {
81
+ const _manager = new SnapshotManager({ projectDir: gitDir });
82
+ // Capture a snapshot to verify it uses git (treeHash will be non-empty)
83
+ // We verify indirectly through the snapshot's isGitProject flag
84
+ expect(fs.existsSync(path.join(gitDir, '.git'))).toBe(true);
85
+ // The manager itself is opaque, but we can verify via a snapshot
86
+ });
87
+
88
+ test('detects non-git project', () => {
89
+ const _manager = new SnapshotManager({ projectDir: nonGitDir });
90
+ // For non-git projects, the manager creates the snapshot directory
91
+ expect(fs.existsSync(path.join(nonGitDir, '.nimbus', 'snapshots'))).toBe(true);
92
+ });
93
+ });
94
+
95
+ // ===========================================================================
96
+ // shouldSnapshot (static method)
97
+ // ===========================================================================
98
+
99
+ describe('SnapshotManager.shouldSnapshot', () => {
100
+ test('edit_file returns true', () => {
101
+ expect(SnapshotManager.shouldSnapshot('edit_file')).toBe(true);
102
+ });
103
+
104
+ test('multi_edit returns true', () => {
105
+ expect(SnapshotManager.shouldSnapshot('multi_edit')).toBe(true);
106
+ });
107
+
108
+ test('write_file returns true', () => {
109
+ expect(SnapshotManager.shouldSnapshot('write_file')).toBe(true);
110
+ });
111
+
112
+ test('bash with "rm -rf dist" returns true', () => {
113
+ expect(SnapshotManager.shouldSnapshot('bash', { command: 'rm -rf dist' })).toBe(true);
114
+ });
115
+
116
+ test('bash with "npm test" returns false', () => {
117
+ expect(SnapshotManager.shouldSnapshot('bash', { command: 'npm test' })).toBe(false);
118
+ });
119
+
120
+ test('read_file returns false', () => {
121
+ expect(SnapshotManager.shouldSnapshot('read_file')).toBe(false);
122
+ });
123
+
124
+ test('glob returns false', () => {
125
+ expect(SnapshotManager.shouldSnapshot('glob')).toBe(false);
126
+ });
127
+
128
+ test('bash with "mv old.txt new.txt" returns true', () => {
129
+ expect(SnapshotManager.shouldSnapshot('bash', { command: 'mv old.txt new.txt' })).toBe(true);
130
+ });
131
+
132
+ test('bash with "cp src dest" returns true', () => {
133
+ expect(SnapshotManager.shouldSnapshot('bash', { command: 'cp src dest' })).toBe(true);
134
+ });
135
+
136
+ test('bash with "echo hello > output.txt" returns true (redirect)', () => {
137
+ expect(SnapshotManager.shouldSnapshot('bash', { command: 'echo hello > output.txt' })).toBe(
138
+ true
139
+ );
140
+ });
141
+
142
+ test('bash with "ls -la" returns false', () => {
143
+ expect(SnapshotManager.shouldSnapshot('bash', { command: 'ls -la' })).toBe(false);
144
+ });
145
+
146
+ test('bash with "sed -i s/old/new/ file.txt" returns true', () => {
147
+ expect(SnapshotManager.shouldSnapshot('bash', { command: 'sed -i s/old/new/ file.txt' })).toBe(
148
+ true
149
+ );
150
+ });
151
+
152
+ test('bash with empty command returns false', () => {
153
+ expect(SnapshotManager.shouldSnapshot('bash', { command: '' })).toBe(false);
154
+ });
155
+
156
+ test('bash with no input returns false', () => {
157
+ expect(SnapshotManager.shouldSnapshot('bash')).toBe(false);
158
+ });
159
+
160
+ test('terraform returns false (not a file-modifying tool)', () => {
161
+ expect(SnapshotManager.shouldSnapshot('terraform')).toBe(false);
162
+ });
163
+
164
+ test('kubectl returns false', () => {
165
+ expect(SnapshotManager.shouldSnapshot('kubectl')).toBe(false);
166
+ });
167
+ });
168
+
169
+ // ===========================================================================
170
+ // Git-based snapshot capture and restore
171
+ // ===========================================================================
172
+
173
+ describe('Git-based snapshots', () => {
174
+ let gitDir: string;
175
+
176
+ beforeEach(() => {
177
+ gitDir = createTempGitRepo();
178
+ });
179
+
180
+ afterEach(() => {
181
+ removeTempDir(gitDir);
182
+ });
183
+
184
+ test('captureSnapshot creates a snapshot in a git project', async () => {
185
+ const manager = new SnapshotManager({ projectDir: gitDir });
186
+ const snap = await manager.captureSnapshot(snapshotParams('edit_file: file.txt'));
187
+
188
+ expect(snap.id).toBeTruthy();
189
+ expect(snap.treeHash).toBeTruthy();
190
+ expect(snap.treeHash.length).toBeGreaterThan(0);
191
+ expect(snap.isGitProject).toBe(true);
192
+ expect(snap.sessionId).toBe('test-session');
193
+ expect(snap.description).toBe('edit_file: file.txt');
194
+ expect(snap.timestamp).toBeInstanceOf(Date);
195
+ });
196
+
197
+ test('restoreSnapshot restores files in a git project', async () => {
198
+ const manager = new SnapshotManager({ projectDir: gitDir });
199
+ const filePath = path.join(gitDir, 'file.txt');
200
+
201
+ // Capture state before modification
202
+ const before = await manager.captureSnapshot(snapshotParams('before edit'));
203
+
204
+ // Modify the file
205
+ fs.writeFileSync(filePath, 'modified content');
206
+ execSync('git add -A', { cwd: gitDir, stdio: 'pipe' });
207
+
208
+ // Capture state after modification
209
+ await manager.captureSnapshot(snapshotParams('after edit'));
210
+
211
+ // Verify file was modified
212
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('modified content');
213
+
214
+ // Restore to the before-edit state
215
+ const result = await manager.restoreSnapshot(before.id);
216
+ expect(result.restored).toBe(true);
217
+ expect(result.description).toContain('before edit');
218
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('original');
219
+ });
220
+
221
+ test('restoreSnapshot returns false for unknown snapshot id', async () => {
222
+ const manager = new SnapshotManager({ projectDir: gitDir });
223
+ const result = await manager.restoreSnapshot('nonexistent-id');
224
+ expect(result.restored).toBe(false);
225
+ expect(result.description).toContain('not found');
226
+ });
227
+
228
+ test('undo reverts the last change', async () => {
229
+ const manager = new SnapshotManager({ projectDir: gitDir });
230
+ const filePath = path.join(gitDir, 'file.txt');
231
+
232
+ // Capture initial state
233
+ await manager.captureSnapshot(snapshotParams('initial'));
234
+
235
+ // Modify and capture
236
+ fs.writeFileSync(filePath, 'changed');
237
+ execSync('git add -A', { cwd: gitDir, stdio: 'pipe' });
238
+ await manager.captureSnapshot(snapshotParams('edit_file: file.txt'));
239
+
240
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('changed');
241
+
242
+ // Undo
243
+ const result = await manager.undo();
244
+ expect(result.success).toBe(true);
245
+ expect(result.description).toContain('Undone');
246
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('original');
247
+ });
248
+
249
+ test('redo re-applies undone change', async () => {
250
+ const manager = new SnapshotManager({ projectDir: gitDir });
251
+ const filePath = path.join(gitDir, 'file.txt');
252
+
253
+ // Capture initial state
254
+ await manager.captureSnapshot(snapshotParams('initial'));
255
+
256
+ // Modify and capture
257
+ fs.writeFileSync(filePath, 'changed');
258
+ execSync('git add -A', { cwd: gitDir, stdio: 'pipe' });
259
+ await manager.captureSnapshot(snapshotParams('edit_file: file.txt'));
260
+
261
+ // Undo
262
+ await manager.undo();
263
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('original');
264
+
265
+ // Redo
266
+ const result = await manager.redo();
267
+ expect(result.success).toBe(true);
268
+ expect(result.description).toContain('Redone');
269
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('changed');
270
+ });
271
+
272
+ test('undo with only one snapshot returns failure', async () => {
273
+ const manager = new SnapshotManager({ projectDir: gitDir });
274
+ await manager.captureSnapshot(snapshotParams('only one'));
275
+ const result = await manager.undo();
276
+ expect(result.success).toBe(false);
277
+ expect(result.description).toContain('Nothing to undo');
278
+ });
279
+
280
+ test('redo with empty redo stack returns failure', async () => {
281
+ const manager = new SnapshotManager({ projectDir: gitDir });
282
+ const result = await manager.redo();
283
+ expect(result.success).toBe(false);
284
+ expect(result.description).toContain('Nothing to redo');
285
+ });
286
+
287
+ test('new capture clears the redo stack', async () => {
288
+ const manager = new SnapshotManager({ projectDir: gitDir });
289
+ const filePath = path.join(gitDir, 'file.txt');
290
+
291
+ // initial -> edit -> undo -> new capture -> redo should fail
292
+ await manager.captureSnapshot(snapshotParams('initial'));
293
+
294
+ fs.writeFileSync(filePath, 'v2');
295
+ execSync('git add -A', { cwd: gitDir, stdio: 'pipe' });
296
+ await manager.captureSnapshot(snapshotParams('v2'));
297
+
298
+ await manager.undo();
299
+
300
+ // Now capture a new state (this should clear the redo stack)
301
+ fs.writeFileSync(filePath, 'v3');
302
+ execSync('git add -A', { cwd: gitDir, stdio: 'pipe' });
303
+ await manager.captureSnapshot(snapshotParams('v3'));
304
+
305
+ // Redo should fail since the stack was cleared
306
+ const result = await manager.redo();
307
+ expect(result.success).toBe(false);
308
+ expect(result.description).toContain('Nothing to redo');
309
+ });
310
+ });
311
+
312
+ // ===========================================================================
313
+ // getHistory and count
314
+ // ===========================================================================
315
+
316
+ describe('getHistory and count', () => {
317
+ let gitDir: string;
318
+
319
+ beforeEach(() => {
320
+ gitDir = createTempGitRepo();
321
+ });
322
+
323
+ afterEach(() => {
324
+ removeTempDir(gitDir);
325
+ });
326
+
327
+ test('getHistory returns all snapshots', async () => {
328
+ const manager = new SnapshotManager({ projectDir: gitDir });
329
+
330
+ await manager.captureSnapshot(snapshotParams('first'));
331
+ await manager.captureSnapshot(snapshotParams('second'));
332
+ await manager.captureSnapshot(snapshotParams('third'));
333
+
334
+ const history = manager.getHistory();
335
+ expect(history).toHaveLength(3);
336
+ expect(history[0].description).toBe('first');
337
+ expect(history[1].description).toBe('second');
338
+ expect(history[2].description).toBe('third');
339
+ });
340
+
341
+ test('getHistory filters by sessionId', async () => {
342
+ const manager = new SnapshotManager({ projectDir: gitDir });
343
+
344
+ await manager.captureSnapshot({
345
+ sessionId: 'session-a',
346
+ messageId: 'msg-1',
347
+ toolCallId: 'tc-1',
348
+ description: 'a-first',
349
+ });
350
+ await manager.captureSnapshot({
351
+ sessionId: 'session-b',
352
+ messageId: 'msg-2',
353
+ toolCallId: 'tc-2',
354
+ description: 'b-first',
355
+ });
356
+ await manager.captureSnapshot({
357
+ sessionId: 'session-a',
358
+ messageId: 'msg-3',
359
+ toolCallId: 'tc-3',
360
+ description: 'a-second',
361
+ });
362
+
363
+ const sessionA = manager.getHistory('session-a');
364
+ expect(sessionA).toHaveLength(2);
365
+ expect(sessionA[0].description).toBe('a-first');
366
+ expect(sessionA[1].description).toBe('a-second');
367
+
368
+ const sessionB = manager.getHistory('session-b');
369
+ expect(sessionB).toHaveLength(1);
370
+ expect(sessionB[0].description).toBe('b-first');
371
+ });
372
+
373
+ test('count getter returns correct count', async () => {
374
+ const manager = new SnapshotManager({ projectDir: gitDir });
375
+
376
+ expect(manager.count).toBe(0);
377
+
378
+ await manager.captureSnapshot(snapshotParams('first'));
379
+ expect(manager.count).toBe(1);
380
+
381
+ await manager.captureSnapshot(snapshotParams('second'));
382
+ expect(manager.count).toBe(2);
383
+ });
384
+
385
+ test('count reflects undo operations', async () => {
386
+ const manager = new SnapshotManager({ projectDir: gitDir });
387
+
388
+ await manager.captureSnapshot(snapshotParams('first'));
389
+ await manager.captureSnapshot(snapshotParams('second'));
390
+ expect(manager.count).toBe(2);
391
+
392
+ await manager.undo();
393
+ expect(manager.count).toBe(1);
394
+ });
395
+ });
396
+
397
+ // ===========================================================================
398
+ // cleanup
399
+ // ===========================================================================
400
+
401
+ describe('cleanup', () => {
402
+ let gitDir: string;
403
+
404
+ beforeEach(() => {
405
+ gitDir = createTempGitRepo();
406
+ });
407
+
408
+ afterEach(() => {
409
+ removeTempDir(gitDir);
410
+ });
411
+
412
+ test('removes old snapshots exceeding maxSnapshots', async () => {
413
+ const manager = new SnapshotManager({
414
+ projectDir: gitDir,
415
+ maxSnapshots: 2,
416
+ });
417
+
418
+ await manager.captureSnapshot(snapshotParams('first'));
419
+ await manager.captureSnapshot(snapshotParams('second'));
420
+ await manager.captureSnapshot(snapshotParams('third'));
421
+ await manager.captureSnapshot(snapshotParams('fourth'));
422
+
423
+ expect(manager.count).toBe(4);
424
+
425
+ const removed = await manager.cleanup();
426
+ expect(removed).toBe(2);
427
+ expect(manager.count).toBe(2);
428
+
429
+ // The remaining snapshots should be the most recent ones
430
+ const history = manager.getHistory();
431
+ expect(history[0].description).toBe('third');
432
+ expect(history[1].description).toBe('fourth');
433
+ });
434
+
435
+ test('cleanup returns 0 when nothing to clean', async () => {
436
+ const manager = new SnapshotManager({
437
+ projectDir: gitDir,
438
+ maxSnapshots: 100,
439
+ });
440
+
441
+ await manager.captureSnapshot(snapshotParams('first'));
442
+ const removed = await manager.cleanup();
443
+ expect(removed).toBe(0);
444
+ expect(manager.count).toBe(1);
445
+ });
446
+ });
447
+
448
+ // ===========================================================================
449
+ // Non-git (filesystem) snapshots
450
+ // ===========================================================================
451
+
452
+ describe('Non-git snapshots', () => {
453
+ let nonGitDir: string;
454
+
455
+ beforeEach(() => {
456
+ nonGitDir = createTempDir();
457
+ });
458
+
459
+ afterEach(() => {
460
+ removeTempDir(nonGitDir);
461
+ });
462
+
463
+ test('capture creates a snapshot directory for non-git project', async () => {
464
+ const filePath = path.join(nonGitDir, 'app.js');
465
+ fs.writeFileSync(filePath, 'console.log("hello");');
466
+
467
+ const manager = new SnapshotManager({ projectDir: nonGitDir });
468
+ const snap = await manager.captureSnapshot(snapshotParams('write_file: app.js'));
469
+
470
+ expect(snap.isGitProject).toBe(false);
471
+ expect(snap.treeHash).toBe('');
472
+ expect(snap.id).toBeTruthy();
473
+
474
+ // Verify the snapshot directory was created
475
+ const snapDir = path.join(nonGitDir, '.nimbus', 'snapshots', snap.id);
476
+ expect(fs.existsSync(snapDir)).toBe(true);
477
+
478
+ // Verify the file was copied
479
+ expect(fs.existsSync(path.join(snapDir, 'app.js'))).toBe(true);
480
+ expect(fs.readFileSync(path.join(snapDir, 'app.js'), 'utf-8')).toBe('console.log("hello");');
481
+ });
482
+
483
+ test('restore recovers files for non-git project', async () => {
484
+ const filePath = path.join(nonGitDir, 'data.txt');
485
+ fs.writeFileSync(filePath, 'version 1');
486
+
487
+ const manager = new SnapshotManager({ projectDir: nonGitDir });
488
+
489
+ // Capture v1
490
+ const snap1 = await manager.captureSnapshot(snapshotParams('v1'));
491
+
492
+ // Modify to v2
493
+ fs.writeFileSync(filePath, 'version 2');
494
+ await manager.captureSnapshot(snapshotParams('v2'));
495
+
496
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('version 2');
497
+
498
+ // Restore v1
499
+ const result = await manager.restoreSnapshot(snap1.id);
500
+ expect(result.restored).toBe(true);
501
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('version 1');
502
+ });
503
+
504
+ test('undo works for non-git project', async () => {
505
+ const filePath = path.join(nonGitDir, 'code.py');
506
+ fs.writeFileSync(filePath, 'print("v1")');
507
+
508
+ const manager = new SnapshotManager({ projectDir: nonGitDir });
509
+
510
+ await manager.captureSnapshot(snapshotParams('initial'));
511
+
512
+ fs.writeFileSync(filePath, 'print("v2")');
513
+ await manager.captureSnapshot(snapshotParams('edit'));
514
+
515
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('print("v2")');
516
+
517
+ const result = await manager.undo();
518
+ expect(result.success).toBe(true);
519
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('print("v1")');
520
+ });
521
+
522
+ test('redo works for non-git project', async () => {
523
+ const filePath = path.join(nonGitDir, 'code.py');
524
+ fs.writeFileSync(filePath, 'print("v1")');
525
+
526
+ const manager = new SnapshotManager({ projectDir: nonGitDir });
527
+
528
+ await manager.captureSnapshot(snapshotParams('initial'));
529
+
530
+ fs.writeFileSync(filePath, 'print("v2")');
531
+ await manager.captureSnapshot(snapshotParams('edit'));
532
+
533
+ await manager.undo();
534
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('print("v1")');
535
+
536
+ const result = await manager.redo();
537
+ expect(result.success).toBe(true);
538
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('print("v2")');
539
+ });
540
+
541
+ test('cleanup removes snapshot directories for non-git project', async () => {
542
+ fs.writeFileSync(path.join(nonGitDir, 'f.txt'), 'data');
543
+
544
+ const manager = new SnapshotManager({
545
+ projectDir: nonGitDir,
546
+ maxSnapshots: 1,
547
+ });
548
+
549
+ const snap1 = await manager.captureSnapshot(snapshotParams('first'));
550
+ await manager.captureSnapshot(snapshotParams('second'));
551
+ await manager.captureSnapshot(snapshotParams('third'));
552
+
553
+ // Before cleanup, all snapshot dirs exist
554
+ const snap1Dir = path.join(nonGitDir, '.nimbus', 'snapshots', snap1.id);
555
+ expect(fs.existsSync(snap1Dir)).toBe(true);
556
+
557
+ const removed = await manager.cleanup();
558
+ expect(removed).toBe(2);
559
+ expect(manager.count).toBe(1);
560
+
561
+ // The oldest snapshot directory should be cleaned up
562
+ expect(fs.existsSync(snap1Dir)).toBe(false);
563
+ });
564
+
565
+ test('non-git snapshot skips node_modules and .git directories', async () => {
566
+ // Create directories that should be skipped
567
+ fs.mkdirSync(path.join(nonGitDir, 'node_modules'), { recursive: true });
568
+ fs.writeFileSync(path.join(nonGitDir, 'node_modules', 'pkg.json'), '{}');
569
+ fs.mkdirSync(path.join(nonGitDir, 'src'), { recursive: true });
570
+ fs.writeFileSync(path.join(nonGitDir, 'src', 'index.ts'), 'export {};');
571
+
572
+ const manager = new SnapshotManager({ projectDir: nonGitDir });
573
+ const snap = await manager.captureSnapshot(snapshotParams('with-node-modules'));
574
+
575
+ const snapDir = path.join(nonGitDir, '.nimbus', 'snapshots', snap.id);
576
+ // node_modules should NOT be copied
577
+ expect(fs.existsSync(path.join(snapDir, 'node_modules'))).toBe(false);
578
+ // src should be copied
579
+ expect(fs.existsSync(path.join(snapDir, 'src', 'index.ts'))).toBe(true);
580
+ });
581
+ });