great-cto 3.59.0 → 3.60.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/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/projects.mjs +2 -1
- package/board/packages/board/lib/routes.mjs +81 -13
- package/board/packages/board/lib/skills-inventory.mjs +228 -0
- package/board/packages/board/lib/usage-report-worker.mjs +2 -2
- package/board/packages/board/lib/usage-report.mjs +18 -10
- package/board/packages/board/lib/view-counter.mjs +1 -1
- package/board/packages/board/public/index.html +139 -34
- package/board/packages/board/public/share.html +4 -4
- package/board/scripts/lib/gate-plan.mjs +3 -2
- package/board/scripts/lib/gate-policy.mjs +97 -0
- package/board/scripts/lib/outcomes.mjs +8 -3
- package/board/scripts/lib/session-usage.mjs +72 -4
- package/board/scripts/skill-lint.mjs +182 -0
- package/package.json +1 -1
- package/postinstall.mjs +15 -9
|
@@ -86,6 +86,68 @@ export function projectName(cwd, homeDir = home()) {
|
|
|
86
86
|
return path.basename(cwd) || null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
const canonical = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
90
|
+
|
|
91
|
+
/** Stable repository identity, including linked worktrees. Never infer it from a title/basename. */
|
|
92
|
+
function repositoryIdentity(cwd) {
|
|
93
|
+
for (let dir = canonical(cwd); ; dir = path.dirname(dir)) {
|
|
94
|
+
const marker = path.join(dir, '.git');
|
|
95
|
+
try {
|
|
96
|
+
const stat = fs.statSync(marker);
|
|
97
|
+
let gitDir;
|
|
98
|
+
if (stat.isDirectory()) gitDir = marker;
|
|
99
|
+
else {
|
|
100
|
+
const match = fs.readFileSync(marker, 'utf8').match(/^gitdir:\s*(.+)\s*$/m);
|
|
101
|
+
if (!match) return null;
|
|
102
|
+
gitDir = path.resolve(dir, match[1].trim());
|
|
103
|
+
}
|
|
104
|
+
let common = gitDir;
|
|
105
|
+
try { common = path.resolve(gitDir, fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim()); } catch { /* main checkout */ }
|
|
106
|
+
return { common: canonical(common), root: dir };
|
|
107
|
+
} catch { /* continue to parent */ }
|
|
108
|
+
if (path.dirname(dir) === dir) return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Filter before aggregation: children without cwd inherit only their own host's parent. */
|
|
113
|
+
export function projectUsageIndex(index, projectPath) {
|
|
114
|
+
if (!projectPath) return index;
|
|
115
|
+
const root = canonical(projectPath);
|
|
116
|
+
const git = repositoryIdentity(root);
|
|
117
|
+
const projectRelative = git ? path.relative(git.root, root) : null;
|
|
118
|
+
const identities = new Map();
|
|
119
|
+
const entries = Object.entries(index.files || {});
|
|
120
|
+
const sessions = new Map();
|
|
121
|
+
for (const [, e] of entries) if (e.meta?.session) sessions.set(`${e.host}|${e.meta.session}`, e);
|
|
122
|
+
const inheritedCwd = e => {
|
|
123
|
+
const visited = new Set();
|
|
124
|
+
while (e) {
|
|
125
|
+
if (e.meta?.cwd) return e.meta.cwd;
|
|
126
|
+
const key = `${e.host}|${e.meta?.parent}`;
|
|
127
|
+
if (!e.meta?.parent || visited.has(key)) return null;
|
|
128
|
+
visited.add(key); e = sessions.get(key);
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
};
|
|
132
|
+
const matches = (cwd) => {
|
|
133
|
+
if (!cwd || typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
|
|
134
|
+
if (identities.has(cwd)) return identities.get(cwd);
|
|
135
|
+
const actual = canonical(cwd);
|
|
136
|
+
const repo = repositoryIdentity(actual);
|
|
137
|
+
// Transfer the selected project's relative directory to this worktree.
|
|
138
|
+
// A shared Git database alone does not make sibling monorepo apps one project.
|
|
139
|
+
const scopeRoot = git && repo && git.common === repo.common
|
|
140
|
+
? path.resolve(repo.root, projectRelative)
|
|
141
|
+
: root;
|
|
142
|
+
const rel = path.relative(scopeRoot, actual);
|
|
143
|
+
const inside = rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
|
|
144
|
+
const match = git && repo ? git.common === repo.common && inside : inside;
|
|
145
|
+
identities.set(cwd, match);
|
|
146
|
+
return match;
|
|
147
|
+
};
|
|
148
|
+
return { ...index, files: Object.fromEntries(entries.filter(([, e]) => matches(inheritedCwd(e)))) };
|
|
149
|
+
}
|
|
150
|
+
|
|
89
151
|
// ── file discovery ────────────────────────────────────────────────────────────
|
|
90
152
|
|
|
91
153
|
function walk(dir, maxDepth, out) {
|
|
@@ -823,7 +885,8 @@ function laneDetail(samples, { fromMs, now }) {
|
|
|
823
885
|
* @param {object} index from scanUsage
|
|
824
886
|
* @param {{days?:number, now?:number, codexTitles?:Record<string,string>, prices?:object, top?:number}} opts
|
|
825
887
|
*/
|
|
826
|
-
export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10, claudeReadings = [], claudeRecorderState = 'off' } = {}) {
|
|
888
|
+
export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10, claudeReadings = [], claudeRecorderState = 'off', projectPath = null } = {}) {
|
|
889
|
+
index = projectUsageIndex(index, projectPath);
|
|
827
890
|
const span = Math.max(1, Math.min(365, Math.floor(days) || 30));
|
|
828
891
|
const dates = [];
|
|
829
892
|
for (let i = span - 1; i >= 0; i--) dates.push(dayOf(now - i * 86400000));
|
|
@@ -918,7 +981,7 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
|
|
|
918
981
|
// The conversation's own file names it; a subagent's file only fills a gap.
|
|
919
982
|
const own = !e.meta?.parent;
|
|
920
983
|
if (own || !s.title) s.title = (host === 'codex' ? codexTitles[e.meta?.session] : null) || e.meta?.title || e.meta?.prompt || s.title;
|
|
921
|
-
if (own || !s.project) s.project = projectName(e.meta?.cwd) || s.project;
|
|
984
|
+
if (own || !s.project) s.project = projectName(projectPath || e.meta?.cwd) || s.project;
|
|
922
985
|
if (own || !s.fn) s.fn = fn;
|
|
923
986
|
}
|
|
924
987
|
|
|
@@ -940,6 +1003,11 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
|
|
|
940
1003
|
|
|
941
1004
|
return {
|
|
942
1005
|
state: 'counted',
|
|
1006
|
+
...(projectPath ? {
|
|
1007
|
+
scope: { kind: 'project', path: canonical(projectPath) },
|
|
1008
|
+
files: Object.keys(index.files || {}).length,
|
|
1009
|
+
seen: Object.fromEntries(['claude', 'codex'].map(host => [host, Object.values(index.files || {}).some(e => e.host === host)])),
|
|
1010
|
+
} : {}),
|
|
943
1011
|
window: { from, to: dates[dates.length - 1], days: span },
|
|
944
1012
|
hosts: Object.fromEntries(Object.entries(hosts).map(([k, h]) => [k, {
|
|
945
1013
|
tokens: tokensOf(h), input: h.in, output: h.out, cacheRead: h.cr, cacheWrite: h.cw + h.cwh, reasoning: h.rs,
|
|
@@ -957,8 +1025,8 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
|
|
|
957
1025
|
tools: rank(l.tools, 15), skills: rank(l.skills, 15), agents: rank(l.agents, 15), mcp: rank(l.mcp, 15, mcpLabel),
|
|
958
1026
|
}])),
|
|
959
1027
|
limits: {
|
|
960
|
-
codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
|
|
961
|
-
claude: { ...claudeLimits(hourly.claude, { fromMs, now, lastHit }), plan: claudePlanDetail(claudeReadings, { fromMs, now }), recorder: claudeRecorderState },
|
|
1028
|
+
codex: !projectPath && codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
|
|
1029
|
+
claude: { ...claudeLimits(hourly.claude, { fromMs, now, lastHit }), plan: projectPath ? null : claudePlanDetail(claudeReadings, { fromMs, now }), recorder: claudeRecorderState },
|
|
962
1030
|
},
|
|
963
1031
|
unpricedModels: [...unpriced].sort(),
|
|
964
1032
|
// Claude Code only: Codex does not write hook outcomes to its session logs.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/skill-lint.mjs — structural validator for skills/*\/SKILL.md, the skill
|
|
4
|
+
* counterpart of agent-prompt-lint.mjs. Agents had a lint, a review and a retire
|
|
5
|
+
* loop; skills shipped with none, so a skill whose frontmatter did not load, whose
|
|
6
|
+
* name drifted from its directory, or which pointed at a deleted script was found
|
|
7
|
+
* only by a model failing to use it.
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* SK-001 error YAML frontmatter present and parses
|
|
11
|
+
* SK-002 error `name` equals the skill's directory
|
|
12
|
+
* SK-003 error `description` present, 40–1024 chars (the model routes on it)
|
|
13
|
+
* SK-004 warn SKILL.md over 20 KB (loaded whole into context) — HEAVY_ALLOWED exempts
|
|
14
|
+
* SK-005 error a relative link or backticked repo path points at a file that does not exist
|
|
15
|
+
* SK-006 error a private absolute path (/Users/<name>/…, /home/<name>/…)
|
|
16
|
+
* SK-007 error an unquoted frontmatter value strict YAML reads differently (`: `, ` #`, a reserved first character)
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* node scripts/skill-lint.mjs [--root <repo>] [--json]
|
|
20
|
+
* Exit: 0 clean or warnings only · 1 errors · 2 invalid invocation
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
export const SIZE_BUDGET = 20 * 1024;
|
|
27
|
+
export const DESC_MIN = 40;
|
|
28
|
+
export const DESC_MAX = 1024;
|
|
29
|
+
|
|
30
|
+
/** Skills allowed over the size budget, each with the reason it is allowed. */
|
|
31
|
+
export const HEAVY_ALLOWED = {
|
|
32
|
+
'ui-ux-pro-max': 'vendored design reference (MIT, upstream layout); its tables are the payload and are loaded only for UI work',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Backticked paths into great_cto's own tree. docs/ and tests/ are left out on
|
|
36
|
+
// purpose: skills name those as paths in the *target* project far more often.
|
|
37
|
+
const REPO_PATH = /^(scripts|skills|agents|commands|shared|hooks|packages)\/[A-Za-z0-9._/-]+\.[A-Za-z0-9]+$/;
|
|
38
|
+
const MD_LINK = /\[[^\]\n]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
39
|
+
const TICKED = /`([^`\n]+)`/g;
|
|
40
|
+
const PRIVATE_PATH = /(?<![\w.~$}])\/(?:Users|home)\/[A-Za-z0-9][A-Za-z0-9._-]*\//g;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Minimal YAML frontmatter reader: top-level `key: value`, block scalars
|
|
44
|
+
* (`|` / `>`), and indented continuation (lists, nested maps) kept as text.
|
|
45
|
+
* Throws on a top-level line that is not a key.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Unquoted top-level values that a strict YAML parser reads differently from how
|
|
49
|
+
* this file's lenient parser reads them — so the frontmatter "works" here and the
|
|
50
|
+
* skill (or agent, or command) silently fails to load elsewhere. ECC shipped a
|
|
51
|
+
* preflight for exactly this after a release did (techwolf-ai/ai-first-toolkit
|
|
52
|
+
* preflight.py, v1.12.2). Quote the value to fix it.
|
|
53
|
+
*/
|
|
54
|
+
export function strictYamlIssues(text) {
|
|
55
|
+
const t = String(text).replace(/\r\n/g, '\n');
|
|
56
|
+
if (!t.startsWith('---\n')) return [];
|
|
57
|
+
const end = t.indexOf('\n---', 3);
|
|
58
|
+
if (end === -1) return [];
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const line of t.slice(4, end).split('\n')) {
|
|
61
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s+(.+)$/);
|
|
62
|
+
if (!m) continue;
|
|
63
|
+
const v = m[2].trim();
|
|
64
|
+
if (/^["'|>\[{]/.test(v)) continue; // quoted, block scalar or flow collection
|
|
65
|
+
if (/:\s/.test(v) || /:$/.test(v)) out.push(`\`${m[1]}\`: an unquoted value containing ": " is read as a nested mapping`);
|
|
66
|
+
else if (/\s#/.test(v)) out.push(`\`${m[1]}\`: an unquoted " #" starts a comment — the value is cut there`);
|
|
67
|
+
else if (/^[@`%&*!]/.test(v)) out.push(`\`${m[1]}\`: an unquoted value may not start with ${v[0]}`);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseFrontmatter(text) {
|
|
73
|
+
const t = String(text).replace(/\r\n/g, '\n');
|
|
74
|
+
if (!t.startsWith('---\n')) return null;
|
|
75
|
+
const end = t.indexOf('\n---', 3);
|
|
76
|
+
if (end === -1) return null;
|
|
77
|
+
const yaml = t.slice(4, end);
|
|
78
|
+
const body = t.slice(end + 4).replace(/^\n/, '');
|
|
79
|
+
const data = {};
|
|
80
|
+
let key = null;
|
|
81
|
+
let block = false;
|
|
82
|
+
for (const line of yaml.split('\n')) {
|
|
83
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
84
|
+
if (/^\s/.test(line)) {
|
|
85
|
+
if (key === null) throw new Error(`indented line before any key: ${line.slice(0, 60)}`);
|
|
86
|
+
const v = line.trim();
|
|
87
|
+
if (block || typeof data[key] === 'string') data[key] = data[key] ? `${data[key]} ${v}` : v;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:(?:\s+(.*)|\s*)$/);
|
|
91
|
+
if (!m) throw new Error(`unparseable line: ${line.slice(0, 60)}`);
|
|
92
|
+
key = m[1];
|
|
93
|
+
const v = (m[2] ?? '').trim();
|
|
94
|
+
block = /^[|>][+-]?$/.test(v);
|
|
95
|
+
data[key] = block ? '' : v === '' ? null : v.replace(/^(["'])([\s\S]*)\1$/, '$2');
|
|
96
|
+
}
|
|
97
|
+
return { data, body };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const placeholder = (s) => /[{}<>*…$]|\.\.\./.test(s);
|
|
101
|
+
|
|
102
|
+
function danglingRefs(text, skillDir, repoRoot) {
|
|
103
|
+
const out = [];
|
|
104
|
+
const exists = (rel) => [skillDir, repoRoot].some((base) => fs.existsSync(path.resolve(base, rel)));
|
|
105
|
+
for (const m of text.matchAll(MD_LINK)) {
|
|
106
|
+
const target = m[1].replace(/#.*$/, '');
|
|
107
|
+
if (!target || /^[a-z][a-z0-9+.-]*:/i.test(m[1]) || m[1].startsWith('#') || target.startsWith('/') || placeholder(target)) continue;
|
|
108
|
+
if (!exists(target)) out.push(`link to missing file \`${target}\``);
|
|
109
|
+
}
|
|
110
|
+
for (const m of text.matchAll(TICKED)) {
|
|
111
|
+
const p = m[1].trim().replace(/:\d+(-\d+)?$/, '');
|
|
112
|
+
if (!REPO_PATH.test(p) || placeholder(p)) continue;
|
|
113
|
+
if (!exists(p)) out.push(`reference to missing file \`${p}\``);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function lintOne(name, file, repoRoot) {
|
|
119
|
+
const errors = [];
|
|
120
|
+
const warnings = [];
|
|
121
|
+
const push = (list, rule, msg) => list.push({ skill: name, rule, msg });
|
|
122
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
123
|
+
|
|
124
|
+
let fm = null;
|
|
125
|
+
try {
|
|
126
|
+
fm = parseFrontmatter(text);
|
|
127
|
+
if (!fm) push(errors, 'SK-001', 'no `---` frontmatter at the start of the file');
|
|
128
|
+
} catch (e) {
|
|
129
|
+
push(errors, 'SK-001', `frontmatter does not parse: ${e.message}`);
|
|
130
|
+
}
|
|
131
|
+
if (fm) {
|
|
132
|
+
const { name: n, description: d } = fm.data;
|
|
133
|
+
if (n !== name) push(errors, 'SK-002', `name \`${n ?? '(missing)'}\` does not match directory \`${name}\``);
|
|
134
|
+
if (typeof d !== 'string' || !d.trim()) push(errors, 'SK-003', 'frontmatter missing `description`');
|
|
135
|
+
else if (d.length < DESC_MIN) push(errors, 'SK-003', `description too short (${d.length} chars; min ${DESC_MIN})`);
|
|
136
|
+
else if (d.length > DESC_MAX) push(errors, 'SK-003', `description too long (${d.length} chars; max ${DESC_MAX})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const bytes = Buffer.byteLength(text);
|
|
140
|
+
if (bytes > SIZE_BUDGET && !HEAVY_ALLOWED[name]) {
|
|
141
|
+
push(warnings, 'SK-004', `${(bytes / 1024).toFixed(1)} KB over the ${SIZE_BUDGET / 1024} KB budget — loaded whole into context`);
|
|
142
|
+
}
|
|
143
|
+
for (const msg of danglingRefs(text, path.dirname(file), repoRoot)) push(errors, 'SK-005', msg);
|
|
144
|
+
for (const m of text.matchAll(PRIVATE_PATH)) push(errors, 'SK-006', `private absolute path \`${m[0]}…\``);
|
|
145
|
+
for (const msg of strictYamlIssues(text)) push(errors, 'SK-007', msg);
|
|
146
|
+
return { errors, warnings };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Lint every skills/<name>/SKILL.md under repoRoot. */
|
|
150
|
+
export function lintSkills({ repoRoot }) {
|
|
151
|
+
const dir = path.join(repoRoot, 'skills');
|
|
152
|
+
const names = fs.readdirSync(dir, { withFileTypes: true })
|
|
153
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
|
|
154
|
+
.map((e) => e.name).sort();
|
|
155
|
+
const errors = [];
|
|
156
|
+
const warnings = [];
|
|
157
|
+
for (const n of names) {
|
|
158
|
+
const r = lintOne(n, path.join(dir, n, 'SKILL.md'), repoRoot);
|
|
159
|
+
errors.push(...r.errors);
|
|
160
|
+
warnings.push(...r.warnings);
|
|
161
|
+
}
|
|
162
|
+
return { skills: names.length, errors, warnings };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
166
|
+
if (isMain) {
|
|
167
|
+
const a = process.argv.slice(2);
|
|
168
|
+
const i = a.indexOf('--root');
|
|
169
|
+
if (i >= 0 && !a[i + 1]) { process.stderr.write('usage: skill-lint.mjs [--root <repo>] [--json]\n'); process.exit(2); }
|
|
170
|
+
const repoRoot = i >= 0 ? path.resolve(a[i + 1]) : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
171
|
+
let r;
|
|
172
|
+
try { r = lintSkills({ repoRoot }); } catch (e) { process.stderr.write(`skill-lint: ${e.message}\n`); process.exit(2); }
|
|
173
|
+
if (a.includes('--json')) {
|
|
174
|
+
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
175
|
+
} else {
|
|
176
|
+
for (const e of r.errors) process.stdout.write(`ERROR ${e.rule} skills/${e.skill}: ${e.msg}\n`);
|
|
177
|
+
for (const w of r.warnings) process.stdout.write(`warn ${w.rule} skills/${w.skill}: ${w.msg}\n`);
|
|
178
|
+
const heavy = Object.keys(HEAVY_ALLOWED).join(', ');
|
|
179
|
+
process.stdout.write(`skill-lint: ${r.skills} skills · ${r.errors.length} errors · ${r.warnings.length} warnings (heavy by allow-list: ${heavy})\n`);
|
|
180
|
+
}
|
|
181
|
+
process.exit(r.errors.length ? 1 : 0);
|
|
182
|
+
}
|
package/package.json
CHANGED
package/postinstall.mjs
CHANGED
|
@@ -138,32 +138,38 @@ function main() {
|
|
|
138
138
|
* - never block. The board takes the better part of a minute to become
|
|
139
139
|
* responsive; the installer does not wait for it. Detached, unref'd, output
|
|
140
140
|
* discarded.
|
|
141
|
-
* - never in CI, and never when asked not to.
|
|
142
|
-
*
|
|
141
|
+
* - never in CI or a Node test runner, and never when asked not to. A test
|
|
142
|
+
* HOME isolates files, NOT ports: a detached board would still claim the
|
|
143
|
+
* user's :3141 and serve fixture projects after the test exits.
|
|
144
|
+
* `GREAT_CTO_NO_BOARD=1` opts out of this specifically.
|
|
143
145
|
* - never twice. `ensure` probes the port first and adopts a board that is
|
|
144
146
|
* already answering, whoever started it.
|
|
145
147
|
*/
|
|
146
|
-
function ensureBoard(
|
|
147
|
-
|
|
148
|
+
export function ensureBoard({ env = process.env, spawnFn = spawn,
|
|
149
|
+
write = (text) => process.stdout.write(text) } = {}) {
|
|
150
|
+
if (env.CI || env.GREAT_CTO_QUIET_POSTINSTALL || env.GREAT_CTO_NO_BOARD || env.NODE_TEST_CONTEXT) return false;
|
|
148
151
|
try {
|
|
149
152
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
150
153
|
const entry = join(here, 'index.mjs');
|
|
151
|
-
if (!existsSync(entry)) return;
|
|
154
|
+
if (!existsSync(entry)) return false;
|
|
152
155
|
|
|
153
|
-
const child =
|
|
156
|
+
const child = spawnFn(process.execPath, [entry, 'board', 'ensure'], {
|
|
154
157
|
detached: true,
|
|
155
158
|
stdio: 'ignore',
|
|
159
|
+
env,
|
|
156
160
|
});
|
|
157
161
|
child.unref();
|
|
158
|
-
|
|
162
|
+
write(' great-cto: starting the board — http://localhost:3141\n');
|
|
163
|
+
return true;
|
|
159
164
|
} catch (e) {
|
|
160
165
|
// Never fail the install — but never fail SILENTLY either. The first version
|
|
161
166
|
// of this function referenced an import that was not there; the catch ate the
|
|
162
167
|
// ReferenceError and the hook printed nothing, so the board simply did not
|
|
163
168
|
// start and nothing said why. A swallowed error is the defect this project
|
|
164
169
|
// spends most of its checks on.
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
write(` great-cto: could not start the board — ${e?.message || e}\n`);
|
|
171
|
+
write(' Start it yourself with: great-cto board\n');
|
|
172
|
+
return false;
|
|
167
173
|
}
|
|
168
174
|
}
|
|
169
175
|
|