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

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.3",
3
+ "version": "4.0.0-beta.5",
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)
package/src/cli/dev.js CHANGED
@@ -1,20 +1,164 @@
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
+
23
+ const flagSchema = {
24
+ port: { type: 'number', description: 'Override dev server port' },
25
+ create: { type: 'boolean', default: true, description: 'Allow creating worktrees/branches (disable with --no-create)' },
26
+ }
27
+
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
+ /**
44
+ * Check if a remote branch exists on origin.
45
+ * @param {string} name
46
+ * @param {string} cwd
47
+ * @returns {boolean}
48
+ */
49
+ function remoteBranchExists(name, cwd) {
50
+ try {
51
+ const result = execFileSync('git', ['ls-remote', '--exit-code', '--heads', 'origin', name], { cwd, encoding: 'utf8' })
52
+ return result.trim().length > 0
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Create a git worktree and install dependencies.
60
+ * @param {string} name — worktree/branch name
61
+ * @param {string} root — repo root path
62
+ * @param {object} opts
63
+ * @param {boolean} opts.newBranch — create a new branch from HEAD
64
+ * @returns {string} path to the new worktree directory
65
+ */
66
+ function createWorktree(name, root, { newBranch = false } = {}) {
67
+ const targetDir = resolve(root, '.worktrees', name)
68
+
69
+ const gitArgs = newBranch
70
+ ? ['worktree', 'add', targetDir, '-b', name]
71
+ : ['worktree', 'add', targetDir, name]
72
+
73
+ p.log.step(`Creating worktree: .worktrees/${name}`)
74
+ execFileSync('git', gitArgs, { cwd: root, stdio: 'inherit' })
75
+
76
+ p.log.step('Installing dependencies…')
77
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'
78
+ execFileSync(npmBin, ['install'], { cwd: targetDir, stdio: 'inherit' })
79
+
80
+ getPort(name)
81
+ return targetDir
82
+ }
83
+
84
+ /**
85
+ * Resolve the target worktree for `storyboard dev [branch]`.
86
+ *
87
+ * @param {string|undefined} branchArg — positional branch argument
88
+ * @param {object} opts
89
+ * @param {boolean} opts.allowCreate — whether creation is allowed
90
+ * @returns {Promise<{ worktreeName: string, targetCwd: string, created: boolean }>}
91
+ */
92
+ async function resolveDevTarget(branchArg, { allowCreate = true } = {}) {
93
+ // No argument — detect from cwd (current behavior)
94
+ if (!branchArg) {
95
+ return { worktreeName: detectWorktreeName(), targetCwd: process.cwd(), created: false }
96
+ }
97
+
98
+ const root = repoRoot()
99
+
100
+ // "main" → repo root
101
+ if (branchArg === 'main') {
102
+ return { worktreeName: 'main', targetCwd: root, created: false }
103
+ }
104
+
105
+ // Existing worktree directory
106
+ const existingDir = worktreeDir(branchArg)
107
+ if (existsSync(resolve(existingDir, '.git'))) {
108
+ return { worktreeName: branchArg, targetCwd: existingDir, created: false }
109
+ }
110
+
111
+ // From here on we need to create — check if allowed
112
+ if (!allowCreate) {
113
+ p.log.error(`Worktree "${branchArg}" does not exist. Use without --no-create to auto-create.`)
114
+ process.exit(1)
115
+ }
116
+
117
+ // Branch exists (local or remote) — create worktree from it
118
+ const hasLocal = localBranchExists(branchArg, root)
119
+ const hasRemote = !hasLocal && remoteBranchExists(branchArg, root)
120
+
121
+ if (hasLocal || hasRemote) {
122
+ const targetDir = createWorktree(branchArg, root, { newBranch: false })
123
+ return { worktreeName: branchArg, targetCwd: targetDir, created: true }
124
+ }
125
+
126
+ // Branch doesn't exist — interactive TTY gets a prompt, non-interactive auto-creates
127
+ const isTTY = process.stdin.isTTY
128
+
129
+ if (isTTY) {
130
+ const confirmed = await p.confirm({
131
+ message: `Branch "${branchArg}" doesn't exist. Create it from HEAD?`,
132
+ })
133
+ if (p.isCancel(confirmed) || !confirmed) {
134
+ p.cancel('Cancelled.')
135
+ process.exit(0)
136
+ }
137
+ } else {
138
+ p.log.step(`Branch "${branchArg}" not found — creating from HEAD`)
139
+ }
140
+
141
+ const targetDir = createWorktree(branchArg, root, { newBranch: true })
142
+ return { worktreeName: branchArg, targetCwd: targetDir, created: true }
143
+ }
15
144
 
16
145
  async function main() {
17
- const worktreeName = detectWorktreeName()
146
+ const { flags, positional } = parseFlags(process.argv.slice(3), flagSchema)
147
+
148
+ const branchArg = positional[0] || undefined
149
+ const overridePort = flags.port || null
150
+ const allowCreate = flags.create
151
+
152
+ p.intro('storyboard dev')
153
+
154
+ const { worktreeName, targetCwd, created } = await resolveDevTarget(branchArg, { allowCreate })
155
+
156
+ if (created) {
157
+ p.log.success(`Worktree ready: .worktrees/${worktreeName}`)
158
+ } else if (branchArg) {
159
+ p.log.info(`Using ${worktreeName === 'main' ? 'main repo' : `.worktrees/${worktreeName}`}`)
160
+ }
161
+
18
162
  const port = getPort(worktreeName)
19
163
  const isMain = worktreeName === 'main'
20
164
 
@@ -22,42 +166,31 @@ async function main() {
22
166
  ? '/'
23
167
  : `/branch--${worktreeName}/`
24
168
 
25
- const domain = readDevDomain()
169
+ const domain = readDevDomain(targetCwd)
26
170
  const proxyUrl = `http://${domain}${basePath}`
27
171
  const directUrl = `http://localhost:${port}${basePath}`
28
172
 
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')
173
+ // Resolve Vite binary relative to target worktree
174
+ const localVite = resolve(targetCwd, 'node_modules', '.bin', 'vite')
44
175
  const useLocalVite = existsSync(localVite)
45
176
 
46
177
  // Start Vite — let it find a free port if assigned one is busy.
47
178
  // Capture stdout to detect actual port and update Caddy.
48
- const viteArgs = ['--port', String(overridePort || port), ...extraArgs]
179
+ const viteArgs = ['--port', String(overridePort || port)]
49
180
  const child = useLocalVite
50
181
  ? spawn(localVite, viteArgs, {
182
+ cwd: targetCwd,
51
183
  env: { ...process.env, VITE_BASE_PATH: basePath },
52
184
  stdio: ['inherit', 'pipe', 'pipe'],
53
185
  })
54
186
  : spawn('npx', ['vite', ...viteArgs], {
187
+ cwd: targetCwd,
55
188
  env: { ...process.env, VITE_BASE_PATH: basePath },
56
189
  stdio: ['inherit', 'pipe', 'pipe'],
57
190
  })
58
191
 
59
- // Start rename watcher after Vite spawn (no need to block startup)
60
- const renameWatcher = startRenameWatcher(process.cwd())
192
+ // Start rename watcher in target directory
193
+ const renameWatcher = startRenameWatcher(targetCwd)
61
194
 
62
195
  let caddyUpdated = false
63
196
  let ready = false
package/src/cli/index.js CHANGED
@@ -70,6 +70,7 @@ 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'),
73
74
  cmd('exit', 'Stop all dev servers and proxy'),
74
75
  '',
75
76
  ` ${bold(cyan('Create'))}`,
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 {
@@ -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
  },