@chemx/starter-kit 26.9.17-606 → 26.9.20-369

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.
Files changed (103) hide show
  1. package/README.md +119 -7
  2. package/blueprints/_package.json +17 -0
  3. package/blueprints/_tsconfig.json +15 -0
  4. package/blueprints/_vitest.config.ts +9 -0
  5. package/cli/audit/extended-text-patterns.js +218 -0
  6. package/cli/audit/extended-visitors.js +12 -222
  7. package/cli/audit.js +1 -0
  8. package/cli/build/detector.js +40 -23
  9. package/cli/build/detector.spec.js +81 -0
  10. package/cli/build.js +29 -7
  11. package/cli/errors/catcher.js +80 -0
  12. package/cli/errors/errors.spec.js +163 -0
  13. package/cli/errors/formatter.js +94 -0
  14. package/cli/errors/index.js +52 -0
  15. package/cli/errors/publisher.js +76 -0
  16. package/cli/errors/sanitizer.js +50 -0
  17. package/cli/errors/storage.js +39 -0
  18. package/cli/errors/types.d.ts +42 -0
  19. package/cli/generator.d.ts +3 -2
  20. package/cli/generator.js +59 -0
  21. package/cli/generator.spec.js +17 -0
  22. package/cli/help.js +18 -0
  23. package/cli/index.js +34 -3
  24. package/cli/installer-templates.js +1 -1
  25. package/cli/installer.d.ts +2 -0
  26. package/cli/installer.js +37 -25
  27. package/cli/installer.spec.js +72 -0
  28. package/cli/mcp/antigravity.js +37 -18
  29. package/cli/mcp/installer.js +8 -5
  30. package/cli/mcp/installer.spec.js +6 -7
  31. package/cli/mcp/manifests.js +162 -14
  32. package/cli/mcp/server.js +48 -12
  33. package/cli/mcp/server.spec.js +114 -21
  34. package/cli/mcp/tools-search.js +237 -0
  35. package/cli/mcp/tools-team.js +145 -0
  36. package/cli/mcp/tools.js +139 -177
  37. package/cli/navigator-banner-helpers.js +33 -0
  38. package/cli/navigator-banner.js +17 -35
  39. package/cli/navigator-banner.spec.js +68 -0
  40. package/cli/patcher.js +42 -0
  41. package/cli/patcher.spec.js +19 -0
  42. package/cli/reader.js +90 -5
  43. package/cli/scaffold.js +27 -7
  44. package/cli/search-schema.js +4 -0
  45. package/cli/team/index.js +30 -0
  46. package/cli/team/team-adversarial-locks.spec.js +105 -0
  47. package/cli/team/team-adversarial-telemetry.spec.js +93 -0
  48. package/cli/team/team-commands.js +199 -0
  49. package/cli/team/team-db-agents.js +79 -0
  50. package/cli/team/team-db-feed.js +84 -0
  51. package/cli/team/team-db-lock-promotion.js +91 -0
  52. package/cli/team/team-db-locks.js +94 -0
  53. package/cli/team/team-db-task-helpers.js +94 -0
  54. package/cli/team/team-db-tasks.js +90 -0
  55. package/cli/team/team-db.js +93 -0
  56. package/cli/team/team-format.js +80 -0
  57. package/cli/team/team-lock-concurrency.spec.js +182 -0
  58. package/cli/team/team-multi-model-telemetry.spec.js +80 -0
  59. package/cli/team/team-schema.js +80 -0
  60. package/cli/team/team-telemetry.js +127 -0
  61. package/cli/team/team-triage.js +97 -0
  62. package/cli/team/team.spec.js +344 -0
  63. package/cli/theme.js +1 -0
  64. package/cli/ui-actions-codebase.js +75 -0
  65. package/cli/ui-actions-helpers.js +66 -0
  66. package/cli/ui-actions-studio.js +91 -0
  67. package/cli/ui-actions-tasks.js +87 -0
  68. package/cli/ui-actions.js +69 -0
  69. package/cli/ui-attention.js +74 -0
  70. package/cli/ui-canonical-routes.spec.js +130 -0
  71. package/cli/ui-client-api.js +87 -0
  72. package/cli/ui-client-cert.js +27 -0
  73. package/cli/ui-client-filetree.js +93 -0
  74. package/cli/ui-client-forum.js +85 -0
  75. package/cli/ui-client-script.js +98 -0
  76. package/cli/ui-client-studio.js +95 -0
  77. package/cli/ui-db-studio.js +79 -0
  78. package/cli/ui-e2e-verification.spec.js +186 -0
  79. package/cli/ui-filetree.spec.js +175 -0
  80. package/cli/ui-forum-data.js +97 -0
  81. package/cli/ui-forum-topics.js +65 -0
  82. package/cli/ui-handlers.js +91 -0
  83. package/cli/ui-html.js +41 -0
  84. package/cli/ui-kanban.spec.js +206 -0
  85. package/cli/ui-server-routes.js +91 -0
  86. package/cli/ui-server.js +81 -0
  87. package/cli/ui-styles.js +87 -0
  88. package/cli/ui-template-agents.js +64 -0
  89. package/cli/ui-template-dbstudio.js +82 -0
  90. package/cli/ui-template-filetree.js +92 -0
  91. package/cli/ui-template-forum.js +86 -0
  92. package/cli/ui-template-kanban.js +83 -0
  93. package/cli/ui-template-thread.js +51 -0
  94. package/cli/ui-template-timeline.js +35 -0
  95. package/cli/ui-template-views.js +63 -0
  96. package/cli/ui-template-workbench.js +55 -0
  97. package/cli/ui-template.js +71 -0
  98. package/cli/ui-vbulletin.spec.js +189 -0
  99. package/cli/ui-workbench-dbstudio.spec.js +172 -0
  100. package/cli/ui.spec.js +305 -0
  101. package/cli/verify.js +123 -42
  102. package/package.json +11 -16
  103. package/scripts/pre-commit.sh +1 -1
