tova 0.1.5 → 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.
package/bin/tova.js CHANGED
@@ -15,6 +15,10 @@ import { getFullStdlib, buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE } from '.
15
15
  import { Formatter } from '../src/formatter/formatter.js';
16
16
  import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE } from '../src/runtime/embedded.js';
17
17
  import '../src/runtime/string-proto.js';
18
+ import { resolveConfig } from '../src/config/resolve.js';
19
+ import { writePackageJson } from '../src/config/package-json.js';
20
+ import { addToSection, removeFromSection } from '../src/config/edit-toml.js';
21
+ import { stringifyTOML } from '../src/config/toml.js';
18
22
 
19
23
  const require = createRequire(import.meta.url);
20
24
  const { version: VERSION } = require('../package.json');
@@ -33,10 +37,13 @@ Usage:
33
37
  Commands:
34
38
  run <file> Compile and execute a .tova file
35
39
  build [dir] Compile .tova files to JavaScript (default: current dir)
36
- dev Start development server with file watching
40
+ dev Start development server with live reload
41
+ new <name> Create a new Tova project
42
+ install Install npm dependencies from tova.toml
43
+ add <pkg> Add an npm dependency (--dev for dev dependency)
44
+ remove <pkg> Remove an npm dependency
37
45
  repl Start interactive Tova REPL
38
46
  lsp Start Language Server Protocol server
39
- new <name> Create a new Tova project
40
47
  fmt <file> Format a .tova file (--check to verify only)
41
48
  test [dir] Run test blocks in .tova files (--filter, --watch)
42
49
  migrate:create <name> Create a new migration file
