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