@njeybe/nativ 2.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/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/bin/cli.js +31 -0
- package/dist/commands/bench.d.ts +8 -0
- package/dist/commands/bench.js +52 -0
- package/dist/commands/db.d.ts +22 -0
- package/dist/commands/db.js +415 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +169 -0
- package/dist/commands/hook.d.ts +31 -0
- package/dist/commands/hook.js +105 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +144 -0
- package/dist/commands/mcp.d.ts +5 -0
- package/dist/commands/mcp.js +16 -0
- package/dist/commands/setup.d.ts +17 -0
- package/dist/commands/setup.js +51 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +75 -0
- package/dist/commands/task.d.ts +83 -0
- package/dist/commands/task.js +807 -0
- package/dist/commands/test-gen.d.ts +14 -0
- package/dist/commands/test-gen.js +52 -0
- package/dist/commands/triage.d.ts +70 -0
- package/dist/commands/triage.js +343 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +112 -0
- package/dist/commands/validate.d.ts +1 -0
- package/dist/commands/validate.js +89 -0
- package/dist/commands/verify.d.ts +8 -0
- package/dist/commands/verify.js +65 -0
- package/dist/commands/worktree.d.ts +47 -0
- package/dist/commands/worktree.js +396 -0
- package/dist/core/benchmark.d.ts +57 -0
- package/dist/core/benchmark.js +772 -0
- package/dist/core/enforcement.d.ts +80 -0
- package/dist/core/enforcement.js +271 -0
- package/dist/core/legacy-templates.d.ts +24 -0
- package/dist/core/legacy-templates.js +60 -0
- package/dist/core/lock-manager.d.ts +54 -0
- package/dist/core/lock-manager.js +260 -0
- package/dist/core/root-resolver.d.ts +32 -0
- package/dist/core/root-resolver.js +149 -0
- package/dist/core/setup-assets.d.ts +123 -0
- package/dist/core/setup-assets.js +438 -0
- package/dist/core/telemetry.d.ts +156 -0
- package/dist/core/telemetry.js +416 -0
- package/dist/core/test-generator-types.d.ts +227 -0
- package/dist/core/test-generator-types.js +179 -0
- package/dist/core/test-generator.d.ts +34 -0
- package/dist/core/test-generator.js +1690 -0
- package/dist/core/tier1-liaison.d.ts +139 -0
- package/dist/core/tier1-liaison.js +320 -0
- package/dist/core/verifier.d.ts +52 -0
- package/dist/core/verifier.js +249 -0
- package/dist/core/version.d.ts +5 -0
- package/dist/core/version.js +14 -0
- package/dist/core/worktree-merge.d.ts +30 -0
- package/dist/core/worktree-merge.js +98 -0
- package/dist/db/data-engine.d.ts +46 -0
- package/dist/db/data-engine.js +802 -0
- package/dist/db/diff.d.ts +39 -0
- package/dist/db/diff.js +420 -0
- package/dist/db/env-parser.d.ts +86 -0
- package/dist/db/env-parser.js +549 -0
- package/dist/db/introspector.d.ts +21 -0
- package/dist/db/introspector.js +835 -0
- package/dist/db/types.d.ts +217 -0
- package/dist/db/types.js +9 -0
- package/dist/governor/circuit-breaker.d.ts +100 -0
- package/dist/governor/circuit-breaker.js +231 -0
- package/dist/governor/evaluator.d.ts +67 -0
- package/dist/governor/evaluator.js +851 -0
- package/dist/governor/index.d.ts +6 -0
- package/dist/governor/index.js +6 -0
- package/dist/governor/rules/api-rules.d.ts +2 -0
- package/dist/governor/rules/api-rules.js +140 -0
- package/dist/governor/rules/db-rules.d.ts +2 -0
- package/dist/governor/rules/db-rules.js +180 -0
- package/dist/governor/store.d.ts +33 -0
- package/dist/governor/store.js +84 -0
- package/dist/governor/types.d.ts +64 -0
- package/dist/governor/types.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +358 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +309 -0
- package/dist/providers/claude-api.d.ts +5 -0
- package/dist/providers/claude-api.js +75 -0
- package/dist/providers/claude-cli.d.ts +13 -0
- package/dist/providers/claude-cli.js +134 -0
- package/dist/providers/gemini.d.ts +8 -0
- package/dist/providers/gemini.js +78 -0
- package/dist/providers/index.d.ts +5 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/registry.d.ts +38 -0
- package/dist/providers/registry.js +121 -0
- package/dist/providers/types.d.ts +74 -0
- package/dist/providers/types.js +25 -0
- package/dist/runner/agent-supervisor.d.ts +363 -0
- package/dist/runner/agent-supervisor.js +1672 -0
- package/dist/scanner/context-builder.d.ts +2 -0
- package/dist/scanner/context-builder.js +29 -0
- package/dist/scanner/detector.d.ts +2 -0
- package/dist/scanner/detector.js +760 -0
- package/dist/scanner/types.d.ts +80 -0
- package/dist/scanner/types.js +50 -0
- package/dist/server/studio-server.d.ts +39 -0
- package/dist/server/studio-server.js +1931 -0
- package/dist/server/studio-ui.d.ts +15 -0
- package/dist/server/studio-ui.js +5321 -0
- package/package.json +70 -0
- package/plugin/.claude-plugin/plugin.json +18 -0
- package/plugin/.mcp.json +10 -0
- package/plugin/agents/architect.md +28 -0
- package/plugin/agents/verifier.md +28 -0
- package/plugin/agents/worker.md +26 -0
- package/plugin/hooks/hooks.json +28 -0
- package/plugin/skills/nativ/SKILL.md +75 -0
- package/templates/.claudeignore +45 -0
- package/templates/AGENTS.md +67 -0
- package/templates/CLAUDE.md +32 -0
- package/templates/GEMINI.md +66 -0
- package/templates/claude-agents/architect.md +28 -0
- package/templates/claude-agents/verifier.md +28 -0
- package/templates/claude-agents/worker.md +26 -0
- package/templates/dot-ai/api_contracts.json +66 -0
- package/templates/dot-ai/context.md +38 -0
- package/templates/dot-ai/db_schema.json +39 -0
- package/templates/dot-ai/master_plan.json +28 -0
- package/templates/dot-ai/subagents/backend.md +11 -0
- package/templates/dot-ai/subagents/database.md +10 -0
- package/templates/dot-ai/subagents/db-migration.md +11 -0
- package/templates/dot-ai/subagents/devops-agent.md +11 -0
- package/templates/dot-ai/subagents/flutter-developer.md +11 -0
- package/templates/dot-ai/subagents/frontend.md +126 -0
- package/templates/dot-ai/subagents/qa-tester.md +13 -0
- package/templates/dot-ai/subagents/security-auditor.md +11 -0
- package/templates/dot-ai/ui_specs.md +78 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Safe environment parser.
|
|
5
|
+
* Reads .env files locally so connection strings stay in-memory inside the nativ
|
|
6
|
+
* process. Nothing returned here for display purposes contains credentials —
|
|
7
|
+
* always pass URLs through maskConnectionString() before rendering them.
|
|
8
|
+
*/
|
|
9
|
+
export const MASK = '••••••••';
|
|
10
|
+
/** Env files scanned in order; later files override earlier ones. */
|
|
11
|
+
const ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local', '.env.production', '.env.production.local'];
|
|
12
|
+
/** Template files scanned to detect project-specific connection variables. */
|
|
13
|
+
export const EXAMPLE_ENV_FILES = ['.env.example', '.env.sample', '.env.template', '.env.dist', '.env.defaults'];
|
|
14
|
+
const DEV_KEYS = [
|
|
15
|
+
'NATIV_DEV_DATABASE_URL',
|
|
16
|
+
'AGENTJ_DEV_DATABASE_URL',
|
|
17
|
+
'DEV_DATABASE_URL',
|
|
18
|
+
'DATABASE_URL_DEV',
|
|
19
|
+
'DEV_MONGODB_URI',
|
|
20
|
+
'DEV_FIRESTORE_URL',
|
|
21
|
+
'DATABASE_URL',
|
|
22
|
+
'MONGODB_URI',
|
|
23
|
+
'MONGO_URL',
|
|
24
|
+
'FIRESTORE_URL',
|
|
25
|
+
];
|
|
26
|
+
const PROD_KEYS = [
|
|
27
|
+
'NATIV_PROD_DATABASE_URL',
|
|
28
|
+
'AGENTJ_PROD_DATABASE_URL',
|
|
29
|
+
'PROD_DATABASE_URL',
|
|
30
|
+
'DATABASE_URL_PROD',
|
|
31
|
+
'PRODUCTION_DATABASE_URL',
|
|
32
|
+
'PROD_MONGODB_URI',
|
|
33
|
+
'PROD_FIRESTORE_URL',
|
|
34
|
+
];
|
|
35
|
+
/** Standard Firestore emulator variables: used to build a Dev URL when no explicit one is set. */
|
|
36
|
+
const FIRESTORE_EMULATOR_KEYS = ['FIRESTORE_EMULATOR_HOST', 'FIRESTORE_PROJECT_ID', 'GCLOUD_PROJECT', 'GOOGLE_CLOUD_PROJECT'];
|
|
37
|
+
/** Parses dotenv-formatted text into key/value pairs without touching process.env. */
|
|
38
|
+
export function parseEnvContent(content) {
|
|
39
|
+
const result = {};
|
|
40
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
41
|
+
const line = rawLine.trim();
|
|
42
|
+
if (!line || line.startsWith('#'))
|
|
43
|
+
continue;
|
|
44
|
+
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/);
|
|
45
|
+
if (!match)
|
|
46
|
+
continue;
|
|
47
|
+
const [, key, rest] = match;
|
|
48
|
+
let value = rest.trim();
|
|
49
|
+
const quote = value[0];
|
|
50
|
+
if ((quote === '"' || quote === "'" || quote === '`') && value.length > 1) {
|
|
51
|
+
const end = value.indexOf(quote, 1);
|
|
52
|
+
value = end === -1 ? value.slice(1) : value.slice(1, end);
|
|
53
|
+
if (quote === '"')
|
|
54
|
+
value = value.replace(/\\n/g, '\n');
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const hash = value.search(/\s#/);
|
|
58
|
+
if (hash !== -1)
|
|
59
|
+
value = value.slice(0, hash).trim();
|
|
60
|
+
}
|
|
61
|
+
result[key] = value;
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/** Loads and merges all known .env files in `cwd`. Missing/unreadable files are skipped. */
|
|
66
|
+
export function loadEnvFiles(cwd = process.cwd()) {
|
|
67
|
+
const merged = {};
|
|
68
|
+
for (const file of ENV_FILES) {
|
|
69
|
+
const full = path.join(cwd, file);
|
|
70
|
+
try {
|
|
71
|
+
if (!fs.existsSync(full))
|
|
72
|
+
continue;
|
|
73
|
+
Object.assign(merged, parseEnvContent(fs.readFileSync(full, 'utf8')));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Unreadable env file — ignore rather than surface its contents in an error.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return merged;
|
|
80
|
+
}
|
|
81
|
+
/** Infers the database engine from a connection URL or file path. */
|
|
82
|
+
export function detectEngine(url) {
|
|
83
|
+
const lower = url.trim().toLowerCase();
|
|
84
|
+
if (lower.startsWith('postgres://') || lower.startsWith('postgresql://'))
|
|
85
|
+
return 'postgresql';
|
|
86
|
+
if (lower.startsWith('mysql://') || lower.startsWith('mysql2://') || lower.startsWith('mariadb://'))
|
|
87
|
+
return 'mysql';
|
|
88
|
+
if (lower.startsWith('mongodb://') || lower.startsWith('mongodb+srv://'))
|
|
89
|
+
return 'mongodb';
|
|
90
|
+
if (lower.startsWith('firestore://'))
|
|
91
|
+
return 'firestore';
|
|
92
|
+
if (lower.startsWith('sqlite:') || lower.startsWith('file:') || /\.(db|sqlite|sqlite3)$/.test(lower) || lower === ':memory:') {
|
|
93
|
+
return 'sqlite';
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
/** Resolves a SQLite URL (`sqlite:./x.db`, `file:./x.db`, or plain path) to a filesystem path. */
|
|
98
|
+
export function sqlitePathFromUrl(url, cwd = process.cwd()) {
|
|
99
|
+
let p = url.trim().replace(/^sqlite:(\/\/)?/i, '').replace(/^file:(\/\/)?/i, '');
|
|
100
|
+
p = p.split('?')[0];
|
|
101
|
+
if (p === ':memory:')
|
|
102
|
+
return p;
|
|
103
|
+
return path.isAbsolute(p) ? p : path.resolve(cwd, p);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Firestore connection URL: `firestore://<projectId>[/<databaseId>][?emulator=<host:port>]`.
|
|
107
|
+
* Credentials are never part of it: real projects use Application Default Credentials.
|
|
108
|
+
*/
|
|
109
|
+
export function parseFirestoreUrl(url) {
|
|
110
|
+
const parsed = new URL(url.trim());
|
|
111
|
+
const projectId = decodeURIComponent(parsed.hostname);
|
|
112
|
+
if (!projectId)
|
|
113
|
+
throw new Error('Firestore URL must include a project id: firestore://<projectId>');
|
|
114
|
+
const databaseId = decodeURIComponent(parsed.pathname.replace(/^\/+|\/+$/g, '')) || undefined;
|
|
115
|
+
const emulatorHost = parsed.searchParams.get('emulator') || undefined;
|
|
116
|
+
return { projectId, ...(databaseId ? { databaseId } : {}), ...(emulatorHost ? { emulatorHost } : {}) };
|
|
117
|
+
}
|
|
118
|
+
export function firestoreUrlFromConfig(config) {
|
|
119
|
+
const db = config.databaseId && config.databaseId !== '(default)' ? `/${encodeURIComponent(config.databaseId)}` : '';
|
|
120
|
+
const emulator = config.emulatorHost ? `?emulator=${config.emulatorHost}` : '';
|
|
121
|
+
return `firestore://${encodeURIComponent(config.projectId)}${db}${emulator}`;
|
|
122
|
+
}
|
|
123
|
+
/** Query parameters that may carry credentials or signed-URL secrets. */
|
|
124
|
+
const SENSITIVE_PARAM = /pass|pwd|secret|token|key|auth|sig|signature|credential/i;
|
|
125
|
+
/**
|
|
126
|
+
* Redacts credentials from a connection string.
|
|
127
|
+
* `postgres://user:secret@host:5432/db?password=x` -> `postgres://user:••••••••@host:5432/db?password=••••••••`
|
|
128
|
+
*/
|
|
129
|
+
export function maskConnectionString(url) {
|
|
130
|
+
if (!url)
|
|
131
|
+
return '';
|
|
132
|
+
const trimmed = url.trim();
|
|
133
|
+
if (detectEngine(trimmed) === 'sqlite')
|
|
134
|
+
return trimmed.replace(/([?&](?:password|pwd|key)=)[^&]*/gi, `$1${MASK}`);
|
|
135
|
+
try {
|
|
136
|
+
const parsed = new URL(trimmed);
|
|
137
|
+
if (parsed.password)
|
|
138
|
+
parsed.password = 'MASKED_PLACEHOLDER';
|
|
139
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
140
|
+
// authSource / authMechanism are non-secret MongoDB options; keep them readable.
|
|
141
|
+
if (SENSITIVE_PARAM.test(key) && !/^auth(source|mechanism)$/i.test(key))
|
|
142
|
+
parsed.searchParams.set(key, 'MASKED_PLACEHOLDER');
|
|
143
|
+
}
|
|
144
|
+
return parsed.toString().replace(/MASKED_PLACEHOLDER/g, MASK);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Unparseable — fall back to regex redaction of anything between ':' and '@' after the scheme.
|
|
148
|
+
return trimmed
|
|
149
|
+
.replace(/(\/\/[^:/@]*:)[^@]*@/, `$1${MASK}@`)
|
|
150
|
+
.replace(/([?&](?:password|pwd|secret|token|key|sig|signature)=)[^&]*/gi, `$1${MASK}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Redacts signed-URL tokens from a media URL (e.g. Firebase Storage `?token=`, GCS `X-Goog-Signature`). */
|
|
154
|
+
export function redactMediaUrl(url) {
|
|
155
|
+
return maskConnectionString(url);
|
|
156
|
+
}
|
|
157
|
+
/** Extracts the database name (or SQLite file name / Firestore project) from a connection URL for display. */
|
|
158
|
+
export function databaseNameFromUrl(url) {
|
|
159
|
+
const engine = detectEngine(url);
|
|
160
|
+
if (engine === 'sqlite')
|
|
161
|
+
return path.basename(sqlitePathFromUrl(url));
|
|
162
|
+
if (engine === 'firestore') {
|
|
163
|
+
try {
|
|
164
|
+
const cfg = parseFirestoreUrl(url);
|
|
165
|
+
return cfg.databaseId ? `${cfg.projectId}/${cfg.databaseId}` : cfg.projectId;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return decodeURIComponent(new URL(url.trim()).pathname.replace(/^\//, '')) || '';
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Multi-host URIs (mongodb://h1,h2/db) aren't WHATWG-parseable; take the path after the host list.
|
|
176
|
+
const match = url.trim().match(/^[a-z0-9+.-]+:\/\/[^/?]*\/([^?#]*)/i);
|
|
177
|
+
try {
|
|
178
|
+
return match ? decodeURIComponent(match[1]) : '';
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return match ? match[1] : '';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function pick(vars, keys) {
|
|
186
|
+
for (const key of keys) {
|
|
187
|
+
const value = vars[key];
|
|
188
|
+
if (value && value.trim())
|
|
189
|
+
return { key, value: value.trim() };
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Scans template environment files (.env.example, .env.sample, .env.template, .env.defaults, .env.dist)
|
|
195
|
+
* to detect project-specific connection string variables, implied database engines, and whether
|
|
196
|
+
* the variables are configured in the current environment.
|
|
197
|
+
*/
|
|
198
|
+
export function detectExampleDbKeys(cwd = process.cwd()) {
|
|
199
|
+
let templateFile = null;
|
|
200
|
+
let rawContent = null;
|
|
201
|
+
for (const file of EXAMPLE_ENV_FILES) {
|
|
202
|
+
const full = path.join(cwd, file);
|
|
203
|
+
try {
|
|
204
|
+
if (fs.existsSync(full)) {
|
|
205
|
+
templateFile = file;
|
|
206
|
+
rawContent = fs.readFileSync(full, 'utf8');
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Unreadable file — ignore safely.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const fragmented = synthesizeConnectionFromComponents(loadComponentVars(cwd), 'dev');
|
|
215
|
+
const fragmentedInfo = { fragmentedDetected: Boolean(fragmented), fragmentedConfig: fragmented?.config ?? null };
|
|
216
|
+
if (!templateFile || !rawContent) {
|
|
217
|
+
return {
|
|
218
|
+
templateFound: false,
|
|
219
|
+
templateFile: null,
|
|
220
|
+
detectedKeys: [],
|
|
221
|
+
missingKeys: [],
|
|
222
|
+
...fragmentedInfo,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const envVars = loadEnvFiles(cwd);
|
|
226
|
+
const detectedKeys = [];
|
|
227
|
+
const lines = rawContent.split(/\r?\n/);
|
|
228
|
+
let lastComment = '';
|
|
229
|
+
for (const rawLine of lines) {
|
|
230
|
+
const line = rawLine.trim();
|
|
231
|
+
if (!line) {
|
|
232
|
+
lastComment = '';
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (line.startsWith('#')) {
|
|
236
|
+
lastComment += ' ' + line.slice(1).trim();
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/);
|
|
240
|
+
if (!match) {
|
|
241
|
+
lastComment = '';
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const key = match[1];
|
|
245
|
+
let exampleVal = match[2].trim();
|
|
246
|
+
if ((exampleVal.startsWith('"') || exampleVal.startsWith("'") || exampleVal.startsWith('`')) && exampleVal.length > 1) {
|
|
247
|
+
const quote = exampleVal[0];
|
|
248
|
+
const end = exampleVal.indexOf(quote, 1);
|
|
249
|
+
exampleVal = end === -1 ? exampleVal.slice(1) : exampleVal.slice(1, end);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const hash = exampleVal.search(/\s#/);
|
|
253
|
+
if (hash !== -1)
|
|
254
|
+
exampleVal = exampleVal.slice(0, hash).trim();
|
|
255
|
+
}
|
|
256
|
+
let engine = null;
|
|
257
|
+
let isDbKey = false;
|
|
258
|
+
if (exampleVal) {
|
|
259
|
+
engine = detectEngine(exampleVal);
|
|
260
|
+
if (engine)
|
|
261
|
+
isDbKey = true;
|
|
262
|
+
}
|
|
263
|
+
const upperKey = key.toUpperCase();
|
|
264
|
+
const isProdKey = /PROD|PRODUCTION|LIVE/.test(upperKey);
|
|
265
|
+
const targetEnv = isProdKey ? 'prod' : 'dev';
|
|
266
|
+
const isDbKeyword = /(?:^|_)(DB|DATABASE|MONGO|MONGODB|POSTGRES|POSTGRESQL|PG|MYSQL|MARIADB|SQLITE|FIRESTORE)(?:_|$)/i.test(key);
|
|
267
|
+
const isConnKeyword = /(URL|URI|DSN|CONN|CONNECTION|STRING|HOST|PATH|FILE)/i.test(key);
|
|
268
|
+
if (isDbKeyword && (isConnKeyword || !exampleVal || upperKey.includes('URL') || upperKey.includes('URI'))) {
|
|
269
|
+
isDbKey = true;
|
|
270
|
+
if (!engine) {
|
|
271
|
+
if (/MONGO/i.test(key))
|
|
272
|
+
engine = 'mongodb';
|
|
273
|
+
else if (/POSTGRES|PG/i.test(key))
|
|
274
|
+
engine = 'postgresql';
|
|
275
|
+
else if (/MYSQL|MARIADB/i.test(key))
|
|
276
|
+
engine = 'mysql';
|
|
277
|
+
else if (/SQLITE/i.test(key))
|
|
278
|
+
engine = 'sqlite';
|
|
279
|
+
else if (/FIRESTORE/i.test(key))
|
|
280
|
+
engine = 'firestore';
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else if (/^(DATABASE_URL|MONGO_URI|MONGODB_URI|MONGO_URL|POSTGRES_URL|POSTGRESQL_URL|PG_URL|MYSQL_URL|SQLITE_URL|SQLITE_DB|FIRESTORE_URL|DB_URL|DB_URI|DB_CONNECTION)$/i.test(key)) {
|
|
284
|
+
isDbKey = true;
|
|
285
|
+
if (!engine) {
|
|
286
|
+
if (/MONGO/i.test(key))
|
|
287
|
+
engine = 'mongodb';
|
|
288
|
+
else if (/POSTGRES|PG/i.test(key))
|
|
289
|
+
engine = 'postgresql';
|
|
290
|
+
else if (/MYSQL/i.test(key))
|
|
291
|
+
engine = 'mysql';
|
|
292
|
+
else if (/SQLITE/i.test(key))
|
|
293
|
+
engine = 'sqlite';
|
|
294
|
+
else if (/FIRESTORE/i.test(key))
|
|
295
|
+
engine = 'firestore';
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (isDbKey && !engine && lastComment) {
|
|
299
|
+
if (/mongo/i.test(lastComment))
|
|
300
|
+
engine = 'mongodb';
|
|
301
|
+
else if (/postgres/i.test(lastComment))
|
|
302
|
+
engine = 'postgresql';
|
|
303
|
+
else if (/mysql/i.test(lastComment))
|
|
304
|
+
engine = 'mysql';
|
|
305
|
+
else if (/sqlite/i.test(lastComment))
|
|
306
|
+
engine = 'sqlite';
|
|
307
|
+
else if (/firestore/i.test(lastComment))
|
|
308
|
+
engine = 'firestore';
|
|
309
|
+
}
|
|
310
|
+
if (isDbKey) {
|
|
311
|
+
const isConfigured = Boolean(envVars[key]?.trim() || process.env[key]?.trim());
|
|
312
|
+
detectedKeys.push({
|
|
313
|
+
key,
|
|
314
|
+
targetEnv,
|
|
315
|
+
engine,
|
|
316
|
+
exampleValue: exampleVal || undefined,
|
|
317
|
+
isConfiguredInEnv: isConfigured,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
lastComment = '';
|
|
321
|
+
}
|
|
322
|
+
const missingKeys = detectedKeys.filter((k) => !k.isConfiguredInEnv);
|
|
323
|
+
return {
|
|
324
|
+
templateFound: true,
|
|
325
|
+
templateFile,
|
|
326
|
+
detectedKeys,
|
|
327
|
+
missingKeys,
|
|
328
|
+
...fragmentedInfo,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const COMPONENT_FAMILIES = {
|
|
332
|
+
dev: [
|
|
333
|
+
{
|
|
334
|
+
connection: ['DB_CONNECTION', 'DB_DRIVER', 'DB_ENGINE', 'DB_TYPE', 'DB_DIALECT'],
|
|
335
|
+
host: ['DB_HOST', 'DB_HOSTNAME', 'DB_SERVER'],
|
|
336
|
+
port: ['DB_PORT'],
|
|
337
|
+
user: ['DB_USERNAME', 'DB_USER'],
|
|
338
|
+
password: ['DB_PASSWORD', 'DB_PASS'],
|
|
339
|
+
database: ['DB_DATABASE', 'DB_NAME'],
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
connection: [],
|
|
343
|
+
host: ['MYSQL_HOST'],
|
|
344
|
+
port: ['MYSQL_PORT'],
|
|
345
|
+
user: ['MYSQL_USER', 'MYSQL_USERNAME'],
|
|
346
|
+
password: ['MYSQL_PASSWORD'],
|
|
347
|
+
database: ['MYSQL_DATABASE', 'MYSQL_DB'],
|
|
348
|
+
impliedEngine: 'mysql',
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
connection: [],
|
|
352
|
+
host: ['POSTGRES_HOST', 'PGHOST'],
|
|
353
|
+
port: ['POSTGRES_PORT', 'PGPORT'],
|
|
354
|
+
user: ['POSTGRES_USER', 'PGUSER'],
|
|
355
|
+
password: ['POSTGRES_PASSWORD', 'PGPASSWORD'],
|
|
356
|
+
database: ['POSTGRES_DB', 'POSTGRES_DATABASE', 'PGDATABASE'],
|
|
357
|
+
impliedEngine: 'postgresql',
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
prod: [
|
|
361
|
+
{
|
|
362
|
+
connection: ['PROD_DB_CONNECTION', 'PROD_DB_DRIVER'],
|
|
363
|
+
host: ['PROD_DB_HOST'],
|
|
364
|
+
port: ['PROD_DB_PORT'],
|
|
365
|
+
user: ['PROD_DB_USERNAME', 'PROD_DB_USER'],
|
|
366
|
+
password: ['PROD_DB_PASSWORD', 'PROD_DB_PASS'],
|
|
367
|
+
database: ['PROD_DB_DATABASE', 'PROD_DB_NAME'],
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
};
|
|
371
|
+
/** Every key that can take part in synthesis, so process.env can override .env values for them. */
|
|
372
|
+
const ALL_COMPONENT_KEYS = [
|
|
373
|
+
...new Set(Object.values(COMPONENT_FAMILIES)
|
|
374
|
+
.flat()
|
|
375
|
+
.flatMap((f) => [...f.connection, ...f.host, ...f.port, ...f.user, ...f.password, ...f.database])),
|
|
376
|
+
];
|
|
377
|
+
export const DEFAULT_PORTS = { mysql: 3306, postgresql: 5432 };
|
|
378
|
+
/** Maps a DB_CONNECTION-style driver name (mysql, mariadb, pgsql, postgres…) to an engine. */
|
|
379
|
+
export function componentEngineFromDriver(driver) {
|
|
380
|
+
const d = (driver ?? '').trim().toLowerCase();
|
|
381
|
+
if (!d)
|
|
382
|
+
return null;
|
|
383
|
+
if (/^(mysql|mysql2|mysqli|mariadb|pdo_mysql)$/.test(d))
|
|
384
|
+
return 'mysql';
|
|
385
|
+
if (/^(pgsql|postgres|postgresql|pg|pdo_pgsql)$/.test(d))
|
|
386
|
+
return 'postgresql';
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Assembles a connection URL from discrete parameters, percent-encoding credentials and database name.
|
|
391
|
+
* The result contains the raw password: pass it through maskConnectionString() before display.
|
|
392
|
+
*/
|
|
393
|
+
export function connectionUrlFromComponents(components) {
|
|
394
|
+
const engine = componentEngineFromDriver(components.engine);
|
|
395
|
+
if (!engine)
|
|
396
|
+
throw new Error(`Unsupported engine for parameter connection: ${String(components.engine)} (expected mysql or postgresql)`);
|
|
397
|
+
const host = (components.host ?? '').trim();
|
|
398
|
+
if (!host)
|
|
399
|
+
throw new Error('Parameter connection requires a host');
|
|
400
|
+
if (/[\s/@?#]/.test(host))
|
|
401
|
+
throw new Error('Host must not contain whitespace or URL delimiters');
|
|
402
|
+
const port = components.port ?? DEFAULT_PORTS[engine];
|
|
403
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
404
|
+
throw new Error(`Invalid port: ${String(components.port)}`);
|
|
405
|
+
const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
406
|
+
const user = components.user?.trim() ?? '';
|
|
407
|
+
const password = components.password ?? '';
|
|
408
|
+
const auth = user ? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ''}@` : '';
|
|
409
|
+
const database = components.database?.trim() ?? '';
|
|
410
|
+
const dbPart = database ? `/${encodeURIComponent(database)}` : '';
|
|
411
|
+
const scheme = engine === 'mysql' ? 'mysql' : 'postgresql';
|
|
412
|
+
return `${scheme}://${auth}${hostPart}:${port}${dbPart}`;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Detects fragmented DB_HOST / DB_PORT / DB_DATABASE / DB_USERNAME / DB_PASSWORD / DB_CONNECTION keys
|
|
416
|
+
* (plus MYSQL_* and POSTGRES_* / PG* families) and assembles a MySQL or PostgreSQL URL in-memory.
|
|
417
|
+
* Returns null when no family has a host or database configured, or the driver is not MySQL/PostgreSQL
|
|
418
|
+
* (e.g. Laravel's DB_CONNECTION=sqlite).
|
|
419
|
+
*/
|
|
420
|
+
export function synthesizeConnectionFromComponents(vars, env = 'dev') {
|
|
421
|
+
for (const family of COMPONENT_FAMILIES[env]) {
|
|
422
|
+
const connection = pick(vars, family.connection);
|
|
423
|
+
const host = pick(vars, family.host);
|
|
424
|
+
const database = pick(vars, family.database);
|
|
425
|
+
if (!host && !database)
|
|
426
|
+
continue;
|
|
427
|
+
const port = pick(vars, family.port);
|
|
428
|
+
const portNum = port && /^\d+$/.test(port.value) ? Number(port.value) : undefined;
|
|
429
|
+
let engine;
|
|
430
|
+
if (connection) {
|
|
431
|
+
engine = componentEngineFromDriver(connection.value);
|
|
432
|
+
if (!engine)
|
|
433
|
+
continue; // Explicit non-MySQL/PostgreSQL driver: not ours to synthesize.
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
engine = family.impliedEngine ?? (portNum === DEFAULT_PORTS.postgresql ? 'postgresql' : 'mysql');
|
|
437
|
+
}
|
|
438
|
+
const user = pick(vars, family.user);
|
|
439
|
+
// Empty passwords are valid (XAMPP root), so read the raw value rather than pick().
|
|
440
|
+
const passwordKey = family.password.find((k) => vars[k] !== undefined);
|
|
441
|
+
const password = passwordKey ? vars[passwordKey] : undefined;
|
|
442
|
+
const components = {
|
|
443
|
+
engine,
|
|
444
|
+
host: host?.value || '127.0.0.1',
|
|
445
|
+
...(portNum ? { port: portNum } : {}),
|
|
446
|
+
...(user ? { user: user.value } : {}),
|
|
447
|
+
...(password ? { password } : {}),
|
|
448
|
+
...(database ? { database: database.value } : {}),
|
|
449
|
+
};
|
|
450
|
+
let url;
|
|
451
|
+
try {
|
|
452
|
+
url = connectionUrlFromComponents(components);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const sourceKeys = [connection, host, port, user, database]
|
|
458
|
+
.filter((x) => Boolean(x))
|
|
459
|
+
.map((x) => x.key);
|
|
460
|
+
if (passwordKey)
|
|
461
|
+
sourceKeys.push(passwordKey);
|
|
462
|
+
return {
|
|
463
|
+
url,
|
|
464
|
+
engine,
|
|
465
|
+
sourceKey: (host ?? database).key,
|
|
466
|
+
config: {
|
|
467
|
+
env,
|
|
468
|
+
engine,
|
|
469
|
+
host: components.host,
|
|
470
|
+
port: components.port ?? DEFAULT_PORTS[engine],
|
|
471
|
+
user: components.user ?? null,
|
|
472
|
+
database: components.database ?? null,
|
|
473
|
+
hasPassword: Boolean(password),
|
|
474
|
+
sourceKeys,
|
|
475
|
+
maskedUrl: maskConnectionString(url),
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
/** Merges .env files with process.env overrides for all fragmented component keys. */
|
|
482
|
+
function loadComponentVars(cwd) {
|
|
483
|
+
const vars = loadEnvFiles(cwd);
|
|
484
|
+
for (const key of ALL_COMPONENT_KEYS) {
|
|
485
|
+
const value = process.env[key];
|
|
486
|
+
if (value !== undefined)
|
|
487
|
+
vars[key] = value;
|
|
488
|
+
}
|
|
489
|
+
return vars;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Resolves Dev and Prod connection URLs from .env files, with process.env taking precedence
|
|
493
|
+
* and template files (.env.example) dynamically prioritizing custom keys.
|
|
494
|
+
* Returns null for an environment with no configured or recognizable URL.
|
|
495
|
+
*/
|
|
496
|
+
export function resolveConnections(cwd = process.cwd()) {
|
|
497
|
+
const templateInfo = detectExampleDbKeys(cwd);
|
|
498
|
+
const vars = { ...loadEnvFiles(cwd) };
|
|
499
|
+
const templateDevKeys = templateInfo.detectedKeys.filter((k) => k.targetEnv === 'dev').map((k) => k.key);
|
|
500
|
+
const templateProdKeys = templateInfo.detectedKeys.filter((k) => k.targetEnv === 'prod').map((k) => k.key);
|
|
501
|
+
const effectiveDevKeys = [...new Set([...templateDevKeys, ...DEV_KEYS])];
|
|
502
|
+
const effectiveProdKeys = [...new Set([...templateProdKeys, ...PROD_KEYS])];
|
|
503
|
+
for (const key of [...effectiveDevKeys, ...effectiveProdKeys, ...FIRESTORE_EMULATOR_KEYS]) {
|
|
504
|
+
const value = process.env[key];
|
|
505
|
+
if (value)
|
|
506
|
+
vars[key] = value;
|
|
507
|
+
}
|
|
508
|
+
const toConnection = (env, url, sourceKey) => {
|
|
509
|
+
const engine = detectEngine(url);
|
|
510
|
+
if (!engine)
|
|
511
|
+
return null;
|
|
512
|
+
const isFromExample = templateInfo.detectedKeys.some((k) => k.key === sourceKey);
|
|
513
|
+
return {
|
|
514
|
+
env,
|
|
515
|
+
url,
|
|
516
|
+
engine,
|
|
517
|
+
maskedUrl: maskConnectionString(url),
|
|
518
|
+
sourceKey,
|
|
519
|
+
detectedFromExample: isFromExample,
|
|
520
|
+
exampleFile: isFromExample ? templateInfo.templateFile : null,
|
|
521
|
+
};
|
|
522
|
+
};
|
|
523
|
+
// Skip keys whose value is not a connection URL (e.g. a template's DB_HOST=127.0.0.1 or DB_CONNECTION=mysql).
|
|
524
|
+
const build = (env, keys) => {
|
|
525
|
+
for (const key of keys) {
|
|
526
|
+
const value = vars[key]?.trim();
|
|
527
|
+
const conn = value ? toConnection(env, value, key) : null;
|
|
528
|
+
if (conn)
|
|
529
|
+
return conn;
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
};
|
|
533
|
+
const componentVars = loadComponentVars(cwd);
|
|
534
|
+
const synthesize = (env) => {
|
|
535
|
+
const synthesized = synthesizeConnectionFromComponents(componentVars, env);
|
|
536
|
+
const conn = synthesized ? toConnection(env, synthesized.url, synthesized.sourceKey) : null;
|
|
537
|
+
return conn ? { ...conn, synthesized: true } : null;
|
|
538
|
+
};
|
|
539
|
+
let dev = build('dev', effectiveDevKeys);
|
|
540
|
+
// Firestore emulator convention: FIRESTORE_EMULATOR_HOST + a project id implies a local Dev database.
|
|
541
|
+
const emulatorHost = vars.FIRESTORE_EMULATOR_HOST?.trim();
|
|
542
|
+
const projectId = (vars.FIRESTORE_PROJECT_ID || vars.GCLOUD_PROJECT || vars.GOOGLE_CLOUD_PROJECT || '').trim();
|
|
543
|
+
if (!dev && emulatorHost && projectId) {
|
|
544
|
+
dev = toConnection('dev', firestoreUrlFromConfig({ projectId, emulatorHost }), 'FIRESTORE_EMULATOR_HOST');
|
|
545
|
+
}
|
|
546
|
+
// No single URI configured: assemble one from fragmented DB_HOST / DB_DATABASE-style keys.
|
|
547
|
+
dev ??= synthesize('dev');
|
|
548
|
+
return { dev, prod: build('prod', effectiveProdKeys) ?? synthesize('prod'), templateInfo };
|
|
549
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DatabaseEngine, DatabaseStatus, TableSchema } from './types.js';
|
|
2
|
+
export interface IntrospectionResult {
|
|
3
|
+
status: DatabaseStatus;
|
|
4
|
+
tables: TableSchema[];
|
|
5
|
+
}
|
|
6
|
+
/** Max documents read per collection, purely to infer field types (see .ai/context.md guardrails). */
|
|
7
|
+
export declare const NOSQL_SAMPLE_LIMIT = 50;
|
|
8
|
+
/** Status for an environment with no usable connection configured. */
|
|
9
|
+
export declare function disconnectedStatus(error: string, engine?: DatabaseEngine, url?: string, meta?: {
|
|
10
|
+
sourceKey?: string;
|
|
11
|
+
detectedFromExample?: boolean;
|
|
12
|
+
exampleFile?: string | null;
|
|
13
|
+
suggestion?: string | null;
|
|
14
|
+
}): DatabaseStatus;
|
|
15
|
+
/**
|
|
16
|
+
* Connects, measures ping latency, and introspects the full table structure.
|
|
17
|
+
* Never throws: failures are reported through `status.error` with credentials scrubbed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function introspectDatabase(url: string): Promise<IntrospectionResult>;
|
|
20
|
+
/** Connection health only (ping + table count); same shape as GET /api/status entries. */
|
|
21
|
+
export declare function getDatabaseStatus(url: string): Promise<DatabaseStatus>;
|