@dfosco/storyboard 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/assets/favicon.svg +12 -0
  2. package/dist/storyboard-ui.css +1 -1
  3. package/dist/storyboard-ui.js +13907 -10816
  4. package/dist/storyboard-ui.js.map +1 -1
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +138 -127
  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 +53 -0
  22. package/scaffold/src/library/_app.module.css +8 -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 +52 -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/core/artifact/operations.js +19 -13
  32. package/src/core/autosync/server.test.js +1 -1
  33. package/src/core/canvas/githubEmbeds.js +12 -0
  34. package/src/core/canvas/githubEmbeds.test.js +71 -2
  35. package/src/core/canvas/hot-pool.js +10 -11
  36. package/src/core/canvas/materializer.js +39 -13
  37. package/src/core/canvas/server.js +324 -69
  38. package/src/core/canvas/server.test.js +365 -2
  39. package/src/core/canvas/terminal-server.js +144 -57
  40. package/src/core/canvas/undoRedo.js +174 -0
  41. package/src/core/canvas/undoRedo.test.js +250 -0
  42. package/src/core/cli/buildManifest.js +34 -0
  43. package/src/core/cli/canvasRedo.js +44 -0
  44. package/src/core/cli/canvasUndo.js +40 -0
  45. package/src/core/cli/create.js +116 -18
  46. package/src/core/cli/dev.js +23 -35
  47. package/src/core/cli/index.js +21 -0
  48. package/src/core/cli/schemas.js +2 -3
  49. package/src/core/cli/serverUrl.js +24 -0
  50. package/src/core/cli/setup.js +14 -25
  51. package/src/core/cli/terminal-welcome.js +12 -6
  52. package/src/core/cli/updateVersion.js +42 -2
  53. package/src/core/cli/workleaf.js +140 -0
  54. package/src/core/comments/api.js +1 -1
  55. package/src/core/comments/queries.js +3 -0
  56. package/src/core/comments/ui/CommentWindow.jsx +3 -2
  57. package/src/core/data/artifactIndex.js +235 -0
  58. package/src/core/data/artifactIndex.test.js +203 -0
  59. package/src/core/data/artifactManifest.js +290 -0
  60. package/src/core/data/artifactManifest.test.js +207 -0
  61. package/src/core/data/viewfinder.js +47 -7
  62. package/src/core/index.js +6 -1
  63. package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +2 -2
  64. package/src/core/lib/components/ui/avatar/avatar-image.jsx +2 -2
  65. package/src/core/lib/components/ui/avatar/avatar.jsx +2 -2
  66. package/src/core/lib/components/ui/checkbox/checkbox.jsx +12 -9
  67. package/src/core/lib/components/ui/dialog/dialog-content.jsx +45 -31
  68. package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +9 -5
  69. package/src/core/lib/components/ui/dialog/dialog.jsx +12 -3
  70. package/src/core/lib/components/ui/dialog/dialog.module.css +118 -0
  71. package/src/core/lib/components/ui/dialog/index.js +4 -34
  72. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +3 -3
  73. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +19 -9
  74. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +8 -8
  75. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +2 -2
  76. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +2 -2
  77. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +15 -4
  78. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +2 -2
  79. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +2 -2
  80. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +14 -7
  81. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +2 -2
  82. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +8 -4
  83. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +4 -4
  84. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +2 -2
  85. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +8 -2
  86. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +2 -2
  87. package/src/core/lib/components/ui/label/label.jsx +1 -2
  88. package/src/core/lib/components/ui/panel/panel-close.jsx +7 -4
  89. package/src/core/lib/components/ui/panel/panel-content.jsx +7 -9
  90. package/src/core/lib/components/ui/panel/panel-title.jsx +3 -3
  91. package/src/core/lib/components/ui/panel/panel.jsx +11 -2
  92. package/src/core/lib/components/ui/separator/separator.jsx +6 -4
  93. package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +7 -6
  94. package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +13 -6
  95. package/src/core/lib/components/ui/tooltip/tooltip.jsx +5 -5
  96. package/src/core/mountStoryboardCore.js +234 -74
  97. package/src/core/mountStoryboardCore.test.js +174 -0
  98. package/src/core/rename-watcher/watcher.js +329 -36
  99. package/src/core/rename-watcher/watcher.test.js +253 -0
  100. package/src/core/scaffold/fragments.js +287 -0
  101. package/src/core/scaffold/fragments.test.js +401 -0
  102. package/src/core/scaffold/index.js +291 -0
  103. package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
  104. package/src/core/scaffold/scaffold-integration.test.js +247 -0
  105. package/src/core/scaffold.js +4 -97
  106. package/src/core/stores/configSchema.js +41 -2
  107. package/src/core/stores/recentArtifacts.js +49 -2
  108. package/src/core/stores/recentArtifacts.test.js +59 -6
  109. package/src/core/stores/themeBootstrap.js +264 -0
  110. package/src/core/stores/themeBootstrap.test.js +211 -0
  111. package/src/core/stores/themeStore.test.js +154 -0
  112. package/src/core/stores/themeStore.ts +235 -220
  113. package/src/core/styles/generateThemesCss.js +63 -0
  114. package/src/core/styles/tailwind.css +12 -4
  115. package/src/core/styles/workspace-chrome.css +30 -0
  116. package/src/core/tools/handlers/paletteTheme.js +11 -8
  117. package/src/core/ui/ActionMenuButton.jsx +2 -1
  118. package/src/core/ui/CanvasCreateMenu.jsx +1 -19
  119. package/src/core/ui/CoreUIBar.jsx +10 -7
  120. package/src/core/ui/ThemeMenuButton.jsx +45 -30
  121. package/src/core/vite/server-plugin.js +36 -0
  122. package/src/core/workleaf/cleanup.js +102 -0
  123. package/src/core/workleaf/create.js +199 -0
  124. package/src/core/workleaf/delete.js +101 -0
  125. package/src/core/workleaf/list.js +57 -0
  126. package/src/core/workleaf/merge.js +238 -0
  127. package/src/core/workleaf/paths.js +146 -0
  128. package/src/core/workleaf/paths.test.js +230 -0
  129. package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +28 -21
  130. package/src/core/workshop/features/createFlow/server.js +5 -1
  131. package/src/core/workshop/features/createPrototype/server.js +5 -1
  132. package/src/core/workshop/features/templateIndex.js +28 -1
  133. package/src/core/worktree/port.js +48 -5
  134. package/src/core/worktree/port.test.js +42 -0
  135. package/src/internals/ArtifactForm/ArtifactForm.jsx +173 -112
  136. package/src/internals/ArtifactForm/ArtifactForm.module.css +48 -66
  137. package/src/internals/ArtifactForm/artifactSchemas.js +40 -5
  138. package/src/internals/BaseUiForm/BaseUiForm.jsx +293 -0
  139. package/src/internals/BaseUiForm/BaseUiForm.module.css +542 -0
  140. package/src/internals/BranchesDropdown/BranchesDropdown.jsx +69 -0
  141. package/src/internals/BranchesDropdown/BranchesDropdown.module.css +84 -0
  142. package/src/internals/CommandPalette/CommandPalette.jsx +25 -19
  143. package/src/internals/CommandPalette/CreateArtifactForm.jsx +150 -0
  144. package/src/internals/CommandPalette/CreateDialog.jsx +20 -110
  145. package/src/internals/CommandPalette/WidgetArtifactDialog.jsx +1 -1
  146. package/src/internals/CommandPalette/createHelpers.js +30 -0
  147. package/src/internals/CreatePage/CreatePage.jsx +57 -0
  148. package/src/internals/CreatePage/CreatePage.module.css +34 -0
  149. package/src/internals/Dialog.jsx +51 -0
  150. package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +533 -0
  151. package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +323 -0
  152. package/src/internals/Tooltip.jsx +39 -0
  153. package/src/internals/Viewfinder.jsx +196 -48
  154. package/src/internals/Viewfinder.module.css +72 -1
  155. package/src/internals/canvas/CanvasPage.jsx +267 -124
  156. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
  157. package/src/internals/canvas/PageSelector.jsx +15 -1
  158. package/src/internals/canvas/PageSelector.module.css +14 -0
  159. package/src/internals/canvas/canvasApi.js +40 -5
  160. package/src/internals/canvas/isolation/createIsolationMiddleware.js +68 -0
  161. package/src/internals/canvas/isolation/mountIsolationApp.jsx +88 -0
  162. package/src/internals/canvas/isolation/prototypeRoutes.jsx +131 -0
  163. package/src/internals/canvas/isolation/prototypesEntry.jsx +28 -0
  164. package/src/internals/canvas/isolation/storiesEntry.jsx +20 -0
  165. package/src/internals/canvas/isolation/storyRoutes.jsx +55 -0
  166. package/src/internals/canvas/useUndoRedo.js +75 -60
  167. package/src/internals/canvas/useUndoRedo.test.js +46 -178
  168. package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +1 -1
  169. package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
  170. package/src/internals/canvas/widgets/ImageWidget.jsx +7 -2
  171. package/src/internals/canvas/widgets/LinkPreview.jsx +45 -1
  172. package/src/internals/canvas/widgets/LinkPreview.module.css +71 -0
  173. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +69 -10
  174. package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +114 -0
  175. package/src/internals/canvas/widgets/StorySetWidget.jsx +8 -4
  176. package/src/internals/canvas/widgets/StoryWidget.jsx +21 -38
  177. package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
  178. package/src/internals/canvas/widgets/WidgetChrome.jsx +1 -1
  179. package/src/internals/canvas/widgets/WidgetChrome.module.css +15 -0
  180. package/src/internals/canvas/widgets/embedTheme.js +2 -2
  181. package/src/internals/canvas/widgets/expandUtils.js +19 -19
  182. package/src/internals/canvas/widgets/normalizeLegacyEmbedSrc.js +74 -0
  183. package/src/internals/context.jsx +22 -1
  184. package/src/internals/context.test.jsx +47 -0
  185. package/src/internals/hooks/useArtifactIndex.js +31 -0
  186. package/src/internals/hooks/useArtifactIndex.test.js +45 -0
  187. package/src/internals/hooks/usePrototypeReloadGuard.js +14 -8
  188. package/src/internals/index.js +6 -0
  189. package/src/internals/loadConsumerWrapper.js +60 -0
  190. package/src/internals/story/ComponentSetPage.jsx +75 -34
  191. package/src/internals/story/StoryPage.jsx +16 -25
  192. package/src/internals/vite/data-plugin.js +313 -148
  193. package/src/internals/vite/data-plugin.test.js +11 -11
  194. package/{scaffold/storyboard.config.json → storyboard.config.json} +25 -0
  195. package/terminal.config.json +1 -1
  196. package/src/core/lib/components/ui/card/card-action.jsx +0 -11
  197. package/src/core/lib/components/ui/card/card-content.jsx +0 -11
  198. package/src/core/lib/components/ui/card/card-description.jsx +0 -11
  199. package/src/core/lib/components/ui/card/card-footer.jsx +0 -11
  200. package/src/core/lib/components/ui/card/card-header.jsx +0 -19
  201. package/src/core/lib/components/ui/card/card-title.jsx +0 -11
  202. package/src/core/lib/components/ui/card/card.jsx +0 -17
  203. package/src/core/lib/components/ui/card/index.js +0 -25
  204. package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +0 -7
  205. package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +0 -7
  206. package/src/core/lib/components/ui/collapsible/collapsible.jsx +0 -7
  207. package/src/core/lib/components/ui/collapsible/index.js +0 -13
  208. package/src/core/lib/components/ui/dialog/dialog-close.jsx +0 -7
  209. package/src/core/lib/components/ui/dialog/dialog-description.jsx +0 -15
  210. package/src/core/lib/components/ui/dialog/dialog-footer.jsx +0 -23
  211. package/src/core/lib/components/ui/dialog/dialog-header.jsx +0 -11
  212. package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +0 -15
  213. package/src/core/lib/components/ui/dialog/dialog-portal.jsx +0 -4
  214. package/src/core/lib/components/ui/dialog/dialog-title.jsx +0 -15
  215. package/src/core/lib/components/ui/popover/index.js +0 -28
  216. package/src/core/lib/components/ui/popover/popover-close.jsx +0 -7
  217. package/src/core/lib/components/ui/popover/popover-content.jsx +0 -22
  218. package/src/core/lib/components/ui/popover/popover-description.jsx +0 -11
  219. package/src/core/lib/components/ui/popover/popover-header.jsx +0 -11
  220. package/src/core/lib/components/ui/popover/popover-portal.jsx +0 -4
  221. package/src/core/lib/components/ui/popover/popover-title.jsx +0 -11
  222. package/src/core/lib/components/ui/popover/popover-trigger.jsx +0 -8
  223. package/src/core/lib/components/ui/popover/popover.jsx +0 -4
  224. package/src/core/lib/components/ui/select/index.js +0 -37
  225. package/src/core/lib/components/ui/select/select-content.jsx +0 -30
  226. package/src/core/lib/components/ui/select/select-group-heading.jsx +0 -17
  227. package/src/core/lib/components/ui/select/select-group.jsx +0 -15
  228. package/src/core/lib/components/ui/select/select-item.jsx +0 -26
  229. package/src/core/lib/components/ui/select/select-label.jsx +0 -11
  230. package/src/core/lib/components/ui/select/select-portal.jsx +0 -4
  231. package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +0 -18
  232. package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +0 -18
  233. package/src/core/lib/components/ui/select/select-separator.jsx +0 -15
  234. package/src/core/lib/components/ui/select/select-trigger.jsx +0 -25
  235. package/src/core/lib/components/ui/select/select.jsx +0 -4
  236. package/src/core/lib/components/ui/sheet/index.js +0 -34
  237. package/src/core/lib/components/ui/sheet/sheet-close.jsx +0 -7
  238. package/src/core/lib/components/ui/sheet/sheet-content.jsx +0 -35
  239. package/src/core/lib/components/ui/sheet/sheet-description.jsx +0 -15
  240. package/src/core/lib/components/ui/sheet/sheet-footer.jsx +0 -11
  241. package/src/core/lib/components/ui/sheet/sheet-header.jsx +0 -11
  242. package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +0 -15
  243. package/src/core/lib/components/ui/sheet/sheet-portal.jsx +0 -4
  244. package/src/core/lib/components/ui/sheet/sheet-title.jsx +0 -15
  245. package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +0 -7
  246. package/src/core/lib/components/ui/sheet/sheet.jsx +0 -4
  247. package/src/core/lib/components/ui/toggle/index.js +0 -8
  248. package/src/core/lib/components/ui/toggle/toggle.jsx +0 -36
  249. package/src/core/lib/components/ui/toggle-group/index.js +0 -10
  250. package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +0 -29
  251. package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +0 -43
  252. package/src/internals/canvas/StoryErrorBoundary.jsx +0 -47
  253. package/src/internals/canvas/componentIsolate.jsx +0 -144
  254. package/src/internals/canvas/componentSetIsolate.jsx +0 -300
  255. package/src/internals/canvas/widgets/InlineStoryRenderer.jsx +0 -129
  256. package/src/internals/canvas/widgets/InlineStoryRenderer.test.jsx +0 -68
  257. package/src/primer/ThemeSync.jsx +0 -73
  258. package/src/primer/index.js +0 -1
