@dfosco/storyboard-core 4.0.0-beta.2 → 4.0.0-beta.21

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 (73) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +11882 -11126
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -3
  6. package/paste.config.json +54 -0
  7. package/scaffold/deploy.yml +101 -0
  8. package/scaffold/githooks/pre-push +114 -0
  9. package/scaffold/manifest.json +11 -0
  10. package/scaffold/storyboard.config.json +4 -1
  11. package/src/ActionMenuButton.svelte +12 -2
  12. package/src/CanvasCreateMenu.svelte +228 -10
  13. package/src/CanvasSnap.svelte +2 -0
  14. package/src/CoreUIBar.svelte +152 -3
  15. package/src/CreateMenuButton.svelte +4 -1
  16. package/src/InspectorPanel.svelte +2 -0
  17. package/src/PwaInstallBanner.svelte +124 -0
  18. package/src/autosync/server.js +99 -111
  19. package/src/autosync/server.test.js +0 -7
  20. package/src/canvas/collision.js +206 -0
  21. package/src/canvas/collision.test.js +271 -0
  22. package/src/canvas/deriveCanvasId.test.js +40 -0
  23. package/src/canvas/identity.js +107 -0
  24. package/src/canvas/identity.test.js +100 -0
  25. package/src/canvas/server.js +285 -31
  26. package/src/canvasConfig.js +56 -0
  27. package/src/canvasConfig.test.js +42 -0
  28. package/src/cli/canvasAdd.js +185 -0
  29. package/src/cli/canvasRead.js +208 -0
  30. package/src/cli/code.js +67 -0
  31. package/src/cli/create.js +339 -72
  32. package/src/cli/dev-helpers.js +53 -0
  33. package/src/cli/dev-helpers.test.js +53 -0
  34. package/src/cli/dev.js +245 -26
  35. package/src/cli/flags.js +174 -0
  36. package/src/cli/flags.test.js +155 -0
  37. package/src/cli/index.js +84 -13
  38. package/src/cli/intro.js +37 -0
  39. package/src/cli/proxy.js +127 -6
  40. package/src/cli/proxy.test.js +63 -0
  41. package/src/cli/schemas.js +200 -0
  42. package/src/cli/serverUrl.js +56 -0
  43. package/src/cli/setup.js +130 -20
  44. package/src/cli/snapshots.js +335 -0
  45. package/src/cli/updateVersion.js +54 -3
  46. package/src/configSchema.js +125 -0
  47. package/src/configSchema.test.js +68 -0
  48. package/src/index.js +5 -0
  49. package/src/inspector/highlighter.js +10 -2
  50. package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
  51. package/src/loader.js +21 -2
  52. package/src/loader.test.js +63 -1
  53. package/src/mobileViewport.js +57 -0
  54. package/src/mobileViewport.test.js +68 -0
  55. package/src/mountStoryboardCore.js +61 -7
  56. package/src/rename-watcher/config.json +23 -0
  57. package/src/rename-watcher/watcher.js +538 -0
  58. package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
  59. package/src/tools/handlers/flows.js +6 -7
  60. package/src/viewfinder.js +21 -9
  61. package/src/viewfinder.test.js +2 -2
  62. package/src/vite/server-plugin.js +150 -7
  63. package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
  64. package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
  65. package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
  66. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  67. package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
  68. package/src/workshop/features/createStory/index.js +14 -0
  69. package/src/workshop/features/registry.js +2 -0
  70. package/src/worktree/port.js +57 -1
  71. package/src/worktree/port.test.js +91 -1
  72. package/toolbar.config.json +3 -3
  73. package/widgets.config.json +132 -27
package/src/cli/create.js CHANGED
@@ -1,23 +1,41 @@
1
1
  /**
2
- * storyboard create — Interactive creation of prototypes, canvases, flows, and pages.
2
+ * storyboard create — Create prototypes, canvases, flows, and pages.
3
+ *
4
+ * Supports both interactive prompts and non-interactive flags.
5
+ * When all required flags are provided, skips prompts entirely.
6
+ * When some flags are provided, prompts only for missing fields.
3
7
  *
4
8
  * Usage:
5
- * storyboard create Interactive picker
6
- * storyboard create prototype Create a prototype
7
- * storyboard create canvas Create a canvas
9
+ * storyboard create Interactive picker
10
+ * storyboard create prototype Interactive prototype creation
11
+ * storyboard create prototype --name my-proto Non-interactive (or partial)
12
+ * storyboard create canvas --name my-canvas Non-interactive (or partial)
13
+ * storyboard create flow --name default --prototype my-proto
14
+ * storyboard create page --prototype my-proto --path settings
8
15
  */
