@dfosco/storyboard 0.7.1 → 0.9.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 (323) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +14128 -10953
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/dist/tiny-canvas.css +1 -1
  6. package/package.json +138 -128
  7. package/scaffold/AGENTS.md +9 -7
  8. package/scaffold/README.md +18 -0
  9. package/scaffold/deploy.yml +21 -0
  10. package/scaffold/gitignore +8 -14
  11. package/scaffold/preview.yml +40 -0
  12. package/scaffold/{manifest.json → scaffold.config.json} +73 -3
  13. package/scaffold/scripts/aggregate-artifacts.mjs +257 -0
  14. package/scaffold/skills/canvas/SKILL.md +1 -1
  15. package/scaffold/skills/migrate-0.5.0/SKILL.md +1 -1
  16. package/scaffold/skills/ship/SKILL.md +2 -2
  17. package/scaffold/skills/worktree/SKILL.md +1 -1
  18. package/scaffold/src/_prototype.jsx +17 -0
  19. package/scaffold/src/_story.jsx +13 -0
  20. package/scaffold/src/library/README.md +34 -0
  21. package/scaffold/src/library/_app.jsx +41 -0
  22. package/scaffold/src/library/_app.module.css +2 -0
  23. package/scaffold/src/library/create.jsx +10 -0
  24. package/scaffold/src/library/index.jsx +14 -0
  25. package/scaffold/src/library/mount.jsx +51 -0
  26. package/scaffold/src/library/prototypes-entry.jsx +57 -0
  27. package/scaffold/src/library/routes.jsx +205 -0
  28. package/scaffold/src/library/viewfinder.jsx +13 -0
  29. package/scaffold/src/library/workspace.jsx +17 -0
  30. package/scripts/aggregate-artifacts.mjs +257 -0
  31. package/src/canvas/style.css +16 -16
  32. package/src/core/artifact/operations.js +54 -15
  33. package/src/core/artifact/schemas/canvas.schema.json +7 -1
  34. package/src/core/autosync/server.test.js +1 -1
  35. package/src/core/canvas/hot-pool.js +21 -33
  36. package/src/core/canvas/materializer.js +39 -13
  37. package/src/core/canvas/selectedWidgets.js +1 -1
  38. package/src/core/canvas/server.js +376 -100
  39. package/src/core/canvas/server.test.js +472 -2
  40. package/src/core/canvas/terminal-server.js +144 -57
  41. package/src/core/canvas/undoRedo.js +174 -0
  42. package/src/core/canvas/undoRedo.test.js +250 -0
  43. package/src/core/cli/artifact.js +2 -0
  44. package/src/core/cli/buildManifest.js +34 -0
  45. package/src/core/cli/canvasRedo.js +44 -0
  46. package/src/core/cli/canvasUndo.js +40 -0
  47. package/src/core/cli/create.js +151 -23
  48. package/src/core/cli/dev.js +23 -35
  49. package/src/core/cli/index.js +21 -0
  50. package/src/core/cli/schemas.js +8 -4
  51. package/src/core/cli/serverUrl.js +24 -0
  52. package/src/core/cli/setup.js +14 -25
  53. package/src/core/cli/terminal-welcome.js +12 -6
  54. package/src/core/cli/updateVersion.js +42 -2
  55. package/src/core/cli/workleaf.js +140 -0
  56. package/src/core/comments/api.js +1 -1
  57. package/src/core/comments/queries.js +3 -0
  58. package/src/core/comments/ui/CommentWindow.jsx +3 -2
  59. package/src/core/comments/ui/comment-layout.css +14 -14
  60. package/src/core/data/artifactIndex.js +235 -0
  61. package/src/core/data/artifactIndex.test.js +203 -0
  62. package/src/core/data/artifactManifest.js +290 -0
  63. package/src/core/data/artifactManifest.test.js +207 -0
  64. package/src/core/data/viewfinder.js +47 -7
  65. package/src/core/index.js +24 -1
  66. package/src/core/inspector/highlighter.js +1 -1
  67. package/src/core/inspector/mouseMode.js +2 -2
  68. package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +2 -2
  69. package/src/core/lib/components/ui/avatar/avatar-image.jsx +2 -2
  70. package/src/core/lib/components/ui/avatar/avatar.jsx +2 -2
  71. package/src/core/lib/components/ui/checkbox/checkbox.jsx +12 -9
  72. package/src/core/lib/components/ui/dialog/dialog-content.jsx +45 -31
  73. package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +9 -5
  74. package/src/core/lib/components/ui/dialog/dialog.jsx +12 -3
  75. package/src/core/lib/components/ui/dialog/dialog.module.css +118 -0
  76. package/src/core/lib/components/ui/dialog/index.js +4 -34
  77. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +3 -3
  78. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +19 -9
  79. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +8 -8
  80. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +2 -2
  81. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +2 -2
  82. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +15 -4
  83. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +2 -2
  84. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +2 -2
  85. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +14 -7
  86. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +2 -2
  87. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +8 -4
  88. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +4 -4
  89. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +2 -2
  90. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +8 -2
  91. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +2 -2
  92. package/src/core/lib/components/ui/label/label.jsx +1 -2
  93. package/src/core/lib/components/ui/panel/panel-close.jsx +7 -4
  94. package/src/core/lib/components/ui/panel/panel-content.jsx +7 -9
  95. package/src/core/lib/components/ui/panel/panel-title.jsx +3 -3
  96. package/src/core/lib/components/ui/panel/panel.jsx +11 -2
  97. package/src/core/lib/components/ui/separator/separator.jsx +6 -4
  98. package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +7 -6
  99. package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +13 -6
  100. package/src/core/lib/components/ui/tooltip/tooltip.jsx +5 -5
  101. package/src/core/modes/modes.css +4 -4
  102. package/src/core/mountStoryboardCore.js +287 -83
  103. package/src/core/mountStoryboardCore.test.js +198 -0
  104. package/src/core/rename-watcher/config.json +1 -1
  105. package/src/core/rename-watcher/watcher.js +330 -37
  106. package/src/core/rename-watcher/watcher.test.js +253 -0
  107. package/src/core/scaffold/fragments.js +287 -0
  108. package/src/core/scaffold/fragments.test.js +401 -0
  109. package/src/core/scaffold/index.js +291 -0
  110. package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
  111. package/src/core/scaffold/scaffold-integration.test.js +247 -0
  112. package/src/core/scaffold.js +4 -97
  113. package/src/core/stores/canvasInteractionStore.js +120 -0
  114. package/src/core/stores/canvasInteractionStore.test.js +126 -0
  115. package/src/core/stores/configSchema.js +64 -8
  116. package/src/core/stores/configSchema.test.js +3 -0
  117. package/src/core/stores/customerModeConfig.js +24 -3
  118. package/src/core/stores/customerModeConfig.test.js +46 -0
  119. package/src/core/stores/presentationStore.js +118 -0
  120. package/src/core/stores/presentationStore.test.js +114 -0
  121. package/src/core/stores/recentArtifacts.js +49 -2
  122. package/src/core/stores/recentArtifacts.test.js +59 -6
  123. package/src/core/stores/themeBootstrap.js +264 -0
  124. package/src/core/stores/themeBootstrap.test.js +211 -0
  125. package/src/core/stores/themeStore.test.js +154 -0
  126. package/src/core/stores/themeStore.ts +235 -220
  127. package/src/core/stores/uiConfig.js +10 -2
  128. package/src/core/styles/generateThemesCss.js +63 -0
  129. package/src/core/styles/tailwind.css +150 -94
  130. package/src/core/styles/workspace-chrome.css +13 -0
  131. package/src/core/tools/handlers/paletteTheme.js +11 -8
  132. package/src/core/ui/ActionMenuButton.jsx +2 -1
  133. package/src/core/ui/AutosyncMenuButton.css +7 -7
  134. package/src/core/ui/BranchSelect.module.css +5 -5
  135. package/src/core/ui/CanvasCreateMenu.jsx +1 -19
  136. package/src/core/ui/ChromeSlot.jsx +92 -0
  137. package/src/core/ui/ChromeSlot.test.jsx +140 -0
  138. package/src/core/ui/CoreUIBar.css +5 -5
  139. package/src/core/ui/CoreUIBar.jsx +170 -142
  140. package/src/core/ui/InspectorPanel.css +5 -5
  141. package/src/core/ui/InspectorPanel.jsx +9 -9
  142. package/src/core/ui/PwaInstallBanner.css +5 -5
  143. package/src/core/ui/PwaInstallBanner.jsx +5 -5
  144. package/src/core/ui/ThemeMenuButton.jsx +45 -30
  145. package/src/core/ui/core-ui-colors.css +28 -66
  146. package/src/core/ui/sidepanel.css +12 -12
  147. package/src/core/vite/server-plugin.js +13 -0
  148. package/src/core/workleaf/cleanup.js +102 -0
  149. package/src/core/workleaf/create.js +199 -0
  150. package/src/core/workleaf/delete.js +101 -0
  151. package/src/core/workleaf/list.js +57 -0
  152. package/src/core/workleaf/merge.js +238 -0
  153. package/src/core/workleaf/paths.js +146 -0
  154. package/src/core/workleaf/paths.test.js +230 -0
  155. package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +130 -34
  156. package/src/core/workshop/features/createFlow/server.js +5 -1
  157. package/src/core/workshop/features/createPrototype/server.js +5 -1
  158. package/src/core/workshop/features/templateIndex.js +28 -1
  159. package/src/core/worktree/port.js +48 -5
  160. package/src/core/worktree/port.test.js +42 -0
  161. package/src/internals/ArtifactForm/ArtifactForm.jsx +173 -112
  162. package/src/internals/ArtifactForm/ArtifactForm.module.css +51 -69
  163. package/src/internals/ArtifactForm/artifactSchemas.js +40 -5
  164. package/src/internals/AuthModal/AuthModal.module.css +34 -34
  165. package/src/internals/BaseUiForm/BaseUiForm.jsx +293 -0
  166. package/src/internals/BaseUiForm/BaseUiForm.module.css +542 -0
  167. package/src/internals/BranchBar/BranchBar.module.css +4 -4
  168. package/src/internals/BranchesDropdown/BranchesDropdown.jsx +69 -0
  169. package/src/internals/BranchesDropdown/BranchesDropdown.module.css +84 -0
  170. package/src/internals/CommandPalette/CommandPalette.jsx +41 -29
  171. package/src/internals/CommandPalette/CreateArtifactForm.jsx +150 -0
  172. package/src/internals/CommandPalette/CreateDialog.jsx +20 -110
  173. package/src/internals/CommandPalette/WidgetArtifactDialog.css +16 -16
  174. package/src/internals/CommandPalette/WidgetArtifactDialog.jsx +1 -1
  175. package/src/internals/CommandPalette/command-palette.css +14 -14
  176. package/src/internals/CommandPalette/createHelpers.js +30 -0
  177. package/src/internals/CreatePage/CreatePage.jsx +57 -0
  178. package/src/internals/CreatePage/CreatePage.module.css +34 -0
  179. package/src/internals/Dialog.jsx +51 -0
  180. package/src/internals/FlowError.module.css +5 -5
  181. package/src/internals/PrototypeErrorBoundary.module.css +15 -15
  182. package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +534 -0
  183. package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +323 -0
  184. package/src/internals/Tooltip.jsx +39 -0
  185. package/src/internals/Viewfinder.jsx +229 -64
  186. package/src/internals/Viewfinder.module.css +253 -182
  187. package/src/internals/canvas/CanvasControls.module.css +11 -11
  188. package/src/internals/canvas/CanvasPage.bridge.test.jsx +11 -37
  189. package/src/internals/canvas/CanvasPage.jsx +358 -217
  190. package/src/internals/canvas/CanvasPage.module.css +20 -19
  191. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
  192. package/src/internals/canvas/CanvasToolbar.module.css +11 -11
  193. package/src/internals/canvas/ConnectorLayer.jsx +39 -2
  194. package/src/internals/canvas/ConnectorLayer.module.css +10 -10
  195. package/src/internals/canvas/PageSelector.jsx +15 -1
  196. package/src/internals/canvas/PageSelector.module.css +41 -27
  197. package/src/internals/canvas/canvasApi.js +40 -5
  198. package/src/internals/canvas/canvasTheme.js +22 -111
  199. package/src/internals/canvas/isolation/createIsolationMiddleware.js +68 -0
  200. package/src/internals/canvas/isolation/mountIsolationApp.jsx +88 -0
  201. package/src/internals/canvas/isolation/prototypeRoutes.jsx +140 -0
  202. package/src/internals/canvas/isolation/prototypesEntry.jsx +28 -0
  203. package/src/internals/canvas/isolation/storiesEntry.jsx +20 -0
  204. package/src/internals/canvas/isolation/storyRoutes.jsx +55 -0
  205. package/src/internals/canvas/useUndoRedo.js +75 -60
  206. package/src/internals/canvas/useUndoRedo.test.js +46 -178
  207. package/src/internals/canvas/widgets/CodePenEmbed.jsx +16 -36
  208. package/src/internals/canvas/widgets/CodePenEmbed.module.css +12 -12
  209. package/src/internals/canvas/widgets/CropOverlay.module.css +23 -23
  210. package/src/internals/canvas/widgets/ExpandedPane.module.css +8 -8
  211. package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +1 -1
  212. package/src/internals/canvas/widgets/FigmaEmbed.jsx +15 -36
  213. package/src/internals/canvas/widgets/FigmaEmbed.module.css +15 -15
  214. package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
  215. package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +5 -5
  216. package/src/internals/canvas/widgets/ImageWidget.jsx +7 -2
  217. package/src/internals/canvas/widgets/ImageWidget.module.css +3 -3
  218. package/src/internals/canvas/widgets/LinkPreview.module.css +79 -80
  219. package/src/internals/canvas/widgets/MarkdownBlock.module.css +31 -31
  220. package/src/internals/canvas/widgets/PromptWidget.jsx +2 -16
  221. package/src/internals/canvas/widgets/PromptWidget.module.css +33 -33
  222. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +92 -17
  223. package/src/internals/canvas/widgets/PrototypeEmbed.module.css +58 -56
  224. package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +118 -4
  225. package/src/internals/canvas/widgets/ResizeHandle.module.css +4 -4
  226. package/src/internals/canvas/widgets/StorySetWidget.jsx +53 -25
  227. package/src/internals/canvas/widgets/StoryWidget.jsx +71 -56
  228. package/src/internals/canvas/widgets/StoryWidget.module.css +22 -20
  229. package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
  230. package/src/internals/canvas/widgets/TerminalReadWidget.module.css +4 -4
  231. package/src/internals/canvas/widgets/TerminalWidget.jsx +56 -45
  232. package/src/internals/canvas/widgets/TerminalWidget.module.css +10 -10
  233. package/src/internals/canvas/widgets/TilesWidget.module.css +17 -17
  234. package/src/internals/canvas/widgets/WidgetChrome.jsx +1 -1
  235. package/src/internals/canvas/widgets/WidgetChrome.module.css +76 -61
  236. package/src/internals/canvas/widgets/WidgetWrapper.module.css +2 -2
  237. package/src/internals/canvas/widgets/embedOverlay.module.css +14 -4
  238. package/src/internals/canvas/widgets/embedTheme.js +12 -104
  239. package/src/internals/canvas/widgets/expandUtils.js +19 -19
  240. package/src/internals/canvas/widgets/normalizeLegacyEmbedSrc.js +74 -0
  241. package/src/internals/canvas/widgets/widgetConfig.js +5 -5
  242. package/src/internals/context.jsx +22 -1
  243. package/src/internals/context.test.jsx +47 -0
  244. package/src/internals/hooks/useArtifactIndex.js +31 -0
  245. package/src/internals/hooks/useArtifactIndex.test.js +45 -0
  246. package/src/internals/hooks/usePrototypeReloadGuard.js +14 -8
  247. package/src/internals/index.js +6 -0
  248. package/src/internals/loadConsumerWrapper.js +60 -0
  249. package/src/internals/story/ComponentSetPage.jsx +75 -34
  250. package/src/internals/story/ComponentSetPage.module.css +14 -14
  251. package/src/internals/story/StoryPage.jsx +16 -25
  252. package/src/internals/story/StoryPage.module.css +2 -2
  253. package/src/internals/vite/data-plugin.js +317 -149
  254. package/src/internals/vite/data-plugin.test.js +11 -11
  255. package/{scaffold/storyboard.config.json → storyboard.config.json} +25 -0
  256. package/terminal.config.json +1 -1
  257. package/src/core/lib/components/ui/card/card-action.jsx +0 -11
  258. package/src/core/lib/components/ui/card/card-content.jsx +0 -11
  259. package/src/core/lib/components/ui/card/card-description.jsx +0 -11
  260. package/src/core/lib/components/ui/card/card-footer.jsx +0 -11
  261. package/src/core/lib/components/ui/card/card-header.jsx +0 -19
  262. package/src/core/lib/components/ui/card/card-title.jsx +0 -11
  263. package/src/core/lib/components/ui/card/card.jsx +0 -17
  264. package/src/core/lib/components/ui/card/index.js +0 -25
  265. package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +0 -7
  266. package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +0 -7
  267. package/src/core/lib/components/ui/collapsible/collapsible.jsx +0 -7
  268. package/src/core/lib/components/ui/collapsible/index.js +0 -13
  269. package/src/core/lib/components/ui/dialog/dialog-close.jsx +0 -7
  270. package/src/core/lib/components/ui/dialog/dialog-description.jsx +0 -15
  271. package/src/core/lib/components/ui/dialog/dialog-footer.jsx +0 -23
  272. package/src/core/lib/components/ui/dialog/dialog-header.jsx +0 -11
  273. package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +0 -15
  274. package/src/core/lib/components/ui/dialog/dialog-portal.jsx +0 -4
  275. package/src/core/lib/components/ui/dialog/dialog-title.jsx +0 -15
  276. package/src/core/lib/components/ui/popover/index.js +0 -28
  277. package/src/core/lib/components/ui/popover/popover-close.jsx +0 -7
  278. package/src/core/lib/components/ui/popover/popover-content.jsx +0 -22
  279. package/src/core/lib/components/ui/popover/popover-description.jsx +0 -11
  280. package/src/core/lib/components/ui/popover/popover-header.jsx +0 -11
  281. package/src/core/lib/components/ui/popover/popover-portal.jsx +0 -4
  282. package/src/core/lib/components/ui/popover/popover-title.jsx +0 -11
  283. package/src/core/lib/components/ui/popover/popover-trigger.jsx +0 -8
  284. package/src/core/lib/components/ui/popover/popover.jsx +0 -4
  285. package/src/core/lib/components/ui/select/index.js +0 -37
  286. package/src/core/lib/components/ui/select/select-content.jsx +0 -30
  287. package/src/core/lib/components/ui/select/select-group-heading.jsx +0 -17
  288. package/src/core/lib/components/ui/select/select-group.jsx +0 -15
  289. package/src/core/lib/components/ui/select/select-item.jsx +0 -26
  290. package/src/core/lib/components/ui/select/select-label.jsx +0 -11
  291. package/src/core/lib/components/ui/select/select-portal.jsx +0 -4
  292. package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +0 -18
  293. package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +0 -18
  294. package/src/core/lib/components/ui/select/select-separator.jsx +0 -15
  295. package/src/core/lib/components/ui/select/select-trigger.jsx +0 -25
  296. package/src/core/lib/components/ui/select/select.jsx +0 -4
  297. package/src/core/lib/components/ui/sheet/index.js +0 -34
  298. package/src/core/lib/components/ui/sheet/sheet-close.jsx +0 -7
  299. package/src/core/lib/components/ui/sheet/sheet-content.jsx +0 -35
  300. package/src/core/lib/components/ui/sheet/sheet-description.jsx +0 -15
  301. package/src/core/lib/components/ui/sheet/sheet-footer.jsx +0 -11
  302. package/src/core/lib/components/ui/sheet/sheet-header.jsx +0 -11
  303. package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +0 -15
  304. package/src/core/lib/components/ui/sheet/sheet-portal.jsx +0 -4
  305. package/src/core/lib/components/ui/sheet/sheet-title.jsx +0 -15
  306. package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +0 -7
  307. package/src/core/lib/components/ui/sheet/sheet.jsx +0 -4
  308. package/src/core/lib/components/ui/toggle/index.js +0 -8
  309. package/src/core/lib/components/ui/toggle/toggle.jsx +0 -36
  310. package/src/core/lib/components/ui/toggle-group/index.js +0 -10
  311. package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +0 -29
  312. package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +0 -43
  313. package/src/internals/canvas/StoryErrorBoundary.jsx +0 -47
  314. package/src/internals/canvas/WebGLContextPool.jsx +0 -291
  315. package/src/internals/canvas/WebGLContextPool.test.jsx +0 -165
  316. package/src/internals/canvas/componentIsolate.jsx +0 -144
  317. package/src/internals/canvas/componentSetIsolate.jsx +0 -300
  318. package/src/internals/canvas/widgets/ComponentSetWidget.module.css +0 -89
  319. package/src/internals/canvas/widgets/InlineStoryRenderer.jsx +0 -129
  320. package/src/internals/canvas/widgets/InlineStoryRenderer.test.jsx +0 -68
  321. package/src/internals/canvas/widgets/StorySetWidget.module.css +0 -89
  322. package/src/primer/ThemeSync.jsx +0 -73
  323. package/src/primer/index.js +0 -1
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Create a new workleaf.
3
+ *
4
+ * A workleaf is an ephemeral git worktree owned by a parent worktree
5
+ * (or main). The full lifecycle:
6
+ *
7
+ * 1. Refuse if the current cwd is already inside a workleaf
8
+ * (workleaves cannot be nested).
9
+ * 2. Derive the parent worktree name from cwd.
10
+ * 3. `git worktree add <dir> -b <branch> <parent-branch>` so the
11
+ * workleaf branches off the parent's CURRENT HEAD.
12
+ * 4. Run `npm install` so the workleaf has a working dependency tree.
13
+ * 5. Allocate a stable port via the shared worktrees/ports.json.
14
+ * 6. Spawn `storyboard dev` as a fully detached child process so the
15
+ * Vite dev server stays alive after this CLI exits.
16
+ * 7. Poll the server registry for self-registration and print the URL.
17
+ *
18
+ * The detached server registers itself in .storyboard/servers.json on
19
+ * startup, which is how `workleaf merge` / `workleaf delete` later
20
+ * locates and stops it.
21
+ */
22
+
23
+ import * as p from '@clack/prompts'
24
+ import { execFileSync, spawn } from 'node:child_process'
25
+ import { mkdirSync, existsSync } from 'node:fs'
26
+ import { dirname, resolve } from 'node:path'
27
+ import { detectWorktreeName, repoRoot, worktreeDir, getPort, slugify } from '../worktree/port.js'
28
+ import { findByWorktree } from '../worktree/serverRegistry.js'
29
+ import { detectWorkleafContext, workleafDir, workleafBranchName, WORKLEAF_PREFIX } from './paths.js'
30
+ import { dim, bold, green } from '../cli/intro.js'
31
+ import { parseFlags } from '../cli/flags.js'
32
+
33
+ const flagSchema = {
34
+ parent: { type: 'string', description: 'Override parent worktree (default: current)' },
35
+ 'no-dev': { type: 'boolean', default: false, description: 'Skip starting the dev server' },
36
+ 'no-install': { type: 'boolean', default: false, description: 'Skip npm install (use parent node_modules at your own risk)' },
37
+ }
38
+
39
+ /**
40
+ * Validate that `name` is a usable workleaf identifier.
41
+ */
42
+ function validateName(name) {
43
+ if (!name || typeof name !== 'string') return 'A workleaf name is required'
44
+ const trimmed = name.trim()
45
+ if (trimmed.length === 0) return 'Workleaf name cannot be empty'
46
+ if (trimmed.startsWith(WORKLEAF_PREFIX)) {
47
+ return `Don't prefix with "${WORKLEAF_PREFIX}" — it's added automatically`
48
+ }
49
+ if (/[\s~^:\\]/.test(trimmed)) return 'Workleaf name cannot contain spaces or ~ ^ : \\'
50
+ if (trimmed.startsWith('-')) return 'Workleaf name cannot start with "-"'
51
+ return null
52
+ }
53
+
54
+ /**
55
+ * Resolve the parent worktree's on-disk path. Errors out if it's gone.
56
+ */
57
+ function resolveParentDir(parent, root) {
58
+ if (parent === 'main') return root
59
+ const dir = worktreeDir(parent, root)
60
+ if (!existsSync(resolve(dir, '.git'))) {
61
+ p.log.error(`Parent worktree "${parent}" not found at ${dim(dir)}`)
62
+ p.log.info(`Create it first with ${green('npx storyboard branch ' + parent)}`)
63
+ process.exit(1)
64
+ }
65
+ return dir
66
+ }
67
+
68
+ /**
69
+ * Public entry — runnable as a subcommand or imported.
70
+ */
71
+ export async function runCreate(argv = process.argv.slice(4)) {
72
+ const { flags, positional } = parseFlags(argv, flagSchema)
73
+ const rawName = positional[0]
74
+
75
+ const nameError = validateName(rawName)
76
+ if (nameError) {
77
+ p.log.error(nameError)
78
+ p.log.info(`Usage: ${green('npx storyboard workleaf create <name>')}`)
79
+ process.exit(1)
80
+ }
81
+
82
+ const slug = slugify(rawName.trim())
83
+
84
+ // 1. Nesting check — refuse if cwd is already a workleaf
85
+ const ctx = detectWorkleafContext()
86
+ if (ctx.inWorkleaf) {
87
+ p.log.error('Workleaves cannot be nested.')
88
+ p.log.info(`You're inside ${bold(ctx.branch)} — cd to its parent worktree first.`)
89
+ process.exit(1)
90
+ }
91
+
92
+ // 2. Derive parent from cwd (or explicit --parent)
93
+ const parent = flags.parent || detectWorktreeName()
94
+ const root = repoRoot()
95
+ const parentDir = resolveParentDir(parent, root)
96
+ const parentBranch = currentBranch(parentDir)
97
+
98
+ const dir = workleafDir(parent, slug)
99
+ const branch = workleafBranchName(parent, slug)
100
+
101
+ // Refuse if the workleaf already exists
102
+ if (existsSync(resolve(dir, '.git'))) {
103
+ p.log.error(`Workleaf already exists at ${dim(dir)}`)
104
+ p.log.info(`Open it with ${green(`cd ${dir}`)}`)
105
+ process.exit(1)
106
+ }
107
+
108
+ p.intro('storyboard workleaf create')
109
+ p.log.info(`${dim('parent:')} ${bold(parent)} ${dim('(branch:')} ${parentBranch}${dim(')')}`)
110
+ p.log.info(`${dim('branch:')} ${bold(branch)}`)
111
+ p.log.info(`${dim('dir:')} ${dim(dir.replace(root + '/', ''))}`)
112
+
113
+ // 3. Create the worktree
114
+ mkdirSync(dirname(dir), { recursive: true })
115
+
116
+ const spin = p.spinner()
117
+ spin.start(`Creating worktree`)
118
+ try {
119
+ execFileSync(
120
+ 'git',
121
+ ['worktree', 'add', dir, '-b', branch, parentBranch],
122
+ { cwd: root, stdio: 'pipe' }
123
+ )
124
+ spin.stop(`Worktree created`)
125
+ } catch (err) {
126
+ spin.stop('Failed to create worktree')
127
+ p.log.error(err.message || 'git worktree add failed')
128
+ process.exit(1)
129
+ }
130
+
131
+ // 4. npm install (skippable)
132
+ if (!flags['no-install']) {
133
+ try {
134
+ spin.start('Installing dependencies (this is the slow part)')
135
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'
136
+ execFileSync(npmBin, ['install'], { cwd: dir, stdio: 'pipe' })
137
+ spin.stop('Dependencies installed')
138
+ } catch {
139
+ spin.stop('npm install failed — run it manually inside the workleaf')
140
+ }
141
+ }
142
+
143
+ // 5. Allocate the port (persisted in shared worktrees/ports.json)
144
+ const port = getPort(branch)
145
+ p.log.info(`${dim('port:')} ${bold(port)}`)
146
+
147
+ // 6. Spawn detached dev server
148
+ if (flags['no-dev']) {
149
+ p.outro(`Workleaf ready at ${green(dir.replace(root + '/', ''))} — start dev with ${green('npx storyboard dev')}`)
150
+ return
151
+ }
152
+
153
+ spin.start('Spawning dev server')
154
+ const npmBin = process.platform === 'win32' ? 'npx.cmd' : 'npx'
155
+ const child = spawn(npmBin, ['storyboard', 'dev'], {
156
+ cwd: dir,
157
+ detached: true,
158
+ stdio: 'ignore',
159
+ env: { ...process.env, STORYBOARD_NO_BUDDY: '1' },
160
+ })
161
+ child.unref()
162
+
163
+ // 7. Poll registry for self-registration
164
+ const start = Date.now()
165
+ let entry = null
166
+ while (Date.now() - start < 30000) {
167
+ await new Promise((r) => setTimeout(r, 500))
168
+ const matches = findByWorktree(branch)
169
+ if (matches.length > 0) { entry = matches[matches.length - 1]; break }
170
+ }
171
+
172
+ if (entry) {
173
+ spin.stop(`Dev server ready`)
174
+ const url = `http://localhost:${entry.port}/storyboard/`
175
+ p.note(
176
+ [
177
+ ` ${green(url)}`,
178
+ '',
179
+ ` ${dim('cd into it:')} ${green(`cd ${dir.replace(root + '/', '')}`)}`,
180
+ ` ${dim('stop it:')} ${green(`npx storyboard workleaf delete ${slug}`)}`,
181
+ ` ${dim('merge & clean:')} ${green(`npx storyboard workleaf merge ${slug}`)}`,
182
+ ].join('\n'),
183
+ `${bold(branch)} is alive`
184
+ )
185
+ } else {
186
+ spin.stop('Spawned but did not self-register within 30s')
187
+ p.log.warn(`Check the workleaf manually: ${dim(`cd ${dir} && npx storyboard dev`)}`)
188
+ }
189
+ p.outro('')
190
+ }
191
+
192
+ /** Read the current branch of a directory. */
193
+ function currentBranch(cwd) {
194
+ try {
195
+ return execFileSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf8' }).trim()
196
+ } catch {
197
+ return 'HEAD'
198
+ }
199
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Delete a workleaf without merging.
3
+ *
4
+ * Identical to `workleaf merge` minus the squash + commit steps. Used
5
+ * to abandon a workleaf entirely — useful when an experiment didn't
6
+ * pan out or when you want to free a port without preserving the work.
7
+ *
8
+ * Prompts for confirmation unless --yes is passed.
9
+ */
10
+
11
+ import * as p from '@clack/prompts'
12
+ import { existsSync } from 'node:fs'
13
+ import { resolve } from 'node:path'
14
+ import { repoRoot } from '../worktree/port.js'
15
+ import { detectWorkleafContext, workleafDir, listWorkleafs, parentOfWorkleaf } from './paths.js'
16
+ import { cleanupWorkleaf, hasDirtyWorkingTree } from './cleanup.js'
17
+ import { dim, bold, green, yellow } from '../cli/intro.js'
18
+ import { parseFlags } from '../cli/flags.js'
19
+
20
+ const flagSchema = {
21
+ yes: { type: 'boolean', default: false, description: 'Skip interactive confirmation' },
22
+ force: { type: 'boolean', default: false, description: 'Delete even if the workleaf has uncommitted changes' },
23
+ }
24
+
25
+ function resolveTarget(positionalName, root) {
26
+ if (positionalName) {
27
+ const branchInfo = parentOfWorkleaf(positionalName)
28
+ if (branchInfo) {
29
+ const dir = workleafDir(branchInfo.parent, branchInfo.slug, root)
30
+ return { parent: branchInfo.parent, slug: branchInfo.slug, branch: positionalName, dir, root }
31
+ }
32
+ const matches = listWorkleafs(root).filter((w) => w.slug === positionalName)
33
+ if (matches.length === 0) {
34
+ p.log.error(`No workleaf named "${positionalName}" found.`)
35
+ process.exit(1)
36
+ }
37
+ if (matches.length > 1) {
38
+ p.log.error(`Multiple workleafs match "${positionalName}":`)
39
+ for (const m of matches) console.log(` ${m.branch}`)
40
+ process.exit(1)
41
+ }
42
+ const m = matches[0]
43
+ return { parent: m.parent, slug: m.slug, branch: m.branch, dir: m.dir, root }
44
+ }
45
+ const ctx = detectWorkleafContext()
46
+ if (!ctx.inWorkleaf) {
47
+ p.log.error('Not inside a workleaf — pass a name or cd into one.')
48
+ p.log.info(`Usage: ${green('npx storyboard workleaf delete <name>')}`)
49
+ process.exit(1)
50
+ }
51
+ return { parent: ctx.parent, slug: ctx.slug, branch: ctx.branch, dir: ctx.dir, root }
52
+ }
53
+
54
+ export async function runDelete(argv = process.argv.slice(4)) {
55
+ const { flags, positional } = parseFlags(argv, flagSchema)
56
+ const root = repoRoot()
57
+ const target = resolveTarget(positional[0], root)
58
+ const { branch, dir } = target
59
+
60
+ p.intro('storyboard workleaf delete')
61
+ p.log.info(`${dim('workleaf:')} ${bold(branch)}`)
62
+
63
+ if (!existsSync(resolve(dir, '.git'))) {
64
+ p.log.warn(`Workleaf directory missing: ${dim(dir)} — cleaning up registry anyway.`)
65
+ } else if (hasDirtyWorkingTree(dir) && !flags.force) {
66
+ p.log.error(`Workleaf has uncommitted changes — pass ${green('--force')} to discard them.`)
67
+ process.exit(1)
68
+ }
69
+
70
+ if (!flags.yes) {
71
+ const confirm = await p.confirm({
72
+ message: `Permanently delete ${yellow(branch)}? This discards any unmerged commits.`,
73
+ initialValue: false,
74
+ })
75
+ if (p.isCancel(confirm) || !confirm) {
76
+ p.cancel('Cancelled')
77
+ process.exit(0)
78
+ }
79
+ }
80
+
81
+ // chdir out of the workleaf BEFORE cleanup so helpers that call
82
+ // realpathSync(cwd) don't ENOENT when the dir is gone.
83
+ try {
84
+ const safeParent = target.parent === 'main' ? root : resolve(root, 'worktrees', target.parent)
85
+ if (existsSync(safeParent)) process.chdir(safeParent)
86
+ else process.chdir(root)
87
+ } catch { /* cwd will stay where it is */ }
88
+
89
+ p.log.step('Cleaning up workleaf')
90
+ const { stopped } = cleanupWorkleaf({ branch, dir, root })
91
+
92
+ p.note(
93
+ [
94
+ ` ${green('✓')} stopped ${stopped} dev server${stopped === 1 ? '' : 's'}`,
95
+ ` ${green('✓')} removed ${dim(dir.replace(root + '/', ''))}`,
96
+ ` ${green('✓')} deleted branch ${dim(branch)}`,
97
+ ].join('\n'),
98
+ `${bold(branch)} deleted`
99
+ )
100
+ p.outro('')
101
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * List workleafs across the repo, grouped by parent, with live server info.
3
+ *
4
+ * Output shape:
5
+ *
6
+ * storyboard workleaf list
7
+ *
8
+ * main
9
+ * ● workleaf--quick-fix :1235 http://localhost:1235/storyboard/ (pid 12345)
10
+ * ○ workleaf--archive (no server)
11
+ *
12
+ * 4.3.0
13
+ * ● workleaf--bench :1236 http://localhost:1236/storyboard/ (pid 23456)
14
+ *
15
+ * `●` = live server, `○` = no live server.
16
+ */
17
+
18
+ import * as p from '@clack/prompts'
19
+ import { listWorkleafs } from './paths.js'
20
+ import { findByWorktree } from '../worktree/serverRegistry.js'
21
+ import { dim, bold, green } from '../cli/intro.js'
22
+
23
+ export function runList() {
24
+ const all = listWorkleafs()
25
+
26
+ if (all.length === 0) {
27
+ p.log.info('No workleafs yet.')
28
+ p.log.info(dim('Create one with: npx storyboard workleaf create <name>'))
29
+ return
30
+ }
31
+
32
+ // Group by parent, preserving insertion order from listWorkleafs (already
33
+ // grouped by readdir order which is parent-first).
34
+ const byParent = new Map()
35
+ for (const w of all) {
36
+ if (!byParent.has(w.parent)) byParent.set(w.parent, [])
37
+ byParent.get(w.parent).push(w)
38
+ }
39
+
40
+ console.log()
41
+ for (const [parent, leaves] of byParent) {
42
+ console.log(` ${bold(parent)}`)
43
+ for (const w of leaves) {
44
+ const servers = findByWorktree(w.branch)
45
+ if (servers.length > 0) {
46
+ const s = servers[0]
47
+ const url = `http://localhost:${s.port}/storyboard/`
48
+ console.log(` ${green('●')} workleaf--${w.slug.padEnd(28)} :${String(s.port).padEnd(5)} ${green(url)} ${dim(`(pid ${s.pid})`)}`)
49
+ } else {
50
+ console.log(` ${dim('○')} workleaf--${w.slug.padEnd(28)} ${dim('(no server)')}`)
51
+ }
52
+ }
53
+ console.log()
54
+ }
55
+
56
+ p.log.info(dim('Tip: storyboard workleaf merge <name> squashes & cleans up'))
57
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Merge a workleaf into its parent (squash) and clean it up.
3
+ *
4
+ * Lifecycle:
5
+ * 1. Resolve the target workleaf — explicit name positional OR auto-detect
6
+ * from cwd via detectWorkleafContext.
7
+ * 2. Refuse if the workleaf has uncommitted changes.
8
+ * 3. Verify the parent worktree still exists.
9
+ * 4. Preflight a squash-merge probe in the parent (and bail cleanly
10
+ * on conflict — never leave the parent in MERGING state).
11
+ * 5. Real `git merge --squash <branch>` in the parent.
12
+ * 6. Commit with a generated message + Copilot co-author trailer.
13
+ * 7. Cleanup: stop dev server, remove worktree, delete branch, release port.
14
+ *
15
+ * If we're inside the workleaf being merged, we move cwd to the parent
16
+ * directory after merge so the user isn't left in a deleted directory.
17
+ */
18
+
19
+ import * as p from '@clack/prompts'
20
+ import { execFileSync } from 'node:child_process'
21
+ import { existsSync } from 'node:fs'
22
+ import { resolve } from 'node:path'
23
+ import { repoRoot, worktreeDir } from '../worktree/port.js'
24
+ import {
25
+ detectWorkleafContext,
26
+ workleafBranchName,
27
+ workleafDir,
28
+ listWorkleafs,
29
+ parentOfWorkleaf,
30
+ } from './paths.js'
31
+ import { cleanupWorkleaf, currentBranch, hasDirtyWorkingTree } from './cleanup.js'
32
+ import { dim, bold, green } from '../cli/intro.js'
33
+ import { parseFlags } from '../cli/flags.js'
34
+
35
+ const flagSchema = {
36
+ message: { type: 'string', description: 'Override the squash commit message' },
37
+ 'no-commit': { type: 'boolean', default: false, description: 'Stage the squash but do not commit (caller commits)' },
38
+ yes: { type: 'boolean', default: false, description: 'Skip interactive confirmation' },
39
+ }
40
+
41
+ const COAUTHOR_TRAILER = 'Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>'
42
+
43
+ /**
44
+ * Locate the target workleaf by name (positional) OR by inspecting cwd.
45
+ * Returns { parent, slug, branch, dir, root } or exits with an error.
46
+ */
47
+ function resolveTarget(positionalName, root) {
48
+ // Explicit positional wins
49
+ if (positionalName) {
50
+ // The user can pass either a slug OR a full branch name. Disambiguate.
51
+ const branchInfo = parentOfWorkleaf(positionalName)
52
+ if (branchInfo) {
53
+ const dir = workleafDir(branchInfo.parent, branchInfo.slug, root)
54
+ return {
55
+ parent: branchInfo.parent,
56
+ slug: branchInfo.slug,
57
+ branch: positionalName,
58
+ dir,
59
+ root,
60
+ }
61
+ }
62
+ // Treat as slug — find a matching workleaf, prefer one parented by
63
+ // the current worktree if multiple match.
64
+ const matches = listWorkleafs(root).filter((w) => w.slug === positionalName)
65
+ if (matches.length === 0) {
66
+ p.log.error(`No workleaf named "${positionalName}" found.`)
67
+ p.log.info(`List workleafs with: ${green('npx storyboard workleaf list')}`)
68
+ process.exit(1)
69
+ }
70
+ if (matches.length > 1) {
71
+ p.log.error(`Multiple workleafs match "${positionalName}":`)
72
+ for (const m of matches) console.log(` ${m.branch} ${dim(m.dir)}`)
73
+ p.log.info(`Pass the full branch name: ${green('npx storyboard workleaf merge <parent>--workleaf--<slug>')}`)
74
+ process.exit(1)
75
+ }
76
+ const m = matches[0]
77
+ return { parent: m.parent, slug: m.slug, branch: m.branch, dir: m.dir, root }
78
+ }
79
+
80
+ // Auto-detect from cwd
81
+ const ctx = detectWorkleafContext()
82
+ if (!ctx.inWorkleaf) {
83
+ p.log.error('Not inside a workleaf — pass a name or cd into one.')
84
+ p.log.info(`Usage: ${green('npx storyboard workleaf merge <name>')}`)
85
+ process.exit(1)
86
+ }
87
+ return { parent: ctx.parent, slug: ctx.slug, branch: ctx.branch, dir: ctx.dir, root }
88
+ }
89
+
90
+ /**
91
+ * Preflight: probe whether `git merge --squash <branch>` in the parent
92
+ * would succeed. We do the probe by running the actual merge command,
93
+ * checking the result, and aborting the merge state if anything was
94
+ * staged (i.e. we always leave the parent in its pre-probe state).
95
+ *
96
+ * Returns { ok: true } on clean merge OR { ok: false, conflicts: [...] }
97
+ * on conflict. The parent is reset either way.
98
+ */
99
+ function preflightSquash(parentDir, branch) {
100
+ try {
101
+ // --squash leaves the changes staged but does NOT create a merge commit
102
+ // and does NOT set MERGE_HEAD, so on success we just need to reset.
103
+ execFileSync('git', ['merge', '--squash', '--no-commit', branch], {
104
+ cwd: parentDir,
105
+ stdio: 'pipe',
106
+ })
107
+ // Reset the staged changes so the probe is non-destructive.
108
+ execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: parentDir, stdio: 'pipe' })
109
+ return { ok: true }
110
+ } catch (err) {
111
+ // Capture conflict markers, then reset state.
112
+ let conflicts = []
113
+ try {
114
+ const out = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
115
+ cwd: parentDir,
116
+ encoding: 'utf8',
117
+ }).trim()
118
+ conflicts = out ? out.split('\n') : []
119
+ } catch { /* ignore */ }
120
+ // Always reset to leave the parent clean.
121
+ try { execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: parentDir, stdio: 'pipe' }) } catch { /* ignore */ }
122
+ return { ok: false, conflicts, error: err.message }
123
+ }
124
+ }
125
+
126
+ export async function runMerge(argv = process.argv.slice(4)) {
127
+ const { flags, positional } = parseFlags(argv, flagSchema)
128
+ const root = repoRoot()
129
+ const target = resolveTarget(positional[0], root)
130
+ const { parent, slug, branch, dir } = target
131
+
132
+ p.intro('storyboard workleaf merge')
133
+ p.log.info(`${dim('workleaf:')} ${bold(branch)}`)
134
+
135
+ // Verify workleaf exists on disk
136
+ if (!existsSync(resolve(dir, '.git'))) {
137
+ p.log.error(`Workleaf directory missing: ${dim(dir)}`)
138
+ process.exit(1)
139
+ }
140
+
141
+ // Refuse on dirty workleaf
142
+ if (hasDirtyWorkingTree(dir)) {
143
+ p.log.error(`Workleaf has uncommitted changes — commit or stash them first.`)
144
+ p.log.info(` ${dim(`cd ${dir}`)}`)
145
+ p.log.info(` ${green('git status')}`)
146
+ process.exit(1)
147
+ }
148
+
149
+ // Verify parent worktree
150
+ const parentDir = parent === 'main' ? root : worktreeDir(parent, root)
151
+ if (!existsSync(resolve(parentDir, '.git'))) {
152
+ p.log.error(`Parent worktree "${parent}" is gone.`)
153
+ p.log.info(`Recreate it (${green('npx storyboard branch ' + parent)}) and re-run, or abandon with`)
154
+ p.log.info(` ${green(`npx storyboard workleaf delete ${slug}`)}`)
155
+ process.exit(1)
156
+ }
157
+ // Refuse if parent is on a different branch than expected — that
158
+ // means the user is intentionally on a wildcard branch and probably
159
+ // doesn't want a squash here.
160
+ const parentBranch = currentBranch(parentDir)
161
+ if (!parentBranch) {
162
+ p.log.error(`Could not determine current branch of parent worktree at ${dim(parentDir)}`)
163
+ process.exit(1)
164
+ }
165
+ if (hasDirtyWorkingTree(parentDir)) {
166
+ p.log.error(`Parent worktree "${parent}" has uncommitted changes — commit or stash them first.`)
167
+ p.log.info(` ${dim(`cd ${parentDir}`)}`)
168
+ p.log.info(` ${green('git status')}`)
169
+ process.exit(1)
170
+ }
171
+
172
+ // Preflight squash
173
+ p.log.step('Checking that the squash will apply cleanly…')
174
+ const probe = preflightSquash(parentDir, branch)
175
+ if (!probe.ok) {
176
+ p.log.error('Squash would conflict with the parent.')
177
+ if (probe.conflicts && probe.conflicts.length > 0) {
178
+ for (const c of probe.conflicts) console.log(` - ${c}`)
179
+ }
180
+ p.log.info(`Resolve manually with:`)
181
+ p.log.info(` ${dim(`cd ${parentDir} && git merge --squash ${branch}`)}`)
182
+ process.exit(1)
183
+ }
184
+
185
+ // Real merge
186
+ p.log.step(`Squash-merging into ${bold(parentBranch)}`)
187
+ try {
188
+ execFileSync('git', ['merge', '--squash', branch], { cwd: parentDir, stdio: 'pipe' })
189
+ } catch (err) {
190
+ p.log.error(`Real squash failed (this shouldn't happen after a clean preflight): ${err.message}`)
191
+ process.exit(1)
192
+ }
193
+
194
+ // Commit (or stage-only)
195
+ let sha = null
196
+ if (flags['no-commit']) {
197
+ p.log.success(`Squash staged in ${bold(parentBranch)} — commit it yourself`)
198
+ } else {
199
+ const message = flags.message || `feat: ${slug} (workleaf squash)`
200
+ try {
201
+ execFileSync('git', ['commit', '-m', `${message}\n\n${COAUTHOR_TRAILER}`], {
202
+ cwd: parentDir,
203
+ stdio: 'pipe',
204
+ })
205
+ sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: parentDir, encoding: 'utf8' }).trim()
206
+ p.log.success(`Committed as ${green(sha)}: ${dim(message)}`)
207
+ } catch (err) {
208
+ p.log.error(`Commit failed: ${err.message}`)
209
+ p.log.warn(`Squash is staged in ${parentDir} but uncommitted. Resolve manually before re-running.`)
210
+ process.exit(1)
211
+ }
212
+ }
213
+
214
+ // CRITICAL: chdir to the parent BEFORE cleanup so cwd is not inside
215
+ // the directory we're about to delete. Without this, every subsequent
216
+ // call into helpers that touch cwd (releasePort -> portsFilePath ->
217
+ // realpathSync) hits ENOENT.
218
+ try { process.chdir(parentDir) } catch { /* parent may also be gone */ }
219
+
220
+ p.log.step('Cleaning up workleaf')
221
+ const { stopped } = cleanupWorkleaf({ branch, dir, root })
222
+
223
+ p.note(
224
+ [
225
+ ` ${green('✓')} squashed into ${bold(parentBranch)}${sha ? ` as ${green(sha)}` : ''}`,
226
+ ` ${green('✓')} stopped ${stopped} dev server${stopped === 1 ? '' : 's'}`,
227
+ ` ${green('✓')} removed ${dim(dir.replace(root + '/', ''))}`,
228
+ ` ${green('✓')} deleted branch ${dim(branch)}`,
229
+ '',
230
+ ` cd to the parent: ${green(`cd ${parentDir.replace(root + '/', '') || '.'}`)}`,
231
+ ].join('\n'),
232
+ `${bold(branch)} merged`
233
+ )
234
+ p.outro('')
235
+ }
236
+
237
+ // Re-export the branch-name helper for symmetry
238
+ export { workleafBranchName }