@dfosco/storyboard-core 4.0.0-beta.2 → 4.0.0-beta.21
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/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +11882 -11126
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +11 -3
- package/paste.config.json +54 -0
- package/scaffold/deploy.yml +101 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/manifest.json +11 -0
- package/scaffold/storyboard.config.json +4 -1
- package/src/ActionMenuButton.svelte +12 -2
- package/src/CanvasCreateMenu.svelte +228 -10
- package/src/CanvasSnap.svelte +2 -0
- package/src/CoreUIBar.svelte +152 -3
- package/src/CreateMenuButton.svelte +4 -1
- package/src/InspectorPanel.svelte +2 -0
- package/src/PwaInstallBanner.svelte +124 -0
- package/src/autosync/server.js +99 -111
- package/src/autosync/server.test.js +0 -7
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/canvas/deriveCanvasId.test.js +40 -0
- package/src/canvas/identity.js +107 -0
- package/src/canvas/identity.test.js +100 -0
- package/src/canvas/server.js +285 -31
- package/src/canvasConfig.js +56 -0
- package/src/canvasConfig.test.js +42 -0
- package/src/cli/canvasAdd.js +185 -0
- package/src/cli/canvasRead.js +208 -0
- package/src/cli/code.js +67 -0
- package/src/cli/create.js +339 -72
- package/src/cli/dev-helpers.js +53 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +245 -26
- package/src/cli/flags.js +174 -0
- package/src/cli/flags.test.js +155 -0
- package/src/cli/index.js +84 -13
- package/src/cli/intro.js +37 -0
- package/src/cli/proxy.js +127 -6
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/schemas.js +200 -0
- package/src/cli/serverUrl.js +56 -0
- package/src/cli/setup.js +130 -20
- package/src/cli/snapshots.js +335 -0
- package/src/cli/updateVersion.js +54 -3
- package/src/configSchema.js +125 -0
- package/src/configSchema.test.js +68 -0
- package/src/index.js +5 -0
- package/src/inspector/highlighter.js +10 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
- package/src/loader.js +21 -2
- package/src/loader.test.js +63 -1
- package/src/mobileViewport.js +57 -0
- package/src/mobileViewport.test.js +68 -0
- package/src/mountStoryboardCore.js +61 -7
- package/src/rename-watcher/config.json +23 -0
- package/src/rename-watcher/watcher.js +538 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
- package/src/tools/handlers/flows.js +6 -7
- package/src/viewfinder.js +21 -9
- package/src/viewfinder.test.js +2 -2
- package/src/vite/server-plugin.js +150 -7
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
- package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
- package/src/workshop/features/createStory/index.js +14 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/toolbar.config.json +3 -3
- package/widgets.config.json +132 -27
package/src/cli/dev.js
CHANGED
|
@@ -1,17 +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'
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
15
|
+
import { spawn, execFileSync } from 'child_process'
|
|
16
|
+
import { existsSync } from 'fs'
|
|
17
|
+
import { resolve } from 'path'
|
|
18
|
+
import { detectWorktreeName, getPort, repoRoot, worktreeDir, listWorktrees } from '../worktree/port.js'
|
|
19
|
+
import { generateCaddyfile, generateRouteConfig, upsertCaddyRoute, isCaddyRunning, reloadCaddy, readDevDomain } from './proxy.js'
|
|
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
|
+
}
|
|
12
199
|
|
|
13
200
|
async function main() {
|
|
14
|
-
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
|
+
|
|
15
217
|
const port = getPort(worktreeName)
|
|
16
218
|
const isMain = worktreeName === 'main'
|
|
17
219
|
|
|
@@ -19,32 +221,40 @@ async function main() {
|
|
|
19
221
|
? '/'
|
|
20
222
|
: `/branch--${worktreeName}/`
|
|
21
223
|
|
|
22
|
-
const domain = readDevDomain()
|
|
224
|
+
const domain = readDevDomain(targetCwd)
|
|
23
225
|
const proxyUrl = `http://${domain}${basePath}`
|
|
24
226
|
const directUrl = `http://localhost:${port}${basePath}`
|
|
25
227
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const args = process.argv.slice(3)
|
|
30
|
-
const portFlagIdx = args.indexOf('--port')
|
|
31
|
-
const overridePort = portFlagIdx >= 0 ? Number(args[portFlagIdx + 1]) : null
|
|
32
|
-
|
|
33
|
-
const extraArgs = args.filter((arg, i, arr) => {
|
|
34
|
-
if (arg === '--port') return false
|
|
35
|
-
if (i > 0 && arr[i - 1] === '--port') return false
|
|
36
|
-
return true
|
|
37
|
-
})
|
|
228
|
+
// Resolve Vite binary relative to target worktree
|
|
229
|
+
const localVite = resolve(targetCwd, 'node_modules', '.bin', 'vite')
|
|
230
|
+
const useLocalVite = existsSync(localVite)
|
|
38
231
|
|
|
39
232
|
// Start Vite — let it find a free port if assigned one is busy.
|
|
40
233
|
// Capture stdout to detect actual port and update Caddy.
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
234
|
+
const viteArgs = ['--port', String(overridePort || port)]
|
|
235
|
+
const child = useLocalVite
|
|
236
|
+
? spawn(localVite, viteArgs, {
|
|
237
|
+
cwd: targetCwd,
|
|
238
|
+
env: { ...process.env, VITE_BASE_PATH: basePath },
|
|
239
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
240
|
+
})
|
|
241
|
+
: spawn('npx', ['vite', ...viteArgs], {
|
|
242
|
+
cwd: targetCwd,
|
|
243
|
+
env: { ...process.env, VITE_BASE_PATH: basePath },
|
|
244
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// Start rename watcher in target directory
|
|
248
|
+
const renameWatcher = startRenameWatcher(targetCwd)
|
|
45
249
|
|
|
46
250
|
let caddyUpdated = false
|
|
47
251
|
let ready = false
|
|
252
|
+
let caddyRunning = null // cached result of isCaddyRunning()
|
|
253
|
+
|
|
254
|
+
function getCaddyRunning() {
|
|
255
|
+
if (caddyRunning === null) caddyRunning = isCaddyRunning()
|
|
256
|
+
return caddyRunning
|
|
257
|
+
}
|
|
48
258
|
|
|
49
259
|
child.stdout.on('data', (data) => {
|
|
50
260
|
const text = data.toString()
|
|
@@ -55,9 +265,17 @@ async function main() {
|
|
|
55
265
|
const actualPort = Number(portMatch[1])
|
|
56
266
|
caddyUpdated = true
|
|
57
267
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
268
|
+
// Try admin API first (additive, doesn't wipe other repos' routes)
|
|
269
|
+
const routeConfig = generateRouteConfig({ [worktreeName]: actualPort })
|
|
270
|
+
if (getCaddyRunning() && upsertCaddyRoute(routeConfig)) {
|
|
271
|
+
// Also write Caddyfile for future cold starts
|
|
272
|
+
generateCaddyfile({ [worktreeName]: actualPort })
|
|
273
|
+
} else {
|
|
274
|
+
// Fall back to full Caddyfile reload
|
|
275
|
+
const caddyfilePath = generateCaddyfile({ [worktreeName]: actualPort })
|
|
276
|
+
if (getCaddyRunning()) {
|
|
277
|
+
reloadCaddy(caddyfilePath)
|
|
278
|
+
}
|
|
61
279
|
}
|
|
62
280
|
} catch {
|
|
63
281
|
// Caddy not available
|
|
@@ -76,7 +294,7 @@ async function main() {
|
|
|
76
294
|
const timeMatch = text.match(/ready in (\d+)/i)
|
|
77
295
|
const ms = timeMatch ? timeMatch[1] : ''
|
|
78
296
|
|
|
79
|
-
if (
|
|
297
|
+
if (getCaddyRunning()) {
|
|
80
298
|
p.log.success(proxyUrl)
|
|
81
299
|
} else {
|
|
82
300
|
p.log.success(directUrl)
|
|
@@ -105,6 +323,7 @@ async function main() {
|
|
|
105
323
|
})
|
|
106
324
|
|
|
107
325
|
child.on('exit', (code) => {
|
|
326
|
+
renameWatcher.close()
|
|
108
327
|
if (code && code !== 0 && !ready) {
|
|
109
328
|
p.log.error(`Vite exited with code ${code}`)
|
|
110
329
|
}
|
package/src/cli/flags.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CLI flag parser — converts argv into a validated flags object.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* --key value string/number value
|
|
6
|
+
* --key=value equals-separated
|
|
7
|
+
* --bool boolean true
|
|
8
|
+
* --no-bool boolean false (negation prefix)
|
|
9
|
+
* --key a --key b repeated flags → array
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const { flags, missing, errors } = parseFlags(process.argv.slice(3), schema)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} FlagDef
|
|
17
|
+
* @property {'string'|'boolean'|'number'|'array'} type
|
|
18
|
+
* @property {boolean} [required]
|
|
19
|
+
* @property {*} [default]
|
|
20
|
+
* @property {string} [description]
|
|
21
|
+
* @property {string[]} [aliases] - short or alternate names (without --)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object<string, FlagDef>} FlagSchema
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse argv tokens against a schema.
|
|
30
|
+
*
|
|
31
|
+
* @param {string[]} argv - process.argv tokens (after command words are stripped)
|
|
32
|
+
* @param {FlagSchema} schema
|
|
33
|
+
* @returns {{ flags: Object, positional: string[], missing: string[], errors: string[] }}
|
|
34
|
+
*/
|
|
35
|
+
export function parseFlags(argv, schema) {
|
|
36
|
+
const flags = {}
|
|
37
|
+
const positional = []
|
|
38
|
+
const errors = []
|
|
39
|
+
|
|
40
|
+
// Build alias → canonical name map
|
|
41
|
+
const aliasMap = {}
|
|
42
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
43
|
+
aliasMap[name] = name
|
|
44
|
+
if (def.aliases) {
|
|
45
|
+
for (const alias of def.aliases) {
|
|
46
|
+
aliasMap[alias] = name
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Seed defaults
|
|
52
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
53
|
+
if (def.default !== undefined) {
|
|
54
|
+
flags[name] = def.default
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let i = 0
|
|
59
|
+
while (i < argv.length) {
|
|
60
|
+
const token = argv[i]
|
|
61
|
+
|
|
62
|
+
if (!token.startsWith('-')) {
|
|
63
|
+
positional.push(token)
|
|
64
|
+
i++
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Strip leading dashes
|
|
69
|
+
let raw = token.replace(/^--?/, '')
|
|
70
|
+
let value
|
|
71
|
+
|
|
72
|
+
// Handle --key=value
|
|
73
|
+
const eqIdx = raw.indexOf('=')
|
|
74
|
+
if (eqIdx !== -1) {
|
|
75
|
+
value = raw.slice(eqIdx + 1)
|
|
76
|
+
raw = raw.slice(0, eqIdx)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Handle --no-<key> negation
|
|
80
|
+
let negated = false
|
|
81
|
+
if (raw.startsWith('no-') && !aliasMap[raw]) {
|
|
82
|
+
const candidate = raw.slice(3)
|
|
83
|
+
if (aliasMap[candidate]) {
|
|
84
|
+
raw = candidate
|
|
85
|
+
negated = true
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const canonical = aliasMap[raw]
|
|
90
|
+
if (!canonical) {
|
|
91
|
+
errors.push(`Unknown flag: --${raw}`)
|
|
92
|
+
i++
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const def = schema[canonical]
|
|
97
|
+
|
|
98
|
+
if (def.type === 'boolean') {
|
|
99
|
+
if (value !== undefined) {
|
|
100
|
+
// Handle --flag=true / --flag=false
|
|
101
|
+
const lower = value.toLowerCase()
|
|
102
|
+
if (lower === 'true' || lower === '1') flags[canonical] = !negated
|
|
103
|
+
else if (lower === 'false' || lower === '0') flags[canonical] = negated
|
|
104
|
+
else errors.push(`Flag --${raw} is boolean; use --${raw}, --no-${raw}, or --${raw}=true|false`)
|
|
105
|
+
} else {
|
|
106
|
+
flags[canonical] = !negated
|
|
107
|
+
}
|
|
108
|
+
i++
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Consume next token as value if we didn't get one from `=`
|
|
113
|
+
if (value === undefined) {
|
|
114
|
+
i++
|
|
115
|
+
if (i >= argv.length) {
|
|
116
|
+
errors.push(`Flag --${raw} requires a value`)
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
value = argv[i]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (def.type === 'number') {
|
|
123
|
+
const num = Number(value)
|
|
124
|
+
if (Number.isNaN(num)) {
|
|
125
|
+
errors.push(`Flag --${raw} must be a number, got "${value}"`)
|
|
126
|
+
} else {
|
|
127
|
+
flags[canonical] = num
|
|
128
|
+
}
|
|
129
|
+
} else if (def.type === 'array') {
|
|
130
|
+
if (!Array.isArray(flags[canonical])) flags[canonical] = []
|
|
131
|
+
flags[canonical].push(value)
|
|
132
|
+
} else {
|
|
133
|
+
// string (or JSON for object-typed flags)
|
|
134
|
+
flags[canonical] = value
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
i++
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check required
|
|
141
|
+
const missing = []
|
|
142
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
143
|
+
if (def.required && (flags[name] === undefined || flags[name] === '')) {
|
|
144
|
+
missing.push(name)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { flags, positional, missing, errors }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if any flags were provided (beyond defaults).
|
|
153
|
+
* Useful to decide interactive vs non-interactive mode.
|
|
154
|
+
*/
|
|
155
|
+
export function hasFlags(argv) {
|
|
156
|
+
return argv.some((t) => t.startsWith('-'))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Format a schema into help text lines.
|
|
161
|
+
*
|
|
162
|
+
* @param {FlagSchema} schema
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function formatFlagHelp(schema) {
|
|
166
|
+
const lines = []
|
|
167
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
168
|
+
const aliases = def.aliases ? def.aliases.map((a) => `-${a}`).join(', ') + ', ' : ''
|
|
169
|
+
const req = def.required ? ' (required)' : ''
|
|
170
|
+
const defVal = def.default !== undefined ? ` [default: ${def.default}]` : ''
|
|
171
|
+
lines.push(` ${aliases}--${name.padEnd(16)} ${def.description || ''}${req}${defVal}`)
|
|
172
|
+
}
|
|
173
|
+
return lines.join('\n')
|
|
174
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseFlags, hasFlags, formatFlagHelp } from './flags.js'
|
|
3
|
+
|
|
4
|
+
const testSchema = {
|
|
5
|
+
name: { type: 'string', required: true, description: 'Name', aliases: ['n'] },
|
|
6
|
+
title: { type: 'string', description: 'Title', aliases: ['t'] },
|
|
7
|
+
count: { type: 'number', description: 'Count', default: 0 },
|
|
8
|
+
verbose: { type: 'boolean', description: 'Verbose', default: false },
|
|
9
|
+
tags: { type: 'array', description: 'Tags', aliases: ['g'] },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('parseFlags', () => {
|
|
13
|
+
it('parses --key value pairs', () => {
|
|
14
|
+
const { flags } = parseFlags(['--name', 'hello', '--title', 'world'], testSchema)
|
|
15
|
+
expect(flags.name).toBe('hello')
|
|
16
|
+
expect(flags.title).toBe('world')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('parses --key=value syntax', () => {
|
|
20
|
+
const { flags } = parseFlags(['--name=hello'], testSchema)
|
|
21
|
+
expect(flags.name).toBe('hello')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('parses short aliases', () => {
|
|
25
|
+
const { flags } = parseFlags(['-n', 'hello', '-t', 'world'], testSchema)
|
|
26
|
+
expect(flags.name).toBe('hello')
|
|
27
|
+
expect(flags.title).toBe('world')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('parses boolean flags', () => {
|
|
31
|
+
const { flags } = parseFlags(['--verbose', '--name', 'x'], testSchema)
|
|
32
|
+
expect(flags.verbose).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('parses --no- negation for booleans', () => {
|
|
36
|
+
const { flags } = parseFlags(['--no-verbose', '--name', 'x'], testSchema)
|
|
37
|
+
expect(flags.verbose).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('parses --flag=false for booleans', () => {
|
|
41
|
+
const { flags } = parseFlags(['--verbose=false', '--name', 'x'], testSchema)
|
|
42
|
+
expect(flags.verbose).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('parses --flag=true for booleans', () => {
|
|
46
|
+
const { flags } = parseFlags(['--verbose=true', '--name', 'x'], testSchema)
|
|
47
|
+
expect(flags.verbose).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('parses --no-flag=false as double negation (true)', () => {
|
|
51
|
+
const { flags } = parseFlags(['--no-verbose=false', '--name', 'x'], testSchema)
|
|
52
|
+
expect(flags.verbose).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('reports error for invalid boolean value', () => {
|
|
56
|
+
const { errors } = parseFlags(['--verbose=maybe', '--name', 'x'], testSchema)
|
|
57
|
+
expect(errors).toHaveLength(1)
|
|
58
|
+
expect(errors[0]).toContain('boolean')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('parses number flags', () => {
|
|
62
|
+
const { flags } = parseFlags(['--name', 'x', '--count', '42'], testSchema)
|
|
63
|
+
expect(flags.count).toBe(42)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('reports error for non-numeric number flag', () => {
|
|
67
|
+
const { errors } = parseFlags(['--name', 'x', '--count', 'abc'], testSchema)
|
|
68
|
+
expect(errors).toHaveLength(1)
|
|
69
|
+
expect(errors[0]).toContain('must be a number')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('collects repeated flags into arrays', () => {
|
|
73
|
+
const { flags } = parseFlags(['--name', 'x', '-g', 'a', '-g', 'b'], testSchema)
|
|
74
|
+
expect(flags.tags).toEqual(['a', 'b'])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('collects positional arguments', () => {
|
|
78
|
+
const { positional } = parseFlags(['sticky-note', '--name', 'x'], testSchema)
|
|
79
|
+
expect(positional).toEqual(['sticky-note'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('reports missing required flags', () => {
|
|
83
|
+
const { missing } = parseFlags(['--title', 'hello'], testSchema)
|
|
84
|
+
expect(missing).toContain('name')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not report missing when required flag is provided', () => {
|
|
88
|
+
const { missing } = parseFlags(['--name', 'hello'], testSchema)
|
|
89
|
+
expect(missing).not.toContain('name')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('reports unknown flags', () => {
|
|
93
|
+
const { errors } = parseFlags(['--name', 'x', '--unknown', 'y'], testSchema)
|
|
94
|
+
expect(errors).toHaveLength(1)
|
|
95
|
+
expect(errors[0]).toContain('Unknown flag')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('applies defaults', () => {
|
|
99
|
+
const { flags } = parseFlags(['--name', 'x'], testSchema)
|
|
100
|
+
expect(flags.count).toBe(0)
|
|
101
|
+
expect(flags.verbose).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('reports error when value flag has no value', () => {
|
|
105
|
+
const { errors } = parseFlags(['--name'], testSchema)
|
|
106
|
+
expect(errors).toHaveLength(1)
|
|
107
|
+
expect(errors[0]).toContain('requires a value')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns empty results for empty argv', () => {
|
|
111
|
+
const { flags, positional, missing, errors } = parseFlags([], testSchema)
|
|
112
|
+
expect(positional).toEqual([])
|
|
113
|
+
expect(errors).toEqual([])
|
|
114
|
+
expect(missing).toContain('name')
|
|
115
|
+
expect(flags.count).toBe(0)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('hasFlags', () => {
|
|
120
|
+
it('returns true when argv has flags', () => {
|
|
121
|
+
expect(hasFlags(['--name', 'x'])).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('returns true for short flags', () => {
|
|
125
|
+
expect(hasFlags(['-n', 'x'])).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('returns false when no flags', () => {
|
|
129
|
+
expect(hasFlags(['positional'])).toBe(false)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('returns false for empty argv', () => {
|
|
133
|
+
expect(hasFlags([])).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('formatFlagHelp', () => {
|
|
138
|
+
it('includes flag names and descriptions', () => {
|
|
139
|
+
const output = formatFlagHelp(testSchema)
|
|
140
|
+
expect(output).toContain('--name')
|
|
141
|
+
expect(output).toContain('Name')
|
|
142
|
+
expect(output).toContain('(required)')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('includes aliases', () => {
|
|
146
|
+
const output = formatFlagHelp(testSchema)
|
|
147
|
+
expect(output).toContain('-n')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('includes defaults', () => {
|
|
151
|
+
const output = formatFlagHelp(testSchema)
|
|
152
|
+
expect(output).toContain('[default: 0]')
|
|
153
|
+
expect(output).toContain('[default: false]')
|
|
154
|
+
})
|
|
155
|
+
})
|