@invisibleloop/pulse 0.1.39 → 0.2.0

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,483 @@
1
+ /**
2
+ * Pulse — Performance Benchmark
3
+ *
4
+ * Measures server-side performance metrics before/after optimisation work.
5
+ * Starts an isolated test server — no external dependencies.
6
+ *
7
+ * Usage:
8
+ * node scripts/bench.js # run all suites, print table
9
+ * node scripts/bench.js --save # also write benchmark/results/YYYY-MM-DD-HH-MM.json
10
+ * node scripts/bench.js --compare # compare latest two saved results
11
+ *
12
+ * Metrics captured per suite:
13
+ * ttfb — Time to first byte (ms): headers received
14
+ * total — Full response time (ms): all bytes received
15
+ * bodyBytes — Raw response body size (bytes)
16
+ * serverData — Server-Timing: data fetcher duration (ms)
17
+ * serverRender— Server-Timing: view render duration (ms)
18
+ * serverTotal — Server-Timing: total server processing (ms)
19
+ *
20
+ * Suites:
21
+ * simple — Page with no server data (routing + render overhead only)
22
+ * data-1 — Page with 1 server fetcher (simulated 20ms DB query)
23
+ * data-3 — Page with 3 parallel server fetchers (20ms, 30ms, 10ms)
24
+ * nav-simple — Client navigation (X-Pulse-Navigate) on simple page
25
+ * nav-data — Client navigation on data page
26
+ * cached — Repeated request to a spec.cache page (in-process cache hit)
27
+ */
28
+
29
+ import http from 'http'
30
+ import fs from 'fs'
31
+ import path from 'path'
32
+ import { performance } from 'perf_hooks'
33
+ import { fileURLToPath } from 'url'
34
+ import { createServer } from '../src/server/index.js'
35
+
36
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
37
+ const ROOT = path.join(__dirname, '..')
38
+ const RESULTS = path.join(ROOT, 'benchmark', 'results')
39
+
40
+ const WARMUP = 10
41
+ const ITERATIONS = 100
42
+ const PORT = 19876
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Benchmark specs — deterministic fake latency simulates real DB queries
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const simpleSpec = {
49
+ route: '/bench/simple',
50
+ hydrate: '/bench/simple.js',
51
+ meta: { title: 'Bench — Simple', description: 'Benchmark page', styles: ['/pulse.css'] },
52
+ state: {},
53
+ view: () => `
54
+ <main id="main-content">
55
+ <h1>Simple page</h1>
56
+ <p>No server data — pure routing and render overhead.</p>
57
+ </main>`,
58
+ }
59
+
60
+ const data1Spec = {
61
+ route: '/bench/data-1',
62
+ hydrate: '/bench/data-1.js',
63
+ meta: { title: 'Bench — 1 Fetcher', description: 'Benchmark page', styles: ['/pulse.css'] },
64
+ state: {},
65
+ server: {
66
+ items: async () => {
67
+ await sleep(20)
68
+ return Array.from({ length: 50 }, (_, i) => ({ id: i, name: `Item ${i}`, value: i * 3.14 }))
69
+ },
70
+ },
71
+ view: (state, { items }) => `
72
+ <main id="main-content">
73
+ <h1>Data page — 1 fetcher</h1>
74
+ <ul>${items.map(it => `<li>${it.name}: ${it.value.toFixed(2)}</li>`).join('')}</ul>
75
+ </main>`,
76
+ }
77
+
78
+ const data3Spec = {
79
+ route: '/bench/data-3',
80
+ hydrate: '/bench/data-3.js',
81
+ meta: { title: 'Bench — 3 Fetchers', description: 'Benchmark page', styles: ['/pulse.css'] },
82
+ state: {},
83
+ server: {
84
+ fast: async () => { await sleep(10); return { label: 'fast', value: 42 } },
85
+ medium: async () => { await sleep(30); return { label: 'medium', value: 99 } },
86
+ slow: async () => { await sleep(20); return { label: 'slow', value: 7 } },
87
+ },
88
+ view: (state, { fast, medium, slow }) => `
89
+ <main id="main-content">
90
+ <h1>Data page — 3 parallel fetchers</h1>
91
+ <p>Fast: ${fast.value} · Medium: ${medium.value} · Slow: ${slow.value}</p>
92
+ </main>`,
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Streaming scope specs — demonstrate segment-level data scoping
97
+ //
98
+ // Both specs have the same two fetchers:
99
+ // user — 20ms (needed by the shell 'header' segment)
100
+ // posts — 100ms (needed by the deferred 'feed' segment)
101
+ //
102
+ // scoped: shell only awaits 'user' → shell arrives at ~20ms
103
+ // unscoped: shell awaits ALL fetchers → shell blocked until ~100ms
104
+ //
105
+ // "shell time" = when the shell content chunk appears in the response stream.
106
+ // "total time" = when all bytes (including deferred) have arrived.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ const streamScopedSpec = {
110
+ route: '/bench/stream-scoped',
111
+ hydrate: '/bench/stream-scoped.js',
112
+ meta: { title: 'Bench — Stream Scoped', description: 'Benchmark page', styles: ['/pulse.css'] },
113
+ state: {},
114
+ server: {
115
+ user: async () => { await sleep(20); return { name: 'Alice' } },
116
+ posts: async () => { await sleep(100); return [{ id: 1, title: 'Post 1' }, { id: 2, title: 'Post 2' }] },
117
+ },
118
+ stream: {
119
+ shell: ['header'],
120
+ deferred: ['feed'],
121
+ scope: { header: ['user'], feed: ['posts'] }, // ← key: shell doesn't wait for posts
122
+ },
123
+ view: {
124
+ header: (s, { user }) => `<header data-bench="shell">Hello ${user.name}</header>`,
125
+ feed: (s, { posts }) => `<main>${posts.map(p => `<h2>${p.title}</h2>`).join('')}</main>`,
126
+ },
127
+ }
128
+
129
+ const streamUnscopedSpec = {
130
+ route: '/bench/stream-unscoped',
131
+ hydrate: '/bench/stream-unscoped.js',
132
+ meta: { title: 'Bench — Stream Unscoped', description: 'Benchmark page', styles: ['/pulse.css'] },
133
+ state: {},
134
+ server: {
135
+ user: async () => { await sleep(20); return { name: 'Alice' } },
136
+ posts: async () => { await sleep(100); return [{ id: 1, title: 'Post 1' }, { id: 2, title: 'Post 2' }] },
137
+ },
138
+ stream: {
139
+ shell: ['header'],
140
+ deferred: ['feed'],
141
+ // no scope — shell awaits ALL fetchers before writing (baseline behaviour)
142
+ },
143
+ view: {
144
+ header: (s, { user }) => `<header data-bench="shell">Hello ${user.name}</header>`,
145
+ feed: (s, { posts }) => `<main>${posts.map(p => `<h2>${p.title}</h2>`).join('')}</main>`,
146
+ },
147
+ }
148
+
149
+ // Cached spec — no server data so in-process page cache applies
150
+ const cachedSpec = {
151
+ route: '/bench/cached',
152
+ hydrate: '/bench/cached.js',
153
+ meta: { title: 'Bench — Cached', description: 'Benchmark page', styles: ['/pulse.css'] },
154
+ state: {},
155
+ cache: { public: true, maxAge: 60 },
156
+ view: () => `
157
+ <main id="main-content">
158
+ <h1>Cached page</h1>
159
+ <p>This response is served from the in-process page cache after the first request.</p>
160
+ </main>`,
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // HTTP helpers
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function sleep(ms) {
168
+ return new Promise(r => setTimeout(r, ms))
169
+ }
170
+
171
+ function request(path, headers = {}) {
172
+ return new Promise((resolve, reject) => {
173
+ const t0 = performance.now()
174
+ let ttfb = null
175
+
176
+ const req = http.get({ hostname: 'localhost', port: PORT, path, headers }, res => {
177
+ ttfb = performance.now() - t0
178
+ const chunks = []
179
+ res.on('data', c => chunks.push(c))
180
+ res.on('end', () => {
181
+ const total = performance.now() - t0
182
+ const body = Buffer.concat(chunks)
183
+ const timing = parseServerTiming(res.headers['server-timing'])
184
+ resolve({ ttfb, total, bodyBytes: body.length, status: res.statusCode, timing, headers: res.headers })
185
+ })
186
+ })
187
+
188
+ req.on('error', reject)
189
+ req.setTimeout(5000, () => { req.destroy(new Error('timeout')) })
190
+ })
191
+ }
192
+
193
+ /**
194
+ * Make a streaming HTTP request and track when a shell content marker
195
+ * first appears in the response body. Used for the stream-scope suites.
196
+ *
197
+ * shellTime — elapsed ms when the marker string appears in any chunk
198
+ * total — elapsed ms when all bytes have arrived (connection closed)
199
+ */
200
+ function streamRequest(path, shellMarker) {
201
+ return new Promise((resolve, reject) => {
202
+ const t0 = performance.now()
203
+ let shellTime = null
204
+ let buffer = ''
205
+
206
+ const req = http.get({ hostname: 'localhost', port: PORT, path }, res => {
207
+ res.on('data', chunk => {
208
+ buffer += chunk.toString()
209
+ if (shellTime === null && buffer.includes(shellMarker)) {
210
+ shellTime = performance.now() - t0
211
+ }
212
+ })
213
+ res.on('end', () => {
214
+ resolve({ shellTime, total: performance.now() - t0, bodyBytes: buffer.length })
215
+ })
216
+ })
217
+
218
+ req.on('error', reject)
219
+ req.setTimeout(5000, () => req.destroy(new Error('timeout')))
220
+ })
221
+ }
222
+
223
+ function parseServerTiming(header) {
224
+ if (!header) return {}
225
+ const result = {}
226
+ for (const part of header.split(',')) {
227
+ const [name, ...attrs] = part.trim().split(';')
228
+ const dur = attrs.find(a => a.trim().startsWith('dur='))
229
+ if (dur) result[name.trim()] = parseFloat(dur.split('=')[1])
230
+ }
231
+ return result
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Statistics
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function stats(values) {
239
+ const sorted = [...values].sort((a, b) => a - b)
240
+ const n = sorted.length
241
+ return {
242
+ mean: values.reduce((s, v) => s + v, 0) / n,
243
+ p50: sorted[Math.floor(n * 0.50)],
244
+ p95: sorted[Math.floor(n * 0.95)],
245
+ p99: sorted[Math.floor(n * 0.99)],
246
+ min: sorted[0],
247
+ max: sorted[n - 1],
248
+ }
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Run a suite
253
+ // ---------------------------------------------------------------------------
254
+
255
+ async function runSuite(label, path, headers = {}) {
256
+ process.stdout.write(` ${label.padEnd(20)}`)
257
+
258
+ // Warm up — prime any caches, JIT, keep-alive connections
259
+ for (let i = 0; i < WARMUP; i++) await request(path, headers)
260
+
261
+ const results = []
262
+ for (let i = 0; i < ITERATIONS; i++) {
263
+ results.push(await request(path, headers))
264
+ }
265
+
266
+ const ttfbs = results.map(r => r.ttfb)
267
+ const totals = results.map(r => r.total)
268
+ const serverData = results.map(r => r.timing.data ?? 0).filter(v => v > 0)
269
+ const serverRend = results.map(r => r.timing.render ?? 0).filter(v => v > 0)
270
+ const bodyBytes = results[0].bodyBytes
271
+
272
+ const out = {
273
+ label,
274
+ path,
275
+ iterations: ITERATIONS,
276
+ ttfb: stats(ttfbs),
277
+ total: stats(totals),
278
+ bodyBytes,
279
+ serverData: serverData.length ? stats(serverData) : null,
280
+ serverRender: serverRend.length ? stats(serverRend) : null,
281
+ }
282
+
283
+ // Quick inline summary
284
+ const t = n => n.toFixed(1).padStart(6)
285
+ process.stdout.write(
286
+ `ttfb p50${t(out.ttfb.p50)}ms p95${t(out.ttfb.p95)}ms p99${t(out.ttfb.p99)}ms` +
287
+ (out.serverData ? ` data p50${t(out.serverData.p50)}ms` : '') +
288
+ (out.serverRender ? ` render p50${t(out.serverRender.p50)}ms` : '') +
289
+ ` body ${(bodyBytes / 1024).toFixed(1)}kB\n`
290
+ )
291
+
292
+ return out
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Run a streaming scope suite
297
+ // Reports shell arrival time (when shell content first appears in the stream)
298
+ // vs total time (all bytes received). The gap shows how much sooner the
299
+ // browser can start rendering when deferred-only fetchers don't block the shell.
300
+ // ---------------------------------------------------------------------------
301
+
302
+ async function runStreamSuite(label, path) {
303
+ const SHELL_MARKER = 'data-bench="shell"'
304
+ process.stdout.write(` ${label.padEnd(22)}`)
305
+
306
+ for (let i = 0; i < WARMUP; i++) await streamRequest(path, SHELL_MARKER)
307
+
308
+ const results = []
309
+ for (let i = 0; i < ITERATIONS; i++) {
310
+ results.push(await streamRequest(path, SHELL_MARKER))
311
+ }
312
+
313
+ const shellStats = stats(results.map(r => r.shellTime))
314
+ const totalStats = stats(results.map(r => r.total))
315
+ const bodyBytes = results[0].bodyBytes
316
+
317
+ const t = n => n.toFixed(1).padStart(6)
318
+ process.stdout.write(
319
+ `shell p50${t(shellStats.p50)}ms p95${t(shellStats.p95)}ms` +
320
+ ` total p50${t(totalStats.p50)}ms p95${t(totalStats.p95)}ms` +
321
+ ` body ${(bodyBytes / 1024).toFixed(1)}kB\n`
322
+ )
323
+
324
+ return { label, path, iterations: ITERATIONS, shellTime: shellStats, total: totalStats, bodyBytes }
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Bundle sizes
329
+ // ---------------------------------------------------------------------------
330
+
331
+ function measureBundles() {
332
+ const distDir = path.join(ROOT, 'public', 'dist')
333
+ if (!fs.existsSync(distDir)) return null
334
+
335
+ const files = fs.readdirSync(distDir).filter(f => f.endsWith('.js'))
336
+ const result = {}
337
+ for (const f of files) {
338
+ const bytes = fs.statSync(path.join(distDir, f)).size
339
+ const key = f.includes('runtime') ? 'runtime' : f.replace(/\.[^.]+$/, '').replace(/-[A-Z0-9]+$/, '')
340
+ result[key] = { file: f, bytes }
341
+ }
342
+ return result
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Compare two result files
347
+ // ---------------------------------------------------------------------------
348
+
349
+ function compare() {
350
+ const files = fs.readdirSync(RESULTS)
351
+ .filter(f => f.endsWith('.json'))
352
+ .sort()
353
+ .slice(-2)
354
+
355
+ if (files.length < 2) {
356
+ console.log('Need at least 2 saved results to compare. Run with --save first.')
357
+ return
358
+ }
359
+
360
+ const [before, after] = files.map(f => JSON.parse(fs.readFileSync(path.join(RESULTS, f), 'utf8')))
361
+
362
+ console.log(`\nComparing: ${files[0]} → ${files[1]}\n`)
363
+ console.log('Suite'.padEnd(22) + 'Metric'.padEnd(16) + 'Before'.padStart(10) + 'After'.padStart(10) + 'Delta'.padStart(10) + ' Change')
364
+ console.log('─'.repeat(78))
365
+
366
+ const fmt = n => n?.toFixed(2).padStart(10) ?? ' —'
367
+ const diff = (a, b) => {
368
+ if (a == null || b == null) return ' —'
369
+ const d = b - a
370
+ const pct = ((d / a) * 100).toFixed(1)
371
+ const sign = d > 0 ? '+' : ''
372
+ return `${(sign + d.toFixed(2)).padStart(10)} ${sign}${pct}%`
373
+ }
374
+
375
+ for (const suite of before.suites) {
376
+ const afterSuite = after.suites.find(s => s.label === suite.label)
377
+ if (!afterSuite) continue
378
+
379
+ // Streaming scope suites use shellTime instead of ttfb
380
+ const rows = suite.shellTime ? [
381
+ ['shell p50', suite.shellTime.p50, afterSuite.shellTime?.p50],
382
+ ['shell p95', suite.shellTime.p95, afterSuite.shellTime?.p95],
383
+ ['total p50', suite.total.p50, afterSuite.total.p50],
384
+ ['body kB', suite.bodyBytes / 1024, afterSuite.bodyBytes / 1024],
385
+ ] : [
386
+ ['ttfb p50', suite.ttfb.p50, afterSuite.ttfb.p50],
387
+ ['ttfb p95', suite.ttfb.p95, afterSuite.ttfb.p95],
388
+ ['total p50', suite.total.p50, afterSuite.total.p50],
389
+ ['body kB', suite.bodyBytes / 1024, afterSuite.bodyBytes / 1024],
390
+ ]
391
+ if (suite.serverData) rows.push(['server data p50', suite.serverData.p50, afterSuite.serverData?.p50])
392
+ if (suite.serverRender) rows.push(['server render p50', suite.serverRender.p50, afterSuite.serverRender?.p50])
393
+
394
+ rows.forEach(([metric, b, a], i) => {
395
+ const label = i === 0 ? suite.label : ''
396
+ console.log(label.padEnd(22) + metric.padEnd(16) + fmt(b) + fmt(a) + diff(b, a))
397
+ })
398
+ console.log()
399
+ }
400
+
401
+ if (before.bundles && after.bundles) {
402
+ console.log('Bundle sizes:')
403
+ for (const [key, b] of Object.entries(before.bundles)) {
404
+ const a = after.bundles[key]
405
+ if (!a) continue
406
+ const d = a.bytes - b.bytes
407
+ console.log(` ${key.padEnd(20)} ${(b.bytes/1024).toFixed(2)}kB → ${(a.bytes/1024).toFixed(2)}kB (${d > 0 ? '+' : ''}${d}B)`)
408
+ }
409
+ }
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Main
414
+ // ---------------------------------------------------------------------------
415
+
416
+ async function main() {
417
+ const args = process.argv.slice(2)
418
+ const doSave = args.includes('--save')
419
+ const doComp = args.includes('--compare')
420
+
421
+ if (doComp) { compare(); return }
422
+
423
+ console.log(`\nPulse Performance Benchmark`)
424
+ console.log(`Warmup: ${WARMUP} req · Iterations: ${ITERATIONS} req per suite\n`)
425
+
426
+ // Start benchmark server
427
+ const { server } = createServer(
428
+ [simpleSpec, data1Spec, data3Spec, cachedSpec, streamScopedSpec, streamUnscopedSpec],
429
+ { port: PORT, stream: true, dev: false }
430
+ )
431
+ await sleep(100) // let server start
432
+
433
+ const suites = []
434
+
435
+ console.log('Server-side suites (streaming SSR):')
436
+ suites.push(await runSuite('simple', '/bench/simple'))
437
+ suites.push(await runSuite('data-1', '/bench/data-1'))
438
+ suites.push(await runSuite('data-3', '/bench/data-3'))
439
+ suites.push(await runSuite('cached', '/bench/cached'))
440
+
441
+ console.log('\nClient navigation suites (X-Pulse-Navigate):')
442
+ suites.push(await runSuite('nav/simple', '/bench/simple', { 'x-pulse-navigate': 'true' }))
443
+ suites.push(await runSuite('nav/data-1', '/bench/data-1', { 'x-pulse-navigate': 'true' }))
444
+ suites.push(await runSuite('nav/data-3', '/bench/data-3', { 'x-pulse-navigate': 'true' }))
445
+
446
+ console.log('\nStreaming scope suites (shell arrival time vs total):')
447
+ console.log(' (shell = when first view content appears in stream; total = all bytes received)')
448
+ suites.push(await runStreamSuite('stream/scoped', '/bench/stream-scoped'))
449
+ suites.push(await runStreamSuite('stream/unscoped', '/bench/stream-unscoped'))
450
+
451
+ // Bundle sizes
452
+ const bundles = measureBundles()
453
+ if (bundles) {
454
+ console.log('\nBundle sizes (uncompressed):')
455
+ for (const [key, { file, bytes }] of Object.entries(bundles)) {
456
+ console.log(` ${key.padEnd(20)} ${(bytes / 1024).toFixed(2)}kB (${file})`)
457
+ }
458
+ }
459
+
460
+ // Shut down
461
+ server.close()
462
+
463
+ // Save results
464
+ if (doSave) {
465
+ fs.mkdirSync(RESULTS, { recursive: true })
466
+ const now = new Date()
467
+ const pad = n => String(n).padStart(2, '0')
468
+ const name = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}-${pad(now.getHours())}-${pad(now.getMinutes())}.json`
469
+ const out = {
470
+ timestamp: now.toISOString(),
471
+ node: process.version,
472
+ suites,
473
+ bundles,
474
+ }
475
+ fs.writeFileSync(path.join(RESULTS, name), JSON.stringify(out, null, 2))
476
+ console.log(`\nSaved → benchmark/results/${name}`)
477
+ console.log('Run again with --save after changes, then --compare to see the diff.')
478
+ } else {
479
+ console.log('\nRun with --save to record this baseline.')
480
+ }
481
+ }
482
+
483
+ main().catch(err => { console.error(err); process.exit(1) })
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Determine the next version from conventional commits.
4
+ *
5
+ * Reads all commits since the last "chore: release X.Y.Z [skip ci]" commit,
6
+ * applies conventional commit rules to determine the bump level, then prints
7
+ * the new version string to stdout.
8
+ *
9
+ * Bump rules:
10
+ * BREAKING CHANGE footer or "type!:" prefix → major
11
+ * feat: → minor
12
+ * anything else (fix, perf, chore, docs…) → patch
13
+ *
14
+ * Usage: node scripts/release-version.js
15
+ */
16
+
17
+ import { execSync } from 'child_process'
18
+ import { readFileSync } from 'fs'
19
+
20
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
21
+ const [major, minor, patch] = pkg.version.split('.').map(Number)
22
+
23
+ // Collect commit subjects since the last release commit.
24
+ // git log outputs newest-first; we stop at the first release commit we see.
25
+ let commits = []
26
+ try {
27
+ const log = execSync('git log --pretty=format:%s', { encoding: 'utf8' }).trim()
28
+ for (const line of log.split('\n')) {
29
+ if (/^chore: release \d+\.\d+\.\d+/.test(line)) break
30
+ if (line) commits.push(line)
31
+ }
32
+ } catch {
33
+ // No git history — default to patch
34
+ }
35
+
36
+ // Determine bump level
37
+ let bump = 'patch'
38
+ for (const msg of commits) {
39
+ // Breaking change: "type!:" or "BREAKING CHANGE" anywhere in the subject
40
+ if (/^[a-z]+(\([^)]+\))?!:/.test(msg) || msg.includes('BREAKING CHANGE')) {
41
+ bump = 'major'
42
+ break
43
+ }
44
+ // New feature: "feat:" or "feat(scope):"
45
+ if (/^feat(\([^)]+\))?:/.test(msg) && bump !== 'major') {
46
+ bump = 'minor'
47
+ }
48
+ }
49
+
50
+ // Compute new version
51
+ let next
52
+ if (bump === 'major') next = `${major + 1}.0.0`
53
+ else if (bump === 'minor') next = `${major}.${minor + 1}.0`
54
+ else next = `${major}.${minor}.${patch + 1}`
55
+
56
+ process.stdout.write(next + '\n')
package/src/cli/index.js CHANGED
@@ -8,6 +8,10 @@
8
8
  * pulse build production build → public/dist/
9
9
  * pulse start production server (requires prior build)
10
10
  * pulse update re-copy pulse-ui.css/js from installed package → public/
11
+ * pulse --version print installed version and exit
12
+ * pulse -v alias for --version
13
+ * pulse --help show usage and exit
14
+ * pulse -h alias for --help
11
15
  */
12
16
 
13
17
  import path from 'path'
@@ -329,6 +333,32 @@ async function runStart(root) {
329
333
  // ---------------------------------------------------------------------------
330
334
 
331
335
  switch (command) {
336
+ case '--version':
337
+ case '-v': {
338
+ const pkgPath = new URL('../../package.json', import.meta.url).pathname
339
+ const { version } = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
340
+ console.log(version)
341
+ process.exit(0)
342
+ }
343
+ case '--help':
344
+ case '-h':
345
+ console.log(`
346
+ ⚡ Pulse — spec-first frontend framework
347
+
348
+ Usage: pulse [command]
349
+
350
+ Commands:
351
+ (none) detect project or start scaffold wizard
352
+ dev start dev server
353
+ build production build → public/dist/
354
+ start production server (requires prior build)
355
+ update re-copy pulse-ui assets from installed package → public/
356
+
357
+ Options:
358
+ -v, --version print version and exit
359
+ -h, --help show this help
360
+ `)
361
+ process.exit(0)
332
362
  case 'dev':
333
363
  await runDev(CWD)
334
364
  break