@meistrari/tela-cli 0.1.1 → 0.3.0

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/src/config.ts CHANGED
@@ -1,24 +1,66 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { isAbsolute, join } from 'node:path'
4
- import type { TelaCliConfig } from './types.ts'
4
+ import { listOrganizations, provisionForgejoUser } from './agent-api.ts'
5
+ import type { FetchLike } from './auth.ts'
6
+ import { createUserFacingError } from './errors.ts'
7
+ import {
8
+ ensureForgejoUserAccess,
9
+ listDiscoveredSshPublicKeys,
10
+ loadSshPublicKey,
11
+ validateSshPublicKeyPath,
12
+ type SelectedSshPublicKey,
13
+ } from './forgejo.ts'
14
+ import type {
15
+ TelaCliAuthState,
16
+ TelaCliConfig,
17
+ TelaCliForgejoOrganization,
18
+ TelaEnvironment,
19
+ } from './types.ts'
5
20
 
6
21
  const CONFIG_FILENAME = 'tela-cli.json'
7
- export const AGENT_API_PRESET_URLS = {
8
- production: 'https://agent-api.tela.com',
9
- staging: 'https://agent-api-staging.tela.com',
10
- } as const
11
- export type AgentApiPreset = keyof typeof AGENT_API_PRESET_URLS | 'custom'
12
- export const DEFAULT_AGENT_API_URL = AGENT_API_PRESET_URLS.production
13
- export const AGENT_API_BROWSER_HELP = 'API-only endpoint; browser navigation is not expected.'
14
- const AGENT_API_PRESET_ALIASES: Record<Exclude<AgentApiPreset, 'custom'>, string[]> = {
15
- production: [AGENT_API_PRESET_URLS.production],
16
- staging: [
17
- AGENT_API_PRESET_URLS.staging,
18
- 'https://agent-api-staging.tela.com',
19
- ],
20
- }
21
22
  const DEFAULT_CLONE_ROOT_SEGMENTS = ['Code', 'Tela']
23
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
24
+
25
+ const PLACEHOLDER_APPLICATION_IDS = new Set([
26
+ '00000000-0000-4000-8000-000000000001',
27
+ '00000000-0000-4000-8000-000000000002',
28
+ '00000000-0000-4000-8000-000000000003',
29
+ '00000000-0000-4000-8000-000000000004',
30
+ ])
31
+
32
+ export const DEFAULT_SETUP_AUTH_TIMEOUT_MS = 15 * 60 * 1000
33
+ export const DEVICE_FLOW_BROWSER_HELP = 'Tela sign-in uses a browser-based device flow and continues polling until you approve the request.'
34
+ const CUSTOM_SSH_KEY_OPTION = '__custom__'
35
+
36
+ export interface SetupEnvironmentConfig {
37
+ environment: TelaEnvironment
38
+ agentApiUrl: string
39
+ authApiUrl: string
40
+ authDashboardUrl: string
41
+ telaApiUrl: string
42
+ requesterApplicationId: string
43
+ targetApplicationId: string
44
+ }
45
+
46
+ export const ENVIRONMENT_PRESETS: Record<Exclude<TelaEnvironment, 'custom'>, Omit<SetupEnvironmentConfig, 'environment'>> = {
47
+ production: {
48
+ agentApiUrl: 'https://agent-api-production-81c9.up.railway.app',
49
+ authApiUrl: 'https://auth.tela.com',
50
+ authDashboardUrl: 'https://accounts.tela.com',
51
+ telaApiUrl: 'https://api.tela.com',
52
+ requesterApplicationId: '860d5506-cab2-4b8b-b08b-fb4222b65bb2',
53
+ targetApplicationId: 'fe9fa028-4d34-407c-b27a-2ecc4779ae69',
54
+ },
55
+ staging: {
56
+ agentApiUrl: 'https://agent-api.telastaging.com',
57
+ authApiUrl: 'https://auth-api.telastaging.com',
58
+ authDashboardUrl: 'https://accounts.telastaging.com',
59
+ telaApiUrl: 'https://api.telastaging.com',
60
+ requesterApplicationId: 'c5cda347-835e-497c-bdeb-a464f5a1beb0',
61
+ targetApplicationId: '1c021ec1-b7d1-4d72-8733-ac69d930b2df',
62
+ },
63
+ }
22
64
 
