@dfosco/storyboard 1.0.0-beta.0 → 1.0.0-beta.1
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.md +14 -3
- package/scaffold/terminal-agent.agent.md +11 -0
- package/src/core/canvas/hot-pool.js +8 -1
- package/src/core/canvas/server.js +6 -4
- package/src/core/canvas/terminal-server.js +11 -1
- package/src/core/cli/dev.js +6 -1
- package/src/core/cli/updateVersion.js +40 -18
- package/src/core/paths/resolvePaths.js +91 -1
- package/src/core/paths/resolvePaths.test.js +64 -0
package/package.json
CHANGED
package/scaffold/AGENTS.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Selected Widgets Context
|
|
4
4
|
|
|
5
|
-
**Before processing ANY user prompt**, read `.storyboard/.selectedwidgets.json` at the repository root. This file is updated in real-time by the Storyboard dev server and reflects which canvas the user is currently viewing and which widgets they have selected in the browser.
|
|
5
|
+
**Before processing ANY user prompt**, read `.storyboard/.selectedwidgets.json` at the repository root (the content root — where you run `storyboard` commands; in the `.backstage` layout this is the repo root you see, **not** the hidden `.backstage/` directory). This file is updated in real-time by the Storyboard dev server and reflects which canvas the user is currently viewing and which widgets they have selected in the browser.
|
|
6
6
|
|
|
7
7
|
**File format:**
|
|
8
8
|
```json
|
|
9
9
|
{
|
|
10
10
|
"canvasId": "design-system",
|
|
11
|
-
"canvasFile": "
|
|
11
|
+
"canvasFile": "canvas/design-system.canvas.jsonl",
|
|
12
12
|
"selectedWidgetIds": ["img-abc123", "sticky-def456"],
|
|
13
13
|
"widgets": [
|
|
14
14
|
{ "id": "img-abc123", "type": "image", "props": { "src": "screenshot.png" } },
|
|
@@ -196,6 +196,17 @@ This is a **Storyboard prototyping app** using Vite and file-based routing via `
|
|
|
196
196
|
> content above the Vite root resolves its dependencies — `storyboard dev`
|
|
197
197
|
> recreates it automatically. Either way, you only edit content +
|
|
198
198
|
> `storyboard.config.json`.
|
|
199
|
+
>
|
|
200
|
+
> **Path convention — read this before following any `src/...` path below.**
|
|
201
|
+
> Every content path in this document is written in the **legacy** form with a
|
|
202
|
+
> `src/` prefix (`src/prototypes/`, `src/canvas/`, `src/components/`). In the
|
|
203
|
+
> **`.backstage`** layout the content folders live at the repo root with **no
|
|
204
|
+
> `src/` prefix** — `src/prototypes/X` becomes `prototypes/X`, `src/components/Y`
|
|
205
|
+
> becomes `components/Y`, and so on. Detect your layout once: if the repo root
|
|
206
|
+
> has a `.backstage/` directory, you are in the backstage layout — drop the
|
|
207
|
+
> `src/` prefix from every path below. Agent files (`.agents/`, `AGENTS.md`) and
|
|
208
|
+
> runtime state (`.storyboard/`) are bridged to the repo root in both layouts,
|
|
209
|
+
> so reference them by their plain names regardless of layout.
|
|
199
210
|
|
|
200
211
|
Detailed architectural documentation lives in `.agents/architecture/`. Consult the relevant architecture docs when:
|
|
201
212
|
|
|
@@ -231,7 +242,7 @@ The fix was a one-line compaction (4MB → 9KB). Everything the user reported
|
|
|
231
242
|
- Use **semantic HTML tags** whenever they are appropriate in between Primer React components
|
|
232
243
|
- Use the **canvas widget type registry** at `.agents/data/widget-types.json` as the authoritative list of valid canvas widget `type` strings, their props, default sizes, and when-to-use guidance. Never invent widget types. In particular: when showing multiple variants of a single story file, use ONE `component-set` widget — not N `story` widgets. Regenerate after changing the widget registry: `node scripts/gen-widget-types.mjs`.
|
|
233
244
|
- Use **CSS Modules** (`*.module.css`) for component-specific styles
|
|
234
|
-
- **Components must live in their own directory:** `src/components/Name/Name.jsx`, `Name.module.css`, `name.story.jsx`. Never place component files flat in
|
|
245
|
+
- **Components must live in their own directory:** `src/components/Name/Name.jsx`, `Name.module.css`, `name.story.jsx` (in the `.backstage` layout: `components/Name/Name.jsx` — drop the `src/` prefix). Never place component files flat in the components folder.
|
|
235
246
|
- **Always use the `create` skill** when creating new components or stories — don't manually create files.
|
|
236
247
|
- **Every piece of data consumed in a page must gracefully handle `null` or `undefined` without crashing.** Since flow data, records, and overrides can all be partial, incomplete, or missing, components must never assume a field exists. Use optional chaining, fallback values, or conditional rendering for every data access.
|
|
237
248
|
- **Branch URL support is required for any feature involving URL fragments or URL matching.** Branch deploys use `VITE_BASE_PATH=/branch--{branch-name}/` which changes `import.meta.env.BASE_URL`. Any URL matching, same-origin detection, or src resolution must account for branch-prefixed paths (e.g. `/branch--my-feature/MyPrototype`). When implementing URL-related features, ask the user about branch URL support — if they're unavailable, build for it by default.
|
|
@@ -23,6 +23,17 @@ tools:
|
|
|
23
23
|
> - ✅ `POST /_storyboard/canvas/batch` with body `{"name":"my-canvas", ...}`
|
|
24
24
|
> - ❌ `POST /_storyboard/canvas/my-canvas/widgets` — **DOES NOT EXIST**
|
|
25
25
|
|
|
26
|
+
> **📂 Your working directory.** You run from the **content root** — the
|
|
27
|
+
> directory holding the user's content folders (`prototypes/`, `canvas/`,
|
|
28
|
+
> `components/`, `assets/`). Edit content there by its plain path
|
|
29
|
+
> (`components/Foo/Foo.jsx`, `prototypes/Bar/index.jsx`) — **no `src/` prefix**.
|
|
30
|
+
> Runtime + agent files are bridged into this same root, so the relative paths
|
|
31
|
+
> in this doc just work: `.storyboard/terminals/…` (your config + env),
|
|
32
|
+
> `.agents/skills/…` (skills), and `AGENTS.md` (repo instructions). In the
|
|
33
|
+
> `.backstage` layout the build machinery (`node_modules`, `vite.config.js`,
|
|
34
|
+
> `package.json`) lives in a hidden `.backstage/` directory you don't touch —
|
|
35
|
+
> stay in the content root and never `cd` into `.backstage/`.
|
|
36
|
+
|
|
26
37
|
## ⚠️ Prime Directive: Results MUST be visible on the canvas
|
|
27
38
|
|
|
28
39
|
**You CANNOT signal completion unless the user can see your result on the canvas.** This is non-negotiable. If you did work but the canvas looks the same as before, you failed.
|
|
@@ -48,6 +48,7 @@ import { writeFileSync, existsSync, unlinkSync, mkdirSync } from 'node:fs'
|
|
|
48
48
|
import { join } from 'node:path'
|
|
49
49
|
import { devLog } from '../logger/devLogger.js'
|
|
50
50
|
import { readCapturedSessionId, captureFilePath, clearCaptureFile } from './agent-session.js'
|
|
51
|
+
import { findSystemRoot } from '../paths/resolvePaths.js'
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
54
|
* @typedef {Object} WarmSession
|
|
@@ -99,7 +100,13 @@ export class HotPool {
|
|
|
99
100
|
* @param {Function} [opts.wsSend] — Vite server.ws.send for browser devlog events
|
|
100
101
|
*/
|
|
101
102
|
constructor({ root, poolId = 'terminal', config = {}, agentConfig = null, wsSend = null }) {
|
|
102
|
-
|
|
103
|
+
// Pre-warmed agents must spawn in the CONTENT root (where the user's
|
|
104
|
+
// prototypes/canvas/components live), not the Vite/system root. In the
|
|
105
|
+
// backstage layout the server passes the system root (`.backstage`); resolve
|
|
106
|
+
// it to the content root so the warm tmux session's fixed cwd, capture
|
|
107
|
+
// files (`.storyboard/…`, bridged), and STORYBOARD_PROJECT_ROOT all match
|
|
108
|
+
// the post-acquisition terminal-server spawn. Legacy layout is unchanged.
|
|
109
|
+
this.#root = findSystemRoot(root).contentRoot
|
|
103
110
|
this.#poolId = poolId
|
|
104
111
|
this.#poolSize = Math.max(0, config.pool_size ?? DEFAULT_POOL_SIZE)
|
|
105
112
|
this.#maxPoolSize = Math.max(this.#poolSize, config.max_pool_size ?? DEFAULT_MAX_POOL_SIZE)
|
|
@@ -3522,9 +3522,11 @@ export function Default() {
|
|
|
3522
3522
|
// Build server URL for agent env vars
|
|
3523
3523
|
const serverUrl = `http://localhost:${req.socket?.localPort || 1234}`
|
|
3524
3524
|
|
|
3525
|
-
// Create headless tmux session
|
|
3525
|
+
// Create headless tmux session. Spawn in the CONTENT root (where the
|
|
3526
|
+
// user's prototypes/canvas/components live), not the Vite/system root —
|
|
3527
|
+
// matches the warm hot-pool + interactive terminal-server spawn cwd.
|
|
3526
3528
|
try {
|
|
3527
|
-
execSync(`tmux new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
|
|
3529
|
+
execSync(`tmux new-session -d -s "${tmuxName}" -c "${findSystemRoot(root).contentRoot}"`, { stdio: 'ignore' })
|
|
3528
3530
|
execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
|
|
3529
3531
|
execSync(`tmux set-option -t "${tmuxName}" mouse on`, { stdio: 'ignore' })
|
|
3530
3532
|
execSync(`tmux set-option -t "${tmuxName}" set-clipboard off`, { stdio: 'ignore' })
|
|
@@ -3874,9 +3876,9 @@ export function Default() {
|
|
|
3874
3876
|
}
|
|
3875
3877
|
|
|
3876
3878
|
if (!usedWarm) {
|
|
3877
|
-
// Fresh tmux session (cold path)
|
|
3879
|
+
// Fresh tmux session (cold path) — spawn in the content root.
|
|
3878
3880
|
try {
|
|
3879
|
-
execSync(`tmux -f /dev/null new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
|
|
3881
|
+
execSync(`tmux -f /dev/null new-session -d -s "${tmuxName}" -c "${findSystemRoot(root).contentRoot}"`, { stdio: 'ignore' })
|
|
3880
3882
|
execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
|
|
3881
3883
|
execSync(`tmux set-option -t "${tmuxName}" set-clipboard off 2>/dev/null`, { stdio: 'ignore' })
|
|
3882
3884
|
} catch { /* session may already exist */ }
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from './terminal-config.js'
|
|
56
56
|
import { findByWorktree } from '../worktree/serverRegistry.js'
|
|
57
57
|
import { detectWorktreeName } from '../worktree/port.js'
|
|
58
|
+
import { findSystemRoot } from '../paths/resolvePaths.js'
|
|
58
59
|
import { bindWidget, unbindWidget } from '../messaging/delivery.js'
|
|
59
60
|
import { joinPresence, leavePresence } from '../messaging/presence.js'
|
|
60
61
|
import {
|
|
@@ -982,7 +983,16 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
|
|
|
982
983
|
ptyProcesses.delete(tmuxName)
|
|
983
984
|
}
|
|
984
985
|
|
|
985
|
-
|
|
986
|
+
// Spawn the agent in the CONTENT root. In the backstage layout the dev
|
|
987
|
+
// server's process.cwd() is `.backstage/` (the Vite + npm root), but an
|
|
988
|
+
// agent must run where the user's content lives (prototypes/canvas/components
|
|
989
|
+
// at the content root). `.storyboard`, `node_modules`, `.agents` and
|
|
990
|
+
// `AGENTS.md` are bridged into the content root (see ensureContentSystemLinks
|
|
991
|
+
// / ensureContentNodeModulesLink), so every `.storyboard/...` path below
|
|
992
|
+
// resolves to the same physical file from either root. Legacy layout is
|
|
993
|
+
// unchanged (content root === process.cwd()).
|
|
994
|
+
const { mode: layoutMode, contentRoot } = findSystemRoot()
|
|
995
|
+
const cwd = layoutMode === 'backstage' ? contentRoot : process.cwd()
|
|
986
996
|
const shell = process.env.SHELL || '/bin/zsh'
|
|
987
997
|
const termCfg = readTerminalConfig()
|
|
988
998
|
const prompt = termCfg.prompt || '$ '
|
package/src/core/cli/dev.js
CHANGED
|
@@ -20,7 +20,7 @@ import { readFileSync, existsSync } from 'node:fs'
|
|
|
20
20
|
import { detectWorktreeName, getPort, releasePort } from '../worktree/port.js'
|
|
21
21
|
import { startFileWatcher } from '../file-watcher/watcher.js'
|
|
22
22
|
import { compactAll } from '../canvas/compact.js'
|
|
23
|
-
import { findSystemRoot, mirrorSystemConfig, ensureContentNodeModulesLink, ensureLinkedDepsOverlay } from '../paths/resolvePaths.js'
|
|
23
|
+
import { findSystemRoot, mirrorSystemConfig, ensureContentNodeModulesLink, ensureLinkedDepsOverlay, ensureContentSystemLinks } from '../paths/resolvePaths.js'
|
|
24
24
|
import { parseFlags } from './flags.js'
|
|
25
25
|
import { setupNeeded, writeUserState, getInstalledStoryboardVersion } from './userState.js'
|
|
26
26
|
import { dim, magenta, bold } from './intro.js'
|
|
@@ -188,6 +188,11 @@ async function main() {
|
|
|
188
188
|
// its hoisted deps aren't reachable from the Vite root. Overlay them so
|
|
189
189
|
// Vite's optimizeDeps.include entries resolve. No-op for published installs.
|
|
190
190
|
ensureLinkedDepsOverlay(layout)
|
|
191
|
+
// Bridge agent-facing system files (.agents, AGENTS.md, .storyboard) into
|
|
192
|
+
// the content root so a canvas/terminal agent — which runs FROM the content
|
|
193
|
+
// root, where prototypes/canvas/components live — can resolve its skills,
|
|
194
|
+
// instructions, and runtime bridge state by their conventional names.
|
|
195
|
+
ensureContentSystemLinks(layout)
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
// Port resolution priority (Vite always rolls forward to the next free
|
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
import * as p from '@clack/prompts'
|
|
13
13
|
import { execSync } from 'child_process'
|
|
14
14
|
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'fs'
|
|
15
|
-
import { dirname, resolve } from 'path'
|
|
15
|
+
import { dirname, relative, resolve } from 'path'
|
|
16
|
+
import { findSystemRoot } from '../paths/resolvePaths.js'
|
|
17
|
+
|
|
18
|
+
// In the backstage layout, package.json / node_modules / scripts / vite.config
|
|
19
|
+
// live under `<contentRoot>/.backstage` (the systemRoot) while `.git` and
|
|
20
|
+
// `.github` live at the contentRoot. Resolve both so `storyboard update` works
|
|
21
|
+
// when run from the content root. In the legacy layout both are the same dir.
|
|
22
|
+
const { systemRoot, contentRoot } = findSystemRoot()
|
|
23
|
+
// Path prefix from the git repo root (contentRoot) to systemRoot — '' in legacy,
|
|
24
|
+
// '.backstage' in backstage. Used to stage systemRoot files from contentRoot.
|
|
25
|
+
const sysPrefix = relative(contentRoot, systemRoot)
|
|
16
26
|
|
|
17
27
|
const command = process.argv[2]
|
|
18
28
|
const channels = { 'update:beta': 'beta', 'update:alpha': 'alpha' }
|
|
@@ -27,7 +37,7 @@ if (channels[command]) {
|
|
|
27
37
|
targetVersion = command.slice('update:'.length)
|
|
28
38
|
}
|
|
29
39
|
|
|
30
|
-
const pkgPath = resolve(
|
|
40
|
+
const pkgPath = resolve(systemRoot, 'package.json')
|
|
31
41
|
let pkg
|
|
32
42
|
try {
|
|
33
43
|
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
@@ -74,7 +84,7 @@ for (const name of storyboardPkgs) {
|
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
try {
|
|
77
|
-
execSync(`npm install ${packages}`, { stdio: 'inherit', cwd:
|
|
87
|
+
execSync(`npm install ${packages}`, { stdio: 'inherit', cwd: systemRoot })
|
|
78
88
|
p.log.success('All storyboard packages updated')
|
|
79
89
|
} catch {
|
|
80
90
|
p.log.error('Failed to update packages — see npm output above')
|
|
@@ -84,22 +94,23 @@ try {
|
|
|
84
94
|
// Sync scaffold files (skills, scripts) from the updated package
|
|
85
95
|
p.log.info('Syncing scaffold files…')
|
|
86
96
|
try {
|
|
87
|
-
execSync('npx storyboard-scaffold', { stdio: 'inherit', cwd:
|
|
97
|
+
execSync('npx storyboard-scaffold', { stdio: 'inherit', cwd: systemRoot })
|
|
88
98
|
} catch {
|
|
89
99
|
p.log.warn('Scaffold sync failed — run `npx storyboard-scaffold` manually')
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
function checkArtifactDiscoveryWorkflow() {
|
|
93
|
-
|
|
103
|
+
// Workflows + their scripts live at the git repo root (contentRoot); the
|
|
104
|
+
// scaffold source ships inside the installed package under systemRoot.
|
|
94
105
|
const workflows = [
|
|
95
|
-
{ name: 'deploy.yml', path: resolve(
|
|
96
|
-
{ name: 'preview.yml', path: resolve(
|
|
106
|
+
{ name: 'deploy.yml', path: resolve(contentRoot, '.github', 'workflows', 'deploy.yml') },
|
|
107
|
+
{ name: 'preview.yml', path: resolve(contentRoot, '.github', 'workflows', 'preview.yml') },
|
|
97
108
|
].filter(workflow => existsSync(workflow.path))
|
|
98
109
|
|
|
99
110
|
if (workflows.length === 0) return
|
|
100
111
|
|
|
101
|
-
const scriptTarget = resolve(
|
|
102
|
-
const scriptSource = resolve(
|
|
112
|
+
const scriptTarget = resolve(contentRoot, '.github', 'workflows', 'scripts', 'aggregate-artifacts.mjs')
|
|
113
|
+
const scriptSource = resolve(systemRoot, 'node_modules', '@dfosco', 'storyboard', 'scaffold', 'scripts', 'aggregate-artifacts.mjs')
|
|
103
114
|
|
|
104
115
|
if (!existsSync(scriptTarget)) {
|
|
105
116
|
if (existsSync(scriptSource)) {
|
|
@@ -133,11 +144,11 @@ try {
|
|
|
133
144
|
// Read the installed version from the storyboard package (try unified first, then legacy core)
|
|
134
145
|
let installedVersion
|
|
135
146
|
try {
|
|
136
|
-
const unified = JSON.parse(readFileSync(resolve(
|
|
147
|
+
const unified = JSON.parse(readFileSync(resolve(systemRoot, 'node_modules', '@dfosco', 'storyboard', 'package.json'), 'utf8'))
|
|
137
148
|
installedVersion = unified.version
|
|
138
149
|
} catch {
|
|
139
150
|
try {
|
|
140
|
-
const core = JSON.parse(readFileSync(resolve(
|
|
151
|
+
const core = JSON.parse(readFileSync(resolve(systemRoot, 'node_modules', '@dfosco', 'storyboard-core', 'package.json'), 'utf8'))
|
|
141
152
|
installedVersion = core.version
|
|
142
153
|
} catch { /* empty */ }
|
|
143
154
|
}
|
|
@@ -150,13 +161,15 @@ try {
|
|
|
150
161
|
// hands-off — customers never hand-patch these. User-facing *.config.json
|
|
151
162
|
// (storyboard.config.json, terminal.config.json, …) are intentionally NOT
|
|
152
163
|
// staged — those are customer-owned.
|
|
153
|
-
|
|
164
|
+
//
|
|
165
|
+
// In the backstage layout these live under systemRoot (`.backstage`) while
|
|
166
|
+
// `.git`/`.github` live at contentRoot. Git runs from contentRoot, so prefix
|
|
167
|
+
// systemRoot-owned paths with sysPrefix; `.github/*` paths stay at the root.
|
|
168
|
+
const systemFiles = [
|
|
154
169
|
'package.json',
|
|
155
170
|
'package-lock.json',
|
|
156
171
|
'yarn.lock',
|
|
157
172
|
'pnpm-lock.yaml',
|
|
158
|
-
'.github/skills',
|
|
159
|
-
'.github/workflows/scripts/aggregate-artifacts.mjs',
|
|
160
173
|
'scripts',
|
|
161
174
|
// Tier-1 package-owned infrastructure (force-synced):
|
|
162
175
|
'vite.config.js',
|
|
@@ -165,22 +178,31 @@ try {
|
|
|
165
178
|
'tsconfig.json',
|
|
166
179
|
'src/library',
|
|
167
180
|
]
|
|
181
|
+
const contentFiles = [
|
|
182
|
+
'.github/skills',
|
|
183
|
+
'.github/workflows/scripts/aggregate-artifacts.mjs',
|
|
184
|
+
]
|
|
185
|
+
const filesToStage = [
|
|
186
|
+
...systemFiles.map(f => (sysPrefix ? `${sysPrefix}/${f}` : f)),
|
|
187
|
+
...contentFiles,
|
|
188
|
+
]
|
|
168
189
|
for (const f of filesToStage) {
|
|
169
|
-
try { execSync(`git add ${f}`, { cwd:
|
|
190
|
+
try { execSync(`git add ${f}`, { cwd: contentRoot, stdio: 'pipe' }) } catch { /* empty */ }
|
|
170
191
|
}
|
|
171
192
|
|
|
172
193
|
// Svelte was removed from the storyboard base — drop the stale empty stub
|
|
173
194
|
// from existing customer repos so the update sheds it automatically.
|
|
174
195
|
try {
|
|
175
|
-
|
|
196
|
+
const svelteStub = sysPrefix ? `${sysPrefix}/svelte.config.js` : 'svelte.config.js'
|
|
197
|
+
execSync(`git rm --quiet --ignore-unmatch ${svelteStub}`, { cwd: contentRoot, stdio: 'pipe' })
|
|
176
198
|
} catch { /* empty */ }
|
|
177
199
|
|
|
178
200
|
// Only commit if there are staged changes
|
|
179
201
|
try {
|
|
180
|
-
execSync('git diff --cached --quiet', { cwd:
|
|
202
|
+
execSync('git diff --cached --quiet', { cwd: contentRoot, stdio: 'pipe' })
|
|
181
203
|
p.log.message('No changes to commit — already up to date')
|
|
182
204
|
} catch {
|
|
183
|
-
execSync(`git commit -m "${commitMsg}"`, { cwd:
|
|
205
|
+
execSync(`git commit -m "${commitMsg}"`, { cwd: contentRoot, stdio: 'pipe' })
|
|
184
206
|
p.log.success(`Committed: ${commitMsg}`)
|
|
185
207
|
}
|
|
186
208
|
} catch (err) {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* @module core/paths/resolvePaths
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import { existsSync, lstatSync, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync, mkdirSync } from 'node:fs'
|
|
41
|
+
import { existsSync, lstatSync, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs'
|
|
42
42
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
43
43
|
import { copyFileSync } from 'node:fs'
|
|
44
44
|
import process from 'node:process'
|
|
@@ -426,6 +426,96 @@ export function ensureLinkedDepsOverlay(loc, pkgName = '@dfosco/storyboard') {
|
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Append a path to `<root>/.git/info/exclude` exactly once (best-effort).
|
|
431
|
+
*
|
|
432
|
+
* Used to keep generated bridge symlinks out of `git status` without touching
|
|
433
|
+
* the committed `.gitignore` — which must stay layout-agnostic (ignoring
|
|
434
|
+
* `/.agents` there would wrongly hide a legacy repo's committed files). The
|
|
435
|
+
* local `info/exclude` is per-clone and never committed, so it is the right
|
|
436
|
+
* place for backstage-only generated artifacts. Skips git worktrees (where
|
|
437
|
+
* `.git` is a file, not a directory) — harmless if those show the symlink.
|
|
438
|
+
*
|
|
439
|
+
* @param {string} root content root containing `.git`
|
|
440
|
+
* @param {string} name entry to exclude (added as `/name`)
|
|
441
|
+
*/
|
|
442
|
+
function registerGitExclude(root, name) {
|
|
443
|
+
try {
|
|
444
|
+
const gitDir = join(root, '.git')
|
|
445
|
+
if (!existsSync(gitDir) || !lstatSync(gitDir).isDirectory()) return
|
|
446
|
+
const infoDir = join(gitDir, 'info')
|
|
447
|
+
mkdirSync(infoDir, { recursive: true })
|
|
448
|
+
const excludeFile = join(infoDir, 'exclude')
|
|
449
|
+
let body = ''
|
|
450
|
+
try { body = readFileSync(excludeFile, 'utf8') } catch { /* new file */ }
|
|
451
|
+
const entry = `/${name}`
|
|
452
|
+
if (body.split(/\r?\n/).includes(entry)) return
|
|
453
|
+
const prefix = body && !body.endsWith('\n') ? '\n' : ''
|
|
454
|
+
appendFileSync(excludeFile, `${prefix}${entry}\n`)
|
|
455
|
+
} catch {
|
|
456
|
+
/* best-effort — never block dev start on git bookkeeping */
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Bridge hidden system files into the content root so an agent (or any tool)
|
|
462
|
+
* running from the content root can resolve them by their conventional name.
|
|
463
|
+
*
|
|
464
|
+
* In the backstage layout a canvas/terminal agent's working directory is the
|
|
465
|
+
* content root (where `prototypes/`, `canvas/`, `components/` live), but the
|
|
466
|
+
* agent instruction + runtime files live under `.backstage`:
|
|
467
|
+
*
|
|
468
|
+
* - `.agents/` — skills, plans and data the agent reads by relative path
|
|
469
|
+
* - `AGENTS.md` — top-level agent instructions read on every prompt
|
|
470
|
+
* - `.storyboard/` — runtime bridge state (`.selectedwidgets.json`,
|
|
471
|
+
* `terminals/*.json`, buffers) the agent and server share
|
|
472
|
+
*
|
|
473
|
+
* Each is symlinked from systemRoot into contentRoot with a relative target
|
|
474
|
+
* (created only when missing, never clobbering a real file the user placed
|
|
475
|
+
* there) and registered in `.git/info/exclude` so the bridged copy never shows
|
|
476
|
+
* up as an untracked file. The canonical copies stay committed under
|
|
477
|
+
* `.backstage`. No-op in the legacy layout (system === content root).
|
|
478
|
+
*
|
|
479
|
+
* @param {{ mode: 'backstage'|'legacy', systemRoot: string, contentRoot: string }} loc
|
|
480
|
+
* @returns {string[]} names that are now bridged into the content root
|
|
481
|
+
*/
|
|
482
|
+
export function ensureContentSystemLinks(loc) {
|
|
483
|
+
const { mode, systemRoot, contentRoot } = loc
|
|
484
|
+
if (mode !== 'backstage') return []
|
|
485
|
+
const names = ['.agents', 'AGENTS.md', '.storyboard']
|
|
486
|
+
const bridged = []
|
|
487
|
+
for (const name of names) {
|
|
488
|
+
const target = join(systemRoot, name)
|
|
489
|
+
// `.storyboard` is created lazily by the dev server at startup; ensure it
|
|
490
|
+
// exists first so the symlink resolves immediately rather than dangling.
|
|
491
|
+
if (name === '.storyboard' && !existsSync(target)) {
|
|
492
|
+
try { mkdirSync(target, { recursive: true }) } catch { /* empty */ }
|
|
493
|
+
}
|
|
494
|
+
if (!existsSync(target)) continue
|
|
495
|
+
const link = join(contentRoot, name)
|
|
496
|
+
try {
|
|
497
|
+
if (isSymlink(link)) {
|
|
498
|
+
if (resolve(dirname(link), readlinkSync(link)) === resolve(target)) {
|
|
499
|
+
bridged.push(name)
|
|
500
|
+
registerGitExclude(contentRoot, name)
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
unlinkSync(link)
|
|
504
|
+
} else if (existsSync(link)) {
|
|
505
|
+
// A real file/dir already lives at the content root — never clobber it.
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
symlinkSync(relative(contentRoot, target), link)
|
|
509
|
+
bridged.push(name)
|
|
510
|
+
registerGitExclude(contentRoot, name)
|
|
511
|
+
} catch {
|
|
512
|
+
/* best-effort bridge — a missing link only degrades agent ergonomics */
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return bridged
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
429
519
|
function isSymlink(p) {
|
|
430
520
|
try {
|
|
431
521
|
return lstatSync(p).isSymbolicLink()
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
mirrorSystemConfig,
|
|
13
13
|
ensureContentNodeModulesLink,
|
|
14
14
|
ensureLinkedDepsOverlay,
|
|
15
|
+
ensureContentSystemLinks,
|
|
15
16
|
ROLES,
|
|
16
17
|
SYSTEM_DIR,
|
|
17
18
|
} from './resolvePaths.js'
|
|
@@ -309,4 +310,67 @@ describe('core/paths/resolvePaths', () => {
|
|
|
309
310
|
expect(ensureLinkedDepsOverlay(findSystemRoot(tmp))).toBe(0)
|
|
310
311
|
})
|
|
311
312
|
})
|
|
313
|
+
|
|
314
|
+
describe('ensureContentSystemLinks', () => {
|
|
315
|
+
// Build a backstage layout with committed system files under .backstage.
|
|
316
|
+
function setupBackstage({ git = false } = {}) {
|
|
317
|
+
const sys = join(tmp, SYSTEM_DIR)
|
|
318
|
+
mkdirSync(join(sys, '.agents', 'skills'), { recursive: true })
|
|
319
|
+
writeFileSync(join(sys, '.agents', 'skills', 'x.md'), '# skill\n')
|
|
320
|
+
writeFileSync(join(sys, 'AGENTS.md'), '# agents\n')
|
|
321
|
+
if (git) mkdirSync(join(tmp, '.git'), { recursive: true })
|
|
322
|
+
return { sys }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
it('is a no-op in the legacy layout', () => {
|
|
326
|
+
expect(ensureContentSystemLinks(findSystemRoot(tmp))).toEqual([])
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('bridges .agents, AGENTS.md and .storyboard into the content root', () => {
|
|
330
|
+
setupBackstage()
|
|
331
|
+
const bridged = ensureContentSystemLinks(findSystemRoot(tmp))
|
|
332
|
+
expect(bridged).toEqual(expect.arrayContaining(['.agents', 'AGENTS.md', '.storyboard']))
|
|
333
|
+
for (const name of ['.agents', 'AGENTS.md', '.storyboard']) {
|
|
334
|
+
const link = join(tmp, name)
|
|
335
|
+
expect(lstatSync(link).isSymbolicLink()).toBe(true)
|
|
336
|
+
expect(realpathSync(link)).toBe(realpathSync(join(tmp, SYSTEM_DIR, name)))
|
|
337
|
+
}
|
|
338
|
+
// The bridge resolves real system content through the symlink.
|
|
339
|
+
expect(readFileSync(join(tmp, '.agents', 'skills', 'x.md'), 'utf8')).toContain('# skill')
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('creates a relative symlink target so the repo stays portable', () => {
|
|
343
|
+
setupBackstage()
|
|
344
|
+
ensureContentSystemLinks(findSystemRoot(tmp))
|
|
345
|
+
expect(readlinkSync(join(tmp, 'AGENTS.md'))).toBe(join(SYSTEM_DIR, 'AGENTS.md'))
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('lazily creates the .storyboard directory when missing', () => {
|
|
349
|
+
setupBackstage()
|
|
350
|
+
expect(existsSync(join(tmp, SYSTEM_DIR, '.storyboard'))).toBe(false)
|
|
351
|
+
ensureContentSystemLinks(findSystemRoot(tmp))
|
|
352
|
+
expect(existsSync(join(tmp, SYSTEM_DIR, '.storyboard'))).toBe(true)
|
|
353
|
+
expect(lstatSync(join(tmp, '.storyboard')).isSymbolicLink()).toBe(true)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('never clobbers a real file already at the content root', () => {
|
|
357
|
+
setupBackstage()
|
|
358
|
+
writeFileSync(join(tmp, 'AGENTS.md'), '# user-owned\n')
|
|
359
|
+
ensureContentSystemLinks(findSystemRoot(tmp))
|
|
360
|
+
expect(lstatSync(join(tmp, 'AGENTS.md')).isSymbolicLink()).toBe(false)
|
|
361
|
+
expect(readFileSync(join(tmp, 'AGENTS.md'), 'utf8')).toBe('# user-owned\n')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('is idempotent and registers bridges in .git/info/exclude', () => {
|
|
365
|
+
setupBackstage({ git: true })
|
|
366
|
+
ensureContentSystemLinks(findSystemRoot(tmp))
|
|
367
|
+
ensureContentSystemLinks(findSystemRoot(tmp))
|
|
368
|
+
const exclude = readFileSync(join(tmp, '.git', 'info', 'exclude'), 'utf8')
|
|
369
|
+
const lines = exclude.split(/\r?\n/)
|
|
370
|
+
// Each entry appears exactly once despite two runs.
|
|
371
|
+
expect(lines.filter((l) => l === '/.agents')).toHaveLength(1)
|
|
372
|
+
expect(lines.filter((l) => l === '/AGENTS.md')).toHaveLength(1)
|
|
373
|
+
expect(lines.filter((l) => l === '/.storyboard')).toHaveLength(1)
|
|
374
|
+
})
|
|
375
|
+
})
|
|
312
376
|
})
|