@dfosco/storyboard-core 4.0.0-beta.5 → 4.0.0-beta.7

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.5",
3
+ "version": "4.0.0-beta.7",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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
@@ -19,27 +19,13 @@ import { detectWorktreeName, getPort, repoRoot, worktreeDir, listWorktrees } fro
19
19
  import { generateCaddyfile, generateRouteConfig, upsertCaddyRoute, isCaddyRunning, reloadCaddy, readDevDomain } from './proxy.js'
20
20
  import { startRenameWatcher } from '../rename-watcher/watcher.js'
21
21
  import { parseFlags } from './flags.js'
22
+ import { hasUncommittedChanges, localBranchExists, resolveDefaultBranch } from './dev-helpers.js'
22
23
 
23
24
  const flagSchema = {
24
25
  port: { type: 'number', description: 'Override dev server port' },
25
26
  create: { type: 'boolean', default: true, description: 'Allow creating worktrees/branches (disable with --no-create)' },
26
27
  }
27
28
 
28
- /**
29
- * Check if a local branch exists.
30
- * @param {string} name
31
- * @param {string} cwd
32
- * @returns {boolean}
33
- */
34
- function localBranchExists(name, cwd) {
35
- try {
36
- execFileSync('git', ['show-ref', '--verify', `refs/heads/${name}`], { cwd, stdio: 'ignore' })
37
- return true
38
- } catch {
39
- return false
40
- }
41
- }
42
-
43
29
  /**
44
30
  * Check if a remote branch exists on origin.
45
31
  * @param {string} name
@@ -84,15 +70,84 @@ function createWorktree(name, root, { newBranch = false } = {}) {
84
70
  /**
85
71
  * Resolve the target worktree for `storyboard dev [branch]`.
86
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
+ *
87
76
  * @param {string|undefined} branchArg — positional branch argument
88
77
  * @param {object} opts
89
78
  * @param {boolean} opts.allowCreate — whether creation is allowed
90
79
  * @returns {Promise<{ worktreeName: string, targetCwd: string, created: boolean }>}
91
80
  */
92
81
  async function resolveDevTarget(branchArg, { allowCreate = true } = {}) {
93
- // No argument — detect from cwd (current behavior)
82
+ // No argument — detect from cwd
94
83
  if (!branchArg) {
95
- return { worktreeName: detectWorktreeName(), targetCwd: process.cwd(), created: false }
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 }
96
151
  }
97
152
 
98
153
  const root = repoRoot()
package/src/cli/index.js CHANGED
@@ -71,6 +71,7 @@ function helpScreen(version) {
71
71
  ` ${bold(cyan('Development'))}`,
72
72
  cmd('dev', 'Start Vite dev server + update proxy'),
73
73
  cmd('dev [branch]', 'Start dev for a specific worktree/branch'),
74
+ cmd('code [branch]', 'Open a worktree in VS Code'),
74
75
  cmd('exit', 'Stop all dev servers and proxy'),
75
76
  '',
76
77
  ` ${bold(cyan('Create'))}`,
@@ -83,6 +84,7 @@ function helpScreen(version) {
83
84
  ` ${bold(cyan('Canvas'))}`,
84
85
  cmd('canvas add <type>', 'Add widget to a canvas'),
85
86
  ` ${dim('types: sticky-note, markdown, prototype')}`,
87
+ cmd('canvas read [name]', 'Read canvas state and list widgets'),
86
88
  '',
87
89
  ` ${bold(cyan('Setup'))}`,
88
90
  cmd('setup', 'Install deps, Caddy proxy, start proxy'),
@@ -120,6 +122,8 @@ switch (command) {
120
122
  case 'canvas':
121
123
  if (process.argv[3] === 'add') {
122
124
  import('./canvasAdd.js')
125
+ } else if (process.argv[3] === 'read' || !process.argv[3]) {
126
+ import('./canvasRead.js')
123
127
  } else {
124
128
  const version = getVersion()
125
129
  console.log(helpScreen(version))
@@ -130,6 +134,9 @@ switch (command) {
130
134
  case 'exit':
131
135
  import('./exit.js')
132
136
  break
137
+ case 'code':
138
+ import('./code.js')
139
+ break
133
140
  default: {
134
141
  if (command === 'update' || (command && command.startsWith('update:'))) {
135
142
  import('./updateVersion.js')
package/src/cli/proxy.js CHANGED
@@ -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()