@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.
Files changed (140) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +334 -0
  4. package/bin/cli.js +31 -0
  5. package/dist/commands/bench.d.ts +8 -0
  6. package/dist/commands/bench.js +52 -0
  7. package/dist/commands/db.d.ts +22 -0
  8. package/dist/commands/db.js +415 -0
  9. package/dist/commands/doctor.d.ts +38 -0
  10. package/dist/commands/doctor.js +169 -0
  11. package/dist/commands/hook.d.ts +31 -0
  12. package/dist/commands/hook.js +105 -0
  13. package/dist/commands/init.d.ts +3 -0
  14. package/dist/commands/init.js +144 -0
  15. package/dist/commands/mcp.d.ts +5 -0
  16. package/dist/commands/mcp.js +16 -0
  17. package/dist/commands/setup.d.ts +17 -0
  18. package/dist/commands/setup.js +51 -0
  19. package/dist/commands/status.d.ts +5 -0
  20. package/dist/commands/status.js +75 -0
  21. package/dist/commands/task.d.ts +83 -0
  22. package/dist/commands/task.js +807 -0
  23. package/dist/commands/test-gen.d.ts +14 -0
  24. package/dist/commands/test-gen.js +52 -0
  25. package/dist/commands/triage.d.ts +70 -0
  26. package/dist/commands/triage.js +343 -0
  27. package/dist/commands/update.d.ts +14 -0
  28. package/dist/commands/update.js +112 -0
  29. package/dist/commands/validate.d.ts +1 -0
  30. package/dist/commands/validate.js +89 -0
  31. package/dist/commands/verify.d.ts +8 -0
  32. package/dist/commands/verify.js +65 -0
  33. package/dist/commands/worktree.d.ts +47 -0
  34. package/dist/commands/worktree.js +396 -0
  35. package/dist/core/benchmark.d.ts +57 -0
  36. package/dist/core/benchmark.js +772 -0
  37. package/dist/core/enforcement.d.ts +80 -0
  38. package/dist/core/enforcement.js +271 -0
  39. package/dist/core/legacy-templates.d.ts +24 -0
  40. package/dist/core/legacy-templates.js +60 -0
  41. package/dist/core/lock-manager.d.ts +54 -0
  42. package/dist/core/lock-manager.js +260 -0
  43. package/dist/core/root-resolver.d.ts +32 -0
  44. package/dist/core/root-resolver.js +149 -0
  45. package/dist/core/setup-assets.d.ts +123 -0
  46. package/dist/core/setup-assets.js +438 -0
  47. package/dist/core/telemetry.d.ts +156 -0
  48. package/dist/core/telemetry.js +416 -0
  49. package/dist/core/test-generator-types.d.ts +227 -0
  50. package/dist/core/test-generator-types.js +179 -0
  51. package/dist/core/test-generator.d.ts +34 -0
  52. package/dist/core/test-generator.js +1690 -0
  53. package/dist/core/tier1-liaison.d.ts +139 -0
  54. package/dist/core/tier1-liaison.js +320 -0
  55. package/dist/core/verifier.d.ts +52 -0
  56. package/dist/core/verifier.js +249 -0
  57. package/dist/core/version.d.ts +5 -0
  58. package/dist/core/version.js +14 -0
  59. package/dist/core/worktree-merge.d.ts +30 -0
  60. package/dist/core/worktree-merge.js +98 -0
  61. package/dist/db/data-engine.d.ts +46 -0
  62. package/dist/db/data-engine.js +802 -0
  63. package/dist/db/diff.d.ts +39 -0
  64. package/dist/db/diff.js +420 -0
  65. package/dist/db/env-parser.d.ts +86 -0
  66. package/dist/db/env-parser.js +549 -0
  67. package/dist/db/introspector.d.ts +21 -0
  68. package/dist/db/introspector.js +835 -0
  69. package/dist/db/types.d.ts +217 -0
  70. package/dist/db/types.js +9 -0
  71. package/dist/governor/circuit-breaker.d.ts +100 -0
  72. package/dist/governor/circuit-breaker.js +231 -0
  73. package/dist/governor/evaluator.d.ts +67 -0
  74. package/dist/governor/evaluator.js +851 -0
  75. package/dist/governor/index.d.ts +6 -0
  76. package/dist/governor/index.js +6 -0
  77. package/dist/governor/rules/api-rules.d.ts +2 -0
  78. package/dist/governor/rules/api-rules.js +140 -0
  79. package/dist/governor/rules/db-rules.d.ts +2 -0
  80. package/dist/governor/rules/db-rules.js +180 -0
  81. package/dist/governor/store.d.ts +33 -0
  82. package/dist/governor/store.js +84 -0
  83. package/dist/governor/types.d.ts +64 -0
  84. package/dist/governor/types.js +1 -0
  85. package/dist/index.d.ts +3 -0
  86. package/dist/index.js +358 -0
  87. package/dist/mcp/server.d.ts +3 -0
  88. package/dist/mcp/server.js +309 -0
  89. package/dist/providers/claude-api.d.ts +5 -0
  90. package/dist/providers/claude-api.js +75 -0
  91. package/dist/providers/claude-cli.d.ts +13 -0
  92. package/dist/providers/claude-cli.js +134 -0
  93. package/dist/providers/gemini.d.ts +8 -0
  94. package/dist/providers/gemini.js +78 -0
  95. package/dist/providers/index.d.ts +5 -0
  96. package/dist/providers/index.js +5 -0
  97. package/dist/providers/registry.d.ts +38 -0
  98. package/dist/providers/registry.js +121 -0
  99. package/dist/providers/types.d.ts +74 -0
  100. package/dist/providers/types.js +25 -0
  101. package/dist/runner/agent-supervisor.d.ts +363 -0
  102. package/dist/runner/agent-supervisor.js +1672 -0
  103. package/dist/scanner/context-builder.d.ts +2 -0
  104. package/dist/scanner/context-builder.js +29 -0
  105. package/dist/scanner/detector.d.ts +2 -0
  106. package/dist/scanner/detector.js +760 -0
  107. package/dist/scanner/types.d.ts +80 -0
  108. package/dist/scanner/types.js +50 -0
  109. package/dist/server/studio-server.d.ts +39 -0
  110. package/dist/server/studio-server.js +1931 -0
  111. package/dist/server/studio-ui.d.ts +15 -0
  112. package/dist/server/studio-ui.js +5321 -0
  113. package/package.json +70 -0
  114. package/plugin/.claude-plugin/plugin.json +18 -0
  115. package/plugin/.mcp.json +10 -0
  116. package/plugin/agents/architect.md +28 -0
  117. package/plugin/agents/verifier.md +28 -0
  118. package/plugin/agents/worker.md +26 -0
  119. package/plugin/hooks/hooks.json +28 -0
  120. package/plugin/skills/nativ/SKILL.md +75 -0
  121. package/templates/.claudeignore +45 -0
  122. package/templates/AGENTS.md +67 -0
  123. package/templates/CLAUDE.md +32 -0
  124. package/templates/GEMINI.md +66 -0
  125. package/templates/claude-agents/architect.md +28 -0
  126. package/templates/claude-agents/verifier.md +28 -0
  127. package/templates/claude-agents/worker.md +26 -0
  128. package/templates/dot-ai/api_contracts.json +66 -0
  129. package/templates/dot-ai/context.md +38 -0
  130. package/templates/dot-ai/db_schema.json +39 -0
  131. package/templates/dot-ai/master_plan.json +28 -0
  132. package/templates/dot-ai/subagents/backend.md +11 -0
  133. package/templates/dot-ai/subagents/database.md +10 -0
  134. package/templates/dot-ai/subagents/db-migration.md +11 -0
  135. package/templates/dot-ai/subagents/devops-agent.md +11 -0
  136. package/templates/dot-ai/subagents/flutter-developer.md +11 -0
  137. package/templates/dot-ai/subagents/frontend.md +126 -0
  138. package/templates/dot-ai/subagents/qa-tester.md +13 -0
  139. package/templates/dot-ai/subagents/security-auditor.md +11 -0
  140. package/templates/dot-ai/ui_specs.md +78 -0
