@meistrari/tela-cli 0.1.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 +95 -0
- package/cli.ts +25 -0
- package/package.json +30 -0
- package/src/agent-api.ts +124 -0
- package/src/auth.ts +16 -0
- package/src/commands/doctor.ts +35 -0
- package/src/commands/feedback.ts +701 -0
- package/src/commands/metadata.ts +208 -0
- package/src/commands/push.ts +208 -0
- package/src/commands/run.ts +1601 -0
- package/src/commands/status.ts +87 -0
- package/src/commands/sync.ts +245 -0
- package/src/commit-message.ts +69 -0
- package/src/config.ts +384 -0
- package/src/diagnostics.ts +578 -0
- package/src/errors.ts +122 -0
- package/src/git.ts +250 -0
- package/src/ink/bark.tsx +288 -0
- package/src/ink/components.tsx +175 -0
- package/src/ink/runtime.tsx +89 -0
- package/src/ink/screens/feedback-screen.tsx +394 -0
- package/src/ink/screens/push-screen.tsx +231 -0
- package/src/ink/screens/run-screen.tsx +291 -0
- package/src/ink/screens/setup-screen.tsx +352 -0
- package/src/ink/screens/sync-screen.tsx +282 -0
- package/src/process.ts +251 -0
- package/src/program.ts +1872 -0
- package/src/runtime.ts +147 -0
- package/src/types.ts +80 -0
- package/src/ui.ts +297 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { inspectSavedConfig, redactApiKey } from './config.ts'
|
|
4
|
+
import {
|
|
5
|
+
findRepositoryRoot,
|
|
6
|
+
getCurrentBranch,
|
|
7
|
+
getHeadShortSha,
|
|
8
|
+
getOriginUrl,
|
|
9
|
+
getShortStatus,
|
|
10
|
+
getStatusCounts,
|
|
11
|
+
getUpstreamBranch,
|
|
12
|
+
} from './git.ts'
|
|
13
|
+
import { commandExists } from './process.ts'
|
|
14
|
+
|
|
15
|
+
export type DiagnosticStatus = 'pass' | 'warn' | 'fail'
|
|
16
|
+
export type AgentApiReachabilityCategory = 'invalid_url' | 'tls_error' | 'network_error' | 'http_response'
|
|
17
|
+
|
|
18
|
+
export interface AgentApiReachability {
|
|
19
|
+
reachable: boolean
|
|
20
|
+
category: AgentApiReachabilityCategory
|
|
21
|
+
statusCode?: number
|
|
22
|
+
detail: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RepoSnapshot {
|
|
26
|
+
cwd: string
|
|
27
|
+
inRepository: boolean
|
|
28
|
+
repoRoot?: string
|
|
29
|
+
branch?: string
|
|
30
|
+
upstream?: string | null
|
|
31
|
+
detached: boolean
|
|
32
|
+
dirty: boolean
|
|
33
|
+
originUrl?: string | null
|
|
34
|
+
shortStatus?: string
|
|
35
|
+
head?: string
|
|
36
|
+
statusCounts?: {
|
|
37
|
+
staged: number
|
|
38
|
+
unstaged: number
|
|
39
|
+
untracked: number
|
|
40
|
+
total: number
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StatusReport {
|
|
45
|
+
ok: boolean
|
|
46
|
+
config: {
|
|
47
|
+
present: boolean
|
|
48
|
+
valid: boolean
|
|
49
|
+
path: string
|
|
50
|
+
agentApiUrl?: string
|
|
51
|
+
cloneRoot?: string
|
|
52
|
+
apiKey?: string
|
|
53
|
+
error?: string
|
|
54
|
+
}
|
|
55
|
+
repo: RepoSnapshot
|
|
56
|
+
tools: {
|
|
57
|
+
git: boolean
|
|
58
|
+
claude: boolean
|
|
59
|
+
agentApiReachable: boolean | null
|
|
60
|
+
agentApiCategory?: AgentApiReachabilityCategory
|
|
61
|
+
agentApiStatus?: number
|
|
62
|
+
agentApiDetail?: string
|
|
63
|
+
}
|
|
64
|
+
warnings: string[]
|
|
65
|
+
nextActions: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DoctorCheck {
|
|
69
|
+
id: string
|
|
70
|
+
group: string
|
|
71
|
+
status: DiagnosticStatus
|
|
72
|
+
summary: string
|
|
73
|
+
fixes: string[]
|
|
74
|
+
evidence?: string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DoctorReport {
|
|
78
|
+
ok: boolean
|
|
79
|
+
checks: DoctorCheck[]
|
|
80
|
+
nextActions: string[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isDetachedHeadError(error: unknown): boolean {
|
|
84
|
+
return error instanceof Error && error.message.includes('Detached HEAD')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function classifyAgentApiReachabilityError(message: string): AgentApiReachabilityCategory {
|
|
88
|
+
return /tls|ssl|certificate|hostname|subject name/i.test(message)
|
|
89
|
+
? 'tls_error'
|
|
90
|
+
: 'network_error'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function checkAgentApiReachability(url: string, fetchFn: typeof fetch = fetch): Promise<AgentApiReachability> {
|
|
94
|
+
try {
|
|
95
|
+
new URL(url)
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return {
|
|
99
|
+
reachable: false,
|
|
100
|
+
category: 'invalid_url',
|
|
101
|
+
detail: 'Agent API origin URL must be a valid URL',
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetchFn(url, {
|
|
107
|
+
method: 'GET',
|
|
108
|
+
signal: AbortSignal.timeout(5_000),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const detail = response.statusText
|
|
112
|
+
? `HTTP ${response.status} ${response.statusText}`
|
|
113
|
+
: `HTTP ${response.status}`
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
reachable: true,
|
|
117
|
+
category: 'http_response',
|
|
118
|
+
statusCode: response.status,
|
|
119
|
+
detail,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
124
|
+
return {
|
|
125
|
+
reachable: false,
|
|
126
|
+
category: classifyAgentApiReachabilityError(detail),
|
|
127
|
+
detail,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getAgentApiOriginCheckStatus(reachability: AgentApiReachability): string {
|
|
133
|
+
switch (reachability.category) {
|
|
134
|
+
case 'http_response':
|
|
135
|
+
return 'responded'
|
|
136
|
+
case 'invalid_url':
|
|
137
|
+
return 'invalid-url'
|
|
138
|
+
case 'tls_error':
|
|
139
|
+
return 'tls-error'
|
|
140
|
+
case 'network_error':
|
|
141
|
+
return 'network-error'
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getAgentApiOriginWarning(reachability: AgentApiReachability): string | undefined {
|
|
146
|
+
switch (reachability.category) {
|
|
147
|
+
case 'http_response':
|
|
148
|
+
return undefined
|
|
149
|
+
case 'invalid_url':
|
|
150
|
+
return `Agent API origin URL is invalid: ${reachability.detail}`
|
|
151
|
+
case 'tls_error':
|
|
152
|
+
return `Agent API origin TLS check failed: ${reachability.detail}`
|
|
153
|
+
case 'network_error':
|
|
154
|
+
return `Agent API origin is unreachable: ${reachability.detail}`
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getAgentApiOriginFixes(reachability: AgentApiReachability): string[] {
|
|
159
|
+
switch (reachability.category) {
|
|
160
|
+
case 'http_response':
|
|
161
|
+
return ['No action required.']
|
|
162
|
+
case 'invalid_url':
|
|
163
|
+
return ['Run `tela setup` and choose production, staging, or a valid custom Agent API origin URL.']
|
|
164
|
+
case 'tls_error':
|
|
165
|
+
return ['Check the configured hostname and TLS certificate, or choose the correct Agent API target in `tela setup`.']
|
|
166
|
+
case 'network_error':
|
|
167
|
+
return ['Check your network connection, VPN, or the configured Agent API origin in `tela setup`.']
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getAgentApiOriginSummary(url: string, reachability: AgentApiReachability): string {
|
|
172
|
+
switch (reachability.category) {
|
|
173
|
+
case 'http_response':
|
|
174
|
+
return `Agent API origin responded at ${url} (${reachability.detail})`
|
|
175
|
+
case 'invalid_url':
|
|
176
|
+
return `Agent API origin URL is invalid: ${url}`
|
|
177
|
+
case 'tls_error':
|
|
178
|
+
return `Agent API origin TLS check failed at ${url}`
|
|
179
|
+
case 'network_error':
|
|
180
|
+
return `Agent API origin is unreachable at ${url}`
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function inspectCurrentRepository(cwd = process.cwd()): Promise<RepoSnapshot> {
|
|
185
|
+
const repoRoot = await findRepositoryRoot(cwd)
|
|
186
|
+
if (!repoRoot) {
|
|
187
|
+
return {
|
|
188
|
+
cwd,
|
|
189
|
+
inRepository: false,
|
|
190
|
+
detached: false,
|
|
191
|
+
dirty: false,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const shortStatus = await getShortStatus(repoRoot)
|
|
196
|
+
const dirty = Boolean(shortStatus.trim())
|
|
197
|
+
const statusCounts = await getStatusCounts(repoRoot)
|
|
198
|
+
const head = await getHeadShortSha(repoRoot)
|
|
199
|
+
|
|
200
|
+
let branch: string | undefined
|
|
201
|
+
let detached = false
|
|
202
|
+
try {
|
|
203
|
+
branch = await getCurrentBranch(repoRoot)
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (!isDetachedHeadError(error)) {
|
|
207
|
+
throw error
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
detached = true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let upstream: string | null | undefined
|
|
214
|
+
if (!detached) {
|
|
215
|
+
upstream = await getUpstreamBranch(repoRoot)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let originUrl: string | null = null
|
|
219
|
+
try {
|
|
220
|
+
originUrl = await getOriginUrl(repoRoot)
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
originUrl = null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
cwd,
|
|
228
|
+
inRepository: true,
|
|
229
|
+
repoRoot,
|
|
230
|
+
branch,
|
|
231
|
+
upstream,
|
|
232
|
+
detached,
|
|
233
|
+
dirty,
|
|
234
|
+
originUrl,
|
|
235
|
+
shortStatus: shortStatus.trim() || undefined,
|
|
236
|
+
head,
|
|
237
|
+
statusCounts,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildStatusNextActions(warnings: string[], repo: RepoSnapshot, hasConfig: boolean): string[] {
|
|
242
|
+
const actions: string[] = []
|
|
243
|
+
|
|
244
|
+
if (!hasConfig) {
|
|
245
|
+
actions.push('Run `tela setup` to configure the CLI.')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!repo.inRepository) {
|
|
249
|
+
actions.push('Run `tela sync <repo>` or change into a cloned repository before mutating commands.')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (repo.detached) {
|
|
253
|
+
actions.push('Check out a branch before syncing or pushing.')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (repo.dirty) {
|
|
257
|
+
actions.push('Review local changes before syncing, or use `tela push` to stage and commit them.')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (warnings.length === 0) {
|
|
261
|
+
actions.push('Run `tela sync --current`, `tela run`, or `tela push` when you are ready.')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return Array.from(new Set(actions))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function createStatusReport(
|
|
268
|
+
cwd = process.cwd(),
|
|
269
|
+
fetchFn: typeof fetch = fetch,
|
|
270
|
+
): Promise<StatusReport> {
|
|
271
|
+
const [configInspection, gitAvailable, claudeAvailable, repo] = await Promise.all([
|
|
272
|
+
Promise.resolve(inspectSavedConfig()),
|
|
273
|
+
commandExists('git'),
|
|
274
|
+
commandExists('claude'),
|
|
275
|
+
inspectCurrentRepository(cwd),
|
|
276
|
+
])
|
|
277
|
+
|
|
278
|
+
const warnings: string[] = []
|
|
279
|
+
let reachability: AgentApiReachability | undefined
|
|
280
|
+
|
|
281
|
+
if (!configInspection.exists) {
|
|
282
|
+
warnings.push('Tela CLI is not configured yet.')
|
|
283
|
+
}
|
|
284
|
+
else if (!configInspection.valid) {
|
|
285
|
+
warnings.push(`Tela CLI config is invalid: ${configInspection.error ?? 'unknown error'}`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!gitAvailable) {
|
|
289
|
+
warnings.push('`git` is not available on PATH.')
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!claudeAvailable) {
|
|
293
|
+
warnings.push('`claude` is not available on PATH.')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (repo.detached) {
|
|
297
|
+
warnings.push('The current repository is on a detached HEAD.')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (repo.dirty) {
|
|
301
|
+
warnings.push('The current repository has local changes.')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (configInspection.valid && configInspection.config) {
|
|
305
|
+
reachability = await checkAgentApiReachability(configInspection.config.agentApiUrl, fetchFn)
|
|
306
|
+
const warning = getAgentApiOriginWarning(reachability)
|
|
307
|
+
if (warning) {
|
|
308
|
+
warnings.push(warning)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const nextActions = buildStatusNextActions(warnings, repo, Boolean(configInspection.valid))
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
ok: warnings.length === 0,
|
|
316
|
+
config: {
|
|
317
|
+
present: configInspection.exists,
|
|
318
|
+
valid: configInspection.valid,
|
|
319
|
+
path: configInspection.path,
|
|
320
|
+
agentApiUrl: configInspection.config?.agentApiUrl,
|
|
321
|
+
cloneRoot: configInspection.config?.cloneRoot,
|
|
322
|
+
apiKey: configInspection.config ? redactApiKey(configInspection.config.apiKey) : undefined,
|
|
323
|
+
error: configInspection.error,
|
|
324
|
+
},
|
|
325
|
+
repo,
|
|
326
|
+
tools: {
|
|
327
|
+
git: gitAvailable,
|
|
328
|
+
claude: claudeAvailable,
|
|
329
|
+
agentApiReachable: reachability?.reachable ?? null,
|
|
330
|
+
agentApiCategory: reachability?.category,
|
|
331
|
+
agentApiStatus: reachability?.statusCode,
|
|
332
|
+
agentApiDetail: reachability?.detail,
|
|
333
|
+
},
|
|
334
|
+
warnings,
|
|
335
|
+
nextActions,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function buildCloneRootCheck(cloneRoot: string | undefined): DoctorCheck {
|
|
340
|
+
if (!cloneRoot) {
|
|
341
|
+
return {
|
|
342
|
+
id: 'clone-root',
|
|
343
|
+
group: 'filesystem',
|
|
344
|
+
status: 'warn',
|
|
345
|
+
summary: 'Clone root is unavailable because config is missing or invalid.',
|
|
346
|
+
fixes: ['Run `tela setup` to configure a clone root.'],
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (existsSync(cloneRoot)) {
|
|
351
|
+
const stats = statSync(cloneRoot)
|
|
352
|
+
if (!stats.isDirectory()) {
|
|
353
|
+
return {
|
|
354
|
+
id: 'clone-root',
|
|
355
|
+
group: 'filesystem',
|
|
356
|
+
status: 'fail',
|
|
357
|
+
summary: `Clone root exists but is not a directory: ${cloneRoot}`,
|
|
358
|
+
fixes: [
|
|
359
|
+
`Move or remove \`${cloneRoot}\`, then rerun \`tela setup\`.`,
|
|
360
|
+
],
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
accessSync(cloneRoot, constants.R_OK | constants.W_OK | constants.X_OK)
|
|
366
|
+
return {
|
|
367
|
+
id: 'clone-root',
|
|
368
|
+
group: 'filesystem',
|
|
369
|
+
status: 'pass',
|
|
370
|
+
summary: `Clone root is ready: ${cloneRoot}`,
|
|
371
|
+
fixes: ['No action required.'],
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
return {
|
|
376
|
+
id: 'clone-root',
|
|
377
|
+
group: 'filesystem',
|
|
378
|
+
status: 'fail',
|
|
379
|
+
summary: `Clone root is not writable: ${cloneRoot}`,
|
|
380
|
+
fixes: [
|
|
381
|
+
`Fix permissions for \`${cloneRoot}\` or choose another clone root in \`tela setup\`.`,
|
|
382
|
+
],
|
|
383
|
+
evidence: [error instanceof Error ? error.message : String(error)],
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const parentDir = dirname(cloneRoot)
|
|
389
|
+
try {
|
|
390
|
+
accessSync(parentDir, constants.W_OK | constants.X_OK)
|
|
391
|
+
return {
|
|
392
|
+
id: 'clone-root',
|
|
393
|
+
group: 'filesystem',
|
|
394
|
+
status: 'warn',
|
|
395
|
+
summary: `Clone root does not exist yet: ${cloneRoot}`,
|
|
396
|
+
fixes: [
|
|
397
|
+
`Create it with \`mkdir -p ${cloneRoot}\` or rerun \`tela setup\`.`,
|
|
398
|
+
],
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
return {
|
|
403
|
+
id: 'clone-root',
|
|
404
|
+
group: 'filesystem',
|
|
405
|
+
status: 'fail',
|
|
406
|
+
summary: `Clone root parent is not writable: ${parentDir}`,
|
|
407
|
+
fixes: [
|
|
408
|
+
`Fix permissions for \`${parentDir}\` or choose another clone root in \`tela setup\`.`,
|
|
409
|
+
],
|
|
410
|
+
evidence: [error instanceof Error ? error.message : String(error)],
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function buildRepoChecks(repo: RepoSnapshot): DoctorCheck[] {
|
|
416
|
+
if (!repo.inRepository) {
|
|
417
|
+
return [
|
|
418
|
+
{
|
|
419
|
+
id: 'repo-context',
|
|
420
|
+
group: 'repository',
|
|
421
|
+
status: 'warn',
|
|
422
|
+
summary: 'Current directory is not inside a git repository.',
|
|
423
|
+
fixes: [
|
|
424
|
+
'Change into a Tela repository or run `tela sync <repo>` first.',
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
]
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const checks: DoctorCheck[] = [
|
|
431
|
+
{
|
|
432
|
+
id: 'repo-context',
|
|
433
|
+
group: 'repository',
|
|
434
|
+
status: 'pass',
|
|
435
|
+
summary: `Inside git repository: ${repo.repoRoot}`,
|
|
436
|
+
fixes: ['No action required.'],
|
|
437
|
+
},
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
checks.push({
|
|
441
|
+
id: 'repo-head',
|
|
442
|
+
group: 'repository',
|
|
443
|
+
status: repo.detached ? 'fail' : 'pass',
|
|
444
|
+
summary: repo.detached
|
|
445
|
+
? 'Repository is on a detached HEAD.'
|
|
446
|
+
: `Current branch is ${repo.branch}.`,
|
|
447
|
+
fixes: repo.detached
|
|
448
|
+
? ['Check out a branch before syncing or pushing.']
|
|
449
|
+
: ['No action required.'],
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
checks.push({
|
|
453
|
+
id: 'repo-worktree',
|
|
454
|
+
group: 'repository',
|
|
455
|
+
status: repo.dirty ? 'warn' : 'pass',
|
|
456
|
+
summary: repo.dirty
|
|
457
|
+
? 'Repository has local changes.'
|
|
458
|
+
: 'Repository worktree is clean.',
|
|
459
|
+
fixes: repo.dirty
|
|
460
|
+
? ['Review the local diff before syncing, or use `tela push` to stage and commit changes.']
|
|
461
|
+
: ['No action required.'],
|
|
462
|
+
evidence: repo.shortStatus ? [repo.shortStatus] : undefined,
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
checks.push({
|
|
466
|
+
id: 'repo-upstream',
|
|
467
|
+
group: 'repository',
|
|
468
|
+
status: repo.detached ? 'warn' : (repo.upstream ? 'pass' : 'warn'),
|
|
469
|
+
summary: repo.detached
|
|
470
|
+
? 'Upstream check skipped because HEAD is detached.'
|
|
471
|
+
: (repo.upstream
|
|
472
|
+
? `Upstream branch is ${repo.upstream}.`
|
|
473
|
+
: 'Current branch does not track an upstream.'),
|
|
474
|
+
fixes: repo.detached
|
|
475
|
+
? ['Check out a branch before configuring tracking.']
|
|
476
|
+
: (repo.upstream
|
|
477
|
+
? ['No action required.']
|
|
478
|
+
: ['Push the branch once or run `git branch --set-upstream-to origin/<branch>` after creating the remote branch.']),
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
checks.push({
|
|
482
|
+
id: 'repo-origin',
|
|
483
|
+
group: 'repository',
|
|
484
|
+
status: repo.originUrl ? 'pass' : 'warn',
|
|
485
|
+
summary: repo.originUrl
|
|
486
|
+
? `Origin remote: ${repo.originUrl}`
|
|
487
|
+
: 'Origin remote is missing.',
|
|
488
|
+
fixes: repo.originUrl
|
|
489
|
+
? ['No action required.']
|
|
490
|
+
: ['Add an origin remote or run `tela sync <repo>` to clone a fresh checkout.'],
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
return checks
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function buildNextActions(checks: DoctorCheck[]): string[] {
|
|
497
|
+
const failing = checks.filter(check => check.status !== 'pass')
|
|
498
|
+
if (failing.length === 0) {
|
|
499
|
+
return ['Environment looks healthy. Run `tela status`, `tela sync`, `tela run`, or `tela push` as needed.']
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return Array.from(new Set(failing.flatMap(check => check.fixes))).slice(0, 5)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export async function createDoctorReport(
|
|
506
|
+
cwd = process.cwd(),
|
|
507
|
+
fetchFn: typeof fetch = fetch,
|
|
508
|
+
): Promise<DoctorReport> {
|
|
509
|
+
const [configInspection, gitAvailable, claudeAvailable, repo] = await Promise.all([
|
|
510
|
+
Promise.resolve(inspectSavedConfig()),
|
|
511
|
+
commandExists('git'),
|
|
512
|
+
commandExists('claude'),
|
|
513
|
+
inspectCurrentRepository(cwd),
|
|
514
|
+
])
|
|
515
|
+
|
|
516
|
+
const checks: DoctorCheck[] = [
|
|
517
|
+
configInspection.exists
|
|
518
|
+
? (configInspection.valid
|
|
519
|
+
? {
|
|
520
|
+
id: 'config',
|
|
521
|
+
group: 'config',
|
|
522
|
+
status: 'pass',
|
|
523
|
+
summary: `Config loaded from ${configInspection.path}`,
|
|
524
|
+
fixes: ['No action required.'],
|
|
525
|
+
}
|
|
526
|
+
: {
|
|
527
|
+
id: 'config',
|
|
528
|
+
group: 'config',
|
|
529
|
+
status: 'fail',
|
|
530
|
+
summary: `Config file is invalid: ${configInspection.path}`,
|
|
531
|
+
fixes: ['Run `tela setup` to rewrite the config file.'],
|
|
532
|
+
evidence: configInspection.error ? [configInspection.error] : undefined,
|
|
533
|
+
})
|
|
534
|
+
: {
|
|
535
|
+
id: 'config',
|
|
536
|
+
group: 'config',
|
|
537
|
+
status: 'fail',
|
|
538
|
+
summary: 'Tela CLI config file is missing.',
|
|
539
|
+
fixes: ['Run `tela setup` to create the config file.'],
|
|
540
|
+
},
|
|
541
|
+
buildCloneRootCheck(configInspection.config?.cloneRoot),
|
|
542
|
+
{
|
|
543
|
+
id: 'tool-git',
|
|
544
|
+
group: 'tools',
|
|
545
|
+
status: gitAvailable ? 'pass' : 'fail',
|
|
546
|
+
summary: gitAvailable ? '`git` is available.' : '`git` is missing from PATH.',
|
|
547
|
+
fixes: gitAvailable ? ['No action required.'] : ['Install `git` and make sure it is available on PATH.'],
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
id: 'tool-claude',
|
|
551
|
+
group: 'tools',
|
|
552
|
+
status: claudeAvailable ? 'pass' : 'warn',
|
|
553
|
+
summary: claudeAvailable ? '`claude` is available.' : '`claude` is not available on PATH.',
|
|
554
|
+
fixes: claudeAvailable ? ['No action required.'] : ['Install the Claude CLI before using `tela run`, `tela push --claude`, or `tela feedback`.'],
|
|
555
|
+
},
|
|
556
|
+
...buildRepoChecks(repo),
|
|
557
|
+
]
|
|
558
|
+
|
|
559
|
+
if (configInspection.valid && configInspection.config) {
|
|
560
|
+
const reachability = await checkAgentApiReachability(configInspection.config.agentApiUrl, fetchFn)
|
|
561
|
+
checks.push({
|
|
562
|
+
id: 'agent-api-origin',
|
|
563
|
+
group: 'network',
|
|
564
|
+
status: reachability.reachable ? 'pass' : 'warn',
|
|
565
|
+
summary: getAgentApiOriginSummary(configInspection.config.agentApiUrl, reachability),
|
|
566
|
+
fixes: getAgentApiOriginFixes(reachability),
|
|
567
|
+
evidence: reachability.reachable ? undefined : [reachability.detail],
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const nextActions = buildNextActions(checks)
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
ok: checks.every(check => check.status === 'pass'),
|
|
575
|
+
checks,
|
|
576
|
+
nextActions,
|
|
577
|
+
}
|
|
578
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { CommandError } from './process.ts'
|
|
2
|
+
import { errorCard, type RenderOptions } from './ui.ts'
|
|
3
|
+
|
|
4
|
+
export interface UserFacingErrorInput {
|
|
5
|
+
code: string
|
|
6
|
+
summary: string
|
|
7
|
+
details?: string
|
|
8
|
+
fixes?: string[]
|
|
9
|
+
evidence?: string[]
|
|
10
|
+
exitCode?: number
|
|
11
|
+
cause?: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class UserFacingError extends Error {
|
|
15
|
+
public readonly code: string
|
|
16
|
+
public readonly summary: string
|
|
17
|
+
public readonly details?: string
|
|
18
|
+
public readonly fixes: string[]
|
|
19
|
+
public readonly evidence: string[]
|
|
20
|
+
public readonly exitCode: number
|
|
21
|
+
public readonly cause?: unknown
|
|
22
|
+
|
|
23
|
+
constructor(input: UserFacingErrorInput) {
|
|
24
|
+
super(input.summary)
|
|
25
|
+
this.name = 'UserFacingError'
|
|
26
|
+
this.code = input.code
|
|
27
|
+
this.summary = input.summary
|
|
28
|
+
this.details = input.details
|
|
29
|
+
this.fixes = input.fixes ?? []
|
|
30
|
+
this.evidence = input.evidence ?? []
|
|
31
|
+
this.exitCode = input.exitCode ?? 1
|
|
32
|
+
this.cause = input.cause
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createUserFacingError(input: UserFacingErrorInput): UserFacingError {
|
|
37
|
+
return new UserFacingError(input)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isUserFacingError(error: unknown): error is UserFacingError {
|
|
41
|
+
return error instanceof UserFacingError
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function toUserFacingError(error: unknown): UserFacingError {
|
|
45
|
+
if (isUserFacingError(error)) {
|
|
46
|
+
return error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (error instanceof CommandError) {
|
|
50
|
+
const stdout = error.result.stdout.trim()
|
|
51
|
+
const stderr = error.result.stderr.trim()
|
|
52
|
+
const evidence = [
|
|
53
|
+
`Command: ${error.result.command.join(' ')}`,
|
|
54
|
+
stdout ? `stdout: ${stdout}` : '',
|
|
55
|
+
stderr ? `stderr: ${stderr}` : '',
|
|
56
|
+
].filter(Boolean)
|
|
57
|
+
|
|
58
|
+
return new UserFacingError({
|
|
59
|
+
code: 'command_failed',
|
|
60
|
+
summary: 'A local command failed.',
|
|
61
|
+
details: error.message,
|
|
62
|
+
evidence,
|
|
63
|
+
exitCode: 1,
|
|
64
|
+
cause: error,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (error instanceof Error) {
|
|
69
|
+
return new UserFacingError({
|
|
70
|
+
code: 'unexpected_error',
|
|
71
|
+
summary: error.message,
|
|
72
|
+
exitCode: 1,
|
|
73
|
+
cause: error,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return new UserFacingError({
|
|
78
|
+
code: 'unexpected_error',
|
|
79
|
+
summary: String(error),
|
|
80
|
+
exitCode: 1,
|
|
81
|
+
cause: error,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function renderUserFacingError(
|
|
86
|
+
error: unknown,
|
|
87
|
+
options: RenderOptions & { verbose?: boolean } = {},
|
|
88
|
+
): string {
|
|
89
|
+
const userError = toUserFacingError(error)
|
|
90
|
+
const evidence = options.verbose ? userError.evidence : []
|
|
91
|
+
|
|
92
|
+
return errorCard({
|
|
93
|
+
summary: userError.summary,
|
|
94
|
+
details: userError.details,
|
|
95
|
+
fixes: userError.fixes,
|
|
96
|
+
evidence,
|
|
97
|
+
}, options)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function serializeError(error: unknown): {
|
|
101
|
+
ok: false
|
|
102
|
+
error: {
|
|
103
|
+
code: string
|
|
104
|
+
summary: string
|
|
105
|
+
details?: string
|
|
106
|
+
fixes: string[]
|
|
107
|
+
evidence: string[]
|
|
108
|
+
}
|
|
109
|
+
} {
|
|
110
|
+
const userError = toUserFacingError(error)
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: {
|
|
115
|
+
code: userError.code,
|
|
116
|
+
summary: userError.summary,
|
|
117
|
+
details: userError.details,
|
|
118
|
+
fixes: userError.fixes,
|
|
119
|
+
evidence: userError.evidence,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|