@techninja/clearstack 0.4.3 → 0.4.5

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,101 @@
1
+ /**
2
+ * File scanning utilities — find, count, check lines, check imports.
3
+ * @module lib/spec-scan
4
+ */
5
+
6
+ import { readFileSync, readdirSync } from 'node:fs';
7
+ import { resolve, extname, relative } from 'node:path';
8
+ import { elapsed } from './spec-utils.js';
9
+
10
+ /** @typedef {import('./spec-utils.js').CheckResult} CheckResult */
11
+ /** @typedef {{ file: string, lines: number, max: number }} LineViolation */
12
+ /** @typedef {{ file: string, spec: string }} ImportViolation */
13
+
14
+ /** Recursively find files matching extensions, skipping ignored dirs. */
15
+ export function findFiles(dir, extensions, ignoreDirs, root = dir) {
16
+ const results = [];
17
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
18
+ const full = resolve(dir, entry.name);
19
+ const rel = relative(root, full);
20
+ if (entry.isDirectory()) {
21
+ if (ignoreDirs.some((ig) => entry.name === ig || rel === ig || rel.startsWith(ig + '/'))) continue;
22
+ results.push(...findFiles(full, extensions, ignoreDirs, root));
23
+ } else if (extensions.includes(extname(entry.name))) {
24
+ results.push(rel);
25
+ }
26
+ }
27
+ return results;
28
+ }
29
+
30
+ /**
31
+ * @param {string} root
32
+ * @param {string[]} extensions
33
+ * @param {string[]} ignoreDirs
34
+ * @returns {number}
35
+ */
36
+ export function countFiles(root, extensions, ignoreDirs) {
37
+ return findFiles(root, extensions, ignoreDirs, root).length;
38
+ }
39
+
40
+ /**
41
+ * @param {string} root
42
+ * @param {string[]} extensions
43
+ * @param {number} max
44
+ * @param {string[]} ignoreDirs
45
+ * @param {string} label
46
+ * @param {{ exclude?: string, include?: string, localeMax?: number, quiet?: boolean }} [filter]
47
+ * @returns {CheckResult & { violations?: LineViolation[] }}
48
+ */
49
+ export function checkFileLines(root, extensions, max, ignoreDirs, label, filter) {
50
+ const start = performance.now();
51
+ let files = findFiles(root, extensions, ignoreDirs, root);
52
+ if (filter?.exclude) files = files.filter((f) => !f.endsWith(filter.exclude));
53
+ if (filter?.include) files = files.filter((f) => f.endsWith(filter.include));
54
+ const localeMax = filter?.localeMax || max * 5;
55
+ const violations = [];
56
+ for (const file of files) {
57
+ const lines = readFileSync(resolve(root, file), 'utf-8').trimEnd().split('\n').length;
58
+ const limit = file.includes('/locales/') ? localeMax : max;
59
+ if (lines > limit) violations.push({ file, lines, max: limit });
60
+ }
61
+ const time = elapsed(start);
62
+ if (violations.length === 0) {
63
+ if (!filter?.quiet) console.log(` ✅ ${label} (${files.length} files, ${time})`);
64
+ return { pass: true, label, time, files: files.length };
65
+ }
66
+ if (!filter?.quiet) {
67
+ console.log(` ❌ ${label} — ${violations.length} violation(s):`);
68
+ for (const v of violations) console.log(` ${v.file}: ${v.lines} lines (max ${v.max})`);
69
+ }
70
+ return { pass: false, label, time, files: files.length, violations };
71
+ }
72
+
73
+ /**
74
+ * @param {string} root
75
+ * @param {string[]} ignoreDirs
76
+ * @param {string} label
77
+ * @param {{ quiet?: boolean }} [opts]
78
+ * @returns {CheckResult & { violations?: ImportViolation[] }}
79
+ */
80
+ export function checkImports(root, ignoreDirs, label, opts) {
81
+ const start = performance.now();
82
+ const files = findFiles(root, ['.js'], ignoreDirs, root)
83
+ .filter((f) => f.startsWith('src/') && !f.includes('vendor/') && !f.includes('api/') && !f.endsWith('.test.js') && !f.endsWith('server.js'));
84
+ const violations = [];
85
+ const importRe = /(?:^|\n)\s*import\s.*?from\s+['"](\.\.[^'"]*)['"]/g;
86
+ for (const file of files) {
87
+ const src = readFileSync(resolve(root, file), 'utf-8');
88
+ let m;
89
+ while ((m = importRe.exec(src)) !== null) violations.push({ file, spec: m[1] });
90
+ }
91
+ const time = elapsed(start);
92
+ if (violations.length === 0) {
93
+ if (!opts?.quiet) console.log(` ✅ ${label} (${files.length} files, ${time})`);
94
+ return { pass: true, label, time, files: files.length };
95
+ }
96
+ if (!opts?.quiet) {
97
+ console.log(` ❌ ${label} — ${violations.length} violation(s):`);
98
+ for (const v of violations) console.log(` ${v.file}: import '${v.spec}' → use #prefix/ alias`);
99
+ }
100
+ return { pass: false, label, time, files: files.length, violations };
101
+ }
package/lib/spec-utils.js CHANGED
@@ -1,11 +1,9 @@
1
1
  /**
2
- * Shared spec utilities — file scanning, command running, timing.
3
- * Used by both the clearstack CLI and the POC spec runner.
2
+ * Shared spec utilities — timing and command running.
3
+ * File scanning lives in spec-scan.js.
4
4
  * @module lib/spec-utils
5
5
  */
