@grainulation/barn 1.0.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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/barn.js +98 -0
- package/lib/index.js +93 -0
- package/lib/server.js +368 -0
- package/package.json +52 -0
- package/public/grainulation-tokens.css +321 -0
- package/public/index.html +907 -0
- package/templates/README.md +48 -0
- package/templates/adr.html +223 -0
- package/templates/adr.json +29 -0
- package/templates/brief.html +297 -0
- package/templates/brief.json +26 -0
- package/templates/certificate.html +247 -0
- package/templates/certificate.json +23 -0
- package/templates/changelog.html +239 -0
- package/templates/changelog.json +19 -0
- package/templates/ci-workflow.yml +52 -0
- package/templates/comparison.html +248 -0
- package/templates/comparison.json +21 -0
- package/templates/conflict-map.html +240 -0
- package/templates/conflict-map.json +19 -0
- package/templates/dashboard.html +515 -0
- package/templates/dashboard.json +22 -0
- package/templates/email-digest.html +178 -0
- package/templates/email-digest.json +18 -0
- package/templates/evidence-matrix.html +232 -0
- package/templates/evidence-matrix.json +21 -0
- package/templates/explainer.html +342 -0
- package/templates/explainer.json +23 -0
- package/templates/handoff.html +343 -0
- package/templates/handoff.json +24 -0
- package/templates/one-pager.html +248 -0
- package/templates/one-pager.json +22 -0
- package/templates/postmortem.html +303 -0
- package/templates/postmortem.json +20 -0
- package/templates/rfc.html +199 -0
- package/templates/rfc.json +32 -0
- package/templates/risk-register.html +231 -0
- package/templates/risk-register.json +22 -0
- package/templates/slide-deck.html +239 -0
- package/templates/slide-deck.json +23 -0
- package/templates/template.schema.json +25 -0
- package/templates/wiki-page.html +222 -0
- package/templates/wiki-page.json +23 -0
- package/tools/README.md +31 -0
- package/tools/build-pdf.js +43 -0
- package/tools/detect-sprints.js +292 -0
- package/tools/generate-manifest.js +237 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- 17 built-in HTML templates (brief, explainer, dashboard, slide-deck, RFC, ADR, and more)
|
|
8
|
+
- Web template browser with tag filtering, source/preview/info tabs
|
|
9
|
+
- `detect-sprints` tool for finding active wheat sprints across repos
|
|
10
|
+
- `generate-manifest` for building wheat-manifest.json
|
|
11
|
+
- `build-pdf` for Markdown-to-PDF conversion
|
|
12
|
+
- SSE live-reload when templates change
|
|
13
|
+
- Zero runtime dependencies
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 grainulation contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# barn
|
|
2
|
+
|
|
3
|
+
Open tools for structured research. Use with wheat, or use standalone.
|
|
4
|
+
|
|
5
|
+
Barn extracts the reusable utilities from the [wheat](https://github.com/grainulation/wheat) research sprint system into a standalone package. Zero npm dependencies -- Node built-in only.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @grainulation/barn
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or use directly:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @grainulation/barn detect-sprints --json
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Tools
|
|
20
|
+
|
|
21
|
+
### detect-sprints
|
|
22
|
+
|
|
23
|
+
Find sprint directories in a repo by scanning for `claims.json` files. Uses git history to determine which sprint is active.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
barn detect-sprints # Human-readable output
|
|
27
|
+
barn detect-sprints --json # Machine-readable JSON
|
|
28
|
+
barn detect-sprints --active # Print only the active sprint path
|
|
29
|
+
barn detect-sprints --root /path # Scan a specific directory
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### generate-manifest
|
|
33
|
+
|
|
34
|
+
Build a `wheat-manifest.json` topic map from claims, files, and git history. Gives AI tools (and humans) a single file that describes the entire sprint state.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
barn generate-manifest # Write wheat-manifest.json
|
|
38
|
+
barn generate-manifest --root /path # Target a specific repo
|
|
39
|
+
barn generate-manifest --out custom-name.json # Custom output path
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### build-pdf
|
|
43
|
+
|
|
44
|
+
Convert markdown to PDF via `md-to-pdf` (invoked through npx -- no local install needed).
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
barn build-pdf output/brief.md
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Templates
|
|
51
|
+
|
|
52
|
+
HTML templates for sprint artifacts. Self-contained (inline CSS/JS, no external deps), dark theme, mobile responsive.
|
|
53
|
+
|
|
54
|
+
- **adr.html** -- Architecture Decision Record
|
|
55
|
+
- **brief.html** -- Sprint brief / recommendation document
|
|
56
|
+
- **certificate.html** -- Compilation certificate
|
|
57
|
+
- **changelog.html** -- Sprint changelog
|
|
58
|
+
- **comparison.html** -- Side-by-side comparison dashboard
|
|
59
|
+
- **conflict-map.html** -- Claim conflict visualization
|
|
60
|
+
- **dashboard.html** -- Sprint status dashboard
|
|
61
|
+
- **email-digest.html** -- Email digest summary
|
|
62
|
+
- **evidence-matrix.html** -- Evidence tier matrix
|
|
63
|
+
- **explainer.html** -- Full-screen scroll-snap presentation
|
|
64
|
+
- **handoff.html** -- Knowledge transfer document
|
|
65
|
+
- **one-pager.html** -- Single-page executive summary
|
|
66
|
+
- **postmortem.html** -- Sprint postmortem
|
|
67
|
+
- **rfc.html** -- Request for Comments
|
|
68
|
+
- **risk-register.html** -- Risk tracking register
|
|
69
|
+
- **slide-deck.html** -- Slide deck presentation
|
|
70
|
+
- **wiki-page.html** -- Wiki-style documentation page
|
|
71
|
+
|
|
72
|
+
Copy templates into your project:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cp node_modules/@grainulation/barn/templates/explainer.html ./output/
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Philosophy
|
|
79
|
+
|
|
80
|
+
- Zero npm dependencies. Node built-in modules only.
|
|
81
|
+
- Git as the source of truth. No config files for state that git already knows.
|
|
82
|
+
- Self-describing structures. New sessions understand the repo without full scans.
|
|
83
|
+
- Works with AI search tools (Glob, Grep, Read) out of the box.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
package/bin/barn.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* barn — CLI for grainulation/barn tools
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* barn <command> [options]
|
|
7
|
+
*
|
|
8
|
+
* Commands:
|
|
9
|
+
* detect-sprints Find sprint directories in a repo
|
|
10
|
+
* generate-manifest Build wheat-manifest.json topic map
|
|
11
|
+
* build-pdf Convert markdown to PDF via npx md-to-pdf
|
|
12
|
+
* help Show this help message
|
|
13
|
+
*
|
|
14
|
+
* Zero npm dependencies (Node built-in only).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { fork } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const TOOLS_DIR = join(__dirname, '..', 'tools');
|
|
23
|
+
|
|
24
|
+
const LIB_DIR = join(__dirname, '..', 'lib');
|
|
25
|
+
|
|
26
|
+
// ── --version / -v ───────────────────────────────────────────────────────────
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const command = args[0];
|
|
31
|
+
|
|
32
|
+
if (command === '--version' || command === '-v') {
|
|
33
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
34
|
+
console.log(pkg.version);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const verbose = process.argv.includes('--verbose');
|
|
39
|
+
function vlog(...a) {
|
|
40
|
+
if (!verbose) return;
|
|
41
|
+
const ts = new Date().toISOString();
|
|
42
|
+
process.stderr.write(`[${ts}] barn: ${a.join(' ')}\n`);
|
|
43
|
+
}
|
|
44
|
+
export { vlog, verbose };
|
|
45
|
+
|
|
46
|
+
const commands = {
|
|
47
|
+
'detect-sprints': 'detect-sprints.js',
|
|
48
|
+
'generate-manifest': 'generate-manifest.js',
|
|
49
|
+
'build-pdf': 'build-pdf.js',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
vlog('startup', `command=${command || '(none)'}`, `cwd=${process.cwd()}`);
|
|
53
|
+
|
|
54
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
55
|
+
console.log(`barn — open tools for structured research
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
barn <command> [options]
|
|
59
|
+
|
|
60
|
+
Commands:
|
|
61
|
+
serve Start the template browser UI
|
|
62
|
+
detect-sprints Find sprint directories in a repo
|
|
63
|
+
generate-manifest Build wheat-manifest.json topic map
|
|
64
|
+
build-pdf <file> Convert markdown to PDF via npx md-to-pdf
|
|
65
|
+
help Show this help message
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
barn serve --port 9093 --root /path/to/repo
|
|
69
|
+
barn detect-sprints --json
|
|
70
|
+
barn detect-sprints --active
|
|
71
|
+
barn generate-manifest --root /path/to/repo
|
|
72
|
+
barn build-pdf output/brief.md
|
|
73
|
+
|
|
74
|
+
Options:
|
|
75
|
+
--version, -v Print version and exit
|
|
76
|
+
--verbose Enable verbose logging to stderr
|
|
77
|
+
|
|
78
|
+
Zero npm dependencies. Node built-in only.
|
|
79
|
+
https://github.com/grainulation/barn`);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── serve command (lib/server.js) ──
|
|
84
|
+
if (command === 'serve') {
|
|
85
|
+
const serverPath = join(LIB_DIR, 'server.js');
|
|
86
|
+
const child = fork(serverPath, args.slice(1), { stdio: 'inherit' });
|
|
87
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
88
|
+
process.on('SIGTERM', () => child.kill('SIGTERM'));
|
|
89
|
+
process.on('SIGINT', () => child.kill('SIGINT'));
|
|
90
|
+
} else if (commands[command]) {
|
|
91
|
+
const toolPath = join(TOOLS_DIR, commands[command]);
|
|
92
|
+
const child = fork(toolPath, args.slice(1), { stdio: 'inherit' });
|
|
93
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
94
|
+
} else {
|
|
95
|
+
console.error(`barn: unknown command: ${command}`);
|
|
96
|
+
console.error(`Run "barn help" for available commands.`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @grainulation/barn — public API surface
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* name, version — package metadata
|
|
6
|
+
* loadTemplates(dir) — scan a directory for .html templates + .json sidecars
|
|
7
|
+
* detectSprints(root) — git-based sprint detection (re-export)
|
|
8
|
+
* generateManifest(opts) — build wheat-manifest.json topic map (re-export)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
17
|
+
|
|
18
|
+
export const name = pkg.name;
|
|
19
|
+
export const version = pkg.version;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Scan a directory for .html templates and optional .json sidecar metadata.
|
|
23
|
+
* Pure function — no side effects, no logging, no server state.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} templatesDir — absolute path to the templates directory
|
|
26
|
+
* @returns {Array<object>} — array of template metadata objects
|
|
27
|
+
*/
|
|
28
|
+
export function loadTemplates(templatesDir) {
|
|
29
|
+
const templates = [];
|
|
30
|
+
if (!existsSync(templatesDir)) return templates;
|
|
31
|
+
|
|
32
|
+
for (const file of readdirSync(templatesDir)) {
|
|
33
|
+
if (!file.endsWith('.html')) continue;
|
|
34
|
+
const filePath = join(templatesDir, file);
|
|
35
|
+
const content = readFileSync(filePath, 'utf8');
|
|
36
|
+
const tplName = file.replace('.html', '');
|
|
37
|
+
|
|
38
|
+
// Extract placeholders
|
|
39
|
+
const placeholders = [...new Set(content.match(/\{\{[A-Z_]+\}\}/g) || [])];
|
|
40
|
+
|
|
41
|
+
// Extract description from first comment
|
|
42
|
+
const commentMatch = content.match(/<!--\s*(.*?)\s*-->/);
|
|
43
|
+
let description = commentMatch ? commentMatch[1] : '';
|
|
44
|
+
|
|
45
|
+
// Count lines and size
|
|
46
|
+
const lines = content.split('\n').length;
|
|
47
|
+
const size = statSync(filePath).size;
|
|
48
|
+
|
|
49
|
+
// Detect features
|
|
50
|
+
const features = [];
|
|
51
|
+
if (content.includes('scroll-snap')) features.push('scroll-snap');
|
|
52
|
+
if (content.includes('@media')) features.push('responsive');
|
|
53
|
+
if (content.includes('var(--')) features.push('css-variables');
|
|
54
|
+
if (content.includes('<table')) features.push('tables');
|
|
55
|
+
if (content.includes('.card')) features.push('cards');
|
|
56
|
+
if (content.includes('.slide')) features.push('slides');
|
|
57
|
+
|
|
58
|
+
// Merge optional template.json metadata
|
|
59
|
+
const metaPath = join(templatesDir, tplName + '.json');
|
|
60
|
+
let title = tplName;
|
|
61
|
+
let tags = [];
|
|
62
|
+
let author = '';
|
|
63
|
+
let tplVersion = '';
|
|
64
|
+
let exportPresets = [];
|
|
65
|
+
let seedPacks = [];
|
|
66
|
+
let scaffoldConfig = null;
|
|
67
|
+
if (existsSync(metaPath)) {
|
|
68
|
+
try {
|
|
69
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
70
|
+
if (meta.title) title = meta.title;
|
|
71
|
+
if (meta.description) description = meta.description;
|
|
72
|
+
if (Array.isArray(meta.tags)) tags = meta.tags;
|
|
73
|
+
if (meta.author) author = meta.author;
|
|
74
|
+
if (meta.version) tplVersion = meta.version;
|
|
75
|
+
if (Array.isArray(meta.exportPresets)) exportPresets = meta.exportPresets;
|
|
76
|
+
if (Array.isArray(meta.seedPacks)) seedPacks = meta.seedPacks;
|
|
77
|
+
if (meta.scaffoldConfig && typeof meta.scaffoldConfig === 'object') scaffoldConfig = meta.scaffoldConfig;
|
|
78
|
+
} catch {
|
|
79
|
+
// skip malformed sidecar
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
templates.push({
|
|
84
|
+
name: tplName, file, title, placeholders, description, lines, size,
|
|
85
|
+
features, tags, author, version: tplVersion, exportPresets, seedPacks, scaffoldConfig,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return templates;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Re-export tools
|
|
92
|
+
export { detectSprints } from '../tools/detect-sprints.js';
|
|
93
|
+
export { generateManifest } from '../tools/generate-manifest.js';
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* barn serve — local HTTP server for the barn UI
|
|
4
|
+
*
|
|
5
|
+
* Two-column template browser with sprint auto-detection.
|
|
6
|
+
* SSE for live updates, POST endpoints for actions.
|
|
7
|
+
* Zero npm dependencies (node:http only).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* barn serve [--port 9093] [--root /path/to/repo]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
15
|
+
import { readFile, stat, readdir } from 'node:fs/promises';
|
|
16
|
+
import { join, resolve, extname, dirname, basename } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { loadTemplates as _loadTemplates } from './index.js';
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
// ── Crash handlers ──
|
|
24
|
+
process.on('uncaughtException', (err) => {
|
|
25
|
+
process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
});
|
|
28
|
+
process.on('unhandledRejection', (reason) => {
|
|
29
|
+
process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const PKG = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
33
|
+
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
|
34
|
+
const TEMPLATES_DIR = join(__dirname, '..', 'templates');
|
|
35
|
+
const TOOLS_DIR = join(__dirname, '..', 'tools');
|
|
36
|
+
|
|
37
|
+
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
function arg(name, fallback) {
|
|
41
|
+
const i = args.indexOf(`--${name}`);
|
|
42
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PORT = parseInt(arg('port', '9093'), 10);
|
|
46
|
+
const ROOT = resolve(arg('root', process.cwd()));
|
|
47
|
+
const CORS_ORIGIN = arg('cors', null);
|
|
48
|
+
|
|
49
|
+
// ── Verbose logging ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
52
|
+
function vlog(...a) {
|
|
53
|
+
if (!verbose) return;
|
|
54
|
+
const ts = new Date().toISOString();
|
|
55
|
+
process.stderr.write(`[${ts}] barn: ${a.join(' ')}\n`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const ROUTES = [
|
|
61
|
+
{ method: 'GET', path: '/health', description: 'Health check (tool, version, port, uptime)' },
|
|
62
|
+
{ method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
|
|
63
|
+
{ method: 'GET', path: '/api/state', description: 'Current state (templates, sprints, manifest)' },
|
|
64
|
+
{ method: 'GET', path: '/api/template', description: 'Template content by ?name parameter' },
|
|
65
|
+
{ method: 'GET', path: '/api/search', description: 'Search templates by ?q=<query> (name, description, placeholders, features)' },
|
|
66
|
+
{ method: 'POST', path: '/api/refresh', description: 'Refresh state from disk' },
|
|
67
|
+
{ method: 'GET', path: '/api/docs', description: 'This API documentation page' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
let state = {
|
|
73
|
+
templates: [],
|
|
74
|
+
sprints: [],
|
|
75
|
+
activeSprint: null,
|
|
76
|
+
manifest: null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const sseClients = new Set();
|
|
80
|
+
|
|
81
|
+
function broadcast(event) {
|
|
82
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
83
|
+
for (const res of sseClients) {
|
|
84
|
+
try { res.write(data); } catch { sseClients.delete(res); }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Data loading ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function loadTemplates() {
|
|
91
|
+
vlog('read', TEMPLATES_DIR);
|
|
92
|
+
return _loadTemplates(TEMPLATES_DIR);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function loadSprints() {
|
|
96
|
+
const mod = join(TOOLS_DIR, 'detect-sprints.js');
|
|
97
|
+
if (!existsSync(mod)) return Promise.resolve({ sprints: [], active: null });
|
|
98
|
+
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
execFile('node', [mod, '--json', '--root', ROOT], {
|
|
101
|
+
timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
|
|
102
|
+
}, (err, stdout) => {
|
|
103
|
+
if (err) { resolve({ sprints: [], active: null }); return; }
|
|
104
|
+
try {
|
|
105
|
+
const data = JSON.parse(stdout);
|
|
106
|
+
resolve({
|
|
107
|
+
sprints: data.sprints || [],
|
|
108
|
+
active: (data.sprints || []).find(s => s.status === 'active') || null,
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
resolve({ sprints: [], active: null });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function loadManifest() {
|
|
118
|
+
const manifestPath = join(ROOT, 'wheat-manifest.json');
|
|
119
|
+
if (!existsSync(manifestPath)) return null;
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let refreshPending = null;
|
|
128
|
+
async function refreshState() {
|
|
129
|
+
if (refreshPending) return refreshPending;
|
|
130
|
+
refreshPending = (async () => {
|
|
131
|
+
state.templates = await loadTemplates();
|
|
132
|
+
const sprintData = await loadSprints();
|
|
133
|
+
state.sprints = sprintData.sprints;
|
|
134
|
+
state.activeSprint = sprintData.active;
|
|
135
|
+
state.manifest = loadManifest();
|
|
136
|
+
broadcast({ type: 'state', data: state });
|
|
137
|
+
})();
|
|
138
|
+
try { return await refreshPending; } finally { refreshPending = null; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── MIME types ────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
const MIME = {
|
|
144
|
+
'.html': 'text/html; charset=utf-8',
|
|
145
|
+
'.css': 'text/css; charset=utf-8',
|
|
146
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
147
|
+
'.json': 'application/json; charset=utf-8',
|
|
148
|
+
'.svg': 'image/svg+xml',
|
|
149
|
+
'.png': 'image/png',
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
const server = createServer(async (req, res) => {
|
|
155
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
156
|
+
|
|
157
|
+
// CORS headers (only when --cors is passed)
|
|
158
|
+
if (CORS_ORIGIN) {
|
|
159
|
+
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
|
160
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
161
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (req.method === 'OPTIONS' && CORS_ORIGIN) {
|
|
165
|
+
res.writeHead(204);
|
|
166
|
+
res.end();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
vlog('request', req.method, url.pathname);
|
|
171
|
+
|
|
172
|
+
// ── Health check ──
|
|
173
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
174
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
175
|
+
res.end(JSON.stringify({ tool: 'barn', version: PKG.version, port: PORT, uptime: process.uptime() }));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── API: docs ──
|
|
180
|
+
if (req.method === 'GET' && url.pathname === '/api/docs') {
|
|
181
|
+
const html = `<!DOCTYPE html><html><head><title>barn API</title>
|
|
182
|
+
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
183
|
+
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
184
|
+
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
185
|
+
<body><h1>barn API</h1><p>${ROUTES.length} endpoints</p>
|
|
186
|
+
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
187
|
+
${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
|
|
188
|
+
</table></body></html>`;
|
|
189
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
190
|
+
res.end(html);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── SSE endpoint ──
|
|
195
|
+
if (req.method === 'GET' && url.pathname === '/events') {
|
|
196
|
+
res.writeHead(200, {
|
|
197
|
+
'Content-Type': 'text/event-stream',
|
|
198
|
+
'Cache-Control': 'no-cache',
|
|
199
|
+
'Connection': 'keep-alive',
|
|
200
|
+
});
|
|
201
|
+
res.write(`data: ${JSON.stringify({ type: 'state', data: state })}\n\n`);
|
|
202
|
+
const heartbeat = setInterval(() => {
|
|
203
|
+
try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
|
|
204
|
+
}, 15000);
|
|
205
|
+
sseClients.add(res);
|
|
206
|
+
vlog('sse', `client connected (${sseClients.size} total)`);
|
|
207
|
+
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── API: state ──
|
|
212
|
+
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
213
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
214
|
+
res.end(JSON.stringify(state));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── API: search templates ──
|
|
219
|
+
if (req.method === 'GET' && url.pathname === '/api/search') {
|
|
220
|
+
const q = (url.searchParams.get('q') || '').toLowerCase().trim();
|
|
221
|
+
if (!q) {
|
|
222
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
223
|
+
res.end(JSON.stringify(state.templates));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const filtered = state.templates.filter(tpl => {
|
|
227
|
+
const haystack = [
|
|
228
|
+
tpl.name,
|
|
229
|
+
tpl.title,
|
|
230
|
+
tpl.description,
|
|
231
|
+
...tpl.placeholders,
|
|
232
|
+
...tpl.features,
|
|
233
|
+
...tpl.tags,
|
|
234
|
+
].join(' ').toLowerCase();
|
|
235
|
+
return haystack.includes(q);
|
|
236
|
+
});
|
|
237
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
238
|
+
res.end(JSON.stringify(filtered));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── API: template content ──
|
|
243
|
+
if (req.method === 'GET' && url.pathname === '/api/template') {
|
|
244
|
+
const name = url.searchParams.get('name');
|
|
245
|
+
if (!name) { res.writeHead(400); res.end('missing name'); return; }
|
|
246
|
+
const filePath = resolve(TEMPLATES_DIR, name + '.html');
|
|
247
|
+
if (!filePath.startsWith(TEMPLATES_DIR)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
248
|
+
if (!existsSync(filePath)) { res.writeHead(404); res.end('not found'); return; }
|
|
249
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
250
|
+
res.end(readFileSync(filePath, 'utf8'));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── API: refresh ──
|
|
255
|
+
if (req.method === 'POST' && url.pathname === '/api/refresh') {
|
|
256
|
+
await refreshState();
|
|
257
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
258
|
+
res.end(JSON.stringify(state));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Static files ──
|
|
263
|
+
let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
264
|
+
|
|
265
|
+
// Prevent directory traversal
|
|
266
|
+
const resolved = resolve(PUBLIC_DIR, '.' + filePath);
|
|
267
|
+
if (!resolved.startsWith(PUBLIC_DIR)) {
|
|
268
|
+
res.writeHead(403);
|
|
269
|
+
res.end('forbidden');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (existsSync(resolved) && statSync(resolved).isFile()) {
|
|
274
|
+
const ext = extname(resolved);
|
|
275
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
276
|
+
res.end(readFileSync(resolved));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
res.writeHead(404);
|
|
281
|
+
res.end('not found');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── File watching ─────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
// Build a fingerprint of filenames + mtimes for change detection
|
|
287
|
+
async function dirFingerprint(dir) {
|
|
288
|
+
if (!existsSync(dir)) return '';
|
|
289
|
+
const files = await readdir(dir);
|
|
290
|
+
const parts = [];
|
|
291
|
+
for (const f of files) {
|
|
292
|
+
if (!f.endsWith('.html') && !f.endsWith('.json')) continue;
|
|
293
|
+
try {
|
|
294
|
+
const s = await stat(join(dir, f));
|
|
295
|
+
parts.push(`${f}:${s.mtimeMs}`);
|
|
296
|
+
} catch { /* removed between readdir and stat */ }
|
|
297
|
+
}
|
|
298
|
+
return parts.sort().join('|');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function claimsFingerprint() {
|
|
302
|
+
const claimsPath = join(ROOT, 'claims.json');
|
|
303
|
+
try {
|
|
304
|
+
const s = await stat(claimsPath);
|
|
305
|
+
return `claims:${s.mtimeMs}`;
|
|
306
|
+
} catch { return ''; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let lastTemplatesFP = '';
|
|
310
|
+
let lastClaimsFP = '';
|
|
311
|
+
|
|
312
|
+
const watchInterval = setInterval(async () => {
|
|
313
|
+
try {
|
|
314
|
+
const [tFP, cFP] = await Promise.all([
|
|
315
|
+
dirFingerprint(TEMPLATES_DIR),
|
|
316
|
+
claimsFingerprint(),
|
|
317
|
+
]);
|
|
318
|
+
if (tFP !== lastTemplatesFP || cFP !== lastClaimsFP) {
|
|
319
|
+
lastTemplatesFP = tFP;
|
|
320
|
+
lastClaimsFP = cFP;
|
|
321
|
+
await refreshState();
|
|
322
|
+
}
|
|
323
|
+
} catch { /* ignore polling errors */ }
|
|
324
|
+
}, 2000);
|
|
325
|
+
|
|
326
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
327
|
+
const shutdown = (signal) => {
|
|
328
|
+
console.log(`\nbarn: ${signal} received, shutting down...`);
|
|
329
|
+
clearInterval(watchInterval);
|
|
330
|
+
for (const res of sseClients) { try { res.end(); } catch {} }
|
|
331
|
+
sseClients.clear();
|
|
332
|
+
server.close(() => process.exit(0));
|
|
333
|
+
setTimeout(() => process.exit(1), 5000);
|
|
334
|
+
};
|
|
335
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
336
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
337
|
+
|
|
338
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
await refreshState();
|
|
341
|
+
// Seed fingerprints so the poller doesn't re-trigger immediately
|
|
342
|
+
[lastTemplatesFP, lastClaimsFP] = await Promise.all([
|
|
343
|
+
dirFingerprint(TEMPLATES_DIR),
|
|
344
|
+
claimsFingerprint(),
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
server.on('error', (err) => {
|
|
348
|
+
if (err.code === 'EADDRINUSE') {
|
|
349
|
+
console.error(`barn: port ${PORT} already in use — try --port <other>`);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
if (err.code === 'EACCES') {
|
|
353
|
+
console.error(`barn: permission denied for port ${PORT}`);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
throw err;
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
360
|
+
vlog('listen', `port=${PORT}`, `root=${ROOT}`);
|
|
361
|
+
console.log(`barn: serving on http://localhost:${PORT}`);
|
|
362
|
+
console.log(` templates: ${state.templates.length} found`);
|
|
363
|
+
console.log(` sprints: ${state.sprints.length} detected`);
|
|
364
|
+
if (state.activeSprint) {
|
|
365
|
+
console.log(` active: ${state.activeSprint.name} (${state.activeSprint.phase})`);
|
|
366
|
+
}
|
|
367
|
+
console.log(` root: ${ROOT}`);
|
|
368
|
+
});
|