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,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 }
|