@dfosco/storyboard 0.6.11 → 0.6.13

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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Migrate legacy ad-hoc `.gitignore` blocks (written by setup.js lines 401–431
3
+ * pre-0.6.10, or copied from the pre-fragment `scaffold/gitignore`) into the
4
+ * new fragment-marker form so subsequent scaffolds keep them in sync via the
5
+ * fragments system.
6
+ *
7
+ * Two legacy forms are handled:
8
+ *
9
+ * 1. The setup.js banner (single trailing block):
10
+ *
11
+ * # Storyboard: runtime state (gitignored) + private tilde-prefixed files
12
+ * .storyboard/
13
+ * src/canvas/images/~*
14
+ * ... etc.
15
+ *
16
+ * 2. The old scaffold/gitignore split form (two separate blocks):
17
+ *
18
+ * # Runtime/transient state (per-machine, per-session)
19
+ * .storyboard/agent-sessions/
20
+ * ... etc.
21
+ *
22
+ * # Private canvas images (tilde prefix = not committed)
23
+ * src/canvas/images/~*
24
+ * ... etc.
25
+ *
26
+ * After migration both end up as:
27
+ *
28
+ * # <!-- storyboard:scaffold/gitignore:runtime-state --start-->
29
+ * ... (library-managed body)
30
+ * # <!-- storyboard:scaffold/gitignore:runtime-state --end-->
31
+ *
32
+ * Idempotent — if the file already contains the marker, leave it alone.
33
+ */
34
+
35
+ import fs from 'node:fs'
36
+
37
+ const SETUP_BANNER =
38
+ '# Storyboard: runtime state (gitignored) + private tilde-prefixed files'
39
+
40
+ // Legacy split form headers from the pre-fragment scaffold/gitignore. We only
41
+ // migrate when BOTH are present to avoid false positives on unrelated content.
42
+ const SPLIT_RUNTIME_HEADER = '# Runtime/transient state (per-machine, per-session)'
43
+ const SPLIT_PRIVATE_HEADER = '# Private canvas images (tilde prefix = not committed)'
44
+
45
+ // Other related comments that the pre-fragment scaffold/gitignore wrote — we
46
+ // strip them too when migrating the split form so we don't leave orphan
47
+ // section headers in the file.
48
+ const RELATED_HEADERS = [
49
+ '# Real-time canvas selection bridge for Copilot',
50
+ '# Auto-generated scaffold dir (copies of library config defaults — overwritten on every dev-server boot)',
51
+ ]
52
+
53
+ const MARKER_START =
54
+ '# <!-- storyboard:scaffold/gitignore:runtime-state --start-->'
55
+ const MARKER_END =
56
+ '# <!-- storyboard:scaffold/gitignore:runtime-state --end-->'
57
+
58
+ const MARKER_PRESENT_SUBSTRING =
59
+ 'storyboard:scaffold/gitignore:runtime-state --start'
60
+
61
+ /**
62
+ * Detect + rewrite legacy block(s) in a `.gitignore` text.
63
+ *
64
+ * @param {string} text
65
+ * @returns {{ text: string, migrated: boolean }}
66
+ */
67
+ export function migrateLegacyGitignore(text) {
68
+ if (text.includes(MARKER_PRESENT_SUBSTRING)) {
69
+ return { text, migrated: false }
70
+ }
71
+
72
+ // --- Form 1: single trailing banner from setup.js ---
73
+ const setupIdx = text.indexOf(SETUP_BANNER)
74
+ if (setupIdx !== -1) {
75
+ const before = text.slice(0, setupIdx).trimEnd()
76
+ const afterBanner = text.slice(setupIdx)
77
+ const bannerEnd = afterBanner.indexOf('\n')
78
+ const body = bannerEnd === -1 ? '' : afterBanner.slice(bannerEnd + 1).trimEnd()
79
+ const rewritten =
80
+ before +
81
+ '\n\n' +
82
+ MARKER_START + '\n' +
83
+ (body ? body + '\n' : '') +
84
+ MARKER_END + '\n'
85
+ return { text: rewritten, migrated: true }
86
+ }
87
+
88
+ // --- Form 2: split runtime + private sections from old scaffold/gitignore ---
89
+ if (text.includes(SPLIT_RUNTIME_HEADER) && text.includes(SPLIT_PRIVATE_HEADER)) {
90
+ return { text: migrateSplitForm(text), migrated: true }
91
+ }
92
+
93
+ return { text, migrated: false }
94
+ }
95
+
96
+ /**
97
+ * Apply the migration to an on-disk `.gitignore`. Returns true if the file
98
+ * was rewritten. Safe to call when the file doesn't exist (returns false).
99
+ *
100
+ * @param {string} gitignorePath
101
+ * @returns {boolean}
102
+ */
103
+ export function migrateLegacyGitignoreFile(gitignorePath) {
104
+ if (!fs.existsSync(gitignorePath)) return false
105
+ const text = fs.readFileSync(gitignorePath, 'utf-8')
106
+ const { text: next, migrated } = migrateLegacyGitignore(text)
107
+ if (!migrated) return false
108
+ fs.writeFileSync(gitignorePath, next, 'utf-8')
109
+ return true
110
+ }
111
+
112
+ /**
113
+ * Rewrite Form 2 (split runtime + private sections) into marker form.
114
+ *
115
+ * Walk lines top-to-bottom; when we hit one of the known legacy section
116
+ * headers, consume all following lines until we see a blank line OR a line
117
+ * that doesn't look like part of that section (= doesn't start with `#`,
118
+ * `.storyboard/`, `src/canvas/`, `assets/`, or `.sync-target`).
119
+ *
120
+ * All consumed sections are dropped from the output, and a single marker
121
+ * block is appended at the end. The fragment-replace pass then populates
122
+ * the body from the library source.
123
+ */
124
+ function migrateSplitForm(text) {
125
+ const lines = text.split(/\r?\n/)
126
+ const out = []
127
+ const droppedSections = new Set([
128
+ SPLIT_RUNTIME_HEADER,
129
+ SPLIT_PRIVATE_HEADER,
130
+ ...RELATED_HEADERS,
131
+ ])
132
+
133
+ let i = 0
134
+ while (i < lines.length) {
135
+ const line = lines[i]
136
+ if (droppedSections.has(line.trim())) {
137
+ // Consume until the next blank line (exclusive of that blank).
138
+ i++
139
+ while (i < lines.length && lines[i].trim() !== '') i++
140
+ // Also consume the blank line itself if present, to avoid double blanks.
141
+ if (i < lines.length && lines[i].trim() === '') i++
142
+ continue
143
+ }
144
+ out.push(line)
145
+ i++
146
+ }
147
+
148
+ // Trim trailing blank lines, then append the marker block.
149
+ while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
150
+
151
+ out.push('')
152
+ out.push(MARKER_START)
153
+ out.push(MARKER_END)
154
+ out.push('')
155
+
156
+ return out.join('\n')
157
+ }
@@ -0,0 +1,244 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+ import { execFileSync } from 'node:child_process'
6
+
7
+ /**
8
+ * End-to-end scaffold tests: spawn the real `storyboard-scaffold` binary
9
+ * against a tmp client directory and assert the file outcomes match the
10
+ * design rules from `.agents/plans/scaffold-fragment-templating.md`.
11
+ *
12
+ * This validates the full pipeline (config → file pass → fragments pass +
13
+ * migration helper + library-marker stripping), not just the individual
14
+ * pure functions covered by `fragments.test.js`.
15
+ */
16
+
17
+ const SCAFFOLD_BIN = path.resolve(
18
+ // From packages/storyboard/src/core/scaffold/scaffold-integration.test.js
19
+ path.dirname(new URL(import.meta.url).pathname),
20
+ '..', 'scaffold.js'
21
+ )
22
+
23
+ function runScaffolder(cwd, args = []) {
24
+ return execFileSync(process.execPath, [SCAFFOLD_BIN, ...args], {
25
+ cwd,
26
+ encoding: 'utf-8',
27
+ stdio: ['ignore', 'pipe', 'pipe'],
28
+ })
29
+ }
30
+
31
+ let tmp
32
+
33
+ beforeEach(() => {
34
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-scaffold-'))
35
+ })
36
+
37
+ afterEach(() => {
38
+ fs.rmSync(tmp, { recursive: true, force: true })
39
+ })
40
+
41
+ describe('storyboard-scaffold integration', () => {
42
+ it('fresh client: copies whole files and strips library markers from the output', () => {
43
+ runScaffolder(tmp)
44
+
45
+ const gitignore = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
46
+
47
+ // Library-side bare markers must NOT leak into the client output.
48
+ expect(gitignore).not.toMatch(/<!--\s+runtime-state\s+--start-->/)
49
+ expect(gitignore).not.toMatch(/<!--\s+runtime-state\s+--end-->/)
50
+
51
+ // The fragment body content should be present.
52
+ expect(gitignore).toContain('.storyboard/')
53
+ expect(gitignore).toContain('src/canvas/**/drafts/')
54
+ expect(gitignore).toContain('src/prototypes/**/drafts/')
55
+ })
56
+
57
+ it('re-run on a fresh client is a no-op (idempotent)', () => {
58
+ runScaffolder(tmp)
59
+ const before = snapshot(tmp)
60
+ runScaffolder(tmp)
61
+ const after = snapshot(tmp)
62
+ expect(after).toEqual(before)
63
+ })
64
+
65
+ it('client with explicit fragment markers: rewrites only the fragment body', () => {
66
+ // Hand-craft a client .gitignore that has its own header + a marker block
67
+ // pointing at the library fragment with stale content inside.
68
+ const clientGitignore = [
69
+ '# my custom header',
70
+ '/node_modules',
71
+ '',
72
+ '# <!-- storyboard:scaffold/gitignore:runtime-state --start-->',
73
+ 'STALE-CONTENT-THAT-MUST-BE-REPLACED',
74
+ '# <!-- storyboard:scaffold/gitignore:runtime-state --end-->',
75
+ '',
76
+ '# my custom footer',
77
+ '',
78
+ ].join('\n')
79
+ fs.writeFileSync(path.join(tmp, '.gitignore'), clientGitignore, 'utf-8')
80
+
81
+ runScaffolder(tmp, ['--fragments-only'])
82
+
83
+ const updated = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
84
+
85
+ // Outside-marker content preserved exactly.
86
+ expect(updated).toContain('# my custom header')
87
+ expect(updated).toContain('# my custom footer')
88
+ expect(updated).toContain('/node_modules')
89
+
90
+ // Fragment body rewritten.
91
+ expect(updated).not.toContain('STALE-CONTENT-THAT-MUST-BE-REPLACED')
92
+ expect(updated).toContain('.storyboard/')
93
+
94
+ // Client markers stay so the next scaffold can keep things in sync.
95
+ expect(updated).toContain('storyboard:scaffold/gitignore:runtime-state --start')
96
+ expect(updated).toContain('storyboard:scaffold/gitignore:runtime-state --end')
97
+ })
98
+
99
+ it('client with unknown fragment id: exits non-zero with a clear error', () => {
100
+ const clientGitignore = [
101
+ '# <!-- storyboard:scaffold/gitignore:does-not-exist --start-->',
102
+ 'x',
103
+ '# <!-- storyboard:scaffold/gitignore:does-not-exist --end-->',
104
+ '',
105
+ ].join('\n')
106
+ fs.writeFileSync(path.join(tmp, '.gitignore'), clientGitignore, 'utf-8')
107
+
108
+ let threw = false
109
+ let errOutput = ''
110
+ try {
111
+ runScaffolder(tmp, ['--fragments-only'])
112
+ } catch (err) {
113
+ threw = true
114
+ errOutput = String(err.stderr || '') + String(err.stdout || '')
115
+ }
116
+
117
+ expect(threw).toBe(true)
118
+ expect(errOutput).toMatch(/No fragment "does-not-exist"/)
119
+ })
120
+
121
+ it('legacy ad-hoc gitignore banner is migrated into marker form', () => {
122
+ // Mimic what setup.js used to append in pre-0.6.10 clients.
123
+ const legacy = [
124
+ '# my header',
125
+ '/node_modules',
126
+ '',
127
+ '# Storyboard: runtime state (gitignored) + private tilde-prefixed files',
128
+ '.storyboard/',
129
+ 'src/canvas/images/~*',
130
+ 'assets/canvas/images/~*',
131
+ '',
132
+ ].join('\n')
133
+ fs.writeFileSync(path.join(tmp, '.gitignore'), legacy, 'utf-8')
134
+
135
+ runScaffolder(tmp, ['--fragments-only'])
136
+
137
+ const updated = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
138
+
139
+ // Header preserved.
140
+ expect(updated).toContain('# my header')
141
+ expect(updated).toContain('/node_modules')
142
+
143
+ // Markers now present.
144
+ expect(updated).toContain('storyboard:scaffold/gitignore:runtime-state --start')
145
+ expect(updated).toContain('storyboard:scaffold/gitignore:runtime-state --end')
146
+
147
+ // Legacy banner gone.
148
+ expect(updated).not.toContain('# Storyboard: runtime state (gitignored) + private tilde-prefixed files')
149
+
150
+ // Fragment body matches the library.
151
+ expect(updated).toContain('src/canvas/**/drafts/')
152
+
153
+ // Second run is a no-op.
154
+ const before = updated
155
+ runScaffolder(tmp, ['--fragments-only'])
156
+ const after = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
157
+ expect(after).toBe(before)
158
+ })
159
+
160
+ it('legacy split-form gitignore (Runtime + Private headers) is migrated into marker form', () => {
161
+ // Mimic a client that received the pre-fragment scaffold/gitignore where
162
+ // runtime state and private images lived in two separate sections.
163
+ const legacy = [
164
+ '# my header',
165
+ '/node_modules',
166
+ '',
167
+ '# Runtime/transient state (per-machine, per-session)',
168
+ '.storyboard/agent-sessions/',
169
+ '.storyboard/hot-pool/',
170
+ '.storyboard/logs/',
171
+ '',
172
+ '# Private canvas images (tilde prefix = not committed)',
173
+ 'src/canvas/images/~*',
174
+ 'assets/canvas/images/~*',
175
+ '.sync-target',
176
+ '',
177
+ '# Integration test results (ephemeral local artifacts)',
178
+ 'test-results/',
179
+ '',
180
+ ].join('\n')
181
+ fs.writeFileSync(path.join(tmp, '.gitignore'), legacy, 'utf-8')
182
+
183
+ runScaffolder(tmp, ['--fragments-only'])
184
+
185
+ const updated = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
186
+
187
+ // Header preserved.
188
+ expect(updated).toContain('# my header')
189
+ // Unrelated section (test-results) preserved.
190
+ expect(updated).toContain('# Integration test results (ephemeral local artifacts)')
191
+ expect(updated).toContain('test-results/')
192
+
193
+ // Both legacy section headers gone.
194
+ expect(updated).not.toContain('# Runtime/transient state (per-machine, per-session)')
195
+ expect(updated).not.toContain('# Private canvas images (tilde prefix = not committed)')
196
+
197
+ // Marker block present with library body.
198
+ expect(updated).toContain('storyboard:scaffold/gitignore:runtime-state --start')
199
+ expect(updated).toContain('src/canvas/**/drafts/')
200
+
201
+ // Second run is a no-op.
202
+ const before = updated
203
+ runScaffolder(tmp, ['--fragments-only'])
204
+ const after = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
205
+ expect(after).toBe(before)
206
+ })
207
+
208
+ it('--files-only skips the fragment pass entirely', () => {
209
+ // Seed a gitignore with a stale fragment body. --files-only should NOT
210
+ // touch it because the fragment-replace pass is suppressed.
211
+ const stale = [
212
+ '# <!-- storyboard:scaffold/gitignore:runtime-state --start-->',
213
+ 'STALE',
214
+ '# <!-- storyboard:scaffold/gitignore:runtime-state --end-->',
215
+ '',
216
+ ].join('\n')
217
+ fs.writeFileSync(path.join(tmp, '.gitignore'), stale, 'utf-8')
218
+
219
+ runScaffolder(tmp, ['--files-only'])
220
+
221
+ const after = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
222
+ expect(after).toContain('STALE')
223
+ })
224
+ })
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // helpers
228
+ // ---------------------------------------------------------------------------
229
+
230
+ function snapshot(dir) {
231
+ const out = {}
232
+ function walk(d) {
233
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
234
+ const full = path.join(d, entry.name)
235
+ if (entry.isDirectory()) {
236
+ walk(full)
237
+ } else {
238
+ out[path.relative(dir, full)] = fs.readFileSync(full, 'utf-8')
239
+ }
240
+ }
241
+ }
242
+ walk(dir)
243
+ return out
244
+ }
@@ -1,100 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * storyboard-scaffold — sync scaffold files from @dfosco/storyboard.
4
- *
5
- * Reads scaffold/manifest.json and copies files to the consumer repo:
6
- * - "scaffold" mode: only if target doesn't exist (never overwrites config)
7
- * - "updateable" mode: always overwrites with latest (skills, scripts)
8
- *
9
- * Usage:
10
- * npx storyboard-scaffold
3
+ * storyboard-scaffold — thin shim that re-exports the implementation from
4
+ * ./scaffold/index.js. Keeps `bin.storyboard-scaffold` resolving to a stable
5
+ * path while the actual logic lives in a dedicated directory.
11
6
  */
