tovuk 0.1.47 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/package.json +2 -2
- package/src/internal/config-parser.ts +99 -45
- package/src/internal/config-validation.ts +111 -12
- package/src/internal/constants.ts +7 -3
- package/src/internal/deploy-plan.ts +1 -1
- package/src/internal/doctor.ts +76 -9
- package/src/internal/frontend-policy.ts +50 -8
- 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 +13 -3
- 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.49",
|
|
4
|
+
"description": "Deploy Rust backends, static frontends, and fullstack apps to Tovuk.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"tovuk": "src/tovuk.ts"
|
|
@@ -1,30 +1,48 @@
|
|
|
1
1
|
import { DEFAULT_RUST_CHECK_COMMAND, PROJECT_KINDS } from './constants.ts'
|
|
2
2
|
import { frontendBuildCommand, frontendCheckCommand } from './frontend-policy.ts'
|
|
3
|
-
import type { BuildConfig, ProjectKind, ResourceConfig, RunConfig, TovukConfig } from './types.ts'
|
|
3
|
+
import type { BackendConfig, BuildConfig, FrontendConfig, ProjectKind, ResourceConfig, RunConfig, TovukConfig } from './types.ts'
|
|
4
4
|
|
|
5
|
-
type SectionName = 'build' | 'resources' | 'root' | 'run'
|
|
5
|
+
type SectionName = 'backend' | 'build' | 'frontend' | 'resources' | 'root' | 'run'
|
|
6
6
|
type TomlValue = boolean | number | string
|
|
7
7
|
type SectionAssigner = (config: MutableTovukConfig, key: string, value: TomlValue) => void
|
|
8
8
|
|
|
9
|
+
interface MutableSection {
|
|
10
|
+
[key: string]: number | string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
interface MutableTovukConfig {
|
|
10
14
|
name?: string
|
|
11
15
|
kind?: ProjectKind
|
|
12
|
-
build:
|
|
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
|
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { PROJECT_KINDS } from './constants.ts'
|
|
1
|
+
import { JAVASCRIPT_BACKEND_RUNTIMES, PROJECT_KINDS } from './constants.ts'
|
|
2
2
|
import { commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun, usesJavascriptLinter } from './frontend-policy.ts'
|
|
3
3
|
import { isSafeRelativePath } from './project.ts'
|
|
4
4
|
import type { ProjectKind, TovukConfig } from './types.ts'
|
|
5
5
|
|
|
6
6
|
function validateConfig(config: TovukConfig): void {
|
|
7
7
|
validateIdentity(config)
|
|
8
|
+
if (config.kind === 'fullstack') {
|
|
9
|
+
validateFullstackConfig(config)
|
|
10
|
+
return
|
|
11
|
+
}
|
|
8
12
|
validateBuildConfig(config)
|
|
9
13
|
if (config.kind === 'static_frontend') {
|
|
10
14
|
validateStaticFrontendConfig(config)
|
|
@@ -18,7 +22,7 @@ function validateIdentity(config: TovukConfig): void {
|
|
|
18
22
|
throw new Error('name must be lowercase DNS-safe text up to 48 characters')
|
|
19
23
|
}
|
|
20
24
|
if (!PROJECT_KINDS.has(config.kind)) {
|
|
21
|
-
throw new Error('kind must be rust_backend or static_frontend')
|
|
25
|
+
throw new Error('kind must be fullstack, rust_backend, or static_frontend')
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -30,28 +34,77 @@ function validateBuildConfig(config: TovukConfig): void {
|
|
|
30
34
|
throw new Error('[build].check is required')
|
|
31
35
|
}
|
|
32
36
|
validateCheckCommand(config.kind, config.build.check)
|
|
37
|
+
if (config.kind === 'rust_backend') {
|
|
38
|
+
validateRustBuildCommand(config.build.command)
|
|
39
|
+
}
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
function validateStaticFrontendConfig(config: TovukConfig): void {
|
|
36
|
-
|
|
37
|
-
throw new Error('[build].output must be a safe relative directory like dist')
|
|
38
|
-
}
|
|
43
|
+
validateOutput(config.build.output, '[build].output')
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
function validateRustBackendConfig(config: TovukConfig): void {
|
|
42
47
|
if (config.build.output) {
|
|
43
48
|
throw new Error('[build].output is only valid for static_frontend')
|
|
44
49
|
}
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
requireCommand(config.run.command, '[run].command')
|
|
51
|
+
validateRustRunCommand(config.run.command)
|
|
52
|
+
validatePort(config.run.port, '[run].port')
|
|
53
|
+
validateHealth(config.run.health, '[run].health')
|
|
54
|
+
validateResourceConfig(config)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateFullstackConfig(config: TovukConfig): void {
|
|
58
|
+
const backendRoot = validateRoot(config.backend.root, '[backend].root')
|
|
59
|
+
const frontendRoot = validateRoot(config.frontend.root, '[frontend].root')
|
|
60
|
+
if (backendRoot === frontendRoot) {
|
|
61
|
+
throw new Error('[backend].root and [frontend].root must be different directories')
|
|
62
|
+
}
|
|
63
|
+
validateFullstackSections(config)
|
|
64
|
+
validateResourceConfig(config)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateFullstackSections(config: TovukConfig): void {
|
|
68
|
+
validateRustCheckCommand(requireCommand(config.backend.check, '[backend].check'))
|
|
69
|
+
validateRustBuildCommand(requireCommand(config.backend.build, '[backend].build'))
|
|
70
|
+
validateRustRunCommand(requireCommand(config.backend.command, '[backend].command'))
|
|
71
|
+
validatePort(config.backend.port, '[backend].port')
|
|
72
|
+
validateHealth(config.backend.health, '[backend].health')
|
|
73
|
+
validateFrontendCheckCommand(requireCommand(config.frontend.check, '[frontend].check'))
|
|
74
|
+
requireCommand(config.frontend.build, '[frontend].build')
|
|
75
|
+
validateOutput(config.frontend.output, '[frontend].output')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateRoot(value: string | undefined, field: string): string {
|
|
79
|
+
if (!value || !isSafeRelativePath(value)) {
|
|
80
|
+
throw new Error(`${field} must be a safe relative directory such as api or web`)
|
|
81
|
+
}
|
|
82
|
+
return value
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function requireCommand(value: string | undefined, field: string): string {
|
|
86
|
+
if (!value?.trim()) {
|
|
87
|
+
throw new Error(`${field} is required`)
|
|
88
|
+
}
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validatePort(value: number | undefined, field: string): void {
|
|
93
|
+
if (!Number.isInteger(value) || (value ?? 0) < 1 || (value ?? 0) > 65535) {
|
|
94
|
+
throw new Error(`${field} must be between 1 and 65535`)
|
|
47
95
|
}
|
|
48
|
-
|
|
49
|
-
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateHealth(value: string | undefined, field: string): void {
|
|
99
|
+
if (!value?.startsWith('/')) {
|
|
100
|
+
throw new Error(`${field} must be an absolute path`)
|
|
50
101
|
}
|
|
51
|
-
|
|
52
|
-
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateOutput(value: string | undefined, field: string): void {
|
|
105
|
+
if (typeof value !== 'string' || !isSafeRelativeDirectory(value)) {
|
|
106
|
+
throw new Error(`${field} must be a safe relative directory like dist or .`)
|
|
53
107
|
}
|
|
54
|
-
validateResourceConfig(config)
|
|
55
108
|
}
|
|
56
109
|
|
|
57
110
|
function validateResourceConfig(config: TovukConfig): void {
|
|
@@ -69,6 +122,10 @@ function validateCheckCommand(kind: ProjectKind, command: string): void {
|
|
|
69
122
|
return
|
|
70
123
|
}
|
|
71
124
|
|
|
125
|
+
validateRustCheckCommand(command)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateRustCheckCommand(command: string): void {
|
|
72
129
|
const required = ['cargo fmt --all --check', 'cargo check --locked', 'cargo clippy --locked', '--all-targets', '--all-features', '-D warnings']
|
|
73
130
|
if (required.every((fragment) => command.includes(fragment))) {
|
|
74
131
|
return
|
|
@@ -76,7 +133,32 @@ function validateCheckCommand(kind: ProjectKind, command: string): void {
|
|
|
76
133
|
throw new Error('[build].check must include cargo fmt --all --check, cargo check --locked, and cargo clippy --locked --all-targets --all-features -- -D warnings')
|
|
77
134
|
}
|
|
78
135
|
|
|
136
|
+
function validateRustBuildCommand(command: string): void {
|
|
137
|
+
if (usesJavascriptBackendRuntime(command)) {
|
|
138
|
+
throw new Error('Rust backend build commands cannot invoke JavaScript or TypeScript runtimes; use cargo build --release')
|
|
139
|
+
}
|
|
140
|
+
const tokens = commandTokens(command)
|
|
141
|
+
if (tokens.some((token) => commandNameFromToken(token) === 'cargo') && tokens.includes('build') && tokens.includes('--release')) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
throw new Error('Rust backend build commands must run cargo build --release')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateRustRunCommand(command: string | undefined): void {
|
|
148
|
+
const value = command ?? ''
|
|
149
|
+
if (usesJavascriptBackendRuntime(value)) {
|
|
150
|
+
throw new Error('Rust backend runtime commands cannot invoke JavaScript or TypeScript runtimes; run ./target/release/<binary> instead')
|
|
151
|
+
}
|
|
152
|
+
if (commandTokens(value).some((token) => token.includes('target/release/'))) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
throw new Error('Rust backend runtime commands must start a binary under ./target/release/')
|
|
156
|
+
}
|
|
157
|
+
|
|
79
158
|
function validateFrontendCheckCommand(command: string): void {
|
|
159
|
+
if (isNoopCommand(command)) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
80
162
|
if (usesJavascriptLinter(command)) {
|
|
81
163
|
throw new Error('[build].check must not run JavaScript-based lint or format tooling; use oxlint, biome, or deno lint')
|
|
82
164
|
}
|
|
@@ -91,4 +173,21 @@ function validateFrontendCheckCommand(command: string): void {
|
|
|
91
173
|
throw new Error('[build].check must install dependencies and run package scripts, for example `bun ci && bun run typecheck && bun run lint` or `npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint`')
|
|
92
174
|
}
|
|
93
175
|
|
|
176
|
+
function usesJavascriptBackendRuntime(command: string): boolean {
|
|
177
|
+
return commandTokens(command)
|
|
178
|
+
.some((token) => JAVASCRIPT_BACKEND_RUNTIMES.has(commandNameFromToken(token)))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isSafeRelativeDirectory(value: string): boolean {
|
|
182
|
+
return value === '.' || isSafeRelativePath(value)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isNoopCommand(command: string): boolean {
|
|
186
|
+
return command.trim() === ':' || command.trim() === 'true'
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function commandNameFromToken(token: string): string {
|
|
190
|
+
return token.split('/').pop() ?? ''
|
|
191
|
+
}
|
|
192
|
+
|
|
94
193
|
export { validateConfig }
|
|
@@ -18,9 +18,10 @@ export const DEFAULT_LOGIN_INTERVAL_SECONDS: number = 5
|
|
|
18
18
|
export const DEFAULT_RUST_CHECK_COMMAND: string = 'cargo fmt --all --check && cargo check --locked && cargo clippy --locked --all-targets --all-features -- -D warnings'
|
|
19
19
|
export const DEFAULT_NPM_FRONTEND_CHECK_COMMAND: string = 'npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint'
|
|
20
20
|
export const DEFAULT_BUN_FRONTEND_CHECK_COMMAND: string = 'bun ci && bun run typecheck && bun run lint'
|
|
21
|
-
export const PROJECT_KINDS: ReadonlySet<string> = new Set(['rust_backend', 'static_frontend'])
|
|
21
|
+
export const PROJECT_KINDS: ReadonlySet<string> = new Set(['fullstack', 'rust_backend', 'static_frontend'])
|
|
22
22
|
export const PROJECT_TEMPLATES: ReadonlySet<string> = new Set(['rust-api', 'tanstack-static-frontend', 'fullstack-rust-tanstack'])
|
|
23
23
|
export const JAVASCRIPT_LINTERS: ReadonlySet<string> = new Set(['eslint', 'eslint_d', 'jscs', 'jshint', 'prettier', 'prettierd', 'standard', 'xo'])
|
|
24
|
+
export const JAVASCRIPT_BACKEND_RUNTIMES: ReadonlySet<string> = new Set(['astro', 'bun', 'deno', 'next', 'node', 'npm', 'npx', 'pnpm', 'svelte-kit', 'tsx', 'ts-node', 'vite', 'yarn'])
|
|
24
25
|
export const FRONTEND_SOURCE_ROOTS: ReadonlySet<string> = new Set(['src', 'app', 'pages', 'routes', 'components'])
|
|
25
26
|
export const FRONTEND_JAVASCRIPT_EXTENSIONS: readonly string[] = ['.js', '.jsx', '.mjs', '.cjs']
|
|
26
27
|
export const FRONTEND_PACKAGE_MANAGERS: ReadonlySet<string> = new Set(['npm', 'bun', 'pnpm', 'yarn'])
|
|
@@ -125,11 +126,14 @@ Usage:
|
|
|
125
126
|
tovuk support resolve <ticket_id> [--api <url>] [--json]
|
|
126
127
|
|
|
127
128
|
Agent contract:
|
|
129
|
+
- Fullstack apps set kind = "fullstack", keep backend and frontend roots in one tovuk.toml, serve the frontend at /, and serve the Rust API under /api.
|
|
128
130
|
- Rust backends keep Cargo.lock committed, pass rustfmt, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
|
|
129
131
|
- Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, stable native typecheck, native lint, and Fallow quality gates.
|
|
132
|
+
- Plain static HTML/CSS/JS frontends may use kind = "static_frontend" with check = ":", command = ":", and output = ".".
|
|
133
|
+
- JavaScript and TypeScript are frontend-only on Tovuk; backend build and runtime commands must be Cargo release builds and Rust release binaries.
|
|
130
134
|
- Frontends call Rust backends for APIs, managed Postgres, and server-side logic.
|
|
131
|
-
- Run deploy from a repo root with
|
|
132
|
-
- When
|
|
135
|
+
- Run deploy from a fullstack repo root with one tovuk.toml to build backend and frontend together.
|
|
136
|
+
- When split frontend and backend apps use different hostnames, configure backend CORS or use a same-origin custom domain.
|
|
133
137
|
- When a plan limit blocks work, run tovuk billing checkout --json and show the returned URL to the human.
|
|
134
138
|
- Create support tickets only with command output, app id, build id, deploy id, and the first actionable log line.
|
|
135
139
|
- Resolve support tickets after the issue is fixed so later agents do not duplicate work.
|
|
@@ -8,7 +8,7 @@ import type { AppSummary, CliOptions, DeployPlanProject, DeployProjectInfo } fro
|
|
|
8
8
|
async function createDeployPlan(projects: DeployProjectInfo[], cli: CliOptions, token: string): Promise<DeployPlanProject[]> {
|
|
9
9
|
const plan = projects.map((project) => ({
|
|
10
10
|
project,
|
|
11
|
-
wantsDatabase: cli.database && project.kind === 'rust_backend'
|
|
11
|
+
wantsDatabase: cli.database && (project.kind === 'rust_backend' || project.kind === 'fullstack')
|
|
12
12
|
}))
|
|
13
13
|
rejectInvalidDatabaseTargets(plan, cli)
|
|
14
14
|
await preflightDeployLimits(plan, cli, token)
|
package/src/internal/doctor.ts
CHANGED
|
@@ -4,9 +4,9 @@ import { doctorCheck } from './checks.ts'
|
|
|
4
4
|
import { agentError } from './errors.ts'
|
|
5
5
|
import { parseTovukToml, validateConfig } from './config.ts'
|
|
6
6
|
import { discoverDeployProjects } from './workspace.ts'
|
|
7
|
-
import { printJson } from './project.ts'
|
|
7
|
+
import { printJson, walkProjectFiles } from './project.ts'
|
|
8
8
|
import { rustDoctorChecks, unsafeCheck } from './rust-doctor.ts'
|
|
9
|
-
import { frontendLockfileExists, frontendScriptChecks, frontendSourceChecks } from './frontend-policy.ts'
|
|
9
|
+
import { frontendLockfileExists, frontendScriptChecks, frontendSourceChecks, isPlainStaticFrontend } from './frontend-policy.ts'
|
|
10
10
|
import type { DoctorCheck, DoctorReport, WorkspaceDoctorReport, TovukConfig } from './types.ts'
|
|
11
11
|
|
|
12
12
|
function doctorProject(projectDir: string, json: boolean): void {
|
|
@@ -58,12 +58,16 @@ function runDoctor(projectDir: string): DoctorReport {
|
|
|
58
58
|
const configResult = readConfig(projectDir)
|
|
59
59
|
const checks: DoctorCheck[] = [configResult.check]
|
|
60
60
|
const kind = configResult.config?.kind || 'rust_backend'
|
|
61
|
+
if (kind === 'fullstack' && configResult.config) {
|
|
62
|
+
checks.push(...fullstackChecks(projectDir, configResult.config, configResult.valid))
|
|
63
|
+
return doctorReport(projectDir, configResult.config, checks)
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
checks.push(...requiredFileChecks(projectDir, kind))
|
|
62
67
|
if (kind === 'static_frontend') {
|
|
63
|
-
checks.push(
|
|
64
|
-
checks.push(...frontendSourceChecks(projectDir))
|
|
65
|
-
checks.push(...frontendScriptChecks(projectDir, configResult.valid))
|
|
68
|
+
checks.push(...staticFrontendChecks(projectDir, configResult.valid))
|
|
66
69
|
} else {
|
|
70
|
+
checks.push(backendJavascriptSourceCheck(projectDir))
|
|
67
71
|
checks.push(...rustDoctorChecks(projectDir, configResult.valid))
|
|
68
72
|
}
|
|
69
73
|
|
|
@@ -71,10 +75,14 @@ function runDoctor(projectDir: string): DoctorReport {
|
|
|
71
75
|
checks.push(unsafeCheck(projectDir))
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
return doctorReport(projectDir, configResult.config, checks)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function doctorReport(projectDir: string, config: TovukConfig | null, checks: DoctorCheck[]): DoctorReport {
|
|
74
82
|
return {
|
|
75
83
|
ok: checks.every((check) => check.ok),
|
|
76
84
|
project: projectDir,
|
|
77
|
-
config
|
|
85
|
+
config,
|
|
78
86
|
checks
|
|
79
87
|
}
|
|
80
88
|
}
|
|
@@ -124,14 +132,50 @@ function readConfig(projectDir: string): { check: DoctorCheck; config: TovukConf
|
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
function requiredFileChecks(projectDir: string, kind: TovukConfig['kind']): DoctorCheck[] {
|
|
127
|
-
return requiredFiles(kind).map((file) => {
|
|
135
|
+
return requiredFiles(projectDir, kind).map((file) => {
|
|
128
136
|
const ok = existsSync(path.join(projectDir, file))
|
|
129
137
|
return doctorCheck(file, ok, 'found', 'missing', `Create and commit ${file}, then retry.`)
|
|
130
138
|
})
|
|
131
139
|
}
|
|
132
140
|
|
|
133
|
-
function requiredFiles(kind: TovukConfig['kind']): string[] {
|
|
134
|
-
|
|
141
|
+
function requiredFiles(projectDir: string, kind: TovukConfig['kind']): string[] {
|
|
142
|
+
if (kind === 'static_frontend') {
|
|
143
|
+
return isPlainStaticFrontend(projectDir) ? ['index.html'] : ['package.json']
|
|
144
|
+
}
|
|
145
|
+
return ['Cargo.toml', 'Cargo.lock']
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function fullstackChecks(projectDir: string, config: TovukConfig, configValid: boolean): DoctorCheck[] {
|
|
149
|
+
const backendRoot = config.backend.root || ''
|
|
150
|
+
const frontendRoot = config.frontend.root || ''
|
|
151
|
+
const backendDir = path.join(projectDir, backendRoot)
|
|
152
|
+
const frontendDir = path.join(projectDir, frontendRoot)
|
|
153
|
+
return [
|
|
154
|
+
...requiredFilesAt(backendDir, backendRoot, ['Cargo.toml', 'Cargo.lock']),
|
|
155
|
+
backendJavascriptSourceCheck(backendDir, backendRoot),
|
|
156
|
+
...rustDoctorChecks(backendDir, configValid),
|
|
157
|
+
...requiredFilesAt(frontendDir, frontendRoot, isPlainStaticFrontend(frontendDir) ? ['index.html'] : ['package.json']),
|
|
158
|
+
...staticFrontendChecks(frontendDir, configValid)
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function requiredFilesAt(projectDir: string, label: string, files: string[]): DoctorCheck[] {
|
|
163
|
+
return files.map((file) => {
|
|
164
|
+
const display = label ? `${label}/${file}` : file
|
|
165
|
+
const ok = existsSync(path.join(projectDir, file))
|
|
166
|
+
return doctorCheck(display, ok, 'found', 'missing', `Create and commit ${display}, then retry.`)
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function staticFrontendChecks(projectDir: string, configValid: boolean): DoctorCheck[] {
|
|
171
|
+
if (isPlainStaticFrontend(projectDir)) {
|
|
172
|
+
return []
|
|
173
|
+
}
|
|
174
|
+
return [
|
|
175
|
+
frontendLockfileCheck(projectDir),
|
|
176
|
+
...frontendSourceChecks(projectDir),
|
|
177
|
+
...frontendScriptChecks(projectDir, configValid)
|
|
178
|
+
]
|
|
135
179
|
}
|
|
136
180
|
|
|
137
181
|
function frontendLockfileCheck(projectDir: string): DoctorCheck {
|
|
@@ -139,6 +183,29 @@ function frontendLockfileCheck(projectDir: string): DoctorCheck {
|
|
|
139
183
|
return doctorCheck('frontend lockfile', ok, 'found', 'missing', 'Commit package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, or bun.lockb, then retry.')
|
|
140
184
|
}
|
|
141
185
|
|
|
186
|
+
function backendJavascriptSourceCheck(projectDir: string, label = ''): DoctorCheck {
|
|
187
|
+
const matches: string[] = []
|
|
188
|
+
walkProjectFiles(projectDir, (_file, relative) => {
|
|
189
|
+
if (isBackendJavascriptOrTypescriptSource(relative)) {
|
|
190
|
+
matches.push(label ? `${label}/${relative}` : relative)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
return doctorCheck(
|
|
194
|
+
'rust backend js/ts server source',
|
|
195
|
+
matches.length === 0,
|
|
196
|
+
'none found',
|
|
197
|
+
matches.slice(0, 5).join(', '),
|
|
198
|
+
'Move API routes, SSR handlers, middleware, and server logic to Rust. Keep JS/TS only in static frontend roots.'
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isBackendJavascriptOrTypescriptSource(relative: string): boolean {
|
|
203
|
+
if (relative.endsWith('.d.ts') || !/\.(?:cjs|js|jsx|mjs|ts|tsx)$/iu.test(relative)) {
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
return relative.split('/').some((component) => ['api', 'app', 'pages', 'routes', 'server', 'src'].includes(component))
|
|
207
|
+
}
|
|
208
|
+
|
|
142
209
|
function isWorkspaceDoctorReport(report: DoctorReport | WorkspaceDoctorReport): report is WorkspaceDoctorReport {
|
|
143
210
|
return 'projects' in report
|
|
144
211
|
}
|
|
@@ -7,6 +7,8 @@ import { readPackageJson, walkProjectFiles } from './project.ts'
|
|
|
7
7
|
import type { DoctorCheck, FrontendSourceReport, PackageManifest } from './types.ts'
|
|
8
8
|
|
|
9
9
|
const REQUIRED_FRONTEND_SCRIPTS = ['typecheck', 'lint'] as const
|
|
10
|
+
const FRONTEND_PAGES_API_PREFIXES: readonly (readonly string[])[] = [['pages', 'api'], ['src', 'pages', 'api']]
|
|
11
|
+
const FRONTEND_APP_API_PREFIXES: readonly (readonly string[])[] = [['app', 'api'], ['src', 'app', 'api']]
|
|
10
12
|
type FrontendScriptName = typeof REQUIRED_FRONTEND_SCRIPTS[number]
|
|
11
13
|
type CommandPredicate = (command: string) => boolean
|
|
12
14
|
|
|
@@ -15,6 +17,10 @@ function frontendLockfileExists(projectDir: string): boolean {
|
|
|
15
17
|
.some((file) => existsSync(path.join(projectDir, file)))
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
function isPlainStaticFrontend(projectDir: string): boolean {
|
|
21
|
+
return !existsSync(path.join(projectDir, 'package.json')) && existsSync(path.join(projectDir, 'index.html'))
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
function frontendPackageManager(projectDir: string): 'bun' | 'npm' {
|
|
19
25
|
return existsSync(path.join(projectDir, 'bun.lock')) || existsSync(path.join(projectDir, 'bun.lockb'))
|
|
20
26
|
? 'bun'
|
|
@@ -22,12 +28,18 @@ function frontendPackageManager(projectDir: string): 'bun' | 'npm' {
|
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
function frontendCheckCommand(projectDir: string): string {
|
|
31
|
+
if (isPlainStaticFrontend(projectDir)) {
|
|
32
|
+
return ':'
|
|
33
|
+
}
|
|
25
34
|
return frontendPackageManager(projectDir) === 'bun'
|
|
26
35
|
? DEFAULT_BUN_FRONTEND_CHECK_COMMAND
|
|
27
36
|
: DEFAULT_NPM_FRONTEND_CHECK_COMMAND
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
function frontendBuildCommand(projectDir: string): string {
|
|
40
|
+
if (isPlainStaticFrontend(projectDir)) {
|
|
41
|
+
return ':'
|
|
42
|
+
}
|
|
31
43
|
return frontendPackageManager(projectDir) === 'bun'
|
|
32
44
|
? 'bun run build'
|
|
33
45
|
: 'npm run build'
|
|
@@ -85,18 +97,26 @@ function frontendSourceChecks(projectDir: string): DoctorCheck[] {
|
|
|
85
97
|
message: report.typescript.length > 0 ? report.typescript.slice(0, 3).join(', ') : 'missing',
|
|
86
98
|
agent_instruction: report.typescript.length > 0 ? null : 'Add browser source as .ts or .tsx under src, app, pages, routes, or components, then retry.'
|
|
87
99
|
},
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
ok: report.javascript.length === 0,
|
|
91
|
-
message: report.javascript.length === 0 ? 'none found' : report.javascript.slice(0, 5).join(', '),
|
|
92
|
-
agent_instruction: report.javascript.length === 0 ? null : 'Rename browser .js, .jsx, .mjs, or .cjs source files to .ts or .tsx and fix type errors before deploying.'
|
|
93
|
-
}
|
|
100
|
+
forbiddenSourceCheck('javascript source', report.javascript, 'Rename browser .js, .jsx, .mjs, or .cjs source files to .ts or .tsx and fix type errors before deploying.'),
|
|
101
|
+
forbiddenSourceCheck('frontend server routes', report.serverRoutes, 'Move API routes, SSR handlers, middleware, and server logic to the Rust backend; static frontend source may only contain browser code.')
|
|
94
102
|
]
|
|
95
103
|
}
|
|
96
104
|
|
|
105
|
+
function forbiddenSourceCheck(name: string, files: readonly string[], instruction: string): DoctorCheck {
|
|
106
|
+
return {
|
|
107
|
+
name,
|
|
108
|
+
ok: files.length === 0,
|
|
109
|
+
message: files.length === 0 ? 'none found' : files.slice(0, 5).join(', '),
|
|
110
|
+
agent_instruction: files.length === 0 ? null : instruction
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
function frontendSourceReport(projectDir: string): FrontendSourceReport {
|
|
98
|
-
const report: FrontendSourceReport = { typescript: [], javascript: [] }
|
|
115
|
+
const report: FrontendSourceReport = { typescript: [], javascript: [], serverRoutes: [] }
|
|
99
116
|
walkProjectFiles(projectDir, (_file, relative) => {
|
|
117
|
+
if (isFrontendServerRoute(relative)) {
|
|
118
|
+
report.serverRoutes.push(relative)
|
|
119
|
+
}
|
|
100
120
|
const sourceKind = frontendSourceKind(relative)
|
|
101
121
|
if (sourceKind) {
|
|
102
122
|
report[sourceKind].push(relative)
|
|
@@ -128,6 +148,28 @@ function isFrontendJavascriptSource(relative: string): boolean {
|
|
|
128
148
|
return FRONTEND_JAVASCRIPT_EXTENSIONS.some((extension) => relative.endsWith(extension))
|
|
129
149
|
}
|
|
130
150
|
|
|
151
|
+
function isFrontendServerRoute(relative: string): boolean {
|
|
152
|
+
if (!isFrontendTypescriptSource(relative) && !isFrontendJavascriptSource(relative)) {
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
const parts = relative.toLowerCase().split('/')
|
|
156
|
+
const file = parts.at(-1) ?? ''
|
|
157
|
+
return isFrontendServerHandlerFile(file) || isFrontendApiRoute(parts, file)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isFrontendServerHandlerFile(file: string): boolean {
|
|
161
|
+
return file.startsWith('+server.') || file.startsWith('middleware.')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isFrontendApiRoute(pathParts: readonly string[], file: string): boolean {
|
|
165
|
+
return FRONTEND_PAGES_API_PREFIXES.some((prefix) => pathStartsWith(pathParts, prefix))
|
|
166
|
+
|| (file.startsWith('route.') && FRONTEND_APP_API_PREFIXES.some((prefix) => pathStartsWith(pathParts, prefix)))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function pathStartsWith(pathParts: readonly string[], prefix: readonly string[]): boolean {
|
|
170
|
+
return pathParts.length >= prefix.length && prefix.every((part, index) => pathParts[index] === part)
|
|
171
|
+
}
|
|
172
|
+
|
|
131
173
|
function packageScriptValue(manifest: PackageManifest | null, script: string): string {
|
|
132
174
|
const value = manifest?.scripts?.[script]
|
|
133
175
|
return typeof value === 'string' ? value.trim() : ''
|
|
@@ -269,4 +311,4 @@ function packageScriptCheck(projectDir: string, script: string): DoctorCheck {
|
|
|
269
311
|
}
|
|
270
312
|
}
|
|
271
313
|
|
|
272
|
-
export { frontendLockfileExists, frontendCheckCommand, frontendBuildCommand, frontendScriptChecks, frontendSourceChecks, usesJavascriptLinter, commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun }
|
|
314
|
+
export { frontendLockfileExists, isPlainStaticFrontend, frontendCheckCommand, frontendBuildCommand, frontendScriptChecks, frontendSourceChecks, usesJavascriptLinter, commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun }
|
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,15 +47,25 @@ export interface PackageManifest {
|
|
|
47
47
|
|
|
48
48
|
export type BuildConfig = JsonObject & { command: string; check: string; output?: string }
|
|
49
49
|
export type RunConfig = JsonObject & { command?: string; port: number; health: string }
|
|
50
|
+
export type FrontendConfig = JsonObject & { root?: string; check?: string; build?: string; output?: string }
|
|
51
|
+
export type BackendConfig = JsonObject & { root?: string; check?: string; build?: string; command?: string; port?: number; health?: string }
|
|
50
52
|
export type ResourceConfig = JsonObject & { memory: string; cpu: string; idle_timeout_minutes: number }
|
|
51
|
-
export type TovukConfig = JsonObject & {
|
|
53
|
+
export type TovukConfig = JsonObject & {
|
|
54
|
+
name?: string
|
|
55
|
+
kind: ProjectKind
|
|
56
|
+
build: BuildConfig
|
|
57
|
+
run: RunConfig
|
|
58
|
+
frontend: FrontendConfig
|
|
59
|
+
backend: BackendConfig
|
|
60
|
+
resources: ResourceConfig
|
|
61
|
+
}
|
|
52
62
|
export type DoctorCheck = JsonObject & { name: string; ok: boolean; message: string; agent_instruction: string | null }
|
|
53
63
|
export type DoctorReport = JsonObject & { ok: boolean; project: string; config: TovukConfig | null; checks: DoctorCheck[] }
|
|
54
64
|
export type ProjectDoctorReport = DoctorReport & { relative: string }
|
|
55
65
|
export type WorkspaceDoctorReport = JsonObject & { ok: boolean; workspace: string; projects: ProjectDoctorReport[] }
|
|
56
66
|
export type DeployProjectInfo = { dir: string; relative: string; name: string; kind: DiscoveredProjectKind }
|
|
57
67
|
export type DeployPlanProject = { project: DeployProjectInfo; wantsDatabase: boolean }
|
|
58
|
-
export type FrontendSourceReport = { typescript: string[]; javascript: string[] }
|
|
68
|
+
export type FrontendSourceReport = { typescript: string[]; javascript: string[]; serverRoutes: string[] }
|
|
59
69
|
export type LoginStartResponse = JsonObject & { loginUrl?: string; userCode?: string; deviceCode?: string; expiresInSeconds?: number; intervalSeconds?: number }
|
|
60
70
|
export type LoginPollResponse = JsonObject & { status?: string; token?: string; email?: string; intervalSeconds?: number }
|
|
61
71
|
export type AppSummary = JsonObject & { id?: string; name?: string; url?: string; databaseStorageMib?: number }
|
|
@@ -52,10 +52,13 @@ function kindOrder(kind: DiscoveredProjectKind): number {
|
|
|
52
52
|
if (kind === 'rust_backend') {
|
|
53
53
|
return 0
|
|
54
54
|
}
|
|
55
|
-
if (kind === '
|
|
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 }
|