@plosson/agentio 0.2.1 → 0.2.2
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/package.json +2 -1
- package/src/auth/github-oauth.ts +141 -0
- package/src/commands/config.ts +25 -0
- package/src/commands/github.ts +169 -0
- package/src/commands/status.ts +47 -7
- package/src/config/credentials.ts +9 -0
- package/src/index.ts +2 -0
- package/src/services/github/client.ts +112 -0
- package/src/types/config.ts +3 -1
- package/src/types/github.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plosson/agentio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "CLI for LLM agents to interact with communication and tracking services",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"commander": "^14.0.2",
|
|
50
50
|
"googleapis": "^169.0.0",
|
|
51
|
+
"libsodium-wrappers": "^0.8.1",
|
|
51
52
|
"rss-parser": "^3.13.0"
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createServer, type Server } from 'http';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import { GITHUB_OAUTH_CONFIG } from '../config/credentials';
|
|
4
|
+
|
|
5
|
+
const GITHUB_SCOPES = ['repo'];
|
|
6
|
+
|
|
7
|
+
const PORT_RANGE_START = 3000;
|
|
8
|
+
const PORT_RANGE_END = 3010;
|
|
9
|
+
|
|
10
|
+
async function findAvailablePort(): Promise<number> {
|
|
11
|
+
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
|
|
12
|
+
try {
|
|
13
|
+
await new Promise<void>((resolve, reject) => {
|
|
14
|
+
const server = createServer();
|
|
15
|
+
server.listen(port, () => {
|
|
16
|
+
server.close(() => resolve());
|
|
17
|
+
});
|
|
18
|
+
server.on('error', reject);
|
|
19
|
+
});
|
|
20
|
+
return port;
|
|
21
|
+
} catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`No available port found in range ${PORT_RANGE_START}-${PORT_RANGE_END}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GitHubOAuthResult {
|
|
29
|
+
accessToken: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function performGitHubOAuthFlow(): Promise<GitHubOAuthResult> {
|
|
33
|
+
const port = await findAvailablePort();
|
|
34
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
35
|
+
|
|
36
|
+
const authUrl = new URL('https://github.com/login/oauth/authorize');
|
|
37
|
+
authUrl.searchParams.set('client_id', GITHUB_OAUTH_CONFIG.clientId);
|
|
38
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
39
|
+
authUrl.searchParams.set('scope', GITHUB_SCOPES.join(' '));
|
|
40
|
+
authUrl.searchParams.set('state', Math.random().toString(36).substring(7));
|
|
41
|
+
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let server: Server;
|
|
44
|
+
|
|
45
|
+
const timeout = setTimeout(() => {
|
|
46
|
+
server?.close();
|
|
47
|
+
reject(new Error('OAuth flow timed out after 5 minutes'));
|
|
48
|
+
}, 5 * 60 * 1000);
|
|
49
|
+
|
|
50
|
+
server = createServer(async (req, res) => {
|
|
51
|
+
const url = new URL(req.url || '', `http://localhost:${port}`);
|
|
52
|
+
|
|
53
|
+
if (url.pathname !== '/callback') {
|
|
54
|
+
res.writeHead(404);
|
|
55
|
+
res.end('Not found');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const code = url.searchParams.get('code');
|
|
60
|
+
const error = url.searchParams.get('error');
|
|
61
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
62
|
+
|
|
63
|
+
if (error) {
|
|
64
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
65
|
+
res.end('<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>');
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
server.close();
|
|
68
|
+
reject(new Error(`GitHub OAuth error: ${error} - ${errorDescription || 'Unknown error'}`));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!code) {
|
|
73
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
74
|
+
res.end('<html><body><h1>Missing Authorization Code</h1><p>You can close this window.</p></body></html>');
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
server.close();
|
|
77
|
+
reject(new Error('Missing authorization code in OAuth callback'));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Exchange code for access token
|
|
83
|
+
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
Accept: 'application/json',
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
client_id: GITHUB_OAUTH_CONFIG.clientId,
|
|
91
|
+
client_secret: GITHUB_OAUTH_CONFIG.clientSecret,
|
|
92
|
+
code,
|
|
93
|
+
redirect_uri: redirectUri,
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const tokenData = await tokenResponse.json() as {
|
|
98
|
+
access_token?: string;
|
|
99
|
+
error?: string;
|
|
100
|
+
error_description?: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (tokenData.error || !tokenData.access_token) {
|
|
104
|
+
throw new Error(tokenData.error_description || tokenData.error || 'Failed to get access token');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
108
|
+
res.end('<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>');
|
|
109
|
+
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
server.close();
|
|
112
|
+
|
|
113
|
+
resolve({
|
|
114
|
+
accessToken: tokenData.access_token,
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
res.writeHead(500);
|
|
118
|
+
res.end('Failed to exchange authorization code');
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
server.close();
|
|
121
|
+
reject(err);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
server.listen(port, () => {
|
|
126
|
+
console.error(`\nOpening browser for GitHub authorization...`);
|
|
127
|
+
console.error(`If browser doesn't open, visit:\n${authUrl.toString()}\n`);
|
|
128
|
+
|
|
129
|
+
// Open browser
|
|
130
|
+
const open = process.platform === 'darwin' ? 'open' :
|
|
131
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
132
|
+
Bun.spawn([open, authUrl.toString()], { stdout: 'ignore', stderr: 'ignore' });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
server.on('error', (err) => {
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
server?.close();
|
|
138
|
+
reject(err);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
package/src/commands/config.ts
CHANGED
|
@@ -58,6 +58,31 @@ function decrypt(data: string, key: Buffer): string {
|
|
|
58
58
|
return decrypted.toString('utf-8');
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Generate encrypted config data for CI/CD environments.
|
|
63
|
+
* Returns the key and encrypted config that can be used as environment variables.
|
|
64
|
+
*/
|
|
65
|
+
export async function generateExportData(): Promise<{ key: string; config: string }> {
|
|
66
|
+
const encryptionKey = generateKey();
|
|
67
|
+
|
|
68
|
+
const configData = await loadConfig();
|
|
69
|
+
const credentials = await getAllCredentials();
|
|
70
|
+
|
|
71
|
+
const exportData: ExportedData = {
|
|
72
|
+
version: 1,
|
|
73
|
+
config: configData,
|
|
74
|
+
credentials,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const key = deriveKeyFromPassword(encryptionKey);
|
|
78
|
+
const encrypted = encrypt(JSON.stringify(exportData), key);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
key: encryptionKey,
|
|
82
|
+
config: encrypted,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
export function registerConfigCommands(program: Command): void {
|
|
62
87
|
const config = program
|
|
63
88
|
.command('config')
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { setCredentials, getCredentials } from '../auth/token-store';
|
|
3
|
+
import { setProfile, getProfile } from '../config/config-manager';
|
|
4
|
+
import { createProfileCommands } from '../utils/profile-commands';
|
|
5
|
+
import { GitHubClient } from '../services/github/client';
|
|
6
|
+
import { performGitHubOAuthFlow } from '../auth/github-oauth';
|
|
7
|
+
import { generateExportData } from './config';
|
|
8
|
+
import { CliError, handleError } from '../utils/errors';
|
|
9
|
+
import { resolveProfileName } from '../utils/stdin';
|
|
10
|
+
import type { GitHubCredentials } from '../types/github';
|
|
11
|
+
|
|
12
|
+
async function getGitHubClient(profileName?: string): Promise<{ client: GitHubClient; profile: string }> {
|
|
13
|
+
const profile = await getProfile('github', profileName);
|
|
14
|
+
|
|
15
|
+
if (!profile) {
|
|
16
|
+
throw new CliError(
|
|
17
|
+
'PROFILE_NOT_FOUND',
|
|
18
|
+
profileName
|
|
19
|
+
? `Profile "${profileName}" not found for github`
|
|
20
|
+
: 'No default profile configured for github',
|
|
21
|
+
'Run: agentio github profile add'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const credentials = await getCredentials<GitHubCredentials>('github', profile);
|
|
26
|
+
|
|
27
|
+
if (!credentials) {
|
|
28
|
+
throw new CliError(
|
|
29
|
+
'AUTH_FAILED',
|
|
30
|
+
`No credentials found for github profile "${profile}"`,
|
|
31
|
+
`Run: agentio github profile add --profile ${profile}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
client: new GitHubClient(credentials),
|
|
37
|
+
profile,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseRepo(repo: string): { owner: string; name: string } {
|
|
42
|
+
const parts = repo.split('/');
|
|
43
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
44
|
+
throw new CliError(
|
|
45
|
+
'INVALID_PARAMS',
|
|
46
|
+
`Invalid repository format: "${repo}"`,
|
|
47
|
+
'Use the format: owner/repo (e.g., octocat/hello-world)'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return { owner: parts[0], name: parts[1] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function registerGitHubCommands(program: Command): void {
|
|
54
|
+
const github = program
|
|
55
|
+
.command('github')
|
|
56
|
+
.description('GitHub operations');
|
|
57
|
+
|
|
58
|
+
github
|
|
59
|
+
.command('install')
|
|
60
|
+
.description('Install AGENTIO_KEY and AGENTIO_CONFIG as GitHub Actions secrets')
|
|
61
|
+
.argument('<repo>', 'Repository in owner/repo format')
|
|
62
|
+
.option('--profile <name>', 'Profile name')
|
|
63
|
+
.action(async (repo: string, options) => {
|
|
64
|
+
try {
|
|
65
|
+
// Validate repo format
|
|
66
|
+
parseRepo(repo);
|
|
67
|
+
|
|
68
|
+
const { client, profile } = await getGitHubClient(options.profile);
|
|
69
|
+
|
|
70
|
+
console.error(`Using GitHub profile: ${profile}`);
|
|
71
|
+
console.error(`Installing secrets to: ${repo}`);
|
|
72
|
+
|
|
73
|
+
// Generate the export data
|
|
74
|
+
const exportData = await generateExportData();
|
|
75
|
+
|
|
76
|
+
// Set secrets on the repo
|
|
77
|
+
console.error('\nSetting AGENTIO_KEY...');
|
|
78
|
+
await client.setRepoSecret(repo, 'AGENTIO_KEY', exportData.key);
|
|
79
|
+
|
|
80
|
+
console.error('Setting AGENTIO_CONFIG...');
|
|
81
|
+
await client.setRepoSecret(repo, 'AGENTIO_CONFIG', exportData.config);
|
|
82
|
+
|
|
83
|
+
console.log(`\nInstalled AGENTIO_KEY and AGENTIO_CONFIG to ${repo}`);
|
|
84
|
+
console.log('\nIn your GitHub Actions workflow, use:');
|
|
85
|
+
console.log(' env:');
|
|
86
|
+
console.log(' AGENTIO_KEY: ${{ secrets.AGENTIO_KEY }}');
|
|
87
|
+
console.log(' AGENTIO_CONFIG: ${{ secrets.AGENTIO_CONFIG }}');
|
|
88
|
+
} catch (error) {
|
|
89
|
+
handleError(error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
github
|
|
94
|
+
.command('uninstall')
|
|
95
|
+
.description('Remove AGENTIO_KEY and AGENTIO_CONFIG secrets from a repository')
|
|
96
|
+
.argument('<repo>', 'Repository in owner/repo format')
|
|
97
|
+
.option('--profile <name>', 'Profile name')
|
|
98
|
+
.action(async (repo: string, options) => {
|
|
99
|
+
try {
|
|
100
|
+
// Validate repo format
|
|
101
|
+
parseRepo(repo);
|
|
102
|
+
|
|
103
|
+
const { client, profile } = await getGitHubClient(options.profile);
|
|
104
|
+
|
|
105
|
+
console.error(`Using GitHub profile: ${profile}`);
|
|
106
|
+
console.error(`Removing secrets from: ${repo}`);
|
|
107
|
+
|
|
108
|
+
// Delete secrets from the repo
|
|
109
|
+
console.error('\nDeleting AGENTIO_KEY...');
|
|
110
|
+
await client.deleteRepoSecret(repo, 'AGENTIO_KEY');
|
|
111
|
+
|
|
112
|
+
console.error('Deleting AGENTIO_CONFIG...');
|
|
113
|
+
await client.deleteRepoSecret(repo, 'AGENTIO_CONFIG');
|
|
114
|
+
|
|
115
|
+
console.log(`\nRemoved AGENTIO_KEY and AGENTIO_CONFIG from ${repo}`);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
handleError(error);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Profile management
|
|
122
|
+
const profile = createProfileCommands<GitHubCredentials>(github, {
|
|
123
|
+
service: 'github',
|
|
124
|
+
displayName: 'GitHub',
|
|
125
|
+
getExtraInfo: (credentials) => credentials?.username ? ` (${credentials.username})` : '',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
profile
|
|
129
|
+
.command('add')
|
|
130
|
+
.description('Add a new GitHub profile')
|
|
131
|
+
.option('--profile <name>', 'Profile name', 'default')
|
|
132
|
+
.action(async (options) => {
|
|
133
|
+
try {
|
|
134
|
+
const profileName = await resolveProfileName('github', options.profile);
|
|
135
|
+
|
|
136
|
+
console.error('\nGitHub Setup\n');
|
|
137
|
+
console.error('This will open your browser to authorize agentio with GitHub.');
|
|
138
|
+
console.error('You will need to grant access to repositories where you want to set secrets.\n');
|
|
139
|
+
|
|
140
|
+
// Perform OAuth flow
|
|
141
|
+
const oauthResult = await performGitHubOAuthFlow();
|
|
142
|
+
|
|
143
|
+
// Create client to fetch user info
|
|
144
|
+
const credentials: GitHubCredentials = {
|
|
145
|
+
accessToken: oauthResult.accessToken,
|
|
146
|
+
username: '',
|
|
147
|
+
email: null,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const client = new GitHubClient(credentials);
|
|
151
|
+
const user = await client.getUser();
|
|
152
|
+
|
|
153
|
+
// Update credentials with user info
|
|
154
|
+
credentials.username = user.login;
|
|
155
|
+
credentials.email = user.email;
|
|
156
|
+
|
|
157
|
+
console.error(`\nAuthenticated as: ${user.login}${user.email ? ` (${user.email})` : ''}`);
|
|
158
|
+
|
|
159
|
+
// Save credentials
|
|
160
|
+
await setProfile('github', profileName);
|
|
161
|
+
await setCredentials('github', profileName, credentials);
|
|
162
|
+
|
|
163
|
+
console.log(`\nProfile "${profileName}" configured!`);
|
|
164
|
+
console.log(` Install secrets: agentio github install owner/repo --profile ${profileName}`);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
handleError(error);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
package/src/commands/status.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createGoogleAuth } from '../auth/token-manager';
|
|
|
5
5
|
import { refreshJiraToken } from '../auth/jira-oauth';
|
|
6
6
|
import { TelegramClient } from '../services/telegram/client';
|
|
7
7
|
import { GmailClient } from '../services/gmail/client';
|
|
8
|
+
import { GitHubClient } from '../services/github/client';
|
|
8
9
|
import { JiraClient } from '../services/jira/client';
|
|
9
10
|
import { GChatClient } from '../services/gchat/client';
|
|
10
11
|
import { SlackClient } from '../services/slack/client';
|
|
@@ -14,6 +15,7 @@ import type { ServiceClient, ValidationResult } from '../types/service';
|
|
|
14
15
|
import type { ServiceName } from '../types/config';
|
|
15
16
|
import type { OAuthTokens } from '../types/tokens';
|
|
16
17
|
import type { TelegramCredentials } from '../types/telegram';
|
|
18
|
+
import type { GitHubCredentials } from '../types/github';
|
|
17
19
|
import type { JiraCredentials } from '../types/jira';
|
|
18
20
|
import type { GChatCredentials } from '../types/gchat';
|
|
19
21
|
import type { SlackCredentials } from '../types/slack';
|
|
@@ -49,22 +51,36 @@ async function createServiceClient(
|
|
|
49
51
|
return new TelegramClient(creds.bot_token, creds.channel_id);
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
case 'github': {
|
|
55
|
+
const creds = credentials as GitHubCredentials;
|
|
56
|
+
return new GitHubClient(creds);
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
case 'jira': {
|
|
53
60
|
let creds = credentials as JiraCredentials;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
|
|
62
|
+
// Helper to refresh token
|
|
63
|
+
const tryRefresh = async (): Promise<JiraCredentials | null> => {
|
|
57
64
|
try {
|
|
58
65
|
const refreshed = await refreshJiraToken(creds.refreshToken);
|
|
59
|
-
|
|
66
|
+
const newCreds = {
|
|
60
67
|
...creds,
|
|
61
68
|
accessToken: refreshed.accessToken,
|
|
62
69
|
refreshToken: refreshed.refreshToken,
|
|
63
70
|
expiryDate: Date.now() + refreshed.expiresIn * 1000,
|
|
64
71
|
};
|
|
65
|
-
await setCredentials('jira', profileName,
|
|
72
|
+
await setCredentials('jira', profileName, newCreds);
|
|
73
|
+
return newCreds;
|
|
66
74
|
} catch {
|
|
67
|
-
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Refresh token if expired or about to expire
|
|
80
|
+
const bufferTime = 5 * 60 * 1000;
|
|
81
|
+
if (creds.expiryDate && Date.now() + bufferTime >= creds.expiryDate) {
|
|
82
|
+
const refreshedCreds = await tryRefresh();
|
|
83
|
+
if (!refreshedCreds) {
|
|
68
84
|
return {
|
|
69
85
|
validate: async () => ({
|
|
70
86
|
valid: false,
|
|
@@ -72,8 +88,32 @@ async function createServiceClient(
|
|
|
72
88
|
}),
|
|
73
89
|
};
|
|
74
90
|
}
|
|
91
|
+
creds = refreshedCreds;
|
|
75
92
|
}
|
|
76
|
-
|
|
93
|
+
|
|
94
|
+
// Create client and return a wrapper that attempts refresh on validation failure
|
|
95
|
+
const client = new JiraClient(creds);
|
|
96
|
+
return {
|
|
97
|
+
validate: async () => {
|
|
98
|
+
const result = await client.validate();
|
|
99
|
+
if (result.valid) {
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Validation failed - try to refresh token and retry
|
|
104
|
+
const refreshedCreds = await tryRefresh();
|
|
105
|
+
if (!refreshedCreds) {
|
|
106
|
+
return {
|
|
107
|
+
valid: false,
|
|
108
|
+
error: 'refresh token expired, re-authenticate',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Retry validation with refreshed credentials
|
|
113
|
+
const refreshedClient = new JiraClient(refreshedCreds);
|
|
114
|
+
return refreshedClient.validate();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
77
117
|
}
|
|
78
118
|
|
|
79
119
|
case 'gchat': {
|
|
@@ -20,3 +20,12 @@ export const JIRA_OAUTH_CONFIG = {
|
|
|
20
20
|
clientId: JIRA_CLIENT_ID,
|
|
21
21
|
clientSecret: reveal(JIRA_CLIENT_SECRET_ENC),
|
|
22
22
|
};
|
|
23
|
+
|
|
24
|
+
// GitHub OAuth credentials
|
|
25
|
+
const GITHUB_CLIENT_ID = 'Ov23liR1X63IRAf6eONJ';
|
|
26
|
+
const GITHUB_CLIENT_SECRET_ENC = 'Sztvs0AEbI5enapA40GFdqQc4RMgf8tmrMGXZ7RQIxYnuKmllPl8bZluGh5e15QfTjRe7HwZ5Bc';
|
|
27
|
+
|
|
28
|
+
export const GITHUB_OAUTH_CONFIG = {
|
|
29
|
+
clientId: GITHUB_CLIENT_ID,
|
|
30
|
+
clientSecret: reveal(GITHUB_CLIENT_SECRET_ENC),
|
|
31
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Command } from 'commander';
|
|
|
3
3
|
import { registerGmailCommands } from './commands/gmail';
|
|
4
4
|
import { registerTelegramCommands } from './commands/telegram';
|
|
5
5
|
import { registerGChatCommands } from './commands/gchat';
|
|
6
|
+
import { registerGitHubCommands } from './commands/github';
|
|
6
7
|
import { registerJiraCommands } from './commands/jira';
|
|
7
8
|
import { registerSlackCommands } from './commands/slack';
|
|
8
9
|
import { registerRssCommands } from './commands/rss';
|
|
@@ -33,6 +34,7 @@ program
|
|
|
33
34
|
registerGmailCommands(program);
|
|
34
35
|
registerTelegramCommands(program);
|
|
35
36
|
registerGChatCommands(program);
|
|
37
|
+
registerGitHubCommands(program);
|
|
36
38
|
registerJiraCommands(program);
|
|
37
39
|
registerSlackCommands(program);
|
|
38
40
|
registerRssCommands(program);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import sodium from 'libsodium-wrappers';
|
|
2
|
+
import type { GitHubCredentials, GitHubUser, GitHubPublicKey } from '../../types/github';
|
|
3
|
+
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
4
|
+
import { CliError } from '../../utils/errors';
|
|
5
|
+
|
|
6
|
+
const GITHUB_API_BASE = 'https://api.github.com';
|
|
7
|
+
|
|
8
|
+
export class GitHubClient implements ServiceClient {
|
|
9
|
+
private accessToken: string;
|
|
10
|
+
|
|
11
|
+
constructor(credentials: GitHubCredentials) {
|
|
12
|
+
this.accessToken = credentials.accessToken;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async validate(): Promise<ValidationResult> {
|
|
16
|
+
try {
|
|
17
|
+
const user = await this.getUser();
|
|
18
|
+
return { valid: true, info: user.login };
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return {
|
|
21
|
+
valid: false,
|
|
22
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async request<T>(
|
|
28
|
+
method: string,
|
|
29
|
+
path: string,
|
|
30
|
+
body?: Record<string, unknown>
|
|
31
|
+
): Promise<T> {
|
|
32
|
+
const url = `${GITHUB_API_BASE}${path}`;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
method,
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
39
|
+
Accept: 'application/vnd.github+json',
|
|
40
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
41
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
42
|
+
},
|
|
43
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Handle no-content responses (204)
|
|
47
|
+
if (response.status === 204) {
|
|
48
|
+
return {} as T;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const data = await response.json();
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const errorMessage = data.message || 'Unknown GitHub API error';
|
|
55
|
+
|
|
56
|
+
if (response.status === 401) {
|
|
57
|
+
throw new CliError('AUTH_FAILED', `GitHub authentication failed: ${errorMessage}`);
|
|
58
|
+
}
|
|
59
|
+
if (response.status === 403) {
|
|
60
|
+
throw new CliError('PERMISSION_DENIED', `Permission denied: ${errorMessage}`, 'You need admin access to set secrets on this repository');
|
|
61
|
+
}
|
|
62
|
+
if (response.status === 404) {
|
|
63
|
+
throw new CliError('NOT_FOUND', `Not found: ${errorMessage}`, 'Check that the repository exists and you have access to it');
|
|
64
|
+
}
|
|
65
|
+
if (response.status === 429) {
|
|
66
|
+
throw new CliError('RATE_LIMITED', `Rate limited: ${errorMessage}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new CliError('API_ERROR', `GitHub API error: ${errorMessage}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return data as T;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof CliError) throw error;
|
|
75
|
+
|
|
76
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
77
|
+
throw new CliError('NETWORK_ERROR', `Failed to connect to GitHub: ${message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getUser(): Promise<GitHubUser> {
|
|
82
|
+
return this.request<GitHubUser>('GET', '/user');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getRepoPublicKey(repo: string): Promise<GitHubPublicKey> {
|
|
86
|
+
return this.request<GitHubPublicKey>('GET', `/repos/${repo}/actions/secrets/public-key`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async setRepoSecret(repo: string, secretName: string, secretValue: string): Promise<void> {
|
|
90
|
+
// Ensure libsodium is ready
|
|
91
|
+
await sodium.ready;
|
|
92
|
+
|
|
93
|
+
// Get the repo's public key
|
|
94
|
+
const publicKey = await this.getRepoPublicKey(repo);
|
|
95
|
+
|
|
96
|
+
// Encrypt the secret using libsodium sealed box
|
|
97
|
+
const keyBytes = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL);
|
|
98
|
+
const messageBytes = sodium.from_string(secretValue);
|
|
99
|
+
const encryptedBytes = sodium.crypto_box_seal(messageBytes, keyBytes);
|
|
100
|
+
const encryptedValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
|
|
101
|
+
|
|
102
|
+
// Upload the encrypted secret
|
|
103
|
+
await this.request('PUT', `/repos/${repo}/actions/secrets/${secretName}`, {
|
|
104
|
+
encrypted_value: encryptedValue,
|
|
105
|
+
key_id: publicKey.key_id,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async deleteRepoSecret(repo: string, secretName: string): Promise<void> {
|
|
110
|
+
await this.request('DELETE', `/repos/${repo}/actions/secrets/${secretName}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/types/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface Config {
|
|
|
2
2
|
profiles: {
|
|
3
3
|
gmail?: string[];
|
|
4
4
|
gchat?: string[];
|
|
5
|
+
github?: string[];
|
|
5
6
|
jira?: string[];
|
|
6
7
|
slack?: string[];
|
|
7
8
|
telegram?: string[];
|
|
@@ -11,6 +12,7 @@ export interface Config {
|
|
|
11
12
|
defaults: {
|
|
12
13
|
gmail?: string;
|
|
13
14
|
gchat?: string;
|
|
15
|
+
github?: string;
|
|
14
16
|
jira?: string;
|
|
15
17
|
slack?: string;
|
|
16
18
|
telegram?: string;
|
|
@@ -20,4 +22,4 @@ export interface Config {
|
|
|
20
22
|
env?: Record<string, string>;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
export type ServiceName = 'gmail' | 'gchat' | 'jira' | 'slack' | 'telegram' | 'discourse' | 'sql';
|
|
25
|
+
export type ServiceName = 'gmail' | 'gchat' | 'github' | 'jira' | 'slack' | 'telegram' | 'discourse' | 'sql';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface GitHubCredentials {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
username: string;
|
|
4
|
+
email: string | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface GitHubUser {
|
|
8
|
+
login: string;
|
|
9
|
+
email: string | null;
|
|
10
|
+
name: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GitHubPublicKey {
|
|
14
|
+
key_id: string;
|
|
15
|
+
key: string;
|
|
16
|
+
}
|