jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,397 @@
1
+ //! Project configuration file I/O.
2
+ //!
3
+ //! Reads and writes `.jettypod/config.json` — project name, state,
4
+ //! and runtime settings.
5
+ //!
6
+ //! Port of `lib/config.js`.
7
+
8
+ use crate::error::{CoreError, Result};
9
+ use serde::{Deserialize, Serialize};
10
+ use std::path::Path;
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /// Top-level project configuration stored in `.jettypod/config.json`.
17
+ #[derive(Debug, Clone, Serialize, Deserialize)]
18
+ pub struct ProjectConfig {
19
+ #[serde(default = "default_name")]
20
+ pub name: String,
21
+
22
+ #[serde(default = "default_stage")]
23
+ pub stage: String,
24
+
25
+ #[serde(default = "default_bundles")]
26
+ pub bundles: Vec<String>,
27
+
28
+ #[serde(default = "default_project_state")]
29
+ pub project_state: String,
30
+
31
+ #[serde(default)]
32
+ pub project_discovery: ProjectDiscovery,
33
+
34
+ /// Optional override for the main branch name.
35
+ #[serde(rename = "mainBranch", skip_serializing_if = "Option::is_none")]
36
+ pub main_branch: Option<String>,
37
+
38
+ /// Preserve unknown fields for forward compatibility.
39
+ #[serde(flatten)]
40
+ pub extra: serde_json::Map<String, serde_json::Value>,
41
+ }
42
+
43
+ /// Project discovery state — tracks UX exploration workflow.
44
+ #[derive(Debug, Clone, Serialize, Deserialize)]
45
+ pub struct ProjectDiscovery {
46
+ #[serde(default = "default_discovery_status")]
47
+ pub status: String,
48
+
49
+ #[serde(default)]
50
+ pub prototypes: Vec<serde_json::Value>,
51
+
52
+ #[serde(default)]
53
+ pub winner: Option<String>,
54
+
55
+ #[serde(default)]
56
+ pub rationale: Option<String>,
57
+
58
+ #[serde(default)]
59
+ pub started_date: Option<String>,
60
+
61
+ #[serde(default)]
62
+ pub completed_date: Option<String>,
63
+
64
+ #[serde(default)]
65
+ pub checkpoint: DiscoveryCheckpoint,
66
+ }
67
+
68
+ /// Checkpoint state within project discovery.
69
+ #[derive(Debug, Clone, Serialize, Deserialize)]
70
+ pub struct DiscoveryCheckpoint {
71
+ #[serde(default = "default_step")]
72
+ pub step: u32,
73
+
74
+ #[serde(default)]
75
+ pub user_journey: Option<String>,
76
+
77
+ #[serde(default)]
78
+ pub ux_approach: Option<String>,
79
+
80
+ #[serde(default)]
81
+ pub epics_created: bool,
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Defaults
86
+ // ---------------------------------------------------------------------------
87
+
88
+ fn default_name() -> String {
89
+ "untitled".into()
90
+ }
91
+ fn default_stage() -> String {
92
+ "empty".into()
93
+ }
94
+ fn default_bundles() -> Vec<String> {
95
+ vec!["core".into()]
96
+ }
97
+ fn default_project_state() -> String {
98
+ "external".into()
99
+ }
100
+ fn default_discovery_status() -> String {
101
+ "not_started".into()
102
+ }
103
+ fn default_step() -> u32 {
104
+ 1
105
+ }
106
+
107
+ impl Default for ProjectDiscovery {
108
+ fn default() -> Self {
109
+ Self {
110
+ status: default_discovery_status(),
111
+ prototypes: Vec::new(),
112
+ winner: None,
113
+ rationale: None,
114
+ started_date: None,
115
+ completed_date: None,
116
+ checkpoint: DiscoveryCheckpoint::default(),
117
+ }
118
+ }
119
+ }
120
+
121
+ impl Default for DiscoveryCheckpoint {
122
+ fn default() -> Self {
123
+ Self {
124
+ step: 1,
125
+ user_journey: None,
126
+ ux_approach: None,
127
+ epics_created: false,
128
+ }
129
+ }
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Valid values
134
+ // ---------------------------------------------------------------------------
135
+
136
+ const VALID_PROJECT_STATES: &[&str] = &["internal", "external"];
137
+
138
+ /// Check whether a project state value is valid.
139
+ pub fn is_valid_project_state(state: &str) -> bool {
140
+ VALID_PROJECT_STATES.contains(&state)
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Operations
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /// Read the project config from `.jettypod/config.json` under the given root.
148
+ ///
149
+ /// Returns defaults if the file doesn't exist or is malformed.
150
+ pub fn read(project_root: &Path) -> ProjectConfig {
151
+ let config_path = project_root.join(".jettypod").join("config.json");
152
+
153
+ if !config_path.exists() {
154
+ return default_config(project_root);
155
+ }
156
+
157
+ match std::fs::read_to_string(&config_path) {
158
+ Ok(contents) => match serde_json::from_str::<ProjectConfig>(&contents) {
159
+ Ok(mut cfg) => {
160
+ // Validate project_state — default to "external" if invalid
161
+ if !is_valid_project_state(&cfg.project_state) {
162
+ cfg.project_state = "external".into();
163
+ }
164
+ cfg
165
+ }
166
+ Err(_) => default_config(project_root),
167
+ },
168
+ Err(_) => default_config(project_root),
169
+ }
170
+ }
171
+
172
+ /// Write the config to `.jettypod/config.json`.
173
+ pub fn write(project_root: &Path, config: &ProjectConfig) -> Result<()> {
174
+ let dir = project_root.join(".jettypod");
175
+ std::fs::create_dir_all(&dir)?;
176
+
177
+ let json = serde_json::to_string_pretty(config)?;
178
+ std::fs::write(dir.join("config.json"), json)?;
179
+ Ok(())
180
+ }
181
+
182
+ /// Read, merge `updates` into the config, and write back.
183
+ ///
184
+ /// The `updates` value is a partial JSON object whose keys overwrite
185
+ /// matching keys in the current config.
186
+ pub fn update(project_root: &Path, updates: serde_json::Value) -> Result<ProjectConfig> {
187
+ // Validate project_state if present in updates
188
+ if let Some(state) = updates.get("project_state").and_then(|v| v.as_str()) {
189
+ if !is_valid_project_state(state) {
190
+ return Err(CoreError::Config(format!(
191
+ "Invalid project_state: {}. Must be 'internal' or 'external'.",
192
+ state
193
+ )));
194
+ }
195
+ }
196
+
197
+ let current = read(project_root);
198
+ let mut current_val = serde_json::to_value(&current)?;
199
+
200
+ // Merge updates into current
201
+ if let (Some(base), Some(patch)) = (current_val.as_object_mut(), updates.as_object()) {
202
+ for (key, value) in patch {
203
+ base.insert(key.clone(), value.clone());
204
+ }
205
+ }
206
+
207
+ let updated: ProjectConfig = serde_json::from_value(current_val)?;
208
+ write(project_root, &updated)?;
209
+ Ok(updated)
210
+ }
211
+
212
+ /// Check whether `.jettypod/config.json` exists.
213
+ pub fn exists(project_root: &Path) -> bool {
214
+ project_root.join(".jettypod").join("config.json").exists()
215
+ }
216
+
217
+ /// Resolve the default branch name.
218
+ ///
219
+ /// Priority: config `mainBranch` → `git symbolic-ref` → `main` → `master`.
220
+ pub fn resolve_default_branch(project_root: &Path) -> String {
221
+ let cfg = read(project_root);
222
+ if let Some(ref branch) = cfg.main_branch {
223
+ let trimmed = branch.trim();
224
+ if !trimmed.is_empty() {
225
+ return trimmed.to_string();
226
+ }
227
+ }
228
+
229
+ // Try git symbolic-ref
230
+ if let Ok(output) = std::process::Command::new("git")
231
+ .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
232
+ .current_dir(project_root)
233
+ .output()
234
+ {
235
+ if output.status.success() {
236
+ let refname = String::from_utf8_lossy(&output.stdout).trim().to_string();
237
+ return refname.replace("refs/remotes/origin/", "");
238
+ }
239
+ }
240
+
241
+ // Try main, then master
242
+ for branch in &["main", "master"] {
243
+ if let Ok(output) = std::process::Command::new("git")
244
+ .args(["rev-parse", "--verify", branch])
245
+ .current_dir(project_root)
246
+ .output()
247
+ {
248
+ if output.status.success() {
249
+ return branch.to_string();
250
+ }
251
+ }
252
+ }
253
+
254
+ "main".into()
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Helpers
259
+ // ---------------------------------------------------------------------------
260
+
261
+ fn default_config(project_root: &Path) -> ProjectConfig {
262
+ let name = project_root
263
+ .file_name()
264
+ .map(|n| n.to_string_lossy().to_string())
265
+ .unwrap_or_else(|| "untitled".into());
266
+
267
+ ProjectConfig {
268
+ name,
269
+ stage: "empty".into(),
270
+ bundles: vec!["core".into()],
271
+ project_state: "external".into(),
272
+ project_discovery: ProjectDiscovery::default(),
273
+ main_branch: None,
274
+ extra: serde_json::Map::new(),
275
+ }
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Tests
280
+ // ---------------------------------------------------------------------------
281
+
282
+ #[cfg(test)]
283
+ mod tests {
284
+ use super::*;
285
+
286
+ #[test]
287
+ fn read_returns_defaults_when_missing() {
288
+ let dir = tempfile::tempdir().unwrap();
289
+ let cfg = read(dir.path());
290
+ assert_eq!(cfg.project_state, "external");
291
+ assert_eq!(cfg.project_discovery.status, "not_started");
292
+ }
293
+
294
+ #[test]
295
+ fn write_and_read_roundtrip() {
296
+ let dir = tempfile::tempdir().unwrap();
297
+ std::fs::create_dir_all(dir.path().join(".jettypod")).unwrap();
298
+
299
+ let mut cfg = default_config(dir.path());
300
+ cfg.project_state = "internal".into();
301
+ cfg.name = "test-project".into();
302
+
303
+ write(dir.path(), &cfg).unwrap();
304
+ let loaded = read(dir.path());
305
+
306
+ assert_eq!(loaded.name, "test-project");
307
+ assert_eq!(loaded.project_state, "internal");
308
+ }
309
+
310
+ #[test]
311
+ fn update_merges_fields() {
312
+ let dir = tempfile::tempdir().unwrap();
313
+ std::fs::create_dir_all(dir.path().join(".jettypod")).unwrap();
314
+
315
+ let cfg = default_config(dir.path());
316
+ write(dir.path(), &cfg).unwrap();
317
+
318
+ let updated = update(
319
+ dir.path(),
320
+ serde_json::json!({ "project_state": "internal" }),
321
+ )
322
+ .unwrap();
323
+
324
+ assert_eq!(updated.project_state, "internal");
325
+ // Name should be preserved
326
+ assert!(!updated.name.is_empty());
327
+ }
328
+
329
+ #[test]
330
+ fn update_rejects_invalid_state() {
331
+ let dir = tempfile::tempdir().unwrap();
332
+ std::fs::create_dir_all(dir.path().join(".jettypod")).unwrap();
333
+
334
+ let cfg = default_config(dir.path());
335
+ write(dir.path(), &cfg).unwrap();
336
+
337
+ let result = update(
338
+ dir.path(),
339
+ serde_json::json!({ "project_state": "invalid" }),
340
+ );
341
+ assert!(result.is_err());
342
+ }
343
+
344
+ #[test]
345
+ fn read_fixes_invalid_project_state() {
346
+ let dir = tempfile::tempdir().unwrap();
347
+ let jettypod_dir = dir.path().join(".jettypod");
348
+ std::fs::create_dir_all(&jettypod_dir).unwrap();
349
+ std::fs::write(
350
+ jettypod_dir.join("config.json"),
351
+ r#"{"project_state": "bogus", "name": "test"}"#,
352
+ )
353
+ .unwrap();
354
+
355
+ let cfg = read(dir.path());
356
+ assert_eq!(cfg.project_state, "external");
357
+ assert_eq!(cfg.name, "test");
358
+ }
359
+
360
+ #[test]
361
+ fn exists_works() {
362
+ let dir = tempfile::tempdir().unwrap();
363
+ assert!(!exists(dir.path()));
364
+
365
+ let jettypod_dir = dir.path().join(".jettypod");
366
+ std::fs::create_dir_all(&jettypod_dir).unwrap();
367
+ std::fs::write(jettypod_dir.join("config.json"), "{}").unwrap();
368
+
369
+ assert!(exists(dir.path()));
370
+ }
371
+
372
+ #[test]
373
+ fn preserves_extra_fields() {
374
+ let dir = tempfile::tempdir().unwrap();
375
+ let jettypod_dir = dir.path().join(".jettypod");
376
+ std::fs::create_dir_all(&jettypod_dir).unwrap();
377
+ std::fs::write(
378
+ jettypod_dir.join("config.json"),
379
+ r#"{"name":"test","custom_field":"preserved","project_state":"internal"}"#,
380
+ )
381
+ .unwrap();
382
+
383
+ let cfg = read(dir.path());
384
+ assert_eq!(
385
+ cfg.extra.get("custom_field").and_then(|v| v.as_str()),
386
+ Some("preserved")
387
+ );
388
+
389
+ // Write back and verify it's still there
390
+ write(dir.path(), &cfg).unwrap();
391
+ let reloaded = read(dir.path());
392
+ assert_eq!(
393
+ reloaded.extra.get("custom_field").and_then(|v| v.as_str()),
394
+ Some("preserved")
395
+ );
396
+ }
397
+ }