@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.
@@ -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
+ }