12
-
13
- import fs from 'node:fs'
14
- import path from 'node:path'
15
-
16
- const __dirname = path.dirname(new URL(import.meta.url).pathname)
17
- const scaffoldRoot = path.resolve(__dirname, '..', '..', 'scaffold')
18
- const consumerRoot = process.cwd()
19
-
20
- const manifestPath = path.join(scaffoldRoot, 'manifest.json')
21
- if (!fs.existsSync(manifestPath)) {
22
- console.error('❌ Could not find scaffold/manifest.json in @dfosco/storyboard-core')
23
- process.exit(1)
24
- }
25
-
26
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
27
-
28
- function copyFileSync(src, dest) {
29
- const dir = path.dirname(dest)
30
- if (!fs.existsSync(dir)) {
31
- fs.mkdirSync(dir, { recursive: true })
32
- }
33
- fs.copyFileSync(src, dest)
34
- }
35
-
36
- function copyDirSync(src, dest) {
37
- if (!fs.existsSync(dest)) {
38
- fs.mkdirSync(dest, { recursive: true })
39
- }
40
- const entries = fs.readdirSync(src, { withFileTypes: true })
41
- for (const entry of entries) {
42
- const srcPath = path.join(src, entry.name)
43
- const destPath = path.join(dest, entry.name)
44
- if (entry.isDirectory()) {
45
- copyDirSync(srcPath, destPath)
46
- } else {
47
- copyFileSync(srcPath, destPath)
48
- }
49
- }
50
- }
51
-
52
- let created = 0
53
- let updated = 0
54
- let skipped = 0
55
-
56
- for (const file of manifest.files) {
57
- const srcPath = path.join(scaffoldRoot, path.relative('scaffold', file.source))
58
- const destPath = path.join(consumerRoot, file.target)
59
-
60
- if (file.directory) {
61
- if (file.mode === 'updateable') {
62
- copyDirSync(srcPath, destPath)
63
- updated++
64
- console.log(` ✔ Updated ${file.target} (sync)`)
65
- } else {
66
- if (!fs.existsSync(destPath)) {
67
- copyDirSync(srcPath, destPath)
68
- created++
69
- console.log(` ✔ Created ${file.target} (scaffold)`)
70
- } else {
71
- skipped++
72
- console.log(` ⏭ Skipped ${file.target} (already exists)`)
73
- }
74
- }
75
- continue
76
- }
77
-
78
- if (file.mode === 'scaffold') {
79
- if (fs.existsSync(destPath)) {
80
- skipped++
81
- console.log(` ⏭ Skipped ${file.target} (already exists)`)
82
- } else {
83
- copyFileSync(srcPath, destPath)
84
- created++
85
- console.log(` ✔ Created ${file.target} (scaffold)`)
86
- }
87
- } else if (file.mode === 'updateable') {
88
- copyFileSync(srcPath, destPath)
89
- updated++
90
- console.log(` ✔ Updated ${file.target} (sync)`)
91
- }
92
-
93
- // Make shell scripts executable
94
- if (file.target.endsWith('.sh')) {
95
- fs.chmodSync(destPath, 0o755)
96
- }
97
- }
98
-
99
- console.log('')
100
- console.log(`✔ Scaffold complete: ${created} created, ${updated} updated, ${skipped} skipped.`)
7
+ import './scaffold/index.js'