@dfosco/storyboard-core 4.2.7 → 4.2.8
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/scaffold/skills/worktree/SKILL.md +5 -2
- package/src/cli/branch.js +251 -97
- package/src/cli/index.js +3 -0
- package/src/cli/setup.js +53 -23
package/package.json
CHANGED
|
@@ -20,12 +20,15 @@ If the branch already exists locally or on the remote:
|
|
|
20
20
|
git worktree add worktrees/<branch-name> <branch-name>
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
If the branch does NOT exist yet, create it from the current
|
|
23
|
+
If the branch does NOT exist yet, create it **from the current branch** (NOT from main):
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
git
|
|
26
|
+
CURRENT_BRANCH=$(git branch --show-current)
|
|
27
|
+
git worktree add worktrees/<branch-name> -b <branch-name> "$CURRENT_BRANCH"
|
|
27
28
|
```
|
|
28
29
|
|
|
30
|
+
> **⚠️ CRITICAL:** Always pass the current branch as the start-point. Without it, git defaults to HEAD of the main worktree, which may be a completely different branch.
|
|
31
|
+
|
|
29
32
|
### Step 2: Register a dev-server port
|
|
30
33
|
|
|
31
34
|
Assign a unique port for this worktree so multiple dev servers can run simultaneously.
|
package/src/cli/branch.js
CHANGED
|
@@ -2,18 +2,24 @@
|
|
|
2
2
|
* storyboard branch — Interactive guide to switch to a branch worktree.
|
|
3
3
|
*
|
|
4
4
|
* Deterministic flow (no AI):
|
|
5
|
-
* 1. Ask which branch to work on
|
|
6
|
-
* 2.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* 1. Ask which branch to work on (or accept --worktree flag)
|
|
6
|
+
* 2. If existing worktree:
|
|
7
|
+
* a. Stash uncommitted work in source (named stash)
|
|
8
|
+
* b. Ensure target is on the correct branch
|
|
9
|
+
* c. Apply source stash in target
|
|
10
|
+
* 3. If new worktree:
|
|
11
|
+
* a. Stash uncommitted work in source
|
|
12
|
+
* b. Create worktree (git worktree add + npm install)
|
|
13
|
+
* c. Pull --rebase from origin
|
|
14
|
+
* d. Apply source stash in target
|
|
15
|
+
* 4. Confirm to user
|
|
11
16
|
*
|
|
12
17
|
* Also available as post-setup prompt in setup.js.
|
|
13
18
|
*
|
|
14
19
|
* Usage:
|
|
15
|
-
* npx storyboard branch
|
|
16
|
-
* npx storyboard branch <name>
|
|
20
|
+
* npx storyboard branch # interactive
|
|
21
|
+
* npx storyboard branch <name> # positional, skip prompt
|
|
22
|
+
* npx storyboard branch --worktree=<name> # non-interactive flag
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
import * as p from '@clack/prompts'
|
|
@@ -22,8 +28,14 @@ import { existsSync } from 'fs'
|
|
|
22
28
|
import { resolve } from 'path'
|
|
23
29
|
import { repoRoot, worktreeDir, listWorktrees, getPort, detectWorktreeName } from '../worktree/port.js'
|
|
24
30
|
import { hasUncommittedChanges, localBranchExists } from './dev-helpers.js'
|
|
31
|
+
import { parseFlags } from './flags.js'
|
|
25
32
|
import { dim, green, bold, cyan } from './intro.js'
|
|
26
33
|
|
|
34
|
+
const flagSchema = {
|
|
35
|
+
worktree: { type: 'string', description: 'Target worktree/branch name (non-interactive)' },
|
|
36
|
+
cd: { type: 'boolean', default: false, description: 'Output shell-evaluable cd command (for eval)' },
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
/** Check if a remote branch exists on origin. */
|
|
28
40
|
function remoteBranchExists(name, cwd) {
|
|
29
41
|
try {
|
|
@@ -34,7 +46,7 @@ function remoteBranchExists(name, cwd) {
|
|
|
34
46
|
}
|
|
35
47
|
}
|
|
36
48
|
|
|
37
|
-
/** Get the current branch name. */
|
|
49
|
+
/** Get the current branch name in a given directory. */
|
|
38
50
|
function currentBranch(cwd) {
|
|
39
51
|
try {
|
|
40
52
|
return execFileSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf8' }).trim()
|
|
@@ -55,96 +67,151 @@ function isValidBranchName(name) {
|
|
|
55
67
|
return undefined
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
/** Build a timestamped stash message. */
|
|
71
|
+
function stashMessage(from, to) {
|
|
72
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
|
73
|
+
return `from-${from}-to-${to}-${ts}`
|
|
74
|
+
}
|
|
63
75
|
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Stash uncommitted changes (including untracked files) and return the stash ref SHA.
|
|
78
|
+
* Returns null if nothing was stashed.
|
|
79
|
+
*/
|
|
80
|
+
function stashChanges(cwd, message) {
|
|
81
|
+
if (!hasUncommittedChanges(cwd)) return null
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
p.log.step('Stashing uncommitted work…')
|
|
84
|
+
try {
|
|
85
|
+
execFileSync('git', ['stash', 'push', '-u', '-m', message], { cwd, stdio: 'pipe' })
|
|
86
|
+
// Capture the exact stash SHA so we can apply it by ref later
|
|
87
|
+
const sha = execFileSync('git', ['stash', 'list', '--format=%H', '-1'], { cwd, encoding: 'utf8' }).trim()
|
|
88
|
+
p.log.success(`Work stashed: ${dim(message)}`)
|
|
89
|
+
return sha
|
|
90
|
+
} catch {
|
|
91
|
+
p.log.warning('Could not stash changes — proceeding anyway')
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Apply a specific stash by SHA in the given directory.
|
|
98
|
+
* Uses apply (not pop) so the backup remains in the stash list.
|
|
99
|
+
*/
|
|
100
|
+
function applyStash(cwd, stashSha) {
|
|
101
|
+
try {
|
|
102
|
+
execFileSync('git', ['stash', 'apply', stashSha], { cwd, stdio: 'pipe' })
|
|
103
|
+
p.log.success('Previous work applied to this branch')
|
|
104
|
+
return true
|
|
105
|
+
} catch {
|
|
106
|
+
p.log.warning('Stash apply had conflicts — resolve them manually')
|
|
107
|
+
p.log.info(` Your work is safe in ${dim('git stash list')}`)
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Core logic for switching to an EXISTING worktree.
|
|
114
|
+
*
|
|
115
|
+
* 1. Stash source changes (if any)
|
|
116
|
+
* 2. Ensure target worktree is on the expected branch
|
|
117
|
+
* 3. Apply source stash in target
|
|
118
|
+
*/
|
|
119
|
+
function switchToExistingWorktree(targetBranch, { sourceDir, fromBranch }) {
|
|
120
|
+
const targetDir = worktreeDir(targetBranch)
|
|
121
|
+
|
|
122
|
+
// 1. Stash source changes
|
|
123
|
+
const sourceStashSha = stashChanges(sourceDir, stashMessage(fromBranch, targetBranch))
|
|
124
|
+
|
|
125
|
+
// 2. Ensure target is on the correct branch
|
|
126
|
+
const targetCurrentBranch = currentBranch(targetDir)
|
|
127
|
+
|
|
128
|
+
if (targetCurrentBranch !== targetBranch) {
|
|
129
|
+
// Target worktree is on a different branch — check if it's clean
|
|
130
|
+
if (hasUncommittedChanges(targetDir)) {
|
|
131
|
+
p.log.error(
|
|
132
|
+
`Worktree ${bold(targetBranch)} is on branch ${bold(targetCurrentBranch)} with uncommitted changes.`
|
|
133
|
+
)
|
|
134
|
+
p.log.info(` Clean up the worktree first:`)
|
|
135
|
+
p.log.info(` ${green('cd')} ${dim(`worktrees/${targetBranch}`)}`)
|
|
136
|
+
p.log.info(` ${dim('git stash')} ${dim('or')} ${dim('git commit')}`)
|
|
137
|
+
p.log.info(` ${dim(`git checkout ${targetBranch}`)}`)
|
|
138
|
+
if (sourceStashSha) {
|
|
139
|
+
p.log.info(`\n Your source stash is safe — apply it later with:`)
|
|
140
|
+
p.log.info(` ${dim(`git stash apply ${sourceStashSha.slice(0, 8)}`)}`)
|
|
85
141
|
}
|
|
86
|
-
p.
|
|
142
|
+
p.outro('')
|
|
143
|
+
process.exit(1)
|
|
87
144
|
}
|
|
88
145
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
146
|
+
// Clean worktree on wrong branch — switch it
|
|
147
|
+
p.log.step(`Switching worktree from ${bold(targetCurrentBranch)} to ${bold(targetBranch)}…`)
|
|
148
|
+
try {
|
|
149
|
+
execFileSync('git', ['checkout', targetBranch], { cwd: targetDir, stdio: 'pipe' })
|
|
150
|
+
p.log.success(`Now on branch ${bold(targetBranch)}`)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
p.log.error(`Could not switch branch: ${err.message || 'git checkout failed'}`)
|
|
153
|
+
if (sourceStashSha) {
|
|
154
|
+
p.log.info(` Your source stash is safe: ${dim(`git stash apply ${sourceStashSha.slice(0, 8)}`)}`)
|
|
155
|
+
}
|
|
156
|
+
p.outro('')
|
|
157
|
+
process.exit(1)
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
p.log.success(`Worktree ${bold(targetBranch)} is on the correct branch`)
|
|
161
|
+
}
|
|
94
162
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
163
|
+
// 3. Apply source stash in target
|
|
164
|
+
if (sourceStashSha) {
|
|
165
|
+
if (hasUncommittedChanges(targetDir)) {
|
|
166
|
+
p.log.warning(`Target worktree has uncommitted changes — skipping stash apply`)
|
|
167
|
+
p.log.info(` Apply your stash manually: ${dim(`git stash apply ${sourceStashSha.slice(0, 8)}`)}`)
|
|
168
|
+
} else {
|
|
169
|
+
applyStash(targetDir, sourceStashSha)
|
|
98
170
|
}
|
|
99
|
-
targetBranch = result.trim()
|
|
100
171
|
}
|
|
101
172
|
|
|
102
|
-
//
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 3. Stash uncommitted work
|
|
118
|
-
let didStash = false
|
|
119
|
-
const sourceDir = fromWorktree === 'main' ? root : worktreeDir(fromWorktree)
|
|
173
|
+
// 4. Summary
|
|
174
|
+
const lines = [
|
|
175
|
+
` Worktree ready: ${green(`worktrees/${targetBranch}`)}`,
|
|
176
|
+
]
|
|
177
|
+
if (sourceStashSha) {
|
|
178
|
+
lines.push(` Your uncommitted work has been safely moved`)
|
|
179
|
+
}
|
|
180
|
+
lines.push('')
|
|
181
|
+
lines.push(` ${green('cd')} ${dim(`worktrees/${targetBranch}`)}`)
|
|
182
|
+
lines.push(` ${green('npx storyboard dev')} ${dim('to start developing')}`)
|
|
183
|
+
lines.push('')
|
|
184
|
+
lines.push(` ${dim('Tip: auto-cd with')} ${green('eval "$(npx sb branch --cd)"')}`)
|
|
120
185
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
186
|
+
p.note(lines.join('\n'), `Branch ${bold(targetBranch)} is ready`)
|
|
187
|
+
p.outro('')
|
|
188
|
+
return targetDir
|
|
189
|
+
}
|
|
124
190
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Core logic for creating a NEW worktree.
|
|
193
|
+
*
|
|
194
|
+
* 1. Stash source changes
|
|
195
|
+
* 2. Resolve branch (local/remote/new)
|
|
196
|
+
* 3. Create worktree + npm install
|
|
197
|
+
* 4. Pull --rebase
|
|
198
|
+
* 5. Apply source stash
|
|
199
|
+
*/
|
|
200
|
+
function createNewWorktree(targetBranch, { sourceDir, fromBranch, root }) {
|
|
201
|
+
// 1. Stash source changes
|
|
202
|
+
const sourceStashSha = stashChanges(sourceDir, stashMessage(fromBranch, targetBranch))
|
|
134
203
|
|
|
135
|
-
//
|
|
204
|
+
// 2. Resolve branch
|
|
136
205
|
const hasLocal = localBranchExists(targetBranch, root)
|
|
137
206
|
const hasRemote = !hasLocal && remoteBranchExists(targetBranch, root)
|
|
138
207
|
const isNew = !hasLocal && !hasRemote
|
|
139
208
|
|
|
140
209
|
if (isNew) {
|
|
141
|
-
p.log.step(`Creating new branch ${bold(targetBranch)} from
|
|
210
|
+
p.log.step(`Creating new branch ${bold(targetBranch)} from ${bold(fromBranch)}`)
|
|
142
211
|
} else if (hasRemote) {
|
|
143
|
-
// Fetch the remote branch so git worktree add can use it
|
|
144
212
|
p.log.step(`Fetching ${bold(targetBranch)} from origin…`)
|
|
145
213
|
try {
|
|
146
214
|
execFileSync('git', ['fetch', 'origin', targetBranch], { cwd: root, stdio: 'pipe' })
|
|
147
|
-
// Create a local tracking branch
|
|
148
215
|
try {
|
|
149
216
|
execFileSync('git', ['branch', targetBranch, `origin/${targetBranch}`], { cwd: root, stdio: 'pipe' })
|
|
150
217
|
} catch { /* may already exist after fetch */ }
|
|
@@ -155,13 +222,14 @@ export async function runBranchGuide(branchArg) {
|
|
|
155
222
|
p.log.step(`Using existing branch ${bold(targetBranch)}`)
|
|
156
223
|
}
|
|
157
224
|
|
|
158
|
-
// Create the worktree
|
|
225
|
+
// 3. Create the worktree
|
|
159
226
|
const targetDir = resolve(root, 'worktrees', targetBranch)
|
|
160
227
|
const spin = p.spinner()
|
|
161
228
|
|
|
162
229
|
try {
|
|
230
|
+
// For new branches, use current branch as start-point (not main's HEAD)
|
|
163
231
|
const gitArgs = isNew
|
|
164
|
-
? ['worktree', 'add', targetDir, '-b', targetBranch]
|
|
232
|
+
? ['worktree', 'add', targetDir, '-b', targetBranch, fromBranch]
|
|
165
233
|
: ['worktree', 'add', targetDir, targetBranch]
|
|
166
234
|
|
|
167
235
|
spin.start(`Creating worktree worktrees/${targetBranch}`)
|
|
@@ -186,7 +254,7 @@ export async function runBranchGuide(branchArg) {
|
|
|
186
254
|
// Assign a dev server port
|
|
187
255
|
getPort(targetBranch)
|
|
188
256
|
|
|
189
|
-
//
|
|
257
|
+
// 4. Pull --rebase from origin
|
|
190
258
|
if (!isNew) {
|
|
191
259
|
try {
|
|
192
260
|
spin.start('Pulling latest changes…')
|
|
@@ -197,36 +265,122 @@ export async function runBranchGuide(branchArg) {
|
|
|
197
265
|
}
|
|
198
266
|
}
|
|
199
267
|
|
|
200
|
-
//
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
// Apply (not pop) — keeps the backup in stash list
|
|
204
|
-
execFileSync('git', ['stash', 'apply'], { cwd: targetDir, stdio: 'pipe' })
|
|
205
|
-
p.log.success('Previous work applied to this branch')
|
|
206
|
-
} catch {
|
|
207
|
-
p.log.warning('Stash apply had conflicts — resolve them manually')
|
|
208
|
-
p.log.info(` Your work is safe in ${dim('git stash list')}`)
|
|
209
|
-
}
|
|
268
|
+
// 5. Apply source stash
|
|
269
|
+
if (sourceStashSha) {
|
|
270
|
+
applyStash(targetDir, sourceStashSha)
|
|
210
271
|
}
|
|
211
272
|
|
|
212
|
-
//
|
|
273
|
+
// 6. Summary
|
|
213
274
|
const lines = [
|
|
214
275
|
` Your branch is set up as a worktree in ${green(`worktrees/${targetBranch}`)}`,
|
|
215
276
|
]
|
|
216
|
-
if (
|
|
277
|
+
if (sourceStashSha) {
|
|
217
278
|
lines.push(` Your uncommitted work has been safely moved`)
|
|
218
279
|
}
|
|
219
280
|
lines.push('')
|
|
220
281
|
lines.push(` ${green('cd')} ${dim(`worktrees/${targetBranch}`)}`)
|
|
221
282
|
lines.push(` ${green('npx storyboard dev')} ${dim('to start developing')}`)
|
|
222
283
|
lines.push('')
|
|
223
|
-
lines.push(` ${dim('Tip:
|
|
284
|
+
lines.push(` ${dim('Tip: auto-cd with')} ${green('eval "$(npx sb branch --cd)"')}`)
|
|
224
285
|
|
|
225
286
|
p.note(lines.join('\n'), `Branch ${bold(targetBranch)} is ready`)
|
|
226
287
|
p.outro('')
|
|
288
|
+
return targetDir
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── Main ───
|
|
292
|
+
|
|
293
|
+
export async function runBranchGuide(branchArg) {
|
|
294
|
+
const root = repoRoot()
|
|
295
|
+
const existing = listWorktrees()
|
|
296
|
+
const fromWorktree = detectWorktreeName()
|
|
297
|
+
const sourceDir = fromWorktree === 'main' ? root : worktreeDir(fromWorktree)
|
|
298
|
+
const fromBranch = currentBranch(sourceDir)
|
|
299
|
+
|
|
300
|
+
// 1. Get branch name — select from existing or type a new one
|
|
301
|
+
let targetBranch = branchArg?.trim()
|
|
302
|
+
|
|
303
|
+
if (!targetBranch) {
|
|
304
|
+
if (existing.length > 0) {
|
|
305
|
+
// Build select options from existing worktrees + "new branch" option
|
|
306
|
+
const NEW_BRANCH = Symbol('new')
|
|
307
|
+
const options = [
|
|
308
|
+
...existing.map(name => ({ value: name, label: name })),
|
|
309
|
+
{ value: NEW_BRANCH, label: dim('Create a new branch…') },
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
const selected = await p.select({
|
|
313
|
+
message: 'Which branch do you want to work on?',
|
|
314
|
+
options,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
if (p.isCancel(selected)) {
|
|
318
|
+
p.cancel('Cancelled')
|
|
319
|
+
process.exit(0)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (selected === NEW_BRANCH) {
|
|
323
|
+
const newName = await p.text({
|
|
324
|
+
message: 'New branch name:',
|
|
325
|
+
placeholder: 'e.g. 4.3.0--my-feature',
|
|
326
|
+
validate: isValidBranchName,
|
|
327
|
+
})
|
|
328
|
+
if (p.isCancel(newName)) {
|
|
329
|
+
p.cancel('Cancelled')
|
|
330
|
+
process.exit(0)
|
|
331
|
+
}
|
|
332
|
+
targetBranch = newName.trim()
|
|
333
|
+
} else {
|
|
334
|
+
targetBranch = selected
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
// No existing worktrees — just ask for a name
|
|
338
|
+
const result = await p.text({
|
|
339
|
+
message: 'Branch name for new worktree:',
|
|
340
|
+
placeholder: 'e.g. 4.3.0--my-feature',
|
|
341
|
+
validate: isValidBranchName,
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
if (p.isCancel(result)) {
|
|
345
|
+
p.cancel('Cancelled')
|
|
346
|
+
process.exit(0)
|
|
347
|
+
}
|
|
348
|
+
targetBranch = result.trim()
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 2. Show equivalent non-interactive command (when user used TUI to select)
|
|
353
|
+
if (!branchArg) {
|
|
354
|
+
p.log.info(`${dim('Non-interactive:')} ${green(`npx sb branch --worktree=${targetBranch}`)}`)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 3. Route to existing or new worktree flow
|
|
358
|
+
const wtDir = worktreeDir(targetBranch)
|
|
359
|
+
if (existsSync(resolve(wtDir, '.git'))) {
|
|
360
|
+
return switchToExistingWorktree(targetBranch, { sourceDir, fromBranch })
|
|
361
|
+
} else {
|
|
362
|
+
return createNewWorktree(targetBranch, { sourceDir, fromBranch, root })
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ─── Direct invocation ───
|
|
367
|
+
|
|
368
|
+
const { flags, positional } = parseFlags(process.argv.slice(3), flagSchema)
|
|
369
|
+
const branchArg = flags.worktree || positional[0] || undefined
|
|
370
|
+
|
|
371
|
+
// When --cd is set, redirect all TUI output (Clack) to stderr so that
|
|
372
|
+
// stdout contains only the shell-evaluable `cd <path>` command.
|
|
373
|
+
// Usage: eval "$(npx sb branch --worktree=<name> --cd)"
|
|
374
|
+
const realStdoutWrite = process.stdout.write.bind(process.stdout)
|
|
375
|
+
if (flags.cd) {
|
|
376
|
+
process.stdout.write = (chunk, encoding, callback) =>
|
|
377
|
+
process.stderr.write(chunk, encoding, callback)
|
|
227
378
|
}
|
|
228
379
|
|
|
229
|
-
// Direct invocation
|
|
230
|
-
const branchArg = process.argv[3] || undefined
|
|
231
380
|
p.intro('storyboard branch')
|
|
232
|
-
runBranchGuide(branchArg)
|
|
381
|
+
runBranchGuide(branchArg).then((targetDir) => {
|
|
382
|
+
if (flags.cd && targetDir) {
|
|
383
|
+
process.stdout.write = realStdoutWrite
|
|
384
|
+
realStdoutWrite(`cd ${JSON.stringify(targetDir)}\n`)
|
|
385
|
+
}
|
|
386
|
+
})
|
package/src/cli/index.js
CHANGED
|
@@ -93,8 +93,11 @@ function helpScreen(version) {
|
|
|
93
93
|
'',
|
|
94
94
|
` ${bold(cyan('Setup'))}`,
|
|
95
95
|
cmd('setup', 'Install deps, Caddy proxy, start proxy'),
|
|
96
|
+
cmd('setup --skip-branch', 'Non-interactive setup (skip branch prompt)'),
|
|
97
|
+
cmd('setup --branch=<name>', 'Setup + switch to a branch'),
|
|
96
98
|
cmd('branch', 'Switch to a branch (interactive worktree guide)'),
|
|
97
99
|
cmd('branch <name>', 'Switch to a specific branch directly'),
|
|
100
|
+
cmd('branch --worktree=<name>', 'Non-interactive branch switch'),
|
|
98
101
|
cmd('proxy start', 'Start or reload Caddy proxy'),
|
|
99
102
|
cmd('proxy close', 'Stop Caddy proxy'),
|
|
100
103
|
'',
|
package/src/cli/setup.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* storyboard setup — One-time setup for the Storyboard dev environment.
|
|
3
3
|
*
|
|
4
4
|
* Idempotent: safe to run multiple times, only does what's needed.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx storyboard setup # interactive
|
|
8
|
+
* npx storyboard setup --skip-branch # non-interactive, skip branch prompt
|
|
9
|
+
* npx storyboard setup --branch=<name> # non-interactive, switch to branch after setup
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
import * as p from '@clack/prompts'
|
|
@@ -9,7 +14,15 @@ import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync, symlin
|
|
|
9
14
|
import path from 'path'
|
|
10
15
|
import { execSync } from 'child_process'
|
|
11
16
|
import { generateCaddyfile, isCaddyInstalled, isCaddyRunning, startCaddy, reloadCaddy } from './proxy.js'
|
|
12
|
-
import { gettingStartedLines, dim, magenta, bold, yellow } from './intro.js'
|
|
17
|
+
import { gettingStartedLines, dim, magenta, bold, yellow, green } from './intro.js'
|
|
18
|
+
import { parseFlags } from './flags.js'
|
|
19
|
+
|
|
20
|
+
const flagSchema = {
|
|
21
|
+
'skip-branch': { type: 'boolean', default: false, description: 'Skip the branch prompt at the end' },
|
|
22
|
+
branch: { type: 'string', description: 'Switch to a branch after setup (non-interactive)' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { flags } = parseFlags(process.argv.slice(3), flagSchema)
|
|
13
26
|
|
|
14
27
|
/**
|
|
15
28
|
* Run a potentially slow task with a spinner that only appears after 500ms.
|
|
@@ -330,11 +343,16 @@ if (isCaddyInstalled()) {
|
|
|
330
343
|
p.log.info(dim('Skipping npm install — dev server is running (would cause restart)'))
|
|
331
344
|
p.log.info(dim('Run `npm install` manually after stopping the dev server if needed'))
|
|
332
345
|
} else {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
346
|
+
try {
|
|
347
|
+
await withSpin(
|
|
348
|
+
'Installing dependencies...',
|
|
349
|
+
'Dependencies installed',
|
|
350
|
+
() => { run('npm install', { stdio: 'ignore' }) }
|
|
351
|
+
)
|
|
352
|
+
} catch {
|
|
353
|
+
p.log.warning('npm install failed — run it manually to see details')
|
|
354
|
+
p.log.info(` ${dim('npm install')}`)
|
|
355
|
+
}
|
|
338
356
|
}
|
|
339
357
|
}
|
|
340
358
|
|
|
@@ -349,26 +367,38 @@ p.note(
|
|
|
349
367
|
|
|
350
368
|
// Offer branch guide
|
|
351
369
|
{
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
currentBranchName = execSync('git branch --show-current', { encoding: 'utf8' }).trim() || 'main'
|
|
355
|
-
} catch { /* empty */ }
|
|
356
|
-
|
|
357
|
-
const wantBranch = await p.select({
|
|
358
|
-
message: 'Want to work from a different branch?',
|
|
359
|
-
options: [
|
|
360
|
-
{ value: true, label: 'Yes (help me create it)' },
|
|
361
|
-
{ value: false, label: `No (stay on current branch ${bold(currentBranchName)})` },
|
|
362
|
-
],
|
|
363
|
-
initialValue: false,
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
if (!p.isCancel(wantBranch) && wantBranch) {
|
|
370
|
+
// Non-interactive: --branch=<name> or --skip-branch
|
|
371
|
+
if (flags.branch) {
|
|
367
372
|
const { runBranchGuide } = await import('./branch.js')
|
|
368
|
-
await runBranchGuide()
|
|
369
|
-
} else {
|
|
373
|
+
await runBranchGuide(flags.branch)
|
|
374
|
+
} else if (flags['skip-branch']) {
|
|
370
375
|
console.log()
|
|
371
376
|
console.log(mascot())
|
|
372
377
|
p.outro('')
|
|
378
|
+
} else {
|
|
379
|
+
// Interactive: ask the user
|
|
380
|
+
let currentBranchName = 'main'
|
|
381
|
+
try {
|
|
382
|
+
currentBranchName = execSync('git branch --show-current', { encoding: 'utf8' }).trim() || 'main'
|
|
383
|
+
} catch { /* empty */ }
|
|
384
|
+
|
|
385
|
+
const wantBranch = await p.select({
|
|
386
|
+
message: 'Want to work from a different branch?',
|
|
387
|
+
options: [
|
|
388
|
+
{ value: true, label: 'Yes (help me create it)' },
|
|
389
|
+
{ value: false, label: `No (stay on current branch ${bold(currentBranchName)})` },
|
|
390
|
+
],
|
|
391
|
+
initialValue: false,
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
if (!p.isCancel(wantBranch) && wantBranch) {
|
|
395
|
+
const { runBranchGuide } = await import('./branch.js')
|
|
396
|
+
await runBranchGuide()
|
|
397
|
+
} else {
|
|
398
|
+
console.log()
|
|
399
|
+
console.log(mascot())
|
|
400
|
+
p.log.info(`${dim('Non-interactive:')} ${green('npx sb setup --skip-branch')}`)
|
|
401
|
+
p.outro('')
|
|
402
|
+
}
|
|
373
403
|
}
|
|
374
404
|
}
|