@@ -88,6 +95,15 @@ async function main() {
88
95
  case 'new':
89
96
  newProject(args[1]);
90
97
  break;
98
+ case 'install':
99
+ await installDeps();
100
+ break;
101
+ case 'add':
102
+ await addDep(args.slice(1));
103
+ break;
104
+ case 'remove':
105
+ await removeDep(args[1]);
106
+ break;
91
107
  case 'fmt':
92
108
  formatFile(args.slice(1));
93
109
  break;
@@ -305,9 +321,23 @@ function findTovaFiles(dir) {
305
321
 
306
322
  async function runFile(filePath, options = {}) {
307
323
  if (!filePath) {
308
- console.error('Error: No file specified');
309
- console.error('Usage: tova run <file.tova>');
310
- process.exit(1);
324
+ // If tova.toml exists, try to find a main file in the entry directory
325
+ const config = resolveConfig(process.cwd());
326
+ if (config._source === 'tova.toml') {
327
+ const entryDir = resolve(config.project.entry || '.');
328
+ for (const name of ['main.tova', 'app.tova']) {
329
+ const candidate = join(entryDir, name);
330
+ if (existsSync(candidate)) {
331
+ filePath = candidate;
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ if (!filePath) {
337
+ console.error('Error: No file specified');
338
+ console.error('Usage: tova run <file.tova>');
339
+ process.exit(1);
340
+ }
311
341
  }
312
342
 
313
343
  const resolved = resolve(filePath);
@@ -339,11 +369,13 @@ async function runFile(filePath, options = {}) {
339
369
  // ─── Build ──────────────────────────────────────────────────
340
370
 
341
371
  async function buildProject(args) {
372
+ const config = resolveConfig(process.cwd());
342
373
  const isProduction = args.includes('--production');
343
374
  const buildStrict = args.includes('--strict');
344
- const srcDir = resolve(args.filter(a => !a.startsWith('--'))[0] || '.');
375
+ const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
376
+ const srcDir = resolve(explicitSrc || config.project.entry || '.');
345
377
  const outIdx = args.indexOf('--output');
346
- const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : '.tova-out');
378
+ const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : (config.build.output || '.tova-out'));
347
379
 
348
380
  // Production build uses a separate optimized pipeline
349
381
  if (isProduction) {
@@ -455,8 +487,11 @@ async function buildProject(args) {
455
487
  // ─── Dev Server ─────────────────────────────────────────────
456
488
 
457
489
  async function devServer(args) {
458
- const srcDir = resolve(args[0] || '.');
459
- const basePort = parseInt(args.find((_, i, a) => a[i - 1] === '--port') || '3000');
490
+ const config = resolveConfig(process.cwd());
491
+ const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
492
+ const srcDir = resolve(explicitSrc || config.project.entry || '.');
493
+ const explicitPort = args.find((_, i, a) => a[i - 1] === '--port');
494
+ const basePort = parseInt(explicitPort || config.dev.port || '3000');
460
495
  const buildStrict = args.includes('--strict');
461
496
 
462
497
  const tovaFiles = findFiles(srcDir, '.tova');
@@ -465,6 +500,8 @@ async function devServer(args) {
465
500
  process.exit(1);
466
501
  }
467
502
 
503
+ const reloadPort = basePort + 100;
504
+
468
505
  console.log(`\n Tova dev server starting...\n`);
469
506
 
470
507
  // Compile all files
@@ -509,7 +546,7 @@ async function devServer(args) {
509
546
  if (output.client) {
510
547
  const p = join(outDir, `${outBaseName}.client.js`);
511
548
  writeFileSync(p, output.client);
512
- clientHTML = await generateDevHTML(output.client, srcDir);
549
+ clientHTML = await generateDevHTML(output.client, srcDir, reloadPort);
513
550
  writeFileSync(join(outDir, 'index.html'), clientHTML);
514
551
  hasClient = true;
515
552
  }
@@ -589,6 +626,49 @@ async function devServer(args) {
589
626
  console.log(` ✓ Client: ${relative('.', outDir)}/index.html`);
590
627
  }
591
628
 
629
+ // Start live-reload SSE server
630
+ const reloadClients = new Set();
631
+ const reloadServer = Bun.serve({
632
+ port: reloadPort,
633
+ fetch(req) {
634
+ const url = new URL(req.url);
635
+ if (url.pathname === '/__tova_reload') {
636
+ const stream = new ReadableStream({
637
+ start(controller) {
638
+ const client = { controller };
639
+ reloadClients.add(client);
640
+ // Send heartbeat to keep connection alive
641
+ const heartbeat = setInterval(() => {
642
+ try { controller.enqueue(new TextEncoder().encode(': heartbeat\n\n')); } catch { clearInterval(heartbeat); }
643
+ }, 15000);
644
+ req.signal.addEventListener('abort', () => {
645
+ clearInterval(heartbeat);
646
+ reloadClients.delete(client);
647
+ });
648
+ },
649
+ });
650
+ return new Response(stream, {
651
+ headers: {
652
+ 'Content-Type': 'text/event-stream',
653
+ 'Cache-Control': 'no-cache',
654
+ 'Connection': 'keep-alive',
655
+ 'Access-Control-Allow-Origin': '*',
656
+ },
657
+ });
658
+ }
659
+ return new Response('Not Found', { status: 404 });
660
+ },
661
+ });
662
+
663
+ function notifyReload() {
664
+ const msg = new TextEncoder().encode('data: reload\n\n');
665
+ for (const client of reloadClients) {
666
+ try { client.controller.enqueue(msg); } catch { reloadClients.delete(client); }
667
+ }
668
+ }
669
+
670
+ console.log(` ✓ Live reload on port ${reloadPort}`);
671
+
592
672
  // Start file watcher for auto-rebuild
593
673
  const watcher = startWatcher(srcDir, async () => {
594
674
  console.log(' Rebuilding...');
@@ -620,7 +700,7 @@ async function devServer(args) {
620
700
  }
621
701
  if (output.client) {
622
702
  writeFileSync(join(outDir, `${outBaseName}.client.js`), output.client);
623
- rebuildClientHTML = await generateDevHTML(output.client, srcDir);
703
+ rebuildClientHTML = await generateDevHTML(output.client, srcDir, reloadPort);
624
704
  writeFileSync(join(outDir, 'index.html'), rebuildClientHTML);
625
705
  }
626
706
  if (output.server) {
@@ -670,6 +750,7 @@ async function devServer(args) {
670
750
  rebuildPortOffset++;
671
751
  }
672
752
  console.log(' ✓ Rebuild complete');
753
+ notifyReload();
673
754
  });
674
755
 
675
756
  console.log(`\n Watching for changes. Press Ctrl+C to stop\n`);
@@ -678,6 +759,7 @@ async function devServer(args) {
678
759
  process.on('SIGINT', () => {
679
760
  console.log('\n Shutting down...');
680
761
  watcher.close();
762
+ reloadServer.stop();
681
763
  for (const p of processes) {
682
764
  p.child.kill('SIGTERM');
683
765
  }
@@ -688,7 +770,16 @@ async function devServer(args) {
688
770
  await new Promise(() => {});
689
771
  }
690
772
 
691
- async function generateDevHTML(clientCode, srcDir) {
773
+ async function generateDevHTML(clientCode, srcDir, reloadPort = 0) {
774
+ const liveReloadScript = reloadPort ? `
775
+ <script>
776
+ (function() {
777
+ var es = new EventSource("http://localhost:${reloadPort}/__tova_reload");
778
+ es.onmessage = function(e) { if (e.data === "reload") window.location.reload(); };
779
+ es.onerror = function() { setTimeout(function() { window.location.reload(); }, 1000); };
780
+ })();
781
+ </script>` : '';
782
+
692
783
  // Check if client code uses npm packages — if so, bundle with Bun.build
693
784
  if (srcDir && hasNpmImports(clientCode)) {
694
785
  const bundled = await bundleClientCode(clientCode, srcDir);
@@ -710,7 +801,7 @@ async function generateDevHTML(clientCode, srcDir) {
710
801
  <div id="app"></div>
711
802
  <script type="module">
712
803
  ${bundled}
713
- </script>
804
+ </script>${liveReloadScript}
714
805
  </body>
715
806
  </html>`;
716
807
  }
@@ -772,7 +863,7 @@ ${inlineRpc}
772
863
 
773
864
  // ── App ──
774
865
  ${inlineClient}
775
- </script>
866
+ </script>${liveReloadScript}
776
867
  </body>
777
868
  </html>`;
778
869
  }
@@ -797,19 +888,34 @@ function newProject(name) {
797
888
  mkdirSync(projectDir, { recursive: true });
798
889
  mkdirSync(join(projectDir, 'src'));
799
890
 
800
- // package.json
801
- writeFileSync(join(projectDir, 'package.json'), JSON.stringify({
802
- name,
803
- version: '0.1.0',
804
- type: 'module',
805
- scripts: {
806
- dev: 'tova dev src',
807
- build: 'tova build src',
891
+ // tova.toml
892
+ const tomlContent = stringifyTOML({
893
+ project: {
894
+ name,
895
+ version: '0.1.0',
896
+ description: 'A full-stack Tova application',
897
+ entry: 'src',
808
898
  },
809
- dependencies: {
810
- 'tova': '^0.1.0',
899
+ build: {
900
+ output: '.tova-out',
811
901
  },
812
- }, null, 2) + '\n');
902
+ dev: {
903
+ port: 3000,
904
+ },
905
+ dependencies: {},
906
+ npm: {},
907
+ });
908
+ writeFileSync(join(projectDir, 'tova.toml'), tomlContent);
909
+
910
+ // .gitignore
911
+ writeFileSync(join(projectDir, '.gitignore'), `node_modules/
912
+ .tova-out/
913
+ package.json
914
+ bun.lock
915
+ *.db
916
+ *.db-shm
917
+ *.db-wal
918
+ `);
813
919
 
814
920
  // Main app file
815
921
  writeFileSync(join(projectDir, 'src', 'app.tova'), `// ${name} — Built with Tova
@@ -853,24 +959,146 @@ Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stac
853
959
  ## Development
854
960
 
855
961
  \`\`\`bash
856
- bun install
857
- bun run dev
962
+ tova install
963
+ tova dev
858
964
  \`\`\`
859
965
 
860
966
  ## Build
861
967
 
862
968
  \`\`\`bash
863
- bun run build
969
+ tova build
970
+ \`\`\`
971
+
972
+ ## Add npm packages
973
+
974
+ \`\`\`bash
975
+ tova add htmx
976
+ tova add prettier --dev
864
977
  \`\`\`
865
978
  `);
866
979
 
867
- console.log(` ✓ Created ${name}/package.json`);
980
+ console.log(` ✓ Created ${name}/tova.toml`);
981
+ console.log(` ✓ Created ${name}/.gitignore`);
868
982
  console.log(` ✓ Created ${name}/src/app.tova`);
869
983
  console.log(` ✓ Created ${name}/README.md`);
870
984
  console.log(`\n Get started:\n`);
871
985
  console.log(` cd ${name}`);
872
- console.log(` bun install`);
873
- console.log(` bun run dev\n`);
986
+ console.log(` tova install`);
987
+ console.log(` tova dev\n`);
988
+ }
989
+
990
+ // ─── Package Management ─────────────────────────────────────
991
+
992
+ async function installDeps() {
993
+ const cwd = process.cwd();
994
+ const config = resolveConfig(cwd);
995
+
996
+ if (config._source !== 'tova.toml') {
997
+ // No tova.toml — just run bun install as normal
998
+ console.log(' No tova.toml found, running bun install...\n');
999
+ const proc = spawn('bun', ['install'], { stdio: 'inherit', cwd });
1000
+ const code = await new Promise(res => proc.on('close', res));
1001
+ process.exit(code);
1002
+ return;
1003
+ }
1004
+
1005
+ // Generate shadow package.json from tova.toml
1006
+ const wrote = writePackageJson(config, cwd);
1007
+ if (wrote) {
1008
+ console.log(' Generated package.json from tova.toml');
1009
+ const proc = spawn('bun', ['install'], { stdio: 'inherit', cwd });
1010
+ const code = await new Promise(res => proc.on('close', res));
1011
+ process.exit(code);
1012
+ } else {
1013
+ console.log(' No npm dependencies in tova.toml. Nothing to install.\n');
1014
+ }
1015
+ }
1016
+
1017
+ async function addDep(args) {
1018
+ const isDev = args.includes('--dev');
1019
+ const pkg = args.find(a => !a.startsWith('--'));
1020
+
1021
+ if (!pkg) {
1022
+ console.error('Error: No package specified');
1023
+ console.error('Usage: tova add <package> [--dev]');
1024
+ process.exit(1);
1025
+ }
1026
+
1027
+ const cwd = process.cwd();
1028
+ const tomlPath = join(cwd, 'tova.toml');
1029
+
1030
+ if (!existsSync(tomlPath)) {
1031
+ console.error('Error: No tova.toml found in current directory');
1032
+ console.error('Run `tova new <name>` to create a new project, or create tova.toml manually.');
1033
+ process.exit(1);
1034
+ }
1035
+
1036
+ // Parse package name and version
1037
+ let name = pkg;
1038
+ let version = 'latest';
1039
+ if (pkg.includes('@') && !pkg.startsWith('@')) {
1040
+ const atIdx = pkg.lastIndexOf('@');
1041
+ name = pkg.slice(0, atIdx);
1042
+ version = pkg.slice(atIdx + 1);
1043
+ } else if (pkg.startsWith('@') && pkg.includes('@', 1)) {
1044
+ // Scoped package with version: @scope/name@version
1045
+ const atIdx = pkg.lastIndexOf('@');
1046
+ name = pkg.slice(0, atIdx);
1047
+ version = pkg.slice(atIdx + 1);
1048
+ }
1049
+
1050
+ // If version is 'latest', resolve it via npm registry
1051
+ if (version === 'latest') {
1052
+ try {
1053
+ const proc = spawn('npm', ['view', name, 'version'], { stdio: ['pipe', 'pipe', 'pipe'] });
1054
+ let out = '';
1055
+ proc.stdout.on('data', d => out += d);
1056
+ const code = await new Promise(res => proc.on('close', res));
1057
+ if (code === 0 && out.trim()) {
1058
+ version = `^${out.trim()}`;
1059
+ } else {
1060
+ version = '*';
1061
+ }
1062
+ } catch {
1063
+ version = '*';
1064
+ }
1065
+ }
1066
+
1067
+ const section = isDev ? 'npm.dev' : 'npm';
1068
+ addToSection(tomlPath, section, name, version);
1069
+
1070
+ console.log(` Added ${name}@${version} to [${section}] in tova.toml`);
1071
+
1072
+ // Run install
1073
+ await installDeps();
1074
+ }
1075
+
1076
+ async function removeDep(pkg) {
1077
+ if (!pkg) {
1078
+ console.error('Error: No package specified');
1079
+ console.error('Usage: tova remove <package>');
1080
+ process.exit(1);
1081
+ }
1082
+
1083
+ const cwd = process.cwd();
1084
+ const tomlPath = join(cwd, 'tova.toml');
1085
+
1086
+ if (!existsSync(tomlPath)) {
1087
+ console.error('Error: No tova.toml found in current directory');
1088
+ process.exit(1);
1089
+ }
1090
+
1091
+ // Try removing from [npm] first, then [npm.dev]
1092
+ const removed = removeFromSection(tomlPath, 'npm', pkg) ||
1093
+ removeFromSection(tomlPath, 'npm.dev', pkg);
1094
+
1095
+ if (removed) {
1096
+ console.log(` Removed ${pkg} from tova.toml`);
1097
+ await installDeps();
1098
+ } else {
1099
+ console.error(` Package '${pkg}' not found in tova.toml`);
1100
+ process.exit(1);
1101
+ }
874
1102
  }
875
1103
 
876
1104
  // ─── Migrations ─────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tova",
3
- "version": "0.1.5",
3
+ "version": "0.2.2",
4
4
  "description": "Tova — a modern programming language that transpiles to JavaScript, unifying frontend and backend",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -34,6 +34,11 @@
34
34
  "url": "https://github.com/tova-lang/tova-lang/issues"
35
35
  },
36
36
  "author": "Enoch Kujem Abassey",
37
- "keywords": ["language", "transpiler", "fullstack", "javascript"],
37
+ "keywords": [
38
+ "language",
39
+ "transpiler",
40
+ "fullstack",
41
+ "javascript"
42
+ ],
38
43
  "license": "MIT"
39
44
  }
@@ -172,7 +172,8 @@ export class ClientCodegen extends BaseCodegen {
172
172
  return `${asyncPrefix}(${params}) => ${this.genExpression(node.body)}`;
173
173
  }
174
174
 
175
- generate(clientBlocks, sharedCode) {
175
+ generate(clientBlocks, sharedCode, sharedBuiltins = null) {
176
+ this._sharedBuiltins = sharedBuiltins || new Set();
176
177
  const lines = [];
177
178
 
178
179
  // Runtime imports
@@ -209,9 +210,10 @@ export class ClientCodegen extends BaseCodegen {
209
210
  lines.push('');
210
211
  }
211
212
 
212
- // Stdlib core functions (available in all Tova code)
213
+ // Stdlib placeholder filled after all client code is generated so tree-shaking sees all usages
214
+ const stdlibPlaceholderIdx = lines.length;
213
215
  lines.push('// ── Stdlib ──');
214
- lines.push(this.getStdlibCore());
216
+ lines.push('__STDLIB_PLACEHOLDER__');
215
217
  lines.push('');
216
218
 
217
219
  // Server RPC proxy
@@ -350,6 +352,9 @@ export class ClientCodegen extends BaseCodegen {
350
352
  lines.push('});');
351
353
  }
352
354
 
355
+ // Replace stdlib placeholder now that all client code has been generated
356
+ lines[stdlibPlaceholderIdx + 1] = this.getStdlibCore();
357
+
353
358
  return lines.join('\n');
354
359
  }
355
360
 
@@ -901,8 +906,14 @@ export class ClientCodegen extends BaseCodegen {
901
906
 
902
907
  getStdlibCore() {
903
908
  const parts = [];
904
- // Only include used builtin functions (tree-shaking)
905
- const selectiveStdlib = buildSelectiveStdlib(this._usedBuiltins);
909
+ // Only include builtins used in client blocks that aren't already in shared code
910
+ const clientOnly = new Set();
911
+ for (const name of this._usedBuiltins) {
912
+ if (!this._sharedBuiltins || !this._sharedBuiltins.has(name)) {
913
+ clientOnly.add(name);
914
+ }
915
+ }
916
+ const selectiveStdlib = buildSelectiveStdlib(clientOnly);
906
917
  if (selectiveStdlib) parts.push(selectiveStdlib);
907
918
  // Include Result/Option if Ok/Err/Some/None are used
908
919
  if (this._needsResultOption) parts.push(RESULT_OPTION);
@@ -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>') {
@@ -48,6 +49,10 @@ export class CodeGenerator {
48
49
  // All shared blocks (regardless of name) are merged into one shared output
49
50
  const sharedCode = sharedBlocks.map(b => sharedGen.generate(b)).join('\n');
50
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
+
51
56
  const helpers = sharedGen.generateHelpers();
52
57
 
53
58
  // Generate data block code (sources, pipelines, validators, refresh)
@@ -105,7 +110,7 @@ export class CodeGenerator {
105
110
  for (const [name, blocks] of clientGroups) {
106
111
  const gen = new ClientCodegen();
107
112
  const key = name || 'default';
108
- clients[key] = gen.generate(blocks, combinedShared);
113
+ clients[key] = gen.generate(blocks, combinedShared, sharedGen._usedBuiltins);
109
114
  }
110
115
 
111
116
  // Generate tests if test blocks exist
@@ -152,6 +157,31 @@ export class CodeGenerator {
152
157
  return result;
153
158
  }
154
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
+
155
185
  _genDataBlock(node, gen) {
156
186
  const lines = [];
157
187
  lines.push('// ── Data Block ──');
@@ -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
+ }
@@ -0,0 +1,209 @@
1
+ // Minimal TOML parser for tova.toml project manifests.
2
+ // Handles: sections ([name], [a.b]), strings, numbers, booleans, simple arrays.
3
+
4
+ export function parseTOML(input) {
5
+ const result = {};
6
+ let current = result;
7
+ const lines = input.split('\n');
8
+
9
+ for (let i = 0; i < lines.length; i++) {
10
+ const raw = lines[i];
11
+ const line = raw.trim();
12
+
13
+ // Skip empty lines and comments
14
+ if (line === '' || line.startsWith('#')) continue;
15
+
16
+ // Section header: [section] or [section.subsection]
17
+ if (line.startsWith('[') && !line.startsWith('[[')) {
18
+ const close = line.indexOf(']');
19
+ if (close === -1) {
20
+ throw new Error(`TOML parse error on line ${i + 1}: unclosed section header`);
21
+ }
22
+ const sectionPath = line.slice(1, close).trim();
23
+ if (!sectionPath) {
24
+ throw new Error(`TOML parse error on line ${i + 1}: empty section name`);
25
+ }
26
+ current = result;
27
+ for (const part of sectionPath.split('.')) {
28
+ const key = part.trim();
29
+ if (!current[key]) current[key] = {};
30
+ current = current[key];
31
+ }
32
+ continue;
33
+ }
34
+
35
+ // Key = value
36
+ const eqIdx = line.indexOf('=');
37
+ if (eqIdx === -1) continue; // skip lines without =
38
+
39
+ const key = line.slice(0, eqIdx).trim();
40
+ const rawValue = line.slice(eqIdx + 1).trim();
41
+
42
+ current[key] = parseValue(rawValue, i + 1);
43
+ }
44
+
45
+ return result;
46
+ }
47
+
48
+ function parseValue(raw, lineNum) {
49
+ if (raw === '') {
50
+ throw new Error(`TOML parse error on line ${lineNum}: missing value`);
51
+ }
52
+
53
+ // Strip inline comment (not inside quotes)
54
+ const stripped = stripInlineComment(raw);
55
+
56
+ // Boolean
57
+ if (stripped === 'true') return true;
58
+ if (stripped === 'false') return false;
59
+
60
+ // Quoted string (double or single)
61
+ if ((stripped.startsWith('"') && stripped.endsWith('"')) ||
62
+ (stripped.startsWith("'") && stripped.endsWith("'"))) {
63
+ return parseString(stripped);
64
+ }
65
+
66
+ // Array
67
+ if (stripped.startsWith('[')) {
68
+ return parseArray(stripped, lineNum);
69
+ }
70
+
71
+ // Number (integer or float)
72
+ if (/^-?\d+(\.\d+)?$/.test(stripped)) {
73
+ return stripped.includes('.') ? parseFloat(stripped) : parseInt(stripped, 10);
74
+ }
75
+
76
+ // Bare value (treat as string for compat with version ranges like ^2.0.0)
77
+ return stripped;
78
+ }
79
+
80
+ function stripInlineComment(raw) {
81
+ // Find # that's not inside a quoted string
82
+ let inStr = null;
83
+ for (let i = 0; i < raw.length; i++) {
84
+ const ch = raw[i];
85
+ if (inStr) {
86
+ if (ch === '\\') { i++; continue; }
87
+ if (ch === inStr) inStr = null;
88
+ } else {
89
+ if (ch === '"' || ch === "'") { inStr = ch; continue; }
90
+ if (ch === '#') return raw.slice(0, i).trim();
91
+ }
92
+ }
93
+ return raw;
94
+ }
95
+
96
+ function parseString(raw) {
97
+ const quote = raw[0];
98
+ const inner = raw.slice(1, -1);
99
+ if (quote === '"') {
100
+ // Handle escape sequences
101
+ return inner
102
+ .replace(/\\n/g, '\n')
103
+ .replace(/\\t/g, '\t')
104
+ .replace(/\\r/g, '\r')
105
+ .replace(/\\\\/g, '\\')
106
+ .replace(/\\"/g, '"');
107
+ }
108
+ // Single-quoted: literal string, no escapes
109
+ return inner;
110
+ }
111
+
112
+ function parseArray(raw, lineNum) {
113
+ // Simple single-line array: [val1, val2, ...]
114
+ if (!raw.endsWith(']')) {
115
+ throw new Error(`TOML parse error on line ${lineNum}: unclosed array`);
116
+ }
117
+ const inner = raw.slice(1, -1).trim();
118
+ if (inner === '') return [];
119
+
120
+ const items = [];
121
+ let current = '';
122
+ let depth = 0;
123
+ let inStr = null;
124
+
125
+ for (let i = 0; i < inner.length; i++) {
126
+ const ch = inner[i];
127
+ if (inStr) {
128
+ current += ch;
129
+ if (ch === '\\') { current += inner[++i] || ''; continue; }
130
+ if (ch === inStr) inStr = null;
131
+ } else {
132
+ if (ch === '"' || ch === "'") { inStr = ch; current += ch; continue; }
133
+ if (ch === '[') { depth++; current += ch; continue; }
134
+ if (ch === ']') { depth--; current += ch; continue; }
135
+ if (ch === ',' && depth === 0) {
136
+ const val = current.trim();
137
+ if (val !== '') items.push(parseValue(val, lineNum));
138
+ current = '';
139
+ continue;
140
+ }
141
+ current += ch;
142
+ }
143
+ }
144
+ const last = current.trim();
145
+ if (last !== '') items.push(parseValue(last, lineNum));
146
+
147
+ return items;
148
+ }
149
+
150
+ export function stringifyTOML(obj, _prefix = '') {
151
+ const lines = [];
152
+ const sections = [];
153
+
154
+ // Write top-level key-value pairs first
155
+ for (const [key, value] of Object.entries(obj)) {
156
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
157
+ sections.push([key, value]);
158
+ } else {
159
+ lines.push(`${key} = ${formatValue(value)}`);
160
+ }
161
+ }
162
+
163
+ // Write sections
164
+ for (const [key, value] of sections) {
165
+ const sectionKey = _prefix ? `${_prefix}.${key}` : key;
166
+ const { topLevel, nested } = splitObject(value);
167
+
168
+ if (lines.length > 0 || sections.indexOf([key, value]) > 0) {
169
+ lines.push('');
170
+ }
171
+ lines.push(`[${sectionKey}]`);
172
+
173
+ for (const [k, v] of Object.entries(topLevel)) {
174
+ lines.push(`${k} = ${formatValue(v)}`);
175
+ }
176
+
177
+ for (const [k, v] of Object.entries(nested)) {
178
+ const nestedKey = `${sectionKey}.${k}`;
179
+ lines.push('');
180
+ lines.push(`[${nestedKey}]`);
181
+ for (const [nk, nv] of Object.entries(v)) {
182
+ lines.push(`${nk} = ${formatValue(nv)}`);
183
+ }
184
+ }
185
+ }
186
+
187
+ return lines.join('\n') + '\n';
188
+ }
189
+
190
+ function splitObject(obj) {
191
+ const topLevel = {};
192
+ const nested = {};
193
+ for (const [key, value] of Object.entries(obj)) {
194
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
195
+ nested[key] = value;
196
+ } else {
197
+ topLevel[key] = value;
198
+ }
199
+ }
200
+ return { topLevel, nested };
201
+ }
202
+
203
+ function formatValue(value) {
204
+ if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
205
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
206
+ if (typeof value === 'number') return String(value);
207
+ if (Array.isArray(value)) return `[${value.map(formatValue).join(', ')}]`;
208
+ return String(value);
209
+ }