@dfosco/storyboard-core 4.2.0-alpha.6 → 4.2.0-alpha.7
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/agents/terminal-agent.agent.md +144 -0
- package/src/canvas/terminal-config.js +13 -5
- package/src/canvas/terminal-server.js +25 -5
- package/src/cli/exit.js +23 -24
- package/src/cli/index.js +4 -0
- package/src/cli/proxy.js +9 -0
- package/src/cli/server.js +147 -25
- package/src/cli/setup.js +25 -1
- package/src/server/index.js +6 -1
- package/src/worktree/serverRegistry.js +120 -0
package/package.json
CHANGED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: terminal-agent
|
|
3
|
+
description: "Canvas-aware terminal agent that reads connected widget context and signals completion via the storyboard API."
|
|
4
|
+
tools:
|
|
5
|
+
- read
|
|
6
|
+
- edit
|
|
7
|
+
- shell
|
|
8
|
+
- search
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Terminal Agent Context
|
|
12
|
+
|
|
13
|
+
Before processing ANY user prompt, read the terminal config file for this session.
|
|
14
|
+
|
|
15
|
+
## Step 1: Read terminal config
|
|
16
|
+
|
|
17
|
+
Your widget ID is available via `$STORYBOARD_WIDGET_ID`. Use it to read your config directly:
|
|
18
|
+
```bash
|
|
19
|
+
cat .storyboard/terminals/${STORYBOARD_WIDGET_ID}.json
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
If the env var is empty, source it from the terminal env file first:
|
|
23
|
+
```bash
|
|
24
|
+
# Find the env file for this tmux session
|
|
25
|
+
ENV_FILE=$(ls -t .storyboard/terminals/*.env 2>/dev/null | head -1)
|
|
26
|
+
if [ -n "$ENV_FILE" ]; then source "$ENV_FILE"; fi
|
|
27
|
+
cat .storyboard/terminals/${STORYBOARD_WIDGET_ID}.json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
As a last resort, list all configs and pick the most recent non-deleted one with `connectedWidgets`:
|
|
31
|
+
```bash
|
|
32
|
+
cat .storyboard/terminals/*.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The config file contains everything you need — no additional API calls required:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"widgetId": "terminal-abc123",
|
|
40
|
+
"canvasId": "storyboarding/my-canvas",
|
|
41
|
+
"branch": "4.2.0--terminal-agents",
|
|
42
|
+
"worktree": "4.2.0--terminal-agents",
|
|
43
|
+
"devDomain": "storyboard-core",
|
|
44
|
+
"serverUrl": "http://localhost:1269",
|
|
45
|
+
"workingDirectory": "/path/to/worktree",
|
|
46
|
+
"connectedWidgets": [
|
|
47
|
+
{
|
|
48
|
+
"id": "sticky-def456",
|
|
49
|
+
"type": "sticky-note",
|
|
50
|
+
"props": { "text": "Build a login form", "color": "yellow" }
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "markdown-ghi789",
|
|
54
|
+
"type": "markdown",
|
|
55
|
+
"props": { "content": "# Requirements\n- Email + password\n- OAuth support" }
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Step 2: Use connected widget context
|
|
62
|
+
|
|
63
|
+
The `connectedWidgets` array contains the FULL props of every widget connected to this terminal. This is your highest priority context:
|
|
64
|
+
|
|
65
|
+
- **sticky-note**: `props.text` — instructions, notes, or requirements
|
|
66
|
+
- **markdown**: `props.content` — documentation, specs, or prose
|
|
67
|
+
- **image**: `props.src` — image filename at `assets/canvas/images/{props.src}`
|
|
68
|
+
- **story**: `props.storyId` + `props.exportName` — component to work with
|
|
69
|
+
- **link-preview**: `props.url` — external reference
|
|
70
|
+
- **prototype**: `props.src` — prototype path
|
|
71
|
+
|
|
72
|
+
Interpret the user's prompt in light of these connected widgets.
|
|
73
|
+
|
|
74
|
+
## Step 3: Prefer CLI commands for canvas operations
|
|
75
|
+
|
|
76
|
+
**Always prefer `npx storyboard` CLI commands over HTTP API calls.** CLI commands run directly in the worktree and resolve the dev server automatically — no port numbers or URLs needed.
|
|
77
|
+
|
|
78
|
+
### Reading canvas state
|
|
79
|
+
```bash
|
|
80
|
+
npx storyboard canvas read <canvas-name> --json
|
|
81
|
+
npx storyboard canvas read <canvas-name> --id <widget-id> --json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Updating a widget
|
|
85
|
+
```bash
|
|
86
|
+
# Update text on a sticky note
|
|
87
|
+
npx storyboard canvas update <widget-id> --canvas <canvas-name> --text "New text"
|
|
88
|
+
|
|
89
|
+
# Update markdown content
|
|
90
|
+
npx storyboard canvas update <widget-id> --canvas <canvas-name> --content "# New heading"
|
|
91
|
+
|
|
92
|
+
# Update arbitrary props
|
|
93
|
+
npx storyboard canvas update <widget-id> --canvas <canvas-name> --props '{"key":"value"}'
|
|
94
|
+
|
|
95
|
+
# Move a widget
|
|
96
|
+
npx storyboard canvas update <widget-id> --canvas <canvas-name> --x 100 --y 200
|
|
97
|
+
|
|
98
|
+
# Shorthand flags: --text, --content, --src, --url, --color
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Adding a widget
|
|
102
|
+
```bash
|
|
103
|
+
npx storyboard canvas add sticky-note --canvas <canvas-name> --props '{"text":"Hello"}'
|
|
104
|
+
npx storyboard canvas add markdown --canvas <canvas-name> --x 100 --y 200
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Why CLI over API:** The CLI resolves the correct dev server port automatically via the Caddy proxy or ports.json. You never need to know the port number. All commands work from any worktree directory.
|
|
108
|
+
|
|
109
|
+
## Step 4: Signal completion
|
|
110
|
+
|
|
111
|
+
When your task is complete:
|
|
112
|
+
```bash
|
|
113
|
+
npx storyboard agent signal --status done --message "Brief summary"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
On failure:
|
|
117
|
+
```bash
|
|
118
|
+
npx storyboard agent signal --status error --message "What went wrong"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**IMPORTANT:**
|
|
122
|
+
- NEVER write directly to `.canvas.jsonl` files — use the canvas CLI or server API
|
|
123
|
+
- **Prefer CLI commands** (`npx storyboard canvas ...`) over direct HTTP calls — they resolve ports automatically
|
|
124
|
+
- Only fall back to HTTP API (`{serverUrl}/_storyboard/canvas/`) if the CLI doesn't support the operation
|
|
125
|
+
- Environment variables `$STORYBOARD_WIDGET_ID`, `$STORYBOARD_CANVAS_ID`, `$STORYBOARD_BRANCH`, `$STORYBOARD_SERVER_URL` are also available in the shell
|
|
126
|
+
|
|
127
|
+
## HTTP API Reference (fallback only)
|
|
128
|
+
|
|
129
|
+
If the CLI fails, use these endpoints. The `serverUrl` is in your terminal config or `$STORYBOARD_SERVER_URL`.
|
|
130
|
+
|
|
131
|
+
### Safe: Update a single widget (PATCH)
|
|
132
|
+
```bash
|
|
133
|
+
curl -s -X PATCH "${STORYBOARD_SERVER_URL}/_storyboard/canvas/widget" \
|
|
134
|
+
-H "Content-Type: application/json" \
|
|
135
|
+
-d '{"name":"<canvasId>","widgetId":"<widgetId>","props":{"text":"new value"}}'
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Safe: Read canvas state (GET)
|
|
139
|
+
```bash
|
|
140
|
+
curl -s "${STORYBOARD_SERVER_URL}/_storyboard/canvas/<canvasId>"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### ⚠️ NEVER use `PUT /_storyboard/canvas/update` with a `widgets` array
|
|
144
|
+
That endpoint **replaces ALL widgets** in the canvas. Sending one widget = deleting everything else.
|
|
@@ -15,7 +15,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, symlink
|
|
|
15
15
|
import { join, dirname } from 'node:path'
|
|
16
16
|
import { createHash } from 'node:crypto'
|
|
17
17
|
import { execSync } from 'node:child_process'
|
|
18
|
-
import {
|
|
18
|
+
import { findByWorktree } from '../worktree/serverRegistry.js'
|
|
19
|
+
import { detectWorktreeName } from '../worktree/port.js'
|
|
19
20
|
|
|
20
21
|
const TERMINALS_DIR = '.storyboard/terminals'
|
|
21
22
|
|
|
@@ -70,7 +71,7 @@ function atomicWrite(filePath, data) {
|
|
|
70
71
|
* Write or update a terminal config file.
|
|
71
72
|
* Called when a terminal widget is created or reconnected.
|
|
72
73
|
*/
|
|
73
|
-
export function writeTerminalConfig({ branch, canvasId, widgetId, canvasFile = null }) {
|
|
74
|
+
export function writeTerminalConfig({ branch, canvasId, widgetId, canvasFile = null, serverUrl = null }) {
|
|
74
75
|
const fp = configPath(branch, canvasId, widgetId)
|
|
75
76
|
const dir = dirname(fp)
|
|
76
77
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
@@ -82,9 +83,16 @@ export function writeTerminalConfig({ branch, canvasId, widgetId, canvasFile = n
|
|
|
82
83
|
|
|
83
84
|
const worktree = getWorktreeName()
|
|
84
85
|
const devDomain = readDevDomain()
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
|
|
87
|
+
// Resolve server URL: use passed value, or query server registry, or default
|
|
88
|
+
if (!serverUrl) {
|
|
89
|
+
try {
|
|
90
|
+
const name = detectWorktreeName()
|
|
91
|
+
const servers = findByWorktree(name)
|
|
92
|
+
if (servers.length > 0) serverUrl = `http://localhost:${servers[0].port}`
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
if (!serverUrl) serverUrl = 'http://localhost:1234'
|
|
88
96
|
|
|
89
97
|
const config = {
|
|
90
98
|
...existing,
|
|
@@ -46,7 +46,8 @@ import {
|
|
|
46
46
|
writeTerminalConfig as writeTermConfig,
|
|
47
47
|
initTerminalConfig,
|
|
48
48
|
} from './terminal-config.js'
|
|
49
|
-
import {
|
|
49
|
+
import { findByWorktree } from '../worktree/serverRegistry.js'
|
|
50
|
+
import { detectWorktreeName } from '../worktree/port.js'
|
|
50
51
|
|
|
51
52
|
let pty
|
|
52
53
|
try {
|
|
@@ -86,6 +87,9 @@ const wsConnections = new Map()
|
|
|
86
87
|
/** Branch name for this worktree, set during setup */
|
|
87
88
|
let currentBranch = 'unknown'
|
|
88
89
|
|
|
90
|
+
/** Actual server port, resolved from httpServer at setup time */
|
|
91
|
+
let actualServerPort = null
|
|
92
|
+
|
|
89
93
|
/** Check if a tmux session with the given name exists */
|
|
90
94
|
function tmuxSessionExists(name) {
|
|
91
95
|
try {
|
|
@@ -151,6 +155,12 @@ export function setupTerminalServer(httpServer, base = '/', branch = 'unknown')
|
|
|
151
155
|
|
|
152
156
|
currentBranch = branch
|
|
153
157
|
|
|
158
|
+
// Capture the actual port from the running HTTP server
|
|
159
|
+
try {
|
|
160
|
+
const addr = httpServer.address()
|
|
161
|
+
if (addr && addr.port) actualServerPort = addr.port
|
|
162
|
+
} catch {}
|
|
163
|
+
|
|
154
164
|
// Initialize registry and terminal config
|
|
155
165
|
const root = process.cwd()
|
|
156
166
|
const termCfg = readTerminalConfig()
|
|
@@ -197,7 +207,7 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
|
|
|
197
207
|
const { entry, conflict } = registerSession({ branch, canvasId, widgetId, prettyName })
|
|
198
208
|
|
|
199
209
|
// Write terminal config for agent context
|
|
200
|
-
writeTermConfig({ branch, canvasId, widgetId })
|
|
210
|
+
writeTermConfig({ branch, canvasId, widgetId, serverUrl })
|
|
201
211
|
|
|
202
212
|
// Close any existing WS for this session (one viewer at a time)
|
|
203
213
|
const existingWs = wsConnections.get(tmuxName)
|
|
@@ -226,9 +236,19 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
|
|
|
226
236
|
writeFileSync(join(zdotdir, '.zshrc'), `export PS1='${prompt.replace(/'/g, "'\\''")}'\nunset RPS1\n`)
|
|
227
237
|
} catch { /* best effort */ }
|
|
228
238
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
239
|
+
// Resolve server URL deterministically:
|
|
240
|
+
// 1. Use the actual port from httpServer (set at setup time)
|
|
241
|
+
// 2. Fall back to server registry (tracks running dev servers)
|
|
242
|
+
// 3. Last resort: default port 1234
|
|
243
|
+
let serverPort = actualServerPort
|
|
244
|
+
if (!serverPort) {
|
|
245
|
+
try {
|
|
246
|
+
const name = detectWorktreeName()
|
|
247
|
+
const servers = findByWorktree(name)
|
|
248
|
+
if (servers.length > 0) serverPort = servers[0].port
|
|
249
|
+
} catch {}
|
|
250
|
+
}
|
|
251
|
+
if (!serverPort) serverPort = 1234
|
|
232
252
|
const serverUrl = `http://localhost:${serverPort}`
|
|
233
253
|
|
|
234
254
|
const env = {
|
package/src/cli/exit.js
CHANGED
|
@@ -1,38 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* storyboard exit — Stop
|
|
2
|
+
* storyboard exit — Stop all running dev servers and the Caddy proxy.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as p from '@clack/prompts'
|
|
6
|
-
import {
|
|
6
|
+
import { list, unregister } from '../worktree/serverRegistry.js'
|
|
7
|
+
import { isCaddyRunning, stopCaddy } from './proxy.js'
|
|
7
8
|
|
|
8
9
|
p.intro('storyboard exit')
|
|
9
10
|
|
|
10
|
-
// 1. Stop all
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
for (const pid of vitePids) {
|
|
21
|
-
try { process.kill(Number(pid), 'SIGTERM') } catch { /* already dead */ }
|
|
22
|
-
}
|
|
23
|
-
p.log.success(`Stopped ${vitePids.length} dev server${vitePids.length > 1 ? 's' : ''}`)
|
|
24
|
-
} else {
|
|
25
|
-
p.log.info('No dev servers running')
|
|
11
|
+
// 1. Stop all registered dev servers
|
|
12
|
+
const servers = list()
|
|
13
|
+
if (servers.length > 0) {
|
|
14
|
+
let stopped = 0
|
|
15
|
+
for (const s of servers) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(s.pid, 'SIGTERM')
|
|
18
|
+
stopped++
|
|
19
|
+
} catch { /* already dead */ }
|
|
20
|
+
unregister(s.id)
|
|
26
21
|
}
|
|
27
|
-
}
|
|
22
|
+
p.log.success(`Stopped ${stopped} dev server${stopped !== 1 ? 's' : ''}`)
|
|
23
|
+
} else {
|
|
28
24
|
p.log.info('No dev servers running')
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
// 2. Stop Caddy proxy
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
27
|
+
// 2. Stop Caddy proxy via admin API (no sudo needed)
|
|
28
|
+
if (isCaddyRunning()) {
|
|
29
|
+
if (stopCaddy()) {
|
|
30
|
+
p.log.success('Proxy stopped')
|
|
31
|
+
} else {
|
|
32
|
+
p.log.warning('Failed to stop proxy via admin API')
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
36
35
|
p.log.info('Proxy was not running')
|
|
37
36
|
}
|
|
38
37
|
|
package/src/cli/index.js
CHANGED
|
@@ -53,6 +53,10 @@ function helpScreen(version) {
|
|
|
53
53
|
` ${bold(cyan('Development'))}`,
|
|
54
54
|
cmd('dev', 'Start Vite dev server + update proxy'),
|
|
55
55
|
cmd('dev [branch]', 'Start dev for a specific worktree/branch'),
|
|
56
|
+
cmd('server list', 'List running dev servers'),
|
|
57
|
+
cmd('server start [wt]', 'Start dev server for a worktree'),
|
|
58
|
+
cmd('server stop <wt|ID>', 'Stop a dev server'),
|
|
59
|
+
cmd('server stop-proxy', 'Stop Caddy proxy (no sudo)'),
|
|
56
60
|
cmd('code [branch]', 'Open a worktree in VS Code'),
|
|
57
61
|
cmd('exit', 'Stop all dev servers and proxy'),
|
|
58
62
|
'',
|
package/src/cli/proxy.js
CHANGED
|
@@ -222,6 +222,15 @@ export function startCaddy(caddyfilePath) {
|
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
export function stopCaddy() {
|
|
226
|
+
try {
|
|
227
|
+
execSync('curl -sf -X POST http://localhost:2019/stop', { timeout: 5000, stdio: 'ignore' })
|
|
228
|
+
return true
|
|
229
|
+
} catch {
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
225
234
|
// When run directly as `storyboard proxy` (not imported by setup.js)
|
|
226
235
|
const isDirectRun = process.argv[2] === 'proxy'
|
|
227
236
|
if (isDirectRun) {
|
package/src/cli/server.js
CHANGED
|
@@ -1,35 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* storyboard server
|
|
3
|
-
*
|
|
4
|
-
* Manages Vite child processes and serves the /_storyboard/ API.
|
|
5
|
-
* If a branch is given, also starts Vite for that branch immediately.
|
|
2
|
+
* storyboard server — Dev server lifecycle management.
|
|
6
3
|
*
|
|
7
4
|
* Usage:
|
|
8
|
-
* storyboard server
|
|
9
|
-
* storyboard server
|
|
10
|
-
* storyboard server
|
|
5
|
+
* storyboard server List running dev servers
|
|
6
|
+
* storyboard server list List running dev servers
|
|
7
|
+
* storyboard server start [wt] Start the persistent server + Vite for a worktree
|
|
8
|
+
* storyboard server stop <id> Stop a dev server by worktree name or ID
|
|
9
|
+
* storyboard server stop-proxy Stop the Caddy proxy (no sudo required)
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
import * as p from '@clack/prompts'
|
|
14
13
|
import { startServer, SERVER_PORT, spawnViteForBranch } from '../server/index.js'
|
|
15
14
|
import { parseFlags } from './flags.js'
|
|
16
|
-
import { readDevDomain, generateCaddyfile, generateRouteConfig, upsertCaddyRoute, isCaddyRunning } from './proxy.js'
|
|
17
|
-
import { detectWorktreeName, getPort } from '../worktree/port.js'
|
|
15
|
+
import { readDevDomain, generateCaddyfile, generateRouteConfig, upsertCaddyRoute, isCaddyRunning, stopCaddy } from './proxy.js'
|
|
16
|
+
import { detectWorktreeName, getPort, repoRoot } from '../worktree/port.js'
|
|
17
|
+
import {
|
|
18
|
+
list,
|
|
19
|
+
findByWorktree,
|
|
20
|
+
findById,
|
|
21
|
+
unregister,
|
|
22
|
+
prune,
|
|
23
|
+
} from '../worktree/serverRegistry.js'
|
|
18
24
|
|
|
19
25
|
const flagSchema = {
|
|
20
26
|
port: { type: 'number', description: 'Server port (default: 4100)' },
|
|
27
|
+
background: { type: 'boolean', default: false, description: 'Run in background (--bg alias)' },
|
|
28
|
+
bg: { type: 'boolean', default: false, description: 'Run in background' },
|
|
29
|
+
multiple: { type: 'boolean', default: false, description: 'Allow multiple servers per worktree' },
|
|
21
30
|
}
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
const { flags, positional } = parseFlags(process.argv.slice(3), flagSchema)
|
|
25
|
-
const port = flags.port || SERVER_PORT
|
|
26
|
-
const branchArg = positional[0] || undefined
|
|
32
|
+
// ─── Commands ────────────────────────────────────────────
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
function serverList() {
|
|
35
|
+
const servers = list()
|
|
36
|
+
if (servers.length === 0) {
|
|
37
|
+
p.log.info('No dev servers running.')
|
|
38
|
+
return
|
|
39
|
+
}
|
|
29
40
|
|
|
30
41
|
const devDomain = readDevDomain()
|
|
31
42
|
|
|
32
|
-
|
|
43
|
+
p.log.info('Running dev servers:\n')
|
|
44
|
+
for (const s of servers) {
|
|
45
|
+
const isMain = s.worktree === 'main'
|
|
46
|
+
const basePath = isMain ? '/' : `/branch--${s.worktree}/`
|
|
47
|
+
const proxyUrl = `http://${devDomain}${basePath}`
|
|
48
|
+
const bg = s.background ? ' (bg)' : ''
|
|
49
|
+
console.log(` ${s.id} ${s.worktree.padEnd(32)} :${String(s.port).padEnd(5)} PID ${String(s.pid).padEnd(7)} ${proxyUrl}${bg}`)
|
|
50
|
+
}
|
|
51
|
+
console.log()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function serverStart(branchArg, flags) {
|
|
55
|
+
const { background, bg, multiple } = flags
|
|
56
|
+
const isBackground = background || bg
|
|
57
|
+
const worktreeName = branchArg || detectWorktreeName()
|
|
58
|
+
|
|
59
|
+
// Check for duplicate worktree servers
|
|
60
|
+
if (!multiple) {
|
|
61
|
+
const existing = findByWorktree(worktreeName)
|
|
62
|
+
if (existing.length > 0) {
|
|
63
|
+
p.log.error(`A dev server is already running for "${worktreeName}" (ID: ${existing[0].id}, PID: ${existing[0].pid}).`)
|
|
64
|
+
p.log.info(`To stop it: npx storyboard server stop ${existing[0].id}`)
|
|
65
|
+
p.log.info(`To allow multiple: npx storyboard server start ${worktreeName} --multiple`)
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const devDomain = readDevDomain()
|
|
71
|
+
const port = flags.port || SERVER_PORT
|
|
72
|
+
|
|
73
|
+
// Register server itself with Caddy
|
|
33
74
|
try {
|
|
34
75
|
const serverRoute = generateRouteConfig({ __server__: port })
|
|
35
76
|
if (isCaddyRunning()) {
|
|
@@ -39,31 +80,112 @@ async function main() {
|
|
|
39
80
|
|
|
40
81
|
const server = startServer(port)
|
|
41
82
|
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const isMain = initialBranch === 'main'
|
|
45
|
-
const basePath = isMain ? '/' : `/branch--${initialBranch}/`
|
|
83
|
+
const isMain = worktreeName === 'main'
|
|
84
|
+
const basePath = isMain ? '/' : `/branch--${worktreeName}/`
|
|
46
85
|
const proxyUrl = `http://${devDomain}${basePath}`
|
|
47
86
|
|
|
48
|
-
p.log.step(`Starting dev session for ${
|
|
87
|
+
p.log.step(`Starting dev session for ${worktreeName}...`)
|
|
49
88
|
|
|
50
89
|
try {
|
|
51
|
-
const entry = spawnViteForBranch(
|
|
52
|
-
|
|
53
|
-
// Wait for ready
|
|
90
|
+
const entry = spawnViteForBranch(worktreeName)
|
|
54
91
|
const { waitForPort } = await import('../server/index.js')
|
|
55
92
|
const ready = await waitForPort(entry.port)
|
|
56
93
|
|
|
57
94
|
if (ready) {
|
|
58
|
-
p.log.success(proxyUrl)
|
|
95
|
+
p.log.success(`[${entry.serverId}] ${proxyUrl}`)
|
|
59
96
|
} else {
|
|
60
97
|
p.log.warning(`Vite started but may not be ready yet — check ${proxyUrl}`)
|
|
61
98
|
}
|
|
62
99
|
} catch (err) {
|
|
63
|
-
p.log.error(`Failed to start dev for ${
|
|
100
|
+
p.log.error(`Failed to start dev for ${worktreeName}: ${err.message}`)
|
|
64
101
|
}
|
|
65
102
|
|
|
66
103
|
p.outro('Server running')
|
|
67
104
|
}
|
|
68
105
|
|
|
106
|
+
function serverStop(target) {
|
|
107
|
+
if (!target) {
|
|
108
|
+
p.log.error('Usage: storyboard server stop <worktree|ID>')
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Try by ID first
|
|
113
|
+
let entry = findById(target)
|
|
114
|
+
|
|
115
|
+
// Then by worktree name
|
|
116
|
+
if (!entry) {
|
|
117
|
+
const matches = findByWorktree(target)
|
|
118
|
+
if (matches.length === 0) {
|
|
119
|
+
p.log.error(`No server found for "${target}".`)
|
|
120
|
+
p.log.info('Run `npx storyboard server list` to see running servers.')
|
|
121
|
+
process.exit(1)
|
|
122
|
+
}
|
|
123
|
+
if (matches.length > 1) {
|
|
124
|
+
p.log.error(`Multiple servers running for worktree "${target}":`)
|
|
125
|
+
for (const s of matches) {
|
|
126
|
+
console.log(` ${s.id} PID: ${s.pid} Port: ${s.port}`)
|
|
127
|
+
}
|
|
128
|
+
p.log.info('Specify an ID: npx storyboard server stop <ID>')
|
|
129
|
+
process.exit(1)
|
|
130
|
+
}
|
|
131
|
+
entry = matches[0]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
process.kill(entry.pid, 'SIGTERM')
|
|
136
|
+
p.log.success(`Stopped server ${entry.id} (PID: ${entry.pid}, worktree: "${entry.worktree}")`)
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err.code === 'ESRCH') {
|
|
139
|
+
p.log.info(`Server ${entry.id} (PID: ${entry.pid}) was already dead.`)
|
|
140
|
+
} else {
|
|
141
|
+
p.log.error(`Failed to kill PID ${entry.pid}: ${err.message}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
unregister(entry.id)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function serverStopProxy() {
|
|
149
|
+
if (!isCaddyRunning()) {
|
|
150
|
+
p.log.info('Caddy proxy is not running.')
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (stopCaddy()) {
|
|
155
|
+
p.log.success('Caddy proxy stopped.')
|
|
156
|
+
} else {
|
|
157
|
+
p.log.error('Failed to stop Caddy proxy.')
|
|
158
|
+
process.exit(1)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Dispatch ────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
async function main() {
|
|
165
|
+
const { flags, positional } = parseFlags(process.argv.slice(3), flagSchema)
|
|
166
|
+
const subcommand = positional[0]
|
|
167
|
+
|
|
168
|
+
p.intro('storyboard server')
|
|
169
|
+
|
|
170
|
+
switch (subcommand) {
|
|
171
|
+
case undefined:
|
|
172
|
+
case 'list':
|
|
173
|
+
serverList()
|
|
174
|
+
break
|
|
175
|
+
case 'start':
|
|
176
|
+
await serverStart(positional[1], flags)
|
|
177
|
+
break
|
|
178
|
+
case 'stop':
|
|
179
|
+
serverStop(positional[1])
|
|
180
|
+
break
|
|
181
|
+
case 'stop-proxy':
|
|
182
|
+
serverStopProxy()
|
|
183
|
+
break
|
|
184
|
+
default:
|
|
185
|
+
// Legacy behavior: treat argument as branch name (storyboard server <branch>)
|
|
186
|
+
await serverStart(subcommand, flags)
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
69
191
|
main()
|
package/src/cli/setup.js
CHANGED
|
@@ -188,7 +188,7 @@ if (isInstalled('code')) {
|
|
|
188
188
|
|
|
189
189
|
// 7. Asset directories
|
|
190
190
|
{
|
|
191
|
-
const dirs = ['assets/canvas/images', '.storyboard']
|
|
191
|
+
const dirs = ['assets/canvas/images', '.storyboard', '.storyboard/terminals']
|
|
192
192
|
for (const dir of dirs) {
|
|
193
193
|
if (!existsSync(dir)) {
|
|
194
194
|
try { mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
|
|
@@ -211,6 +211,30 @@ if (isInstalled('code')) {
|
|
|
211
211
|
p.log.success('Canvas asset directories ready')
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
// 7b. Copilot agents
|
|
215
|
+
{
|
|
216
|
+
const agentsDir = '.github/agents'
|
|
217
|
+
if (!existsSync(agentsDir)) {
|
|
218
|
+
try { mkdirSync(agentsDir, { recursive: true }) } catch { /* ignore */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const scaffoldAgents = path.resolve(import.meta.dirname, '..', '..', 'scaffold', 'agents')
|
|
222
|
+
if (existsSync(scaffoldAgents)) {
|
|
223
|
+
try {
|
|
224
|
+
const agentFiles = execSync(`ls "${scaffoldAgents}"`, { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
|
|
225
|
+
for (const file of agentFiles) {
|
|
226
|
+
const dest = path.join(agentsDir, file)
|
|
227
|
+
if (!existsSync(dest)) {
|
|
228
|
+
run(`cp "${path.join(scaffoldAgents, file)}" "${dest}"`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (agentFiles.length > 0) {
|
|
232
|
+
p.log.success('Copilot agents scaffolded (.github/agents/)')
|
|
233
|
+
}
|
|
234
|
+
} catch { /* ignore */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
214
238
|
// 8. Proxy
|
|
215
239
|
if (isCaddyInstalled()) {
|
|
216
240
|
const proxySpin = p.spinner()
|
package/src/server/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { resolve, join, dirname } from 'node:path'
|
|
|
19
19
|
import { getPort, portsFilePath, repoRoot, worktreeDir, listWorktrees } from '../worktree/port.js'
|
|
20
20
|
import { generateCaddyfile, generateRouteConfig, upsertCaddyRoute, isCaddyRunning, reloadCaddy, readDevDomain } from '../cli/proxy.js'
|
|
21
21
|
import { compactAll } from '../canvas/compact.js'
|
|
22
|
+
import { register, unregister, generateId } from '../worktree/serverRegistry.js'
|
|
22
23
|
|
|
23
24
|
const SERVER_PORT_BASE = 4100
|
|
24
25
|
|
|
@@ -117,9 +118,12 @@ function spawnVite(branch) {
|
|
|
117
118
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
118
119
|
})
|
|
119
120
|
|
|
120
|
-
const entry = { child, port, status: 'starting', cwd, branch }
|
|
121
|
+
const entry = { child, port, status: 'starting', cwd, branch, serverId: generateId() }
|
|
121
122
|
processes.set(branch, entry)
|
|
122
123
|
|
|
124
|
+
// Register in persistent server registry
|
|
125
|
+
register({ id: entry.serverId, worktree: branch, pid: child.pid, port, background: true })
|
|
126
|
+
|
|
123
127
|
// Detect ready state from stdout
|
|
124
128
|
child.stdout.on('data', (data) => {
|
|
125
129
|
const text = data.toString()
|
|
@@ -138,6 +142,7 @@ function spawnVite(branch) {
|
|
|
138
142
|
child.on('exit', (code) => {
|
|
139
143
|
entry.status = 'stopped'
|
|
140
144
|
processes.delete(branch)
|
|
145
|
+
unregister(entry.serverId)
|
|
141
146
|
})
|
|
142
147
|
|
|
143
148
|
return entry
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Registry — tracks running dev servers in .storyboard/servers.json.
|
|
3
|
+
*
|
|
4
|
+
* Each server gets a unique hex ID. The registry is pruned on every read
|
|
5
|
+
* to remove entries whose PIDs are no longer alive.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from 'crypto'
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'fs'
|
|
10
|
+
import { join, dirname } from 'path'
|
|
11
|
+
import { repoRoot } from './port.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Absolute path to .storyboard/servers.json.
|
|
15
|
+
*/
|
|
16
|
+
export function registryPath(cwd) {
|
|
17
|
+
return join(repoRoot(cwd), '.storyboard', 'servers.json')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate a short unique hex ID (6 chars).
|
|
22
|
+
*/
|
|
23
|
+
export function generateId() {
|
|
24
|
+
return randomBytes(3).toString('hex')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check whether a PID is alive.
|
|
29
|
+
*/
|
|
30
|
+
function isAlive(pid) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0)
|
|
33
|
+
return true
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read the registry file. Returns an object keyed by server ID.
|
|
41
|
+
*/
|
|
42
|
+
function readRegistry(cwd) {
|
|
43
|
+
const file = registryPath(cwd)
|
|
44
|
+
if (!existsSync(file)) return {}
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(readFileSync(file, 'utf8'))
|
|
47
|
+
return data.servers || {}
|
|
48
|
+
} catch {
|
|
49
|
+
return {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write the registry atomically (write tmp then rename).
|
|
55
|
+
*/
|
|
56
|
+
function writeRegistry(servers, cwd) {
|
|
57
|
+
const file = registryPath(cwd)
|
|
58
|
+
const dir = dirname(file)
|
|
59
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
60
|
+
const tmp = file + '.tmp'
|
|
61
|
+
writeFileSync(tmp, JSON.stringify({ servers }, null, 2) + '\n')
|
|
62
|
+
renameSync(tmp, file)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove entries whose PIDs are dead.
|
|
67
|
+
* @returns {object} pruned servers map
|
|
68
|
+
*/
|
|
69
|
+
export function prune(cwd) {
|
|
70
|
+
const servers = readRegistry(cwd)
|
|
71
|
+
const alive = {}
|
|
72
|
+
for (const [id, entry] of Object.entries(servers)) {
|
|
73
|
+
if (isAlive(entry.pid)) {
|
|
74
|
+
alive[id] = entry
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
writeRegistry(alive, cwd)
|
|
78
|
+
return alive
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Register a new server entry.
|
|
83
|
+
*/
|
|
84
|
+
export function register({ id, worktree, pid, port, background = false }, cwd) {
|
|
85
|
+
const servers = prune(cwd)
|
|
86
|
+
servers[id] = { id, worktree, pid, port, background, startedAt: new Date().toISOString() }
|
|
87
|
+
writeRegistry(servers, cwd)
|
|
88
|
+
return servers[id]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Unregister a server by ID.
|
|
93
|
+
*/
|
|
94
|
+
export function unregister(id, cwd) {
|
|
95
|
+
const servers = readRegistry(cwd)
|
|
96
|
+
delete servers[id]
|
|
97
|
+
writeRegistry(servers, cwd)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* List all live servers (prunes dead ones first).
|
|
102
|
+
*/
|
|
103
|
+
export function list(cwd) {
|
|
104
|
+
return Object.values(prune(cwd))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Find servers running for a given worktree name.
|
|
109
|
+
*/
|
|
110
|
+
export function findByWorktree(name, cwd) {
|
|
111
|
+
return list(cwd).filter((s) => s.worktree === name)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Find a server by its unique ID.
|
|
116
|
+
*/
|
|
117
|
+
export function findById(id, cwd) {
|
|
118
|
+
const servers = prune(cwd)
|
|
119
|
+
return servers[id] || null
|
|
120
|
+
}
|