tovuk 0.1.47 → 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tovuk
2
2
 
3
- Deploy Rust backends and static frontends to Tovuk.
3
+ Deploy Rust backends, static frontends, and fullstack apps to Tovuk.
4
4
 
5
5
  ```sh
6
6
  npx tovuk init my-app --template fullstack-rust-tanstack
@@ -19,8 +19,9 @@ Static frontends must use TypeScript browser source, stable native type-aware
19
19
  TypeScript checks, native linting such as `oxlint`, `biome check`, or
20
20
  `deno lint`, and Fallow dead-code, semantic duplicate-code, and health gates.
21
21
 
22
- From a full-stack repo root, the same deploy command discovers nested
23
- `tovuk.toml` files and deploys the whole workspace in one command.
22
+ From a fullstack repo root, the same deploy command reads one root `tovuk.toml`,
23
+ builds the backend and frontend roots, and returns one app URL with `/api/*`
24
+ routed to the Rust backend.
24
25
 
25
26
  Preview before deploying:
26
27
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tovuk",
3
- "version": "0.1.47",
4
- "description": "Deploy Rust backends and static frontends to Tovuk.",
3
+ "version": "0.1.49",
4
+ "description": "Deploy Rust backends, static frontends, and fullstack apps to Tovuk.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "tovuk": "src/tovuk.ts"
@@ -1,30 +1,48 @@
1
1
  import { DEFAULT_RUST_CHECK_COMMAND, PROJECT_KINDS } from './constants.ts'
2
2
  import { frontendBuildCommand, frontendCheckCommand } from './frontend-policy.ts'
3
- import type { BuildConfig, ProjectKind, ResourceConfig, RunConfig, TovukConfig } from './types.ts'
3
+ import type { BackendConfig, BuildConfig, FrontendConfig, ProjectKind, ResourceConfig, RunConfig, TovukConfig } from './types.ts'
4
4
 
5
- type SectionName = 'build' | 'resources' | 'root' | 'run'
5
+ type SectionName = 'backend' | 'build' | 'frontend' | 'resources' | 'root' | 'run'
6
6
  type TomlValue = boolean | number | string
7
7
  type SectionAssigner = (config: MutableTovukConfig, key: string, value: TomlValue) => void
8
8
 
9
+ interface MutableSection {
10
+ [key: string]: number | string | undefined
11
+ }
12
+
9
13
  interface MutableTovukConfig {
10
14
  name?: string
11
15
  kind?: ProjectKind
12
- build: Partial<BuildConfig>
13
- run: Partial<RunConfig>
14
- resources: Partial<ResourceConfig>
16
+ build: MutableSection
17
+ run: MutableSection
18
+ frontend: MutableSection
19
+ backend: MutableSection
20
+ resources: MutableSection
21
+ }
22
+
23
+ const SECTION_FIELD_TYPES: Readonly<Record<Exclude<SectionName, 'root'>, { strings: ReadonlySet<string>; numbers: ReadonlySet<string> }>> = {
24
+ backend: fieldTypes(['root', 'check', 'build', 'command', 'health'], ['port']),
25
+ build: fieldTypes(['command', 'check', 'output'], []),
26
+ frontend: fieldTypes(['root', 'check', 'build', 'output'], []),
27
+ resources: fieldTypes(['memory', 'cpu'], ['idle_timeout_minutes']),
28
+ run: fieldTypes(['command', 'health'], ['port'])
15
29
  }
16
30
 
17
31
  const SECTION_ASSIGNERS: Readonly<Record<SectionName, SectionAssigner>> = {
18
- build: (config, key, value): void => assignBuildValue(config.build, key, value),
19
- resources: (config, key, value): void => assignResourceValue(config.resources, key, value),
32
+ backend: (config, key, value): void => assignSectionValue(config.backend, key, value, 'backend'),
33
+ build: (config, key, value): void => assignSectionValue(config.build, key, value, 'build'),
34
+ frontend: (config, key, value): void => assignSectionValue(config.frontend, key, value, 'frontend'),
35
+ resources: (config, key, value): void => assignSectionValue(config.resources, key, value, 'resources'),
20
36
  root: assignRootValue,
21
- run: (config, key, value): void => assignRunValue(config.run, key, value)
37
+ run: (config, key, value): void => assignSectionValue(config.run, key, value, 'run')
22
38
  }
23
39
 
24
40
  function parseTovukToml(source: string, projectDir: string): TovukConfig {
25
41
  const config: MutableTovukConfig = {
26
42
  build: {},
27
43
  run: {},
44
+ frontend: {},
45
+ backend: {},
28
46
  resources: {}
29
47
  }
30
48
  let section: SectionName = 'root'
@@ -50,6 +68,8 @@ function tovukConfig(config: MutableTovukConfig, projectDir: string): TovukConfi
50
68
  kind,
51
69
  build: buildConfig(config, kind, projectDir),
52
70
  run: runConfig(config),
71
+ frontend: frontendConfig(config, kind, projectDir),
72
+ backend: backendConfig(config, kind),
53
73
  resources: resourceConfig(config)
54
74
  }
55
75
  if (config.name) {
@@ -75,7 +95,7 @@ function parseTomlLine(rawLine: string): { kind: 'section'; section: SectionName
75
95
  }
76
96
 
77
97
  function parseSection(value: string): SectionName {
78
- if (value === 'build' || value === 'run' || value === 'resources') {
98
+ if (value === 'backend' || value === 'build' || value === 'frontend' || value === 'run' || value === 'resources') {
79
99
  return value
80
100
  }
81
101
  throw new Error(`unsupported section [${value}]`)
@@ -97,36 +117,24 @@ function assignRootValue(config: MutableTovukConfig, key: string, value: TomlVal
97
117
  throw new Error(`unsupported root key ${key}`)
98
118
  }
99
119
 
100
- function assignBuildValue(build: Partial<BuildConfig>, key: string, value: TomlValue): void {
101
- if (key === 'command' || key === 'check' || key === 'output') {
102
- build[key] = expectString(key, value)
103
- return
104
- }
105
- throw new Error(`unsupported [build] key ${key}`)
106
- }
107
-
108
- function assignRunValue(run: Partial<RunConfig>, key: string, value: TomlValue): void {
109
- if (key === 'command' || key === 'health') {
110
- run[key] = expectString(key, value)
120
+ function assignSectionValue(sectionValues: MutableSection, key: string, value: TomlValue, section: Exclude<SectionName, 'root'>): void {
121
+ const schema = SECTION_FIELD_TYPES[section]
122
+ if (schema.strings.has(key)) {
123
+ sectionValues[key] = expectString(key, value)
111
124
  return
112
125
  }
113
- if (key === 'port') {
114
- run.port = expectNumber(key, value)
126
+ if (schema.numbers.has(key)) {
127
+ sectionValues[key] = expectNumber(key, value)
115
128
  return
116
129
  }
117
- throw new Error(`unsupported [run] key ${key}`)
130
+ throw new Error(`unsupported [${section}] key ${key}`)
118
131
  }
119
132
 
120
- function assignResourceValue(resources: Partial<ResourceConfig>, key: string, value: TomlValue): void {
121
- if (key === 'memory' || key === 'cpu') {
122
- resources[key] = expectString(key, value)
123
- return
124
- }
125
- if (key === 'idle_timeout_minutes') {
126
- resources.idle_timeout_minutes = expectNumber(key, value)
127
- return
133
+ function fieldTypes(strings: readonly string[], numbers: readonly string[]): { strings: ReadonlySet<string>; numbers: ReadonlySet<string> } {
134
+ return {
135
+ strings: new Set(strings),
136
+ numbers: new Set(numbers)
128
137
  }
129
- throw new Error(`unsupported [resources] key ${key}`)
130
138
  }
131
139
 
132
140
  function expectString(key: string, value: TomlValue): string {
@@ -147,7 +155,7 @@ function expectProjectKind(value: string): ProjectKind {
147
155
  if (isProjectKind(value)) {
148
156
  return value
149
157
  }
150
- throw new Error('kind must be rust_backend or static_frontend')
158
+ throw new Error('kind must be fullstack, rust_backend, or static_frontend')
151
159
  }
152
160
 
153
161
  function isProjectKind(value: string): value is ProjectKind {
@@ -173,12 +181,12 @@ function parseTomlValue(raw: string): TomlValue {
173
181
 
174
182
  function buildConfig(config: MutableTovukConfig, kind: ProjectKind, projectDir: string): BuildConfig {
175
183
  const build: BuildConfig = {
176
- check: config.build.check ?? defaultCheckCommand(kind, projectDir),
177
- command: config.build.command ?? defaultBuildCommand(kind, projectDir)
184
+ check: optionalString(config.build['check']) ?? defaultCheckCommand(kind, projectDir),
185
+ command: optionalString(config.build['command']) ?? defaultBuildCommand(kind, projectDir)
178
186
  }
179
187
  const output = kind === 'static_frontend'
180
- ? config.build.output ?? 'dist'
181
- : config.build.output
188
+ ? optionalString(config.build['output']) ?? 'dist'
189
+ : optionalString(config.build['output'])
182
190
  if (typeof output === 'string') {
183
191
  build.output = output
184
192
  }
@@ -193,22 +201,68 @@ function defaultBuildCommand(kind: ProjectKind, projectDir: string): string {
193
201
  return kind === 'static_frontend' ? frontendBuildCommand(projectDir) : 'cargo build --release'
194
202
  }
195
203
 
204
+ function frontendConfig(config: MutableTovukConfig, kind: ProjectKind, projectDir: string): FrontendConfig {
205
+ if (kind !== 'fullstack') {
206
+ return {}
207
+ }
208
+ const root = optionalString(config.frontend['root'])
209
+ const frontendDir = root ? pathJoin(projectDir, root) : projectDir
210
+ const result: FrontendConfig = {
211
+ check: optionalString(config.frontend['check']) ?? frontendCheckCommand(frontendDir),
212
+ build: optionalString(config.frontend['build']) ?? frontendBuildCommand(frontendDir),
213
+ output: optionalString(config.frontend['output']) ?? 'dist'
214
+ }
215
+ assignIfPresent(root, (value) => { result.root = value })
216
+ return result
217
+ }
218
+
219
+ function backendConfig(config: MutableTovukConfig, kind: ProjectKind): BackendConfig {
220
+ if (kind !== 'fullstack') {
221
+ return {}
222
+ }
223
+ const result: BackendConfig = {
224
+ check: optionalString(config.backend['check']) ?? DEFAULT_RUST_CHECK_COMMAND,
225
+ build: optionalString(config.backend['build']) ?? 'cargo build --release',
226
+ port: optionalNumber(config.backend['port']) ?? 3000,
227
+ health: optionalString(config.backend['health']) ?? '/api/healthz'
228
+ }
229
+ assignIfPresent(optionalString(config.backend['root']), (value) => { result.root = value })
230
+ assignIfPresent(optionalString(config.backend['command']), (value) => { result.command = value })
231
+ return result
232
+ }
233
+
234
+ function pathJoin(root: string, relative: string): string {
235
+ return `${root.replace(/\/+$/u, '')}/${relative.replace(/^\/+/u, '')}`
236
+ }
237
+
196
238
  function runConfig(config: MutableTovukConfig): RunConfig {
197
239
  const run: RunConfig = {
198
- port: config.run.port ?? 3000,
199
- health: config.run.health ?? '/healthz'
200
- }
201
- if (typeof config.run.command === 'string') {
202
- run.command = config.run.command
240
+ port: optionalNumber(config.run['port']) ?? 3000,
241
+ health: optionalString(config.run['health']) ?? '/healthz'
203
242
  }
243
+ assignIfPresent(optionalString(config.run['command']), (value) => { run.command = value })
204
244
  return run
205
245
  }
206
246
 
207
247
  function resourceConfig(config: MutableTovukConfig): ResourceConfig {
208
248
  return {
209
- memory: config.resources.memory ?? '512mb',
210
- cpu: config.resources.cpu ?? '0.25',
211
- idle_timeout_minutes: config.resources.idle_timeout_minutes ?? 15
249
+ memory: optionalString(config.resources['memory']) ?? '512mb',
250
+ cpu: optionalString(config.resources['cpu']) ?? '0.25',
251
+ idle_timeout_minutes: optionalNumber(config.resources['idle_timeout_minutes']) ?? 15
252
+ }
253
+ }
254
+
255
+ function optionalString(value: number | string | undefined): string | undefined {
256
+ return typeof value === 'string' ? value : undefined
257
+ }
258
+
259
+ function optionalNumber(value: number | string | undefined): number | undefined {
260
+ return typeof value === 'number' ? value : undefined
261
+ }
262
+
263
+ function assignIfPresent(value: string | undefined, assign: (value: string) => void): void {
264
+ if (value) {
265
+ assign(value)
212
266
  }
213
267
  }
214
268
 
@@ -1,10 +1,14 @@
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'
5
5
 
6
6
  function validateConfig(config: TovukConfig): void {
7
7
  validateIdentity(config)
8
+ if (config.kind === 'fullstack') {
9
+ validateFullstackConfig(config)
10
+ return
11
+ }
8
12
  validateBuildConfig(config)
9
13
  if (config.kind === 'static_frontend') {
10
14
  validateStaticFrontendConfig(config)
@@ -18,7 +22,7 @@ function validateIdentity(config: TovukConfig): void {
18
22
  throw new Error('name must be lowercase DNS-safe text up to 48 characters')
19
23
  }
20
24
  if (!PROJECT_KINDS.has(config.kind)) {
21
- throw new Error('kind must be rust_backend or static_frontend')
25
+ throw new Error('kind must be fullstack, rust_backend, or static_frontend')
22
26
  }
23
27
  }
24
28
 
@@ -30,28 +34,77 @@ function validateBuildConfig(config: TovukConfig): void {
30
34
  throw new Error('[build].check is required')
31
35
  }
32
36
  validateCheckCommand(config.kind, config.build.check)
37
+ if (config.kind === 'rust_backend') {
38
+ validateRustBuildCommand(config.build.command)
39
+ }
33
40
  }
34
41
 
35
42
  function validateStaticFrontendConfig(config: TovukConfig): void {
36
- if (typeof config.build.output !== 'string' || !isSafeRelativePath(config.build.output)) {
37
- throw new Error('[build].output must be a safe relative directory like dist')
38
- }
43
+ validateOutput(config.build.output, '[build].output')
39
44
  }
40
45
 
41
46
  function validateRustBackendConfig(config: TovukConfig): void {
42
47
  if (config.build.output) {
43
48
  throw new Error('[build].output is only valid for static_frontend')
44
49
  }
45
- if (!config.run.command?.trim()) {
46
- throw new Error('[run].command is required')
50
+ requireCommand(config.run.command, '[run].command')
51
+ validateRustRunCommand(config.run.command)
52
+ validatePort(config.run.port, '[run].port')
53
+ validateHealth(config.run.health, '[run].health')
54
+ validateResourceConfig(config)
55
+ }
56
+
57
+ function validateFullstackConfig(config: TovukConfig): void {
58
+ const backendRoot = validateRoot(config.backend.root, '[backend].root')
59
+ const frontendRoot = validateRoot(config.frontend.root, '[frontend].root')
60
+ if (backendRoot === frontendRoot) {
61
+ throw new Error('[backend].root and [frontend].root must be different directories')
62
+ }
63
+ validateFullstackSections(config)
64
+ validateResourceConfig(config)
65
+ }
66
+
67
+ function validateFullstackSections(config: TovukConfig): void {
68
+ validateRustCheckCommand(requireCommand(config.backend.check, '[backend].check'))
69
+ validateRustBuildCommand(requireCommand(config.backend.build, '[backend].build'))
70
+ validateRustRunCommand(requireCommand(config.backend.command, '[backend].command'))
71
+ validatePort(config.backend.port, '[backend].port')
72
+ validateHealth(config.backend.health, '[backend].health')
73
+ validateFrontendCheckCommand(requireCommand(config.frontend.check, '[frontend].check'))
74
+ requireCommand(config.frontend.build, '[frontend].build')
75
+ validateOutput(config.frontend.output, '[frontend].output')
76
+ }
77
+
78
+ function validateRoot(value: string | undefined, field: string): string {
79
+ if (!value || !isSafeRelativePath(value)) {
80
+ throw new Error(`${field} must be a safe relative directory such as api or web`)
81
+ }
82
+ return value
83
+ }
84
+
85
+ function requireCommand(value: string | undefined, field: string): string {
86
+ if (!value?.trim()) {
87
+ throw new Error(`${field} is required`)
88
+ }
89
+ return value
90
+ }
91
+
92
+ function validatePort(value: number | undefined, field: string): void {
93
+ if (!Number.isInteger(value) || (value ?? 0) < 1 || (value ?? 0) > 65535) {
94
+ throw new Error(`${field} must be between 1 and 65535`)
47
95
  }
48
- if (!Number.isInteger(config.run.port) || config.run.port < 1 || config.run.port > 65535) {
49
- throw new Error('[run].port must be between 1 and 65535')
96
+ }
97
+
98
+ function validateHealth(value: string | undefined, field: string): void {
99
+ if (!value?.startsWith('/')) {
100
+ throw new Error(`${field} must be an absolute path`)
50
101
  }
51
- if (!config.run.health.startsWith('/')) {
52
- throw new Error('[run].health must be an absolute path')
102
+ }
103
+
104
+ function validateOutput(value: string | undefined, field: string): void {
105
+ if (typeof value !== 'string' || !isSafeRelativeDirectory(value)) {
106
+ throw new Error(`${field} must be a safe relative directory like dist or .`)
53
107
  }
54
- validateResourceConfig(config)
55
108
  }
56
109
 
57
110
  function validateResourceConfig(config: TovukConfig): void {
@@ -69,6 +122,10 @@ function validateCheckCommand(kind: ProjectKind, command: string): void {
69
122
  return
70
123
  }
71
124
 
125
+ validateRustCheckCommand(command)
126
+ }
127
+
128
+ function validateRustCheckCommand(command: string): void {
72
129
  const required = ['cargo fmt --all --check', 'cargo check --locked', 'cargo clippy --locked', '--all-targets', '--all-features', '-D warnings']
73
130
  if (required.every((fragment) => command.includes(fragment))) {
74
131
  return
@@ -76,7 +133,32 @@ function validateCheckCommand(kind: ProjectKind, command: string): void {
76
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')
77
134
  }
78
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
+
79
158
  function validateFrontendCheckCommand(command: string): void {
159
+ if (isNoopCommand(command)) {
160
+ return
161
+ }
80
162
  if (usesJavascriptLinter(command)) {
81
163
  throw new Error('[build].check must not run JavaScript-based lint or format tooling; use oxlint, biome, or deno lint')
82
164
  }
@@ -91,4 +173,21 @@ function validateFrontendCheckCommand(command: string): void {
91
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`')
92
174
  }
93
175
 
176
+ function usesJavascriptBackendRuntime(command: string): boolean {
177
+ return commandTokens(command)
178
+ .some((token) => JAVASCRIPT_BACKEND_RUNTIMES.has(commandNameFromToken(token)))
179
+ }
180
+
181
+ function isSafeRelativeDirectory(value: string): boolean {
182
+ return value === '.' || isSafeRelativePath(value)
183
+ }
184
+
185
+ function isNoopCommand(command: string): boolean {
186
+ return command.trim() === ':' || command.trim() === 'true'
187
+ }
188
+
189
+ function commandNameFromToken(token: string): string {
190
+ return token.split('/').pop() ?? ''
191
+ }
192
+
94
193
  export { validateConfig }
@@ -18,9 +18,10 @@ export const DEFAULT_LOGIN_INTERVAL_SECONDS: number = 5
18
18
  export const DEFAULT_RUST_CHECK_COMMAND: string = 'cargo fmt --all --check && cargo check --locked && cargo clippy --locked --all-targets --all-features -- -D warnings'
19
19
  export const DEFAULT_NPM_FRONTEND_CHECK_COMMAND: string = 'npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint'
20
20
  export const DEFAULT_BUN_FRONTEND_CHECK_COMMAND: string = 'bun ci && bun run typecheck && bun run lint'
21
- export const PROJECT_KINDS: ReadonlySet<string> = new Set(['rust_backend', 'static_frontend'])
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'])
@@ -125,11 +126,14 @@ Usage:
125
126
  tovuk support resolve <ticket_id> [--api <url>] [--json]
126
127
 
127
128
  Agent contract:
129
+ - Fullstack apps set kind = "fullstack", keep backend and frontend roots in one tovuk.toml, serve the frontend at /, and serve the Rust API under /api.
128
130
  - Rust backends keep Cargo.lock committed, pass rustfmt, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
129
131
  - Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, stable native typecheck, native lint, and Fallow quality gates.
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.
130
134
  - Frontends call Rust backends for APIs, managed Postgres, and server-side logic.
131
- - Run deploy from a repo root with nested tovuk.toml files to deploy the whole workspace in one command.
132
- - When a frontend calls a backend on another hostname, configure backend CORS or use a same-origin custom domain.
135
+ - Run deploy from a fullstack repo root with one tovuk.toml to build backend and frontend together.
136
+ - When split frontend and backend apps use different hostnames, configure backend CORS or use a same-origin custom domain.
133
137
  - When a plan limit blocks work, run tovuk billing checkout --json and show the returned URL to the human.
134
138
  - Create support tickets only with command output, app id, build id, deploy id, and the first actionable log line.
135
139
  - Resolve support tickets after the issue is fixed so later agents do not duplicate work.
@@ -8,7 +8,7 @@ import type { AppSummary, CliOptions, DeployPlanProject, DeployProjectInfo } fro
8
8
  async function createDeployPlan(projects: DeployProjectInfo[], cli: CliOptions, token: string): Promise<DeployPlanProject[]> {
9
9
  const plan = projects.map((project) => ({
10
10
  project,
11
- wantsDatabase: cli.database && project.kind === 'rust_backend'
11
+ wantsDatabase: cli.database && (project.kind === 'rust_backend' || project.kind === 'fullstack')
12
12
  }))
13
13
  rejectInvalidDatabaseTargets(plan, cli)
14
14
  await preflightDeployLimits(plan, cli, token)
@@ -4,9 +4,9 @@ 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
- import { frontendLockfileExists, frontendScriptChecks, frontendSourceChecks } from './frontend-policy.ts'
9
+ import { frontendLockfileExists, frontendScriptChecks, frontendSourceChecks, isPlainStaticFrontend } from './frontend-policy.ts'
10
10
  import type { DoctorCheck, DoctorReport, WorkspaceDoctorReport, TovukConfig } from './types.ts'
11
11
 
12
12
  function doctorProject(projectDir: string, json: boolean): void {
@@ -58,12 +58,16 @@ function runDoctor(projectDir: string): DoctorReport {
58
58
  const configResult = readConfig(projectDir)
59
59
  const checks: DoctorCheck[] = [configResult.check]
60
60
  const kind = configResult.config?.kind || 'rust_backend'
61
+ if (kind === 'fullstack' && configResult.config) {
62
+ checks.push(...fullstackChecks(projectDir, configResult.config, configResult.valid))
63
+ return doctorReport(projectDir, configResult.config, checks)
64
+ }
65
+
61
66
  checks.push(...requiredFileChecks(projectDir, kind))
62
67
  if (kind === 'static_frontend') {
63
- checks.push(frontendLockfileCheck(projectDir))
64
- checks.push(...frontendSourceChecks(projectDir))
65
- checks.push(...frontendScriptChecks(projectDir, configResult.valid))
68
+ checks.push(...staticFrontendChecks(projectDir, configResult.valid))
66
69
  } else {
70
+ checks.push(backendJavascriptSourceCheck(projectDir))
67
71
  checks.push(...rustDoctorChecks(projectDir, configResult.valid))
68
72
  }
69
73
 
@@ -71,10 +75,14 @@ function runDoctor(projectDir: string): DoctorReport {
71
75
  checks.push(unsafeCheck(projectDir))
72
76
  }
73
77
 
78
+ return doctorReport(projectDir, configResult.config, checks)
79
+ }
80
+
81
+ function doctorReport(projectDir: string, config: TovukConfig | null, checks: DoctorCheck[]): DoctorReport {
74
82
  return {
75
83
  ok: checks.every((check) => check.ok),
76
84
  project: projectDir,
77
- config: configResult.config,
85
+ config,
78
86
  checks
79
87
  }
80
88
  }
@@ -124,14 +132,50 @@ function readConfig(projectDir: string): { check: DoctorCheck; config: TovukConf
124
132
  }
125
133
 
126
134
  function requiredFileChecks(projectDir: string, kind: TovukConfig['kind']): DoctorCheck[] {
127
- return requiredFiles(kind).map((file) => {
135
+ return requiredFiles(projectDir, kind).map((file) => {
128
136
  const ok = existsSync(path.join(projectDir, file))
129
137
  return doctorCheck(file, ok, 'found', 'missing', `Create and commit ${file}, then retry.`)
130
138
  })
131
139
  }
132
140
 
133
- function requiredFiles(kind: TovukConfig['kind']): string[] {
134
- return kind === 'static_frontend' ? ['package.json'] : ['Cargo.toml', 'Cargo.lock']
141
+ function requiredFiles(projectDir: string, kind: TovukConfig['kind']): string[] {
142
+ if (kind === 'static_frontend') {
143
+ return isPlainStaticFrontend(projectDir) ? ['index.html'] : ['package.json']
144
+ }
145
+ return ['Cargo.toml', 'Cargo.lock']
146
+ }
147
+
148
+ function fullstackChecks(projectDir: string, config: TovukConfig, configValid: boolean): DoctorCheck[] {
149
+ const backendRoot = config.backend.root || ''
150
+ const frontendRoot = config.frontend.root || ''
151
+ const backendDir = path.join(projectDir, backendRoot)
152
+ const frontendDir = path.join(projectDir, frontendRoot)
153
+ return [
154
+ ...requiredFilesAt(backendDir, backendRoot, ['Cargo.toml', 'Cargo.lock']),
155
+ backendJavascriptSourceCheck(backendDir, backendRoot),
156
+ ...rustDoctorChecks(backendDir, configValid),
157
+ ...requiredFilesAt(frontendDir, frontendRoot, isPlainStaticFrontend(frontendDir) ? ['index.html'] : ['package.json']),
158
+ ...staticFrontendChecks(frontendDir, configValid)
159
+ ]
160
+ }
161
+
162
+ function requiredFilesAt(projectDir: string, label: string, files: string[]): DoctorCheck[] {
163
+ return files.map((file) => {
164
+ const display = label ? `${label}/${file}` : file
165
+ const ok = existsSync(path.join(projectDir, file))
166
+ return doctorCheck(display, ok, 'found', 'missing', `Create and commit ${display}, then retry.`)
167
+ })
168
+ }
169
+
170
+ function staticFrontendChecks(projectDir: string, configValid: boolean): DoctorCheck[] {
171
+ if (isPlainStaticFrontend(projectDir)) {
172
+ return []
173
+ }
174
+ return [
175
+ frontendLockfileCheck(projectDir),
176
+ ...frontendSourceChecks(projectDir),
177
+ ...frontendScriptChecks(projectDir, configValid)
178
+ ]
135
179
  }
136
180
 
137
181
  function frontendLockfileCheck(projectDir: string): DoctorCheck {
@@ -139,6 +183,29 @@ function frontendLockfileCheck(projectDir: string): DoctorCheck {
139
183
  return doctorCheck('frontend lockfile', ok, 'found', 'missing', 'Commit package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, or bun.lockb, then retry.')
140
184
  }
141
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
+
142
209
  function isWorkspaceDoctorReport(report: DoctorReport | WorkspaceDoctorReport): report is WorkspaceDoctorReport {
143
210
  return 'projects' in report
144
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
 
@@ -15,6 +17,10 @@ function frontendLockfileExists(projectDir: string): boolean {
15
17
  .some((file) => existsSync(path.join(projectDir, file)))
16
18
  }
17
19
 
20
+ function isPlainStaticFrontend(projectDir: string): boolean {
21
+ return !existsSync(path.join(projectDir, 'package.json')) && existsSync(path.join(projectDir, 'index.html'))
22
+ }
23
+
18
24
  function frontendPackageManager(projectDir: string): 'bun' | 'npm' {
19
25
  return existsSync(path.join(projectDir, 'bun.lock')) || existsSync(path.join(projectDir, 'bun.lockb'))
20
26
  ? 'bun'
@@ -22,12 +28,18 @@ function frontendPackageManager(projectDir: string): 'bun' | 'npm' {
22
28
  }
23
29
 
24
30
  function frontendCheckCommand(projectDir: string): string {
31
+ if (isPlainStaticFrontend(projectDir)) {
32
+ return ':'
33
+ }
25
34
  return frontendPackageManager(projectDir) === 'bun'
26
35
  ? DEFAULT_BUN_FRONTEND_CHECK_COMMAND
27
36
  : DEFAULT_NPM_FRONTEND_CHECK_COMMAND
28
37
  }
29
38
 
30
39
  function frontendBuildCommand(projectDir: string): string {
40
+ if (isPlainStaticFrontend(projectDir)) {
41
+ return ':'
42
+ }
31
43
  return frontendPackageManager(projectDir) === 'bun'
32
44
  ? 'bun run build'
33
45
  : 'npm run build'
@@ -85,18 +97,26 @@ function frontendSourceChecks(projectDir: string): DoctorCheck[] {
85
97
  message: report.typescript.length > 0 ? report.typescript.slice(0, 3).join(', ') : 'missing',
86
98
  agent_instruction: report.typescript.length > 0 ? null : 'Add browser source as .ts or .tsx under src, app, pages, routes, or components, then retry.'
87
99
  },
88
- {
89
- name: 'javascript source',
90
- ok: report.javascript.length === 0,
91
- message: report.javascript.length === 0 ? 'none found' : report.javascript.slice(0, 5).join(', '),
92
- 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.'
93
- }
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.')
94
102
  ]
95
103
  }
96
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
+
97
114
  function frontendSourceReport(projectDir: string): FrontendSourceReport {
98
- const report: FrontendSourceReport = { typescript: [], javascript: [] }
115
+ const report: FrontendSourceReport = { typescript: [], javascript: [], serverRoutes: [] }
99
116
  walkProjectFiles(projectDir, (_file, relative) => {
117
+ if (isFrontendServerRoute(relative)) {
118
+ report.serverRoutes.push(relative)
119
+ }
100
120
  const sourceKind = frontendSourceKind(relative)
101
121
  if (sourceKind) {
102
122
  report[sourceKind].push(relative)
@@ -128,6 +148,28 @@ function isFrontendJavascriptSource(relative: string): boolean {
128
148
  return FRONTEND_JAVASCRIPT_EXTENSIONS.some((extension) => relative.endsWith(extension))
129
149
  }
130
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
+
131
173
  function packageScriptValue(manifest: PackageManifest | null, script: string): string {
132
174
  const value = manifest?.scripts?.[script]
133
175
  return typeof value === 'string' ? value.trim() : ''
@@ -269,4 +311,4 @@ function packageScriptCheck(projectDir: string, script: string): DoctorCheck {
269
311
  }
270
312
  }
271
313
 
272
- export { frontendLockfileExists, frontendCheckCommand, frontendBuildCommand, frontendScriptChecks, frontendSourceChecks, usesJavascriptLinter, commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun }
314
+ export { frontendLockfileExists, isPlainStaticFrontend, frontendCheckCommand, frontendBuildCommand, frontendScriptChecks, frontendSourceChecks, usesJavascriptLinter, commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun }
@@ -1,16 +1,23 @@
1
- import { spawnSync } from 'node:child_process'
1
+ import { spawn, spawnSync } from 'node:child_process'
2
2
  import { existsSync, readFileSync, statSync } from 'node:fs'
3
- import { createServer } from 'node:http'
3
+ import { createServer, request as httpRequest } from 'node:http'
4
4
  import path from 'node:path'
5
5
  import { parseTovukToml, validateConfig } from './config.ts'
6
6
  import { runDoctorWorkspace } from './doctor.ts'
7
7
  import { agentError } from './errors.ts'
8
8
  import { ensureDirectory } from './project.ts'
9
+ import type { TovukConfig } from './types.ts'
10
+ import type { IncomingMessage, ServerResponse } from 'node:http'
9
11
 
10
12
  function previewProject(projectDir: string, port: number): void {
13
+ const config = previewConfig(projectDir)
14
+ previewValidatedProject(projectDir, config, port)
15
+ }
16
+
17
+ function previewConfig(projectDir: string): TovukConfig {
11
18
  const report = runDoctorWorkspace(projectDir)
12
19
  if ('projects' in report) {
13
- throw agentError('workspace_preview_unsupported', 'Preview one project at a time.', 'Run `npx tovuk preview api` or `npx tovuk preview web` from the workspace root.', false)
20
+ throw agentError('workspace_preview_unsupported', 'Preview one project at a time.', 'Run `npx tovuk preview <project-dir>` for one discovered project, or use a fullstack root tovuk.toml.', false)
14
21
  }
15
22
  if (!report.ok) {
16
23
  const firstFailure = report.checks.find((check) => !check.ok)
@@ -19,6 +26,14 @@ function previewProject(projectDir: string, port: number): void {
19
26
 
20
27
  const config = parseTovukToml(readFileSync(path.join(projectDir, 'tovuk.toml'), 'utf8'), projectDir)
21
28
  validateConfig(config)
29
+ return config
30
+ }
31
+
32
+ function previewValidatedProject(projectDir: string, config: TovukConfig, port: number): void {
33
+ if (config.kind === 'fullstack') {
34
+ previewFullstack(projectDir, config, port)
35
+ return
36
+ }
22
37
  runShell(config.build.command, projectDir, 'Build failed before preview.')
23
38
  if (config.kind === 'static_frontend') {
24
39
  previewStatic(projectDir, config.build.output ?? 'dist', port)
@@ -27,6 +42,31 @@ function previewProject(projectDir: string, port: number): void {
27
42
  previewRuntime(projectDir, config.run.command ?? '', port || config.run.port)
28
43
  }
29
44
 
45
+ function previewFullstack(projectDir: string, config: TovukConfig, port: number): void {
46
+ const backendDir = path.join(projectDir, config.backend.root ?? '')
47
+ const frontendDir = path.join(projectDir, config.frontend.root ?? '')
48
+ const backendPort = config.backend.port ?? 3000
49
+ runShell(config.backend.build ?? '', backendDir, 'Backend build failed before preview.')
50
+ runShell(config.frontend.build ?? '', frontendDir, 'Frontend build failed before preview.')
51
+ const backend = spawn(config.backend.command ?? '', {
52
+ cwd: backendDir,
53
+ env: { ...process.env, PORT: String(backendPort) },
54
+ shell: true,
55
+ stdio: 'inherit'
56
+ })
57
+ backend.on('error', (error) => {
58
+ throw agentError('preview_failed', 'Backend preview command failed.', error.message, false)
59
+ })
60
+ const stopBackend = (): void => {
61
+ if (!backend.killed) {
62
+ backend.kill('SIGTERM')
63
+ }
64
+ }
65
+ process.once('SIGINT', stopBackend)
66
+ process.once('SIGTERM', stopBackend)
67
+ serveStatic(path.join(frontendDir, config.frontend.output ?? 'dist'), port || 4173, backendPort)
68
+ }
69
+
30
70
  function previewStatic(projectDir: string, output: string, port: number): void {
31
71
  serveStatic(path.join(projectDir, output), port || 4173)
32
72
  }
@@ -63,10 +103,14 @@ function runShell(command: string, projectDir: string, failureMessage: string):
63
103
  }
64
104
  }
65
105
 
66
- function serveStatic(root: string, port: number): void {
106
+ function serveStatic(root: string, port: number, apiProxyPort?: number): void {
67
107
  ensureDirectory(root)
68
108
  const server = createServer((request, response) => {
69
109
  const pathname = decodeURIComponent(new URL(request.url || '/', `http://127.0.0.1:${port}`).pathname)
110
+ if (apiProxyPort && (pathname === '/api' || pathname.startsWith('/api/'))) {
111
+ proxyToBackend(request, response, apiProxyPort)
112
+ return
113
+ }
70
114
  const target = staticTarget(root, pathname)
71
115
  if (!target) {
72
116
  response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
@@ -81,6 +125,24 @@ function serveStatic(root: string, port: number): void {
81
125
  })
82
126
  }
83
127
 
128
+ function proxyToBackend(clientRequest: IncomingMessage, response: ServerResponse, port: number): void {
129
+ const upstream = httpRequest({
130
+ hostname: '127.0.0.1',
131
+ port,
132
+ method: clientRequest.method,
133
+ path: clientRequest.url ?? '/',
134
+ headers: { ...clientRequest.headers, host: `127.0.0.1:${port}` }
135
+ }, (upstreamResponse) => {
136
+ response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers)
137
+ upstreamResponse.pipe(response)
138
+ })
139
+ upstream.on('error', () => {
140
+ response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
141
+ response.end('backend unavailable')
142
+ })
143
+ clientRequest.pipe(upstream)
144
+ }
145
+
84
146
  function staticTarget(root: string, pathname: string): string {
85
147
  const safePath = pathname.replace(/^\/+/u, '')
86
148
  const candidate = path.resolve(root, safePath || 'index.html')
@@ -5,6 +5,11 @@ import { WALK_EXCLUDED_DIRS } from './constants.ts'
5
5
  import { agentError } from './errors.ts'
6
6
  import type { CliOptions, FileVisitor, JsonValue, PackageManifest, PathVisitor, ProjectKind } from './types.ts'
7
7
 
8
+ interface FullstackRoots {
9
+ backend: string
10
+ frontend: string
11
+ }
12
+
8
13
  function hasCommand(command: string): boolean {
9
14
  return (process.env['PATH'] ?? '')
10
15
  .split(path.delimiter)
@@ -72,15 +77,26 @@ function serviceNameFromValue(value: string): string {
72
77
  }
73
78
 
74
79
  function inferProjectKind(projectDir: string): ProjectKind {
80
+ if (detectFullstackRoots(projectDir)) {
81
+ return 'fullstack'
82
+ }
75
83
  if (existsSync(path.join(projectDir, 'Cargo.toml'))) {
76
84
  return 'rust_backend'
77
85
  }
78
- if (existsSync(path.join(projectDir, 'package.json'))) {
86
+ if (existsSync(path.join(projectDir, 'package.json')) || existsSync(path.join(projectDir, 'index.html'))) {
79
87
  return 'static_frontend'
80
88
  }
81
89
  return 'rust_backend'
82
90
  }
83
91
 
92
+ function detectFullstackRoots(projectDir: string): FullstackRoots | null {
93
+ const backendRoot = ['api', 'backend', 'server']
94
+ .find((root) => existsSync(path.join(projectDir, root, 'Cargo.toml')))
95
+ const frontendRoot = ['web', 'frontend', 'app', 'site']
96
+ .find((root) => existsSync(path.join(projectDir, root, 'package.json')) || existsSync(path.join(projectDir, root, 'index.html')))
97
+ return backendRoot && frontendRoot ? { backend: backendRoot, frontend: frontendRoot } : null
98
+ }
99
+
84
100
  function readPackageJson(projectDir: string): PackageManifest | null {
85
101
  try {
86
102
  const parsed: unknown = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
@@ -132,4 +148,4 @@ function progress(cli: CliOptions, message: string): void {
132
148
  console.log(message)
133
149
  }
134
150
 
135
- export { hasCommand, isSafeRelativePath, walkProjectFiles, ensureDirectory, serviceNameFromDir, serviceNameFromCargo, serviceNameFromPackage, inferProjectKind, readPackageJson, printJson, openUrl, sleep, progress }
151
+ export { hasCommand, isSafeRelativePath, walkProjectFiles, ensureDirectory, serviceNameFromDir, serviceNameFromCargo, serviceNameFromPackage, inferProjectKind, detectFullstackRoots, readPackageJson, printJson, openUrl, sleep, progress }
@@ -108,7 +108,7 @@ fn handle(mut stream: TcpStream) -> std::io::Result<()> {
108
108
  return write_response(&mut stream, "204 No Content", "", &cors_origin);
109
109
  }
110
110
 
111
- let body = if path == "/healthz" {
111
+ let body = if path == "/healthz" || path == "/api/healthz" {
112
112
  r#"{"ok":true}"#
113
113
  } else {
114
114
  r#"{"message":"hello from tovuk","backend":"rust"}"#
@@ -1,10 +1,10 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { DEFAULT_RUST_CHECK_COMMAND, PROJECT_TEMPLATES } from './constants.ts'
3
+ import { DEFAULT_BUN_FRONTEND_CHECK_COMMAND, DEFAULT_RUST_CHECK_COMMAND, PROJECT_TEMPLATES } from './constants.ts'
4
4
  import { agentError } from './errors.ts'
5
5
  import { doctorProject } from './doctor.ts'
6
- import { frontendBuildCommand, frontendCheckCommand } from './frontend-policy.ts'
7
- import { ensureDirectory, inferProjectKind, serviceNameFromCargo, serviceNameFromDir, serviceNameFromPackage } from './project.ts'
6
+ import { frontendBuildCommand, frontendCheckCommand, isPlainStaticFrontend } from './frontend-policy.ts'
7
+ import { detectFullstackRoots, ensureDirectory, inferProjectKind, serviceNameFromCargo, serviceNameFromDir, serviceNameFromPackage } from './project.ts'
8
8
  import { frontendPackageJson, frontendSource, frontendTsConfig, frontendViteEnvSource, rustApiSource } from './template-sources.ts'
9
9
  import type { TemplateName } from './types.ts'
10
10
 
@@ -15,8 +15,7 @@ const TEMPLATE_WRITERS: Readonly<Record<TemplateName, TemplateWriter>> = {
15
15
  'rust-api': (projectDir): void => writeRustApiTemplate(projectDir, serviceNameFromDir(projectDir)),
16
16
  'tanstack-static-frontend': (projectDir): void => writeFrontendTemplate(projectDir, serviceNameFromDir(projectDir), '/api'),
17
17
  'fullstack-rust-tanstack': (projectDir): void => {
18
- writeRustApiTemplate(path.join(projectDir, 'api'), 'api')
19
- writeFrontendTemplate(path.join(projectDir, 'web'), 'web', 'http://localhost:3000')
18
+ writeFullstackTemplate(projectDir)
20
19
  }
21
20
  }
22
21
 
@@ -35,9 +34,7 @@ function initProject(projectDir: string, template = ''): void {
35
34
  }
36
35
 
37
36
  const kind = inferProjectKind(projectDir)
38
- const source = kind === 'static_frontend'
39
- ? frontendConfig(projectDir)
40
- : rustBackendConfig(projectDir)
37
+ const source = initConfig(projectDir, kind)
41
38
 
42
39
  writeFileSync(configPath, source, { mode: 0o644 })
43
40
  console.log(`created ${path.relative(process.cwd(), configPath)}`)
@@ -52,9 +49,15 @@ function createTemplate(projectDir: string, template: string): void {
52
49
  console.log(`created ${template} template`)
53
50
  }
54
51
 
55
- function writeRustApiTemplate(projectDir: string, name: string): void {
52
+ function writeFullstackTemplate(projectDir: string): void {
53
+ writeRustApiTemplate(path.join(projectDir, 'api'), 'api', false)
54
+ writeFrontendTemplate(path.join(projectDir, 'web'), 'web', '/api', false)
55
+ writeNewFile(path.join(projectDir, 'tovuk.toml'), fullstackConfig(projectDir, { backend: 'api', frontend: 'web' }, true))
56
+ }
57
+
58
+ function writeRustApiTemplate(projectDir: string, name: string, includeConfig = true): void {
56
59
  mkdirSync(path.join(projectDir, 'src'), { recursive: true, mode: 0o755 })
57
- const files: readonly TemplateFile[] = [
60
+ const files: TemplateFile[] = [
58
61
  ['Cargo.toml', `[package]
59
62
  name = "${name}"
60
63
  version = "0.1.0"
@@ -72,24 +75,28 @@ version = 4
72
75
  name = "${name}"
73
76
  version = "0.1.0"
74
77
  `],
75
- ['src/main.rs', rustApiSource()],
76
- ['tovuk.toml', rustBackendConfig(projectDir)]
78
+ ['src/main.rs', rustApiSource()]
77
79
  ]
80
+ if (includeConfig) {
81
+ files.push(['tovuk.toml', rustBackendConfig(projectDir)])
82
+ }
78
83
  writeTemplateFiles(projectDir, files)
79
84
  }
80
85
 
81
- function writeFrontendTemplate(projectDir: string, name: string, apiBaseUrl: string): void {
86
+ function writeFrontendTemplate(projectDir: string, name: string, apiBaseUrl: string, includeConfig = true): void {
82
87
  mkdirSync(path.join(projectDir, 'src'), { recursive: true, mode: 0o755 })
83
- const files: readonly TemplateFile[] = [
88
+ const files: TemplateFile[] = [
84
89
  ['package.json', frontendPackageJson(name)],
85
90
  ['index.html', '<div id="root"></div><script type="module" src="/src/main.tsx"></script>\n'],
86
91
  ['src/styles.css', 'body{margin:0;font-family:system-ui,sans-serif}main{min-height:100svh;display:grid;place-items:center;padding:2rem}code{font-family:ui-monospace,monospace}\n'],
87
92
  ['src/vite-env.d.ts', frontendViteEnvSource()],
88
93
  ['src/main.tsx', frontendSource(apiBaseUrl)],
89
94
  ['tsconfig.json', frontendTsConfig()],
90
- ['vite.config.ts', 'import react from "@vitejs/plugin-react";\nimport { defineConfig } from "vite";\n\nexport default defineConfig({ plugins: [react()] });\n'],
91
- ['tovuk.toml', frontendConfig(projectDir)]
95
+ ['vite.config.ts', 'import react from "@vitejs/plugin-react";\nimport { defineConfig } from "vite";\n\nexport default defineConfig({ plugins: [react()] });\n']
92
96
  ]
97
+ if (includeConfig) {
98
+ files.push(['tovuk.toml', frontendConfig(projectDir, true)])
99
+ }
93
100
  writeTemplateFiles(projectDir, files)
94
101
  console.log('run package install in the frontend directory before doctor: bun install or npm install')
95
102
  }
@@ -107,6 +114,19 @@ function writeNewFile(file: string, source: string): void {
107
114
  writeFileSync(file, source, { mode: 0o644 })
108
115
  }
109
116
 
117
+ function initConfig(projectDir: string, kind: ReturnType<typeof inferProjectKind>): string {
118
+ if (kind === 'fullstack') {
119
+ const roots = detectFullstackRoots(projectDir)
120
+ if (!roots) {
121
+ throw agentError('fullstack_roots_missing', 'Could not find fullstack roots.', 'Create api/Cargo.toml and web/package.json or web/index.html, then retry.', false)
122
+ }
123
+ return fullstackConfig(projectDir, roots)
124
+ }
125
+ return kind === 'static_frontend'
126
+ ? frontendConfig(projectDir)
127
+ : rustBackendConfig(projectDir)
128
+ }
129
+
110
130
  function rustBackendConfig(projectDir: string): string {
111
131
  const name = serviceNameFromCargo(projectDir) || serviceNameFromDir(projectDir)
112
132
  return `name = "${name}"
@@ -127,18 +147,58 @@ idle_timeout_minutes = 15
127
147
  `
128
148
  }
129
149
 
130
- function frontendConfig(projectDir: string): string {
150
+ function frontendConfig(projectDir: string, preferBun = false): string {
131
151
  const name = serviceNameFromPackage(projectDir) || serviceNameFromDir(projectDir)
152
+ const frontend = frontendBuildSettings(projectDir, preferBun)
132
153
  return `name = "${name}"
133
154
  kind = "static_frontend"
134
155
 
135
156
  [build]
136
- check = "${frontendCheckCommand(projectDir)}"
137
- command = "${frontendBuildCommand(projectDir)}"
138
- output = "dist"
157
+ check = "${frontend.check}"
158
+ command = "${frontend.build}"
159
+ output = "${frontend.output}"
139
160
  `
140
161
  }
141
162
 
163
+ function fullstackConfig(projectDir: string, roots: { backend: string; frontend: string }, preferBun = false): string {
164
+ const name = serviceNameFromDir(projectDir)
165
+ const backendDir = path.join(projectDir, roots.backend)
166
+ const frontendDir = path.join(projectDir, roots.frontend)
167
+ const backendName = serviceNameFromCargo(backendDir) || serviceNameFromDir(backendDir)
168
+ const frontend = frontendBuildSettings(frontendDir, preferBun)
169
+ return `name = "${name}"
170
+ kind = "fullstack"
171
+
172
+ [backend]
173
+ root = "${roots.backend}"
174
+ check = "${DEFAULT_RUST_CHECK_COMMAND}"
175
+ build = "cargo build --release"
176
+ command = "./target/release/${backendName}"
177
+ port = 3000
178
+ health = "/api/healthz"
179
+
180
+ [frontend]
181
+ root = "${roots.frontend}"
182
+ check = "${frontend.check}"
183
+ build = "${frontend.build}"
184
+ output = "${frontend.output}"
185
+
186
+ [resources]
187
+ memory = "512mb"
188
+ cpu = "0.25"
189
+ idle_timeout_minutes = 15
190
+ `
191
+ }
192
+
193
+ function frontendBuildSettings(projectDir: string, preferBun: boolean): { check: string; build: string; output: string } {
194
+ const output = isPlainStaticFrontend(projectDir) ? '.' : 'dist'
195
+ return {
196
+ check: preferBun && output !== '.' ? DEFAULT_BUN_FRONTEND_CHECK_COMMAND : frontendCheckCommand(projectDir),
197
+ build: preferBun && output !== '.' ? 'bun run build' : frontendBuildCommand(projectDir),
198
+ output
199
+ }
200
+ }
201
+
142
202
  function installProject(projectDir: string, template = ''): void {
143
203
  initProject(projectDir, template)
144
204
  doctorProject(projectDir, false)
@@ -5,7 +5,7 @@ export interface JsonObject {
5
5
  }
6
6
 
7
7
  export type ApiMethod = 'DELETE' | 'GET' | 'POST' | 'PUT'
8
- export type ProjectKind = 'rust_backend' | 'static_frontend'
8
+ export type ProjectKind = 'fullstack' | 'rust_backend' | 'static_frontend'
9
9
  export type DiscoveredProjectKind = ProjectKind | 'unknown'
10
10
  export type TemplateName = 'fullstack-rust-tanstack' | 'rust-api' | 'tanstack-static-frontend'
11
11
 
@@ -47,15 +47,25 @@ export interface PackageManifest {
47
47
 
48
48
  export type BuildConfig = JsonObject & { command: string; check: string; output?: string }
49
49
  export type RunConfig = JsonObject & { command?: string; port: number; health: string }
50
+ export type FrontendConfig = JsonObject & { root?: string; check?: string; build?: string; output?: string }
51
+ export type BackendConfig = JsonObject & { root?: string; check?: string; build?: string; command?: string; port?: number; health?: string }
50
52
  export type ResourceConfig = JsonObject & { memory: string; cpu: string; idle_timeout_minutes: number }
51
- export type TovukConfig = JsonObject & { name?: string; kind: ProjectKind; build: BuildConfig; run: RunConfig; resources: ResourceConfig }
53
+ export type TovukConfig = JsonObject & {
54
+ name?: string
55
+ kind: ProjectKind
56
+ build: BuildConfig
57
+ run: RunConfig
58
+ frontend: FrontendConfig
59
+ backend: BackendConfig
60
+ resources: ResourceConfig
61
+ }
52
62
  export type DoctorCheck = JsonObject & { name: string; ok: boolean; message: string; agent_instruction: string | null }
53
63
  export type DoctorReport = JsonObject & { ok: boolean; project: string; config: TovukConfig | null; checks: DoctorCheck[] }
54
64
  export type ProjectDoctorReport = DoctorReport & { relative: string }
55
65
  export type WorkspaceDoctorReport = JsonObject & { ok: boolean; workspace: string; projects: ProjectDoctorReport[] }
56
66
  export type DeployProjectInfo = { dir: string; relative: string; name: string; kind: DiscoveredProjectKind }
57
67
  export type DeployPlanProject = { project: DeployProjectInfo; wantsDatabase: boolean }
58
- export type FrontendSourceReport = { typescript: string[]; javascript: string[] }
68
+ export type FrontendSourceReport = { typescript: string[]; javascript: string[]; serverRoutes: string[] }
59
69
  export type LoginStartResponse = JsonObject & { loginUrl?: string; userCode?: string; deviceCode?: string; expiresInSeconds?: number; intervalSeconds?: number }
60
70
  export type LoginPollResponse = JsonObject & { status?: string; token?: string; email?: string; intervalSeconds?: number }
61
71
  export type AppSummary = JsonObject & { id?: string; name?: string; url?: string; databaseStorageMib?: number }
@@ -52,10 +52,13 @@ function kindOrder(kind: DiscoveredProjectKind): number {
52
52
  if (kind === 'rust_backend') {
53
53
  return 0
54
54
  }
55
- if (kind === 'static_frontend') {
55
+ if (kind === 'fullstack') {
56
56
  return 1
57
57
  }
58
- return 2
58
+ if (kind === 'static_frontend') {
59
+ return 2
60
+ }
61
+ return 3
59
62
  }
60
63
 
61
64
  export { discoverDeployProjects }