spooder 3.2.7 → 4.0.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/README.md +588 -280
- package/package.json +1 -4
- package/src/api.d.ts +45 -16
- package/src/api.ts +349 -115
- package/src/cli.ts +40 -19
- package/src/config.d.ts +2 -1
- package/src/config.ts +12 -2
- package/src/dispatch.ts +36 -43
- package/src/github.d.ts +11 -0
- package/src/github.ts +121 -0
package/src/cli.ts
CHANGED
|
@@ -36,35 +36,56 @@ async function start_server() {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const crash_console_history = config.canary.crash_console_history;
|
|
40
|
+
const include_crash_history = crash_console_history > 0;
|
|
41
|
+
|
|
42
|
+
const std_mode = include_crash_history ? 'pipe' : 'inherit';
|
|
39
43
|
const proc = Bun.spawn(parse_command_line(config.run), {
|
|
40
44
|
cwd: process.cwd(),
|
|
41
|
-
stdout:
|
|
42
|
-
stderr:
|
|
45
|
+
stdout: std_mode,
|
|
46
|
+
stderr: std_mode
|
|
43
47
|
});
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
const stream_history = new Array<string>();
|
|
50
|
+
if (include_crash_history) {
|
|
51
|
+
const text_decoder = new TextDecoder();
|
|
46
52
|
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
function capture_stream(stream: ReadableStream, output: NodeJS.WritableStream) {
|
|
54
|
+
const reader = stream.getReader();
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
reader.read().then(function read_chunk(chunk: ReadableStreamDefaultReadResult<Uint8Array>) {
|
|
57
|
+
if (chunk.done)
|
|
58
|
+
return;
|
|
59
|
+
|
|
60
|
+
const chunk_str = text_decoder.decode(chunk.value);
|
|
61
|
+
stream_history.push(...chunk_str.split(/\r?\n/));
|
|
62
|
+
|
|
63
|
+
if (stream_history.length > crash_console_history)
|
|
64
|
+
stream_history.splice(0, stream_history.length - crash_console_history);
|
|
65
|
+
|
|
66
|
+
output.write(chunk.value);
|
|
67
|
+
reader.read().then(read_chunk);
|
|
59
68
|
});
|
|
60
|
-
} else {
|
|
61
|
-
dispatch_report('crash: service exited unexpectedly', [{
|
|
62
|
-
proc_exit_code
|
|
63
|
-
}]);
|
|
64
69
|
}
|
|
70
|
+
|
|
71
|
+
capture_stream(proc.stdout as ReadableStream, process.stdout);
|
|
72
|
+
capture_stream(proc.stderr as ReadableStream, process.stderr);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await proc.exited;
|
|
76
|
+
|
|
77
|
+
const proc_exit_code = proc.exitCode;
|
|
78
|
+
log('server exited with code %s', proc_exit_code);
|
|
79
|
+
|
|
80
|
+
if (proc_exit_code !== 0) {
|
|
81
|
+
const console_output = include_crash_history ? strip_color_codes(stream_history.join('\n')) : undefined;
|
|
82
|
+
dispatch_report('crash: server exited unexpectedly', [{
|
|
83
|
+
proc_exit_code, console_output
|
|
84
|
+
}]);
|
|
65
85
|
}
|
|
86
|
+
|
|
66
87
|
|
|
67
|
-
const auto_restart_ms = config.
|
|
88
|
+
const auto_restart_ms = config.auto_restart;
|
|
68
89
|
if (auto_restart_ms > -1) {
|
|
69
90
|
log('restarting server in %dms', auto_restart_ms);
|
|
70
91
|
setTimeout(start_server, auto_restart_ms);
|
package/src/config.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
declare const internal_config: {
|
|
2
2
|
run: string;
|
|
3
|
-
|
|
3
|
+
auto_restart: number;
|
|
4
4
|
update: never[];
|
|
5
5
|
canary: {
|
|
6
6
|
account: string;
|
|
7
7
|
repository: string;
|
|
8
8
|
labels: never[];
|
|
9
|
+
crash_console_history: number;
|
|
9
10
|
throttle: number;
|
|
10
11
|
sanitize: boolean;
|
|
11
12
|
};
|
package/src/config.ts
CHANGED
|
@@ -3,12 +3,13 @@ import { log } from './utils';
|
|
|
3
3
|
|
|
4
4
|
const internal_config = {
|
|
5
5
|
run: 'bun run index.ts',
|
|
6
|
-
|
|
6
|
+
auto_restart: -1,
|
|
7
7
|
update: [],
|
|
8
8
|
canary: {
|
|
9
9
|
account: '',
|
|
10
10
|
repository: '',
|
|
11
11
|
labels: [],
|
|
12
|
+
crash_console_history: 64,
|
|
12
13
|
throttle: 86400,
|
|
13
14
|
sanitize: true
|
|
14
15
|
}
|
|
@@ -17,6 +18,8 @@ const internal_config = {
|
|
|
17
18
|
type Config = typeof internal_config;
|
|
18
19
|
type ConfigObject = Record<string, unknown>;
|
|
19
20
|
|
|
21
|
+
let cached_config: Config | null = null;
|
|
22
|
+
|
|
20
23
|
function validate_config_option(source: ConfigObject, target: ConfigObject, root_name: string) {
|
|
21
24
|
for (const [key, value] of Object.entries(target)) {
|
|
22
25
|
const key_name = `${root_name}.${key}`;
|
|
@@ -59,7 +62,7 @@ function validate_config_option(source: ConfigObject, target: ConfigObject, root
|
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
|
|
65
|
+
async function load_config(): Promise<Config> {
|
|
63
66
|
try {
|
|
64
67
|
const config_file = Bun.file(path.join(process.cwd(), 'package.json'));
|
|
65
68
|
const json = await config_file.json();
|
|
@@ -75,4 +78,11 @@ export async function get_config(): Promise<Config> {
|
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
return internal_config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function get_config(): Promise<Config> {
|
|
84
|
+
if (cached_config === null)
|
|
85
|
+
cached_config = await load_config();
|
|
86
|
+
|
|
87
|
+
return cached_config;
|
|
78
88
|
}
|
package/src/dispatch.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { App } from '@octokit/app';
|
|
2
1
|
import { get_config } from './config';
|
|
2
|
+
import { create_github_issue } from './github';
|
|
3
3
|
import { log } from './utils';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -93,9 +93,9 @@ async function check_cache_table(key: string, repository: string, expiry: number
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
} catch (e) {
|
|
96
|
-
log('
|
|
97
|
-
log('
|
|
98
|
-
log('
|
|
96
|
+
log('failed to read canary cache file ' + cache_file_path);
|
|
97
|
+
log('error: ' + (e as Error).message);
|
|
98
|
+
log('you should resolve this issue to prevent spamming GitHub with canary reports');
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
if (cache_table.has(key_hash)) {
|
|
@@ -142,6 +142,12 @@ function generate_diagnostics(): object {
|
|
|
142
142
|
'platform': os.platform(),
|
|
143
143
|
'uptime': os.uptime(),
|
|
144
144
|
'versions': process.versions,
|
|
145
|
+
'bun': {
|
|
146
|
+
'version': Bun.version,
|
|
147
|
+
'rev': Bun.revision,
|
|
148
|
+
'memory_usage': process.memoryUsage(),
|
|
149
|
+
'cpu_usage': process.cpuUsage()
|
|
150
|
+
}
|
|
145
151
|
}
|
|
146
152
|
}
|
|
147
153
|
|
|
@@ -149,16 +155,17 @@ export async function dispatch_report(report_title: string, report_body: Array<u
|
|
|
149
155
|
try {
|
|
150
156
|
const config = await get_config();
|
|
151
157
|
|
|
152
|
-
const canary_account = config.canary.account
|
|
153
|
-
const canary_repostiory = config.canary.repository
|
|
154
|
-
const canary_labels = config.canary.labels;
|
|
158
|
+
const canary_account = config.canary.account;
|
|
159
|
+
const canary_repostiory = config.canary.repository;
|
|
155
160
|
|
|
156
|
-
if (canary_account.length === 0|| canary_repostiory.length === 0)
|
|
161
|
+
if (canary_account.length === 0|| canary_repostiory.length === 0) {
|
|
162
|
+
log('[canary] report dispatch failed; no account/repository configured');
|
|
157
163
|
return;
|
|
164
|
+
}
|
|
158
165
|
|
|
159
166
|
const is_cached = await check_cache_table(report_title, canary_repostiory, config.canary.throttle);
|
|
160
167
|
if (is_cached) {
|
|
161
|
-
log('
|
|
168
|
+
log('[canary] throttled canary report: ' + report_title);
|
|
162
169
|
return;
|
|
163
170
|
}
|
|
164
171
|
|
|
@@ -166,58 +173,44 @@ export async function dispatch_report(report_title: string, report_body: Array<u
|
|
|
166
173
|
const canary_app_key = process.env.SPOODER_CANARY_KEY as string;
|
|
167
174
|
|
|
168
175
|
if (canary_app_id === undefined)
|
|
169
|
-
throw new Error('
|
|
176
|
+
throw new Error('SPOODER_CANARY_APP_ID environment variable is not set');
|
|
170
177
|
|
|
171
178
|
if (canary_app_key === undefined)
|
|
172
|
-
throw new Error('
|
|
179
|
+
throw new Error('SPOODER_CANARY_KEY environment variable is not set');
|
|
173
180
|
|
|
174
181
|
const key_file = Bun.file(canary_app_key);
|
|
175
182
|
if (key_file.size === 0)
|
|
176
|
-
throw new Error('
|
|
183
|
+
throw new Error('Unable to read private key file defined by SPOODER_CANARY_KEY environment variable');
|
|
177
184
|
|
|
178
185
|
const app_id = parseInt(canary_app_id, 10);
|
|
179
186
|
if (isNaN(app_id))
|
|
180
|
-
throw new Error('
|
|
181
|
-
|
|
182
|
-
const app = new App({
|
|
183
|
-
appId: app_id,
|
|
184
|
-
privateKey: await key_file.text(),
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
await app.octokit.request('GET /app');
|
|
187
|
+
throw new Error('Invalid app ID defined by SPOODER_CANARY_APP_ID environment variable');
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
body: '',
|
|
192
|
-
labels: canary_labels
|
|
193
|
-
};
|
|
189
|
+
let issue_title = report_title;
|
|
190
|
+
let issue_body = '';
|
|
194
191
|
|
|
195
192
|
report_body.push(generate_diagnostics());
|
|
196
193
|
|
|
197
194
|
if (config.canary.sanitize) {
|
|
198
195
|
const local_env = await load_local_env();
|
|
199
|
-
|
|
200
|
-
|
|
196
|
+
issue_body = sanitize_string(JSON.stringify(report_body, null, 4), local_env);
|
|
197
|
+
issue_title = sanitize_string(report_title, local_env);
|
|
201
198
|
} else {
|
|
202
|
-
|
|
199
|
+
issue_body = JSON.stringify(report_body, null, 4);
|
|
203
200
|
}
|
|
204
201
|
|
|
205
|
-
|
|
202
|
+
issue_body = '```json\n' + issue_body + '\n```\n\nℹ️ *This issue has been created automatically in response to a server panic, caution or crash.*';
|
|
206
203
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
await octokit.request('POST /repos/' + canary_repostiory + '/issues', post_object);
|
|
217
|
-
log('Dispatched canary report to %s: %s', canary_repostiory, report_title);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
204
|
+
await create_github_issue({
|
|
205
|
+
app_id,
|
|
206
|
+
private_key: await key_file.text(),
|
|
207
|
+
repository_name: canary_repostiory,
|
|
208
|
+
login_name: canary_account,
|
|
209
|
+
issue_title,
|
|
210
|
+
issue_body,
|
|
211
|
+
issue_labels: config.canary.labels
|
|
212
|
+
});
|
|
220
213
|
} catch (e) {
|
|
221
|
-
log('
|
|
214
|
+
log('[canary error] ' + (e as Error)?.message ?? 'unspecified error');
|
|
222
215
|
}
|
|
223
216
|
}
|
package/src/github.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Issue = {
|
|
2
|
+
app_id: number;
|
|
3
|
+
private_key: string;
|
|
4
|
+
login_name: string;
|
|
5
|
+
repository_name: string;
|
|
6
|
+
issue_title: string;
|
|
7
|
+
issue_body: string;
|
|
8
|
+
issue_labels?: Array<string>;
|
|
9
|
+
};
|
|
10
|
+
export declare function create_github_issue(issue: Issue): Promise<void>;
|
|
11
|
+
export {};
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { log } from './utils';
|
|
3
|
+
|
|
4
|
+
type InstallationResponse = Array<{
|
|
5
|
+
id: number,
|
|
6
|
+
account: {
|
|
7
|
+
login: string
|
|
8
|
+
},
|
|
9
|
+
access_tokens_url: string,
|
|
10
|
+
repositories_url: string
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
type AccessTokenResponse = {
|
|
14
|
+
token: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type RepositoryResponse = {
|
|
18
|
+
repositories: Array<{
|
|
19
|
+
full_name: string,
|
|
20
|
+
name: string,
|
|
21
|
+
url: string,
|
|
22
|
+
owner: {
|
|
23
|
+
login: string
|
|
24
|
+
}
|
|
25
|
+
}>
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type IssueResponse = {
|
|
29
|
+
number: number,
|
|
30
|
+
url: string
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type Issue = {
|
|
34
|
+
app_id: number,
|
|
35
|
+
private_key: string,
|
|
36
|
+
login_name: string,
|
|
37
|
+
repository_name: string,
|
|
38
|
+
issue_title: string,
|
|
39
|
+
issue_body: string
|
|
40
|
+
issue_labels?: Array<string>
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function generate_jwt(app_id: number, private_key: string): string {
|
|
44
|
+
const encoded_header = Buffer.from(JSON.stringify({
|
|
45
|
+
alg: 'RS256',
|
|
46
|
+
typ: 'JWT'
|
|
47
|
+
})).toString('base64');
|
|
48
|
+
|
|
49
|
+
const encoded_payload = Buffer.from(JSON.stringify({
|
|
50
|
+
iat: Math.floor(Date.now() / 1000),
|
|
51
|
+
exp: Math.floor(Date.now() / 1000) + 60,
|
|
52
|
+
iss: app_id
|
|
53
|
+
})).toString('base64');
|
|
54
|
+
|
|
55
|
+
const sign = crypto.createSign('RSA-SHA256');
|
|
56
|
+
sign.update(encoded_header + '.' + encoded_payload);
|
|
57
|
+
|
|
58
|
+
return encoded_header + '.' + encoded_payload + '.' + sign.sign(private_key, 'base64');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function request_endpoint(url: string, bearer: string, method: string = 'GET', body?: object): Promise<Response> {
|
|
62
|
+
return fetch(url, {
|
|
63
|
+
method,
|
|
64
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: 'Bearer ' + bearer,
|
|
67
|
+
Accept: 'application/vnd.github.v3+json'
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function check_response_is_ok(res: Response, message: string): void {
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
throw new Error(message + ' (' + res.status + ' ' + res.statusText + ')');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function create_github_issue(issue: Issue): Promise<void> {
|
|
78
|
+
const jwt = generate_jwt(issue.app_id, issue.private_key);
|
|
79
|
+
const app_res = await request_endpoint('https://api.github.com/app', jwt);
|
|
80
|
+
|
|
81
|
+
check_response_is_ok(app_res, 'cannot authenticate GitHub app ' + issue.app_id);
|
|
82
|
+
|
|
83
|
+
const res_installs = await request_endpoint('https://api.github.com/app/installations', jwt);
|
|
84
|
+
check_response_is_ok(res_installs, 'cannot fetch GitHub app installations');
|
|
85
|
+
|
|
86
|
+
const json_installs = await res_installs.json() as InstallationResponse;
|
|
87
|
+
|
|
88
|
+
const login_name = issue.login_name.toLowerCase();
|
|
89
|
+
const install = json_installs.find((install) => install.account.login.toLowerCase() === login_name);
|
|
90
|
+
|
|
91
|
+
if (!install)
|
|
92
|
+
throw new Error('spooder-bot is not installed on account ' + login_name);
|
|
93
|
+
|
|
94
|
+
const res_access_token = await request_endpoint(install.access_tokens_url, jwt, 'POST');
|
|
95
|
+
check_response_is_ok(res_access_token, 'cannot fetch GitHub app access token');
|
|
96
|
+
|
|
97
|
+
const json_access_token = await res_access_token.json() as AccessTokenResponse;
|
|
98
|
+
const access_token = json_access_token.token;
|
|
99
|
+
|
|
100
|
+
const repositories = await request_endpoint(install.repositories_url, access_token);
|
|
101
|
+
check_response_is_ok(repositories, 'cannot fetch GitHub app repositories');
|
|
102
|
+
|
|
103
|
+
const repositories_json = await repositories.json() as RepositoryResponse;
|
|
104
|
+
|
|
105
|
+
const repository_name = issue.repository_name.toLowerCase();
|
|
106
|
+
const repository = repositories_json.repositories.find((repository) => repository.full_name.toLowerCase() === repository_name);
|
|
107
|
+
|
|
108
|
+
if (!repository)
|
|
109
|
+
throw new Error('spooder-bot is not installed on repository ' + repository_name);
|
|
110
|
+
|
|
111
|
+
const issue_res = await request_endpoint(repository.url + '/issues', access_token, 'POST', {
|
|
112
|
+
title: issue.issue_title,
|
|
113
|
+
body: issue.issue_body,
|
|
114
|
+
labels: issue.issue_labels
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
check_response_is_ok(issue_res, 'cannot create GitHub issue');
|
|
118
|
+
|
|
119
|
+
const json_issue = await issue_res.json() as IssueResponse;
|
|
120
|
+
log('raised canary issue #%d in %s: %s', json_issue.number, repository.full_name, json_issue.url);
|
|
121
|
+
}
|