@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.
Files changed (73) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +11882 -11126
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -3
  6. package/paste.config.json +54 -0
  7. package/scaffold/deploy.yml +101 -0
  8. package/scaffold/githooks/pre-push +114 -0
  9. package/scaffold/manifest.json +11 -0
  10. package/scaffold/storyboard.config.json +4 -1
  11. package/src/ActionMenuButton.svelte +12 -2
  12. package/src/CanvasCreateMenu.svelte +228 -10
  13. package/src/CanvasSnap.svelte +2 -0
  14. package/src/CoreUIBar.svelte +152 -3
  15. package/src/CreateMenuButton.svelte +4 -1
  16. package/src/InspectorPanel.svelte +2 -0
  17. package/src/PwaInstallBanner.svelte +124 -0
  18. package/src/autosync/server.js +99 -111
  19. package/src/autosync/server.test.js +0 -7
  20. package/src/canvas/collision.js +206 -0
  21. package/src/canvas/collision.test.js +271 -0
  22. package/src/canvas/deriveCanvasId.test.js +40 -0
  23. package/src/canvas/identity.js +107 -0
  24. package/src/canvas/identity.test.js +100 -0
  25. package/src/canvas/server.js +285 -31
  26. package/src/canvasConfig.js +56 -0
  27. package/src/canvasConfig.test.js +42 -0
  28. package/src/cli/canvasAdd.js +185 -0
  29. package/src/cli/canvasRead.js +208 -0
  30. package/src/cli/code.js +67 -0
  31. package/src/cli/create.js +339 -72
  32. package/src/cli/dev-helpers.js +53 -0
  33. package/src/cli/dev-helpers.test.js +53 -0
  34. package/src/cli/dev.js +245 -26
  35. package/src/cli/flags.js +174 -0
  36. package/src/cli/flags.test.js +155 -0
  37. package/src/cli/index.js +84 -13
  38. package/src/cli/intro.js +37 -0
  39. package/src/cli/proxy.js +127 -6
  40. package/src/cli/proxy.test.js +63 -0
  41. package/src/cli/schemas.js +200 -0
  42. package/src/cli/serverUrl.js +56 -0
  43. package/src/cli/setup.js +130 -20
  44. package/src/cli/snapshots.js +335 -0
  45. package/src/cli/updateVersion.js +54 -3
  46. package/src/configSchema.js +125 -0
  47. package/src/configSchema.test.js +68 -0
  48. package/src/index.js +5 -0
  49. package/src/inspector/highlighter.js +10 -2
  50. package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
  51. package/src/loader.js +21 -2
  52. package/src/loader.test.js +63 -1
  53. package/src/mobileViewport.js +57 -0
  54. package/src/mobileViewport.test.js +68 -0
  55. package/src/mountStoryboardCore.js +61 -7
  56. package/src/rename-watcher/config.json +23 -0
  57. package/src/rename-watcher/watcher.js +538 -0
  58. package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
  59. package/src/tools/handlers/flows.js +6 -7
  60. package/src/viewfinder.js +21 -9
  61. package/src/viewfinder.test.js +2 -2
  62. package/src/vite/server-plugin.js +150 -7
  63. package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
  64. package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
  65. package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
  66. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  67. package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
  68. package/src/workshop/features/createStory/index.js +14 -0
  69. package/src/workshop/features/registry.js +2 -0
  70. package/src/worktree/port.js +57 -1
  71. package/src/worktree/port.test.js +91 -1
  72. package/toolbar.config.json +3 -3
  73. 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 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'
10
- import { detectWorktreeName, getPort } from '../worktree/port.js'
11
- import { generateCaddyfile, isCaddyRunning, reloadCaddy, readDevDomain } from './proxy.js'
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 worktreeName = detectWorktreeName()
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
- p.intro('storyboard dev')
27
-
28
- // Parse --port override from argv (skip 'dev' subcommand)
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 child = spawn('npx', ['vite', '--port', String(overridePort || port), ...extraArgs], {
42
- env: { ...process.env, VITE_BASE_PATH: basePath },
43
- stdio: ['inherit', 'pipe', 'pipe'],
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
- const caddyfilePath = generateCaddyfile({ [worktreeName]: actualPort })
59
- if (isCaddyRunning()) {
60
- reloadCaddy(caddyfilePath)
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 (isCaddyRunning()) {
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
  }
@@ -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
+ })