karajan-code 2.7.4 → 2.9.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/README.md +1 -1
- package/package.json +24 -4
- package/scripts/diet-baseline.mjs +170 -0
- package/scripts/regen-arch-stats.sh +71 -0
- package/src/agents/availability.js +15 -9
- package/src/agents/claude-agent.js +143 -45
- package/src/agents/model-registry.js +1 -1
- package/src/audit/agent-readiness.js +240 -0
- package/src/audit/basal-cost.js +92 -9
- package/src/audit/deterministic-summary.js +200 -0
- package/src/audit/osv-findings.js +162 -0
- package/src/audit/semgrep-findings.js +117 -0
- package/src/audit/sonar-findings.js +81 -0
- package/src/audit/webperf-input.js +90 -0
- package/src/bootstrap.js +26 -1
- package/src/brain/decision-tracker.js +3 -1
- package/src/brain/decisor.js +1 -1
- package/src/canvas/parse-md.js +238 -0
- package/src/canvas/schema.js +101 -0
- package/src/canvas/to-flat.js +85 -0
- package/src/canvas/validate.js +163 -0
- package/src/checks/binaries.js +3 -3
- package/src/checks/ci.js +2 -2
- package/src/checks/config-files.js +6 -6
- package/src/checks/dir-setup.js +4 -0
- package/src/checks/mcp-health.js +6 -0
- package/src/checks/ports.js +91 -29
- package/src/checks/rtk.js +1 -1
- package/src/checks/skills.js +5 -1
- package/src/checks/sonar.js +2 -2
- package/src/checks/system.js +3 -3
- package/src/checks/tokens.js +1 -1
- package/src/checks/types.js +0 -12
- package/src/cli/_shared.js +30 -0
- package/src/cli/register-meta.js +218 -0
- package/src/cli/register-pipeline.js +212 -0
- package/src/cli/register-plan.js +152 -0
- package/src/cli/register-roles-skills.js +38 -0
- package/src/cli/register-sonar.js +28 -0
- package/src/cli.js +83 -468
- package/src/commands/agents.js +18 -6
- package/src/commands/architect.js +29 -2
- package/src/commands/audit.js +265 -30
- package/src/commands/board.js +160 -30
- package/src/commands/clean.js +83 -0
- package/src/commands/code.js +7 -2
- package/src/commands/discover.js +7 -2
- package/src/commands/doctor.js +1 -1
- package/src/commands/init.js +2 -2
- package/src/commands/plan/_shared.js +40 -0
- package/src/commands/plan/add-hu.js +21 -0
- package/src/commands/plan/delete.js +9 -0
- package/src/commands/plan/generate.js +284 -0
- package/src/commands/plan/index.js +14 -0
- package/src/commands/plan/list.js +21 -0
- package/src/commands/plan/ready.js +46 -0
- package/src/commands/plan/remove-hu.js +17 -0
- package/src/commands/plan/show.js +81 -0
- package/src/commands/plan/validate.js +20 -0
- package/src/commands/plan.js +14 -291
- package/src/commands/researcher.js +7 -2
- package/src/commands/resume.js +24 -1
- package/src/commands/review.js +7 -2
- package/src/commands/run.js +52 -1
- package/src/commands/status.js +1 -1
- package/src/commands/sync.js +75 -0
- package/src/commands/triage.js +7 -2
- package/src/commands/undo.js +1 -1
- package/src/config/defaults.js +213 -0
- package/src/config/loader.js +137 -0
- package/src/config/overrides.js +199 -0
- package/src/config/role-resolver.js +159 -0
- package/src/config/test-harness.js +78 -0
- package/src/config.js +30 -632
- package/src/git/automation.js +2 -2
- package/src/guards/output-guard.js +0 -2
- package/src/hu/acceptance-runner.js +52 -10
- package/src/hu/store.js +11 -0
- package/src/mcp/handlers/direct-handlers.js +2 -5
- package/src/mcp/handlers/management-handlers.js +48 -4
- package/src/mcp/handlers/run-handler.js +9 -8
- package/src/mcp/preflight.js +32 -13
- package/src/mcp/server-handlers.js +4 -3
- package/src/mcp/suggest-handler.js +3 -2
- package/src/mcp/tools.js +15 -0
- package/src/orchestrator/agent-fallback.js +1 -1
- package/src/orchestrator/ci-integration.js +11 -11
- package/src/orchestrator/config-init.js +25 -7
- package/src/orchestrator/direct-actions.js +7 -3
- package/src/orchestrator/drivers/error-recovery.js +287 -0
- package/src/orchestrator/drivers/init-context.js +222 -0
- package/src/orchestrator/drivers/iteration-loop.js +301 -0
- package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
- package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
- package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
- package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
- package/src/orchestrator/drivers/post-loop.js +370 -0
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
- package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
- package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
- package/src/orchestrator/drivers/pre-loop.js +431 -0
- package/src/orchestrator/drivers/run-hu-batch.js +367 -0
- package/src/orchestrator/feedback-queue.js +0 -1
- package/src/orchestrator/flow-control.js +4 -3
- package/src/orchestrator/flow-runner.js +121 -2077
- package/src/orchestrator/fs-leak-detector.js +156 -0
- package/src/orchestrator/hu-sub-pipeline.js +197 -9
- package/src/orchestrator/integrations.js +81 -0
- package/src/orchestrator/post-loop-stages.js +35 -8
- package/src/orchestrator/preflight-checks.js +20 -7
- package/src/orchestrator/repair/repair-detector.js +117 -0
- package/src/orchestrator/repair/repair-runner.js +142 -0
- package/src/orchestrator/reviewer-fallback.js +1 -1
- package/src/orchestrator/role-output-compressor.js +0 -1
- package/src/orchestrator/session-journal.js +4 -41
- package/src/orchestrator/solomon-escalation.js +3 -2
- package/src/orchestrator/solomon-rules.js +7 -3
- package/src/orchestrator/stages/architect-stage.js +12 -11
- package/src/orchestrator/stages/coder-stage.js +56 -7
- package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
- package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
- package/src/orchestrator/stages/planner-stage.js +1 -1
- package/src/orchestrator/stages/research-stage.js +1 -1
- package/src/orchestrator/stages/reviewer-stage.js +7 -7
- package/src/orchestrator/stages/sonar-stage.js +94 -10
- package/src/orchestrator/stages/stage-classes.js +84 -0
- package/src/orchestrator/stages/stage-executor.js +1 -1
- package/src/orchestrator/stages/triage-stage.js +1 -1
- package/src/orchestrator/standby.js +3 -2
- package/src/orchestrator/verification-gate.js +20 -12
- package/src/plan/adr-generator.js +170 -0
- package/src/plan/adr-loader.js +60 -0
- package/src/plan/plan-executor.js +19 -1
- package/src/plan/plan-hu-ops.js +170 -1
- package/src/plan/plan-reviewer.js +199 -0
- package/src/plan/plan-schema.js +69 -1
- package/src/plan/plan-store.js +40 -17
- package/src/plan/spec-detector.js +61 -0
- package/src/plan/tests-synthesizer.js +116 -0
- package/src/planning-game/client.js +3 -28
- package/src/planning-game/decomposition.js +1 -1
- package/src/planning-game/pipeline-adapter.js +37 -6
- package/src/prompts/audit.js +206 -6
- package/src/prompts/coder.js +172 -1
- package/src/prompts/hu-reviewer.js +0 -1
- package/src/prompts/planner.js +81 -4
- package/src/prompts/rtk-snippet.js +1 -1
- package/src/review/diff-generator.js +35 -22
- package/src/review/snapshot-diff.js +1 -1
- package/src/roles/audit-role.js +125 -13
- package/src/roles/coder-role.js +25 -3
- package/src/roles/planner-role.js +42 -1
- package/src/roles/repairer-role.js +181 -0
- package/src/roles/tester-role.js +70 -4
- package/src/session/mutators.js +304 -0
- package/src/session/runtime-overrides.js +59 -0
- package/src/{session-store.js → session/store.js} +2 -2
- package/src/skills/semantic-detector.js +10 -5
- package/src/skills/skill-detector.js +1 -1
- package/src/sonar/api.js +1 -1
- package/src/sonar/cloud-scanner.js +0 -1
- package/src/sonar/credentials.js +14 -6
- package/src/sonar/discovery.js +123 -0
- package/src/sonar/manager.js +18 -0
- package/src/sync/detect-drift.js +113 -0
- package/src/tracker-bootstrap.js +44 -0
- package/src/utils/agent-detect.js +15 -11
- package/src/utils/board-prompt-bridge.js +109 -0
- package/src/utils/cli-progress.js +161 -0
- package/src/utils/cwd-confirm.js +39 -0
- package/src/utils/derive-project-name-from-cwd.js +119 -0
- package/src/utils/display/event-handlers.js +1 -1
- package/src/utils/display/header.js +1 -1
- package/src/utils/display/session-summary.js +3 -3
- package/src/utils/display/solomon.js +1 -1
- package/src/utils/garbage-collector.js +396 -0
- package/src/utils/git.js +16 -4
- package/src/utils/locale.js +1 -1
- package/src/utils/paths.js +21 -1
- package/src/utils/process.js +1 -1
- package/src/utils/prompt-project-name.js +35 -0
- package/src/utils/role-assigner.js +1 -1
- package/src/utils/run-context.js +75 -0
- package/src/utils/run-log.js +12 -0
- package/src/utils/task-file.js +1 -1
- package/src/utils/telemetry.js +13 -1
- package/src/webperf/devtools-detect.js +1 -1
- package/templates/coder-rules.md +13 -0
- package/templates/roles/coder.md +34 -0
- package/templates/roles/repairer.md +94 -0
|
@@ -2,6 +2,7 @@ import { createAgent } from "../agents/index.js";
|
|
|
2
2
|
import { assertAgentsAvailable } from "../agents/availability.js";
|
|
3
3
|
import { resolveRole } from "../config.js";
|
|
4
4
|
import { buildArchitectPrompt, parseArchitectOutput } from "../prompts/architect.js";
|
|
5
|
+
import { createCliProgressReporter } from "../utils/cli-progress.js";
|
|
5
6
|
|
|
6
7
|
function formatLayers(layers, lines) {
|
|
7
8
|
lines.push("### Layers");
|
|
@@ -67,8 +68,12 @@ export async function architectCommand({ task, config, logger, context, json })
|
|
|
67
68
|
|
|
68
69
|
const agent = createAgent(architectRole.provider, config, logger);
|
|
69
70
|
const prompt = await buildArchitectPrompt({ task, researchContext: context });
|
|
70
|
-
const
|
|
71
|
-
|
|
71
|
+
const progress = createCliProgressReporter({ role: "architect" });
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
result = await agent.runTask({ prompt, onOutput: progress.onOutput, role: "architect" });
|
|
75
|
+
progress.finish(result.ok ? "done" : "failed");
|
|
76
|
+
} catch (err) { progress.finish("failed"); throw err; }
|
|
72
77
|
|
|
73
78
|
if (!result.ok) {
|
|
74
79
|
throw new Error(result.error || result.output || "Architect failed");
|
|
@@ -86,6 +91,28 @@ export async function architectCommand({ task, config, logger, context, json })
|
|
|
86
91
|
} else {
|
|
87
92
|
console.log(result.output);
|
|
88
93
|
}
|
|
94
|
+
|
|
95
|
+
// Persist ADRs from the architect's tradeoffs[] (v2.7.5). One ADR
|
|
96
|
+
// per tradeoff in Nygard format under `~/.kj/plans/_loose/adrs/`.
|
|
97
|
+
// Coder/reviewer in subsequent runs can pull the active ADRs into
|
|
98
|
+
// their prompt for context — done in a follow-up PR.
|
|
99
|
+
if (parsed?.architecture && config?.projectDir) {
|
|
100
|
+
try {
|
|
101
|
+
const { persistAdrsFromArchitecture } = await import("../plan/adr-generator.js");
|
|
102
|
+
const { adrs, paths } = await persistAdrsFromArchitecture({
|
|
103
|
+
projectDir: config.projectDir,
|
|
104
|
+
planId: null, // standalone architect → "_loose" bucket
|
|
105
|
+
architecture: parsed.architecture,
|
|
106
|
+
});
|
|
107
|
+
if (paths.length > 0) {
|
|
108
|
+
console.log(`\n${adrs.length} ADR${adrs.length === 1 ? "" : "s"} written:`);
|
|
109
|
+
for (const p of paths) console.log(` - ${p}`);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
logger.warn(`ADR write skipped: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
89
116
|
logger.info("Architect completed.");
|
|
90
117
|
return { ok: true };
|
|
91
118
|
});
|
package/src/commands/audit.js
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
2
6
|
import { assertAgentsAvailable } from "../agents/availability.js";
|
|
3
7
|
import { resolveRole } from "../config.js";
|
|
4
|
-
import {
|
|
8
|
+
import { AUDIT_DIMENSIONS } from "../prompts/audit.js";
|
|
9
|
+
import { AuditRole } from "../roles/audit-role.js";
|
|
5
10
|
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
11
|
+
import { createCliProgressReporter } from "../utils/cli-progress.js";
|
|
12
|
+
import { runAgentReadiness, formatAgentReadinessReport } from "../audit/agent-readiness.js";
|
|
13
|
+
import { formatDeterministicSummary } from "../audit/deterministic-summary.js";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Prompt the user (TTY only) to decide whether to continue with the LLM
|
|
19
|
+
* phase after deterministic findings have been printed. KJC-TSK-0364.
|
|
20
|
+
*
|
|
21
|
+
* @returns {Promise<boolean>} true to continue with LLM, false to skip
|
|
22
|
+
*/
|
|
23
|
+
function promptContinueWithLlm({ promptFn = null } = {}) {
|
|
24
|
+
if (typeof promptFn === "function") return promptFn();
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
if (!process.stdin.isTTY) { resolve(true); return; } // CI / piped input → continue
|
|
27
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
28
|
+
rl.question("Continue with LLM analysis? [y/N] ", (answer) => {
|
|
29
|
+
rl.close();
|
|
30
|
+
resolve(/^y(es)?$/i.test((answer || "").trim()));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
6
34
|
|
|
7
35
|
function formatFindings(findings) {
|
|
8
36
|
const lines = [];
|
|
@@ -42,10 +70,11 @@ const DIMENSION_LABELS = {
|
|
|
42
70
|
codeQuality: "Code Quality",
|
|
43
71
|
performance: "Performance",
|
|
44
72
|
architecture: "Architecture",
|
|
45
|
-
testing: "Testing"
|
|
73
|
+
testing: "Testing",
|
|
74
|
+
accessibility: "Accessibility (WCAG 2.x)"
|
|
46
75
|
};
|
|
47
76
|
|
|
48
|
-
function formatAudit(parsed) {
|
|
77
|
+
function formatAudit(parsed, usage = null) {
|
|
49
78
|
const lines = [];
|
|
50
79
|
lines.push("## Codebase Health Report");
|
|
51
80
|
lines.push(`**Overall Health:** ${parsed.summary.overallHealth}`);
|
|
@@ -63,45 +92,251 @@ function formatAudit(parsed) {
|
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
if (parsed.textSummary) lines.push(`---\n${parsed.textSummary}`);
|
|
95
|
+
|
|
96
|
+
const usageBlock = formatUsageSummary(usage);
|
|
97
|
+
if (usageBlock) {
|
|
98
|
+
lines.push("");
|
|
99
|
+
lines.push(usageBlock);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Format the LLM token + cost summary that closes every audit report.
|
|
107
|
+
* KJC-TSK-0363: makes the LLM cost visible (was silently discarded
|
|
108
|
+
* pre-patch — auditCommand never read roleResult.usage).
|
|
109
|
+
*
|
|
110
|
+
* @param {object|null} usage - shape produced by AuditRole's extractUsage
|
|
111
|
+
* @returns {string|null} markdown block, or null when there's nothing useful to show
|
|
112
|
+
*/
|
|
113
|
+
function formatUsageSummary(usage) {
|
|
114
|
+
if (!usage) return null;
|
|
115
|
+
const lines = ["## LLM Usage"];
|
|
116
|
+
lines.push(`- Provider: ${usage.provider}${usage.model ? ` (${usage.model})` : ""}`);
|
|
117
|
+
if (typeof usage.durationMs === "number") {
|
|
118
|
+
lines.push(`- Duration: ${(usage.durationMs / 1000).toFixed(1)}s`);
|
|
119
|
+
}
|
|
120
|
+
if (!usage.available) {
|
|
121
|
+
lines.push(`- Tokens: usage data not available — ${usage.reason || "provider does not report it"}`);
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
// Force en-US locale so the comma grouping is deterministic across CI
|
|
125
|
+
// machines (Node's default Intl.NumberFormat depends on the runtime
|
|
126
|
+
// locale; without this the rendering would look like "1234" on
|
|
127
|
+
// locale-less Node and "1,234" on a US-locale workstation).
|
|
128
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
129
|
+
lines.push(`- Tokens: ${fmt(usage.total_tokens)} total (${fmt(usage.tokens_in)} in, ${fmt(usage.tokens_out)} out)`);
|
|
130
|
+
if (typeof usage.cost_usd === "number") {
|
|
131
|
+
lines.push(`- Estimated cost: $${usage.cost_usd.toFixed(4)} USD`);
|
|
132
|
+
}
|
|
66
133
|
return lines.join("\n");
|
|
67
134
|
}
|
|
68
135
|
|
|
69
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Resolve where to write the audit report on disk, given a flag value, an
|
|
138
|
+
* env override, and the desired format. Returns null if no write target
|
|
139
|
+
* was requested. Creates parent directories on demand.
|
|
140
|
+
*
|
|
141
|
+
* Resolution rules (KJC-TSK-0362):
|
|
142
|
+
* - flag undefined + no env → null (legacy behaviour, stdout only)
|
|
143
|
+
* - flag is a directory → write `audit-<ISO>.{md,json}` inside it
|
|
144
|
+
* - flag is a non-directory path → use it verbatim (extension drives format
|
|
145
|
+
* when --json/--md not explicit)
|
|
146
|
+
* - flag is undefined but env $KJ_AUDIT_REPORT_DIR is set → treat env as dir
|
|
147
|
+
*/
|
|
148
|
+
async function resolveReportFilePath(flagValue, isJson) {
|
|
149
|
+
const envDir = process.env.KJ_AUDIT_REPORT_DIR;
|
|
150
|
+
const target = flagValue || (envDir ? envDir : null);
|
|
151
|
+
if (!target) return null;
|
|
152
|
+
|
|
153
|
+
const ext = isJson ? "json" : "md";
|
|
154
|
+
let stats = null;
|
|
155
|
+
try { stats = await fs.stat(target); } catch { /* not a directory yet */ }
|
|
156
|
+
const isDir = stats?.isDirectory() || target.endsWith(path.sep) || target === envDir;
|
|
157
|
+
|
|
158
|
+
let resolved;
|
|
159
|
+
if (isDir) {
|
|
160
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
161
|
+
resolved = path.resolve(target, `audit-${stamp}.${ext}`);
|
|
162
|
+
} else {
|
|
163
|
+
resolved = path.resolve(target);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
167
|
+
return resolved;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the markdown header that prefixes a persisted report — captures
|
|
172
|
+
* timestamp + repo state + invocation flags so the file is reproducible.
|
|
173
|
+
*/
|
|
174
|
+
async function buildReportHeader(projectDir, invocation) {
|
|
175
|
+
const lines = ["# Karajan Audit Report", ""];
|
|
176
|
+
lines.push(`- **Generated:** ${new Date().toISOString()}`);
|
|
177
|
+
lines.push(`- **Project:** ${projectDir || process.cwd()}`);
|
|
178
|
+
|
|
179
|
+
// Best-effort git info — silent if outside a repo.
|
|
180
|
+
try {
|
|
181
|
+
const branch = (await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectDir, timeout: 2000 })).stdout.trim();
|
|
182
|
+
const commit = (await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { cwd: projectDir, timeout: 2000 })).stdout.trim();
|
|
183
|
+
lines.push(`- **Branch:** ${branch}`);
|
|
184
|
+
lines.push(`- **Commit:** ${commit}`);
|
|
185
|
+
} catch { /* not a git repo or git missing */ }
|
|
186
|
+
|
|
187
|
+
if (invocation) lines.push(`- **Invocation:** \`${invocation}\``);
|
|
188
|
+
lines.push("");
|
|
189
|
+
lines.push("---");
|
|
190
|
+
lines.push("");
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, json, deterministicOnly, yes }) {
|
|
195
|
+
const parts = ["kj audit"];
|
|
196
|
+
if (task && task !== "Analyze the full codebase") parts.push(JSON.stringify(task));
|
|
197
|
+
if (dimensions && dimensions !== "all") parts.push(`--dimensions=${dimensions}`);
|
|
198
|
+
if (noSonar) parts.push("--no-sonar");
|
|
199
|
+
if (noOsv) parts.push("--no-osv");
|
|
200
|
+
if (noSemgrep) parts.push("--no-semgrep");
|
|
201
|
+
if (deterministicOnly) parts.push("--deterministic-only");
|
|
202
|
+
if (yes) parts.push("--yes");
|
|
203
|
+
if (json) parts.push("--json");
|
|
204
|
+
return parts.join(" ");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function auditCommand({ task, config, logger, dimensions, json, agentReadiness, path: pathArg, noSonar = false, noOsv = false, noSemgrep = false, reportFile = null, deterministicOnly = false, yes = false, promptFn = null }) {
|
|
208
|
+
// --agent-readiness is a STANDALONE, deterministic, LLM-free audit
|
|
209
|
+
// dimension. It scores any third-party repo for AI-agent readability
|
|
210
|
+
// (llms.txt presence, page token budgets, robots allowlist, etc.).
|
|
211
|
+
// Per addyosmani/agentic-seo. Issue #542.
|
|
212
|
+
if (agentReadiness) {
|
|
213
|
+
const rootDir = path.resolve(pathArg || config?.projectDir || process.cwd());
|
|
214
|
+
logger.info(`Auditing agent-readiness of ${rootDir}`);
|
|
215
|
+
const report = runAgentReadiness(rootDir);
|
|
216
|
+
if (json) {
|
|
217
|
+
console.log(JSON.stringify(report, null, 2));
|
|
218
|
+
} else {
|
|
219
|
+
console.log(formatAgentReadinessReport(report, rootDir));
|
|
220
|
+
}
|
|
221
|
+
// Exit code: 0 always (audit succeeded). The score is the signal.
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
70
225
|
return withCliRunLog("audit", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
|
|
71
|
-
const
|
|
72
|
-
await assertAgentsAvailable([
|
|
73
|
-
logger.info(`Audit (${
|
|
74
|
-
runLog.logText(`[audit] provider=${
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
226
|
+
const auditRoleConfig = resolveRole(config, "audit");
|
|
227
|
+
await assertAgentsAvailable([auditRoleConfig.provider]);
|
|
228
|
+
logger.info(`Audit (${auditRoleConfig.provider}) starting...`);
|
|
229
|
+
runLog.logText(`[audit] provider=${auditRoleConfig.provider} dimensions=${dimensions || "all"} deterministicOnly=${deterministicOnly}`);
|
|
230
|
+
|
|
231
|
+
// Two-phase audit (KJC-TSK-0364) — collect deterministic findings
|
|
232
|
+
// first; show them; ask the user (when TTY + not --yes + not
|
|
233
|
+
// --deterministic-only) whether to spend tokens on the LLM analysis.
|
|
234
|
+
// CI / piped input / --yes / --deterministic-only never prompts.
|
|
235
|
+
const role = new AuditRole({ config, logger });
|
|
236
|
+
const roleInput = {
|
|
237
|
+
task: task || "Analyze the full codebase",
|
|
238
|
+
dimensions: dimensions || null,
|
|
239
|
+
noSonar,
|
|
240
|
+
noOsv,
|
|
241
|
+
noSemgrep,
|
|
242
|
+
};
|
|
243
|
+
const deterministicCtx = await role.collectDeterministic(roleInput);
|
|
244
|
+
const deterministicMd = formatDeterministicSummary(deterministicCtx);
|
|
245
|
+
|
|
246
|
+
// --json suppresses the interactive flow regardless: it would mangle
|
|
247
|
+
// a script's stdout. JSON consumers always get the full audit unless
|
|
248
|
+
// they explicitly pass deterministicOnly.
|
|
249
|
+
const reportPath = await resolveReportFilePath(reportFile, json);
|
|
250
|
+
let runLlm = !deterministicOnly;
|
|
251
|
+
if (runLlm && !json && !yes) {
|
|
252
|
+
console.log(deterministicMd);
|
|
253
|
+
console.log("");
|
|
254
|
+
const ok = await promptContinueWithLlm({ promptFn });
|
|
255
|
+
runLlm = ok;
|
|
256
|
+
if (!ok) logger.info("LLM analysis skipped (deterministic-only mode).");
|
|
87
257
|
}
|
|
88
258
|
|
|
89
|
-
|
|
90
|
-
|
|
259
|
+
if (!runLlm) {
|
|
260
|
+
// Deterministic-only path. Produce the report from what we already
|
|
261
|
+
// have, persist it (md or json), and exit. Zero tokens spent.
|
|
262
|
+
const stdoutContent = json
|
|
263
|
+
? JSON.stringify({ deterministic: deterministicCtx, mode: "deterministic-only" }, null, 2)
|
|
264
|
+
: (deterministicOnly ? deterministicMd : ""); // already printed above when interactive
|
|
265
|
+
if (json || deterministicOnly) console.log(stdoutContent);
|
|
266
|
+
|
|
267
|
+
if (reportPath) {
|
|
268
|
+
let payload;
|
|
269
|
+
if (reportPath.endsWith(".json")) {
|
|
270
|
+
payload = JSON.stringify({ deterministic: deterministicCtx, mode: "deterministic-only" }, null, 2);
|
|
271
|
+
} else {
|
|
272
|
+
const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, json, deterministicOnly, yes }));
|
|
273
|
+
payload = header + deterministicMd + "\n";
|
|
274
|
+
}
|
|
275
|
+
await fs.writeFile(reportPath, payload, "utf8");
|
|
276
|
+
runLog.logText(`[audit] report written (deterministic-only) → ${reportPath}`);
|
|
277
|
+
logger.info(`Audit report written: ${reportPath}`);
|
|
278
|
+
}
|
|
279
|
+
logger.info("Audit completed (deterministic-only).");
|
|
280
|
+
return { ok: true, mode: "deterministic-only", reportPath: reportPath || undefined };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Continue with the LLM phase using the already-collected context.
|
|
284
|
+
const progress = createCliProgressReporter({ role: "auditor" });
|
|
285
|
+
let roleResult;
|
|
286
|
+
try {
|
|
287
|
+
roleResult = await role.executeWithDeterministic({ ...roleInput, onOutput: progress.onOutput }, deterministicCtx);
|
|
288
|
+
progress.finish(roleResult.ok ? "done" : "failed");
|
|
289
|
+
} catch (err) { progress.finish("failed"); throw err; }
|
|
290
|
+
|
|
291
|
+
if (!roleResult.ok) {
|
|
292
|
+
const err = roleResult.result?.error || "Audit failed";
|
|
293
|
+
throw new Error(err);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const parsed = roleResult.result;
|
|
297
|
+
const usage = roleResult.usage;
|
|
298
|
+
if (parsed?.summary?.overallHealth) {
|
|
91
299
|
runLog.logText(`[audit] findings=${parsed.summary.totalFindings} (critical=${parsed.summary.critical}, high=${parsed.summary.high})`);
|
|
92
300
|
}
|
|
301
|
+
if (usage?.available) {
|
|
302
|
+
runLog.logText(`[audit] usage tokens=${usage.total_tokens} cost=$${(usage.cost_usd ?? 0).toFixed(4)} duration=${(usage.durationMs / 1000).toFixed(1)}s`);
|
|
303
|
+
}
|
|
93
304
|
|
|
305
|
+
let stdoutContent;
|
|
94
306
|
if (json) {
|
|
95
|
-
|
|
96
|
-
|
|
307
|
+
const jsonPayload = parsed
|
|
308
|
+
? { ...parsed, usage: usage || undefined }
|
|
309
|
+
: { ...roleResult, usage: usage || undefined };
|
|
310
|
+
stdoutContent = JSON.stringify(jsonPayload, null, 2);
|
|
311
|
+
} else if (parsed?.summary?.overallHealth) {
|
|
312
|
+
stdoutContent = formatAudit(parsed, usage);
|
|
313
|
+
} else if (parsed?.raw) {
|
|
314
|
+
stdoutContent = parsed.raw + (usage ? `\n\n${formatUsageSummary(usage) || ""}` : "");
|
|
315
|
+
} else {
|
|
316
|
+
stdoutContent = roleResult.summary || "Audit complete.";
|
|
97
317
|
}
|
|
318
|
+
console.log(stdoutContent);
|
|
98
319
|
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
320
|
+
if (reportPath) {
|
|
321
|
+
let payload;
|
|
322
|
+
if (reportPath.endsWith(".json")) {
|
|
323
|
+
const jsonPayload = parsed
|
|
324
|
+
? { ...parsed, usage: usage || undefined }
|
|
325
|
+
: { ...roleResult, usage: usage || undefined };
|
|
326
|
+
payload = JSON.stringify(jsonPayload, null, 2);
|
|
327
|
+
} else {
|
|
328
|
+
const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, json, deterministicOnly, yes }));
|
|
329
|
+
payload = header + stdoutContent + "\n";
|
|
330
|
+
}
|
|
331
|
+
await fs.writeFile(reportPath, payload, "utf8");
|
|
332
|
+
runLog.logText(`[audit] report written → ${reportPath}`);
|
|
333
|
+
logger.info(`Audit report written: ${reportPath}`);
|
|
103
334
|
}
|
|
335
|
+
|
|
104
336
|
logger.info("Audit completed.");
|
|
105
|
-
return { ok: true };
|
|
337
|
+
return { ok: true, reportPath: reportPath || undefined };
|
|
106
338
|
});
|
|
107
339
|
}
|
|
340
|
+
|
|
341
|
+
// Exposed for tests — kept module-private otherwise.
|
|
342
|
+
export { formatAudit, formatUsageSummary, AUDIT_DIMENSIONS };
|
package/src/commands/board.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import net from "node:net";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
5
6
|
import { getKarajanHome } from "../utils/paths.js";
|
|
7
|
+
import { getPortOccupant } from "../utils/port-occupant.js";
|
|
6
8
|
|
|
7
9
|
const BOARD_DIR = path.resolve(import.meta.dirname, "../../packages/hu-board");
|
|
8
10
|
const PID_FILE = path.join(getKarajanHome(), "hu-board.pid");
|
|
@@ -25,6 +27,27 @@ function isProcessAlive(pid) {
|
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* HTTP-probe the board's /api/dashboard. Returns true when it
|
|
32
|
+
* responds with 2xx within ~750ms. Used as a fallback when the PID
|
|
33
|
+
* file is missing/stale to detect "board is up, just untracked".
|
|
34
|
+
*/
|
|
35
|
+
async function isBoardReachable(port, timeoutMs = 750) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const req = http.request(
|
|
38
|
+
{ host: '127.0.0.1', port, path: '/api/dashboard', method: 'GET', timeout: timeoutMs },
|
|
39
|
+
(res) => {
|
|
40
|
+
const ok = res.statusCode >= 200 && res.statusCode < 500;
|
|
41
|
+
res.resume(); // drain
|
|
42
|
+
resolve(ok);
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
req.on('error', () => resolve(false));
|
|
46
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
47
|
+
req.end();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
/**
|
|
29
52
|
* Check if a TCP port is available on localhost.
|
|
30
53
|
* Returns true when we can successfully bind (port is free).
|
|
@@ -52,11 +75,45 @@ async function findAvailablePort(desiredPort, maxTries = 10) {
|
|
|
52
75
|
return null;
|
|
53
76
|
}
|
|
54
77
|
|
|
55
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Build the board URL. When `projectSlug` is provided, the URL points at
|
|
80
|
+
* the per-project view (`/p/<slug>`) so the caller's project shows up
|
|
81
|
+
* pre-filtered instead of "All projects".
|
|
82
|
+
* @param {number} port
|
|
83
|
+
* @param {string|null} [projectSlug]
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function buildBoardUrl(port, projectSlug) {
|
|
87
|
+
const base = `http://localhost:${port}`;
|
|
88
|
+
return projectSlug ? `${base}/p/${projectSlug}` : base;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
92
|
+
const { projectSlug = null } = opts;
|
|
56
93
|
const existingPid = readPid();
|
|
57
94
|
if (existingPid && isProcessAlive(existingPid)) {
|
|
58
95
|
// Trust the saved PID — port info comes from the PID file if present
|
|
59
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
alreadyRunning: true,
|
|
99
|
+
pid: existingPid,
|
|
100
|
+
url: buildBoardUrl(desiredPort, projectSlug),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Belt-and-suspenders (v2.7.5 fix): even if the PID file is missing
|
|
105
|
+
// or stale, HTTP-probe the configured port before spawning. If
|
|
106
|
+
// /api/dashboard responds, a board is already there — reuse it. The
|
|
107
|
+
// pre-fix scenario: 🔁 self-restart didn't update the PID file
|
|
108
|
+
// (now fixed), so a `kj plan` afterwards saw a stale PID, spawned
|
|
109
|
+
// a duplicate board on port 4001, and confused the user.
|
|
110
|
+
if (await isBoardReachable(desiredPort)) {
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
alreadyRunning: true,
|
|
114
|
+
pid: existingPid || null,
|
|
115
|
+
url: buildBoardUrl(desiredPort, projectSlug),
|
|
116
|
+
};
|
|
60
117
|
}
|
|
61
118
|
|
|
62
119
|
// Find a free port starting from desiredPort
|
|
@@ -79,34 +136,85 @@ export async function startBoard(desiredPort = 4000) {
|
|
|
79
136
|
});
|
|
80
137
|
// Swallow spawn errors so an HU Board failure never crashes the pipeline.
|
|
81
138
|
child.on("error", () => { /* non-blocking — caller logs via tryAutoStartBoard catch */ });
|
|
82
|
-
child.unref();
|
|
83
|
-
|
|
84
139
|
if (!child.pid) {
|
|
85
140
|
throw new Error("Failed to spawn HU Board server");
|
|
86
141
|
}
|
|
87
142
|
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
88
|
-
|
|
143
|
+
// Detach for real: `detached: true` only sets up the new session;
|
|
144
|
+
// without `unref()` the parent's event loop still holds a reference
|
|
145
|
+
// to the child handle and `kj board start` hangs at the prompt
|
|
146
|
+
// instead of returning. Pair this with stdio:"ignore" (already set)
|
|
147
|
+
// to ensure the parent isn't waiting on any of the child's pipes.
|
|
148
|
+
child.unref();
|
|
149
|
+
return { ok: true, alreadyRunning: false, pid: child.pid, url: buildBoardUrl(port, projectSlug), port };
|
|
89
150
|
}
|
|
90
151
|
|
|
91
|
-
export async function stopBoard() {
|
|
152
|
+
export async function stopBoard(opts = {}) {
|
|
153
|
+
const { port = 4000 } = opts;
|
|
154
|
+
// Path 1: live PID in the file → straight SIGTERM. Pre-v2.7.5
|
|
155
|
+
// this was the only path; if the file was missing or stale (e.g.
|
|
156
|
+
// user ran a board built before the server-writes-own-PID fix in
|
|
157
|
+
// PR #508) we'd return "wasn't running" while the ghost server
|
|
158
|
+
// happily kept serving requests. The fallbacks below close that gap.
|
|
92
159
|
const pid = readPid();
|
|
93
|
-
if (
|
|
94
|
-
try {
|
|
95
|
-
|
|
160
|
+
if (pid && isProcessAlive(pid)) {
|
|
161
|
+
try { process.kill(pid, "SIGTERM"); } catch { /* race with dying process */ }
|
|
162
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
163
|
+
return { ok: true, wasRunning: true, pid, via: "pidfile" };
|
|
96
164
|
}
|
|
165
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
97
166
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
167
|
+
// Path 2: HTTP-ask the server to exit cleanly. Works for any
|
|
168
|
+
// running server that has the /api/board/shutdown endpoint —
|
|
169
|
+
// even when there's no PID file, no process tree to walk, etc.
|
|
170
|
+
const shutdownUrl = `/api/board/shutdown`;
|
|
171
|
+
const shutdownOk = await postBoardShutdown(port, shutdownUrl);
|
|
172
|
+
if (shutdownOk) {
|
|
173
|
+
return { ok: true, wasRunning: true, pid: null, via: "api" };
|
|
102
174
|
}
|
|
103
|
-
|
|
104
|
-
|
|
175
|
+
|
|
176
|
+
// Path 3: there's something on the port and the API didn't take it
|
|
177
|
+
// down. Find its PID via lsof / ss / netstat and SIGTERM it.
|
|
178
|
+
// Works against truly old servers that don't have the shutdown
|
|
179
|
+
// endpoint, with the cost of one extra subprocess call.
|
|
180
|
+
try {
|
|
181
|
+
const occupant = await getPortOccupant(port);
|
|
182
|
+
if (occupant?.pid) {
|
|
183
|
+
try { process.kill(occupant.pid, "SIGTERM"); } catch { /* gone */ }
|
|
184
|
+
return { ok: true, wasRunning: true, pid: occupant.pid, via: "port-occupant" };
|
|
185
|
+
}
|
|
186
|
+
} catch { /* lsof not installed etc — fall through */ }
|
|
187
|
+
|
|
188
|
+
return { ok: true, wasRunning: false };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Best-effort POST to the board's shutdown endpoint. Returns true
|
|
193
|
+
* when the server acks (and is presumably exiting); false on any
|
|
194
|
+
* error so the caller falls through to the next strategy.
|
|
195
|
+
*/
|
|
196
|
+
function postBoardShutdown(port, urlPath, timeoutMs = 750) {
|
|
197
|
+
return new Promise((resolve) => {
|
|
198
|
+
const req = http.request(
|
|
199
|
+
{ host: '127.0.0.1', port, path: urlPath, method: 'POST', timeout: timeoutMs,
|
|
200
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': '2' } },
|
|
201
|
+
(res) => {
|
|
202
|
+
const ok = res.statusCode >= 200 && res.statusCode < 400;
|
|
203
|
+
res.resume();
|
|
204
|
+
resolve(ok);
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
req.on('error', () => resolve(false));
|
|
208
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
209
|
+
req.write('{}');
|
|
210
|
+
req.end();
|
|
211
|
+
});
|
|
105
212
|
}
|
|
106
213
|
|
|
107
214
|
export async function boardStatus(port = 4000) {
|
|
108
215
|
const pid = readPid();
|
|
109
216
|
const running = pid !== null && isProcessAlive(pid);
|
|
217
|
+
|
|
110
218
|
return {
|
|
111
219
|
ok: true,
|
|
112
220
|
running,
|
|
@@ -115,27 +223,46 @@ export async function boardStatus(port = 4000) {
|
|
|
115
223
|
};
|
|
116
224
|
}
|
|
117
225
|
|
|
226
|
+
// Strip ANSI escapes so we can measure the actual on-screen width of a
|
|
227
|
+
// styled string. Used to size the rules around the banner content.
|
|
228
|
+
const ANSI_RE = /\[[0-9;]*m/g;
|
|
229
|
+
const visibleWidth = (s) => s.replace(ANSI_RE, "").length;
|
|
230
|
+
|
|
118
231
|
/**
|
|
119
|
-
* Render a
|
|
120
|
-
*
|
|
121
|
-
*
|
|
232
|
+
* Render a HU Board announcement framed by two horizontal rules of EQUAL
|
|
233
|
+
* length. Pre-v2.7.5 we used a fixed 50-char rounded box drawn with
|
|
234
|
+
* corners + verticals — fine when the URL fit, but a project slug
|
|
235
|
+
* pushed past the right wall and the box looked broken. The user's
|
|
236
|
+
* verdict: "looks worse than no box at all when it overflows".
|
|
237
|
+
*
|
|
238
|
+
* New rule: just an over-and-under rule sized to the longest visible
|
|
239
|
+
* content line. No left/right verticals, no fixed width. Always tidy.
|
|
122
240
|
*
|
|
123
241
|
* @param {Object} params
|
|
124
242
|
* @param {string} params.url - "http://localhost:4000"
|
|
125
243
|
* @param {string} params.status - "started" | "already running"
|
|
126
244
|
* @param {string} [params.projectName] - optional project label
|
|
127
|
-
* @returns {string} ANSI-
|
|
245
|
+
* @returns {string} ANSI-coloured banner ready for console.log
|
|
128
246
|
*/
|
|
129
247
|
export function renderBoardBanner({ url, status, projectName }) {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
];
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
248
|
+
const cyan = (s) => `[36m${s}[0m`;
|
|
249
|
+
const bold = (s) => `[1m${s}[0m`;
|
|
250
|
+
const underline = (s) => `[4m${s}[0m`;
|
|
251
|
+
|
|
252
|
+
const heading = ` ${bold("HU Board")} · ${status} → ${underline(url)}`;
|
|
253
|
+
const content = [heading];
|
|
254
|
+
if (projectName) content.push(` Project: ${bold(projectName)}`);
|
|
255
|
+
|
|
256
|
+
// Width = widest visible content line + 2-cell padding. Cap at the
|
|
257
|
+
// terminal width so a silly-long URL doesn't draw a 200-char rule.
|
|
258
|
+
const termWidth = (process.stdout.columns && process.stdout.columns > 20)
|
|
259
|
+
? process.stdout.columns
|
|
260
|
+
: 100;
|
|
261
|
+
const longest = Math.max(...content.map(visibleWidth));
|
|
262
|
+
const ruleWidth = Math.min(longest + 2, termWidth);
|
|
263
|
+
const rule = cyan("─".repeat(ruleWidth));
|
|
264
|
+
|
|
265
|
+
return ["", rule, ...content, rule, ""].join("\n");
|
|
139
266
|
}
|
|
140
267
|
|
|
141
268
|
export async function boardCommand({ action = "start", port = 4000, logger }) {
|
|
@@ -150,9 +277,11 @@ export async function boardCommand({ action = "start", port = 4000, logger }) {
|
|
|
150
277
|
return result;
|
|
151
278
|
}
|
|
152
279
|
case "stop": {
|
|
153
|
-
const result = await stopBoard();
|
|
280
|
+
const result = await stopBoard({ port });
|
|
154
281
|
if (result.wasRunning) {
|
|
155
|
-
|
|
282
|
+
const pidPart = result.pid ? ` (PID ${result.pid})` : "";
|
|
283
|
+
const viaPart = result.via && result.via !== "pidfile" ? ` [via ${result.via}]` : "";
|
|
284
|
+
logger.info(`HU Board stopped${pidPart}${viaPart}`);
|
|
156
285
|
} else {
|
|
157
286
|
logger.info("HU Board was not running");
|
|
158
287
|
}
|
|
@@ -174,6 +303,7 @@ export async function boardCommand({ action = "start", port = 4000, logger }) {
|
|
|
174
303
|
await startBoard(port);
|
|
175
304
|
}
|
|
176
305
|
const url = `http://localhost:${port}`;
|
|
306
|
+
// eslint-disable-next-line import-x/no-unresolved -- optional peer; the .catch handles its absence
|
|
177
307
|
const { default: open } = await import("open").catch(() => ({ default: null }));
|
|
178
308
|
if (open) {
|
|
179
309
|
await open(url);
|