@dfosco/storyboard 0.6.0-beta.6 → 0.6.0-beta.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.6.0-beta.6",
3
+ "version": "0.6.0-beta.8",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -3103,7 +3103,10 @@ export function Default() {
3103
3103
  // Write env file for this terminal session — sourced before copilot launch
3104
3104
  // This avoids race conditions with tmux send-keys export
3105
3105
  const envFile = path.join(root, '.storyboard', 'terminals', `${tmuxName}.env`)
3106
- const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\n'
3106
+ // Trailing echo is the readiness signal the post-startup poller
3107
+ // matches against. Don't drop it — without it /allow-all and the
3108
+ // identity/role/broadcast bind wait the full 30s timeout fallback.
3109
+ const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\necho "Environment loaded:"\n'
3107
3110
  fsModule.writeFileSync(envFile, envContent)
3108
3111
 
3109
3112
  // Resolve agent config from storyboard.config.json
@@ -1245,11 +1245,17 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1245
1245
  try { mkdirSync(envScriptDir, { recursive: true }) } catch { /* empty */ }
1246
1246
  const envScriptPath = join(envScriptDir, `${widgetId}.env.sh`)
1247
1247
  try {
1248
- writeFileSync(envScriptPath, envParts.join('\n') + '\n')
1248
+ // Trailing echo is the readiness signal the post-startup poller
1249
+ // looks for. Without it, the 30s timeout fallback fires before
1250
+ // /allow-all, identity, role/broadcast bind are sent — making
1251
+ // the agent feel "stuck" for the first half-minute after launch.
1252
+ writeFileSync(envScriptPath, envParts.join('\n') + '\necho "Environment loaded:"\n')
1249
1253
  } catch { /* empty */ }
1250
- const envSourceCmd = startupCommand
1251
- ? `clear && source ${JSON.stringify(envScriptPath)} && clear`
1252
- : `source ${JSON.stringify(envScriptPath)}`
1254
+ // Source env script; the trailing readiness echo MUST remain on
1255
+ // the pane so the post-startup poller can match it. Don't append
1256
+ // a `clear` here — the welcomeCmd that runs next clears the pane
1257
+ // itself before launching the agent.
1258
+ const envSourceCmd = `source ${JSON.stringify(envScriptPath)}`
1253
1259
 
1254
1260
  setTimeout(() => {
1255
1261
  try {
@@ -21,6 +21,21 @@ import { detectWorktreeName, getPort, releasePort } from '../worktree/port.js'
21
21
  import { startRenameWatcher } from '../rename-watcher/watcher.js'
22
22
  import { compactAll } from '../canvas/compact.js'
23
23
  import { parseFlags } from './flags.js'
24
+ import { setupNeeded, writeUserState, getInstalledStoryboardVersion } from './userState.js'
25
+ import { dim, magenta, bold } from './intro.js'
26
+
27
+ /** Render the storyboard mascot with two info lines beside it. */
28
+ function mascotBanner(line1, line2) {
29
+ const d = dim('·')
30
+ const f = magenta
31
+ const b = dim
32
+ return [
33
+ ` ${b('╭─────────────────╮')}`,
34
+ ` ${b('│')} ${d} ${f('◠')} ${f('◡')} ${f('◠')} ${d} ${b('│')} ${line1}`,
35
+ ` ${b('│')} ${d} ${d} ${d} ${d} ${d} ${b('│')} ${line2}`,
36
+ ` ${b('╰─────────────────╯')}`,
37
+ ].join('\n')
38
+ }
24
39
 
25
40
  const flagSchema = {
26
41
  port: { type: 'number', description: 'Override dev server port' },
@@ -55,6 +70,34 @@ async function main() {
55
70
  p.intro('storyboard dev')
56
71
  p.log.info(`worktree: ${worktreeName}`)
57
72
 
73
+ // Re-run setup automatically if it has never run here, or if the installed
74
+ // @dfosco/storyboard version no longer matches the one setup was last run
75
+ // against. This lets `npm install` upgrades trigger fresh scaffolding
76
+ // without requiring `npx storyboard update`.
77
+ {
78
+ const need = setupNeeded(targetCwd)
79
+ if (need) {
80
+ const why = need.reason === 'first-run'
81
+ ? 'first run in this repo'
82
+ : `version changed ${need.from} → ${need.to}`
83
+ p.log.info(`Running setup (${why})…`)
84
+ await new Promise((resolveSetup) => {
85
+ const setupChild = spawn(
86
+ process.platform === 'win32' ? 'npx.cmd' : 'npx',
87
+ ['storyboard', 'setup', '--skip-branch'],
88
+ { cwd: targetCwd, stdio: 'inherit' }
89
+ )
90
+ setupChild.on('exit', () => resolveSetup())
91
+ setupChild.on('error', () => resolveSetup())
92
+ })
93
+ // Belt-and-suspenders: even if setup failed to write the marker,
94
+ // stamp the current version so dev doesn't loop forever asking to run
95
+ // setup on every boot.
96
+ const version = getInstalledStoryboardVersion(targetCwd)
97
+ if (version) writeUserState({ setupVersion: version, setupRanAt: new Date().toISOString() }, targetCwd)
98
+ }
99
+ }
100
+
58
101
  // Compact bloated canvas JSONL files before booting Vite.
59
102
  const compacted = compactAll(targetCwd)
60
103
  for (const r of compacted) {
@@ -78,15 +121,23 @@ async function main() {
78
121
  const viteArgs = ['vite', '--port', String(port)]
79
122
  if (strictPort) viteArgs.push('--strictPort')
80
123
  if (strictPort) p.log.info(`port ${port} (strict — from storyboard.config.json)`)
124
+
125
+ // Render the storyboard mascot just before Vite takes over stdio. The
126
+ // mascot acts as a visual anchor between our setup output and Vite's
127
+ // own "ready in Xms" banner.
128
+ console.log()
129
+ console.log(mascotBanner(
130
+ bold(`http://localhost:${port}/storyboard/`),
131
+ dim('Stop with Ctrl+C'),
132
+ ))
133
+ console.log()
134
+
81
135
  const child = spawn(npmBin, viteArgs, {
82
136
  cwd: targetCwd,
83
137
  stdio: 'inherit',
84
138
  env: { ...process.env, STORYBOARD_WORKTREE: worktreeName },
85
139
  })
86
140
 
87
- p.log.success(`http://localhost:${port}/storyboard/`)
88
- p.log.info('Stop with Ctrl+C')
89
-
90
141
  function shutdown() {
91
142
  clearInterval(compactInterval)
92
143
  renameWatcher.close()
@@ -15,6 +15,7 @@ import path from 'path'
15
15
  import { execSync, spawn } from 'child_process'
16
16
  import { gettingStartedLines, dim, magenta, bold, yellow, green } from './intro.js'
17
17
  import { parseFlags } from './flags.js'
18
+ import { readUserState, writeUserState, getInstalledStoryboardVersion } from './userState.js'
18
19
 
19
20
  const flagSchema = {
20
21
  'skip-branch': { type: 'boolean', default: false, description: 'Skip the branch prompt at the end' },
@@ -259,38 +260,102 @@ if (isInstalled('code')) {
259
260
  }
260
261
  }
261
262
 
262
- // 6a. Copilot CLI install via the official script (no homebrew dependency).
263
- // curl ships with macOS and all major Linux distros.
264
- // Auth is separate from `gh`: copilot has its own credential store.
263
+ // 6a. Coding agents (Copilot CLI / Claude Code / Codex CLI).
264
+ // On first-time setup, ask the user which agents they want installed.
265
+ // On subsequent runs, only install the previously-opted-in agents that
266
+ // went missing — never re-prompt to avoid being annoying after upgrades.
265
267
  {
266
- let copilotNewlyInstalled = false
267
- if (isInstalled('copilot')) {
268
- p.log.success('Copilot CLI installed')
268
+ const priorState = readUserState()
269
+ const priorAgents = priorState.agents || null
270
+ const firstRun = !priorState.setupVersion
271
+
272
+ const ensureLocalBinOnPath = () => {
273
+ const localBin = `${process.env.HOME}/.local/bin`
274
+ if (!process.env.PATH.includes(localBin)) {
275
+ process.env.PATH = `${localBin}:${process.env.PATH}`
276
+ }
277
+ }
278
+
279
+ const installers = {
280
+ copilot: {
281
+ label: 'Copilot CLI',
282
+ bin: 'copilot',
283
+ install: async () => {
284
+ await runAsync('bash', ['-c', 'curl -fsSL https://gh.io/copilot-install | bash'])
285
+ ensureLocalBinOnPath()
286
+ },
287
+ manualHint: 'curl -fsSL https://gh.io/copilot-install | bash',
288
+ authHint: `Auth is separate from gh — run ${yellow('copilot')} then ${yellow('/login')}`,
289
+ },
290
+ claude: {
291
+ label: 'Claude Code',
292
+ bin: 'claude',
293
+ install: async () => {
294
+ await runAsync('bash', ['-c', 'curl -fsSL https://claude.ai/install.sh | bash'])
295
+ ensureLocalBinOnPath()
296
+ },
297
+ manualHint: 'curl -fsSL https://claude.ai/install.sh | bash',
298
+ authHint: `Run ${yellow('claude')} once to authenticate`,
299
+ },
300
+ codex: {
301
+ label: 'Codex CLI',
302
+ bin: 'codex',
303
+ install: async () => {
304
+ await runAsync('npm', ['install', '-g', '@openai/codex'])
305
+ },
306
+ manualHint: 'npm i -g @openai/codex',
307
+ authHint: `Run ${yellow('codex login')} to authenticate`,
308
+ },
309
+ }
310
+
311
+ let chosen
312
+ if (firstRun) {
313
+ const selection = await p.multiselect({
314
+ message: 'Which coding agents do you want installed?',
315
+ options: [
316
+ { value: 'copilot', label: 'Copilot CLI', hint: 'recommended' },
317
+ { value: 'claude', label: 'Claude Code' },
318
+ { value: 'codex', label: 'Codex CLI' },
319
+ ],
320
+ initialValues: ['copilot'],
321
+ required: false,
322
+ })
323
+ if (p.isCancel(selection)) {
324
+ chosen = { copilot: true, claude: false, codex: false }
325
+ } else {
326
+ chosen = Object.fromEntries(Object.keys(installers).map((k) => [k, selection.includes(k)]))
327
+ }
269
328
  } else {
329
+ // Returning user — install only what they previously opted into and is
330
+ // currently missing. If we have no record (older setup), default to
331
+ // re-installing copilot only when missing.
332
+ chosen = priorAgents || { copilot: true, claude: false, codex: false }
333
+ }
334
+
335
+ for (const [key, agent] of Object.entries(installers)) {
336
+ if (!chosen[key]) continue
337
+ if (isInstalled(agent.bin)) {
338
+ p.log.success(`${agent.label} installed`)
339
+ p.log.info(` ${agent.authHint}`)
340
+ continue
341
+ }
270
342
  const spin = p.spinner()
271
- spin.start('Installing Copilot CLI')
343
+ spin.start(`Installing ${agent.label}`)
272
344
  try {
273
- await runAsync('bash', ['-c', 'curl -fsSL https://gh.io/copilot-install | bash'])
274
- // Install script drops the binary in ~/.local/bin when run as
275
- // non-root. Make sure the current process can find it for the
276
- // remainder of setup, and warn the user to add it to their shell rc.
277
- const localBin = `${process.env.HOME}/.local/bin`
278
- if (!process.env.PATH.includes(localBin)) {
279
- process.env.PATH = `${localBin}:${process.env.PATH}`
280
- }
281
- spin.stop('Copilot CLI installed')
282
- copilotNewlyInstalled = true
283
- if (!isInstalled('copilot')) {
284
- p.log.warning(`copilot not on PATH — add ${yellow('export PATH="$HOME/.local/bin:$PATH"')} to your shell rc`)
345
+ await agent.install()
346
+ spin.stop(`${agent.label} installed`)
347
+ if (!isInstalled(agent.bin)) {
348
+ p.log.warning(`${agent.bin} not on PATH add ${yellow('export PATH="$HOME/.local/bin:$PATH"')} to your shell rc`)
285
349
  }
350
+ p.log.info(` ${agent.authHint}`)
286
351
  } catch {
287
- spin.stop('Failed to install Copilot CLI')
288
- p.log.warning('Install manually: curl -fsSL https://gh.io/copilot-install | bash')
352
+ spin.stop(`Failed to install ${agent.label}`)
353
+ p.log.warning(`Install manually: ${agent.manualHint}`)
289
354
  }
290
355
  }
291
- if (copilotNewlyInstalled || isInstalled('copilot')) {
292
- p.log.info(` Auth is separate from gh run ${yellow('copilot')} then ${yellow('/login')}`)
293
- }
356
+
357
+ // Persist the choice so future setups know what to keep installed.
358
+ writeUserState({ agents: chosen })
294
359
  }
295
360
 
296
361
  // 8. Git hooks
@@ -360,6 +425,8 @@ if (isInstalled('code')) {
360
425
  'src/canvas/~*/',
361
426
  'src/prototypes/~*/',
362
427
  'src/prototypes/**/~*.{flow,object,record,prototype,folder}.json',
428
+ // Per-user local state (setup version marker, agent prefs, future onboarding state)
429
+ '.storyboard/.user.json',
363
430
  ]
364
431
  if (existsSync(gitignorePath)) {
365
432
  try {
@@ -441,6 +508,14 @@ if (isInstalled('code')) {
441
508
  }
442
509
  }
443
510
 
511
+ // 11. Stamp the user-state marker so `npm run dev` knows setup is fresh
512
+ // and won't re-run it until the storyboard package version changes.
513
+ {
514
+ const version = getInstalledStoryboardVersion() || 'unknown'
515
+ writeUserState({ setupVersion: version, setupRanAt: new Date().toISOString() })
516
+ p.log.success(`Setup marker written (.storyboard/.user.json @ ${version})`)
517
+ }
518
+
444
519
  p.note(
445
520
  [
446
521
  ...gettingStartedLines(),
@@ -0,0 +1,63 @@
1
+ /**
2
+ * userState — Per-user local state for the current repo, stored in
3
+ * `.storyboard/.user.json` (gitignored).
4
+ *
5
+ * This is a free-form key/value bag for things that should persist across
6
+ * runs but never be committed:
7
+ * - setupVersion (string) — @dfosco/storyboard version setup was last
8
+ * run against. Compared against the installed version on `npm run dev`
9
+ * to decide whether scaffolding needs to re-run.
10
+ * - setupRanAt (ISO date) — last setup timestamp.
11
+ * - agents (object) — per-agent opt-in flags from the
12
+ * first-run install prompt (copilot/claude/codex booleans).
13
+ * - … future: onboarded, username, lastSeenChangelog, etc.
14
+ */
15
+
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
17
+ import { resolve, dirname } from 'node:path'
18
+
19
+ const FILE = '.storyboard/.user.json'
20
+
21
+ export function userStatePath(cwd = process.cwd()) {
22
+ return resolve(cwd, FILE)
23
+ }
24
+
25
+ export function readUserState(cwd = process.cwd()) {
26
+ const file = userStatePath(cwd)
27
+ if (!existsSync(file)) return {}
28
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return {} }
29
+ }
30
+
31
+ export function writeUserState(patch, cwd = process.cwd()) {
32
+ const file = userStatePath(cwd)
33
+ const dir = dirname(file)
34
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
35
+ const next = { ...readUserState(cwd), ...patch }
36
+ writeFileSync(file, JSON.stringify(next, null, 2) + '\n', 'utf8')
37
+ return next
38
+ }
39
+
40
+ /**
41
+ * Read the installed @dfosco/storyboard package version, or null if not
42
+ * resolvable (e.g. running from a worktree without node_modules).
43
+ */
44
+ export function getInstalledStoryboardVersion(cwd = process.cwd()) {
45
+ try {
46
+ const pkgPath = resolve(cwd, 'node_modules', '@dfosco', 'storyboard', 'package.json')
47
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version || null
48
+ } catch { return null }
49
+ }
50
+
51
+ /**
52
+ * Decide whether `storyboard setup` should run before `dev`.
53
+ * Returns null when up-to-date, or { reason, ... } otherwise.
54
+ */
55
+ export function setupNeeded(cwd = process.cwd()) {
56
+ const state = readUserState(cwd)
57
+ const current = getInstalledStoryboardVersion(cwd)
58
+ if (!state.setupVersion) return { reason: 'first-run', current }
59
+ if (current && state.setupVersion !== current) {
60
+ return { reason: 'version-changed', from: state.setupVersion, to: current }
61
+ }
62
+ return null
63
+ }