tova 0.5.1 → 0.8.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.
Files changed (60) hide show
  1. package/bin/tova.js +261 -60
  2. package/package.json +1 -1
  3. package/src/analyzer/analyzer.js +351 -11
  4. package/src/analyzer/{client-analyzer.js → browser-analyzer.js} +20 -17
  5. package/src/analyzer/deploy-analyzer.js +44 -0
  6. package/src/analyzer/form-analyzer.js +113 -0
  7. package/src/analyzer/scope.js +2 -2
  8. package/src/codegen/base-codegen.js +1160 -10
  9. package/src/codegen/{client-codegen.js → browser-codegen.js} +444 -5
  10. package/src/codegen/codegen.js +119 -28
  11. package/src/codegen/deploy-codegen.js +49 -0
  12. package/src/codegen/edge-codegen.js +1351 -0
  13. package/src/codegen/form-codegen.js +553 -0
  14. package/src/codegen/security-codegen.js +5 -5
  15. package/src/codegen/server-codegen.js +88 -7
  16. package/src/codegen/shared-codegen.js +5 -0
  17. package/src/codegen/wasm-codegen.js +6 -0
  18. package/src/config/edit-toml.js +6 -2
  19. package/src/config/git-resolver.js +128 -0
  20. package/src/config/lock-file.js +57 -0
  21. package/src/config/module-cache.js +58 -0
  22. package/src/config/module-entry.js +37 -0
  23. package/src/config/module-path.js +31 -0
  24. package/src/config/pkg-errors.js +62 -0
  25. package/src/config/resolve.js +17 -0
  26. package/src/config/resolver.js +139 -0
  27. package/src/config/search.js +28 -0
  28. package/src/config/semver.js +72 -0
  29. package/src/config/toml.js +48 -5
  30. package/src/deploy/deploy.js +217 -0
  31. package/src/deploy/infer.js +218 -0
  32. package/src/deploy/provision.js +311 -0
  33. package/src/diagnostics/error-codes.js +1 -1
  34. package/src/docs/generator.js +1 -1
  35. package/src/formatter/formatter.js +4 -4
  36. package/src/lexer/tokens.js +12 -2
  37. package/src/lsp/server.js +483 -1
  38. package/src/parser/ast.js +60 -5
  39. package/src/parser/{client-ast.js → browser-ast.js} +3 -3
  40. package/src/parser/{client-parser.js → browser-parser.js} +42 -15
  41. package/src/parser/concurrency-ast.js +15 -0
  42. package/src/parser/concurrency-parser.js +236 -0
  43. package/src/parser/deploy-ast.js +37 -0
  44. package/src/parser/deploy-parser.js +132 -0
  45. package/src/parser/edge-ast.js +83 -0
  46. package/src/parser/edge-parser.js +262 -0
  47. package/src/parser/form-ast.js +80 -0
  48. package/src/parser/form-parser.js +206 -0
  49. package/src/parser/parser.js +82 -14
  50. package/src/parser/select-ast.js +39 -0
  51. package/src/registry/plugins/browser-plugin.js +30 -0
  52. package/src/registry/plugins/concurrency-plugin.js +32 -0
  53. package/src/registry/plugins/deploy-plugin.js +33 -0
  54. package/src/registry/plugins/edge-plugin.js +32 -0
  55. package/src/registry/register-all.js +8 -2
  56. package/src/runtime/ssr.js +2 -2
  57. package/src/stdlib/inline.js +38 -6
  58. package/src/stdlib/runtime-bridge.js +152 -0
  59. package/src/version.js +1 -1
  60. package/src/registry/plugins/client-plugin.js +0 -30
@@ -136,6 +136,69 @@ export class ServerCodegen extends BaseCodegen {
136
136
  return checks;
137
137
  }
138
138
 
