@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/README.md +5 -6
- package/package.json +4 -2
- 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 +17 -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/components.tsx +20 -18
- 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 +280 -124
- package/src/types.ts +64 -1
- package/src/ui.ts +2 -4
- package/src/ink/screens/setup-screen.tsx +0 -352
package/README.md
CHANGED
|
@@ -18,12 +18,12 @@ source ~/.zshrc
|
|
|
18
18
|
|
|
19
19
|
### `setup`
|
|
20
20
|
|
|
21
|
-
Configure Tela CLI, choose
|
|
21
|
+
Configure Tela CLI, sign in with device flow, choose the workspace organization, provision Forgejo access, and verify the local environment
|
|
22
22
|
|
|
23
|
-
When to use: Run this first on a new machine, or any time your
|
|
23
|
+
When to use: Run this first on a new machine, or any time your environment target, clone root, Tela session, or SSH key changes.
|
|
24
24
|
|
|
25
25
|
Examples:
|
|
26
|
-
- `tela setup` — Choose production, staging, or a custom
|
|
26
|
+
- `tela setup` — Choose production, staging, or a custom environment, complete browser-based sign-in, select the workspace organization, and provision Forgejo access with an SSH key
|
|
27
27
|
|
|
28
28
|
### `sync [repo]`
|
|
29
29
|
|
|
@@ -71,7 +71,7 @@ Examples:
|
|
|
71
71
|
|
|
72
72
|
### `status`
|
|
73
73
|
|
|
74
|
-
Show Tela config, tool availability, repo state, and Agent API
|
|
74
|
+
Show Tela config, session state, tool availability, repo state, and Agent API reachability
|
|
75
75
|
|
|
76
76
|
When to use: Use this for a quick snapshot of whether the local environment is ready before running mutating commands.
|
|
77
77
|
|
|
@@ -81,7 +81,7 @@ Examples:
|
|
|
81
81
|
|
|
82
82
|
### `doctor`
|
|
83
83
|
|
|
84
|
-
Diagnose config, tools, filesystem state, repo health, and Agent API
|
|
84
|
+
Diagnose config, auth session, tools, filesystem state, repo health, and Agent API reachability
|
|
85
85
|
|
|
86
86
|
When to use: Use this when Tela fails or behaves unexpectedly and you need exact remediation steps.
|
|
87
87
|
|
|
@@ -95,7 +95,6 @@ Examples:
|
|
|
95
95
|
|
|
96
96
|
- Human output is structured for terminal use with grouped summaries and next actions.
|
|
97
97
|
- `--json` is available on read-heavy flows and on `tela run` for the final execution envelope.
|
|
98
|
-
- `--silent-bark` disables bark audio during interactive Ink startup while keeping the visual intro.
|
|
99
98
|
|
|
100
99
|
## First run
|
|
101
100
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meistrari/tela-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tela": "cli.ts"
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"typecheck": "bunx tsc --noEmit"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@meistrari/auth-cli": "^1.0.1",
|
|
21
|
+
"better-auth": "^1.6.3",
|
|
20
22
|
"commander": "13.1.0",
|
|
21
23
|
"ink": "6.2.3",
|
|
22
24
|
"react": "19"
|
|
@@ -29,4 +31,4 @@
|
|
|
29
31
|
"patchedDependencies": {
|
|
30
32
|
"ink@6.2.3": "patches/ink@6.2.3.patch"
|
|
31
33
|
}
|
|
32
|
-
}
|
|
34
|
+
}
|
package/src/agent-api.ts
CHANGED
|
@@ -2,8 +2,12 @@ import { buildAgentApiHeaders, type FetchLike } from './auth.ts'
|
|
|
2
2
|
import type {
|
|
3
3
|
AgentFeedbackItem,
|
|
4
4
|
AgentFeedbackListResponse,
|
|
5
|
+
ForgejoUserCreateResponse,
|
|
6
|
+
ForgejoUserSshKeyInput,
|
|
7
|
+
OrganizationListResponse,
|
|
5
8
|
RepositoryLookupResponse,
|
|
6
9
|
TelaCliConfig,
|
|
10
|
+
TelaCliForgejoOrganization,
|
|
7
11
|
} from './types.ts'
|
|
8
12
|
|
|
9
13
|
const DEFAULT_TIMEOUT_MS = 10_000
|
|
@@ -32,6 +36,15 @@ function isFeedbackItem(value: unknown): value is AgentFeedbackItem {
|
|
|
32
36
|
&& typeof candidate.suggestion === 'string'
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
function isForgejoOrganization(value: unknown): value is TelaCliForgejoOrganization {
|
|
40
|
+
if (!value || typeof value !== 'object') {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const candidate = value as Partial<TelaCliForgejoOrganization>
|
|
45
|
+
return typeof candidate.id === 'string' && typeof candidate.name === 'string'
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
export class AgentApiError extends Error {
|
|
36
49
|
constructor(
|
|
37
50
|
message: string,
|
|
@@ -47,11 +60,12 @@ export async function fetchRepositoryLookup(
|
|
|
47
60
|
config: TelaCliConfig,
|
|
48
61
|
fetchFn: FetchLike = fetch,
|
|
49
62
|
): Promise<Required<Pick<RepositoryLookupResponse, 'organization' | 'agentRepo' | 'forgejoRepository'>> & { success: true }> {
|
|
63
|
+
const headers = await buildAgentApiHeaders(config, { fetchFn })
|
|
50
64
|
const response = await fetchFn(
|
|
51
65
|
joinUrl(config.agentApiUrl, `/v3/agents/repositories/${encodeURIComponent(repository)}`),
|
|
52
66
|
{
|
|
53
67
|
method: 'GET',
|
|
54
|
-
headers
|
|
68
|
+
headers,
|
|
55
69
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
56
70
|
},
|
|
57
71
|
)
|
|
@@ -86,6 +100,7 @@ export async function fetchCommitFeedback(
|
|
|
86
100
|
config: TelaCliConfig,
|
|
87
101
|
fetchFn: FetchLike = fetch,
|
|
88
102
|
): Promise<AgentFeedbackListResponse> {
|
|
103
|
+
const headers = await buildAgentApiHeaders(config, { fetchFn })
|
|
89
104
|
const response = await fetchFn(
|
|
90
105
|
joinUrl(
|
|
91
106
|
config.agentApiUrl,
|
|
@@ -93,7 +108,7 @@ export async function fetchCommitFeedback(
|
|
|
93
108
|
),
|
|
94
109
|
{
|
|
95
110
|
method: 'GET',
|
|
96
|
-
headers
|
|
111
|
+
headers,
|
|
97
112
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
98
113
|
},
|
|
99
114
|
)
|
|
@@ -122,3 +137,88 @@ export async function fetchCommitFeedback(
|
|
|
122
137
|
feedbacks: payload.feedbacks,
|
|
123
138
|
}
|
|
124
139
|
}
|
|
140
|
+
|
|
141
|
+
export async function listOrganizations(
|
|
142
|
+
config: TelaCliConfig,
|
|
143
|
+
fetchFn: FetchLike = fetch,
|
|
144
|
+
): Promise<TelaCliForgejoOrganization[]> {
|
|
145
|
+
const headers = await buildAgentApiHeaders(config, { fetchFn })
|
|
146
|
+
const response = await fetchFn(
|
|
147
|
+
joinUrl(config.agentApiUrl, '/v3/organizations'),
|
|
148
|
+
{
|
|
149
|
+
method: 'GET',
|
|
150
|
+
headers,
|
|
151
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const responseText = await response.text()
|
|
156
|
+
const payload = parseJsonBody(responseText) as OrganizationListResponse | null
|
|
157
|
+
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new AgentApiError(
|
|
160
|
+
payload?.error ?? `Agent API organization lookup failed with status ${response.status}`,
|
|
161
|
+
response.status,
|
|
162
|
+
responseText,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!payload
|
|
167
|
+
|| payload.success !== true
|
|
168
|
+
|| !Array.isArray(payload.organizations)
|
|
169
|
+
|| !payload.organizations.every(isForgejoOrganization)) {
|
|
170
|
+
throw new AgentApiError('Agent API organization lookup returned an invalid payload', response.status, responseText)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return payload.organizations
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function provisionForgejoUser(
|
|
177
|
+
input: {
|
|
178
|
+
organizationName: string
|
|
179
|
+
sshKey: ForgejoUserSshKeyInput
|
|
180
|
+
},
|
|
181
|
+
config: TelaCliConfig,
|
|
182
|
+
fetchFn: FetchLike = fetch,
|
|
183
|
+
): Promise<ForgejoUserCreateResponse> {
|
|
184
|
+
const headers = await buildAgentApiHeaders(config, { fetchFn })
|
|
185
|
+
const response = await fetchFn(
|
|
186
|
+
joinUrl(config.agentApiUrl, '/v3/forgejo/users'),
|
|
187
|
+
{
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: {
|
|
190
|
+
...headers,
|
|
191
|
+
'Content-Type': 'application/json',
|
|
192
|
+
},
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
organizationName: input.organizationName,
|
|
195
|
+
sshKey: input.sshKey,
|
|
196
|
+
}),
|
|
197
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const responseText = await response.text()
|
|
202
|
+
const payload = parseJsonBody(responseText) as ForgejoUserCreateResponse | null
|
|
203
|
+
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
throw new AgentApiError(
|
|
206
|
+
payload?.error ?? `Agent API Forgejo user provisioning failed with status ${response.status}`,
|
|
207
|
+
response.status,
|
|
208
|
+
responseText,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!payload
|
|
213
|
+
|| payload.success !== true
|
|
214
|
+
|| typeof payload.username !== 'string'
|
|
215
|
+
|| typeof payload.organizationName !== 'string'
|
|
216
|
+
|| typeof payload.teamName !== 'string'
|
|
217
|
+
|| typeof payload.userCreated !== 'boolean'
|
|
218
|
+
|| typeof payload.teamCreated !== 'boolean'
|
|
219
|
+
|| typeof payload.sshKeyCreated !== 'boolean') {
|
|
220
|
+
throw new AgentApiError('Agent API Forgejo user provisioning returned an invalid payload', response.status, responseText)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return payload
|
|
224
|
+
}
|
package/src/assets.d.ts
ADDED
package/src/auth.ts
CHANGED
|
@@ -1,16 +1,432 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
DeviceAccessDeniedError,
|
|
3
|
+
DeviceCodeExpiredError,
|
|
4
|
+
DeviceFlow,
|
|
5
|
+
DeviceFlowAbortedError,
|
|
6
|
+
RefreshTokenExpiredError,
|
|
7
|
+
} from '@meistrari/auth-cli'
|
|
8
|
+
import {
|
|
9
|
+
clearSavedAuth,
|
|
10
|
+
isPlaceholderApplicationId,
|
|
11
|
+
saveConfig,
|
|
12
|
+
} from './config.ts'
|
|
13
|
+
import { createUserFacingError } from './errors.ts'
|
|
14
|
+
import { runCommand } from './process.ts'
|
|
15
|
+
import type {
|
|
16
|
+
TelaCliAuthState,
|
|
17
|
+
TelaCliConfig,
|
|
18
|
+
TelaCliUser,
|
|
19
|
+
TelaCliWorkspace,
|
|
20
|
+
} from './types.ts'
|
|
2
21
|
|
|
3
22
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
|
|
4
23
|
|
|
5
|
-
|
|
6
|
-
|
|
24
|
+
type StoredTokensLike = {
|
|
25
|
+
accessToken: string
|
|
26
|
+
refreshToken: string
|
|
27
|
+
expiresAt: number
|
|
28
|
+
user: {
|
|
29
|
+
id: string
|
|
30
|
+
email: string
|
|
31
|
+
name?: string | null
|
|
32
|
+
}
|
|
33
|
+
organization: {
|
|
34
|
+
id: string
|
|
35
|
+
name?: string | null
|
|
36
|
+
title?: string | null
|
|
37
|
+
slug?: string | null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DeviceFlowLike {
|
|
42
|
+
on(
|
|
43
|
+
eventName: 'userCode',
|
|
44
|
+
listener: (payload: {
|
|
45
|
+
userCode: string
|
|
46
|
+
verificationUri: string
|
|
47
|
+
verificationUriComplete: string
|
|
48
|
+
expiresIn: number
|
|
49
|
+
interval: number
|
|
50
|
+
}) => void | Promise<void>,
|
|
51
|
+
): unknown
|
|
52
|
+
on(
|
|
53
|
+
eventName: 'pending',
|
|
54
|
+
listener: (payload: { attempt: number, elapsedMs: number }) => void | Promise<void>,
|
|
55
|
+
): unknown
|
|
56
|
+
on(
|
|
57
|
+
eventName: 'slowDown',
|
|
58
|
+
listener: (payload: { previousInterval: number, newInterval: number }) => void | Promise<void>,
|
|
59
|
+
): unknown
|
|
60
|
+
on(
|
|
61
|
+
eventName: 'transientError',
|
|
62
|
+
listener: (payload: { attempt: number }) => void | Promise<void>,
|
|
63
|
+
): unknown
|
|
64
|
+
authenticate(): Promise<StoredTokensLike>
|
|
65
|
+
refresh(refreshToken?: string): Promise<StoredTokensLike>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type DeviceFlowFactory = (input: {
|
|
69
|
+
apiUrl: string
|
|
70
|
+
requesterApplicationId: string
|
|
71
|
+
targetApplicationId: string
|
|
72
|
+
signal?: AbortSignal
|
|
73
|
+
}) => DeviceFlowLike
|
|
74
|
+
|
|
75
|
+
export interface SetupAuthEvent {
|
|
76
|
+
type: 'message' | 'user_code' | 'browser_opened' | 'browser_open_failed' | 'success'
|
|
77
|
+
message: string
|
|
78
|
+
title?: string
|
|
79
|
+
verificationUrl?: string
|
|
80
|
+
userCode?: string
|
|
81
|
+
expiresInSeconds?: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface AuthenticateWithDeviceFlowOptions {
|
|
85
|
+
createDeviceFlow?: DeviceFlowFactory
|
|
86
|
+
openBrowser?: (url: string) => Promise<boolean>
|
|
87
|
+
timeoutMs?: number
|
|
88
|
+
onEvent?: (event: SetupAuthEvent) => void
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface BuildAgentApiHeadersOptions {
|
|
92
|
+
clearAuth?: (config: TelaCliConfig) => TelaCliConfig
|
|
93
|
+
createDeviceFlow?: DeviceFlowFactory
|
|
94
|
+
fetchFn?: FetchLike
|
|
95
|
+
nowMs?: number
|
|
96
|
+
persistConfig?: (config: TelaCliConfig) => void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function defaultDeviceFlowFactory(input: ConstructorParameters<typeof DeviceFlow>[0]): DeviceFlowLike {
|
|
100
|
+
return new DeviceFlow(input)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createAuthRequiredError(summary: string): ReturnType<typeof createUserFacingError> {
|
|
104
|
+
return createUserFacingError({
|
|
105
|
+
code: 'auth_required',
|
|
106
|
+
summary,
|
|
107
|
+
fixes: ['Run `tela setup` in an interactive terminal to sign in again.'],
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mapUser(user: StoredTokensLike['user']): TelaCliUser {
|
|
112
|
+
return {
|
|
113
|
+
id: user.id,
|
|
114
|
+
email: user.email,
|
|
115
|
+
name: user.name?.trim() || user.email,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function mapWorkspace(organization: StoredTokensLike['organization']): TelaCliWorkspace {
|
|
120
|
+
return {
|
|
121
|
+
id: organization.id,
|
|
122
|
+
title: organization.name?.trim()
|
|
123
|
+
|| organization.title?.trim()
|
|
124
|
+
|| organization.slug?.trim()
|
|
125
|
+
|| organization.id,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function mapTokens(tokens: StoredTokensLike): TelaCliAuthState {
|
|
130
|
+
return {
|
|
131
|
+
user: mapUser(tokens.user),
|
|
132
|
+
workspace: mapWorkspace(tokens.organization),
|
|
133
|
+
session: {
|
|
134
|
+
accessToken: tokens.accessToken,
|
|
135
|
+
refreshToken: tokens.refreshToken,
|
|
136
|
+
expiresAt: tokens.expiresAt,
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildDashboardVerificationUrl(dashboardUrl: string, userCode: string, fallbackUrl: string): string {
|
|
142
|
+
try {
|
|
143
|
+
const url = new URL('/applications/device', dashboardUrl)
|
|
144
|
+
url.searchParams.set('user_code', userCode)
|
|
145
|
+
return url.toString()
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return fallbackUrl
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function defaultOpenBrowser(url: string): Promise<boolean> {
|
|
153
|
+
const command = (() => {
|
|
154
|
+
switch (process.platform) {
|
|
155
|
+
case 'darwin':
|
|
156
|
+
return ['open', url]
|
|
157
|
+
case 'win32':
|
|
158
|
+
return ['cmd', '/c', 'start', '', url]
|
|
159
|
+
default:
|
|
160
|
+
return ['xdg-open', url]
|
|
161
|
+
}
|
|
162
|
+
})()
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const result = await runCommand(command, { allowFailure: true })
|
|
166
|
+
return result.exitCode === 0
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
7
171
|
}
|
|
8
172
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
173
|
+
function ensurePresetApplicationIds(input: {
|
|
174
|
+
environment: TelaCliConfig['environment']
|
|
175
|
+
requesterApplicationId: string
|
|
176
|
+
targetApplicationId: string
|
|
177
|
+
}): void {
|
|
178
|
+
if (input.environment === 'custom') {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!isPlaceholderApplicationId(input.requesterApplicationId) && !isPlaceholderApplicationId(input.targetApplicationId)) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw createUserFacingError({
|
|
187
|
+
code: 'setup_preset_incomplete',
|
|
188
|
+
summary: `The ${input.environment} auth preset is missing the real Tela CLI application IDs.`,
|
|
189
|
+
fixes: [
|
|
190
|
+
'Update the production/staging requester and target application IDs in `src/config.ts`, or use the custom setup target.',
|
|
191
|
+
],
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function authenticateWithDeviceFlow(
|
|
196
|
+
input: Pick<TelaCliConfig, 'environment' | 'authApiUrl' | 'authDashboardUrl' | 'requesterApplicationId' | 'targetApplicationId'>,
|
|
197
|
+
options: AuthenticateWithDeviceFlowOptions = {},
|
|
198
|
+
): Promise<TelaCliAuthState> {
|
|
199
|
+
ensurePresetApplicationIds(input)
|
|
200
|
+
|
|
201
|
+
const timeoutMs = options.timeoutMs ?? (15 * 60 * 1000)
|
|
202
|
+
const signal = AbortSignal.timeout(timeoutMs)
|
|
203
|
+
const createDeviceFlow = options.createDeviceFlow ?? defaultDeviceFlowFactory
|
|
204
|
+
const openBrowser = options.openBrowser ?? defaultOpenBrowser
|
|
205
|
+
const emit = (event: SetupAuthEvent) => {
|
|
206
|
+
options.onEvent?.(event)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const flow = createDeviceFlow({
|
|
210
|
+
apiUrl: input.authApiUrl,
|
|
211
|
+
requesterApplicationId: input.requesterApplicationId,
|
|
212
|
+
targetApplicationId: input.targetApplicationId,
|
|
213
|
+
signal,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
flow.on('userCode', async ({ userCode, verificationUriComplete, expiresIn }) => {
|
|
217
|
+
const verificationUrl = buildDashboardVerificationUrl(
|
|
218
|
+
input.authDashboardUrl,
|
|
219
|
+
userCode,
|
|
220
|
+
verificationUriComplete,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
emit({
|
|
224
|
+
type: 'user_code',
|
|
225
|
+
title: 'Authentication',
|
|
226
|
+
message: `Open ${verificationUrl}\nUser code: ${userCode}\nCode expires in ${expiresIn}s`,
|
|
227
|
+
verificationUrl,
|
|
228
|
+
userCode,
|
|
229
|
+
expiresInSeconds: expiresIn,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const opened = await openBrowser(verificationUrl)
|
|
233
|
+
emit(opened
|
|
234
|
+
? {
|
|
235
|
+
type: 'browser_opened',
|
|
236
|
+
title: 'Authentication',
|
|
237
|
+
message: 'Opened the verification URL in your browser.',
|
|
238
|
+
verificationUrl,
|
|
239
|
+
}
|
|
240
|
+
: {
|
|
241
|
+
type: 'browser_open_failed',
|
|
242
|
+
title: 'Authentication',
|
|
243
|
+
message: 'Could not open the browser automatically. Open the verification URL manually.',
|
|
244
|
+
verificationUrl,
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
flow.on('pending', ({ attempt }) => {
|
|
249
|
+
emit({
|
|
250
|
+
type: 'message',
|
|
251
|
+
title: 'Authentication',
|
|
252
|
+
message: `[poll ${attempt}] Waiting for approval...`,
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
flow.on('slowDown', ({ newInterval }) => {
|
|
257
|
+
emit({
|
|
258
|
+
type: 'message',
|
|
259
|
+
title: 'Authentication',
|
|
260
|
+
message: `Server requested a slower polling interval (${newInterval}s).`,
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
flow.on('transientError', ({ attempt }) => {
|
|
265
|
+
emit({
|
|
266
|
+
type: 'message',
|
|
267
|
+
title: 'Authentication',
|
|
268
|
+
message: `[poll ${attempt}] Temporary auth server error. Retrying...`,
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const tokens = await flow.authenticate()
|
|
274
|
+
const auth = mapTokens(tokens)
|
|
275
|
+
emit({
|
|
276
|
+
type: 'success',
|
|
277
|
+
title: 'Authentication',
|
|
278
|
+
message: `Signed in as ${auth.user.email} for workspace ${auth.workspace.title}.`,
|
|
279
|
+
})
|
|
280
|
+
return auth
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (error instanceof DeviceAccessDeniedError) {
|
|
284
|
+
throw createUserFacingError({
|
|
285
|
+
code: 'device_access_denied',
|
|
286
|
+
summary: 'The device authorization request was denied.',
|
|
287
|
+
fixes: ['Run `tela setup` again and approve the request in the browser.'],
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (error instanceof DeviceCodeExpiredError) {
|
|
292
|
+
throw createUserFacingError({
|
|
293
|
+
code: 'device_code_expired',
|
|
294
|
+
summary: 'The device authorization expired before approval completed.',
|
|
295
|
+
fixes: ['Run `tela setup` again and approve the request before the code expires.'],
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (error instanceof DeviceFlowAbortedError) {
|
|
300
|
+
throw createUserFacingError({
|
|
301
|
+
code: 'device_flow_timeout',
|
|
302
|
+
summary: `Timed out waiting for device authorization after ${Math.round(timeoutMs / 60_000)} minutes.`,
|
|
303
|
+
fixes: ['Run `tela setup` again and complete the browser approval before the timeout.'],
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
throw error
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function refreshAuthState(
|
|
312
|
+
config: TelaCliConfig,
|
|
313
|
+
options: BuildAgentApiHeadersOptions,
|
|
314
|
+
): Promise<TelaCliAuthState> {
|
|
315
|
+
if (!config.auth?.session.refreshToken) {
|
|
316
|
+
throw createAuthRequiredError('Tela CLI is not authenticated.')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const createDeviceFlow = options.createDeviceFlow ?? defaultDeviceFlowFactory
|
|
320
|
+
const flow = createDeviceFlow({
|
|
321
|
+
apiUrl: config.authApiUrl,
|
|
322
|
+
requesterApplicationId: config.requesterApplicationId,
|
|
323
|
+
targetApplicationId: config.targetApplicationId,
|
|
324
|
+
signal: AbortSignal.timeout(10_000),
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const refreshed = await flow.refresh(config.auth.session.refreshToken)
|
|
329
|
+
const nextAuth = mapTokens(refreshed)
|
|
330
|
+
const nextConfig: TelaCliConfig = {
|
|
331
|
+
...config,
|
|
332
|
+
auth: nextAuth,
|
|
333
|
+
}
|
|
334
|
+
;(options.persistConfig ?? saveConfig)(nextConfig)
|
|
335
|
+
config.auth = nextAuth
|
|
336
|
+
return nextAuth
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (error instanceof RefreshTokenExpiredError) {
|
|
340
|
+
;(options.clearAuth ?? clearSavedAuth)(config)
|
|
341
|
+
config.auth = null
|
|
342
|
+
throw createAuthRequiredError('The saved Tela session expired and must be reauthenticated.')
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
throw error
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function getValidAccessToken(
|
|
350
|
+
config: TelaCliConfig,
|
|
351
|
+
options: BuildAgentApiHeadersOptions = {},
|
|
352
|
+
): Promise<string> {
|
|
353
|
+
if (!config.auth?.session.accessToken) {
|
|
354
|
+
throw createAuthRequiredError('Tela CLI is not authenticated.')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const nowMs = options.nowMs ?? Date.now()
|
|
358
|
+
if (config.auth.session.expiresAt > nowMs + 30_000) {
|
|
359
|
+
return config.auth.session.accessToken
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const nextAuth = await refreshAuthState(config, options)
|
|
363
|
+
return nextAuth.session.accessToken
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
type DataTokenResponse = {
|
|
367
|
+
dataToken?: string
|
|
368
|
+
error?: string
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function exchangeDataToken(
|
|
372
|
+
config: TelaCliConfig,
|
|
373
|
+
accessToken: string,
|
|
374
|
+
fetchFn: FetchLike,
|
|
375
|
+
): Promise<string> {
|
|
376
|
+
const dataTokenUrl = new URL('/data-token', config.authApiUrl)
|
|
377
|
+
dataTokenUrl.searchParams.set('expiresAt', '1d')
|
|
378
|
+
|
|
379
|
+
const response = await fetchFn(dataTokenUrl.toString(), {
|
|
380
|
+
method: 'GET',
|
|
381
|
+
headers: {
|
|
382
|
+
Authorization: `Bearer ${accessToken}`,
|
|
383
|
+
Accept: 'application/json',
|
|
384
|
+
},
|
|
385
|
+
signal: AbortSignal.timeout(10_000),
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
const responseText = await response.text()
|
|
389
|
+
let payload: DataTokenResponse | null = null
|
|
390
|
+
try {
|
|
391
|
+
payload = responseText ? JSON.parse(responseText) as DataTokenResponse : null
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
payload = null
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!response.ok) {
|
|
398
|
+
throw createUserFacingError({
|
|
399
|
+
code: 'data_token_exchange_failed',
|
|
400
|
+
summary: payload?.error ?? `Tela data-token exchange failed with status ${response.status}.`,
|
|
401
|
+
fixes: ['Run `tela setup` again if the saved session is stale.'],
|
|
402
|
+
evidence: responseText ? [responseText] : [],
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const headerToken = response.headers.get('x-data-token')?.trim()
|
|
407
|
+
const dataToken = headerToken || payload?.dataToken?.trim()
|
|
408
|
+
if (!dataToken) {
|
|
409
|
+
throw createUserFacingError({
|
|
410
|
+
code: 'data_token_exchange_invalid',
|
|
411
|
+
summary: 'Tela data-token exchange returned an invalid response.',
|
|
412
|
+
fixes: ['Run `tela setup` again if the saved session is stale.'],
|
|
413
|
+
evidence: responseText ? [responseText] : [],
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return dataToken
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function buildAgentApiHeaders(
|
|
421
|
+
config: TelaCliConfig,
|
|
422
|
+
options: BuildAgentApiHeadersOptions = {},
|
|
423
|
+
): Promise<Record<string, string>> {
|
|
424
|
+
const fetchFn = options.fetchFn ?? fetch
|
|
425
|
+
const accessToken = await getValidAccessToken(config, options)
|
|
426
|
+
const dataToken = await exchangeDataToken(config, accessToken, fetchFn)
|
|
427
|
+
|
|
12
428
|
return {
|
|
13
|
-
|
|
429
|
+
'x-data-token': dataToken,
|
|
14
430
|
Accept: 'application/json',
|
|
15
431
|
}
|
|
16
432
|
}
|
package/src/commands/feedback.ts
CHANGED
|
@@ -540,7 +540,7 @@ export async function runFeedbackCommand(options: RunFeedbackCommandOptions): Pr
|
|
|
540
540
|
const runInteractiveCommandFn = options.runInteractiveCommand ?? runInteractiveCommand
|
|
541
541
|
const selectionMode = getFeedbackSelectionMode(options)
|
|
542
542
|
|
|
543
|
-
const config: TelaCliConfig = await ensureConfig(
|
|
543
|
+
const config: TelaCliConfig = await ensureConfig()
|
|
544
544
|
const data = await loadFeedbackData({
|
|
545
545
|
cwd: repoRoot,
|
|
546
546
|
config,
|