@njeybe/nativ 2.4.1 → 2.5.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/CHANGELOG.md +26 -0
- package/README.md +26 -0
- package/dist/commands/db/db-formatter.d.ts +5 -0
- package/dist/commands/db/db-formatter.js +116 -0
- package/dist/commands/db/db-introspect.d.ts +11 -0
- package/dist/commands/db/db-introspect.js +84 -0
- package/dist/commands/db.js +4 -206
- package/dist/commands/profile.d.ts +3 -0
- package/dist/commands/profile.js +39 -0
- package/dist/commands/task/task-add.d.ts +11 -0
- package/dist/commands/task/task-add.js +169 -0
- package/dist/commands/task/task-common.d.ts +57 -0
- package/dist/commands/task/task-common.js +45 -0
- package/dist/commands/task/task-escalate.d.ts +7 -0
- package/dist/commands/task/task-escalate.js +119 -0
- package/dist/commands/task/task-lifecycle.d.ts +5 -0
- package/dist/commands/task/task-lifecycle.js +252 -0
- package/dist/commands/task/task-query.d.ts +10 -0
- package/dist/commands/task/task-query.js +269 -0
- package/dist/commands/task.d.ts +9 -87
- package/dist/commands/task.js +8 -850
- package/dist/commands/triage/triage-store.d.ts +56 -0
- package/dist/commands/triage/triage-store.js +108 -0
- package/dist/commands/triage/triage-ui.d.ts +14 -0
- package/dist/commands/triage/triage-ui.js +104 -0
- package/dist/commands/triage.d.ts +3 -52
- package/dist/commands/triage.js +8 -216
- package/dist/core/enforcement.d.ts +1 -0
- package/dist/core/enforcement.js +19 -0
- package/dist/core/human-gate.d.ts +2 -1
- package/dist/core/human-gate.js +9 -2
- package/dist/core/profiles.d.ts +14 -0
- package/dist/core/profiles.js +82 -0
- package/dist/core/setup/config-merger.d.ts +59 -0
- package/dist/core/setup/config-merger.js +107 -0
- package/dist/core/setup/session-context.d.ts +4 -0
- package/dist/core/setup/session-context.js +65 -0
- package/dist/core/setup/setup-planner.d.ts +14 -0
- package/dist/core/setup/setup-planner.js +180 -0
- package/dist/core/setup/template-planner.d.ts +8 -0
- package/dist/core/setup/template-planner.js +32 -0
- package/dist/core/setup/types.d.ts +65 -0
- package/dist/core/setup/types.js +55 -0
- package/dist/core/setup-assets.d.ts +8 -119
- package/dist/core/setup-assets.js +7 -437
- package/dist/core/tier1-liaison.d.ts +2 -0
- package/dist/core/tier1-liaison.js +64 -3
- package/dist/core/verifier/executor.d.ts +5 -0
- package/dist/core/verifier/executor.js +62 -0
- package/dist/core/verifier/phases.d.ts +5 -0
- package/dist/core/verifier/phases.js +71 -0
- package/dist/core/verifier/task-verifier.d.ts +37 -0
- package/dist/core/verifier/task-verifier.js +233 -0
- package/dist/core/verifier/types.d.ts +51 -0
- package/dist/core/verifier/types.js +1 -0
- package/dist/core/verifier.d.ts +5 -93
- package/dist/core/verifier.js +5 -359
- package/dist/db/introspector.js +59 -59
- package/dist/governor/evaluator.d.ts +4 -64
- package/dist/governor/evaluator.js +21 -686
- package/dist/governor/patch-applier.d.ts +8 -0
- package/dist/governor/patch-applier.js +121 -0
- package/dist/governor/self-healing.d.ts +24 -0
- package/dist/governor/self-healing.js +351 -0
- package/dist/governor/test-integrity.d.ts +41 -0
- package/dist/governor/test-integrity.js +237 -0
- package/dist/index.js +8 -0
- package/dist/mcp/capture.d.ts +4 -0
- package/dist/mcp/capture.js +33 -0
- package/dist/mcp/resources.d.ts +39 -0
- package/dist/mcp/resources.js +44 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +14 -89
- package/dist/mcp/tools.d.ts +1 -0
- package/dist/mcp/tools.js +1 -0
- package/dist/runner/agent-supervisor.d.ts +8 -302
- package/dist/runner/agent-supervisor.js +41 -1030
- package/dist/runner/cli-config.d.ts +84 -0
- package/dist/runner/cli-config.js +215 -0
- package/dist/runner/native-driver.d.ts +18 -0
- package/dist/runner/native-driver.js +208 -0
- package/dist/runner/native-engine.d.ts +27 -0
- package/dist/runner/native-engine.js +184 -0
- package/dist/runner/native-tools.d.ts +31 -0
- package/dist/runner/native-tools.js +241 -0
- package/dist/runner/process-utils.d.ts +12 -0
- package/dist/runner/process-utils.js +96 -0
- package/dist/runner/run-lifecycle.d.ts +6 -0
- package/dist/runner/run-lifecycle.js +42 -0
- package/dist/runner/types.d.ts +215 -0
- package/dist/runner/types.js +41 -0
- package/dist/scanner/detector.js +42 -663
- package/dist/scanner/detectors/ecosystem-detector.d.ts +16 -0
- package/dist/scanner/detectors/ecosystem-detector.js +354 -0
- package/dist/scanner/detectors/orm-detector.d.ts +5 -0
- package/dist/scanner/detectors/orm-detector.js +59 -0
- package/dist/scanner/detectors/workspace-detector.d.ts +19 -0
- package/dist/scanner/detectors/workspace-detector.js +266 -0
- package/dist/server/cli-runner.d.ts +15 -0
- package/dist/server/cli-runner.js +43 -0
- package/dist/server/event-hub.d.ts +42 -0
- package/dist/server/event-hub.js +169 -0
- package/dist/server/http-utils.d.ts +15 -0
- package/dist/server/http-utils.js +56 -0
- package/dist/server/plan-utils.d.ts +9 -0
- package/dist/server/plan-utils.js +31 -0
- package/dist/server/routes/db-routes.d.ts +57 -0
- package/dist/server/routes/db-routes.js +484 -0
- package/dist/server/routes/task-routes.d.ts +266 -0
- package/dist/server/routes/task-routes.js +285 -0
- package/dist/server/routes/telemetry-routes.d.ts +128 -0
- package/dist/server/routes/telemetry-routes.js +179 -0
- package/dist/server/routes/triage-routes.d.ts +81 -0
- package/dist/server/routes/triage-routes.js +244 -0
- package/dist/server/routes/worktree-routes.d.ts +31 -0
- package/dist/server/routes/worktree-routes.js +144 -0
- package/dist/server/studio-server.d.ts +4 -11
- package/dist/server/studio-server.js +34 -1578
- package/dist/server/studio-ui/shell/pipeline-state.js +2 -0
- package/dist/server/studio-ui/shell/triage.js +10 -0
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +4 -2
- package/plugin/agents/verifier.md +1 -1
- package/plugin/agents/worker.md +7 -4
- package/plugin/skills/nativ/SKILL.md +1 -0
- package/templates/.claudeignore +62 -45
- package/templates/AGENTS.md +1 -0
- package/templates/CLAUDE.md +13 -0
- package/templates/claude-agents/architect.md +4 -2
- package/templates/claude-agents/verifier.md +1 -1
- package/templates/claude-agents/worker.md +7 -4
- package/templates/dot-ai/api_contracts.json +66 -66
- package/templates/dot-ai/context.md +38 -38
- package/templates/dot-ai/db_schema.json +39 -39
- package/templates/dot-ai/subagents/architect.md +54 -0
- package/dist/commands/session.d.ts +0 -22
- package/dist/commands/session.js +0 -106
- package/dist/core/session-memory.d.ts +0 -42
- package/dist/core/session-memory.js +0 -121
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type http from 'node:http';
|
|
2
|
+
import type { ComponentEngine, ConnectionComponents, ConnectResponse, DatabaseEngine, DatabaseEnv, DatabaseStatus, ExportContractResponse, FirestoreConfig, SchemaResponse, StatusResponse, TableSchema } from '../../db/types.js';
|
|
3
|
+
import { type IntrospectionResult } from '../../db/introspector.js';
|
|
4
|
+
import { type ContractSchemaDiff } from '../../db/diff.js';
|
|
5
|
+
import { type TestGenResult } from '../../core/test-generator-types.js';
|
|
6
|
+
export declare const CACHE_TTL_MS = 5000;
|
|
7
|
+
export declare const ENVS: DatabaseEnv[];
|
|
8
|
+
/** Where a session URL came from; shown as badges in status output. */
|
|
9
|
+
export interface ConnectionMeta {
|
|
10
|
+
sourceKey?: string;
|
|
11
|
+
detectedFromExample?: boolean;
|
|
12
|
+
exampleFile?: string | null;
|
|
13
|
+
synthesized?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** In-memory studio session: raw URLs never leave this object except to database drivers. */
|
|
16
|
+
export declare class StudioSession {
|
|
17
|
+
private readonly urls;
|
|
18
|
+
private readonly meta;
|
|
19
|
+
private readonly cache;
|
|
20
|
+
private readonly cwd;
|
|
21
|
+
constructor(initial: Partial<Record<DatabaseEnv, string | null>>, cwd?: string, initialMeta?: Partial<Record<DatabaseEnv, ConnectionMeta | null>>);
|
|
22
|
+
set(env: DatabaseEnv, url: string, result: IntrospectionResult, meta?: ConnectionMeta): void;
|
|
23
|
+
getUrl(env: DatabaseEnv): string | null;
|
|
24
|
+
invalidate(): void;
|
|
25
|
+
introspect(env: DatabaseEnv, fresh?: boolean): Promise<IntrospectionResult>;
|
|
26
|
+
}
|
|
27
|
+
export declare function handleStatus(session: StudioSession, fresh: boolean): Promise<StatusResponse>;
|
|
28
|
+
export declare function handleSchema(session: StudioSession, envParam: string | null, fresh: boolean): Promise<SchemaResponse>;
|
|
29
|
+
/**
|
|
30
|
+
* GET /api/diff: contract keys (added/dropped/altered/unchanged, summary.*Count + engine,
|
|
31
|
+
* migrationScript) plus legacy *Tables aliases. The script targets the engine being migrated:
|
|
32
|
+
* Prod's engine, or the contract's declared engine (falling back to Dev's).
|
|
33
|
+
*/
|
|
34
|
+
export declare function handleDiff(session: StudioSession, cwd: string, targetParam: string | null, fresh: boolean): Promise<ContractSchemaDiff>;
|
|
35
|
+
export declare const ENGINES: DatabaseEngine[];
|
|
36
|
+
export declare function parseFirestoreConfig(value: unknown): FirestoreConfig | null;
|
|
37
|
+
export declare const COMPONENT_ENGINES: ComponentEngine[];
|
|
38
|
+
export declare function optionalString(raw: Record<string, unknown>, field: string): string | undefined;
|
|
39
|
+
/** Validates the `components` payload ({ engine, host, port, user, password, database }). */
|
|
40
|
+
export declare function parseComponents(value: unknown, topLevelEngine: DatabaseEngine | null): ConnectionComponents | null;
|
|
41
|
+
/**
|
|
42
|
+
* POST /api/connect. Accepts `connectionUrl`, `components` (parameter / XAMPP form, assembled
|
|
43
|
+
* in-memory), or (for Firestore) `firestoreConfig` alone. When `engine` is given it must agree
|
|
44
|
+
* with the URL's scheme.
|
|
45
|
+
*/
|
|
46
|
+
export declare function handleConnect(session: StudioSession, body: Record<string, unknown>): Promise<ConnectResponse>;
|
|
47
|
+
/** Writes structure-only table metadata into .ai/db_schema.json, preserving other contract fields. */
|
|
48
|
+
export declare function writeContractSchema(cwd: string, status: DatabaseStatus, tables: TableSchema[]): string;
|
|
49
|
+
export declare function handleExportContract(session: StudioSession, cwd: string, body: Record<string, unknown>): Promise<ExportContractResponse>;
|
|
50
|
+
export declare const TEST_GEN_ERROR_STATUS: Record<string, number>;
|
|
51
|
+
/** POST /api/tests/generate. Output is confined to the project directory. */
|
|
52
|
+
export declare function handleTestGenerate(cwd: string, body: Record<string, unknown>): TestGenResult;
|
|
53
|
+
export declare function logProdAudit(cwd: string, action: 'INSERT' | 'UPDATE' | 'DELETE', entity: string, primaryKey: string | number | null, details: Record<string, unknown>, req: http.IncomingMessage): void;
|
|
54
|
+
export declare function handleDataGet(session: StudioSession, searchParams: URLSearchParams): Promise<unknown>;
|
|
55
|
+
export declare function handleDataPost(session: StudioSession, cwd: string, body: Record<string, unknown>, req: http.IncomingMessage): Promise<unknown>;
|
|
56
|
+
export declare function handleDataPut(session: StudioSession, cwd: string, body: Record<string, unknown>, req: http.IncomingMessage): Promise<unknown>;
|
|
57
|
+
export declare function handleDataDelete(session: StudioSession, cwd: string, body: Record<string, unknown>, req: http.IncomingMessage): Promise<unknown>;
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { connectionUrlFromComponents, detectEngine, firestoreUrlFromConfig, maskConnectionString, resolveConnections, } from '../../db/env-parser.js';
|
|
4
|
+
import { disconnectedStatus, introspectDatabase } from '../../db/introspector.js';
|
|
5
|
+
import { CONTRACT_SCHEMA_PATH, diffSchemas, loadContractEngine, loadContractTables, toContractDiff, } from '../../db/diff.js';
|
|
6
|
+
import { fetchData, insertRecord, updateRecord, deleteRecord } from '../../db/data-engine.js';
|
|
7
|
+
import { generateContractTests, TestGenError } from '../../core/test-generator.js';
|
|
8
|
+
import { isTestFramework, TEST_FRAMEWORKS } from '../../core/test-generator-types.js';
|
|
9
|
+
import { HttpError, parseEnv } from '../http-utils.js';
|
|
10
|
+
export const CACHE_TTL_MS = 5000;
|
|
11
|
+
export const ENVS = ['dev', 'prod'];
|
|
12
|
+
/** In-memory studio session: raw URLs never leave this object except to database drivers. */
|
|
13
|
+
export class StudioSession {
|
|
14
|
+
urls = { dev: null, prod: null };
|
|
15
|
+
meta = new Map();
|
|
16
|
+
cache = new Map();
|
|
17
|
+
cwd;
|
|
18
|
+
constructor(initial, cwd = process.cwd(), initialMeta) {
|
|
19
|
+
this.cwd = cwd;
|
|
20
|
+
for (const env of ENVS) {
|
|
21
|
+
this.urls[env] = initial[env] ?? null;
|
|
22
|
+
if (initialMeta?.[env])
|
|
23
|
+
this.meta.set(env, initialMeta[env]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
set(env, url, result, meta) {
|
|
27
|
+
this.urls[env] = url;
|
|
28
|
+
if (meta)
|
|
29
|
+
this.meta.set(env, meta);
|
|
30
|
+
else
|
|
31
|
+
this.meta.delete(env);
|
|
32
|
+
this.cache.set(env, { at: Date.now(), result });
|
|
33
|
+
}
|
|
34
|
+
getUrl(env) {
|
|
35
|
+
return this.urls[env];
|
|
36
|
+
}
|
|
37
|
+
invalidate() {
|
|
38
|
+
this.cache.clear();
|
|
39
|
+
}
|
|
40
|
+
async introspect(env, fresh = false) {
|
|
41
|
+
const url = this.urls[env];
|
|
42
|
+
if (!url) {
|
|
43
|
+
const conns = resolveConnections(this.cwd);
|
|
44
|
+
const missingKey = conns.templateInfo?.missingKeys.find((k) => k.targetEnv === env);
|
|
45
|
+
if (missingKey) {
|
|
46
|
+
const engineLabel = missingKey.engine ? ` (${missingKey.engine})` : '';
|
|
47
|
+
const templateName = conns.templateInfo?.templateFile ?? '.env.example';
|
|
48
|
+
const suggestion = `Found "${missingKey.key}"${engineLabel} in ${templateName}, but it is not set in your .env`;
|
|
49
|
+
return {
|
|
50
|
+
status: disconnectedStatus(`No ${env === 'dev' ? 'Dev' : 'Prod'} database configured. ${suggestion}`, missingKey.engine ?? 'postgresql', '', {
|
|
51
|
+
sourceKey: missingKey.key,
|
|
52
|
+
detectedFromExample: true,
|
|
53
|
+
exampleFile: templateName,
|
|
54
|
+
suggestion,
|
|
55
|
+
}),
|
|
56
|
+
tables: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
status: disconnectedStatus(`No ${env === 'dev' ? 'Dev' : 'Prod'} database configured. Use Connection Settings or set ${env.toUpperCase()}_DATABASE_URL.`),
|
|
61
|
+
tables: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const cached = this.cache.get(env);
|
|
65
|
+
if (!fresh && cached && Date.now() - cached.at < CACHE_TTL_MS)
|
|
66
|
+
return cached.result;
|
|
67
|
+
const result = await introspectDatabase(url);
|
|
68
|
+
const m = this.meta.get(env);
|
|
69
|
+
if (m) {
|
|
70
|
+
result.status.sourceKey = m.sourceKey;
|
|
71
|
+
result.status.detectedFromExample = m.detectedFromExample;
|
|
72
|
+
result.status.exampleFile = m.exampleFile;
|
|
73
|
+
if (m.synthesized)
|
|
74
|
+
result.status.synthesized = true;
|
|
75
|
+
}
|
|
76
|
+
this.cache.set(env, { at: Date.now(), result });
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function handleStatus(session, fresh) {
|
|
81
|
+
const [dev, prod] = await Promise.all(ENVS.map((env) => session.introspect(env, fresh)));
|
|
82
|
+
return { dev: dev.status, prod: prod.status };
|
|
83
|
+
}
|
|
84
|
+
export async function handleSchema(session, envParam, fresh) {
|
|
85
|
+
const env = envParam ? parseEnv(envParam, 'env') : null;
|
|
86
|
+
const [devTables, prodTables] = await Promise.all(ENVS.map(async (e) => (!env || env === e ? (await session.introspect(e, fresh)).tables : [])));
|
|
87
|
+
return { devTables, prodTables };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* GET /api/diff: contract keys (added/dropped/altered/unchanged, summary.*Count + engine,
|
|
91
|
+
* migrationScript) plus legacy *Tables aliases. The script targets the engine being migrated:
|
|
92
|
+
* Prod's engine, or the contract's declared engine (falling back to Dev's).
|
|
93
|
+
*/
|
|
94
|
+
export async function handleDiff(session, cwd, targetParam, fresh) {
|
|
95
|
+
const target = targetParam ?? 'prod';
|
|
96
|
+
if (target !== 'prod' && target !== 'contract') {
|
|
97
|
+
throw new HttpError(400, 'VALIDATION_ERROR', `"target" must be 'prod' or 'contract'`);
|
|
98
|
+
}
|
|
99
|
+
const dev = await session.introspect('dev', fresh);
|
|
100
|
+
if (!dev.status.connected)
|
|
101
|
+
throw new HttpError(409, 'DEV_NOT_CONNECTED', dev.status.error ?? 'Dev database is not connected');
|
|
102
|
+
if (target === 'contract') {
|
|
103
|
+
const engine = loadContractEngine(cwd) ?? dev.status.engine;
|
|
104
|
+
return toContractDiff(diffSchemas(dev.tables, loadContractTables(cwd)), engine, '.ai/db_schema.json');
|
|
105
|
+
}
|
|
106
|
+
const prod = await session.introspect('prod', fresh);
|
|
107
|
+
if (!prod.status.connected)
|
|
108
|
+
throw new HttpError(409, 'PROD_NOT_CONNECTED', prod.status.error ?? 'Prod database is not connected');
|
|
109
|
+
return toContractDiff(diffSchemas(dev.tables, prod.tables), prod.status.engine, 'Prod');
|
|
110
|
+
}
|
|
111
|
+
export const ENGINES = ['postgresql', 'mysql', 'sqlite', 'mongodb', 'firestore'];
|
|
112
|
+
export function parseFirestoreConfig(value) {
|
|
113
|
+
if (value === undefined || value === null)
|
|
114
|
+
return null;
|
|
115
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
116
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig" must be an object');
|
|
117
|
+
const raw = value;
|
|
118
|
+
const projectId = typeof raw.projectId === 'string' ? raw.projectId.trim() : '';
|
|
119
|
+
if (!/^[a-z0-9][a-z0-9-]{2,62}$/.test(projectId)) {
|
|
120
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig.projectId" must be a valid Firestore project id');
|
|
121
|
+
}
|
|
122
|
+
const config = { projectId };
|
|
123
|
+
if (raw.emulatorHost !== undefined && raw.emulatorHost !== '') {
|
|
124
|
+
const host = typeof raw.emulatorHost === 'string' ? raw.emulatorHost.trim() : '';
|
|
125
|
+
if (!/^[A-Za-z0-9.\-[\]:]+:\d{1,5}$/.test(host))
|
|
126
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig.emulatorHost" must look like host:port');
|
|
127
|
+
config.emulatorHost = host;
|
|
128
|
+
}
|
|
129
|
+
if (raw.databaseId !== undefined && raw.databaseId !== '') {
|
|
130
|
+
const db = typeof raw.databaseId === 'string' ? raw.databaseId.trim() : '';
|
|
131
|
+
if (!/^[a-z0-9()][a-z0-9-()]{0,62}$/.test(db))
|
|
132
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig.databaseId" is invalid');
|
|
133
|
+
config.databaseId = db;
|
|
134
|
+
}
|
|
135
|
+
return config;
|
|
136
|
+
}
|
|
137
|
+
export const COMPONENT_ENGINES = ['mysql', 'postgresql'];
|
|
138
|
+
export function optionalString(raw, field) {
|
|
139
|
+
const value = raw[field];
|
|
140
|
+
if (value === undefined || value === null)
|
|
141
|
+
return undefined;
|
|
142
|
+
if (typeof value !== 'string')
|
|
143
|
+
throw new HttpError(400, 'VALIDATION_ERROR', `"components.${field}" must be a string`);
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
/** Validates the `components` payload ({ engine, host, port, user, password, database }). */
|
|
147
|
+
export function parseComponents(value, topLevelEngine) {
|
|
148
|
+
if (value === undefined || value === null)
|
|
149
|
+
return null;
|
|
150
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
151
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"components" must be an object');
|
|
152
|
+
const raw = value;
|
|
153
|
+
const engine = raw.engine ?? topLevelEngine;
|
|
154
|
+
if (typeof engine !== 'string' || !COMPONENT_ENGINES.includes(engine)) {
|
|
155
|
+
throw new HttpError(400, 'VALIDATION_ERROR', `"components.engine" must be one of ${COMPONENT_ENGINES.join(', ')}`);
|
|
156
|
+
}
|
|
157
|
+
if (topLevelEngine && topLevelEngine !== engine) {
|
|
158
|
+
throw new HttpError(400, 'VALIDATION_ERROR', `"engine" is "${topLevelEngine}" but "components.engine" is "${engine}"`);
|
|
159
|
+
}
|
|
160
|
+
const host = (optionalString(raw, 'host') ?? '').trim();
|
|
161
|
+
if (!/^[A-Za-z0-9._:[\]-]{1,253}$/.test(host))
|
|
162
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"components.host" must be a hostname or IP address');
|
|
163
|
+
let port;
|
|
164
|
+
if (raw.port !== undefined && raw.port !== null && raw.port !== '') {
|
|
165
|
+
port = typeof raw.port === 'number' ? raw.port : typeof raw.port === 'string' && /^\d+$/.test(raw.port.trim()) ? Number(raw.port) : NaN;
|
|
166
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
167
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"components.port" must be an integer between 1 and 65535');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const user = optionalString(raw, 'user')?.trim();
|
|
171
|
+
const password = optionalString(raw, 'password');
|
|
172
|
+
const database = optionalString(raw, 'database')?.trim();
|
|
173
|
+
return {
|
|
174
|
+
engine: engine,
|
|
175
|
+
host,
|
|
176
|
+
...(port ? { port } : {}),
|
|
177
|
+
...(user ? { user } : {}),
|
|
178
|
+
...(password ? { password } : {}),
|
|
179
|
+
...(database ? { database } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* POST /api/connect. Accepts `connectionUrl`, `components` (parameter / XAMPP form, assembled
|
|
184
|
+
* in-memory), or (for Firestore) `firestoreConfig` alone. When `engine` is given it must agree
|
|
185
|
+
* with the URL's scheme.
|
|
186
|
+
*/
|
|
187
|
+
export async function handleConnect(session, body) {
|
|
188
|
+
const env = parseEnv(body.env, 'env');
|
|
189
|
+
let engine = null;
|
|
190
|
+
if (body.engine !== undefined) {
|
|
191
|
+
if (typeof body.engine !== 'string' || !ENGINES.includes(body.engine)) {
|
|
192
|
+
throw new HttpError(400, 'VALIDATION_ERROR', `"engine" must be one of ${ENGINES.join(', ')}`);
|
|
193
|
+
}
|
|
194
|
+
engine = body.engine;
|
|
195
|
+
}
|
|
196
|
+
const firestoreConfig = parseFirestoreConfig(body.firestoreConfig);
|
|
197
|
+
let connectionUrl = typeof body.connectionUrl === 'string' ? body.connectionUrl.trim() : '';
|
|
198
|
+
const components = connectionUrl ? null : parseComponents(body.components, engine);
|
|
199
|
+
if (!connectionUrl && components) {
|
|
200
|
+
try {
|
|
201
|
+
connectionUrl = connectionUrlFromComponents(components);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
throw new HttpError(400, 'VALIDATION_ERROR', err.message);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!connectionUrl && firestoreConfig) {
|
|
208
|
+
if (engine && engine !== 'firestore')
|
|
209
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"firestoreConfig" requires engine "firestore"');
|
|
210
|
+
connectionUrl = firestoreUrlFromConfig(firestoreConfig);
|
|
211
|
+
}
|
|
212
|
+
if (!connectionUrl) {
|
|
213
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"connectionUrl" or "components" is required (or "firestoreConfig" for Firestore)');
|
|
214
|
+
}
|
|
215
|
+
const detected = detectEngine(connectionUrl);
|
|
216
|
+
if (!detected) {
|
|
217
|
+
throw new HttpError(400, 'VALIDATION_ERROR', 'Unsupported connection URL (expected postgres://, mysql://, sqlite:, file:, mongodb://, mongodb+srv:// or firestore://)');
|
|
218
|
+
}
|
|
219
|
+
if (engine && engine !== detected) {
|
|
220
|
+
throw new HttpError(400, 'VALIDATION_ERROR', `"engine" is "${engine}" but the connection URL is for ${detected}`);
|
|
221
|
+
}
|
|
222
|
+
const result = await introspectDatabase(connectionUrl);
|
|
223
|
+
// Only persist to the in-memory session once the connection is proven to work.
|
|
224
|
+
if (result.status.connected)
|
|
225
|
+
session.set(env, connectionUrl, result);
|
|
226
|
+
const count = result.status.entityCount ?? result.status.tableCount;
|
|
227
|
+
return {
|
|
228
|
+
success: result.status.connected,
|
|
229
|
+
pingMs: result.status.pingMs,
|
|
230
|
+
engine: result.status.engine,
|
|
231
|
+
entityCount: count,
|
|
232
|
+
tableCount: count,
|
|
233
|
+
maskedUrl: maskConnectionString(connectionUrl),
|
|
234
|
+
error: result.status.error,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** Writes structure-only table metadata into .ai/db_schema.json, preserving other contract fields. */
|
|
238
|
+
export function writeContractSchema(cwd, status, tables) {
|
|
239
|
+
const filePath = path.join(cwd, CONTRACT_SCHEMA_PATH);
|
|
240
|
+
let existing = {};
|
|
241
|
+
try {
|
|
242
|
+
existing = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Missing or unreadable contract — start fresh.
|
|
246
|
+
}
|
|
247
|
+
const previousDescriptions = new Map();
|
|
248
|
+
for (const t of existing.tables ?? []) {
|
|
249
|
+
if (t?.name && t.description)
|
|
250
|
+
previousDescriptions.set(t.name.toLowerCase(), t.description);
|
|
251
|
+
}
|
|
252
|
+
// Rebuild each table field-by-field so nothing beyond structural metadata can leak in.
|
|
253
|
+
// Sampled values (media URLs, Base64 prefixes, document counts) are deliberately left out.
|
|
254
|
+
const sanitizedTables = tables.map((t) => {
|
|
255
|
+
const description = t.description ?? previousDescriptions.get(t.name.toLowerCase());
|
|
256
|
+
return {
|
|
257
|
+
name: t.name,
|
|
258
|
+
...(t.entityType ? { entityType: t.entityType } : {}),
|
|
259
|
+
...(description ? { description } : {}),
|
|
260
|
+
columns: t.columns.map((c) => ({
|
|
261
|
+
name: c.name,
|
|
262
|
+
type: c.type,
|
|
263
|
+
primaryKey: c.primaryKey,
|
|
264
|
+
nullable: c.nullable,
|
|
265
|
+
default: c.default,
|
|
266
|
+
...(c.isMedia ? { isMedia: true } : {}),
|
|
267
|
+
...(c.isSubcollection ? { isSubcollection: true } : {}),
|
|
268
|
+
})),
|
|
269
|
+
indexes: t.indexes.map((i) => ({ name: i.name, columns: [...i.columns], unique: i.unique })),
|
|
270
|
+
foreignKeys: t.foreignKeys.map((f) => ({
|
|
271
|
+
name: f.name,
|
|
272
|
+
column: f.column,
|
|
273
|
+
referencedTable: f.referencedTable,
|
|
274
|
+
referencedColumn: f.referencedColumn,
|
|
275
|
+
...(f.onDelete ? { onDelete: f.onDelete } : {}),
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
const contract = {
|
|
280
|
+
$schema: existing.$schema ?? 'http://json-schema.org/draft-07/schema#',
|
|
281
|
+
version: existing.version ?? '1.0.0',
|
|
282
|
+
projectName: existing.projectName ?? path.basename(cwd),
|
|
283
|
+
updatedAt: new Date().toISOString(),
|
|
284
|
+
engine: status.engine,
|
|
285
|
+
tables: sanitizedTables,
|
|
286
|
+
migrations: existing.migrations ?? [],
|
|
287
|
+
};
|
|
288
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
289
|
+
fs.writeFileSync(filePath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
|
|
290
|
+
return filePath;
|
|
291
|
+
}
|
|
292
|
+
export async function handleExportContract(session, cwd, body) {
|
|
293
|
+
const sourceEnv = parseEnv(body.sourceEnv, 'sourceEnv');
|
|
294
|
+
const result = await session.introspect(sourceEnv, true);
|
|
295
|
+
if (!result.status.connected) {
|
|
296
|
+
throw new HttpError(409, `${sourceEnv.toUpperCase()}_NOT_CONNECTED`, result.status.error ?? 'Source database is not connected');
|
|
297
|
+
}
|
|
298
|
+
writeContractSchema(cwd, result.status, result.tables);
|
|
299
|
+
return {
|
|
300
|
+
success: true,
|
|
301
|
+
filePath: CONTRACT_SCHEMA_PATH.split(path.sep).join('/'),
|
|
302
|
+
exportedEntitiesCount: result.tables.length,
|
|
303
|
+
exportedTablesCount: result.tables.length,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
export const TEST_GEN_ERROR_STATUS = {
|
|
307
|
+
INVALID_FRAMEWORK: 400,
|
|
308
|
+
INVALID_BASE_URL: 400,
|
|
309
|
+
INVALID_OUTPUT_PATH: 400,
|
|
310
|
+
NO_CONTRACTS: 404,
|
|
311
|
+
OUTPUT_CONFLICT: 409,
|
|
312
|
+
INVALID_CONTRACT: 422,
|
|
313
|
+
};
|
|
314
|
+
/** POST /api/tests/generate. Output is confined to the project directory. */
|
|
315
|
+
export function handleTestGenerate(cwd, body) {
|
|
316
|
+
const { outputDir, framework, baseUrl, dryRun } = body;
|
|
317
|
+
if (outputDir !== undefined && typeof outputDir !== 'string') {
|
|
318
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"outputDir" must be a string');
|
|
319
|
+
}
|
|
320
|
+
if (framework !== undefined && (typeof framework !== 'string' || !isTestFramework(framework))) {
|
|
321
|
+
throw new HttpError(400, 'VALIDATION_ERROR', `"framework" must be one of: ${TEST_FRAMEWORKS.join(', ')}`);
|
|
322
|
+
}
|
|
323
|
+
if (baseUrl !== undefined && typeof baseUrl !== 'string') {
|
|
324
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"baseUrl" must be a string');
|
|
325
|
+
}
|
|
326
|
+
if (dryRun !== undefined && typeof dryRun !== 'boolean') {
|
|
327
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"dryRun" must be a boolean');
|
|
328
|
+
}
|
|
329
|
+
if (outputDir) {
|
|
330
|
+
const rel = path.relative(cwd, path.resolve(cwd, outputDir));
|
|
331
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
332
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"outputDir" must be a subdirectory of the project');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
return generateContractTests({ targetDir: cwd, outputDir, framework, baseUrl, dryRun });
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
if (err instanceof TestGenError)
|
|
340
|
+
throw new HttpError(TEST_GEN_ERROR_STATUS[err.code] ?? 400, err.code, err.message);
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
export function logProdAudit(cwd, action, entity, primaryKey, details, req) {
|
|
345
|
+
try {
|
|
346
|
+
const entry = {
|
|
347
|
+
timestamp: new Date().toISOString(),
|
|
348
|
+
env: 'prod',
|
|
349
|
+
action,
|
|
350
|
+
entity,
|
|
351
|
+
primaryKey,
|
|
352
|
+
clientIp: req.socket.remoteAddress ?? '127.0.0.1',
|
|
353
|
+
details,
|
|
354
|
+
};
|
|
355
|
+
const dir = path.join(cwd, '.nativ');
|
|
356
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
357
|
+
const logFile = path.join(dir, 'prod_audit.log');
|
|
358
|
+
fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`, 'utf8');
|
|
359
|
+
const legacyDir = path.join(cwd, '.agentj');
|
|
360
|
+
if (fs.existsSync(legacyDir)) {
|
|
361
|
+
try {
|
|
362
|
+
fs.appendFileSync(path.join(legacyDir, 'prod_audit.log'), `${JSON.stringify(entry)}\n`, 'utf8');
|
|
363
|
+
}
|
|
364
|
+
catch { }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
console.error('Failed to append to prod_audit.log:', err);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
export async function handleDataGet(session, searchParams) {
|
|
372
|
+
const env = parseEnv(searchParams.get('env'), 'env');
|
|
373
|
+
const entity = searchParams.get('entity')?.trim();
|
|
374
|
+
if (!entity)
|
|
375
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"entity" query parameter is required');
|
|
376
|
+
const page = searchParams.has('page') ? parseInt(searchParams.get('page'), 10) : undefined;
|
|
377
|
+
const limit = searchParams.has('limit') ? parseInt(searchParams.get('limit'), 10) : undefined;
|
|
378
|
+
const sort = searchParams.get('sort')?.trim() || undefined;
|
|
379
|
+
const rawOrder = searchParams.get('order')?.toLowerCase();
|
|
380
|
+
const order = rawOrder === 'desc' ? 'desc' : rawOrder === 'asc' ? 'asc' : undefined;
|
|
381
|
+
const search = searchParams.get('search')?.trim() || undefined;
|
|
382
|
+
const url = session.getUrl(env);
|
|
383
|
+
if (!url) {
|
|
384
|
+
throw new HttpError(409, `${env.toUpperCase()}_NOT_CONNECTED`, `No ${env === 'dev' ? 'Dev' : 'Prod'} database configured.`);
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
return await fetchData(url, entity, { page, limit, sort, order, search });
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
throw new HttpError(500, 'DATA_FETCH_ERROR', err.message);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
export async function handleDataPost(session, cwd, body, req) {
|
|
394
|
+
const env = parseEnv(body.env, 'env');
|
|
395
|
+
const entity = typeof body.entity === 'string' ? body.entity.trim() : '';
|
|
396
|
+
if (!entity)
|
|
397
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"entity" is required');
|
|
398
|
+
if (typeof body.record !== 'object' || body.record === null || Array.isArray(body.record)) {
|
|
399
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"record" must be an object');
|
|
400
|
+
}
|
|
401
|
+
const record = body.record;
|
|
402
|
+
if (env === 'prod') {
|
|
403
|
+
if (body.confirmProd !== true || typeof body.challengePhrase !== 'string' || body.challengePhrase.trim() !== entity) {
|
|
404
|
+
throw new HttpError(403, 'PROD_CHALLENGE_REQUIRED', `Production insert requires confirmProd=true and challengePhrase matching "${entity}"`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const url = session.getUrl(env);
|
|
408
|
+
if (!url) {
|
|
409
|
+
throw new HttpError(409, `${env.toUpperCase()}_NOT_CONNECTED`, `No ${env === 'dev' ? 'Dev' : 'Prod'} database configured.`);
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const result = await insertRecord(url, entity, record);
|
|
413
|
+
if (env === 'prod') {
|
|
414
|
+
const pk = result?.id ?? null;
|
|
415
|
+
logProdAudit(cwd, 'INSERT', entity, pk, { record }, req);
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
throw new HttpError(500, 'DATA_MUTATION_ERROR', err.message);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
export async function handleDataPut(session, cwd, body, req) {
|
|
424
|
+
const env = parseEnv(body.env, 'env');
|
|
425
|
+
const entity = typeof body.entity === 'string' ? body.entity.trim() : '';
|
|
426
|
+
if (!entity)
|
|
427
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"entity" is required');
|
|
428
|
+
const primaryKey = body.primaryKey;
|
|
429
|
+
if (primaryKey === undefined || primaryKey === null || primaryKey === '') {
|
|
430
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"primaryKey" is required for updates');
|
|
431
|
+
}
|
|
432
|
+
if (typeof body.updates !== 'object' || body.updates === null || Array.isArray(body.updates)) {
|
|
433
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"updates" must be an object');
|
|
434
|
+
}
|
|
435
|
+
const updates = body.updates;
|
|
436
|
+
if (env === 'prod') {
|
|
437
|
+
if (body.confirmProd !== true || typeof body.challengePhrase !== 'string' || body.challengePhrase.trim() !== entity) {
|
|
438
|
+
throw new HttpError(403, 'PROD_CHALLENGE_REQUIRED', `Production update requires confirmProd=true and challengePhrase matching "${entity}"`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const url = session.getUrl(env);
|
|
442
|
+
if (!url) {
|
|
443
|
+
throw new HttpError(409, `${env.toUpperCase()}_NOT_CONNECTED`, `No ${env === 'dev' ? 'Dev' : 'Prod'} database configured.`);
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const result = await updateRecord(url, entity, primaryKey, updates);
|
|
447
|
+
if (env === 'prod') {
|
|
448
|
+
logProdAudit(cwd, 'UPDATE', entity, primaryKey, { updates }, req);
|
|
449
|
+
}
|
|
450
|
+
return result;
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
throw new HttpError(500, 'DATA_MUTATION_ERROR', err.message);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
export async function handleDataDelete(session, cwd, body, req) {
|
|
457
|
+
const env = parseEnv(body.env, 'env');
|
|
458
|
+
const entity = typeof body.entity === 'string' ? body.entity.trim() : '';
|
|
459
|
+
if (!entity)
|
|
460
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"entity" is required');
|
|
461
|
+
const primaryKey = body.primaryKey;
|
|
462
|
+
if (primaryKey === undefined || primaryKey === null || primaryKey === '') {
|
|
463
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"primaryKey" is required for deletion');
|
|
464
|
+
}
|
|
465
|
+
if (env === 'prod') {
|
|
466
|
+
if (body.confirmProd !== true || typeof body.challengePhrase !== 'string' || body.challengePhrase.trim() !== entity) {
|
|
467
|
+
throw new HttpError(403, 'PROD_CHALLENGE_REQUIRED', `Production deletion requires confirmProd=true and challengePhrase matching "${entity}"`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const url = session.getUrl(env);
|
|
471
|
+
if (!url) {
|
|
472
|
+
throw new HttpError(409, `${env.toUpperCase()}_NOT_CONNECTED`, `No ${env === 'dev' ? 'Dev' : 'Prod'} database configured.`);
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const result = await deleteRecord(url, entity, primaryKey);
|
|
476
|
+
if (env === 'prod') {
|
|
477
|
+
logProdAudit(cwd, 'DELETE', entity, primaryKey, {}, req);
|
|
478
|
+
}
|
|
479
|
+
return result;
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
throw new HttpError(500, 'DATA_MUTATION_ERROR', err.message);
|
|
483
|
+
}
|
|
484
|
+
}
|