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,154 @@
|
|
|
1
|
+
import { agentError } from './errors.ts'
|
|
2
|
+
import { apiRequest } from './api.ts'
|
|
3
|
+
import { buildStatusResponseFromJson, deployResponseFromJson } from './api-models.ts'
|
|
4
|
+
import { createArchiveBase64, gitCommitSha } from './archive.ts'
|
|
5
|
+
import { readOrLoginToken } from './auth.ts'
|
|
6
|
+
import { createDeployPlan } from './deploy-plan.ts'
|
|
7
|
+
import { runDoctor } from './doctor.ts'
|
|
8
|
+
import { discoverDeployProjects } from './workspace.ts'
|
|
9
|
+
import { printJson, progress, sleep } from './project.ts'
|
|
10
|
+
import type {
|
|
11
|
+
BuildRecord,
|
|
12
|
+
CliOptions,
|
|
13
|
+
DeployPlanProject,
|
|
14
|
+
DeployResponse,
|
|
15
|
+
JsonObject,
|
|
16
|
+
WorkspaceDeployResult
|
|
17
|
+
} from './types.ts'
|
|
18
|
+
|
|
19
|
+
async function deploy(projectDir: string, cli: CliOptions): Promise<void> {
|
|
20
|
+
const projects = discoverDeployProjects(projectDir)
|
|
21
|
+
if (projects.length === 0) {
|
|
22
|
+
throw agentError('missing_project_contract', 'No tovuk.toml was found.', 'Run `npx tovuk init` in each app directory, or pass a project path.', cli.json)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const token = await readOrLoginToken(cli)
|
|
26
|
+
const plan = await createDeployPlan(projects, cli, token)
|
|
27
|
+
const results = await deployProjects(plan, cli, token)
|
|
28
|
+
|
|
29
|
+
if (cli.wait) {
|
|
30
|
+
await waitForWorkspaceBuilds(cli, token, results)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const singleResult = results.length === 1 ? results[0] : undefined
|
|
34
|
+
if (singleResult) {
|
|
35
|
+
printDeployResult(singleResult.response, cli)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
printWorkspaceDeployResults(projectDir, results, cli)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function deployProjects(plan: DeployPlanProject[], cli: CliOptions, token: string): Promise<WorkspaceDeployResult[]> {
|
|
43
|
+
const results: WorkspaceDeployResult[] = []
|
|
44
|
+
const workspaceDeploy = plan.length > 1
|
|
45
|
+
if (workspaceDeploy && !cli.json) {
|
|
46
|
+
console.log(`deploying ${plan.length} projects`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const { project, wantsDatabase } of plan) {
|
|
50
|
+
if (workspaceDeploy && !cli.json) {
|
|
51
|
+
console.log(`checking ${project.relative}`)
|
|
52
|
+
}
|
|
53
|
+
const response = await deployProject(project.dir, cli, token, wantsDatabase)
|
|
54
|
+
results.push({ project, wantsDatabase, response })
|
|
55
|
+
if (workspaceDeploy && !cli.json) {
|
|
56
|
+
console.log(`${project.relative} queued ${response.build_job.id}`)
|
|
57
|
+
console.log(`${project.relative} url ${response.app.url}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return results
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function deployProject(projectDir: string, cli: CliOptions, token: string, wantsDatabase: boolean): Promise<DeployResponse> {
|
|
65
|
+
const report = runDoctor(projectDir)
|
|
66
|
+
if (!report.ok) {
|
|
67
|
+
const firstFailure = report.checks.find((check) => !check.ok)
|
|
68
|
+
throw agentError('doctor_failed', 'Tovuk doctor failed.', firstFailure?.agent_instruction ?? 'Fix the failed checks and retry.', cli.json)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const body: JsonObject = {
|
|
72
|
+
config: report.config,
|
|
73
|
+
commit_sha: gitCommitSha(projectDir),
|
|
74
|
+
wants_database: wantsDatabase,
|
|
75
|
+
source_archive_base64: createArchiveBase64(projectDir)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return deployResponseFromJson(await apiRequest(cli, 'POST', '/v1/deploy', token, body))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function printDeployResult(response: DeployResponse, cli: CliOptions): void {
|
|
82
|
+
if (cli.json) {
|
|
83
|
+
printJson(response)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(`queued ${response.build_job.id}`)
|
|
88
|
+
console.log(`app ${response.app.id}`)
|
|
89
|
+
console.log(`url ${response.app.url}`)
|
|
90
|
+
console.log(`next npx tovuk logs --app ${response.app.id}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function printWorkspaceDeployResults(projectDir: string, results: WorkspaceDeployResult[], cli: CliOptions): void {
|
|
94
|
+
if (cli.json) {
|
|
95
|
+
printJson({
|
|
96
|
+
workspace: projectDir,
|
|
97
|
+
deploys: results.map((result) => ({
|
|
98
|
+
path: result.project.relative,
|
|
99
|
+
kind: result.project.kind,
|
|
100
|
+
wants_database: result.wantsDatabase,
|
|
101
|
+
app: result.response.app,
|
|
102
|
+
build_job: result.response.build_job,
|
|
103
|
+
final_build: result.finalBuild ?? null
|
|
104
|
+
}))
|
|
105
|
+
})
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const firstApp = results[0]?.response.app.id
|
|
110
|
+
if (firstApp) {
|
|
111
|
+
console.log(`next npx tovuk logs --app ${firstApp}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function waitForWorkspaceBuilds(cli: CliOptions, token: string, results: WorkspaceDeployResult[]): Promise<void> {
|
|
116
|
+
await Promise.all(results.map(async (result): Promise<void> => {
|
|
117
|
+
const finalBuild = await waitForBuild(cli, token, result.response.build_job.id)
|
|
118
|
+
result.finalBuild = finalBuild
|
|
119
|
+
result.response.final_build = finalBuild
|
|
120
|
+
}))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function waitForBuild(cli: CliOptions, token: string, buildId: string): Promise<BuildRecord> {
|
|
124
|
+
const deadline = Date.now() + cli.waitTimeoutSeconds * 1000
|
|
125
|
+
let lastStatus = ''
|
|
126
|
+
|
|
127
|
+
while (Date.now() <= deadline) {
|
|
128
|
+
const response = buildStatusResponseFromJson(await apiRequest(cli, 'GET', `/v1/builds/${encodeURIComponent(buildId)}`, token, null))
|
|
129
|
+
const build = response.build
|
|
130
|
+
if (!build?.status) {
|
|
131
|
+
throw agentError('build_status_unavailable', 'Build status is unavailable.', `Retry with \`npx tovuk logs --build ${buildId}\`.`, cli.json)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (build.status !== lastStatus) {
|
|
135
|
+
progress(cli, `build ${build.id} ${build.status}`)
|
|
136
|
+
lastStatus = build.status
|
|
137
|
+
}
|
|
138
|
+
if (['succeeded', 'failed', 'canceled'].includes(build.status)) {
|
|
139
|
+
return build
|
|
140
|
+
}
|
|
141
|
+
await sleep(3000)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw agentError(
|
|
145
|
+
'build_wait_timeout',
|
|
146
|
+
`Timed out waiting for build ${buildId}.`,
|
|
147
|
+
`Run \`npx tovuk logs --build ${buildId}\` to continue watching.`,
|
|
148
|
+
cli.json
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export {
|
|
153
|
+
deploy
|
|
154
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { doctorCheck } from './checks.ts'
|
|
4
|
+
import { agentError } from './errors.ts'
|
|
5
|
+
import { parseTovukToml, validateConfig } from './config.ts'
|
|
6
|
+
import { discoverDeployProjects } from './workspace.ts'
|
|
7
|
+
import { printJson } from './project.ts'
|
|
8
|
+
import { rustDoctorChecks, unsafeCheck } from './rust-doctor.ts'
|
|
9
|
+
import { frontendLockfileExists, frontendScriptChecks, frontendSourceChecks } from './frontend-policy.ts'
|
|
10
|
+
import type { DoctorCheck, DoctorReport, WorkspaceDoctorReport, TovukConfig } from './types.ts'
|
|
11
|
+
|
|
12
|
+
function doctorProject(projectDir: string, json: boolean): void {
|
|
13
|
+
const report = runDoctorWorkspace(projectDir)
|
|
14
|
+
if (json) {
|
|
15
|
+
printJson(report)
|
|
16
|
+
if (!report.ok) {
|
|
17
|
+
process.exitCode = 1
|
|
18
|
+
}
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
printDoctorReport(report)
|
|
23
|
+
|
|
24
|
+
if (!report.ok) {
|
|
25
|
+
const firstFailure = doctorChecks(report).find((check) => !check.ok)
|
|
26
|
+
throw agentError('doctor_failed', 'Tovuk doctor failed.', firstFailure?.agent_instruction || 'Fix the failed checks and retry `npx tovuk doctor`.', json)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runDoctorWorkspace(projectDir: string): DoctorReport | WorkspaceDoctorReport {
|
|
31
|
+
if (existsSync(path.join(projectDir, 'tovuk.toml'))) {
|
|
32
|
+
return runDoctor(projectDir)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const projects = discoverDeployProjects(projectDir)
|
|
36
|
+
if (projects.length === 0) {
|
|
37
|
+
return runDoctor(projectDir)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const reports = projects.map((project) => {
|
|
41
|
+
const report = runDoctor(project.dir)
|
|
42
|
+
return {
|
|
43
|
+
relative: project.relative,
|
|
44
|
+
ok: report.ok,
|
|
45
|
+
project: report.project,
|
|
46
|
+
config: report.config,
|
|
47
|
+
checks: report.checks
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
return {
|
|
51
|
+
ok: reports.every((report) => report.ok),
|
|
52
|
+
workspace: projectDir,
|
|
53
|
+
projects: reports
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function runDoctor(projectDir: string): DoctorReport {
|
|
58
|
+
const configResult = readConfig(projectDir)
|
|
59
|
+
const checks: DoctorCheck[] = [configResult.check]
|
|
60
|
+
const kind = configResult.config?.kind || 'rust_backend'
|
|
61
|
+
checks.push(...requiredFileChecks(projectDir, kind))
|
|
62
|
+
if (kind === 'static_frontend') {
|
|
63
|
+
checks.push(frontendLockfileCheck(projectDir))
|
|
64
|
+
checks.push(...frontendSourceChecks(projectDir))
|
|
65
|
+
checks.push(...frontendScriptChecks(projectDir, configResult.valid))
|
|
66
|
+
} else {
|
|
67
|
+
checks.push(...rustDoctorChecks(projectDir, configResult.valid))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (kind === 'static_frontend') {
|
|
71
|
+
checks.push(unsafeCheck(projectDir))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ok: checks.every((check) => check.ok),
|
|
76
|
+
project: projectDir,
|
|
77
|
+
config: configResult.config,
|
|
78
|
+
checks
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function printDoctorReport(report: DoctorReport | WorkspaceDoctorReport): void {
|
|
83
|
+
if (isWorkspaceDoctorReport(report)) {
|
|
84
|
+
report.projects.forEach(printProjectReport)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
printChecks(report.checks)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function printProjectReport(report: DoctorReport & { relative: string }): void {
|
|
91
|
+
console.log(`project ${report.relative}`)
|
|
92
|
+
printChecks(report.checks)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function printChecks(checks: readonly DoctorCheck[]): void {
|
|
96
|
+
for (const check of checks) {
|
|
97
|
+
console.log(`${check.ok ? 'ok' : 'fail'} ${check.name}${check.message ? ` - ${check.message}` : ''}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function doctorChecks(report: DoctorReport | WorkspaceDoctorReport): DoctorCheck[] {
|
|
102
|
+
return isWorkspaceDoctorReport(report)
|
|
103
|
+
? report.projects.flatMap((project) => project.checks)
|
|
104
|
+
: report.checks
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readConfig(projectDir: string): { check: DoctorCheck; config: TovukConfig | null; valid: boolean } {
|
|
108
|
+
const configPath = path.join(projectDir, 'tovuk.toml')
|
|
109
|
+
if (!existsSync(configPath)) {
|
|
110
|
+
return {
|
|
111
|
+
check: { name: 'tovuk.toml', ok: false, message: 'missing', agent_instruction: 'Create and commit tovuk.toml, then retry.' },
|
|
112
|
+
config: null,
|
|
113
|
+
valid: false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const config = parseTovukToml(readFileSync(configPath, 'utf8'), projectDir)
|
|
118
|
+
validateConfig(config)
|
|
119
|
+
return { check: { name: 'tovuk.toml', ok: true, message: 'valid', agent_instruction: null }, config, valid: true }
|
|
120
|
+
} catch (error: unknown) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
122
|
+
return { check: { name: 'tovuk.toml', ok: false, message, agent_instruction: `Fix tovuk.toml: ${message}.` }, config: null, valid: false }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requiredFileChecks(projectDir: string, kind: TovukConfig['kind']): DoctorCheck[] {
|
|
127
|
+
return requiredFiles(kind).map((file) => {
|
|
128
|
+
const ok = existsSync(path.join(projectDir, file))
|
|
129
|
+
return doctorCheck(file, ok, 'found', 'missing', `Create and commit ${file}, then retry.`)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function requiredFiles(kind: TovukConfig['kind']): string[] {
|
|
134
|
+
return kind === 'static_frontend' ? ['package.json'] : ['Cargo.toml', 'Cargo.lock']
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function frontendLockfileCheck(projectDir: string): DoctorCheck {
|
|
138
|
+
const ok = frontendLockfileExists(projectDir)
|
|
139
|
+
return doctorCheck('frontend lockfile', ok, 'found', 'missing', 'Commit package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, or bun.lockb, then retry.')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isWorkspaceDoctorReport(report: DoctorReport | WorkspaceDoctorReport): report is WorkspaceDoctorReport {
|
|
143
|
+
return 'projects' in report
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { doctorProject, runDoctor, runDoctorWorkspace }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import type { AgentErrorPayload } from './types.ts'
|
|
4
|
+
|
|
5
|
+
function agentError(code: string, message: string, agentInstruction: string, json: boolean): TovukError {
|
|
6
|
+
return new TovukError({
|
|
7
|
+
code,
|
|
8
|
+
message,
|
|
9
|
+
agent_instruction: agentInstruction,
|
|
10
|
+
docs_url: null,
|
|
11
|
+
checkout_url: null
|
|
12
|
+
}, json, 1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function printAgentError(payload: AgentErrorPayload, json: boolean): void {
|
|
16
|
+
if (json) {
|
|
17
|
+
console.error(JSON.stringify(payload, null, 2))
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.error(payload.message || 'Tovuk command failed.')
|
|
22
|
+
if (payload.agent_instruction) {
|
|
23
|
+
console.error(`agent_instruction: ${payload.agent_instruction}`)
|
|
24
|
+
}
|
|
25
|
+
if (payload.docs_url) {
|
|
26
|
+
console.error(`docs: ${payload.docs_url}`)
|
|
27
|
+
}
|
|
28
|
+
if (payload.checkout_url) {
|
|
29
|
+
console.error(`checkout: ${payload.checkout_url}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class TovukError extends Error {
|
|
34
|
+
payload: AgentErrorPayload
|
|
35
|
+
json: boolean
|
|
36
|
+
exitCode: number
|
|
37
|
+
|
|
38
|
+
constructor(payload: AgentErrorPayload, json: boolean, exitCode: number) {
|
|
39
|
+
super(payload.message || 'Tovuk command failed.')
|
|
40
|
+
this.payload = payload
|
|
41
|
+
this.json = json
|
|
42
|
+
this.exitCode = exitCode
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { agentError, printAgentError, TovukError }
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { doctorCheck } from './checks.ts'
|
|
5
|
+
import { DEFAULT_BUN_FRONTEND_CHECK_COMMAND, DEFAULT_NPM_FRONTEND_CHECK_COMMAND, FRONTEND_INSTALL_COMMANDS, FRONTEND_JAVASCRIPT_EXTENSIONS, FRONTEND_PACKAGE_MANAGERS, FRONTEND_SOURCE_ROOTS, JAVASCRIPT_LINTERS } from './constants.ts'
|
|
6
|
+
import { readPackageJson, walkProjectFiles } from './project.ts'
|
|
7
|
+
import type { DoctorCheck, FrontendSourceReport, PackageManifest } from './types.ts'
|
|
8
|
+
|
|
9
|
+
const REQUIRED_FRONTEND_SCRIPTS = ['typecheck', 'lint'] as const
|
|
10
|
+
type FrontendScriptName = typeof REQUIRED_FRONTEND_SCRIPTS[number]
|
|
11
|
+
type CommandPredicate = (command: string) => boolean
|
|
12
|
+
|
|
13
|
+
function frontendLockfileExists(projectDir: string): boolean {
|
|
14
|
+
return ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']
|
|
15
|
+
.some((file) => existsSync(path.join(projectDir, file)))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function frontendPackageManager(projectDir: string): 'bun' | 'npm' {
|
|
19
|
+
return existsSync(path.join(projectDir, 'bun.lock')) || existsSync(path.join(projectDir, 'bun.lockb'))
|
|
20
|
+
? 'bun'
|
|
21
|
+
: 'npm'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function frontendCheckCommand(projectDir: string): string {
|
|
25
|
+
return frontendPackageManager(projectDir) === 'bun'
|
|
26
|
+
? DEFAULT_BUN_FRONTEND_CHECK_COMMAND
|
|
27
|
+
: DEFAULT_NPM_FRONTEND_CHECK_COMMAND
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function frontendBuildCommand(projectDir: string): string {
|
|
31
|
+
return frontendPackageManager(projectDir) === 'bun'
|
|
32
|
+
? 'bun run build'
|
|
33
|
+
: 'npm run build'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function frontendScriptChecks(projectDir: string, runScripts: boolean): DoctorCheck[] {
|
|
37
|
+
const manifest = readPackageJson(projectDir)
|
|
38
|
+
const scripts: Record<FrontendScriptName, string> = {
|
|
39
|
+
typecheck: packageScriptValue(manifest, 'typecheck'),
|
|
40
|
+
lint: packageScriptValue(manifest, 'lint')
|
|
41
|
+
}
|
|
42
|
+
const checks: DoctorCheck[] = [
|
|
43
|
+
...REQUIRED_FRONTEND_SCRIPTS.map((script) => packageScriptExistsCheck(script, scripts[script])),
|
|
44
|
+
strictTypecheckCheck(scripts.typecheck),
|
|
45
|
+
nativeLintCheck(manifest),
|
|
46
|
+
nativeQualityGateCheck(manifest)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
if (runScripts && checks.every((check) => check.ok)) {
|
|
50
|
+
checks.push(...REQUIRED_FRONTEND_SCRIPTS.map((script) => packageScriptCheck(projectDir, script)))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return checks
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function packageScriptExistsCheck(script: FrontendScriptName, command: string): DoctorCheck {
|
|
57
|
+
const ok = command !== ''
|
|
58
|
+
return doctorCheck(`package script ${script}`, ok, 'found', 'missing', `Add a non-empty "${script}" script to package.json, then retry.`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function strictTypecheckCheck(command: string): DoctorCheck {
|
|
62
|
+
const ok = usesStrictFrontendTypechecker(command)
|
|
63
|
+
return doctorCheck('strict frontend typecheck', ok, 'accepted', 'native typecheck missing', 'Set package.json `typecheck` to `oxlint src vite.config.ts --deny-warnings --type-aware --type-check --tsconfig tsconfig.json`, then retry.')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function nativeLintCheck(manifest: PackageManifest | null): DoctorCheck {
|
|
67
|
+
const ok = !packageScriptTreeUses(manifest, 'lint', usesJavascriptLinter)
|
|
68
|
+
&& packageScriptTreeUses(manifest, 'lint', usesNativeFrontendLinter)
|
|
69
|
+
return doctorCheck('native frontend lint', ok, 'accepted', 'native linter missing', 'Replace the lint script with native tooling such as `oxlint src vite.config.ts --deny-warnings`, `biome check .`, or `deno lint`, then retry.')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nativeQualityGateCheck(manifest: PackageManifest | null): DoctorCheck {
|
|
73
|
+
const ok = packageScriptTreeUses(manifest, 'lint', usesNativeDeadCodeChecker)
|
|
74
|
+
&& packageScriptTreeUses(manifest, 'lint', usesNativeDuplicateChecker)
|
|
75
|
+
&& packageScriptTreeUses(manifest, 'lint', usesNativeHealthChecker)
|
|
76
|
+
return doctorCheck('native frontend quality gates', ok, 'accepted', 'dead-code, duplicate-code, or health gate missing', 'Add Fallow checks for `dead-code`, semantic `dupes`, and `health` to package.json `lint`, then retry.')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function frontendSourceChecks(projectDir: string): DoctorCheck[] {
|
|
80
|
+
const report = frontendSourceReport(projectDir)
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
name: 'typescript source',
|
|
84
|
+
ok: report.typescript.length > 0,
|
|
85
|
+
message: report.typescript.length > 0 ? report.typescript.slice(0, 3).join(', ') : 'missing',
|
|
86
|
+
agent_instruction: report.typescript.length > 0 ? null : 'Add browser source as .ts or .tsx under src, app, pages, routes, or components, then retry.'
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'javascript source',
|
|
90
|
+
ok: report.javascript.length === 0,
|
|
91
|
+
message: report.javascript.length === 0 ? 'none found' : report.javascript.slice(0, 5).join(', '),
|
|
92
|
+
agent_instruction: report.javascript.length === 0 ? null : 'Rename browser .js, .jsx, .mjs, or .cjs source files to .ts or .tsx and fix type errors before deploying.'
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function frontendSourceReport(projectDir: string): FrontendSourceReport {
|
|
98
|
+
const report: FrontendSourceReport = { typescript: [], javascript: [] }
|
|
99
|
+
walkProjectFiles(projectDir, (_file, relative) => {
|
|
100
|
+
const sourceKind = frontendSourceKind(relative)
|
|
101
|
+
if (sourceKind) {
|
|
102
|
+
report[sourceKind].push(relative)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
return report
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function frontendSourceKind(relative: string): keyof FrontendSourceReport | null {
|
|
109
|
+
if (!isFrontendSourcePath(relative)) {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
if (isFrontendTypescriptSource(relative)) {
|
|
113
|
+
return 'typescript'
|
|
114
|
+
}
|
|
115
|
+
return isFrontendJavascriptSource(relative) ? 'javascript' : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isFrontendSourcePath(relative: string): boolean {
|
|
119
|
+
const [root = ''] = relative.split('/')
|
|
120
|
+
return FRONTEND_SOURCE_ROOTS.has(root)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isFrontendTypescriptSource(relative: string): boolean {
|
|
124
|
+
return !relative.endsWith('.d.ts') && (relative.endsWith('.ts') || relative.endsWith('.tsx'))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isFrontendJavascriptSource(relative: string): boolean {
|
|
128
|
+
return FRONTEND_JAVASCRIPT_EXTENSIONS.some((extension) => relative.endsWith(extension))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function packageScriptValue(manifest: PackageManifest | null, script: string): string {
|
|
132
|
+
const value = manifest?.scripts?.[script]
|
|
133
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function usesJavascriptLinter(command: string): boolean {
|
|
137
|
+
const tokens = commandTokens(command)
|
|
138
|
+
return tokens.some((token, index) => {
|
|
139
|
+
const commandName = commandNameFromToken(token)
|
|
140
|
+
return JAVASCRIPT_LINTERS.has(commandName)
|
|
141
|
+
|| (commandName === 'next' && tokens[index + 1] === 'lint')
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function usesStrictFrontendTypechecker(command: string): boolean {
|
|
146
|
+
const tokens = commandTokens(command)
|
|
147
|
+
return tokens.some((token, index) => {
|
|
148
|
+
const commandName = commandNameFromToken(token)
|
|
149
|
+
return (commandName === 'oxlint' && tokens.includes('--type-aware') && tokens.includes('--type-check'))
|
|
150
|
+
|| (commandName === 'deno' && tokens[index + 1] === 'check')
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function usesNativeFrontendLinter(command: string): boolean {
|
|
155
|
+
const tokens = commandTokens(command)
|
|
156
|
+
return tokens.some((token, index) => {
|
|
157
|
+
const commandName = commandNameFromToken(token)
|
|
158
|
+
return commandName === 'oxlint'
|
|
159
|
+
|| (commandName === 'biome' && ['check', 'lint'].includes(tokens[index + 1] ?? ''))
|
|
160
|
+
|| (commandName === 'deno' && tokens[index + 1] === 'lint')
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function usesNativeDeadCodeChecker(command: string): boolean {
|
|
165
|
+
return usesFallowSubcommand(command, 'dead-code')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function usesNativeDuplicateChecker(command: string): boolean {
|
|
169
|
+
return usesFallowSubcommand(command, 'dupes')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function usesNativeHealthChecker(command: string): boolean {
|
|
173
|
+
return usesFallowSubcommand(command, 'health')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function usesFallowSubcommand(command: string, subcommand: string): boolean {
|
|
177
|
+
return commandTokens(command).some((token, index, tokens) => (
|
|
178
|
+
commandNameFromToken(token) === 'fallow' && tokens[index + 1] === subcommand
|
|
179
|
+
))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function packageScriptTreeUses(manifest: PackageManifest | null, script: string, predicate: CommandPredicate, seen = new Set<string>()): boolean {
|
|
183
|
+
if (seen.has(script)) {
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
seen.add(script)
|
|
187
|
+
|
|
188
|
+
const command = packageScriptValue(manifest, script)
|
|
189
|
+
if (command === '') {
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
if (predicate(command)) {
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return referencedPackageScripts(command).some((referencedScript) => packageScriptTreeUses(manifest, referencedScript, predicate, seen))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function referencedPackageScripts(command: string): string[] {
|
|
200
|
+
const tokens = commandTokens(command)
|
|
201
|
+
const scripts: string[] = []
|
|
202
|
+
for (const [index, token] of tokens.entries()) {
|
|
203
|
+
if (!FRONTEND_PACKAGE_MANAGERS.has(commandNameFromToken(token)) || tokens[index + 1] !== 'run') {
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
const script = scriptNameAfterRun(tokens, index + 2)
|
|
207
|
+
if (script !== null) {
|
|
208
|
+
scripts.push(script)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return scripts
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function scriptNameAfterRun(tokens: readonly string[], start: number): string | null {
|
|
215
|
+
let index = start
|
|
216
|
+
while (tokens[index]?.startsWith('-')) {
|
|
217
|
+
index += 1
|
|
218
|
+
}
|
|
219
|
+
return tokens[index] ?? null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function commandTokens(command: string): string[] {
|
|
223
|
+
return command
|
|
224
|
+
.replace(/[&|;()]/gu, ' ')
|
|
225
|
+
.split(/\s+/u)
|
|
226
|
+
.map((token) => token.trim().replace(/^["']|["']$/gu, ''))
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function commandNameFromToken(token: string): string {
|
|
231
|
+
return token.split('/').pop() ?? ''
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function hasFrontendInstallCommand(tokens: string[]): boolean {
|
|
235
|
+
return tokens.some((token, index) => FRONTEND_INSTALL_COMMANDS.has(`${commandNameFromToken(token)} ${tokens[index + 1] || ''}`))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function hasFrontendScriptRun(tokens: string[], script: string): boolean {
|
|
239
|
+
return tokens.some((token, index) => {
|
|
240
|
+
if (!FRONTEND_PACKAGE_MANAGERS.has(commandNameFromToken(token)) || tokens[index + 1] !== 'run') {
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
return tokens[index + 2] === script || ((tokens[index + 2] ?? '').startsWith('-') && tokens[index + 3] === script)
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function packageScriptCheck(projectDir: string, script: string): DoctorCheck {
|
|
248
|
+
const manager = frontendPackageManager(projectDir)
|
|
249
|
+
const args = manager === 'bun' ? ['run', script] : ['run', '--silent', script]
|
|
250
|
+
const result = spawnSync(manager, args, {
|
|
251
|
+
cwd: projectDir,
|
|
252
|
+
encoding: 'utf8',
|
|
253
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
254
|
+
})
|
|
255
|
+
if (result.error) {
|
|
256
|
+
return {
|
|
257
|
+
name: `${manager} run ${script}`,
|
|
258
|
+
ok: false,
|
|
259
|
+
message: result.error.message,
|
|
260
|
+
agent_instruction: `Install ${manager === 'bun' ? 'Bun' : 'Node.js and npm'}, then run \`${manager} run ${script}\` before deploying.`
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
name: `${manager} run ${script}`,
|
|
266
|
+
ok: result.status === 0,
|
|
267
|
+
message: result.status === 0 ? 'passed' : (result.stderr || result.stdout || `${manager} run ${script} failed`).trim().slice(0, 240),
|
|
268
|
+
agent_instruction: result.status === 0 ? null : `Run \`${manager} run ${script}\`, fix every error, then redeploy.`
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export { frontendLockfileExists, frontendCheckCommand, frontendBuildCommand, frontendScriptChecks, frontendSourceChecks, usesJavascriptLinter, commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun }
|