tovuk 0.1.47 → 0.1.48

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.48",
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
 
@@ -5,6 +5,10 @@ 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
 
@@ -33,25 +37,70 @@ function validateBuildConfig(config: TovukConfig): void {
33
37
  }
34
38
 
35
39
  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
- }
40
+ validateOutput(config.build.output, '[build].output')
39
41
  }
40
42
 
41
43
  function validateRustBackendConfig(config: TovukConfig): void {
42
44
  if (config.build.output) {
43
45
  throw new Error('[build].output is only valid for static_frontend')
44
46
  }
45
- if (!config.run.command?.trim()) {
46
- throw new Error('[run].command is required')
47
+ requireCommand(config.run.command, '[run].command')
48
+ validatePort(config.run.port, '[run].port')
49
+ validateHealth(config.run.health, '[run].health')
50
+ validateResourceConfig(config)
51
+ }
52
+
53
+ function validateFullstackConfig(config: TovukConfig): void {
54
+ const backendRoot = validateRoot(config.backend.root, '[backend].root')
55
+ const frontendRoot = validateRoot(config.frontend.root, '[frontend].root')
56
+ if (backendRoot === frontendRoot) {
57
+ throw new Error('[backend].root and [frontend].root must be different directories')
47
58
  }
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')
59
+ validateFullstackSections(config)
60
+ validateResourceConfig(config)
61
+ }
62
+
63
+ function validateFullstackSections(config: TovukConfig): void {
64
+ validateRustCheckCommand(requireCommand(config.backend.check, '[backend].check'))
65
+ requireCommand(config.backend.build, '[backend].build')
66
+ requireCommand(config.backend.command, '[backend].command')
67
+ validatePort(config.backend.port, '[backend].port')
68
+ validateHealth(config.backend.health, '[backend].health')
69
+ validateFrontendCheckCommand(requireCommand(config.frontend.check, '[frontend].check'))
70
+ requireCommand(config.frontend.build, '[frontend].build')
71
+ validateOutput(config.frontend.output, '[frontend].output')
72
+ }
73
+
74
+ function validateRoot(value: string | undefined, field: string): string {
75
+ if (!value || !isSafeRelativePath(value)) {
76
+ throw new Error(`${field} must be a safe relative directory such as api or web`)
50
77
  }
51
- if (!config.run.health.startsWith('/')) {
52
- throw new Error('[run].health must be an absolute path')
78
+ return value
79
+ }
80
+
81
+ function requireCommand(value: string | undefined, field: string): string {
82
+ if (!value?.trim()) {
83
+ throw new Error(`${field} is required`)
84
+ }
85
+ return value
86
+ }
87
+
88
+ function validatePort(value: number | undefined, field: string): void {
89
+ if (!Number.isInteger(value) || (value ?? 0) < 1 || (value ?? 0) > 65535) {
90
+ throw new Error(`${field} must be between 1 and 65535`)
91
+ }
92
+ }
93
+
94
+ function validateHealth(value: string | undefined, field: string): void {
95
+ if (!value?.startsWith('/')) {
96
+ throw new Error(`${field} must be an absolute path`)
97
+ }
98
+ }
99
+
100
+ function validateOutput(value: string | undefined, field: string): void {
101
+ if (typeof value !== 'string' || !isSafeRelativeDirectory(value)) {
102
+ throw new Error(`${field} must be a safe relative directory like dist or .`)
53
103
  }
54
- validateResourceConfig(config)
55
104
  }
56
105
 
57
106
  function validateResourceConfig(config: TovukConfig): void {
@@ -69,6 +118,10 @@ function validateCheckCommand(kind: ProjectKind, command: string): void {
69
118
  return
70
119
  }
71
120
 
121
+ validateRustCheckCommand(command)
122
+ }
123
+
124
+ function validateRustCheckCommand(command: string): void {
72
125
  const required = ['cargo fmt --all --check', 'cargo check --locked', 'cargo clippy --locked', '--all-targets', '--all-features', '-D warnings']
73
126
  if (required.every((fragment) => command.includes(fragment))) {
74
127
  return
@@ -77,6 +130,9 @@ function validateCheckCommand(kind: ProjectKind, command: string): void {
77
130
  }
78
131
 
79
132
  function validateFrontendCheckCommand(command: string): void {
133
+ if (isNoopCommand(command)) {
134
+ return
135
+ }
80
136
  if (usesJavascriptLinter(command)) {
81
137
  throw new Error('[build].check must not run JavaScript-based lint or format tooling; use oxlint, biome, or deno lint')
82
138
  }
@@ -91,4 +147,12 @@ function validateFrontendCheckCommand(command: string): void {
91
147
  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
148
  }
93
149
 
150
+ function isSafeRelativeDirectory(value: string): boolean {
151
+ return value === '.' || isSafeRelativePath(value)
152
+ }
153
+
154
+ function isNoopCommand(command: string): boolean {
155
+ return command.trim() === ':' || command.trim() === 'true'
156
+ }
157
+
94
158
  export { validateConfig }
@@ -18,7 +18,7 @@ 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
24
  export const FRONTEND_SOURCE_ROOTS: ReadonlySet<string> = new Set(['src', 'app', 'pages', 'routes', 'components'])
@@ -125,11 +125,13 @@ Usage:
125
125
  tovuk support resolve <ticket_id> [--api <url>] [--json]
126
126
 
127
127
  Agent contract:
128
+ - 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
129
  - Rust backends keep Cargo.lock committed, pass rustfmt, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
129
130
  - Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, stable native typecheck, native lint, and Fallow quality gates.
131
+ - Plain static HTML/CSS/JS frontends may use kind = "static_frontend" with check = ":", command = ":", and output = ".".
130
132
  - 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.
133
+ - Run deploy from a fullstack repo root with one tovuk.toml to build backend and frontend together.
134
+ - When split frontend and backend apps use different hostnames, configure backend CORS or use a same-origin custom domain.
133
135
  - When a plan limit blocks work, run tovuk billing checkout --json and show the returned URL to the human.
134
136
  - Create support tickets only with command output, app id, build id, deploy id, and the first actionable log line.
135
137
  - 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)
@@ -6,7 +6,7 @@ import { parseTovukToml, validateConfig } from './config.ts'
6
6
  import { discoverDeployProjects } from './workspace.ts'
7
7
  import { printJson } 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,11 +58,14 @@ 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 {
67
70
  checks.push(...rustDoctorChecks(projectDir, configResult.valid))
68
71
  }
@@ -71,10 +74,14 @@ function runDoctor(projectDir: string): DoctorReport {
71
74
  checks.push(unsafeCheck(projectDir))
72
75
  }
73
76
 
77
+ return doctorReport(projectDir, configResult.config, checks)
78
+ }
79
+
80
+ function doctorReport(projectDir: string, config: TovukConfig | null, checks: DoctorCheck[]): DoctorReport {
74
81
  return {
75
82
  ok: checks.every((check) => check.ok),
76
83
  project: projectDir,
77
- config: configResult.config,
84
+ config,
78
85
  checks
79
86
  }
80
87
  }
@@ -124,14 +131,49 @@ function readConfig(projectDir: string): { check: DoctorCheck; config: TovukConf
124
131
  }
125
132
 
126
133
  function requiredFileChecks(projectDir: string, kind: TovukConfig['kind']): DoctorCheck[] {
127
- return requiredFiles(kind).map((file) => {
134
+ return requiredFiles(projectDir, kind).map((file) => {
128
135
  const ok = existsSync(path.join(projectDir, file))
129
136
  return doctorCheck(file, ok, 'found', 'missing', `Create and commit ${file}, then retry.`)
130
137
  })
131
138
  }
132
139
 
133
- function requiredFiles(kind: TovukConfig['kind']): string[] {
134
- return kind === 'static_frontend' ? ['package.json'] : ['Cargo.toml', 'Cargo.lock']
140
+ function requiredFiles(projectDir: string, kind: TovukConfig['kind']): string[] {
141
+ if (kind === 'static_frontend') {
142
+ return isPlainStaticFrontend(projectDir) ? ['index.html'] : ['package.json']
143
+ }
144
+ return ['Cargo.toml', 'Cargo.lock']
145
+ }
146
+
147
+ function fullstackChecks(projectDir: string, config: TovukConfig, configValid: boolean): DoctorCheck[] {
148
+ const backendRoot = config.backend.root || ''
149
+ const frontendRoot = config.frontend.root || ''
150
+ const backendDir = path.join(projectDir, backendRoot)
151
+ const frontendDir = path.join(projectDir, frontendRoot)
152
+ return [
153
+ ...requiredFilesAt(backendDir, backendRoot, ['Cargo.toml', 'Cargo.lock']),
154
+ ...rustDoctorChecks(backendDir, configValid),
155
+ ...requiredFilesAt(frontendDir, frontendRoot, isPlainStaticFrontend(frontendDir) ? ['index.html'] : ['package.json']),
156
+ ...staticFrontendChecks(frontendDir, configValid)
157
+ ]
158
+ }
159
+
160
+ function requiredFilesAt(projectDir: string, label: string, files: string[]): DoctorCheck[] {
161
+ return files.map((file) => {
162
+ const display = label ? `${label}/${file}` : file
163
+ const ok = existsSync(path.join(projectDir, file))
164
+ return doctorCheck(display, ok, 'found', 'missing', `Create and commit ${display}, then retry.`)
165
+ })
166
+ }
167
+
168
+ function staticFrontendChecks(projectDir: string, configValid: boolean): DoctorCheck[] {
169
+ if (isPlainStaticFrontend(projectDir)) {
170
+ return []
171
+ }
172
+ return [
173
+ frontendLockfileCheck(projectDir),
174
+ ...frontendSourceChecks(projectDir),
175
+ ...frontendScriptChecks(projectDir, configValid)
176
+ ]
135
177
  }
136
178
 
137
179
  function frontendLockfileCheck(projectDir: string): DoctorCheck {
@@ -15,6 +15,10 @@ function frontendLockfileExists(projectDir: string): boolean {
15
15
  .some((file) => existsSync(path.join(projectDir, file)))
16
16
  }
17
17
 
18
+ function isPlainStaticFrontend(projectDir: string): boolean {
19
+ return !existsSync(path.join(projectDir, 'package.json')) && existsSync(path.join(projectDir, 'index.html'))
20
+ }
21
+
18
22
  function frontendPackageManager(projectDir: string): 'bun' | 'npm' {
19
23
  return existsSync(path.join(projectDir, 'bun.lock')) || existsSync(path.join(projectDir, 'bun.lockb'))
20
24
  ? 'bun'
@@ -22,12 +26,18 @@ function frontendPackageManager(projectDir: string): 'bun' | 'npm' {
22
26
  }
23
27
 
24
28
  function frontendCheckCommand(projectDir: string): string {
29
+ if (isPlainStaticFrontend(projectDir)) {
30
+ return ':'
31
+ }
25
32
  return frontendPackageManager(projectDir) === 'bun'
26
33
  ? DEFAULT_BUN_FRONTEND_CHECK_COMMAND
27
34
  : DEFAULT_NPM_FRONTEND_CHECK_COMMAND
28
35
  }
29
36
 
30
37
  function frontendBuildCommand(projectDir: string): string {
38
+ if (isPlainStaticFrontend(projectDir)) {
39
+ return ':'
40
+ }
31
41
  return frontendPackageManager(projectDir) === 'bun'
32
42
  ? 'bun run build'
33
43
  : 'npm run build'
@@ -269,4 +279,4 @@ function packageScriptCheck(projectDir: string, script: string): DoctorCheck {
269
279
  }
270
280
  }
271
281
 
272
- export { frontendLockfileExists, frontendCheckCommand, frontendBuildCommand, frontendScriptChecks, frontendSourceChecks, usesJavascriptLinter, commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun }
282
+ 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,8 +47,18 @@ 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 }
@@ -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 }