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 ADDED
@@ -0,0 +1,72 @@
1
+ # tovuk
2
+
3
+ Deploy Rust backends and static frontends to Tovuk.
4
+
5
+ ```sh
6
+ npx tovuk init my-app --template fullstack-rust-tanstack
7
+ cd my-app/web && bun install && cd ..
8
+ npx tovuk doctor --json
9
+ npx tovuk deploy --wait --json
10
+ ```
11
+
12
+ `npx tovuk` is the public npm command.
13
+
14
+ Rust backends expect `Cargo.toml`, `Cargo.lock`, and `tovuk.toml`. They must
15
+ pass `cargo fmt --all --check`, locked Cargo checks, listen on
16
+ `0.0.0.0:$PORT`, and expose the configured health endpoint.
17
+
18
+ Static frontends must use TypeScript browser source, stable native type-aware
19
+ TypeScript checks, native linting such as `oxlint`, `biome check`, or
20
+ `deno lint`, and Fallow dead-code, semantic duplicate-code, and health gates.
21
+
22
+ From a full-stack repo root, the same deploy command discovers nested
23
+ `tovuk.toml` files and deploys the whole workspace in one command.
24
+
25
+ Preview before deploying:
26
+
27
+ ```sh
28
+ npx tovuk preview
29
+ ```
30
+
31
+ Agent repair loop:
32
+
33
+ ```sh
34
+ npx tovuk doctor --json
35
+ npx tovuk deploy --wait --json
36
+ npx tovuk logs --build job_1 --json
37
+ ```
38
+
39
+ Fix the first failed `agent_instruction`. If a build fails, inspect build logs,
40
+ fix the first actionable log error, rerun doctor, then redeploy.
41
+
42
+ Managed Postgres apps receive `DATABASE_URL`, `TOVUK_DATABASE_URL`, and
43
+ `TOVUK_DATABASE_CONNECTION_LIMIT`. Use that limit as the max size for your
44
+ database pool.
45
+
46
+ Agents can also inspect API capabilities, account identity, usage, account
47
+ activity, apps, complete app overviews, deploys, builds, app/deploy/build logs,
48
+ env metadata, custom domains, domain verification, billing checkout links,
49
+ billing portal links, and support ticket create, list, and resolve actions
50
+ through the same CLI.
51
+
52
+ When a free-tier limit blocks work, run:
53
+
54
+ ```sh
55
+ npx tovuk billing checkout --json
56
+ ```
57
+
58
+ When Tovuk support is needed, include enough evidence for a support agent:
59
+
60
+ ```sh
61
+ npx tovuk support create "Deploy failed" "Agent retried deploy after doctor." --app app_1 --build job_1 --deploy deploy_1 --failing-command "npx tovuk deploy --wait --json" --first-log-line "cargo check failed in src/main.rs" --json
62
+ ```
63
+
64
+ When the issue is fixed, resolve the ticket:
65
+
66
+ ```sh
67
+ npx tovuk support resolve ticket_0123456789abcdef0123 --json
68
+ ```
69
+
70
+ On first deploy, the CLI opens browser login, waits for GitHub or Google, stores
71
+ the Tovuk session in the OS credential store when available, and continues the
72
+ deploy. Later commands reuse that session.
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "tovuk",
3
+ "version": "0.1.47",
4
+ "description": "Deploy Rust backends and static frontends to Tovuk.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tovuk": "src/tovuk.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "tsconfig.json",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "engines": {
18
+ "node": ">=18.17"
19
+ },
20
+ "keywords": [
21
+ "tovuk",
22
+ "rust",
23
+ "deploy",
24
+ "backend",
25
+ "hosting"
26
+ ],
27
+ "homepage": "https://tovuk.com",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/tovuk/tovuk.git",
31
+ "directory": "packages/tovuk"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/tovuk/tovuk/issues"
35
+ },
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "tsx": "^4.22.3"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^25.9.1",
42
+ "fallow": "^2.84.0",
43
+ "oxlint": "^1.67.0",
44
+ "oxlint-tsgolint": "^0.23.0"
45
+ },
46
+ "scripts": {
47
+ "check": "npm run check:policy && npm run typecheck && npm run lint && npm run lint:dead && npm run lint:dupes && npm run lint:health && npm run check:deps && npm run runtime && npm run pack:dry",
48
+ "check:policy": "node ../../scripts/check-npm-cli-package.mjs",
49
+ "typecheck": "oxlint src --deny-warnings --type-aware --type-check --tsconfig tsconfig.json",
50
+ "lint": "oxlint src -D correctness -D suspicious -D perf -A no-await-in-loop --deny-warnings --type-aware --type-check --tsconfig tsconfig.json --promise-plugin --node-plugin --report-unused-disable-directives",
51
+ "lint:dead": "fallow dead-code --production --include-dupes --include-entry-exports --fail-on-issues",
52
+ "lint:dupes": "fallow dupes --production --mode semantic --threshold 1 --ignore-imports --fail-on-issues; dupes=$(fallow dupes --production --mode semantic --threshold 1 --ignore-imports --format json | jq '.clone_groups | length'); test \"$dupes\" = 0",
53
+ "lint:health": "fallow health --production --max-cyclomatic 10 --max-cognitive 15 --max-crap 20 --complexity --format json | jq -e '[.findings[] | select(.severity == \"critical\")] | length == 0' >/dev/null && fallow health --production --score --format json | jq -e '.health_score.score >= 90' >/dev/null",
54
+ "check:deps": "npm ls --all && npm audit --audit-level=moderate && npm audit signatures --omit=dev --json",
55
+ "runtime": "src/tovuk.ts --version",
56
+ "pack:dry": "npm pack --dry-run"
57
+ }
58
+ }
@@ -0,0 +1,94 @@
1
+ import { TovukError } from './errors.ts'
2
+ import { isJsonObject, parseJson, stringField } from './json.ts'
3
+ import type { AgentErrorPayload, CliOptions, JsonValue } from './types.ts'
4
+
5
+ interface AgentErrorContext {
6
+ cli: CliOptions
7
+ route: string
8
+ token: string | null
9
+ }
10
+
11
+ const BILLING_CHECKOUT_ROUTE = '/v1/billing/checkout'
12
+
13
+ async function enrichAgentErrorPayload(
14
+ context: AgentErrorContext,
15
+ payload: AgentErrorPayload
16
+ ): Promise<void> {
17
+ if (!shouldCreateCheckoutUrl(context, payload)) {
18
+ return
19
+ }
20
+
21
+ const checkoutUrl = await createCheckoutUrl(context.cli, context.token, payload.message)
22
+ if (checkoutUrl) {
23
+ payload.checkout_url = checkoutUrl
24
+ }
25
+ }
26
+
27
+ async function paymentRequiredAgentError(
28
+ cli: CliOptions,
29
+ token: string,
30
+ message: string,
31
+ agentInstruction: string
32
+ ): Promise<TovukError> {
33
+ const payload: AgentErrorPayload = {
34
+ code: 'payment_required',
35
+ message,
36
+ agent_instruction: agentInstruction,
37
+ docs_url: null,
38
+ checkout_url: null
39
+ }
40
+ await enrichAgentErrorPayload({ cli, route: 'local:preflight', token }, payload)
41
+ return new TovukError(payload, cli.json, 1)
42
+ }
43
+
44
+ function shouldCreateCheckoutUrl(
45
+ context: AgentErrorContext,
46
+ payload: AgentErrorPayload
47
+ ): boolean {
48
+ return payload.code === 'payment_required'
49
+ && !payload.checkout_url
50
+ && Boolean(context.token)
51
+ && context.route !== BILLING_CHECKOUT_ROUTE
52
+ }
53
+
54
+ async function createCheckoutUrl(
55
+ cli: CliOptions,
56
+ token: string | null,
57
+ reason: string
58
+ ): Promise<string> {
59
+ if (!token) {
60
+ return ''
61
+ }
62
+
63
+ try {
64
+ const response = await fetch(`${cli.apiUrl}${BILLING_CHECKOUT_ROUTE}`, {
65
+ body: JSON.stringify({
66
+ reason: reason || 'Plan limit reached.',
67
+ target_plan: 'pro'
68
+ }),
69
+ headers: new Headers({
70
+ accept: 'application/json',
71
+ authorization: `Bearer ${token}`,
72
+ 'content-type': 'application/json'
73
+ }),
74
+ method: 'POST'
75
+ })
76
+ if (!response.ok) {
77
+ return ''
78
+ }
79
+ return checkoutUrlFromJson(parseJson(await response.text()))
80
+ } catch {
81
+ return ''
82
+ }
83
+ }
84
+
85
+ function checkoutUrlFromJson(value: JsonValue | null): string {
86
+ if (!isJsonObject(value)) {
87
+ return ''
88
+ }
89
+ const checkoutValue = value['checkout'] ?? null
90
+ const checkout = isJsonObject(checkoutValue) ? checkoutValue : {}
91
+ return stringField(checkout, 'url')
92
+ }
93
+
94
+ export { enrichAgentErrorPayload, paymentRequiredAgentError }
@@ -0,0 +1,133 @@
1
+ import {
2
+ isJsonObject,
3
+ jsonArrayField,
4
+ jsonObjectField,
5
+ jsonObjectOrEmpty,
6
+ numberField,
7
+ optionalJsonObjectField,
8
+ stringField
9
+ } from './json.ts'
10
+ import type {
11
+ AppsResponse,
12
+ AppSummary,
13
+ BuildRecord,
14
+ BuildStatusResponse,
15
+ CheckoutResponse,
16
+ DeployResponse,
17
+ JsonObject,
18
+ JsonValue,
19
+ LogLine,
20
+ LogsResponse
21
+ } from './types.ts'
22
+
23
+ function appsResponseFromJson(value: JsonValue | null): AppsResponse {
24
+ return {
25
+ apps: jsonArrayField(jsonObjectOrEmpty(value), 'apps')
26
+ .map(appSummaryFromJson)
27
+ .filter((app): app is AppSummary => app !== null)
28
+ }
29
+ }
30
+
31
+ function appSummaryFromJson(value: JsonValue): AppSummary | null {
32
+ if (!isJsonObject(value)) {
33
+ return null
34
+ }
35
+ const app: AppSummary = {}
36
+ const id = stringField(value, 'id')
37
+ const name = stringField(value, 'name')
38
+ const url = stringField(value, 'url')
39
+ const databaseStorageMib = numberField(value, 'databaseStorageMib')
40
+ if (id) {
41
+ app.id = id
42
+ }
43
+ if (name) {
44
+ app.name = name
45
+ }
46
+ if (url) {
47
+ app.url = url
48
+ }
49
+ if (databaseStorageMib > 0) {
50
+ app.databaseStorageMib = databaseStorageMib
51
+ }
52
+ return app
53
+ }
54
+
55
+ function deployResponseFromJson(value: JsonValue | null): DeployResponse {
56
+ const source = jsonObjectOrEmpty(value)
57
+ const finalBuild = optionalJsonObjectField(source, 'final_build')
58
+ const response: DeployResponse = {
59
+ app: appDeployTargetFromJson(jsonObjectField(source, 'app')),
60
+ build_job: {
61
+ id: stringField(jsonObjectField(source, 'build_job'), 'id')
62
+ }
63
+ }
64
+ if (finalBuild) {
65
+ response.final_build = buildRecordFromJson(finalBuild)
66
+ }
67
+ return response
68
+ }
69
+
70
+ function appDeployTargetFromJson(source: JsonObject): DeployResponse['app'] {
71
+ return {
72
+ id: idFromJson(source),
73
+ url: stringField(source, 'url')
74
+ }
75
+ }
76
+
77
+ function buildStatusResponseFromJson(value: JsonValue | null): BuildStatusResponse {
78
+ const source = jsonObjectOrEmpty(value)
79
+ const build = optionalJsonObjectField(source, 'build')
80
+ return build ? { build: buildRecordFromJson(build) } : {}
81
+ }
82
+
83
+ function buildRecordFromJson(source: JsonObject): BuildRecord {
84
+ return {
85
+ id: idFromJson(source),
86
+ status: stringField(source, 'status')
87
+ }
88
+ }
89
+
90
+ function idFromJson(source: JsonObject): string {
91
+ return stringField(source, 'id')
92
+ }
93
+
94
+ function logsResponseFromJson(value: JsonValue | null): LogsResponse {
95
+ const source = jsonObjectOrEmpty(value)
96
+ const lines = jsonArrayField(source, 'lines')
97
+ .map(logLineFromJson)
98
+ .filter((line): line is LogLine => line !== null)
99
+ return {
100
+ lines,
101
+ has_more: source['has_more'] === true,
102
+ next_cursor: stringField(source, 'next_cursor')
103
+ }
104
+ }
105
+
106
+ function logLineFromJson(value: JsonValue): LogLine | null {
107
+ if (!isJsonObject(value)) {
108
+ return null
109
+ }
110
+ const timestamp = stringField(value, 'timestamp')
111
+ const stream = stringField(value, 'stream')
112
+ const message = stringField(value, 'message')
113
+ return timestamp && stream && message ? { timestamp, stream, message } : null
114
+ }
115
+
116
+ function checkoutResponseFromJson(value: JsonValue | null): CheckoutResponse {
117
+ const source = jsonObjectOrEmpty(value)
118
+ const checkout = jsonObjectField(source, 'checkout')
119
+ return {
120
+ checkout: {
121
+ reason: stringField(checkout, 'reason'),
122
+ url: stringField(checkout, 'url')
123
+ }
124
+ }
125
+ }
126
+
127
+ export {
128
+ appsResponseFromJson,
129
+ buildStatusResponseFromJson,
130
+ checkoutResponseFromJson,
131
+ deployResponseFromJson,
132
+ logsResponseFromJson
133
+ }
@@ -0,0 +1,77 @@
1
+ import { TovukError, agentError } from './errors.ts'
2
+ import { enrichAgentErrorPayload } from './agent-error-enrichment.ts'
3
+ import { isJsonObject, parseJson } from './json.ts'
4
+ import type { AgentErrorPayload, ApiMethod, CliOptions, JsonValue } from './types.ts'
5
+
6
+ function requireApp(cli: CliOptions): string {
7
+ if (!cli.app) {
8
+ throw agentError('missing_app', 'App is required.', 'Pass `--app <app>` using either the app name from tovuk.toml or the app id printed by deploy.', cli.json)
9
+ }
10
+ return cli.app
11
+ }
12
+
13
+ function pageQuery(cli: CliOptions): string {
14
+ const params = new URLSearchParams()
15
+ if (cli.limit) {
16
+ params.set('limit', cli.limit)
17
+ }
18
+ if (cli.cursor) {
19
+ params.set('cursor', cli.cursor)
20
+ }
21
+ const value = params.toString()
22
+ return value ? `?${value}` : ''
23
+ }
24
+
25
+ async function apiRequest(
26
+ cli: CliOptions,
27
+ method: ApiMethod,
28
+ route: string,
29
+ token: string | null,
30
+ body: JsonValue | null
31
+ ): Promise<JsonValue | null> {
32
+ const headers = new Headers({ accept: 'application/json' })
33
+ if (token) {
34
+ headers.set('authorization', `Bearer ${token}`)
35
+ }
36
+ if (body !== null) {
37
+ headers.set('content-type', 'application/json')
38
+ }
39
+
40
+ const init: RequestInit = {
41
+ method,
42
+ headers
43
+ }
44
+ if (body !== null) {
45
+ init.body = JSON.stringify(body)
46
+ }
47
+ const response = await fetch(`${cli.apiUrl}${route}`, init)
48
+
49
+ const text = await response.text()
50
+ const data = parseJson(text)
51
+ if (!response.ok) {
52
+ const payload: AgentErrorPayload = isAgentErrorPayload(data) ? data : {
53
+ code: 'api_error',
54
+ message: `Tovuk API returned HTTP ${response.status}.`,
55
+ agent_instruction: 'Retry the command. If it keeps failing, check Tovuk status before changing your project.',
56
+ docs_url: null,
57
+ checkout_url: null
58
+ }
59
+ await enrichAgentErrorPayload({ cli, route, token }, payload)
60
+ throw new TovukError(payload, cli.json, response.status >= 500 ? 2 : 1)
61
+ }
62
+
63
+ return data
64
+ }
65
+
66
+ function isAgentErrorPayload(value: JsonValue | null): value is AgentErrorPayload {
67
+ return isJsonObject(value)
68
+ && typeof value['code'] === 'string'
69
+ && typeof value['message'] === 'string'
70
+ && (typeof value['agent_instruction'] === 'string' || value['agent_instruction'] === null)
71
+ }
72
+
73
+ export {
74
+ requireApp,
75
+ pageQuery,
76
+ apiRequest
77
+ }
@@ -0,0 +1,35 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { ARCHIVE_EXCLUDES, ARCHIVE_LIMIT_BYTES } from './constants.ts'
3
+ import { agentError } from './errors.ts'
4
+
5
+ function createArchiveBase64(projectDir: string): string {
6
+ const excludeArgs = ARCHIVE_EXCLUDES.map((pattern) => `--exclude=${pattern}`)
7
+ const tar = spawnSync('tar', [...excludeArgs, '-czf', '-', '-C', projectDir, '.'], {
8
+ encoding: 'buffer',
9
+ env: { ...process.env, COPYFILE_DISABLE: '1' },
10
+ maxBuffer: ARCHIVE_LIMIT_BYTES + 1024 * 1024
11
+ })
12
+
13
+ if (tar.error) {
14
+ throw agentError('archive_failed', 'Could not create source archive.', 'Install `tar`, remove local build outputs, then retry `npx tovuk deploy`.', false)
15
+ }
16
+ if (tar.status !== 0) {
17
+ throw agentError('archive_failed', 'Could not create source archive.', String(tar.stderr || 'Check project files and retry.'), false)
18
+ }
19
+ if (tar.stdout.length > ARCHIVE_LIMIT_BYTES) {
20
+ throw agentError('archive_too_large', 'Source archive is too large.', 'Remove build outputs, target directories, logs, and local caches before deploying.', false)
21
+ }
22
+
23
+ return tar.stdout.toString('base64')
24
+ }
25
+
26
+ function gitCommitSha(projectDir: string): string | null {
27
+ const git = spawnSync('git', ['rev-parse', 'HEAD'], {
28
+ cwd: projectDir,
29
+ encoding: 'utf8',
30
+ stdio: ['ignore', 'pipe', 'ignore']
31
+ })
32
+ return git.status === 0 ? git.stdout.trim() || null : null
33
+ }
34
+
35
+ export { createArchiveBase64, gitCommitSha }
@@ -0,0 +1,185 @@
1
+ import path from 'node:path'
2
+ import { DEFAULT_API_URL, DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS } from './constants.ts'
3
+ import { agentError } from './errors.ts'
4
+ import type { CliOptions } from './types.ts'
5
+
6
+ type BooleanCliField = 'database' | 'help' | 'json' | 'version' | 'wait'
7
+ type StringCliField = 'apiUrl' | 'app' | 'build' | 'cursor' | 'deploy' | 'failingCommand' | 'firstLogLine' | 'limit' | 'severity' | 'template' | 'token'
8
+ type NumberCliField = 'port' | 'waitTimeoutSeconds'
9
+ type FlagValueKind = 'none' | 'string' | 'positiveInteger'
10
+ type FlagSetter = (cli: CliOptions, value: string | null, name: string) => void
11
+
12
+ interface FlagSpec {
13
+ valueKind: FlagValueKind
14
+ set: FlagSetter
15
+ }
16
+
17
+ interface ParsedFlag {
18
+ name: string
19
+ inlineValue: string | null
20
+ }
21
+
22
+ const FLAG_SPECS = new Map<string, FlagSpec>([
23
+ ['--help', booleanOption('help', true)],
24
+ ['-h', booleanOption('help', true)],
25
+ ['--version', booleanOption('version', true)],
26
+ ['-v', booleanOption('version', true)],
27
+ ['-V', booleanOption('version', true)],
28
+ ['--json', booleanOption('json', true)],
29
+ ['--database', booleanOption('database', true)],
30
+ ['--no-database', booleanOption('database', false)],
31
+ ['--wait', booleanOption('wait', true)],
32
+ ['--wait-timeout', positiveIntegerOption('waitTimeoutSeconds')],
33
+ ['--api', stringOption('apiUrl')],
34
+ ['--app', stringOption('app')],
35
+ ['--build', stringOption('build')],
36
+ ['--deploy', stringOption('deploy')],
37
+ ['--failing-command', stringOption('failingCommand')],
38
+ ['--first-log-line', stringOption('firstLogLine')],
39
+ ['--limit', stringOption('limit')],
40
+ ['--cursor', stringOption('cursor')],
41
+ ['--severity', stringOption('severity')],
42
+ ['--token', stringOption('token')],
43
+ ['--template', stringOption('template')],
44
+ ['--port', positiveIntegerOption('port')]
45
+ ])
46
+
47
+ function parseArgs(argv: string[]): CliOptions {
48
+ const cli: CliOptions = {
49
+ command: 'help',
50
+ args: [],
51
+ apiUrl: DEFAULT_API_URL,
52
+ app: '',
53
+ build: '',
54
+ deploy: '',
55
+ limit: '',
56
+ cursor: '',
57
+ failingCommand: '',
58
+ firstLogLine: '',
59
+ token: '',
60
+ template: '',
61
+ severity: '',
62
+ port: 0,
63
+ waitTimeoutSeconds: DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS,
64
+ json: false,
65
+ database: false,
66
+ wait: false,
67
+ help: false,
68
+ version: false
69
+ }
70
+
71
+ const positional: string[] = []
72
+ for (let index = 0; index < argv.length; index += 1) {
73
+ const arg = argv[index] ?? ''
74
+ if (arg === '--') {
75
+ positional.push(...argv.slice(index + 1))
76
+ break
77
+ }
78
+ const parsedFlag = parseFlag(arg)
79
+ const spec = FLAG_SPECS.get(parsedFlag.name)
80
+ if (spec) {
81
+ index = applyFlag(cli, spec, parsedFlag, argv, index)
82
+ } else if (arg.startsWith('-')) {
83
+ throw unknownFlag(cli, arg)
84
+ } else {
85
+ positional.push(arg)
86
+ }
87
+ }
88
+
89
+ if (positional.length > 0) {
90
+ cli.command = positional[0] ?? 'help'
91
+ cli.args = positional.slice(1)
92
+ }
93
+
94
+ cli.apiUrl = trimTrailingSlash(cli.apiUrl)
95
+ return cli
96
+ }
97
+
98
+ function booleanOption(field: BooleanCliField, value: boolean): FlagSpec {
99
+ return {
100
+ valueKind: 'none',
101
+ set: (cli): void => {
102
+ cli[field] = value
103
+ }
104
+ }
105
+ }
106
+
107
+ function stringOption(field: StringCliField): FlagSpec {
108
+ return {
109
+ valueKind: 'string',
110
+ set: (cli, value): void => {
111
+ cli[field] = value ?? ''
112
+ }
113
+ }
114
+ }
115
+
116
+ function positiveIntegerOption(field: NumberCliField): FlagSpec {
117
+ return {
118
+ valueKind: 'positiveInteger',
119
+ set: (cli, value, name): void => {
120
+ cli[field] = parsePositiveInteger(value ?? '', name)
121
+ }
122
+ }
123
+ }
124
+
125
+ function applyFlag(cli: CliOptions, spec: FlagSpec, flag: ParsedFlag, argv: readonly string[], index: number): number {
126
+ if (spec.valueKind === 'none') {
127
+ if (flag.inlineValue !== null) {
128
+ throw agentError('invalid_argument', `${flag.name} does not accept a value.`, `Use ${flag.name} without =value.`, cli.json)
129
+ }
130
+ spec.set(cli, null, flag.name)
131
+ return index
132
+ }
133
+
134
+ const value = flag.inlineValue ?? requireValue(argv, index, flag.name)
135
+ if (value === '') {
136
+ throw agentError('missing_argument', `${flag.name} requires a value.`, `Pass a value after ${flag.name}.`, cli.json)
137
+ }
138
+ spec.set(cli, value, flag.name)
139
+ return flag.inlineValue === null ? index + 1 : index
140
+ }
141
+
142
+ function parseFlag(arg: string): ParsedFlag {
143
+ if (!arg.startsWith('--')) {
144
+ return { name: arg, inlineValue: null }
145
+ }
146
+ const separator = arg.indexOf('=')
147
+ return separator > 2
148
+ ? { name: arg.slice(0, separator), inlineValue: arg.slice(separator + 1) }
149
+ : { name: arg, inlineValue: null }
150
+ }
151
+
152
+ function parsePositiveInteger(value: string, name: string): number {
153
+ const parsed = Number.parseInt(value, 10)
154
+ if (!Number.isInteger(parsed) || parsed <= 0) {
155
+ throw agentError('invalid_argument', `${name} must be a positive integer.`, `Pass ${name} as seconds, for example ${name} 900.`, false)
156
+ }
157
+ return parsed
158
+ }
159
+
160
+ function requireValue(argv: readonly string[], index: number, name: string): string {
161
+ const value = argv[index + 1]
162
+ if (!value || value.startsWith('--')) {
163
+ throw agentError('missing_argument', `${name} requires a value.`, `Pass a value after ${name}.`, false)
164
+ }
165
+ return value
166
+ }
167
+
168
+ function unknownFlag(cli: CliOptions, value: string): never {
169
+ throw agentError(
170
+ 'unknown_argument',
171
+ `Unknown Tovuk option: ${value}.`,
172
+ 'Run `npx tovuk --help`, remove or correct the unsupported option, then retry.',
173
+ cli.json
174
+ )
175
+ }
176
+
177
+ function projectPath(value: string | undefined): string {
178
+ return path.resolve(value || process.cwd())
179
+ }
180
+
181
+ function trimTrailingSlash(value: string): string {
182
+ return value.replace(/\/+$/u, '')
183
+ }
184
+
185
+ export { parseArgs, projectPath }