@meistrari/tela-cli 0.1.0 → 0.2.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/README.md +18 -6
- package/package.json +5 -1
- package/postinstall.sh +19 -0
- package/src/agent-api.ts +102 -2
- package/src/assets.d.ts +4 -0
- package/src/auth.ts +423 -7
- package/src/commands/feedback.ts +1 -1
- package/src/commands/metadata.ts +18 -6
- package/src/commands/status.ts +10 -1
- package/src/config.ts +522 -147
- package/src/diagnostics.ts +127 -5
- package/src/forgejo.ts +189 -0
- package/src/ink/assets/160092__jorickhoofd__dog-bark-1.mp3 +0 -0
- package/src/ink/bark.tsx +14 -62
- package/src/ink/screens/setup/breadcrumb.tsx +23 -0
- package/src/ink/screens/setup/setup-screen.tsx +574 -0
- package/src/ink/screens/setup/step-frame.tsx +30 -0
- package/src/ink/screens/setup/steps/done-step.tsx +28 -0
- package/src/ink/screens/setup/steps/environment-step.tsx +22 -0
- package/src/ink/screens/setup/steps/organization-step.tsx +34 -0
- package/src/ink/screens/setup/steps/review-step.tsx +76 -0
- package/src/ink/screens/setup/steps/running-step.tsx +36 -0
- package/src/ink/screens/setup/steps/ssh-key-step.tsx +47 -0
- package/src/ink/screens/setup/steps/text-field-step.tsx +17 -0
- package/src/ink/screens/setup/steps.ts +145 -0
- package/src/ink/screens/setup/types.ts +63 -0
- package/src/program.ts +228 -96
- package/src/types.ts +64 -1
- package/src/ink/screens/setup-screen.tsx +0 -352
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
|
|
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.tela.com',
|
|
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
|
-
|
|
66
|
-
|
|
102
|
+
export interface SetupConfigInput extends SetupEnvironmentConfig {
|
|
103
|
+
cloneRoot: string
|
|
67
104
|
}
|
|
68
105
|
|
|
69
|
-
export
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
111
|
+
export const ENVIRONMENT_PRESET_OPTIONS: Array<Option<TelaEnvironment>> = [
|
|
82
112
|
{
|
|
83
113
|
value: 'production',
|
|
84
114
|
label: 'Production',
|
|
85
|
-
hint:
|
|
115
|
+
hint: ENVIRONMENT_PRESETS.production.agentApiUrl,
|
|
86
116
|
},
|
|
87
117
|
{
|
|
88
118
|
value: 'staging',
|
|
89
119
|
label: 'Staging',
|
|
90
|
-
hint:
|
|
120
|
+
hint: ENVIRONMENT_PRESETS.staging.agentApiUrl,
|
|
91
121
|
},
|
|
92
122
|
{
|
|
93
123
|
value: 'custom',
|
|
94
124
|
label: 'Custom',
|
|
95
|
-
hint: 'Enter
|
|
125
|
+
hint: 'Enter custom Tela service URLs and app IDs',
|
|
96
126
|
},
|
|
97
127
|
]
|
|
98
128
|
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
function ensureEnvironment(value: string): TelaEnvironment {
|
|
143
|
+
if (value === 'production' || value === 'staging' || value === 'custom') {
|
|
144
|
+
return value
|
|
145
|
+
}
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
return join(getHomeDir(), '.tela')
|
|
147
|
+
throw new Error('Environment must be production, staging, or custom')
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return undefined
|
|
171
|
+
function normalizeAuth(auth: TelaCliConfig['auth']): TelaCliConfig['auth'] {
|
|
172
|
+
if (!auth) {
|
|
173
|
+
return null
|
|
172
174
|
}
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
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
|
-
|
|
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?.(
|
|
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
|
-
|
|
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
|
|
321
|
-
|
|
322
|
-
?
|
|
323
|
-
:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|