6
6
 
7
- import { readFileSync, readdirSync } from 'node:fs';
8
- import { resolve, extname, relative } from 'node:path';
9
7
  import { execSync } from 'node:child_process';
10
8
 
11
9
  /** @param {number} start */
@@ -14,104 +12,7 @@ export function elapsed(start) {
14
12
  return d < 1000 ? `${Math.round(d)}ms` : `${(d / 1000).toFixed(1)}s`;
15
13
  }
16
14
 
17
- /** Recursively find files matching extensions, skipping ignored dirs. */
18
- export function findFiles(dir, extensions, ignoreDirs, root = dir) {
19
- const results = [];
20
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
21
- const full = resolve(dir, entry.name);
22
- const rel = relative(root, full);
23
- if (entry.isDirectory()) {
24
- if (ignoreDirs.some((ig) => entry.name === ig || rel === ig || rel.startsWith(ig + '/'))) continue;
25
- results.push(...findFiles(full, extensions, ignoreDirs, root));
26
- } else if (extensions.includes(extname(entry.name))) {
27
- results.push(rel);
28
- }
29
- }
30
- return results;
31
- }
32
-
33
- /**
34
- * Count files matching extensions.
35
- * @param {string} root
36
- * @param {string[]} extensions
37
- * @param {string[]} ignoreDirs
38
- * @returns {number}
39
- */
40
- export function countFiles(root, extensions, ignoreDirs) {
41
- return findFiles(root, extensions, ignoreDirs, root).length;
42
- }
43
-
44
- /** @typedef {{ pass: boolean, label: string, time: string, files?: number, errors?: string[] }} CheckResult */
45
- /** @typedef {{ file: string, lines: number, max: number }} LineViolation */
46
- /** @typedef {{ file: string, spec: string }} ImportViolation */
47
-
48
- /**
49
- * Check file line counts with timing.
50
- * @param {string} root
51
- * @param {string[]} extensions
52
- * @param {number} max
53
- * @param {string[]} ignoreDirs
54
- * @param {string} label
55
- * @param {{ exclude?: string, include?: string, quiet?: boolean }} [filter]
56
- * @returns {CheckResult & { violations?: LineViolation[] }}
57
- */
58
- export function checkFileLines(root, extensions, max, ignoreDirs, label, filter) {
59
- const start = performance.now();
60
- let files = findFiles(root, extensions, ignoreDirs, root);
61
- if (filter?.exclude) files = files.filter((f) => !f.endsWith(filter.exclude));
62
- if (filter?.include) files = files.filter((f) => f.endsWith(filter.include));
63
- const violations = [];
64
- for (const file of files) {
65
- const lines = readFileSync(resolve(root, file), 'utf-8').trimEnd().split('\n').length;
66
- if (lines > max) violations.push({ file, lines, max });
67
- }
68
- const time = elapsed(start);
69
- if (violations.length === 0) {
70
- if (!filter?.quiet) console.log(` ✅ ${label} (${files.length} files, ${time})`);
71
- return { pass: true, label, time, files: files.length };
72
- }
73
- if (!filter?.quiet) {
74
- console.log(` ❌ ${label} — ${violations.length} violation(s):`);
75
- for (const v of violations) console.log(` ${v.file}: ${v.lines} lines (max ${v.max})`);
76
- }
77
- return { pass: false, label, time, files: files.length, violations };
78
- }
79
-
80
- /**
81
- * Check for relative parent imports (`../`) in browser-facing JS files.
82
- * Same-directory (`./`) and server/test files are allowed.
83
- * @param {string} root
84
- * @param {string[]} ignoreDirs
85
- * @param {string} label
86
- * @param {{ quiet?: boolean }} [opts]
87
- * @returns {CheckResult & { violations?: ImportViolation[] }}
88
- */
89
- export function checkImports(root, ignoreDirs, label, opts) {
90
- const start = performance.now();
91
- const files = findFiles(root, ['.js'], ignoreDirs, root)
92
- .filter((f) => f.startsWith('src/') && !f.includes('vendor/') && !f.includes('api/') && !f.endsWith('.test.js') && !f.endsWith('server.js'));
93
- const violations = [];
94
- const importRe = /(?:^|\n)\s*import\s.*?from\s+['"](\.\.[^'"]*)['"]|(?:^|\n)\s*import\s+['"](\.\.[^'"]*)['"]|(?:^|\n)\s*export\s.*?from\s+['"](\.\.[^'"]*)['"/]/g;
95
- for (const file of files) {
96
- const src = readFileSync(resolve(root, file), 'utf-8');
97
- let m;
98
- while ((m = importRe.exec(src)) !== null) {
99
- const spec = m[1] || m[2] || m[3];
100
- violations.push({ file, spec });
101
- }
102
- }
103
- const time = elapsed(start);
104
- if (violations.length === 0) {
105
- if (!opts?.quiet) console.log(` ✅ ${label} (${files.length} files, ${time})`);
106
- return { pass: true, label, time, files: files.length };
107
- }
108
- if (!opts?.quiet) {
109
- console.log(` ❌ ${label} — ${violations.length} violation(s):`);
110
- for (const v of violations) console.log(` ${v.file}: import '${v.spec}' → use #prefix/ alias`);
111
- }
112
- return { pass: false, label, time, files: files.length, violations };
113
- }
114
-
15
+ /** @typedef {{ pass: boolean, label: string, time: string, detail?: string, files?: number, errors?: string[], violations?: object[] }} CheckResult */
115
16
 
