@invisibleloop/pulse 0.20.11 → 0.20.20
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 +6 -5
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +26 -0
- package/scripts/release-version.js +17 -8
- package/scripts/strip-server.test.js +60 -0
- package/src/agent/checklist.md +8 -12
- package/src/agent/commands/pulse-start.md +2 -0
- package/src/agent/commands/verify.md +30 -8
- package/src/agent/guide-components.md +7 -25
- package/src/agent/guide-routing.md +0 -12
- package/src/agent/guide-server.md +102 -1
- package/src/agent/guide-spec.md +8 -1
- package/src/agent/guide-styles.md +23 -0
- package/src/agent/identity.md +9 -0
- package/src/agent/workflow.md +4 -23
- package/src/cli/check-bundles.js +113 -0
- package/src/cli/check-bundles.test.js +137 -0
- package/src/cli/cli.test.js +105 -0
- package/src/cli/dev.js +48 -3
- package/src/cli/diagnose.js +105 -0
- package/src/cli/diagnose.test.js +124 -0
- package/src/cli/discover.js +9 -2
- package/src/cli/index.js +171 -9
- package/src/cli/process-utils.js +57 -0
- package/src/cli/process-utils.test.js +114 -0
- package/src/cli/review.js +391 -0
- package/src/cli/review.test.js +143 -0
- package/src/cli/scaffold.js +9 -4
- package/src/cli/stamp.js +102 -0
- package/src/cli/stamp.test.js +149 -0
- package/src/cli/start.js +15 -2
- package/src/cli/validate.js +184 -0
- package/src/cli/validate.test.js +153 -0
- package/src/mcp/mcp.test.js +261 -30
- package/src/mcp/server.js +207 -515
- package/src/mcp/validate-worker.js +18 -1
- package/src/runtime/index.js +144 -11
- package/src/runtime/runtime.test.js +199 -0
- package/src/runtime/ssr.js +13 -6
- package/src/runtime/ssr.test.js +31 -0
- package/src/server/index.js +162 -9
- package/src/server/server.test.js +225 -2
- package/src/spec/schema.js +1 -1
- package/src/ui/index.js +0 -5
- package/src/ui/ui.test.js +0 -75
- package/types/ui.d.ts +0 -21
- package/.claude/commands/build-page.md +0 -63
- package/.claude/commands/new-doc-page.md +0 -45
- package/.claude/commands/pulse-start.md +0 -11
- package/.claude/commands/pulse-stop.md +0 -5
- package/.claude/commands/verify.md +0 -118
- package/.claude/pulse-checklist.md +0 -189
- package/.github/workflows/ci.yml +0 -25
- package/.github/workflows/publish.yml +0 -45
- package/.pulse/load-reports/home/1773432711417.json +0 -22
- package/.pulse-verified +0 -1
- package/AGENTS.md +0 -697
- package/CLAUDE.md +0 -697
- package/README.md +0 -95
- package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
- package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
- package/benchmark/public/dist/manifest.json +0 -5
- package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
- package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/benchmark/results/2026-03-27-09-20.json +0 -188
- package/benchmark/results/2026-03-27-09-21.json +0 -188
- package/benchmark/results/2026-03-27-09-29.json +0 -188
- package/benchmark/results/2026-03-27-10-14.json +0 -188
- package/benchmark/results/2026-03-27-10-18.json +0 -232
- package/benchmark/src/pages/counter.js +0 -37
- package/benchmark/src/pages/home.js +0 -21
- package/docs/.claude/commands/pulse-build.md +0 -7
- package/docs/.claude/commands/pulse-dev.md +0 -7
- package/docs/.claude/commands/pulse-start.md +0 -9
- package/docs/.claude/commands/pulse-stop.md +0 -5
- package/docs/.claude/commands/verify.md +0 -84
- package/docs/.claude/coverage-check.js +0 -196
- package/docs/.claude/pulse-checklist.md +0 -189
- package/docs/public/.pulse-ui-version +0 -1
- package/docs/public/andypixel.css +0 -237
- package/docs/public/bookworms.css +0 -422
- package/docs/public/coutumes.css +0 -561
- package/docs/public/docs.css +0 -2388
- package/docs/public/favicon.svg +0 -4
- package/docs/public/home-brut.css +0 -982
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/img/heic1501a.jpg +0 -0
- package/docs/public/img/placeholder-landscape.svg +0 -18
- package/docs/public/img/placeholder-square.svg +0 -14
- package/docs/public/img/placeholder-tall.svg +0 -22
- package/docs/public/journey.css +0 -528
- package/docs/public/menu.js +0 -327
- package/docs/public/pulse-ui.css +0 -3028
- package/docs/public/pulse-ui.js +0 -257
- package/docs/public/retrowheels.css +0 -425
- package/docs/public/shop.css +0 -269
- package/docs/public/sparkys.css +0 -382
- package/docs/public/suzukibento.css +0 -517
- package/docs/public/swish.css +0 -394
- package/docs/public/theme.css +0 -52
- package/docs/public/themes/lumio.css +0 -50
- package/docs/public/themes/observatory.css +0 -104
- package/docs/server.js +0 -161
- package/docs/src/lib/component-page.js +0 -49
- package/docs/src/lib/highlight.js +0 -2
- package/docs/src/lib/layout.js +0 -190
- package/docs/src/lib/metrics-store.js +0 -6
- package/docs/src/lib/nav.js +0 -171
- package/docs/src/lib/stats.js +0 -81
- package/docs/src/pages/accessibility.js +0 -157
- package/docs/src/pages/actions.js +0 -194
- package/docs/src/pages/auth.js +0 -176
- package/docs/src/pages/caching.js +0 -95
- package/docs/src/pages/components/accordion.js +0 -48
- package/docs/src/pages/components/alert.js +0 -35
- package/docs/src/pages/components/app-badge.js +0 -41
- package/docs/src/pages/components/avatar.js +0 -35
- package/docs/src/pages/components/badge.js +0 -36
- package/docs/src/pages/components/breadcrumbs.js +0 -94
- package/docs/src/pages/components/button.js +0 -84
- package/docs/src/pages/components/card.js +0 -225
- package/docs/src/pages/components/charts.js +0 -283
- package/docs/src/pages/components/checkbox.js +0 -129
- package/docs/src/pages/components/cluster.js +0 -47
- package/docs/src/pages/components/container.js +0 -40
- package/docs/src/pages/components/cta.js +0 -54
- package/docs/src/pages/components/divider.js +0 -37
- package/docs/src/pages/components/empty.js +0 -36
- package/docs/src/pages/components/feature.js +0 -60
- package/docs/src/pages/components/fieldset.js +0 -65
- package/docs/src/pages/components/fileupload.js +0 -128
- package/docs/src/pages/components/footer.js +0 -58
- package/docs/src/pages/components/grid.js +0 -169
- package/docs/src/pages/components/heading.js +0 -107
- package/docs/src/pages/components/hero.js +0 -123
- package/docs/src/pages/components/icons.js +0 -410
- package/docs/src/pages/components/image.js +0 -71
- package/docs/src/pages/components/input.js +0 -51
- package/docs/src/pages/components/media.js +0 -51
- package/docs/src/pages/components/modal.js +0 -111
- package/docs/src/pages/components/nav.js +0 -136
- package/docs/src/pages/components/phone-frame.js +0 -74
- package/docs/src/pages/components/progress.js +0 -102
- package/docs/src/pages/components/prose.js +0 -111
- package/docs/src/pages/components/pullquote.js +0 -71
- package/docs/src/pages/components/radio.js +0 -194
- package/docs/src/pages/components/rating.js +0 -106
- package/docs/src/pages/components/search.js +0 -61
- package/docs/src/pages/components/section.js +0 -98
- package/docs/src/pages/components/segmented.js +0 -121
- package/docs/src/pages/components/select.js +0 -45
- package/docs/src/pages/components/slider.js +0 -115
- package/docs/src/pages/components/spinner.js +0 -73
- package/docs/src/pages/components/stack.js +0 -48
- package/docs/src/pages/components/stat.js +0 -55
- package/docs/src/pages/components/stepper.js +0 -66
- package/docs/src/pages/components/table.js +0 -45
- package/docs/src/pages/components/textarea.js +0 -31
- package/docs/src/pages/components/toggle.js +0 -84
- package/docs/src/pages/components/tooltip.js +0 -48
- package/docs/src/pages/components.js +0 -200
- package/docs/src/pages/config.js +0 -193
- package/docs/src/pages/constraints.js +0 -100
- package/docs/src/pages/deploy.js +0 -233
- package/docs/src/pages/error-pages.js +0 -99
- package/docs/src/pages/extending.js +0 -198
- package/docs/src/pages/faq.js +0 -99
- package/docs/src/pages/forms.js +0 -129
- package/docs/src/pages/getting-started.js +0 -147
- package/docs/src/pages/guard.js +0 -120
- package/docs/src/pages/home.js +0 -350
- package/docs/src/pages/how-it-works.js +0 -244
- package/docs/src/pages/hydration.js +0 -108
- package/docs/src/pages/images.js +0 -120
- package/docs/src/pages/markdown.js +0 -156
- package/docs/src/pages/meta.js +0 -193
- package/docs/src/pages/mutations.js +0 -107
- package/docs/src/pages/navigation.js +0 -85
- package/docs/src/pages/performance.js +0 -157
- package/docs/src/pages/persist.js +0 -89
- package/docs/src/pages/project-structure.js +0 -91
- package/docs/src/pages/prompt-examples.js +0 -186
- package/docs/src/pages/raw-responses.js +0 -122
- package/docs/src/pages/routing.js +0 -120
- package/docs/src/pages/server-api.js +0 -291
- package/docs/src/pages/server-data.js +0 -203
- package/docs/src/pages/slash-commands.js +0 -55
- package/docs/src/pages/spec.js +0 -207
- package/docs/src/pages/state.js +0 -102
- package/docs/src/pages/store.js +0 -203
- package/docs/src/pages/streaming.js +0 -107
- package/docs/src/pages/stripe.js +0 -194
- package/docs/src/pages/supabase.js +0 -324
- package/docs/src/pages/templates/andypixel.js +0 -260
- package/docs/src/pages/templates/blog-post.js +0 -226
- package/docs/src/pages/templates/bookworms.js +0 -227
- package/docs/src/pages/templates/coutumes.js +0 -237
- package/docs/src/pages/templates/journey.js +0 -186
- package/docs/src/pages/templates/mobile-app.js +0 -371
- package/docs/src/pages/templates/retrowheels.js +0 -355
- package/docs/src/pages/templates/shop.js +0 -268
- package/docs/src/pages/templates/sparkys.js +0 -264
- package/docs/src/pages/templates/suzukibento.js +0 -200
- package/docs/src/pages/templates/swish.js +0 -313
- package/docs/src/pages/templates.js +0 -534
- package/docs/src/pages/testing.js +0 -199
- package/docs/src/pages/validation.js +0 -138
- package/examples/.claude/commands/pulse-build.md +0 -7
- package/examples/.claude/commands/pulse-dev.md +0 -7
- package/examples/.claude/commands/pulse-start.md +0 -9
- package/examples/.claude/commands/pulse-stop.md +0 -5
- package/examples/.claude/commands/verify.md +0 -84
- package/examples/.claude/coverage-check.js +0 -196
- package/examples/.claude/pulse-checklist.md +0 -189
- package/examples/andypixel.css +0 -237
- package/examples/andypixel.js +0 -258
- package/examples/bookworms.css +0 -422
- package/examples/bookworms.js +0 -225
- package/examples/contact.js +0 -166
- package/examples/counter.js +0 -94
- package/examples/dev.server.js +0 -110
- package/examples/examples.test.js +0 -394
- package/examples/journey.css +0 -528
- package/examples/journey.js +0 -186
- package/examples/pricing.js +0 -262
- package/examples/products.js +0 -190
- package/examples/public/.pulse-ui-version +0 -1
- package/examples/public/pulse-ui.css +0 -3028
- package/examples/public/pulse-ui.js +0 -257
- package/examples/quiz.js +0 -208
- package/examples/retrowheels.css +0 -425
- package/examples/retrowheels.js +0 -353
- package/examples/shared.js +0 -84
- package/examples/suzukibento.css +0 -513
- package/examples/suzukibento.js +0 -200
- package/examples/swish.css +0 -394
- package/examples/swish.js +0 -311
- package/examples/todos.js +0 -162
- package/src/ui/fieldset.js +0 -37
- package/src/ui/phone-frame.js +0 -40
- package/src/ui/search.js +0 -78
- package/src/ui/segmented.js +0 -50
- package/src/ui/tooltip.js +0 -28
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — diagnose.js tests
|
|
3
|
+
* run: node src/cli/diagnose.test.js
|
|
4
|
+
*
|
|
5
|
+
* Shared logic behind pulse_diagnose/pulse_resolve_error (MCP tools) and
|
|
6
|
+
* `pulse diagnose`/`pulse resolve-error` (CLI commands) — a human running
|
|
7
|
+
* the CLI must see the exact same journal an agent sees.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { readJournal, formatJournal, resolveEntries, formatResolveResult } from './diagnose.js'
|
|
16
|
+
|
|
17
|
+
function tmpProject() {
|
|
18
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-diagnose-test-'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeJournal(root, entries) {
|
|
22
|
+
fs.mkdirSync(path.join(root, '.pulse'), { recursive: true })
|
|
23
|
+
fs.writeFileSync(path.join(root, '.pulse', 'errors.json'), JSON.stringify(entries, null, 2))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sampleEntry(overrides = {}) {
|
|
27
|
+
return {
|
|
28
|
+
id: 'abc-123', ts: '2026-01-01T00:00:00.000Z', route: '/dashboard',
|
|
29
|
+
phase: 'view', message: 'boom', stack: 'Error: boom', resolved: false,
|
|
30
|
+
...overrides,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('readJournal reports exists:false when no journal file exists', () => {
|
|
35
|
+
const root = tmpProject()
|
|
36
|
+
const result = readJournal(root)
|
|
37
|
+
assert.equal(result.exists, false)
|
|
38
|
+
assert.deepEqual(result.entries, [])
|
|
39
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('readJournal returns unresolved entries by default', () => {
|
|
43
|
+
const root = tmpProject()
|
|
44
|
+
writeJournal(root, [sampleEntry({ id: 'a', resolved: false }), sampleEntry({ id: 'b', resolved: true })])
|
|
45
|
+
const result = readJournal(root)
|
|
46
|
+
assert.equal(result.entries.length, 1)
|
|
47
|
+
assert.equal(result.entries[0].id, 'a')
|
|
48
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('readJournal with includeResolved:true returns both', () => {
|
|
52
|
+
const root = tmpProject()
|
|
53
|
+
writeJournal(root, [sampleEntry({ id: 'a', resolved: false }), sampleEntry({ id: 'b', resolved: true })])
|
|
54
|
+
const result = readJournal(root, { includeResolved: true })
|
|
55
|
+
assert.equal(result.entries.length, 2)
|
|
56
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('readJournal filters by route', () => {
|
|
60
|
+
const root = tmpProject()
|
|
61
|
+
writeJournal(root, [
|
|
62
|
+
sampleEntry({ id: 'a', route: '/dashboard' }),
|
|
63
|
+
sampleEntry({ id: 'b', route: '/other' }),
|
|
64
|
+
])
|
|
65
|
+
const result = readJournal(root, { route: '/dashboard' })
|
|
66
|
+
assert.equal(result.entries.length, 1)
|
|
67
|
+
assert.equal(result.entries[0].id, 'a')
|
|
68
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('formatJournal reports a clean message when nothing is recorded', () => {
|
|
72
|
+
const root = tmpProject()
|
|
73
|
+
const result = readJournal(root)
|
|
74
|
+
assert.match(formatJournal(result), /does not exist/)
|
|
75
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('formatJournal includes the route, phase, message, and stack for each entry', () => {
|
|
79
|
+
const root = tmpProject()
|
|
80
|
+
writeJournal(root, [sampleEntry()])
|
|
81
|
+
const result = readJournal(root)
|
|
82
|
+
const output = formatJournal(result)
|
|
83
|
+
assert.match(output, /\/dashboard/)
|
|
84
|
+
assert.match(output, /view/)
|
|
85
|
+
assert.match(output, /boom/)
|
|
86
|
+
assert.match(output, /Error: boom/)
|
|
87
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('resolveEntries by id marks only the matching entry', () => {
|
|
91
|
+
const root = tmpProject()
|
|
92
|
+
writeJournal(root, [sampleEntry({ id: 'a' }), sampleEntry({ id: 'b' })])
|
|
93
|
+
const result = resolveEntries(root, { id: 'a' })
|
|
94
|
+
assert.equal(result.count, 1)
|
|
95
|
+
const after = JSON.parse(fs.readFileSync(path.join(root, '.pulse', 'errors.json'), 'utf8'))
|
|
96
|
+
assert.equal(after.find(e => e.id === 'a').resolved, true)
|
|
97
|
+
assert.equal(after.find(e => e.id === 'b').resolved, false)
|
|
98
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('resolveEntries by route marks every unresolved entry on that route', () => {
|
|
102
|
+
const root = tmpProject()
|
|
103
|
+
writeJournal(root, [
|
|
104
|
+
sampleEntry({ id: 'a', route: '/x' }),
|
|
105
|
+
sampleEntry({ id: 'b', route: '/x' }),
|
|
106
|
+
sampleEntry({ id: 'c', route: '/y' }),
|
|
107
|
+
])
|
|
108
|
+
const result = resolveEntries(root, { route: '/x' })
|
|
109
|
+
assert.equal(result.count, 2)
|
|
110
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('resolveEntries with neither id nor route returns an error', () => {
|
|
114
|
+
const root = tmpProject()
|
|
115
|
+
const result = resolveEntries(root, {})
|
|
116
|
+
assert.match(result.error, /pass either id or route/)
|
|
117
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('formatResolveResult reports the count or "no matching entries"', () => {
|
|
121
|
+
assert.match(formatResolveResult({ count: 2, error: null }), /Resolved 2 entries/)
|
|
122
|
+
assert.match(formatResolveResult({ count: 1, error: null }), /Resolved 1 entry\./)
|
|
123
|
+
assert.match(formatResolveResult({ count: 0, error: null }), /No matching unresolved entries/)
|
|
124
|
+
})
|
package/src/cli/discover.js
CHANGED
|
@@ -91,9 +91,16 @@ export async function loadPages(projectRoot, bust = 0) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
// Auto-set hydrate only for pages that need client-side JS.
|
|
94
|
-
// Purely SSR pages (no mutations, actions, persist) ship zero JS.
|
|
94
|
+
// Purely SSR pages (no mutations, actions, persist, store) ship zero JS.
|
|
95
95
|
// spec.hydrate wins if explicitly provided.
|
|
96
|
-
|
|
96
|
+
//
|
|
97
|
+
// spec.store alone must count as a hydration need even with no local
|
|
98
|
+
// mutations/actions: mount() is what wires up data-store-event dispatch
|
|
99
|
+
// AND the live SSE store-push subscription (initLiveStore) — a
|
|
100
|
+
// store-only page with no client bundle would silently never receive
|
|
101
|
+
// pushStore() broadcasts and have a dead data-store-event button, with
|
|
102
|
+
// no error surfaced anywhere (server-rendered HTML looks correct).
|
|
103
|
+
const needsHydration = spec.hydrate || spec.mutations || spec.actions || spec.persist || spec.store?.length
|
|
97
104
|
const hydrateUrl = needsHydration
|
|
98
105
|
? spec.hydrate || ('/src/pages/' + path.relative(
|
|
99
106
|
path.join(projectRoot, 'src', 'pages'),
|
package/src/cli/index.js
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* pulse dev dev server only (no AI)
|
|
8
8
|
* pulse build production build → public/dist/
|
|
9
9
|
* pulse start production server (requires prior build)
|
|
10
|
+
* pulse validate <file> validate a spec — same check pulse_validate (MCP) runs, no agent needed
|
|
11
|
+
* pulse diagnose [--route </path>] [--include-resolved] read the dev-only error journal
|
|
12
|
+
* pulse resolve-error --id <id> | --route </path> mark error journal entries resolved
|
|
13
|
+
* pulse stamp [--route </path>] [--full|--quick] write .pulse-verified — same check pulse_stamp (MCP) runs
|
|
14
|
+
* pulse check-bundles inspect public/dist/ bundle contents (requires prior build)
|
|
15
|
+
* pulse review <file> [--quick] run the mechanized review checklist — same check pulse_review (MCP) runs
|
|
10
16
|
* pulse update re-copy pulse-ui.css/js from installed package → public/
|
|
11
17
|
* pulse --version print installed version and exit
|
|
12
18
|
* pulse -v alias for --version
|
|
@@ -18,6 +24,7 @@
|
|
|
18
24
|
import path from 'path'
|
|
19
25
|
import fs from 'fs'
|
|
20
26
|
import { scaffold } from './scaffold.js'
|
|
27
|
+
import { killPulseServerOnPort } from './process-utils.js'
|
|
21
28
|
|
|
22
29
|
// Extract --agent flag before routing, so it works alongside any subcommand
|
|
23
30
|
const rawArgs = process.argv.slice(2)
|
|
@@ -51,9 +58,11 @@ function isPulseProject(dir) {
|
|
|
51
58
|
async function runDev(root) {
|
|
52
59
|
const devScript = new URL('./dev.js', import.meta.url).pathname
|
|
53
60
|
const { spawn } = await import('child_process')
|
|
61
|
+
// Forward any extra flags (e.g. --port 3002) to the dev script
|
|
62
|
+
const extraArgs = args.slice(1)
|
|
54
63
|
const proc = spawn(
|
|
55
64
|
process.execPath,
|
|
56
|
-
[devScript, '--root', root],
|
|
65
|
+
[devScript, '--root', root, ...extraArgs],
|
|
57
66
|
{ stdio: 'inherit' }
|
|
58
67
|
)
|
|
59
68
|
proc.on('exit', code => process.exit(code ?? 0))
|
|
@@ -74,6 +83,124 @@ async function runBuild(root) {
|
|
|
74
83
|
proc.on('exit', code => process.exit(code ?? 0))
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// pulse validate — the same check pulse_validate (MCP) runs, reachable
|
|
88
|
+
// without an agent. Shares validate.js's logic exactly — never a second
|
|
89
|
+
// implementation that can drift from what an agent sees.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
async function runValidate(root, fileArg) {
|
|
93
|
+
const { validateFile, formatValidationResult } = await import('./validate.js')
|
|
94
|
+
|
|
95
|
+
if (!fileArg) {
|
|
96
|
+
console.error('Usage: pulse validate <file>\n <file> is a path to a spec file, relative to the current directory or absolute.')
|
|
97
|
+
process.exit(1)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const file = path.isAbsolute(fileArg) ? fileArg : path.resolve(root, fileArg)
|
|
101
|
+
const result = await validateFile(file, root)
|
|
102
|
+
console.log(formatValidationResult(result))
|
|
103
|
+
process.exit(result.valid ? 0 : 1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// pulse diagnose / pulse resolve-error — the dev-only error journal, reachable
|
|
108
|
+
// without an agent. Shares diagnose.js's logic exactly with pulse_diagnose
|
|
109
|
+
// and pulse_resolve_error (MCP tools).
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function flagValue(subArgs, name) {
|
|
113
|
+
const i = subArgs.indexOf(name)
|
|
114
|
+
return i !== -1 ? subArgs[i + 1] : undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function runDiagnose(root, subArgs) {
|
|
118
|
+
const { readJournal, formatJournal } = await import('./diagnose.js')
|
|
119
|
+
const route = flagValue(subArgs, '--route')
|
|
120
|
+
const includeResolved = subArgs.includes('--include-resolved')
|
|
121
|
+
|
|
122
|
+
const result = readJournal(root, { route, includeResolved })
|
|
123
|
+
console.log(formatJournal(result, { route, includeResolved }))
|
|
124
|
+
process.exit(result.error ? 1 : 0)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function runResolveError(root, subArgs) {
|
|
128
|
+
const { resolveEntries, formatResolveResult } = await import('./diagnose.js')
|
|
129
|
+
const id = flagValue(subArgs, '--id')
|
|
130
|
+
const route = flagValue(subArgs, '--route')
|
|
131
|
+
|
|
132
|
+
if (!id && !route) {
|
|
133
|
+
console.error('Usage: pulse resolve-error --id <id>\n or: pulse resolve-error --route </path>')
|
|
134
|
+
process.exit(1)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = resolveEntries(root, { id, route })
|
|
138
|
+
console.log(formatResolveResult(result))
|
|
139
|
+
process.exit(result.error ? 1 : 0)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// pulse stamp — write .pulse-verified, same check pulse_stamp (MCP) runs
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
async function runStamp(root, subArgs) {
|
|
147
|
+
const { writeStamp, formatStampResult } = await import('./stamp.js')
|
|
148
|
+
const route = flagValue(subArgs, '--route')
|
|
149
|
+
const mode = subArgs.includes('--full') ? 'full' : subArgs.includes('--quick') ? 'quick' : undefined
|
|
150
|
+
|
|
151
|
+
const result = writeStamp(root, { route, mode })
|
|
152
|
+
console.log(formatStampResult(result, { agentFacing: false }))
|
|
153
|
+
process.exit(result.stamped ? 0 : 1)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// pulse check-bundles — inspect production bundle contents (not just sizes),
|
|
158
|
+
// reachable without an agent. Shares check-bundles.js's logic exactly with
|
|
159
|
+
// pulse_check_bundles (MCP tool). Requires a prior `pulse build`.
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
async function runCheckBundles(root) {
|
|
163
|
+
const { checkBundles, formatBundleCheck } = await import('./check-bundles.js')
|
|
164
|
+
const result = checkBundles(root)
|
|
165
|
+
console.log(formatBundleCheck(result))
|
|
166
|
+
process.exit(result.error || result.issues.length > 0 ? 1 : 0)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// pulse review — the same checklist pulse_review (MCP) runs, reachable
|
|
171
|
+
// without an agent. Shares review.js's logic exactly — the report is
|
|
172
|
+
// reformatted for a human reader (no "you are now a reviewer" framing,
|
|
173
|
+
// no "continue to the verification workflow" — that's agent-only guidance).
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
async function runReview(root, subArgs) {
|
|
177
|
+
const quick = subArgs.includes('--quick')
|
|
178
|
+
const fileArg = subArgs.find(a => !a.startsWith('--'))
|
|
179
|
+
|
|
180
|
+
if (!fileArg) {
|
|
181
|
+
console.error('Usage: pulse review <file> [--quick]\n <file> is a path to a spec file, relative to the current directory or absolute.')
|
|
182
|
+
process.exit(1)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { runQuickReview, formatQuickReview, runFullReview, formatFullReview } = await import('./review.js')
|
|
186
|
+
const file = path.isAbsolute(fileArg) ? fileArg : path.resolve(root, fileArg)
|
|
187
|
+
if (!fs.existsSync(file)) {
|
|
188
|
+
console.error(`File not found: ${file}`)
|
|
189
|
+
process.exit(1)
|
|
190
|
+
}
|
|
191
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
192
|
+
|
|
193
|
+
if (quick) {
|
|
194
|
+
const result = await runQuickReview(source, file)
|
|
195
|
+
console.log(formatQuickReview(result, { agentFacing: false }))
|
|
196
|
+
process.exit(result.issues.length > 0 ? 1 : 0)
|
|
197
|
+
} else {
|
|
198
|
+
const result = await runFullReview(source, file)
|
|
199
|
+
console.log(formatFullReview(result, { source, file, agentFacing: false }))
|
|
200
|
+
process.exit(result.validationResult.includes('✓') ? 0 : 1)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
77
204
|
// ---------------------------------------------------------------------------
|
|
78
205
|
// pulse (no subcommand) — scaffold or start AI session
|
|
79
206
|
// ---------------------------------------------------------------------------
|
|
@@ -259,7 +386,6 @@ async function launchCopilotSession(root, mcpServerPath, spawn, os) {
|
|
|
259
386
|
// ---------------------------------------------------------------------------
|
|
260
387
|
|
|
261
388
|
async function runStop(root) {
|
|
262
|
-
const { execSync } = await import('child_process')
|
|
263
389
|
let port = 3000
|
|
264
390
|
const configPath = path.join(root, 'pulse.config.js')
|
|
265
391
|
if (fs.existsSync(configPath)) {
|
|
@@ -268,13 +394,25 @@ async function runStop(root) {
|
|
|
268
394
|
if (mod.default?.port) port = mod.default.port
|
|
269
395
|
} catch { /* use default */ }
|
|
270
396
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
397
|
+
// Kill the dev server AND the local production server (dev port + 1) —
|
|
398
|
+
// pulse_build spawns the prod server detached, so without this it lingers
|
|
399
|
+
// after the session and blocks the next build's Lighthouse run. Only
|
|
400
|
+
// kills a process that's actually a Pulse server — see
|
|
401
|
+
// killPulseServerOnPort's doc comment for why: a blind lsof|kill on the
|
|
402
|
+
// port used to kill whatever else happened to be listening there.
|
|
403
|
+
const dev = killPulseServerOnPort(port)
|
|
404
|
+
const prod = killPulseServerOnPort(port + 1)
|
|
405
|
+
const stopped = [
|
|
406
|
+
...(dev.killed.length ? [`${port} (dev)`] : []),
|
|
407
|
+
...(prod.killed.length ? [`${port + 1} (prod)`] : []),
|
|
408
|
+
]
|
|
409
|
+
if (stopped.length > 0) console.log(`\n⚡ Server${stopped.length > 1 ? 's' : ''} on ${stopped.join(' and ')} stopped.\n`)
|
|
410
|
+
for (const skipped of [...dev.skipped, ...prod.skipped]) {
|
|
411
|
+
console.log(`⚠ Left PID ${skipped.pid} running — not a Pulse server: ${skipped.command}`)
|
|
412
|
+
}
|
|
413
|
+
if (stopped.length === 0 && dev.skipped.length === 0 && prod.skipped.length === 0) {
|
|
414
|
+
console.log('\nNothing running on those ports.\n')
|
|
415
|
+
}
|
|
278
416
|
}
|
|
279
417
|
|
|
280
418
|
// ---------------------------------------------------------------------------
|
|
@@ -475,6 +613,12 @@ switch (command) {
|
|
|
475
613
|
${c.cyan('dev')} start the dev server
|
|
476
614
|
${c.cyan('build')} production build ${c.dim('→ public/dist/')}
|
|
477
615
|
${c.cyan('start')} production server ${c.dim('(requires prior build)')}
|
|
616
|
+
${c.cyan('validate')} ${c.dim('<file>')} validate a spec — the same check an agent runs, without an agent
|
|
617
|
+
${c.cyan('diagnose')} read the dev-only error journal ${c.dim('(.pulse/errors.json)')}
|
|
618
|
+
${c.cyan('resolve-error')} mark error journal entries resolved
|
|
619
|
+
${c.cyan('stamp')} write .pulse-verified ${c.dim('[--route </path>] [--full|--quick]')}
|
|
620
|
+
${c.cyan('check-bundles')} inspect public/dist/ bundle contents ${c.dim('(requires prior build)')}
|
|
621
|
+
${c.cyan('review')} ${c.dim('<file>')} run the mechanized review checklist ${c.dim('[--quick]')}
|
|
478
622
|
${c.cyan('update')} re-copy pulse-ui assets from installed package
|
|
479
623
|
|
|
480
624
|
${c.bold('Options:')}
|
|
@@ -503,6 +647,24 @@ switch (command) {
|
|
|
503
647
|
case 'start':
|
|
504
648
|
await runStart(CWD)
|
|
505
649
|
break
|
|
650
|
+
case 'validate':
|
|
651
|
+
await runValidate(CWD, args[1])
|
|
652
|
+
break
|
|
653
|
+
case 'diagnose':
|
|
654
|
+
await runDiagnose(CWD, args.slice(1))
|
|
655
|
+
break
|
|
656
|
+
case 'resolve-error':
|
|
657
|
+
await runResolveError(CWD, args.slice(1))
|
|
658
|
+
break
|
|
659
|
+
case 'stamp':
|
|
660
|
+
await runStamp(CWD, args.slice(1))
|
|
661
|
+
break
|
|
662
|
+
case 'check-bundles':
|
|
663
|
+
await runCheckBundles(CWD)
|
|
664
|
+
break
|
|
665
|
+
case 'review':
|
|
666
|
+
await runReview(CWD, args.slice(1))
|
|
667
|
+
break
|
|
506
668
|
case 'report-server':
|
|
507
669
|
await runReportServer(CWD)
|
|
508
670
|
break
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — process management helpers
|
|
3
|
+
*
|
|
4
|
+
* Shared between the MCP server (pulse_restart_server, pulse_build) and the
|
|
5
|
+
* CLI (`pulse stop`) — both need to free a port before starting a server,
|
|
6
|
+
* and both previously did it with a blind `lsof -ti:PORT | xargs kill -9`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from 'child_process'
|
|
10
|
+
|
|
11
|
+
/** Command-line substrings that identify a process as a Pulse server. */
|
|
12
|
+
export const PULSE_SERVER_MARKERS = ['cli/dev.js', 'cli/start.js', 'scripts/build.js']
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Kill only processes on `port` that are actually a Pulse dev/prod server —
|
|
16
|
+
* never an unconditional `lsof -ti:PORT | xargs kill -9`.
|
|
17
|
+
*
|
|
18
|
+
* That blind pattern used to be at every restart/build/stop call site: it
|
|
19
|
+
* kills whatever happens to be listening on the configured port, no
|
|
20
|
+
* ownership check at all. On a machine running other projects, a plain
|
|
21
|
+
* port collision (an unrelated Next.js/Vite/Rails dev server left running
|
|
22
|
+
* on 3000, say) means one Pulse command silently kills someone else's
|
|
23
|
+
* server. Found via a real user report: pulse_fetch_page returned a stale
|
|
24
|
+
* 500 from an unrelated project on port 3000, then the next
|
|
25
|
+
* pulse_restart_server call — which still ran the blind kill — coincided
|
|
26
|
+
* with the MCP connection itself dropping.
|
|
27
|
+
*
|
|
28
|
+
* Only kills a PID whose command line contains one of PULSE_SERVER_MARKERS
|
|
29
|
+
* — anything else on the port is left alone and reported back instead.
|
|
30
|
+
*
|
|
31
|
+
* @param {number} port
|
|
32
|
+
* @returns {{ killed: string[], skipped: { pid: string, command: string }[] }}
|
|
33
|
+
*/
|
|
34
|
+
export function killPulseServerOnPort(port) {
|
|
35
|
+
const result = { killed: [], skipped: [] }
|
|
36
|
+
let pids
|
|
37
|
+
try {
|
|
38
|
+
pids = execFileSync('sh', ['-c', `lsof -ti:${port} 2>/dev/null`], { encoding: 'utf8' })
|
|
39
|
+
.split('\n').map(s => s.trim()).filter(Boolean)
|
|
40
|
+
} catch {
|
|
41
|
+
return result // lsof found nothing — nothing to do
|
|
42
|
+
}
|
|
43
|
+
for (const pid of pids) {
|
|
44
|
+
let command = ''
|
|
45
|
+
try {
|
|
46
|
+
command = execFileSync('sh', ['-c', `ps -o command= -p ${pid} 2>/dev/null`], { encoding: 'utf8' }).trim()
|
|
47
|
+
} catch { continue } // process gone between lsof and ps — nothing to kill
|
|
48
|
+
if (!command) continue
|
|
49
|
+
if (PULSE_SERVER_MARKERS.some(marker => command.includes(marker))) {
|
|
50
|
+
try { execFileSync('sh', ['-c', `kill -9 ${pid} 2>/dev/null`]) } catch { /* already gone */ }
|
|
51
|
+
result.killed.push(pid)
|
|
52
|
+
} else {
|
|
53
|
+
result.skipped.push({ pid, command })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — process-utils.js tests
|
|
3
|
+
* run: node src/cli/process-utils.test.js
|
|
4
|
+
*
|
|
5
|
+
* killPulseServerOnPort must never kill a process it didn't spawn as a
|
|
6
|
+
* Pulse dev/prod server — see its doc comment for the real user report
|
|
7
|
+
* (an unrelated Next.js dev server on port 3000 was at risk of being
|
|
8
|
+
* killed by a blind lsof|kill) this fixes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import net from 'node:net'
|
|
15
|
+
import { killPulseServerOnPort } from './process-utils.js'
|
|
16
|
+
|
|
17
|
+
/** Find a free TCP port by binding to port 0 and reading it back. */
|
|
18
|
+
async function freePort() {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const srv = net.createServer()
|
|
21
|
+
srv.listen(0, () => {
|
|
22
|
+
const { port } = srv.address()
|
|
23
|
+
srv.close(() => resolve(port))
|
|
24
|
+
})
|
|
25
|
+
srv.on('error', reject)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Spawn a plain (non-Pulse) process holding a port open, for negative tests. */
|
|
30
|
+
function spawnPlainListener(port) {
|
|
31
|
+
const proc = spawn(process.execPath, ['-e', `
|
|
32
|
+
const net = require('net')
|
|
33
|
+
net.createServer().listen(${port})
|
|
34
|
+
setInterval(() => {}, 1000) // keep the process alive
|
|
35
|
+
`])
|
|
36
|
+
return proc
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function waitUntilListening(port, maxMs = 3000) {
|
|
40
|
+
const deadline = Date.now() + maxMs
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
const alive = await new Promise(resolve => {
|
|
43
|
+
const sock = net.createConnection(port, '127.0.0.1')
|
|
44
|
+
sock.on('connect', () => { sock.destroy(); resolve(true) })
|
|
45
|
+
sock.on('error', () => resolve(false))
|
|
46
|
+
})
|
|
47
|
+
if (alive) return true
|
|
48
|
+
await new Promise(r => setTimeout(r, 50))
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function waitUntilFree(port, maxMs = 3000) {
|
|
54
|
+
const deadline = Date.now() + maxMs
|
|
55
|
+
while (Date.now() < deadline) {
|
|
56
|
+
const alive = await new Promise(resolve => {
|
|
57
|
+
const sock = net.createConnection(port, '127.0.0.1')
|
|
58
|
+
sock.on('connect', () => { sock.destroy(); resolve(true) })
|
|
59
|
+
sock.on('error', () => resolve(false))
|
|
60
|
+
})
|
|
61
|
+
if (!alive) return true
|
|
62
|
+
await new Promise(r => setTimeout(r, 50))
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('killPulseServerOnPort does not kill a process whose command line has no Pulse marker', async () => {
|
|
68
|
+
const port = await freePort()
|
|
69
|
+
const proc = spawnPlainListener(port)
|
|
70
|
+
try {
|
|
71
|
+
assert.equal(await waitUntilListening(port), true, 'test listener never came up')
|
|
72
|
+
|
|
73
|
+
const result = killPulseServerOnPort(port)
|
|
74
|
+
|
|
75
|
+
assert.equal(result.killed.length, 0, 'must not report the unrelated process as killed')
|
|
76
|
+
assert.equal(result.skipped.length, 1, 'must report exactly one skipped process')
|
|
77
|
+
assert.equal(result.skipped[0].pid, String(proc.pid))
|
|
78
|
+
// The process must actually still be alive — not just unreported as killed.
|
|
79
|
+
assert.equal(await waitUntilFree(port, 300), false, 'the unrelated process must still be listening')
|
|
80
|
+
} finally {
|
|
81
|
+
proc.kill('SIGKILL')
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('killPulseServerOnPort kills a process whose command line matches a Pulse marker', async () => {
|
|
86
|
+
const port = await freePort()
|
|
87
|
+
// Simulate a Pulse dev server by giving the child process's argv a marker
|
|
88
|
+
// substring (cli/dev.js) without actually running the real dev server —
|
|
89
|
+
// exercises the same ps-based matching path a real `node .../cli/dev.js`
|
|
90
|
+
// invocation would hit.
|
|
91
|
+
const proc = spawn(process.execPath, ['-e', `
|
|
92
|
+
const net = require('net')
|
|
93
|
+
net.createServer().listen(${port})
|
|
94
|
+
setInterval(() => {}, 1000)
|
|
95
|
+
`, '--', 'cli/dev.js'])
|
|
96
|
+
try {
|
|
97
|
+
assert.equal(await waitUntilListening(port), true, 'test listener never came up')
|
|
98
|
+
|
|
99
|
+
const result = killPulseServerOnPort(port)
|
|
100
|
+
|
|
101
|
+
assert.equal(result.skipped.length, 0, 'must not skip a process matching a Pulse marker')
|
|
102
|
+
assert.equal(result.killed.length, 1)
|
|
103
|
+
assert.equal(result.killed[0], String(proc.pid))
|
|
104
|
+
assert.equal(await waitUntilFree(port), true, 'the matched process must actually be killed')
|
|
105
|
+
} finally {
|
|
106
|
+
try { proc.kill('SIGKILL') } catch { /* already dead */ }
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('killPulseServerOnPort on a free port returns empty killed and skipped', async () => {
|
|
111
|
+
const port = await freePort()
|
|
112
|
+
const result = killPulseServerOnPort(port)
|
|
113
|
+
assert.deepEqual(result, { killed: [], skipped: [] })
|
|
114
|
+
})
|