@marcusrbrown/infra 0.13.0 → 0.13.2

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.
Files changed (108) hide show
  1. package/dist/cli.js +4977 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,250 +0,0 @@
1
- /// <reference types="bun" />
2
-
3
- import type {SpinnerResult} from '@clack/prompts'
4
-
5
- import {confirm, log, spinner} from '@clack/prompts'
6
-
7
- import {managementHeaders, parseManagementKeyList, requestJson} from '../shared'
8
- import {cancelAndExit, promptValue} from './prompts'
9
-
10
- // ─── Types ────────────────────────────────────────────────────────────────────
11
-
12
- export interface CommandResult {
13
- stdout: string
14
- stderr: string
15
- exitCode: number
16
- }
17
-
18
- // ─── Local helpers ────────────────────────────────────────────────────────────
19
-
20
- /** Local copy — avoids a circular import with setup.ts (gh → setup → gh). */
21
- function extractErrorMessage(error: unknown): string {
22
- return error instanceof Error ? error.message : String(error)
23
- }
24
-
25
- // ─── Spawn helpers ────────────────────────────────────────────────────────────
26
-
27
- export async function withSpinner<T>(message: string, run: (spinnerInstance: SpinnerResult) => Promise<T>): Promise<T> {
28
- const spinnerInstance = spinner()
29
- spinnerInstance.start(message)
30
-
31
- try {
32
- const result = await run(spinnerInstance)
33
- spinnerInstance.stop(message)
34
- return result
35
- } catch (error) {
36
- spinnerInstance.error(`${message} failed`)
37
- throw error
38
- }
39
- }
40
-
41
- async function runCommand(command: string, args: string[]): Promise<CommandResult> {
42
- const child = Bun.spawn([command, ...args], {
43
- stdout: 'pipe',
44
- stderr: 'pipe',
45
- env: process.env,
46
- })
47
-
48
- const [stdout, stderr, exitCode] = await Promise.all([
49
- new Response(child.stdout).text(),
50
- new Response(child.stderr).text(),
51
- child.exited,
52
- ])
53
-
54
- return {stdout, stderr, exitCode}
55
- }
56
-
57
- export async function runGh(args: string[]): Promise<CommandResult> {
58
- return runCommand('gh', args)
59
- }
60
-
61
- // ─── Rate-limit helpers ───────────────────────────────────────────────────────
62
-
63
- export function isGhRateLimitError(text: string): boolean {
64
- return /rate limit/i.test(text)
65
- }
66
-
67
- /**
68
- * Query the GitHub API rate limit reset time. The `rate_limit` endpoint is
69
- * exempt from rate limiting itself, so this should succeed even when the
70
- * primary GraphQL limit is exhausted. Returns a formatted local time string
71
- * or a fallback phrase when the endpoint is unreachable.
72
- */
73
- async function queryRateLimitReset(): Promise<string> {
74
- try {
75
- const result = await runGh(['api', 'rate_limit'])
76
- if (result.exitCode === 0) {
77
- const parsed = JSON.parse(result.stdout) as {
78
- resources?: {graphql?: {reset?: number}; core?: {reset?: number}}
79
- }
80
- const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset
81
- if (reset) {
82
- return new Date(reset * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
83
- }
84
- }
85
- } catch {
86
- // Fall through to generic phrase
87
- }
88
- return 'an unknown time'
89
- }
90
-
91
- /**
92
- * Run a GitHub API operation wrapped in a spinner, retrying indefinitely on
93
- * rate-limit errors when in interactive mode. In non-interactive mode the
94
- * error is re-thrown with the reset time appended so the caller can surface
95
- * it without prompting.
96
- */
97
- export async function withGhRetry<T>(
98
- label: string,
99
- fn: (spinnerInstance: SpinnerResult) => Promise<T>,
100
- interactive: boolean,
101
- queryReset: () => Promise<string> = queryRateLimitReset,
102
- ): Promise<T> {
103
- for (;;) {
104
- try {
105
- return await withSpinner(label, fn)
106
- } catch (error) {
107
- const message = extractErrorMessage(error)
108
- if (!isGhRateLimitError(message)) {
109
- throw error
110
- }
111
- const reset = await queryReset()
112
- if (!interactive) {
113
- throw new Error(`${message} — GitHub API rate limit resets at ${reset}. Re-run when ready.`)
114
- }
115
- log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`)
116
- const retry = await promptValue(
117
- confirm({
118
- message: 'Retry this step when ready?',
119
- active: 'retry',
120
- inactive: 'abort',
121
- initialValue: true,
122
- }),
123
- 'Setup aborted after rate limit.',
124
- )
125
- if (!retry) {
126
- cancelAndExit('Setup aborted after GitHub API rate limit.')
127
- }
128
- }
129
- }
130
- }
131
-
132
- // ─── Preflight assertions ─────────────────────────────────────────────────────
133
-
134
- export async function assertGhInstalled(): Promise<void> {
135
- if (!Bun.which('gh')) {
136
- throw new Error('GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/')
137
- }
138
- }
139
-
140
- export async function assertGhAuthenticated(): Promise<void> {
141
- const result = await runGh(['auth', 'status'])
142
- if (result.exitCode !== 0) {
143
- throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim())
144
- }
145
- }
146
-
147
- export async function assertRepoAccess(repo: string): Promise<void> {
148
- const {z} = await import('zod')
149
- const ghRepoViewSchema = z.object({
150
- nameWithOwner: z.string(),
151
- viewerPermission: z.string(),
152
- })
153
-
154
- const result = await runGh(['repo', 'view', repo, '--json', 'nameWithOwner,viewerPermission'])
155
- if (result.exitCode !== 0) {
156
- throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim())
157
- }
158
-
159
- const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout))
160
- const writePermissions = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
161
-
162
- if (!writePermissions.has(parsed.viewerPermission)) {
163
- throw new Error(
164
- `GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`,
165
- )
166
- }
167
- }
168
-
169
- export async function listExistingGhNames(repo: string, kind: 'secret' | 'variable'): Promise<string[]> {
170
- const {z} = await import('zod')
171
- const ghNameListSchema = z.array(z.object({name: z.string()}))
172
-
173
- const result = await runGh([kind, 'list', '--repo', repo, '--json', 'name'])
174
- if (result.exitCode !== 0) {
175
- throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim())
176
- }
177
-
178
- return ghNameListSchema.parse(JSON.parse(result.stdout)).map(entry => entry.name)
179
- }
180
-
181
- // ─── Management API key helpers ───────────────────────────────────────────────
182
-
183
- export async function createManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
184
- const endpoint = `${baseUrl}/v0/management/api-keys`
185
- const currentPayload = await requestJson(endpoint, {
186
- method: 'GET',
187
- headers: managementHeaders(managementKey),
188
- })
189
- const currentKeys = parseManagementKeyList(currentPayload)
190
-
191
- if (currentKeys.includes(keyValue)) {
192
- return
193
- }
194
-
195
- await requestJson(endpoint, {
196
- method: 'PUT',
197
- headers: managementHeaders(managementKey),
198
- body: JSON.stringify([...currentKeys, keyValue]),
199
- })
200
- }
201
-
202
- export async function deleteManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
203
- const endpoint = `${baseUrl}/v0/management/api-keys`
204
- const currentPayload = await requestJson(endpoint, {
205
- method: 'GET',
206
- headers: managementHeaders(managementKey),
207
- })
208
- const currentKeys = parseManagementKeyList(currentPayload)
209
- const filtered = currentKeys.filter(k => k !== keyValue)
210
-
211
- if (filtered.length === currentKeys.length) {
212
- return
213
- }
214
-
215
- await requestJson(endpoint, {
216
- method: 'PUT',
217
- headers: managementHeaders(managementKey),
218
- body: JSON.stringify(filtered),
219
- })
220
- }
221
-
222
- // ─── GitHub value application ─────────────────────────────────────────────────
223
-
224
- export async function applyGhValue(
225
- kind: 'secret' | 'variable',
226
- name: string,
227
- repo: string,
228
- value: string,
229
- ): Promise<void> {
230
- if (kind === 'secret') {
231
- const child = Bun.spawn(['gh', 'secret', 'set', name, '--repo', repo], {
232
- stdin: new Blob([value]).stream(),
233
- stdout: 'pipe',
234
- stderr: 'pipe',
235
- env: process.env,
236
- })
237
-
238
- const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited])
239
-
240
- if (exitCode !== 0) {
241
- throw new Error(`gh secret set ${name} failed: ${stderr.trim()}`.trim())
242
- }
243
- return
244
- }
245
-
246
- const result = await runGh([kind, 'set', name, '--repo', repo, '--body', value])
247
- if (result.exitCode !== 0) {
248
- throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim())
249
- }
250
- }
@@ -1,159 +0,0 @@
1
- /// <reference types="bun" />
2
-
3
- import {describe, expect, it} from 'bun:test'
4
-
5
- import {formatDryRunPreview} from './preview'
6
- import {getHarnessTemplate} from './templates'
7
-
8
- const KEY = 'sk-placeholder'
9
- const BASE_URL = 'https://cliproxy.fro.bot'
10
-
11
- describe('formatDryRunPreview', () => {
12
- it('renders the dry-run header with repo and providers', () => {
13
- const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
14
- const preview = formatDryRunPreview({
15
- repo: 'owner/repo',
16
- harness: 'opencode',
17
- providers: ['anthropic'],
18
- model: 'anthropic/claude-sonnet-4-6',
19
- template,
20
- })
21
-
22
- expect(preview).toContain('Dry run: cliproxy setup --harness opencode')
23
- expect(preview).toContain('Repository: owner/repo')
24
- expect(preview).toContain('Providers: anthropic')
25
- })
26
-
27
- it('renders planned secrets with byte sizes', () => {
28
- const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
29
- const preview = formatDryRunPreview({
30
- repo: 'owner/repo',
31
- harness: 'opencode',
32
- providers: ['anthropic'],
33
- model: 'anthropic/claude-sonnet-4-6',
34
- template,
35
- })
36
-
37
- expect(preview).toContain('Planned secrets:')
38
- expect(preview).toContain('OPENCODE_AUTH_JSON')
39
- expect(preview).toContain('OPENCODE_CONFIG')
40
- expect(preview).not.toContain('OMO_PROVIDERS')
41
- })
42
-
43
- it('renders planned variables', () => {
44
- const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
45
- const preview = formatDryRunPreview({
46
- repo: 'owner/repo',
47
- harness: 'opencode',
48
- providers: ['anthropic'],
49
- model: 'anthropic/claude-sonnet-4-6',
50
- template,
51
- })
52
-
53
- expect(preview).toContain('Planned variables:')
54
- expect(preview).toContain('FRO_BOT_MODEL')
55
- })
56
-
57
- it('renders proxy key as <proxy-key> placeholder, NOT the actual key value', () => {
58
- const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
59
- const preview = formatDryRunPreview({
60
- repo: 'owner/repo',
61
- harness: 'opencode',
62
- providers: ['anthropic'],
63
- model: 'anthropic/claude-sonnet-4-6',
64
- template,
65
- })
66
-
67
- expect(preview).toContain('<proxy-key>')
68
- expect(preview).not.toContain(KEY)
69
- })
70
-
71
- it('renders "No mutations will be performed." footer', () => {
72
- const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
73
- const preview = formatDryRunPreview({
74
- repo: 'owner/repo',
75
- harness: 'opencode',
76
- providers: ['anthropic'],
77
- model: 'anthropic/claude-sonnet-4-6',
78
- template,
79
- })
80
-
81
- expect(preview).toContain('No mutations will be performed.')
82
- })
83
-
84
- it('dual-provider preview lists both providers', () => {
85
- const template = getHarnessTemplate('opencode', {
86
- keyValue: KEY,
87
- baseUrl: BASE_URL,
88
- providers: ['anthropic', 'openai'],
89
- model: 'openai/gpt-5.4-mini',
90
- })
91
- const preview = formatDryRunPreview({
92
- repo: 'owner/repo',
93
- harness: 'opencode',
94
- providers: ['anthropic', 'openai'],
95
- model: 'openai/gpt-5.4-mini',
96
- template,
97
- })
98
-
99
- expect(preview).toContain('anthropic')
100
- expect(preview).toContain('openai')
101
- expect(preview).not.toContain(KEY)
102
- })
103
-
104
- it('secret values in preview do NOT contain the actual key value', () => {
105
- // Even if the template has the key embedded in JSON, the preview must redact it
106
- const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
107
- const preview = formatDryRunPreview({
108
- repo: 'owner/repo',
109
- harness: 'opencode',
110
- providers: ['anthropic'],
111
- model: 'anthropic/claude-sonnet-4-6',
112
- template,
113
- })
114
-
115
- // The actual key must not appear anywhere in the preview output
116
- expect(preview).not.toContain(KEY)
117
- })
118
-
119
- // ── byte-count contract tests ────────────────────
120
-
121
- it('anthropic-only: OPENCODE_AUTH_JSON is 51 bytes with sk-placeholder key', () => {
122
- const template = getHarnessTemplate('opencode', {
123
- keyValue: KEY,
124
- baseUrl: BASE_URL,
125
- providers: ['anthropic'],
126
- model: 'anthropic/claude-sonnet-4-6',
127
- })
128
- const preview = formatDryRunPreview({
129
- repo: 'owner/repo',
130
- harness: 'opencode',
131
- providers: ['anthropic'],
132
- model: 'anthropic/claude-sonnet-4-6',
133
- template,
134
- })
135
-
136
- expect(preview).toContain('Providers: anthropic')
137
- expect(preview).toContain('OPENCODE_AUTH_JSON (51 bytes)')
138
- expect(preview).toContain('<proxy-key>')
139
- })
140
-
141
- it('dual-provider: OPENCODE_AUTH_JSON is 98 bytes with sk-placeholder key', () => {
142
- const template = getHarnessTemplate('opencode', {
143
- keyValue: KEY,
144
- baseUrl: BASE_URL,
145
- providers: ['anthropic', 'openai'],
146
- model: 'anthropic/claude-sonnet-4-6',
147
- })
148
- const preview = formatDryRunPreview({
149
- repo: 'owner/repo',
150
- harness: 'opencode',
151
- providers: ['anthropic', 'openai'],
152
- model: 'anthropic/claude-sonnet-4-6',
153
- template,
154
- })
155
-
156
- expect(preview).toContain('Providers: anthropic, openai')
157
- expect(preview).toContain('OPENCODE_AUTH_JSON (98 bytes)')
158
- })
159
- })
@@ -1,41 +0,0 @@
1
- import type {ProviderId} from './providers'
2
- import type {Harness, HarnessTemplate} from './templates'
3
-
4
- export interface DryRunPreviewOptions {
5
- repo: string
6
- harness: Harness
7
- providers: ProviderId[]
8
- model: string
9
- template: HarnessTemplate
10
- }
11
-
12
- /**
13
- * Format a dry-run preview string. The proxy key value is NEVER included —
14
- * it is rendered as `<proxy-key>` in all positions.
15
- */
16
- export function formatDryRunPreview(opts: DryRunPreviewOptions): string {
17
- const {repo, harness, providers, model, template} = opts
18
-
19
- const lines: string[] = [
20
- `Dry run: cliproxy setup --harness ${harness}`,
21
- `Repository: ${repo}`,
22
- `Providers: ${providers.join(', ')}`,
23
- `Model: ${model}`,
24
- 'Planned secrets:',
25
- ]
26
-
27
- for (const secret of template.secrets) {
28
- const size = new TextEncoder().encode(secret.value).byteLength
29
- lines.push(` - ${secret.name} (${size} bytes)`)
30
- }
31
-
32
- lines.push('Planned variables:')
33
- for (const variable of template.variables) {
34
- lines.push(` - ${variable.name} = ${variable.value}`)
35
- }
36
-
37
- lines.push('Proxy key (redacted): <proxy-key>')
38
- lines.push('No mutations will be performed.')
39
-
40
- return lines.join('\n')
41
- }
@@ -1,58 +0,0 @@
1
- /// <reference types="bun" />
2
-
3
- import {describe, expect, it} from 'bun:test'
4
-
5
- import {buildApiKeyValue, ensureRepoFormat, ensureSecretName} from './prompts'
6
-
7
- describe('ensureRepoFormat', () => {
8
- it('happy path: returns owner/repo unchanged', () => {
9
- expect(ensureRepoFormat('owner/repo')).toBe('owner/repo')
10
- })
11
-
12
- it('error path: throws on whitespace in value', () => {
13
- expect(() => ensureRepoFormat('owner repo')).toThrow()
14
- })
15
-
16
- it('error path: throws on extra slash', () => {
17
- expect(() => ensureRepoFormat('owner/repo/extra')).toThrow()
18
- })
19
-
20
- it('error path: throws on empty string', () => {
21
- expect(() => ensureRepoFormat('')).toThrow()
22
- })
23
- })
24
-
25
- describe('ensureSecretName', () => {
26
- it('happy path: returns VALID_NAME unchanged', () => {
27
- expect(ensureSecretName('VALID_NAME', 'secret')).toBe('VALID_NAME')
28
- })
29
-
30
- it('error path: throws on lowercase name', () => {
31
- expect(() => ensureSecretName('lower_case', 'secret')).toThrow()
32
- })
33
-
34
- it('error path: throws on name with hyphens', () => {
35
- expect(() => ensureSecretName('with-dash', 'secret')).toThrow()
36
- })
37
-
38
- it('error path: throws when name starts with a digit', () => {
39
- expect(() => ensureSecretName('123_START_DIGIT', 'secret')).toThrow()
40
- })
41
- })
42
-
43
- describe('buildApiKeyValue', () => {
44
- it('happy path: slugifies key name and appends uuid suffix', () => {
45
- const result = buildApiKeyValue('my repo ci!')
46
- expect(result).toMatch(/^sk-my-repo-ci-[a-f0-9]+$/)
47
- })
48
-
49
- it('edge case: empty string falls back to cliproxy slug', () => {
50
- const result = buildApiKeyValue('')
51
- expect(result).toMatch(/^sk-cliproxy-[a-f0-9]+$/)
52
- })
53
-
54
- it('edge case: uppercased input is lowercased in slug', () => {
55
- const result = buildApiKeyValue('UPPERCASE')
56
- expect(result).toMatch(/^sk-uppercase-[a-f0-9]+$/)
57
- })
58
- })
@@ -1,99 +0,0 @@
1
- /// <reference types="bun" />
2
-
3
- import * as clack from '@clack/prompts'
4
-
5
- export interface GenericSecretNames {
6
- apiKeySecretName: string
7
- baseUrlSecretName: string
8
- }
9
-
10
- export function ensureRepoFormat(value: string): string {
11
- const trimmed = value.trim()
12
- if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
13
- throw new Error('Repository must be in owner/repo format.')
14
- }
15
- return trimmed
16
- }
17
-
18
- export function ensureSecretName(value: string, label: string): string {
19
- const trimmed = value.trim()
20
- if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
21
- throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`)
22
- }
23
- return trimmed
24
- }
25
-
26
- export function cancelAndExit(message = 'Setup cancelled.'): never {
27
- clack.cancel(message)
28
- process.exit(0)
29
- }
30
-
31
- export async function promptValue<T extends string | boolean>(
32
- promise: Promise<T | symbol>,
33
- cancelMessage?: string,
34
- ): Promise<T> {
35
- const value = await promise
36
- if (clack.isCancel(value)) {
37
- cancelAndExit(cancelMessage)
38
- }
39
- return value
40
- }
41
-
42
- export function buildApiKeyValue(keyName: string): string {
43
- const slug = (
44
- keyName
45
- .trim()
46
- .toLowerCase()
47
- .match(/[a-z0-9]+/g) ?? []
48
- )
49
- .join('-')
50
- .slice(0, 24)
51
- const random = crypto.randomUUID().split('-').join('')
52
- return `sk-${slug || 'cliproxy'}-${random}`
53
- }
54
-
55
- function extractErrorMessage(error: unknown): string {
56
- return error instanceof Error ? error.message : String(error)
57
- }
58
-
59
- export async function promptGenericSecretNames(): Promise<GenericSecretNames> {
60
- const apiKeySecretName = ensureSecretName(
61
- await promptValue(
62
- clack.text({
63
- message: 'Name for the API key secret',
64
- placeholder: 'CLIPROXY_API_KEY',
65
- validate: value => {
66
- try {
67
- ensureSecretName(value ?? '', 'API key secret name')
68
- return undefined
69
- } catch (error) {
70
- return extractErrorMessage(error)
71
- }
72
- },
73
- }),
74
- 'Setup cancelled before choosing the generic API key secret name.',
75
- ),
76
- 'API key secret name',
77
- )
78
-
79
- const baseUrlSecretName = ensureSecretName(
80
- await promptValue(
81
- clack.text({
82
- message: 'Name for the proxy base URL secret',
83
- placeholder: 'CLIPROXY_BASE_URL',
84
- validate: value => {
85
- try {
86
- ensureSecretName(value ?? '', 'Base URL secret name')
87
- return undefined
88
- } catch (error) {
89
- return extractErrorMessage(error)
90
- }
91
- },
92
- }),
93
- 'Setup cancelled before choosing the generic base URL secret name.',
94
- ),
95
- 'Base URL secret name',
96
- )
97
-
98
- return {apiKeySecretName, baseUrlSecretName}
99
- }