tovuk 0.1.48 → 0.1.49

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tovuk",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "description": "Deploy Rust backends, static frontends, and fullstack apps to Tovuk.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- import { PROJECT_KINDS } from './constants.ts'
1
+ import { JAVASCRIPT_BACKEND_RUNTIMES, PROJECT_KINDS } from './constants.ts'
2
2
  import { commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun, usesJavascriptLinter } from './frontend-policy.ts'
3
3
  import { isSafeRelativePath } from './project.ts'
4
4
  import type { ProjectKind, TovukConfig } from './types.ts'
@@ -34,6 +34,9 @@ function validateBuildConfig(config: TovukConfig): void {
34
34
  throw new Error('[build].check is required')
35
35
  }
36
36
  validateCheckCommand(config.kind, config.build.check)
37
+ if (config.kind === 'rust_backend') {
38
+ validateRustBuildCommand(config.build.command)
39
+ }
37
40
  }
38
41
 
39
42
  function validateStaticFrontendConfig(config: TovukConfig): void {
@@ -45,6 +48,7 @@ function validateRustBackendConfig(config: TovukConfig): void {
45
48
  throw new Error('[build].output is only valid for static_frontend')
46
49
  }
47
50
  requireCommand(config.run.command, '[run].command')
51
+ validateRustRunCommand(config.run.command)
48
52
  validatePort(config.run.port, '[run].port')
49
53
  validateHealth(config.run.health, '[run].health')
50
54
  validateResourceConfig(config)
@@ -62,8 +66,8 @@ function validateFullstackConfig(config: TovukConfig): void {
62
66
 
63
67
  function validateFullstackSections(config: TovukConfig): void {
64
68
  validateRustCheckCommand(requireCommand(config.backend.check, '[backend].check'))
65
- requireCommand(config.backend.build, '[backend].build')
66
- requireCommand(config.backend.command, '[backend].command')
69
+ validateRustBuildCommand(requireCommand(config.backend.build, '[backend].build'))
70
+ validateRustRunCommand(requireCommand(config.backend.command, '[backend].command'))
67
71
  validatePort(config.backend.port, '[backend].port')
68
72
  validateHealth(config.backend.health, '[backend].health')
69
73
  validateFrontendCheckCommand(requireCommand(config.frontend.check, '[frontend].check'))
@@ -129,6 +133,28 @@ function validateRustCheckCommand(command: string): void {
129
133
  throw new Error('[build].check must include cargo fmt --all --check, cargo check --locked, and cargo clippy --locked --all-targets --all-features -- -D warnings')
130
134
  }
131
135
 
136
+ function validateRustBuildCommand(command: string): void {
137
+ if (usesJavascriptBackendRuntime(command)) {
138
+ throw new Error('Rust backend build commands cannot invoke JavaScript or TypeScript runtimes; use cargo build --release')
139
+ }
140
+ const tokens = commandTokens(command)
141
+ if (tokens.some((token) => commandNameFromToken(token) === 'cargo') && tokens.includes('build') && tokens.includes('--release')) {
142
+ return
143
+ }
144
+ throw new Error('Rust backend build commands must run cargo build --release')
145
+ }
146
+
147
+ function validateRustRunCommand(command: string | undefined): void {
148
+ const value = command ?? ''
149
+ if (usesJavascriptBackendRuntime(value)) {
150
+ throw new Error('Rust backend runtime commands cannot invoke JavaScript or TypeScript runtimes; run ./target/release/<binary> instead')
151
+ }
152
+ if (commandTokens(value).some((token) => token.includes('target/release/'))) {
153
+ return
154
+ }
155
+ throw new Error('Rust backend runtime commands must start a binary under ./target/release/')
156
+ }
157
+
132
158
  function validateFrontendCheckCommand(command: string): void {
133
159
  if (isNoopCommand(command)) {
134
160
  return
@@ -147,6 +173,11 @@ function validateFrontendCheckCommand(command: string): void {
147
173
  throw new Error('[build].check must install dependencies and run package scripts, for example `bun ci && bun run typecheck && bun run lint` or `npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint`')
148
174
  }
149
175
 
176
+ function usesJavascriptBackendRuntime(command: string): boolean {
177
+ return commandTokens(command)
178
+ .some((token) => JAVASCRIPT_BACKEND_RUNTIMES.has(commandNameFromToken(token)))
179
+ }
180
+
150
181
  function isSafeRelativeDirectory(value: string): boolean {
151
182
  return value === '.' || isSafeRelativePath(value)
152
183
  }
@@ -155,4 +186,8 @@ function isNoopCommand(command: string): boolean {
155
186
  return command.trim() === ':' || command.trim() === 'true'
156
187
  }
157
188
 
189
+ function commandNameFromToken(token: string): string {
190
+ return token.split('/').pop() ?? ''
191
+ }
192
+
158
193
  export { validateConfig }
@@ -21,6 +21,7 @@ export const DEFAULT_BUN_FRONTEND_CHECK_COMMAND: string = 'bun ci && bun run typ
21
21
  export const PROJECT_KINDS: ReadonlySet<string> = new Set(['fullstack', 'rust_backend', 'static_frontend'])
22
22
  export const PROJECT_TEMPLATES: ReadonlySet<string> = new Set(['rust-api', 'tanstack-static-frontend', 'fullstack-rust-tanstack'])
23
23
  export const JAVASCRIPT_LINTERS: ReadonlySet<string> = new Set(['eslint', 'eslint_d', 'jscs', 'jshint', 'prettier', 'prettierd', 'standard', 'xo'])
24
+ export const JAVASCRIPT_BACKEND_RUNTIMES: ReadonlySet<string> = new Set(['astro', 'bun', 'deno', 'next', 'node', 'npm', 'npx', 'pnpm', 'svelte-kit', 'tsx', 'ts-node', 'vite', 'yarn'])
24
25
  export const FRONTEND_SOURCE_ROOTS: ReadonlySet<string> = new Set(['src', 'app', 'pages', 'routes', 'components'])
25
26
  export const FRONTEND_JAVASCRIPT_EXTENSIONS: readonly string[] = ['.js', '.jsx', '.mjs', '.cjs']
26
27
  export const FRONTEND_PACKAGE_MANAGERS: ReadonlySet<string> = new Set(['npm', 'bun', 'pnpm', 'yarn'])
@@ -129,6 +130,7 @@ Agent contract:
129
130
  - Rust backends keep Cargo.lock committed, pass rustfmt, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
130
131
  - Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, stable native typecheck, native lint, and Fallow quality gates.
131
132
  - Plain static HTML/CSS/JS frontends may use kind = "static_frontend" with check = ":", command = ":", and output = ".".
133
+ - JavaScript and TypeScript are frontend-only on Tovuk; backend build and runtime commands must be Cargo release builds and Rust release binaries.
132
134
  - Frontends call Rust backends for APIs, managed Postgres, and server-side logic.
133
135
  - Run deploy from a fullstack repo root with one tovuk.toml to build backend and frontend together.
134
136
  - When split frontend and backend apps use different hostnames, configure backend CORS or use a same-origin custom domain.
@@ -4,7 +4,7 @@ import { doctorCheck } from './checks.ts'
4
4
  import { agentError } from './errors.ts'
5
5
  import { parseTovukToml, validateConfig } from './config.ts'
6
6
  import { discoverDeployProjects } from './workspace.ts'
7
- import { printJson } from './project.ts'
7
+ import { printJson, walkProjectFiles } from './project.ts'
8
8
  import { rustDoctorChecks, unsafeCheck } from './rust-doctor.ts'
9
9
  import { frontendLockfileExists, frontendScriptChecks, frontendSourceChecks, isPlainStaticFrontend } from './frontend-policy.ts'
10
10
  import type { DoctorCheck, DoctorReport, WorkspaceDoctorReport, TovukConfig } from './types.ts'
@@ -67,6 +67,7 @@ function runDoctor(projectDir: string): DoctorReport {
67
67
  if (kind === 'static_frontend') {
68
68
  checks.push(...staticFrontendChecks(projectDir, configResult.valid))
69
69
  } else {
70
+ checks.push(backendJavascriptSourceCheck(projectDir))
70
71
  checks.push(...rustDoctorChecks(projectDir, configResult.valid))
71
72
  }
72
73
 
@@ -151,6 +152,7 @@ function fullstackChecks(projectDir: string, config: TovukConfig, configValid: b
151
152
  const frontendDir = path.join(projectDir, frontendRoot)
152
153
  return [
153
154
  ...requiredFilesAt(backendDir, backendRoot, ['Cargo.toml', 'Cargo.lock']),
155
+ backendJavascriptSourceCheck(backendDir, backendRoot),
154
156
  ...rustDoctorChecks(backendDir, configValid),
155
157
  ...requiredFilesAt(frontendDir, frontendRoot, isPlainStaticFrontend(frontendDir) ? ['index.html'] : ['package.json']),
156
158
  ...staticFrontendChecks(frontendDir, configValid)
@@ -181,6 +183,29 @@ function frontendLockfileCheck(projectDir: string): DoctorCheck {
181
183
  return doctorCheck('frontend lockfile', ok, 'found', 'missing', 'Commit package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, or bun.lockb, then retry.')
182
184
  }
183
185
 
186
+ function backendJavascriptSourceCheck(projectDir: string, label = ''): DoctorCheck {
187
+ const matches: string[] = []
188
+ walkProjectFiles(projectDir, (_file, relative) => {
189
+ if (isBackendJavascriptOrTypescriptSource(relative)) {
190
+ matches.push(label ? `${label}/${relative}` : relative)
191
+ }
192
+ })
193
+ return doctorCheck(
194
+ 'rust backend js/ts server source',
195
+ matches.length === 0,
196
+ 'none found',
197
+ matches.slice(0, 5).join(', '),
198
+ 'Move API routes, SSR handlers, middleware, and server logic to Rust. Keep JS/TS only in static frontend roots.'
199
+ )
200
+ }
201
+
202
+ function isBackendJavascriptOrTypescriptSource(relative: string): boolean {
203
+ if (relative.endsWith('.d.ts') || !/\.(?:cjs|js|jsx|mjs|ts|tsx)$/iu.test(relative)) {
204
+ return false
205
+ }
206
+ return relative.split('/').some((component) => ['api', 'app', 'pages', 'routes', 'server', 'src'].includes(component))
207
+ }
208
+
184
209
  function isWorkspaceDoctorReport(report: DoctorReport | WorkspaceDoctorReport): report is WorkspaceDoctorReport {
185
210
  return 'projects' in report
186
211
  }
@@ -7,6 +7,8 @@ import { readPackageJson, walkProjectFiles } from './project.ts'
7
7
  import type { DoctorCheck, FrontendSourceReport, PackageManifest } from './types.ts'
8
8
 
9
9
  const REQUIRED_FRONTEND_SCRIPTS = ['typecheck', 'lint'] as const
10
+ const FRONTEND_PAGES_API_PREFIXES: readonly (readonly string[])[] = [['pages', 'api'], ['src', 'pages', 'api']]
11
+ const FRONTEND_APP_API_PREFIXES: readonly (readonly string[])[] = [['app', 'api'], ['src', 'app', 'api']]
10
12
  type FrontendScriptName = typeof REQUIRED_FRONTEND_SCRIPTS[number]
11
13
  type CommandPredicate = (command: string) => boolean
12
14
 
@@ -95,18 +97,26 @@ function frontendSourceChecks(projectDir: string): DoctorCheck[] {
95
97
  message: report.typescript.length > 0 ? report.typescript.slice(0, 3).join(', ') : 'missing',
96
98
  agent_instruction: report.typescript.length > 0 ? null : 'Add browser source as .ts or .tsx under src, app, pages, routes, or components, then retry.'
97
99
  },
98
- {
99
- name: 'javascript source',
100
- ok: report.javascript.length === 0,
101
- message: report.javascript.length === 0 ? 'none found' : report.javascript.slice(0, 5).join(', '),
102
- agent_instruction: report.javascript.length === 0 ? null : 'Rename browser .js, .jsx, .mjs, or .cjs source files to .ts or .tsx and fix type errors before deploying.'
103
- }
100
+ forbiddenSourceCheck('javascript source', report.javascript, 'Rename browser .js, .jsx, .mjs, or .cjs source files to .ts or .tsx and fix type errors before deploying.'),
101
+ forbiddenSourceCheck('frontend server routes', report.serverRoutes, 'Move API routes, SSR handlers, middleware, and server logic to the Rust backend; static frontend source may only contain browser code.')
104
102
  ]
105
103
  }
106
104
 
105
+ function forbiddenSourceCheck(name: string, files: readonly string[], instruction: string): DoctorCheck {
106
+ return {
107
+ name,
108
+ ok: files.length === 0,
109
+ message: files.length === 0 ? 'none found' : files.slice(0, 5).join(', '),
110
+ agent_instruction: files.length === 0 ? null : instruction
111
+ }
112
+ }
113
+
107
114
  function frontendSourceReport(projectDir: string): FrontendSourceReport {
108
- const report: FrontendSourceReport = { typescript: [], javascript: [] }
115
+ const report: FrontendSourceReport = { typescript: [], javascript: [], serverRoutes: [] }
109
116
  walkProjectFiles(projectDir, (_file, relative) => {
117
+ if (isFrontendServerRoute(relative)) {
118
+ report.serverRoutes.push(relative)
119
+ }
110
120
  const sourceKind = frontendSourceKind(relative)
111
121
  if (sourceKind) {
112
122
  report[sourceKind].push(relative)
@@ -138,6 +148,28 @@ function isFrontendJavascriptSource(relative: string): boolean {
138
148
  return FRONTEND_JAVASCRIPT_EXTENSIONS.some((extension) => relative.endsWith(extension))
139
149
  }
140
150
 
151
+ function isFrontendServerRoute(relative: string): boolean {
152
+ if (!isFrontendTypescriptSource(relative) && !isFrontendJavascriptSource(relative)) {
153
+ return false
154
+ }
155
+ const parts = relative.toLowerCase().split('/')
156
+ const file = parts.at(-1) ?? ''
157
+ return isFrontendServerHandlerFile(file) || isFrontendApiRoute(parts, file)
158
+ }
159
+
160
+ function isFrontendServerHandlerFile(file: string): boolean {
161
+ return file.startsWith('+server.') || file.startsWith('middleware.')
162
+ }
163
+
164
+ function isFrontendApiRoute(pathParts: readonly string[], file: string): boolean {
165
+ return FRONTEND_PAGES_API_PREFIXES.some((prefix) => pathStartsWith(pathParts, prefix))
166
+ || (file.startsWith('route.') && FRONTEND_APP_API_PREFIXES.some((prefix) => pathStartsWith(pathParts, prefix)))
167
+ }
168
+
169
+ function pathStartsWith(pathParts: readonly string[], prefix: readonly string[]): boolean {
170
+ return pathParts.length >= prefix.length && prefix.every((part, index) => pathParts[index] === part)
171
+ }
172
+
141
173
  function packageScriptValue(manifest: PackageManifest | null, script: string): string {
142
174
  const value = manifest?.scripts?.[script]
143
175
  return typeof value === 'string' ? value.trim() : ''
@@ -65,7 +65,7 @@ export type ProjectDoctorReport = DoctorReport & { relative: string }
65
65
  export type WorkspaceDoctorReport = JsonObject & { ok: boolean; workspace: string; projects: ProjectDoctorReport[] }
66
66
  export type DeployProjectInfo = { dir: string; relative: string; name: string; kind: DiscoveredProjectKind }
67
67
  export type DeployPlanProject = { project: DeployProjectInfo; wantsDatabase: boolean }
68
- export type FrontendSourceReport = { typescript: string[]; javascript: string[] }
68
+ export type FrontendSourceReport = { typescript: string[]; javascript: string[]; serverRoutes: string[] }
69
69
  export type LoginStartResponse = JsonObject & { loginUrl?: string; userCode?: string; deviceCode?: string; expiresInSeconds?: number; intervalSeconds?: number }
70
70
  export type LoginPollResponse = JsonObject & { status?: string; token?: string; email?: string; intervalSeconds?: number }
71
71
  export type AppSummary = JsonObject & { id?: string; name?: string; url?: string; databaseStorageMib?: number }