@chemx/starter-kit 26.9.17-606 → 26.9.20-228
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 +119 -7
- package/cli/audit/extended-text-patterns.js +218 -0
- package/cli/audit/extended-visitors.js +12 -222
- package/cli/audit.js +1 -0
- package/cli/build/detector.js +40 -23
- package/cli/build/detector.spec.js +81 -0
- package/cli/build.js +29 -7
- package/cli/errors/catcher.js +80 -0
- package/cli/errors/errors.spec.js +163 -0
- package/cli/errors/formatter.js +94 -0
- package/cli/errors/index.js +52 -0
- package/cli/errors/publisher.js +76 -0
- package/cli/errors/sanitizer.js +50 -0
- package/cli/errors/storage.js +39 -0
- package/cli/errors/types.d.ts +42 -0
- package/cli/index.js +34 -3
- package/cli/installer-templates.js +1 -1
- package/cli/installer.d.ts +2 -0
- package/cli/installer.js +37 -25
- package/cli/installer.spec.js +72 -0
- package/cli/mcp/antigravity.js +37 -18
- package/cli/mcp/installer.js +8 -5
- package/cli/mcp/installer.spec.js +2 -1
- package/cli/mcp/manifests.js +162 -14
- package/cli/mcp/server.js +48 -12
- package/cli/mcp/server.spec.js +114 -21
- package/cli/mcp/tools-search.js +237 -0
- package/cli/mcp/tools-team.js +145 -0
- package/cli/mcp/tools.js +139 -177
- package/cli/navigator-banner-helpers.js +33 -0
- package/cli/navigator-banner.js +17 -35
- package/cli/navigator-banner.spec.js +68 -0
- package/cli/reader.js +65 -5
- package/cli/search-schema.js +4 -0
- package/cli/team/index.js +30 -0
- package/cli/team/team-adversarial-locks.spec.js +105 -0
- package/cli/team/team-adversarial-telemetry.spec.js +93 -0
- package/cli/team/team-commands.js +199 -0
- package/cli/team/team-db-agents.js +79 -0
- package/cli/team/team-db-feed.js +84 -0
- package/cli/team/team-db-lock-promotion.js +91 -0
- package/cli/team/team-db-locks.js +94 -0
- package/cli/team/team-db-task-helpers.js +94 -0
- package/cli/team/team-db-tasks.js +90 -0
- package/cli/team/team-db.js +93 -0
- package/cli/team/team-format.js +80 -0
- package/cli/team/team-lock-concurrency.spec.js +182 -0
- package/cli/team/team-schema.js +74 -0
- package/cli/team/team-telemetry.js +104 -0
- package/cli/team/team-triage.js +97 -0
- package/cli/team/team.spec.js +344 -0
- package/cli/ui-actions-helpers.js +66 -0
- package/cli/ui-actions.js +85 -0
- package/cli/ui-attention.js +74 -0
- package/cli/ui-client-api.js +49 -0
- package/cli/ui-client-cert.js +27 -0
- package/cli/ui-client-script.js +83 -0
- package/cli/ui-db-studio.js +79 -0
- package/cli/ui-handlers.js +95 -0
- package/cli/ui-html.js +35 -0
- package/cli/ui-server-routes.js +63 -0
- package/cli/ui-server.js +75 -0
- package/cli/ui-styles.js +34 -0
- package/cli/ui-template-views.js +63 -0
- package/cli/ui-template.js +91 -0
- package/cli/ui.spec.js +305 -0
- package/cli/verify.js +16 -26
- package/package.json +11 -16
- package/scripts/pre-commit.sh +1 -1
package/cli/build/detector.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
const findFileUpward = (fileName, startDir) => {
|
|
4
|
+
export const findFileUpward = (fileName, startDir) => {
|
|
5
5
|
let currentDir = path.resolve(startDir);
|
|
6
6
|
while (true) {
|
|
7
7
|
const candidate = path.join(currentDir, fileName);
|
|
@@ -13,18 +13,17 @@ const findFileUpward = (fileName, startDir) => {
|
|
|
13
13
|
return null;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
return 'npm';
|
|
16
|
+
export const findProjectRoot = (startDir = process.cwd()) => {
|
|
17
|
+
const pkgFile = findFileUpward('package.json', startDir);
|
|
18
|
+
if (pkgFile) return path.dirname(pkgFile);
|
|
19
|
+
const chemxDir = findFileUpward('.chemx', startDir);
|
|
20
|
+
if (chemxDir) return path.dirname(chemxDir);
|
|
21
|
+
const cardVault = '/home/xopher/www/elysium/apps/my-card-vault';
|
|
22
|
+
if (fs.existsSync(cardVault)) return cardVault;
|
|
23
|
+
return startDir;
|
|
25
24
|
};
|
|
26
25
|
|
|
27
|
-
const loadLocalPackageJson = (cwd) => {
|
|
26
|
+
export const loadLocalPackageJson = (cwd) => {
|
|
28
27
|
const pkgPath = path.join(cwd, 'package.json');
|
|
29
28
|
if (!fs.existsSync(pkgPath)) return null;
|
|
30
29
|
try {
|
|
@@ -34,6 +33,31 @@ const loadLocalPackageJson = (cwd) => {
|
|
|
34
33
|
}
|
|
35
34
|
};
|
|
36
35
|
|
|
36
|
+
export const resolvePackageManager = (cwd) => {
|
|
37
|
+
const pkg = loadLocalPackageJson(cwd);
|
|
38
|
+
if (pkg && typeof pkg.packageManager === 'string') {
|
|
39
|
+
const pmField = pkg.packageManager.toLowerCase();
|
|
40
|
+
if (pmField.startsWith('pnpm')) return 'pnpm';
|
|
41
|
+
if (pmField.startsWith('bun')) return 'bun';
|
|
42
|
+
if (pmField.startsWith('yarn')) return 'yarn';
|
|
43
|
+
if (pmField.startsWith('npm')) return 'npm';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let currentDir = path.resolve(cwd);
|
|
47
|
+
while (true) {
|
|
48
|
+
if (fs.existsSync(path.join(currentDir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
49
|
+
if (fs.existsSync(path.join(currentDir, 'bun.lockb')) || fs.existsSync(path.join(currentDir, 'bun.lock'))) return 'bun';
|
|
50
|
+
if (fs.existsSync(path.join(currentDir, 'yarn.lock'))) return 'yarn';
|
|
51
|
+
if (fs.existsSync(path.join(currentDir, 'package-lock.json'))) return 'npm';
|
|
52
|
+
|
|
53
|
+
const parent = path.dirname(currentDir);
|
|
54
|
+
if (parent === currentDir) break;
|
|
55
|
+
currentDir = parent;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return 'npm';
|
|
59
|
+
};
|
|
60
|
+
|
|
37
61
|
export const detectProjectBuildCommand = (customCmd, cwd = process.cwd()) => {
|
|
38
62
|
const hasCustomCommand = Boolean(customCmd && customCmd.trim().length > 0);
|
|
39
63
|
if (hasCustomCommand) return customCmd.trim();
|
|
@@ -42,18 +66,11 @@ export const detectProjectBuildCommand = (customCmd, cwd = process.cwd()) => {
|
|
|
42
66
|
const pkg = loadLocalPackageJson(cwd);
|
|
43
67
|
const scripts = (pkg && pkg.scripts) || {};
|
|
44
68
|
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return pm === 'yarn' ? 'yarn build' : `${pm} run build`;
|
|
51
|
-
}
|
|
52
|
-
if (hasBuildPublicScript) {
|
|
53
|
-
return pm === 'yarn' ? 'yarn build:public' : `${pm} run build:public`;
|
|
54
|
-
}
|
|
55
|
-
if (hasBuildProdScript) {
|
|
56
|
-
return pm === 'yarn' ? 'yarn build:prod' : `${pm} run build:prod`;
|
|
69
|
+
const scriptCandidates = ['build', 'build:prod', 'build:public', 'bundle'];
|
|
70
|
+
for (const scriptName of scriptCandidates) {
|
|
71
|
+
if (scripts[scriptName]) {
|
|
72
|
+
return pm === 'yarn' ? `yarn ${scriptName}` : `${pm} run ${scriptName}`;
|
|
73
|
+
}
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
const hasViteConfig = fs.existsSync(path.join(cwd, 'vite.config.js')) ||
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
findFileUpward,
|
|
7
|
+
resolvePackageManager,
|
|
8
|
+
detectProjectBuildCommand
|
|
9
|
+
} from './detector.js';
|
|
10
|
+
|
|
11
|
+
test('detector: resolvePackageManager detects packageManager field in package.json', () => {
|
|
12
|
+
const tmpDir = path.resolve(process.cwd(), 'scratch/test-detector-pm-field');
|
|
13
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
14
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
path.join(tmpDir, 'package.json'),
|
|
18
|
+
JSON.stringify({ name: 'test-app', packageManager: 'pnpm@9.1.0' }),
|
|
19
|
+
'utf-8'
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const pm = resolvePackageManager(tmpDir);
|
|
23
|
+
assert.strictEqual(pm, 'pnpm');
|
|
24
|
+
|
|
25
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('detector: resolvePackageManager detects upward lockfiles', () => {
|
|
29
|
+
const tmpDir = path.resolve(process.cwd(), 'scratch/test-detector-lockfile');
|
|
30
|
+
const subDir = path.join(tmpDir, 'apps/sub-app');
|
|
31
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
32
|
+
fs.mkdirSync(subDir, { recursive: true });
|
|
33
|
+
|
|
34
|
+
fs.writeFileSync(path.join(tmpDir, 'bun.lockb'), '', 'utf-8');
|
|
35
|
+
fs.writeFileSync(path.join(subDir, 'package.json'), JSON.stringify({ name: 'sub-app' }), 'utf-8');
|
|
36
|
+
|
|
37
|
+
const pm = resolvePackageManager(subDir);
|
|
38
|
+
assert.strictEqual(pm, 'bun');
|
|
39
|
+
|
|
40
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('detector: detectProjectBuildCommand uses custom command verbatim', () => {
|
|
44
|
+
const cmd = detectProjectBuildCommand('vite build --mode production');
|
|
45
|
+
assert.strictEqual(cmd, 'vite build --mode production');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('detector: detectProjectBuildCommand auto-detects from package.json scripts.build', () => {
|
|
49
|
+
const tmpDir = path.resolve(process.cwd(), 'scratch/test-detector-build-script');
|
|
50
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
51
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
52
|
+
|
|
53
|
+
fs.writeFileSync(
|
|
54
|
+
path.join(tmpDir, 'package.json'),
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
name: 'test-app',
|
|
57
|
+
packageManager: 'pnpm@9.0.0',
|
|
58
|
+
scripts: { build: 'vue-tsc --noEmit && vite build' }
|
|
59
|
+
}),
|
|
60
|
+
'utf-8'
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const cmd = detectProjectBuildCommand(null, tmpDir);
|
|
64
|
+
assert.strictEqual(cmd, 'pnpm run build');
|
|
65
|
+
|
|
66
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('detector: detectProjectBuildCommand falls back to vite when vite.config exists', () => {
|
|
70
|
+
const tmpDir = path.resolve(process.cwd(), 'scratch/test-detector-vite-fallback');
|
|
71
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
72
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'test-app' }), 'utf-8');
|
|
75
|
+
fs.writeFileSync(path.join(tmpDir, 'vite.config.ts'), 'export default {}', 'utf-8');
|
|
76
|
+
|
|
77
|
+
const cmd = detectProjectBuildCommand('', tmpDir);
|
|
78
|
+
assert.strictEqual(cmd, 'npx vite build');
|
|
79
|
+
|
|
80
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
81
|
+
});
|
package/cli/build.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { detectProjectBuildCommand } from './build/detector.js';
|
|
1
|
+
import { detectProjectBuildCommand, findProjectRoot } from './build/detector.js';
|
|
2
2
|
import { executeBuild } from './build/executor.js';
|
|
3
3
|
import { parseBuildOutput } from './build/parser.js';
|
|
4
4
|
import { groupBuildDiagnostics } from './build/grouper.js';
|
|
5
5
|
import { formatTerminalBuildReport, formatJsonBuildReport } from './build/reporter.js';
|
|
6
6
|
import { ANSI } from './theme.js';
|
|
7
|
+
import { handleError } from './errors/index.js';
|
|
7
8
|
|
|
8
9
|
const IGNORED_COMMAND_TOKENS = new Set(['build', 'run', 'wrap']);
|
|
9
10
|
|
|
@@ -21,21 +22,22 @@ const parseCommandFromArgs = (args) => {
|
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
export const runBuildAudit = async (rawArgs = [], isCli = false, options = {}) => {
|
|
24
|
-
const isJson = rawArgs.includes('--json');
|
|
25
|
+
const isJson = rawArgs.includes('--json') || options.json === true;
|
|
25
26
|
const isSilent = rawArgs.includes('--silent') || options.silent === true;
|
|
26
|
-
const isRaw = rawArgs.includes('--raw');
|
|
27
|
-
const isSummary = rawArgs.includes('--summary');
|
|
27
|
+
const isRaw = rawArgs.includes('--raw') || options.raw === true;
|
|
28
|
+
const isSummary = rawArgs.includes('--summary') || options.summary === true;
|
|
28
29
|
const shouldPrint = options.print !== false;
|
|
30
|
+
const cwd = findProjectRoot(options.cwd || process.cwd());
|
|
29
31
|
|
|
30
|
-
const customCommand = parseCommandFromArgs(rawArgs);
|
|
31
|
-
const command = detectProjectBuildCommand(customCommand,
|
|
32
|
+
const customCommand = parseCommandFromArgs(rawArgs) || options.command;
|
|
33
|
+
const command = detectProjectBuildCommand(customCommand, cwd);
|
|
32
34
|
|
|
33
35
|
const shouldPrintStart = shouldPrint && !isJson && !isSilent && !isRaw;
|
|
34
36
|
if (shouldPrintStart) {
|
|
35
37
|
process.stdout.write(`${ANSI.CYAN}Auditing build:${ANSI.RESET} ${ANSI.DIM}${command}${ANSI.RESET}\n`);
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
const executionResult = await executeBuild(command,
|
|
40
|
+
const executionResult = await executeBuild(command, cwd, { raw: isRaw });
|
|
39
41
|
const rawDiagnostics = parseBuildOutput(executionResult.stdout, executionResult.stderr);
|
|
40
42
|
const report = groupBuildDiagnostics(rawDiagnostics, executionResult);
|
|
41
43
|
|
|
@@ -52,6 +54,26 @@ export const runBuildAudit = async (rawArgs = [], isCli = false, options = {}) =
|
|
|
52
54
|
process.stdout.write(terminalOutput);
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
const isFailedBuild = report.exitCode !== 0;
|
|
58
|
+
const hasIssueFlag = rawArgs.includes('--prep-issue') || rawArgs.includes('--post-issue');
|
|
59
|
+
const shouldHandleError = isFailedBuild && (hasIssueFlag || options.postIssue || (isCli && !isJson));
|
|
60
|
+
|
|
61
|
+
if (shouldHandleError) {
|
|
62
|
+
const errorDetails = `Build command failed with exit code ${report.exitCode}: ${command}`;
|
|
63
|
+
await handleError(new Error(errorDetails), {
|
|
64
|
+
cwd,
|
|
65
|
+
command,
|
|
66
|
+
exitCode: report.exitCode,
|
|
67
|
+
autoPost: rawArgs.includes('--post-issue') || options.postIssue,
|
|
68
|
+
silent: isSilent || isJson,
|
|
69
|
+
context: {
|
|
70
|
+
totalErrors: report.totalErrors,
|
|
71
|
+
totalWarnings: report.totalWarnings,
|
|
72
|
+
categories: report.categories
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
if (isCli) {
|
|
56
78
|
process.exit(report.exitCode);
|
|
57
79
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { copyToClipboard } from '../audit/social-git.js';
|
|
2
|
+
import { formatIssueContent } from './formatter.js';
|
|
3
|
+
import { publishIssue } from './publisher.js';
|
|
4
|
+
import { openBrowser, confirmAction } from '../terminal.js';
|
|
5
|
+
import { resolveTargetIssuesRepo, saveIssueArtifact } from './storage.js';
|
|
6
|
+
|
|
7
|
+
export { resolveTargetIssuesRepo, saveIssueArtifact };
|
|
8
|
+
|
|
9
|
+
const promptUserToPublish = async (targetRepo, issue) => {
|
|
10
|
+
const confirmed = await confirmAction('Post this error report directly to GitHub Issues?', 'Post Issue', 'Skip', false);
|
|
11
|
+
if (!confirmed) {
|
|
12
|
+
return { success: false, url: null, issueNumber: null, error: null };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const publishResult = await publishIssue(targetRepo, issue.title, issue.body, issue.labels);
|
|
16
|
+
if (publishResult.success) {
|
|
17
|
+
process.stdout.write(`\x1b[32m✔ Issue published: ${publishResult.url}\x1b[0m\n`);
|
|
18
|
+
openBrowser(publishResult.url);
|
|
19
|
+
} else {
|
|
20
|
+
copyToClipboard(issue.body);
|
|
21
|
+
process.stdout.write(`\x1b[33m⚠ Publish failed. Markdown copied to clipboard. Opening browser...\x1b[0m\n`);
|
|
22
|
+
openBrowser(issue.webUrl);
|
|
23
|
+
}
|
|
24
|
+
return publishResult;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const handleError = async (err, options = {}) => {
|
|
28
|
+
const cwd = options.cwd || process.cwd();
|
|
29
|
+
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
30
|
+
const targetRepo = resolveTargetIssuesRepo(options, cwd);
|
|
31
|
+
|
|
32
|
+
const rawCmd = options.command || process.argv.slice(2).join(' ');
|
|
33
|
+
const command = rawCmd || 'chemx';
|
|
34
|
+
|
|
35
|
+
const report = {
|
|
36
|
+
message: errorObj.message,
|
|
37
|
+
name: errorObj.name,
|
|
38
|
+
stack: errorObj.stack || '',
|
|
39
|
+
command,
|
|
40
|
+
cwd,
|
|
41
|
+
exitCode: options.exitCode ?? 1,
|
|
42
|
+
timestamp: new Date().toISOString(),
|
|
43
|
+
nodeVersion: process.version,
|
|
44
|
+
platform: `${process.platform}-${process.arch}`,
|
|
45
|
+
chemxVersion: options.chemxVersion || '26.9.17',
|
|
46
|
+
context: options.context || {}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const issue = formatIssueContent(report, targetRepo, options.labels);
|
|
50
|
+
const savedPath = saveIssueArtifact(cwd, issue, options);
|
|
51
|
+
|
|
52
|
+
const hasToken = Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN);
|
|
53
|
+
const isCiEnv = Boolean(process.env.CI || process.env.GITHUB_ACTIONS);
|
|
54
|
+
const isExplicitAutoPost = Boolean(options.autoPost || process.env.CHEMX_AUTO_POST_ISSUES === 'true');
|
|
55
|
+
const isCiAutoPost = isCiEnv && hasToken && options.autoPost !== false;
|
|
56
|
+
const shouldAutoPost = isExplicitAutoPost || isCiAutoPost;
|
|
57
|
+
|
|
58
|
+
let publishResult = { success: false, url: null, issueNumber: null, error: null };
|
|
59
|
+
|
|
60
|
+
if (shouldAutoPost) {
|
|
61
|
+
publishResult = await publishIssue(targetRepo, issue.title, issue.body, issue.labels);
|
|
62
|
+
if (publishResult.success && !options.silent) {
|
|
63
|
+
process.stdout.write(`\n\x1b[32m✔ Issue automatically created: ${publishResult.url}\x1b[0m\n`);
|
|
64
|
+
}
|
|
65
|
+
} else if (!options.silent) {
|
|
66
|
+
const isTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
67
|
+
const isInteractive = isTty && !isCiEnv;
|
|
68
|
+
process.stderr.write(`\n\x1b[31m✕ Command Failed: ${report.message}\x1b[0m\n`);
|
|
69
|
+
process.stderr.write(` \x1b[33m• Prepped Issue:\x1b[0m ${issue.webUrl}\n`);
|
|
70
|
+
if (savedPath) {
|
|
71
|
+
process.stderr.write(` \x1b[33m• Local Report:\x1b[0m ${savedPath}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (isInteractive) {
|
|
75
|
+
publishResult = await promptUserToPublish(targetRepo, issue);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { report, issue, publishResult, savedPath };
|
|
80
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import {
|
|
7
|
+
maskSensitiveTokens,
|
|
8
|
+
normalizeHomePath,
|
|
9
|
+
sanitizeText,
|
|
10
|
+
sanitizeStackTrace
|
|
11
|
+
} from './sanitizer.js';
|
|
12
|
+
import {
|
|
13
|
+
buildIssueTitle,
|
|
14
|
+
buildIssueBody,
|
|
15
|
+
buildIssueWebUrl,
|
|
16
|
+
formatIssueContent
|
|
17
|
+
} from './formatter.js';
|
|
18
|
+
import {
|
|
19
|
+
handleError,
|
|
20
|
+
resolveTargetIssuesRepo,
|
|
21
|
+
saveIssueArtifact
|
|
22
|
+
} from './catcher.js';
|
|
23
|
+
import { withErrorCatcher } from './index.js';
|
|
24
|
+
|
|
25
|
+
describe('Universal Error Catcher: Sanitizer', () => {
|
|
26
|
+
it('masks GitHub personal access tokens and npm tokens', () => {
|
|
27
|
+
const input = 'Failed with token ghp_123456789012345678901234567890123456 and npm_abcdefghijklmnopqrstuvwxyz012345';
|
|
28
|
+
const sanitized = maskSensitiveTokens(input);
|
|
29
|
+
assert.ok(!sanitized.includes('ghp_1234567890'));
|
|
30
|
+
assert.ok(!sanitized.includes('npm_abcdefgh'));
|
|
31
|
+
assert.ok(sanitized.includes('[REDACTED_SECRET]'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('masks Bearer tokens and api-key parameters', () => {
|
|
35
|
+
const input = 'Authorization: Bearer mySecretToken123456789\napiKey="superSecretApiKey123"';
|
|
36
|
+
const sanitized = maskSensitiveTokens(input);
|
|
37
|
+
assert.ok(!sanitized.includes('mySecretToken123456789'));
|
|
38
|
+
assert.ok(!sanitized.includes('superSecretApiKey123'));
|
|
39
|
+
assert.ok(sanitized.includes('Bearer [REDACTED]'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('normalizes home directory paths', () => {
|
|
43
|
+
const home = os.homedir();
|
|
44
|
+
const input = `Error reading file in ${home}/my-project/index.ts`;
|
|
45
|
+
const normalized = normalizeHomePath(input);
|
|
46
|
+
assert.ok(!normalized.includes(home));
|
|
47
|
+
assert.ok(normalized.includes('~/my-project/index.ts'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('sanitizes full stack traces', () => {
|
|
51
|
+
const home = os.homedir();
|
|
52
|
+
const stack = `Error: crash\n at test (${home}/apps/test.js:10:5)\n token: ghp_123456789012345678901234567890`;
|
|
53
|
+
const sanitized = sanitizeStackTrace(stack);
|
|
54
|
+
assert.ok(!sanitized.includes(home));
|
|
55
|
+
assert.ok(!sanitized.includes('ghp_1234567890'));
|
|
56
|
+
assert.ok(sanitized.includes('~/apps/test.js'));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Universal Error Catcher: Formatter', () => {
|
|
61
|
+
const sampleReport = {
|
|
62
|
+
message: 'TypeError: Cannot read properties of undefined',
|
|
63
|
+
name: 'TypeError',
|
|
64
|
+
stack: 'TypeError: Cannot read properties of undefined\n at run (/src/app.ts:1:1)',
|
|
65
|
+
command: 'chemx build --production',
|
|
66
|
+
cwd: '/workspace',
|
|
67
|
+
exitCode: 1,
|
|
68
|
+
timestamp: '2026-09-17T12:00:00.000Z',
|
|
69
|
+
nodeVersion: 'v22.20.0',
|
|
70
|
+
platform: 'linux-x64',
|
|
71
|
+
chemxVersion: '26.9.17'
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
it('builds concise issue title', () => {
|
|
75
|
+
const title = buildIssueTitle(sampleReport);
|
|
76
|
+
assert.ok(title.startsWith('[ChemX Failure] chemx:'));
|
|
77
|
+
assert.ok(title.includes('TypeError: Cannot read properties'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('builds detailed markdown issue body with environment and details', () => {
|
|
81
|
+
const body = buildIssueBody(sampleReport);
|
|
82
|
+
assert.ok(body.includes('Execution Context'));
|
|
83
|
+
assert.ok(body.includes('`chemx build --production`'));
|
|
84
|
+
assert.ok(body.includes('v22.20.0'));
|
|
85
|
+
assert.ok(body.includes('Diagnostic Stack Trace'));
|
|
86
|
+
assert.ok(body.includes('<details open>'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('constructs valid GitHub new issue web url', () => {
|
|
90
|
+
const url = buildIssueWebUrl('org/repo', 'Issue Title', 'Issue Body', ['bug', 'build-failure']);
|
|
91
|
+
assert.ok(url.startsWith('https://github.com/org/repo/issues/new?'));
|
|
92
|
+
const parsed = new URL(url);
|
|
93
|
+
assert.equal(parsed.searchParams.get('title'), 'Issue Title');
|
|
94
|
+
assert.equal(parsed.searchParams.get('labels'), 'bug,build-failure');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('formats full issue content object', () => {
|
|
98
|
+
const content = formatIssueContent(sampleReport, 'my-org/my-repo', ['critical-error']);
|
|
99
|
+
assert.equal(content.targetRepo, 'my-org/my-repo');
|
|
100
|
+
assert.deepEqual(content.labels, ['critical-error']);
|
|
101
|
+
assert.ok(content.webUrl.includes('my-org/my-repo/issues/new'));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('Universal Error Catcher: Catcher & Orchestration', () => {
|
|
106
|
+
it('resolves explicit repo option over defaults', () => {
|
|
107
|
+
const repo = resolveTargetIssuesRepo({ repo: 'custom/repo' }, '/tmp');
|
|
108
|
+
assert.equal(repo, 'custom/repo');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('saves issue markdown artifact to .chemx/issues directory', () => {
|
|
112
|
+
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-err-test-'));
|
|
113
|
+
try {
|
|
114
|
+
const issue = {
|
|
115
|
+
title: 'Test Error',
|
|
116
|
+
body: '# Test Error Markdown Body',
|
|
117
|
+
labels: ['test'],
|
|
118
|
+
webUrl: 'https://github.com/test/repo/issues/new',
|
|
119
|
+
targetRepo: 'test/repo'
|
|
120
|
+
};
|
|
121
|
+
const savedPath = saveIssueArtifact(tempCwd, issue);
|
|
122
|
+
assert.ok(savedPath && fs.existsSync(savedPath));
|
|
123
|
+
const content = fs.readFileSync(savedPath, 'utf-8');
|
|
124
|
+
assert.equal(content, issue.body);
|
|
125
|
+
|
|
126
|
+
const lastPath = path.join(tempCwd, '.chemx', 'issues', 'last-error-issue.md');
|
|
127
|
+
assert.ok(fs.existsSync(lastPath));
|
|
128
|
+
} finally {
|
|
129
|
+
fs.rmSync(tempCwd, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('withErrorCatcher returns function result on success', async () => {
|
|
134
|
+
const result = await withErrorCatcher(async () => 42, { exitOnError: false });
|
|
135
|
+
assert.equal(result, 42);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('withErrorCatcher catches error, prepares issue, and returns error result without crashing', async () => {
|
|
139
|
+
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-catcher-test-'));
|
|
140
|
+
try {
|
|
141
|
+
const res = await withErrorCatcher(
|
|
142
|
+
async () => {
|
|
143
|
+
throw new Error('Simulated build pipeline failure');
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
cwd: tempCwd,
|
|
147
|
+
silent: true,
|
|
148
|
+
exitOnError: false,
|
|
149
|
+
repo: 'test-org/test-repo'
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
assert.ok(res.report);
|
|
154
|
+
assert.equal(res.report.message, 'Simulated build pipeline failure');
|
|
155
|
+
assert.ok(res.issue);
|
|
156
|
+
assert.equal(res.issue.targetRepo, 'test-org/test-repo');
|
|
157
|
+
assert.ok(res.savedPath);
|
|
158
|
+
assert.ok(fs.existsSync(res.savedPath));
|
|
159
|
+
} finally {
|
|
160
|
+
fs.rmSync(tempCwd, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { sanitizeText, sanitizeStackTrace } from './sanitizer.js';
|
|
2
|
+
|
|
3
|
+
export const buildIssueTitle = (report) => {
|
|
4
|
+
const cmd = report.command ? report.command.split(' ')[0] : 'chemx';
|
|
5
|
+
const rawMsg = report.message || 'Unknown error occurred';
|
|
6
|
+
const cleanMsg = rawMsg.split('\n')[0].replace(/[\r\n\t]+/g, ' ').trim();
|
|
7
|
+
const truncatedMsg = cleanMsg.length > 70 ? `${cleanMsg.slice(0, 67)}...` : cleanMsg;
|
|
8
|
+
return `[ChemX Failure] ${cmd}: ${truncatedMsg}`;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const buildIssueBody = (report) => {
|
|
12
|
+
const rawStack = report.stack || report.message;
|
|
13
|
+
const stackContent = rawStack || 'No stack trace available';
|
|
14
|
+
const sanitizedStack = sanitizeStackTrace(stackContent);
|
|
15
|
+
const sanitizedCommand = sanitizeText(report.command || 'chemx');
|
|
16
|
+
const sanitizedCwd = sanitizeText(report.cwd || process.cwd());
|
|
17
|
+
|
|
18
|
+
const lines = [
|
|
19
|
+
'### 🚨 Chemical X Execution Failure Report',
|
|
20
|
+
'',
|
|
21
|
+
'An automated failure report was prepared by the Chemical X Universal Error Catcher.',
|
|
22
|
+
'',
|
|
23
|
+
'#### 📊 Execution Context',
|
|
24
|
+
`| Property | Value |`,
|
|
25
|
+
`| :--- | :--- |`,
|
|
26
|
+
`| **Command** | \`${sanitizedCommand}\` |`,
|
|
27
|
+
`| **Exit Code** | \`${report.exitCode ?? 1}\` |`,
|
|
28
|
+
`| **Timestamp** | \`${report.timestamp || new Date().toISOString()}\` |`,
|
|
29
|
+
`| **Node.js** | \`${report.nodeVersion || process.version}\` |`,
|
|
30
|
+
`| **Platform** | \`${report.platform || process.platform}\` |`,
|
|
31
|
+
`| **ChemX Version** | \`${report.chemxVersion || 'unknown'}\` |`,
|
|
32
|
+
`| **Working Dir** | \`${sanitizedCwd}\` |`
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
if (report.gitBranch) {
|
|
36
|
+
lines.push(`| **Git Branch** | \`${report.gitBranch}\` |`);
|
|
37
|
+
}
|
|
38
|
+
if (report.gitCommit) {
|
|
39
|
+
lines.push(`| **Git Commit** | \`${report.gitCommit}\` |`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push('#### 💥 Diagnostic Stack Trace');
|
|
44
|
+
lines.push('<details open>');
|
|
45
|
+
lines.push('<summary>Click to collapse error details</summary>');
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push('```text');
|
|
48
|
+
lines.push(sanitizedStack.trim());
|
|
49
|
+
lines.push('```');
|
|
50
|
+
lines.push('</details>');
|
|
51
|
+
|
|
52
|
+
if (report.context && Object.keys(report.context).length > 0) {
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push('#### 🔍 Additional Metadata');
|
|
55
|
+
lines.push('```json');
|
|
56
|
+
lines.push(JSON.stringify(report.context, null, 2));
|
|
57
|
+
lines.push('```');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push('---');
|
|
62
|
+
lines.push('> *Generated automatically by Chemical X Protocol Universal Error Catcher.*');
|
|
63
|
+
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const buildIssueWebUrl = (repo, title, body, labels = []) => {
|
|
68
|
+
const hasRepo = Boolean(repo && repo.includes('/'));
|
|
69
|
+
const safeRepo = hasRepo ? repo : 'Chemical-X-Protocol/starter-kit';
|
|
70
|
+
const base = `https://github.com/${safeRepo}/issues/new`;
|
|
71
|
+
const params = new URLSearchParams();
|
|
72
|
+
params.set('title', title);
|
|
73
|
+
params.set('body', body);
|
|
74
|
+
if (labels && labels.length > 0) {
|
|
75
|
+
params.set('labels', labels.join(','));
|
|
76
|
+
}
|
|
77
|
+
return `${base}?${params.toString()}`;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const formatIssueContent = (report, targetRepo, customLabels = null) => {
|
|
81
|
+
const defaultLabels = ['chemx-failure', 'bug'];
|
|
82
|
+
const labels = customLabels || defaultLabels;
|
|
83
|
+
const title = buildIssueTitle(report);
|
|
84
|
+
const body = buildIssueBody(report);
|
|
85
|
+
const webUrl = buildIssueWebUrl(targetRepo, title, body, labels);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
title,
|
|
89
|
+
body,
|
|
90
|
+
labels,
|
|
91
|
+
webUrl,
|
|
92
|
+
targetRepo
|
|
93
|
+
};
|
|
94
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { handleError, resolveTargetIssuesRepo, saveIssueArtifact } from './catcher.js';
|
|
2
|
+
import { formatIssueContent, buildIssueTitle, buildIssueBody, buildIssueWebUrl } from './formatter.js';
|
|
3
|
+
import { publishIssue, publishIssueViaHttp, publishIssueViaGh } from './publisher.js';
|
|
4
|
+
import { sanitizeText, sanitizeStackTrace, maskSensitiveTokens } from './sanitizer.js';
|
|
5
|
+
|
|
6
|
+
export const withErrorCatcher = async (fn, options = {}) => {
|
|
7
|
+
try {
|
|
8
|
+
return await fn();
|
|
9
|
+
} catch (err) {
|
|
10
|
+
const res = await handleError(err, options);
|
|
11
|
+
const shouldExit = options.exitOnError !== false;
|
|
12
|
+
if (shouldExit) {
|
|
13
|
+
process.exit(options.exitCode ?? 1);
|
|
14
|
+
}
|
|
15
|
+
return res;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const installGlobalErrorCatcher = (options = {}) => {
|
|
20
|
+
const handler = async (err, errorType) => {
|
|
21
|
+
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
22
|
+
await handleError(errorObj, {
|
|
23
|
+
...options,
|
|
24
|
+
context: { ...options.context, errorType }
|
|
25
|
+
});
|
|
26
|
+
process.exit(1);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
process.on('uncaughtException', (err) => {
|
|
30
|
+
handler(err, 'uncaughtException');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
process.on('unhandledRejection', (reason) => {
|
|
34
|
+
handler(reason, 'unhandledRejection');
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
handleError,
|
|
40
|
+
resolveTargetIssuesRepo,
|
|
41
|
+
saveIssueArtifact,
|
|
42
|
+
formatIssueContent,
|
|
43
|
+
buildIssueTitle,
|
|
44
|
+
buildIssueBody,
|
|
45
|
+
buildIssueWebUrl,
|
|
46
|
+
publishIssue,
|
|
47
|
+
publishIssueViaHttp,
|
|
48
|
+
publishIssueViaGh,
|
|
49
|
+
sanitizeText,
|
|
50
|
+
sanitizeStackTrace,
|
|
51
|
+
maskSensitiveTokens
|
|
52
|
+
};
|