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