tova 0.1.1 → 0.2.2

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.
@@ -5,6 +5,7 @@
5
5
  import { SharedCodegen } from './shared-codegen.js';
6
6
  import { ServerCodegen } from './server-codegen.js';
7
7
  import { ClientCodegen } from './client-codegen.js';
8
+ import { BUILTIN_NAMES } from '../stdlib/inline.js';
8
9
 
9
10
  export class CodeGenerator {
10
11
  constructor(ast, filename = '<stdin>') {
@@ -30,6 +31,7 @@ export class CodeGenerator {
30
31
  const topLevel = [];
31
32
 
32
33
  const testBlocks = [];
34
+ const dataBlocks = [];
33
35
 
34
36
  for (const node of this.ast.body) {
35
37
  switch (node.type) {
@@ -37,6 +39,7 @@ export class CodeGenerator {
37
39
  case 'ServerBlock': serverBlocks.push(node); break;
38
40
  case 'ClientBlock': clientBlocks.push(node); break;
39
41
  case 'TestBlock': testBlocks.push(node); break;
42
+ case 'DataBlock': dataBlocks.push(node); break;
40
43
  default: topLevel.push(node); break;
41
44
  }
42
45
  }
@@ -46,8 +49,16 @@ export class CodeGenerator {
46
49
  // All shared blocks (regardless of name) are merged into one shared output
47
50
  const sharedCode = sharedBlocks.map(b => sharedGen.generate(b)).join('\n');
48
51
  const topLevelCode = topLevel.map(s => sharedGen.generateStatement(s)).join('\n');
52
+
53
+ // Pre-scan server/client blocks for builtin usage so shared stdlib includes them
54
+ this._scanBlocksForBuiltins([...serverBlocks, ...clientBlocks], sharedGen._usedBuiltins);
55
+
49
56
  const helpers = sharedGen.generateHelpers();
50
- const combinedShared = [helpers, sharedCode, topLevelCode].filter(s => s.trim()).join('\n').trim();
57
+
58
+ // Generate data block code (sources, pipelines, validators, refresh)
59
+ const dataCode = dataBlocks.map(b => this._genDataBlock(b, sharedGen)).join('\n');
60
+
61
+ const combinedShared = [helpers, sharedCode, topLevelCode, dataCode].filter(s => s.trim()).join('\n').trim();
51
62
 
52
63
  // Group server and client blocks by name
53
64
  const serverGroups = this._groupByName(serverBlocks);
@@ -99,7 +110,7 @@ export class CodeGenerator {
99
110
  for (const [name, blocks] of clientGroups) {
100
111
  const gen = new ClientCodegen();
101
112
  const key = name || 'default';
102
- clients[key] = gen.generate(blocks, combinedShared);
113
+ clients[key] = gen.generate(blocks, combinedShared, sharedGen._usedBuiltins);
103
114
  }
104
115
 
105
116
  // Generate tests if test blocks exist
@@ -145,4 +156,102 @@ export class CodeGenerator {
145
156
  if (testCode) result.test = testCode;
146
157
  return result;
147
158
  }
159
+
160
+ // Walk AST nodes to find builtin function calls/identifiers
161
+ _scanBlocksForBuiltins(blocks, targetSet) {
162
+ const walk = (node) => {
163
+ if (!node || typeof node !== 'object') return;
164
+ if (node.type === 'Identifier' && BUILTIN_NAMES.has(node.name)) {
165
+ targetSet.add(node.name);
166
+ }
167
+ if (node.type === 'CallExpression' && node.callee && node.callee.type === 'Identifier' && BUILTIN_NAMES.has(node.callee.name)) {
168
+ targetSet.add(node.callee.name);
169
+ }
170
+ for (const key of Object.keys(node)) {
171
+ if (key === 'loc' || key === 'type') continue;
172
+ const val = node[key];
173
+ if (Array.isArray(val)) {
174
+ for (const item of val) {
175
+ if (item && typeof item === 'object') walk(item);
176
+ }
177
+ } else if (val && typeof val === 'object' && val.type) {
178
+ walk(val);
179
+ }
180
+ }
181
+ };
182
+ for (const block of blocks) walk(block);
183
+ }
184
+
185
+ _genDataBlock(node, gen) {
186
+ const lines = [];
187
+ lines.push('// ── Data Block ──');
188
+
189
+ for (const stmt of node.body) {
190
+ switch (stmt.type) {
191
+ case 'SourceDeclaration': {
192
+ // Source: lazy cached getter
193
+ const expr = gen.genExpression(stmt.expression);
194
+ lines.push(`let __data_${stmt.name}_cache = null;`);
195
+ lines.push(`async function __data_${stmt.name}_load() {`);
196
+ lines.push(` if (__data_${stmt.name}_cache === null) {`);
197
+ lines.push(` __data_${stmt.name}_cache = await ${expr};`);
198
+ lines.push(` }`);
199
+ lines.push(` return __data_${stmt.name}_cache;`);
200
+ lines.push(`}`);
201
+ // Also expose as a simple getter variable via lazy init
202
+ lines.push(`let ${stmt.name} = null;`);
203
+ lines.push(`Object.defineProperty(globalThis, ${JSON.stringify(stmt.name)}, {`);
204
+ lines.push(` get() { if (${stmt.name} === null) { ${stmt.name} = __data_${stmt.name}_load(); } return ${stmt.name}; },`);
205
+ lines.push(` configurable: true,`);
206
+ lines.push(`});`);
207
+ break;
208
+ }
209
+ case 'PipelineDeclaration': {
210
+ // Pipeline: function that chains transforms
211
+ const expr = gen.genExpression(stmt.expression);
212
+ lines.push(`async function __pipeline_${stmt.name}() {`);
213
+ lines.push(` return ${expr};`);
214
+ lines.push(`}`);
215
+ lines.push(`let ${stmt.name} = null;`);
216
+ lines.push(`Object.defineProperty(globalThis, ${JSON.stringify(stmt.name)}, {`);
217
+ lines.push(` get() { if (${stmt.name} === null) { ${stmt.name} = __pipeline_${stmt.name}(); } return ${stmt.name}; },`);
218
+ lines.push(` configurable: true,`);
219
+ lines.push(`});`);
220
+ break;
221
+ }
222
+ case 'ValidateBlock': {
223
+ // Validate: validator function
224
+ const rules = stmt.rules.map(r => gen.genExpression(r));
225
+ lines.push(`function __validate_${stmt.typeName}(it) {`);
226
+ lines.push(` const errors = [];`);
227
+ for (let i = 0; i < rules.length; i++) {
228
+ lines.push(` if (!(${rules[i]})) errors.push("Validation rule ${i + 1} failed for ${stmt.typeName}");`);
229
+ }
230
+ lines.push(` return errors.length === 0 ? { valid: true, errors: [] } : { valid: false, errors };`);
231
+ lines.push(`}`);
232
+ break;
233
+ }
234
+ case 'RefreshPolicy': {
235
+ // Refresh: interval cache invalidation
236
+ if (stmt.interval === 'on_demand') {
237
+ lines.push(`function refresh_${stmt.sourceName}() { __data_${stmt.sourceName}_cache = null; ${stmt.sourceName} = null; }`);
238
+ } else {
239
+ const { value, unit } = stmt.interval;
240
+ let ms;
241
+ switch (unit) {
242
+ case 'seconds': case 'second': ms = value * 1000; break;
243
+ case 'minutes': case 'minute': ms = value * 60 * 1000; break;
244
+ case 'hours': case 'hour': ms = value * 60 * 60 * 1000; break;
245
+ case 'days': case 'day': ms = value * 24 * 60 * 60 * 1000; break;
246
+ default: ms = value * 60 * 1000; // default to minutes
247
+ }
248
+ lines.push(`setInterval(() => { __data_${stmt.sourceName}_cache = null; ${stmt.sourceName} = null; }, ${ms});`);
249
+ }
250
+ break;
251
+ }
252
+ }
253
+ }
254
+
255
+ return lines.join('\n');
256
+ }
148
257
  }
@@ -154,6 +154,7 @@ export class ServerCodegen extends BaseCodegen {
154
154
  let cacheConfig = null;
155
155
  const sseDecls = [];
156
156
  const modelDecls = [];
157
+ const aiConfigs = []; // { name: string|null, config: object }
157
158
 
158
159
  const collectFromBody = (stmts, groupPrefix = null, groupMiddlewares = []) => {
159
160
  for (const stmt of stmts) {
@@ -225,6 +226,8 @@ export class ServerCodegen extends BaseCodegen {
225
226
  sseDecls.push(stmt);
226
227
  } else if (stmt.type === 'ModelDeclaration') {
227
228
  modelDecls.push(stmt);
229
+ } else if (stmt.type === 'AiConfigDeclaration') {
230
+ aiConfigs.push(stmt);
228
231
  } else {
229
232
  otherStatements.push(stmt);
230
233
  }
@@ -463,6 +466,32 @@ export class ServerCodegen extends BaseCodegen {
463
466
  lines.push('');
464
467
  }
465
468
 
469
+ // ════════════════════════════════════════════════════════════
470
+ // 3b. AI Client Initialization
471
+ // ════════════════════════════════════════════════════════════
472
+ if (aiConfigs.length > 0) {
473
+ lines.push('// ── AI Clients ──');
474
+ lines.push(this._getAiRuntime());
475
+ lines.push('');
476
+
477
+ for (const aiConf of aiConfigs) {
478
+ const configParts = [];
479
+ for (const [key, valueNode] of Object.entries(aiConf.config)) {
480
+ configParts.push(` ${key}: ${this.genExpression(valueNode)}`);
481
+ }
482
+ const configStr = `{\n${configParts.join(',\n')}\n}`;
483
+
484
+ if (aiConf.name) {
485
+ // Named provider: ai "claude" { ... } → const claude = __createAI({...})
486
+ lines.push(`const ${aiConf.name} = __createAI(${configStr});`);
487
+ } else {
488
+ // Default provider: ai { ... } → const ai = __createAI({...})
489
+ lines.push(`const ai = __createAI(${configStr});`);
490
+ }
491
+ }
492
+ lines.push('');
493
+ }
494
+
466
495
  // ════════════════════════════════════════════════════════════
467
496
  // 4. Peer Server RPC Proxies (with circuit breaker + retry)
468
497
  // ════════════════════════════════════════════════════════════
@@ -1179,7 +1208,17 @@ export class ServerCodegen extends BaseCodegen {
1179
1208
  const aw = isAsync ? 'await ' : '';
1180
1209
  for (const modelDecl of modelDecls) {
1181
1210
  const typeName = modelDecl.name;
1182
- const typeInfo = sharedTypes.get(typeName);
1211
+ let typeInfo = sharedTypes.get(typeName);
1212
+ // Fall back to model declaration fields if shared type not in this file (multi-file)
1213
+ if (!typeInfo && modelDecl.config) {
1214
+ const fields = [{ name: 'id', type: 'Int' }];
1215
+ for (const [key, value] of Object.entries(modelDecl.config)) {
1216
+ if (key === 'table' || key === 'timestamps' || key === 'belongs_to' || key === 'has_many') continue;
1217
+ const fieldType = value.name || (value.type === 'ArrayTypeAnnotation' ? 'Array' : 'Any');
1218
+ fields.push({ name: key, type: fieldType });
1219
+ }
1220
+ if (fields.length > 0) typeInfo = { fields };
1221
+ }
1183
1222
  if (!typeInfo) continue;
1184
1223
  const tableName = modelDecl.config && modelDecl.config.table
1185
1224
  ? this.genExpression(modelDecl.config.table).replace(/"/g, '')
@@ -1378,6 +1417,10 @@ export class ServerCodegen extends BaseCodegen {
1378
1417
  }
1379
1418
 
1380
1419
  lines.push('};');
1420
+ // Alias so server functions can reference the model by its original type name
1421
+ // Use var so it works both when Task is already declared (single-file shared block)
1422
+ // and when it isn't (multi-file: server in separate file from shared types)
1423
+ lines.push(`var ${typeName} = ${typeName}Model;`);
1381
1424
  lines.push('');
1382
1425
  }
1383
1426
  }
@@ -1937,8 +1980,8 @@ export class ServerCodegen extends BaseCodegen {
1937
1980
  lines.push(' const html = `<!DOCTYPE html><html><head><title>API Docs</title>');
1938
1981
  lines.push(' <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"></head>');
1939
1982
  lines.push(' <body><div id="swagger-ui"></div>');
1940
- lines.push(' <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>');
1941
- lines.push(' <script>SwaggerUIBundle({ url: "/openapi.json", dom_id: "#swagger-ui" });</script>');
1983
+ lines.push(' <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"><\\/script>');
1984
+ lines.push(' <script>SwaggerUIBundle({ url: "/openapi.json", dom_id: "#swagger-ui" });<\\/script>');
1942
1985
  lines.push(' </body></html>`;');
1943
1986
  lines.push(' return new Response(html, { headers: { "Content-Type": "text/html" } });');
1944
1987
  lines.push('});');
@@ -2503,4 +2546,133 @@ export class ServerCodegen extends BaseCodegen {
2503
2546
 
2504
2547
  return lines.join('\n');
2505
2548
  }
2549
+
2550
+ _getAiRuntime() {
2551
+ return `// AI Client Runtime
2552
+ function __createAI(config) {
2553
+ const providerName = config.provider || 'custom';
2554
+ async function __aiRequest(method, args, callOpts = {}) {
2555
+ const cfg = { ...config, ...callOpts };
2556
+ const baseUrl = cfg.base_url || (providerName === 'anthropic' ? 'https://api.anthropic.com' : providerName === 'ollama' ? 'http://localhost:11434' : 'https://api.openai.com');
2557
+ const headers = { 'Content-Type': 'application/json', ...(cfg.headers || {}) };
2558
+ if (providerName === 'anthropic') {
2559
+ headers['x-api-key'] = cfg.api_key;
2560
+ headers['anthropic-version'] = '2023-06-01';
2561
+ } else if (cfg.api_key) {
2562
+ headers['Authorization'] = 'Bearer ' + cfg.api_key;
2563
+ }
2564
+ const timeout = cfg.timeout || 60000;
2565
+
2566
+ if (method === 'ask') {
2567
+ const [prompt, opts] = args;
2568
+ let body, url;
2569
+ if (providerName === 'anthropic') {
2570
+ body = { model: cfg.model, max_tokens: opts?.max_tokens || cfg.max_tokens || 4096, messages: [{ role: 'user', content: prompt }] };
2571
+ if (opts?.temperature ?? cfg.temperature) body.temperature = opts?.temperature ?? cfg.temperature;
2572
+ if (opts?.tools) body.tools = opts.tools.map(t => ({ name: t.name, description: t.description, input_schema: { type: 'object', properties: t.params ? Object.fromEntries(Object.entries(t.params).map(([k, v]) => [k, { type: typeof v === 'string' ? v.toLowerCase() : 'string' }])) : {} } }));
2573
+ url = baseUrl + '/v1/messages';
2574
+ } else if (providerName === 'ollama') {
2575
+ body = { model: cfg.model, messages: [{ role: 'user', content: prompt }], stream: false };
2576
+ url = baseUrl + '/api/chat';
2577
+ } else {
2578
+ body = { model: cfg.model, messages: [{ role: 'user', content: prompt }] };
2579
+ if (opts?.max_tokens || cfg.max_tokens) body.max_tokens = opts?.max_tokens || cfg.max_tokens;
2580
+ if (opts?.temperature ?? cfg.temperature) body.temperature = opts?.temperature ?? cfg.temperature;
2581
+ if (opts?.tools) body.tools = opts.tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: { type: 'object', properties: t.params ? Object.fromEntries(Object.entries(t.params).map(([k, v]) => [k, { type: typeof v === 'string' ? v.toLowerCase() : 'string' }])) : {} } } }));
2582
+ url = baseUrl + '/v1/chat/completions';
2583
+ }
2584
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) });
2585
+ if (!res.ok) throw new Error(providerName + ' API error ' + res.status + ': ' + (await res.text()));
2586
+ const data = await res.json();
2587
+ if (providerName === 'anthropic') {
2588
+ if (opts?.tools && data.content?.some(c => c.type === 'tool_use')) return { text: data.content.filter(c => c.type === 'text').map(c => c.text).join(''), tool_calls: data.content.filter(c => c.type === 'tool_use') };
2589
+ return data.content.map(c => c.text).join('');
2590
+ }
2591
+ if (providerName === 'ollama') return data.message.content;
2592
+ const choice = data.choices[0];
2593
+ if (opts?.tools && choice.message.tool_calls) return { text: choice.message.content || '', tool_calls: choice.message.tool_calls };
2594
+ return choice.message.content;
2595
+ }
2596
+
2597
+ if (method === 'chat') {
2598
+ const [messages, opts] = args;
2599
+ let body, url;
2600
+ if (providerName === 'anthropic') {
2601
+ const sys = messages.filter(m => m.role === 'system');
2602
+ const msgs = messages.filter(m => m.role !== 'system');
2603
+ body = { model: cfg.model, max_tokens: opts?.max_tokens || cfg.max_tokens || 4096, messages: msgs };
2604
+ if (sys.length > 0) body.system = sys.map(m => m.content).join('\\n');
2605
+ url = baseUrl + '/v1/messages';
2606
+ } else if (providerName === 'ollama') {
2607
+ body = { model: cfg.model, messages, stream: false };
2608
+ url = baseUrl + '/api/chat';
2609
+ } else {
2610
+ body = { model: cfg.model, messages };
2611
+ if (opts?.max_tokens || cfg.max_tokens) body.max_tokens = opts?.max_tokens || cfg.max_tokens;
2612
+ url = baseUrl + '/v1/chat/completions';
2613
+ }
2614
+ if (opts?.temperature ?? cfg.temperature) body.temperature = opts?.temperature ?? cfg.temperature;
2615
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) });
2616
+ if (!res.ok) throw new Error(providerName + ' API error ' + res.status + ': ' + (await res.text()));
2617
+ const data = await res.json();
2618
+ if (providerName === 'anthropic') return data.content.map(c => c.text).join('');
2619
+ if (providerName === 'ollama') return data.message.content;
2620
+ return data.choices[0].message.content;
2621
+ }
2622
+
2623
+ if (method === 'embed') {
2624
+ const [input, opts] = args;
2625
+ let body, url;
2626
+ if (providerName === 'ollama') {
2627
+ url = baseUrl + '/api/embeddings';
2628
+ if (Array.isArray(input)) {
2629
+ const results = [];
2630
+ for (const text of input) {
2631
+ const r = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ model: cfg.model, prompt: text }) });
2632
+ results.push((await r.json()).embedding);
2633
+ }
2634
+ return results;
2635
+ }
2636
+ body = { model: cfg.model, prompt: input };
2637
+ } else {
2638
+ body = { model: cfg.model || 'text-embedding-3-small', input };
2639
+ url = baseUrl + '/v1/embeddings';
2640
+ }
2641
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) });
2642
+ if (!res.ok) throw new Error(providerName + ' API error ' + res.status + ': ' + (await res.text()));
2643
+ const data = await res.json();
2644
+ if (providerName === 'ollama') return data.embedding;
2645
+ if (Array.isArray(input)) return data.data.map(d => d.embedding);
2646
+ return data.data[0].embedding;
2647
+ }
2648
+
2649
+ if (method === 'extract') {
2650
+ const [prompt, schema, opts] = args;
2651
+ const extractPrompt = prompt + '\\n\\nRespond with a JSON object matching this schema: ' + JSON.stringify(schema);
2652
+ const text = await __aiRequest('ask', [extractPrompt, opts]);
2653
+ try { return JSON.parse(text); } catch { return JSON.parse(text.match(/\\{[\\s\\S]*\\}/)?.[0] || '{}'); }
2654
+ }
2655
+
2656
+ if (method === 'classify') {
2657
+ const [text, categories, opts] = args;
2658
+ const catList = Array.isArray(categories) ? categories : Object.keys(categories);
2659
+ const classifyPrompt = 'Classify into one of: ' + catList.join(', ') + '\\n\\nText: "' + text + '"\\n\\nRespond with only the category name.';
2660
+ const result = (await __aiRequest('ask', [classifyPrompt, { ...opts, max_tokens: 100 }])).trim();
2661
+ return catList.find(c => c.toLowerCase() === result.toLowerCase()) || result;
2662
+ }
2663
+
2664
+ throw new Error('Unknown AI method: ' + method);
2665
+ }
2666
+
2667
+ return {
2668
+ ask(prompt, opts) { return __aiRequest('ask', [prompt, opts || {}], opts); },
2669
+ chat(messages, opts) { return __aiRequest('chat', [messages, opts || {}], opts); },
2670
+ embed(input, opts) { return __aiRequest('embed', [input, opts || {}], opts); },
2671
+ extract(prompt, schema, opts) { return __aiRequest('extract', [prompt, schema, opts || {}], opts); },
2672
+ classify(text, categories, opts) { return __aiRequest('classify', [text, categories, opts || {}], opts); },
2673
+ };
2674
+ }
2675
+ // Default AI object for one-off calls (no config block required)
2676
+ const ai = typeof ai === 'undefined' ? __createAI({}) : ai;`;
2677
+ }
2506
2678
  }
@@ -0,0 +1,100 @@
1
+ // Line-level TOML editing for tova add / tova remove.
2
+ // Operates on raw text to preserve formatting, comments, and whitespace.
3
+
4
+ import { readFileSync, writeFileSync } from 'fs';
5
+
6
+ export function addToSection(filePath, section, key, value) {
7
+ const content = readFileSync(filePath, 'utf-8');
8
+ const lines = content.split('\n');
9
+ const entry = `${key} = "${value}"`;
10
+
11
+ // Find the section header
12
+ const sectionIdx = findSectionIndex(lines, section);
13
+
14
+ if (sectionIdx === -1) {
15
+ // Section doesn't exist — append it at end of file
16
+ const newLines = [...lines];
17
+ // Ensure blank line before new section
18
+ if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
19
+ newLines.push('');
20
+ }
21
+ newLines.push(`[${section}]`);
22
+ newLines.push(entry);
23
+ writeFileSync(filePath, newLines.join('\n'));
24
+ return;
25
+ }
26
+
27
+ // Find the end of this section (next section header or EOF)
28
+ const endIdx = findSectionEnd(lines, sectionIdx);
29
+
30
+ // Check if key already exists in this section — update it
31
+ for (let i = sectionIdx + 1; i < endIdx; i++) {
32
+ const line = lines[i].trim();
33
+ if (line === '' || line.startsWith('#')) continue;
34
+ const eqIdx = line.indexOf('=');
35
+ if (eqIdx !== -1) {
36
+ const existingKey = line.slice(0, eqIdx).trim();
37
+ if (existingKey === key) {
38
+ lines[i] = entry;
39
+ writeFileSync(filePath, lines.join('\n'));
40
+ return;
41
+ }
42
+ }
43
+ }
44
+
45
+ // Key doesn't exist — insert after last non-blank line in section
46
+ let insertIdx = sectionIdx + 1;
47
+ for (let i = endIdx - 1; i > sectionIdx; i--) {
48
+ if (lines[i].trim() !== '') {
49
+ insertIdx = i + 1;
50
+ break;
51
+ }
52
+ }
53
+ lines.splice(insertIdx, 0, entry);
54
+ writeFileSync(filePath, lines.join('\n'));
55
+ }
56
+
57
+ export function removeFromSection(filePath, section, key) {
58
+ const content = readFileSync(filePath, 'utf-8');
59
+ const lines = content.split('\n');
60
+
61
+ const sectionIdx = findSectionIndex(lines, section);
62
+ if (sectionIdx === -1) return false;
63
+
64
+ const endIdx = findSectionEnd(lines, sectionIdx);
65
+
66
+ for (let i = sectionIdx + 1; i < endIdx; i++) {
67
+ const line = lines[i].trim();
68
+ if (line === '' || line.startsWith('#')) continue;
69
+ const eqIdx = line.indexOf('=');
70
+ if (eqIdx !== -1) {
71
+ const existingKey = line.slice(0, eqIdx).trim();
72
+ if (existingKey === key) {
73
+ lines.splice(i, 1);
74
+ writeFileSync(filePath, lines.join('\n'));
75
+ return true;
76
+ }
77
+ }
78
+ }
79
+
80
+ return false;
81
+ }
82
+
83
+ function findSectionIndex(lines, section) {
84
+ const pattern = `[${section}]`;
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const line = lines[i].trim();
87
+ if (line === pattern) return i;
88
+ }
89
+ return -1;
90
+ }
91
+
92
+ function findSectionEnd(lines, sectionIdx) {
93
+ for (let i = sectionIdx + 1; i < lines.length; i++) {
94
+ const line = lines[i].trim();
95
+ if (line.startsWith('[') && !line.startsWith('[[')) {
96
+ return i;
97
+ }
98
+ }
99
+ return lines.length;
100
+ }
@@ -0,0 +1,52 @@
1
+ // Generate a shadow package.json from tova.toml config.
2
+
3
+ import { writeFileSync, existsSync, readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ const MARKER = '// Auto-generated from tova.toml. Do not edit.';
7
+
8
+ export function generatePackageJson(config, cwd) {
9
+ const npmProd = config.npm?.prod || {};
10
+ const npmDev = config.npm?.dev || {};
11
+
12
+ const hasNpmDeps = Object.keys(npmProd).length > 0 || Object.keys(npmDev).length > 0;
13
+ if (!hasNpmDeps) return null;
14
+
15
+ const pkg = {
16
+ '//': MARKER,
17
+ name: config.project.name,
18
+ version: config.project.version,
19
+ private: true,
20
+ type: 'module',
21
+ };
22
+
23
+ if (Object.keys(npmProd).length > 0) {
24
+ pkg.dependencies = npmProd;
25
+ }
26
+
27
+ if (Object.keys(npmDev).length > 0) {
28
+ pkg.devDependencies = npmDev;
29
+ }
30
+
31
+ return pkg;
32
+ }
33
+
34
+ export function writePackageJson(config, cwd) {
35
+ const pkg = generatePackageJson(config, cwd);
36
+ if (!pkg) return false;
37
+
38
+ const pkgPath = join(cwd, 'package.json');
39
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
40
+ return true;
41
+ }
42
+
43
+ export function isGeneratedPackageJson(cwd) {
44
+ const pkgPath = join(cwd, 'package.json');
45
+ if (!existsSync(pkgPath)) return false;
46
+ try {
47
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
48
+ return pkg['//'] === MARKER;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
@@ -0,0 +1,100 @@
1
+ // Config resolution: reads tova.toml → falls back to package.json → defaults.
2
+
3
+ import { readFileSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { parseTOML } from './toml.js';
6
+
7
+ const DEFAULTS = {
8
+ project: {
9
+ name: 'tova-app',
10
+ version: '0.1.0',
11
+ description: '',
12
+ entry: 'src',
13
+ },
14
+ build: {
15
+ output: '.tova-out',
16
+ },
17
+ dev: {
18
+ port: 3000,
19
+ },
20
+ dependencies: {},
21
+ npm: {},
22
+ };
23
+
24
+ export function resolveConfig(cwd) {
25
+ const tomlPath = join(cwd, 'tova.toml');
26
+ const pkgPath = join(cwd, 'package.json');
27
+
28
+ // Try tova.toml first
29
+ if (existsSync(tomlPath)) {
30
+ const raw = readFileSync(tomlPath, 'utf-8');
31
+ const parsed = parseTOML(raw);
32
+ return normalizeConfig(parsed, 'tova.toml');
33
+ }
34
+
35
+ // Fall back to package.json
36
+ if (existsSync(pkgPath)) {
37
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
38
+ return configFromPackageJson(pkg);
39
+ }
40
+
41
+ // Return defaults
42
+ return { ...DEFAULTS, _source: 'defaults' };
43
+ }
44
+
45
+ function normalizeConfig(parsed, source) {
46
+ const config = {
47
+ project: {
48
+ name: parsed.project?.name || DEFAULTS.project.name,
49
+ version: parsed.project?.version || DEFAULTS.project.version,
50
+ description: parsed.project?.description || DEFAULTS.project.description,
51
+ entry: parsed.project?.entry || DEFAULTS.project.entry,
52
+ },
53
+ build: {
54
+ output: parsed.build?.output || DEFAULTS.build.output,
55
+ },
56
+ dev: {
57
+ port: parsed.dev?.port ?? DEFAULTS.dev.port,
58
+ },
59
+ dependencies: parsed.dependencies || {},
60
+ npm: {},
61
+ _source: source,
62
+ };
63
+
64
+ // Collect npm deps: top-level from [npm], dev deps from [npm.dev]
65
+ if (parsed.npm) {
66
+ for (const [key, value] of Object.entries(parsed.npm)) {
67
+ if (key === 'dev' && typeof value === 'object' && !Array.isArray(value)) {
68
+ config.npm.dev = value;
69
+ } else if (typeof value === 'string') {
70
+ if (!config.npm.prod) config.npm.prod = {};
71
+ config.npm.prod[key] = value;
72
+ }
73
+ }
74
+ }
75
+
76
+ return config;
77
+ }
78
+
79
+ function configFromPackageJson(pkg) {
80
+ return {
81
+ project: {
82
+ name: pkg.name || DEFAULTS.project.name,
83
+ version: pkg.version || DEFAULTS.project.version,
84
+ description: pkg.description || DEFAULTS.project.description,
85
+ entry: DEFAULTS.project.entry,
86
+ },
87
+ build: {
88
+ output: DEFAULTS.build.output,
89
+ },
90
+ dev: {
91
+ port: DEFAULTS.dev.port,
92
+ },
93
+ dependencies: {},
94
+ npm: {
95
+ prod: pkg.dependencies || {},
96
+ dev: pkg.devDependencies || {},
97
+ },
98
+ _source: 'package.json',
99
+ };
100
+ }