spooder 4.6.2 → 5.0.1
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 +1119 -342
- package/bun.lock +9 -5
- package/package.json +2 -2
- package/src/api.ts +976 -531
- package/src/api_db.ts +670 -0
- package/src/cli.ts +93 -19
- package/src/config.ts +13 -8
- package/src/dispatch.ts +136 -11
- package/src/template/directory_index.html +303 -0
- package/src/github.ts +0 -121
- package/src/utils.ts +0 -57
package/src/cli.ts
CHANGED
|
@@ -1,35 +1,85 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { get_config } from './config';
|
|
3
|
-
import { parse_command_line, log, strip_color_codes } from './utils';
|
|
4
3
|
import { dispatch_report } from './dispatch';
|
|
4
|
+
import { log_create_logger } from './api';
|
|
5
|
+
|
|
6
|
+
const log_cli = log_create_logger('spooder_cli');
|
|
7
|
+
|
|
8
|
+
let restart_delay = 100;
|
|
9
|
+
let restart_attempts = 0;
|
|
10
|
+
let restart_success_timer: Timer | null = null;
|
|
11
|
+
|
|
12
|
+
function strip_color_codes(str: string): string {
|
|
13
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parse_command_line(command: string): string[] {
|
|
17
|
+
const args = [];
|
|
18
|
+
let current_arg = '';
|
|
19
|
+
let in_quotes = false;
|
|
20
|
+
let in_escape = false;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < command.length; i++) {
|
|
23
|
+
const char = command[i];
|
|
24
|
+
|
|
25
|
+
if (in_escape) {
|
|
26
|
+
current_arg += char;
|
|
27
|
+
in_escape = false;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (char === '\\') {
|
|
32
|
+
in_escape = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (char === '"') {
|
|
37
|
+
in_quotes = !in_quotes;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (char === ' ' && !in_quotes) {
|
|
42
|
+
args.push(current_arg);
|
|
43
|
+
current_arg = '';
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
current_arg += char;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (current_arg.length > 0)
|
|
51
|
+
args.push(current_arg);
|
|
52
|
+
|
|
53
|
+
return args;
|
|
54
|
+
}
|
|
5
55
|
|
|
6
56
|
async function start_server() {
|
|
7
|
-
|
|
57
|
+
log_cli('start_server');
|
|
8
58
|
|
|
9
59
|
const argv = process.argv.slice(2);
|
|
10
60
|
const is_dev_mode = argv.includes('--dev');
|
|
11
61
|
const skip_updates = argv.includes('--no-update');
|
|
12
62
|
|
|
13
63
|
if (is_dev_mode)
|
|
14
|
-
|
|
64
|
+
log_cli('[{dev}] spooder has been started in {dev mode}');
|
|
15
65
|
|
|
16
66
|
const config = await get_config();
|
|
17
67
|
|
|
18
68
|
if (is_dev_mode) {
|
|
19
|
-
|
|
69
|
+
log_cli('[{update}] skipping update commands in {dev mode}');
|
|
20
70
|
} else if (skip_updates) {
|
|
21
|
-
|
|
71
|
+
log_cli('[{update}] skipping update commands due to {--no-update} flag');
|
|
22
72
|
} else {
|
|
23
73
|
const update_commands = config.update;
|
|
24
74
|
const n_update_commands = update_commands.length;
|
|
25
75
|
|
|
26
76
|
if (n_update_commands > 0) {
|
|
27
|
-
|
|
77
|
+
log_cli(`running {${n_update_commands}} updated commands`);
|
|
28
78
|
|
|
29
79
|
for (let i = 0; i < n_update_commands; i++) {
|
|
30
80
|
const config_update_command = update_commands[i];
|
|
31
81
|
|
|
32
|
-
|
|
82
|
+
log_cli(`[{${i}}] ${config_update_command}`);
|
|
33
83
|
|
|
34
84
|
const update_proc = Bun.spawn(parse_command_line(config_update_command), {
|
|
35
85
|
cwd: process.cwd(),
|
|
@@ -39,10 +89,10 @@ async function start_server() {
|
|
|
39
89
|
|
|
40
90
|
await update_proc.exited;
|
|
41
91
|
|
|
42
|
-
|
|
92
|
+
log_cli(`[{${i}}] exited with code {${update_proc.exitCode}}`);
|
|
43
93
|
|
|
44
94
|
if (update_proc.exitCode !== 0) {
|
|
45
|
-
|
|
95
|
+
log_cli(`aborting update due to non-zero exit code from [${i}]`);
|
|
46
96
|
break;
|
|
47
97
|
}
|
|
48
98
|
}
|
|
@@ -88,15 +138,15 @@ async function start_server() {
|
|
|
88
138
|
}
|
|
89
139
|
|
|
90
140
|
const proc_exit_code = await proc.exited;
|
|
91
|
-
|
|
141
|
+
log_cli(`server exited with code {${proc_exit_code}}`);
|
|
92
142
|
|
|
93
143
|
if (proc_exit_code !== 0) {
|
|
94
144
|
const console_output = include_crash_history ? strip_color_codes(stream_history.join('\n')) : undefined;
|
|
95
145
|
|
|
96
146
|
if (is_dev_mode) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
147
|
+
log_cli(`[{dev}] crash: server exited unexpectedly (exit code {${proc_exit_code}}`);
|
|
148
|
+
log_cli(`[{dev}] without {--dev}, this would raise a canary report`);
|
|
149
|
+
log_cli(`[{dev}] console output:\n${console_output}`);
|
|
100
150
|
} else {
|
|
101
151
|
dispatch_report('crash: server exited unexpectedly', [{
|
|
102
152
|
proc_exit_code, console_output
|
|
@@ -104,15 +154,39 @@ async function start_server() {
|
|
|
104
154
|
}
|
|
105
155
|
}
|
|
106
156
|
|
|
107
|
-
|
|
108
|
-
if (auto_restart_ms > -1) {
|
|
157
|
+
if (config.auto_restart) {
|
|
109
158
|
if (is_dev_mode) {
|
|
110
|
-
|
|
159
|
+
log_cli(`[{dev}] auto-restart is {disabled} in {dev mode}`);
|
|
111
160
|
process.exit(proc_exit_code ?? 0);
|
|
112
|
-
} else {
|
|
113
|
-
|
|
114
|
-
|
|
161
|
+
} else if (proc_exit_code !== 0) {
|
|
162
|
+
if (restart_success_timer) {
|
|
163
|
+
clearTimeout(restart_success_timer);
|
|
164
|
+
restart_success_timer = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (config.auto_restart_attempts !== -1 && restart_attempts >= config.auto_restart_attempts) {
|
|
168
|
+
log_cli(`maximum restart attempts ({${config.auto_restart_attempts}}) reached, stopping auto-restart`);
|
|
169
|
+
process.exit(proc_exit_code ?? 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
restart_attempts++;
|
|
173
|
+
const current_delay = Math.min(restart_delay, config.auto_restart_max);
|
|
174
|
+
|
|
175
|
+
const max_attempt_str = config.auto_restart_attempts === -1 ? '∞' : config.auto_restart_attempts;
|
|
176
|
+
log_cli(`restarting server in {${current_delay}ms} (attempt {${restart_attempts}}/{${max_attempt_str}}, delay capped at {${config.auto_restart_max}ms})`);
|
|
177
|
+
|
|
178
|
+
setTimeout(() => {
|
|
179
|
+
restart_delay = Math.min(restart_delay * 2, config.auto_restart_max);
|
|
180
|
+
restart_success_timer = setTimeout(() => {
|
|
181
|
+
restart_delay = 100;
|
|
182
|
+
restart_attempts = 0;
|
|
183
|
+
restart_success_timer = null;
|
|
184
|
+
}, config.auto_restart_grace);
|
|
185
|
+
start_server();
|
|
186
|
+
}, current_delay);
|
|
115
187
|
}
|
|
188
|
+
} else {
|
|
189
|
+
log_cli(`auto-restart is {disabled}, exiting`);
|
|
116
190
|
}
|
|
117
191
|
}
|
|
118
192
|
|
package/src/config.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { log_create_logger } from './api';
|
|
3
|
+
|
|
4
|
+
const log_config = log_create_logger('config', 'spooder');
|
|
3
5
|
|
|
4
6
|
const internal_config = {
|
|
5
7
|
run: 'bun run index.ts',
|
|
6
|
-
auto_restart:
|
|
8
|
+
auto_restart: false,
|
|
9
|
+
auto_restart_max: 30000,
|
|
10
|
+
auto_restart_attempts: -1,
|
|
11
|
+
auto_restart_grace: 30000,
|
|
7
12
|
update: [],
|
|
8
13
|
canary: {
|
|
9
14
|
account: '',
|
|
@@ -30,7 +35,7 @@ function validate_config_option(source: ConfigObject, target: ConfigObject, root
|
|
|
30
35
|
const actual_type = typeof value;
|
|
31
36
|
|
|
32
37
|
if (actual_type !== expected_type) {
|
|
33
|
-
|
|
38
|
+
log_config(`ignoring invalid configuration value {${key_name}} (expected {${expected_type}}, got {${actual_type}})`);
|
|
34
39
|
continue;
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -40,14 +45,14 @@ function validate_config_option(source: ConfigObject, target: ConfigObject, root
|
|
|
40
45
|
|
|
41
46
|
if (is_default_array) {
|
|
42
47
|
if (!is_actual_array) {
|
|
43
|
-
|
|
48
|
+
log_config(`ignoring invalid configuration value {${key_name}} (expected array)`);
|
|
44
49
|
continue;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
source[key as keyof Config] = value as Config[keyof Config];
|
|
48
53
|
} else {
|
|
49
54
|
if (is_actual_array) {
|
|
50
|
-
|
|
55
|
+
log_config(`ignoring invalid configuration value '${key_name}' (expected object)`);
|
|
51
56
|
continue;
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -57,7 +62,7 @@ function validate_config_option(source: ConfigObject, target: ConfigObject, root
|
|
|
57
62
|
source[key as keyof Config] = value as Config[keyof Config];
|
|
58
63
|
}
|
|
59
64
|
} else {
|
|
60
|
-
|
|
65
|
+
log_config(`ignoring unknown configuration key {${key_name}}`);
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
}
|
|
@@ -68,13 +73,13 @@ async function load_config(): Promise<Config> {
|
|
|
68
73
|
const json = await config_file.json();
|
|
69
74
|
|
|
70
75
|
if (json.spooder === null || typeof json.spooder !== 'object') {
|
|
71
|
-
|
|
76
|
+
log_config('failed to parse spooder configuration in {package.json}, using defaults');
|
|
72
77
|
return internal_config;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
validate_config_option(internal_config, json.spooder, 'spooder');
|
|
76
81
|
} catch (e) {
|
|
77
|
-
|
|
82
|
+
log_config('failed to read {package.json}, using configuration defaults');
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
return internal_config;
|
package/src/dispatch.ts
CHANGED
|
@@ -1,10 +1,134 @@
|
|
|
1
1
|
import { get_config } from './config';
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import { log_create_logger } from './api';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
|
|
8
|
+
const log = log_create_logger('canary', 'spooder');
|
|
9
|
+
|
|
10
|
+
// region github
|
|
11
|
+
type GitHubInstallationResponse = Array<{
|
|
12
|
+
id: number,
|
|
13
|
+
account: {
|
|
14
|
+
login: string
|
|
15
|
+
},
|
|
16
|
+
access_tokens_url: string,
|
|
17
|
+
repositories_url: string
|
|
18
|
+
}>;
|
|
19
|
+
|
|
20
|
+
type GitHubAccessTokenResponse = {
|
|
21
|
+
token: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type GitHubRepositoryResponse = {
|
|
25
|
+
repositories: Array<{
|
|
26
|
+
full_name: string,
|
|
27
|
+
name: string,
|
|
28
|
+
url: string,
|
|
29
|
+
owner: {
|
|
30
|
+
login: string
|
|
31
|
+
}
|
|
32
|
+
}>
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type GitHubIssueResponse = {
|
|
36
|
+
number: number,
|
|
37
|
+
url: string
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type GitHubIssue = {
|
|
41
|
+
app_id: number,
|
|
42
|
+
private_key: string,
|
|
43
|
+
login_name: string,
|
|
44
|
+
repository_name: string,
|
|
45
|
+
issue_title: string,
|
|
46
|
+
issue_body: string
|
|
47
|
+
issue_labels?: Array<string>
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function github_generate_jwt(app_id: number, private_key: string): string {
|
|
51
|
+
const encoded_header = Buffer.from(JSON.stringify({
|
|
52
|
+
alg: 'RS256',
|
|
53
|
+
typ: 'JWT'
|
|
54
|
+
})).toString('base64');
|
|
55
|
+
|
|
56
|
+
const encoded_payload = Buffer.from(JSON.stringify({
|
|
57
|
+
iat: Math.floor(Date.now() / 1000),
|
|
58
|
+
exp: Math.floor(Date.now() / 1000) + 60,
|
|
59
|
+
iss: app_id
|
|
60
|
+
})).toString('base64');
|
|
61
|
+
|
|
62
|
+
const sign = crypto.createSign('RSA-SHA256');
|
|
63
|
+
sign.update(encoded_header + '.' + encoded_payload);
|
|
64
|
+
|
|
65
|
+
return encoded_header + '.' + encoded_payload + '.' + sign.sign(private_key, 'base64');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function github_request_endpoint(url: string, bearer: string, method: string = 'GET', body?: object): Promise<Response> {
|
|
69
|
+
return fetch(url, {
|
|
70
|
+
method,
|
|
71
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: 'Bearer ' + bearer,
|
|
74
|
+
Accept: 'application/vnd.github.v3+json'
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function github_assert_res(res: Response, message: string): void {
|
|
80
|
+
if (!res.ok)
|
|
81
|
+
throw new Error(message + ' (' + res.status + ' ' + res.statusText + ')');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function github_create_issue(issue: GitHubIssue): Promise<void> {
|
|
85
|
+
const jwt = github_generate_jwt(issue.app_id, issue.private_key);
|
|
86
|
+
const app_res = await github_request_endpoint('https://api.github.com/app', jwt);
|
|
87
|
+
|
|
88
|
+
github_assert_res(app_res, 'cannot authenticate GitHub app ' + issue.app_id);
|
|
89
|
+
|
|
90
|
+
const res_installs = await github_request_endpoint('https://api.github.com/app/installations', jwt);
|
|
91
|
+
github_assert_res(res_installs, 'cannot fetch GitHub app installations');
|
|
92
|
+
|
|
93
|
+
const json_installs = await res_installs.json() as GitHubInstallationResponse;
|
|
94
|
+
|
|
95
|
+
const login_name = issue.login_name.toLowerCase();
|
|
96
|
+
const install = json_installs.find((install) => install.account.login.toLowerCase() === login_name);
|
|
97
|
+
|
|
98
|
+
if (!install)
|
|
99
|
+
throw new Error('spooder-bot is not installed on account ' + login_name);
|
|
100
|
+
|
|
101
|
+
const res_access_token = await github_request_endpoint(install.access_tokens_url, jwt, 'POST');
|
|
102
|
+
github_assert_res(res_access_token, 'cannot fetch GitHub app access token');
|
|
103
|
+
|
|
104
|
+
const json_access_token = await res_access_token.json() as GitHubAccessTokenResponse;
|
|
105
|
+
const access_token = json_access_token.token;
|
|
106
|
+
|
|
107
|
+
const repositories = await github_request_endpoint(install.repositories_url, access_token);
|
|
108
|
+
github_assert_res(repositories, 'cannot fetch GitHub app repositories');
|
|
109
|
+
|
|
110
|
+
const repositories_json = await repositories.json() as GitHubRepositoryResponse;
|
|
111
|
+
|
|
112
|
+
const repository_name = issue.repository_name.toLowerCase();
|
|
113
|
+
const repository = repositories_json.repositories.find((repository) => repository.full_name.toLowerCase() === repository_name);
|
|
114
|
+
|
|
115
|
+
if (!repository)
|
|
116
|
+
throw new Error('spooder-bot is not installed on repository ' + repository_name);
|
|
117
|
+
|
|
118
|
+
const issue_res = await github_request_endpoint(repository.url + '/issues', access_token, 'POST', {
|
|
119
|
+
title: issue.issue_title,
|
|
120
|
+
body: issue.issue_body,
|
|
121
|
+
labels: issue.issue_labels
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
github_assert_res(issue_res, 'cannot create GitHub issue');
|
|
125
|
+
|
|
126
|
+
const json_issue = await issue_res.json() as GitHubIssueResponse;
|
|
127
|
+
log(`raised issue {${json_issue.number}} in {${repository.full_name}}: ${json_issue.url}`);
|
|
128
|
+
}
|
|
129
|
+
// endregion
|
|
130
|
+
|
|
131
|
+
// region canary
|
|
8
132
|
async function load_local_env(): Promise<Map<string, string>> {
|
|
9
133
|
const env = new Map<string, string>();
|
|
10
134
|
|
|
@@ -54,7 +178,7 @@ async function save_cache_table(table: Map<bigint, number>, cache_file_path: str
|
|
|
54
178
|
}
|
|
55
179
|
|
|
56
180
|
await new Promise(resolve => fs.mkdir(path.dirname(cache_file_path), { recursive: true }, resolve));
|
|
57
|
-
await Bun.write(cache_file_path, data);
|
|
181
|
+
await Bun.write(cache_file_path, data.buffer);
|
|
58
182
|
}
|
|
59
183
|
|
|
60
184
|
async function check_cache_table(key: string, repository: string, expiry: number): Promise<boolean> {
|
|
@@ -93,9 +217,9 @@ async function check_cache_table(key: string, repository: string, expiry: number
|
|
|
93
217
|
}
|
|
94
218
|
}
|
|
95
219
|
} catch (e) {
|
|
96
|
-
log(
|
|
97
|
-
log(
|
|
98
|
-
log('
|
|
220
|
+
log(`failed to read canary cache file {${cache_file_path}}`);
|
|
221
|
+
log(`error: ${(e as Error).message}`);
|
|
222
|
+
log('resolve this issue to prevent spamming GitHub with canary reports');
|
|
99
223
|
}
|
|
100
224
|
|
|
101
225
|
if (cache_table.has(key_hash)) {
|
|
@@ -159,13 +283,13 @@ export async function dispatch_report(report_title: string, report_body: Array<u
|
|
|
159
283
|
const canary_repostiory = config.canary.repository;
|
|
160
284
|
|
|
161
285
|
if (canary_account.length === 0|| canary_repostiory.length === 0) {
|
|
162
|
-
log(
|
|
286
|
+
log(`report dispatch failed; no canary account/repository configured`);
|
|
163
287
|
return;
|
|
164
288
|
}
|
|
165
289
|
|
|
166
290
|
const is_cached = await check_cache_table(report_title, canary_repostiory, config.canary.throttle);
|
|
167
291
|
if (is_cached) {
|
|
168
|
-
log(
|
|
292
|
+
log(`throttled canary report: {${report_title}}`);
|
|
169
293
|
return;
|
|
170
294
|
}
|
|
171
295
|
|
|
@@ -201,7 +325,7 @@ export async function dispatch_report(report_title: string, report_body: Array<u
|
|
|
201
325
|
|
|
202
326
|
issue_body = '```json\n' + issue_body + '\n```\n\nℹ️ *This issue has been created automatically in response to a server panic, caution or crash.*';
|
|
203
327
|
|
|
204
|
-
await
|
|
328
|
+
await github_create_issue({
|
|
205
329
|
app_id,
|
|
206
330
|
private_key: await key_file.text(),
|
|
207
331
|
repository_name: canary_repostiory,
|
|
@@ -211,6 +335,7 @@ export async function dispatch_report(report_title: string, report_body: Array<u
|
|
|
211
335
|
issue_labels: config.canary.labels
|
|
212
336
|
});
|
|
213
337
|
} catch (e) {
|
|
214
|
-
log(
|
|
338
|
+
log((e as Error).message);
|
|
215
339
|
}
|
|
216
|
-
}
|
|
340
|
+
}
|
|
341
|
+
// endregion
|