@chemx/starter-kit 26.9.9-786 → 26.9.11-362

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 (40) hide show
  1. package/cli/audit/ast-visitors.js +243 -0
  2. package/cli/audit/discussion-store.js +39 -0
  3. package/cli/audit/history.d.ts +63 -0
  4. package/cli/audit/history.js +352 -0
  5. package/cli/audit/metrics.js +179 -0
  6. package/cli/audit/prompts.js +174 -0
  7. package/cli/audit/reporter-grades.js +259 -0
  8. package/cli/audit/reporter-markdown.js +136 -0
  9. package/cli/audit/reporter-utils.js +107 -0
  10. package/cli/audit/reporter.js +459 -0
  11. package/cli/audit/rules-helpers.js +109 -0
  12. package/cli/audit/rules-registry.js +97 -0
  13. package/cli/audit/rules.js +123 -0
  14. package/cli/audit/social-constants.js +4 -0
  15. package/cli/audit/social-gh.js +126 -0
  16. package/cli/audit/social-git.js +214 -0
  17. package/cli/audit/social-http.js +222 -0
  18. package/cli/audit/social-publisher.js +193 -0
  19. package/cli/audit/social.d.ts +66 -0
  20. package/cli/audit/social.js +254 -0
  21. package/cli/audit/types.d.ts +131 -0
  22. package/cli/audit.d.ts +3 -24
  23. package/cli/audit.js +195 -210
  24. package/cli/help.d.ts +1 -0
  25. package/cli/help.js +136 -0
  26. package/cli/index.js +128 -566
  27. package/cli/installer-templates.js +80 -0
  28. package/cli/installer.d.ts +20 -0
  29. package/cli/installer.js +56 -0
  30. package/cli/license.js +320 -0
  31. package/cli/navigator-conversion.js +100 -0
  32. package/cli/navigator-grades.js +70 -0
  33. package/cli/navigator-paged.js +86 -0
  34. package/cli/navigator-share.js +129 -0
  35. package/cli/navigator.d.ts +24 -0
  36. package/cli/navigator.js +400 -0
  37. package/cli/scaffold.js +148 -0
  38. package/cli/terminal.js +126 -0
  39. package/docs/CHANGELOG.md +73 -0
  40. package/package.json +11 -10
