sickbay 1.16.2 → 1.16.4

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.
@@ -1 +1 @@
1
- {"version":3,"file":"init-Dn7eDMEp.js","names":[],"sources":["../src/commands/init.ts"],"sourcesContent":["import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync } from 'fs';\nimport { join } from 'path';\n\nimport {\n runSickbay,\n runSickbayMonorepo,\n detectContext,\n detectMonorepo,\n getAvailableChecks,\n} from 'sickbay-core';\n\nimport { saveEntry } from '../lib/history.js';\n\nimport type { MonorepoInfo } from 'sickbay-core';\n\nconst CONFIG_FILES = ['sickbay.config.ts', 'sickbay.config.js', 'sickbay.config.mjs'];\n\nconst CATEGORY_ORDER = ['dependencies', 'code-quality', 'performance', 'security', 'git'] as const;\n\nconst CATEGORY_LABELS: Record<string, string> = {\n dependencies: 'Dependencies',\n 'code-quality': 'Code Quality',\n performance: 'Performance',\n security: 'Security',\n git: 'Git',\n};\n\n/**\n * Get all applicable checks for a project path.\n * For monorepos, unions checks across all packages so the root config\n * lists every check that applies to any package.\n */\nasync function getAllApplicableChecks(\n projectPath: string,\n monorepoInfo?: MonorepoInfo | { isMonorepo: false },\n): Promise<{ name: string; category: string }[]> {\n const mono = monorepoInfo ?? (await detectMonorepo(projectPath));\n\n if (!mono.isMonorepo) {\n const context = await detectContext(projectPath);\n return getAvailableChecks(context);\n }\n\n // Union checks across all packages — deduplicate by name\n const seen = new Set<string>();\n const allChecks: { name: string; category: string }[] = [];\n\n for (const pkgPath of mono.packagePaths) {\n const context = await detectContext(pkgPath);\n for (const check of getAvailableChecks(context)) {\n if (!seen.has(check.name)) {\n seen.add(check.name);\n allChecks.push(check);\n }\n }\n }\n\n return allChecks;\n}\n\nexport async function generateConfigFile(\n projectPath: string,\n options?: { force?: boolean },\n): Promise<void> {\n const configPath = join(projectPath, 'sickbay.config.ts');\n\n if (!options?.force && existsSync(configPath)) {\n console.log('Config already exists, skipping');\n return;\n }\n\n const checks = await getAllApplicableChecks(projectPath);\n\n // Group checks by category\n const grouped: Record<string, string[]> = {};\n for (const check of checks) {\n if (!grouped[check.category]) {\n grouped[check.category] = [];\n }\n grouped[check.category].push(check.name);\n }\n\n // Build check lines grouped by category in display order\n const checkLineGroups: string[] = [];\n for (const category of CATEGORY_ORDER) {\n const names = grouped[category];\n if (!names || names.length === 0) continue;\n const label = CATEGORY_LABELS[category] ?? category;\n const lines = names.map((name) => {\n // Quote names with hyphens\n const key = name.includes('-') ? `'${name}'` : name;\n return ` ${key}: true,`;\n });\n checkLineGroups.push(` // --- ${label} ---\\n${lines.join('\\n')}`);\n }\n\n const checkLines = checkLineGroups.join('\\n\\n');\n\n const template = `// Threshold overrides, suppressions, and more:\n// https://nebulord-dev.github.io/sickbay/guide/configuration\n\n/** @type {import('sickbay/config').SickbayConfig} */\nexport default {\n checks: {\n${checkLines}\n },\n}\n`;\n\n writeFileSync(configPath, template);\n console.log(options?.force ? `Regenerated sickbay.config.ts` : `Created sickbay.config.ts`);\n}\n\nfunction formatCheckKey(name: string): string {\n return name.includes('-') ? `'${name}'` : name;\n}\n\nexport async function syncConfigFile(projectPath: string): Promise<void> {\n // Find existing config file\n const configFile = CONFIG_FILES.map((f) => join(projectPath, f)).find((p) => existsSync(p));\n\n if (!configFile) {\n console.log('No config file found. Run `sickbay init` to create one.');\n return;\n }\n\n const content = readFileSync(configFile, 'utf-8');\n\n // Check that the file has a checks block\n if (!content.includes('checks:') && !content.includes('checks :')) {\n console.log(\n 'Config file has no `checks` block. Add a `checks: { ... }` section or run `sickbay init --reset-config`.',\n );\n return;\n }\n\n // Detect applicable checks (unions across packages for monorepos)\n const applicableChecks = await getAllApplicableChecks(projectPath);\n\n // Scan file content for existing check IDs\n const existingIds = new Set<string>();\n const checkPattern = /^\\s*['\"]?([\\w-]+)['\"]?\\s*:/gm;\n let match;\n while ((match = checkPattern.exec(content)) !== null) {\n existingIds.add(match[1]);\n }\n\n // Find missing checks\n const missing = applicableChecks.filter((c) => !existingIds.has(c.name));\n\n if (missing.length === 0) {\n console.log('Config is up to date — no new checks to add.');\n return;\n }\n\n // Group missing checks by category\n const grouped: Record<string, string[]> = {};\n for (const check of missing) {\n if (!grouped[check.category]) {\n grouped[check.category] = [];\n }\n grouped[check.category].push(check.name);\n }\n\n // Build new check lines\n const newLineGroups: string[] = [];\n for (const category of CATEGORY_ORDER) {\n const names = grouped[category];\n if (!names || names.length === 0) continue;\n const label = CATEGORY_LABELS[category] ?? category;\n const lines = names.map((name) => ` ${formatCheckKey(name)}: true,`);\n newLineGroups.push(`\\n // --- New: ${label} ---\\n${lines.join('\\n')}`);\n }\n\n const newBlock = newLineGroups.join('');\n\n // Find insertion point: the closing `}` of the checks object.\n // Strategy: find 'checks:' then find its matching closing brace.\n const lines = content.split('\\n');\n let checksStartLine = -1;\n let insertionLine = -1;\n let braceDepth = 0;\n let inChecksBlock = false;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n\n if (!inChecksBlock && /^\\s*checks\\s*:/.test(line)) {\n checksStartLine = i;\n // Count opening braces on this line\n braceDepth = (line.match(/\\{/g) || []).length - (line.match(/\\}/g) || []).length;\n inChecksBlock = braceDepth > 0;\n continue;\n }\n\n if (inChecksBlock) {\n braceDepth += (line.match(/\\{/g) || []).length - (line.match(/\\}/g) || []).length;\n if (braceDepth <= 0) {\n insertionLine = i;\n break;\n }\n }\n }\n\n if (checksStartLine === -1 || insertionLine === -1) {\n console.log(\n 'Could not find the checks block boundary. Please add the new checks manually or run `sickbay init --reset-config`.',\n );\n return;\n }\n\n // Insert new checks before the closing brace of the checks block\n lines.splice(insertionLine, 0, newBlock);\n writeFileSync(configFile, lines.join('\\n'));\n\n const names = missing.map((c) => c.name).join(', ');\n console.log(`Added ${missing.length} new check(s): ${names}`);\n}\n\nexport async function initSickbay(projectPath: string): Promise<void> {\n const monorepoInfo = await detectMonorepo(projectPath);\n\n await generateConfigFile(projectPath);\n\n const sickbayDir = join(projectPath, '.sickbay');\n\n // Scaffold .sickbay/\n mkdirSync(sickbayDir, { recursive: true });\n\n // Write .gitignore — history is local, baseline is committed\n writeFileSync(join(sickbayDir, '.gitignore'), 'history.json\\ndep-tree.json\\ncache/\\n');\n\n // Add .sickbay entries to project's root .gitignore if not already present\n const rootGitignorePath = join(projectPath, '.gitignore');\n const gitignoreEntries = ['.sickbay/history.json', '.sickbay/dep-tree.json', '.sickbay/cache/'];\n const existingGitignore = existsSync(rootGitignorePath)\n ? readFileSync(rootGitignorePath, 'utf-8')\n : '';\n const toAdd = gitignoreEntries.filter((e) => !existingGitignore.includes(e));\n if (toAdd.length > 0) {\n const prefix = existingGitignore.endsWith('\\n') || existingGitignore === '' ? '' : '\\n';\n appendFileSync(rootGitignorePath, `${prefix}${toAdd.join('\\n')}\\n`);\n }\n\n const baselinePath = join(sickbayDir, 'baseline.json');\n if (existsSync(baselinePath)) {\n console.log('⚠ .sickbay/baseline.json already exists. Overwriting with new scan.');\n }\n\n console.log('Running initial scan to generate baseline...\\n');\n\n // Skip config loading during init — the just-generated config only has defaults\n // and `sickbay/config` isn't resolvable from the target project yet\n let overallScore: number;\n let projectName: string;\n\n if (monorepoInfo.isMonorepo) {\n const report = await runSickbayMonorepo({ projectPath, _config: null });\n writeFileSync(baselinePath, JSON.stringify(report, null, 2));\n overallScore = report.overallScore;\n projectName = `monorepo (${report.packages.length} packages)`;\n } else {\n const report = await runSickbay({ projectPath, _config: null });\n writeFileSync(baselinePath, JSON.stringify(report, null, 2));\n\n // Seed history with this first entry\n try {\n saveEntry(report);\n } catch {\n // Non-critical\n }\n\n overallScore = report.overallScore;\n projectName = report.projectInfo.name;\n }\n\n const scoreLabel = overallScore >= 80 ? 'good' : overallScore >= 60 ? 'fair' : 'needs work';\n\n console.log(`\\n✓ Sickbay initialized for ${projectName}`);\n console.log(` Overall score: ${overallScore}/100 (${scoreLabel})`);\n console.log(`\\nCreated:`);\n console.log(` sickbay.config.ts — project configuration`);\n console.log(` .sickbay/baseline.json — committed (team baseline)`);\n console.log(` .sickbay/.gitignore — ignores history.json, dep-tree.json + cache/`);\n if (toAdd.length > 0) {\n console.log(` .gitignore — added ${toAdd.join(', ')}`);\n }\n console.log(`\\nRun \\`sickbay\\` to add history entries over time.`);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"init-Czn8P7An.js","names":[],"sources":["../src/commands/init.ts"],"sourcesContent":["import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync } from 'fs';\nimport { join } from 'path';\n\nimport {\n runSickbay,\n runSickbayMonorepo,\n detectContext,\n detectMonorepo,\n getAvailableChecks,\n} from 'sickbay-core';\n\nimport { saveEntry } from '../lib/history.js';\n\nimport type { MonorepoInfo } from 'sickbay-core';\n\nconst CONFIG_FILES = ['sickbay.config.ts', 'sickbay.config.js', 'sickbay.config.mjs'];\n\nconst CATEGORY_ORDER = ['dependencies', 'code-quality', 'performance', 'security', 'git'] as const;\n\nconst CATEGORY_LABELS: Record<string, string> = {\n dependencies: 'Dependencies',\n 'code-quality': 'Code Quality',\n performance: 'Performance',\n security: 'Security',\n git: 'Git',\n};\n\n/**\n * Get all applicable checks for a project path.\n * For monorepos, unions checks across all packages so the root config\n * lists every check that applies to any package.\n */\nasync function getAllApplicableChecks(\n projectPath: string,\n monorepoInfo?: MonorepoInfo | { isMonorepo: false },\n): Promise<{ name: string; category: string }[]> {\n const mono = monorepoInfo ?? (await detectMonorepo(projectPath));\n\n if (!mono.isMonorepo) {\n const context = await detectContext(projectPath);\n return getAvailableChecks(context);\n }\n\n // Union checks across all packages — deduplicate by name\n const seen = new Set<string>();\n const allChecks: { name: string; category: string }[] = [];\n\n for (const pkgPath of mono.packagePaths) {\n const context = await detectContext(pkgPath);\n for (const check of getAvailableChecks(context)) {\n if (!seen.has(check.name)) {\n seen.add(check.name);\n allChecks.push(check);\n }\n }\n }\n\n return allChecks;\n}\n\nexport async function generateConfigFile(\n projectPath: string,\n options?: { force?: boolean },\n): Promise<void> {\n const configPath = join(projectPath, 'sickbay.config.ts');\n\n if (!options?.force && existsSync(configPath)) {\n console.log('Config already exists, skipping');\n return;\n }\n\n const checks = await getAllApplicableChecks(projectPath);\n\n // Group checks by category\n const grouped: Record<string, string[]> = {};\n for (const check of checks) {\n if (!grouped[check.category]) {\n grouped[check.category] = [];\n }\n grouped[check.category].push(check.name);\n }\n\n // Build check lines grouped by category in display order\n const checkLineGroups: string[] = [];\n for (const category of CATEGORY_ORDER) {\n const names = grouped[category];\n if (!names || names.length === 0) continue;\n const label = CATEGORY_LABELS[category] ?? category;\n const lines = names.map((name) => {\n // Quote names with hyphens\n const key = name.includes('-') ? `'${name}'` : name;\n return ` ${key}: true,`;\n });\n checkLineGroups.push(` // --- ${label} ---\\n${lines.join('\\n')}`);\n }\n\n const checkLines = checkLineGroups.join('\\n\\n');\n\n const template = `// Threshold overrides, suppressions, and more:\n// https://nebulord-dev.github.io/sickbay/guide/configuration\n\n/** @type {import('sickbay/config').SickbayConfig} */\nexport default {\n checks: {\n${checkLines}\n },\n}\n`;\n\n writeFileSync(configPath, template);\n console.log(options?.force ? `Regenerated sickbay.config.ts` : `Created sickbay.config.ts`);\n}\n\nfunction formatCheckKey(name: string): string {\n return name.includes('-') ? `'${name}'` : name;\n}\n\nexport async function syncConfigFile(projectPath: string): Promise<void> {\n // Find existing config file\n const configFile = CONFIG_FILES.map((f) => join(projectPath, f)).find((p) => existsSync(p));\n\n if (!configFile) {\n console.log('No config file found. Run `sickbay init` to create one.');\n return;\n }\n\n const content = readFileSync(configFile, 'utf-8');\n\n // Check that the file has a checks block\n if (!content.includes('checks:') && !content.includes('checks :')) {\n console.log(\n 'Config file has no `checks` block. Add a `checks: { ... }` section or run `sickbay init --reset-config`.',\n );\n return;\n }\n\n // Detect applicable checks (unions across packages for monorepos)\n const applicableChecks = await getAllApplicableChecks(projectPath);\n\n // Scan file content for existing check IDs\n const existingIds = new Set<string>();\n const checkPattern = /^\\s*['\"]?([\\w-]+)['\"]?\\s*:/gm;\n let match;\n while ((match = checkPattern.exec(content)) !== null) {\n existingIds.add(match[1]);\n }\n\n // Find missing checks\n const missing = applicableChecks.filter((c) => !existingIds.has(c.name));\n\n if (missing.length === 0) {\n console.log('Config is up to date — no new checks to add.');\n return;\n }\n\n // Group missing checks by category\n const grouped: Record<string, string[]> = {};\n for (const check of missing) {\n if (!grouped[check.category]) {\n grouped[check.category] = [];\n }\n grouped[check.category].push(check.name);\n }\n\n // Build new check lines\n const newLineGroups: string[] = [];\n for (const category of CATEGORY_ORDER) {\n const names = grouped[category];\n if (!names || names.length === 0) continue;\n const label = CATEGORY_LABELS[category] ?? category;\n const lines = names.map((name) => ` ${formatCheckKey(name)}: true,`);\n newLineGroups.push(`\\n // --- New: ${label} ---\\n${lines.join('\\n')}`);\n }\n\n const newBlock = newLineGroups.join('');\n\n // Find insertion point: the closing `}` of the checks object.\n // Strategy: find 'checks:' then find its matching closing brace.\n const lines = content.split('\\n');\n let checksStartLine = -1;\n let insertionLine = -1;\n let braceDepth = 0;\n let inChecksBlock = false;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n\n if (!inChecksBlock && /^\\s*checks\\s*:/.test(line)) {\n checksStartLine = i;\n // Count opening braces on this line\n braceDepth = (line.match(/\\{/g) || []).length - (line.match(/\\}/g) || []).length;\n inChecksBlock = braceDepth > 0;\n continue;\n }\n\n if (inChecksBlock) {\n braceDepth += (line.match(/\\{/g) || []).length - (line.match(/\\}/g) || []).length;\n if (braceDepth <= 0) {\n insertionLine = i;\n break;\n }\n }\n }\n\n if (checksStartLine === -1 || insertionLine === -1) {\n console.log(\n 'Could not find the checks block boundary. Please add the new checks manually or run `sickbay init --reset-config`.',\n );\n return;\n }\n\n // Insert new checks before the closing brace of the checks block\n lines.splice(insertionLine, 0, newBlock);\n writeFileSync(configFile, lines.join('\\n'));\n\n const names = missing.map((c) => c.name).join(', ');\n console.log(`Added ${missing.length} new check(s): ${names}`);\n}\n\nexport async function initSickbay(projectPath: string): Promise<void> {\n const monorepoInfo = await detectMonorepo(projectPath);\n\n await generateConfigFile(projectPath);\n\n const sickbayDir = join(projectPath, '.sickbay');\n\n // Scaffold .sickbay/\n mkdirSync(sickbayDir, { recursive: true });\n\n // Write .gitignore — history is local, baseline is committed\n writeFileSync(join(sickbayDir, '.gitignore'), 'history.json\\ndep-tree.json\\ncache/\\n');\n\n // Add .sickbay entries to project's root .gitignore if not already present\n const rootGitignorePath = join(projectPath, '.gitignore');\n const gitignoreEntries = ['.sickbay/history.json', '.sickbay/dep-tree.json', '.sickbay/cache/'];\n const existingGitignore = existsSync(rootGitignorePath)\n ? readFileSync(rootGitignorePath, 'utf-8')\n : '';\n const toAdd = gitignoreEntries.filter((e) => !existingGitignore.includes(e));\n if (toAdd.length > 0) {\n const prefix = existingGitignore.endsWith('\\n') || existingGitignore === '' ? '' : '\\n';\n appendFileSync(rootGitignorePath, `${prefix}${toAdd.join('\\n')}\\n`);\n }\n\n const baselinePath = join(sickbayDir, 'baseline.json');\n if (existsSync(baselinePath)) {\n console.log('⚠ .sickbay/baseline.json already exists. Overwriting with new scan.');\n }\n\n console.log('Running initial scan to generate baseline...\\n');\n\n // Skip config loading during init — the just-generated config only has defaults\n // and `sickbay/config` isn't resolvable from the target project yet\n let overallScore: number;\n let projectName: string;\n\n if (monorepoInfo.isMonorepo) {\n const report = await runSickbayMonorepo({ projectPath, _config: null });\n writeFileSync(baselinePath, JSON.stringify(report, null, 2));\n overallScore = report.overallScore;\n projectName = `monorepo (${report.packages.length} packages)`;\n } else {\n const report = await runSickbay({ projectPath, _config: null });\n writeFileSync(baselinePath, JSON.stringify(report, null, 2));\n\n // Seed history with this first entry\n try {\n saveEntry(report);\n } catch {\n // Non-critical\n }\n\n overallScore = report.overallScore;\n projectName = report.projectInfo.name;\n }\n\n const scoreLabel = overallScore >= 80 ? 'good' : overallScore >= 60 ? 'fair' : 'needs work';\n\n console.log(`\\n✓ Sickbay initialized for ${projectName}`);\n console.log(` Overall score: ${overallScore}/100 (${scoreLabel})`);\n console.log(`\\nCreated:`);\n console.log(` sickbay.config.ts — project configuration`);\n console.log(` .sickbay/baseline.json — committed (team baseline)`);\n console.log(` .sickbay/.gitignore — ignores history.json, dep-tree.json + cache/`);\n if (toAdd.length > 0) {\n console.log(` .gitignore — added ${toAdd.join(', ')}`);\n }\n console.log(`\\nRun \\`sickbay\\` to add history entries over time.`);\n}\n"],"mappings":"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"}
@@ -94,6 +94,12 @@ async function serveWeb(report, preferredPort = 3030, aiService) {
94
94
  if (!distDir) throw new Error("Web dashboard assets not found. Try reinstalling: npx sickbay@latest --web");
95
95
  const reportJson = JSON.stringify(report);
96
96
  const port = await getFreePort(preferredPort);
97
+ const serverUrl = `http://localhost:${port}`;
98
+ const jsonHeaders = {
99
+ "Content-Type": "application/json",
100
+ "Access-Control-Allow-Origin": serverUrl,
101
+ Vary: "Origin"
102
+ };
97
103
  let aiSummary = null;
98
104
  if (aiService && !("isMonorepo" in report)) try {
99
105
  aiSummary = await aiService.generateSummary(report);
@@ -103,17 +109,17 @@ async function serveWeb(report, preferredPort = 3030, aiService) {
103
109
  const server = http.createServer(async (req, res) => {
104
110
  const url = req.url ?? "/";
105
111
  if (url === "/sickbay-report.json") {
106
- res.writeHead(200, { "Content-Type": "application/json" });
112
+ res.writeHead(200, jsonHeaders);
107
113
  res.end(reportJson);
108
114
  return;
109
115
  }
110
116
  if (url === "/sickbay-history.json") {
111
117
  const historyPath = join("isMonorepo" in report ? report.rootPath : report.projectPath, ".sickbay", "history.json");
112
118
  if (existsSync(historyPath)) {
113
- res.writeHead(200, { "Content-Type": "application/json" });
119
+ res.writeHead(200, jsonHeaders);
114
120
  res.end(readFileSync(historyPath, "utf-8"));
115
121
  } else {
116
- res.writeHead(404, { "Content-Type": "application/json" });
122
+ res.writeHead(404, jsonHeaders);
117
123
  res.end("{}");
118
124
  }
119
125
  return;
@@ -121,10 +127,10 @@ async function serveWeb(report, preferredPort = 3030, aiService) {
121
127
  if (url === "/sickbay-dep-tree.json") {
122
128
  const treePath = join("isMonorepo" in report ? report.rootPath : report.projectPath, ".sickbay", "dep-tree.json");
123
129
  if (existsSync(treePath)) {
124
- res.writeHead(200, { "Content-Type": "application/json" });
130
+ res.writeHead(200, jsonHeaders);
125
131
  res.end(readFileSync(treePath, "utf-8"));
126
132
  } else {
127
- res.writeHead(404, { "Content-Type": "application/json" });
133
+ res.writeHead(404, jsonHeaders);
128
134
  res.end("{}");
129
135
  }
130
136
  return;
@@ -135,14 +141,14 @@ async function serveWeb(report, preferredPort = 3030, aiService) {
135
141
  const { loadConfig } = await import("./dist-Bjhxbcak.js").then((n) => n.s);
136
142
  const config = await loadConfig(basePath);
137
143
  if (config) {
138
- res.writeHead(200, { "Content-Type": "application/json" });
144
+ res.writeHead(200, jsonHeaders);
139
145
  res.end(JSON.stringify(config));
140
146
  } else {
141
- res.writeHead(404, { "Content-Type": "application/json" });
147
+ res.writeHead(404, jsonHeaders);
142
148
  res.end("{}");
143
149
  }
144
150
  } catch {
145
- res.writeHead(500, { "Content-Type": "application/json" });
151
+ res.writeHead(500, jsonHeaders);
146
152
  res.end("{}");
147
153
  }
148
154
  return;
@@ -151,36 +157,35 @@ async function serveWeb(report, preferredPort = 3030, aiService) {
151
157
  const packageName = parsedUrl.searchParams.get("package");
152
158
  if (parsedUrl.pathname === "/ai/summary") {
153
159
  if (req.method === "HEAD") {
154
- if (aiService) res.writeHead(200);
155
- else res.writeHead(404);
160
+ res.writeHead(aiService ? 200 : 404, jsonHeaders);
156
161
  res.end();
157
162
  return;
158
163
  }
159
164
  if (packageName && aiService && "isMonorepo" in report) {
160
165
  const pkg = report.packages.find((p) => p.name === packageName);
161
166
  if (!pkg) {
162
- res.writeHead(404, { "Content-Type": "application/json" });
167
+ res.writeHead(404, jsonHeaders);
163
168
  res.end(JSON.stringify({ error: "Package not found" }));
164
169
  return;
165
170
  }
166
171
  try {
167
172
  const pkgReport = packageReportToSickbayReport(pkg, report);
168
173
  const summary = await aiService.generateSummary(pkgReport);
169
- res.writeHead(200, { "Content-Type": "application/json" });
174
+ res.writeHead(200, jsonHeaders);
170
175
  res.end(JSON.stringify({ summary }));
171
176
  } catch (err) {
172
177
  const message = err instanceof Error ? err.message : "Internal error";
173
- res.writeHead(500, { "Content-Type": "application/json" });
178
+ res.writeHead(500, jsonHeaders);
174
179
  res.end(JSON.stringify({ error: message }));
175
180
  }
176
181
  return;
177
182
  }
178
183
  if (aiSummary) {
179
- res.writeHead(200, { "Content-Type": "application/json" });
184
+ res.writeHead(200, jsonHeaders);
180
185
  res.end(JSON.stringify({ summary: aiSummary }));
181
186
  return;
182
187
  }
183
- res.writeHead(404, { "Content-Type": "application/json" });
188
+ res.writeHead(404, jsonHeaders);
184
189
  res.end("{}");
185
190
  return;
186
191
  }
@@ -193,7 +198,7 @@ async function serveWeb(report, preferredPort = 3030, aiService) {
193
198
  body += chunk;
194
199
  if (Buffer.byteLength(body) > MAX_BODY) {
195
200
  aborted = true;
196
- res.writeHead(413, { "Content-Type": "application/json" });
201
+ res.writeHead(413, jsonHeaders);
197
202
  res.end(JSON.stringify({ error: "Payload too large" }));
198
203
  req.destroy();
199
204
  }
@@ -206,23 +211,23 @@ async function serveWeb(report, preferredPort = 3030, aiService) {
206
211
  if (packageName && "isMonorepo" in report) {
207
212
  const pkg = report.packages.find((p) => p.name === packageName);
208
213
  if (!pkg) {
209
- res.writeHead(404, { "Content-Type": "application/json" });
214
+ res.writeHead(404, jsonHeaders);
210
215
  res.end(JSON.stringify({ error: "Package not found" }));
211
216
  return;
212
217
  }
213
218
  chatReport = packageReportToSickbayReport(pkg, report);
214
219
  } else if (!("isMonorepo" in report)) chatReport = report;
215
220
  else {
216
- res.writeHead(400, { "Content-Type": "application/json" });
221
+ res.writeHead(400, jsonHeaders);
217
222
  res.end(JSON.stringify({ error: "Package name required for monorepo" }));
218
223
  return;
219
224
  }
220
225
  const response = await aiService.chat(message, chatReport, history ?? []);
221
- res.writeHead(200, { "Content-Type": "application/json" });
226
+ res.writeHead(200, jsonHeaders);
222
227
  res.end(JSON.stringify({ response }));
223
228
  } catch (err) {
224
229
  const errMessage = err instanceof Error ? err.message : "Internal error";
225
- res.writeHead(500, { "Content-Type": "application/json" });
230
+ res.writeHead(500, jsonHeaders);
226
231
  res.end(JSON.stringify({ error: errMessage }));
227
232
  }
228
233
  });
@@ -249,11 +254,11 @@ async function serveWeb(report, preferredPort = 3030, aiService) {
249
254
  else reject(err);
250
255
  });
251
256
  server.listen(port, "127.0.0.1", () => {
252
- resolve(`http://localhost:${port}`);
257
+ resolve(serverUrl);
253
258
  });
254
259
  });
255
260
  }
256
261
  //#endregion
257
262
  export { serveWeb };
258
263
 
259
- //# sourceMappingURL=web-D5etye5Q.js.map
264
+ //# sourceMappingURL=web-CFBLfBdc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-CFBLfBdc.js","names":[],"sources":["../src/commands/web.ts"],"sourcesContent":["import { readFileSync, existsSync } from 'fs';\nimport http from 'http';\nimport { join, extname, resolve, relative, isAbsolute } from 'path';\nimport { fileURLToPath } from 'url';\n\nimport type { AIService } from '../services/ai.js';\nimport type { SickbayReport, MonorepoReport } from 'sickbay-core';\n\n/**\n * This module implements a simple HTTP server to serve a web dashboard for visualizing Sickbay reports.\n * It dynamically serves the report data as JSON and provides endpoints for AI-generated summaries and chat interactions if an AI service is available.\n * The server also serves static files from the built dashboard located in the sickbay-web package. This allows users to interact with their health reports in a user-friendly web interface.\n */\n\nconst MIME_TYPES: Record<string, string> = {\n '.html': 'text/html',\n '.js': 'application/javascript',\n '.css': 'text/css',\n '.json': 'application/json',\n '.svg': 'image/svg+xml',\n '.ico': 'image/x-icon',\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n};\n\n/**\n * Safely resolve a URL pathname to a file path within baseDir, rejecting\n * any attempt to escape the directory via `..`, absolute paths, URL-encoded\n * traversal (`%2e%2e`), or null bytes. Returns null if the path is unsafe.\n *\n * Note that `new URL()` (used by the request handler) already normalizes\n * `..` segments from the pathname before this function sees it, so in\n * practice most traversal attempts are neutralized upstream. This function\n * is defense-in-depth — it guards against null bytes, manually constructed\n * malformed paths, and any future code path that bypasses URL normalization.\n *\n * Even though the dashboard server binds to 127.0.0.1, the dashboard exposes\n * scan results including secret-scan findings — treat it like any public\n * static server.\n *\n * Exported for direct unit testing; not part of the stable public API.\n */\nexport function resolveSafeStaticPath(baseDir: string, pathname: string): string | null {\n let decoded: string;\n try {\n decoded = decodeURIComponent(pathname);\n } catch {\n return null;\n }\n if (decoded.includes('\\0')) return null;\n // Strip leading slashes so resolve() treats the path as relative to baseDir.\n const cleaned = decoded.replace(/^\\/+/, '');\n const resolved = resolve(baseDir, cleaned);\n const rel = relative(baseDir, resolved);\n // relative() returns '' when identical, '..'-prefixed when outside, or absolute on other drives (Windows).\n if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {\n return resolved;\n }\n return null;\n}\n\nfunction findWebDist(): string | null {\n // Resolve relative to this file's location\n const thisFile = fileURLToPath(import.meta.url);\n const candidates = [\n join(thisFile, '..', 'web'), // published: dist/web/ (embedded at build time)\n join(thisFile, '..', '..', '..', 'web', 'dist'), // monorepo dev: apps/web/dist/\n ];\n for (const p of candidates) {\n if (existsSync(join(p, 'index.html'))) return p;\n }\n return null;\n}\n\nasync function getFreePort(preferred: number, attempts = 0): Promise<number> {\n if (attempts >= 10) {\n throw new Error(\n `No free port found after 10 attempts starting from port ${preferred - attempts}`,\n );\n }\n return new Promise((resolve, reject) => {\n const server = http.createServer();\n // Probe must bind to the same host as the real server (127.0.0.1), otherwise\n // Node defaults to `::` and we can miss an IPv4-loopback-only conflict —\n // the probe says \"free\", then the real listen crashes with EADDRINUSE.\n server.listen(preferred, '127.0.0.1', () => {\n const addr = server.address() as { port: number };\n server.close(() => resolve(addr.port));\n });\n server.on('error', (err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE') {\n resolve(getFreePort(preferred + 1, attempts + 1));\n } else {\n reject(err);\n }\n });\n });\n}\n\nfunction packageReportToSickbayReport(\n pkg: import('sickbay-core').PackageReport,\n parent: MonorepoReport,\n): SickbayReport {\n return {\n timestamp: parent.timestamp,\n projectPath: pkg.path,\n projectInfo: {\n name: pkg.name,\n version: 'unknown',\n hasTypeScript: false,\n hasESLint: false,\n hasPrettier: false,\n framework: pkg.framework,\n packageManager: parent.packageManager,\n totalDependencies:\n Object.keys(pkg.dependencies).length + Object.keys(pkg.devDependencies).length,\n dependencies: pkg.dependencies,\n devDependencies: pkg.devDependencies,\n },\n checks: pkg.checks,\n overallScore: pkg.score,\n summary: pkg.summary,\n };\n}\n\nexport async function serveWeb(\n report: SickbayReport | MonorepoReport,\n preferredPort = 3030,\n aiService?: AIService,\n): Promise<string> {\n const distDir = findWebDist();\n if (!distDir) {\n throw new Error('Web dashboard assets not found. Try reinstalling: npx sickbay@latest --web');\n }\n\n const reportJson = JSON.stringify(report);\n const port = await getFreePort(preferredPort);\n\n // Single source of truth for the dashboard URL. The server binds to\n // 127.0.0.1 but this string (used for both the returned URL and the\n // Access-Control-Allow-Origin header) must stay consistent — browser\n // origins are derived from the URL string the tab was opened at, not\n // from the resolved IP, so mixing `localhost` in one place and\n // `127.0.0.1` in another would produce a mismatch that only bites\n // when the user navigates via a different loopback representation.\n const serverUrl = `http://localhost:${port}`;\n\n // Lock API responses to the dashboard's own origin. Without this header,\n // browser default behavior is already to block cross-origin reads of the\n // response body (so an evil.com tab couldn't exfiltrate a report), but\n // declaring it explicitly is defense-in-depth: it makes the intent clear,\n // prevents future browser-policy relaxations from widening the surface,\n // and makes the server's contract auditable from response headers alone.\n const jsonHeaders = {\n 'Content-Type': 'application/json',\n 'Access-Control-Allow-Origin': serverUrl,\n Vary: 'Origin',\n };\n\n // Generate AI summary on startup if service is available (single-project only)\n let aiSummary: string | null = null;\n if (aiService && !('isMonorepo' in report)) {\n try {\n aiSummary = await aiService.generateSummary(report);\n } catch (err) {\n console.warn('AI summary generation failed:', err);\n }\n }\n\n const server = http.createServer(async (req, res) => {\n const url = req.url ?? '/';\n\n // Serve the report JSON directly from memory\n if (url === '/sickbay-report.json') {\n res.writeHead(200, jsonHeaders);\n res.end(reportJson);\n return;\n }\n\n // Serve project-local history\n if (url === '/sickbay-history.json') {\n const basePath = 'isMonorepo' in report ? report.rootPath : report.projectPath;\n const historyPath = join(basePath, '.sickbay', 'history.json');\n if (existsSync(historyPath)) {\n res.writeHead(200, jsonHeaders);\n res.end(readFileSync(historyPath, 'utf-8'));\n } else {\n res.writeHead(404, jsonHeaders);\n res.end('{}');\n }\n return;\n }\n\n // Serve dependency tree\n if (url === '/sickbay-dep-tree.json') {\n const basePath = 'isMonorepo' in report ? report.rootPath : report.projectPath;\n const treePath = join(basePath, '.sickbay', 'dep-tree.json');\n if (existsSync(treePath)) {\n res.writeHead(200, jsonHeaders);\n res.end(readFileSync(treePath, 'utf-8'));\n } else {\n res.writeHead(404, jsonHeaders);\n res.end('{}');\n }\n return;\n }\n\n // Serve project config\n if (url === '/sickbay-config.json') {\n const basePath = 'isMonorepo' in report ? report.rootPath : report.projectPath;\n try {\n const { loadConfig } = await import('sickbay-core');\n const config = await loadConfig(basePath);\n if (config) {\n res.writeHead(200, jsonHeaders);\n res.end(JSON.stringify(config));\n } else {\n res.writeHead(404, jsonHeaders);\n res.end('{}');\n }\n } catch {\n res.writeHead(500, jsonHeaders);\n res.end('{}');\n }\n return;\n }\n\n // AI summary endpoint\n const parsedUrl = new URL(url, 'http://localhost');\n const packageName = parsedUrl.searchParams.get('package');\n\n if (parsedUrl.pathname === '/ai/summary') {\n if (req.method === 'HEAD') {\n // Availability check. Include CORS headers for consistency with the\n // rest of the JSON endpoints even though opaque cross-origin responses\n // already hide the status from attacker scripts.\n res.writeHead(aiService ? 200 : 404, jsonHeaders);\n res.end();\n return;\n }\n if (packageName && aiService && 'isMonorepo' in report) {\n // Per-package summary: generate on demand\n const pkg = report.packages.find((p) => p.name === packageName);\n if (!pkg) {\n res.writeHead(404, jsonHeaders);\n res.end(JSON.stringify({ error: 'Package not found' }));\n return;\n }\n try {\n const pkgReport = packageReportToSickbayReport(pkg, report);\n const summary = await aiService.generateSummary(pkgReport);\n res.writeHead(200, jsonHeaders);\n res.end(JSON.stringify({ summary }));\n } catch (err) {\n // Don't leak internal error details (stack, constructor name, dep internals)\n // over the wire, even on loopback — other processes on the machine can reach it.\n const message = err instanceof Error ? err.message : 'Internal error';\n res.writeHead(500, jsonHeaders);\n res.end(JSON.stringify({ error: message }));\n }\n return;\n }\n if (aiSummary) {\n res.writeHead(200, jsonHeaders);\n res.end(JSON.stringify({ summary: aiSummary }));\n return;\n }\n res.writeHead(404, jsonHeaders);\n res.end('{}');\n return;\n }\n\n // AI chat endpoint\n if (parsedUrl.pathname === '/ai/chat' && req.method === 'POST' && aiService) {\n // Cap request body to prevent unbounded memory growth from a malicious\n // or misbehaving client on the loopback interface.\n const MAX_BODY = 1024 * 1024; // 1 MB\n let body = '';\n let aborted = false;\n req.on('data', (chunk) => {\n if (aborted) return;\n body += chunk;\n if (Buffer.byteLength(body) > MAX_BODY) {\n aborted = true;\n res.writeHead(413, jsonHeaders);\n res.end(JSON.stringify({ error: 'Payload too large' }));\n req.destroy();\n }\n });\n req.on('end', async () => {\n if (aborted) return;\n try {\n const { message, history } = JSON.parse(body);\n let chatReport: SickbayReport;\n if (packageName && 'isMonorepo' in report) {\n const pkg = report.packages.find((p) => p.name === packageName);\n if (!pkg) {\n res.writeHead(404, jsonHeaders);\n res.end(JSON.stringify({ error: 'Package not found' }));\n return;\n }\n chatReport = packageReportToSickbayReport(pkg, report);\n } else if (!('isMonorepo' in report)) {\n chatReport = report;\n } else {\n res.writeHead(400, jsonHeaders);\n res.end(JSON.stringify({ error: 'Package name required for monorepo' }));\n return;\n }\n const response = await aiService.chat(message, chatReport, history ?? []);\n res.writeHead(200, jsonHeaders);\n res.end(JSON.stringify({ response }));\n } catch (err) {\n // See above — no internal error leakage.\n const errMessage = err instanceof Error ? err.message : 'Internal error';\n res.writeHead(500, jsonHeaders);\n res.end(JSON.stringify({ error: errMessage }));\n }\n });\n return;\n }\n\n // Serve static files from dist. Use the parsed pathname (not the raw url)\n // so query strings are stripped, and route through resolveSafeStaticPath\n // to prevent path traversal (e.g. `/../../etc/passwd`, `%2e%2e/`).\n const requestedPath = parsedUrl.pathname === '/' ? 'index.html' : parsedUrl.pathname;\n const filePath = resolveSafeStaticPath(distDir, requestedPath);\n if (filePath && existsSync(filePath)) {\n const ext = extname(filePath);\n const mime = MIME_TYPES[ext] ?? 'application/octet-stream';\n res.writeHead(200, { 'Content-Type': mime });\n res.end(readFileSync(filePath));\n } else {\n // SPA fallback — serve index.html for all unknown routes\n res.writeHead(200, { 'Content-Type': 'text/html' });\n res.end(readFileSync(join(distDir, 'index.html')));\n }\n });\n\n return new Promise((resolve, reject) => {\n // Close server on SIGINT/SIGTERM so the process can exit cleanly when\n // the user presses Ctrl+C. Without this the HTTP handle keeps the event\n // loop alive after Ink's render loop exits.\n const shutdown = () => server.close();\n process.once('SIGINT', shutdown);\n process.once('SIGTERM', shutdown);\n\n // Reject the promise on any listen failure (EADDRINUSE, EACCES, etc.)\n // instead of letting an unhandled 'error' event crash the process with a\n // raw Node stack trace. The CLI renders rejection messages nicely.\n server.once('error', (err: NodeJS.ErrnoException) => {\n process.off('SIGINT', shutdown);\n process.off('SIGTERM', shutdown);\n if (err.code === 'EADDRINUSE') {\n reject(new Error(`Port ${port} is already in use. Stop the other process or try again.`));\n } else {\n reject(err);\n }\n });\n\n // SECURITY: bind explicitly to loopback so the dashboard (which contains\n // file paths, dep lists, and secret-scan results) is not exposed to other\n // hosts on the LAN, container networks, or VPN peers.\n server.listen(port, '127.0.0.1', () => {\n resolve(serverUrl);\n });\n });\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sickbay",
3
- "version": "1.16.2",
3
+ "version": "1.16.4",
4
4
  "description": "Zero-config health check CLI for JavaScript and TypeScript projects",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1 +0,0 @@
1
- {"version":3,"file":"FixApp-MWXohP60.js","names":[],"sources":["../src/commands/fix.ts","../src/components/FixApp.tsx"],"sourcesContent":["import { execFile } from 'child_process';\nimport { promisify } from 'util';\n\nimport type { Issue, SickbayReport } from 'sickbay-core';\n\n/**\n * This module provides functions to collect fixable issues from a Sickbay report and execute the associated fix commands.\n * The collectFixableIssues function scans through the checks and their issues to find those that have a fix command,\n * while ensuring that duplicate commands are not included. The executeFix function runs the specified command in the context of the project directory,\n * capturing the output and any errors that may occur during execution. This allows for an automated way to address issues found in the health checks.\n */\n\nconst execFileAsync = promisify(execFile);\n\nexport interface FixableIssue {\n issue: Issue;\n checkId: string;\n checkName: string;\n command?: string;\n}\n\nexport interface FixResult {\n fixable: FixableIssue;\n success: boolean;\n stdout: string;\n stderr: string;\n duration: number;\n}\n\nexport function collectFixableIssues(report: SickbayReport): FixableIssue[] {\n const seen = new Set<string>();\n const fixable: FixableIssue[] = [];\n\n for (const check of report.checks) {\n for (const issue of check.issues) {\n if (issue.fix) {\n const dedupeKey = issue.fix.command ?? issue.fix.description;\n if (!seen.has(dedupeKey)) {\n seen.add(dedupeKey);\n fixable.push({\n issue,\n checkId: check.id,\n checkName: check.name,\n command: issue.fix.command,\n });\n }\n }\n }\n }\n\n const order = { critical: 0, warning: 1, info: 2 };\n fixable.sort((a, b) => order[a.issue.severity] - order[b.issue.severity]);\n\n return fixable;\n}\n\nexport async function executeFix(fix: FixableIssue, projectPath: string): Promise<FixResult> {\n const start = Date.now();\n if (!fix.command) {\n return {\n fixable: fix,\n success: false,\n stdout: '',\n stderr: 'No command to execute (guidance-only fix)',\n duration: 0,\n };\n }\n try {\n const parts = fix.command.split(/\\s+/);\n const cmd = parts[0];\n const args = parts.slice(1);\n // SECURITY: shell is intentionally NOT enabled. Running with `shell: true`\n // would pass cmd+args through `/bin/sh -c`, making any shell metacharacter\n // in `fix.command` (e.g. `;`, `|`, `$()`, backticks) injectable. Today these\n // commands come from internal runner constants, but this is the trust\n // boundary that protects future runners from accidental command injection.\n const { stdout, stderr } = await execFileAsync(cmd, args, {\n cwd: projectPath,\n timeout: 60_000,\n });\n return {\n fixable: fix,\n success: true,\n stdout: stdout ?? '',\n stderr: stderr ?? '',\n duration: Date.now() - start,\n };\n } catch (err) {\n return {\n fixable: fix,\n success: false,\n stdout: '',\n stderr: err instanceof Error ? err.message : String(err),\n duration: Date.now() - start,\n };\n }\n}\n","import React, { useState, useEffect, useCallback } from 'react';\n\nimport { Box, Text, useApp, useInput } from 'ink';\nimport Spinner from 'ink-spinner';\nimport { runSickbay } from 'sickbay-core';\n\nimport {\n collectFixableIssues,\n executeFix,\n type FixableIssue,\n type FixResult,\n} from '../commands/fix.js';\nimport { shortName } from '../lib/resolve-package.js';\nimport { Header } from './Header.js';\nimport { ProgressList } from './ProgressList.js';\n\ninterface FixAppProps {\n projectPath: string;\n checks?: string[];\n applyAll: boolean;\n dryRun: boolean;\n verbose: boolean;\n isMonorepo?: boolean;\n packagePaths?: string[];\n packageNames?: Map<string, string>;\n}\n\ntype Phase = 'scanning' | 'selecting' | 'confirming' | 'fixing' | 'done' | 'error';\n\ninterface ProgressItem {\n name: string;\n status: 'pending' | 'running' | 'done';\n}\n\ninterface MonorepoFixableIssue extends FixableIssue {\n packageName: string;\n packagePath: string;\n}\n\nconst SEVERITY_COLOR = {\n critical: 'red' as const,\n warning: 'yellow' as const,\n info: 'gray' as const,\n};\n\nexport function FixApp({\n projectPath,\n checks,\n applyAll,\n dryRun,\n verbose,\n isMonorepo,\n packagePaths,\n packageNames,\n}: FixAppProps) {\n const { exit } = useApp();\n const [phase, setPhase] = useState<Phase>('scanning');\n const [fixableIssues, setFixableIssues] = useState<MonorepoFixableIssue[]>([]);\n const [selected, setSelected] = useState<Set<number>>(new Set());\n const [cursor, setCursor] = useState(0);\n const [results, setResults] = useState<FixResult[]>([]);\n const [currentFixIndex, setCurrentFixIndex] = useState(0);\n const [error, setError] = useState<string | null>(null);\n const [progress, setProgress] = useState<ProgressItem[]>([]);\n const [projectName, setProjectName] = useState<string | undefined>();\n\n // Confirmation state\n const [confirmQueue, setConfirmQueue] = useState<MonorepoFixableIssue[]>([]);\n const [confirmIndex, setConfirmIndex] = useState(0);\n const [confirmedFixes, setConfirmedFixes] = useState<MonorepoFixableIssue[]>([]);\n\n // Phase 1: Run scan\n useEffect(() => {\n if (isMonorepo && packagePaths && packageNames) {\n // Monorepo: scan each package\n const pkgItems = packagePaths.map((p) => ({\n name: shortName(packageNames.get(p) ?? p),\n status: 'pending' as const,\n }));\n setProgress(pkgItems);\n\n (async () => {\n try {\n const allFixable: MonorepoFixableIssue[] = [];\n\n for (const pkgPath of packagePaths) {\n const pkgName = packageNames.get(pkgPath) ?? pkgPath;\n const display = shortName(pkgName);\n\n setProgress((prev) =>\n prev.map((p) => (p.name === display ? { ...p, status: 'running' } : p)),\n );\n\n const report = await runSickbay({\n projectPath: pkgPath,\n checks,\n verbose,\n });\n\n const fixable = collectFixableIssues(report);\n for (const fix of fixable) {\n allFixable.push({\n ...fix,\n packageName: pkgName,\n packagePath: pkgPath,\n });\n }\n\n setProgress((prev) =>\n prev.map((p) => (p.name === display ? { ...p, status: 'done' } : p)),\n );\n }\n\n setFixableIssues(allFixable);\n\n const hasActionable = allFixable.some((f) => f.command);\n if (!hasActionable) {\n setPhase('done');\n } else if (applyAll) {\n setSelected(new Set(allFixable.map((_, i) => i)));\n startConfirmation(allFixable, new Set(allFixable.map((_, i) => i)), true);\n } else {\n setPhase('selecting');\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : String(err));\n setPhase('error');\n setTimeout(() => exit(), 100);\n }\n })();\n } else {\n // Single project scan\n const initial: ProgressItem[] = (\n checks ?? [\n 'knip',\n 'depcheck',\n 'npm-check-updates',\n 'npm-audit',\n 'madge',\n 'source-map-explorer',\n 'coverage',\n 'license-checker',\n 'jscpd',\n 'git',\n 'eslint',\n 'typescript',\n 'todo-scanner',\n 'complexity',\n 'secrets',\n 'heavy-deps',\n 'react-perf',\n 'asset-size',\n ]\n ).map((name) => ({ name, status: 'pending' as const }));\n setProgress(initial);\n\n runSickbay({\n projectPath,\n checks,\n verbose,\n onCheckStart: (name) => {\n setProgress((prev) =>\n prev.map((p) => (p.name === name ? { ...p, status: 'running' } : p)),\n );\n },\n onCheckComplete: (result) => {\n setProgress((prev) =>\n prev.map((p) => (p.name === result.id ? { ...p, status: 'done' } : p)),\n );\n },\n })\n .then((r) => {\n setProjectName(r.projectInfo.name);\n const fixable = collectFixableIssues(r);\n const tagged = fixable.map((f) => ({\n ...f,\n packageName: r.projectInfo.name,\n packagePath: projectPath,\n }));\n setFixableIssues(tagged);\n\n const hasActionable = tagged.some((f) => f.command);\n if (!hasActionable) {\n setPhase('done');\n } else if (applyAll) {\n setSelected(new Set(fixable.map((_, i) => i)));\n startConfirmation(tagged, new Set(fixable.map((_, i) => i)), true);\n } else {\n setPhase('selecting');\n }\n })\n .catch((err) => {\n setError(err instanceof Error ? err.message : String(err));\n setPhase('error');\n setTimeout(() => exit(), 100);\n });\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Start confirmation flow for selected actionable fixes\n function startConfirmation(\n issues: MonorepoFixableIssue[],\n selectedSet: Set<number>,\n isApplyAll: boolean,\n ) {\n // Only actionable fixes (with command) need confirmation\n const actionable = issues.filter((f, i) => selectedSet.has(i) && f.command);\n\n if (isApplyAll) {\n // --apply-all: skip tier-1, but still confirm tier-2 (modifiesSource)\n const tier2 = actionable.filter((f) => f.issue.fix?.modifiesSource);\n const autoApproved = actionable.filter((f) => !f.issue.fix?.modifiesSource);\n if (tier2.length === 0) {\n // No tier-2 confirms needed, go straight to fixing\n setConfirmedFixes(autoApproved);\n setPhase('fixing');\n return;\n }\n setConfirmedFixes(autoApproved);\n setConfirmQueue(tier2);\n } else {\n setConfirmedFixes([]);\n setConfirmQueue(actionable);\n }\n setConfirmIndex(0);\n\n if (actionable.length === 0) {\n // All selected are guidance-only\n setPhase('done');\n setTimeout(() => exit(), 100);\n return;\n }\n\n setPhase('confirming');\n }\n\n // Phase 3: Execute fixes\n useEffect(() => {\n if (phase !== 'fixing') return;\n\n const toFix = confirmedFixes;\n if (toFix.length === 0) {\n setPhase('done');\n setTimeout(() => exit(), 100);\n return;\n }\n\n (async () => {\n const fixResults: FixResult[] = [];\n for (let i = 0; i < toFix.length; i++) {\n setCurrentFixIndex(i);\n if (dryRun) {\n fixResults.push({\n fixable: toFix[i],\n success: true,\n stdout: '(dry run)',\n stderr: '',\n duration: 0,\n });\n } else {\n const result = await executeFix(toFix[i], toFix[i].packagePath);\n fixResults.push(result);\n }\n setResults([...fixResults]);\n }\n setPhase('done');\n setTimeout(() => exit(), 100);\n })();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [phase]);\n\n // Handle keyboard input during selection and confirmation phases\n const handleInput = useCallback(\n (input: string, key: { upArrow: boolean; downArrow: boolean; return: boolean }) => {\n if (phase === 'selecting') {\n const actionable = fixableIssues.filter((f) => f.command);\n\n if (key.upArrow) {\n setCursor((c) => Math.max(0, c - 1));\n } else if (key.downArrow) {\n setCursor((c) => Math.min(actionable.length - 1, c + 1));\n } else if (input === ' ') {\n setSelected((prev) => {\n const next = new Set(prev);\n if (next.has(cursor)) {\n next.delete(cursor);\n } else {\n next.add(cursor);\n }\n return next;\n });\n } else if (input === 'a') {\n setSelected(new Set(actionable.map((_, i) => i)));\n } else if (input === 'n') {\n setSelected(new Set());\n } else if (key.return) {\n if (selected.size > 0) {\n // Map selected indices back to the actionable items\n const selectedIssues = actionable.filter((_, i) => selected.has(i));\n startConfirmation(selectedIssues, new Set(selectedIssues.map((_, i) => i)), false);\n }\n }\n } else if (phase === 'confirming') {\n const lower = input.toLowerCase();\n if (lower === 'y' || key.return) {\n // Confirm current fix\n setConfirmedFixes((prev) => [...prev, confirmQueue[confirmIndex]]);\n if (confirmIndex + 1 < confirmQueue.length) {\n setConfirmIndex((i) => i + 1);\n } else {\n setPhase('fixing');\n }\n } else if (lower === 'n') {\n // Skip current fix\n if (confirmIndex + 1 < confirmQueue.length) {\n setConfirmIndex((i) => i + 1);\n } else {\n setPhase('fixing');\n }\n }\n }\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [phase, cursor, selected, confirmIndex, confirmQueue, fixableIssues],\n );\n\n useInput(handleInput);\n\n const currentConfirmFix = confirmQueue[confirmIndex];\n const isTier2 = currentConfirmFix?.issue.fix?.modifiesSource;\n\n return (\n <Box flexDirection=\"column\" padding={1}>\n <Header projectName={projectName} />\n\n {/* Scanning phase */}\n {phase === 'scanning' && (\n <Box flexDirection=\"column\">\n <Text dimColor>\n Scanning for fixable issues\n {isMonorepo ? ` across ${packagePaths?.length} packages` : ''}...\n </Text>\n <Box marginTop={1} marginLeft={2}>\n <ProgressList items={progress} />\n </Box>\n </Box>\n )}\n\n {/* Error phase */}\n {phase === 'error' && (\n <Box>\n <Text color=\"red\">✗ Error: {error}</Text>\n </Box>\n )}\n\n {/* Selection phase */}\n {phase === 'selecting' &&\n (() => {\n const actionable = fixableIssues.filter((f) => f.command);\n return (\n <Box flexDirection=\"column\">\n <Box marginBottom={1}>\n <Text color=\"yellow\">⚠ </Text>\n <Text dimColor>\n Sickbay can make mistakes. Review each fix before applying — false positives exist\n and some commands modify your project.\n </Text>\n </Box>\n <Text bold>Select fixes to apply ({actionable.length} available)</Text>\n <Box flexDirection=\"column\" marginTop={1} marginLeft={2}>\n {actionable.map((fix, i) => (\n <Box key={`${fix.packageName}-${fix.checkId}-${fix.command}`}>\n <Text color={i === cursor ? 'cyan' : undefined}>\n {i === cursor ? '❯ ' : ' '}\n </Text>\n <Text color={i === cursor ? 'cyan' : undefined}>\n {selected.has(i) ? '[✓] ' : '[ ] '}\n </Text>\n {isMonorepo && (\n <Text color=\"magenta\" dimColor>\n [{shortName(fix.packageName)}]{' '}\n </Text>\n )}\n <Text color={SEVERITY_COLOR[fix.issue.severity]}>\n {fix.issue.fix!.description}\n </Text>\n <Text dimColor> ({fix.command})</Text>\n </Box>\n ))}\n </Box>\n <Box marginTop={1} marginLeft={2}>\n <Text dimColor>\n ↑↓ navigate · space toggle · a all · n none · enter confirm\n {selected.size > 0 && ` (${selected.size} selected)`}\n </Text>\n </Box>\n {dryRun && (\n <Box marginLeft={2}>\n <Text color=\"yellow\">⚠ Dry run mode — no changes will be made</Text>\n </Box>\n )}\n </Box>\n );\n })()}\n\n {/* Confirmation phase */}\n {phase === 'confirming' && currentConfirmFix && (\n <Box flexDirection=\"column\">\n <Text bold>\n Confirming fixes ({confirmIndex + 1}/{confirmQueue.length})\n </Text>\n <Box marginTop={1} marginLeft={2} flexDirection=\"column\">\n <Text>\n {currentConfirmFix.issue.fix!.description} ({currentConfirmFix.command})\n </Text>\n {isTier2 ? (\n <Text color=\"yellow\">⚠ This will modify source files. Proceed? (Y/n)</Text>\n ) : (\n <Text dimColor>→ Proceed? (Y/n)</Text>\n )}\n </Box>\n </Box>\n )}\n\n {/* Fixing phase */}\n {phase === 'fixing' && !dryRun && (\n <Box marginBottom={1}>\n <Text color=\"yellow\">⚠ </Text>\n <Text dimColor>\n Sickbay can make mistakes — verify results afterwards and revert anything that looks\n wrong.\n </Text>\n </Box>\n )}\n {phase === 'fixing' && (\n <Box flexDirection=\"column\">\n <Text bold>\n {dryRun ? 'Dry run' : 'Applying fixes'}... ({results.length}/{confirmedFixes.length})\n </Text>\n <Box flexDirection=\"column\" marginTop={1} marginLeft={2}>\n {results.map((r) => (\n <Box\n key={`${r.fixable.checkId}-${r.fixable.command ?? r.fixable.issue.fix?.description}`}\n flexDirection=\"column\"\n >\n <Box>\n <Text color={r.success ? 'green' : 'red'}>{r.success ? '✓' : '✗'} </Text>\n {isMonorepo && (\n <Text color=\"magenta\" dimColor>\n [{shortName((r.fixable as MonorepoFixableIssue).packageName)}]{' '}\n </Text>\n )}\n <Text>{r.fixable.issue.fix!.description}</Text>\n </Box>\n {r.success && r.fixable.issue.fix?.nextSteps && (\n <Box marginLeft={2}>\n <Text dimColor> → Next: {r.fixable.issue.fix.nextSteps}</Text>\n </Box>\n )}\n </Box>\n ))}\n {results.length < confirmedFixes.length && (\n <Box>\n <Text color=\"green\">\n <Spinner type=\"dots\" />\n </Text>\n <Text> {confirmedFixes[currentFixIndex]?.issue.fix!.description}</Text>\n </Box>\n )}\n </Box>\n </Box>\n )}\n\n {/* Done phase */}\n {phase === 'done' && (\n <Box flexDirection=\"column\">\n {results.length === 0 ? (\n <Box flexDirection=\"column\">\n <Box>\n <Text color=\"green\">✓ </Text>\n <Text>\n No auto-fixable issues found\n {isMonorepo ? ' across any package' : ''}\n </Text>\n </Box>\n <Box marginTop={1}>\n <Text dimColor>\n Run <Text color=\"cyan\">sickbay</Text> to see the full report — there may be issues\n that require manual attention.\n </Text>\n </Box>\n </Box>\n ) : (\n <>\n <Text bold>{dryRun ? 'Dry Run Results' : 'Fix Results'}</Text>\n <Box flexDirection=\"column\" marginTop={1} marginLeft={2}>\n {results.map((r) => (\n <Box\n key={`${r.fixable.checkId}-${r.fixable.command ?? r.fixable.issue.fix?.description}`}\n flexDirection=\"column\"\n >\n <Box>\n <Text color={r.success ? 'green' : 'red'}>{r.success ? '✓' : '✗'} </Text>\n {isMonorepo && (\n <Text color=\"magenta\" dimColor>\n [{shortName((r.fixable as MonorepoFixableIssue).packageName)}]{' '}\n </Text>\n )}\n <Text>{r.fixable.issue.fix!.description}</Text>\n {r.duration > 0 && <Text dimColor> ({r.duration}ms)</Text>}\n </Box>\n {r.success && r.fixable.issue.fix?.nextSteps && (\n <Box marginLeft={2}>\n <Text dimColor> → Next: {r.fixable.issue.fix.nextSteps}</Text>\n </Box>\n )}\n </Box>\n ))}\n </Box>\n <Box marginTop={1}>\n <Text dimColor>{'━'.repeat(52)}</Text>\n </Box>\n <Box marginTop={1}>\n <Text bold>\n {results.filter((r) => r.success).length}/{results.length} fixes{' '}\n {dryRun ? 'would be applied' : 'applied successfully'}\n </Text>\n </Box>\n {!dryRun && results.some((r) => r.success) && (\n <Box marginTop={1}>\n <Text dimColor>Run </Text>\n <Text color=\"cyan\">sickbay</Text>\n <Text dimColor> to see your updated score</Text>\n </Box>\n )}\n </>\n )}\n </Box>\n )}\n </Box>\n );\n}\n"],"mappings":"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"}
@@ -1,37 +0,0 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { join } from "path";
3
- //#region src/commands/badge.ts
4
- function getScoreColor(score) {
5
- if (score >= 90) return "brightgreen";
6
- if (score >= 80) return "green";
7
- if (score >= 60) return "yellow";
8
- return "red";
9
- }
10
- function encodeLabel(label) {
11
- return label.replace(/-/g, "--").replace(/_/g, "__").replace(/ /g, "%20");
12
- }
13
- function badgeUrl(score, label = "sickbay") {
14
- const color = getScoreColor(score);
15
- return `https://img.shields.io/badge/${encodeLabel(label)}-${score}%2F100-${color}`;
16
- }
17
- function badgeMarkdown(score, label = "sickbay") {
18
- return `![${label}](${badgeUrl(score, label)})`;
19
- }
20
- function badgeHtml(score, label = "sickbay") {
21
- return `<img src="${badgeUrl(score, label)}" alt="${label}" />`;
22
- }
23
- function loadScoreFromLastReport(projectPath) {
24
- const filePath = join(projectPath, ".sickbay", "last-report.json");
25
- if (!existsSync(filePath)) return null;
26
- try {
27
- const data = JSON.parse(readFileSync(filePath, "utf-8"));
28
- if (typeof data.overallScore !== "number") return null;
29
- return data.overallScore;
30
- } catch {
31
- return null;
32
- }
33
- }
34
- //#endregion
35
- export { badgeHtml, badgeMarkdown, badgeUrl, loadScoreFromLastReport };
36
-
37
- //# sourceMappingURL=badge-DB3y0fWt.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"badge-DB3y0fWt.js","names":[],"sources":["../src/commands/badge.ts"],"sourcesContent":["import { existsSync, readFileSync } from 'fs';\nimport { join } from 'path';\n\nexport function getScoreColor(score: number): string {\n if (score >= 90) return 'brightgreen';\n if (score >= 80) return 'green';\n if (score >= 60) return 'yellow';\n return 'red';\n}\n\nfunction encodeLabel(label: string): string {\n return label.replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '%20');\n}\n\nexport function badgeUrl(score: number, label = 'sickbay'): string {\n const color = getScoreColor(score);\n const encoded = encodeLabel(label);\n return `https://img.shields.io/badge/${encoded}-${score}%2F100-${color}`;\n}\n\nexport function badgeMarkdown(score: number, label = 'sickbay'): string {\n return `![${label}](${badgeUrl(score, label)})`;\n}\n\nexport function badgeHtml(score: number, label = 'sickbay'): string {\n return `<img src=\"${badgeUrl(score, label)}\" alt=\"${label}\" />`;\n}\n\nexport function loadScoreFromLastReport(projectPath: string): number | null {\n const filePath = join(projectPath, '.sickbay', 'last-report.json');\n if (!existsSync(filePath)) return null;\n try {\n const data = JSON.parse(readFileSync(filePath, 'utf-8'));\n if (typeof data.overallScore !== 'number') return null;\n return data.overallScore;\n } catch {\n return null;\n }\n}\n"],"mappings":";;;AAGA,SAAgB,cAAc,OAAuB;AACnD,KAAI,SAAS,GAAI,QAAO;AACxB,KAAI,SAAS,GAAI,QAAO;AACxB,KAAI,SAAS,GAAI,QAAO;AACxB,QAAO;;AAGT,SAAS,YAAY,OAAuB;AAC1C,QAAO,MAAM,QAAQ,MAAM,KAAK,CAAC,QAAQ,MAAM,KAAK,CAAC,QAAQ,MAAM,MAAM;;AAG3E,SAAgB,SAAS,OAAe,QAAQ,WAAmB;CACjE,MAAM,QAAQ,cAAc,MAAM;AAElC,QAAO,gCADS,YAAY,MAAM,CACa,GAAG,MAAM,SAAS;;AAGnE,SAAgB,cAAc,OAAe,QAAQ,WAAmB;AACtE,QAAO,KAAK,MAAM,IAAI,SAAS,OAAO,MAAM,CAAC;;AAG/C,SAAgB,UAAU,OAAe,QAAQ,WAAmB;AAClE,QAAO,aAAa,SAAS,OAAO,MAAM,CAAC,SAAS,MAAM;;AAG5D,SAAgB,wBAAwB,aAAoC;CAC1E,MAAM,WAAW,KAAK,aAAa,YAAY,mBAAmB;AAClE,KAAI,CAAC,WAAW,SAAS,CAAE,QAAO;AAClC,KAAI;EACF,MAAM,OAAO,KAAK,MAAM,aAAa,UAAU,QAAQ,CAAC;AACxD,MAAI,OAAO,KAAK,iBAAiB,SAAU,QAAO;AAClD,SAAO,KAAK;SACN;AACN,SAAO"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"history-DVegAwFp.js","names":[],"sources":["../src/lib/history.ts"],"sourcesContent":["import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';\nimport { join } from 'path';\n\nimport type { SickbayReport } from 'sickbay-core';\n\nexport interface TrendEntry {\n timestamp: string;\n overallScore: number;\n categoryScores: Record<string, number>;\n summary: { critical: number; warnings: number; info: number };\n checksRun: number;\n}\n\nexport interface TrendHistory {\n projectPath: string;\n projectName: string;\n entries: TrendEntry[];\n}\n\nfunction historyFilePath(projectPath: string): string {\n return join(projectPath, '.sickbay', 'history.json');\n}\n\nexport function loadHistory(projectPath: string): TrendHistory | null {\n const filePath = historyFilePath(projectPath);\n if (!existsSync(filePath)) return null;\n try {\n return JSON.parse(readFileSync(filePath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\nexport function saveEntry(report: SickbayReport): void {\n mkdirSync(join(report.projectPath, '.sickbay'), { recursive: true });\n\n const filePath = historyFilePath(report.projectPath);\n const existing = loadHistory(report.projectPath) ?? {\n projectPath: report.projectPath,\n projectName: report.projectInfo.name,\n entries: [],\n };\n\n const categoryScores: Record<string, number> = {};\n const categoryChecks: Record<string, number[]> = {};\n for (const check of report.checks) {\n if (check.status === 'skipped') continue;\n if (!categoryChecks[check.category]) categoryChecks[check.category] = [];\n categoryChecks[check.category].push(check.score);\n }\n for (const [cat, scores] of Object.entries(categoryChecks)) {\n categoryScores[cat] = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);\n }\n\n existing.entries.push({\n timestamp: report.timestamp,\n overallScore: report.overallScore,\n categoryScores,\n summary: { ...report.summary },\n checksRun: report.checks.filter((c) => c.status !== 'skipped').length,\n });\n\n if (existing.entries.length > 100) {\n existing.entries = existing.entries.slice(-100);\n }\n\n writeFileSync(filePath, JSON.stringify(existing, null, 2));\n}\n\nexport function saveLastReport(report: SickbayReport): void {\n mkdirSync(join(report.projectPath, '.sickbay'), { recursive: true });\n writeFileSync(\n join(report.projectPath, '.sickbay', 'last-report.json'),\n JSON.stringify(report, null, 2),\n );\n}\n\nexport function saveDepTree(projectPath: string, tree: unknown): void {\n mkdirSync(join(projectPath, '.sickbay'), { recursive: true });\n writeFileSync(join(projectPath, '.sickbay', 'dep-tree.json'), JSON.stringify(tree, null, 2));\n}\n\nexport function detectRegressions(\n entries: TrendEntry[],\n): Array<{ category: string; drop: number; from: number; to: number }> {\n if (entries.length < 2) return [];\n const latest = entries[entries.length - 1];\n const previous = entries[entries.length - 2];\n\n const regressions: Array<{\n category: string;\n drop: number;\n from: number;\n to: number;\n }> = [];\n\n if (latest.overallScore < previous.overallScore - 5) {\n regressions.push({\n category: 'overall',\n drop: previous.overallScore - latest.overallScore,\n from: previous.overallScore,\n to: latest.overallScore,\n });\n }\n\n for (const [cat, score] of Object.entries(latest.categoryScores)) {\n const prevScore = previous.categoryScores[cat];\n if (prevScore !== undefined && score < prevScore - 5) {\n regressions.push({\n category: cat,\n drop: prevScore - score,\n from: prevScore,\n to: score,\n });\n }\n }\n\n return regressions;\n}\n"],"mappings":";;;;;;;;;;;AAmBA,SAAS,gBAAgB,aAA6B;AACpD,QAAO,KAAK,aAAa,YAAY,eAAe;;AAGtD,SAAgB,YAAY,aAA0C;CACpE,MAAM,WAAW,gBAAgB,YAAY;AAC7C,KAAI,CAAC,WAAW,SAAS,CAAE,QAAO;AAClC,KAAI;AACF,SAAO,KAAK,MAAM,aAAa,UAAU,QAAQ,CAAC;SAC5C;AACN,SAAO;;;AAIX,SAAgB,UAAU,QAA6B;AACrD,WAAU,KAAK,OAAO,aAAa,WAAW,EAAE,EAAE,WAAW,MAAM,CAAC;CAEpE,MAAM,WAAW,gBAAgB,OAAO,YAAY;CACpD,MAAM,WAAW,YAAY,OAAO,YAAY,IAAI;EAClD,aAAa,OAAO;EACpB,aAAa,OAAO,YAAY;EAChC,SAAS,EAAE;EACZ;CAED,MAAM,iBAAyC,EAAE;CACjD,MAAM,iBAA2C,EAAE;AACnD,MAAK,MAAM,SAAS,OAAO,QAAQ;AACjC,MAAI,MAAM,WAAW,UAAW;AAChC,MAAI,CAAC,eAAe,MAAM,UAAW,gBAAe,MAAM,YAAY,EAAE;AACxE,iBAAe,MAAM,UAAU,KAAK,MAAM,MAAM;;AAElD,MAAK,MAAM,CAAC,KAAK,WAAW,OAAO,QAAQ,eAAe,CACxD,gBAAe,OAAO,KAAK,MAAM,OAAO,QAAQ,GAAG,MAAM,IAAI,GAAG,EAAE,GAAG,OAAO,OAAO;AAGrF,UAAS,QAAQ,KAAK;EACpB,WAAW,OAAO;EAClB,cAAc,OAAO;EACrB;EACA,SAAS,EAAE,GAAG,OAAO,SAAS;EAC9B,WAAW,OAAO,OAAO,QAAQ,MAAM,EAAE,WAAW,UAAU,CAAC;EAChE,CAAC;AAEF,KAAI,SAAS,QAAQ,SAAS,IAC5B,UAAS,UAAU,SAAS,QAAQ,MAAM,KAAK;AAGjD,eAAc,UAAU,KAAK,UAAU,UAAU,MAAM,EAAE,CAAC;;AAG5D,SAAgB,eAAe,QAA6B;AAC1D,WAAU,KAAK,OAAO,aAAa,WAAW,EAAE,EAAE,WAAW,MAAM,CAAC;AACpE,eACE,KAAK,OAAO,aAAa,YAAY,mBAAmB,EACxD,KAAK,UAAU,QAAQ,MAAM,EAAE,CAChC;;AAGH,SAAgB,YAAY,aAAqB,MAAqB;AACpE,WAAU,KAAK,aAAa,WAAW,EAAE,EAAE,WAAW,MAAM,CAAC;AAC7D,eAAc,KAAK,aAAa,YAAY,gBAAgB,EAAE,KAAK,UAAU,MAAM,MAAM,EAAE,CAAC;;AAG9F,SAAgB,kBACd,SACqE;AACrE,KAAI,QAAQ,SAAS,EAAG,QAAO,EAAE;CACjC,MAAM,SAAS,QAAQ,QAAQ,SAAS;CACxC,MAAM,WAAW,QAAQ,QAAQ,SAAS;CAE1C,MAAM,cAKD,EAAE;AAEP,KAAI,OAAO,eAAe,SAAS,eAAe,EAChD,aAAY,KAAK;EACf,UAAU;EACV,MAAM,SAAS,eAAe,OAAO;EACrC,MAAM,SAAS;EACf,IAAI,OAAO;EACZ,CAAC;AAGJ,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,eAAe,EAAE;EAChE,MAAM,YAAY,SAAS,eAAe;AAC1C,MAAI,cAAc,KAAA,KAAa,QAAQ,YAAY,EACjD,aAAY,KAAK;GACf,UAAU;GACV,MAAM,YAAY;GAClB,MAAM;GACN,IAAI;GACL,CAAC;;AAIN,QAAO"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"web-D5etye5Q.js","names":[],"sources":["../src/commands/web.ts"],"sourcesContent":["import { readFileSync, existsSync } from 'fs';\nimport http from 'http';\nimport { join, extname, resolve, relative, isAbsolute } from 'path';\nimport { fileURLToPath } from 'url';\n\nimport type { AIService } from '../services/ai.js';\nimport type { SickbayReport, MonorepoReport } from 'sickbay-core';\n\n/**\n * This module implements a simple HTTP server to serve a web dashboard for visualizing Sickbay reports.\n * It dynamically serves the report data as JSON and provides endpoints for AI-generated summaries and chat interactions if an AI service is available.\n * The server also serves static files from the built dashboard located in the sickbay-web package. This allows users to interact with their health reports in a user-friendly web interface.\n */\n\nconst MIME_TYPES: Record<string, string> = {\n '.html': 'text/html',\n '.js': 'application/javascript',\n '.css': 'text/css',\n '.json': 'application/json',\n '.svg': 'image/svg+xml',\n '.ico': 'image/x-icon',\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n};\n\n/**\n * Safely resolve a URL pathname to a file path within baseDir, rejecting\n * any attempt to escape the directory via `..`, absolute paths, URL-encoded\n * traversal (`%2e%2e`), or null bytes. Returns null if the path is unsafe.\n *\n * Note that `new URL()` (used by the request handler) already normalizes\n * `..` segments from the pathname before this function sees it, so in\n * practice most traversal attempts are neutralized upstream. This function\n * is defense-in-depth — it guards against null bytes, manually constructed\n * malformed paths, and any future code path that bypasses URL normalization.\n *\n * Even though the dashboard server binds to 127.0.0.1, the dashboard exposes\n * scan results including secret-scan findings — treat it like any public\n * static server.\n *\n * Exported for direct unit testing; not part of the stable public API.\n */\nexport function resolveSafeStaticPath(baseDir: string, pathname: string): string | null {\n let decoded: string;\n try {\n decoded = decodeURIComponent(pathname);\n } catch {\n return null;\n }\n if (decoded.includes('\\0')) return null;\n // Strip leading slashes so resolve() treats the path as relative to baseDir.\n const cleaned = decoded.replace(/^\\/+/, '');\n const resolved = resolve(baseDir, cleaned);\n const rel = relative(baseDir, resolved);\n // relative() returns '' when identical, '..'-prefixed when outside, or absolute on other drives (Windows).\n if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {\n return resolved;\n }\n return null;\n}\n\nfunction findWebDist(): string | null {\n // Resolve relative to this file's location\n const thisFile = fileURLToPath(import.meta.url);\n const candidates = [\n join(thisFile, '..', 'web'), // published: dist/web/ (embedded at build time)\n join(thisFile, '..', '..', '..', 'web', 'dist'), // monorepo dev: apps/web/dist/\n ];\n for (const p of candidates) {\n if (existsSync(join(p, 'index.html'))) return p;\n }\n return null;\n}\n\nasync function getFreePort(preferred: number, attempts = 0): Promise<number> {\n if (attempts >= 10) {\n throw new Error(\n `No free port found after 10 attempts starting from port ${preferred - attempts}`,\n );\n }\n return new Promise((resolve, reject) => {\n const server = http.createServer();\n // Probe must bind to the same host as the real server (127.0.0.1), otherwise\n // Node defaults to `::` and we can miss an IPv4-loopback-only conflict —\n // the probe says \"free\", then the real listen crashes with EADDRINUSE.\n server.listen(preferred, '127.0.0.1', () => {\n const addr = server.address() as { port: number };\n server.close(() => resolve(addr.port));\n });\n server.on('error', (err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE') {\n resolve(getFreePort(preferred + 1, attempts + 1));\n } else {\n reject(err);\n }\n });\n });\n}\n\nfunction packageReportToSickbayReport(\n pkg: import('sickbay-core').PackageReport,\n parent: MonorepoReport,\n): SickbayReport {\n return {\n timestamp: parent.timestamp,\n projectPath: pkg.path,\n projectInfo: {\n name: pkg.name,\n version: 'unknown',\n hasTypeScript: false,\n hasESLint: false,\n hasPrettier: false,\n framework: pkg.framework,\n packageManager: parent.packageManager,\n totalDependencies:\n Object.keys(pkg.dependencies).length + Object.keys(pkg.devDependencies).length,\n dependencies: pkg.dependencies,\n devDependencies: pkg.devDependencies,\n },\n checks: pkg.checks,\n overallScore: pkg.score,\n summary: pkg.summary,\n };\n}\n\nexport async function serveWeb(\n report: SickbayReport | MonorepoReport,\n preferredPort = 3030,\n aiService?: AIService,\n): Promise<string> {\n const distDir = findWebDist();\n if (!distDir) {\n throw new Error('Web dashboard assets not found. Try reinstalling: npx sickbay@latest --web');\n }\n\n const reportJson = JSON.stringify(report);\n const port = await getFreePort(preferredPort);\n\n // Generate AI summary on startup if service is available (single-project only)\n let aiSummary: string | null = null;\n if (aiService && !('isMonorepo' in report)) {\n try {\n aiSummary = await aiService.generateSummary(report);\n } catch (err) {\n console.warn('AI summary generation failed:', err);\n }\n }\n\n const server = http.createServer(async (req, res) => {\n const url = req.url ?? '/';\n\n // Serve the report JSON directly from memory\n if (url === '/sickbay-report.json') {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(reportJson);\n return;\n }\n\n // Serve project-local history\n if (url === '/sickbay-history.json') {\n const basePath = 'isMonorepo' in report ? report.rootPath : report.projectPath;\n const historyPath = join(basePath, '.sickbay', 'history.json');\n if (existsSync(historyPath)) {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(readFileSync(historyPath, 'utf-8'));\n } else {\n res.writeHead(404, { 'Content-Type': 'application/json' });\n res.end('{}');\n }\n return;\n }\n\n // Serve dependency tree\n if (url === '/sickbay-dep-tree.json') {\n const basePath = 'isMonorepo' in report ? report.rootPath : report.projectPath;\n const treePath = join(basePath, '.sickbay', 'dep-tree.json');\n if (existsSync(treePath)) {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(readFileSync(treePath, 'utf-8'));\n } else {\n res.writeHead(404, { 'Content-Type': 'application/json' });\n res.end('{}');\n }\n return;\n }\n\n // Serve project config\n if (url === '/sickbay-config.json') {\n const basePath = 'isMonorepo' in report ? report.rootPath : report.projectPath;\n try {\n const { loadConfig } = await import('sickbay-core');\n const config = await loadConfig(basePath);\n if (config) {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(config));\n } else {\n res.writeHead(404, { 'Content-Type': 'application/json' });\n res.end('{}');\n }\n } catch {\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end('{}');\n }\n return;\n }\n\n // AI summary endpoint\n const parsedUrl = new URL(url, 'http://localhost');\n const packageName = parsedUrl.searchParams.get('package');\n\n if (parsedUrl.pathname === '/ai/summary') {\n if (req.method === 'HEAD') {\n // Availability check\n if (aiService) {\n res.writeHead(200);\n } else {\n res.writeHead(404);\n }\n res.end();\n return;\n }\n if (packageName && aiService && 'isMonorepo' in report) {\n // Per-package summary: generate on demand\n const pkg = report.packages.find((p) => p.name === packageName);\n if (!pkg) {\n res.writeHead(404, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Package not found' }));\n return;\n }\n try {\n const pkgReport = packageReportToSickbayReport(pkg, report);\n const summary = await aiService.generateSummary(pkgReport);\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ summary }));\n } catch (err) {\n // Don't leak internal error details (stack, constructor name, dep internals)\n // over the wire, even on loopback — other processes on the machine can reach it.\n const message = err instanceof Error ? err.message : 'Internal error';\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: message }));\n }\n return;\n }\n if (aiSummary) {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ summary: aiSummary }));\n return;\n }\n res.writeHead(404, { 'Content-Type': 'application/json' });\n res.end('{}');\n return;\n }\n\n // AI chat endpoint\n if (parsedUrl.pathname === '/ai/chat' && req.method === 'POST' && aiService) {\n // Cap request body to prevent unbounded memory growth from a malicious\n // or misbehaving client on the loopback interface.\n const MAX_BODY = 1024 * 1024; // 1 MB\n let body = '';\n let aborted = false;\n req.on('data', (chunk) => {\n if (aborted) return;\n body += chunk;\n if (Buffer.byteLength(body) > MAX_BODY) {\n aborted = true;\n res.writeHead(413, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Payload too large' }));\n req.destroy();\n }\n });\n req.on('end', async () => {\n if (aborted) return;\n try {\n const { message, history } = JSON.parse(body);\n let chatReport: SickbayReport;\n if (packageName && 'isMonorepo' in report) {\n const pkg = report.packages.find((p) => p.name === packageName);\n if (!pkg) {\n res.writeHead(404, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Package not found' }));\n return;\n }\n chatReport = packageReportToSickbayReport(pkg, report);\n } else if (!('isMonorepo' in report)) {\n chatReport = report;\n } else {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Package name required for monorepo' }));\n return;\n }\n const response = await aiService.chat(message, chatReport, history ?? []);\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ response }));\n } catch (err) {\n // See above — no internal error leakage.\n const errMessage = err instanceof Error ? err.message : 'Internal error';\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: errMessage }));\n }\n });\n return;\n }\n\n // Serve static files from dist. Use the parsed pathname (not the raw url)\n // so query strings are stripped, and route through resolveSafeStaticPath\n // to prevent path traversal (e.g. `/../../etc/passwd`, `%2e%2e/`).\n const requestedPath = parsedUrl.pathname === '/' ? 'index.html' : parsedUrl.pathname;\n const filePath = resolveSafeStaticPath(distDir, requestedPath);\n if (filePath && existsSync(filePath)) {\n const ext = extname(filePath);\n const mime = MIME_TYPES[ext] ?? 'application/octet-stream';\n res.writeHead(200, { 'Content-Type': mime });\n res.end(readFileSync(filePath));\n } else {\n // SPA fallback — serve index.html for all unknown routes\n res.writeHead(200, { 'Content-Type': 'text/html' });\n res.end(readFileSync(join(distDir, 'index.html')));\n }\n });\n\n return new Promise((resolve, reject) => {\n // Close server on SIGINT/SIGTERM so the process can exit cleanly when\n // the user presses Ctrl+C. Without this the HTTP handle keeps the event\n // loop alive after Ink's render loop exits.\n const shutdown = () => server.close();\n process.once('SIGINT', shutdown);\n process.once('SIGTERM', shutdown);\n\n // Reject the promise on any listen failure (EADDRINUSE, EACCES, etc.)\n // instead of letting an unhandled 'error' event crash the process with a\n // raw Node stack trace. The CLI renders rejection messages nicely.\n server.once('error', (err: NodeJS.ErrnoException) => {\n process.off('SIGINT', shutdown);\n process.off('SIGTERM', shutdown);\n if (err.code === 'EADDRINUSE') {\n reject(new Error(`Port ${port} is already in use. Stop the other process or try again.`));\n } else {\n reject(err);\n }\n });\n\n // SECURITY: bind explicitly to loopback so the dashboard (which contains\n // file paths, dep lists, and secret-scan results) is not exposed to other\n // hosts on the LAN, container networks, or VPN peers.\n server.listen(port, '127.0.0.1', () => {\n resolve(`http://localhost:${port}`);\n });\n });\n}\n"],"mappings":"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"}