@meistrari/tela-cli 0.4.0 → 0.5.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.
@@ -1,7 +1,13 @@
1
1
  import { accessSync, constants, existsSync, statSync } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
3
  import { buildAgentApiHeaders } from './auth.ts'
4
+ import {
5
+ formatClaudeCliVersion,
6
+ inspectClaudeCli,
7
+ MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN,
8
+ } from './claude.ts'
4
9
  import { inspectSavedConfig, redactSecret } from './config.ts'
10
+ import { resolveSshPrivateKeyPath } from './forgejo.ts'
5
11
  import {
6
12
  findRepositoryRoot,
7
13
  getCurrentBranch,
@@ -57,6 +63,9 @@ export interface StatusReport {
57
63
  workspace?: string
58
64
  forgejoOrganization?: string
59
65
  forgejoUserRegistered?: boolean
66
+ gitSshKeyTitle?: string
67
+ gitSshPublicKeyPath?: string
68
+ gitSshPrivateKeyPath?: string
60
69
  accessToken?: string
61
70
  sessionExpiresAt?: number
62
71
  hasRefreshToken?: boolean
@@ -66,6 +75,9 @@ export interface StatusReport {
66
75
  tools: {
67
76
  git: boolean
68
77
  claude: boolean
78
+ claudeVersion?: string
79
+ claudeProjectScopedRunSupported?: boolean | null
80
+ claudeDetail?: string
69
81
  agentApiReachable: boolean | null
70
82
  agentApiCategory?: AgentApiReachabilityCategory
71
83
  agentApiStatus?: number
@@ -264,6 +276,8 @@ function buildStatusNextActions(
264
276
  hasAuth: boolean,
265
277
  hasForgejoOrganization: boolean,
266
278
  hasForgejoAccess: boolean,
279
+ hasGitSshKey: boolean,
280
+ claudeFix?: string,
267
281
  ): string[] {
268
282
  const actions: string[] = []
269
283
 
@@ -279,6 +293,9 @@ function buildStatusNextActions(
279
293
  else if (!hasForgejoAccess) {
280
294
  actions.push('Run `tela setup` again to provision Forgejo access with your SSH key.')
281
295
  }
296
+ else if (!hasGitSshKey) {
297
+ actions.push('Run `tela setup` again to choose the SSH key Tela should force on Git operations.')
298
+ }
282
299
 
283
300
  if (!repo.inRepository) {
284
301
  actions.push('Run `tela sync <repo>` or change into a cloned repository before mutating commands.')
@@ -292,6 +309,10 @@ function buildStatusNextActions(
292
309
  actions.push('Review local changes before syncing, or use `tela push` to stage and commit them.')
293
310
  }
294
311
 
312
+ if (claudeFix) {
313
+ actions.push(claudeFix)
314
+ }
315
+
295
316
  if (warnings.length === 0) {
296
317
  actions.push('Run `tela sync --current`, `tela run`, or `tela push` when you are ready.')
297
318
  }
@@ -303,15 +324,16 @@ export async function createStatusReport(
303
324
  cwd = process.cwd(),
304
325
  fetchFn: typeof fetch = fetch,
305
326
  ): Promise<StatusReport> {
306
- const [configInspection, gitAvailable, claudeAvailable, repo] = await Promise.all([
327
+ const [configInspection, gitAvailable, claudeInspection, repo] = await Promise.all([
307
328
  Promise.resolve(inspectSavedConfig()),
308
329
  commandExists('git'),
309
- commandExists('claude'),
330
+ inspectClaudeCli(),
310
331
  inspectCurrentRepository(cwd),
311
332
  ])
312
333
 
313
334
  const warnings: string[] = []
314
335
  let reachability: AgentApiReachability | undefined
336
+ let claudeFix: string | undefined
315
337
 
316
338
  if (!configInspection.exists) {
317
339
  warnings.push('Tela CLI is not configured yet.')
@@ -329,14 +351,35 @@ export async function createStatusReport(
329
351
  if (!configInspection.config.forgejoUserRegistered) {
330
352
  warnings.push('Forgejo user access is not provisioned yet.')
331
353
  }
354
+ if (!configInspection.config.gitSshKey) {
355
+ warnings.push('Git SSH key selection is missing.')
356
+ }
357
+ else {
358
+ try {
359
+ resolveSshPrivateKeyPath(configInspection.config.gitSshKey.privateKeyPath)
360
+ }
361
+ catch (error) {
362
+ warnings.push(`Configured Git SSH private key is unavailable: ${error instanceof Error ? error.message : String(error)}`)
363
+ }
364
+ }
332
365
  }
333
366
 
334
367
  if (!gitAvailable) {
335
368
  warnings.push('`git` is not available on PATH.')
336
369
  }
337
370
 
338
- if (!claudeAvailable) {
371
+ if (!claudeInspection.available) {
339
372
  warnings.push('`claude` is not available on PATH.')
373
+ claudeFix = 'Install the Claude CLI before using `tela run`, `tela push --claude`, or `tela feedback`.'
374
+ }
375
+ else if (!claudeInspection.supportsProjectScopedRun) {
376
+ const requiredVersion = formatClaudeCliVersion(MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN)
377
+ warnings.push(
378
+ claudeInspection.problem === 'unparseable'
379
+ ? `Claude CLI version could not be determined for project-scoped runs: ${claudeInspection.detail ?? 'unknown output'}`
380
+ : `Claude CLI must be updated to ${requiredVersion} or newer for project-scoped \`tela run\`.`,
381
+ )
382
+ claudeFix = `Update Claude CLI to version ${requiredVersion} or newer before using \`tela run\`.`
340
383
  }
341
384
 
342
385
  if (repo.detached) {
@@ -362,6 +405,8 @@ export async function createStatusReport(
362
405
  Boolean(configInspection.config?.auth),
363
406
  Boolean(configInspection.config?.forgejoOrganization),
364
407
  Boolean(configInspection.config?.forgejoUserRegistered),
408
+ Boolean(configInspection.config?.gitSshKey),
409
+ claudeFix,
365
410
  )
366
411
 
367
412
  return {
@@ -379,6 +424,9 @@ export async function createStatusReport(
379
424
  workspace: configInspection.config?.auth?.workspace.title,
380
425
  forgejoOrganization: configInspection.config?.forgejoOrganization?.name,
381
426
  forgejoUserRegistered: configInspection.config?.forgejoUserRegistered,
427
+ gitSshKeyTitle: configInspection.config?.gitSshKey?.title,
428
+ gitSshPublicKeyPath: configInspection.config?.gitSshKey?.publicKeyPath,
429
+ gitSshPrivateKeyPath: configInspection.config?.gitSshKey?.privateKeyPath,
382
430
  accessToken: configInspection.config?.auth ? redactSecret(configInspection.config.auth.session.accessToken) : undefined,
383
431
  sessionExpiresAt: configInspection.config?.auth?.session.expiresAt,
384
432
  hasRefreshToken: Boolean(configInspection.config?.auth?.session.refreshToken),
@@ -387,7 +435,12 @@ export async function createStatusReport(
387
435
  repo,
388
436
  tools: {
389
437
  git: gitAvailable,
390
- claude: claudeAvailable,
438
+ claude: claudeInspection.available,
439
+ claudeVersion: claudeInspection.version,
440
+ claudeProjectScopedRunSupported: claudeInspection.available
441
+ ? claudeInspection.supportsProjectScopedRun
442
+ : null,
443
+ claudeDetail: claudeInspection.detail,
391
444
  agentApiReachable: reachability?.reachable ?? null,
392
445
  agentApiCategory: reachability?.category,
393
446
  agentApiStatus: reachability?.statusCode,
@@ -474,6 +527,47 @@ function buildCloneRootCheck(cloneRoot: string | undefined): DoctorCheck {
474
527
  }
475
528
  }
476
529
 
530
+ function buildGitSshKeyCheck(gitSshKey: {
531
+ title?: string
532
+ publicKeyPath?: string
533
+ privateKeyPath?: string
534
+ }): DoctorCheck {
535
+ if (!gitSshKey.privateKeyPath) {
536
+ return {
537
+ id: 'git-ssh-key',
538
+ group: 'access',
539
+ status: 'warn',
540
+ summary: 'No Git SSH key is configured.',
541
+ fixes: ['Run `tela setup` again and choose the SSH key to force on Git operations.'],
542
+ }
543
+ }
544
+
545
+ try {
546
+ const privateKeyPath = resolveSshPrivateKeyPath(gitSshKey.privateKeyPath)
547
+ return {
548
+ id: 'git-ssh-key',
549
+ group: 'access',
550
+ status: 'pass',
551
+ summary: `Git SSH key is configured: ${gitSshKey.title ?? '<unnamed>'}.`,
552
+ fixes: ['No action required.'],
553
+ evidence: [
554
+ gitSshKey.publicKeyPath ? `public key: ${gitSshKey.publicKeyPath}` : '',
555
+ `private key: ${privateKeyPath}`,
556
+ ].filter(Boolean),
557
+ }
558
+ }
559
+ catch (error) {
560
+ return {
561
+ id: 'git-ssh-key',
562
+ group: 'access',
563
+ status: 'fail',
564
+ summary: 'Configured Git SSH private key is unavailable.',
565
+ fixes: ['Restore the private key file or run `tela setup` again and choose another SSH key.'],
566
+ evidence: [error instanceof Error ? error.message : String(error)],
567
+ }
568
+ }
569
+ }
570
+
477
571
  function buildRepoChecks(repo: RepoSnapshot): DoctorCheck[] {
478
572
  if (!repo.inRepository) {
479
573
  return [
@@ -568,10 +662,10 @@ export async function createDoctorReport(
568
662
  cwd = process.cwd(),
569
663
  fetchFn: typeof fetch = fetch,
570
664
  ): Promise<DoctorReport> {
571
- const [configInspection, gitAvailable, claudeAvailable, repo] = await Promise.all([
665
+ const [configInspection, gitAvailable, claudeInspection, repo] = await Promise.all([
572
666
  Promise.resolve(inspectSavedConfig()),
573
667
  commandExists('git'),
574
- commandExists('claude'),
668
+ inspectClaudeCli(),
575
669
  inspectCurrentRepository(cwd),
576
670
  ])
577
671
 
@@ -611,9 +705,22 @@ export async function createDoctorReport(
611
705
  {
612
706
  id: 'tool-claude',
613
707
  group: 'tools',
614
- status: claudeAvailable ? 'pass' : 'warn',
615
- summary: claudeAvailable ? '`claude` is available.' : '`claude` is not available on PATH.',
616
- fixes: claudeAvailable ? ['No action required.'] : ['Install the Claude CLI before using `tela run`, `tela push --claude`, or `tela feedback`.'],
708
+ status: !claudeInspection.available
709
+ ? 'warn'
710
+ : (claudeInspection.supportsProjectScopedRun ? 'pass' : 'fail'),
711
+ summary: !claudeInspection.available
712
+ ? '`claude` is not available on PATH.'
713
+ : (claudeInspection.supportsProjectScopedRun
714
+ ? `Claude CLI is available (${claudeInspection.version ?? 'unknown version'}) and supports project-scoped runs.`
715
+ : (claudeInspection.problem === 'unparseable'
716
+ ? 'Claude CLI version could not be determined for project-scoped runs.'
717
+ : `Claude CLI ${claudeInspection.version ?? '<unknown>'} is too old for project-scoped runs.`)),
718
+ fixes: !claudeInspection.available
719
+ ? ['Install the Claude CLI before using `tela run`, `tela push --claude`, or `tela feedback`.']
720
+ : (claudeInspection.supportsProjectScopedRun
721
+ ? ['No action required.']
722
+ : [`Update Claude CLI to version ${formatClaudeCliVersion(MIN_CLAUDE_VERSION_FOR_PROJECT_SCOPED_RUN)} or newer before using \`tela run\`.`]),
723
+ evidence: claudeInspection.available && claudeInspection.detail ? [claudeInspection.detail] : undefined,
617
724
  },
618
725
  ...buildRepoChecks(repo),
619
726
  ]
@@ -688,6 +795,12 @@ export async function createDoctorReport(
688
795
  ? ['No action required.']
689
796
  : ['Run `tela setup` again and complete SSH-key provisioning for Forgejo access.'],
690
797
  })
798
+
799
+ checks.push(buildGitSshKeyCheck({
800
+ title: configInspection.config.gitSshKey?.title,
801
+ publicKeyPath: configInspection.config.gitSshKey?.publicKeyPath,
802
+ privateKeyPath: configInspection.config.gitSshKey?.privateKeyPath,
803
+ }))
691
804
  }
692
805
 
693
806
  const nextActions = buildNextActions(checks)
package/src/forgejo.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  ForgejoUserCreateResponse,
9
9
  ForgejoUserSshKeyInput,
10
10
  TelaCliConfig,
11
+ TelaCliGitSshKey,
11
12
  TelaCliForgejoOrganization,
12
13
  } from './types.ts'
13
14
 
@@ -95,6 +96,49 @@ export function validateSshPublicKeyPath(value: string): string | undefined {
95
96
  }
96
97
  }
97
98
 
99
+ export function inferSshPrivateKeyPath(publicKeyPath: string): string {
100
+ const expandedPath = expandHome(publicKeyPath.trim())
101
+ return expandedPath.endsWith('.pub')
102
+ ? expandedPath.slice(0, -4)
103
+ : expandedPath
104
+ }
105
+
106
+ export function resolveSshPrivateKeyPath(filePath: string): string {
107
+ const expandedPath = expandHome(filePath.trim())
108
+ if (!expandedPath) {
109
+ throw new Error('SSH private key path is required')
110
+ }
111
+
112
+ if (!existsSync(expandedPath)) {
113
+ throw new Error(`SSH private key file does not exist: ${expandedPath}`)
114
+ }
115
+
116
+ const stats = statSync(expandedPath)
117
+ if (!stats.isFile()) {
118
+ throw new Error(`SSH private key path is not a file: ${expandedPath}`)
119
+ }
120
+
121
+ return expandedPath
122
+ }
123
+
124
+ export function validateSshPrivateKeyPath(value: string): string | undefined {
125
+ try {
126
+ resolveSshPrivateKeyPath(value)
127
+ return undefined
128
+ }
129
+ catch (error) {
130
+ return error instanceof Error ? error.message : 'SSH private key path is invalid'
131
+ }
132
+ }
133
+
134
+ export function createGitSshKey(sshKey: SelectedSshPublicKey, privateKeyPath: string): TelaCliGitSshKey {
135
+ return {
136
+ title: sshKey.title,
137
+ publicKeyPath: sshKey.path,
138
+ privateKeyPath: resolveSshPrivateKeyPath(privateKeyPath),
139
+ }
140
+ }
141
+
98
142
  export function listDiscoveredSshPublicKeys(): SelectedSshPublicKey[] {
99
143
  const sshDirectory = getSshDirectory()
100
144
  if (!existsSync(sshDirectory)) {