23
65
  export interface TextPromptOptions {
24
66
  message: string
@@ -27,10 +69,6 @@ export interface TextPromptOptions {
27
69
  validate?: (value: string) => string | undefined
28
70
  }
29
71
 
30
- export interface PasswordPromptOptions extends TextPromptOptions {
31
- mask?: string
32
- }
33
-
34
72
  export interface Option<T> {
35
73
  value: T
36
74
  label: string
@@ -59,74 +97,37 @@ export interface PromptModule {
59
97
  note?(message: string, title?: string): void
60
98
  select<T>(options: SelectOptions<T>): Promise<T | symbol>
61
99
  text(options: TextPromptOptions): Promise<string | symbol>
62
- password(options: PasswordPromptOptions): Promise<string | symbol>
63
100
  }
64
101
 
65
- function promptValue<T>(value: T | symbol): T {
66
- return value as T
102
+ export interface SetupConfigInput extends SetupEnvironmentConfig {
103
+ cloneRoot: string
67
104
  }
68
105
 
69
- export function ensureAbsoluteUrl(value: string, label: string): string {
70
- let url: URL
71
- try {
72
- url = new URL(value)
73
- }
74
- catch {
75
- throw new Error(`${label} must be a valid URL`)
76
- }
77
-
78
- return url.toString().replace(/\/$/, '')
79
- }
106
+ export type SetupAuthenticateFn = (
107
+ input: SetupConfigInput,
108
+ notify: (message: string, title?: string) => void,
109
+ ) => Promise<TelaCliAuthState>
80
110
 
81
- export const AGENT_API_PRESET_OPTIONS: Array<Option<AgentApiPreset>> = [
111
+ export const ENVIRONMENT_PRESET_OPTIONS: Array<Option<TelaEnvironment>> = [
82
112
  {
83
113
  value: 'production',
84
114
  label: 'Production',
85
- hint: AGENT_API_PRESET_URLS.production,
115
+ hint: ENVIRONMENT_PRESETS.production.agentApiUrl,
86
116
  },
87
117
  {
88
118
  value: 'staging',
89
119
  label: 'Staging',
90
- hint: AGENT_API_PRESET_URLS.staging,
120
+ hint: ENVIRONMENT_PRESETS.staging.agentApiUrl,
91
121
  },
92
122
  {
93
123
  value: 'custom',
94
124
  label: 'Custom',
95
- hint: 'Enter a custom API origin URL',
125
+ hint: 'Enter custom Tela service URLs and app IDs',
96
126
  },
97
127
  ]
98
128
 
99
- export function inferAgentApiPreset(agentApiUrl: string | null | undefined): AgentApiPreset {
100
- if (!agentApiUrl) {
101
- return 'production'
102
- }
103
-
104
- let normalized = agentApiUrl.trim()
105
- try {
106
- normalized = ensureAbsoluteUrl(normalized, 'Agent API origin URL')
107
- }
108
- catch {
109
- return 'custom'
110
- }
111
-
112
- for (const preset of ['production', 'staging'] as const) {
113
- if (AGENT_API_PRESET_ALIASES[preset].includes(normalized)) {
114
- return preset
115
- }
116
- }
117
-
118
- return 'custom'
119
- }
120
-
121
- export function resolveAgentApiUrlForPreset(preset: AgentApiPreset, customUrl = ''): string {
122
- switch (preset) {
123
- case 'production':
124
- return AGENT_API_PRESET_URLS.production
125
- case 'staging':
126
- return AGENT_API_PRESET_URLS.staging
127
- case 'custom':
128
- return ensureAbsoluteUrl(customUrl, 'Agent API origin URL')
129
- }
129
+ function promptValue<T>(value: T | symbol): T {
130
+ return value as T
130
131
  }
131
132
 
132
133
  function ensureNonEmpty(value: string, label: string): string {
@@ -138,63 +139,124 @@ function ensureNonEmpty(value: string, label: string): string {
138
139
  return normalized
139
140
  }
140
141
 
141
- function getHomeDir(): string {
142
- return process.env.HOME || homedir()
143
- }
142
+ function ensureEnvironment(value: string): TelaEnvironment {
143
+ if (value === 'production' || value === 'staging' || value === 'custom') {
144
+ return value
145
+ }
144
146
 
145
- function getTelaDir(): string {
146
- return join(getHomeDir(), '.tela')
147
+ throw new Error('Environment must be production, staging, or custom')
147
148
  }
148
149
 
149
- export function getDefaultCloneRoot(): string {
150
- return join(getHomeDir(), ...DEFAULT_CLONE_ROOT_SEGMENTS)
151
- }
150
+ function ensureAbsoluteUrl(value: string, label: string): string {
151
+ let url: URL
152
+ try {
153
+ url = new URL(value)
154
+ }
155
+ catch {
156
+ throw new Error(`${label} must be a valid URL`)
157
+ }
152
158
 
153
- export function resolveCloneRoot(value: string): string {
154
- const expanded = expandHome(ensureNonEmpty(value, 'Clone root'))
155
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
159
+ return url.toString().replace(/\/$/, '')
156
160
  }
157
161
 
158
- export function validateAgentApiUrl(value: string): string | undefined {
159
- try {
160
- ensureAbsoluteUrl(value, 'Agent API origin URL')
161
- return undefined
162
- }
163
- catch (error) {
164
- return error instanceof Error ? error.message : 'Invalid URL'
162
+ function ensureUuid(value: string, label: string): string {
163
+ const normalized = ensureNonEmpty(value, label)
164
+ if (!UUID_PATTERN.test(normalized)) {
165
+ throw new Error(`${label} must be a UUID`)
165
166
  }
167
+
168
+ return normalized
166
169
  }
167
170
 
168
- export function validateCloneRoot(value: string): string | undefined {
169
- try {
170
- resolveCloneRoot(value)
171
- return undefined
171
+ function normalizeAuth(auth: TelaCliConfig['auth']): TelaCliConfig['auth'] {
172
+ if (!auth) {
173
+ return null
172
174
  }
173
- catch (error) {
174
- return error instanceof Error ? error.message : 'Invalid directory'
175
+
176
+ const user = auth.user ?? {} as TelaCliAuthState['user']
177
+ const workspace = auth.workspace ?? {} as TelaCliAuthState['workspace']
178
+ const session = auth.session ?? {} as TelaCliAuthState['session']
179
+
180
+ const expiresAt = Number(session.expiresAt)
181
+ if (!Number.isFinite(expiresAt) || expiresAt <= 0) {
182
+ throw new Error('Auth session expiration must be a valid timestamp')
183
+ }
184
+
185
+ return {
186
+ user: {
187
+ id: ensureNonEmpty(String(user.id ?? ''), 'Authenticated user ID'),
188
+ email: ensureNonEmpty(String(user.email ?? ''), 'Authenticated user email'),
189
+ name: ensureNonEmpty(String(user.name ?? user.email ?? ''), 'Authenticated user name'),
190
+ },
191
+ workspace: {
192
+ id: ensureNonEmpty(String(workspace.id ?? ''), 'Authenticated workspace ID'),
193
+ title: ensureNonEmpty(String(workspace.title ?? ''), 'Authenticated workspace title'),
194
+ },
195
+ session: {
196
+ accessToken: ensureNonEmpty(String(session.accessToken ?? ''), 'Auth access token'),
197
+ refreshToken: ensureNonEmpty(String(session.refreshToken ?? ''), 'Auth refresh token'),
198
+ expiresAt,
199
+ },
175
200
  }
176
201
  }
177
202
 
178
- export function validateApiKey(value: string, existingApiKey?: string): string | undefined {
179
- const nextValue = value.trim() || existingApiKey || ''
180
- return nextValue ? undefined : 'API key is required'
203
+ function normalizeForgejoOrganization(
204
+ organization: TelaCliConfig['forgejoOrganization'],
205
+ ): TelaCliForgejoOrganization | null {
206
+ if (!organization) {
207
+ return null
208
+ }
209
+
210
+ return {
211
+ id: ensureNonEmpty(String(organization.id ?? ''), 'Forgejo organization ID'),
212
+ name: ensureNonEmpty(String(organization.name ?? ''), 'Forgejo organization name'),
213
+ }
181
214
  }
182
215
 
183
216
  function normalizeConfig(config: TelaCliConfig): TelaCliConfig {
217
+ const environment = ensureEnvironment(config.environment)
218
+ const auth = normalizeAuth(config.auth)
219
+
184
220
  return {
221
+ environment,
185
222
  agentApiUrl: ensureAbsoluteUrl(config.agentApiUrl, 'Agent API origin URL'),
223
+ authApiUrl: ensureAbsoluteUrl(config.authApiUrl, 'Auth API URL'),
224
+ authDashboardUrl: ensureAbsoluteUrl(config.authDashboardUrl, 'Auth dashboard URL'),
225
+ telaApiUrl: ensureAbsoluteUrl(config.telaApiUrl, 'Tela API URL'),
186
226
  cloneRoot: resolveCloneRoot(config.cloneRoot),
187
- apiKey: ensureNonEmpty(config.apiKey, 'API key'),
227
+ requesterApplicationId: ensureUuid(config.requesterApplicationId, 'Requester application ID'),
228
+ targetApplicationId: ensureUuid(config.targetApplicationId, 'Target application ID'),
229
+ auth,
230
+ forgejoOrganization: auth ? normalizeForgejoOrganization(config.forgejoOrganization) : null,
231
+ forgejoUserRegistered: Boolean(config.forgejoUserRegistered),
188
232
  }
189
233
  }
190
234
 
191
235
  function parseJsonConfig(input: string): TelaCliConfig {
192
- const parsed = JSON.parse(input) as Partial<Record<keyof TelaCliConfig, unknown>>
236
+ const parsed = JSON.parse(input) as Partial<Record<keyof TelaCliConfig, unknown>> & {
237
+ auth?: Partial<Record<'user' | 'workspace' | 'session', unknown>> | null
238
+ }
239
+
240
+ const auth = parsed.auth == null
241
+ ? null
242
+ : {
243
+ user: parsed.auth.user as TelaCliAuthState['user'],
244
+ workspace: parsed.auth.workspace as TelaCliAuthState['workspace'],
245
+ session: parsed.auth.session as TelaCliAuthState['session'],
246
+ }
193
247
 
194
248
  return normalizeConfig({
249
+ environment: String(parsed.environment ?? '') as TelaEnvironment,
195
250
  agentApiUrl: String(parsed.agentApiUrl ?? ''),
251
+ authApiUrl: String(parsed.authApiUrl ?? ''),
252
+ authDashboardUrl: String(parsed.authDashboardUrl ?? ''),
253
+ telaApiUrl: String(parsed.telaApiUrl ?? ''),
196
254
  cloneRoot: String(parsed.cloneRoot ?? ''),
197
- apiKey: String(parsed.apiKey ?? ''),
255
+ requesterApplicationId: String(parsed.requesterApplicationId ?? ''),
256
+ targetApplicationId: String(parsed.targetApplicationId ?? ''),
257
+ auth,
258
+ forgejoOrganization: parsed.forgejoOrganization as TelaCliConfig['forgejoOrganization'] ?? null,
259
+ forgejoUserRegistered: Boolean(parsed.forgejoUserRegistered ?? false),
198
260
  })
199
261
  }
200
262
 
@@ -212,6 +274,57 @@ function cancelSetup(prompts: PromptModule): never {
212
274
  throw new Error('Setup cancelled')
213
275
  }
214
276
 
277
+ async function promptForSshPublicKey(prompts: PromptModule): Promise<SelectedSshPublicKey> {
278
+ const discoveredKeys = listDiscoveredSshPublicKeys()
279
+ if (discoveredKeys.length === 0) {
280
+ prompts.note?.('No SSH public keys were found under ~/.ssh. Enter a custom public key path to continue.', 'SSH key')
281
+ }
282
+
283
+ const selection = await prompts.select<string>({
284
+ message: 'SSH public key to send',
285
+ initialValue: discoveredKeys[0]?.path ?? CUSTOM_SSH_KEY_OPTION,
286
+ options: [
287
+ ...discoveredKeys.map(key => ({
288
+ value: key.path,
289
+ label: key.title,
290
+ hint: key.path,
291
+ })),
292
+ {
293
+ value: CUSTOM_SSH_KEY_OPTION,
294
+ label: 'Custom path…',
295
+ hint: 'Load another public key file',
296
+ },
297
+ ],
298
+ })
299
+ if (prompts.isCancel(selection)) {
300
+ cancelSetup(prompts)
301
+ }
302
+
303
+ const selectedValue = promptValue(selection)
304
+ if (selectedValue !== CUSTOM_SSH_KEY_OPTION) {
305
+ return discoveredKeys.find(key => key.path === selectedValue) ?? loadSshPublicKey(selectedValue)
306
+ }
307
+
308
+ const customPath = await prompts.text({
309
+ message: 'SSH public key path',
310
+ placeholder: '~/.ssh/id_ed25519.pub',
311
+ validate: validateSshPublicKeyPath,
312
+ })
313
+ if (prompts.isCancel(customPath)) {
314
+ cancelSetup(prompts)
315
+ }
316
+
317
+ return loadSshPublicKey(promptValue(customPath))
318
+ }
319
+
320
+ function getHomeDir(): string {
321
+ return process.env.HOME || homedir()
322
+ }
323
+
324
+ function getTelaDir(): string {
325
+ return join(getHomeDir(), '.tela')
326
+ }
327
+
215
328
  export function getConfigPath(): string {
216
329
  return join(getTelaDir(), CONFIG_FILENAME)
217
330
  }
@@ -228,8 +341,77 @@ export function expandHome(input: string): string {
228
341
  return input
229
342
  }
230
343
 
231
- export function redactApiKey(apiKey: string): string {
232
- const trimmed = apiKey.trim()
344
+ export function getDefaultCloneRoot(): string {
345
+ return join(getHomeDir(), ...DEFAULT_CLONE_ROOT_SEGMENTS)
346
+ }
347
+
348
+ export function resolveCloneRoot(value: string): string {
349
+ const expanded = expandHome(ensureNonEmpty(value, 'Clone root'))
350
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
351
+ }
352
+
353
+ export function validateCloneRoot(value: string): string | undefined {
354
+ try {
355
+ resolveCloneRoot(value)
356
+ return undefined
357
+ }
358
+ catch (error) {
359
+ return error instanceof Error ? error.message : 'Invalid directory'
360
+ }
361
+ }
362
+
363
+ export function validateAgentApiUrl(value: string): string | undefined {
364
+ try {
365
+ ensureAbsoluteUrl(value, 'Agent API origin URL')
366
+ return undefined
367
+ }
368
+ catch (error) {
369
+ return error instanceof Error ? error.message : 'Invalid URL'
370
+ }
371
+ }
372
+
373
+ export function validateAuthApiUrl(value: string): string | undefined {
374
+ try {
375
+ ensureAbsoluteUrl(value, 'Auth API URL')
376
+ return undefined
377
+ }
378
+ catch (error) {
379
+ return error instanceof Error ? error.message : 'Invalid URL'
380
+ }
381
+ }
382
+
383
+ export function validateAuthDashboardUrl(value: string): string | undefined {
384
+ try {
385
+ ensureAbsoluteUrl(value, 'Auth dashboard URL')
386
+ return undefined
387
+ }
388
+ catch (error) {
389
+ return error instanceof Error ? error.message : 'Invalid URL'
390
+ }
391
+ }
392
+
393
+ export function validateTelaApiUrl(value: string): string | undefined {
394
+ try {
395
+ ensureAbsoluteUrl(value, 'Tela API URL')
396
+ return undefined
397
+ }
398
+ catch (error) {
399
+ return error instanceof Error ? error.message : 'Invalid URL'
400
+ }
401
+ }
402
+
403
+ export function validateApplicationId(value: string, label = 'Application ID'): string | undefined {
404
+ try {
405
+ ensureUuid(value, label)
406
+ return undefined
407
+ }
408
+ catch (error) {
409
+ return error instanceof Error ? error.message : 'Invalid application ID'
410
+ }
411
+ }
412
+
413
+ export function redactSecret(secret: string): string {
414
+ const trimmed = secret.trim()
233
415
  if (!trimmed) {
234
416
  return '<missing>'
235
417
  }
@@ -241,6 +423,37 @@ export function redactApiKey(apiKey: string): string {
241
423
  return `${trimmed.slice(0, 4)}***${trimmed.slice(-4)}`
242
424
  }
243
425
 
426
+ export function resolveEnvironmentConfigForPreset(
427
+ environment: TelaEnvironment,
428
+ custom: Partial<Omit<SetupEnvironmentConfig, 'environment'>> = {},
429
+ ): SetupEnvironmentConfig {
430
+ if (environment === 'custom') {
431
+ return {
432
+ environment,
433
+ agentApiUrl: ensureAbsoluteUrl(String(custom.agentApiUrl ?? ''), 'Agent API origin URL'),
434
+ authApiUrl: ensureAbsoluteUrl(String(custom.authApiUrl ?? ''), 'Auth API URL'),
435
+ authDashboardUrl: ensureAbsoluteUrl(String(custom.authDashboardUrl ?? ''), 'Auth dashboard URL'),
436
+ telaApiUrl: ensureAbsoluteUrl(String(custom.telaApiUrl ?? ''), 'Tela API URL'),
437
+ requesterApplicationId: ensureUuid(String(custom.requesterApplicationId ?? ''), 'Requester application ID'),
438
+ targetApplicationId: ensureUuid(String(custom.targetApplicationId ?? ''), 'Target application ID'),
439
+ }
440
+ }
441
+
442
+ const preset = ENVIRONMENT_PRESETS[environment]
443
+ return {
444
+ environment,
445
+ ...preset,
446
+ }
447
+ }
448
+
449
+ export function isPlaceholderApplicationId(value: string): boolean {
450
+ return PLACEHOLDER_APPLICATION_IDS.has(value.trim())
451
+ }
452
+
453
+ export function hasSavedAuth(config: TelaCliConfig | null | undefined): boolean {
454
+ return Boolean(config?.auth?.session.accessToken && config.auth?.session.refreshToken)
455
+ }
456
+
244
457
  export function loadConfig(): TelaCliConfig | null {
245
458
  const configPath = getConfigPath()
246
459
  if (!existsSync(configPath)) {
@@ -290,50 +503,191 @@ export function saveConfig(config: TelaCliConfig): void {
290
503
  chmodSync(configPath, 0o600)
291
504
  }
292
505
 
506
+ export function clearSavedAuth(config: TelaCliConfig): TelaCliConfig {
507
+ const cleared = normalizeConfig({
508
+ ...config,
509
+ auth: null,
510
+ forgejoOrganization: null,
511
+ forgejoUserRegistered: false,
512
+ })
513
+ saveConfig(cleared)
514
+ return cleared
515
+ }
516
+
517
+ function createMissingForgejoOrganizationsError(workspaceTitle: string): ReturnType<typeof createUserFacingError> {
518
+ return createUserFacingError({
519
+ code: 'workspace_organization_required',
520
+ summary: 'No Forgejo organizations are registered for this workspace.',
521
+ details: `Tela authenticated successfully for ${workspaceTitle}, but the workspace does not have any registered organizations yet.`,
522
+ fixes: [
523
+ 'Ask the Tela team to register at least one organization for this workspace, then rerun `tela setup`.',
524
+ ],
525
+ })
526
+ }
527
+
528
+ export function getPreferredForgejoOrganization(
529
+ existingConfig: TelaCliConfig | null | undefined,
530
+ auth: TelaCliAuthState,
531
+ organizations: TelaCliForgejoOrganization[],
532
+ ): TelaCliForgejoOrganization | null {
533
+ if (!existingConfig?.forgejoOrganization) {
534
+ return null
535
+ }
536
+
537
+ if (existingConfig.auth?.workspace.id !== auth.workspace.id) {
538
+ return null
539
+ }
540
+
541
+ return organizations.find(organization => organization.id === existingConfig.forgejoOrganization?.id) ?? null
542
+ }
543
+
544
+ async function resolveForgejoOrganizationInteractive(options: {
545
+ prompts: PromptModule
546
+ existingConfig: TelaCliConfig | null
547
+ auth: TelaCliAuthState
548
+ config: TelaCliConfig
549
+ fetchFn?: FetchLike
550
+ }): Promise<TelaCliForgejoOrganization> {
551
+ const organizations = await listOrganizations(options.config, options.fetchFn)
552
+ if (organizations.length === 0) {
553
+ throw createMissingForgejoOrganizationsError(options.auth.workspace.title)
554
+ }
555
+
556
+ const preferredOrganization = getPreferredForgejoOrganization(
557
+ options.existingConfig,
558
+ options.auth,
559
+ organizations,
560
+ )
561
+
562
+ if (organizations.length === 1) {
563
+ const organization = organizations[0]!
564
+ options.prompts.note?.(`Using workspace organization ${organization.name}.`, 'Organization')
565
+ return organization
566
+ }
567
+
568
+ const selection = await options.prompts.select<string>({
569
+ message: 'Forgejo organization',
570
+ initialValue: preferredOrganization?.id ?? organizations[0]?.id,
571
+ options: organizations.map(organization => ({
572
+ value: organization.id,
573
+ label: organization.name,
574
+ hint: organization.id === preferredOrganization?.id ? 'saved from previous setup' : undefined,
575
+ })),
576
+ })
577
+ if (options.prompts.isCancel(selection)) {
578
+ cancelSetup(options.prompts)
579
+ }
580
+
581
+ const selectedId = promptValue(selection)
582
+ const selectedOrganization = organizations.find(organization => organization.id === selectedId)
583
+ if (!selectedOrganization) {
584
+ throw new Error(`Unknown organization selection: ${selectedId}`)
585
+ }
586
+
587
+ options.prompts.note?.(`Selected workspace organization ${selectedOrganization.name}.`, 'Organization')
588
+ return selectedOrganization
589
+ }
590
+
293
591
  export async function setupConfigInteractive(options: {
294
592
  prompts?: PromptModule
295
593
  existingConfig?: TelaCliConfig | null
296
594
  skipLifecycle?: boolean
297
- } = {}): Promise<TelaCliConfig> {
595
+ authenticate: SetupAuthenticateFn
596
+ fetchFn?: FetchLike
597
+ provisionForgejoUser?: typeof provisionForgejoUser
598
+ }): Promise<TelaCliConfig> {
298
599
  const prompts = options.prompts
299
600
  if (!prompts) {
300
601
  throw new Error('Interactive prompts are not available without a prompt implementation.')
301
602
  }
302
- const existingConfig = options.existingConfig ?? tryLoadConfig()
303
603
 
604
+ const existingConfig = options.existingConfig ?? tryLoadConfig()
304
605
  if (!options.skipLifecycle) {
305
606
  prompts.intro('Tela CLI setup')
306
607
  }
307
608
 
308
- prompts.note?.(AGENT_API_BROWSER_HELP, 'Agent API')
309
- const initialAgentApiPreset = inferAgentApiPreset(existingConfig?.agentApiUrl ?? DEFAULT_AGENT_API_URL)
310
- const agentApiPresetResult = await prompts.select<AgentApiPreset>({
311
- message: 'Agent API target',
312
- initialValue: initialAgentApiPreset,
313
- options: AGENT_API_PRESET_OPTIONS,
314
- })
609
+ prompts.note?.(DEVICE_FLOW_BROWSER_HELP, 'Authentication')
315
610
 
316
- if (prompts.isCancel(agentApiPresetResult)) {
611
+ const environmentResult = await prompts.select<TelaEnvironment>({
612
+ message: 'Tela environment',
613
+ initialValue: existingConfig?.environment ?? 'production',
614
+ options: ENVIRONMENT_PRESET_OPTIONS,
615
+ })
616
+ if (prompts.isCancel(environmentResult)) {
317
617
  cancelSetup(prompts)
318
618
  }
319
619
 
320
- const agentApiPreset = promptValue(agentApiPresetResult)
321
- let agentApiUrl = agentApiPreset === 'custom'
322
- ? (initialAgentApiPreset === 'custom' ? (existingConfig?.agentApiUrl ?? '') : '')
323
- : resolveAgentApiUrlForPreset(agentApiPreset)
324
- if (agentApiPreset === 'custom') {
325
- prompts.note?.(AGENT_API_BROWSER_HELP, 'Agent API origin')
326
- const customAgentApiUrlResult = await prompts.text({
327
- message: 'Custom Agent API origin URL',
328
- defaultValue: initialAgentApiPreset === 'custom' ? existingConfig?.agentApiUrl : undefined,
329
- validate: validateAgentApiUrl,
330
- })
331
-
332
- if (prompts.isCancel(customAgentApiUrlResult)) {
333
- cancelSetup(prompts)
620
+ const environment = promptValue(environmentResult)
621
+ const customDefaults = environment === 'custom' && existingConfig?.environment === 'custom'
622
+ ? existingConfig
623
+ : null
624
+
625
+ const customValues: Partial<Omit<SetupEnvironmentConfig, 'environment'>> = {}
626
+ if (environment === 'custom') {
627
+ const promptsByField: Array<{
628
+ key: keyof Omit<SetupEnvironmentConfig, 'environment'>
629
+ message: string
630
+ placeholder: string
631
+ defaultValue?: string
632
+ validate: (value: string) => string | undefined
633
+ }> = [
634
+ {
635
+ key: 'agentApiUrl',
636
+ message: 'Custom Agent API origin URL',
637
+ placeholder: 'https://agent-api.example.com',
638
+ defaultValue: customDefaults?.agentApiUrl,
639
+ validate: validateAgentApiUrl,
640
+ },
641
+ {
642
+ key: 'authApiUrl',
643
+ message: 'Custom Auth API URL',
644
+ placeholder: 'https://auth.example.com',
645
+ defaultValue: customDefaults?.authApiUrl,
646
+ validate: validateAuthApiUrl,
647
+ },
648
+ {
649
+ key: 'authDashboardUrl',
650
+ message: 'Custom Auth dashboard URL',
651
+ placeholder: 'https://accounts.example.com',
652
+ defaultValue: customDefaults?.authDashboardUrl,
653
+ validate: validateAuthDashboardUrl,
654
+ },
655
+ {
656
+ key: 'telaApiUrl',
657
+ message: 'Custom Tela API URL',
658
+ placeholder: 'https://api.example.com',
659
+ defaultValue: customDefaults?.telaApiUrl,
660
+ validate: validateTelaApiUrl,
661
+ },
662
+ {
663
+ key: 'requesterApplicationId',
664
+ message: 'Requester application ID',
665
+ placeholder: '00000000-0000-0000-0000-000000000000',
666
+ defaultValue: customDefaults?.requesterApplicationId,
667
+ validate: value => validateApplicationId(value, 'Requester application ID'),
668
+ },
669
+ {
670
+ key: 'targetApplicationId',
671
+ message: 'Target application ID',
672
+ placeholder: '00000000-0000-0000-0000-000000000000',
673
+ defaultValue: customDefaults?.targetApplicationId,
674
+ validate: value => validateApplicationId(value, 'Target application ID'),
675
+ },
676
+ ]
677
+
678
+ for (const field of promptsByField) {
679
+ const response = await prompts.text({
680
+ message: field.message,
681
+ placeholder: field.placeholder,
682
+ defaultValue: field.defaultValue,
683
+ validate: field.validate,
684
+ })
685
+ if (prompts.isCancel(response)) {
686
+ cancelSetup(prompts)
687
+ }
688
+
689
+ customValues[field.key] = promptValue(response)
334
690
  }
335
-
336
- agentApiUrl = resolveAgentApiUrlForPreset('custom', promptValue(customAgentApiUrlResult))
337
691
  }
338
692
 
339
693
  const cloneRootResult = await prompts.text({
@@ -341,44 +695,65 @@ export async function setupConfigInteractive(options: {
341
695
  defaultValue: existingConfig?.cloneRoot ?? getDefaultCloneRoot(),
342
696
  validate: validateCloneRoot,
343
697
  })
344
-
345
698
  if (prompts.isCancel(cloneRootResult)) {
346
699
  cancelSetup(prompts)
347
700
  }
348
701
 
349
- const apiKeyResult = await prompts.password({
350
- message: existingConfig?.apiKey ? 'API key' : 'API key',
351
- placeholder: existingConfig?.apiKey ? 'Leave blank to keep the current API key' : undefined,
352
- mask: '*',
353
- validate: value => validateApiKey(value, existingConfig?.apiKey),
354
- })
702
+ const sshKey = await promptForSshPublicKey(prompts)
355
703
 
356
- if (prompts.isCancel(apiKeyResult)) {
357
- cancelSetup(prompts)
704
+ const resolvedSetup = {
705
+ ...resolveEnvironmentConfigForPreset(environment, customValues),
706
+ cloneRoot: resolveCloneRoot(promptValue(cloneRootResult)),
358
707
  }
359
708
 
709
+ const auth = await options.authenticate(
710
+ resolvedSetup,
711
+ (message, title) => {
712
+ prompts.note?.(message, title)
713
+ },
714
+ )
715
+
360
716
  const config = normalizeConfig({
361
- agentApiUrl,
362
- cloneRoot: promptValue(cloneRootResult),
363
- apiKey: promptValue(apiKeyResult).trim() || existingConfig?.apiKey || '',
717
+ ...resolvedSetup,
718
+ auth,
719
+ forgejoOrganization: null,
720
+ forgejoUserRegistered: false,
721
+ })
722
+ const organization = await resolveForgejoOrganizationInteractive({
723
+ prompts,
724
+ existingConfig,
725
+ auth,
726
+ config,
727
+ fetchFn: options.fetchFn,
728
+ })
729
+ const provisioned = await ensureForgejoUserAccess({
730
+ config,
731
+ organization,
732
+ sshKey,
733
+ fetchFn: options.fetchFn,
734
+ onProgress: (message, title) => {
735
+ prompts.note?.(message, title)
736
+ },
737
+ provisionFn: options.provisionForgejoUser,
738
+ saveConfig,
364
739
  })
365
740
 
366
- saveConfig(config)
367
741
  if (!options.skipLifecycle) {
368
742
  prompts.outro('Tela CLI configured')
369
743
  }
370
- return config
744
+
745
+ return provisioned.config
371
746
  }
372
747
 
373
- export async function ensureConfigInteractive(
374
- options: {
375
- prompts?: PromptModule
376
- } = {},
377
- ): Promise<TelaCliConfig> {
748
+ export async function ensureConfigInteractive(): Promise<TelaCliConfig> {
378
749
  const existing = tryLoadConfig()
379
- if (existing) {
750
+ if (existing?.auth) {
380
751
  return existing
381
752
  }
382
753
 
383
- return await setupConfigInteractive(options)
754
+ throw createUserFacingError({
755
+ code: 'config_required',
756
+ summary: 'Tela CLI is not authenticated.',
757
+ fixes: ['Run `tela setup` in an interactive terminal first.'],
758
+ })
384
759
  }