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 +4 -3
- package/package.json +2 -2
- package/src/internal/config-parser.ts +99 -45
- package/src/internal/config-validation.ts +75 -11
- package/src/internal/constants.ts +5 -3
- package/src/internal/deploy-plan.ts +1 -1
- package/src/internal/doctor.ts +50 -8
- package/src/internal/frontend-policy.ts +11 -1
- package/src/internal/preview.ts +66 -4
- package/src/internal/project.ts +18 -2
- package/src/internal/template-sources.ts +1 -1
- package/src/internal/templates.ts +80 -20
- package/src/internal/types.ts +12 -2
- package/src/internal/workspace.ts +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tovuk
|
|
2
2
|
|
|
3
|
-
Deploy Rust backends
|
|
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
|
|
23
|
-
|
|
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.
|
|
4
|
-
"description": "Deploy Rust backends
|
|
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:
|
|
13
|
-
run:
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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 =>
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
114
|
-
|
|
126
|
+
if (schema.numbers.has(key)) {
|
|
127
|
+
sectionValues[key] = expectNumber(key, value)
|
|
115
128
|
return
|
|
116
129
|
}
|
|
117
|
-
throw new Error(`unsupported [
|
|
130
|
+
throw new Error(`unsupported [${section}] key ${key}`)
|
|
118
131
|
}
|
|
119
132
|
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
177
|
-
command: config.build
|
|
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
|
|
181
|
-
: config.build
|
|
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
|
|
199
|
-
health: config.run
|
|
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
|
|
210
|
-
cpu: config.resources
|
|
211
|
-
idle_timeout_minutes: config.resources
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
132
|
-
- When
|
|
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)
|
package/src/internal/doctor.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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 }
|
package/src/internal/preview.ts
CHANGED
|
@@ -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
|
|
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')
|
package/src/internal/project.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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 = "${
|
|
137
|
-
command = "${
|
|
138
|
-
output = "
|
|
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)
|
package/src/internal/types.ts
CHANGED
|
@@ -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 & {
|
|
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 === '
|
|
55
|
+
if (kind === 'fullstack') {
|
|
56
56
|
return 1
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
if (kind === 'static_frontend') {
|
|
59
|
+
return 2
|
|
60
|
+
}
|
|
61
|
+
return 3
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
export { discoverDeployProjects }
|