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.
@@ -0,0 +1,103 @@
1
+ import type { JsonObject, JsonValue } from './types.ts'
2
+
3
+ function parseJson(text: string): JsonValue | null {
4
+ if (!text.trim()) {
5
+ return null
6
+ }
7
+ try {
8
+ return toJsonValue(JSON.parse(text)) ?? null
9
+ } catch {
10
+ return null
11
+ }
12
+ }
13
+
14
+ function toJsonValue(value: unknown): JsonValue | undefined {
15
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
16
+ return value
17
+ }
18
+ if (typeof value === 'number') {
19
+ return Number.isFinite(value) ? value : undefined
20
+ }
21
+ if (isUnknownArray(value)) {
22
+ return jsonArrayValue(value)
23
+ }
24
+ if (isUnknownRecord(value)) {
25
+ return jsonObjectValue(value)
26
+ }
27
+ return undefined
28
+ }
29
+
30
+ function jsonArrayValue(value: readonly unknown[]): JsonValue[] | undefined {
31
+ const items: JsonValue[] = []
32
+ for (const item of value) {
33
+ const parsed = toJsonValue(item)
34
+ if (parsed === undefined) {
35
+ return undefined
36
+ }
37
+ items.push(parsed)
38
+ }
39
+ return items
40
+ }
41
+
42
+ function jsonObjectValue(value: Record<string, unknown>): JsonObject | undefined {
43
+ const object: JsonObject = {}
44
+ for (const [key, item] of Object.entries(value)) {
45
+ const parsed = toJsonValue(item)
46
+ if (parsed === undefined) {
47
+ return undefined
48
+ }
49
+ object[key] = parsed
50
+ }
51
+ return object
52
+ }
53
+
54
+ function isUnknownArray(value: unknown): value is readonly unknown[] {
55
+ return Array.isArray(value)
56
+ }
57
+
58
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
60
+ }
61
+
62
+ function isJsonObject(value: JsonValue | null): value is JsonObject {
63
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
64
+ }
65
+
66
+ function jsonObjectOrEmpty(value: JsonValue | null): JsonObject {
67
+ return isJsonObject(value) ? value : {}
68
+ }
69
+
70
+ function jsonObjectField(source: JsonObject, key: string): JsonObject {
71
+ return jsonObjectOrEmpty(source[key] ?? null)
72
+ }
73
+
74
+ function optionalJsonObjectField(source: JsonObject, key: string): JsonObject | null {
75
+ const value = source[key] ?? null
76
+ return isJsonObject(value) ? value : null
77
+ }
78
+
79
+ function jsonArrayField(source: JsonObject, key: string): JsonValue[] {
80
+ const value = source[key]
81
+ return Array.isArray(value) ? value : []
82
+ }
83
+
84
+ function stringField(source: JsonObject, key: string): string {
85
+ const value = source[key]
86
+ return typeof value === 'string' ? value : ''
87
+ }
88
+
89
+ function numberField(source: JsonObject, key: string): number {
90
+ const value = source[key]
91
+ return typeof value === 'number' ? value : Number(value ?? 0)
92
+ }
93
+
94
+ export {
95
+ parseJson,
96
+ isJsonObject,
97
+ jsonObjectOrEmpty,
98
+ jsonObjectField,
99
+ optionalJsonObjectField,
100
+ jsonArrayField,
101
+ stringField,
102
+ numberField
103
+ }
@@ -0,0 +1,116 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync, statSync } from 'node:fs'
3
+ import { createServer } from 'node:http'
4
+ import path from 'node:path'
5
+ import { parseTovukToml, validateConfig } from './config.ts'
6
+ import { runDoctorWorkspace } from './doctor.ts'
7
+ import { agentError } from './errors.ts'
8
+ import { ensureDirectory } from './project.ts'
9
+
10
+ function previewProject(projectDir: string, port: number): void {
11
+ const report = runDoctorWorkspace(projectDir)
12
+ if ('projects' in report) {
13
+ throw agentError('workspace_preview_unsupported', 'Preview one project at a time.', 'Run `npx tovuk preview api` or `npx tovuk preview web` from the workspace root.', false)
14
+ }
15
+ if (!report.ok) {
16
+ const firstFailure = report.checks.find((check) => !check.ok)
17
+ throw agentError('doctor_failed', 'Tovuk doctor failed.', firstFailure?.agent_instruction || 'Fix the failed checks and retry `npx tovuk preview`.', false)
18
+ }
19
+
20
+ const config = parseTovukToml(readFileSync(path.join(projectDir, 'tovuk.toml'), 'utf8'), projectDir)
21
+ validateConfig(config)
22
+ runShell(config.build.command, projectDir, 'Build failed before preview.')
23
+ if (config.kind === 'static_frontend') {
24
+ previewStatic(projectDir, config.build.output ?? 'dist', port)
25
+ return
26
+ }
27
+ previewRuntime(projectDir, config.run.command ?? '', port || config.run.port)
28
+ }
29
+
30
+ function previewStatic(projectDir: string, output: string, port: number): void {
31
+ serveStatic(path.join(projectDir, output), port || 4173)
32
+ }
33
+
34
+ function previewRuntime(projectDir: string, command: string, port: number): void {
35
+ console.log(`preview http://127.0.0.1:${port}`)
36
+ const result = spawnSync(command, {
37
+ cwd: projectDir,
38
+ env: { ...process.env, PORT: String(port) },
39
+ shell: true,
40
+ stdio: 'inherit'
41
+ })
42
+ if (result.error) {
43
+ throw agentError('preview_failed', 'Preview command failed.', result.error.message, false)
44
+ }
45
+ if (result.status !== 0) {
46
+ throw agentError('preview_failed', 'Preview command exited with an error.', 'Fix the local runtime command and retry `npx tovuk preview`.', false)
47
+ }
48
+ }
49
+
50
+ function runShell(command: string, projectDir: string, failureMessage: string): void {
51
+ console.log(command)
52
+ const result = spawnSync(command, {
53
+ cwd: projectDir,
54
+ env: process.env,
55
+ shell: true,
56
+ stdio: 'inherit'
57
+ })
58
+ if (result.error) {
59
+ throw agentError('command_failed', failureMessage, result.error.message, false)
60
+ }
61
+ if (result.status !== 0) {
62
+ throw agentError('command_failed', failureMessage, 'Fix the command output above, then retry.', false)
63
+ }
64
+ }
65
+
66
+ function serveStatic(root: string, port: number): void {
67
+ ensureDirectory(root)
68
+ const server = createServer((request, response) => {
69
+ const pathname = decodeURIComponent(new URL(request.url || '/', `http://127.0.0.1:${port}`).pathname)
70
+ const target = staticTarget(root, pathname)
71
+ if (!target) {
72
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
73
+ response.end('not found')
74
+ return
75
+ }
76
+ response.writeHead(200, { 'content-type': contentType(target) })
77
+ response.end(readFileSync(target))
78
+ })
79
+ server.listen(port, '127.0.0.1', () => {
80
+ console.log(`preview http://127.0.0.1:${port}`)
81
+ })
82
+ }
83
+
84
+ function staticTarget(root: string, pathname: string): string {
85
+ const safePath = pathname.replace(/^\/+/u, '')
86
+ const candidate = path.resolve(root, safePath || 'index.html')
87
+ if (!candidate.startsWith(path.resolve(root) + path.sep) && candidate !== path.resolve(root)) {
88
+ return ''
89
+ }
90
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
91
+ return candidate
92
+ }
93
+ const index = path.join(root, 'index.html')
94
+ return existsSync(index) ? index : ''
95
+ }
96
+
97
+ function contentType(file: string): string {
98
+ if (file.endsWith('.html')) {
99
+ return 'text/html; charset=utf-8'
100
+ }
101
+ if (file.endsWith('.css')) {
102
+ return 'text/css; charset=utf-8'
103
+ }
104
+ if (file.endsWith('.js') || file.endsWith('.mjs')) {
105
+ return 'text/javascript; charset=utf-8'
106
+ }
107
+ if (file.endsWith('.json')) {
108
+ return 'application/json; charset=utf-8'
109
+ }
110
+ if (file.endsWith('.svg')) {
111
+ return 'image/svg+xml'
112
+ }
113
+ return 'application/octet-stream'
114
+ }
115
+
116
+ export { previewProject }
@@ -0,0 +1,135 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { spawnSync } from 'node:child_process'
3
+ import path from 'node:path'
4
+ import { WALK_EXCLUDED_DIRS } from './constants.ts'
5
+ import { agentError } from './errors.ts'
6
+ import type { CliOptions, FileVisitor, JsonValue, PackageManifest, PathVisitor, ProjectKind } from './types.ts'
7
+
8
+ function hasCommand(command: string): boolean {
9
+ return (process.env['PATH'] ?? '')
10
+ .split(path.delimiter)
11
+ .filter(Boolean)
12
+ .some((directory) => existsSync(path.join(directory, command)))
13
+ }
14
+
15
+ function isSafeRelativePath(value: string | undefined): value is string {
16
+ return typeof value === 'string'
17
+ && value.length > 0
18
+ && !path.isAbsolute(value)
19
+ && !value.includes('\\')
20
+ && value.split('/').every((part) => part && part !== '.' && part !== '..')
21
+ }
22
+
23
+ function walkProjectFiles(projectDir: string, visit: FileVisitor): void {
24
+ walk(projectDir, (file) => {
25
+ visit(file, path.relative(projectDir, file).replace(/\\/gu, '/'))
26
+ })
27
+ }
28
+
29
+ function walk(dir: string, visit: PathVisitor): void {
30
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
31
+ if (WALK_EXCLUDED_DIRS.has(entry.name)) {
32
+ continue
33
+ }
34
+ const fullPath = path.join(dir, entry.name)
35
+ if (entry.isDirectory()) {
36
+ walk(fullPath, visit)
37
+ continue
38
+ }
39
+ if (entry.isFile()) {
40
+ visit(fullPath)
41
+ }
42
+ }
43
+ }
44
+
45
+ function ensureDirectory(dir: string): void {
46
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
47
+ throw agentError('missing_project', 'Project directory does not exist.', 'Run Tovuk from the root of a Rust project or pass the project path.', false)
48
+ }
49
+ }
50
+
51
+ function serviceNameFromDir(projectDir: string): string {
52
+ const name = serviceNameFromValue(path.basename(projectDir))
53
+ return name || 'api'
54
+ }
55
+
56
+ function serviceNameFromCargo(projectDir: string): string {
57
+ try {
58
+ const source = readFileSync(path.join(projectDir, 'Cargo.toml'), 'utf8')
59
+ return serviceNameFromValue(source.match(/^\s*name\s*=\s*"([^"]+)"/mu)?.[1] || '')
60
+ } catch {
61
+ return ''
62
+ }
63
+ }
64
+
65
+ function serviceNameFromPackage(projectDir: string): string {
66
+ const manifest = readPackageJson(projectDir)
67
+ return serviceNameFromValue(typeof manifest?.name === 'string' ? manifest.name : '')
68
+ }
69
+
70
+ function serviceNameFromValue(value: string): string {
71
+ return value.toLowerCase().replace(/[^a-z0-9-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 48)
72
+ }
73
+
74
+ function inferProjectKind(projectDir: string): ProjectKind {
75
+ if (existsSync(path.join(projectDir, 'Cargo.toml'))) {
76
+ return 'rust_backend'
77
+ }
78
+ if (existsSync(path.join(projectDir, 'package.json'))) {
79
+ return 'static_frontend'
80
+ }
81
+ return 'rust_backend'
82
+ }
83
+
84
+ function readPackageJson(projectDir: string): PackageManifest | null {
85
+ try {
86
+ const parsed: unknown = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
87
+ return isPackageManifest(parsed) ? parsed : null
88
+ } catch {
89
+ return null
90
+ }
91
+ }
92
+
93
+ function isPackageManifest(value: unknown): value is PackageManifest {
94
+ if (!isRecord(value)) {
95
+ return false
96
+ }
97
+ const name = value['name']
98
+ const scripts = value['scripts']
99
+ return (name === undefined || typeof name === 'string') &&
100
+ (scripts === undefined || isStringRecord(scripts))
101
+ }
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> {
104
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
105
+ }
106
+
107
+ function isStringRecord(value: unknown): value is Record<string, string | undefined> {
108
+ return isRecord(value) && Object.values(value).every((entry) => entry === undefined || typeof entry === 'string')
109
+ }
110
+
111
+ function printJson(value: JsonValue | null): void {
112
+ console.log(JSON.stringify(value, null, 2))
113
+ }
114
+
115
+ function openUrl(url: string): void {
116
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
117
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
118
+ spawnSync(command, args, { stdio: 'ignore' })
119
+ }
120
+
121
+ function sleep(milliseconds: number): Promise<void> {
122
+ return new Promise((resolve) => {
123
+ setTimeout(resolve, milliseconds)
124
+ })
125
+ }
126
+
127
+ function progress(cli: CliOptions, message: string): void {
128
+ if (cli.json) {
129
+ console.error(message)
130
+ return
131
+ }
132
+ console.log(message)
133
+ }
134
+
135
+ export { hasCommand, isSafeRelativePath, walkProjectFiles, ensureDirectory, serviceNameFromDir, serviceNameFromCargo, serviceNameFromPackage, inferProjectKind, readPackageJson, printJson, openUrl, sleep, progress }
@@ -0,0 +1,157 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { walkProjectFiles } from './project.ts'
5
+ import type { DoctorCheck } from './types.ts'
6
+
7
+ interface CargoCheckSpec {
8
+ name: string
9
+ args: string[]
10
+ missing: string
11
+ failed: string
12
+ }
13
+
14
+ function rustDoctorChecks(projectDir: string, configValid: boolean): DoctorCheck[] {
15
+ const checks = [cargoLints(projectDir), unsafeCheck(projectDir)]
16
+ if (configValid) {
17
+ checks.push(cargoFmt(projectDir), cargoCheck(projectDir), cargoClippy(projectDir))
18
+ }
19
+ return checks
20
+ }
21
+
22
+ function unsafeCheck(projectDir: string): DoctorCheck {
23
+ const unsafeHits = scanUnsafe(projectDir)
24
+ return {
25
+ name: 'unsafe',
26
+ ok: unsafeHits.length === 0,
27
+ message: unsafeHits.length === 0 ? 'no direct unsafe found' : unsafeHits.slice(0, 5).join(', '),
28
+ agent_instruction: unsafeHits.length === 0 ? null : 'Remove direct unsafe usage from workspace Rust source before deploying.'
29
+ }
30
+ }
31
+
32
+ function scanUnsafe(projectDir: string): string[] {
33
+ const hits: string[] = []
34
+ walkProjectFiles(projectDir, (file, relative) => {
35
+ if (!file.endsWith('.rs')) {
36
+ return
37
+ }
38
+ const source = readFileSync(file, 'utf8')
39
+ if (/\bunsafe\b/u.test(source)) {
40
+ hits.push(relative)
41
+ }
42
+ })
43
+ return hits
44
+ }
45
+
46
+ function cargoCheck(projectDir: string): DoctorCheck {
47
+ return cargoCommandCheck(projectDir, {
48
+ name: 'cargo check',
49
+ args: ['check', '--locked', '--quiet'],
50
+ missing: 'Install Rust and Cargo, then run `cargo check --locked` locally before deploying.',
51
+ failed: 'Run `cargo check --locked`, fix every compiler error and warning, then redeploy.'
52
+ })
53
+ }
54
+
55
+ function cargoFmt(projectDir: string): DoctorCheck {
56
+ return cargoCommandCheck(projectDir, {
57
+ name: 'cargo fmt',
58
+ args: ['fmt', '--all', '--check'],
59
+ missing: 'Install rustfmt with Rust, then run `cargo fmt --all --check` before deploying.',
60
+ failed: 'Run `cargo fmt --all`, then redeploy.'
61
+ })
62
+ }
63
+
64
+ function cargoClippy(projectDir: string): DoctorCheck {
65
+ return cargoCommandCheck(projectDir, {
66
+ name: 'cargo clippy',
67
+ args: ['clippy', '--locked', '--all-targets', '--all-features', '--quiet', '--', '-D', 'warnings'],
68
+ missing: 'Install Rust clippy, then run `cargo clippy --locked --all-targets --all-features -- -D warnings` before deploying.',
69
+ failed: 'Run `cargo clippy --locked --all-targets --all-features -- -D warnings`, fix every warning, then redeploy.'
70
+ })
71
+ }
72
+
73
+ function cargoCommandCheck(projectDir: string, check: CargoCheckSpec): DoctorCheck {
74
+ const cargo = spawnSync('cargo', check.args, {
75
+ cwd: projectDir,
76
+ encoding: 'utf8',
77
+ env: { ...process.env, CARGO_TERM_COLOR: 'never' },
78
+ stdio: ['ignore', 'pipe', 'pipe']
79
+ })
80
+
81
+ if (cargo.error) {
82
+ return {
83
+ name: check.name,
84
+ ok: false,
85
+ message: cargo.error.message,
86
+ agent_instruction: check.missing
87
+ }
88
+ }
89
+
90
+ return {
91
+ name: check.name,
92
+ ok: cargo.status === 0,
93
+ message: cargo.status === 0 ? 'passed' : (cargo.stderr || cargo.stdout || `${check.name} failed`).trim().slice(0, 240),
94
+ agent_instruction: cargo.status === 0 ? null : check.failed
95
+ }
96
+ }
97
+
98
+ function cargoLints(projectDir: string): DoctorCheck {
99
+ const cargoToml = path.join(projectDir, 'Cargo.toml')
100
+ let source = ''
101
+ try {
102
+ source = readFileSync(cargoToml, 'utf8')
103
+ } catch (error: unknown) {
104
+ const message = error instanceof Error ? error.message : String(error)
105
+ return {
106
+ name: 'cargo lints',
107
+ ok: false,
108
+ message,
109
+ agent_instruction: 'Create Cargo.toml with strict Rust lints, then retry.'
110
+ }
111
+ }
112
+
113
+ const ok = cargoLintLevel(source, 'unsafe_code') === 'forbid' &&
114
+ cargoLintLevel(source, 'warnings') === 'deny'
115
+ return {
116
+ name: 'cargo lints',
117
+ ok,
118
+ message: ok ? 'strict' : 'missing unsafe_code=forbid or warnings=deny',
119
+ agent_instruction: ok ? null : 'Add `[lints.rust]` with `unsafe_code = "forbid"` and `warnings = "deny"`, then retry.'
120
+ }
121
+ }
122
+
123
+ function cargoLintLevel(source: string, lintName: string): string {
124
+ let section = ''
125
+ for (const rawLine of source.split(/\r?\n/u)) {
126
+ const line = rawLine.replace(/#.*/u, '').trim()
127
+ const nextSection = tomlSection(line)
128
+ if (nextSection !== null) {
129
+ section = nextSection
130
+ continue
131
+ }
132
+ if (!isRustLintSection(section)) {
133
+ continue
134
+ }
135
+ const level = lintAssignmentLevel(line, lintName)
136
+ if (level) {
137
+ return level
138
+ }
139
+ }
140
+
141
+ return ''
142
+ }
143
+
144
+ function tomlSection(line: string): string | null {
145
+ return line.match(/^\[([^\]]+)\]$/u)?.[1] ?? null
146
+ }
147
+
148
+ function isRustLintSection(section: string): boolean {
149
+ return section === 'lints.rust' || section === 'workspace.lints.rust'
150
+ }
151
+
152
+ function lintAssignmentLevel(line: string, lintName: string): string {
153
+ const assignment = line.match(/^([A-Za-z0-9_]+)\s*=\s*(?:"([^"]+)"|\{[^}]*level\s*=\s*"([^"]+)")/u)
154
+ return assignment?.[1] === lintName ? assignment[2] ?? assignment[3] ?? '' : ''
155
+ }
156
+
157
+ export { rustDoctorChecks, unsafeCheck }