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,215 @@
1
+ import { DEFAULT_RUST_CHECK_COMMAND, PROJECT_KINDS } from './constants.ts'
2
+ import { frontendBuildCommand, frontendCheckCommand } from './frontend-policy.ts'
3
+ import type { BuildConfig, ProjectKind, ResourceConfig, RunConfig, TovukConfig } from './types.ts'
4
+
5
+ type SectionName = 'build' | 'resources' | 'root' | 'run'
6
+ type TomlValue = boolean | number | string
7
+ type SectionAssigner = (config: MutableTovukConfig, key: string, value: TomlValue) => void
8
+
9
+ interface MutableTovukConfig {
10
+ name?: string
11
+ kind?: ProjectKind
12
+ build: Partial<BuildConfig>
13
+ run: Partial<RunConfig>
14
+ resources: Partial<ResourceConfig>
15
+ }
16
+
17
+ const SECTION_ASSIGNERS: Readonly<Record<SectionName, SectionAssigner>> = {
18
+ build: (config, key, value): void => assignBuildValue(config.build, key, value),
19
+ resources: (config, key, value): void => assignResourceValue(config.resources, key, value),
20
+ root: assignRootValue,
21
+ run: (config, key, value): void => assignRunValue(config.run, key, value)
22
+ }
23
+
24
+ function parseTovukToml(source: string, projectDir: string): TovukConfig {
25
+ const config: MutableTovukConfig = {
26
+ build: {},
27
+ run: {},
28
+ resources: {}
29
+ }
30
+ let section: SectionName = 'root'
31
+
32
+ for (const rawLine of source.split(/\r?\n/u)) {
33
+ const parsedLine = parseTomlLine(rawLine)
34
+ if (parsedLine === null) {
35
+ continue
36
+ }
37
+ if (parsedLine.kind === 'section') {
38
+ section = parsedLine.section
39
+ continue
40
+ }
41
+ assignTomlValue(config, section, parsedLine.key, parsedLine.value)
42
+ }
43
+
44
+ return tovukConfig(config, projectDir)
45
+ }
46
+
47
+ function tovukConfig(config: MutableTovukConfig, projectDir: string): TovukConfig {
48
+ const kind = config.kind ?? 'rust_backend'
49
+ const result: TovukConfig = {
50
+ kind,
51
+ build: buildConfig(config, kind, projectDir),
52
+ run: runConfig(config),
53
+ resources: resourceConfig(config)
54
+ }
55
+ if (config.name) {
56
+ result.name = config.name
57
+ }
58
+ return result
59
+ }
60
+
61
+ function parseTomlLine(rawLine: string): { kind: 'section'; section: SectionName } | { kind: 'assignment'; key: string; value: TomlValue } | null {
62
+ const line = rawLine.trim()
63
+ if (!line || line.startsWith('#')) {
64
+ return null
65
+ }
66
+ const sectionMatch = line.match(/^\[([a-z_]+)\]$/u)
67
+ if (sectionMatch) {
68
+ return { kind: 'section', section: parseSection(sectionMatch[1] ?? '') }
69
+ }
70
+ const assignment = line.match(/^([a-z_]+)\s*=\s*(.+)$/u)
71
+ if (assignment) {
72
+ return { kind: 'assignment', key: assignment[1] ?? '', value: parseTomlValue(assignment[2] ?? '') }
73
+ }
74
+ throw new Error(`invalid line: ${line}`)
75
+ }
76
+
77
+ function parseSection(value: string): SectionName {
78
+ if (value === 'build' || value === 'run' || value === 'resources') {
79
+ return value
80
+ }
81
+ throw new Error(`unsupported section [${value}]`)
82
+ }
83
+
84
+ function assignTomlValue(config: MutableTovukConfig, section: SectionName, key: string, value: TomlValue): void {
85
+ SECTION_ASSIGNERS[section](config, key, value)
86
+ }
87
+
88
+ function assignRootValue(config: MutableTovukConfig, key: string, value: TomlValue): void {
89
+ if (key === 'name') {
90
+ config.name = expectString(key, value)
91
+ return
92
+ }
93
+ if (key === 'kind') {
94
+ config.kind = expectProjectKind(expectString(key, value))
95
+ return
96
+ }
97
+ throw new Error(`unsupported root key ${key}`)
98
+ }
99
+
100
+ function assignBuildValue(build: Partial<BuildConfig>, key: string, value: TomlValue): void {
101
+ if (key === 'command' || key === 'check' || key === 'output') {
102
+ build[key] = expectString(key, value)
103
+ return
104
+ }
105
+ throw new Error(`unsupported [build] key ${key}`)
106
+ }
107
+
108
+ function assignRunValue(run: Partial<RunConfig>, key: string, value: TomlValue): void {
109
+ if (key === 'command' || key === 'health') {
110
+ run[key] = expectString(key, value)
111
+ return
112
+ }
113
+ if (key === 'port') {
114
+ run.port = expectNumber(key, value)
115
+ return
116
+ }
117
+ throw new Error(`unsupported [run] key ${key}`)
118
+ }
119
+
120
+ function assignResourceValue(resources: Partial<ResourceConfig>, key: string, value: TomlValue): void {
121
+ if (key === 'memory' || key === 'cpu') {
122
+ resources[key] = expectString(key, value)
123
+ return
124
+ }
125
+ if (key === 'idle_timeout_minutes') {
126
+ resources.idle_timeout_minutes = expectNumber(key, value)
127
+ return
128
+ }
129
+ throw new Error(`unsupported [resources] key ${key}`)
130
+ }
131
+
132
+ function expectString(key: string, value: TomlValue): string {
133
+ if (typeof value === 'string') {
134
+ return value
135
+ }
136
+ throw new Error(`${key} must be a string`)
137
+ }
138
+
139
+ function expectNumber(key: string, value: TomlValue): number {
140
+ if (typeof value === 'number') {
141
+ return value
142
+ }
143
+ throw new Error(`${key} must be a number`)
144
+ }
145
+
146
+ function expectProjectKind(value: string): ProjectKind {
147
+ if (isProjectKind(value)) {
148
+ return value
149
+ }
150
+ throw new Error('kind must be rust_backend or static_frontend')
151
+ }
152
+
153
+ function isProjectKind(value: string): value is ProjectKind {
154
+ return PROJECT_KINDS.has(value)
155
+ }
156
+
157
+ function parseTomlValue(raw: string): TomlValue {
158
+ const value = raw.trim()
159
+ if (value.startsWith('"') && value.endsWith('"')) {
160
+ return value.slice(1, -1).replace(/\\"/gu, '"')
161
+ }
162
+ if (value === 'true') {
163
+ return true
164
+ }
165
+ if (value === 'false') {
166
+ return false
167
+ }
168
+ if (/^\d+$/u.test(value)) {
169
+ return Number(value)
170
+ }
171
+ throw new Error(`unsupported TOML value: ${value}`)
172
+ }
173
+
174
+ function buildConfig(config: MutableTovukConfig, kind: ProjectKind, projectDir: string): BuildConfig {
175
+ const build: BuildConfig = {
176
+ check: config.build.check ?? defaultCheckCommand(kind, projectDir),
177
+ command: config.build.command ?? defaultBuildCommand(kind, projectDir)
178
+ }
179
+ const output = kind === 'static_frontend'
180
+ ? config.build.output ?? 'dist'
181
+ : config.build.output
182
+ if (typeof output === 'string') {
183
+ build.output = output
184
+ }
185
+ return build
186
+ }
187
+
188
+ function defaultCheckCommand(kind: ProjectKind, projectDir: string): string {
189
+ return kind === 'static_frontend' ? frontendCheckCommand(projectDir) : DEFAULT_RUST_CHECK_COMMAND
190
+ }
191
+
192
+ function defaultBuildCommand(kind: ProjectKind, projectDir: string): string {
193
+ return kind === 'static_frontend' ? frontendBuildCommand(projectDir) : 'cargo build --release'
194
+ }
195
+
196
+ function runConfig(config: MutableTovukConfig): RunConfig {
197
+ const run: RunConfig = {
198
+ port: config.run.port ?? 3000,
199
+ health: config.run.health ?? '/healthz'
200
+ }
201
+ if (typeof config.run.command === 'string') {
202
+ run.command = config.run.command
203
+ }
204
+ return run
205
+ }
206
+
207
+ function resourceConfig(config: MutableTovukConfig): ResourceConfig {
208
+ return {
209
+ memory: config.resources.memory ?? '512mb',
210
+ cpu: config.resources.cpu ?? '0.25',
211
+ idle_timeout_minutes: config.resources.idle_timeout_minutes ?? 15
212
+ }
213
+ }
214
+
215
+ export { parseTovukToml }
@@ -0,0 +1,94 @@
1
+ import { PROJECT_KINDS } from './constants.ts'
2
+ import { commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun, usesJavascriptLinter } from './frontend-policy.ts'
3
+ import { isSafeRelativePath } from './project.ts'
4
+ import type { ProjectKind, TovukConfig } from './types.ts'
5
+
6
+ function validateConfig(config: TovukConfig): void {
7
+ validateIdentity(config)
8
+ validateBuildConfig(config)
9
+ if (config.kind === 'static_frontend') {
10
+ validateStaticFrontendConfig(config)
11
+ return
12
+ }
13
+ validateRustBackendConfig(config)
14
+ }
15
+
16
+ function validateIdentity(config: TovukConfig): void {
17
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/u.test(config.name ?? '')) {
18
+ throw new Error('name must be lowercase DNS-safe text up to 48 characters')
19
+ }
20
+ if (!PROJECT_KINDS.has(config.kind)) {
21
+ throw new Error('kind must be rust_backend or static_frontend')
22
+ }
23
+ }
24
+
25
+ function validateBuildConfig(config: TovukConfig): void {
26
+ if (!config.build.command.trim()) {
27
+ throw new Error('[build].command is required')
28
+ }
29
+ if (!config.build.check.trim()) {
30
+ throw new Error('[build].check is required')
31
+ }
32
+ validateCheckCommand(config.kind, config.build.check)
33
+ }
34
+
35
+ function validateStaticFrontendConfig(config: TovukConfig): void {
36
+ if (typeof config.build.output !== 'string' || !isSafeRelativePath(config.build.output)) {
37
+ throw new Error('[build].output must be a safe relative directory like dist')
38
+ }
39
+ }
40
+
41
+ function validateRustBackendConfig(config: TovukConfig): void {
42
+ if (config.build.output) {
43
+ throw new Error('[build].output is only valid for static_frontend')
44
+ }
45
+ if (!config.run.command?.trim()) {
46
+ throw new Error('[run].command is required')
47
+ }
48
+ if (!Number.isInteger(config.run.port) || config.run.port < 1 || config.run.port > 65535) {
49
+ throw new Error('[run].port must be between 1 and 65535')
50
+ }
51
+ if (!config.run.health.startsWith('/')) {
52
+ throw new Error('[run].health must be an absolute path')
53
+ }
54
+ validateResourceConfig(config)
55
+ }
56
+
57
+ function validateResourceConfig(config: TovukConfig): void {
58
+ if (!/^\d+\s*(mb|mib|gb|gib)$/iu.test(config.resources.memory)) {
59
+ throw new Error('[resources].memory must look like 512mb or 1gb')
60
+ }
61
+ if (!/^\d+(?:\.\d{1,3})?$/u.test(config.resources.cpu)) {
62
+ throw new Error('[resources].cpu must look like 0.25, 0.5, 1, or 2')
63
+ }
64
+ }
65
+
66
+ function validateCheckCommand(kind: ProjectKind, command: string): void {
67
+ if (kind === 'static_frontend') {
68
+ validateFrontendCheckCommand(command)
69
+ return
70
+ }
71
+
72
+ const required = ['cargo fmt --all --check', 'cargo check --locked', 'cargo clippy --locked', '--all-targets', '--all-features', '-D warnings']
73
+ if (required.every((fragment) => command.includes(fragment))) {
74
+ return
75
+ }
76
+ throw new Error('[build].check must include cargo fmt --all --check, cargo check --locked, and cargo clippy --locked --all-targets --all-features -- -D warnings')
77
+ }
78
+
79
+ function validateFrontendCheckCommand(command: string): void {
80
+ if (usesJavascriptLinter(command)) {
81
+ throw new Error('[build].check must not run JavaScript-based lint or format tooling; use oxlint, biome, or deno lint')
82
+ }
83
+ const tokens = commandTokens(command)
84
+ if (
85
+ hasFrontendInstallCommand(tokens) &&
86
+ hasFrontendScriptRun(tokens, 'typecheck') &&
87
+ hasFrontendScriptRun(tokens, 'lint')
88
+ ) {
89
+ return
90
+ }
91
+ throw new Error('[build].check must install dependencies and run package scripts, for example `bun ci && bun run typecheck && bun run lint` or `npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint`')
92
+ }
93
+
94
+ export { validateConfig }
@@ -0,0 +1,2 @@
1
+ export { parseTovukToml } from './config-parser.ts'
2
+ export { validateConfig } from './config-validation.ts'
@@ -0,0 +1,153 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ interface PackageVersionManifest {
4
+ version: string
5
+ }
6
+
7
+ export const VERSION: string = packageVersion()
8
+ export const DEFAULT_API_URL: string = 'https://api.tovuk.com'
9
+ export const ARCHIVE_LIMIT_BYTES: number = 48 * 1024 * 1024
10
+ export const DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS: number = 900
11
+ export const SESSION_DIR: string = '.tovuk'
12
+ export const SESSION_FILE: string = 'session-token'
13
+ export const SESSION_SERVICE: string = 'com.tovuk.cli'
14
+ export const SESSION_ACCOUNT: string = 'session-token'
15
+ export const SESSION_LABEL: string = 'Tovuk session'
16
+ export const DEFAULT_LOGIN_EXPIRES_SECONDS: number = 600
17
+ export const DEFAULT_LOGIN_INTERVAL_SECONDS: number = 5
18
+ export const DEFAULT_RUST_CHECK_COMMAND: string = 'cargo fmt --all --check && cargo check --locked && cargo clippy --locked --all-targets --all-features -- -D warnings'
19
+ export const DEFAULT_NPM_FRONTEND_CHECK_COMMAND: string = 'npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint'
20
+ export const DEFAULT_BUN_FRONTEND_CHECK_COMMAND: string = 'bun ci && bun run typecheck && bun run lint'
21
+ export const PROJECT_KINDS: ReadonlySet<string> = new Set(['rust_backend', 'static_frontend'])
22
+ export const PROJECT_TEMPLATES: ReadonlySet<string> = new Set(['rust-api', 'tanstack-static-frontend', 'fullstack-rust-tanstack'])
23
+ export const JAVASCRIPT_LINTERS: ReadonlySet<string> = new Set(['eslint', 'eslint_d', 'jscs', 'jshint', 'prettier', 'prettierd', 'standard', 'xo'])
24
+ export const FRONTEND_SOURCE_ROOTS: ReadonlySet<string> = new Set(['src', 'app', 'pages', 'routes', 'components'])
25
+ export const FRONTEND_JAVASCRIPT_EXTENSIONS: readonly string[] = ['.js', '.jsx', '.mjs', '.cjs']
26
+ export const FRONTEND_PACKAGE_MANAGERS: ReadonlySet<string> = new Set(['npm', 'bun', 'pnpm', 'yarn'])
27
+ export const FRONTEND_INSTALL_COMMANDS: ReadonlySet<string> = new Set(['npm ci', 'bun ci', 'bun install', 'pnpm install', 'yarn install'])
28
+ export const ARCHIVE_EXCLUDES: readonly string[] = [
29
+ '.git',
30
+ 'target',
31
+ 'node_modules',
32
+ '.tovuk',
33
+ '.env',
34
+ '.env.*',
35
+ '.npmrc',
36
+ '.pypirc',
37
+ '.netrc',
38
+ '.docker',
39
+ '.gnupg',
40
+ '.terraform',
41
+ '.terraformrc',
42
+ '.ssh',
43
+ '.aws',
44
+ '.azure',
45
+ '.kube',
46
+ '.pulumi',
47
+ '.cargo/credentials',
48
+ '.cargo/credentials.toml',
49
+ '.config/gcloud',
50
+ '.config/gh',
51
+ '.config/hub',
52
+ '.config/heroku',
53
+ '.config/doctl',
54
+ '*.pem',
55
+ '*.key',
56
+ '*.p12',
57
+ '*.pfx',
58
+ '*.tfstate',
59
+ '*.tfstate.*',
60
+ 'id_rsa',
61
+ 'id_ed25519',
62
+ '*.sqlite',
63
+ '*.sqlite3',
64
+ '*.db',
65
+ '*.log',
66
+ '._*',
67
+ '.DS_Store'
68
+ ]
69
+ export const WALK_EXCLUDED_DIRS: ReadonlySet<string> = new Set([
70
+ '.git',
71
+ 'target',
72
+ 'node_modules',
73
+ '.tovuk',
74
+ '.terraform',
75
+ '.docker',
76
+ '.gnupg',
77
+ '.ssh',
78
+ '.aws',
79
+ '.azure',
80
+ '.kube',
81
+ '.pulumi'
82
+ ])
83
+ export const WORKSPACE_EXCLUDED_DIRS: ReadonlySet<string> = new Set([
84
+ ...WALK_EXCLUDED_DIRS,
85
+ '.cache',
86
+ '.next',
87
+ '.turbo',
88
+ 'build',
89
+ 'coverage',
90
+ 'dist',
91
+ 'vendor'
92
+ ])
93
+
94
+ export const HELP: string = `Tovuk ${VERSION}
95
+
96
+ Usage:
97
+ tovuk init [path] [--template rust-api|tanstack-static-frontend|fullstack-rust-tanstack]
98
+ tovuk install [path] [--template rust-api|tanstack-static-frontend|fullstack-rust-tanstack]
99
+ tovuk doctor [path] [--json]
100
+ tovuk preview [path] [--port <port>]
101
+ tovuk login [--token <token>] [--api <url>]
102
+ tovuk deploy [path] [--database] [--wait] [--wait-timeout <seconds>] [--api <url>] [--json]
103
+ tovuk capabilities [--api <url>] [--json]
104
+ tovuk me [--api <url>] [--json]
105
+ tovuk usage [--api <url>] [--json]
106
+ tovuk activity [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
107
+ tovuk apps [--api <url>] [--json]
108
+ tovuk overview --app <app> [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
109
+ tovuk deploys [--app <app>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
110
+ tovuk builds [--app <app>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
111
+ tovuk logs --app <app> [--deploy <deploy_id>] [--build <build_id>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
112
+ tovuk status --app <app> [--api <url>] [--json]
113
+ tovuk inspect --app <app> [--api <url>] [--json]
114
+ tovuk db --app <app> [--api <url>] [--json]
115
+ tovuk env list --app <app> [--api <url>] [--json]
116
+ tovuk env set --app <app> KEY=value [--api <url>] [--json]
117
+ tovuk env delete --app <app> KEY [--api <url>] [--json]
118
+ tovuk domains list --app <app> [--api <url>] [--json]
119
+ tovuk domains add --app <app> <domain> [--api <url>] [--json]
120
+ tovuk domains verify --app <app> <domain> [--api <url>] [--json]
121
+ tovuk domains delete --app <app> <domain> [--api <url>] [--json]
122
+ tovuk billing [checkout|portal] [reason] [--api <url>] [--json]
123
+ tovuk support list [--limit <n>] [--api <url>] [--json]
124
+ tovuk support create "Subject" "Details" [--app <app>] [--build <build_id>] [--deploy <deploy_id>] [--failing-command <command>] [--first-log-line <line>] [--severity low|normal|urgent] [--api <url>] [--json]
125
+ tovuk support resolve <ticket_id> [--api <url>] [--json]
126
+
127
+ Agent contract:
128
+ - Rust backends keep Cargo.lock committed, pass rustfmt, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
129
+ - Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, stable native typecheck, native lint, and Fallow quality gates.
130
+ - Frontends call Rust backends for APIs, managed Postgres, and server-side logic.
131
+ - Run deploy from a repo root with nested tovuk.toml files to deploy the whole workspace in one command.
132
+ - When a frontend calls a backend on another hostname, configure backend CORS or use a same-origin custom domain.
133
+ - When a plan limit blocks work, run tovuk billing checkout --json and show the returned URL to the human.
134
+ - Create support tickets only with command output, app id, build id, deploy id, and the first actionable log line.
135
+ - Resolve support tickets after the issue is fixed so later agents do not duplicate work.
136
+ - Keep direct unsafe out of Rust source.
137
+ `
138
+
139
+ function packageVersion(): string {
140
+ const manifest: unknown = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
141
+ if (isPackageVersionManifest(manifest)) {
142
+ return manifest.version
143
+ }
144
+ throw new Error('packages/tovuk/package.json must include a version string')
145
+ }
146
+
147
+ function isPackageVersionManifest(value: unknown): value is PackageVersionManifest {
148
+ return typeof value === 'object'
149
+ && value !== null
150
+ && !Array.isArray(value)
151
+ && 'version' in value
152
+ && typeof value.version === 'string'
153
+ }
@@ -0,0 +1,89 @@
1
+ import { agentError } from './errors.ts'
2
+ import { paymentRequiredAgentError } from './agent-error-enrichment.ts'
3
+ import { apiRequest } from './api.ts'
4
+ import { appsResponseFromJson } from './api-models.ts'
5
+ import { jsonObjectField, jsonObjectOrEmpty, numberField } from './json.ts'
6
+ import type { AppSummary, CliOptions, DeployPlanProject, DeployProjectInfo } from './types.ts'
7
+
8
+ async function createDeployPlan(projects: DeployProjectInfo[], cli: CliOptions, token: string): Promise<DeployPlanProject[]> {
9
+ const plan = projects.map((project) => ({
10
+ project,
11
+ wantsDatabase: cli.database && project.kind === 'rust_backend'
12
+ }))
13
+ rejectInvalidDatabaseTargets(plan, cli)
14
+ await preflightDeployLimits(plan, cli, token)
15
+ return plan
16
+ }
17
+
18
+ function rejectInvalidDatabaseTargets(plan: DeployPlanProject[], cli: CliOptions): void {
19
+ if (cli.database && plan.length === 1 && plan[0]?.project.kind === 'static_frontend') {
20
+ throw agentError('invalid_database_target', 'Static frontends cannot attach managed Postgres directly.', 'Deploy a Rust backend with managed Postgres and call it from the frontend.', cli.json)
21
+ }
22
+ }
23
+
24
+ async function preflightDeployLimits(plan: DeployPlanProject[], cli: CliOptions, token: string): Promise<void> {
25
+ const [usageResponse, appsResponse] = await Promise.all([
26
+ apiRequest(cli, 'GET', '/v1/usage', token, null),
27
+ apiRequest(cli, 'GET', '/v1/apps', token, null)
28
+ ])
29
+ const usageRoot = jsonObjectOrEmpty(usageResponse)
30
+ const usage = jsonObjectField(usageRoot, 'usage')
31
+ const limits = jsonObjectField(usageRoot, 'limits')
32
+ const existingApps = appNameMap(appsResponseFromJson(appsResponse).apps)
33
+ const requested = requestedNewResources(plan, existingApps)
34
+
35
+ const usedProjects = numberField(usage, 'appCount')
36
+ const projectLimit = numberField(limits, 'projects')
37
+ const usedDatabases = numberField(usage, 'databaseCount')
38
+ const databaseLimit = numberField(limits, 'managedDatabases')
39
+
40
+ if (requested.projects > 0 && usedProjects + requested.projects > projectLimit) {
41
+ throw await paymentRequiredAgentError(
42
+ cli,
43
+ token,
44
+ `Project limit reached: ${usedProjects}/${projectLimit} projects are already used.`,
45
+ 'Redeploy an existing app by reusing its `name` in tovuk.toml, or open the returned Stripe Checkout URL before creating another project.'
46
+ )
47
+ }
48
+
49
+ if (requested.databases > 0 && usedDatabases + requested.databases > databaseLimit) {
50
+ throw await paymentRequiredAgentError(
51
+ cli,
52
+ token,
53
+ `Managed Postgres limit reached: ${usedDatabases}/${databaseLimit} databases are already used.`,
54
+ 'Redeploy an app that already has managed Postgres, deploy without `--database`, or open the returned Stripe Checkout URL.'
55
+ )
56
+ }
57
+ }
58
+
59
+ function appNameMap(apps: AppSummary[]): Map<string, AppSummary> {
60
+ const existingApps = new Map<string, AppSummary>()
61
+ for (const app of apps) {
62
+ if (app.name) {
63
+ existingApps.set(app.name, app)
64
+ }
65
+ }
66
+ return existingApps
67
+ }
68
+
69
+ function requestedNewResources(plan: DeployPlanProject[], existingApps: Map<string, AppSummary>): { projects: number; databases: number } {
70
+ let projects = 0
71
+ let databases = 0
72
+
73
+ for (const target of plan) {
74
+ if (!target.project.name || target.project.kind === 'unknown') {
75
+ continue
76
+ }
77
+ const existing = existingApps.get(target.project.name)
78
+ if (!existing) {
79
+ projects += 1
80
+ }
81
+ if (target.wantsDatabase && existing?.databaseStorageMib === undefined) {
82
+ databases += 1
83
+ }
84
+ }
85
+
86
+ return { projects, databases }
87
+ }
88
+
89
+ export { createDeployPlan }