@@ -0,0 +1,415 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import pc from 'picocolors';
5
+ import { resolveConnections } from '../db/env-parser.js';
6
+ import { disconnectedStatus, introspectDatabase } from '../db/introspector.js';
7
+ import { diffSchemas, loadContractTables } from '../db/diff.js';
8
+ import { DEFAULT_STUDIO_PORT, startStudioServer, writeContractSchema } from '../server/studio-server.js';
9
+ import { renderStudioHtml } from '../server/studio-ui.js';
10
+ /**
11
+ * `nativ db` command handlers.
12
+ * Air-gap: raw connection URLs are resolved in-process and never printed; all output
13
+ * (text and --json) uses masked URLs and structural metadata only.
14
+ */
15
+ const ENGINE_LABEL = {
16
+ postgresql: 'PostgreSQL',
17
+ mysql: 'MySQL',
18
+ sqlite: 'SQLite',
19
+ mongodb: 'MongoDB',
20
+ firestore: 'Firebase Firestore',
21
+ };
22
+ function resolveDir(targetDirArg) {
23
+ return path.resolve(targetDirArg || process.cwd());
24
+ }
25
+ function packageVersion() {
26
+ try {
27
+ const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
28
+ return pkg.version ?? '1.0.0';
29
+ }
30
+ catch {
31
+ return '1.0.0';
32
+ }
33
+ }
34
+ function parseEnvOption(value, fallback) {
35
+ if (!value)
36
+ return fallback;
37
+ const v = value.toLowerCase();
38
+ if (v === 'dev' || v === 'prod')
39
+ return v;
40
+ console.error(pc.red(`\n✖ Invalid environment "${value}". Use 'dev' or 'prod'.\n`));
41
+ process.exitCode = 1;
42
+ return null;
43
+ }
44
+ async function introspectEnv(targetDir, env) {
45
+ const conns = resolveConnections(targetDir);
46
+ const conn = conns[env];
47
+ if (!conn) {
48
+ const missingKey = conns.templateInfo?.missingKeys.find((k) => k.targetEnv === env);
49
+ if (missingKey) {
50
+ const engineLabel = missingKey.engine ? ` (${ENGINE_LABEL[missingKey.engine] ?? missingKey.engine})` : '';
51
+ const templateName = conns.templateInfo?.templateFile ?? '.env.example';
52
+ const suggestion = `Found "${missingKey.key}"${engineLabel} in ${templateName}, but it is not set in your .env`;
53
+ return {
54
+ status: disconnectedStatus(`No ${env} database configured. ${suggestion}`, missingKey.engine ?? 'postgresql', '', {
55
+ sourceKey: missingKey.key,
56
+ detectedFromExample: true,
57
+ exampleFile: templateName,
58
+ suggestion,
59
+ }),
60
+ tables: [],
61
+ };
62
+ }
63
+ const key = env === 'dev' ? 'DEV_DATABASE_URL (or DATABASE_URL)' : 'PROD_DATABASE_URL';
64
+ return { status: disconnectedStatus(`No ${env} database configured. Set ${key} in .env`), tables: [] };
65
+ }
66
+ const result = await introspectDatabase(conn.url);
67
+ result.status.sourceKey = conn.sourceKey;
68
+ result.status.detectedFromExample = conn.detectedFromExample;
69
+ result.status.exampleFile = conn.exampleFile;
70
+ if (conn.synthesized)
71
+ result.status.synthesized = true;
72
+ if (!result.status.connected) {
73
+ const fragmentedKeys = env === 'dev' && conn.synthesized ? conns.templateInfo.fragmentedConfig?.sourceKeys : undefined;
74
+ const diagnostic = localStackDiagnostic(conn.url, conn.engine, result.status, fragmentedKeys ?? (conn.synthesized ? [conn.sourceKey] : undefined));
75
+ if (diagnostic)
76
+ result.status.suggestion = diagnostic;
77
+ }
78
+ return result;
79
+ }
80
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
81
+ /**
82
+ * Actionable hint when a local MySQL/PostgreSQL server refuses the connection, e.g. MySQL not started in
83
+ * the XAMPP Control Panel. Only host/port are read from the URL; credentials are never touched.
84
+ */
85
+ function localStackDiagnostic(url, engine, status, fragmentedKeys) {
86
+ if (engine !== 'mysql' && engine !== 'postgresql')
87
+ return null;
88
+ if (!/ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|connection refused/i.test(status.error ?? ''))
89
+ return null;
90
+ let host = '';
91
+ let port = engine === 'mysql' ? 3306 : 5432;
92
+ try {
93
+ const parsed = new URL(url);
94
+ host = parsed.hostname;
95
+ if (parsed.port)
96
+ port = Number(parsed.port);
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ const label = ENGINE_LABEL[engine];
102
+ const isLocal = LOCAL_HOSTS.has(host);
103
+ const action = engine === 'mysql'
104
+ ? isLocal
105
+ ? 'Ensure MySQL is started in XAMPP Control Panel.'
106
+ : `Ensure the MySQL server at ${host}:${port} is running and reachable.`
107
+ : `Ensure the PostgreSQL service is running on ${host}:${port}.`;
108
+ if (fragmentedKeys?.length) {
109
+ const shown = fragmentedKeys.filter((k) => /HOST|DATABASE|_DB$|DB_NAME$/.test(k));
110
+ return `Detected fragmented ${label} config (${(shown.length ? shown : fragmentedKeys).join(', ')}). ${action}`;
111
+ }
112
+ return isLocal ? `${label} refused connections on ${host}:${port}. ${action}` : null;
113
+ }
114
+ /** DB_HOST -> DB_*, PROD_DB_HOST -> PROD_DB_*, PGHOST -> PG*. */
115
+ function keyFamily(key) {
116
+ const m = /^(.*_)[^_]*$/.exec(key);
117
+ return m ? `${m[1]}*` : `${key.slice(0, 2)}*`;
118
+ }
119
+ function printStatusLine(env, s) {
120
+ const label = pc.bold(env.toUpperCase().padEnd(4));
121
+ const synthBadge = s.synthesized && s.sourceKey ? ` ${pc.magenta(`[synthesized from ${keyFamily(s.sourceKey)}]`)}` : '';
122
+ if (!s.connected) {
123
+ console.log(` ${pc.red('●')} ${label} ${pc.red('[OFFLINE]')}${synthBadge} ${pc.dim(s.error ?? 'Unknown error')}`);
124
+ if (s.suggestion) {
125
+ const isDiagnostic = /Ensure (MySQL|the MySQL|the PostgreSQL)/.test(s.suggestion);
126
+ console.log(` ${pc.yellow(isDiagnostic ? `[DIAGNOSTIC] ${s.suggestion}` : `Tip: ${s.suggestion}`)}`);
127
+ }
128
+ if (s.maskedUrl)
129
+ console.log(pc.dim(` ${s.maskedUrl}`));
130
+ return;
131
+ }
132
+ const count = s.entityCount ?? s.tableCount;
133
+ const isCollection = s.entityType === 'collection';
134
+ const entityWord = isCollection ? (count === 1 ? 'collection' : 'collections') : (count === 1 ? 'table' : 'tables');
135
+ const sourceInfo = synthBadge
136
+ ? synthBadge
137
+ : s.detectedFromExample && s.sourceKey
138
+ ? pc.cyan(` (via ${s.sourceKey} from ${s.exampleFile ?? '.env.example'})`)
139
+ : s.sourceKey
140
+ ? pc.dim(` (via ${s.sourceKey})`)
141
+ : '';
142
+ console.log(` ${pc.green('●')} ${label} ${pc.green('[ONLINE]')} ${ENGINE_LABEL[s.engine] ?? s.engine}` +
143
+ `${s.database ? ` · ${s.database}` : ''} ${pc.dim(`(${s.pingMs}ms)`)} – ${count} ${entityWord}${sourceInfo}`);
144
+ console.log(pc.dim(` ${s.maskedUrl}`));
145
+ }
146
+ function describeColumn(c) {
147
+ const flags = [];
148
+ if (c.primaryKey)
149
+ flags.push('PK');
150
+ if (c.isMedia)
151
+ flags.push('[IMAGE]');
152
+ if (c.isSubcollection)
153
+ flags.push('[SUBCOLLECTION]');
154
+ const flagStr = flags.length ? ` ${pc.magenta(flags.join(' '))}` : '';
155
+ return `${c.type}${c.nullable ? '' : ' NOT NULL'}${c.default != null ? ` DEFAULT ${c.default}` : ''}${flagStr}`;
156
+ }
157
+ function printDiff(diff, targetLabel) {
158
+ const s = diff.summary;
159
+ console.log(pc.bold(pc.cyan(`\nSchema Drift: Dev → ${targetLabel}`)));
160
+ console.log(` ${pc.green(`+${s.addedTablesCount} added`)} ${pc.yellow(`~${s.alteredTablesCount} altered`)} ` +
161
+ `${pc.red(`-${s.droppedTablesCount} dropped`)} ${pc.dim(`${s.unchangedTablesCount} unchanged`)}`);
162
+ if (!s.addedTablesCount && !s.alteredTablesCount && !s.droppedTablesCount) {
163
+ console.log(pc.green(`\n✔ In sync: Dev matches ${targetLabel}.\n`));
164
+ return;
165
+ }
166
+ for (const t of diff.addedTables) {
167
+ const isCol = t.entityType === 'collection';
168
+ const tag = isCol ? '[NEW COLLECTION]' : '[NEW TABLE]';
169
+ const fieldWord = isCol ? 'fields' : 'columns';
170
+ console.log(`\n ${pc.green(tag)} ${pc.bold(t.name)} ${pc.dim(`(${t.columns.length} ${fieldWord})`)}`);
171
+ }
172
+ for (const t of diff.alteredTables) {
173
+ console.log(`\n ${pc.yellow('[ALTERED]')} ${pc.bold(t.name)}${t.isDestructive ? ` ${pc.red('[DESTRUCTIVE]')}` : ''}`);
174
+ for (const c of t.addedColumns)
175
+ console.log(pc.green(` + ${c.name}: ${describeColumn(c)}`));
176
+ for (const ch of t.alteredColumns) {
177
+ console.log(pc.yellow(` ~ ${ch.name} (${ch.changedFields.join(', ')}): ${describeColumn(ch.before)} → ${describeColumn(ch.after)}`));
178
+ }
179
+ for (const c of t.droppedColumns)
180
+ console.log(pc.red(` - ${c.name}: ${describeColumn(c)}`));
181
+ for (const i of t.addedIndexes)
182
+ console.log(pc.green(` + index ${i.name} (${i.columns.join(', ')})${i.unique ? ' unique' : ''}`));
183
+ for (const i of t.droppedIndexes)
184
+ console.log(pc.red(` - index ${i.name} (${i.columns.join(', ')})`));
185
+ for (const f of t.addedForeignKeys)
186
+ console.log(pc.green(` + FK ${f.column} → ${f.referencedTable}.${f.referencedColumn}`));
187
+ for (const f of t.droppedForeignKeys)
188
+ console.log(pc.red(` - FK ${f.column} → ${f.referencedTable}.${f.referencedColumn}`));
189
+ }
190
+ for (const t of diff.droppedTables) {
191
+ const isCol = t.entityType === 'collection';
192
+ const tag = isCol ? '[DROPPED COLLECTION]' : '[DROPPED]';
193
+ const fieldWord = isCol ? 'fields' : 'columns';
194
+ console.log(`\n ${pc.red(tag)} ${pc.bold(t.name)} ${pc.dim(`(${t.columns.length} ${fieldWord})`)}`);
195
+ }
196
+ if (s.hasDestructiveChanges) {
197
+ console.log(pc.bgRed(pc.white(pc.bold(' HIGH SEVERITY '))) +
198
+ pc.red(` Destructive changes detected against ${targetLabel}. Applying them would drop data or fail on existing rows.`));
199
+ }
200
+ console.log('');
201
+ }
202
+ // ─── nativ db status ─────────────────────────────────────────────────────────
203
+ export async function runDbStatus(targetDirArg, options = {}) {
204
+ const targetDir = resolveDir(targetDirArg);
205
+ const [dev, prod] = await Promise.all([introspectEnv(targetDir, 'dev'), introspectEnv(targetDir, 'prod')]);
206
+ if (options.json) {
207
+ console.log(JSON.stringify({ dev: dev.status, prod: prod.status }, null, 2));
208
+ return;
209
+ }
210
+ console.log(pc.bold(pc.cyan('\nnativ Database Telemetry')));
211
+ printStatusLine('dev', dev.status);
212
+ printStatusLine('prod', prod.status);
213
+ console.log('');
214
+ if (!dev.status.connected && !prod.status.connected)
215
+ process.exitCode = 1;
216
+ }
217
+ // ─── nativ db inspect ────────────────────────────────────────────────────────
218
+ export async function runDbInspect(targetDirArg, options = {}) {
219
+ const env = parseEnvOption(options.env, 'dev');
220
+ if (!env)
221
+ return;
222
+ const targetDir = resolveDir(targetDirArg);
223
+ const result = await introspectEnv(targetDir, env);
224
+ if (!result.status.connected) {
225
+ if (options.json)
226
+ console.log(JSON.stringify({ status: result.status, tables: [] }, null, 2));
227
+ else
228
+ console.error(pc.red(`\n✖ ${env.toUpperCase()} database unavailable: ${result.status.error}\n`));
229
+ process.exitCode = 1;
230
+ return;
231
+ }
232
+ const isCollection = result.status.entityType === 'collection';
233
+ const entityWord = isCollection ? 'Collections' : 'Tables';
234
+ const singleEntityWord = isCollection ? 'Collection' : 'Table';
235
+ let tables = result.tables;
236
+ if (options.table) {
237
+ const wanted = options.table.toLowerCase();
238
+ tables = tables.filter((t) => t.name.toLowerCase() === wanted);
239
+ if (!tables.length) {
240
+ console.error(pc.red(`\n✖ ${singleEntityWord} "${options.table}" not found in ${env.toUpperCase()}.\n`));
241
+ process.exitCode = 1;
242
+ return;
243
+ }
244
+ }
245
+ if (options.json) {
246
+ console.log(JSON.stringify({ status: result.status, tables }, null, 2));
247
+ return;
248
+ }
249
+ console.log(pc.bold(pc.cyan(`\n${env.toUpperCase()} Schema (${entityWord})`)) + pc.dim(` · ${result.status.maskedUrl}`));
250
+ for (const t of tables) {
251
+ const isCol = t.entityType === 'collection' || isCollection;
252
+ const fieldWord = isCol ? 'fields' : 'columns';
253
+ const tag = isCol ? pc.green('[COLLECTION] ') : '';
254
+ console.log(`\n ${tag}${pc.bold(t.name)} ${pc.dim(`(${t.columns.length} ${fieldWord})`)}`);
255
+ if (t.description)
256
+ console.log(pc.dim(` ${t.description}`));
257
+ const fkByColumn = new Map(t.foreignKeys.map((f) => [f.column.toLowerCase(), f]));
258
+ const nameWidth = Math.max(...t.columns.map((c) => c.name.length), 4);
259
+ for (const c of t.columns) {
260
+ const fk = fkByColumn.get(c.name.toLowerCase());
261
+ const flags = [];
262
+ if (c.primaryKey)
263
+ flags.push(pc.magenta('PK'));
264
+ if (c.isMedia)
265
+ flags.push(pc.magenta('[IMAGE]'));
266
+ if (c.isSubcollection)
267
+ flags.push(pc.cyan('[SUBCOLLECTION]'));
268
+ if (fk)
269
+ flags.push(pc.cyan(`FK→${fk.referencedTable}.${fk.referencedColumn}`));
270
+ const tags = flags.join(' ');
271
+ console.log(` ${c.name.padEnd(nameWidth)} ${c.type}${c.nullable ? '' : pc.dim(' NOT NULL')}` +
272
+ `${c.default != null ? pc.dim(` DEFAULT ${c.default}`) : ''}${tags ? ` ${tags}` : ''}`);
273
+ }
274
+ for (const i of t.indexes)
275
+ console.log(pc.dim(` index ${i.name} (${i.columns.join(', ')})${i.unique ? ' unique' : ''}`));
276
+ }
277
+ console.log('');
278
+ }
279
+ // ─── nativ db diff ───────────────────────────────────────────────────────────
280
+ export async function runDbDiff(targetDirArg, options = {}) {
281
+ const target = (options.target ?? 'prod').toLowerCase();
282
+ if (target !== 'prod' && target !== 'contract') {
283
+ console.error(pc.red(`\n✖ Invalid --target "${options.target}". Use 'prod' or 'contract'.\n`));
284
+ process.exitCode = 1;
285
+ return;
286
+ }
287
+ const targetDir = resolveDir(targetDirArg);
288
+ const dev = await introspectEnv(targetDir, 'dev');
289
+ if (!dev.status.connected) {
290
+ console.error(pc.red(`\n✖ Dev database unavailable: ${dev.status.error}\n`));
291
+ process.exitCode = 1;
292
+ return;
293
+ }
294
+ let targetTables;
295
+ let targetLabel;
296
+ if (target === 'contract') {
297
+ targetTables = loadContractTables(targetDir);
298
+ targetLabel = '.ai/db_schema.json';
299
+ }
300
+ else {
301
+ const prod = await introspectEnv(targetDir, 'prod');
302
+ if (!prod.status.connected) {
303
+ console.error(pc.red(`\n✖ Prod database unavailable: ${prod.status.error}`));
304
+ console.log(pc.yellow(' Tip: use --target contract to compare Dev against .ai/db_schema.json instead.\n'));
305
+ process.exitCode = 1;
306
+ return;
307
+ }
308
+ targetTables = prod.tables;
309
+ targetLabel = 'Prod';
310
+ }
311
+ const diff = diffSchemas(dev.tables, targetTables);
312
+ if (options.json)
313
+ console.log(JSON.stringify(diff, null, 2));
314
+ else
315
+ printDiff(diff, targetLabel);
316
+ const s = diff.summary;
317
+ if (options.exitCode && s.addedTablesCount + s.alteredTablesCount + s.droppedTablesCount > 0)
318
+ process.exitCode = 1;
319
+ }
320
+ // ─── nativ db sync ───────────────────────────────────────────────────────────
321
+ /** Exports a live schema into .ai/db_schema.json. Previews unless --yes is passed. */
322
+ export async function runDbSync(targetDirArg, options = {}) {
323
+ const source = parseEnvOption(options.source, 'dev');
324
+ if (!source)
325
+ return;
326
+ const targetDir = resolveDir(targetDirArg);
327
+ const result = await introspectEnv(targetDir, source);
328
+ if (!result.status.connected) {
329
+ console.error(pc.red(`\n✖ ${source.toUpperCase()} database unavailable: ${result.status.error}\n`));
330
+ process.exitCode = 1;
331
+ return;
332
+ }
333
+ const diff = diffSchemas(result.tables, loadContractTables(targetDir));
334
+ const s = diff.summary;
335
+ const changed = s.addedTablesCount + s.alteredTablesCount + s.droppedTablesCount;
336
+ console.log(pc.bold(pc.cyan(`\nSync ${source.toUpperCase()} schema → .ai/db_schema.json`)) + pc.dim(` · ${result.status.maskedUrl}`));
337
+ if (!changed) {
338
+ console.log(pc.green('✔ Contract already matches the live schema. Nothing to write.\n'));
339
+ return;
340
+ }
341
+ printDiff(diff, '.ai/db_schema.json');
342
+ if (!options.yes) {
343
+ console.log(pc.yellow('Dry run: no files were changed. Re-run with --yes to write .ai/db_schema.json.\n'));
344
+ return;
345
+ }
346
+ const filePath = writeContractSchema(targetDir, result.status, result.tables);
347
+ const count = result.tables.length;
348
+ const isCollection = result.status.entityType === 'collection';
349
+ const entityWord = isCollection ? (count === 1 ? 'collection' : 'collections') : (count === 1 ? 'table' : 'tables');
350
+ console.log(pc.green(`✔ Wrote ${count} ${entityWord} (structure only, no credentials) to ${path.relative(targetDir, filePath)}\n`));
351
+ }
352
+ // ─── nativ db ui ─────────────────────────────────────────────────────────────
353
+ function openBrowser(url) {
354
+ const [cmd, args] = process.platform === 'win32'
355
+ ? ['cmd', ['/c', 'start', '""', url]]
356
+ : process.platform === 'darwin'
357
+ ? ['open', [url]]
358
+ : ['xdg-open', [url]];
359
+ try {
360
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
361
+ child.on('error', () => undefined);
362
+ child.unref();
363
+ }
364
+ catch {
365
+ // Headless environment — the URL is printed anyway.
366
+ }
367
+ }
368
+ export async function runDbUi(targetDirArg, options = {}) {
369
+ const targetDir = resolveDir(targetDirArg);
370
+ const port = options.port ? Number.parseInt(options.port, 10) : DEFAULT_STUDIO_PORT;
371
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
372
+ console.error(pc.red(`\n✖ Invalid --port "${options.port}".\n`));
373
+ process.exitCode = 1;
374
+ return;
375
+ }
376
+ const version = packageVersion();
377
+ let handle;
378
+ try {
379
+ handle = await startStudioServer({ port, cwd: targetDir, html: () => renderStudioHtml({ version }) });
380
+ }
381
+ catch (err) {
382
+ console.error(pc.red(`\n✖ Failed to start DB Studio: ${err instanceof Error ? err.message : String(err)}\n`));
383
+ process.exitCode = 1;
384
+ return;
385
+ }
386
+ const conns = resolveConnections(targetDir);
387
+ console.log(pc.bold(pc.cyan(`\nNativ DB Studio v${version}`)));
388
+ const describeSource = (c) => !c
389
+ ? ''
390
+ : c.synthesized
391
+ ? ` [synthesized from ${keyFamily(c.sourceKey)}]`
392
+ : c.detectedFromExample
393
+ ? ` (from ${c.sourceKey} in ${c.exampleFile ?? '.env.example'})`
394
+ : c.sourceKey
395
+ ? ` (${c.sourceKey})`
396
+ : '';
397
+ const devSource = describeSource(conns.dev);
398
+ const prodSource = describeSource(conns.prod);
399
+ console.log(pc.dim(` Dev: ${conns.dev ? `${conns.dev.maskedUrl}${devSource}` : 'not configured'}`));
400
+ console.log(pc.dim(` Prod: ${conns.prod ? `${conns.prod.maskedUrl}${prodSource}` : 'not configured'}`));
401
+ console.log(pc.dim(' Credentials stay in this process only. Press Ctrl+C to stop.\n'));
402
+ if (options.open !== false)
403
+ openBrowser(handle.url);
404
+ await new Promise((resolve) => {
405
+ const shutdown = () => {
406
+ process.off('SIGINT', shutdown);
407
+ process.off('SIGTERM', shutdown);
408
+ console.log(pc.dim('\nStopping DB Studio…'));
409
+ handle.server.closeAllConnections?.();
410
+ handle.close().then(resolve, resolve);
411
+ };
412
+ process.on('SIGINT', shutdown);
413
+ process.on('SIGTERM', shutdown);
414
+ });
415
+ }
@@ -0,0 +1,38 @@
1
+ import { type SetupOptions } from '../core/setup-assets.js';
2
+ import type { ProviderDeps } from '../providers/index.js';
3
+ export type CheckStatus = 'ok' | 'warn' | 'fail' | 'info';
4
+ export interface DoctorCheck {
5
+ id: string;
6
+ status: CheckStatus;
7
+ message: string;
8
+ /** True when `nativ doctor --fix` (which runs setup) can repair it. */
9
+ fixable?: boolean;
10
+ }
11
+ export interface DoctorReport {
12
+ ok: boolean;
13
+ root: string;
14
+ fixed: string[];
15
+ checks: DoctorCheck[];
16
+ }
17
+ export interface DoctorOptions extends SetupOptions {
18
+ fix?: boolean;
19
+ json?: boolean;
20
+ /** Also ask Claude Code itself (`claude mcp list`, a few seconds) whether the nativ server connects and is not shadowed. */
21
+ deep?: boolean;
22
+ /** Returns the text of `claude mcp list`, or null when it could not run; injectable for tests. */
23
+ mcpList?: (cwd: string) => string | null;
24
+ /** Runs `<command> --version`; injectable so tests need no real binaries. */
25
+ runVersion?: (command: string, args: string[]) => {
26
+ ok: boolean;
27
+ output: string;
28
+ };
29
+ providerDeps?: ProviderDeps;
30
+ }
31
+ /**
32
+ * Reads Claude Code's own view of the nativ MCP server. A `nativ` server registered in the user's local or
33
+ * user scope silently shadows the project's `.mcp.json`, so setup can succeed while Claude runs something else.
34
+ */
35
+ export declare function analyzeMcpList(text: string): DoctorCheck[];
36
+ /** Collects every check without changing anything. */
37
+ export declare function collectChecks(root: string, options?: DoctorOptions): DoctorCheck[];
38
+ export declare function runDoctor(targetDirArg?: string, options?: DoctorOptions): DoctorReport;
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import pc from 'picocolors';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { loadEnforcementMode, parseJsonLoose } from '../core/enforcement.js';
6
+ import { resolveProjectRoot } from '../core/root-resolver.js';
7
+ import { applySetup, defaultHasBinary, planSetup } from '../core/setup-assets.js';
8
+ import { createProviders, providerCooldownRemaining } from '../providers/index.js';
9
+ function defaultRunVersion(command, args) {
10
+ try {
11
+ // The Windows shims (nativ.cmd, npx.cmd) only start through a shell.
12
+ const result = spawnSync(command, [...args, '--version'], { encoding: 'utf8', timeout: 20_000, shell: process.platform === 'win32' });
13
+ return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim().split(/\r?\n/)[0] ?? '' };
14
+ }
15
+ catch (err) {
16
+ return { ok: false, output: err instanceof Error ? err.message : String(err) };
17
+ }
18
+ }
19
+ function defaultMcpList(cwd) {
20
+ try {
21
+ const result = spawnSync('claude', ['mcp', 'list'], { cwd, encoding: 'utf8', timeout: 60_000, shell: process.platform === 'win32' });
22
+ return result.status === 0 ? `${result.stdout ?? ''}` : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /**
29
+ * Reads Claude Code's own view of the nativ MCP server. A `nativ` server registered in the user's local or
30
+ * user scope silently shadows the project's `.mcp.json`, so setup can succeed while Claude runs something else.
31
+ */
32
+ export function analyzeMcpList(text) {
33
+ const checks = [];
34
+ const line = text.split(/\r?\n/).find((l) => /^nativ:/.test(l.trim()));
35
+ if (!line) {
36
+ checks.push({ id: 'mcp-listed', status: 'warn', message: 'Claude Code does not list a "nativ" MCP server here. Check that the project .mcp.json is approved (enabledMcpjsonServers) and the workspace is trusted.' });
37
+ }
38
+ else if (/Connected/.test(line)) {
39
+ checks.push({ id: 'mcp-connected', status: 'ok', message: `Claude Code connects to the nativ MCP server (${line.replace(/\s+-\s+.*$/, '').trim()})` });
40
+ }
41
+ else {
42
+ checks.push({ id: 'mcp-connected', status: 'fail', message: `Claude Code lists the nativ MCP server but it is not connected: ${line.trim()}` });
43
+ }
44
+ // eslint-disable-next-line no-control-regex
45
+ const plain = text.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '');
46
+ const conflict = /Server "nativ" is defined in multiple scopes[^\n]*/.exec(plain);
47
+ if (conflict) {
48
+ checks.push({
49
+ id: 'mcp-scope-conflict',
50
+ status: 'warn',
51
+ message: `${conflict[0].trim()} The other scope wins over the project .mcp.json. Remove the one you do not want, e.g. \`claude mcp remove nativ -s local\`.`,
52
+ });
53
+ }
54
+ return checks;
55
+ }
56
+ /** Collects every check without changing anything. */
57
+ export function collectChecks(root, options = {}) {
58
+ const checks = [];
59
+ const add = (id, status, message, fixable = false) => checks.push({ id, status, message, fixable });
60
+ const hasBinary = options.hasBinary ?? defaultHasBinary;
61
+ const runVersion = options.runVersion ?? defaultRunVersion;
62
+ const planFile = path.join(root, '.ai', 'master_plan.json');
63
+ if (!fs.existsSync(planFile)) {
64
+ add('project', 'fail', 'No .ai/master_plan.json. Run `nativ init` first.');
65
+ }
66
+ else {
67
+ try {
68
+ parseJsonLoose(fs.readFileSync(planFile, 'utf8'));
69
+ add('project', 'ok', '.ai/ workflow found');
70
+ }
71
+ catch (err) {
72
+ // Enforcement cannot see the active task in a plan it cannot read, so this must never be a quiet state.
73
+ add('project', 'fail', `.ai/master_plan.json cannot be parsed (${err instanceof Error ? err.message : String(err)}). Role enforcement cannot see the active task until it is fixed.`);
74
+ }
75
+ }
76
+ // Claude Code itself
77
+ if (hasBinary('claude')) {
78
+ const version = runVersion('claude', []);
79
+ add('claude', version.ok ? 'ok' : 'warn', version.ok ? `Claude Code found (${version.output})` : 'The claude executable is on PATH but did not run.');
80
+ }
81
+ else {
82
+ add('claude', 'warn', 'Claude Code (`claude`) is not on PATH. Install it from https://claude.com/claude-code; nativ still works without it for the CLI.');
83
+ }
84
+ // How the generated config will start nativ
85
+ const plan = planSetup(root, options);
86
+ const invocation = plan.invocation;
87
+ const cli = [invocation.command, ...invocation.prefixArgs];
88
+ if (!invocation.portable) {
89
+ add('cli-portable', 'warn', 'The nativ command points at an absolute path on this machine. Install nativ globally or run `nativ setup --command "npx -y @njeybe/nativ"`.');
90
+ }
91
+ const version = runVersion(invocation.command, invocation.prefixArgs);
92
+ add('cli-runs', version.ok ? 'ok' : 'fail', version.ok ? `\`${cli.join(' ')}\` runs (${version.output})` : `\`${cli.join(' ')}\` does not run: ${version.output || 'no output'}. The hooks and MCP server would fail.`);
93
+ // Generated assets: whatever setup would still change is drift
94
+ for (const change of plan.changes) {
95
+ if (change.action === 'created')
96
+ add(`asset:${change.path}`, 'fail', `${change.path} is missing`, true);
97
+ else if (change.action === 'updated')
98
+ add(`asset:${change.path}`, 'warn', `${change.path} is out of date${change.detail ? ` (${change.detail})` : ''}`, true);
99
+ else if (change.action === 'skipped')
100
+ add(`asset:${change.path}`, 'warn', `${change.path}: ${change.detail ?? 'not managed by nativ'}`);
101
+ else if (change.detail)
102
+ add(`asset:${change.path}`, 'info', `${change.path}: ${change.detail}`);
103
+ }
104
+ if (!plan.changes.some((c) => c.action === 'created' || c.action === 'updated'))
105
+ add('assets', 'ok', 'MCP server, hooks, permissions, agents and directives are in place');
106
+ // Claude Code's own view (opt-in: it connects to every configured MCP server)
107
+ if (options.deep && hasBinary('claude')) {
108
+ const listing = (options.mcpList ?? defaultMcpList)(root);
109
+ if (listing === null)
110
+ add('mcp-listed', 'warn', '`claude mcp list` could not run, so the live MCP connection was not checked.');
111
+ else
112
+ for (const c of analyzeMcpList(listing))
113
+ checks.push(c);
114
+ }
115
+ // Trust: Claude Code ignores a project's permissions.allow (and may skip its hooks) until the folder is trusted
116
+ add('trust', 'info', 'Open Claude Code in this folder once and accept the trust dialog, otherwise project permissions.allow entries are ignored.');
117
+ // Enforcement
118
+ const mode = loadEnforcementMode(root);
119
+ add('enforcement', mode === 'off' ? 'warn' : 'ok', mode === 'off' ? 'Role enforcement is off: nothing stops an agent editing outside its task.' : `Role enforcement is ${mode}`);
120
+ // Providers
121
+ const providers = createProviders(options.providerDeps);
122
+ const usable = [];
123
+ for (const [id, provider] of Object.entries(providers)) {
124
+ const cooling = providerCooldownRemaining(root, id);
125
+ if (provider.hasCredentials(root) && !cooling)
126
+ usable.push(id);
127
+ else if (cooling)
128
+ add(`provider:${id}`, 'info', `${id} is cooling down after a limit (${Math.ceil(cooling / 60_000)} min left)`);
129
+ }
130
+ if (usable.length)
131
+ add('providers', 'ok', `Triage can use: ${usable.join(', ')}`);
132
+ else
133
+ add('providers', 'warn', 'No AI provider is available for triage (sign in to Claude Code or set ANTHROPIC_API_KEY or GEMINI_API_KEY). The offline rules engine will be used.');
134
+ return checks;
135
+ }
136
+ export function runDoctor(targetDirArg, options = {}) {
137
+ const root = resolveProjectRoot(targetDirArg);
138
+ const fixed = [];
139
+ if (options.fix) {
140
+ const before = collectChecks(root, options).filter((c) => c.fixable);
141
+ if (before.length) {
142
+ const result = applySetup(root, options);
143
+ for (const change of result.changes)
144
+ if (change.action === 'created' || change.action === 'updated')
145
+ fixed.push(change.path);
146
+ }
147
+ }
148
+ const checks = collectChecks(root, options);
149
+ const report = { ok: !checks.some((c) => c.status === 'fail'), root, fixed, checks };
150
+ if (options.json) {
151
+ console.log(JSON.stringify(report, null, 2));
152
+ }
153
+ else {
154
+ console.log(pc.bold(pc.cyan('\nnativ doctor')) + pc.dim(` ${root}`));
155
+ const paint = { ok: pc.green, warn: pc.yellow, fail: pc.red, info: pc.dim };
156
+ const mark = { ok: '✔', warn: '⚠', fail: '✖', info: '·' };
157
+ for (const c of checks)
158
+ console.log(paint[c.status](` ${mark[c.status]} ${c.message}`) + (c.fixable && !options.fix ? pc.dim(' (fixable)') : ''));
159
+ if (fixed.length)
160
+ console.log(pc.green(`\n✔ Repaired: ${fixed.join(', ')}`));
161
+ const fixable = checks.filter((c) => c.fixable).length;
162
+ console.log(report.ok
163
+ ? pc.green(`\n✔ Healthy${checks.some((c) => c.status === 'warn') ? ' (with warnings)' : ''}.\n`)
164
+ : pc.red(`\n✖ Problems found.${fixable ? ' Run `nativ doctor --fix` to repair what can be repaired.' : ''}\n`));
165
+ }
166
+ if (!report.ok)
167
+ process.exitCode = 1;
168
+ return report;
169
+ }
@@ -0,0 +1,31 @@
1
+ import { type EnforcementMode } from '../core/enforcement.js';
2
+ export interface HookCheckOptions {
3
+ /** Stdin and stdout are injectable for tests. */
4
+ input?: NodeJS.ReadableStream;
5
+ output?: (text: string) => void;
6
+ /** Forces a mode instead of reading .nativ/config.json. */
7
+ mode?: EnforcementMode;
8
+ role?: string;
9
+ }
10
+ /**
11
+ * `nativ hook check`: the PreToolUse hook entry point. Reads Claude Code's JSON from stdin and prints the
12
+ * hook decision to stdout. It always exits 0 and fails open: a broken payload, unreadable state or a bug
13
+ * here must never stop an agent's tool call. Only `block` mode ever denies, and only on a real violation.
14
+ */
15
+ export declare function runHookCheck(targetDirArg?: string, options?: HookCheckOptions): Promise<void>;
16
+ export interface HookStatus {
17
+ ok: true;
18
+ root: string;
19
+ mode: EnforcementMode;
20
+ role: string;
21
+ activeTasks: Array<{
22
+ id: string;
23
+ targetFiles: string[];
24
+ unlocked: boolean;
25
+ }>;
26
+ }
27
+ /** `nativ hook status`: what the hook would enforce right now. Read-only. */
28
+ export declare function getHookStatus(targetDirArg?: string): HookStatus;
29
+ export declare function runHookStatus(targetDirArg?: string, options?: {
30
+ json?: boolean;
31
+ }): HookStatus;