139
+ // Generate validation checks from type-level validators (Phase 3)
140
+ // Returns an array of JS code lines for inline validation
141
+ _genTypeValidatorCode(paramName, typeInfo, indent = ' ') {
142
+ const checks = [];
143
+ for (const field of typeInfo.fields) {
144
+ if (!field.validators || field.validators.length === 0) continue;
145
+ const accessor = `${paramName}.${field.name}`;
146
+ for (const v of field.validators) {
147
+ // The last argument is typically the error message
148
+ const msgArg = v.args.length > 0 ? this.genExpression(v.args[v.args.length - 1]) : null;
149
+ switch (v.name) {
150
+ case 'required': {
151
+ const msg = msgArg || `"${field.name} is required"`;
152
+ checks.push(`${indent}if (${accessor} === undefined || ${accessor} === null || ${accessor} === "") __validationErrors.push({ field: "${field.name}", message: ${msg} });`);
153
+ break;
154
+ }
155
+ case 'email': {
156
+ const msg = msgArg || `"${field.name} must be a valid email"`;
157
+ checks.push(`${indent}if (${accessor} && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(${accessor})) __validationErrors.push({ field: "${field.name}", message: ${msg} });`);
158
+ break;
159
+ }
160
+ case 'min': {
161
+ const minVal = this.genExpression(v.args[0]);
162
+ const minMsg = v.args.length >= 2 ? this.genExpression(v.args[1]) : `"${field.name} must be at least ${v.args[0].value || v.args[0].name}"`;
163
+ checks.push(`${indent}if (typeof ${accessor} === "number" && ${accessor} < ${minVal}) __validationErrors.push({ field: "${field.name}", message: ${minMsg} });`);
164
+ break;
165
+ }
166
+ case 'max': {
167
+ const maxVal = this.genExpression(v.args[0]);
168
+ const maxMsg = v.args.length >= 2 ? this.genExpression(v.args[1]) : `"${field.name} must be at most ${v.args[0].value || v.args[0].name}"`;
169
+ checks.push(`${indent}if (typeof ${accessor} === "number" && ${accessor} > ${maxVal}) __validationErrors.push({ field: "${field.name}", message: ${maxMsg} });`);
170
+ break;
171
+ }
172
+ case 'minLength': {
173
+ const minLen = this.genExpression(v.args[0]);
174
+ const minLenMsg = v.args.length >= 2 ? this.genExpression(v.args[1]) : `"${field.name} is too short"`;
175
+ checks.push(`${indent}if (typeof ${accessor} === "string" && ${accessor}.length < ${minLen}) __validationErrors.push({ field: "${field.name}", message: ${minLenMsg} });`);
176
+ break;
177
+ }
178
+ case 'maxLength': {
179
+ const maxLen = this.genExpression(v.args[0]);
180
+ const maxLenMsg = v.args.length >= 2 ? this.genExpression(v.args[1]) : `"${field.name} is too long"`;
181
+ checks.push(`${indent}if (typeof ${accessor} === "string" && ${accessor}.length > ${maxLen}) __validationErrors.push({ field: "${field.name}", message: ${maxLenMsg} });`);
182
+ break;
183
+ }
184
+ case 'pattern': {
185
+ const regex = this.genExpression(v.args[0]);
186
+ const patMsg = v.args.length >= 2 ? this.genExpression(v.args[1]) : `"${field.name} has invalid format"`;
187
+ checks.push(`${indent}if (typeof ${accessor} === "string" && !${regex}.test(${accessor})) __validationErrors.push({ field: "${field.name}", message: ${patMsg} });`);
188
+ break;
189
+ }
190
+ case 'oneOf': {
191
+ const vals = this.genExpression(v.args[0]);
192
+ const oneOfMsg = v.args.length >= 2 ? this.genExpression(v.args[1]) : `"${field.name} has invalid value"`;
193
+ checks.push(`${indent}if (!${vals}.includes(${accessor})) __validationErrors.push({ field: "${field.name}", message: ${oneOfMsg} });`);
194
+ break;
195
+ }
196
+ }
197
+ }
198
+ }
199
+ return checks;
200
+ }
201
+
139
202
  // Emit handler call, optionally wrapped in Promise.race for timeout
