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,197 @@
|
|
|
1
|
+
import type { JsonObject } from './types.ts'
|
|
2
|
+
|
|
3
|
+
function frontendPackageJson(name: string): string {
|
|
4
|
+
return jsonSource({
|
|
5
|
+
name,
|
|
6
|
+
private: true,
|
|
7
|
+
type: 'module',
|
|
8
|
+
scripts: {
|
|
9
|
+
typecheck: 'oxlint src vite.config.ts --deny-warnings --type-aware --type-check --tsconfig tsconfig.json',
|
|
10
|
+
lint: 'oxlint src vite.config.ts --deny-warnings && fallow dead-code --production --include-dupes --include-entry-exports --fail-on-issues && fallow dupes --production --mode semantic --threshold 1 --ignore-imports --fail-on-issues && fallow health --production --max-cyclomatic 10 --max-cognitive 15 --max-crap 20 --complexity',
|
|
11
|
+
build: 'vite build',
|
|
12
|
+
preview: 'vite preview --host 0.0.0.0'
|
|
13
|
+
},
|
|
14
|
+
dependencies: {
|
|
15
|
+
'@tanstack/react-router': '^1.170.8',
|
|
16
|
+
react: '^19.2.6',
|
|
17
|
+
'react-dom': '^19.2.6'
|
|
18
|
+
},
|
|
19
|
+
devDependencies: {
|
|
20
|
+
'@types/node': '^25.9.1',
|
|
21
|
+
'@types/react': '^19.2.15',
|
|
22
|
+
'@types/react-dom': '^19.2.3',
|
|
23
|
+
'@vitejs/plugin-react': '^6.0.2',
|
|
24
|
+
fallow: '^2.84.0',
|
|
25
|
+
oxlint: '^1.67.0',
|
|
26
|
+
'oxlint-tsgolint': '^0.23.0',
|
|
27
|
+
vite: '^8.0.14'
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function frontendTsConfig(): string {
|
|
33
|
+
return jsonSource({
|
|
34
|
+
compilerOptions: {
|
|
35
|
+
allowUnreachableCode: false,
|
|
36
|
+
allowUnusedLabels: false,
|
|
37
|
+
alwaysStrict: true,
|
|
38
|
+
erasableSyntaxOnly: true,
|
|
39
|
+
exactOptionalPropertyTypes: true,
|
|
40
|
+
forceConsistentCasingInFileNames: true,
|
|
41
|
+
isolatedModules: true,
|
|
42
|
+
jsx: 'react-jsx',
|
|
43
|
+
lib: ['ESNext', 'DOM'],
|
|
44
|
+
module: 'ESNext',
|
|
45
|
+
moduleDetection: 'force',
|
|
46
|
+
moduleResolution: 'Bundler',
|
|
47
|
+
noEmit: true,
|
|
48
|
+
noFallthroughCasesInSwitch: true,
|
|
49
|
+
noImplicitAny: true,
|
|
50
|
+
noImplicitOverride: true,
|
|
51
|
+
noImplicitReturns: true,
|
|
52
|
+
noImplicitThis: true,
|
|
53
|
+
noPropertyAccessFromIndexSignature: true,
|
|
54
|
+
noUncheckedIndexedAccess: true,
|
|
55
|
+
noUncheckedSideEffectImports: true,
|
|
56
|
+
noUnusedLocals: true,
|
|
57
|
+
noUnusedParameters: true,
|
|
58
|
+
skipLibCheck: false,
|
|
59
|
+
strict: true,
|
|
60
|
+
strictBindCallApply: true,
|
|
61
|
+
strictFunctionTypes: true,
|
|
62
|
+
strictNullChecks: true,
|
|
63
|
+
strictPropertyInitialization: true,
|
|
64
|
+
target: 'ES2022',
|
|
65
|
+
types: ['vite/client', 'node'],
|
|
66
|
+
useUnknownInCatchVariables: true,
|
|
67
|
+
verbatimModuleSyntax: true
|
|
68
|
+
},
|
|
69
|
+
include: ['src', 'vite.config.ts']
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rustApiSource(): string {
|
|
74
|
+
return `use std::{
|
|
75
|
+
io::{Read, Write},
|
|
76
|
+
net::{TcpListener, TcpStream},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
fn main() -> std::io::Result<()> {
|
|
80
|
+
let port = std::env::var("PORT").unwrap_or_else(|_error| "3000".to_owned());
|
|
81
|
+
let listener = TcpListener::bind(format!("0.0.0.0:{port}"))?;
|
|
82
|
+
|
|
83
|
+
for stream in listener.incoming() {
|
|
84
|
+
handle(stream?)?;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
Ok(())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn handle(mut stream: TcpStream) -> std::io::Result<()> {
|
|
91
|
+
let mut buffer = [0_u8; 2048];
|
|
92
|
+
let size = stream.read(&mut buffer)?;
|
|
93
|
+
let request = String::from_utf8_lossy(&buffer[..size]);
|
|
94
|
+
let mut parts = request
|
|
95
|
+
.lines()
|
|
96
|
+
.next()
|
|
97
|
+
.unwrap_or_default()
|
|
98
|
+
.split_whitespace();
|
|
99
|
+
let method = parts.next().unwrap_or_default();
|
|
100
|
+
let path = parts.next().unwrap_or("/");
|
|
101
|
+
let origin = request
|
|
102
|
+
.lines()
|
|
103
|
+
.find_map(|line| line.strip_prefix("Origin: "))
|
|
104
|
+
.unwrap_or("*");
|
|
105
|
+
let cors_origin = allowed_origin(origin);
|
|
106
|
+
|
|
107
|
+
if method == "OPTIONS" {
|
|
108
|
+
return write_response(&mut stream, "204 No Content", "", &cors_origin);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let body = if path == "/healthz" {
|
|
112
|
+
r#"{"ok":true}"#
|
|
113
|
+
} else {
|
|
114
|
+
r#"{"message":"hello from tovuk","backend":"rust"}"#
|
|
115
|
+
};
|
|
116
|
+
write_response(&mut stream, "200 OK", body, &cors_origin)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fn allowed_origin(request_origin: &str) -> String {
|
|
120
|
+
let configured = std::env::var("FRONTEND_ORIGIN").unwrap_or_else(|_error| request_origin.to_owned());
|
|
121
|
+
if configured == "*" || configured == request_origin {
|
|
122
|
+
configured
|
|
123
|
+
} else {
|
|
124
|
+
"null".to_owned()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fn write_response(
|
|
129
|
+
stream: &mut TcpStream,
|
|
130
|
+
status: &str,
|
|
131
|
+
body: &str,
|
|
132
|
+
origin: &str,
|
|
133
|
+
) -> std::io::Result<()> {
|
|
134
|
+
write!(
|
|
135
|
+
stream,
|
|
136
|
+
"HTTP/1.1 {status}\\r\\ncontent-type: application/json\\r\\ncontent-length: {}\\r\\naccess-control-allow-origin: {origin}\\r\\naccess-control-allow-methods: GET, OPTIONS\\r\\naccess-control-allow-headers: content-type, authorization\\r\\nconnection: close\\r\\n\\r\\n{body}",
|
|
137
|
+
body.len()
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function frontendSource(apiBaseUrl: string): string {
|
|
144
|
+
return `import { createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router'
|
|
145
|
+
import { createRoot } from 'react-dom/client'
|
|
146
|
+
import './styles.css'
|
|
147
|
+
|
|
148
|
+
const apiBaseUrl = import.meta.env.VITE_API_URL ?? '${apiBaseUrl}'
|
|
149
|
+
|
|
150
|
+
function App() {
|
|
151
|
+
return (
|
|
152
|
+
<main>
|
|
153
|
+
<section>
|
|
154
|
+
<h1>Tovuk TanStack Frontend</h1>
|
|
155
|
+
<p>Static runtime, dynamic Rust backend calls.</p>
|
|
156
|
+
<code>{apiBaseUrl}</code>
|
|
157
|
+
</section>
|
|
158
|
+
</main>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rootRoute = createRootRoute({ component: App })
|
|
163
|
+
const router = createRouter({ routeTree: rootRoute })
|
|
164
|
+
|
|
165
|
+
declare module '@tanstack/react-router' {
|
|
166
|
+
interface Register {
|
|
167
|
+
router: typeof router
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const rootElement = document.getElementById('root')
|
|
172
|
+
if (rootElement === null) {
|
|
173
|
+
throw new Error('missing root element')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
createRoot(rootElement).render(<RouterProvider router={router} />)
|
|
177
|
+
`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function frontendViteEnvSource(): string {
|
|
181
|
+
return `/// <reference types="vite/client" />
|
|
182
|
+
|
|
183
|
+
interface ViteTypeOptions {
|
|
184
|
+
strictImportMetaEnv: unknown
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface ImportMetaEnv {
|
|
188
|
+
readonly VITE_API_URL?: string
|
|
189
|
+
}
|
|
190
|
+
`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function jsonSource(value: JsonObject): string {
|
|
194
|
+
return `${JSON.stringify(value, null, 2)}\n`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export { frontendPackageJson, frontendSource, frontendTsConfig, frontendViteEnvSource, rustApiSource }
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { DEFAULT_RUST_CHECK_COMMAND, PROJECT_TEMPLATES } from './constants.ts'
|
|
4
|
+
import { agentError } from './errors.ts'
|
|
5
|
+
import { doctorProject } from './doctor.ts'
|
|
6
|
+
import { frontendBuildCommand, frontendCheckCommand } from './frontend-policy.ts'
|
|
7
|
+
import { ensureDirectory, inferProjectKind, serviceNameFromCargo, serviceNameFromDir, serviceNameFromPackage } from './project.ts'
|
|
8
|
+
import { frontendPackageJson, frontendSource, frontendTsConfig, frontendViteEnvSource, rustApiSource } from './template-sources.ts'
|
|
9
|
+
import type { TemplateName } from './types.ts'
|
|
10
|
+
|
|
11
|
+
type TemplateWriter = (projectDir: string) => void
|
|
12
|
+
type TemplateFile = readonly [relative: string, source: string]
|
|
13
|
+
|
|
14
|
+
const TEMPLATE_WRITERS: Readonly<Record<TemplateName, TemplateWriter>> = {
|
|
15
|
+
'rust-api': (projectDir): void => writeRustApiTemplate(projectDir, serviceNameFromDir(projectDir)),
|
|
16
|
+
'tanstack-static-frontend': (projectDir): void => writeFrontendTemplate(projectDir, serviceNameFromDir(projectDir), '/api'),
|
|
17
|
+
'fullstack-rust-tanstack': (projectDir): void => {
|
|
18
|
+
writeRustApiTemplate(path.join(projectDir, 'api'), 'api')
|
|
19
|
+
writeFrontendTemplate(path.join(projectDir, 'web'), 'web', 'http://localhost:3000')
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function initProject(projectDir: string, template = ''): void {
|
|
24
|
+
if (template) {
|
|
25
|
+
mkdirSync(projectDir, { recursive: true, mode: 0o755 })
|
|
26
|
+
createTemplate(projectDir, template)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
ensureDirectory(projectDir)
|
|
30
|
+
|
|
31
|
+
const configPath = path.join(projectDir, 'tovuk.toml')
|
|
32
|
+
if (existsSync(configPath)) {
|
|
33
|
+
console.log('tovuk.toml already exists')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const kind = inferProjectKind(projectDir)
|
|
38
|
+
const source = kind === 'static_frontend'
|
|
39
|
+
? frontendConfig(projectDir)
|
|
40
|
+
: rustBackendConfig(projectDir)
|
|
41
|
+
|
|
42
|
+
writeFileSync(configPath, source, { mode: 0o644 })
|
|
43
|
+
console.log(`created ${path.relative(process.cwd(), configPath)}`)
|
|
44
|
+
console.log(`detected ${kind}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createTemplate(projectDir: string, template: string): void {
|
|
48
|
+
if (!isTemplateName(template)) {
|
|
49
|
+
throw agentError('invalid_template', 'Tovuk template is unknown.', `Use one of: ${[...PROJECT_TEMPLATES].join(', ')}.`, false)
|
|
50
|
+
}
|
|
51
|
+
TEMPLATE_WRITERS[template](projectDir)
|
|
52
|
+
console.log(`created ${template} template`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeRustApiTemplate(projectDir: string, name: string): void {
|
|
56
|
+
mkdirSync(path.join(projectDir, 'src'), { recursive: true, mode: 0o755 })
|
|
57
|
+
const files: readonly TemplateFile[] = [
|
|
58
|
+
['Cargo.toml', `[package]
|
|
59
|
+
name = "${name}"
|
|
60
|
+
version = "0.1.0"
|
|
61
|
+
edition = "2024"
|
|
62
|
+
publish = false
|
|
63
|
+
|
|
64
|
+
[lints.rust]
|
|
65
|
+
unsafe_code = "forbid"
|
|
66
|
+
warnings = "deny"
|
|
67
|
+
`],
|
|
68
|
+
['Cargo.lock', `# This file is automatically @generated by Cargo.
|
|
69
|
+
version = 4
|
|
70
|
+
|
|
71
|
+
[[package]]
|
|
72
|
+
name = "${name}"
|
|
73
|
+
version = "0.1.0"
|
|
74
|
+
`],
|
|
75
|
+
['src/main.rs', rustApiSource()],
|
|
76
|
+
['tovuk.toml', rustBackendConfig(projectDir)]
|
|
77
|
+
]
|
|
78
|
+
writeTemplateFiles(projectDir, files)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeFrontendTemplate(projectDir: string, name: string, apiBaseUrl: string): void {
|
|
82
|
+
mkdirSync(path.join(projectDir, 'src'), { recursive: true, mode: 0o755 })
|
|
83
|
+
const files: readonly TemplateFile[] = [
|
|
84
|
+
['package.json', frontendPackageJson(name)],
|
|
85
|
+
['index.html', '<div id="root"></div><script type="module" src="/src/main.tsx"></script>\n'],
|
|
86
|
+
['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
|
+
['src/vite-env.d.ts', frontendViteEnvSource()],
|
|
88
|
+
['src/main.tsx', frontendSource(apiBaseUrl)],
|
|
89
|
+
['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)]
|
|
92
|
+
]
|
|
93
|
+
writeTemplateFiles(projectDir, files)
|
|
94
|
+
console.log('run package install in the frontend directory before doctor: bun install or npm install')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeTemplateFiles(projectDir: string, files: readonly TemplateFile[]): void {
|
|
98
|
+
for (const [relative, source] of files) {
|
|
99
|
+
writeNewFile(path.join(projectDir, relative), source)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeNewFile(file: string, source: string): void {
|
|
104
|
+
if (existsSync(file)) {
|
|
105
|
+
throw agentError('file_exists', `Refusing to overwrite ${path.relative(process.cwd(), file)}.`, 'Move the existing file or choose an empty directory, then retry.', false)
|
|
106
|
+
}
|
|
107
|
+
writeFileSync(file, source, { mode: 0o644 })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rustBackendConfig(projectDir: string): string {
|
|
111
|
+
const name = serviceNameFromCargo(projectDir) || serviceNameFromDir(projectDir)
|
|
112
|
+
return `name = "${name}"
|
|
113
|
+
|
|
114
|
+
[build]
|
|
115
|
+
check = "${DEFAULT_RUST_CHECK_COMMAND}"
|
|
116
|
+
command = "cargo build --release"
|
|
117
|
+
|
|
118
|
+
[run]
|
|
119
|
+
command = "./target/release/${name}"
|
|
120
|
+
port = 3000
|
|
121
|
+
health = "/healthz"
|
|
122
|
+
|
|
123
|
+
[resources]
|
|
124
|
+
memory = "512mb"
|
|
125
|
+
cpu = "0.25"
|
|
126
|
+
idle_timeout_minutes = 15
|
|
127
|
+
`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function frontendConfig(projectDir: string): string {
|
|
131
|
+
const name = serviceNameFromPackage(projectDir) || serviceNameFromDir(projectDir)
|
|
132
|
+
return `name = "${name}"
|
|
133
|
+
kind = "static_frontend"
|
|
134
|
+
|
|
135
|
+
[build]
|
|
136
|
+
check = "${frontendCheckCommand(projectDir)}"
|
|
137
|
+
command = "${frontendBuildCommand(projectDir)}"
|
|
138
|
+
output = "dist"
|
|
139
|
+
`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function installProject(projectDir: string, template = ''): void {
|
|
143
|
+
initProject(projectDir, template)
|
|
144
|
+
doctorProject(projectDir, false)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isTemplateName(value: string): value is TemplateName {
|
|
148
|
+
return PROJECT_TEMPLATES.has(value)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export { initProject, installProject }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
type JsonPrimitive = string | number | boolean | null
|
|
2
|
+
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]
|
|
3
|
+
export interface JsonObject {
|
|
4
|
+
[key: string]: JsonValue | undefined
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type ApiMethod = 'DELETE' | 'GET' | 'POST' | 'PUT'
|
|
8
|
+
export type ProjectKind = 'rust_backend' | 'static_frontend'
|
|
9
|
+
export type DiscoveredProjectKind = ProjectKind | 'unknown'
|
|
10
|
+
export type TemplateName = 'fullstack-rust-tanstack' | 'rust-api' | 'tanstack-static-frontend'
|
|
11
|
+
|
|
12
|
+
export interface AgentErrorPayload extends JsonObject {
|
|
13
|
+
code: string
|
|
14
|
+
message: string
|
|
15
|
+
agent_instruction: string | null
|
|
16
|
+
docs_url: string | null
|
|
17
|
+
checkout_url: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CliOptions {
|
|
21
|
+
command: string
|
|
22
|
+
args: string[]
|
|
23
|
+
apiUrl: string
|
|
24
|
+
app: string
|
|
25
|
+
build: string
|
|
26
|
+
deploy: string
|
|
27
|
+
limit: string
|
|
28
|
+
cursor: string
|
|
29
|
+
failingCommand: string
|
|
30
|
+
firstLogLine: string
|
|
31
|
+
token: string
|
|
32
|
+
template: string
|
|
33
|
+
severity: string
|
|
34
|
+
port: number
|
|
35
|
+
waitTimeoutSeconds: number
|
|
36
|
+
json: boolean
|
|
37
|
+
database: boolean
|
|
38
|
+
wait: boolean
|
|
39
|
+
help: boolean
|
|
40
|
+
version: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PackageManifest {
|
|
44
|
+
name?: string
|
|
45
|
+
scripts?: Record<string, string | undefined>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type BuildConfig = JsonObject & { command: string; check: string; output?: string }
|
|
49
|
+
export type RunConfig = JsonObject & { command?: string; port: number; health: string }
|
|
50
|
+
export type ResourceConfig = JsonObject & { memory: string; cpu: string; idle_timeout_minutes: number }
|
|
51
|
+
export type TovukConfig = JsonObject & { name?: string; kind: ProjectKind; build: BuildConfig; run: RunConfig; resources: ResourceConfig }
|
|
52
|
+
export type DoctorCheck = JsonObject & { name: string; ok: boolean; message: string; agent_instruction: string | null }
|
|
53
|
+
export type DoctorReport = JsonObject & { ok: boolean; project: string; config: TovukConfig | null; checks: DoctorCheck[] }
|
|
54
|
+
export type ProjectDoctorReport = DoctorReport & { relative: string }
|
|
55
|
+
export type WorkspaceDoctorReport = JsonObject & { ok: boolean; workspace: string; projects: ProjectDoctorReport[] }
|
|
56
|
+
export type DeployProjectInfo = { dir: string; relative: string; name: string; kind: DiscoveredProjectKind }
|
|
57
|
+
export type DeployPlanProject = { project: DeployProjectInfo; wantsDatabase: boolean }
|
|
58
|
+
export type FrontendSourceReport = { typescript: string[]; javascript: string[] }
|
|
59
|
+
export type LoginStartResponse = JsonObject & { loginUrl?: string; userCode?: string; deviceCode?: string; expiresInSeconds?: number; intervalSeconds?: number }
|
|
60
|
+
export type LoginPollResponse = JsonObject & { status?: string; token?: string; email?: string; intervalSeconds?: number }
|
|
61
|
+
export type AppSummary = JsonObject & { id?: string; name?: string; url?: string; databaseStorageMib?: number }
|
|
62
|
+
export type AppsResponse = JsonObject & { apps: AppSummary[] }
|
|
63
|
+
export type BuildJob = JsonObject & { id: string }
|
|
64
|
+
export type AppDeployTarget = JsonObject & { id: string; url: string }
|
|
65
|
+
export type BuildRecord = JsonObject & { id: string; status: string }
|
|
66
|
+
export type BuildStatusResponse = JsonObject & { build?: BuildRecord }
|
|
67
|
+
export type DeployResponse = JsonObject & { app: AppDeployTarget; build_job: BuildJob; final_build?: BuildRecord | null }
|
|
68
|
+
export type WorkspaceDeployResult = { project: DeployProjectInfo; wantsDatabase: boolean; response: DeployResponse; finalBuild?: BuildRecord }
|
|
69
|
+
export type LogLine = JsonObject & { timestamp: string; stream: string; message: string }
|
|
70
|
+
export type LogsResponse = JsonObject & { lines: LogLine[]; has_more: boolean; next_cursor: string }
|
|
71
|
+
export type CheckoutResponse = JsonObject & { checkout: { reason?: string; url: string } }
|
|
72
|
+
|
|
73
|
+
export type FileVisitor = (file: string, relative: string) => void
|
|
74
|
+
export type PathVisitor = (file: string) => void
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { WORKSPACE_EXCLUDED_DIRS } from './constants.ts'
|
|
4
|
+
import { parseTovukToml } from './config.ts'
|
|
5
|
+
import { ensureDirectory } from './project.ts'
|
|
6
|
+
import type { DeployProjectInfo, DiscoveredProjectKind } from './types.ts'
|
|
7
|
+
|
|
8
|
+
function discoverDeployProjects(rootDir: string): DeployProjectInfo[] {
|
|
9
|
+
ensureDirectory(rootDir)
|
|
10
|
+
if (existsSync(path.join(rootDir, 'tovuk.toml'))) {
|
|
11
|
+
return [deployProjectInfo(rootDir, rootDir)]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const projectDirs: string[] = []
|
|
15
|
+
discoverProjectDirs(rootDir, projectDirs)
|
|
16
|
+
return projectDirs
|
|
17
|
+
.map((dir) => deployProjectInfo(dir, rootDir))
|
|
18
|
+
.toSorted(compareDeployProjects)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function discoverProjectDirs(dir: string, projectDirs: string[]): void {
|
|
22
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
if (!entry.isDirectory() || WORKSPACE_EXCLUDED_DIRS.has(entry.name)) {
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const child = path.join(dir, entry.name)
|
|
28
|
+
if (existsSync(path.join(child, 'tovuk.toml'))) {
|
|
29
|
+
projectDirs.push(child)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
discoverProjectDirs(child, projectDirs)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function deployProjectInfo(dir: string, rootDir: string): DeployProjectInfo {
|
|
37
|
+
const relative = path.relative(rootDir, dir).replace(/\\/gu, '/') || '.'
|
|
38
|
+
try {
|
|
39
|
+
const config = parseTovukToml(readFileSync(path.join(dir, 'tovuk.toml'), 'utf8'), dir)
|
|
40
|
+
return { dir, relative, name: config.name || '', kind: config.kind }
|
|
41
|
+
} catch {
|
|
42
|
+
return { dir, relative, name: '', kind: 'unknown' }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function compareDeployProjects(left: DeployProjectInfo, right: DeployProjectInfo): number {
|
|
47
|
+
return kindOrder(left.kind) - kindOrder(right.kind)
|
|
48
|
+
|| left.relative.localeCompare(right.relative)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function kindOrder(kind: DiscoveredProjectKind): number {
|
|
52
|
+
if (kind === 'rust_backend') {
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
if (kind === 'static_frontend') {
|
|
56
|
+
return 1
|
|
57
|
+
}
|
|
58
|
+
return 2
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { discoverDeployProjects }
|
package/src/tovuk.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { VERSION, HELP } from './internal/constants.ts'
|
|
3
|
+
import { parseArgs, projectPath } from './internal/args.ts'
|
|
4
|
+
import { agentError, printAgentError, TovukError } from './internal/errors.ts'
|
|
5
|
+
import { initProject, installProject } from './internal/templates.ts'
|
|
6
|
+
import { doctorProject } from './internal/doctor.ts'
|
|
7
|
+
import { previewProject } from './internal/preview.ts'
|
|
8
|
+
import { login } from './internal/auth.ts'
|
|
9
|
+
import { deploy } from './internal/deploy.ts'
|
|
10
|
+
import { apps, builds, activity, billing, capabilities, database, deploys, domainsCommand, envCommand, inspect, logs, me, overview, status, support, usage } from './internal/commands.ts'
|
|
11
|
+
import type { CliOptions } from './internal/types.ts'
|
|
12
|
+
|
|
13
|
+
type CommandHandler = (cli: CliOptions) => Promise<void> | void
|
|
14
|
+
|
|
15
|
+
const COMMANDS = new Map<string, CommandHandler>([
|
|
16
|
+
['init', (cli): void => initProject(projectPath(cli.args[0]), cli.template)],
|
|
17
|
+
['install', (cli): void => installProject(projectPath(cli.args[0]), cli.template)],
|
|
18
|
+
['doctor', (cli): void => doctorProject(projectPath(cli.args[0]), cli.json)],
|
|
19
|
+
['preview', (cli): void => previewProject(projectPath(cli.args[0]), cli.port)],
|
|
20
|
+
['login', login],
|
|
21
|
+
['deploy', (cli): Promise<void> => deploy(projectPath(cli.args[0]), cli)],
|
|
22
|
+
['capabilities', capabilities],
|
|
23
|
+
['me', me],
|
|
24
|
+
['usage', usage],
|
|
25
|
+
['activity', activity],
|
|
26
|
+
['apps', apps],
|
|
27
|
+
['overview', overview],
|
|
28
|
+
['deploys', deploys],
|
|
29
|
+
['builds', builds],
|
|
30
|
+
['logs', logs],
|
|
31
|
+
['status', status],
|
|
32
|
+
['inspect', inspect],
|
|
33
|
+
['db', database],
|
|
34
|
+
['database', database],
|
|
35
|
+
['env', envCommand],
|
|
36
|
+
['domains', domainsCommand],
|
|
37
|
+
['billing', billing],
|
|
38
|
+
['support', support]
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
async function main(): Promise<void> {
|
|
42
|
+
const cli = parseArgs(process.argv.slice(2))
|
|
43
|
+
|
|
44
|
+
if (cli.help) {
|
|
45
|
+
console.log(HELP)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (cli.version) {
|
|
50
|
+
console.log(VERSION)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const command = COMMANDS.get(cli.command)
|
|
55
|
+
if (!command) {
|
|
56
|
+
throw agentError('unknown_command', 'Unknown Tovuk command.', 'Run `npx tovuk --help` and retry with a supported command.', cli.json)
|
|
57
|
+
}
|
|
58
|
+
await command(cli)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
main().catch((error: unknown): void => {
|
|
62
|
+
if (error instanceof TovukError) {
|
|
63
|
+
printAgentError(error.payload, error.json)
|
|
64
|
+
process.exitCode = error.exitCode
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
69
|
+
console.error(`tovuk failed: ${message}`)
|
|
70
|
+
process.exitCode = 1
|
|
71
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023", "DOM"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"allowJs": false,
|
|
10
|
+
"checkJs": false,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"noEmitOnError": true,
|
|
14
|
+
"strict": true,
|
|
15
|
+
"strictBuiltinIteratorReturn": true,
|
|
16
|
+
"noImplicitAny": true,
|
|
17
|
+
"strictNullChecks": true,
|
|
18
|
+
"strictFunctionTypes": true,
|
|
19
|
+
"strictBindCallApply": true,
|
|
20
|
+
"strictPropertyInitialization": true,
|
|
21
|
+
"noImplicitThis": true,
|
|
22
|
+
"useUnknownInCatchVariables": true,
|
|
23
|
+
"alwaysStrict": true,
|
|
24
|
+
"exactOptionalPropertyTypes": true,
|
|
25
|
+
"noUncheckedIndexedAccess": true,
|
|
26
|
+
"noUncheckedSideEffectImports": true,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
28
|
+
"noImplicitOverride": true,
|
|
29
|
+
"noImplicitReturns": true,
|
|
30
|
+
"noFallthroughCasesInSwitch": true,
|
|
31
|
+
"noUnusedLocals": true,
|
|
32
|
+
"noUnusedParameters": true,
|
|
33
|
+
"allowUnreachableCode": false,
|
|
34
|
+
"allowUnusedLabels": false,
|
|
35
|
+
"verbatimModuleSyntax": true,
|
|
36
|
+
"isolatedModules": true,
|
|
37
|
+
"isolatedDeclarations": true,
|
|
38
|
+
"erasableSyntaxOnly": true,
|
|
39
|
+
"moduleDetection": "force",
|
|
40
|
+
"resolvePackageJsonExports": true,
|
|
41
|
+
"resolvePackageJsonImports": true,
|
|
42
|
+
"maxNodeModuleJsDepth": 0,
|
|
43
|
+
"forceConsistentCasingInFileNames": true,
|
|
44
|
+
"skipLibCheck": false,
|
|
45
|
+
"types": ["node"]
|
|
46
|
+
},
|
|
47
|
+
"include": ["src/**/*.ts"]
|
|
48
|
+
}
|