ownkey 0.1.1 → 0.2.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/.prettierrc +9 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/commands/apply.d.ts +3 -0
- package/dist/commands/apply.d.ts.map +1 -0
- package/dist/commands/apply.js +26 -0
- package/dist/commands/apply.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +142 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/scan.d.ts +3 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +97 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/suggest.d.ts +3 -0
- package/dist/commands/suggest.d.ts.map +1 -0
- package/dist/commands/suggest.js +34 -0
- package/dist/commands/suggest.js.map +1 -0
- package/dist/config/defaults.d.ts +12 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +74 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/keychain.d.ts +14 -0
- package/dist/config/keychain.d.ts.map +1 -0
- package/dist/config/keychain.js +112 -0
- package/dist/config/keychain.js.map +1 -0
- package/dist/config/manager.d.ts +21 -0
- package/dist/config/manager.d.ts.map +1 -0
- package/dist/config/manager.js +144 -0
- package/dist/config/manager.js.map +1 -0
- package/dist/core/scanner.d.ts +17 -0
- package/dist/core/scanner.d.ts.map +1 -0
- package/dist/core/scanner.js +150 -0
- package/dist/core/scanner.js.map +1 -0
- package/dist/db/client.d.ts +15 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +130 -0
- package/dist/db/client.js.map +1 -0
- package/dist/db/operations.d.ts +14 -0
- package/dist/db/operations.d.ts.map +1 -0
- package/dist/db/operations.js +175 -0
- package/dist/db/operations.js.map +1 -0
- package/dist/db/schema.d.ts +683 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +107 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -2
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +138 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +37 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/encryption.d.ts +6 -0
- package/dist/utils/encryption.d.ts.map +1 -0
- package/dist/utils/encryption.js +39 -0
- package/dist/utils/encryption.js.map +1 -0
- package/dist/utils/logger.d.ts +25 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +97 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +56 -29
- package/src/commands/apply.ts +27 -0
- package/src/commands/config.ts +154 -0
- package/src/commands/scan.ts +114 -0
- package/src/commands/suggest.ts +37 -0
- package/src/config/defaults.ts +80 -0
- package/src/config/keychain.ts +125 -0
- package/src/config/manager.ts +166 -0
- package/src/core/scanner.ts +174 -0
- package/src/db/client.ts +151 -0
- package/src/db/migrations/001_init.sql +80 -0
- package/src/db/operations.ts +217 -0
- package/src/db/schema.ts +118 -0
- package/src/index.ts +74 -0
- package/src/types/index.ts +198 -0
- package/src/utils/encryption.ts +54 -0
- package/src/utils/logger.ts +111 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Config, ConfigError, AIProvider } from '../types/index.js';
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
import { keychainManager } from './keychain.js';
|
|
8
|
+
import { DEFAULT_CONFIG, CONFIG_DIR, CONFIG_FILE } from './defaults.js';
|
|
9
|
+
|
|
10
|
+
export class ConfigManager {
|
|
11
|
+
private configPath: string;
|
|
12
|
+
private config: Config | null = null;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
this.configPath = join(homedir(), CONFIG_DIR, CONFIG_FILE);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async ensureConfigDir(): Promise<void> {
|
|
19
|
+
const dir = join(homedir(), CONFIG_DIR);
|
|
20
|
+
if (!existsSync(dir)) {
|
|
21
|
+
await mkdir(dir, { recursive: true });
|
|
22
|
+
logger.verbose(`Created config directory: ${dir}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async exists(): Promise<boolean> {
|
|
27
|
+
return existsSync(this.configPath);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async load(): Promise<Config> {
|
|
31
|
+
if (this.config) {
|
|
32
|
+
return this.config;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!existsSync(this.configPath)) {
|
|
36
|
+
logger.verbose('No config file found, using defaults');
|
|
37
|
+
this.config = { ...DEFAULT_CONFIG };
|
|
38
|
+
return this.config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const content = await readFile(this.configPath, 'utf-8');
|
|
43
|
+
const loaded = JSON.parse(content);
|
|
44
|
+
|
|
45
|
+
// Merge with defaults to ensure all fields exist
|
|
46
|
+
this.config = {
|
|
47
|
+
...DEFAULT_CONFIG,
|
|
48
|
+
...loaded,
|
|
49
|
+
providers: {
|
|
50
|
+
...DEFAULT_CONFIG.providers,
|
|
51
|
+
...loaded.providers,
|
|
52
|
+
},
|
|
53
|
+
defaults: {
|
|
54
|
+
...DEFAULT_CONFIG.defaults,
|
|
55
|
+
...loaded.defaults,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
logger.verbose('Loaded configuration from file');
|
|
60
|
+
return this.config!;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new ConfigError('Failed to load configuration file', error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async save(config: Config): Promise<void> {
|
|
67
|
+
await this.ensureConfigDir();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const content = JSON.stringify(config, null, 2);
|
|
71
|
+
await writeFile(this.configPath, content, 'utf-8');
|
|
72
|
+
this.config = config;
|
|
73
|
+
logger.verbose('Saved configuration to file');
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new ConfigError('Failed to save configuration file', error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async update(updates: Partial<Config>): Promise<void> {
|
|
80
|
+
const current = await this.load();
|
|
81
|
+
const updated = {
|
|
82
|
+
...current,
|
|
83
|
+
...updates,
|
|
84
|
+
providers: {
|
|
85
|
+
...current.providers,
|
|
86
|
+
...updates.providers,
|
|
87
|
+
},
|
|
88
|
+
defaults: {
|
|
89
|
+
...current.defaults,
|
|
90
|
+
...updates.defaults,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
await this.save(updated);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async setSupabase(url: string, serviceRoleKey: string): Promise<void> {
|
|
97
|
+
const config = await this.load();
|
|
98
|
+
config.supabaseUrl = url;
|
|
99
|
+
config.supabaseServiceRoleKey = serviceRoleKey;
|
|
100
|
+
await this.save(config);
|
|
101
|
+
logger.success('Supabase configuration saved');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async setProvider(provider: AIProvider, apiKey: string, model?: string): Promise<void> {
|
|
105
|
+
const config = await this.load();
|
|
106
|
+
config.activeProvider = provider;
|
|
107
|
+
|
|
108
|
+
if (model) {
|
|
109
|
+
if (!config.providers[provider]) {
|
|
110
|
+
config.providers[provider] = {};
|
|
111
|
+
}
|
|
112
|
+
config.providers[provider]!.model = model;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await this.save(config);
|
|
116
|
+
await keychainManager.setApiKey(provider, apiKey);
|
|
117
|
+
logger.success(`${provider} provider configured`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async getApiKey(provider?: AIProvider): Promise<string | null> {
|
|
121
|
+
const config = await this.load();
|
|
122
|
+
const targetProvider = provider || config.activeProvider;
|
|
123
|
+
|
|
124
|
+
if (!targetProvider) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return await keychainManager.getApiKey(targetProvider);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async validate(): Promise<{ valid: boolean; errors: string[] }> {
|
|
132
|
+
const errors: string[] = [];
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const config = await this.load();
|
|
136
|
+
|
|
137
|
+
// Check if at least one provider is configured
|
|
138
|
+
if (!config.activeProvider) {
|
|
139
|
+
errors.push('No active AI provider configured. Run: ownkey config');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check if API key exists for active provider
|
|
143
|
+
if (config.activeProvider) {
|
|
144
|
+
const apiKey = await this.getApiKey(config.activeProvider);
|
|
145
|
+
if (!apiKey) {
|
|
146
|
+
errors.push(`No API key found for ${config.activeProvider}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { valid: errors.length === 0, errors };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
errors.push('Failed to load configuration');
|
|
153
|
+
return { valid: false, errors };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async reset(): Promise<void> {
|
|
158
|
+
this.config = null;
|
|
159
|
+
if (existsSync(this.configPath)) {
|
|
160
|
+
await writeFile(this.configPath, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf-8');
|
|
161
|
+
logger.success('Configuration reset to defaults');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const configManager = new ConfigManager();
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import ignore, { Ignore } from 'ignore';
|
|
2
|
+
import { readFile, stat, readdir } from 'fs/promises';
|
|
3
|
+
import { join, relative, extname } from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { FileMetadata, FileContent, ScanResult } from '../types/index.js';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
import { DEFAULT_EXCLUDES, BINARY_EXTENSIONS, MAX_FILE_SIZE_BYTES } from '../config/defaults.js';
|
|
8
|
+
|
|
9
|
+
export class FileScanner {
|
|
10
|
+
private ig: Ignore;
|
|
11
|
+
private maxFileSize: number;
|
|
12
|
+
private rootPath: string;
|
|
13
|
+
private skippedFiles: string[] = [];
|
|
14
|
+
private warnings: string[] = [];
|
|
15
|
+
|
|
16
|
+
constructor(rootPath: string, maxFileSize: number = MAX_FILE_SIZE_BYTES) {
|
|
17
|
+
this.rootPath = rootPath;
|
|
18
|
+
this.maxFileSize = maxFileSize;
|
|
19
|
+
this.ig = ignore();
|
|
20
|
+
this.ig.add(DEFAULT_EXCLUDES);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async initialize(): Promise<void> {
|
|
24
|
+
// Load .gitignore
|
|
25
|
+
const gitignorePath = join(this.rootPath, '.gitignore');
|
|
26
|
+
if (existsSync(gitignorePath)) {
|
|
27
|
+
const content = await readFile(gitignorePath, 'utf-8');
|
|
28
|
+
this.ig.add(content);
|
|
29
|
+
logger.verbose('Loaded .gitignore patterns');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Load .ownkeyignore
|
|
33
|
+
const ownkeyignorePath = join(this.rootPath, '.ownkeyignore');
|
|
34
|
+
if (existsSync(ownkeyignorePath)) {
|
|
35
|
+
const content = await readFile(ownkeyignorePath, 'utf-8');
|
|
36
|
+
this.ig.add(content);
|
|
37
|
+
logger.verbose('Loaded .ownkeyignore patterns');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async scan(): Promise<ScanResult> {
|
|
42
|
+
await this.initialize();
|
|
43
|
+
|
|
44
|
+
const files: FileMetadata[] = [];
|
|
45
|
+
let totalSize = 0;
|
|
46
|
+
|
|
47
|
+
await this.scanDirectory(this.rootPath, files);
|
|
48
|
+
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
totalSize += file.size;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
logger.verbose(`Scanned ${files.length} files (${this.formatBytes(totalSize)})`);
|
|
54
|
+
|
|
55
|
+
if (this.skippedFiles.length > 0) {
|
|
56
|
+
logger.verbose(`Skipped ${this.skippedFiles.length} files`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
files,
|
|
61
|
+
totalSize,
|
|
62
|
+
skippedFiles: this.skippedFiles,
|
|
63
|
+
warnings: this.warnings,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async scanDirectory(dirPath: string, files: FileMetadata[]): Promise<void> {
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = await readdir(dirPath, { withFileTypes: true });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
this.warnings.push(`Cannot read directory: ${dirPath}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const fullPath = join(dirPath, entry.name);
|
|
78
|
+
const relativePath = relative(this.rootPath, fullPath);
|
|
79
|
+
|
|
80
|
+
// Check if ignored
|
|
81
|
+
if (this.ig.ignores(relativePath)) {
|
|
82
|
+
logger.debug(`Ignored: ${relativePath}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
await this.scanDirectory(fullPath, files);
|
|
88
|
+
} else if (entry.isFile()) {
|
|
89
|
+
await this.processFile(fullPath, relativePath, files);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async processFile(
|
|
95
|
+
fullPath: string,
|
|
96
|
+
relativePath: string,
|
|
97
|
+
files: FileMetadata[]
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
const stats = await stat(fullPath);
|
|
101
|
+
|
|
102
|
+
// Check file size
|
|
103
|
+
if (stats.size > this.maxFileSize) {
|
|
104
|
+
this.skippedFiles.push(`${relativePath} (too large: ${this.formatBytes(stats.size)})`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if binary
|
|
109
|
+
const ext = extname(fullPath).toLowerCase();
|
|
110
|
+
if (BINARY_EXTENSIONS.includes(ext)) {
|
|
111
|
+
this.skippedFiles.push(`${relativePath} (binary file)`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check if file is actually text (simple heuristic)
|
|
116
|
+
if (!(await this.isTextFile(fullPath))) {
|
|
117
|
+
this.skippedFiles.push(`${relativePath} (binary content)`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
files.push({
|
|
122
|
+
path: fullPath,
|
|
123
|
+
relativePath,
|
|
124
|
+
size: stats.size,
|
|
125
|
+
lastModified: stats.mtime,
|
|
126
|
+
extension: ext,
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.warnings.push(`Cannot process file: ${relativePath}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async isTextFile(filePath: string): Promise<boolean> {
|
|
134
|
+
try {
|
|
135
|
+
const buffer = await readFile(filePath);
|
|
136
|
+
const sample = buffer.subarray(0, Math.min(8000, buffer.length));
|
|
137
|
+
|
|
138
|
+
// Check for null bytes (common in binary files)
|
|
139
|
+
for (let i = 0; i < sample.length; i++) {
|
|
140
|
+
if (sample[i] === 0) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async loadFileContents(files: FileMetadata[]): Promise<FileContent[]> {
|
|
152
|
+
const contents: FileContent[] = [];
|
|
153
|
+
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
try {
|
|
156
|
+
const content = await readFile(file.path, 'utf-8');
|
|
157
|
+
contents.push({
|
|
158
|
+
...file,
|
|
159
|
+
content,
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
logger.warn(`Failed to read file: ${file.relativePath}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return contents;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private formatBytes(bytes: number): string {
|
|
170
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
171
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
172
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/db/client.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import postgres from 'postgres';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
3
|
+
import { readFile } from 'fs/promises';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { DatabaseError } from '../types/index.js';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
import { configManager } from '../config/manager.js';
|
|
9
|
+
import * as schema from './schema.js';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = dirname(__filename);
|
|
13
|
+
|
|
14
|
+
export class DatabaseClient {
|
|
15
|
+
private sql: postgres.Sql | null = null;
|
|
16
|
+
private db: ReturnType<typeof drizzle> | null = null;
|
|
17
|
+
private isLocalOnly: boolean = false;
|
|
18
|
+
|
|
19
|
+
async connect(localOnly: boolean = false): Promise<void> {
|
|
20
|
+
if (localOnly) {
|
|
21
|
+
this.isLocalOnly = true;
|
|
22
|
+
logger.info('Running in local-only mode (no database persistence)');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const config = await configManager.load();
|
|
28
|
+
|
|
29
|
+
if (!config.supabaseUrl || !config.supabaseServiceRoleKey) {
|
|
30
|
+
logger.warn('Supabase not configured. Running in local-only mode.');
|
|
31
|
+
logger.info('Run "ownkey config" to set up database persistence.');
|
|
32
|
+
this.isLocalOnly = true;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create connection string
|
|
37
|
+
const connectionString = `${config.supabaseUrl}?sslmode=require`;
|
|
38
|
+
|
|
39
|
+
this.sql = postgres(connectionString, {
|
|
40
|
+
password: config.supabaseServiceRoleKey,
|
|
41
|
+
max: 10,
|
|
42
|
+
idle_timeout: 20,
|
|
43
|
+
connect_timeout: 10,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.db = drizzle(this.sql, { schema });
|
|
47
|
+
|
|
48
|
+
// Test connection
|
|
49
|
+
await this.sql`SELECT 1`;
|
|
50
|
+
logger.verbose('Database connection established');
|
|
51
|
+
|
|
52
|
+
// Run migrations
|
|
53
|
+
await this.runMigrations();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.warn('Failed to connect to database. Running in local-only mode.');
|
|
56
|
+
logger.debug('Database error:', error);
|
|
57
|
+
this.isLocalOnly = true;
|
|
58
|
+
this.sql = null;
|
|
59
|
+
this.db = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async disconnect(): Promise<void> {
|
|
64
|
+
if (this.sql) {
|
|
65
|
+
await this.sql.end();
|
|
66
|
+
this.sql = null;
|
|
67
|
+
this.db = null;
|
|
68
|
+
logger.verbose('Database connection closed');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getDb(): ReturnType<typeof drizzle> {
|
|
73
|
+
if (this.isLocalOnly) {
|
|
74
|
+
throw new DatabaseError('Database not available in local-only mode');
|
|
75
|
+
}
|
|
76
|
+
if (!this.db) {
|
|
77
|
+
throw new DatabaseError('Database not connected');
|
|
78
|
+
}
|
|
79
|
+
return this.db;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
isConnected(): boolean {
|
|
83
|
+
return this.db !== null && !this.isLocalOnly;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
isLocalOnlyMode(): boolean {
|
|
87
|
+
return this.isLocalOnly;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async runMigrations(): Promise<void> {
|
|
91
|
+
if (!this.sql) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
logger.verbose('Checking database migrations...');
|
|
97
|
+
|
|
98
|
+
// Check if migrations table exists
|
|
99
|
+
const tableExists = await this.sql`
|
|
100
|
+
SELECT EXISTS (
|
|
101
|
+
SELECT FROM information_schema.tables
|
|
102
|
+
WHERE table_name = 'schema_migrations'
|
|
103
|
+
);
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
if (!tableExists[0]?.exists) {
|
|
107
|
+
logger.info('Initializing database schema...');
|
|
108
|
+
await this.runMigration(1);
|
|
109
|
+
logger.success('Database schema initialized');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check current version
|
|
114
|
+
const currentVersion = await this.sql`
|
|
115
|
+
SELECT MAX(version) as version FROM schema_migrations;
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const version = currentVersion[0]?.version || 0;
|
|
119
|
+
logger.verbose(`Current schema version: ${version}`);
|
|
120
|
+
|
|
121
|
+
// Run pending migrations
|
|
122
|
+
const latestVersion = 1; // Update this as we add more migrations
|
|
123
|
+
if (version < latestVersion) {
|
|
124
|
+
for (let v = version + 1; v <= latestVersion; v++) {
|
|
125
|
+
await this.runMigration(v);
|
|
126
|
+
logger.success(`Applied migration ${v}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new DatabaseError('Failed to run migrations', error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async runMigration(version: number): Promise<void> {
|
|
135
|
+
if (!this.sql) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const migrationFile = join(__dirname, 'migrations', `${String(version).padStart(3, '0')}_init.sql`);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const migrationSql = await readFile(migrationFile, 'utf-8');
|
|
143
|
+
await this.sql.unsafe(migrationSql);
|
|
144
|
+
logger.verbose(`Migration ${version} applied successfully`);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw new DatabaseError(`Failed to apply migration ${version}`, error);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const dbClient = new DatabaseClient();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
-- OwnKey CLI Initial Schema Migration
|
|
2
|
+
-- Version: 001
|
|
3
|
+
-- Description: Create all initial tables for OwnKey CLI
|
|
4
|
+
|
|
5
|
+
-- Projects table
|
|
6
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
7
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
8
|
+
name TEXT NOT NULL,
|
|
9
|
+
root_path TEXT NOT NULL,
|
|
10
|
+
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
11
|
+
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- Runs table
|
|
15
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
16
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
17
|
+
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
18
|
+
command TEXT NOT NULL,
|
|
19
|
+
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed')),
|
|
20
|
+
summary TEXT,
|
|
21
|
+
started_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
|
22
|
+
finished_at TIMESTAMP
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
-- Suggestions table
|
|
26
|
+
CREATE TABLE IF NOT EXISTS suggestions (
|
|
27
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
28
|
+
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
29
|
+
type TEXT NOT NULL CHECK (type IN ('bug', 'security', 'performance', 'refactor', 'style', 'architecture', 'test', 'documentation')),
|
|
30
|
+
severity TEXT NOT NULL CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')),
|
|
31
|
+
content TEXT NOT NULL,
|
|
32
|
+
diff TEXT NOT NULL,
|
|
33
|
+
file_path TEXT NOT NULL,
|
|
34
|
+
rationale TEXT NOT NULL,
|
|
35
|
+
tags JSONB,
|
|
36
|
+
created_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- Suggestion history table
|
|
40
|
+
CREATE TABLE IF NOT EXISTS suggestion_history (
|
|
41
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
42
|
+
suggestion_id UUID NOT NULL REFERENCES suggestions(id) ON DELETE CASCADE,
|
|
43
|
+
action TEXT NOT NULL CHECK (action IN ('applied', 'rejected', 'undone')),
|
|
44
|
+
details TEXT,
|
|
45
|
+
acted_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
-- Run suggestions junction table
|
|
49
|
+
CREATE TABLE IF NOT EXISTS run_suggestions (
|
|
50
|
+
run_id UUID NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
51
|
+
suggestion_id UUID NOT NULL REFERENCES suggestions(id) ON DELETE CASCADE,
|
|
52
|
+
PRIMARY KEY (run_id, suggestion_id)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
-- User API keys table (fingerprints only)
|
|
56
|
+
CREATE TABLE IF NOT EXISTS user_api_keys (
|
|
57
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
58
|
+
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
|
59
|
+
provider TEXT NOT NULL,
|
|
60
|
+
key_fingerprint TEXT NOT NULL,
|
|
61
|
+
added_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
-- Schema migrations table
|
|
65
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
66
|
+
version INTEGER PRIMARY KEY,
|
|
67
|
+
applied_at TIMESTAMP DEFAULT NOW() NOT NULL
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
-- Create indexes for better query performance
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_runs_project_id ON runs(project_id);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_suggestions_project_id ON suggestions(project_id);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_suggestions_type ON suggestions(type);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_suggestions_severity ON suggestions(severity);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_suggestion_history_suggestion_id ON suggestion_history(suggestion_id);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_user_api_keys_project_id ON user_api_keys(project_id);
|
|
78
|
+
|
|
79
|
+
-- Insert migration record
|
|
80
|
+
INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT (version) DO NOTHING;
|