@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.
- package/cli/audit/ast-visitors.js +243 -0
- package/cli/audit/discussion-store.js +39 -0
- package/cli/audit/history.d.ts +63 -0
- package/cli/audit/history.js +352 -0
- package/cli/audit/metrics.js +179 -0
- package/cli/audit/prompts.js +174 -0
- package/cli/audit/reporter-grades.js +259 -0
- package/cli/audit/reporter-markdown.js +136 -0
- package/cli/audit/reporter-utils.js +107 -0
- package/cli/audit/reporter.js +459 -0
- package/cli/audit/rules-helpers.js +109 -0
- package/cli/audit/rules-registry.js +97 -0
- package/cli/audit/rules.js +123 -0
- package/cli/audit/social-constants.js +4 -0
- package/cli/audit/social-gh.js +126 -0
- package/cli/audit/social-git.js +214 -0
- package/cli/audit/social-http.js +222 -0
- package/cli/audit/social-publisher.js +193 -0
- package/cli/audit/social.d.ts +66 -0
- package/cli/audit/social.js +254 -0
- package/cli/audit/types.d.ts +131 -0
- package/cli/audit.d.ts +3 -24
- package/cli/audit.js +195 -210
- package/cli/help.d.ts +1 -0
- package/cli/help.js +136 -0
- package/cli/index.js +128 -566
- package/cli/installer-templates.js +80 -0
- package/cli/installer.d.ts +20 -0
- package/cli/installer.js +56 -0
- package/cli/license.js +320 -0
- package/cli/navigator-conversion.js +100 -0
- package/cli/navigator-grades.js +70 -0
- package/cli/navigator-paged.js +86 -0
- package/cli/navigator-share.js +129 -0
- package/cli/navigator.d.ts +24 -0
- package/cli/navigator.js +400 -0
- package/cli/scaffold.js +148 -0
- package/cli/terminal.js +126 -0
- package/docs/CHANGELOG.md +73 -0
- 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
|
+
};
|