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/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
- log('start_server');
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
- log('[{dev}] spooder has been started in {dev mode}');
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
- log('[{update}] skipping update commands in {dev mode}');
69
+ log_cli('[{update}] skipping update commands in {dev mode}');
20
70
  } else if (skip_updates) {
21
- log('[{update}] skipping update commands due to {--no-update} flag');
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
- log('running {%d} update commands', n_update_commands);
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
- log('[{%d}] %s', i, config_update_command);
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
- log('[{%d}] exited with code {%d}', i, update_proc.exitCode);
92
+ log_cli(`[{${i}}] exited with code {${update_proc.exitCode}}`);
43
93
 
44
94
  if (update_proc.exitCode !== 0) {
45
- log('aborting update due to non-zero exit code from [%d]', i);
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
- log('server exited with code {%s}', proc_exit_code);
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
- log('[{dev}] crash: server exited unexpectedly (exit code {%d})', proc_exit_code);
98
- log('[{dev}] without {--dev}, this would raise a canary report');
99
- log('[{dev}] console output:\n%s', console_output);
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
- const auto_restart_ms = config.auto_restart;
108
- if (auto_restart_ms > -1) {
157
+ if (config.auto_restart) {
109
158
  if (is_dev_mode) {
110
- log('[{dev}] auto-restart is {disabled} in {dev mode}');
159
+ log_cli(`[{dev}] auto-restart is {disabled} in {dev mode}`);
111
160
  process.exit(proc_exit_code ?? 0);
112
- } else {
113
- log('restarting server in {%dms}', auto_restart_ms);
114
- setTimeout(start_server, auto_restart_ms);
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 { log } from './utils';
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: -1,
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
- log('ignoring invalid configuration value {%s} (expected {%s}, got {%s})', key_name, expected_type, actual_type);
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
- log('ignoring invalid configuration value {%s} (expected array)', key_name);
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
- log('ignoring invalid configuration value `%s` (expected object)', key_name);
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
- log('ignoring unknown configuration key {%s}', key_name);
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
- log('failed to parse spooder configuration in {package.json}, using defaults');
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
- log('failed to read {package.json}, using configuration defaults');
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 { create_github_issue } from './github';
3
- import { log } from './utils';
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('[{canary}] failed to read canary cache file {%s}', cache_file_path);
97
- log('[{canary}] error: ' + (e as Error).message);
98
- log('[{canary}] resolve this issue to prevent spamming GitHub with canary reports');
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('[{canary}] report dispatch failed; no account/repository configured');
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('[{canary}] throttled canary report: {%s}', report_title);
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 create_github_issue({
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('[{canary error}] ' + (e as Error)?.message ?? 'unspecified error');
338
+ log((e as Error).message);
215
339
  }
216
- }
340
+ }
341
+ // endregion