@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 +1 -1
- package/src/autosync/server.js +1 -1
- package/src/cli/canvasRead.js +212 -0
- package/src/cli/code.js +67 -0
- package/src/cli/dev-helpers.js +47 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +211 -23
- package/src/cli/index.js +8 -0
- package/src/cli/proxy.js +56 -3
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/setup.js +41 -1
- package/src/cli/updateVersion.js +13 -3
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/widgets.config.json +5 -6
package/package.json
CHANGED
package/src/autosync/server.js
CHANGED
|
@@ -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 [
|
|
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()
|
package/src/cli/code.js
ADDED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
60
|
-
const renameWatcher = startRenameWatcher(
|
|
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
|
-
|
|
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.
|
|
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()
|
package/src/cli/updateVersion.js
CHANGED
|
@@ -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
|
-
|
|
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}`)
|
package/src/worktree/port.js
CHANGED
|
@@ -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
|
+
})
|
package/widgets.config.json
CHANGED
|
@@ -495,12 +495,11 @@
|
|
|
495
495
|
{
|
|
496
496
|
"action": "copy-as-png",
|
|
497
497
|
"label": "$label:copy-png",
|
|
498
|
-
"icon": "copy"
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
},
|