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,281 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { DEFAULT_LOGIN_EXPIRES_SECONDS, DEFAULT_LOGIN_INTERVAL_SECONDS, SESSION_ACCOUNT, SESSION_DIR, SESSION_FILE, SESSION_LABEL, SESSION_SERVICE } from './constants.ts'
6
+ import { agentError } from './errors.ts'
7
+ import { apiRequest } from './api.ts'
8
+ import { jsonObjectOrEmpty, numberField, stringField } from './json.ts'
9
+ import { hasCommand, openUrl, progress, sleep } from './project.ts'
10
+ import type { CliOptions, JsonObject, LoginPollResponse, LoginStartResponse } from './types.ts'
11
+
12
+ type AliasSpec<Key extends string> = Readonly<{
13
+ field: Key
14
+ aliases: readonly string[]
15
+ }>
16
+
17
+ const LOGIN_START_STRING_FIELDS = [
18
+ { field: 'deviceCode', aliases: ['deviceCode', 'device_code'] },
19
+ { field: 'loginUrl', aliases: ['loginUrl', 'login_url'] },
20
+ { field: 'userCode', aliases: ['userCode', 'user_code'] }
21
+ ] as const
22
+
23
+ const LOGIN_START_NUMBER_FIELDS = [
24
+ { field: 'expiresInSeconds', aliases: ['expiresInSeconds', 'expires_in_seconds'] },
25
+ { field: 'intervalSeconds', aliases: ['intervalSeconds', 'interval_seconds'] }
26
+ ] as const
27
+
28
+ const LOGIN_POLL_STRING_FIELDS = [
29
+ { field: 'email', aliases: ['email'] },
30
+ { field: 'status', aliases: ['status'] },
31
+ { field: 'token', aliases: ['token'] }
32
+ ] as const
33
+
34
+ const LOGIN_POLL_NUMBER_FIELDS = [
35
+ { field: 'intervalSeconds', aliases: ['intervalSeconds', 'interval_seconds'] }
36
+ ] as const
37
+
38
+ async function login(cli: CliOptions): Promise<void> {
39
+ if (cli.token) {
40
+ writeSessionToken(cli.token)
41
+ console.log('saved Tovuk session token')
42
+ return
43
+ }
44
+
45
+ await loginAndStore(cli)
46
+ }
47
+
48
+ async function readOrLoginToken(cli: CliOptions): Promise<string> {
49
+ const token = readStoredToken(cli)
50
+ if (token) {
51
+ return token
52
+ }
53
+
54
+ return loginAndStore(cli)
55
+ }
56
+
57
+ async function loginAndStore(cli: CliOptions): Promise<string> {
58
+ const start = loginStartResponse(await apiRequest(cli, 'POST', '/v1/login/device', null, null))
59
+ if (!start.loginUrl) {
60
+ throw agentError('login_failed', 'Tovuk login did not return a browser URL.', 'Retry `npx tovuk login`. If it keeps failing, check Tovuk status.', cli.json)
61
+ }
62
+ openUrl(start.loginUrl)
63
+ progress(cli, 'opened browser login')
64
+ progress(cli, `waiting for browser login code ${start.userCode ?? 'TOVUK'}`)
65
+
66
+ const session = await pollLogin(cli, start)
67
+ if (!session.token) {
68
+ throw agentError('login_failed', 'Tovuk login did not return a session token.', 'Run `npx tovuk login` again and complete the browser login.', cli.json)
69
+ }
70
+
71
+ writeSessionToken(session.token)
72
+ progress(cli, `logged in as ${session.email ?? 'Tovuk user'}`)
73
+ return session.token
74
+ }
75
+
76
+ async function pollLogin(cli: CliOptions, start: LoginStartResponse): Promise<LoginPollResponse> {
77
+ if (!start.deviceCode) {
78
+ throw agentError('login_failed', 'Tovuk login did not return a device code.', 'Retry `npx tovuk login`. If it keeps failing, check Tovuk status.', cli.json)
79
+ }
80
+
81
+ const expiresMs = (start.expiresInSeconds ?? DEFAULT_LOGIN_EXPIRES_SECONDS) * 1000
82
+ const deadline = Date.now() + expiresMs
83
+ let intervalMs = (start.intervalSeconds ?? DEFAULT_LOGIN_INTERVAL_SECONDS) * 1000
84
+
85
+ while (Date.now() < deadline) {
86
+ await sleep(intervalMs)
87
+ const response = loginPollResponse(await apiRequest(cli, 'GET', `/v1/login/device/${encodeURIComponent(start.deviceCode)}`, null, null))
88
+ if (response.status === 'complete') {
89
+ return response
90
+ }
91
+ if (response.status === 'expired') {
92
+ throw agentError('login_expired', 'Tovuk login expired before it completed.', 'Run `npx tovuk login` again and finish the browser login in the newly opened tab.', cli.json)
93
+ }
94
+ intervalMs = Math.max(
95
+ DEFAULT_LOGIN_INTERVAL_SECONDS * 1000,
96
+ (response.intervalSeconds ?? DEFAULT_LOGIN_INTERVAL_SECONDS) * 1000
97
+ )
98
+ }
99
+
100
+ throw agentError('login_expired', 'Tovuk login expired before it completed.', 'Run `npx tovuk login` again and finish the browser login in the newly opened tab.', cli.json)
101
+ }
102
+
103
+ function readStoredToken(cli: CliOptions): string {
104
+ if (cli.token) {
105
+ return cli.token
106
+ }
107
+ if (process.env['TOVUK_TOKEN']) {
108
+ return process.env['TOVUK_TOKEN']
109
+ }
110
+
111
+ const keychainToken = readKeychainToken()
112
+ if (keychainToken) {
113
+ return keychainToken
114
+ }
115
+
116
+ const userToken = readTokenFile(userSessionPath())
117
+ if (userToken) {
118
+ return userToken
119
+ }
120
+
121
+ const homeToken = path.join(homedir(), SESSION_DIR, SESSION_FILE)
122
+ return readTokenFile(homeToken)
123
+ }
124
+
125
+ function writeSessionToken(token: string): void {
126
+ const cleanToken = token.trim()
127
+ if (!cleanToken) {
128
+ throw agentError('login_failed', 'Tovuk session token is empty.', 'Run `npx tovuk login` again and complete the browser login.', false)
129
+ }
130
+ if (writeKeychainToken(cleanToken)) {
131
+ return
132
+ }
133
+
134
+ writeTokenFile(userSessionPath(), cleanToken)
135
+ }
136
+
137
+ function readTokenFile(filePath: string): string {
138
+ if (!existsSync(filePath)) {
139
+ return ''
140
+ }
141
+ return readFileSync(filePath, 'utf8').trim()
142
+ }
143
+
144
+ function writeTokenFile(filePath: string, token: string): void {
145
+ const dir = path.dirname(filePath)
146
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
147
+ writeFileSync(filePath, `${token}\n`, { mode: 0o600 })
148
+ chmodSync(filePath, 0o600)
149
+ }
150
+
151
+ function userSessionPath(): string {
152
+ if (process.platform === 'win32' && process.env['APPDATA']) {
153
+ return path.join(process.env['APPDATA'], 'Tovuk', SESSION_FILE)
154
+ }
155
+ const configHome = process.env['XDG_CONFIG_HOME'] ?? path.join(homedir(), '.config')
156
+ return path.join(configHome, 'tovuk', SESSION_FILE)
157
+ }
158
+
159
+ function readKeychainToken(): string {
160
+ if (process.platform === 'darwin') {
161
+ const result = spawnSync('security', ['find-generic-password', '-s', SESSION_SERVICE, '-a', SESSION_ACCOUNT, '-w'], {
162
+ encoding: 'utf8',
163
+ stdio: ['ignore', 'pipe', 'ignore']
164
+ })
165
+ return result.status === 0 ? result.stdout.trim() : ''
166
+ }
167
+
168
+ if (process.platform === 'linux' && hasCommand('secret-tool')) {
169
+ const result = spawnSync('secret-tool', ['lookup', 'service', SESSION_SERVICE, 'account', SESSION_ACCOUNT], {
170
+ encoding: 'utf8',
171
+ stdio: ['ignore', 'pipe', 'ignore']
172
+ })
173
+ return result.status === 0 ? result.stdout.trim() : ''
174
+ }
175
+
176
+ return ''
177
+ }
178
+
179
+ function writeKeychainToken(token: string): boolean {
180
+ if (process.platform === 'darwin') {
181
+ const result = spawnSync('security', [
182
+ 'add-generic-password',
183
+ '-U',
184
+ '-s',
185
+ SESSION_SERVICE,
186
+ '-a',
187
+ SESSION_ACCOUNT,
188
+ '-l',
189
+ SESSION_LABEL,
190
+ '-w',
191
+ token
192
+ ], { stdio: 'ignore' })
193
+ return result.status === 0
194
+ }
195
+
196
+ if (process.platform === 'linux' && hasCommand('secret-tool')) {
197
+ const result = spawnSync('secret-tool', [
198
+ 'store',
199
+ '--label',
200
+ SESSION_LABEL,
201
+ 'service',
202
+ SESSION_SERVICE,
203
+ 'account',
204
+ SESSION_ACCOUNT
205
+ ], {
206
+ input: token,
207
+ stdio: ['pipe', 'ignore', 'ignore']
208
+ })
209
+ return result.status === 0
210
+ }
211
+
212
+ return false
213
+ }
214
+
215
+ function loginStartResponse(value: Awaited<ReturnType<typeof apiRequest>>): LoginStartResponse {
216
+ const source = jsonObjectOrEmpty(value)
217
+ return {
218
+ ...stringAliasFields(source, LOGIN_START_STRING_FIELDS),
219
+ ...numberAliasFields(source, LOGIN_START_NUMBER_FIELDS)
220
+ }
221
+ }
222
+
223
+ function loginPollResponse(value: Awaited<ReturnType<typeof apiRequest>>): LoginPollResponse {
224
+ const source = jsonObjectOrEmpty(value)
225
+ return {
226
+ ...stringAliasFields(source, LOGIN_POLL_STRING_FIELDS),
227
+ ...numberAliasFields(source, LOGIN_POLL_NUMBER_FIELDS)
228
+ }
229
+ }
230
+
231
+ function stringAliasFields<Key extends string>(
232
+ source: JsonObject,
233
+ specs: readonly AliasSpec<Key>[]
234
+ ): Partial<Record<Key, string>> {
235
+ return aliasFields(source, specs, firstStringAlias, (value) => value !== '')
236
+ }
237
+
238
+ function numberAliasFields<Key extends string>(
239
+ source: JsonObject,
240
+ specs: readonly AliasSpec<Key>[]
241
+ ): Partial<Record<Key, number>> {
242
+ return aliasFields(source, specs, firstPositiveNumberAlias, (value) => value > 0)
243
+ }
244
+
245
+ function aliasFields<Key extends string, Value>(
246
+ source: JsonObject,
247
+ specs: readonly AliasSpec<Key>[],
248
+ read: (source: JsonObject, aliases: readonly string[]) => Value,
249
+ keep: (value: Value) => boolean
250
+ ): Partial<Record<Key, Value>> {
251
+ const response: Partial<Record<Key, Value>> = {}
252
+ for (const spec of specs) {
253
+ const value = read(source, spec.aliases)
254
+ if (keep(value)) {
255
+ response[spec.field] = value
256
+ }
257
+ }
258
+ return response
259
+ }
260
+
261
+ function firstStringAlias(source: JsonObject, aliases: readonly string[]): string {
262
+ for (const alias of aliases) {
263
+ const value = stringField(source, alias)
264
+ if (value) {
265
+ return value
266
+ }
267
+ }
268
+ return ''
269
+ }
270
+
271
+ function firstPositiveNumberAlias(source: JsonObject, aliases: readonly string[]): number {
272
+ for (const alias of aliases) {
273
+ const value = numberField(source, alias)
274
+ if (value > 0) {
275
+ return value
276
+ }
277
+ }
278
+ return 0
279
+ }
280
+
281
+ export { login, readOrLoginToken }
@@ -0,0 +1,12 @@
1
+ import type { DoctorCheck } from './types.ts'
2
+
3
+ function doctorCheck(name: string, ok: boolean, success: string, failure: string, instruction: string): DoctorCheck {
4
+ return {
5
+ name,
6
+ ok,
7
+ message: ok ? success : failure,
8
+ agent_instruction: ok ? null : instruction
9
+ }
10
+ }
11
+
12
+ export { doctorCheck }
@@ -0,0 +1,298 @@
1
+ import { agentError } from './errors.ts'
2
+ import { apiRequest, pageQuery, requireApp } from './api.ts'
3
+ import { checkoutResponseFromJson, logsResponseFromJson } from './api-models.ts'
4
+ import { readOrLoginToken } from './auth.ts'
5
+ import { openUrl, printJson } from './project.ts'
6
+ import type { ApiMethod, CliOptions, JsonObject, JsonValue } from './types.ts'
7
+
8
+ interface LogsRequest {
9
+ route: string
10
+ target: string
11
+ }
12
+
13
+ type SubcommandHandler = (cli: CliOptions) => Promise<void>
14
+ type SubcommandTable = Readonly<Record<string, SubcommandHandler>>
15
+ type DomainMethod = Extract<ApiMethod, 'DELETE' | 'POST'>
16
+ type DomainRoute = (app: string, domain: string) => string
17
+ type DomainBody = (domain: string) => JsonObject | null
18
+ type BillingAction = 'checkout' | 'portal'
19
+
20
+ const ENV_COMMANDS: SubcommandTable = {
21
+ list: async (cli): Promise<void> => printJson(await appGet(cli, 'env')),
22
+ set: envSet,
23
+ delete: envDelete
24
+ }
25
+
26
+ const DOMAIN_COMMANDS: SubcommandTable = {
27
+ list: async (cli): Promise<void> => printJson(await appGet(cli, 'domains')),
28
+ add: domainMutation('POST', (app): string => `/v1/apps/${encodeURIComponent(app)}/domains`, (domain): JsonObject => ({ domain })),
29
+ verify: domainMutation('POST', (app, domain): string => `/v1/apps/${encodeURIComponent(app)}/domains/${encodeURIComponent(domain)}/verify`, (): null => null),
30
+ delete: domainMutation('DELETE', (app, domain): string => `/v1/apps/${encodeURIComponent(app)}/domains/${encodeURIComponent(domain)}`, (): null => null)
31
+ }
32
+
33
+ const SUPPORT_COMMANDS: SubcommandTable = {
34
+ list: supportList,
35
+ create: supportCreate,
36
+ resolve: supportResolve
37
+ }
38
+
39
+ async function logs(cli: CliOptions): Promise<void> {
40
+ const token = await readOrLoginToken(cli)
41
+ const request = logsRequest(cli)
42
+ const response = logsResponseFromJson(await apiRequest(cli, 'GET', request.route, token, null))
43
+ if (cli.json) {
44
+ printJson(response)
45
+ return
46
+ }
47
+ for (const line of response.lines) {
48
+ console.log(`[${line.timestamp}] ${line.stream}: ${line.message}`)
49
+ }
50
+ if (response.has_more && response.next_cursor) {
51
+ console.log(`next npx tovuk logs ${request.target} --cursor ${response.next_cursor}`)
52
+ }
53
+ }
54
+
55
+ function logsRequest(cli: CliOptions): LogsRequest {
56
+ const page = pageQuery(cli)
57
+ if (cli.build) {
58
+ return {
59
+ route: `/v1/builds/${encodeURIComponent(cli.build)}/logs${page}`,
60
+ target: `--build ${cli.build}`
61
+ }
62
+ }
63
+ if (cli.deploy) {
64
+ return {
65
+ route: `/v1/deploys/${encodeURIComponent(cli.deploy)}/logs${page}`,
66
+ target: `--deploy ${cli.deploy}`
67
+ }
68
+ }
69
+
70
+ const app = requireApp(cli)
71
+ return {
72
+ route: `/v1/apps/${encodeURIComponent(app)}/logs${page}`,
73
+ target: `--app ${app}`
74
+ }
75
+ }
76
+
77
+ async function apps(cli: CliOptions): Promise<void> {
78
+ await printAuthenticated(cli, '/v1/apps')
79
+ }
80
+
81
+ async function capabilities(cli: CliOptions): Promise<void> {
82
+ const response = await apiRequest(cli, 'GET', '/v1/capabilities', null, null)
83
+ printJson(response)
84
+ }
85
+
86
+ async function me(cli: CliOptions): Promise<void> {
87
+ await printAuthenticated(cli, '/v1/me')
88
+ }
89
+
90
+ async function usage(cli: CliOptions): Promise<void> {
91
+ await printAuthenticated(cli, '/v1/usage')
92
+ }
93
+
94
+ async function activity(cli: CliOptions): Promise<void> {
95
+ await printPagedAuthenticated(cli, '/v1/activity')
96
+ }
97
+
98
+ async function overview(cli: CliOptions): Promise<void> {
99
+ await printPagedAuthenticated(cli, appRoute(cli, 'overview'))
100
+ }
101
+
102
+ async function deploys(cli: CliOptions): Promise<void> {
103
+ await printAuthenticated(cli, appScopedCollectionRoute(cli, 'deploys'))
104
+ }
105
+
106
+ async function builds(cli: CliOptions): Promise<void> {
107
+ await printAuthenticated(cli, appScopedCollectionRoute(cli, 'builds'))
108
+ }
109
+
110
+ function appScopedCollectionRoute(cli: CliOptions, collection: 'builds' | 'deploys'): string {
111
+ const route = cli.app
112
+ ? `/v1/apps/${encodeURIComponent(cli.app)}/${collection}`
113
+ : `/v1/${collection}`
114
+ return `${route}${pageQuery(cli)}`
115
+ }
116
+
117
+ async function status(cli: CliOptions): Promise<void> {
118
+ printJson(await appGet(cli, 'status'))
119
+ }
120
+
121
+ async function inspect(cli: CliOptions): Promise<void> {
122
+ printJson(await appGet(cli, 'inspect'))
123
+ }
124
+
125
+ async function database(cli: CliOptions): Promise<void> {
126
+ printJson(await appGet(cli, 'database'))
127
+ }
128
+
129
+ async function envCommand(cli: CliOptions): Promise<void> {
130
+ await runSubcommand(cli, ENV_COMMANDS, 'list', 'Unknown env command.', 'Use `npx tovuk env list`, `env set`, or `env delete`.')
131
+ }
132
+
133
+ async function envDelete(cli: CliOptions): Promise<void> {
134
+ const name = requireCommandArg(cli, 'invalid_env', 'Environment variable name is required.', 'Use `npx tovuk env delete --app <app> KEY`.')
135
+ await printAuthenticatedMutation(cli, 'DELETE', appRoute(cli, `env/${encodeURIComponent(name)}`), null)
136
+ }
137
+
138
+ async function envSet(cli: CliOptions): Promise<void> {
139
+ const assignment = cli.args[1] ?? ''
140
+ const separator = assignment.indexOf('=')
141
+ if (separator <= 0) {
142
+ throw agentError('invalid_env', 'Environment assignment must be KEY=value.', 'Pass one uppercase shell-safe environment assignment, for example `API_KEY=value`.', cli.json)
143
+ }
144
+
145
+ const name = assignment.slice(0, separator)
146
+ const value = assignment.slice(separator + 1)
147
+ await printAuthenticatedMutation(cli, 'PUT', appRoute(cli, 'env'), { name, value })
148
+ }
149
+
150
+ async function domainsCommand(cli: CliOptions): Promise<void> {
151
+ await runSubcommand(cli, DOMAIN_COMMANDS, 'list', 'Unknown domains command.', 'Use `domains list`, `domains add`, `domains verify`, or `domains delete`.')
152
+ }
153
+
154
+ function domainMutation(method: DomainMethod, route: DomainRoute, body: DomainBody): SubcommandHandler {
155
+ return async (cli): Promise<void> => {
156
+ const domain = requireCommandArg(cli, 'missing_domain', 'Domain is required.', 'Use `npx tovuk domains add --app <app> api.example.com`.')
157
+ const app = requireApp(cli)
158
+ await printAuthenticatedMutation(cli, method, route(app, domain), body(domain))
159
+ }
160
+ }
161
+
162
+ async function billing(cli: CliOptions): Promise<void> {
163
+ const token = await readOrLoginToken(cli)
164
+ const action = billingAction(cli.args[0] ?? 'checkout', cli.json)
165
+ const route = action === 'portal' ? '/v1/billing/portal' : '/v1/billing/checkout'
166
+ const reason = cli.args.slice(1).join(' ').trim() || 'Upgrade to Tovuk Pro.'
167
+ const body: JsonObject | null = action === 'checkout'
168
+ ? { target_plan: 'pro', reason }
169
+ : null
170
+ const response = checkoutResponseFromJson(await apiRequest(cli, 'POST', route, token, body))
171
+ if (cli.json) {
172
+ printJson(response)
173
+ return
174
+ }
175
+ console.log(response.checkout.url)
176
+ openUrl(response.checkout.url)
177
+ }
178
+
179
+ function billingAction(value: string, json: boolean): BillingAction {
180
+ if (!value || value === 'checkout') {
181
+ return 'checkout'
182
+ }
183
+ if (value === 'portal') {
184
+ return 'portal'
185
+ }
186
+ throw agentError('unknown_billing_command', 'Unknown billing command.', 'Use `npx tovuk billing checkout --json` or `npx tovuk billing portal`.', json)
187
+ }
188
+
189
+ async function support(cli: CliOptions): Promise<void> {
190
+ await runSubcommand(cli, SUPPORT_COMMANDS, 'list', 'Unknown support command.', 'Use `npx tovuk support list --json` or `support create` with subject and details.')
191
+ }
192
+
193
+ async function supportList(cli: CliOptions): Promise<void> {
194
+ await printPagedAuthenticated(cli, '/v1/support/tickets')
195
+ }
196
+
197
+ async function supportCreate(cli: CliOptions): Promise<void> {
198
+ const subject = cli.args[1] ?? ''
199
+ const details = cli.args.slice(2).join(' ').trim()
200
+ if (!subject || !details) {
201
+ throw agentError(
202
+ 'invalid_support_ticket',
203
+ 'Support ticket subject and details are required.',
204
+ 'Use `npx tovuk support create "Short subject" "Command, app id, build id, deploy id, and first actionable log line" --json`.',
205
+ cli.json
206
+ )
207
+ }
208
+
209
+ const token = await readOrLoginToken(cli)
210
+ const body = supportTicketBody(cli, subject, details)
211
+ const response = await apiRequest(cli, 'POST', '/v1/support/tickets', token, body)
212
+ printJson(response)
213
+ }
214
+
215
+ function supportTicketBody(cli: CliOptions, subject: string, details: string): JsonObject {
216
+ const body: JsonObject = { details, severity: cli.severity || 'normal', subject }
217
+ addOptionalField(body, 'app_id', cli.app)
218
+ addOptionalField(body, 'failing_command', cli.failingCommand)
219
+ addOptionalField(body, 'build_id', cli.build)
220
+ addOptionalField(body, 'deploy_id', cli.deploy)
221
+ addOptionalField(body, 'first_log_line', cli.firstLogLine)
222
+ return body
223
+ }
224
+
225
+ function addOptionalField(body: JsonObject, key: string, value: string): void {
226
+ if (value) {
227
+ body[key] = value
228
+ }
229
+ }
230
+
231
+ async function supportResolve(cli: CliOptions): Promise<void> {
232
+ const ticketId = requireCommandArg(
233
+ cli,
234
+ 'invalid_support_ticket',
235
+ 'Support ticket id is required.',
236
+ 'Use `npx tovuk support resolve <ticket_id> --json` with an id from support list.'
237
+ )
238
+ await printAuthenticatedMutation(cli, 'POST', `/v1/support/tickets/${encodeURIComponent(ticketId)}/resolve`, null)
239
+ }
240
+
241
+ async function printAuthenticated(cli: CliOptions, route: string): Promise<void> {
242
+ const token = await readOrLoginToken(cli)
243
+ const response = await apiRequest(cli, 'GET', route, token, null)
244
+ printJson(response)
245
+ }
246
+
247
+ async function printPagedAuthenticated(cli: CliOptions, route: string): Promise<void> {
248
+ await printAuthenticated(cli, `${route}${pageQuery(cli)}`)
249
+ }
250
+
251
+ async function printAuthenticatedMutation(cli: CliOptions, method: ApiMethod, route: string, body: JsonObject | null): Promise<void> {
252
+ const token = await readOrLoginToken(cli)
253
+ printJson(await apiRequest(cli, method, route, token, body))
254
+ }
255
+
256
+ function appRoute(cli: CliOptions, suffix: string): string {
257
+ return `/v1/apps/${encodeURIComponent(requireApp(cli))}/${suffix}`
258
+ }
259
+
260
+ async function appGet(cli: CliOptions, kind: string): Promise<JsonValue | null> {
261
+ const token = await readOrLoginToken(cli)
262
+ return apiRequest(cli, 'GET', appRoute(cli, kind), token, null)
263
+ }
264
+
265
+ async function runSubcommand(cli: CliOptions, commands: SubcommandTable, defaultName: string, message: string, instruction: string): Promise<void> {
266
+ const command = commands[cli.args[0] ?? defaultName]
267
+ if (!command) {
268
+ throw agentError('unknown_command', message, instruction, cli.json)
269
+ }
270
+ await command(cli)
271
+ }
272
+
273
+ function requireCommandArg(cli: CliOptions, code: string, message: string, instruction: string): string {
274
+ const value = cli.args[1] ?? ''
275
+ if (!value) {
276
+ throw agentError(code, message, instruction, cli.json)
277
+ }
278
+ return value
279
+ }
280
+
281
+ export {
282
+ logs,
283
+ apps,
284
+ capabilities,
285
+ me,
286
+ usage,
287
+ activity,
288
+ overview,
289
+ deploys,
290
+ builds,
291
+ status,
292
+ inspect,
293
+ database,
294
+ envCommand,
295
+ domainsCommand,
296
+ billing,
297
+ support
298
+ }