@@ -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 }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Workleaf path helpers.
3
+ *
4
+ * A workleaf is an ephemeral sub-worktree owned by a parent worktree (or
5
+ * main). It lives at:
6
+ *
7
+ * <repo-root>/workleafs/<parent>/workleaf--<slug>/
8
+ *
9
+ * The parent name is encoded in the path — no metadata file is needed.
10
+ * Workleaves cannot be nested: spawning a workleaf from inside another
11
+ * workleaf is refused at the CLI layer using `detectWorkleafContext`.
12
+ *
13
+ * Branch naming convention is `<parent>--workleaf--<slug>` so the branch
14
+ * is self-describing in `git branch` output even without spatial context.
15
+ *
16
+ * This module is intentionally pure and side-effect-free so it can be
17
+ * unit-tested without touching the filesystem (apart from the listing
18
+ * helper, which obviously must read disk).
19
+ */
20
+
21
+ import { existsSync, readdirSync, realpathSync } from 'fs'
22
+ import { join } from 'path'
23
+ import { repoRoot, slugify } from '../worktree/port.js'
24
+
25
+ /** Literal prefix every workleaf directory and branch slug carries. */
26
+ export const WORKLEAF_PREFIX = 'workleaf--'
27
+
28
+ /** Branch convention: `<parent>--workleaf--<slug>`. */
29
+ export const WORKLEAF_BRANCH_SEPARATOR = '--workleaf--'
30
+
31
+ /**
32
+ * Build the absolute on-disk path for a workleaf.
33
+ *
34
+ * @param {string} parent — parent worktree name (e.g. 'main' or '4.3.0')
35
+ * @param {string} name — user-supplied workleaf name (will be slugified)
36
+ * @param {string} [cwd] — for tests; falls back to process.cwd()
37
+ * @returns {string} absolute path
38
+ */
39
+ export function workleafDir(parent, name, cwd) {
40
+ const root = repoRoot(cwd)
41
+ const slug = slugify(name)
42
+ return join(root, 'workleafs', parent, WORKLEAF_PREFIX + slug)
43
+ }
44
+
45
+ /**
46
+ * Compose the git branch name for a workleaf.
47
+ *
48
+ * @param {string} parent
49
+ * @param {string} name
50
+ * @returns {string} e.g. '4.3.0--workleaf--quick-fix'
51
+ */
52
+ export function workleafBranchName(parent, name) {
53
+ const slug = slugify(name)
54
+ return `${parent}${WORKLEAF_BRANCH_SEPARATOR}${slug}`
55
+ }
56
+
57
+ /**
58
+ * Inverse of {@link workleafBranchName}. Splits `parent--workleaf--slug`
59
+ * into its parts. Uses the LAST occurrence of `--workleaf--` so the
60
+ * function survives parent names that contain `--workleaf--` literally
61
+ * (unlikely, but cheap to handle).
62
+ *
63
+ * Returns null if the input is not a workleaf branch.
64
+ */
65
+ export function parentOfWorkleaf(branchName) {
66
+ if (typeof branchName !== 'string') return null
67
+ const idx = branchName.lastIndexOf(WORKLEAF_BRANCH_SEPARATOR)
68
+ if (idx <= 0) return null
69
+ const parent = branchName.slice(0, idx)
70
+ const slug = branchName.slice(idx + WORKLEAF_BRANCH_SEPARATOR.length)
71
+ if (!parent || !slug) return null
72
+ return { parent, slug }
73
+ }
74
+
75
+ /**
76
+ * Detect whether the given cwd is inside a workleaf, and if so, which
77
+ * parent and slug it belongs to.
78
+ *
79
+ * Returns:
80
+ * { inWorkleaf: true, parent, slug, branch, dir, root } when inside
81
+ * { inWorkleaf: false } when outside
82
+ *
83
+ * Uses realpathSync to resolve symlinks before pattern-matching — same
84
+ * convention as `worktree/port.js`. Falls back to the raw cwd if the
85
+ * path does not exist on disk yet (e.g. during dry-run tests).
86
+ */
87
+ export function detectWorkleafContext(cwd = process.cwd()) {
88
+ let realCwd
89
+ try {
90
+ realCwd = realpathSync(cwd)
91
+ } catch {
92
+ realCwd = cwd
93
+ }
94
+
95
+ // Match <root>/workleafs/<parent>/workleaf--<slug> (anywhere within it).
96
+ // Capture groups: 1=root, 2=parent, 3=slug.
97
+ const re = new RegExp(`^(.+)[/\\\\]workleafs[/\\\\]([^/\\\\]+)[/\\\\]${WORKLEAF_PREFIX}([^/\\\\]+)`)
98
+ const m = realCwd.match(re)
99
+ if (!m) return { inWorkleaf: false }
100
+
101
+ const [, root, parent, slug] = m
102
+ return {
103
+ inWorkleaf: true,
104
+ parent,
105
+ slug,
106
+ branch: workleafBranchName(parent, slug),
107
+ dir: join(root, 'workleafs', parent, WORKLEAF_PREFIX + slug),
108
+ root,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Enumerate every workleaf on disk under <repoRoot>/workleafs/, grouped
114
+ * by parent. Skips entries that don't look like real git worktrees
115
+ * (must contain a `.git` file at the workleaf root).
116
+ *
117
+ * @param {string} [cwd]
118
+ * @returns {Array<{ parent: string, slug: string, branch: string, dir: string }>}
119
+ */
120
+ export function listWorkleafs(cwd) {
121
+ const root = repoRoot(cwd)
122
+ const base = join(root, 'workleafs')
123
+ if (!existsSync(base)) return []
124
+
125
+ const out = []
126
+ for (const parentEntry of readdirSync(base, { withFileTypes: true })) {
127
+ if (!parentEntry.isDirectory()) continue
128
+ const parent = parentEntry.name
129
+ const parentDir = join(base, parent)
130
+ for (const child of readdirSync(parentDir, { withFileTypes: true })) {
131
+ if (!child.isDirectory()) continue
132
+ if (!child.name.startsWith(WORKLEAF_PREFIX)) continue
133
+ const dir = join(parentDir, child.name)
134
+ // A worktree-managed directory has a `.git` file (not dir) inside.
135
+ if (!existsSync(join(dir, '.git'))) continue
136
+ const slug = child.name.slice(WORKLEAF_PREFIX.length)
137
+ out.push({
138
+ parent,
139
+ slug,
140
+ branch: workleafBranchName(parent, slug),
141
+ dir,
142
+ })
143
+ }
144
+ }
145
+ return out
146
+ }