tova 0.7.0 → 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 +192 -10
- package/package.json +2 -7
- package/src/analyzer/analyzer.js +134 -2
- package/src/analyzer/deploy-analyzer.js +44 -0
- package/src/codegen/base-codegen.js +1159 -10
- package/src/codegen/codegen.js +20 -0
- package/src/codegen/deploy-codegen.js +49 -0
- 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/lsp/server.js +482 -0
- package/src/parser/ast.js +24 -0
- 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/parser.js +21 -3
- package/src/parser/select-ast.js +39 -0
- package/src/registry/plugins/concurrency-plugin.js +32 -0
- package/src/registry/plugins/deploy-plugin.js +33 -0
- package/src/registry/register-all.js +4 -0
- package/src/stdlib/inline.js +35 -3
- package/src/stdlib/runtime-bridge.js +152 -0
- package/src/version.js +1 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Infrastructure Inference Engine for the Tova language
|
|
2
|
+
// Walks the entire program AST and produces a complete infrastructure manifest
|
|
3
|
+
// by combining explicit deploy block config with inferred requirements from
|
|
4
|
+
// server, browser, and security blocks.
|
|
5
|
+
|
|
6
|
+
import { DeployCodegen } from '../codegen/deploy-codegen.js';
|
|
7
|
+
|
|
8
|
+
const MANIFEST_DEFAULTS = {
|
|
9
|
+
name: null,
|
|
10
|
+
server: null,
|
|
11
|
+
domain: null,
|
|
12
|
+
instances: 1,
|
|
13
|
+
memory: '512mb',
|
|
14
|
+
branch: 'main',
|
|
15
|
+
health: '/healthz',
|
|
16
|
+
health_interval: 30,
|
|
17
|
+
health_timeout: 5,
|
|
18
|
+
restart_on_failure: true,
|
|
19
|
+
keep_releases: 5,
|
|
20
|
+
env: {},
|
|
21
|
+
databases: [],
|
|
22
|
+
requires: { bun: false, caddy: false, ufw: false },
|
|
23
|
+
hasWebSocket: false,
|
|
24
|
+
hasSSE: false,
|
|
25
|
+
hasBrowser: false,
|
|
26
|
+
requiredSecrets: [],
|
|
27
|
+
blockTypes: [],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Walk an AST node tree recursively and invoke a visitor callback on each node.
|
|
32
|
+
*/
|
|
33
|
+
function walkNode(node, visitor) {
|
|
34
|
+
if (!node || typeof node !== 'object') return;
|
|
35
|
+
if (node.type) visitor(node);
|
|
36
|
+
|
|
37
|
+
// Walk arrays (e.g., body, entries, arguments)
|
|
38
|
+
for (const key of Object.keys(node)) {
|
|
39
|
+
const val = node[key];
|
|
40
|
+
if (Array.isArray(val)) {
|
|
41
|
+
for (const item of val) {
|
|
42
|
+
walkNode(item, visitor);
|
|
43
|
+
}
|
|
44
|
+
} else if (val && typeof val === 'object' && val.type) {
|
|
45
|
+
walkNode(val, visitor);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Collect all env() call arguments from a node tree.
|
|
52
|
+
* env("JWT_SECRET") => CallExpression with callee.name === "env" and args[0].value
|
|
53
|
+
*/
|
|
54
|
+
function collectEnvCalls(node) {
|
|
55
|
+
const secrets = [];
|
|
56
|
+
walkNode(node, (n) => {
|
|
57
|
+
if (
|
|
58
|
+
n.type === 'CallExpression' &&
|
|
59
|
+
n.callee &&
|
|
60
|
+
n.callee.type === 'Identifier' &&
|
|
61
|
+
n.callee.name === 'env' &&
|
|
62
|
+
n.arguments &&
|
|
63
|
+
n.arguments.length > 0
|
|
64
|
+
) {
|
|
65
|
+
const arg = n.arguments[0];
|
|
66
|
+
if (arg && (arg.value !== undefined)) {
|
|
67
|
+
secrets.push(typeof arg.value === 'string' ? arg.value : String(arg.value));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return secrets;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Infer infrastructure requirements from the full program AST.
|
|
76
|
+
*
|
|
77
|
+
* @param {Object} ast - Program AST with ast.body array of top-level blocks
|
|
78
|
+
* @returns {Object} Complete infrastructure manifest
|
|
79
|
+
*/
|
|
80
|
+
export function inferInfrastructure(ast) {
|
|
81
|
+
const manifest = JSON.parse(JSON.stringify(MANIFEST_DEFAULTS));
|
|
82
|
+
const blockTypes = new Set();
|
|
83
|
+
const deployBlocks = [];
|
|
84
|
+
const inferredDatabases = [];
|
|
85
|
+
const secretsSet = new Set();
|
|
86
|
+
|
|
87
|
+
if (!ast || !ast.body) return manifest;
|
|
88
|
+
|
|
89
|
+
for (const node of ast.body) {
|
|
90
|
+
switch (node.type) {
|
|
91
|
+
case 'DeployBlock': {
|
|
92
|
+
blockTypes.add('deploy');
|
|
93
|
+
deployBlocks.push(node);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
case 'ServerBlock': {
|
|
98
|
+
blockTypes.add('server');
|
|
99
|
+
manifest.requires.bun = true;
|
|
100
|
+
manifest.requires.caddy = true;
|
|
101
|
+
manifest.requires.ufw = true;
|
|
102
|
+
|
|
103
|
+
// Scan server block body for specific declarations
|
|
104
|
+
if (node.body && Array.isArray(node.body)) {
|
|
105
|
+
for (const stmt of node.body) {
|
|
106
|
+
if (stmt.type === 'DbDeclaration') {
|
|
107
|
+
// Server-block db is always SQLite (bun:sqlite)
|
|
108
|
+
const dbConfig = {};
|
|
109
|
+
if (stmt.config && typeof stmt.config === 'object') {
|
|
110
|
+
for (const [k, v] of Object.entries(stmt.config)) {
|
|
111
|
+
dbConfig[k] = v && v.value !== undefined ? v.value : v;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
inferredDatabases.push({ engine: 'sqlite', config: dbConfig });
|
|
115
|
+
}
|
|
116
|
+
if (stmt.type === 'WebSocketDeclaration') {
|
|
117
|
+
manifest.hasWebSocket = true;
|
|
118
|
+
}
|
|
119
|
+
if (stmt.type === 'SseDeclaration') {
|
|
120
|
+
manifest.hasSSE = true;
|
|
121
|
+
}
|
|
122
|
+
// Also check inside route groups
|
|
123
|
+
if (stmt.type === 'RouteGroupDeclaration' && stmt.body) {
|
|
124
|
+
for (const inner of stmt.body) {
|
|
125
|
+
if (inner.type === 'WebSocketDeclaration') {
|
|
126
|
+
manifest.hasWebSocket = true;
|
|
127
|
+
}
|
|
128
|
+
if (inner.type === 'SseDeclaration') {
|
|
129
|
+
manifest.hasSSE = true;
|
|
130
|
+
}
|
|
131
|
+
if (inner.type === 'DbDeclaration') {
|
|
132
|
+
const dbConfig = {};
|
|
133
|
+
if (inner.config && typeof inner.config === 'object') {
|
|
134
|
+
for (const [k, v] of Object.entries(inner.config)) {
|
|
135
|
+
dbConfig[k] = v && v.value !== undefined ? v.value : v;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
inferredDatabases.push({ engine: 'sqlite', config: dbConfig });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'BrowserBlock': {
|
|
148
|
+
blockTypes.add('browser');
|
|
149
|
+
manifest.hasBrowser = true;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'SecurityBlock': {
|
|
154
|
+
blockTypes.add('security');
|
|
155
|
+
// Scan for env() calls to find required secrets
|
|
156
|
+
if (node.body && Array.isArray(node.body)) {
|
|
157
|
+
for (const stmt of node.body) {
|
|
158
|
+
if (stmt.type === 'SecurityAuthDeclaration') {
|
|
159
|
+
// Walk the config looking for env() calls
|
|
160
|
+
if (stmt.config && typeof stmt.config === 'object') {
|
|
161
|
+
for (const [, value] of Object.entries(stmt.config)) {
|
|
162
|
+
const secrets = collectEnvCalls(value);
|
|
163
|
+
for (const s of secrets) secretsSet.add(s);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Merge explicit deploy config via DeployCodegen
|
|
175
|
+
if (deployBlocks.length > 0) {
|
|
176
|
+
const deployConfig = DeployCodegen.mergeDeployBlocks(deployBlocks);
|
|
177
|
+
// Apply deploy config fields to manifest
|
|
178
|
+
if (deployConfig.name) manifest.name = deployConfig.name;
|
|
179
|
+
if (deployConfig.server) manifest.server = deployConfig.server;
|
|
180
|
+
if (deployConfig.domain) manifest.domain = deployConfig.domain;
|
|
181
|
+
if (deployConfig.instances !== undefined) manifest.instances = deployConfig.instances;
|
|
182
|
+
if (deployConfig.memory) manifest.memory = deployConfig.memory;
|
|
183
|
+
if (deployConfig.branch) manifest.branch = deployConfig.branch;
|
|
184
|
+
if (deployConfig.health) manifest.health = deployConfig.health;
|
|
185
|
+
if (deployConfig.health_interval !== undefined) manifest.health_interval = deployConfig.health_interval;
|
|
186
|
+
if (deployConfig.health_timeout !== undefined) manifest.health_timeout = deployConfig.health_timeout;
|
|
187
|
+
if (deployConfig.restart_on_failure !== undefined) manifest.restart_on_failure = deployConfig.restart_on_failure;
|
|
188
|
+
if (deployConfig.keep_releases !== undefined) manifest.keep_releases = deployConfig.keep_releases;
|
|
189
|
+
if (deployConfig.env && Object.keys(deployConfig.env).length > 0) {
|
|
190
|
+
manifest.env = { ...manifest.env, ...deployConfig.env };
|
|
191
|
+
}
|
|
192
|
+
// Declared databases from deploy block
|
|
193
|
+
if (deployConfig.databases && deployConfig.databases.length > 0) {
|
|
194
|
+
manifest.databases = [...deployConfig.databases];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Merge inferred databases with declared ones (avoid duplicates by engine name)
|
|
199
|
+
const declaredEngines = new Set(manifest.databases.map(d => d.engine));
|
|
200
|
+
for (const inferred of inferredDatabases) {
|
|
201
|
+
if (!declaredEngines.has(inferred.engine)) {
|
|
202
|
+
manifest.databases.push(inferred);
|
|
203
|
+
declaredEngines.add(inferred.engine);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// If server block is present, ensure bun/caddy/ufw are required
|
|
208
|
+
if (blockTypes.has('server')) {
|
|
209
|
+
manifest.requires.bun = true;
|
|
210
|
+
manifest.requires.caddy = true;
|
|
211
|
+
manifest.requires.ufw = true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
manifest.requiredSecrets = [...secretsSet].sort();
|
|
215
|
+
manifest.blockTypes = [...blockTypes].sort();
|
|
216
|
+
|
|
217
|
+
return manifest;
|
|
218
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Provisioning Script Generator for the Tova language
|
|
2
|
+
// Generates idempotent bash scripts from an infrastructure manifest.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a complete provisioning shell script from an infrastructure manifest.
|
|
6
|
+
*
|
|
7
|
+
* The script is idempotent — it checks before installing (command -v, dpkg, etc.)
|
|
8
|
+
* and is organized in layers:
|
|
9
|
+
* Layer 1: System (Bun, Caddy, UFW, tova user)
|
|
10
|
+
* Layer 2: Databases (PostgreSQL, Redis — conditional)
|
|
11
|
+
* Layer 3: App directories
|
|
12
|
+
* Layer 5: Caddy config
|
|
13
|
+
* Layer 6: systemd services
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} manifest - Infrastructure manifest from inferInfrastructure()
|
|
16
|
+
* @returns {string} Complete provisioning bash script
|
|
17
|
+
*/
|
|
18
|
+
export function generateProvisionScript(manifest) {
|
|
19
|
+
const appName = manifest.name || 'tova-app';
|
|
20
|
+
const lines = [];
|
|
21
|
+
|
|
22
|
+
lines.push('#!/bin/bash');
|
|
23
|
+
lines.push('set -euo pipefail');
|
|
24
|
+
lines.push('');
|
|
25
|
+
lines.push(`# Provisioning script for ${appName}`);
|
|
26
|
+
lines.push(`# Generated by Tova deploy — idempotent, safe to re-run`);
|
|
27
|
+
lines.push('');
|
|
28
|
+
|
|
29
|
+
// ── Layer 1: System ────────────────────────────────────────
|
|
30
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
31
|
+
lines.push('# Layer 1: System dependencies');
|
|
32
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
33
|
+
lines.push('');
|
|
34
|
+
|
|
35
|
+
if (manifest.requires && manifest.requires.bun) {
|
|
36
|
+
lines.push('# Install Bun runtime');
|
|
37
|
+
lines.push('if ! command -v bun &>/dev/null; then');
|
|
38
|
+
lines.push(' echo "Installing Bun..."');
|
|
39
|
+
lines.push(' curl -fsSL https://bun.sh/install | bash');
|
|
40
|
+
lines.push(' export PATH="$HOME/.bun/bin:$PATH"');
|
|
41
|
+
lines.push('fi');
|
|
42
|
+
lines.push('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (manifest.requires && manifest.requires.caddy) {
|
|
46
|
+
lines.push('# Install Caddy web server');
|
|
47
|
+
lines.push('if ! command -v caddy &>/dev/null; then');
|
|
48
|
+
lines.push(' echo "Installing Caddy..."');
|
|
49
|
+
lines.push(' apt-get update -qq');
|
|
50
|
+
lines.push(' apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl');
|
|
51
|
+
lines.push(' curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg');
|
|
52
|
+
lines.push(' curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | tee /etc/apt/sources.list.d/caddy-stable.list');
|
|
53
|
+
lines.push(' apt-get update -qq');
|
|
54
|
+
lines.push(' apt-get install -y -qq caddy');
|
|
55
|
+
lines.push('fi');
|
|
56
|
+
lines.push('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (manifest.requires && manifest.requires.ufw) {
|
|
60
|
+
lines.push('# Configure UFW firewall');
|
|
61
|
+
lines.push('if command -v ufw &>/dev/null; then');
|
|
62
|
+
lines.push(' ufw allow 22/tcp');
|
|
63
|
+
lines.push(' ufw allow 80/tcp');
|
|
64
|
+
lines.push(' ufw allow 443/tcp');
|
|
65
|
+
lines.push(' echo "y" | ufw enable || true');
|
|
66
|
+
lines.push('fi');
|
|
67
|
+
lines.push('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Create tova system user
|
|
71
|
+
lines.push('# Create tova system user');
|
|
72
|
+
lines.push('if ! id "tova" &>/dev/null; then');
|
|
73
|
+
lines.push(' useradd --system --create-home --shell /bin/bash tova');
|
|
74
|
+
lines.push('fi');
|
|
75
|
+
lines.push('');
|
|
76
|
+
|
|
77
|
+
// ── Layer 2: Databases ─────────────────────────────────────
|
|
78
|
+
const databases = manifest.databases || [];
|
|
79
|
+
const hasPostgres = databases.some(d => d.engine === 'postgres');
|
|
80
|
+
const hasRedis = databases.some(d => d.engine === 'redis');
|
|
81
|
+
|
|
82
|
+
if (hasPostgres || hasRedis) {
|
|
83
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
84
|
+
lines.push('# Layer 2: Databases');
|
|
85
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
86
|
+
lines.push('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (hasPostgres) {
|
|
90
|
+
const pgDb = databases.find(d => d.engine === 'postgres');
|
|
91
|
+
const dbName = (pgDb && pgDb.config && pgDb.config.name) || `${appName}_db`;
|
|
92
|
+
lines.push('# Install PostgreSQL');
|
|
93
|
+
lines.push('if ! command -v psql &>/dev/null; then');
|
|
94
|
+
lines.push(' echo "Installing PostgreSQL..."');
|
|
95
|
+
lines.push(' apt-get update -qq');
|
|
96
|
+
lines.push(' apt-get install -y -qq postgresql postgresql-contrib');
|
|
97
|
+
lines.push(' systemctl enable postgresql');
|
|
98
|
+
lines.push(' systemctl start postgresql');
|
|
99
|
+
lines.push('fi');
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push(`# Create database: ${dbName}`);
|
|
102
|
+
lines.push(`sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname = '${dbName}'" | grep -q 1 || sudo -u postgres createdb "${dbName}"`);
|
|
103
|
+
lines.push('');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (hasRedis) {
|
|
107
|
+
lines.push('# Install Redis');
|
|
108
|
+
lines.push('if ! command -v redis-server &>/dev/null; then');
|
|
109
|
+
lines.push(' echo "Installing Redis..."');
|
|
110
|
+
lines.push(' apt-get update -qq');
|
|
111
|
+
lines.push(' apt-get install -y -qq redis-server');
|
|
112
|
+
lines.push(' systemctl enable redis-server');
|
|
113
|
+
lines.push(' systemctl start redis-server');
|
|
114
|
+
lines.push('fi');
|
|
115
|
+
lines.push('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Layer 3: App directories ───────────────────────────────
|
|
119
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
120
|
+
lines.push('# Layer 3: App directories');
|
|
121
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('mkdir -p /opt/tova/apps');
|
|
124
|
+
lines.push(`APP_DIR="/opt/tova/apps/${appName}"`);
|
|
125
|
+
lines.push('mkdir -p "$APP_DIR/releases"');
|
|
126
|
+
lines.push('mkdir -p "$APP_DIR/shared/logs"');
|
|
127
|
+
lines.push('mkdir -p "$APP_DIR/shared/data"');
|
|
128
|
+
lines.push('chown -R tova:tova /opt/tova');
|
|
129
|
+
lines.push('');
|
|
130
|
+
|
|
131
|
+
// ── Layer 5: Caddy config ──────────────────────────────────
|
|
132
|
+
if (manifest.requires && manifest.requires.caddy && manifest.domain) {
|
|
133
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
134
|
+
lines.push('# Layer 5: Caddy config');
|
|
135
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
136
|
+
lines.push('');
|
|
137
|
+
const caddyConfig = generateCaddyConfig(appName, {
|
|
138
|
+
domain: manifest.domain,
|
|
139
|
+
instances: manifest.instances || 1,
|
|
140
|
+
health: manifest.health,
|
|
141
|
+
hasWebSocket: manifest.hasWebSocket,
|
|
142
|
+
});
|
|
143
|
+
lines.push(`cat > /etc/caddy/Caddyfile <<'CADDY_EOF'`);
|
|
144
|
+
lines.push(caddyConfig);
|
|
145
|
+
lines.push('CADDY_EOF');
|
|
146
|
+
lines.push('');
|
|
147
|
+
lines.push('systemctl reload caddy || systemctl restart caddy');
|
|
148
|
+
lines.push('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Layer 6: systemd services ──────────────────────────────
|
|
152
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
153
|
+
lines.push('# Layer 6: systemd services');
|
|
154
|
+
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
155
|
+
lines.push('');
|
|
156
|
+
const serviceUnit = generateSystemdService(appName, {
|
|
157
|
+
memory: manifest.memory || '512mb',
|
|
158
|
+
restart_on_failure: manifest.restart_on_failure !== false,
|
|
159
|
+
env: manifest.env || {},
|
|
160
|
+
});
|
|
161
|
+
lines.push(`cat > /etc/systemd/system/${appName}@.service <<'SYSTEMD_EOF'`);
|
|
162
|
+
lines.push(serviceUnit);
|
|
163
|
+
lines.push('SYSTEMD_EOF');
|
|
164
|
+
lines.push('');
|
|
165
|
+
lines.push('systemctl daemon-reload');
|
|
166
|
+
|
|
167
|
+
// Enable and start instances
|
|
168
|
+
const instances = manifest.instances || 1;
|
|
169
|
+
for (let i = 0; i < instances; i++) {
|
|
170
|
+
const port = 3000 + i;
|
|
171
|
+
lines.push(`systemctl enable ${appName}@${port}`);
|
|
172
|
+
}
|
|
173
|
+
lines.push('');
|
|
174
|
+
|
|
175
|
+
lines.push(`echo "Provisioning complete for ${appName}"`);
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Parse a memory string like "512mb", "1gb" into bytes for systemd MemoryMax.
|
|
181
|
+
*/
|
|
182
|
+
function parseMemory(mem) {
|
|
183
|
+
if (typeof mem === 'number') return mem;
|
|
184
|
+
const str = String(mem).toLowerCase().trim();
|
|
185
|
+
const match = str.match(/^(\d+(?:\.\d+)?)\s*(mb|gb|m|g|kb|k)?$/);
|
|
186
|
+
if (!match) return str;
|
|
187
|
+
const num = parseFloat(match[1]);
|
|
188
|
+
const unit = match[2] || 'mb';
|
|
189
|
+
switch (unit) {
|
|
190
|
+
case 'kb': case 'k': return `${Math.round(num)}K`;
|
|
191
|
+
case 'mb': case 'm': return `${Math.round(num)}M`;
|
|
192
|
+
case 'gb': case 'g': return `${Math.round(num * 1024)}M`;
|
|
193
|
+
default: return str;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Generate a systemd unit template for the application.
|
|
199
|
+
*
|
|
200
|
+
* Uses %i for the instance port (template unit: appName@.service).
|
|
201
|
+
*
|
|
202
|
+
* @param {string} appName - Application name
|
|
203
|
+
* @param {Object} config - { memory, restart_on_failure, env }
|
|
204
|
+
* @returns {string} systemd unit file content
|
|
205
|
+
*/
|
|
206
|
+
export function generateSystemdService(appName, config = {}) {
|
|
207
|
+
const memLimit = parseMemory(config.memory || '512mb');
|
|
208
|
+
const restart = config.restart_on_failure !== false ? 'on-failure' : 'no';
|
|
209
|
+
const env = config.env || {};
|
|
210
|
+
|
|
211
|
+
const lines = [];
|
|
212
|
+
lines.push('[Unit]');
|
|
213
|
+
lines.push(`Description=${appName} instance on port %i`);
|
|
214
|
+
lines.push('After=network.target');
|
|
215
|
+
lines.push('');
|
|
216
|
+
lines.push('[Service]');
|
|
217
|
+
lines.push('Type=simple');
|
|
218
|
+
lines.push('User=tova');
|
|
219
|
+
lines.push('Group=tova');
|
|
220
|
+
lines.push(`WorkingDirectory=/opt/tova/apps/${appName}/current`);
|
|
221
|
+
lines.push(`ExecStart=/home/tova/.bun/bin/bun run server.js --port %i`);
|
|
222
|
+
lines.push(`Restart=${restart}`);
|
|
223
|
+
lines.push('RestartSec=5');
|
|
224
|
+
lines.push(`MemoryMax=${memLimit}`);
|
|
225
|
+
lines.push('');
|
|
226
|
+
|
|
227
|
+
// Environment — load secrets from .env.production, then set inline defaults
|
|
228
|
+
lines.push(`EnvironmentFile=-/opt/tova/apps/${appName}/.env.production`);
|
|
229
|
+
lines.push('Environment=NODE_ENV=production');
|
|
230
|
+
lines.push('Environment=PORT=%i');
|
|
231
|
+
for (const [key, value] of Object.entries(env)) {
|
|
232
|
+
if (key !== 'NODE_ENV' && key !== 'PORT') {
|
|
233
|
+
lines.push(`Environment=${key}=${value}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
lines.push('');
|
|
237
|
+
|
|
238
|
+
// Logging
|
|
239
|
+
lines.push('StandardOutput=journal');
|
|
240
|
+
lines.push('StandardError=journal');
|
|
241
|
+
lines.push(`SyslogIdentifier=${appName}-%i`);
|
|
242
|
+
lines.push('');
|
|
243
|
+
lines.push('[Install]');
|
|
244
|
+
lines.push('WantedBy=multi-user.target');
|
|
245
|
+
|
|
246
|
+
return lines.join('\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Generate a Caddy site block configuration.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} appName - Application name
|
|
253
|
+
* @param {Object} opts - { domain, instances, health, hasWebSocket }
|
|
254
|
+
* @returns {string} Caddy config block
|
|
255
|
+
*/
|
|
256
|
+
export function generateCaddyConfig(appName, opts = {}) {
|
|
257
|
+
const domain = opts.domain || 'localhost';
|
|
258
|
+
const instances = opts.instances || 1;
|
|
259
|
+
const health = opts.health || '/healthz';
|
|
260
|
+
const hasWebSocket = opts.hasWebSocket || false;
|
|
261
|
+
|
|
262
|
+
const lines = [];
|
|
263
|
+
lines.push(`${domain} {`);
|
|
264
|
+
|
|
265
|
+
// Upstream / reverse proxy
|
|
266
|
+
if (instances === 1) {
|
|
267
|
+
lines.push(' reverse_proxy localhost:3000 {');
|
|
268
|
+
} else {
|
|
269
|
+
// Multiple instances with round-robin load balancing
|
|
270
|
+
const upstreams = [];
|
|
271
|
+
for (let i = 0; i < instances; i++) {
|
|
272
|
+
upstreams.push(`localhost:${3000 + i}`);
|
|
273
|
+
}
|
|
274
|
+
lines.push(` reverse_proxy ${upstreams.join(' ')} {`);
|
|
275
|
+
lines.push(' lb_policy round_robin');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Health check
|
|
279
|
+
lines.push(` health_uri ${health}`);
|
|
280
|
+
lines.push(' health_interval 30s');
|
|
281
|
+
lines.push(' health_timeout 5s');
|
|
282
|
+
|
|
283
|
+
lines.push(' }');
|
|
284
|
+
|
|
285
|
+
// WebSocket support
|
|
286
|
+
if (hasWebSocket) {
|
|
287
|
+
lines.push('');
|
|
288
|
+
lines.push(' @websocket {');
|
|
289
|
+
lines.push(' header Connection *Upgrade*');
|
|
290
|
+
lines.push(' header Upgrade websocket');
|
|
291
|
+
lines.push(' }');
|
|
292
|
+
if (instances === 1) {
|
|
293
|
+
lines.push(' reverse_proxy @websocket localhost:3000');
|
|
294
|
+
} else {
|
|
295
|
+
const upstreams = [];
|
|
296
|
+
for (let i = 0; i < instances; i++) {
|
|
297
|
+
upstreams.push(`localhost:${3000 + i}`);
|
|
298
|
+
}
|
|
299
|
+
lines.push(` reverse_proxy @websocket ${upstreams.join(' ')}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Logging
|
|
304
|
+
lines.push('');
|
|
305
|
+
lines.push(' log {');
|
|
306
|
+
lines.push(` output file /var/log/caddy/${appName}.log`);
|
|
307
|
+
lines.push(' }');
|
|
308
|
+
|
|
309
|
+
lines.push('}');
|
|
310
|
+
return lines.join('\n');
|
|
311
|
+
}
|