@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 +1 -1
- package/src/core/canvas/server.js +4 -1
- package/src/core/canvas/terminal-server.js +10 -4
- package/src/core/cli/dev.js +54 -3
- package/src/core/cli/setup.js +99 -24
- package/src/core/cli/userState.js +63 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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 {
|
package/src/core/cli/dev.js
CHANGED
|
@@ -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()
|
package/src/core/cli/setup.js
CHANGED
|
@@ -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
|
|
263
|
-
//
|
|
264
|
-
//
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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(
|
|
343
|
+
spin.start(`Installing ${agent.label}`)
|
|
272
344
|
try {
|
|
273
|
-
await
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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(
|
|
288
|
-
p.log.warning(
|
|
352
|
+
spin.stop(`Failed to install ${agent.label}`)
|
|
353
|
+
p.log.warning(`Install manually: ${agent.manualHint}`)
|
|
289
354
|
}
|
|
290
355
|
}
|
|
291
|
-
|
|
292
|
-
|
|
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
|
+
}
|