chorus-cli 0.4.0
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/index.js +1184 -0
- package/package.json +29 -0
- package/providers/azuredevops.js +202 -0
- package/providers/github.js +144 -0
- package/providers/index.js +51 -0
- package/scripts/postinstall.js +125 -0
- package/tools/coder.py +970 -0
- package/tools/mapper.py +465 -0
- package/tools/qa.py +528 -0
- package/tools/requirements.txt +3 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chorus-cli",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Automated ticket resolution with AI, Teams, and Slack integration",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"chorus": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"providers/",
|
|
12
|
+
"tools/",
|
|
13
|
+
"scripts/postinstall.js"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"postinstall": "node scripts/postinstall.js",
|
|
17
|
+
"setup": "node index.js setup",
|
|
18
|
+
"start": "node index.js run"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@anthropic-ai/sdk": "^0.73.0",
|
|
22
|
+
"@octokit/rest": "^20.0.2",
|
|
23
|
+
"dotenv": "^17.2.4",
|
|
24
|
+
"playwright": "^1.40.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { exec } = require('child_process');
|
|
4
|
+
const util = require('util');
|
|
5
|
+
const fs = require('fs').promises;
|
|
6
|
+
const execPromise = util.promisify(exec);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize an Azure DevOps work item into the shared issue shape.
|
|
10
|
+
*/
|
|
11
|
+
function normalize(workItem) {
|
|
12
|
+
const fields = workItem.fields || {};
|
|
13
|
+
return {
|
|
14
|
+
number: workItem.id,
|
|
15
|
+
title: fields['System.Title'] || '(untitled)',
|
|
16
|
+
body: fields['System.Description'] || '',
|
|
17
|
+
labels: (fields['System.Tags'] || '')
|
|
18
|
+
.split(';')
|
|
19
|
+
.map(t => t.trim())
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map(name => ({ name })),
|
|
22
|
+
user: {
|
|
23
|
+
login: fields['System.CreatedBy']?.displayName
|
|
24
|
+
|| fields['System.CreatedBy']?.uniqueName
|
|
25
|
+
|| 'unknown',
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createAzureDevOpsProvider(config) {
|
|
31
|
+
const { org, project, repo, pat } = config;
|
|
32
|
+
|
|
33
|
+
const baseUrl = `https://dev.azure.com/${org}`;
|
|
34
|
+
const apiVersion = '7.1';
|
|
35
|
+
|
|
36
|
+
function authHeaders() {
|
|
37
|
+
const encoded = Buffer.from(`:${pat}`).toString('base64');
|
|
38
|
+
return {
|
|
39
|
+
Authorization: `Basic ${encoded}`,
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function adoFetch(url, options = {}) {
|
|
45
|
+
const res = await fetch(url, {
|
|
46
|
+
...options,
|
|
47
|
+
headers: { ...authHeaders(), ...options.headers },
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const text = await res.text();
|
|
51
|
+
throw new Error(`Azure DevOps API error (${res.status}): ${text}`);
|
|
52
|
+
}
|
|
53
|
+
return res.json();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function fetchLatestIssue() {
|
|
57
|
+
const wiql = {
|
|
58
|
+
query: `SELECT [System.Id] FROM WorkItems
|
|
59
|
+
WHERE [System.AssignedTo] = @me
|
|
60
|
+
AND [System.State] <> 'Closed'
|
|
61
|
+
AND [System.State] <> 'Removed'
|
|
62
|
+
ORDER BY [System.CreatedDate] DESC`,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const result = await adoFetch(
|
|
66
|
+
`${baseUrl}/${project}/_apis/wit/wiql?api-version=${apiVersion}`,
|
|
67
|
+
{ method: 'POST', body: JSON.stringify(wiql) }
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!result.workItems || result.workItems.length === 0) {
|
|
71
|
+
throw new Error('No open work items found assigned to you in Azure DevOps');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const topId = result.workItems[0].id;
|
|
75
|
+
return fetchIssueByNumber(topId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function fetchIssueByNumber(id) {
|
|
79
|
+
const data = await adoFetch(
|
|
80
|
+
`${baseUrl}/${project}/_apis/wit/workitems/${id}?$expand=all&api-version=${apiVersion}`
|
|
81
|
+
);
|
|
82
|
+
return normalize(data);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function getUserDisplayName(identifier) {
|
|
86
|
+
// ADO work items already include the display name, so just pass it through
|
|
87
|
+
return identifier;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseIssueArg(arg) {
|
|
91
|
+
if (!arg) return null;
|
|
92
|
+
|
|
93
|
+
// Full ADO URL: https://dev.azure.com/{org}/{project}/_workitems/edit/{id}
|
|
94
|
+
const urlMatch = arg.match(
|
|
95
|
+
/dev\.azure\.com\/([^/]+)\/([^/]+)\/_workitems\/edit\/(\d+)/
|
|
96
|
+
);
|
|
97
|
+
if (urlMatch) {
|
|
98
|
+
return {
|
|
99
|
+
org: urlMatch[1],
|
|
100
|
+
project: urlMatch[2],
|
|
101
|
+
number: parseInt(urlMatch[3], 10),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Bare work item number
|
|
106
|
+
if (/^\d+$/.test(arg)) {
|
|
107
|
+
return { org, project, number: parseInt(arg, 10) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Invalid issue argument: "${arg}". Pass a number (456) or Azure DevOps URL.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getSolutionFiles(solution) {
|
|
116
|
+
return [...(solution.files_modified || []), ...(solution.files_created || [])];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function gitAddSolutionFiles(solution) {
|
|
120
|
+
const files = getSolutionFiles(solution);
|
|
121
|
+
if (files.length > 0) {
|
|
122
|
+
const quoted = files.map(f => `"${f}"`).join(' ');
|
|
123
|
+
await execPromise(`git add ${quoted}`);
|
|
124
|
+
} else {
|
|
125
|
+
await execPromise('git add .');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function createPR(issue, solution) {
|
|
130
|
+
const branchName = `fix/ab${issue.number}`;
|
|
131
|
+
const isCoder = config.codingTool === 'coder';
|
|
132
|
+
|
|
133
|
+
console.log('🌿 Creating PR...');
|
|
134
|
+
|
|
135
|
+
if (!isCoder) {
|
|
136
|
+
// Legacy: parse code blocks and write files
|
|
137
|
+
const fileRegex = /```(.+?)\n([\s\S]+?)```/g;
|
|
138
|
+
const raw = solution._raw || solution;
|
|
139
|
+
let match;
|
|
140
|
+
while ((match = fileRegex.exec(raw)) !== null) {
|
|
141
|
+
const [, filepath, content] = match;
|
|
142
|
+
const cleanPath = filepath.trim();
|
|
143
|
+
if (cleanPath.includes('/') || cleanPath.includes('.')) {
|
|
144
|
+
const dir = cleanPath.substring(0, cleanPath.lastIndexOf('/'));
|
|
145
|
+
if (dir) await execPromise(`mkdir -p ${dir}`).catch(() => {});
|
|
146
|
+
await fs.writeFile(cleanPath, content.trim());
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Clean up stale branch
|
|
152
|
+
await execPromise(`git branch -D ${branchName}`).catch(() => {});
|
|
153
|
+
await execPromise(`git push origin --delete ${branchName}`).catch(() => {});
|
|
154
|
+
|
|
155
|
+
await execPromise(`git checkout -b ${branchName}`);
|
|
156
|
+
await gitAddSolutionFiles(solution);
|
|
157
|
+
|
|
158
|
+
const { stdout: diffCheck } = await execPromise(`git diff --cached --stat`);
|
|
159
|
+
if (!diffCheck.trim()) {
|
|
160
|
+
await execPromise(`git checkout main`);
|
|
161
|
+
await execPromise(`git branch -D ${branchName}`).catch(() => {});
|
|
162
|
+
throw new Error('No file changes to commit — coder did not produce any code');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// AB#123 syntax links the commit to the Azure DevOps work item
|
|
166
|
+
await execPromise(`git commit -m "Fix: ${issue.title} AB#${issue.number}"`);
|
|
167
|
+
await execPromise(`git push origin ${branchName}`);
|
|
168
|
+
|
|
169
|
+
// Create PR via Azure DevOps REST API
|
|
170
|
+
const summary = isCoder ? solution.summary : (solution._raw || solution);
|
|
171
|
+
const filesChanged = isCoder
|
|
172
|
+
? getSolutionFiles(solution).map(f => `- \`${f}\``).join('\n')
|
|
173
|
+
: '';
|
|
174
|
+
|
|
175
|
+
const prBody = {
|
|
176
|
+
sourceRefName: `refs/heads/${branchName}`,
|
|
177
|
+
targetRefName: 'refs/heads/main',
|
|
178
|
+
title: `Fix: ${issue.title}`,
|
|
179
|
+
description: `Resolves AB#${issue.number}\n\n## Summary\n${summary}${filesChanged ? `\n\n## Files Changed\n${filesChanged}` : ''}`,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const prResult = await adoFetch(
|
|
183
|
+
`${baseUrl}/${project}/_apis/git/repositories/${repo}/pullrequests?api-version=${apiVersion}`,
|
|
184
|
+
{ method: 'POST', body: JSON.stringify(prBody) }
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
console.log('✅ PR created:', prResult.url || `PR #${prResult.pullRequestId}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
name: 'azuredevops',
|
|
192
|
+
fetchLatestIssue,
|
|
193
|
+
fetchIssueByNumber,
|
|
194
|
+
getUserDisplayName,
|
|
195
|
+
parseIssueArg,
|
|
196
|
+
createPR,
|
|
197
|
+
getSolutionFiles,
|
|
198
|
+
gitAddSolutionFiles,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { createAzureDevOpsProvider };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Octokit } = require('@octokit/rest');
|
|
4
|
+
const { exec } = require('child_process');
|
|
5
|
+
const util = require('util');
|
|
6
|
+
const fs = require('fs').promises;
|
|
7
|
+
const execPromise = util.promisify(exec);
|
|
8
|
+
|
|
9
|
+
function createGitHubProvider(config) {
|
|
10
|
+
const { owner, repo, token, codingTool } = config;
|
|
11
|
+
|
|
12
|
+
function getOctokit() {
|
|
13
|
+
return new Octokit({ auth: token });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function fetchLatestIssue() {
|
|
17
|
+
const octokit = getOctokit();
|
|
18
|
+
const { data: issues } = await octokit.issues.listForRepo({
|
|
19
|
+
owner,
|
|
20
|
+
repo,
|
|
21
|
+
state: 'open',
|
|
22
|
+
assignee: 'james-baloyi',
|
|
23
|
+
sort: 'created',
|
|
24
|
+
direction: 'desc',
|
|
25
|
+
per_page: 10,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (issues.length === 0) {
|
|
29
|
+
throw new Error(`No open issues found assigned to james-baloyi in ${owner}/${repo}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return issues[0];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchIssueByNumber(number, issueOwner, issueRepo) {
|
|
36
|
+
const octokit = getOctokit();
|
|
37
|
+
const { data: issue } = await octokit.issues.get({
|
|
38
|
+
owner: issueOwner || owner,
|
|
39
|
+
repo: issueRepo || repo,
|
|
40
|
+
issue_number: number,
|
|
41
|
+
});
|
|
42
|
+
return issue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function getUserDisplayName(username) {
|
|
46
|
+
const octokit = getOctokit();
|
|
47
|
+
const { data: user } = await octokit.users.getByUsername({ username });
|
|
48
|
+
return user;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseIssueArg(arg) {
|
|
52
|
+
if (!arg) return null;
|
|
53
|
+
|
|
54
|
+
// Full GitHub URL: https://github.com/owner/repo/issues/123
|
|
55
|
+
const urlMatch = arg.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
|
|
56
|
+
if (urlMatch) {
|
|
57
|
+
return { owner: urlMatch[1], repo: urlMatch[2], number: parseInt(urlMatch[3], 10) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Bare issue number
|
|
61
|
+
if (/^\d+$/.test(arg)) {
|
|
62
|
+
return { owner, repo, number: parseInt(arg, 10) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw new Error(`Invalid issue argument: "${arg}". Pass a number (4464) or GitHub URL.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getSolutionFiles(solution) {
|
|
69
|
+
return [...(solution.files_modified || []), ...(solution.files_created || [])];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function gitAddSolutionFiles(solution) {
|
|
73
|
+
const files = getSolutionFiles(solution);
|
|
74
|
+
if (files.length > 0) {
|
|
75
|
+
const quoted = files.map(f => `"${f}"`).join(' ');
|
|
76
|
+
await execPromise(`git add ${quoted}`);
|
|
77
|
+
} else {
|
|
78
|
+
await execPromise('git add .');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function createPR(issue, solution) {
|
|
83
|
+
const branchName = `fix/issue-${issue.number}`;
|
|
84
|
+
const isCoder = codingTool === 'coder';
|
|
85
|
+
|
|
86
|
+
console.log('🌿 Creating PR...');
|
|
87
|
+
|
|
88
|
+
if (!isCoder) {
|
|
89
|
+
await writeSolutionToFiles(solution._raw || solution);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Clean up stale branch from previous runs
|
|
93
|
+
await execPromise(`git branch -D ${branchName}`).catch(() => {});
|
|
94
|
+
await execPromise(`git push origin --delete ${branchName}`).catch(() => {});
|
|
95
|
+
|
|
96
|
+
await execPromise(`git checkout -b ${branchName}`);
|
|
97
|
+
await gitAddSolutionFiles(solution);
|
|
98
|
+
|
|
99
|
+
const { stdout: diffCheck } = await execPromise(`git diff --cached --stat`);
|
|
100
|
+
if (!diffCheck.trim()) {
|
|
101
|
+
await execPromise(`git checkout main`);
|
|
102
|
+
await execPromise(`git branch -D ${branchName}`).catch(() => {});
|
|
103
|
+
throw new Error('No file changes to commit — coder did not produce any code');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await execPromise(`git commit -m "Fix: ${issue.title} (resolves #${issue.number})"`);
|
|
107
|
+
await execPromise(`git push origin ${branchName}`);
|
|
108
|
+
|
|
109
|
+
const summary = isCoder ? solution.summary : (solution._raw || solution);
|
|
110
|
+
const filesChanged = isCoder
|
|
111
|
+
? getSolutionFiles(solution).map(f => `- \`${f}\``).join('\n')
|
|
112
|
+
: '';
|
|
113
|
+
|
|
114
|
+
const prBody = `Resolves #${issue.number}
|
|
115
|
+
|
|
116
|
+
## Summary
|
|
117
|
+
${summary}
|
|
118
|
+
${filesChanged ? `\n## Files Changed\n${filesChanged}` : ''}`;
|
|
119
|
+
|
|
120
|
+
const tmpBody = `/tmp/pr-body-${Date.now()}.md`;
|
|
121
|
+
await fs.writeFile(tmpBody, prBody);
|
|
122
|
+
|
|
123
|
+
const escapedTitle = issue.title.replace(/"/g, '\\"');
|
|
124
|
+
const { stdout } = await execPromise(
|
|
125
|
+
`gh pr create --title "Fix: ${escapedTitle}" --body-file "${tmpBody}" --base main`
|
|
126
|
+
);
|
|
127
|
+
await fs.unlink(tmpBody).catch(() => {});
|
|
128
|
+
|
|
129
|
+
console.log('✅ PR created:', stdout);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
name: 'github',
|
|
134
|
+
fetchLatestIssue,
|
|
135
|
+
fetchIssueByNumber,
|
|
136
|
+
getUserDisplayName,
|
|
137
|
+
parseIssueArg,
|
|
138
|
+
createPR,
|
|
139
|
+
getSolutionFiles,
|
|
140
|
+
gitAddSolutionFiles,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { createGitHubProvider };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createGitHubProvider } = require('./github');
|
|
4
|
+
const { createAzureDevOpsProvider } = require('./azuredevops');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect provider from an issue argument URL.
|
|
8
|
+
* Returns 'github', 'azuredevops', or null if undetectable.
|
|
9
|
+
*/
|
|
10
|
+
function detectProviderFromArg(issueArg) {
|
|
11
|
+
if (!issueArg || typeof issueArg !== 'string') return null;
|
|
12
|
+
if (issueArg.includes('github.com')) return 'github';
|
|
13
|
+
if (issueArg.includes('dev.azure.com')) return 'azuredevops';
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a provider instance.
|
|
19
|
+
*
|
|
20
|
+
* Resolution order:
|
|
21
|
+
* 1. Explicit PROVIDER env var
|
|
22
|
+
* 2. URL detection from issueArg
|
|
23
|
+
* 3. Default to 'github'
|
|
24
|
+
*/
|
|
25
|
+
function createProvider(config, issueArg) {
|
|
26
|
+
const explicit = process.env.PROVIDER;
|
|
27
|
+
const detected = detectProviderFromArg(issueArg);
|
|
28
|
+
const name = (explicit || detected || 'github').toLowerCase();
|
|
29
|
+
|
|
30
|
+
if (name === 'azuredevops' || name === 'azure' || name === 'ado') {
|
|
31
|
+
if (!config.azuredevops.org || !config.azuredevops.pat) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'Azure DevOps provider requires AZDO_ORG, AZDO_PROJECT, AZDO_REPO, and AZDO_PAT environment variables. Run "chorus setup" to configure.'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return createAzureDevOpsProvider({
|
|
37
|
+
...config.azuredevops,
|
|
38
|
+
codingTool: config.ai.codingTool,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Default: GitHub
|
|
43
|
+
return createGitHubProvider({
|
|
44
|
+
owner: config.github.owner,
|
|
45
|
+
repo: config.github.repo,
|
|
46
|
+
token: config.github.token,
|
|
47
|
+
codingTool: config.ai.codingTool,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { createProvider, detectProviderFromArg };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { execFileSync, execSync } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
10
|
+
const VENV = path.join(os.homedir(), '.config', 'chorus', '.venv');
|
|
11
|
+
const REQUIREMENTS = path.join(ROOT, 'tools', 'requirements.txt');
|
|
12
|
+
|
|
13
|
+
// Skip in CI or when explicitly opted out
|
|
14
|
+
if (process.env.CHORUS_SKIP_POSTINSTALL === '1') {
|
|
15
|
+
console.log('CHORUS_SKIP_POSTINSTALL=1 — skipping Python setup');
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function run(cmd, args, opts) {
|
|
24
|
+
try {
|
|
25
|
+
return execFileSync(cmd, args, { stdio: 'inherit', ...opts });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error(` Command failed: ${cmd} ${args.join(' ')}`);
|
|
28
|
+
if (e.message) console.error(` ${e.message}`);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function findPython() {
|
|
34
|
+
for (const candidate of ['python3', 'python']) {
|
|
35
|
+
try {
|
|
36
|
+
const version = execFileSync(candidate, ['--version'], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
39
|
+
}).trim(); // e.g. "Python 3.11.4"
|
|
40
|
+
|
|
41
|
+
const match = version.match(/Python (\d+)\.(\d+)/);
|
|
42
|
+
if (match) {
|
|
43
|
+
const major = parseInt(match[1], 10);
|
|
44
|
+
const minor = parseInt(match[2], 10);
|
|
45
|
+
if (major === 3 && minor >= 8) {
|
|
46
|
+
console.log(`Found ${version} (${candidate})`);
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// candidate not found, try next
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Main
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
console.log('\nChorus postinstall — setting up Python environment\n');
|
|
62
|
+
|
|
63
|
+
const python = findPython();
|
|
64
|
+
|
|
65
|
+
if (!python) {
|
|
66
|
+
console.error(
|
|
67
|
+
'⚠ Python 3.8+ not found.\n' +
|
|
68
|
+
' Chorus needs Python 3 to run its AI tools (coder, qa, mapper).\n' +
|
|
69
|
+
' Install Python 3 and re-run: npm rebuild chorus\n'
|
|
70
|
+
);
|
|
71
|
+
// Exit 0 so npm install still succeeds
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 1. Create venv (skip if it already exists and looks valid)
|
|
76
|
+
const venvPython = process.platform === 'win32'
|
|
77
|
+
? path.join(VENV, 'Scripts', 'python.exe')
|
|
78
|
+
: path.join(VENV, 'bin', 'python');
|
|
79
|
+
|
|
80
|
+
if (!fs.existsSync(venvPython)) {
|
|
81
|
+
console.log('Creating virtual environment...');
|
|
82
|
+
if (run(python, ['-m', 'venv', VENV]) === null) {
|
|
83
|
+
console.error('⚠ Failed to create virtual environment. Install the venv module and re-run: npm rebuild chorus');
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
console.log('Virtual environment already exists');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Install Python dependencies using python -m pip (more reliable than pip binary)
|
|
91
|
+
console.log('Installing Python dependencies...');
|
|
92
|
+
run(venvPython, ['-m', 'pip', 'install', '-r', REQUIREMENTS]);
|
|
93
|
+
|
|
94
|
+
// 3. Verify critical dependency installed
|
|
95
|
+
try {
|
|
96
|
+
execFileSync(venvPython, ['-c', 'import anthropic'], { stdio: 'ignore' });
|
|
97
|
+
console.log(' Dependencies installed ✓');
|
|
98
|
+
} catch {
|
|
99
|
+
console.error(
|
|
100
|
+
'⚠ "anthropic" module is missing after pip install.\n' +
|
|
101
|
+
' Run manually: ' + venvPython + ' -m pip install -r ' + REQUIREMENTS
|
|
102
|
+
);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. Verify slack_sdk installed
|
|
107
|
+
try {
|
|
108
|
+
execFileSync(venvPython, ['-c', 'import slack_sdk'], { stdio: 'ignore' });
|
|
109
|
+
console.log(' slack_sdk installed ✓');
|
|
110
|
+
} catch {
|
|
111
|
+
console.warn(
|
|
112
|
+
'⚠ "slack_sdk" module is missing after pip install.\n' +
|
|
113
|
+
' Slack messenger will not work. Run manually:\n' +
|
|
114
|
+
' ' + venvPython + ' -m pip install slack_sdk>=3.27.0'
|
|
115
|
+
);
|
|
116
|
+
// Non-blocking — Slack is optional
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 5. Install Playwright Firefox for the Python side
|
|
120
|
+
console.log('Installing Playwright Firefox...');
|
|
121
|
+
if (run(venvPython, ['-m', 'playwright', 'install', 'firefox']) === null) {
|
|
122
|
+
console.error('⚠ Playwright Firefox install failed. You can run it manually later:\n ' + venvPython + ' -m playwright install firefox');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log('\n✅ Chorus Python environment ready\n');
|