watchfix 0.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/agents/base.d.ts +19 -0
- package/dist/agents/base.js +140 -0
- package/dist/agents/claude.d.ts +11 -0
- package/dist/agents/claude.js +6 -0
- package/dist/agents/codex.d.ts +11 -0
- package/dist/agents/codex.js +6 -0
- package/dist/agents/defaults.d.ts +21 -0
- package/dist/agents/defaults.js +21 -0
- package/dist/agents/gemini.d.ts +11 -0
- package/dist/agents/gemini.js +6 -0
- package/dist/agents/index.d.ts +19 -0
- package/dist/agents/index.js +63 -0
- package/dist/agents/types.d.ts +22 -0
- package/dist/agents/types.js +1 -0
- package/dist/cli/commands/clean.d.ts +9 -0
- package/dist/cli/commands/clean.js +173 -0
- package/dist/cli/commands/config.d.ts +7 -0
- package/dist/cli/commands/config.js +134 -0
- package/dist/cli/commands/fix.d.ts +12 -0
- package/dist/cli/commands/fix.js +391 -0
- package/dist/cli/commands/ignore.d.ts +7 -0
- package/dist/cli/commands/ignore.js +74 -0
- package/dist/cli/commands/init.d.ts +5 -0
- package/dist/cli/commands/init.js +115 -0
- package/dist/cli/commands/logs.d.ts +9 -0
- package/dist/cli/commands/logs.js +106 -0
- package/dist/cli/commands/show.d.ts +8 -0
- package/dist/cli/commands/show.js +165 -0
- package/dist/cli/commands/status.d.ts +7 -0
- package/dist/cli/commands/status.js +110 -0
- package/dist/cli/commands/stop.d.ts +7 -0
- package/dist/cli/commands/stop.js +106 -0
- package/dist/cli/commands/version.d.ts +7 -0
- package/dist/cli/commands/version.js +36 -0
- package/dist/cli/commands/watch.d.ts +10 -0
- package/dist/cli/commands/watch.js +204 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +152 -0
- package/dist/config/loader.d.ts +4 -0
- package/dist/config/loader.js +96 -0
- package/dist/config/schema.d.ts +375 -0
- package/dist/config/schema.js +99 -0
- package/dist/db/index.d.ts +15 -0
- package/dist/db/index.js +71 -0
- package/dist/db/queries.d.ts +45 -0
- package/dist/db/queries.js +111 -0
- package/dist/db/schema.d.ts +4 -0
- package/dist/db/schema.js +84 -0
- package/dist/fixer/context.d.ts +9 -0
- package/dist/fixer/context.js +361 -0
- package/dist/fixer/index.d.ts +37 -0
- package/dist/fixer/index.js +398 -0
- package/dist/fixer/lock.d.ts +7 -0
- package/dist/fixer/lock.js +49 -0
- package/dist/fixer/output.d.ts +21 -0
- package/dist/fixer/output.js +108 -0
- package/dist/fixer/queue.d.ts +15 -0
- package/dist/fixer/queue.js +53 -0
- package/dist/fixer/verifier.d.ts +44 -0
- package/dist/fixer/verifier.js +133 -0
- package/dist/utils/daemon.d.ts +22 -0
- package/dist/utils/daemon.js +143 -0
- package/dist/utils/duration.d.ts +2 -0
- package/dist/utils/duration.js +31 -0
- package/dist/utils/errors.d.ts +17 -0
- package/dist/utils/errors.js +20 -0
- package/dist/utils/hash.d.ts +2 -0
- package/dist/utils/hash.js +18 -0
- package/dist/utils/http.d.ts +6 -0
- package/dist/utils/http.js +61 -0
- package/dist/utils/logger.d.ts +25 -0
- package/dist/utils/logger.js +85 -0
- package/dist/utils/process.d.ts +16 -0
- package/dist/utils/process.js +146 -0
- package/dist/watcher/index.d.ts +55 -0
- package/dist/watcher/index.js +234 -0
- package/dist/watcher/parser.d.ts +42 -0
- package/dist/watcher/parser.js +162 -0
- package/dist/watcher/patterns.d.ts +5 -0
- package/dist/watcher/patterns.js +92 -0
- package/dist/watcher/sources/command.d.ts +27 -0
- package/dist/watcher/sources/command.js +143 -0
- package/dist/watcher/sources/docker.d.ts +28 -0
- package/dist/watcher/sources/docker.js +183 -0
- package/dist/watcher/sources/file.d.ts +30 -0
- package/dist/watcher/sources/file.js +177 -0
- package/dist/watcher/sources/types.d.ts +27 -0
- package/dist/watcher/sources/types.js +1 -0
- package/package.json +38 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Config } from '../config/schema.js';
|
|
2
|
+
import { Logger } from '../utils/logger.js';
|
|
3
|
+
type VerificationCommandResult = {
|
|
4
|
+
command: string;
|
|
5
|
+
success: boolean;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
exitCode: number;
|
|
9
|
+
timedOut: boolean;
|
|
10
|
+
};
|
|
11
|
+
type VerificationHealthCheckResult = {
|
|
12
|
+
url: string;
|
|
13
|
+
success: boolean;
|
|
14
|
+
status?: number;
|
|
15
|
+
error?: string;
|
|
16
|
+
};
|
|
17
|
+
type VerificationFailure = {
|
|
18
|
+
type: 'command';
|
|
19
|
+
command: string;
|
|
20
|
+
message: string;
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
exitCode: number;
|
|
24
|
+
timedOut: boolean;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'health_check';
|
|
27
|
+
url: string;
|
|
28
|
+
message: string;
|
|
29
|
+
status?: number;
|
|
30
|
+
error?: string;
|
|
31
|
+
};
|
|
32
|
+
export type VerificationResult = {
|
|
33
|
+
success: boolean;
|
|
34
|
+
commandResults: VerificationCommandResult[];
|
|
35
|
+
healthCheckResults: VerificationHealthCheckResult[];
|
|
36
|
+
failure?: VerificationFailure;
|
|
37
|
+
};
|
|
38
|
+
type RunVerificationOptions = {
|
|
39
|
+
logger?: Logger;
|
|
40
|
+
terminalEnabled?: boolean;
|
|
41
|
+
sleep?: (ms: number) => Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
export declare function runVerification(config: Config, options?: RunVerificationOptions): Promise<VerificationResult>;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { parseDuration, formatDuration } from '../utils/duration.js';
|
|
2
|
+
import { checkHealth } from '../utils/http.js';
|
|
3
|
+
import { Logger } from '../utils/logger.js';
|
|
4
|
+
import { spawnWithTimeout } from '../utils/process.js';
|
|
5
|
+
const defaultSleep = async (ms) => await new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
const formatCommandFailureMessage = (command, result, timeoutMs) => {
|
|
7
|
+
if (result.timedOut) {
|
|
8
|
+
return `Command timed out after ${formatDuration(timeoutMs)}: ${command}`;
|
|
9
|
+
}
|
|
10
|
+
if (result.exitCode !== 0) {
|
|
11
|
+
return `Command exited with code ${result.exitCode}: ${command}`;
|
|
12
|
+
}
|
|
13
|
+
return `Command failed: ${command}`;
|
|
14
|
+
};
|
|
15
|
+
const logCommandOutput = (logger, command, result, level = 'debug') => {
|
|
16
|
+
const stdout = result.stdout.trim();
|
|
17
|
+
const stderr = result.stderr.trim();
|
|
18
|
+
const log = (message) => {
|
|
19
|
+
if (level === 'info') {
|
|
20
|
+
logger.info(message);
|
|
21
|
+
}
|
|
22
|
+
else if (level === 'warn') {
|
|
23
|
+
logger.warn(message);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
logger.debug(message);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
if (stdout) {
|
|
30
|
+
log(`Command stdout (${command}):\n${stdout}`);
|
|
31
|
+
}
|
|
32
|
+
if (stderr) {
|
|
33
|
+
logger.warn(`Command stderr (${command}):\n${stderr}`);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const logHealthCheckFailure = (logger, url, result) => {
|
|
37
|
+
if (result.error) {
|
|
38
|
+
const message = `Health check failed for ${url}: ${result.error}`;
|
|
39
|
+
logger.error(message);
|
|
40
|
+
return message;
|
|
41
|
+
}
|
|
42
|
+
if (typeof result.status === 'number') {
|
|
43
|
+
const message = `Health check failed for ${url}: status ${result.status}`;
|
|
44
|
+
logger.error(message);
|
|
45
|
+
return message;
|
|
46
|
+
}
|
|
47
|
+
const message = `Health check failed for ${url}`;
|
|
48
|
+
logger.error(message);
|
|
49
|
+
return message;
|
|
50
|
+
};
|
|
51
|
+
export async function runVerification(config, options) {
|
|
52
|
+
const logger = options?.logger ??
|
|
53
|
+
new Logger({
|
|
54
|
+
rootDir: config.project.root,
|
|
55
|
+
terminalEnabled: options?.terminalEnabled ?? true,
|
|
56
|
+
});
|
|
57
|
+
const sleep = options?.sleep ?? defaultSleep;
|
|
58
|
+
const verification = config.verification;
|
|
59
|
+
const waitMs = parseDuration(verification.wait_after_fix);
|
|
60
|
+
if (waitMs > 0) {
|
|
61
|
+
logger.info(`Waiting ${formatDuration(waitMs)} before verification`);
|
|
62
|
+
await sleep(waitMs);
|
|
63
|
+
}
|
|
64
|
+
const commandResults = [];
|
|
65
|
+
const healthCheckResults = [];
|
|
66
|
+
const commands = verification.test_commands ?? [];
|
|
67
|
+
if (commands.length > 0) {
|
|
68
|
+
const timeoutMs = parseDuration(verification.test_command_timeout);
|
|
69
|
+
for (const command of commands) {
|
|
70
|
+
logger.info(`Running verification command: ${command}`);
|
|
71
|
+
const result = await spawnWithTimeout(command, [], { cwd: config.project.root, shell: true }, timeoutMs);
|
|
72
|
+
const commandResult = {
|
|
73
|
+
command,
|
|
74
|
+
success: result.success,
|
|
75
|
+
stdout: result.stdout,
|
|
76
|
+
stderr: result.stderr,
|
|
77
|
+
exitCode: result.exitCode,
|
|
78
|
+
timedOut: result.timedOut,
|
|
79
|
+
};
|
|
80
|
+
commandResults.push(commandResult);
|
|
81
|
+
if (!result.success) {
|
|
82
|
+
const message = formatCommandFailureMessage(command, result, timeoutMs);
|
|
83
|
+
logger.error(message);
|
|
84
|
+
logCommandOutput(logger, command, result, 'info');
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
commandResults,
|
|
88
|
+
healthCheckResults,
|
|
89
|
+
failure: {
|
|
90
|
+
type: 'command',
|
|
91
|
+
command,
|
|
92
|
+
message,
|
|
93
|
+
stdout: result.stdout,
|
|
94
|
+
stderr: result.stderr,
|
|
95
|
+
exitCode: result.exitCode,
|
|
96
|
+
timedOut: result.timedOut,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
logCommandOutput(logger, command, result);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const healthChecks = verification.health_checks ?? [];
|
|
104
|
+
if (healthChecks.length > 0) {
|
|
105
|
+
const timeoutMs = parseDuration(verification.health_check_timeout);
|
|
106
|
+
for (const url of healthChecks) {
|
|
107
|
+
logger.info(`Running health check: ${url}`);
|
|
108
|
+
const result = await checkHealth(url, timeoutMs);
|
|
109
|
+
healthCheckResults.push({ url, ...result });
|
|
110
|
+
if (!result.success) {
|
|
111
|
+
const message = logHealthCheckFailure(logger, url, result);
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
commandResults,
|
|
115
|
+
healthCheckResults,
|
|
116
|
+
failure: {
|
|
117
|
+
type: 'health_check',
|
|
118
|
+
url,
|
|
119
|
+
message,
|
|
120
|
+
status: result.status,
|
|
121
|
+
error: result.error,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
logger.info('Verification succeeded');
|
|
128
|
+
return {
|
|
129
|
+
success: true,
|
|
130
|
+
commandResults,
|
|
131
|
+
healthCheckResults,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Database } from '../db/index.js';
|
|
2
|
+
import type { Logger } from './logger.js';
|
|
3
|
+
export type CurrentFix = {
|
|
4
|
+
promise: Promise<unknown>;
|
|
5
|
+
abort: () => void;
|
|
6
|
+
};
|
|
7
|
+
export type DaemonOrchestrator = {
|
|
8
|
+
stopWatchers?: () => void | Promise<void>;
|
|
9
|
+
stop?: () => void | Promise<void>;
|
|
10
|
+
getCurrentFix?: () => CurrentFix | null;
|
|
11
|
+
releaseAllLocks?: () => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
type SetupSignalOptions = {
|
|
14
|
+
logger?: Logger;
|
|
15
|
+
db?: Database;
|
|
16
|
+
clearWatcherState?: () => void;
|
|
17
|
+
onShutdown?: () => void | Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
export declare function daemonize(): number;
|
|
20
|
+
export declare function setupSignalHandlers(orchestrator: DaemonOrchestrator, options?: SetupSignalOptions): void;
|
|
21
|
+
export declare function recoverStaleErrors(db: Database, logger?: Logger): void;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { logActivity } from '../db/queries.js';
|
|
3
|
+
import { LOCK_TIMEOUT_MS } from '../fixer/lock.js';
|
|
4
|
+
import { EXIT_CODES, InternalError, UserError } from './errors.js';
|
|
5
|
+
const SHUTDOWN_TIMEOUT_MS = 30_000;
|
|
6
|
+
function getForwardedArgs() {
|
|
7
|
+
const argv = process.argv.slice(2);
|
|
8
|
+
const filtered = argv.filter((arg) => arg !== '--daemon' && arg !== '--daemon-child');
|
|
9
|
+
const watchIndex = filtered.indexOf('watch');
|
|
10
|
+
if (watchIndex >= 0) {
|
|
11
|
+
filtered.splice(watchIndex + 1, 0, '--daemon-child');
|
|
12
|
+
return filtered;
|
|
13
|
+
}
|
|
14
|
+
return ['watch', '--daemon-child', ...filtered];
|
|
15
|
+
}
|
|
16
|
+
export function daemonize() {
|
|
17
|
+
if (process.platform === 'win32') {
|
|
18
|
+
throw new UserError('Daemon mode is not supported on Windows.\n' +
|
|
19
|
+
'Use foreground mode: watchfix watch --autonomous\n' +
|
|
20
|
+
'Or use a process manager like PM2: pm2 start watchfix -- watch --autonomous');
|
|
21
|
+
}
|
|
22
|
+
const scriptPath = process.argv[1];
|
|
23
|
+
if (!scriptPath) {
|
|
24
|
+
throw new InternalError('Unable to resolve watchfix entrypoint.');
|
|
25
|
+
}
|
|
26
|
+
const args = getForwardedArgs();
|
|
27
|
+
const child = spawn(process.execPath, [scriptPath, ...args], {
|
|
28
|
+
detached: true,
|
|
29
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
30
|
+
cwd: process.cwd(),
|
|
31
|
+
env: { ...process.env, WATCHFIX_DAEMON: '1' },
|
|
32
|
+
});
|
|
33
|
+
child.unref();
|
|
34
|
+
if (!child.pid) {
|
|
35
|
+
throw new InternalError('Failed to spawn daemon process.');
|
|
36
|
+
}
|
|
37
|
+
return child.pid;
|
|
38
|
+
}
|
|
39
|
+
export function setupSignalHandlers(orchestrator, options = {}) {
|
|
40
|
+
let shuttingDown = false;
|
|
41
|
+
const logger = options.logger;
|
|
42
|
+
const logInfo = (message) => {
|
|
43
|
+
if (logger) {
|
|
44
|
+
logger.info(message);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
process.stderr.write(`${message}\n`);
|
|
48
|
+
};
|
|
49
|
+
const logWarn = (message) => {
|
|
50
|
+
if (logger) {
|
|
51
|
+
logger.warn(message);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
process.stderr.write(`${message}\n`);
|
|
55
|
+
};
|
|
56
|
+
const shutdown = async (signal) => {
|
|
57
|
+
if (shuttingDown) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
shuttingDown = true;
|
|
61
|
+
logInfo(`Received ${signal}, shutting down...`);
|
|
62
|
+
const stopResult = orchestrator.stopWatchers?.() ?? orchestrator.stop?.();
|
|
63
|
+
if (stopResult instanceof Promise) {
|
|
64
|
+
await stopResult;
|
|
65
|
+
}
|
|
66
|
+
const currentFix = orchestrator.getCurrentFix?.() ?? null;
|
|
67
|
+
if (currentFix) {
|
|
68
|
+
logInfo('Waiting for current fix to complete...');
|
|
69
|
+
let timeoutId = null;
|
|
70
|
+
try {
|
|
71
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
72
|
+
timeoutId = setTimeout(() => {
|
|
73
|
+
logWarn('Fix did not complete in time, aborting');
|
|
74
|
+
currentFix.abort();
|
|
75
|
+
resolve('timeout');
|
|
76
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
77
|
+
});
|
|
78
|
+
const result = await Promise.race([
|
|
79
|
+
currentFix.promise.then(() => 'completed'),
|
|
80
|
+
timeoutPromise,
|
|
81
|
+
]);
|
|
82
|
+
if (result === 'timeout') {
|
|
83
|
+
logWarn('Continuing shutdown without waiting for fix completion');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
logWarn(`Current fix ended with error during shutdown: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
if (timeoutId) {
|
|
91
|
+
clearTimeout(timeoutId);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (orchestrator.releaseAllLocks) {
|
|
96
|
+
await orchestrator.releaseAllLocks();
|
|
97
|
+
}
|
|
98
|
+
if (options.clearWatcherState) {
|
|
99
|
+
options.clearWatcherState();
|
|
100
|
+
}
|
|
101
|
+
if (options.db) {
|
|
102
|
+
logActivity(options.db, 'watcher_stop', undefined, JSON.stringify({ signal, graceful: true }));
|
|
103
|
+
options.db.close();
|
|
104
|
+
}
|
|
105
|
+
if (options.onShutdown) {
|
|
106
|
+
await options.onShutdown();
|
|
107
|
+
}
|
|
108
|
+
logInfo('Shutdown complete');
|
|
109
|
+
const exitCode = signal === 'SIGINT' ? EXIT_CODES.INTERRUPTED : EXIT_CODES.SUCCESS;
|
|
110
|
+
process.exit(exitCode);
|
|
111
|
+
};
|
|
112
|
+
process.on('SIGTERM', () => {
|
|
113
|
+
void shutdown('SIGTERM');
|
|
114
|
+
});
|
|
115
|
+
process.on('SIGINT', () => {
|
|
116
|
+
void shutdown('SIGINT');
|
|
117
|
+
});
|
|
118
|
+
if (process.platform !== 'win32') {
|
|
119
|
+
process.on('SIGHUP', () => {
|
|
120
|
+
void shutdown('SIGHUP');
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function recoverStaleErrors(db, logger) {
|
|
125
|
+
const staleThreshold = new Date(Date.now() - LOCK_TIMEOUT_MS).toISOString();
|
|
126
|
+
const now = new Date().toISOString();
|
|
127
|
+
const result1 = db.run(`UPDATE errors
|
|
128
|
+
SET status = 'pending', locked_by = NULL, locked_at = NULL, updated_at = ?
|
|
129
|
+
WHERE status IN ('analyzing', 'fixing')
|
|
130
|
+
AND locked_at < ?`, [now, staleThreshold]);
|
|
131
|
+
const result2 = db.run(`UPDATE errors
|
|
132
|
+
SET locked_by = NULL, locked_at = NULL, updated_at = ?
|
|
133
|
+
WHERE status = 'suggested'
|
|
134
|
+
AND locked_by IS NOT NULL
|
|
135
|
+
AND locked_at < ?`, [now, staleThreshold]);
|
|
136
|
+
const total = result1.changes + result2.changes;
|
|
137
|
+
if (total > 0) {
|
|
138
|
+
if (logger) {
|
|
139
|
+
logger.warn(`Recovered ${result1.changes} stale error(s), cleared ${result2.changes} stale lock(s)`);
|
|
140
|
+
}
|
|
141
|
+
logActivity(db, 'stale_recovery', undefined, JSON.stringify({ reset: result1.changes, unlocked: result2.changes }));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { UserError } from './errors.js';
|
|
2
|
+
const DURATION_REGEX = /^(\d+(?:\.\d+)?)([smh])$/;
|
|
3
|
+
const SECOND_MS = 1000;
|
|
4
|
+
const MINUTE_MS = 60 * SECOND_MS;
|
|
5
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
6
|
+
export function parseDuration(input) {
|
|
7
|
+
const trimmed = input.trim();
|
|
8
|
+
const match = DURATION_REGEX.exec(trimmed);
|
|
9
|
+
if (!match) {
|
|
10
|
+
throw new UserError(`Invalid duration: ${input}`);
|
|
11
|
+
}
|
|
12
|
+
const value = Number(match[1]);
|
|
13
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
14
|
+
throw new UserError(`Invalid duration: ${input}`);
|
|
15
|
+
}
|
|
16
|
+
const unit = match[2];
|
|
17
|
+
const multiplier = unit === 'h' ? HOUR_MS : unit === 'm' ? MINUTE_MS : SECOND_MS;
|
|
18
|
+
return value * multiplier;
|
|
19
|
+
}
|
|
20
|
+
export function formatDuration(ms) {
|
|
21
|
+
if (ms % HOUR_MS === 0) {
|
|
22
|
+
return `${ms / HOUR_MS}h`;
|
|
23
|
+
}
|
|
24
|
+
if (ms % MINUTE_MS === 0) {
|
|
25
|
+
return `${ms / MINUTE_MS}m`;
|
|
26
|
+
}
|
|
27
|
+
if (ms % SECOND_MS === 0) {
|
|
28
|
+
return `${ms / SECOND_MS}s`;
|
|
29
|
+
}
|
|
30
|
+
return `${ms}ms`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class UserError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class InternalError extends Error {
|
|
5
|
+
constructor(message: string, options?: {
|
|
6
|
+
cause?: unknown;
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export type ErrorStatus = 'pending' | 'analyzing' | 'suggested' | 'fixing' | 'fixed' | 'failed' | 'ignored';
|
|
10
|
+
export declare const EXIT_CODES: {
|
|
11
|
+
readonly SUCCESS: 0;
|
|
12
|
+
readonly GENERAL_ERROR: 1;
|
|
13
|
+
readonly WATCHER_CONFLICT: 2;
|
|
14
|
+
readonly NOT_ACTIONABLE: 3;
|
|
15
|
+
readonly SCHEMA_MISMATCH: 4;
|
|
16
|
+
readonly INTERRUPTED: 130;
|
|
17
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class UserError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'UserError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class InternalError extends Error {
|
|
8
|
+
constructor(message, options) {
|
|
9
|
+
super(message, options);
|
|
10
|
+
this.name = 'InternalError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export const EXIT_CODES = {
|
|
14
|
+
SUCCESS: 0,
|
|
15
|
+
GENERAL_ERROR: 1,
|
|
16
|
+
WATCHER_CONFLICT: 2,
|
|
17
|
+
NOT_ACTIONABLE: 3,
|
|
18
|
+
SCHEMA_MISMATCH: 4,
|
|
19
|
+
INTERRUPTED: 130,
|
|
20
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
const ISO_TIMESTAMP_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?/g;
|
|
3
|
+
const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
4
|
+
const HEX_ADDRESS_REGEX = /0x[0-9a-f]+/gi;
|
|
5
|
+
const WHITESPACE_REGEX = /\s+/g;
|
|
6
|
+
export function normalizeMessage(message) {
|
|
7
|
+
return message
|
|
8
|
+
.replace(ISO_TIMESTAMP_REGEX, '')
|
|
9
|
+
.replace(UUID_REGEX, '')
|
|
10
|
+
.replace(HEX_ADDRESS_REGEX, '')
|
|
11
|
+
.replace(WHITESPACE_REGEX, ' ')
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
export function computeErrorHash(source, errorType, message) {
|
|
15
|
+
const normalizedMessage = normalizeMessage(message);
|
|
16
|
+
const input = `${source}${errorType}${normalizedMessage}`;
|
|
17
|
+
return createHash('sha256').update(input).digest('hex');
|
|
18
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const MAX_REDIRECTS = 5;
|
|
2
|
+
const USER_AGENT = 'watchfix/1.0';
|
|
3
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
4
|
+
function isRedirect(status) {
|
|
5
|
+
return REDIRECT_STATUSES.has(status);
|
|
6
|
+
}
|
|
7
|
+
function isAbortError(error) {
|
|
8
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
9
|
+
}
|
|
10
|
+
function resolveErrorMessage(error) {
|
|
11
|
+
if (error && typeof error === 'object') {
|
|
12
|
+
const err = error;
|
|
13
|
+
return (err.cause?.code ??
|
|
14
|
+
err.cause?.message ??
|
|
15
|
+
err.code ??
|
|
16
|
+
err.message ??
|
|
17
|
+
'Network error');
|
|
18
|
+
}
|
|
19
|
+
return 'Network error';
|
|
20
|
+
}
|
|
21
|
+
export async function checkHealth(url, timeout = 10_000) {
|
|
22
|
+
let currentUrl = url;
|
|
23
|
+
let redirects = 0;
|
|
24
|
+
while (redirects <= MAX_REDIRECTS) {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(currentUrl, {
|
|
29
|
+
method: 'GET',
|
|
30
|
+
headers: { 'User-Agent': USER_AGENT },
|
|
31
|
+
redirect: 'manual',
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
});
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
if (isRedirect(response.status)) {
|
|
36
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
37
|
+
return { success: false, error: 'Too many redirects' };
|
|
38
|
+
}
|
|
39
|
+
const location = response.headers.get('location');
|
|
40
|
+
if (!location) {
|
|
41
|
+
return { success: false, status: response.status };
|
|
42
|
+
}
|
|
43
|
+
redirects += 1;
|
|
44
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (response.status >= 200 && response.status <= 299) {
|
|
48
|
+
return { success: true, status: response.status };
|
|
49
|
+
}
|
|
50
|
+
return { success: false, status: response.status };
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (isAbortError(error)) {
|
|
55
|
+
return { success: false, error: 'Request timed out' };
|
|
56
|
+
}
|
|
57
|
+
return { success: false, error: resolveErrorMessage(error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { success: false, error: 'Too many redirects' };
|
|
61
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type Verbosity = 'quiet' | 'normal' | 'verbose';
|
|
2
|
+
export declare class Logger {
|
|
3
|
+
private verbosity;
|
|
4
|
+
private terminalEnabled;
|
|
5
|
+
private readonly logDir;
|
|
6
|
+
private readonly logPath;
|
|
7
|
+
constructor(options?: {
|
|
8
|
+
rootDir?: string;
|
|
9
|
+
verbosity?: Verbosity;
|
|
10
|
+
terminalEnabled?: boolean;
|
|
11
|
+
logDir?: string;
|
|
12
|
+
logFile?: string;
|
|
13
|
+
});
|
|
14
|
+
setVerbosity(level: Verbosity): void;
|
|
15
|
+
setTerminalEnabled(enabled: boolean): void;
|
|
16
|
+
debug(message: string): void;
|
|
17
|
+
info(message: string): void;
|
|
18
|
+
warn(message: string): void;
|
|
19
|
+
error(message: string): void;
|
|
20
|
+
private write;
|
|
21
|
+
private shouldLog;
|
|
22
|
+
private ensureLogDir;
|
|
23
|
+
private rotateIfNeeded;
|
|
24
|
+
}
|
|
25
|
+
export declare const DEFAULT_LOG_PATH = ".watchfix/daemon.log";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const LOG_ROTATION = {
|
|
4
|
+
maxSize: 10 * 1024 * 1024,
|
|
5
|
+
maxFiles: 5,
|
|
6
|
+
};
|
|
7
|
+
const VERBOSITY_LEVELS = {
|
|
8
|
+
quiet: ['WARN', 'ERROR'],
|
|
9
|
+
normal: ['INFO', 'WARN', 'ERROR'],
|
|
10
|
+
verbose: ['DEBUG', 'INFO', 'WARN', 'ERROR'],
|
|
11
|
+
};
|
|
12
|
+
export class Logger {
|
|
13
|
+
verbosity;
|
|
14
|
+
terminalEnabled;
|
|
15
|
+
logDir;
|
|
16
|
+
logPath;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
const rootDir = options?.rootDir ?? process.cwd();
|
|
19
|
+
this.logDir = options?.logDir ?? path.join(rootDir, '.watchfix');
|
|
20
|
+
const logFile = options?.logFile ?? 'daemon.log';
|
|
21
|
+
this.logPath = path.join(this.logDir, logFile);
|
|
22
|
+
this.verbosity = options?.verbosity ?? 'normal';
|
|
23
|
+
this.terminalEnabled = options?.terminalEnabled ?? true;
|
|
24
|
+
}
|
|
25
|
+
setVerbosity(level) {
|
|
26
|
+
this.verbosity = level;
|
|
27
|
+
}
|
|
28
|
+
setTerminalEnabled(enabled) {
|
|
29
|
+
this.terminalEnabled = enabled;
|
|
30
|
+
}
|
|
31
|
+
debug(message) {
|
|
32
|
+
this.write('DEBUG', message);
|
|
33
|
+
}
|
|
34
|
+
info(message) {
|
|
35
|
+
this.write('INFO', message);
|
|
36
|
+
}
|
|
37
|
+
warn(message) {
|
|
38
|
+
this.write('WARN', message);
|
|
39
|
+
}
|
|
40
|
+
error(message) {
|
|
41
|
+
this.write('ERROR', message);
|
|
42
|
+
}
|
|
43
|
+
write(level, message) {
|
|
44
|
+
if (!this.shouldLog(level)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.ensureLogDir();
|
|
48
|
+
this.rotateIfNeeded();
|
|
49
|
+
const line = `${new Date().toISOString()} [${level}] ${message}\n`;
|
|
50
|
+
fs.appendFileSync(this.logPath, line, 'utf8');
|
|
51
|
+
if (this.terminalEnabled) {
|
|
52
|
+
process.stderr.write(line);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
shouldLog(level) {
|
|
56
|
+
return VERBOSITY_LEVELS[this.verbosity].includes(level);
|
|
57
|
+
}
|
|
58
|
+
ensureLogDir() {
|
|
59
|
+
if (!fs.existsSync(this.logDir)) {
|
|
60
|
+
fs.mkdirSync(this.logDir, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
rotateIfNeeded() {
|
|
64
|
+
if (!fs.existsSync(this.logPath)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const { size } = fs.statSync(this.logPath);
|
|
68
|
+
if (size < LOG_ROTATION.maxSize) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const oldest = `${this.logPath}.${LOG_ROTATION.maxFiles}`;
|
|
72
|
+
if (fs.existsSync(oldest)) {
|
|
73
|
+
fs.rmSync(oldest);
|
|
74
|
+
}
|
|
75
|
+
for (let index = LOG_ROTATION.maxFiles - 1; index >= 1; index -= 1) {
|
|
76
|
+
const source = `${this.logPath}.${index}`;
|
|
77
|
+
const destination = `${this.logPath}.${index + 1}`;
|
|
78
|
+
if (fs.existsSync(source)) {
|
|
79
|
+
fs.renameSync(source, destination);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
fs.renameSync(this.logPath, `${this.logPath}.1`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export const DEFAULT_LOG_PATH = '.watchfix/daemon.log';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type SpawnOptions } from 'node:child_process';
|
|
2
|
+
export interface CliCheckResult {
|
|
3
|
+
exists: boolean;
|
|
4
|
+
version?: string;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SpawnResult {
|
|
8
|
+
success: boolean;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
exitCode: number;
|
|
12
|
+
timedOut: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function checkCliExists(command: string): CliCheckResult;
|
|
15
|
+
export declare function spawnWithTimeout(command: string, args: string[], options: SpawnOptions | undefined, timeoutMs: number): Promise<SpawnResult>;
|
|
16
|
+
export declare function isOurProcess(pid: number, expectedRoot: string): boolean;
|