140
203
  _emitHandlerCall(lines, callExpr, timeoutMs) {
141
204
  if (timeoutMs) {
@@ -304,14 +367,19 @@ export class ServerCodegen extends BaseCodegen {
304
367
  }
305
368
 
306
369
  // Collect type declarations from shared blocks for model/ORM generation
307
- const sharedTypes = new Map(); // typeName -> { fields: [{ name, type }] }
370
+ const sharedTypes = new Map(); // typeName -> { fields: [{ name, type, validators? }] }
308
371
  const _collectTypes = (stmts) => {
309
372
  for (const stmt of stmts) {
310
373
  if (stmt.type === 'TypeDeclaration' && stmt.variants) {
311
374
  const fields = [];
312
375
  for (const v of stmt.variants) {
313
376
  if (v.type === 'TypeField' && v.typeAnnotation) {
314
- fields.push({ name: v.name, type: v.typeAnnotation.name || (v.typeAnnotation.type === 'ArrayTypeAnnotation' ? 'Array' : 'Any') });
377
+ const fieldInfo = { name: v.name, type: v.typeAnnotation.name || (v.typeAnnotation.type === 'ArrayTypeAnnotation' ? 'Array' : 'Any') };
378
+ // Capture type-level validators (Phase 3) if present
379
+ if (v.validators && v.validators.length > 0) {
380
+ fieldInfo.validators = v.validators;
381
+ }
382
+ fields.push(fieldInfo);
315
383
  }
316
384
  }
317
385
  if (fields.length > 0) {
@@ -2166,9 +2234,19 @@ export class ServerCodegen extends BaseCodegen {
2166
2234
  lines.push(` const ${paramNames[pi]} = body.__args ? body.__args[${pi}] : body.${paramNames[pi]};`);
2167
2235
  }
2168
2236
  const validationChecks = this._genValidationCode(fn.params);
2169
- if (validationChecks.length > 0) {
2237
+ // Phase 3: Also generate type-level validator checks for typed parameters
2238
+ const typeValidatorChecks = [];
2239
+ for (const p of fn.params) {
2240
+ if (p.typeAnnotation && p.typeAnnotation.type === 'TypeAnnotation' && sharedTypes.has(p.typeAnnotation.name)) {
2241
+ const typeInfo = sharedTypes.get(p.typeAnnotation.name);
2242
+ const tvChecks = this._genTypeValidatorCode(p.name, typeInfo);
2243
+ typeValidatorChecks.push(...tvChecks);
2244
+ }
2245
+ }
2246
+ const allChecks = [...validationChecks, ...typeValidatorChecks];
2247
+ if (allChecks.length > 0) {
2170
2248
  lines.push(` const __validationErrors = [];`);
2171
- for (const check of validationChecks) {
2249
+ for (const check of allChecks) {
2172
2250
  lines.push(check);
2173
2251
  }
2174
2252
  lines.push(` if (__validationErrors.length > 0) return __errorResponse(400, "VALIDATION_FAILED", "Validation failed", __validationErrors);`);
@@ -2702,6 +2780,9 @@ export class ServerCodegen extends BaseCodegen {
2702
2780
  lines.push(' responses: { "200": { description: "Success" } },');
2703
2781
  }
2704
2782
 
2783
+ // Close the OpenAPI path entry object
2784
+ lines.push('};');
2785
+
2705
2786
  // Auto-generate error responses based on route context
2706
2787
  const routeHasAuth = (route.decorators || []).some(d => d.name === 'auth');
2707
2788
  const routeHasRateLimit = (route.decorators || []).some(d => d.name === 'rate_limit');
@@ -2709,7 +2790,8 @@ export class ServerCodegen extends BaseCodegen {
2709
2790
  const routeHasTimeout = (route.decorators || []).some(d => d.name === 'timeout');
2710
2791
  const errRef = '{ "$ref": "#/components/schemas/ErrorResponse" }';
2711
2792
 
2712
- // Merge error responses into existing 200 response
2793
+ // Merge error responses into existing 200 response (wrapped in block scope to avoid __r redeclaration)
2794
+ lines.push('{');
2713
2795
  lines.push(`const __r = __openApiSpec.paths[${JSON.stringify(path)}][${JSON.stringify(method)}].responses;`);
2714
2796
  if (routeHasValidation || ['post', 'put', 'patch'].includes(method)) {
2715
2797
  lines.push(`__r["400"] = { description: "Validation Failed", content: { "application/json": { schema: ${errRef} } } };`);
@@ -2733,8 +2815,7 @@ export class ServerCodegen extends BaseCodegen {
2733
2815
  lines.push(`__r["504"] = { description: "Gateway Timeout", content: { "application/json": { schema: ${errRef} } } };`);
2734
2816
  }
2735
2817
  lines.push(`__r["500"] = { description: "Internal Server Error", content: { "application/json": { schema: ${errRef} } } };`);
2736
-
2737
- lines.push('};');
2818
+ lines.push('}');
2738
2819
  }
2739
2820
 
2740
2821
  // Add the /docs endpoint
@@ -10,6 +10,11 @@ export class SharedCodegen extends BaseCodegen {
10
10
  // Generate any needed helpers (called after all code is generated)
11
11
  generateHelpers() {
12
12
  const helpers = [];
13
+ // Runtime bridge for WASM-Tokio concurrent execution
14
+ if (this._needsRuntimeBridge) {
15
+ // Try multiple paths: relative to script, package require, absolute from process.cwd()
16
+ helpers.push(`let __tova_rt = null; try { const __p = require('path'); const __d = __p.dirname(typeof __filename !== 'undefined' ? __filename : process.argv[1] || ''); const __candidates = [__p.join(__d, '..', 'src', 'stdlib', 'runtime-bridge.js'), __p.join(process.cwd(), 'src', 'stdlib', 'runtime-bridge.js')]; for (const __c of __candidates) { try { __tova_rt = require(__c); break; } catch(_) {} } } catch(_) {}`);
17
+ }
13
18
  helpers.push(this.getStringProtoHelper());
14
19
  // Only include Result/Option if Ok/Err/Some/None are used
15
20
  if (this._needsResultOption) {
@@ -602,6 +602,12 @@ export function generateWasmGlue(funcNode, wasmBytes) {
602
602
  return `const ${name} = new WebAssembly.Instance(new WebAssembly.Module(new Uint8Array([${bytesStr}]))).exports.${name};`;
603
603
  }
604
604
 
605
+ // Generate a module-level constant holding the raw WASM bytes for runtime use
606
+ export function generateWasmBytesExport(funcName, wasmBytes) {
607
+ const bytesStr = Array.from(wasmBytes).join(',');
608
+ return `const __wasm_bytes_${funcName} = new Uint8Array([${bytesStr}]);`;
609
+ }
610
+
605
611
  // Generate JS glue code for a multi-function WASM module
606
612
  export function generateMultiWasmGlue(funcNodes, wasmBytes) {
607
613
  const bytesStr = Array.from(wasmBytes).join(',');
@@ -28,13 +28,15 @@ export function addToSection(filePath, section, key, value) {
28
28
  const endIdx = findSectionEnd(lines, sectionIdx);
29
29
 
30
30
  // Check if key already exists in this section — update it
31
+ const bareKey = key.replace(/^"|"$/g, '');
31
32
  for (let i = sectionIdx + 1; i < endIdx; i++) {
32
33
  const line = lines[i].trim();
33
34
  if (line === '' || line.startsWith('#')) continue;
34
35
  const eqIdx = line.indexOf('=');
35
36
  if (eqIdx !== -1) {
36
37
  const existingKey = line.slice(0, eqIdx).trim();
37
- if (existingKey === key) {
38
+ const existingBare = existingKey.replace(/^"|"$/g, '');
39
+ if (existingKey === key || existingBare === bareKey) {
38
40
  lines[i] = entry;
39
41
  writeFileSync(filePath, lines.join('\n'));
40
42
  return;
@@ -57,6 +59,7 @@ export function addToSection(filePath, section, key, value) {
57
59
  export function removeFromSection(filePath, section, key) {
58
60
  const content = readFileSync(filePath, 'utf-8');
59
61
  const lines = content.split('\n');
62
+ const bareKey = key.replace(/^"|"$/g, '');
60
63
 
61
64
  const sectionIdx = findSectionIndex(lines, section);
62
65
  if (sectionIdx === -1) return false;
@@ -69,7 +72,8 @@ export function removeFromSection(filePath, section, key) {
69
72
  const eqIdx = line.indexOf('=');
70
73
  if (eqIdx !== -1) {
71
74
  const existingKey = line.slice(0, eqIdx).trim();
72
- if (existingKey === key) {
75
+ const existingBare = existingKey.replace(/^"|"$/g, '');
76
+ if (existingKey === key || existingBare === bareKey) {
73
77
  lines.splice(i, 1);
74
78
  writeFileSync(filePath, lines.join('\n'));
75
79
  return true;
@@ -0,0 +1,128 @@
1
+ // Git resolver for the Tova package manager.
2
+ // Handles git operations: parsing tag lists, sorting by semver,
3
+ // picking the latest tag, and fetching modules from remote repositories.
4
+
5
+ import { spawn } from 'child_process';
6
+ import { join } from 'path';
7
+ import { mkdirSync, renameSync, rmSync, existsSync } from 'fs';
8
+ import { parseSemver, compareSemver } from './semver.js';
9
+ import { moduleToGitUrl, parseModulePath } from './module-path.js';
10
+ import { getModuleCachePath, getCacheDir } from './module-cache.js';
11
+
12
+ /**
13
+ * Parse `git ls-remote --tags` output into an array of { version, sha } objects.
14
+ * Filters out non-semver tags and prefers dereferenced (^{}) SHAs for annotated tags.
15
+ */
16
+ export function parseTagList(output) {
17
+ if (!output.trim()) return [];
18
+ const lines = output.trim().split('\n');
19
+ const tagMap = new Map();
20
+ for (const line of lines) {
21
+ const [sha, ref] = line.split('\t');
22
+ if (!ref || !ref.startsWith('refs/tags/')) continue;
23
+ const tagName = ref.replace('refs/tags/', '');
24
+ const isDeref = tagName.endsWith('^{}');
25
+ const cleanName = isDeref ? tagName.slice(0, -3) : tagName;
26
+ const versionStr = cleanName.startsWith('v') ? cleanName.slice(1) : cleanName;
27
+ try { parseSemver(versionStr); } catch { continue; }
28
+ if (isDeref || !tagMap.has(versionStr)) {
29
+ tagMap.set(versionStr, sha);
30
+ }
31
+ }
32
+ return Array.from(tagMap.entries()).map(([version, sha]) => ({ version, sha }));
33
+ }
34
+
35
+ /**
36
+ * Sort an array of { version, sha } tags by semver in ascending order.
37
+ * Returns a new array (does not mutate the input).
38
+ */
39
+ export function sortTags(tags) {
40
+ return [...tags].sort((a, b) => compareSemver(a.version, b.version));
41
+ }
42
+
43
+ /**
44
+ * Pick the tag with the highest semver version.
45
+ * Returns null if the tags array is empty.
46
+ */
47
+ export function pickLatestTag(tags) {
48
+ if (tags.length === 0) return null;
49
+ const sorted = sortTags(tags);
50
+ return sorted[sorted.length - 1];
51
+ }
52
+
53
+ /**
54
+ * List all semver tags from a remote git repository.
55
+ * Returns a promise resolving to an array of { version, sha } objects.
56
+ */
57
+ export function listRemoteTags(modulePath) {
58
+ const gitUrl = moduleToGitUrl(modulePath);
59
+ return new Promise((resolve, reject) => {
60
+ const proc = spawn('git', ['ls-remote', '--tags', gitUrl], {
61
+ stdio: ['pipe', 'pipe', 'pipe'],
62
+ });
63
+ let stdout = '';
64
+ let stderr = '';
65
+ proc.stdout.on('data', d => stdout += d);
66
+ proc.stderr.on('data', d => stderr += d);
67
+ proc.on('close', code => {
68
+ if (code !== 0) {
69
+ reject(new Error(`Failed to list tags for ${modulePath}: ${stderr.trim()}`));
70
+ return;
71
+ }
72
+ resolve(parseTagList(stdout));
73
+ });
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Clone a specific version of a module into the local cache.
79
+ * Performs a shallow clone, removes .git directory, and moves to the cache path.
80
+ * Returns the destination path on success.
81
+ */
82
+ export function fetchModule(modulePath, version, cacheDir) {
83
+ const gitUrl = moduleToGitUrl(modulePath);
84
+ const destPath = getModuleCachePath(modulePath, version, cacheDir);
85
+ const dir = getCacheDir(cacheDir);
86
+ const tmpPath = join(dir, '.tmp-' + Date.now());
87
+ return new Promise((resolve, reject) => {
88
+ const proc = spawn('git', [
89
+ 'clone', '--depth', '1', '--branch', `v${version}`, gitUrl, tmpPath,
90
+ ], { stdio: ['pipe', 'pipe', 'pipe'] });
91
+ let stderr = '';
92
+ proc.stderr.on('data', d => stderr += d);
93
+ proc.on('close', code => {
94
+ if (code !== 0) {
95
+ if (existsSync(tmpPath)) rmSync(tmpPath, { recursive: true, force: true });
96
+ reject(new Error(`Failed to fetch ${modulePath}@v${version}: ${stderr.trim()}`));
97
+ return;
98
+ }
99
+ const dotGit = join(tmpPath, '.git');
100
+ if (existsSync(dotGit)) rmSync(dotGit, { recursive: true, force: true });
101
+ mkdirSync(join(destPath, '..'), { recursive: true });
102
+ renameSync(tmpPath, destPath);
103
+ resolve(destPath);
104
+ });
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Get the commit SHA for a specific version tag from a remote repository.
110
+ * Prefers the dereferenced SHA for annotated tags.
111
+ * Returns null if the version tag is not found.
112
+ */
113
+ export function getCommitSha(modulePath, version, cacheDir) {
114
+ const gitUrl = moduleToGitUrl(modulePath);
115
+ return new Promise((resolve, reject) => {
116
+ const proc = spawn('git', ['ls-remote', gitUrl, `refs/tags/v${version}^{}`, `refs/tags/v${version}`], {
117
+ stdio: ['pipe', 'pipe', 'pipe'],
118
+ });
119
+ let stdout = '';
120
+ proc.stdout.on('data', d => stdout += d);
121
+ proc.on('close', code => {
122
+ if (code !== 0) { reject(new Error('Failed to get SHA')); return; }
123
+ const tags = parseTagList(stdout);
124
+ const tag = tags.find(t => t.version === version);
125
+ resolve(tag ? tag.sha : null);
126
+ });
127
+ });
128
+ }
@@ -0,0 +1,57 @@
1
+ // src/config/lock-file.js
2
+ import { join } from 'path';
3
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
4
+ import { parseTOML } from './toml.js';
5
+
6
+ export function writeLockFile(cwd, resolvedModules, npmDeps) {
7
+ const lines = [];
8
+ lines.push('[lock]');
9
+ lines.push(`generated = "${new Date().toISOString()}"`);
10
+ lines.push('');
11
+
12
+ for (const [mod, info] of Object.entries(resolvedModules)) {
13
+ lines.push(`["${mod}"]`);
14
+ lines.push(`version = "${info.version}"`);
15
+ lines.push(`sha = "${info.sha}"`);
16
+ lines.push(`source = "${info.source}"`);
17
+ lines.push('');
18
+ }
19
+
20
+ if (npmDeps && Object.keys(npmDeps).length > 0) {
21
+ lines.push('[npm]');
22
+ for (const [name, version] of Object.entries(npmDeps)) {
23
+ lines.push(`${name} = "${version}"`);
24
+ }
25
+ lines.push('');
26
+ }
27
+
28
+ writeFileSync(join(cwd, 'tova.lock'), lines.join('\n'));
29
+ }
30
+
31
+ export function readLockFile(cwd) {
32
+ const lockPath = join(cwd, 'tova.lock');
33
+ if (!existsSync(lockPath)) return null;
34
+ const content = readFileSync(lockPath, 'utf-8');
35
+ const parsed = parseTOML(content);
36
+
37
+ const modules = {};
38
+ const npm = {};
39
+
40
+ for (const [key, value] of Object.entries(parsed)) {
41
+ if (key === 'lock') continue;
42
+ if (key === 'npm') {
43
+ Object.assign(npm, value);
44
+ continue;
45
+ }
46
+ // Module entries are quoted keys like "github.com/alice/http"
47
+ if (typeof value === 'object' && value.version) {
48
+ modules[key] = {
49
+ version: value.version,
50
+ sha: value.sha,
51
+ source: value.source,
52
+ };
53
+ }
54
+ }
55
+
56
+ return { modules, npm, generated: parsed.lock?.generated };
57
+ }
@@ -0,0 +1,58 @@
1
+ // Global cache manager for the Tova package manager.
2
+ // Manages the ~/.tova/pkg/ directory where downloaded packages are stored.
3
+ // Provides path resolution, version lookup, and cleanup utilities.
4
+
5
+ import { join } from 'path';
6
+ import { existsSync, readdirSync, rmSync } from 'fs';
7
+ import { homedir } from 'os';
8
+ import { compareSemver } from './semver.js';
9
+ import { parseModulePath } from './module-path.js';
10
+
11
+ const DEFAULT_CACHE = join(homedir(), '.tova', 'pkg');
12
+
13
+ export function getCacheDir(override) {
14
+ return override || process.env.TOVA_CACHE_DIR || DEFAULT_CACHE;
15
+ }
16
+
17
+ export function getModuleCachePath(modulePath, version, cacheDir) {
18
+ const dir = getCacheDir(cacheDir);
19
+ const parsed = typeof modulePath === 'string' ? parseModulePath(modulePath) : modulePath;
20
+ return join(dir, parsed.host, parsed.owner, parsed.repo, `v${version}`);
21
+ }
22
+
23
+ export function isVersionCached(modulePath, version, cacheDir) {
24
+ const p = getModuleCachePath(modulePath, version, cacheDir);
25
+ return existsSync(p) && existsSync(join(p, 'tova.toml'));
26
+ }
27
+
28
+ export function listCachedVersions(modulePath, cacheDir) {
29
+ const dir = getCacheDir(cacheDir);
30
+ const parsed = typeof modulePath === 'string' ? parseModulePath(modulePath) : modulePath;
31
+ const moduleDir = join(dir, parsed.host, parsed.owner, parsed.repo);
32
+ if (!existsSync(moduleDir)) return [];
33
+ const entries = readdirSync(moduleDir).filter(e => e.startsWith('v'));
34
+ const versions = entries.map(e => e.slice(1));
35
+ return versions.sort((a, b) => compareSemver(a, b));
36
+ }
37
+
38
+ export function getCompileCachePath(modulePath, version, cacheDir) {
39
+ const dir = getCacheDir(cacheDir);
40
+ return join(dir, '.cache', modulePath, `v${version}`);
41
+ }
42
+
43
+ export function cleanUnusedVersions(modulePath, keepVersions, cacheDir) {
44
+ const dir = getCacheDir(cacheDir);
45
+ const parsed = typeof modulePath === 'string' ? parseModulePath(modulePath) : modulePath;
46
+ const moduleDir = join(dir, parsed.host, parsed.owner, parsed.repo);
47
+ if (!existsSync(moduleDir)) return [];
48
+ const entries = readdirSync(moduleDir).filter(e => e.startsWith('v'));
49
+ const keepSet = new Set(keepVersions.map(v => `v${v}`));
50
+ const removed = [];
51
+ for (const entry of entries) {
52
+ if (!keepSet.has(entry)) {
53
+ rmSync(join(moduleDir, entry), { recursive: true, force: true });
54
+ removed.push(entry.slice(1));
55
+ }
56
+ }
57
+ return removed.sort((a, b) => compareSemver(a, b));
58
+ }
@@ -0,0 +1,37 @@
1
+ // src/config/module-entry.js
2
+ import { join } from 'path';
3
+ import { existsSync } from 'fs';
4
+
5
+ const ENTRY_CANDIDATES = [
6
+ 'src/lib.tova',
7
+ 'lib.tova',
8
+ 'index.tova',
9
+ 'src/index.tova',
10
+ 'src/main.tova',
11
+ 'main.tova',
12
+ ];
13
+
14
+ /**
15
+ * Finds the entry point .tova file for a module.
16
+ * Checks: explicit entry → src/lib.tova → lib.tova → index.tova
17
+ */
18
+ export function findEntryPoint(moduleDir, explicitEntry, subpath) {
19
+ const base = subpath ? join(moduleDir, subpath) : moduleDir;
20
+
21
+ if (explicitEntry) {
22
+ const p = join(base, explicitEntry);
23
+ if (existsSync(p)) return p;
24
+ throw new Error(`Explicit entry point not found: ${explicitEntry} in ${base}`);
25
+ }
26
+
27
+ for (const candidate of ENTRY_CANDIDATES) {
28
+ const p = join(base, candidate);
29
+ if (existsSync(p)) return p;
30
+ }
31
+
32
+ throw new Error(
33
+ `No entry point found in ${base}\n` +
34
+ ` Looked for: ${ENTRY_CANDIDATES.join(', ')}\n` +
35
+ ` Tip: Add an \`entry\` field to the package's tova.toml.`
36
+ );
37
+ }
@@ -0,0 +1,31 @@
1
+ // Module path utilities for Tova package management.
2
+ // Determines whether an import is a Tova module (vs npm/relative),
3
+ // parses module paths into components, and converts them to git URLs.
4
+
5
+ export function isTovModule(source) {
6
+ if (!source || source.startsWith('.') || source.startsWith('/') || source.startsWith('@') || source.includes(':')) {
7
+ return false;
8
+ }
9
+ const firstSegment = source.split('/')[0];
10
+ return firstSegment.includes('.');
11
+ }
12
+
13
+ export function parseModulePath(source) {
14
+ if (!isTovModule(source)) {
15
+ throw new Error(`Invalid Tova module path: "${source}"`);
16
+ }
17
+ const parts = source.split('/');
18
+ if (parts.length < 3) {
19
+ throw new Error(`Invalid Tova module path: "${source}" — expected at least host/owner/repo`);
20
+ }
21
+ const host = parts[0];
22
+ const owner = parts[1];
23
+ const repo = parts[2];
24
+ const subpath = parts.length > 3 ? parts.slice(3).join('/') : null;
25
+ return { host, owner, repo, subpath, full: `${host}/${owner}/${repo}` };
26
+ }
27
+
28
+ export function moduleToGitUrl(modulePath) {
29
+ const parsed = typeof modulePath === 'string' ? parseModulePath(modulePath) : modulePath;
30
+ return `https://${parsed.full}.git`;
31
+ }
@@ -0,0 +1,62 @@
1
+ // src/config/pkg-errors.js
2
+
3
+ export function formatVersionConflict(modulePath, sources) {
4
+ const lines = [`error: version conflict for ${modulePath}`, ''];
5
+ for (const s of sources) {
6
+ lines.push(` ${s.source} requires ${s.constraint}`);
7
+ }
8
+ lines.push('');
9
+ lines.push(' These constraints cannot be satisfied simultaneously.');
10
+ lines.push(' Tip: Check if either dependency has a newer version that resolves this.');
11
+ return lines.join('\n');
12
+ }
13
+
14
+ export function formatFetchError(modulePath, detail, cachedVersions = []) {
15
+ const lines = [`error: failed to fetch ${modulePath}`, '', ` ${detail}`];
16
+ if (cachedVersions.length > 0) {
17
+ lines.push('');
18
+ lines.push(` Cached versions available: ${cachedVersions.join(', ')}`);
19
+ lines.push(' Tip: Run with --offline to use cached versions only.');
20
+ }
21
+ return lines.join('\n');
22
+ }
23
+
24
+ export function formatMissingEntry(modulePath, version) {
25
+ return [
26
+ `error: no entry point found for ${modulePath}@v${version}`,
27
+ '',
28
+ ' Looked for: src/lib.tova, lib.tova, index.tova',
29
+ " Tip: The package may need an `entry` field in its tova.toml.",
30
+ ].join('\n');
31
+ }
32
+
33
+ export function formatAuthError(modulePath) {
34
+ return [
35
+ `error: authentication failed for ${modulePath}`,
36
+ '',
37
+ ' git clone returned: Permission denied (publickey)',
38
+ ' Tip: Ensure your SSH key or git credentials have access to this repo.',
39
+ ].join('\n');
40
+ }
41
+
42
+ export function formatCircularDep(chain) {
43
+ return [
44
+ 'error: circular dependency detected',
45
+ '',
46
+ ` ${chain.join(' \u2192 ')}`,
47
+ '',
48
+ ' Tova does not allow circular module dependencies.',
49
+ ].join('\n');
50
+ }
51
+
52
+ export function formatIntegrityError(modulePath, version, expectedSha, actualSha) {
53
+ return [
54
+ `error: integrity check failed for ${modulePath}@v${version}`,
55
+ '',
56
+ ` Expected SHA: ${expectedSha}`,
57
+ ` Got SHA: ${actualSha}`,
58
+ '',
59
+ ' The git tag may have been force-pushed. This could indicate tampering.',
60
+ ` Run \`tova update ${modulePath}\` to re-resolve.`,
61
+ ].join('\n');
62
+ }
@@ -73,6 +73,23 @@ function normalizeConfig(parsed, source) {
73
73
  }
74
74
  }
75
75
 
76
+ // [package] section: marks this as a publishable package
77
+ if (parsed.package) {
78
+ config.package = {
79
+ name: parsed.package.name || '',
80
+ version: parsed.package.version || '0.1.0',
81
+ description: parsed.package.description || '',
82
+ license: parsed.package.license || '',
83
+ keywords: parsed.package.keywords || [],
84
+ homepage: parsed.package.homepage || '',
85
+ exports: parsed.package.exports || null,
86
+ entry: parsed.package.entry || null,
87
+ };
88
+ config.isPackage = true;
89
+ } else {
90
+ config.isPackage = false;
91
+ }
92
+
76
93
  return config;
77
94
  }
78
95