@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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +11882 -11126
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +11 -3
- package/paste.config.json +54 -0
- package/scaffold/deploy.yml +101 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/manifest.json +11 -0
- package/scaffold/storyboard.config.json +4 -1
- package/src/ActionMenuButton.svelte +12 -2
- package/src/CanvasCreateMenu.svelte +228 -10
- package/src/CanvasSnap.svelte +2 -0
- package/src/CoreUIBar.svelte +152 -3
- package/src/CreateMenuButton.svelte +4 -1
- package/src/InspectorPanel.svelte +2 -0
- package/src/PwaInstallBanner.svelte +124 -0
- package/src/autosync/server.js +99 -111
- package/src/autosync/server.test.js +0 -7
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/canvas/deriveCanvasId.test.js +40 -0
- package/src/canvas/identity.js +107 -0
- package/src/canvas/identity.test.js +100 -0
- package/src/canvas/server.js +285 -31
- package/src/canvasConfig.js +56 -0
- package/src/canvasConfig.test.js +42 -0
- package/src/cli/canvasAdd.js +185 -0
- package/src/cli/canvasRead.js +208 -0
- package/src/cli/code.js +67 -0
- package/src/cli/create.js +339 -72
- package/src/cli/dev-helpers.js +53 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +245 -26
- package/src/cli/flags.js +174 -0
- package/src/cli/flags.test.js +155 -0
- package/src/cli/index.js +84 -13
- package/src/cli/intro.js +37 -0
- package/src/cli/proxy.js +127 -6
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/schemas.js +200 -0
- package/src/cli/serverUrl.js +56 -0
- package/src/cli/setup.js +130 -20
- package/src/cli/snapshots.js +335 -0
- package/src/cli/updateVersion.js +54 -3
- package/src/configSchema.js +125 -0
- package/src/configSchema.test.js +68 -0
- package/src/index.js +5 -0
- package/src/inspector/highlighter.js +10 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
- package/src/loader.js +21 -2
- package/src/loader.test.js +63 -1
- package/src/mobileViewport.js +57 -0
- package/src/mobileViewport.test.js +68 -0
- package/src/mountStoryboardCore.js +61 -7
- package/src/rename-watcher/config.json +23 -0
- package/src/rename-watcher/watcher.js +538 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
- package/src/tools/handlers/flows.js +6 -7
- package/src/viewfinder.js +21 -9
- package/src/viewfinder.test.js +2 -2
- package/src/vite/server-plugin.js +150 -7
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
- package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
- package/src/workshop/features/createStory/index.js +14 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/toolbar.config.json +3 -3
- package/widgets.config.json +132 -27
package/src/cli/create.js
CHANGED
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* storyboard create —
|
|
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
|
|
6
|
-
* storyboard create prototype
|
|
7
|
-
* storyboard create
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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:
|
|
186
|
-
defaultValue:
|
|
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
|
|
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:
|
|
303
|
-
defaultValue:
|
|
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
|
-
|
|
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
|
+
})
|