116
17
  /**
117
18
  * Run a shell command, report pass/fail with timing.
@@ -131,16 +32,20 @@ export function runCmd(label, cmd, cwd, stats, opts) {
131
32
  return { pass: true, label, time: elapsed(start), files: parseInt(stats) || undefined };
132
33
  } catch (err) {
133
34
  const out = (err.stdout || '') + (err.stderr || '');
134
- const ownErrors = out.trim().split('\n')
135
- .filter((l) => l.trim() && !l.includes('node_modules'));
136
- if (ownErrors.length === 0) {
35
+ const lines = out.trim().split('\n').filter((l) => l.trim());
36
+ const ownErrors = lines.filter((l) => !/[/\\]node_modules[/\\]/.test(l));
37
+ const isPrettier = cmd.includes('prettier');
38
+ const display = isPrettier
39
+ ? ownErrors.filter((l) => l.startsWith('[warn]') && !l.includes('Run Prettier'))
40
+ : ownErrors;
41
+ if (display.length === 0) {
137
42
  if (!opts?.quiet) console.log(` ✅ ${label}${suffix(stats)}`);
138
43
  return { pass: true, label, time: elapsed(start) };
139
44
  }
140
45
  if (!opts?.quiet) {
141
46
  console.log(` ❌ ${label}${suffix(stats)}`);
142
- for (const line of ownErrors) console.log(` ${line}`);
47
+ for (const line of display) console.log(` ${line}`);
143
48
  }
144
- return { pass: false, label, time: elapsed(start), errors: ownErrors };
49
+ return { pass: false, label, time: elapsed(start), errors: display };
145
50
  }
146
51
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Watch lifecycle — quit, self-restart, and fs.watch setup.
3
+ * @module lib/watch-lifecycle
4
+ */
5
+
6
+ import { watch } from 'node:fs';
7
+ import { existsSync } from 'node:fs';
8
+ import { resolve, extname } from 'node:path';
9
+ import { destroyScreen, setQuit } from './watch-ui.js';
10
+ import { setOnCopyNote } from './watch-widgets.js';
11
+
12
+ /**
13
+ * Register quit + restart handlers and start fs.watch on project and lib dirs.
14
+ * @param {{ serverRow: object|null, specRows: object[], projectDir: string, watchDirs: string[], schedule: (ext: string) => void, renderNote: () => void }} ctx
15
+ */
16
+ export function setupLifecycle(ctx) {
17
+ const { serverRow, specRows, projectDir, watchDirs, schedule, renderNote } = ctx;
18
+
19
+ const quit = () => {
20
+ serverRow?.kill();
21
+ destroyScreen();
22
+ const passing = specRows.filter((r) => r.pass).length;
23
+ console.log(`Spec watch stopped. Final state: ${passing}/${specRows.length} checks passing`);
24
+ process.exit(0);
25
+ };
26
+
27
+ const restart = () => {
28
+ serverRow?.kill();
29
+ destroyScreen();
30
+ console.log('\nSpec files changed — run `npm run spec watch` to restart.');
31
+ process.exit(0);
32
+ };
33
+
34
+ setQuit(quit);
35
+ setOnCopyNote(renderNote);
36
+ process.on('SIGINT', quit);
37
+
38
+ for (const dir of watchDirs) {
39
+ watch(resolve(projectDir, dir), { recursive: true }, (_event, filename) => {
40
+ if (filename) schedule(extname(filename));
41
+ });
42
+ }
43
+
44
+ for (const dir of ['lib', 'bin'].filter((d) => existsSync(resolve(projectDir, d)))) {
45
+ watch(resolve(projectDir, dir), { recursive: true }, restart);
46
+ }
47
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Spec watch UI — rendering, split candidates, violation extraction.
3
+ * Widget construction lives in watch-widgets.js.
4
+ * @module lib/watch-ui
5
+ */
6
+
7
+ import { readFileSync, existsSync } from 'node:fs';
8
+ import { resolve } from 'node:path';
9
+ import { screen, statusBox, divider, logBox, getSpin, getCopyNote } from './watch-widgets.js';
10
+
11
+ export { setQuit } from './watch-widgets.js';
12
+
13
+ // ── Split candidate detection ─────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Suggest split seams for a file that exceeds the line limit.
17
+ * Prefers `// SPLIT CANDIDATE:` comments, falls back to heuristics.
18
+ * @param {string} filePath absolute path
19
+ * @returns {string[]}
20
+ */
21
+ export function splitCandidates(filePath) {
22
+ if (!existsSync(filePath)) return [];
23
+ const lines = readFileSync(filePath, 'utf-8').split('\n');
24
+ const explicit = [];
25
+ lines.forEach((l, i) => {
26
+ const m = l.match(/\/\/\s*SPLIT CANDIDATE:\s*(.+)/i);
27
+ if (m) explicit.push(` L${i + 1}: ${m[1].trim()}`);
28
+ });
29
+ if (explicit.length) return explicit;
30
+
31
+ const seams = [];
32
+ lines.forEach((l, i) => {
33
+ // Only exported functions/classes are meaningful split boundaries
34
+ if (/^export (async function|function|class)/.test(l)) seams.push(i + 1);
35
+ });
36
+ if (seams.length < 2) return [];
37
+ const suggestions = [];
38
+ for (let i = 0; i < seams.length - 1 && suggestions.length < 3; i++) {
39
+ const start = seams[i], end = seams[i + 1] - 1;
40
+ if (end - start < 10) continue;
41
+ // Peek at the function name for a more useful suggestion
42
+ const nameMatch = lines[start - 1]?.match(/^export (?:async )?function (\w+)/);
43
+ const hint = nameMatch ? `→ ${nameMatch[1]}()` : 'consider extracting';
44
+ suggestions.push(` L${start}-${end}: ${hint}`);
45
+ }
46
+ return suggestions;
47
+ }
48
+
49
+ // ── Violation extraction ──────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Process a failed check row into violation tuples for the log panel.
53
+ * @param {object} row
54
+ * @param {string} projectDir
55
+ * @returns {string[][]}
56
+ */
57
+ export function extractViolations(row, projectDir) {
58
+ if (row.pass) return [];
59
+ if (row.result?.violations?.length) {
60
+ return row.result.violations.map((v) => {
61
+ if (v.spec !== undefined) return [v.file, `import '${v.spec}' → use #prefix/ alias`];
62
+ const candidates = splitCandidates(resolve(projectDir, v.file));
63
+ return [v.file, `${v.lines} lines (max ${v.max}, +${v.lines - v.max} over)`, ...candidates];
64
+ });
65
+ }
66
+ if (row.result?.errors?.length) {
67
+ return [[(row.label ?? row.name ?? row.key), row.result.errors.slice(0, 3).join('\n')]];
68
+ }
69
+ return [];
70
+ }
71
+
72
+ // ── Render ────────────────────────────────────────────────────────────────────
73
+
74
+ /** @param {object} r */
75
+ function rowIcon(r) {
76
+ if (r.key === 'server') {
77
+ if (r.status === 'running') return '{green-fg}ok{/}';
78
+ if (r.status === 'crashed') return '{red-fg}!{/} ';
79
+ if (r.status === 'restarting') return '{yellow-fg}~{/} ';
80
+ return '{grey-fg}' + getSpin() + '{/}';
81
+ }
82
+ if (r.pass === null) return '{grey-fg}' + getSpin() + '{/}';
83
+ if (r.pass) return '{green-fg}ok{/}';
84
+ return '{red-fg}!{/} ';
85
+ }
86
+
87
+ /**
88
+ * Render the dashboard.
89
+ * @param {object[]} rows
90
+ * @param {string} lastCheck
91
+ * @param {string[]} watchDirs
92
+ * @param {string[][]} violations
93
+ */
94
+ export function render(rows, lastCheck, watchDirs, violations) {
95
+ const labelWidth = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
96
+ const statusLines = rows.map((r) => {
97
+ const name = (r.label ?? r.name ?? r.key).padEnd(labelWidth);
98
+ const styled = r.pass === false ? `{red-fg}${name}{/}` : name;
99
+ const detail = r.detail ? `{grey-fg}${r.detail}{/}` : '';
100
+ return ` ${rowIcon(r)} ${styled} ${detail}`;
101
+ });
102
+ statusLines.push('');
103
+ statusLines.push(`{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`);
104
+ statusBox.setContent(statusLines.join('\n'));
105
+ statusBox.height = statusLines.length;
106
+
107
+ const statusHeight = statusLines.length + 1;
108
+ divider.top = statusHeight;
109
+ logBox.top = statusHeight + 1;
110
+
111
+ if (violations.length) {
112
+ const logLines = ['{yellow-fg}─── Copy below into your LLM session ───{/}'];
113
+ for (const [file, detail, ...candidates] of violations) {
114
+ logLines.push('');
115
+ logLines.push(`{cyan-fg}${file}{/}`);
116
+ logLines.push(detail);
117
+ if (candidates.length) {
118
+ logLines.push('{grey-fg}Split candidates:{/}');
119
+ for (const c of candidates) logLines.push(`{grey-fg}${c}{/}`);
120
+ }
121
+ }
122
+ logBox.setContent(logLines.join('\n'));
123
+ logBox.show();
124
+ divider.show();
125
+ } else {
126
+ logBox.hide();
127
+ divider.hide();
128
+ }
129
+
130
+ screen.render();
131
+ }
132
+
133
+ /** Tear down the blessed screen cleanly. */
134
+ export function destroyScreen() {
135
+ screen.destroy();
136
+ }
137
+
138
+ /** Refresh just the status box — used for transient notes like copy confirmation. */
139
+ export function renderNote(rows, lastCheck, watchDirs) {
140
+ const labelWidth = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
141
+ const lines = [
142
+ ...rows.map((r) => { const n = (r.label ?? r.name ?? r.key).padEnd(labelWidth); return ` ${rowIcon(r)} ${r.pass === false ? `{red-fg}${n}{/}` : n} ${r.detail ? `{grey-fg}${r.detail}{/}` : ''}`; }),
143
+ '',
144
+ `{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`,
145
+ ];
146
+ statusBox.setContent(lines.join('\n'));
147
+ screen.render();
148
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Blessed widget tree for the spec watch dashboard.
3
+ * Constructed once at import time and shared across render calls.
4
+ * @module lib/watch-widgets
5
+ */
6
+
7
+ import { createRequire } from 'node:module';
8
+ import { execFile } from 'node:child_process';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const blessed = require('blessed');
12
+
13
+ export const screen = blessed.screen({ smartCSR: true, title: 'Clearstack Spec Watch' });
14
+
15
+ export const outer = blessed.box({
16
+ top: 0, left: 0, width: '100%', height: '100%',
17
+ border: { type: 'line' },
18
+ label: { text: ' {blue-fg}♥{/blue-fg} Clearstack Spec Watch q quit ↑↓ scroll c copy {/}', side: 'left' },
19
+ tags: true,
20
+ style: { border: { fg: 'cyan' }, label: { fg: 'grey', bold: false } },
21
+ });
22
+
23
+ export const statusBox = blessed.box({
24
+ top: 0, left: 0, width: '100%-2', height: 1,
25
+ tags: true,
26
+ style: { fg: 'white' },
27
+ });
28
+
29
+ export const divider = blessed.line({
30
+ top: 1, left: 0, width: '100%-2', orientation: 'horizontal',
31
+ style: { fg: 'grey' },
32
+ });
33
+
34
+ export const logBox = blessed.scrollablebox({
35
+ top: 2, left: 0, width: '100%-2', bottom: 1,
36
+ tags: true, scrollable: true, alwaysScroll: true,
37
+ scrollbar: { ch: '│', style: { fg: 'grey' } },
38
+ style: { fg: 'white' },
39
+ });
40
+
41
+ outer.append(statusBox);
42
+ outer.append(divider);
43
+ outer.append(logBox);
44
+ screen.append(outer);
45
+
46
+ const SPINNER = ['⠋ ', '⠙ ', '⠹ ', '⠸ ', '⠼ ', '⠴ ', '⠦ ', '⠧ ', '⠇ ', '⠏ '];
47
+ let spinFrame = 0;
48
+ export const getSpin = () => SPINNER[spinFrame % SPINNER.length];
49
+
50
+ const spinInterval = setInterval(() => { spinFrame++; screen.render(); }, 80);
51
+ spinInterval.unref(); // don't keep process alive just for the spinner
52
+
53
+ let _quit = () => process.exit(0);
54
+ export const setQuit = (fn) => { _quit = fn; };
55
+
56
+ let _copyNote = '';
57
+ export const getCopyNote = () => _copyNote;
58
+ let _onCopyNote = () => {};
59
+ export const setOnCopyNote = (fn) => { _onCopyNote = fn; };
60
+
61
+ screen.key(['C-c', 'q'], () => _quit());
62
+ screen.key(['pageup', 'up'], () => { logBox.scroll(-1); screen.render(); });
63
+ screen.key(['pagedown', 'down'], () => { logBox.scroll(1); screen.render(); });
64
+ screen.key(['c'], () => {
65
+ const raw = logBox.getContent()
66
+ .replace(/\{[^}]+\}/g, '') // blessed tags
67
+ .replace(/\x1b\[[\d;]*m/g, '') // ANSI escape codes
68
+ .split('\n').slice(1).join('\n') // drop header line
69
+ .trim();
70
+ if (!raw) return;
71
+ const [cmd, ...args] = process.platform === 'darwin'
72
+ ? ['pbcopy']
73
+ : ['xclip', '-selection', 'clipboard'];
74
+ const proc = execFile(cmd, args);
75
+ proc.stdin.end(raw);
76
+ proc.on('close', (code) => {
77
+ _copyNote = code === 0 ? '{green-fg} ✓ copied{/}' : '{red-fg} ✗ copy failed{/}';
78
+ _onCopyNote();
79
+ setTimeout(() => { _copyNote = ''; _onCopyNote(); }, 1500);
80
+ });
81
+ proc.on('error', () => { _copyNote = '{red-fg} ✗ clipboard unavailable{/}'; _onCopyNote(); });
82
+ });
package/lib/watch.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Spec watch dashboard — continuous compliance monitoring.
3
+ * Runs affected checks on file change with 500ms debounce.
4
+ * UI rendering lives in watch-ui.js.
5
+ * @module lib/watch
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+ import { loadConfig, buildCmds, makeExtMap, FAST_CHECKS } from './spec-config.js';
11
+ import { buildChecks } from './check.js';
12
+ import { render, extractViolations, renderNote } from './watch-ui.js';
13
+ import { spawnServer, detectServerCmd } from './server-proc.js';
14
+ import { setupLifecycle } from './watch-lifecycle.js';
15
+
16
+ /** Adapt a Check from buildChecks into a watch row (adds pass/detail/result state). */
17
+ function toRow(check) {
18
+ return { ...check, pass: null, detail: '', result: null };
19
+ }
20
+
21
+ /**
22
+ * Start the spec watch dashboard.
23
+ * @param {string} projectDir
24
+ */
25
+ export async function startWatch(projectDir) {
26
+ const cfg = loadConfig(projectDir);
27
+ const cmds = buildCmds(projectDir, { watch: true });
28
+ const checks = await buildChecks(projectDir, cfg, cmds);
29
+ const specRows = checks.map(toRow);
30
+ const checksForExt = makeExtMap(checks);
31
+
32
+ const watchDirs = ['src', 'scripts', 'docs'].filter((d) =>
33
+ existsSync(resolve(projectDir, d)),
34
+ );
35
+
36
+ let lastCheck = 'never';
37
+ let fastTimer = null;
38
+ let slowTimer = null;
39
+ /** @type {Set<string>} */
40
+ const fastPending = new Set();
41
+ /** @type {Set<string>} */
42
+ const slowPending = new Set();
43
+
44
+ // Render immediately — server spawns and checks run async behind it
45
+ const serverCmd = detectServerCmd(projectDir, cfg);
46
+
47
+ /** @type {import('./server-proc.js').ServerRow|null} */
48
+ const serverRow = serverCmd ? { key: 'server', label: 'server', status: 'starting', pass: null, detail: 'starting…', kill: () => {} } : null;
49
+ const rows = serverRow ? [serverRow, ...specRows] : specRows;
50
+
51
+ /**
52
+ *
53
+ */
54
+ function spawnAndWatch() {
55
+ const proc = spawnServer(serverCmd, projectDir, cfg.rawEnv, () => {
56
+ serverRow.status = proc.status;
57
+ serverRow.pass = proc.pass;
58
+ serverRow.detail = proc.detail;
59
+ serverRow.kill = proc.kill;
60
+ render(rows, lastCheck, watchDirs, currentViolations);
61
+ });
62
+ }
63
+ if (serverCmd) spawnAndWatch();
64
+
65
+ /** @type {string[][]} */
66
+ let currentViolations = [];
67
+ render(rows, lastCheck, watchDirs, currentViolations);
68
+
69
+ /**
70
+ *
71
+ */
72
+ function runKeys(keys) {
73
+ const toRun = specRows.filter((r) => keys.has(r.key));
74
+ if (!toRun.length) return;
75
+ let i = 0;
76
+ /**
77
+ *
78
+ */
79
+ async function next() {
80
+ if (i >= toRun.length) {
81
+ const now = new Date();
82
+ lastCheck = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
83
+ render(rows, lastCheck, watchDirs, currentViolations);
84
+ return;
85
+ }
86
+ const row = toRun[i++];
87
+ const raw = await row.run({ quiet: true });
88
+ row.result = typeof raw === 'boolean' ? { pass: raw } : raw;
89
+ row.pass = row.result.pass;
90
+ row.detail = row.pass
91
+ ? (row.result.detail ?? (row.result.files ? `${row.result.files} files` : ''))
92
+ : (row.result.violations?.length
93
+ ? `${row.result.violations.length} violation(s)`
94
+ : (() => { const errs = row.result.errors ?? []; const n = errs.filter((l) => /\.(js|ts|css|md)/.test(l)).length || errs.length; return `${n} error(s)`; })());
95
+ // Rebuild violations from all currently-failed rows
96
+ currentViolations = specRows.flatMap((r) => extractViolations(r, projectDir));
97
+ render(rows, lastCheck, watchDirs, currentViolations);
98
+ setImmediate(next);
99
+ }
100
+ setImmediate(next);
101
+ }
102
+
103
+ /**
104
+ *
105
+ */
106
+ function runFast() { const keys = new Set(fastPending); fastPending.clear(); runKeys(keys); }
107
+ /**
108
+ *
109
+ */
110
+ function runSlow() { const keys = new Set(slowPending); slowPending.clear(); runKeys(keys); }
111
+
112
+ /**
113
+ *
114
+ */
115
+ function schedule(ext) {
116
+ for (const k of checksForExt(ext)) {
117
+ if (FAST_CHECKS.has(k)) fastPending.add(k);
118
+ else slowPending.add(k);
119
+ }
120
+ if (fastPending.size) { clearTimeout(fastTimer); fastTimer = setTimeout(runFast, 50); }
121
+ if (slowPending.size) { clearTimeout(slowTimer); slowTimer = setTimeout(runSlow, 1500); }
122
+ }
123
+
124
+ runKeys(new Set(specRows.map((r) => r.key)));
125
+
126
+ setupLifecycle({
127
+ serverRow, specRows, projectDir, watchDirs,
128
+ schedule,
129
+ renderNote: () => renderNote(rows, lastCheck, watchDirs),
130
+ });
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -12,6 +12,10 @@
12
12
  "templates/",
13
13
  "docs/"
14
14
  ],
15
+ "exports": {
16
+ ".": "./lib/check.js",
17
+ "./lib/*": "./lib/*"
18
+ },
15
19
  "repository": {
16
20
  "type": "git",
17
21
  "url": "git+https://github.com/techninja/clearstack.git"
@@ -50,7 +54,8 @@
50
54
  ],
51
55
  "license": "MIT",
52
56
  "dependencies": {
53
- "@inquirer/prompts": "^8.3.2"
57
+ "@inquirer/prompts": "^8.3.2",
58
+ "blessed": "^0.1.81"
54
59
  },
55
60
  "devDependencies": {
56
61
  "@open-wc/testing": "^4.0.0",