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,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.
|
package/src/internal/doctor.ts
CHANGED
|
@@ -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
|
-
|
|
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() : ''
|
package/src/internal/types.ts
CHANGED
|
@@ -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 }
|