stagent 0.9.5 → 0.10.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 (277) hide show
  1. package/README.md +5 -42
  2. package/dist/cli.js +42 -18
  3. package/docs/.coverage-gaps.json +13 -55
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/provider-runtimes.md +4 -0
  6. package/docs/features/schedules.md +32 -4
  7. package/docs/features/settings.md +28 -5
  8. package/docs/features/tables.md +9 -2
  9. package/docs/features/workflows.md +10 -4
  10. package/docs/journeys/developer.md +15 -1
  11. package/docs/journeys/personal-use.md +21 -4
  12. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  13. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  14. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  15. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  16. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  17. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  18. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  19. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  20. package/package.json +3 -2
  21. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  22. package/src/app/analytics/page.tsx +1 -21
  23. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  24. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  25. package/src/app/api/instance/config/route.ts +41 -0
  26. package/src/app/api/instance/init/route.ts +34 -0
  27. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  28. package/src/app/api/instance/upgrade/route.ts +96 -0
  29. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  30. package/src/app/api/memory/route.ts +0 -11
  31. package/src/app/api/notifications/route.ts +4 -2
  32. package/src/app/api/projects/[id]/route.ts +5 -155
  33. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  34. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  35. package/src/app/api/schedules/[id]/route.ts +9 -1
  36. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  37. package/src/app/api/schedules/route.ts +3 -12
  38. package/src/app/api/settings/openai/login/route.ts +22 -0
  39. package/src/app/api/settings/openai/logout/route.ts +7 -0
  40. package/src/app/api/settings/openai/route.ts +21 -1
  41. package/src/app/api/settings/providers/route.ts +35 -8
  42. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  43. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  44. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  45. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  46. package/src/app/api/tasks/[id]/execute/route.ts +0 -21
  47. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  48. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  49. package/src/app/api/workspace/context/route.ts +2 -0
  50. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  51. package/src/app/chat/page.tsx +11 -0
  52. package/src/app/inbox/page.tsx +12 -5
  53. package/src/app/layout.tsx +42 -21
  54. package/src/app/page.tsx +0 -2
  55. package/src/app/settings/page.tsx +6 -9
  56. package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
  57. package/src/components/chat/chat-command-popover.tsx +2 -2
  58. package/src/components/chat/chat-input.tsx +2 -3
  59. package/src/components/chat/chat-session-provider.tsx +720 -0
  60. package/src/components/chat/chat-shell.tsx +92 -401
  61. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  62. package/src/components/instance/instance-section.tsx +382 -0
  63. package/src/components/instance/upgrade-badge.tsx +219 -0
  64. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  65. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  66. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  67. package/src/components/notifications/inbox-list.tsx +11 -2
  68. package/src/components/notifications/notification-item.tsx +56 -2
  69. package/src/components/notifications/pending-approval-host.tsx +56 -37
  70. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  71. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  72. package/src/components/schedules/schedule-form.tsx +31 -0
  73. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  74. package/src/components/settings/auth-method-selector.tsx +19 -4
  75. package/src/components/settings/auth-status-badge.tsx +28 -3
  76. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  77. package/src/components/settings/openai-runtime-section.tsx +7 -1
  78. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  79. package/src/components/shared/app-sidebar.tsx +4 -3
  80. package/src/components/shared/command-palette.tsx +4 -5
  81. package/src/components/shared/theme-toggle.tsx +5 -24
  82. package/src/components/shared/workspace-indicator.tsx +61 -2
  83. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  84. package/src/components/tables/table-create-sheet.tsx +4 -0
  85. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  86. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  87. package/src/components/tables/table-spreadsheet.tsx +29 -5
  88. package/src/components/tables/table-toolbar.tsx +10 -1
  89. package/src/components/tasks/kanban-board.tsx +1 -0
  90. package/src/components/tasks/kanban-column.tsx +53 -14
  91. package/src/components/tasks/task-bento-grid.tsx +19 -0
  92. package/src/components/tasks/task-card.tsx +26 -3
  93. package/src/components/tasks/task-chip-bar.tsx +24 -0
  94. package/src/components/tasks/task-result-renderer.tsx +1 -1
  95. package/src/components/workflows/delay-step-body.tsx +109 -0
  96. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  97. package/src/components/workflows/loop-status-view.tsx +1 -1
  98. package/src/components/workflows/shared/step-result.tsx +78 -0
  99. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  100. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  101. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  102. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  103. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  104. package/src/components/workflows/workflow-form-view.tsx +133 -16
  105. package/src/components/workflows/workflow-status-view.tsx +30 -740
  106. package/src/instrumentation-node.ts +94 -0
  107. package/src/instrumentation.ts +4 -48
  108. package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
  109. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  110. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  111. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  112. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  113. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  114. package/src/lib/agents/claude-agent.ts +155 -18
  115. package/src/lib/agents/execution-manager.ts +0 -35
  116. package/src/lib/agents/learned-context.ts +0 -12
  117. package/src/lib/agents/learning-session.ts +18 -5
  118. package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
  119. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
  120. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
  121. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  122. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  123. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  124. package/src/lib/agents/runtime/openai-codex.ts +29 -60
  125. package/src/lib/agents/runtime/types.ts +8 -0
  126. package/src/lib/book/chapter-mapping.ts +11 -0
  127. package/src/lib/book/content.ts +10 -0
  128. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  129. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  130. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  131. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  132. package/src/lib/chat/active-streams.ts +27 -0
  133. package/src/lib/chat/codex-engine.ts +16 -17
  134. package/src/lib/chat/context-builder.ts +5 -3
  135. package/src/lib/chat/engine.ts +50 -3
  136. package/src/lib/chat/reconcile.ts +117 -0
  137. package/src/lib/chat/stagent-tools.ts +1 -0
  138. package/src/lib/chat/stream-telemetry.ts +132 -0
  139. package/src/lib/chat/suggested-prompts.ts +28 -1
  140. package/src/lib/chat/system-prompt.ts +26 -1
  141. package/src/lib/chat/tool-catalog.ts +2 -1
  142. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  143. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  144. package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
  145. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
  146. package/src/lib/chat/tools/document-tools.ts +29 -13
  147. package/src/lib/chat/tools/helpers.ts +39 -0
  148. package/src/lib/chat/tools/notification-tools.ts +9 -5
  149. package/src/lib/chat/tools/project-tools.ts +33 -0
  150. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  151. package/src/lib/chat/tools/table-tools.ts +71 -0
  152. package/src/lib/chat/tools/task-tools.ts +84 -20
  153. package/src/lib/chat/tools/workflow-tools.ts +234 -32
  154. package/src/lib/constants/settings.ts +8 -18
  155. package/src/lib/data/__tests__/clear.test.ts +56 -2
  156. package/src/lib/data/clear.ts +20 -15
  157. package/src/lib/data/delete-project.ts +171 -0
  158. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  159. package/src/lib/db/bootstrap.ts +45 -16
  160. package/src/lib/db/index.ts +5 -0
  161. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  162. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  163. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  164. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  165. package/src/lib/db/migrations/meta/_journal.json +21 -0
  166. package/src/lib/db/schema.ts +68 -23
  167. package/src/lib/environment/workspace-context.ts +13 -1
  168. package/src/lib/import/dedup.ts +4 -54
  169. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  170. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  171. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  172. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  173. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  174. package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
  175. package/src/lib/instance/bootstrap.ts +270 -0
  176. package/src/lib/instance/detect.ts +49 -0
  177. package/src/lib/instance/fingerprint.ts +78 -0
  178. package/src/lib/instance/git-ops.ts +95 -0
  179. package/src/lib/instance/settings.ts +61 -0
  180. package/src/lib/instance/types.ts +77 -0
  181. package/src/lib/instance/upgrade-poller.ts +153 -0
  182. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  183. package/src/lib/notifications/visibility.ts +33 -0
  184. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  185. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  186. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  187. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  188. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  189. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  190. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  191. package/src/lib/schedules/collision-check.ts +105 -0
  192. package/src/lib/schedules/config.ts +53 -0
  193. package/src/lib/schedules/scheduler.ts +232 -13
  194. package/src/lib/schedules/slot-claim.ts +105 -0
  195. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  196. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  197. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  198. package/src/lib/settings/openai-auth.ts +105 -10
  199. package/src/lib/settings/openai-login-manager.ts +260 -0
  200. package/src/lib/settings/runtime-setup.ts +14 -4
  201. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  202. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  203. package/src/lib/tables/enrichment-planner.ts +454 -0
  204. package/src/lib/tables/enrichment.ts +328 -0
  205. package/src/lib/tables/query-builder.ts +5 -2
  206. package/src/lib/tables/trigger-evaluator.ts +3 -2
  207. package/src/lib/theme.ts +71 -0
  208. package/src/lib/usage/ledger.ts +2 -18
  209. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  210. package/src/lib/util/similarity.ts +77 -0
  211. package/src/lib/utils/format-timestamp.ts +24 -0
  212. package/src/lib/utils/stagent-paths.ts +12 -0
  213. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  214. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  215. package/src/lib/validators/blueprint.ts +70 -9
  216. package/src/lib/validators/profile.ts +2 -2
  217. package/src/lib/validators/settings.ts +3 -1
  218. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  219. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  220. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  221. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  222. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  223. package/src/lib/workflows/blueprints/types.ts +10 -2
  224. package/src/lib/workflows/delay.ts +106 -0
  225. package/src/lib/workflows/engine.ts +207 -4
  226. package/src/lib/workflows/loop-executor.ts +349 -24
  227. package/src/lib/workflows/post-action.ts +91 -0
  228. package/src/lib/workflows/types.ts +166 -1
  229. package/src/app/api/license/checkout/route.ts +0 -28
  230. package/src/app/api/license/portal/route.ts +0 -26
  231. package/src/app/api/license/route.ts +0 -89
  232. package/src/app/api/license/usage/route.ts +0 -63
  233. package/src/app/api/marketplace/browse/route.ts +0 -15
  234. package/src/app/api/marketplace/import/route.ts +0 -28
  235. package/src/app/api/marketplace/publish/route.ts +0 -40
  236. package/src/app/api/onboarding/email/route.ts +0 -53
  237. package/src/app/api/settings/telemetry/route.ts +0 -14
  238. package/src/app/api/sync/export/route.ts +0 -54
  239. package/src/app/api/sync/restore/route.ts +0 -37
  240. package/src/app/api/sync/sessions/route.ts +0 -24
  241. package/src/app/auth/callback/route.ts +0 -73
  242. package/src/app/marketplace/page.tsx +0 -19
  243. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  244. package/src/components/marketplace/blueprint-card.tsx +0 -61
  245. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  246. package/src/components/onboarding/email-capture-card.tsx +0 -104
  247. package/src/components/settings/activation-form.tsx +0 -95
  248. package/src/components/settings/cloud-account-section.tsx +0 -147
  249. package/src/components/settings/cloud-sync-section.tsx +0 -155
  250. package/src/components/settings/subscription-section.tsx +0 -410
  251. package/src/components/settings/telemetry-section.tsx +0 -80
  252. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  253. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  254. package/src/components/shared/upgrade-banner.tsx +0 -112
  255. package/src/hooks/use-supabase-auth.ts +0 -79
  256. package/src/lib/billing/email.ts +0 -54
  257. package/src/lib/billing/products.ts +0 -80
  258. package/src/lib/billing/stripe.ts +0 -101
  259. package/src/lib/cloud/supabase-browser.ts +0 -32
  260. package/src/lib/cloud/supabase-client.ts +0 -56
  261. package/src/lib/license/__tests__/features.test.ts +0 -56
  262. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  263. package/src/lib/license/__tests__/manager.test.ts +0 -64
  264. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  265. package/src/lib/license/cloud-validation.ts +0 -60
  266. package/src/lib/license/features.ts +0 -44
  267. package/src/lib/license/key-format.ts +0 -101
  268. package/src/lib/license/limit-check.ts +0 -111
  269. package/src/lib/license/limit-queries.ts +0 -51
  270. package/src/lib/license/manager.ts +0 -345
  271. package/src/lib/license/notifications.ts +0 -59
  272. package/src/lib/license/tier-limits.ts +0 -71
  273. package/src/lib/marketplace/marketplace-client.ts +0 -107
  274. package/src/lib/sync/cloud-sync.ts +0 -235
  275. package/src/lib/telemetry/conversion-events.ts +0 -71
  276. package/src/lib/telemetry/queue.ts +0 -122
  277. package/src/lib/validators/license.ts +0 -33
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir, homedir } from "os";
5
+
6
+ let tempDir: string;
7
+ let gitDir: string;
8
+
9
+ beforeEach(() => {
10
+ tempDir = mkdtempSync(join(tmpdir(), "stagent-detect-"));
11
+ gitDir = join(tempDir, ".git");
12
+ mkdirSync(gitDir, { recursive: true });
13
+ vi.resetModules();
14
+ vi.unstubAllEnvs();
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.unstubAllEnvs();
19
+ rmSync(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ async function loadDetect() {
23
+ return await import("../detect");
24
+ }
25
+
26
+ describe("isDevMode", () => {
27
+ it("returns true when STAGENT_DEV_MODE=true", async () => {
28
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
29
+ const { isDevMode } = await loadDetect();
30
+ expect(isDevMode(tempDir)).toBe(true);
31
+ });
32
+
33
+ it("returns true when .git/stagent-dev-mode sentinel file exists", async () => {
34
+ writeFileSync(join(gitDir, "stagent-dev-mode"), "");
35
+ const { isDevMode } = await loadDetect();
36
+ expect(isDevMode(tempDir)).toBe(true);
37
+ });
38
+
39
+ it("returns false when neither gate is set", async () => {
40
+ const { isDevMode } = await loadDetect();
41
+ expect(isDevMode(tempDir)).toBe(false);
42
+ });
43
+
44
+ it("returns false when STAGENT_INSTANCE_MODE=true overrides env gate", async () => {
45
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
46
+ vi.stubEnv("STAGENT_INSTANCE_MODE", "true");
47
+ const { isDevMode } = await loadDetect();
48
+ expect(isDevMode(tempDir)).toBe(false);
49
+ });
50
+
51
+ it("returns false when STAGENT_INSTANCE_MODE=true overrides sentinel gate", async () => {
52
+ writeFileSync(join(gitDir, "stagent-dev-mode"), "");
53
+ vi.stubEnv("STAGENT_INSTANCE_MODE", "true");
54
+ const { isDevMode } = await loadDetect();
55
+ expect(isDevMode(tempDir)).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe("hasGitDir", () => {
60
+ it("returns true when .git directory exists", async () => {
61
+ const { hasGitDir } = await loadDetect();
62
+ expect(hasGitDir(tempDir)).toBe(true);
63
+ });
64
+
65
+ it("returns false when .git is absent", async () => {
66
+ rmSync(gitDir, { recursive: true, force: true });
67
+ const { hasGitDir } = await loadDetect();
68
+ expect(hasGitDir(tempDir)).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe("isPrivateInstance", () => {
73
+ it("returns false when STAGENT_DATA_DIR is unset", async () => {
74
+ vi.stubEnv("STAGENT_DATA_DIR", "");
75
+ const { isPrivateInstance } = await loadDetect();
76
+ expect(isPrivateInstance()).toBe(false);
77
+ });
78
+
79
+ it("returns false when STAGENT_DATA_DIR equals default ~/.stagent", async () => {
80
+ vi.stubEnv("STAGENT_DATA_DIR", join(homedir(), ".stagent"));
81
+ const { isPrivateInstance } = await loadDetect();
82
+ expect(isPrivateInstance()).toBe(false);
83
+ });
84
+
85
+ it("returns true when STAGENT_DATA_DIR is a custom path", async () => {
86
+ vi.stubEnv("STAGENT_DATA_DIR", "/Users/navam/.stagent-wealth");
87
+ const { isPrivateInstance } = await loadDetect();
88
+ expect(isPrivateInstance()).toBe(true);
89
+ });
90
+
91
+ it("returns false when STAGENT_DATA_DIR equals default with trailing slash", async () => {
92
+ vi.stubEnv("STAGENT_DATA_DIR", join(homedir(), ".stagent") + "/");
93
+ const { isPrivateInstance } = await loadDetect();
94
+ expect(isPrivateInstance()).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe("detectRebaseInProgress", () => {
99
+ it("returns true when .git/rebase-merge exists", async () => {
100
+ mkdirSync(join(gitDir, "rebase-merge"));
101
+ const { detectRebaseInProgress } = await loadDetect();
102
+ expect(detectRebaseInProgress(tempDir)).toBe(true);
103
+ });
104
+
105
+ it("returns true when .git/rebase-apply exists", async () => {
106
+ mkdirSync(join(gitDir, "rebase-apply"));
107
+ const { detectRebaseInProgress } = await loadDetect();
108
+ expect(detectRebaseInProgress(tempDir)).toBe(true);
109
+ });
110
+
111
+ it("returns false when no rebase state directories exist", async () => {
112
+ const { detectRebaseInProgress } = await loadDetect();
113
+ expect(detectRebaseInProgress(tempDir)).toBe(false);
114
+ });
115
+ });
@@ -0,0 +1,48 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ beforeEach(() => {
4
+ vi.resetModules();
5
+ });
6
+
7
+ afterEach(() => {
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ describe("getMachineFingerprint", () => {
12
+ it("returns a 64-character hex SHA-256 string", async () => {
13
+ const { getMachineFingerprint, _resetFingerprintCache } = await import("../fingerprint");
14
+ _resetFingerprintCache();
15
+ const fp = getMachineFingerprint();
16
+ expect(fp).toMatch(/^[a-f0-9]{64}$/);
17
+ });
18
+
19
+ it("is stable across multiple calls in the same process (memoized)", async () => {
20
+ const { getMachineFingerprint, _resetFingerprintCache } = await import("../fingerprint");
21
+ _resetFingerprintCache();
22
+ const fp1 = getMachineFingerprint();
23
+ const fp2 = getMachineFingerprint();
24
+ const fp3 = getMachineFingerprint();
25
+ expect(fp1).toBe(fp2);
26
+ expect(fp2).toBe(fp3);
27
+ });
28
+
29
+ it("does not contain the raw hostname, username, or MAC address", async () => {
30
+ const os = await import("os");
31
+ const host = os.hostname();
32
+ const user = os.userInfo().username;
33
+
34
+ const { getMachineFingerprint, _resetFingerprintCache } = await import("../fingerprint");
35
+ _resetFingerprintCache();
36
+ const fp = getMachineFingerprint();
37
+
38
+ expect(fp).not.toContain(host);
39
+ expect(fp).not.toContain(user);
40
+ expect(fp).not.toMatch(/[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}/i); // no MAC pattern
41
+ });
42
+
43
+ // Note: an ideal test here would mock os.hostname() to verify the
44
+ // fingerprint changes when hostname changes, but Node's "os" module
45
+ // exports are non-configurable in ESM and vi.spyOn throws
46
+ // "Cannot redefine property: hostname". The behavior is covered
47
+ // indirectly by the memoization and composition tests above.
48
+ });
@@ -0,0 +1,95 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { execFileSync } from "child_process";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+
7
+ let tempDir: string;
8
+
9
+ function runGit(args: string[], cwd: string) {
10
+ execFileSync("git", args, { cwd, stdio: "pipe" });
11
+ }
12
+
13
+ beforeEach(() => {
14
+ tempDir = mkdtempSync(join(tmpdir(), "stagent-git-ops-"));
15
+ runGit(["init", "-b", "main"], tempDir);
16
+ runGit(["config", "user.email", "test@example.com"], tempDir);
17
+ runGit(["config", "user.name", "Test"], tempDir);
18
+ writeFileSync(join(tempDir, "README.md"), "# test\n");
19
+ runGit(["add", "README.md"], tempDir);
20
+ runGit(["commit", "-m", "initial"], tempDir);
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(tempDir, { recursive: true, force: true });
25
+ });
26
+
27
+ describe("RealGitOps", () => {
28
+ it("isGitRepo returns true in a real repo", async () => {
29
+ const { createGitOps } = await import("../git-ops");
30
+ const ops = createGitOps(tempDir);
31
+ expect(ops.isGitRepo()).toBe(true);
32
+ });
33
+
34
+ it("isGitRepo returns false outside a git repo", async () => {
35
+ const nonRepo = mkdtempSync(join(tmpdir(), "stagent-nogit-"));
36
+ try {
37
+ const { createGitOps } = await import("../git-ops");
38
+ const ops = createGitOps(nonRepo);
39
+ expect(ops.isGitRepo()).toBe(false);
40
+ } finally {
41
+ rmSync(nonRepo, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ it("getCurrentBranch returns main after init", async () => {
46
+ const { createGitOps } = await import("../git-ops");
47
+ const ops = createGitOps(tempDir);
48
+ expect(ops.getCurrentBranch()).toBe("main");
49
+ });
50
+
51
+ it("branchExists returns true for main, false for missing", async () => {
52
+ const { createGitOps } = await import("../git-ops");
53
+ const ops = createGitOps(tempDir);
54
+ expect(ops.branchExists("main")).toBe(true);
55
+ expect(ops.branchExists("local")).toBe(false);
56
+ });
57
+
58
+ it("createAndCheckoutBranch creates local at current HEAD", async () => {
59
+ const { createGitOps } = await import("../git-ops");
60
+ const ops = createGitOps(tempDir);
61
+ const mainSha = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
62
+ ops.createAndCheckoutBranch("local");
63
+ expect(ops.getCurrentBranch()).toBe("local");
64
+ expect(ops.branchExists("local")).toBe(true);
65
+ const localSha = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
66
+ expect(localSha).toBe(mainSha);
67
+ // main is not modified
68
+ const mainShaAfter = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
69
+ expect(mainShaAfter).toBe(mainSha);
70
+ });
71
+
72
+ it("setConfig writes branch.local.pushRemote", async () => {
73
+ const { createGitOps } = await import("../git-ops");
74
+ const ops = createGitOps(tempDir);
75
+ ops.createAndCheckoutBranch("local");
76
+ ops.setConfig("branch.local.pushRemote", "no_push");
77
+ const value = execFileSync("git", ["config", "--get", "branch.local.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim();
78
+ expect(value).toBe("no_push");
79
+ });
80
+
81
+ it("getGitDir returns absolute path to .git directory", async () => {
82
+ const { createGitOps } = await import("../git-ops");
83
+ const ops = createGitOps(tempDir);
84
+ expect(ops.getGitDir()).toBe(join(tempDir, ".git"));
85
+ });
86
+
87
+ it("getCurrentBranch returns null when HEAD is detached", async () => {
88
+ // Detach HEAD by checking out the commit SHA directly
89
+ const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: tempDir, encoding: "utf-8" }).trim();
90
+ execFileSync("git", ["checkout", sha], { cwd: tempDir, stdio: "pipe" });
91
+ const { createGitOps } = await import("../git-ops");
92
+ const ops = createGitOps(tempDir);
93
+ expect(ops.getCurrentBranch()).toBeNull();
94
+ });
95
+ });
@@ -0,0 +1,83 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+
6
+ let tempDir: string;
7
+
8
+ beforeEach(() => {
9
+ tempDir = mkdtempSync(join(tmpdir(), "stagent-instance-settings-"));
10
+ vi.resetModules();
11
+ vi.stubEnv("STAGENT_DATA_DIR", tempDir);
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.unstubAllEnvs();
16
+ rmSync(tempDir, { recursive: true, force: true });
17
+ });
18
+
19
+ async function loadModule() {
20
+ return await import("../settings");
21
+ }
22
+
23
+ describe("getInstanceConfig / setInstanceConfig", () => {
24
+ it("returns null before any config is written", async () => {
25
+ const { getInstanceConfig } = await loadModule();
26
+ expect(getInstanceConfig()).toBeNull();
27
+ });
28
+
29
+ it("round-trips a config through set/get", async () => {
30
+ const { setInstanceConfig, getInstanceConfig } = await loadModule();
31
+ await setInstanceConfig({
32
+ instanceId: "abc-123",
33
+ branchName: "local",
34
+ isPrivateInstance: false,
35
+ createdAt: 1700000000,
36
+ });
37
+ const config = getInstanceConfig();
38
+ expect(config).toEqual({
39
+ instanceId: "abc-123",
40
+ branchName: "local",
41
+ isPrivateInstance: false,
42
+ createdAt: 1700000000,
43
+ });
44
+ });
45
+
46
+ it("returns null when stored value is corrupt JSON", async () => {
47
+ const { setSetting } = await import("@/lib/settings/helpers");
48
+ await setSetting("instance", "not-valid-json");
49
+ const { getInstanceConfig } = await loadModule();
50
+ expect(getInstanceConfig()).toBeNull();
51
+ });
52
+ });
53
+
54
+ describe("getGuardrails / setGuardrails", () => {
55
+ it("returns defaults before any guardrails are written", async () => {
56
+ const { getGuardrails } = await loadModule();
57
+ expect(getGuardrails()).toEqual({
58
+ prePushHookInstalled: false,
59
+ prePushHookVersion: "",
60
+ pushRemoteBlocked: [],
61
+ consentStatus: "not_yet",
62
+ firstBootCompletedAt: null,
63
+ });
64
+ });
65
+
66
+ it("round-trips guardrails through set/get", async () => {
67
+ const { setGuardrails, getGuardrails } = await loadModule();
68
+ await setGuardrails({
69
+ prePushHookInstalled: true,
70
+ prePushHookVersion: "1.0.0",
71
+ pushRemoteBlocked: ["local", "wealth-mgr"],
72
+ consentStatus: "enabled",
73
+ firstBootCompletedAt: 1700000000,
74
+ });
75
+ expect(getGuardrails()).toEqual({
76
+ prePushHookInstalled: true,
77
+ prePushHookVersion: "1.0.0",
78
+ pushRemoteBlocked: ["local", "wealth-mgr"],
79
+ consentStatus: "enabled",
80
+ firstBootCompletedAt: 1700000000,
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,131 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { execFileSync } from "child_process";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+
7
+ let tempDir: string;
8
+ let dataDir: string;
9
+
10
+ function runGit(args: string[], cwd: string) {
11
+ execFileSync("git", args, { cwd, stdio: "pipe" });
12
+ }
13
+
14
+ function initRepo(dir: string) {
15
+ runGit(["init", "-b", "main"], dir);
16
+ runGit(["config", "user.email", "test@example.com"], dir);
17
+ runGit(["config", "user.name", "Test"], dir);
18
+ writeFileSync(join(dir, "README.md"), "# test\n");
19
+ runGit(["add", "README.md"], dir);
20
+ runGit(["commit", "-m", "initial"], dir);
21
+ }
22
+
23
+ beforeEach(() => {
24
+ tempDir = mkdtempSync(join(tmpdir(), "stagent-upgrade-poller-"));
25
+ dataDir = mkdtempSync(join(tmpdir(), "stagent-upgrade-poller-data-"));
26
+ initRepo(tempDir);
27
+ vi.resetModules();
28
+ vi.unstubAllEnvs();
29
+ vi.stubEnv("STAGENT_DATA_DIR", dataDir);
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.unstubAllEnvs();
34
+ rmSync(tempDir, { recursive: true, force: true });
35
+ rmSync(dataDir, { recursive: true, force: true });
36
+ });
37
+
38
+ describe("tick", () => {
39
+ it("returns skipped=dev_mode_or_no_git when STAGENT_DEV_MODE=true", async () => {
40
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
41
+ const { tick } = await import("../upgrade-poller");
42
+ const result = await tick(tempDir);
43
+ expect(result.skipped).toBe("dev_mode_or_no_git");
44
+ });
45
+
46
+ it("returns skipped=dev_mode_or_no_git when .git is absent", async () => {
47
+ const noGitDir = mkdtempSync(join(tmpdir(), "stagent-nogit-"));
48
+ try {
49
+ const { tick } = await import("../upgrade-poller");
50
+ const result = await tick(noGitDir);
51
+ expect(result.skipped).toBe("dev_mode_or_no_git");
52
+ } finally {
53
+ rmSync(noGitDir, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ it("returns skipped=fetch_failed and records failure count when fetch fails (no remote)", async () => {
58
+ // No origin configured → fetch will fail
59
+ const { tick } = await import("../upgrade-poller");
60
+ const result = await tick(tempDir);
61
+ expect(result.skipped).toBe("fetch_failed");
62
+ expect(result.error).toBeDefined();
63
+ const { getUpgradeState } = await import("../settings");
64
+ const state = getUpgradeState();
65
+ expect(state.pollFailureCount).toBe(1);
66
+ expect(state.lastPollError).toBeTruthy();
67
+ expect(state.lastPolledAt).not.toBeNull();
68
+ });
69
+
70
+ it("increments pollFailureCount on repeated failures", async () => {
71
+ const { tick } = await import("../upgrade-poller");
72
+ await tick(tempDir);
73
+ await tick(tempDir);
74
+ await tick(tempDir);
75
+ const { getUpgradeState } = await import("../settings");
76
+ expect(getUpgradeState().pollFailureCount).toBe(3);
77
+ });
78
+
79
+ it("successfully updates state with zero commitsBehind when local == origin/main", async () => {
80
+ // Set up a local 'origin' remote pointing to a bare copy of the same repo
81
+ const bareDir = mkdtempSync(join(tmpdir(), "stagent-bare-"));
82
+ try {
83
+ runGit(["init", "--bare", "-b", "main"], bareDir);
84
+ runGit(["remote", "add", "origin", bareDir], tempDir);
85
+ runGit(["push", "origin", "main"], tempDir);
86
+
87
+ const { tick } = await import("../upgrade-poller");
88
+ const result = await tick(tempDir);
89
+ expect(result.updated).toBeDefined();
90
+ expect(result.updated!.commitsBehind).toBe(0);
91
+ expect(result.updated!.upgradeAvailable).toBe(false);
92
+ expect(result.updated!.pollFailureCount).toBe(0);
93
+ expect(result.updated!.lastUpstreamSha).toBeTruthy();
94
+ expect(result.updated!.localMainSha).toBe(result.updated!.lastUpstreamSha);
95
+ } finally {
96
+ rmSync(bareDir, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ it("detects commits-behind when origin has new commits not in local main", async () => {
101
+ const bareDir = mkdtempSync(join(tmpdir(), "stagent-bare-"));
102
+ const otherCloneDir = mkdtempSync(join(tmpdir(), "stagent-other-"));
103
+ try {
104
+ runGit(["init", "--bare", "-b", "main"], bareDir);
105
+ runGit(["remote", "add", "origin", bareDir], tempDir);
106
+ runGit(["push", "origin", "main"], tempDir);
107
+
108
+ // Clone, add 2 commits, push back to bare
109
+ runGit(["clone", bareDir, otherCloneDir], otherCloneDir + "/..");
110
+ runGit(["config", "user.email", "test@example.com"], otherCloneDir);
111
+ runGit(["config", "user.name", "Test"], otherCloneDir);
112
+ writeFileSync(join(otherCloneDir, "a.txt"), "a\n");
113
+ runGit(["add", "a.txt"], otherCloneDir);
114
+ runGit(["commit", "-m", "a"], otherCloneDir);
115
+ writeFileSync(join(otherCloneDir, "b.txt"), "b\n");
116
+ runGit(["add", "b.txt"], otherCloneDir);
117
+ runGit(["commit", "-m", "b"], otherCloneDir);
118
+ runGit(["push", "origin", "main"], otherCloneDir);
119
+
120
+ const { tick } = await import("../upgrade-poller");
121
+ const result = await tick(tempDir);
122
+ expect(result.updated).toBeDefined();
123
+ expect(result.updated!.commitsBehind).toBe(2);
124
+ expect(result.updated!.upgradeAvailable).toBe(true);
125
+ expect(result.updated!.lastUpstreamSha).not.toBe(result.updated!.localMainSha);
126
+ } finally {
127
+ rmSync(bareDir, { recursive: true, force: true });
128
+ rmSync(otherCloneDir, { recursive: true, force: true });
129
+ }
130
+ });
131
+ });