tovuk 0.1.50 → 0.1.51

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 CHANGED
@@ -9,7 +9,9 @@ npx tovuk doctor --json
9
9
  npx tovuk deploy --wait --json
10
10
  ```
11
11
 
12
- `npx tovuk` is the public npm command.
12
+ The npm package installs the native Tovuk binary for the current platform.
13
+ Node is required by npm to install the package, but the `tovuk` command itself
14
+ does not delegate to `npx`, `tsx`, or any JavaScript runtime.
13
15
 
14
16
  Rust backends expect `Cargo.toml`, `Cargo.lock`, and `tovuk.toml`. They must
15
17
  pass `cargo fmt --all --check`, locked release-mode check/test/Clippy gates,
@@ -59,12 +61,13 @@ npx tovuk billing checkout --json
59
61
  When Tovuk support is needed, include enough evidence for a support agent:
60
62
 
61
63
  ```sh
62
- 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
64
+ npx tovuk support create "Deploy failed" "Agent retried deploy after doctor." --app app_1 --build job_1 --deploy deploy_1 --failing-command "tovuk deploy --wait --json" --first-log-line "cargo check failed in src/main.rs" --json
63
65
  ```
64
66
 
65
67
  When the issue is fixed, resolve the ticket:
66
68
 
67
69
  ```sh
70
+ npx tovuk support list --json
68
71
  npx tovuk support resolve ticket_0123456789abcdef0123 --json
69
72
  ```
70
73
 
package/bin/tovuk ADDED
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ echo "Tovuk native binary was not installed. Reinstall with npm scripts enabled, or install from https://github.com/tovuk/tovuk/releases." >&2
3
+ exit 1
package/install.mjs ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import { createWriteStream, chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
3
+ import { get } from 'node:https'
4
+ import { arch, platform, tmpdir } from 'node:os'
5
+ import { basename, dirname, join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const packageRoot = dirname(fileURLToPath(import.meta.url))
9
+ const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
10
+ const target = nativeTarget()
11
+ const binaryPath = join(packageRoot, 'bin', 'tovuk')
12
+
13
+ mkdirSync(dirname(binaryPath), { recursive: true })
14
+
15
+ if (process.env.TOVUK_NATIVE_BINARY) {
16
+ installFromLocal(process.env.TOVUK_NATIVE_BINARY)
17
+ } else {
18
+ await installFromRelease()
19
+ }
20
+
21
+ function installFromLocal(source) {
22
+ copyFileSync(source, binaryPath)
23
+ chmodSync(binaryPath, 0o755)
24
+ }
25
+
26
+ async function installFromRelease() {
27
+ const asset = `tovuk-${manifest.version}-${target}${target.endsWith('windows-msvc') ? '.exe' : ''}`
28
+ const url = `https://github.com/tovuk/tovuk/releases/download/v${manifest.version}/${asset}`
29
+ const tempPath = join(tmpdir(), `${basename(asset)}-${process.pid}`)
30
+ try {
31
+ await download(url, tempPath)
32
+ renameSync(tempPath, binaryPath)
33
+ chmodSync(binaryPath, 0o755)
34
+ } catch (error) {
35
+ rmSync(tempPath, { force: true })
36
+ throw new Error(`Could not install native Tovuk binary from ${url}: ${error instanceof Error ? error.message : String(error)}`)
37
+ }
38
+ }
39
+
40
+ function nativeTarget() {
41
+ const os = platform()
42
+ const cpu = arch()
43
+ if (os === 'darwin' && cpu === 'arm64') return 'aarch64-apple-darwin'
44
+ if (os === 'darwin' && cpu === 'x64') return 'x86_64-apple-darwin'
45
+ if (os === 'linux' && cpu === 'arm64') return 'aarch64-unknown-linux-gnu'
46
+ if (os === 'linux' && cpu === 'x64') return 'x86_64-unknown-linux-gnu'
47
+ if (os === 'win32' && cpu === 'x64') return 'x86_64-pc-windows-msvc'
48
+ throw new Error(`Unsupported Tovuk native target: ${os}/${cpu}`)
49
+ }
50
+
51
+ function download(url, destination) {
52
+ return new Promise((resolve, reject) => {
53
+ get(url, (response) => {
54
+ if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
55
+ response.resume()
56
+ download(response.headers.location, destination).then(resolve, reject)
57
+ return
58
+ }
59
+ if (response.statusCode !== 200) {
60
+ response.resume()
61
+ reject(new Error(`HTTP ${response.statusCode ?? 'unknown'}`))
62
+ return
63
+ }
64
+ const file = createWriteStream(destination, { mode: 0o755 })
65
+ response.pipe(file)
66
+ file.on('finish', () => file.close(resolve))
67
+ file.on('error', reject)
68
+ }).on('error', reject)
69
+ })
70
+ }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "tovuk",
3
- "version": "0.1.50",
3
+ "version": "0.1.51",
4
4
  "description": "Deploy Rust backends, static frontends, and fullstack apps to Tovuk.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "tovuk": "src/tovuk.ts"
7
+ "tovuk": "bin/tovuk"
8
8
  },
9
9
  "files": [
10
- "src",
11
- "tsconfig.json",
10
+ "bin",
11
+ "install.mjs",
12
12
  "README.md"
13
13
  ],
14
14
  "publishConfig": {
@@ -34,25 +34,11 @@
34
34
  "url": "https://github.com/tovuk/tovuk/issues"
35
35
  },
36
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
37
  "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",
38
+ "postinstall": "node install.mjs",
39
+ "check": "npm run check:policy && npm run runtime && npm run pack:dry",
48
40
  "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",
41
+ "runtime": "node ../../scripts/check-npm-native-runtime.mjs",
56
42
  "pack:dry": "npm pack --dry-run"
57
43
  }
58
44
  }
@@ -1,94 +0,0 @@
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 }
@@ -1,133 +0,0 @@
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
- }
@@ -1,77 +0,0 @@
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
- }
@@ -1,35 +0,0 @@
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 }
@@ -1,185 +0,0 @@
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 }