@@ -0,0 +1,123 @@
1
+ import { parse } from '@babel/parser';
2
+ import traverse from '@babel/traverse';
3
+ import path from 'node:path';
4
+ import { PILLARS, RULE_REGISTRY } from './rules-registry.js';
5
+ import {
6
+ extractParseableCode,
7
+ checkTypographyEmDash,
8
+ checkMockDataPatterns,
9
+ resolveMonolithTier,
10
+ resolveMoleculeTier
11
+ } from './rules-helpers.js';
12
+ import { createAstVisitors } from './ast-visitors.js';
13
+
14
+ export { PILLARS, RULE_REGISTRY };
15
+
16
+ export const auditCode = (content, filePath, relativePath) => {
17
+ const violations = [];
18
+ const lines = content.split('\n');
19
+ const lineCount = lines.length;
20
+ const ext = path.extname(filePath);
21
+ const baseName = path.basename(filePath);
22
+
23
+ const isMolecule = relativePath.includes('molecules') || relativePath.includes('/m-') || baseName.startsWith('m-');
24
+ const isView = relativePath.includes('/views/') || relativePath.includes('/pages/') || /View\.[tj]sx?$/.test(baseName);
25
+ const isRootTypeFile = baseName === 'types.ts' || baseName === 'global.d.ts';
26
+
27
+ // Pillar 1: Sliding Scale Monolith Detection
28
+ if (lineCount > 500) {
29
+ const tier = resolveMonolithTier(lineCount);
30
+ violations.push({
31
+ filePath: relativePath,
32
+ line: 1,
33
+ column: 1,
34
+ hazard: tier.hazard,
35
+ rule: 'LINE_BUDGET_FILE',
36
+ severity: tier.severity,
37
+ pillar: PILLARS.PILLAR_1,
38
+ directive: tier.directive
39
+ });
40
+ } else if (isMolecule && lineCount > 100) {
41
+ const tier = resolveMoleculeTier(lineCount);
42
+ violations.push({
43
+ filePath: relativePath,
44
+ line: 1,
45
+ column: 1,
46
+ hazard: tier.hazard,
47
+ rule: 'LINE_BUDGET_MOLECULE',
48
+ severity: tier.severity,
49
+ pillar: PILLARS.PILLAR_1,
50
+ directive: tier.directive
51
+ });
52
+ } else if (isView && lineCount > 200) {
53
+ const isSevere = lineCount >= 500;
54
+ const severity = isSevere ? 'CRITICAL' : 'MEDIUM';
55
+ const hazardLabel = isSevere
56
+ ? `Extreme view monolith (${lineCount} >= 500 lines)`
57
+ : `View template budget warning (${lineCount} > 200 lines)`;
58
+
59
+ violations.push({
60
+ filePath: relativePath,
61
+ line: 1,
62
+ column: 1,
63
+ hazard: hazardLabel,
64
+ rule: 'VIEW_MONOLITH',
65
+ severity,
66
+ pillar: PILLARS.PILLAR_1,
67
+ directive: 'Refactor top-level view to a 10 to 20 line Table of Contents'
68
+ });
69
+ }
70
+
71
+ // Pillar 4: Type Monolith
72
+ if (isRootTypeFile && lineCount > 150) {
73
+ const meta = RULE_REGISTRY.TYPE_MONOLITH;
74
+ violations.push({
75
+ filePath: relativePath,
76
+ line: 1,
77
+ column: 1,
78
+ hazard: `Root type file monolith detected (${lineCount} > 150 lines)`,
79
+ rule: 'TYPE_MONOLITH',
80
+ severity: meta.severity,
81
+ pillar: meta.pillar,
82
+ directive: meta.directive
83
+ });
84
+ }
85
+
86
+ // Pillar 7: Typography & Mock Data Fast Checks
87
+ checkTypographyEmDash(content, lines, relativePath, violations);
88
+ checkMockDataPatterns(content, lines, relativePath, violations);
89
+
90
+ const codeToParse = extractParseableCode(content, ext);
91
+ if (!codeToParse.trim()) {
92
+ return violations;
93
+ }
94
+
95
+ let ast;
96
+ try {
97
+ ast = parse(codeToParse, {
98
+ sourceType: 'module',
99
+ plugins: ['typescript', 'jsx']
100
+ });
101
+ } catch (err) {
102
+ const errorMsg = err instanceof Error ? err.message : String(err);
103
+ const meta = RULE_REGISTRY.SYNTAX_PARSE_ERROR;
104
+ violations.push({
105
+ filePath: relativePath,
106
+ line: 1,
107
+ column: 1,
108
+ hazard: `Parse error: ${errorMsg}`,
109
+ rule: 'SYNTAX_PARSE_ERROR',
110
+ severity: meta.severity,
111
+ pillar: meta.pillar,
112
+ directive: meta.directive
113
+ });
114
+ return violations;
115
+ }
116
+
117
+ const traverseFn = traverse.default || traverse;
118
+ const visitors = createAstVisitors({ relativePath, violations });
119
+
120
+ traverseFn(ast, visitors);
121
+
122
+ return violations;
123
+ };
@@ -0,0 +1,4 @@
1
+ export const DISCUSSION_CATEGORY = 'npx chemx audit';
2
+ export const DISCUSSION_CATEGORY_SLUG = 'npx-chemx-audit';
3
+ export const ORG_DISCUSSIONS_URL = 'https://github.com/orgs/Chemical-X-Protocol/discussions';
4
+ export const DEFAULT_DISCUSSION_REPO = 'Chemical-X-Protocol/.github';
@@ -0,0 +1,126 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import {
3
+ DISCUSSION_CATEGORY,
4
+ DISCUSSION_CATEGORY_SLUG
5
+ } from './social-constants.js';
6
+
7
+ export const publishDiscussionViaGh = (repo, title, body, category = DISCUSSION_CATEGORY) => {
8
+ try {
9
+ let res = spawnSync(
10
+ 'gh',
11
+ ['discussion', 'create', '-R', repo, '--title', title, '--body', body, '--category', category],
12
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
13
+ );
14
+ if (res.status === 0) {
15
+ return { success: true, url: (res.stdout || '').trim(), error: null };
16
+ }
17
+
18
+ const errText = (res.stderr || '').trim();
19
+
20
+ if (errText.includes('category') && category !== DISCUSSION_CATEGORY_SLUG) {
21
+ res = spawnSync(
22
+ 'gh',
23
+ ['discussion', 'create', '-R', repo, '--title', title, '--body', body, '--category', DISCUSSION_CATEGORY_SLUG],
24
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
25
+ );
26
+ if (res.status === 0) {
27
+ return { success: true, url: (res.stdout || '').trim(), error: null };
28
+ }
29
+ }
30
+
31
+ if (errText.includes('category') && category !== 'Audits') {
32
+ res = spawnSync(
33
+ 'gh',
34
+ ['discussion', 'create', '-R', repo, '--title', title, '--body', body, '--category', 'Audits'],
35
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
36
+ );
37
+ if (res.status === 0) {
38
+ return { success: true, url: (res.stdout || '').trim(), error: null };
39
+ }
40
+ }
41
+
42
+ if (errText.includes('category')) {
43
+ res = spawnSync(
44
+ 'gh',
45
+ ['discussion', 'create', '-R', repo, '--title', title, '--body', body, '--category', 'General'],
46
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
47
+ );
48
+ if (res.status === 0) {
49
+ return { success: true, url: (res.stdout || '').trim(), error: null };
50
+ }
51
+ }
52
+
53
+ return { success: false, url: null, error: errText };
54
+ } catch (err) {
55
+ return { success: false, url: null, error: err.message };
56
+ }
57
+ };
58
+
59
+ export const viewDiscussionViaGh = (repo, discussionNumber) => {
60
+ try {
61
+ const res = spawnSync(
62
+ 'gh',
63
+ ['discussion', 'view', String(discussionNumber), '-R', repo, '--json', 'id,number,title,body,url'],
64
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
65
+ );
66
+ if (res.status === 0 && res.stdout) {
67
+ return JSON.parse(res.stdout);
68
+ }
69
+ return null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ };
74
+
75
+ export const postDiscussionCommentViaGh = (repo, discussionNumber, commentBody) => {
76
+ try {
77
+ const res = spawnSync(
78
+ 'gh',
79
+ ['discussion', 'comment', String(discussionNumber), '-R', repo, '--body', commentBody],
80
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
81
+ );
82
+ return res.status === 0;
83
+ } catch {
84
+ return false;
85
+ }
86
+ };
87
+
88
+ export const editDiscussionViaGh = (repo, discussionNumber, title, body) => {
89
+ try {
90
+ const res = spawnSync(
91
+ 'gh',
92
+ ['discussion', 'edit', String(discussionNumber), '-R', repo, '--title', title, '--body', body],
93
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
94
+ );
95
+ return res.status === 0;
96
+ } catch {
97
+ return false;
98
+ }
99
+ };
100
+
101
+ export const findExistingDiscussionViaGh = (repo, projectName) => {
102
+ try {
103
+ const res = spawnSync(
104
+ 'gh',
105
+ ['discussion', 'list', '-R', repo, '--limit', '25', '--json', 'id,number,title,body,url'],
106
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
107
+ );
108
+ if (res.status === 0 && res.stdout) {
109
+ const list = JSON.parse(res.stdout);
110
+ const targetLower = projectName.toLowerCase();
111
+ const match = list.find((d) => {
112
+ const titleLower = (d.title || '').toLowerCase();
113
+ return (
114
+ titleLower.includes(` ${targetLower} :`) ||
115
+ titleLower.includes(` ${targetLower} [`) ||
116
+ titleLower.includes(`: ${targetLower}`) ||
117
+ titleLower.includes(`${targetLower} audit`)
118
+ );
119
+ });
120
+ return match || null;
121
+ }
122
+ return null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ };
@@ -0,0 +1,214 @@
1
+ import fs from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+
5
+ export const parseGitRemoteUrl = (url) => {
6
+ if (!url || typeof url !== 'string') return null;
7
+ const clean = url.trim().replace(/^git\+/, '').replace(/\.git$/, '');
8
+
9
+ // Match HTTPS / SSH: https://github.com/owner/repo or ssh://git@github.com/owner/repo
10
+ const httpMatch = clean.match(/^(?:https?|ssh|git):\/\/[^/]+\/([^/]+)\/([^/]+)$/);
11
+ if (httpMatch) {
12
+ const owner = httpMatch[1];
13
+ const repo = httpMatch[2];
14
+ return {
15
+ owner,
16
+ repo,
17
+ nameWithOwner: `${owner}/${repo}`,
18
+ url: `https://github.com/${owner}/${repo}`
19
+ };
20
+ }
21
+
22
+ // Match SCP-like SSH: git@github.com:owner/repo
23
+ const scpMatch = clean.match(/^[\w\-]+@[^:]+:([^/]+)\/([^/]+)$/);
24
+ if (scpMatch) {
25
+ const owner = scpMatch[1];
26
+ const repo = scpMatch[2];
27
+ return {
28
+ owner,
29
+ repo,
30
+ nameWithOwner: `${owner}/${repo}`,
31
+ url: `https://github.com/${owner}/${repo}`
32
+ };
33
+ }
34
+
35
+ return null;
36
+ };
37
+
38
+ export const detectGitRepoInfo = (cwd = process.cwd()) => {
39
+ // 1. Try git remote get-url origin
40
+ try {
41
+ const res = spawnSync('git', ['remote', 'get-url', 'origin'], {
42
+ cwd,
43
+ encoding: 'utf-8',
44
+ stdio: ['ignore', 'pipe', 'ignore']
45
+ });
46
+ if (res.status === 0 && res.stdout) {
47
+ const parsed = parseGitRemoteUrl(res.stdout);
48
+ if (parsed) return parsed;
49
+ }
50
+ } catch {}
51
+
52
+ // 2. Try git config --get remote.origin.url
53
+ try {
54
+ const res = spawnSync('git', ['config', '--get', 'remote.origin.url'], {
55
+ cwd,
56
+ encoding: 'utf-8',
57
+ stdio: ['ignore', 'pipe', 'ignore']
58
+ });
59
+ if (res.status === 0 && res.stdout) {
60
+ const parsed = parseGitRemoteUrl(res.stdout);
61
+ if (parsed) return parsed;
62
+ }
63
+ } catch {}
64
+
65
+ // 3. Try any available remote if origin is not configured
66
+ try {
67
+ const remotesRes = spawnSync('git', ['remote'], {
68
+ cwd,
69
+ encoding: 'utf-8',
70
+ stdio: ['ignore', 'pipe', 'ignore']
71
+ });
72
+ if (remotesRes.status === 0 && remotesRes.stdout) {
73
+ const firstRemote = (remotesRes.stdout.trim().split(/\s+/)[0] || '').trim();
74
+ if (firstRemote) {
75
+ const urlRes = spawnSync('git', ['remote', 'get-url', firstRemote], {
76
+ cwd,
77
+ encoding: 'utf-8',
78
+ stdio: ['ignore', 'pipe', 'ignore']
79
+ });
80
+ if (urlRes.status === 0 && urlRes.stdout) {
81
+ const parsed = parseGitRemoteUrl(urlRes.stdout);
82
+ if (parsed) return parsed;
83
+ }
84
+ }
85
+ }
86
+ } catch {}
87
+
88
+ // 4. Try package.json repository or name field
89
+ try {
90
+ const pkgPath = path.resolve(cwd, 'package.json');
91
+ if (fs.existsSync(pkgPath)) {
92
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
93
+ const repoUrl = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
94
+ if (repoUrl) {
95
+ const parsed = parseGitRemoteUrl(repoUrl);
96
+ if (parsed) return parsed;
97
+ }
98
+ if (pkg.name) {
99
+ if (pkg.name.startsWith('@') && pkg.name.includes('/')) {
100
+ const [scope, pkgRepo] = pkg.name.slice(1).split('/');
101
+ return {
102
+ owner: scope,
103
+ repo: pkgRepo,
104
+ nameWithOwner: `${scope}/${pkgRepo}`,
105
+ url: `https://github.com/${scope}/${pkgRepo}`
106
+ };
107
+ }
108
+ return {
109
+ owner: '',
110
+ repo: pkg.name,
111
+ nameWithOwner: pkg.name,
112
+ url: ''
113
+ };
114
+ }
115
+ }
116
+ } catch {}
117
+
118
+ // 5. Try git rev-parse toplevel folder name
119
+ try {
120
+ const topRes = spawnSync('git', ['rev-parse', '--show-toplevel'], {
121
+ cwd,
122
+ encoding: 'utf-8',
123
+ stdio: ['ignore', 'pipe', 'ignore']
124
+ });
125
+ if (topRes.status === 0 && topRes.stdout) {
126
+ const topDir = topRes.stdout.trim();
127
+ const repoName = path.basename(topDir);
128
+ if (repoName) {
129
+ return {
130
+ owner: '',
131
+ repo: repoName,
132
+ nameWithOwner: repoName,
133
+ url: ''
134
+ };
135
+ }
136
+ }
137
+ } catch {}
138
+
139
+ const fallback = path.basename(cwd) || 'Codebase';
140
+ return {
141
+ owner: '',
142
+ repo: fallback,
143
+ nameWithOwner: fallback,
144
+ url: ''
145
+ };
146
+ };
147
+
148
+ export const detectGitHubUser = (preferredUser) => {
149
+ if (preferredUser && typeof preferredUser === 'string') {
150
+ const trimmed = preferredUser.trim();
151
+ if (trimmed && trimmed !== 'Architect') return trimmed;
152
+ }
153
+
154
+ // 1. Try gh api if authenticated with valid token
155
+ try {
156
+ const res = spawnSync('gh', ['api', 'user', '-q', '.login'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
157
+ if (res.status === 0) {
158
+ const user = (res.stdout || '').trim();
159
+ if (user && /^[a-zA-Z0-9_\-]+$/.test(user)) return user;
160
+ }
161
+ } catch {}
162
+
163
+ // 2. Try gh auth status account name (persists in config even if token is temporarily expired)
164
+ try {
165
+ const statusRes = spawnSync('gh', ['auth', 'status'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
166
+ const output = `${statusRes.stdout || ''} ${statusRes.stderr || ''}`.trim();
167
+ const match = output.match(/account\s+([a-zA-Z0-9_\-]+)/i) || output.match(/Logged in to [^\s]+ account ([a-zA-Z0-9_\-]+)/i);
168
+ if (match && match[1]) {
169
+ const user = match[1].trim();
170
+ if (user && user !== 'default') return user;
171
+ }
172
+ } catch {}
173
+
174
+ // 3. Try git config github.user
175
+ try {
176
+ const ghUser = spawnSync('git', ['config', 'github.user'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
177
+ const u = (ghUser.stdout || '').trim();
178
+ if (u && /^[a-zA-Z0-9_\-]+$/.test(u)) return u;
179
+ } catch {}
180
+
181
+ // 4. Try git config user.name
182
+ try {
183
+ const gitRes = spawnSync('git', ['config', 'user.name'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
184
+ const name = (gitRes.stdout || '').trim();
185
+ if (name && /^[a-zA-Z0-9_\-]+$/.test(name)) return name;
186
+ } catch {}
187
+
188
+ return 'Architect';
189
+ };
190
+
191
+ export const copyToClipboard = (text) => {
192
+ try {
193
+ if (process.platform === 'darwin') {
194
+ const res = spawnSync('pbcopy', [], { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
195
+ return res.status === 0;
196
+ }
197
+ if (process.platform === 'win32') {
198
+ const res = spawnSync('clip', [], { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
199
+ return res.status === 0;
200
+ }
201
+ // Linux: try wl-copy then xclip
202
+ const wl = spawnSync('which', ['wl-copy'], { stdio: 'ignore' });
203
+ if (wl.status === 0) {
204
+ const res = spawnSync('wl-copy', [], { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
205
+ if (res.status === 0) return true;
206
+ }
207
+ const xc = spawnSync('which', ['xclip'], { stdio: 'ignore' });
208
+ if (xc.status === 0) {
209
+ const res = spawnSync('xclip', ['-selection', 'clipboard'], { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
210
+ if (res.status === 0) return true;
211
+ }
212
+ } catch {}
213
+ return false;
214
+ };
@@ -0,0 +1,222 @@
1
+ import {
2
+ DISCUSSION_CATEGORY,
3
+ DISCUSSION_CATEGORY_SLUG
4
+ } from './social-constants.js';
5
+
6
+ export const findMatchedCategory = (categories, categoryName) => {
7
+ const exactName = categories.find((c) => c.name === categoryName);
8
+ if (exactName) return exactName;
9
+
10
+ const exactSlug = categories.find((c) => c.slug === categoryName);
11
+ if (exactSlug) return exactSlug;
12
+
13
+ const defaultSlug = categories.find((c) => c.slug === DISCUSSION_CATEGORY_SLUG);
14
+ if (defaultSlug) return defaultSlug;
15
+
16
+ const targetLower = categoryName.toLowerCase();
17
+ const lowerName = categories.find((c) => c.name.toLowerCase() === targetLower);
18
+ if (lowerName) return lowerName;
19
+
20
+ const lowerSlug = categories.find((c) => c.slug.toLowerCase() === targetLower);
21
+ if (lowerSlug) return lowerSlug;
22
+
23
+ const chemxName = categories.find((c) => c.name.toLowerCase().includes('chemx'));
24
+ if (chemxName) return chemxName;
25
+
26
+ const chemxSlug = categories.find((c) => c.slug.toLowerCase().includes('chemx'));
27
+ if (chemxSlug) return chemxSlug;
28
+
29
+ const auditName = categories.find((c) => c.name.toLowerCase().includes('audit'));
30
+ if (auditName) return auditName;
31
+
32
+ const auditSlug = categories.find((c) => c.slug.toLowerCase().includes('audit'));
33
+ if (auditSlug) return auditSlug;
34
+
35
+ const generalName = categories.find((c) => c.name.toLowerCase().includes('general'));
36
+ if (generalName) return generalName;
37
+
38
+ return categories[0];
39
+ };
40
+
41
+ export const publishDiscussionViaHttp = async (token, repo, title, body, categoryName = DISCUSSION_CATEGORY) => {
42
+ if (!token) return { success: false, url: null, id: null, number: null, error: 'No GitHub token provided' };
43
+
44
+ const [owner, name] = repo.split('/');
45
+ if (!owner || !name) return { success: false, url: null, id: null, number: null, error: `Invalid repository format: ${repo}` };
46
+
47
+ try {
48
+ const metaQuery = `
49
+ query($owner: String!, $name: String!) {
50
+ repository(owner: $owner, name: $name) {
51
+ id
52
+ discussionCategories(first: 25) {
53
+ nodes {
54
+ id
55
+ name
56
+ slug
57
+ emoji
58
+ }
59
+ }
60
+ }
61
+ }
62
+ `;
63
+
64
+ const metaRes = await fetch('https://api.github.com/graphql', {
65
+ method: 'POST',
66
+ headers: {
67
+ 'Authorization': `Bearer ${token}`,
68
+ 'Content-Type': 'application/json',
69
+ 'User-Agent': 'Chemical-X-Audit-CLI'
70
+ },
71
+ body: JSON.stringify({ query: metaQuery, variables: { owner, name } })
72
+ });
73
+
74
+ const metaJson = await metaRes.json();
75
+ if (metaJson.errors) {
76
+ return { success: false, url: null, id: null, number: null, error: metaJson.errors[0]?.message || 'GraphQL error fetching categories' };
77
+ }
78
+
79
+ const repositoryId = metaJson.data?.repository?.id;
80
+ const categories = metaJson.data?.repository?.discussionCategories?.nodes || [];
81
+
82
+ if (!repositoryId || categories.length === 0) {
83
+ return { success: false, url: null, id: null, number: null, error: `Repository ${repo} has no discussions enabled` };
84
+ }
85
+
86
+ const matchedCat = findMatchedCategory(categories, categoryName);
87
+
88
+ const mutation = `
89
+ mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
90
+ createDiscussion(input: { repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body }) {
91
+ discussion {
92
+ id
93
+ number
94
+ url
95
+ }
96
+ }
97
+ }
98
+ `;
99
+
100
+ const postRes = await fetch('https://api.github.com/graphql', {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Authorization': `Bearer ${token}`,
104
+ 'Content-Type': 'application/json',
105
+ 'User-Agent': 'Chemical-X-Audit-CLI'
106
+ },
107
+ body: JSON.stringify({
108
+ query: mutation,
109
+ variables: { repositoryId, categoryId: matchedCat.id, title, body }
110
+ })
111
+ });
112
+
113
+ const postJson = await postRes.json();
114
+ if (postJson.errors) {
115
+ return { success: false, url: null, id: null, number: null, error: postJson.errors[0]?.message || 'Failed creating discussion' };
116
+ }
117
+
118
+ const disc = postJson.data?.createDiscussion?.discussion;
119
+ const url = disc?.url;
120
+ return {
121
+ success: Boolean(url),
122
+ url: url || null,
123
+ id: disc?.id || null,
124
+ number: disc?.number || null,
125
+ error: null
126
+ };
127
+ } catch (err) {
128
+ return { success: false, url: null, id: null, number: null, error: err.message };
129
+ }
130
+ };
131
+
132
+ export const viewDiscussionViaHttp = async (token, repo, discussionNumber) => {
133
+ const [owner, name] = repo.split('/');
134
+ if (!owner || !name) return null;
135
+
136
+ try {
137
+ const query = `
138
+ query($owner: String!, $name: String!, $number: Int!) {
139
+ repository(owner: $owner, name: $name) {
140
+ discussion(number: $number) {
141
+ id
142
+ number
143
+ title
144
+ body
145
+ url
146
+ }
147
+ }
148
+ }
149
+ `;
150
+ const res = await fetch('https://api.github.com/graphql', {
151
+ method: 'POST',
152
+ headers: {
153
+ 'Authorization': `Bearer ${token}`,
154
+ 'Content-Type': 'application/json',
155
+ 'User-Agent': 'Chemical-X-Audit-CLI'
156
+ },
157
+ body: JSON.stringify({ query, variables: { owner, name, number: Number(discussionNumber) } })
158
+ });
159
+ const json = await res.json();
160
+ return json.data?.repository?.discussion || null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ };
165
+
166
+ export const postDiscussionCommentViaHttp = async (token, discussionId, commentBody) => {
167
+ try {
168
+ const mutation = `
169
+ mutation($discussionId: ID!, $body: String!) {
170
+ addDiscussionComment(input: { discussionId: $discussionId, body: $body }) {
171
+ comment {
172
+ id
173
+ url
174
+ }
175
+ }
176
+ }
177
+ `;
178
+ const res = await fetch('https://api.github.com/graphql', {
179
+ method: 'POST',
180
+ headers: {
181
+ 'Authorization': `Bearer ${token}`,
182
+ 'Content-Type': 'application/json',
183
+ 'User-Agent': 'Chemical-X-Audit-CLI'
184
+ },
185
+ body: JSON.stringify({ query: mutation, variables: { discussionId, body: commentBody } })
186
+ });
187
+ const json = await res.json();
188
+ return Boolean(json.data?.addDiscussionComment?.comment?.id);
189
+ } catch {
190
+ return false;
191
+ }
192
+ };
193
+
194
+ export const editDiscussionViaHttp = async (token, discussionId, title, body) => {
195
+ try {
196
+ const mutation = `
197
+ mutation($discussionId: ID!, $title: String!, $body: String!) {
198
+ updateDiscussion(input: { discussionId: $discussionId, title: $title, body: $body }) {
199
+ discussion {
200
+ id
201
+ number
202
+ url
203
+ title
204
+ }
205
+ }
206
+ }
207
+ `;
208
+ const res = await fetch('https://api.github.com/graphql', {
209
+ method: 'POST',
210
+ headers: {
211
+ 'Authorization': `Bearer ${token}`,
212
+ 'Content-Type': 'application/json',
213
+ 'User-Agent': 'Chemical-X-Audit-CLI'
214
+ },
215
+ body: JSON.stringify({ query: mutation, variables: { discussionId, title, body } })
216
+ });
217
+ const json = await res.json();
218
+ return Boolean(json.data?.updateDiscussion?.discussion?.id);
219
+ } catch {
220
+ return false;
221
+ }
222
+ };