@dfosco/storyboard-core 4.0.0-beta.4 → 4.0.0-beta.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "4.0.0-beta.4",
3
+ "version": "4.0.0-beta.6",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -81,7 +81,7 @@ function hasScopedStagedChanges(root, files) {
81
81
  function listChangedFiles(root) {
82
82
  const tracked = git(['diff', '--name-only'], root)
83
83
  const untracked = git(['ls-files', '--others', '--exclude-standard'], root)
84
- return [...tracked, ...untracked]
84
+ return [tracked, untracked]
85
85
  .flatMap((raw) => raw.split('\n'))
86
86
  .map((file) => file.trim())
87
87
  .filter(Boolean)
@@ -0,0 +1,212 @@
1
+ /**
2
+ * storyboard canvas read — Read canvas state and list widgets with their IDs, URLs, and content.
3
+ *
4
+ * Usage:
5
+ * storyboard canvas read my-canvas List all widgets
6
+ * storyboard canvas read my-canvas --json Output as JSON
7
+ * storyboard canvas read my-canvas --id abc Get a specific widget by ID
8
+ *
9
+ * Output includes:
10
+ * - Widget ID
11
+ * - Widget type
12
+ * - Position (x, y)
13
+ * - Content (text, url, src depending on type)
14
+ * - File path (for images)
15
+ */
16
+
17
+ import * as p from '@clack/prompts'
18
+ import { detectWorktreeName, getPort } from '../worktree/port.js'
19
+
20
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
21
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`
22
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`
23
+
24
+ function getServerUrl() {
25
+ const name = detectWorktreeName()
26
+ const port = getPort(name)
27
+ return `http://localhost:${port}`
28
+ }
29
+
30
+ async function checkServer() {
31
+ try {
32
+ await fetch(getServerUrl(), { signal: AbortSignal.timeout(2000) })
33
+ return true
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Extract the primary content from a widget based on its type.
41
+ */
42
+ function getWidgetContent(widget) {
43
+ const { type, props = {} } = widget
44
+ switch (type) {
45
+ case 'sticky-note':
46
+ return { content: props.text || '', contentType: 'text' }
47
+ case 'markdown':
48
+ return { content: props.content || '', contentType: 'markdown' }
49
+ case 'prototype':
50
+ return { content: props.src || '', contentType: 'url', url: props.src }
51
+ case 'figma-embed':
52
+ return { content: props.url || '', contentType: 'url', url: props.url }
53
+ case 'link-preview':
54
+ return { content: props.url || '', contentType: 'url', url: props.url }
55
+ case 'image':
56
+ return {
57
+ content: props.src || '',
58
+ contentType: 'image',
59
+ url: props.src ? `/_storyboard/canvas/images/${props.src}` : '',
60
+ filePath: props.src ? `src/canvas/images/${props.src}` : '',
61
+ }
62
+ default:
63
+ return { content: JSON.stringify(props), contentType: 'props' }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Format a widget for human-readable output.
69
+ */
70
+ function formatWidget(widget) {
71
+ const { id, type, position = {} } = widget
72
+ const { content, contentType, url, filePath } = getWidgetContent(widget)
73
+
74
+ const lines = []
75
+ lines.push(`${bold(id)} ${dim(`(${type})`)}`)
76
+ lines.push(` Position: ${position.x ?? 0}, ${position.y ?? 0}`)
77
+
78
+ if (contentType === 'image') {
79
+ lines.push(` File: ${cyan(filePath)}`)
80
+ if (content) lines.push(` Filename: ${content}`)
81
+ } else if (contentType === 'url') {
82
+ lines.push(` URL: ${cyan(url || content)}`)
83
+ } else if (content) {
84
+ const preview = content.length > 80 ? content.slice(0, 77) + '...' : content
85
+ lines.push(` Content: ${preview}`)
86
+ }
87
+
88
+ return lines.join('\n')
89
+ }
90
+
91
+ async function canvasRead() {
92
+ const args = process.argv.slice(4)
93
+
94
+ if (args.includes('--help') || args.includes('-h')) {
95
+ console.log(`
96
+ canvas read — Read canvas state and list widgets
97
+
98
+ Usage:
99
+ storyboard canvas read <canvas-name> List all widgets
100
+ storyboard canvas read <canvas-name> --json Output as JSON
101
+ storyboard canvas read <canvas-name> --id <widget-id> Get specific widget
102
+
103
+ Output includes widget ID, type, position, content, URLs, and file paths.
104
+ `)
105
+ process.exit(0)
106
+ }
107
+
108
+ // Parse args
109
+ let canvasName = ''
110
+ let outputJson = false
111
+ let widgetId = ''
112
+
113
+ for (let i = 0; i < args.length; i++) {
114
+ const arg = args[i]
115
+ if (arg === '--json') {
116
+ outputJson = true
117
+ } else if (arg === '--id' && args[i + 1]) {
118
+ widgetId = args[++i]
119
+ } else if (!arg.startsWith('-') && !canvasName) {
120
+ canvasName = arg
121
+ }
122
+ }
123
+
124
+ // Check server
125
+ const serverUp = await checkServer()
126
+ if (!serverUp) {
127
+ p.log.error('Dev server is not running. Start it with: storyboard dev')
128
+ process.exit(1)
129
+ }
130
+
131
+ const base = getServerUrl()
132
+
133
+ // If no canvas name, list available canvases
134
+ if (!canvasName) {
135
+ try {
136
+ const res = await fetch(`${base}/_storyboard/canvas/list`)
137
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
138
+ const data = await res.json()
139
+ const canvases = data.canvases || []
140
+
141
+ if (outputJson) {
142
+ console.log(JSON.stringify(canvases, null, 2))
143
+ } else {
144
+ if (canvases.length === 0) {
145
+ p.log.info('No canvases found')
146
+ } else {
147
+ console.log('\nAvailable canvases:\n')
148
+ for (const c of canvases) {
149
+ console.log(` ${bold(c.name)} ${dim(`(${c.widgetCount} widgets)`)}`)
150
+ }
151
+ console.log('')
152
+ }
153
+ }
154
+ } catch (err) {
155
+ p.log.error(`Failed to list canvases: ${err.message}`)
156
+ process.exit(1)
157
+ }
158
+ return
159
+ }
160
+
161
+ // Read canvas state
162
+ try {
163
+ const res = await fetch(`${base}/_storyboard/canvas/read?name=${encodeURIComponent(canvasName)}`)
164
+ if (!res.ok) {
165
+ const text = await res.text().catch(() => '')
166
+ throw new Error(`${res.status} ${res.statusText}${text ? ': ' + text : ''}`)
167
+ }
168
+ const data = await res.json()
169
+ const widgets = data.widgets || []
170
+
171
+ // If filtering by widget ID
172
+ if (widgetId) {
173
+ const widget = widgets.find((w) => w.id === widgetId)
174
+ if (!widget) {
175
+ p.log.error(`Widget "${widgetId}" not found in canvas "${canvasName}"`)
176
+ process.exit(1)
177
+ }
178
+
179
+ if (outputJson) {
180
+ const enriched = { ...widget, ...getWidgetContent(widget) }
181
+ console.log(JSON.stringify(enriched, null, 2))
182
+ } else {
183
+ console.log('')
184
+ console.log(formatWidget(widget))
185
+ console.log('')
186
+ }
187
+ return
188
+ }
189
+
190
+ // List all widgets
191
+ if (outputJson) {
192
+ const enriched = widgets.map((w) => ({ ...w, ...getWidgetContent(w) }))
193
+ console.log(JSON.stringify({ ...data, widgets: enriched }, null, 2))
194
+ } else {
195
+ console.log(`\n${bold(data.title || canvasName)} ${dim(`(${widgets.length} widgets)`)}\n`)
196
+
197
+ if (widgets.length === 0) {
198
+ console.log(' No widgets on this canvas.\n')
199
+ } else {
200
+ for (const widget of widgets) {
201
+ console.log(formatWidget(widget))
202
+ console.log('')
203
+ }
204
+ }
205
+ }
206
+ } catch (err) {
207
+ p.log.error(`Failed to read canvas: ${err.message}`)
208
+ process.exit(1)
209
+ }
210
+ }
211
+
212
+ canvasRead()
@@ -0,0 +1,67 @@
1
+ /**
2
+ * storyboard code [branch] — Open a worktree in VS Code.
3
+ *
4
+ * Usage:
5
+ * storyboard code # open current worktree or repo root
6
+ * storyboard code main # open repo root
7
+ * storyboard code <branch> # open .worktrees/<branch>/
8
+ */
9
+
10
+ import * as p from '@clack/prompts'
11
+ import { execFileSync } from 'child_process'
12
+ import { existsSync } from 'fs'
13
+ import { resolve } from 'path'
14
+ import { detectWorktreeName, repoRoot, worktreeDir, listWorktrees } from '../worktree/port.js'
15
+
16
+ const branch = process.argv[3] || undefined
17
+
18
+ p.intro('storyboard code')
19
+
20
+ function openInCode(dir) {
21
+ try {
22
+ execFileSync('code', [dir], { stdio: 'inherit' })
23
+ return true
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ const root = repoRoot()
30
+
31
+ if (!branch) {
32
+ // No argument — open current directory
33
+ const name = detectWorktreeName()
34
+ const dir = name === 'main' ? root : worktreeDir(name)
35
+ if (openInCode(dir)) {
36
+ p.outro(`Opened ${name === 'main' ? 'repo root' : `.worktrees/${name}/`}`)
37
+ } else {
38
+ p.log.error('Could not open VS Code. Is the `code` CLI installed?')
39
+ p.log.info('Run `npx storyboard setup` to install it, or open VS Code and run:')
40
+ p.log.info(' Cmd+Shift+P → "Shell Command: Install \'code\' command in PATH"')
41
+ process.exit(1)
42
+ }
43
+ } else if (branch === 'main') {
44
+ if (openInCode(root)) {
45
+ p.outro('Opened repo root')
46
+ } else {
47
+ p.log.error('Could not open VS Code.')
48
+ process.exit(1)
49
+ }
50
+ } else {
51
+ const dir = worktreeDir(branch)
52
+ if (!existsSync(resolve(dir, '.git'))) {
53
+ // List available worktrees as a hint
54
+ const available = listWorktrees()
55
+ p.log.error(`Worktree "${branch}" does not exist.`)
56
+ if (available.length > 0) {
57
+ p.log.info(`Available worktrees: ${available.join(', ')}`)
58
+ }
59
+ process.exit(1)
60
+ }
61
+ if (openInCode(dir)) {
62
+ p.outro(`Opened .worktrees/${branch}/`)
63
+ } else {
64
+ p.log.error('Could not open VS Code.')
65
+ process.exit(1)
66
+ }
67
+ }
@@ -0,0 +1,47 @@
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
+ for (const candidate of ['main', 'master']) {
38
+ if (localBranchExists(candidate, cwd)) return candidate
39
+ }
40
+ // Try origin/HEAD
41
+ try {
42
+ const ref = execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, encoding: 'utf8' }).trim()
43
+ const name = ref.replace('refs/remotes/origin/', '')
44
+ if (name && localBranchExists(name, cwd)) return name
45
+ } catch { /* no origin/HEAD */ }
46
+ return null
47
+ }
@@ -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
+ })
package/src/cli/dev.js CHANGED
@@ -1,20 +1,219 @@
1
1
  /**
2
- * storyboard dev — Start Vite with correct base path for the current worktree.
2
+ * storyboard dev [branch] — Start Vite with correct base path.
3
+ *
4
+ * Usage:
5
+ * storyboard dev # detect worktree from cwd
6
+ * storyboard dev main # start dev for repo root
7
+ * storyboard dev <worktree> # start dev for existing worktree
8
+ * storyboard dev <branch> # auto-create worktree + start dev
3
9
  *
4
10
  * Main: http://<devDomain>.localhost/
5
11
  * Branch: http://<devDomain>.localhost/branch--<name>/
6
12
  */
7
13
 
8
14
  import * as p from '@clack/prompts'
9
- import { spawn } from 'child_process'
15
+ import { spawn, execFileSync } from 'child_process'
10
16
  import { existsSync } from 'fs'
11
17
  import { resolve } from 'path'
12
- import { detectWorktreeName, getPort } from '../worktree/port.js'
18
+ import { detectWorktreeName, getPort, repoRoot, worktreeDir, listWorktrees } from '../worktree/port.js'
13
19
  import { generateCaddyfile, generateRouteConfig, upsertCaddyRoute, isCaddyRunning, reloadCaddy, readDevDomain } from './proxy.js'
14
20
  import { startRenameWatcher } from '../rename-watcher/watcher.js'
21
+ import { parseFlags } from './flags.js'
22
+ import { hasUncommittedChanges, localBranchExists, resolveDefaultBranch } from './dev-helpers.js'
23
+
24
+ const flagSchema = {
25
+ port: { type: 'number', description: 'Override dev server port' },
26
+ create: { type: 'boolean', default: true, description: 'Allow creating worktrees/branches (disable with --no-create)' },
27
+ }
28
+
29
+ /**
30
+ * Check if a remote branch exists on origin.
31
+ * @param {string} name
32
+ * @param {string} cwd
33
+ * @returns {boolean}
34
+ */
35
+ function remoteBranchExists(name, cwd) {
36
+ try {
37
+ const result = execFileSync('git', ['ls-remote', '--exit-code', '--heads', 'origin', name], { cwd, encoding: 'utf8' })
38
+ return result.trim().length > 0
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Create a git worktree and install dependencies.
46
+ * @param {string} name — worktree/branch name
47
+ * @param {string} root — repo root path
48
+ * @param {object} opts
49
+ * @param {boolean} opts.newBranch — create a new branch from HEAD
50
+ * @returns {string} path to the new worktree directory
51
+ */
52
+ function createWorktree(name, root, { newBranch = false } = {}) {
53
+ const targetDir = resolve(root, '.worktrees', name)
54
+
55
+ const gitArgs = newBranch
56
+ ? ['worktree', 'add', targetDir, '-b', name]
57
+ : ['worktree', 'add', targetDir, name]
58
+
59
+ p.log.step(`Creating worktree: .worktrees/${name}`)
60
+ execFileSync('git', gitArgs, { cwd: root, stdio: 'inherit' })
61
+
62
+ p.log.step('Installing dependencies…')
63
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'
64
+ execFileSync(npmBin, ['install'], { cwd: targetDir, stdio: 'inherit' })
65
+
66
+ getPort(name)
67
+ return targetDir
68
+ }
69
+
70
+ /**
71
+ * Resolve the target worktree for `storyboard dev [branch]`.
72
+ *
73
+ * When no argument is given and the repo root is on a non-main branch,
74
+ * prompts the user to convert it to a proper worktree.
75
+ *
76
+ * @param {string|undefined} branchArg — positional branch argument
77
+ * @param {object} opts
78
+ * @param {boolean} opts.allowCreate — whether creation is allowed
79
+ * @returns {Promise<{ worktreeName: string, targetCwd: string, created: boolean }>}
80
+ */
81
+ async function resolveDevTarget(branchArg, { allowCreate = true } = {}) {
82
+ // No argument — detect from cwd
83
+ if (!branchArg) {
84
+ const detectedName = detectWorktreeName()
85
+
86
+ // Already in a worktree or on main — use cwd as-is
87
+ const root = repoRoot()
88
+ const realCwd = resolve(process.cwd())
89
+ const isAtRoot = realCwd === resolve(root)
90
+ if (detectedName === 'main' || !isAtRoot) {
91
+ return { worktreeName: detectedName, targetCwd: process.cwd(), created: false }
92
+ }
93
+
94
+ // Root is on a non-main branch — check for existing worktree first
95
+ const branch = detectedName
96
+ const existingDir = worktreeDir(branch)
97
+ if (existsSync(resolve(existingDir, '.git'))) {
98
+ p.log.info(`Root is on branch "${branch}" — using existing worktree`)
99
+ return { worktreeName: branch, targetCwd: existingDir, created: false }
100
+ }
101
+
102
+ // No worktree exists — prompt the user to convert
103
+ p.log.warning(`Root is on branch "${branch}" instead of main.`)
104
+ const shouldConvert = await p.confirm({
105
+ message: `Convert "${branch}" to a worktree? (moves branch to .worktrees/${branch}/)`,
106
+ initialValue: true,
107
+ })
108
+
109
+ if (p.isCancel(shouldConvert) || !shouldConvert) {
110
+ // User declined — proceed with root as-is (legacy behavior)
111
+ return { worktreeName: detectedName, targetCwd: process.cwd(), created: false }
112
+ }
113
+
114
+ // User accepted — validate and convert
115
+ if (!allowCreate) {
116
+ p.log.error('Cannot convert — --no-create flag is set.')
117
+ process.exit(1)
118
+ }
119
+
120
+ if (hasUncommittedChanges(root)) {
121
+ p.log.error('Cannot convert — uncommitted changes in working tree.')
122
+ p.log.info('Commit or stash your changes first, then run `sb dev` again.')
123
+ process.exit(1)
124
+ }
125
+
126
+ const defaultBranch = resolveDefaultBranch(root)
127
+ if (!defaultBranch) {
128
+ p.log.error('Cannot determine default branch (main/master). Switch root manually.')
129
+ process.exit(1)
130
+ }
131
+
132
+ p.log.step(`Switching root to "${defaultBranch}"`)
133
+ execFileSync('git', ['checkout', defaultBranch], { cwd: root, stdio: 'inherit' })
134
+
135
+ const targetDir = createWorktree(branch, root, { newBranch: false })
136
+
137
+ // Offer to open the new worktree in VS Code
138
+ const shouldOpen = await p.confirm({
139
+ message: 'Open this worktree in VS Code?',
140
+ initialValue: true,
141
+ })
142
+ if (shouldOpen && !p.isCancel(shouldOpen)) {
143
+ try {
144
+ execFileSync('code', [targetDir], { stdio: 'inherit' })
145
+ } catch {
146
+ p.log.warning(`Could not open VS Code. Run: code ${targetDir}`)
147
+ }
148
+ }
149
+
150
+ return { worktreeName: branch, targetCwd: targetDir, created: true }
151
+ }
152
+
153
+ const root = repoRoot()
154
+
155
+ // "main" → repo root
156
+ if (branchArg === 'main') {
157
+ return { worktreeName: 'main', targetCwd: root, created: false }
158
+ }
159
+
160
+ // Existing worktree directory
161
+ const existingDir = worktreeDir(branchArg)
162
+ if (existsSync(resolve(existingDir, '.git'))) {
163
+ return { worktreeName: branchArg, targetCwd: existingDir, created: false }
164
+ }
165
+
166
+ // From here on we need to create — check if allowed
167
+ if (!allowCreate) {
168
+ p.log.error(`Worktree "${branchArg}" does not exist. Use without --no-create to auto-create.`)
169
+ process.exit(1)
170
+ }
171
+
172
+ // Branch exists (local or remote) — create worktree from it
173
+ const hasLocal = localBranchExists(branchArg, root)
174
+ const hasRemote = !hasLocal && remoteBranchExists(branchArg, root)
175
+
176
+ if (hasLocal || hasRemote) {
177
+ const targetDir = createWorktree(branchArg, root, { newBranch: false })
178
+ return { worktreeName: branchArg, targetCwd: targetDir, created: true }
179
+ }
180
+
181
+ // Branch doesn't exist — interactive TTY gets a prompt, non-interactive auto-creates
182
+ const isTTY = process.stdin.isTTY
183
+
184
+ if (isTTY) {
185
+ const confirmed = await p.confirm({
186
+ message: `Branch "${branchArg}" doesn't exist. Create it from HEAD?`,
187
+ })
188
+ if (p.isCancel(confirmed) || !confirmed) {
189
+ p.cancel('Cancelled.')
190
+ process.exit(0)
191
+ }
192
+ } else {
193
+ p.log.step(`Branch "${branchArg}" not found — creating from HEAD`)
194
+ }
195
+
196
+ const targetDir = createWorktree(branchArg, root, { newBranch: true })
197
+ return { worktreeName: branchArg, targetCwd: targetDir, created: true }
198
+ }
15
199
 
16
200
  async function main() {
17
- const worktreeName = detectWorktreeName()
201
+ const { flags, positional } = parseFlags(process.argv.slice(3), flagSchema)
202
+
203
+ const branchArg = positional[0] || undefined
204
+ const overridePort = flags.port || null
205
+ const allowCreate = flags.create
206
+
207
+ p.intro('storyboard dev')
208
+
209
+ const { worktreeName, targetCwd, created } = await resolveDevTarget(branchArg, { allowCreate })
210
+
211
+ if (created) {
212
+ p.log.success(`Worktree ready: .worktrees/${worktreeName}`)
213
+ } else if (branchArg) {
214
+ p.log.info(`Using ${worktreeName === 'main' ? 'main repo' : `.worktrees/${worktreeName}`}`)
215
+ }
216
+
18
217
  const port = getPort(worktreeName)
19
218
  const isMain = worktreeName === 'main'
20
219
 
@@ -22,42 +221,31 @@ async function main() {
22
221
  ? '/'
23
222
  : `/branch--${worktreeName}/`
24
223
 
25
- const domain = readDevDomain()
224
+ const domain = readDevDomain(targetCwd)
26
225
  const proxyUrl = `http://${domain}${basePath}`
27
226
  const directUrl = `http://localhost:${port}${basePath}`
28
227
 
29
- p.intro('storyboard dev')
30
-
31
- // Parse --port override from argv (skip 'dev' subcommand)
32
- const args = process.argv.slice(3)
33
- const portFlagIdx = args.indexOf('--port')
34
- const overridePort = portFlagIdx >= 0 ? Number(args[portFlagIdx + 1]) : null
35
-
36
- const extraArgs = args.filter((arg, i, arr) => {
37
- if (arg === '--port') return false
38
- if (i > 0 && arr[i - 1] === '--port') return false
39
- return true
40
- })
41
-
42
- // Resolve Vite binary directly to skip npx overhead (~5s)
43
- const localVite = resolve(process.cwd(), 'node_modules', '.bin', 'vite')
228
+ // Resolve Vite binary relative to target worktree
229
+ const localVite = resolve(targetCwd, 'node_modules', '.bin', 'vite')
44
230
  const useLocalVite = existsSync(localVite)
45
231
 
46
232
  // Start Vite — let it find a free port if assigned one is busy.
47
233
  // Capture stdout to detect actual port and update Caddy.
48
- const viteArgs = ['--port', String(overridePort || port), ...extraArgs]
234
+ const viteArgs = ['--port', String(overridePort || port)]
49
235
  const child = useLocalVite
50
236
  ? spawn(localVite, viteArgs, {
237
+ cwd: targetCwd,
51
238
  env: { ...process.env, VITE_BASE_PATH: basePath },
52
239
  stdio: ['inherit', 'pipe', 'pipe'],
53
240
  })
54
241
  : spawn('npx', ['vite', ...viteArgs], {
242
+ cwd: targetCwd,
55
243
  env: { ...process.env, VITE_BASE_PATH: basePath },
56
244
  stdio: ['inherit', 'pipe', 'pipe'],
57
245
  })
58
246
 
59
- // Start rename watcher after Vite spawn (no need to block startup)
60
- const renameWatcher = startRenameWatcher(process.cwd())
247
+ // Start rename watcher in target directory
248
+ const renameWatcher = startRenameWatcher(targetCwd)
61
249
 
62
250
  let caddyUpdated = false
63
251
  let ready = false
package/src/cli/index.js CHANGED
@@ -70,6 +70,8 @@ function helpScreen(version) {
70
70
  '',
71
71
  ` ${bold(cyan('Development'))}`,
72
72
  cmd('dev', 'Start Vite dev server + update proxy'),
73
+ cmd('dev [branch]', 'Start dev for a specific worktree/branch'),
74
+ cmd('code [branch]', 'Open a worktree in VS Code'),
73
75
  cmd('exit', 'Stop all dev servers and proxy'),
74
76
  '',
75
77
  ` ${bold(cyan('Create'))}`,
@@ -82,6 +84,7 @@ function helpScreen(version) {
82
84
  ` ${bold(cyan('Canvas'))}`,
83
85
  cmd('canvas add <type>', 'Add widget to a canvas'),
84
86
  ` ${dim('types: sticky-note, markdown, prototype')}`,
87
+ cmd('canvas read [name]', 'Read canvas state and list widgets'),
85
88
  '',
86
89
  ` ${bold(cyan('Setup'))}`,
87
90
  cmd('setup', 'Install deps, Caddy proxy, start proxy'),
@@ -119,6 +122,8 @@ switch (command) {
119
122
  case 'canvas':
120
123
  if (process.argv[3] === 'add') {
121
124
  import('./canvasAdd.js')
125
+ } else if (process.argv[3] === 'read' || !process.argv[3]) {
126
+ import('./canvasRead.js')
122
127
  } else {
123
128
  const version = getVersion()
124
129
  console.log(helpScreen(version))
@@ -129,6 +134,9 @@ switch (command) {
129
134
  case 'exit':
130
135
  import('./exit.js')
131
136
  break
137
+ case 'code':
138
+ import('./code.js')
139
+ break
132
140
  default: {
133
141
  if (command === 'update' || (command && command.startsWith('update:'))) {
134
142
  import('./updateVersion.js')
package/src/cli/proxy.js CHANGED
@@ -14,9 +14,9 @@ import { execSync } from 'child_process'
14
14
  import { dirname, resolve } from 'path'
15
15
  import { portsFilePath } from '../worktree/port.js'
16
16
 
17
- export function readDevDomain() {
17
+ export function readDevDomain(cwd) {
18
18
  try {
19
- const configPath = resolve(process.cwd(), 'storyboard.config.json')
19
+ const configPath = resolve(cwd || process.cwd(), 'storyboard.config.json')
20
20
  const config = JSON.parse(readFileSync(configPath, 'utf8'))
21
21
  return `${config.devDomain || 'storyboard'}.localhost`
22
22
  } catch {
@@ -130,21 +130,73 @@ export function generateRouteConfig(portOverrides = {}) {
130
130
  }
131
131
  }
132
132
 
133
+ /**
134
+ * Find indices of stale routes that match the same host but lack an @id.
135
+ * Returns indices sorted descending (highest first) for safe deletion.
136
+ * Exported for testing.
137
+ */
138
+ export function findStaleRouteIndices(routes, keepId, host) {
139
+ const indices = []
140
+ for (let i = 0; i < routes.length; i++) {
141
+ const route = routes[i]
142
+ if (route['@id'] === keepId) continue
143
+ if (route['@id']) continue // different intentional route — leave it
144
+ const routeHosts = route.match?.[0]?.host || []
145
+ if (routeHosts.includes(host)) {
146
+ indices.push(i)
147
+ }
148
+ }
149
+ return indices.reverse()
150
+ }
151
+
152
+ /**
153
+ * Remove stale routes that match the same host but lack an @id.
154
+ * These are leftovers from Caddyfile reloads that shadow admin-API routes.
155
+ * Deletes from highest index to lowest to preserve indices during removal.
156
+ * Best-effort — warns on failure but does not throw.
157
+ */
158
+ function cleanupDuplicateRoutes(keepId, host) {
159
+ try {
160
+ const config = execSync(
161
+ `curl -sf '${CADDY_ADMIN}/config/apps/http/servers/srv0/routes'`,
162
+ { encoding: 'utf-8', timeout: 5000 },
163
+ )
164
+ const routes = JSON.parse(config)
165
+ const staleIndices = findStaleRouteIndices(routes, keepId, host)
166
+ for (const idx of staleIndices) {
167
+ try {
168
+ execSync(
169
+ `curl -sf -X DELETE '${CADDY_ADMIN}/config/apps/http/servers/srv0/routes/${idx}'`,
170
+ { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] },
171
+ )
172
+ } catch {
173
+ console.warn(`[storyboard] failed to remove stale proxy route at index ${idx}`)
174
+ }
175
+ }
176
+ } catch {
177
+ console.warn('[storyboard] failed to clean up stale proxy routes — branch URLs may not work via proxy')
178
+ }
179
+ }
180
+
133
181
  /**
134
182
  * Upsert this repo's route in the running Caddy instance via admin API.
135
183
  * Uses PATCH if the @id exists, POST if it doesn't.
184
+ * After a successful upsert, removes any stale non-@id routes for the
185
+ * same host (leftovers from prior Caddyfile reloads).
136
186
  * Returns true on success, false on failure.
137
187
  */
138
188
  export function upsertCaddyRoute(routeConfig) {
139
189
  const id = routeConfig['@id']
190
+ const host = routeConfig.match?.[0]?.host?.[0]
140
191
  const payload = JSON.stringify(routeConfig)
141
192
 
142
193
  // Try PATCH first (update existing route by @id)
143
194
  try {
144
- const patchResult = execSync(
195
+ execSync(
145
196
  `curl -sf -X PATCH '${CADDY_ADMIN}/id/${id}' -H 'Content-Type: application/json' -d '${payload.replace(/'/g, "'\\''")}'`,
146
197
  { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] },
147
198
  )
199
+ if (host) cleanupDuplicateRoutes(id, host)
148
200
  return true
149
201
  } catch {
150
202
  // @id doesn't exist yet — POST a new route
@@ -153,6 +205,7 @@ export function upsertCaddyRoute(routeConfig) {
153
205
  `curl -sf -X POST '${CADDY_ADMIN}/config/apps/http/servers/srv0/routes' -H 'Content-Type: application/json' -d '${payload.replace(/'/g, "'\\''")}'`,
154
206
  { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] },
155
207
  )
208
+ if (host) cleanupDuplicateRoutes(id, host)
156
209
  return true
157
210
  } catch {
158
211
  return false
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { findStaleRouteIndices } from './proxy.js'
3
+
4
+ describe('findStaleRouteIndices', () => {
5
+ it('identifies stale non-@id routes matching the same host', () => {
6
+ const routes = [
7
+ { match: [{ host: ['storyboard.localhost'] }], terminal: true }, // index 0: stale
8
+ { '@id': 'storyboard', match: [{ host: ['storyboard.localhost'] }] }, // index 1: ours
9
+ ]
10
+ const indices = findStaleRouteIndices(routes, 'storyboard', 'storyboard.localhost')
11
+ expect(indices).toEqual([0])
12
+ })
13
+
14
+ it('preserves routes with a different @id for the same host', () => {
15
+ const routes = [
16
+ { '@id': 'other-app', match: [{ host: ['storyboard.localhost'] }] },
17
+ { '@id': 'storyboard', match: [{ host: ['storyboard.localhost'] }] },
18
+ ]
19
+ const indices = findStaleRouteIndices(routes, 'storyboard', 'storyboard.localhost')
20
+ expect(indices).toEqual([])
21
+ })
22
+
23
+ it('returns multiple stale indices in descending order', () => {
24
+ const routes = [
25
+ { match: [{ host: ['storyboard.localhost'] }] }, // index 0: stale
26
+ { '@id': 'storyboard-core', match: [{ host: ['storyboard-core.localhost'] }] }, // index 1: different host
27
+ { '@id': 'storyboard', match: [{ host: ['storyboard.localhost'] }] }, // index 2: ours
28
+ { match: [{ host: ['storyboard.localhost'] }] }, // index 3: stale
29
+ ]
30
+ const indices = findStaleRouteIndices(routes, 'storyboard', 'storyboard.localhost')
31
+ expect(indices).toEqual([3, 0]) // descending for safe deletion
32
+ })
33
+
34
+ it('ignores routes for a different host', () => {
35
+ const routes = [
36
+ { match: [{ host: ['other.localhost'] }] },
37
+ { '@id': 'storyboard', match: [{ host: ['storyboard.localhost'] }] },
38
+ ]
39
+ const indices = findStaleRouteIndices(routes, 'storyboard', 'storyboard.localhost')
40
+ expect(indices).toEqual([])
41
+ })
42
+
43
+ it('handles routes with no match field', () => {
44
+ const routes = [
45
+ { handle: [{ handler: 'reverse_proxy' }] }, // no match
46
+ { '@id': 'storyboard', match: [{ host: ['storyboard.localhost'] }] },
47
+ ]
48
+ const indices = findStaleRouteIndices(routes, 'storyboard', 'storyboard.localhost')
49
+ expect(indices).toEqual([])
50
+ })
51
+
52
+ it('returns empty array when no stale routes exist', () => {
53
+ const routes = [
54
+ { '@id': 'storyboard', match: [{ host: ['storyboard.localhost'] }] },
55
+ ]
56
+ const indices = findStaleRouteIndices(routes, 'storyboard', 'storyboard.localhost')
57
+ expect(indices).toEqual([])
58
+ })
59
+
60
+ it('handles empty routes array', () => {
61
+ expect(findStaleRouteIndices([], 'storyboard', 'storyboard.localhost')).toEqual([])
62
+ })
63
+ })
package/src/cli/setup.js CHANGED
@@ -114,7 +114,47 @@ if (hasBrew) {
114
114
  }
115
115
  }
116
116
 
117
- // 5. Proxy
117
+ // 5. VS Code CLI
118
+ if (isInstalled('code')) {
119
+ p.log.success('VS Code CLI installed')
120
+ } else {
121
+ // Try to install the `code` CLI from VS Code's known locations
122
+ const codePaths = [
123
+ '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
124
+ '/usr/local/bin/code',
125
+ ]
126
+ let installed = false
127
+ for (const codePath of codePaths) {
128
+ if (existsSync(codePath)) {
129
+ p.log.success('VS Code CLI available (symlink exists)')
130
+ installed = true
131
+ break
132
+ }
133
+ }
134
+ if (!installed) {
135
+ // Try the VS Code shell command installer
136
+ const vsCodeApp = '/Applications/Visual Studio Code.app'
137
+ if (existsSync(vsCodeApp)) {
138
+ const shellScript = `${vsCodeApp}/Contents/Resources/app/bin/code`
139
+ if (existsSync(shellScript)) {
140
+ try {
141
+ // Create symlink in /usr/local/bin
142
+ run(`ln -sf "${shellScript}" /usr/local/bin/code`)
143
+ p.log.success('VS Code CLI installed (symlinked to /usr/local/bin/code)')
144
+ installed = true
145
+ } catch {
146
+ // Fall through to manual instructions
147
+ }
148
+ }
149
+ }
150
+ if (!installed) {
151
+ p.log.warning('VS Code CLI not found. Open VS Code and run:')
152
+ p.log.info(' Cmd+Shift+P → "Shell Command: Install \'code\' command in PATH"')
153
+ }
154
+ }
155
+ }
156
+
157
+ // 6. Proxy
118
158
  if (isCaddyInstalled()) {
119
159
  const proxySpin = p.spinner()
120
160
  const caddyfilePath = generateCaddyfile()
@@ -79,18 +79,28 @@ try {
79
79
  p.log.warn('Scaffold sync failed — run `npx storyboard-scaffold` manually')
80
80
  }
81
81
 
82
- // Auto-commit the version update
82
+ // Auto-commit the version update (only if package.json or lock file changed)
83
83
  try {
84
84
  // Read the installed version from the core package
85
85
  const corePkg = JSON.parse(readFileSync(resolve(process.cwd(), 'node_modules', '@dfosco', 'storyboard-core', 'package.json'), 'utf8'))
86
86
  const installedVersion = corePkg.version || suffix.slice(1)
87
87
  const commitMsg = `[storyboard-update] Update storyboard to ${installedVersion}`
88
88
 
89
- execSync('git add -A', { cwd: process.cwd(), stdio: 'pipe' })
89
+ // Only stage update-related files (package.json, lock files, scaffold outputs)
90
+ const filesToStage = [
91
+ 'package.json',
92
+ 'package-lock.json',
93
+ 'yarn.lock',
94
+ 'pnpm-lock.yaml',
95
+ '.github/skills',
96
+ 'scripts',
97
+ ]
98
+ execSync(`git add ${filesToStage.join(' ')} 2>/dev/null || true`, { cwd: process.cwd(), stdio: 'pipe' })
99
+
90
100
  // Only commit if there are staged changes
91
101
  try {
92
102
  execSync('git diff --cached --quiet', { cwd: process.cwd(), stdio: 'pipe' })
93
- p.log.message('No changes to commit')
103
+ p.log.message('No changes to commit — already up to date')
94
104
  } catch {
95
105
  execSync(`git commit -m "${commitMsg}"`, { cwd: process.cwd(), stdio: 'pipe' })
96
106
  p.log.success(`Committed: ${commitMsg}`)
@@ -11,7 +11,7 @@
11
11
  * import { getPort, detectWorktreeName, resolvePort } from '@dfosco/storyboard-core/worktree/port'
12
12
  */
13
13
 
14
- import { readFileSync, writeFileSync, existsSync, mkdirSync, realpathSync } from 'fs'
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, realpathSync } from 'fs'
15
15
  import { join, dirname, basename } from 'path'
16
16
  import { execSync } from 'child_process'
17
17
 
@@ -60,6 +60,10 @@ export function detectWorktreeName() {
60
60
  const worktreeMatch = realCwd.match(/\.worktrees[/\\]([^/\\]+)/)
61
61
  if (worktreeMatch) return worktreeMatch[1]
62
62
 
63
+ // Not a worktree — check the current branch name
64
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim()
65
+ if (branch && branch !== 'main' && branch !== 'master') return branch
66
+
63
67
  return 'main'
64
68
  } catch {
65
69
  return 'main'
@@ -138,3 +142,55 @@ export function slugify(name) {
138
142
  .map((s) => s.replace(/^-+|-+$/g, ''))
139
143
  .join('/')
140
144
  }
145
+
146
+ /**
147
+ * Resolve the repo root — the directory that contains `.worktrees/`.
148
+ *
149
+ * Works whether cwd is the repo root itself or inside `.worktrees/<name>/`.
150
+ *
151
+ * @param {string} [cwd]
152
+ * @returns {string} absolute path to repo root
153
+ */
154
+ export function repoRoot(cwd = process.cwd()) {
155
+ const realCwd = realpathSync(cwd)
156
+
157
+ const worktreeMatch = realCwd.match(/^(.+)[/\\]\.worktrees[/\\][^/\\]+/)
158
+ if (worktreeMatch) return worktreeMatch[1]
159
+
160
+ return realCwd
161
+ }
162
+
163
+ /**
164
+ * Resolve the full path to a worktree directory.
165
+ *
166
+ * Returns repo root for 'main', `.worktrees/<name>` otherwise.
167
+ *
168
+ * @param {string} name — worktree name
169
+ * @param {string} [cwd]
170
+ * @returns {string} absolute path
171
+ */
172
+ export function worktreeDir(name, cwd) {
173
+ const root = repoRoot(cwd)
174
+ if (name === 'main') return root
175
+ return join(root, '.worktrees', name)
176
+ }
177
+
178
+ /**
179
+ * List existing worktree directory names from `.worktrees/`.
180
+ *
181
+ * Only returns directories that look like real worktrees (contain a `.git` file).
182
+ * Does not include 'main'.
183
+ *
184
+ * @param {string} [cwd]
185
+ * @returns {string[]}
186
+ */
187
+ export function listWorktrees(cwd) {
188
+ const root = repoRoot(cwd)
189
+ const worktreesDir = join(root, '.worktrees')
190
+
191
+ if (!existsSync(worktreesDir)) return []
192
+
193
+ return readdirSync(worktreesDir, { withFileTypes: true })
194
+ .filter((d) => d.isDirectory() && existsSync(join(worktreesDir, d.name, '.git')))
195
+ .map((d) => d.name)
196
+ }
@@ -4,7 +4,7 @@ import { join } from 'path'
4
4
  import { tmpdir } from 'os'
5
5
 
6
6
  // We test the pure functions by importing and overriding cwd
7
- import { portsFilePath, getPort, resolvePort, slugify } from './port.js'
7
+ import { portsFilePath, getPort, resolvePort, slugify, repoRoot, worktreeDir, listWorktrees } from './port.js'
8
8
 
9
9
  describe('slugify', () => {
10
10
  it('lowercases and replaces dots with hyphens', () => {
@@ -130,3 +130,93 @@ describe('getPort / resolvePort', () => {
130
130
  expect(port).toBe(1235)
131
131
  })
132
132
  })
133
+
134
+ describe('repoRoot', () => {
135
+ let tempRoot
136
+
137
+ beforeEach(() => {
138
+ tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-root-test-')))
139
+ mkdirSync(join(tempRoot, '.worktrees', 'my-branch'), { recursive: true })
140
+ })
141
+
142
+ afterEach(() => {
143
+ rmSync(tempRoot, { recursive: true, force: true })
144
+ })
145
+
146
+ it('returns cwd when at repo root', () => {
147
+ expect(repoRoot(tempRoot)).toBe(tempRoot)
148
+ })
149
+
150
+ it('returns parent of .worktrees when inside a worktree', () => {
151
+ const wt = join(tempRoot, '.worktrees', 'my-branch')
152
+ expect(repoRoot(wt)).toBe(tempRoot)
153
+ })
154
+ })
155
+
156
+ describe('worktreeDir', () => {
157
+ let tempRoot
158
+
159
+ beforeEach(() => {
160
+ tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-wtdir-test-')))
161
+ mkdirSync(join(tempRoot, '.worktrees'), { recursive: true })
162
+ })
163
+
164
+ afterEach(() => {
165
+ rmSync(tempRoot, { recursive: true, force: true })
166
+ })
167
+
168
+ it('returns repo root for main', () => {
169
+ expect(worktreeDir('main', tempRoot)).toBe(tempRoot)
170
+ })
171
+
172
+ it('returns .worktrees/<name> for branches', () => {
173
+ expect(worktreeDir('my-feature', tempRoot)).toBe(join(tempRoot, '.worktrees', 'my-feature'))
174
+ })
175
+ })
176
+
177
+ describe('listWorktrees', () => {
178
+ let tempRoot
179
+
180
+ beforeEach(() => {
181
+ tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-list-test-')))
182
+ })
183
+
184
+ afterEach(() => {
185
+ rmSync(tempRoot, { recursive: true, force: true })
186
+ })
187
+
188
+ it('returns empty array when .worktrees does not exist', () => {
189
+ expect(listWorktrees(tempRoot)).toEqual([])
190
+ })
191
+
192
+ it('returns only directories with a .git file', () => {
193
+ const wtDir = join(tempRoot, '.worktrees')
194
+ mkdirSync(wtDir)
195
+
196
+ // Valid worktree — has .git file
197
+ mkdirSync(join(wtDir, 'valid'))
198
+ writeFileSync(join(wtDir, 'valid', '.git'), 'gitdir: /some/path')
199
+
200
+ // Not a worktree — no .git file
201
+ mkdirSync(join(wtDir, 'no-git'))
202
+
203
+ // Not a directory — file
204
+ writeFileSync(join(wtDir, 'ports.json'), '{}')
205
+
206
+ const result = listWorktrees(tempRoot)
207
+ expect(result).toEqual(['valid'])
208
+ })
209
+
210
+ it('returns multiple worktrees', () => {
211
+ const wtDir = join(tempRoot, '.worktrees')
212
+ mkdirSync(wtDir)
213
+
214
+ for (const name of ['alpha', 'beta', 'gamma']) {
215
+ mkdirSync(join(wtDir, name))
216
+ writeFileSync(join(wtDir, name, '.git'), 'gitdir: /some/path')
217
+ }
218
+
219
+ const result = listWorktrees(tempRoot)
220
+ expect(result.sort()).toEqual(['alpha', 'beta', 'gamma'])
221
+ })
222
+ })
@@ -495,12 +495,11 @@
495
495
  {
496
496
  "action": "copy-as-png",
497
497
  "label": "$label:copy-png",
498
- "icon": "copy"
499
- },
500
- {
501
- "action": "copy-file-path",
502
- "label": "$label:copy-path",
503
- "icon": "link"
498
+ "icon": "copy",
499
+ "alt": {
500
+ "label": "$label:copy-path",
501
+ "action": "copy-file-path"
502
+ }
504
503
  }
505
504
  ]
506
505
  },