tovuk 0.1.47
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 +72 -0
- package/package.json +58 -0
- package/src/internal/agent-error-enrichment.ts +94 -0
- package/src/internal/api-models.ts +133 -0
- package/src/internal/api.ts +77 -0
- package/src/internal/archive.ts +35 -0
- package/src/internal/args.ts +185 -0
- package/src/internal/auth.ts +281 -0
- package/src/internal/checks.ts +12 -0
- package/src/internal/commands.ts +298 -0
- package/src/internal/config-parser.ts +215 -0
- package/src/internal/config-validation.ts +94 -0
- package/src/internal/config.ts +2 -0
- package/src/internal/constants.ts +153 -0
- package/src/internal/deploy-plan.ts +89 -0
- package/src/internal/deploy.ts +154 -0
- package/src/internal/doctor.ts +146 -0
- package/src/internal/errors.ts +46 -0
- package/src/internal/frontend-policy.ts +272 -0
- package/src/internal/json.ts +103 -0
- package/src/internal/preview.ts +116 -0
- package/src/internal/project.ts +135 -0
- package/src/internal/rust-doctor.ts +157 -0
- package/src/internal/template-sources.ts +197 -0
- package/src/internal/templates.ts +151 -0
- package/src/internal/types.ts +74 -0
- package/src/internal/workspace.ts +61 -0
- package/src/tovuk.ts +71 -0
- package/tsconfig.json +48 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { DEFAULT_RUST_CHECK_COMMAND, PROJECT_KINDS } from './constants.ts'
|
|
2
|
+
import { frontendBuildCommand, frontendCheckCommand } from './frontend-policy.ts'
|
|
3
|
+
import type { BuildConfig, ProjectKind, ResourceConfig, RunConfig, TovukConfig } from './types.ts'
|
|
4
|
+
|
|
5
|
+
type SectionName = 'build' | 'resources' | 'root' | 'run'
|
|
6
|
+
type TomlValue = boolean | number | string
|
|
7
|
+
type SectionAssigner = (config: MutableTovukConfig, key: string, value: TomlValue) => void
|
|
8
|
+
|
|
9
|
+
interface MutableTovukConfig {
|
|
10
|
+
name?: string
|
|
11
|
+
kind?: ProjectKind
|
|
12
|
+
build: Partial<BuildConfig>
|
|
13
|
+
run: Partial<RunConfig>
|
|
14
|
+
resources: Partial<ResourceConfig>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SECTION_ASSIGNERS: Readonly<Record<SectionName, SectionAssigner>> = {
|
|
18
|
+
build: (config, key, value): void => assignBuildValue(config.build, key, value),
|
|
19
|
+
resources: (config, key, value): void => assignResourceValue(config.resources, key, value),
|
|
20
|
+
root: assignRootValue,
|
|
21
|
+
run: (config, key, value): void => assignRunValue(config.run, key, value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseTovukToml(source: string, projectDir: string): TovukConfig {
|
|
25
|
+
const config: MutableTovukConfig = {
|
|
26
|
+
build: {},
|
|
27
|
+
run: {},
|
|
28
|
+
resources: {}
|
|
29
|
+
}
|
|
30
|
+
let section: SectionName = 'root'
|
|
31
|
+
|
|
32
|
+
for (const rawLine of source.split(/\r?\n/u)) {
|
|
33
|
+
const parsedLine = parseTomlLine(rawLine)
|
|
34
|
+
if (parsedLine === null) {
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (parsedLine.kind === 'section') {
|
|
38
|
+
section = parsedLine.section
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
assignTomlValue(config, section, parsedLine.key, parsedLine.value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return tovukConfig(config, projectDir)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tovukConfig(config: MutableTovukConfig, projectDir: string): TovukConfig {
|
|
48
|
+
const kind = config.kind ?? 'rust_backend'
|
|
49
|
+
const result: TovukConfig = {
|
|
50
|
+
kind,
|
|
51
|
+
build: buildConfig(config, kind, projectDir),
|
|
52
|
+
run: runConfig(config),
|
|
53
|
+
resources: resourceConfig(config)
|
|
54
|
+
}
|
|
55
|
+
if (config.name) {
|
|
56
|
+
result.name = config.name
|
|
57
|
+
}
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseTomlLine(rawLine: string): { kind: 'section'; section: SectionName } | { kind: 'assignment'; key: string; value: TomlValue } | null {
|
|
62
|
+
const line = rawLine.trim()
|
|
63
|
+
if (!line || line.startsWith('#')) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
const sectionMatch = line.match(/^\[([a-z_]+)\]$/u)
|
|
67
|
+
if (sectionMatch) {
|
|
68
|
+
return { kind: 'section', section: parseSection(sectionMatch[1] ?? '') }
|
|
69
|
+
}
|
|
70
|
+
const assignment = line.match(/^([a-z_]+)\s*=\s*(.+)$/u)
|
|
71
|
+
if (assignment) {
|
|
72
|
+
return { kind: 'assignment', key: assignment[1] ?? '', value: parseTomlValue(assignment[2] ?? '') }
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`invalid line: ${line}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseSection(value: string): SectionName {
|
|
78
|
+
if (value === 'build' || value === 'run' || value === 'resources') {
|
|
79
|
+
return value
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`unsupported section [${value}]`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assignTomlValue(config: MutableTovukConfig, section: SectionName, key: string, value: TomlValue): void {
|
|
85
|
+
SECTION_ASSIGNERS[section](config, key, value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assignRootValue(config: MutableTovukConfig, key: string, value: TomlValue): void {
|
|
89
|
+
if (key === 'name') {
|
|
90
|
+
config.name = expectString(key, value)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (key === 'kind') {
|
|
94
|
+
config.kind = expectProjectKind(expectString(key, value))
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`unsupported root key ${key}`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assignBuildValue(build: Partial<BuildConfig>, key: string, value: TomlValue): void {
|
|
101
|
+
if (key === 'command' || key === 'check' || key === 'output') {
|
|
102
|
+
build[key] = expectString(key, value)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`unsupported [build] key ${key}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assignRunValue(run: Partial<RunConfig>, key: string, value: TomlValue): void {
|
|
109
|
+
if (key === 'command' || key === 'health') {
|
|
110
|
+
run[key] = expectString(key, value)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (key === 'port') {
|
|
114
|
+
run.port = expectNumber(key, value)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`unsupported [run] key ${key}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function assignResourceValue(resources: Partial<ResourceConfig>, key: string, value: TomlValue): void {
|
|
121
|
+
if (key === 'memory' || key === 'cpu') {
|
|
122
|
+
resources[key] = expectString(key, value)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (key === 'idle_timeout_minutes') {
|
|
126
|
+
resources.idle_timeout_minutes = expectNumber(key, value)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`unsupported [resources] key ${key}`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function expectString(key: string, value: TomlValue): string {
|
|
133
|
+
if (typeof value === 'string') {
|
|
134
|
+
return value
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`${key} must be a string`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function expectNumber(key: string, value: TomlValue): number {
|
|
140
|
+
if (typeof value === 'number') {
|
|
141
|
+
return value
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`${key} must be a number`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function expectProjectKind(value: string): ProjectKind {
|
|
147
|
+
if (isProjectKind(value)) {
|
|
148
|
+
return value
|
|
149
|
+
}
|
|
150
|
+
throw new Error('kind must be rust_backend or static_frontend')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isProjectKind(value: string): value is ProjectKind {
|
|
154
|
+
return PROJECT_KINDS.has(value)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseTomlValue(raw: string): TomlValue {
|
|
158
|
+
const value = raw.trim()
|
|
159
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
160
|
+
return value.slice(1, -1).replace(/\\"/gu, '"')
|
|
161
|
+
}
|
|
162
|
+
if (value === 'true') {
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
if (value === 'false') {
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
if (/^\d+$/u.test(value)) {
|
|
169
|
+
return Number(value)
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`unsupported TOML value: ${value}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildConfig(config: MutableTovukConfig, kind: ProjectKind, projectDir: string): BuildConfig {
|
|
175
|
+
const build: BuildConfig = {
|
|
176
|
+
check: config.build.check ?? defaultCheckCommand(kind, projectDir),
|
|
177
|
+
command: config.build.command ?? defaultBuildCommand(kind, projectDir)
|
|
178
|
+
}
|
|
179
|
+
const output = kind === 'static_frontend'
|
|
180
|
+
? config.build.output ?? 'dist'
|
|
181
|
+
: config.build.output
|
|
182
|
+
if (typeof output === 'string') {
|
|
183
|
+
build.output = output
|
|
184
|
+
}
|
|
185
|
+
return build
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function defaultCheckCommand(kind: ProjectKind, projectDir: string): string {
|
|
189
|
+
return kind === 'static_frontend' ? frontendCheckCommand(projectDir) : DEFAULT_RUST_CHECK_COMMAND
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function defaultBuildCommand(kind: ProjectKind, projectDir: string): string {
|
|
193
|
+
return kind === 'static_frontend' ? frontendBuildCommand(projectDir) : 'cargo build --release'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function runConfig(config: MutableTovukConfig): RunConfig {
|
|
197
|
+
const run: RunConfig = {
|
|
198
|
+
port: config.run.port ?? 3000,
|
|
199
|
+
health: config.run.health ?? '/healthz'
|
|
200
|
+
}
|
|
201
|
+
if (typeof config.run.command === 'string') {
|
|
202
|
+
run.command = config.run.command
|
|
203
|
+
}
|
|
204
|
+
return run
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resourceConfig(config: MutableTovukConfig): ResourceConfig {
|
|
208
|
+
return {
|
|
209
|
+
memory: config.resources.memory ?? '512mb',
|
|
210
|
+
cpu: config.resources.cpu ?? '0.25',
|
|
211
|
+
idle_timeout_minutes: config.resources.idle_timeout_minutes ?? 15
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { parseTovukToml }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { PROJECT_KINDS } from './constants.ts'
|
|
2
|
+
import { commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun, usesJavascriptLinter } from './frontend-policy.ts'
|
|
3
|
+
import { isSafeRelativePath } from './project.ts'
|
|
4
|
+
import type { ProjectKind, TovukConfig } from './types.ts'
|
|
5
|
+
|
|
6
|
+
function validateConfig(config: TovukConfig): void {
|
|
7
|
+
validateIdentity(config)
|
|
8
|
+
validateBuildConfig(config)
|
|
9
|
+
if (config.kind === 'static_frontend') {
|
|
10
|
+
validateStaticFrontendConfig(config)
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
validateRustBackendConfig(config)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function validateIdentity(config: TovukConfig): void {
|
|
17
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/u.test(config.name ?? '')) {
|
|
18
|
+
throw new Error('name must be lowercase DNS-safe text up to 48 characters')
|
|
19
|
+
}
|
|
20
|
+
if (!PROJECT_KINDS.has(config.kind)) {
|
|
21
|
+
throw new Error('kind must be rust_backend or static_frontend')
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateBuildConfig(config: TovukConfig): void {
|
|
26
|
+
if (!config.build.command.trim()) {
|
|
27
|
+
throw new Error('[build].command is required')
|
|
28
|
+
}
|
|
29
|
+
if (!config.build.check.trim()) {
|
|
30
|
+
throw new Error('[build].check is required')
|
|
31
|
+
}
|
|
32
|
+
validateCheckCommand(config.kind, config.build.check)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateStaticFrontendConfig(config: TovukConfig): void {
|
|
36
|
+
if (typeof config.build.output !== 'string' || !isSafeRelativePath(config.build.output)) {
|
|
37
|
+
throw new Error('[build].output must be a safe relative directory like dist')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateRustBackendConfig(config: TovukConfig): void {
|
|
42
|
+
if (config.build.output) {
|
|
43
|
+
throw new Error('[build].output is only valid for static_frontend')
|
|
44
|
+
}
|
|
45
|
+
if (!config.run.command?.trim()) {
|
|
46
|
+
throw new Error('[run].command is required')
|
|
47
|
+
}
|
|
48
|
+
if (!Number.isInteger(config.run.port) || config.run.port < 1 || config.run.port > 65535) {
|
|
49
|
+
throw new Error('[run].port must be between 1 and 65535')
|
|
50
|
+
}
|
|
51
|
+
if (!config.run.health.startsWith('/')) {
|
|
52
|
+
throw new Error('[run].health must be an absolute path')
|
|
53
|
+
}
|
|
54
|
+
validateResourceConfig(config)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateResourceConfig(config: TovukConfig): void {
|
|
58
|
+
if (!/^\d+\s*(mb|mib|gb|gib)$/iu.test(config.resources.memory)) {
|
|
59
|
+
throw new Error('[resources].memory must look like 512mb or 1gb')
|
|
60
|
+
}
|
|
61
|
+
if (!/^\d+(?:\.\d{1,3})?$/u.test(config.resources.cpu)) {
|
|
62
|
+
throw new Error('[resources].cpu must look like 0.25, 0.5, 1, or 2')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateCheckCommand(kind: ProjectKind, command: string): void {
|
|
67
|
+
if (kind === 'static_frontend') {
|
|
68
|
+
validateFrontendCheckCommand(command)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const required = ['cargo fmt --all --check', 'cargo check --locked', 'cargo clippy --locked', '--all-targets', '--all-features', '-D warnings']
|
|
73
|
+
if (required.every((fragment) => command.includes(fragment))) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
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
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateFrontendCheckCommand(command: string): void {
|
|
80
|
+
if (usesJavascriptLinter(command)) {
|
|
81
|
+
throw new Error('[build].check must not run JavaScript-based lint or format tooling; use oxlint, biome, or deno lint')
|
|
82
|
+
}
|
|
83
|
+
const tokens = commandTokens(command)
|
|
84
|
+
if (
|
|
85
|
+
hasFrontendInstallCommand(tokens) &&
|
|
86
|
+
hasFrontendScriptRun(tokens, 'typecheck') &&
|
|
87
|
+
hasFrontendScriptRun(tokens, 'lint')
|
|
88
|
+
) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
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
|
+
}
|
|
93
|
+
|
|
94
|
+
export { validateConfig }
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
interface PackageVersionManifest {
|
|
4
|
+
version: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const VERSION: string = packageVersion()
|
|
8
|
+
export const DEFAULT_API_URL: string = 'https://api.tovuk.com'
|
|
9
|
+
export const ARCHIVE_LIMIT_BYTES: number = 48 * 1024 * 1024
|
|
10
|
+
export const DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS: number = 900
|
|
11
|
+
export const SESSION_DIR: string = '.tovuk'
|
|
12
|
+
export const SESSION_FILE: string = 'session-token'
|
|
13
|
+
export const SESSION_SERVICE: string = 'com.tovuk.cli'
|
|
14
|
+
export const SESSION_ACCOUNT: string = 'session-token'
|
|
15
|
+
export const SESSION_LABEL: string = 'Tovuk session'
|
|
16
|
+
export const DEFAULT_LOGIN_EXPIRES_SECONDS: number = 600
|
|
17
|
+
export const DEFAULT_LOGIN_INTERVAL_SECONDS: number = 5
|
|
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
|
+
export const DEFAULT_NPM_FRONTEND_CHECK_COMMAND: string = 'npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint'
|
|
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'])
|
|
22
|
+
export const PROJECT_TEMPLATES: ReadonlySet<string> = new Set(['rust-api', 'tanstack-static-frontend', 'fullstack-rust-tanstack'])
|
|
23
|
+
export const JAVASCRIPT_LINTERS: ReadonlySet<string> = new Set(['eslint', 'eslint_d', 'jscs', 'jshint', 'prettier', 'prettierd', 'standard', 'xo'])
|
|
24
|
+
export const FRONTEND_SOURCE_ROOTS: ReadonlySet<string> = new Set(['src', 'app', 'pages', 'routes', 'components'])
|
|
25
|
+
export const FRONTEND_JAVASCRIPT_EXTENSIONS: readonly string[] = ['.js', '.jsx', '.mjs', '.cjs']
|
|
26
|
+
export const FRONTEND_PACKAGE_MANAGERS: ReadonlySet<string> = new Set(['npm', 'bun', 'pnpm', 'yarn'])
|
|
27
|
+
export const FRONTEND_INSTALL_COMMANDS: ReadonlySet<string> = new Set(['npm ci', 'bun ci', 'bun install', 'pnpm install', 'yarn install'])
|
|
28
|
+
export const ARCHIVE_EXCLUDES: readonly string[] = [
|
|
29
|
+
'.git',
|
|
30
|
+
'target',
|
|
31
|
+
'node_modules',
|
|
32
|
+
'.tovuk',
|
|
33
|
+
'.env',
|
|
34
|
+
'.env.*',
|
|
35
|
+
'.npmrc',
|
|
36
|
+
'.pypirc',
|
|
37
|
+
'.netrc',
|
|
38
|
+
'.docker',
|
|
39
|
+
'.gnupg',
|
|
40
|
+
'.terraform',
|
|
41
|
+
'.terraformrc',
|
|
42
|
+
'.ssh',
|
|
43
|
+
'.aws',
|
|
44
|
+
'.azure',
|
|
45
|
+
'.kube',
|
|
46
|
+
'.pulumi',
|
|
47
|
+
'.cargo/credentials',
|
|
48
|
+
'.cargo/credentials.toml',
|
|
49
|
+
'.config/gcloud',
|
|
50
|
+
'.config/gh',
|
|
51
|
+
'.config/hub',
|
|
52
|
+
'.config/heroku',
|
|
53
|
+
'.config/doctl',
|
|
54
|
+
'*.pem',
|
|
55
|
+
'*.key',
|
|
56
|
+
'*.p12',
|
|
57
|
+
'*.pfx',
|
|
58
|
+
'*.tfstate',
|
|
59
|
+
'*.tfstate.*',
|
|
60
|
+
'id_rsa',
|
|
61
|
+
'id_ed25519',
|
|
62
|
+
'*.sqlite',
|
|
63
|
+
'*.sqlite3',
|
|
64
|
+
'*.db',
|
|
65
|
+
'*.log',
|
|
66
|
+
'._*',
|
|
67
|
+
'.DS_Store'
|
|
68
|
+
]
|
|
69
|
+
export const WALK_EXCLUDED_DIRS: ReadonlySet<string> = new Set([
|
|
70
|
+
'.git',
|
|
71
|
+
'target',
|
|
72
|
+
'node_modules',
|
|
73
|
+
'.tovuk',
|
|
74
|
+
'.terraform',
|
|
75
|
+
'.docker',
|
|
76
|
+
'.gnupg',
|
|
77
|
+
'.ssh',
|
|
78
|
+
'.aws',
|
|
79
|
+
'.azure',
|
|
80
|
+
'.kube',
|
|
81
|
+
'.pulumi'
|
|
82
|
+
])
|
|
83
|
+
export const WORKSPACE_EXCLUDED_DIRS: ReadonlySet<string> = new Set([
|
|
84
|
+
...WALK_EXCLUDED_DIRS,
|
|
85
|
+
'.cache',
|
|
86
|
+
'.next',
|
|
87
|
+
'.turbo',
|
|
88
|
+
'build',
|
|
89
|
+
'coverage',
|
|
90
|
+
'dist',
|
|
91
|
+
'vendor'
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
export const HELP: string = `Tovuk ${VERSION}
|
|
95
|
+
|
|
96
|
+
Usage:
|
|
97
|
+
tovuk init [path] [--template rust-api|tanstack-static-frontend|fullstack-rust-tanstack]
|
|
98
|
+
tovuk install [path] [--template rust-api|tanstack-static-frontend|fullstack-rust-tanstack]
|
|
99
|
+
tovuk doctor [path] [--json]
|
|
100
|
+
tovuk preview [path] [--port <port>]
|
|
101
|
+
tovuk login [--token <token>] [--api <url>]
|
|
102
|
+
tovuk deploy [path] [--database] [--wait] [--wait-timeout <seconds>] [--api <url>] [--json]
|
|
103
|
+
tovuk capabilities [--api <url>] [--json]
|
|
104
|
+
tovuk me [--api <url>] [--json]
|
|
105
|
+
tovuk usage [--api <url>] [--json]
|
|
106
|
+
tovuk activity [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
107
|
+
tovuk apps [--api <url>] [--json]
|
|
108
|
+
tovuk overview --app <app> [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
109
|
+
tovuk deploys [--app <app>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
110
|
+
tovuk builds [--app <app>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
111
|
+
tovuk logs --app <app> [--deploy <deploy_id>] [--build <build_id>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
112
|
+
tovuk status --app <app> [--api <url>] [--json]
|
|
113
|
+
tovuk inspect --app <app> [--api <url>] [--json]
|
|
114
|
+
tovuk db --app <app> [--api <url>] [--json]
|
|
115
|
+
tovuk env list --app <app> [--api <url>] [--json]
|
|
116
|
+
tovuk env set --app <app> KEY=value [--api <url>] [--json]
|
|
117
|
+
tovuk env delete --app <app> KEY [--api <url>] [--json]
|
|
118
|
+
tovuk domains list --app <app> [--api <url>] [--json]
|
|
119
|
+
tovuk domains add --app <app> <domain> [--api <url>] [--json]
|
|
120
|
+
tovuk domains verify --app <app> <domain> [--api <url>] [--json]
|
|
121
|
+
tovuk domains delete --app <app> <domain> [--api <url>] [--json]
|
|
122
|
+
tovuk billing [checkout|portal] [reason] [--api <url>] [--json]
|
|
123
|
+
tovuk support list [--limit <n>] [--api <url>] [--json]
|
|
124
|
+
tovuk support create "Subject" "Details" [--app <app>] [--build <build_id>] [--deploy <deploy_id>] [--failing-command <command>] [--first-log-line <line>] [--severity low|normal|urgent] [--api <url>] [--json]
|
|
125
|
+
tovuk support resolve <ticket_id> [--api <url>] [--json]
|
|
126
|
+
|
|
127
|
+
Agent contract:
|
|
128
|
+
- Rust backends keep Cargo.lock committed, pass rustfmt, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
|
|
129
|
+
- Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, stable native typecheck, native lint, and Fallow quality gates.
|
|
130
|
+
- Frontends call Rust backends for APIs, managed Postgres, and server-side logic.
|
|
131
|
+
- Run deploy from a repo root with nested tovuk.toml files to deploy the whole workspace in one command.
|
|
132
|
+
- When a frontend calls a backend on another hostname, configure backend CORS or use a same-origin custom domain.
|
|
133
|
+
- When a plan limit blocks work, run tovuk billing checkout --json and show the returned URL to the human.
|
|
134
|
+
- Create support tickets only with command output, app id, build id, deploy id, and the first actionable log line.
|
|
135
|
+
- Resolve support tickets after the issue is fixed so later agents do not duplicate work.
|
|
136
|
+
- Keep direct unsafe out of Rust source.
|
|
137
|
+
`
|
|
138
|
+
|
|
139
|
+
function packageVersion(): string {
|
|
140
|
+
const manifest: unknown = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
141
|
+
if (isPackageVersionManifest(manifest)) {
|
|
142
|
+
return manifest.version
|
|
143
|
+
}
|
|
144
|
+
throw new Error('packages/tovuk/package.json must include a version string')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isPackageVersionManifest(value: unknown): value is PackageVersionManifest {
|
|
148
|
+
return typeof value === 'object'
|
|
149
|
+
&& value !== null
|
|
150
|
+
&& !Array.isArray(value)
|
|
151
|
+
&& 'version' in value
|
|
152
|
+
&& typeof value.version === 'string'
|
|
153
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { agentError } from './errors.ts'
|
|
2
|
+
import { paymentRequiredAgentError } from './agent-error-enrichment.ts'
|
|
3
|
+
import { apiRequest } from './api.ts'
|
|
4
|
+
import { appsResponseFromJson } from './api-models.ts'
|
|
5
|
+
import { jsonObjectField, jsonObjectOrEmpty, numberField } from './json.ts'
|
|
6
|
+
import type { AppSummary, CliOptions, DeployPlanProject, DeployProjectInfo } from './types.ts'
|
|
7
|
+
|
|
8
|
+
async function createDeployPlan(projects: DeployProjectInfo[], cli: CliOptions, token: string): Promise<DeployPlanProject[]> {
|
|
9
|
+
const plan = projects.map((project) => ({
|
|
10
|
+
project,
|
|
11
|
+
wantsDatabase: cli.database && project.kind === 'rust_backend'
|
|
12
|
+
}))
|
|
13
|
+
rejectInvalidDatabaseTargets(plan, cli)
|
|
14
|
+
await preflightDeployLimits(plan, cli, token)
|
|
15
|
+
return plan
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function rejectInvalidDatabaseTargets(plan: DeployPlanProject[], cli: CliOptions): void {
|
|
19
|
+
if (cli.database && plan.length === 1 && plan[0]?.project.kind === 'static_frontend') {
|
|
20
|
+
throw agentError('invalid_database_target', 'Static frontends cannot attach managed Postgres directly.', 'Deploy a Rust backend with managed Postgres and call it from the frontend.', cli.json)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function preflightDeployLimits(plan: DeployPlanProject[], cli: CliOptions, token: string): Promise<void> {
|
|
25
|
+
const [usageResponse, appsResponse] = await Promise.all([
|
|
26
|
+
apiRequest(cli, 'GET', '/v1/usage', token, null),
|
|
27
|
+
apiRequest(cli, 'GET', '/v1/apps', token, null)
|
|
28
|
+
])
|
|
29
|
+
const usageRoot = jsonObjectOrEmpty(usageResponse)
|
|
30
|
+
const usage = jsonObjectField(usageRoot, 'usage')
|
|
31
|
+
const limits = jsonObjectField(usageRoot, 'limits')
|
|
32
|
+
const existingApps = appNameMap(appsResponseFromJson(appsResponse).apps)
|
|
33
|
+
const requested = requestedNewResources(plan, existingApps)
|
|
34
|
+
|
|
35
|
+
const usedProjects = numberField(usage, 'appCount')
|
|
36
|
+
const projectLimit = numberField(limits, 'projects')
|
|
37
|
+
const usedDatabases = numberField(usage, 'databaseCount')
|
|
38
|
+
const databaseLimit = numberField(limits, 'managedDatabases')
|
|
39
|
+
|
|
40
|
+
if (requested.projects > 0 && usedProjects + requested.projects > projectLimit) {
|
|
41
|
+
throw await paymentRequiredAgentError(
|
|
42
|
+
cli,
|
|
43
|
+
token,
|
|
44
|
+
`Project limit reached: ${usedProjects}/${projectLimit} projects are already used.`,
|
|
45
|
+
'Redeploy an existing app by reusing its `name` in tovuk.toml, or open the returned Stripe Checkout URL before creating another project.'
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (requested.databases > 0 && usedDatabases + requested.databases > databaseLimit) {
|
|
50
|
+
throw await paymentRequiredAgentError(
|
|
51
|
+
cli,
|
|
52
|
+
token,
|
|
53
|
+
`Managed Postgres limit reached: ${usedDatabases}/${databaseLimit} databases are already used.`,
|
|
54
|
+
'Redeploy an app that already has managed Postgres, deploy without `--database`, or open the returned Stripe Checkout URL.'
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function appNameMap(apps: AppSummary[]): Map<string, AppSummary> {
|
|
60
|
+
const existingApps = new Map<string, AppSummary>()
|
|
61
|
+
for (const app of apps) {
|
|
62
|
+
if (app.name) {
|
|
63
|
+
existingApps.set(app.name, app)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return existingApps
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function requestedNewResources(plan: DeployPlanProject[], existingApps: Map<string, AppSummary>): { projects: number; databases: number } {
|
|
70
|
+
let projects = 0
|
|
71
|
+
let databases = 0
|
|
72
|
+
|
|
73
|
+
for (const target of plan) {
|
|
74
|
+
if (!target.project.name || target.project.kind === 'unknown') {
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
const existing = existingApps.get(target.project.name)
|
|
78
|
+
if (!existing) {
|
|
79
|
+
projects += 1
|
|
80
|
+
}
|
|
81
|
+
if (target.wantsDatabase && existing?.databaseStorageMib === undefined) {
|
|
82
|
+
databases += 1
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { projects, databases }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { createDeployPlan }
|