@@ -1,13 +1,8 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
1
  import * as t from '@babel/types';
4
2
  import { RULE_REGISTRY } from './rules-registry.js';
5
- import { toResultSync } from './rules-helpers.js';
6
- import {
7
- isComponentExtension,
8
- isTemplateExtension,
9
- isCommentLine
10
- } from './rules-predicates.js';
3
+ import { checkExtendedTextPatterns } from './extended-text-patterns.js';
4
+
5
+ export { checkExtendedTextPatterns };
11
6
 
12
7
  const BARE_BOOLEANS = new Set([
13
8
  'loading', 'valid', 'active', 'visible', 'open', 'disabled',
@@ -20,19 +15,6 @@ const BARE_HANDLERS = new Set([
20
15
  'reset', 'confirm', 'cancel', 'refresh', 'sync', 'send', 'pay'
21
16
  ]);
22
17
 
23
- const SECRET_PATTERNS = [
24
- /(['"])(?:sk-[a-zA-Z0-9_-]{24,}|ghp_[a-zA-Z0-9]{30,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z-_]{35})\1/
25
- ];
26
-
27
- const FAKE_GREEN_PATTERNS = [
28
- /expect\s*\(\s*(true|false|1|0)\s*\)\s*\.\s*to(?:Be|Equal|StrictEqual)\s*\(\s*\1\s*\)/,
29
- /assert\s*\.\s*(?:strictEqual|equal|deepEqual)\s*\(\s*(true|false|1|0)\s*,\s*\1\s*\)/,
30
- /assert\s*\.\s*ok\s*\(\s*(?:true|1)\s*\)/
31
- ];
32
-
33
- const IMG_WITHOUT_ALT_PATTERN = new RegExp(['<', 'img\\b', '(?![^>]*\\balt\\s*=)', '[^>]*>'].join(''), 'i');
34
- const CLICKABLE_CONTAINER_PATTERN = new RegExp(['<', '(div|span)\\b', '(?![^>]*\\brole\\s*=\\s*[\'"](?:button|link|tab)[\'"])', '[^>]*\\b(?:@click|v-on:click)\\s*='].join(''), 'i');
35
-
36
18
  const SENSITIVE_KEYWORDS = new Set([
37
19
  'password', 'passwd', 'secret', 'token', 'apikey', 'api_key',
38
20
  'authtoken', 'auth_token', 'accesstoken', 'access_token',
@@ -47,8 +29,14 @@ const isSensitiveName = (name = '') => {
47
29
  };
48
30
 
49
31
  const JAVASCRIPT_URL_REGEX = /^\s*javascript:/i;
50
- const TABNABBING_TEMPLATE_PATTERN = /<a\b[^>]*\btarget\s*=\s*["']_blank["'][^>]*>/i;
51
- const JAVASCRIPT_URL_TEMPLATE_PATTERN = /\b(?:href|src|action|formaction)\s*=\s*["']\s*javascript:/i;
32
+
33
+ const resolvePropertyKeyName = (key) => {
34
+ const isIdentifier = t.isIdentifier(key);
35
+ if (isIdentifier) return key.name;
36
+ const isStringLiteral = t.isStringLiteral(key);
37
+ if (isStringLiteral) return key.value;
38
+ return '';
39
+ };
52
40
 
53
41
  const isSensitiveExpression = (node) => {
54
42
  if (!node) return false;
@@ -66,11 +54,7 @@ const isSensitiveExpression = (node) => {
66
54
  if (t.isObjectExpression(node)) {
67
55
  return node.properties.some((prop) => {
68
56
  if (t.isObjectProperty(prop)) {
69
- const keyName = t.isIdentifier(prop.key)
70
- ? prop.key.name
71
- : t.isStringLiteral(prop.key)
72
- ? prop.key.value
73
- : '';
57
+ const keyName = resolvePropertyKeyName(prop.key);
74
58
  return isSensitiveName(keyName);
75
59
  }
76
60
  return false;
@@ -82,200 +66,6 @@ const isSensitiveExpression = (node) => {
82
66
  return false;
83
67
  };
84
68
 
85
- /**
86
- * Pillar 10: Check for co-located test files for molecule capsules.
87
- */
88
- const checkMoleculeCoLocatedTest = (filePath, relativePath, violations) => {
89
- const baseName = path.basename(filePath);
90
- const ext = path.extname(filePath);
91
-
92
- // Stage 1: Atomic Concept Declarations
93
- const isMoleculePath = relativePath.includes('molecules') || baseName.startsWith('m-');
94
- const isComponentExt = isComponentExtension(ext);
95
- const isTestOrSpecFile = baseName.includes('.spec.') || baseName.includes('.test.');
96
-
97
- // Stage 2: Unified Decision Variable & Early Guard Clause
98
- const isCandidateComponent = isMoleculePath && isComponentExt;
99
- const isMoleculeComponent = isCandidateComponent && !isTestOrSpecFile;
100
- if (!isMoleculeComponent) return;
101
-
102
- const dir = path.dirname(filePath);
103
- const [siblings, dirError] = toResultSync(() => (fs.existsSync(dir) ? fs.readdirSync(dir) : []));
104
-
105
- // Stage 1: Atomic Guard Check
106
- const hasDirError = Boolean(dirError);
107
- const hasSiblings = Boolean(siblings);
108
- const canInspectSiblings = !hasDirError && hasSiblings;
109
- if (!canInspectSiblings) return;
110
-
111
- const isTestSibling = (fileName) => /\.(test|spec)\.[jt]sx?$/.test(fileName);
112
- const hasCoLocatedTest = siblings.some(isTestSibling);
113
- if (hasCoLocatedTest) return;
114
-
115
- const meta = RULE_REGISTRY.TEST_MISSING_COLOCATED;
116
- violations.push({
117
- filePath: relativePath,
118
- line: 1,
119
- column: 1,
120
- hazard: 'Molecule capsule missing co-located test file (*.spec.ts or *.test.ts)',
121
- rule: 'TEST_MISSING_COLOCATED',
122
- severity: meta.severity,
123
- pillar: meta.pillar,
124
- directive: meta.directive
125
- });
126
- };
127
-
128
- export const checkExtendedTextPatterns = (content, lines, relativePath, filePath, violations) => {
129
- checkMoleculeCoLocatedTest(filePath, relativePath, violations);
130
-
131
- const isTestFile = /\.(test|spec)\.[jt]sx?$/.test(filePath);
132
- const ext = path.extname(filePath);
133
- const isTemplateFile = isTemplateExtension(ext);
134
-
135
- lines.forEach((lineText, idx) => {
136
- const lineNum = idx + 1;
137
- const trimmed = lineText.trim();
138
- const isComment = isCommentLine(trimmed);
139
-
140
- // Pillar 9: Hardcoded Secret Detection
141
- const hasEnvReference = trimmed.includes('process.env') || trimmed.includes('import.meta.env');
142
- const canScanSecrets = !isComment && !hasEnvReference;
143
-
144
- if (canScanSecrets) {
145
- for (const pat of SECRET_PATTERNS) {
146
- const matchesSecretPattern = pat.test(lineText);
147
- const hasPlaceholder = lineText.includes('dummy') || lineText.includes('placeholder');
148
- const isHardcodedSecret = matchesSecretPattern && !hasPlaceholder;
149
-
150
- if (isHardcodedSecret) {
151
- const meta = RULE_REGISTRY.SECURITY_HARDCODED_SECRET;
152
- violations.push({
153
- filePath: relativePath,
154
- line: lineNum,
155
- column: 1,
156
- hazard: 'High-entropy API key or secret token detected in source code',
157
- rule: 'SECURITY_HARDCODED_SECRET',
158
- severity: meta.severity,
159
- pillar: meta.pillar,
160
- directive: meta.directive
161
- });
162
- break;
163
- }
164
- }
165
- }
166
-
167
- // Pillar 9: Unsanitized v-html injection (Vue / HTML templates)
168
- const hasVHtml = isTemplateFile && /\bv-html\s*=\s*["']/.test(lineText);
169
- if (hasVHtml) {
170
- const hasSanitizer = /(?:sanitize|DOMPurify|escapeHtml|filterXSS)/i.test(lineText);
171
- const isUnsanitizedVHtml = !hasSanitizer;
172
-
173
- if (isUnsanitizedVHtml) {
174
- const meta = RULE_REGISTRY.SECURITY_RAW_HTML_INJECTION;
175
- violations.push({
176
- filePath: relativePath,
177
- line: lineNum,
178
- column: 1,
179
- hazard: 'Unsanitized HTML injection via v-html detected without DOMPurify',
180
- rule: 'SECURITY_RAW_HTML_INJECTION',
181
- severity: meta.severity,
182
- pillar: meta.pillar,
183
- directive: meta.directive
184
- });
185
- }
186
- }
187
-
188
- // Pillar 9: Reverse Tabnabbing in HTML / Vue templates
189
- const hasTargetBlank = isTemplateFile && TABNABBING_TEMPLATE_PATTERN.test(lineText);
190
- if (hasTargetBlank) {
191
- const hasRelProtection = /\brel\s*=\s*["'][^"']*(?:noopener|noreferrer)[^"']*["']/i.test(lineText);
192
- if (!hasRelProtection) {
193
- const meta = RULE_REGISTRY.SECURITY_REVERSE_TABNABBING;
194
- violations.push({
195
- filePath: relativePath,
196
- line: lineNum,
197
- column: 1,
198
- hazard: 'External link with target="_blank" missing rel="noopener noreferrer"',
199
- rule: 'SECURITY_REVERSE_TABNABBING',
200
- severity: meta.severity,
201
- pillar: meta.pillar,
202
- directive: meta.directive
203
- });
204
- }
205
- }
206
-
207
- // Pillar 9: javascript: pseudo-protocol in HTML / Vue templates
208
- const hasJsUrl = isTemplateFile && JAVASCRIPT_URL_TEMPLATE_PATTERN.test(lineText);
209
- if (hasJsUrl) {
210
- const meta = RULE_REGISTRY.SECURITY_JAVASCRIPT_URL;
211
- violations.push({
212
- filePath: relativePath,
213
- line: lineNum,
214
- column: 1,
215
- hazard: 'javascript: pseudo-protocol detected in template attribute',
216
- rule: 'SECURITY_JAVASCRIPT_URL',
217
- severity: meta.severity,
218
- pillar: meta.pillar,
219
- directive: meta.directive
220
- });
221
- }
222
-
223
- // Pillar 8: Non-semantic clickable containers in templates (@click on div/span)
224
- const hasClickableContainer = isTemplateFile && CLICKABLE_CONTAINER_PATTERN.test(lineText);
225
- if (hasClickableContainer) {
226
- const meta = RULE_REGISTRY.A11Y_CLICKABLE_NON_SEMANTIC;
227
- violations.push({
228
- filePath: relativePath,
229
- line: lineNum,
230
- column: 1,
231
- hazard: 'Clickable generic container detected without native button/link semantics',
232
- rule: 'A11Y_CLICKABLE_NON_SEMANTIC',
233
- severity: meta.severity,
234
- pillar: meta.pillar,
235
- directive: meta.directive
236
- });
237
- }
238
-
239
- // Pillar 8: Missing alt attribute on img tags
240
- const hasMissingAltImg = isTemplateFile && IMG_WITHOUT_ALT_PATTERN.test(lineText);
241
- if (hasMissingAltImg) {
242
- const meta = RULE_REGISTRY.A11Y_IMAGE_MISSING_ALT;
243
- violations.push({
244
- filePath: relativePath,
245
- line: lineNum,
246
- column: 1,
247
- hazard: 'Image element missing alt text attribute',
248
- rule: 'A11Y_IMAGE_MISSING_ALT',
249
- severity: meta.severity,
250
- pillar: meta.pillar,
251
- directive: meta.directive
252
- });
253
- }
254
-
255
- // Pillar 10: Fake green tests (only in spec/test files)
256
- const canCheckFakeGreen = isTestFile && !isComment;
257
- if (canCheckFakeGreen) {
258
- for (const pat of FAKE_GREEN_PATTERNS) {
259
- const isFakeGreenMatch = pat.test(lineText);
260
- if (isFakeGreenMatch) {
261
- const meta = RULE_REGISTRY.TEST_FAKE_GREEN;
262
- violations.push({
263
- filePath: relativePath,
264
- line: lineNum,
265
- column: 1,
266
- hazard: 'Fake green test assertion detected (asserting literal against itself)',
267
- rule: 'TEST_FAKE_GREEN',
268
- severity: meta.severity,
269
- pillar: meta.pillar,
270
- directive: meta.directive
271
- });
272
- break;
273
- }
274
- }
275
- }
276
- });
277
- };
278
-
279
69
  export const createExtendedVisitors = ({ relativePath, violations }) => {
280
70
  return {
281
71
  JSXOpeningElement(astPath) {
package/cli/audit.js CHANGED
@@ -93,6 +93,7 @@ const isSourceFile = (name) => {
93
93
  };
94
94
 
95
95
  export const auditFile = (filePath, relativePath) => {
96
+ if (!isSourceFile(path.basename(filePath))) return [];
96
97
  const content = fs.readFileSync(filePath, 'utf-8');
97
98
  return auditCode(content, filePath, relativePath);
98
99
  };
@@ -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 resolvePackageManager = (cwd) => {
17
- const hasPnpmLock = Boolean(findFileUpward('pnpm-lock.yaml', cwd));
18
- const hasYarnLock = Boolean(findFileUpward('yarn.lock', cwd));
19
- const hasBunLock = Boolean(findFileUpward('bun.lockb', cwd) || findFileUpward('bun.lock', cwd));
20
-
21
- if (hasPnpmLock) return 'pnpm';
22
- if (hasYarnLock) return 'yarn';
23
- if (hasBunLock) return 'bun';
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 hasBuildScript = Boolean(scripts.build);
46
- const hasBuildPublicScript = Boolean(scripts['build:public']);
47
- const hasBuildProdScript = Boolean(scripts['build:prod']);
48
-
49
- if (hasBuildScript) {
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, process.cwd());
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, process.cwd(), { raw: isRaw });
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
+ };