@invisibleloop/pulse 0.20.0 → 0.20.2
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/.claude/commands/pulse-start.md +11 -0
- package/.claude/commands/pulse-stop.md +5 -0
- package/CLAUDE.md +4 -2
- package/docs/.claude/coverage-check.js +153 -84
- package/docs/src/pages/store.js +4 -5
- package/examples/.claude/coverage-check.js +153 -84
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +30 -10
- package/scripts/strip-server.test.js +31 -2
- package/src/agent/commands/pulse-start.md +6 -4
- package/src/agent/commands/pulse-stop.md +1 -1
- package/src/agent/coverage-check.js +154 -90
- package/src/agent/guide-server.md +3 -5
- package/src/agent/workflow.md +16 -2
- package/src/cli/cli.test.js +125 -0
- package/src/cli/dev.js +26 -5
- package/src/cli/discover.js +26 -0
- package/src/cli/index.js +5 -2
- package/src/cli/scaffold.js +2 -2
- package/src/cli/start.js +5 -1
- package/src/mcp/mcp.test.js +44 -0
- package/src/mcp/server.js +406 -21
- package/src/runtime/ssr.js +5 -2
- package/src/server/index.js +19 -7
- package/src/server/server.test.js +24 -0
- package/types/server.d.ts +3 -0
package/src/cli/dev.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import path from 'path'
|
|
12
12
|
import fs from 'fs'
|
|
13
13
|
import { createServer } from '../server/index.js'
|
|
14
|
-
import { loadPages }
|
|
14
|
+
import { loadPages, loadStore } from './discover.js'
|
|
15
15
|
import * as log from './logger.js'
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -109,6 +109,10 @@ const PUBLIC_DIR = path.join(ROOT, 'public')
|
|
|
109
109
|
|
|
110
110
|
const specs = await loadPages(ROOT)
|
|
111
111
|
|
|
112
|
+
// Auto-discover the global store — pulse.store.js in the project root.
|
|
113
|
+
// Mirrors page discovery; no custom server entry needed.
|
|
114
|
+
const store = await loadStore(ROOT)
|
|
115
|
+
|
|
112
116
|
if (specs.length === 0) {
|
|
113
117
|
log.error('No pages found in src/pages/. Create a page to get started.')
|
|
114
118
|
process.exit(1)
|
|
@@ -192,6 +196,9 @@ async function triggerReload(label = 'File changed') {
|
|
|
192
196
|
try {
|
|
193
197
|
const fresh = await loadPages(ROOT, now)
|
|
194
198
|
updateSpecs(fresh)
|
|
199
|
+
// Hot-swap the store too — pulse.store.js edits take effect like spec edits
|
|
200
|
+
const freshStore = await loadStore(ROOT, now)
|
|
201
|
+
updateStore(freshStore)
|
|
195
202
|
log.info(`${label} — specs reloaded`)
|
|
196
203
|
checkUndefinedClasses(fresh)
|
|
197
204
|
} catch (err) {
|
|
@@ -204,6 +211,11 @@ async function triggerReload(label = 'File changed') {
|
|
|
204
211
|
// Watch src/ for changes to existing files (spec edits, component edits)
|
|
205
212
|
fs.watch(path.join(ROOT, 'src'), { recursive: true }, () => triggerReload('File changed'))
|
|
206
213
|
|
|
214
|
+
// Watch the store file — it lives in the project root, outside src/
|
|
215
|
+
if (fs.existsSync(path.join(ROOT, 'pulse.store.js'))) {
|
|
216
|
+
fs.watch(path.join(ROOT, 'pulse.store.js'), () => triggerReload('Store changed'))
|
|
217
|
+
}
|
|
218
|
+
|
|
207
219
|
// Also watch the pages directory explicitly so macOS reliably fires on new file creation.
|
|
208
220
|
// On macOS, fs.watch with recursive=true watches subdirectory contents but may not fire
|
|
209
221
|
// for the parent dir entry when a brand-new file is created. Watching the dir non-recursively
|
|
@@ -227,7 +239,7 @@ const reloadScript = (nonce) => `<script nonce="${nonce}">
|
|
|
227
239
|
})();
|
|
228
240
|
</script>`
|
|
229
241
|
|
|
230
|
-
const { updateSpecs } = await createServer(specs, {
|
|
242
|
+
const { updateSpecs, updateStore } = await createServer(specs, {
|
|
231
243
|
port: PORT,
|
|
232
244
|
stream: true,
|
|
233
245
|
staticDir: fs.existsSync(PUBLIC_DIR) ? PUBLIC_DIR : null,
|
|
@@ -235,6 +247,7 @@ const { updateSpecs } = await createServer(specs, {
|
|
|
235
247
|
extraBody: reloadScript,
|
|
236
248
|
dev: true,
|
|
237
249
|
agentMode: !!process.env.PULSE_AGENT_MODE,
|
|
250
|
+
...(store ? { store } : {}),
|
|
238
251
|
...(_config.csp ? { csp: _config.csp } : {}),
|
|
239
252
|
// Server options forwarded verbatim from pulse.config.js so dev behaves like
|
|
240
253
|
// production — keep in sync with start.js PASSTHROUGH_OPTIONS
|
|
@@ -262,11 +275,14 @@ const { updateSpecs } = await createServer(specs, {
|
|
|
262
275
|
// Strip server-only imports that can't resolve in the browser:
|
|
263
276
|
// - node:* built-ins (fs, path, etc.)
|
|
264
277
|
// - JSON files imported with `with { type: 'json' }` (e.g. package.json for version)
|
|
265
|
-
//
|
|
266
|
-
//
|
|
278
|
+
// - the package ROOT (@invisibleloop/pulse) — that export is the server
|
|
279
|
+
// itself (createServer, pushStore); the closing quote anchors the match
|
|
280
|
+
// so subpaths like @invisibleloop/pulse/ui are untouched
|
|
281
|
+
// These are only used inside spec.server/submit functions or module-level
|
|
282
|
+
// server init; the client runtime never calls those code paths.
|
|
267
283
|
let content = fs.readFileSync(filePath, 'utf-8')
|
|
268
284
|
content = content.replace(
|
|
269
|
-
/^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
|
|
285
|
+
/^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json|@invisibleloop\/pulse)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
|
|
270
286
|
''
|
|
271
287
|
)
|
|
272
288
|
res.writeHead(200, {
|
|
@@ -285,6 +301,11 @@ const { updateSpecs } = await createServer(specs, {
|
|
|
285
301
|
return serveFile(filePath)
|
|
286
302
|
}
|
|
287
303
|
|
|
304
|
+
// Global store — pulse.store.js lives in the project root (not src/), and the
|
|
305
|
+
// dev hydrate bootstrap imports it so client store mutations register.
|
|
306
|
+
// serveFile strips node:/server-only imports, same treatment as page specs.
|
|
307
|
+
if (url === '/pulse.store.js' && serveFile(path.join(ROOT, 'pulse.store.js'))) return false
|
|
308
|
+
|
|
288
309
|
// Project source — /src/pages/, /src/components/, /src/lib/, etc.
|
|
289
310
|
// Falls back to framework source for imports like /src/ui/ that sub-projects
|
|
290
311
|
// resolve from relative paths (e.g. docs component pages → ../../../../src/ui/).
|
package/src/cli/discover.js
CHANGED
|
@@ -111,3 +111,29 @@ export async function loadPages(projectRoot, bust = 0) {
|
|
|
111
111
|
|
|
112
112
|
return specs
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Auto-discover the global store definition from pulse.store.js in the
|
|
117
|
+
* project root. Returns the store definition with `hydrate` set to its
|
|
118
|
+
* browser-importable path (used by the dev inline bootstrap), or null when
|
|
119
|
+
* the file doesn't exist.
|
|
120
|
+
*
|
|
121
|
+
* Mirrors page auto-discovery: pulse.store.js is the documented filename
|
|
122
|
+
* convention, so CLI-managed projects (pulse dev / pulse start) get the
|
|
123
|
+
* store without writing a custom server entry.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} projectRoot
|
|
126
|
+
* @returns {Promise<object|null>}
|
|
127
|
+
*/
|
|
128
|
+
export async function loadStore(projectRoot, cacheBust = null) {
|
|
129
|
+
const storePath = path.join(projectRoot, 'pulse.store.js')
|
|
130
|
+
if (!fs.existsSync(storePath)) return null
|
|
131
|
+
|
|
132
|
+
const url = 'file://' + storePath + (cacheBust ? `?t=${cacheBust}` : '')
|
|
133
|
+
const mod = await import(url)
|
|
134
|
+
const def = mod.default
|
|
135
|
+
if (!def || typeof def !== 'object') {
|
|
136
|
+
throw new Error('pulse.store.js must export a default store object ({ state?, server?, mutations? })')
|
|
137
|
+
}
|
|
138
|
+
return { ...def, hydrate: def.hydrate || '/pulse.store.js' }
|
|
139
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -269,8 +269,11 @@ async function runStop(root) {
|
|
|
269
269
|
} catch { /* use default */ }
|
|
270
270
|
}
|
|
271
271
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
// Kill the dev server AND the local production server (dev port + 1) —
|
|
273
|
+
// pulse_build spawns the prod server detached, so without this it lingers
|
|
274
|
+
// after the session and blocks the next build's Lighthouse run.
|
|
275
|
+
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null; lsof -ti:${port + 1} | xargs kill -9 2>/dev/null; true`, { stdio: 'inherit' })
|
|
276
|
+
console.log(`\n⚡ Servers on ports ${port} (dev) and ${port + 1} (prod) stopped.\n`)
|
|
274
277
|
} catch { /* nothing was running */ }
|
|
275
278
|
}
|
|
276
279
|
|
package/src/cli/scaffold.js
CHANGED
|
@@ -207,7 +207,7 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
|
|
|
207
207
|
hooks: [
|
|
208
208
|
{
|
|
209
209
|
type: 'command',
|
|
210
|
-
command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let changed=new Set();let hasGit=false;try{execSync('git rev-parse --is-inside-work-tree',{stdio:'pipe'});hasGit=true;execSync('git status --porcelain',{encoding:'utf8'}).split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f)changed.add(f);});}catch{}if(!hasGit&&fs.existsSync('src/pages')){for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))changed.add('src/pages/'+f);}}const specs=[...changed].filter(f=>f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'));const missing=specs.filter(f=>{const t=f.replace(/\\.js$/,'.test.js');return!fs.existsSync(t)&&!changed.has(t);});if(missing.length){process.stdout.write(JSON.stringify({decision:'block',reason:'TESTS MISSING: '+missing.join(', ')+' — you must write tests before finishing. Use renderSync/render from @invisibleloop/pulse/testing to test view output, mutations, and any extracted logic functions. Run the tests with node to confirm they pass.\\n\\nEXCEPTION —
|
|
210
|
+
command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let changed=new Set();let hasGit=false;try{execSync('git rev-parse --is-inside-work-tree',{stdio:'pipe'});hasGit=true;execSync('git status --porcelain',{encoding:'utf8'}).split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f)changed.add(f);});}catch{}if(!hasGit&&fs.existsSync('src/pages')){for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))changed.add('src/pages/'+f);}}const specs=[...changed].filter(f=>f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'));const missing=specs.filter(f=>{const t=f.replace(/\\.js$/,'.test.js');return!fs.existsSync(t)&&!changed.has(t);});if(missing.length){process.stdout.write(JSON.stringify({decision:'block',reason:'TESTS MISSING: '+missing.join(', ')+' — you must write tests before finishing. Use renderSync/render from @invisibleloop/pulse/testing to test view output, mutations, and any extracted logic functions. Run the tests with node to confirm they pass.\\n\\nEXCEPTION — pausing for the user: if you are ending this turn to wait for the user\\'s answer (design-approval gate) OR to report progress mid-verification (e.g. between Lighthouse runs across multiple pages), call pulse_await_approval first, then end your turn. Do not write tests or run /verify on an unapproved design.'}));}"`,
|
|
211
211
|
statusMessage: 'Checking for missing tests...',
|
|
212
212
|
},
|
|
213
213
|
{
|
|
@@ -217,7 +217,7 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
|
|
|
217
217
|
},
|
|
218
218
|
{
|
|
219
219
|
type: 'command',
|
|
220
|
-
command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let specs=[];try{const out=execSync('git status --porcelain',{encoding:'utf8'});out.split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f&&f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'))specs.push(f);});}catch{if(fs.existsSync('src/pages'))for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))specs.push('src/pages/'+f);}}if(!specs.length)process.exit(0);const stamp='.pulse-verified';let stampMtime=0;try{stampMtime=fs.statSync(stamp).mtimeMs;}catch{}const stale=specs.filter(f=>{try{return fs.statSync(f).mtimeMs>stampMtime;}catch{return true;}});if(stale.length){process.stdout.write(JSON.stringify({decision:'block',reason:'VERIFY REQUIRED: These pages have not been verified since last edit:\\n'+stale.map(f=>' '+f).join('\\n')+'\\n\\nIMPORTANT — check first: are you ending this turn to wait for the user\\'s answer (e.g.
|
|
220
|
+
command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let specs=[];try{const out=execSync('git status --porcelain',{encoding:'utf8'});out.split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f&&f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'))specs.push(f);});}catch{if(fs.existsSync('src/pages'))for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))specs.push('src/pages/'+f);}}if(!specs.length)process.exit(0);const stamp='.pulse-verified';let stampMtime=0;try{stampMtime=fs.statSync(stamp).mtimeMs;}catch{}const stale=specs.filter(f=>{try{return fs.statSync(f).mtimeMs>stampMtime;}catch{return true;}});if(stale.length){process.stdout.write(JSON.stringify({decision:'block',reason:'VERIFY REQUIRED: These pages have not been verified since last edit:\\n'+stale.map(f=>' '+f).join('\\n')+'\\n\\nIMPORTANT — check first: are you ending this turn to wait for the user\\'s answer (design-approval question) or to report progress mid-verification (e.g. between Lighthouse runs across multiple pages)? If so, do NOT rush /verify — call pulse_await_approval and end your turn; this gate will let you stop, and the marker is consumed when the user replies.\\n\\nOtherwise run /verify for each changed page. /verify runs Lighthouse (desktop + mobile), performance trace, console check, and code review — then writes the stamp that clears this check.'}));}"`,
|
|
221
221
|
statusMessage: 'Checking verification stamp...',
|
|
222
222
|
}
|
|
223
223
|
]
|
package/src/cli/start.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import path from 'path'
|
|
12
12
|
import fs from 'fs'
|
|
13
13
|
import { createServer } from '../server/index.js'
|
|
14
|
-
import { loadPages }
|
|
14
|
+
import { loadPages, loadStore } from './discover.js'
|
|
15
15
|
|
|
16
16
|
const args = process.argv.slice(2)
|
|
17
17
|
const rootArg = args.indexOf('--root')
|
|
@@ -71,6 +71,9 @@ port = parseInt(process.env.PORT, 10) || port || 3000
|
|
|
71
71
|
|
|
72
72
|
const specs = await loadPages(ROOT)
|
|
73
73
|
|
|
74
|
+
// Auto-discover the global store — pulse.store.js in the project root
|
|
75
|
+
const store = await loadStore(ROOT)
|
|
76
|
+
|
|
74
77
|
if (specs.length === 0) {
|
|
75
78
|
console.error('No pages found in src/pages/.')
|
|
76
79
|
process.exit(1)
|
|
@@ -81,6 +84,7 @@ createServer(specs, {
|
|
|
81
84
|
stream: true,
|
|
82
85
|
staticDir: PUBLIC_DIR,
|
|
83
86
|
defaultCache,
|
|
87
|
+
...(store ? { store } : {}),
|
|
84
88
|
...(csp ? { csp } : {}),
|
|
85
89
|
...passthrough,
|
|
86
90
|
})
|
package/src/mcp/mcp.test.js
CHANGED
|
@@ -114,6 +114,8 @@ const SYNC_PAIRS = [
|
|
|
114
114
|
['src/agent/checklist.md', 'docs/.claude/pulse-checklist.md'],
|
|
115
115
|
['src/agent/checklist.md', 'examples/.claude/pulse-checklist.md'],
|
|
116
116
|
['src/agent/commands/verify.md', '.claude/commands/verify.md'],
|
|
117
|
+
['src/agent/coverage-check.js', 'docs/.claude/coverage-check.js'],
|
|
118
|
+
['src/agent/coverage-check.js', 'examples/.claude/coverage-check.js'],
|
|
117
119
|
]
|
|
118
120
|
|
|
119
121
|
for (const [a, b] of SYNC_PAIRS) {
|
|
@@ -277,6 +279,48 @@ test('approval pause mechanism is wired end to end', () => {
|
|
|
277
279
|
`Instruct: always call pulse_await_approval before asking, whichever way the question is asked.`)
|
|
278
280
|
})
|
|
279
281
|
|
|
282
|
+
test('pulse_validate prop-alias check excludes attrs blocks (false-positive fix)', () => {
|
|
283
|
+
// Regression: input({ attrs: { autocomplete } }) — the RECOMMENDED pattern —
|
|
284
|
+
// was flagged as a wrong top-level prop because the match crossed into the
|
|
285
|
+
// attrs object. The exclusion regex must be present in the alias loop.
|
|
286
|
+
assert(serverSrc.includes('attrs\\s*:\\s*\\{[^}]*$'),
|
|
287
|
+
'The PROP_ALIASES loop must exclude matches that fall inside an open attrs: { … } block')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('pulse_validate CSP check matches config by host, not full URL', () => {
|
|
291
|
+
// Regression: CSP sources are valid without a scheme (images.unsplash.com),
|
|
292
|
+
// but the config check required https?:// — flagging correctly configured
|
|
293
|
+
// projects on every validate.
|
|
294
|
+
assert(serverSrc.includes('configImgSrc.includes(host)'),
|
|
295
|
+
'External-image CSP check must test the config for the bare host')
|
|
296
|
+
assert(serverSrc.includes(`host: 'images.unsplash.com'`),
|
|
297
|
+
'External image host entries must carry a bare host field')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('validator resolves relative imports from the spec file\'s own directory', () => {
|
|
301
|
+
// Regression: the temp validation file was always written into src/pages/
|
|
302
|
+
// root, so subdirectory pages (src/pages/news/index.js) had their relative
|
|
303
|
+
// imports (../../components/layout.js) resolve from the wrong depth.
|
|
304
|
+
assert(/async function validateContent\(content, tmpDir = PAGES_DIR\)/.test(serverSrc),
|
|
305
|
+
'validateContent must accept a tmpDir parameter')
|
|
306
|
+
assert(serverSrc.includes('validateContent(content, path.dirname(fullPath))'),
|
|
307
|
+
'pulse_create_page must validate from the file\'s own directory')
|
|
308
|
+
assert(serverSrc.includes('validateContent(content, path.dirname(file))'),
|
|
309
|
+
'pulse_validate file mode must validate from the file\'s own directory')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('package-root import is stripped from client bundles in build and dev', () => {
|
|
313
|
+
// Regression: import { pushStore } from '@invisibleloop/pulse' (the documented
|
|
314
|
+
// live-push pattern) pulled the entire server — http, fs, zlib — into the
|
|
315
|
+
// browser bundle and broke `pulse build`.
|
|
316
|
+
const buildSrc = fs.readFileSync(path.join(ROOT, 'scripts/build.js'), 'utf8')
|
|
317
|
+
const devSrc = fs.readFileSync(path.join(ROOT, 'src/cli/dev.js'), 'utf8')
|
|
318
|
+
assert(buildSrc.includes(`'@invisibleloop/pulse/md', '@invisibleloop/pulse'`),
|
|
319
|
+
'build.js SERVER_ONLY_IMPORTS must include the package root')
|
|
320
|
+
assert(devSrc.includes(`@invisibleloop\\/pulse)['"]`),
|
|
321
|
+
'dev.js serveFile must strip package-root imports before serving specs to the browser')
|
|
322
|
+
})
|
|
323
|
+
|
|
280
324
|
test('pulse_check_contrast extracts variables from [data-theme="light"] blocks', () => {
|
|
281
325
|
// Regression: the light-theme block pattern was an ungrouped alternation
|
|
282
326
|
// (a|b) + '\\s*\\{([^}]+)\\}' — the body matcher bound only to the second
|