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.
- package/bin/tova.js +261 -60
- package/package.json +1 -1
- package/src/analyzer/analyzer.js +351 -11
- package/src/analyzer/{client-analyzer.js → browser-analyzer.js} +20 -17
- package/src/analyzer/deploy-analyzer.js +44 -0
- package/src/analyzer/form-analyzer.js +113 -0
- package/src/analyzer/scope.js +2 -2
- package/src/codegen/base-codegen.js +1160 -10
- package/src/codegen/{client-codegen.js → browser-codegen.js} +444 -5
- package/src/codegen/codegen.js +119 -28
- package/src/codegen/deploy-codegen.js +49 -0
- package/src/codegen/edge-codegen.js +1351 -0
- package/src/codegen/form-codegen.js +553 -0
- package/src/codegen/security-codegen.js +5 -5
- package/src/codegen/server-codegen.js +88 -7
- package/src/codegen/shared-codegen.js +5 -0
- package/src/codegen/wasm-codegen.js +6 -0
- package/src/config/edit-toml.js +6 -2
- package/src/config/git-resolver.js +128 -0
- package/src/config/lock-file.js +57 -0
- package/src/config/module-cache.js +58 -0
- package/src/config/module-entry.js +37 -0
- package/src/config/module-path.js +31 -0
- package/src/config/pkg-errors.js +62 -0
- package/src/config/resolve.js +17 -0
- package/src/config/resolver.js +139 -0
- package/src/config/search.js +28 -0
- package/src/config/semver.js +72 -0
- package/src/config/toml.js +48 -5
- package/src/deploy/deploy.js +217 -0
- package/src/deploy/infer.js +218 -0
- package/src/deploy/provision.js +311 -0
- package/src/diagnostics/error-codes.js +1 -1
- package/src/docs/generator.js +1 -1
- package/src/formatter/formatter.js +4 -4
- package/src/lexer/tokens.js +12 -2
- package/src/lsp/server.js +483 -1
- package/src/parser/ast.js +60 -5
- package/src/parser/{client-ast.js → browser-ast.js} +3 -3
- package/src/parser/{client-parser.js → browser-parser.js} +42 -15
- package/src/parser/concurrency-ast.js +15 -0
- package/src/parser/concurrency-parser.js +236 -0
- package/src/parser/deploy-ast.js +37 -0
- package/src/parser/deploy-parser.js +132 -0
- package/src/parser/edge-ast.js +83 -0
- package/src/parser/edge-parser.js +262 -0
- package/src/parser/form-ast.js +80 -0
- package/src/parser/form-parser.js +206 -0
- package/src/parser/parser.js +82 -14
- package/src/parser/select-ast.js +39 -0
- package/src/registry/plugins/browser-plugin.js +30 -0
- package/src/registry/plugins/concurrency-plugin.js +32 -0
- package/src/registry/plugins/deploy-plugin.js +33 -0
- package/src/registry/plugins/edge-plugin.js +32 -0
- package/src/registry/register-all.js +8 -2
- package/src/runtime/ssr.js +2 -2
- package/src/stdlib/inline.js +38 -6
- package/src/stdlib/runtime-bridge.js +152 -0
- package/src/version.js +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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(',');
|
package/src/config/edit-toml.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/config/resolve.js
CHANGED
|
@@ -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
|
|