9
16
 
10
17
  import * as p from '@clack/prompts'
18
+ import { parseFlags, hasFlags, formatFlagHelp } from './flags.js'
19
+ import { prototypeSchema, canvasSchema, flowSchema, pageSchema, componentSchema } from './schemas.js'
20
+ import { getServerUrl } from './serverUrl.js'
11
21
  import { detectWorktreeName, getPort } from '../worktree/port.js'
12
22
 
13
23
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
14
24
  const green = (s) => `\x1b[32m${s}\x1b[0m`
15
25
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`
16
26
 
17
- function getServerUrl() {
18
- const name = detectWorktreeName()
19
- const port = getPort(name)
20
- return `http://localhost:${port}`
27
+ function promptOrCancel(promise) {
28
+ return promise.then((v) => {
29
+ if (p.isCancel(v)) process.exit(0)
30
+ return v
31
+ })
32
+ }
33
+
34
+ function showHelp(type, schema) {
35
+ console.log(`\n ${type} flags:\n`)
36
+ console.log(formatFlagHelp(schema))
37
+ console.log('')
38
+ process.exit(0)
21
39
  }
22
40
 
23
41
  async function serverGet(path) {
@@ -137,6 +155,16 @@ async function postCreateFlow(resultPath, type) {
137
155
  // ── Prototype creation ────────────────────────────────────────
138
156
 
139
157
  async function createPrototype() {
158
+ const argv = process.argv.slice(4)
159
+ if (argv.includes('--help') || argv.includes('-h')) return showHelp('create prototype', prototypeSchema)
160
+ const flagMode = hasFlags(argv)
161
+ const { flags, errors } = flagMode ? parseFlags(argv, prototypeSchema) : { flags: {}, errors: [] }
162
+
163
+ if (errors.length) {
164
+ for (const e of errors) p.log.error(e)
165
+ process.exit(1)
166
+ }
167
+
140
168
  p.intro('storyboard create prototype')
141
169
  await ensureDevServer()
142
170
 
@@ -151,58 +179,56 @@ async function createPrototype() {
151
179
  // Server may not support this endpoint — continue without options
152
180
  }
153
181
 
154
- const isExternal = await p.confirm({
155
- message: 'Is this an external prototype?',
156
- initialValue: false,
157
- })
158
- if (p.isCancel(isExternal)) return process.exit(0)
182
+ // Resolve each field: use flag if provided, otherwise prompt
183
+ const isExternal = flags.url !== undefined
184
+ ? true
185
+ : flagMode
186
+ ? false
187
+ : await promptOrCancel(p.confirm({ message: 'Is this an external prototype?', initialValue: false }))
159
188
 
160
- let url = ''
161
- if (isExternal) {
162
- url = await p.text({
189
+ let url = flags.url || ''
190
+ if (isExternal && !url) {
191
+ url = await promptOrCancel(p.text({
163
192
  message: 'External URL',
164
193
  placeholder: 'https://example.com/prototype',
165
194
  validate: (v) => {
166
195
  if (!v) return 'URL is required for external prototypes'
167
196
  if (!/^https?:\/\//.test(v)) return 'URL must start with http:// or https://'
168
197
  },
169
- })
170
- if (p.isCancel(url)) return process.exit(0)
198
+ }))
171
199
  }
172
200
 
173
- const name = await p.text({
201
+ const name = flags.name || await promptOrCancel(p.text({
174
202
  message: 'Prototype name',
175
203
  placeholder: 'my-prototype',
176
204
  validate: (v) => {
177
205
  if (!v) return 'Name is required'
178
206
  if (/[A-Z\s]/.test(v)) return 'Use kebab-case (lowercase, hyphens)'
179
207
  },
180
- })
181
- if (p.isCancel(name)) return process.exit(0)
208
+ }))
182
209
 
183
- const title = await p.text({
210
+ const defaultTitle = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
211
+ const title = flags.title || (flagMode ? defaultTitle : await promptOrCancel(p.text({
184
212
  message: 'Display title',
185
- placeholder: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
186
- defaultValue: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
187
- })
188
- if (p.isCancel(title)) return process.exit(0)
213
+ placeholder: defaultTitle,
214
+ defaultValue: defaultTitle,
215
+ })))
189
216
 
190
217
  // Folder selection
191
- let folder = ''
192
- if (folders.length > 0) {
193
- folder = await p.select({
218
+ let folder = flags.folder || ''
219
+ if (!folder && !flagMode && folders.length > 0) {
220
+ folder = await promptOrCancel(p.select({
194
221
  message: 'Folder',
195
222
  options: [
196
223
  { value: '', label: 'None (root)' },
197
224
  ...folders.map((f) => ({ value: f, label: f })),
198
225
  ],
199
- })
200
- if (p.isCancel(folder)) return process.exit(0)
226
+ }))
201
227
  }
202
228
 
203
229
  // Template selection
204
- let partial = ''
205
- if (!isExternal && partials.length > 0) {
230
+ let partial = flags.partial || ''
231
+ if (!partial && !isExternal && !flagMode && partials.length > 0) {
206
232
  const templateOptions = [
207
233
  { value: '', label: 'Blank (no template)' },
208
234
  ...partials.map((t) => ({
@@ -211,34 +237,24 @@ async function createPrototype() {
211
237
  hint: t.directory || undefined,
212
238
  })),
213
239
  ]
214
- partial = await p.select({
215
- message: 'Template',
216
- options: templateOptions,
217
- })
218
- if (p.isCancel(partial)) return process.exit(0)
240
+ partial = await promptOrCancel(p.select({ message: 'Template', options: templateOptions }))
219
241
  }
220
242
 
221
- const author = await p.text({
243
+ const author = flags.author || (flagMode ? '' : await promptOrCancel(p.text({
222
244
  message: 'Author',
223
245
  placeholder: 'your-name',
224
246
  defaultValue: '',
225
- })
226
- if (p.isCancel(author)) return process.exit(0)
247
+ })))
227
248
 
228
- const description = await p.text({
249
+ const description = flags.description || (flagMode ? '' : await promptOrCancel(p.text({
229
250
  message: 'Description',
230
251
  placeholder: 'What is this prototype about?',
231
252
  defaultValue: '',
232
- })
233
- if (p.isCancel(description)) return process.exit(0)
253
+ })))
234
254
 
235
- let createFlow = false
236
- if (!isExternal) {
237
- createFlow = await p.confirm({
238
- message: 'Create a default flow file?',
239
- initialValue: false,
240
- })
241
- if (p.isCancel(createFlow)) return process.exit(0)
255
+ let createFlow = flags.flow ?? false
256
+ if (!flagMode && !isExternal) {
257
+ createFlow = await promptOrCancel(p.confirm({ message: 'Create a default flow file?', initialValue: false }))
242
258
  }
243
259
 
244
260
  // Submit
@@ -275,6 +291,16 @@ async function createPrototype() {
275
291
  // ── Canvas creation ───────────────────────────────────────────
276
292
 
277
293
  async function createCanvas() {
294
+ const argv = process.argv.slice(4)
295
+ if (argv.includes('--help') || argv.includes('-h')) return showHelp('create canvas', canvasSchema)
296
+ const flagMode = hasFlags(argv)
297
+ const { flags, errors } = flagMode ? parseFlags(argv, canvasSchema) : { flags: {}, errors: [] }
298
+
299
+ if (errors.length) {
300
+ for (const e of errors) p.log.error(e)
301
+ process.exit(1)
302
+ }
303
+
278
304
  p.intro('storyboard create canvas')
279
305
  await ensureDevServer()
280
306
 
@@ -287,46 +313,47 @@ async function createCanvas() {
287
313
  // Continue without folders
288
314
  }
289
315
 
290
- const name = await p.text({
316
+ const name = flags.name || await promptOrCancel(p.text({
291
317
  message: 'Canvas name',
292
318
  placeholder: 'my-canvas',
293
319
  validate: (v) => {
294
320
  if (!v) return 'Name is required'
295
321
  if (/[A-Z\s]/.test(v)) return 'Use kebab-case (lowercase, hyphens)'
296
322
  },
297
- })
298
- if (p.isCancel(name)) return process.exit(0)
323
+ }))
299
324
 
300
- const title = await p.text({
325
+ const defaultTitle = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
326
+ const title = flags.title || (flagMode ? defaultTitle : await promptOrCancel(p.text({
301
327
  message: 'Display title',
302
- placeholder: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
303
- defaultValue: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
304
- })
305
- if (p.isCancel(title)) return process.exit(0)
328
+ placeholder: defaultTitle,
329
+ defaultValue: defaultTitle,
330
+ })))
306
331
 
307
- let folder = ''
308
- if (Array.isArray(folders) && folders.length > 0) {
309
- folder = await p.select({
332
+ let folder = flags.folder || ''
333
+ if (!folder && !flagMode && Array.isArray(folders) && folders.length > 0) {
334
+ folder = await promptOrCancel(p.select({
310
335
  message: 'Folder',
311
336
  options: [
312
337
  { value: '', label: 'None (root)' },
313
338
  ...folders.map((f) => ({ value: f, label: f })),
314
339
  ],
315
- })
316
- if (p.isCancel(folder)) return process.exit(0)
340
+ }))
317
341
  }
318
342
 
319
- const grid = await p.confirm({
343
+ const grid = flags.grid ?? (flagMode ? true : await promptOrCancel(p.confirm({
320
344
  message: 'Show dot grid?',
321
345
  initialValue: true,
322
- })
323
- if (p.isCancel(grid)) return process.exit(0)
346
+ })))
324
347
 
325
- const includeJsx = await p.confirm({
348
+ const includeJsx = flags.jsx ?? (flagMode ? false : await promptOrCancel(p.confirm({
326
349
  message: 'Include JSX companion file?',
327
350
  initialValue: false,
328
- })
329
- if (p.isCancel(includeJsx)) return process.exit(0)
351
+ })))
352
+
353
+ const description = flags.description || (flagMode ? '' : (await promptOrCancel(p.text({
354
+ message: 'Description (optional)',
355
+ placeholder: 'A brief description of this canvas',
356
+ }))) || '')
330
357
 
331
358
  // Submit
332
359
  const s = p.spinner()
@@ -339,6 +366,7 @@ async function createCanvas() {
339
366
  folder: folder || undefined,
340
367
  grid,
341
368
  includeJsx,
369
+ description: description || undefined,
342
370
  })
343
371
  s.stop('Canvas created!')
344
372
  if (result.path || result.name) {
@@ -354,13 +382,245 @@ async function createCanvas() {
354
382
  p.outro('')
355
383
  }
356
384
 
385
+ // ── Flow creation ─────────────────────────────────────────────
386
+
387
+ async function createFlow() {
388
+ const argv = process.argv.slice(4)
389
+ if (argv.includes('--help') || argv.includes('-h')) return showHelp('create flow', flowSchema)
390
+ const flagMode = hasFlags(argv)
391
+ const { flags, errors } = flagMode ? parseFlags(argv, flowSchema) : { flags: {}, errors: [] }
392
+
393
+ if (errors.length) {
394
+ for (const e of errors) p.log.error(e)
395
+ process.exit(1)
396
+ }
397
+
398
+ p.intro('storyboard create flow')
399
+ await ensureDevServer()
400
+
401
+ const prototype = flags.prototype || await promptOrCancel(p.text({
402
+ message: 'Prototype name',
403
+ placeholder: 'my-prototype',
404
+ validate: (v) => { if (!v) return 'Prototype is required' },
405
+ }))
406
+
407
+ const name = flags.name || await promptOrCancel(p.text({
408
+ message: 'Flow name',
409
+ placeholder: 'default',
410
+ validate: (v) => { if (!v) return 'Name is required' },
411
+ }))
412
+
413
+ const title = flags.title || (flagMode ? '' : await promptOrCancel(p.text({
414
+ message: 'Display title',
415
+ placeholder: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
416
+ defaultValue: '',
417
+ })))
418
+
419
+ const folder = flags.folder || ''
420
+ const author = flags.author || ''
421
+ const description = flags.description || ''
422
+ const copyFrom = flags['copy-from'] || undefined
423
+ const startingPage = flags['starting-page'] || undefined
424
+ const globals = flags.globals || undefined
425
+
426
+ // Submit
427
+ const s = p.spinner()
428
+ s.start('Creating flow...')
429
+
430
+ try {
431
+ const body = {
432
+ name,
433
+ prototype,
434
+ title: title || undefined,
435
+ folder: folder || undefined,
436
+ author: author || undefined,
437
+ description: description || undefined,
438
+ copyFrom,
439
+ startingPage,
440
+ globals,
441
+ }
442
+
443
+ const result = await serverPost('/_storyboard/workshop/flows', body)
444
+ s.stop('Flow created!')
445
+ if (result.path) {
446
+ p.log.success(` ${result.path}`)
447
+ }
448
+ p.outro('')
449
+ } catch (err) {
450
+ s.stop('Failed to create flow')
451
+ p.log.error(err.message)
452
+ p.outro('')
453
+ }
454
+ }
455
+
456
+ // ── Page creation ─────────────────────────────────────────────
457
+
458
+ async function createPage() {
459
+ const argv = process.argv.slice(4)
460
+ if (argv.includes('--help') || argv.includes('-h')) return showHelp('create page', pageSchema)
461
+ const flagMode = hasFlags(argv)
462
+ const { flags, errors } = flagMode ? parseFlags(argv, pageSchema) : { flags: {}, errors: [] }
463
+
464
+ if (errors.length) {
465
+ for (const e of errors) p.log.error(e)
466
+ process.exit(1)
467
+ }
468
+
469
+ p.intro('storyboard create page')
470
+ await ensureDevServer()
471
+
472
+ const prototype = flags.prototype || await promptOrCancel(p.text({
473
+ message: 'Prototype name',
474
+ placeholder: 'my-prototype',
475
+ validate: (v) => { if (!v) return 'Prototype is required' },
476
+ }))
477
+
478
+ const pagePath = flags.path || await promptOrCancel(p.text({
479
+ message: 'Page path (e.g. settings/general)',
480
+ placeholder: 'settings',
481
+ validate: (v) => { if (!v) return 'Path is required' },
482
+ }))
483
+
484
+ const folder = flags.folder || ''
485
+ const template = flags.template || ''
486
+
487
+ // Submit
488
+ const s = p.spinner()
489
+ s.start('Creating page...')
490
+
491
+ try {
492
+ const body = {
493
+ prototype,
494
+ path: pagePath,
495
+ folder: folder || undefined,
496
+ template: template || undefined,
497
+ }
498
+
499
+ const result = await serverPost('/_storyboard/workshop/pages', body)
500
+ s.stop('Page created!')
501
+ if (result.path) {
502
+ p.log.success(` ${result.path}`)
503
+ }
504
+ p.outro('')
505
+ } catch (err) {
506
+ s.stop('Failed to create page')
507
+ p.log.error(err.message)
508
+ p.outro('')
509
+ }
510
+ }
511
+
512
+ // ── Create Component ───────────────────────────────────────────
513
+
514
+ async function createComponent() {
515
+ const rest = process.argv.slice(4)
516
+ if (rest.includes('--help') || rest.includes('-h')) showHelp('component', componentSchema)
517
+
518
+ const flagMode = hasFlags(rest)
519
+ const { flags, errors } = flagMode ? parseFlags(rest, componentSchema) : { flags: {}, errors: [] }
520
+ if (errors.length) {
521
+ for (const e of errors) p.log.error(e)
522
+ process.exit(1)
523
+ }
524
+
525
+ p.intro('storyboard create component')
526
+
527
+ const componentName = flags.name || await promptOrCancel(
528
+ p.text({
529
+ message: 'Component name',
530
+ placeholder: 'my-component',
531
+ validate: (v) => {
532
+ if (!v) return 'Name is required'
533
+ if (!/^[a-z][a-z0-9-]*$/.test(v)) return 'Use kebab-case (e.g. my-component)'
534
+ },
535
+ }),
536
+ )
537
+
538
+ // Directory picker — list existing subdirectories inside src/components/
539
+ const fs = await import('node:fs')
540
+ const path = await import('node:path')
541
+ const componentsRoot = path.resolve('src/components')
542
+
543
+ let directory = flags.directory || ''
544
+ if (!directory) {
545
+ const existingDirs = []
546
+ if (fs.existsSync(componentsRoot)) {
547
+ for (const entry of fs.readdirSync(componentsRoot, { withFileTypes: true })) {
548
+ if (entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.')) {
549
+ existingDirs.push(entry.name)
550
+ }
551
+ }
552
+ }
553
+
554
+ if (existingDirs.length > 0) {
555
+ const dirChoice = await promptOrCancel(
556
+ p.select({
557
+ message: 'Directory',
558
+ options: [
559
+ { value: '', label: 'src/components/ (root)', hint: 'Top-level component' },
560
+ ...existingDirs.map((d) => ({ value: d, label: `src/components/${d}/` })),
561
+ ],
562
+ }),
563
+ )
564
+ directory = dirChoice
565
+ }
566
+ }
567
+
568
+ // Build file path
569
+ const targetDir = directory
570
+ ? path.join(componentsRoot, directory)
571
+ : componentsRoot
572
+ const storyFile = path.join(targetDir, `${componentName}.story.jsx`)
573
+
574
+ if (fs.existsSync(storyFile)) {
575
+ p.log.error(`File already exists: ${path.relative('.', storyFile)}`)
576
+ p.outro('')
577
+ return
578
+ }
579
+
580
+ // Scaffold the story file
581
+ const pascalName = componentName
582
+ .split('-')
583
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
584
+ .join('')
585
+
586
+ const content = `/**
587
+ * ${pascalName} component stories.
588
+ * Each named export renders as an embeddable component at /components/${directory ? directory + '/' : ''}${componentName}
589
+ */
590
+
591
+ export function Default() {
592
+ return (
593
+ <div style={{ padding: '1.5rem', minWidth: 280 }}>
594
+ <p>${pascalName} component</p>
595
+ </div>
596
+ )
597
+ }
598
+ `
599
+
600
+ const s = p.spinner()
601
+ s.start('Creating component...')
602
+
603
+ fs.mkdirSync(targetDir, { recursive: true })
604
+ fs.writeFileSync(storyFile, content)
605
+
606
+ s.stop('Component created!')
607
+ p.log.success(` ${green(path.relative('.', storyFile))}`)
608
+ p.log.info(` ${dim('Route:')} /components/${directory ? directory + '/' : ''}${componentName}`)
609
+ p.outro('')
610
+ }
611
+
357
612
  // ── Dispatcher ────────────────────────────────────────────────
358
613
 
614
+ export { createFlow, createPage, createComponent, ensureDevServer, serverPost, postCreateFlow, getServerUrl }
615
+
359
616
  async function main() {
360
617
  const subcommand = process.argv[3]
361
618
 
362
619
  if (subcommand === 'prototype') return createPrototype()
363
620
  if (subcommand === 'canvas') return createCanvas()
621
+ if (subcommand === 'flow') return createFlow()
622
+ if (subcommand === 'page') return createPage()
623
+ if (subcommand === 'component') return createComponent()
364
624
 
365
625
  // Interactive picker
366
626
  p.intro('storyboard create')
@@ -370,14 +630,21 @@ async function main() {
370
630
  options: [
371
631
  { value: 'prototype', label: 'Prototype', hint: 'React-based interactive prototype' },
372
632
  { value: 'canvas', label: 'Canvas', hint: 'Freeform canvas with widgets' },
633
+ { value: 'flow', label: 'Flow', hint: 'Data context for a prototype page' },
634
+ { value: 'page', label: 'Page', hint: 'New page in a prototype' },
635
+ { value: 'component', label: 'Component', hint: 'Story-format component (.story.jsx)' },
373
636
  ],
374
637
  })
375
638
 
376
639
  if (p.isCancel(type)) return process.exit(0)
377
640
 
378
- // Re-run with the selected subcommand
379
641
  if (type === 'prototype') return createPrototype()
380
642
  if (type === 'canvas') return createCanvas()
643
+ if (type === 'flow') return createFlow()
644
+ if (type === 'page') return createPage()
645
+ if (type === 'component') return createComponent()
381
646
  }
382
647
 
383
- main()
648
+ // Only run main() when this file is the entry point, not when imported
649
+ const isDirectEntry = process.argv[2] === 'create'
650
+ if (isDirectEntry) main()
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Git helpers for storyboard dev CLI.
3
+ * Extracted for testability — no side effects on import.
4
+ */
5
+
6
+ import { execFileSync } from 'child_process'
7
+
8
+ /**
9
+ * Check if the working tree has uncommitted changes (staged or unstaged).
10
+ */
11
+ export function hasUncommittedChanges(cwd) {
12
+ try {
13
+ const status = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8' }).trim()
14
+ return status.length > 0
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Check if a local branch exists.
22
+ */
23
+ export function localBranchExists(name, cwd) {
24
+ try {
25
+ execFileSync('git', ['show-ref', '--verify', `refs/heads/${name}`], { cwd, stdio: 'ignore' })
26
+ return true
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Resolve the default branch for the repo root (main, master, or origin/HEAD target).
34
+ * Returns null if none can be determined.
35
+ */
36
+ export function resolveDefaultBranch(cwd) {
37
+ // Verify we're actually inside a git repository
38
+ try {
39
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'ignore' })
40
+ } catch {
41
+ return null
42
+ }
43
+ for (const candidate of ['main', 'master']) {
44
+ if (localBranchExists(candidate, cwd)) return candidate
45
+ }
46
+ // Try origin/HEAD
47
+ try {
48
+ const ref = execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, encoding: 'utf8' }).trim()
49
+ const name = ref.replace('refs/remotes/origin/', '')
50
+ if (name && localBranchExists(name, cwd)) return name
51
+ } catch { /* no origin/HEAD */ }
52
+ return null
53
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { hasUncommittedChanges, localBranchExists, resolveDefaultBranch } from './dev-helpers.js'
3
+ import { execSync } from 'child_process'
4
+
5
+ // These tests run against the real git repo — they verify the helpers
6
+ // work correctly with actual git state.
7
+
8
+ const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
9
+
10
+ describe('hasUncommittedChanges', () => {
11
+ it('returns a boolean', () => {
12
+ const result = hasUncommittedChanges(repoRoot)
13
+ expect(typeof result).toBe('boolean')
14
+ })
15
+
16
+ it('returns false for non-existent directory', () => {
17
+ expect(hasUncommittedChanges('/tmp/nonexistent-repo-12345')).toBe(false)
18
+ })
19
+ })
20
+
21
+ describe('localBranchExists', () => {
22
+ it('returns true for a branch that exists', () => {
23
+ // The current branch must exist
24
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoRoot, encoding: 'utf8' }).trim()
25
+ expect(localBranchExists(branch, repoRoot)).toBe(true)
26
+ })
27
+
28
+ it('returns false for a branch that does not exist', () => {
29
+ expect(localBranchExists('__nonexistent-branch-xyz-99999__', repoRoot)).toBe(false)
30
+ })
31
+
32
+ it('returns false for invalid cwd', () => {
33
+ expect(localBranchExists('main', '/tmp/nonexistent-repo-12345')).toBe(false)
34
+ })
35
+ })
36
+
37
+ describe('resolveDefaultBranch', () => {
38
+ it('returns a string or null', () => {
39
+ const result = resolveDefaultBranch(repoRoot)
40
+ expect(result === null || typeof result === 'string').toBe(true)
41
+ })
42
+
43
+ it('prefers main over master when main exists', () => {
44
+ // If main exists in this repo, it should be the default
45
+ if (localBranchExists('main', repoRoot)) {
46
+ expect(resolveDefaultBranch(repoRoot)).toBe('main')
47
+ }
48
+ })
49
+
50
+ it('returns null for non-git directory', () => {
51
+ expect(resolveDefaultBranch('/tmp')).toBe(null)
52
+ })
53
+ })