@meistrari/tela-cli 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tela": "cli.ts"
@@ -31,6 +31,9 @@ export function renderStatusReport(report: StatusReport, options: RenderOptions
31
31
  { label: 'Workspace', value: report.config.workspace ?? '<signed out>' },
32
32
  { label: 'Organization', value: report.config.forgejoOrganization ?? '<not selected>' },
33
33
  { label: 'Forgejo user', value: report.config.forgejoUserRegistered == null ? '<unknown>' : (report.config.forgejoUserRegistered ? 'registered' : 'pending') },
34
+ { label: 'Git SSH key', value: report.config.gitSshKeyTitle ?? '<missing>' },
35
+ { label: 'Git SSH public key', value: report.config.gitSshPublicKeyPath ?? '<missing>' },
36
+ { label: 'Git SSH private key', value: report.config.gitSshPrivateKeyPath ?? '<missing>' },
34
37
  { label: 'Access token', value: report.config.accessToken ?? '<missing>' },
35
38
  { label: 'Session expiry', value: report.config.sessionExpiresAt ? new Date(report.config.sessionExpiresAt).toISOString() : '<missing>' },
36
39
  { label: 'Refresh token', value: report.config.hasRefreshToken ? 'present' : 'missing' },
package/src/config.ts CHANGED
@@ -5,9 +5,13 @@ import { listOrganizations, provisionForgejoUser } from './agent-api.ts'
5
5
  import type { FetchLike } from './auth.ts'
6
6
  import { createUserFacingError } from './errors.ts'
7
7
  import {
8
+ createGitSshKey,
8
9
  ensureForgejoUserAccess,
10
+ inferSshPrivateKeyPath,
9
11
  listDiscoveredSshPublicKeys,
10
12
  loadSshPublicKey,
13
+ resolveSshPrivateKeyPath,
14
+ validateSshPrivateKeyPath,
11
15
  validateSshPublicKeyPath,
12
16
  type SelectedSshPublicKey,
13
17
  } from './forgejo.ts'
@@ -15,6 +19,7 @@ import type {
15
19
  TelaCliAuthState,
16
20
  TelaCliConfig,
17
21
  TelaCliForgejoOrganization,
22
+ TelaCliGitSshKey,
18
23
  TelaEnvironment,
19
24
  } from './types.ts'
20
25
 
@@ -213,6 +218,18 @@ function normalizeForgejoOrganization(
213
218
  }
214
219
  }
215
220
 
221
+ function normalizeGitSshKey(gitSshKey: TelaCliConfig['gitSshKey']): TelaCliGitSshKey | null {
222
+ if (!gitSshKey) {
223
+ return null
224
+ }
225
+
226
+ return {
227
+ title: ensureNonEmpty(String(gitSshKey.title ?? ''), 'Git SSH key title'),
228
+ publicKeyPath: expandHome(ensureNonEmpty(String(gitSshKey.publicKeyPath ?? ''), 'Git SSH public key path')),
229
+ privateKeyPath: expandHome(ensureNonEmpty(String(gitSshKey.privateKeyPath ?? ''), 'Git SSH private key path')),
230
+ }
231
+ }
232
+
216
233
  function normalizeConfig(config: TelaCliConfig): TelaCliConfig {
217
234
  const environment = ensureEnvironment(config.environment)
218
235
  const auth = normalizeAuth(config.auth)
@@ -229,6 +246,7 @@ function normalizeConfig(config: TelaCliConfig): TelaCliConfig {
229
246
  auth,
230
247
  forgejoOrganization: auth ? normalizeForgejoOrganization(config.forgejoOrganization) : null,
231
248
  forgejoUserRegistered: Boolean(config.forgejoUserRegistered),
249
+ gitSshKey: normalizeGitSshKey(config.gitSshKey),
232
250
  }
233
251
  }
234
252
 
@@ -257,6 +275,7 @@ function parseJsonConfig(input: string): TelaCliConfig {
257
275
  auth,
258
276
  forgejoOrganization: parsed.forgejoOrganization as TelaCliConfig['forgejoOrganization'] ?? null,
259
277
  forgejoUserRegistered: Boolean(parsed.forgejoUserRegistered ?? false),
278
+ gitSshKey: parsed.gitSshKey as TelaCliConfig['gitSshKey'] ?? null,
260
279
  })
261
280
  }
262
281
 
@@ -317,6 +336,25 @@ async function promptForSshPublicKey(prompts: PromptModule): Promise<SelectedSsh
317
336
  return loadSshPublicKey(promptValue(customPath))
318
337
  }
319
338
 
339
+ async function promptForSshPrivateKeyPath(
340
+ prompts: PromptModule,
341
+ sshKey: SelectedSshPublicKey,
342
+ ): Promise<string> {
343
+ const privateKeyPath = await prompts.text({
344
+ message: 'SSH private key path',
345
+ defaultValue: inferSshPrivateKeyPath(sshKey.path),
346
+ placeholder: sshKey.path.endsWith('.pub')
347
+ ? sshKey.path.slice(0, -4)
348
+ : '~/.ssh/id_ed25519',
349
+ validate: validateSshPrivateKeyPath,
350
+ })
351
+ if (prompts.isCancel(privateKeyPath)) {
352
+ cancelSetup(prompts)
353
+ }
354
+
355
+ return resolveSshPrivateKeyPath(promptValue(privateKeyPath))
356
+ }
357
+
320
358
  function getHomeDir(): string {
321
359
  return process.env.HOME || homedir()
322
360
  }
@@ -700,6 +738,7 @@ export async function setupConfigInteractive(options: {
700
738
  }
701
739
 
702
740
  const sshKey = await promptForSshPublicKey(prompts)
741
+ const privateKeyPath = await promptForSshPrivateKeyPath(prompts, sshKey)
703
742
 
704
743
  const resolvedSetup = {
705
744
  ...resolveEnvironmentConfigForPreset(environment, customValues),
@@ -718,6 +757,7 @@ export async function setupConfigInteractive(options: {
718
757
  auth,
719
758
  forgejoOrganization: null,
720
759
  forgejoUserRegistered: false,
760
+ gitSshKey: createGitSshKey(sshKey, privateKeyPath),
721
761
  })
722
762
  const organization = await resolveForgejoOrganizationInteractive({
723
763
  prompts,
@@ -2,6 +2,7 @@ import { accessSync, constants, existsSync, statSync } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
3
  import { buildAgentApiHeaders } from './auth.ts'
4
4
  import { inspectSavedConfig, redactSecret } from './config.ts'
5
+ import { resolveSshPrivateKeyPath } from './forgejo.ts'
5
6
  import {
6
7
  findRepositoryRoot,
7
8
  getCurrentBranch,
@@ -57,6 +58,9 @@ export interface StatusReport {
57
58
  workspace?: string
58
59
  forgejoOrganization?: string
59
60
  forgejoUserRegistered?: boolean
61
+ gitSshKeyTitle?: string
62
+ gitSshPublicKeyPath?: string
63
+ gitSshPrivateKeyPath?: string
60
64
  accessToken?: string
61
65
  sessionExpiresAt?: number
62
66
  hasRefreshToken?: boolean
@@ -264,6 +268,7 @@ function buildStatusNextActions(
264
268
  hasAuth: boolean,
265
269
  hasForgejoOrganization: boolean,
266
270
  hasForgejoAccess: boolean,
271
+ hasGitSshKey: boolean,
267
272
  ): string[] {
268
273
  const actions: string[] = []
269
274
 
@@ -279,6 +284,9 @@ function buildStatusNextActions(
279
284
  else if (!hasForgejoAccess) {
280
285
  actions.push('Run `tela setup` again to provision Forgejo access with your SSH key.')
281
286
  }
287
+ else if (!hasGitSshKey) {
288
+ actions.push('Run `tela setup` again to choose the SSH key Tela should force on Git operations.')
289
+ }
282
290
 
283
291
  if (!repo.inRepository) {
284
292
  actions.push('Run `tela sync <repo>` or change into a cloned repository before mutating commands.')
@@ -329,6 +337,17 @@ export async function createStatusReport(
329
337
  if (!configInspection.config.forgejoUserRegistered) {
330
338
  warnings.push('Forgejo user access is not provisioned yet.')
331
339
  }
340
+ if (!configInspection.config.gitSshKey) {
341
+ warnings.push('Git SSH key selection is missing.')
342
+ }
343
+ else {
344
+ try {
345
+ resolveSshPrivateKeyPath(configInspection.config.gitSshKey.privateKeyPath)
346
+ }
347
+ catch (error) {
348
+ warnings.push(`Configured Git SSH private key is unavailable: ${error instanceof Error ? error.message : String(error)}`)
349
+ }
350
+ }
332
351
  }
333
352
 
334
353
  if (!gitAvailable) {
@@ -362,6 +381,7 @@ export async function createStatusReport(
362
381
  Boolean(configInspection.config?.auth),
363
382
  Boolean(configInspection.config?.forgejoOrganization),
364
383
  Boolean(configInspection.config?.forgejoUserRegistered),
384
+ Boolean(configInspection.config?.gitSshKey),
365
385
  )
366
386
 
367
387
  return {
@@ -379,6 +399,9 @@ export async function createStatusReport(
379
399
  workspace: configInspection.config?.auth?.workspace.title,
380
400
  forgejoOrganization: configInspection.config?.forgejoOrganization?.name,
381
401
  forgejoUserRegistered: configInspection.config?.forgejoUserRegistered,
402
+ gitSshKeyTitle: configInspection.config?.gitSshKey?.title,
403
+ gitSshPublicKeyPath: configInspection.config?.gitSshKey?.publicKeyPath,
404
+ gitSshPrivateKeyPath: configInspection.config?.gitSshKey?.privateKeyPath,
382
405
  accessToken: configInspection.config?.auth ? redactSecret(configInspection.config.auth.session.accessToken) : undefined,
383
406
  sessionExpiresAt: configInspection.config?.auth?.session.expiresAt,
384
407
  hasRefreshToken: Boolean(configInspection.config?.auth?.session.refreshToken),
@@ -474,6 +497,47 @@ function buildCloneRootCheck(cloneRoot: string | undefined): DoctorCheck {
474
497
  }
475
498
  }
476
499
 
500
+ function buildGitSshKeyCheck(gitSshKey: {
501
+ title?: string
502
+ publicKeyPath?: string
503
+ privateKeyPath?: string
504
+ }): DoctorCheck {
505
+ if (!gitSshKey.privateKeyPath) {
506
+ return {
507
+ id: 'git-ssh-key',
508
+ group: 'access',
509
+ status: 'warn',
510
+ summary: 'No Git SSH key is configured.',
511
+ fixes: ['Run `tela setup` again and choose the SSH key to force on Git operations.'],
512
+ }
513
+ }
514
+
515
+ try {
516
+ const privateKeyPath = resolveSshPrivateKeyPath(gitSshKey.privateKeyPath)
517
+ return {
518
+ id: 'git-ssh-key',
519
+ group: 'access',
520
+ status: 'pass',
521
+ summary: `Git SSH key is configured: ${gitSshKey.title ?? '<unnamed>'}.`,
522
+ fixes: ['No action required.'],
523
+ evidence: [
524
+ gitSshKey.publicKeyPath ? `public key: ${gitSshKey.publicKeyPath}` : '',
525
+ `private key: ${privateKeyPath}`,
526
+ ].filter(Boolean),
527
+ }
528
+ }
529
+ catch (error) {
530
+ return {
531
+ id: 'git-ssh-key',
532
+ group: 'access',
533
+ status: 'fail',
534
+ summary: 'Configured Git SSH private key is unavailable.',
535
+ fixes: ['Restore the private key file or run `tela setup` again and choose another SSH key.'],
536
+ evidence: [error instanceof Error ? error.message : String(error)],
537
+ }
538
+ }
539
+ }
540
+
477
541
  function buildRepoChecks(repo: RepoSnapshot): DoctorCheck[] {
478
542
  if (!repo.inRepository) {
479
543
  return [
@@ -688,6 +752,12 @@ export async function createDoctorReport(
688
752
  ? ['No action required.']
689
753
  : ['Run `tela setup` again and complete SSH-key provisioning for Forgejo access.'],
690
754
  })
755
+
756
+ checks.push(buildGitSshKeyCheck({
757
+ title: configInspection.config.gitSshKey?.title,
758
+ publicKeyPath: configInspection.config.gitSshKey?.publicKeyPath,
759
+ privateKeyPath: configInspection.config.gitSshKey?.privateKeyPath,
760
+ }))
691
761
  }
692
762
 
693
763
  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)) {
package/src/git.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { basename } from 'node:path'
2
- import { randomUUID } from 'node:crypto'
3
- import { CommandError, runCommand, runShell } from './process.ts'
2
+ import { inspectSavedConfig } from './config.ts'
3
+ import { createUserFacingError } from './errors.ts'
4
+ import { resolveSshPrivateKeyPath } from './forgejo.ts'
5
+ import {
6
+ CommandError,
7
+ runCommand,
8
+ type CommandResult,
9
+ type RunCommandOptions,
10
+ } from './process.ts'
11
+ import type { TelaCliGitSshKey } from './types.ts'
4
12
 
5
13
  export interface GitStatusEntry {
6
14
  index: string
@@ -15,8 +23,167 @@ export interface GitStatusCounts {
15
23
  total: number
16
24
  }
17
25
 
26
+ interface GitRunOptions extends Omit<RunCommandOptions, 'env'> {
27
+ requireConfiguredSsh?: boolean
28
+ }
29
+
30
+ function quoteForShell(value: string): string {
31
+ return `'${value.replaceAll('\'', `'"'"'`)}'`
32
+ }
33
+
34
+ export function buildGitSshCommand(privateKeyPath: string): string {
35
+ return `ssh -i ${quoteForShell(privateKeyPath)} -o IdentitiesOnly=yes`
36
+ }
37
+
38
+ export function isSshRemoteUrl(remoteUrl: string): boolean {
39
+ const trimmed = remoteUrl.trim()
40
+ return /^ssh:\/\//i.test(trimmed) || /^[^\s@]+@[^\s:]+:.+$/.test(trimmed)
41
+ }
42
+
43
+ function resolveConfiguredGitSshKey(requireConfiguredSsh: boolean): TelaCliGitSshKey | null {
44
+ const inspection = inspectSavedConfig()
45
+ if (!inspection.exists) {
46
+ if (requireConfiguredSsh) {
47
+ throw createUserFacingError({
48
+ code: 'git_ssh_key_config_missing',
49
+ summary: 'Tela CLI is not configured with a Git SSH key.',
50
+ fixes: [
51
+ 'Run `tela setup` and choose the SSH key to force on Git operations.',
52
+ ],
53
+ })
54
+ }
55
+
56
+ return null
57
+ }
58
+
59
+ if (!inspection.valid || !inspection.config) {
60
+ if (requireConfiguredSsh) {
61
+ throw createUserFacingError({
62
+ code: 'git_ssh_key_config_invalid',
63
+ summary: 'Tela CLI config is invalid, so the Git SSH key could not be loaded.',
64
+ details: inspection.error,
65
+ fixes: [
66
+ 'Run `tela setup` to rewrite the config and choose the SSH key again.',
67
+ ],
68
+ })
69
+ }
70
+
71
+ return null
72
+ }
73
+
74
+ if (!inspection.config.gitSshKey) {
75
+ if (requireConfiguredSsh) {
76
+ throw createUserFacingError({
77
+ code: 'git_ssh_key_missing',
78
+ summary: 'No Git SSH key is configured.',
79
+ fixes: [
80
+ 'Run `tela setup` again and choose the SSH key to use for Git operations.',
81
+ ],
82
+ })
83
+ }
84
+
85
+ return null
86
+ }
87
+
88
+ try {
89
+ return {
90
+ ...inspection.config.gitSshKey,
91
+ privateKeyPath: resolveSshPrivateKeyPath(inspection.config.gitSshKey.privateKeyPath),
92
+ }
93
+ }
94
+ catch (error) {
95
+ if (requireConfiguredSsh) {
96
+ throw createUserFacingError({
97
+ code: 'git_ssh_key_invalid',
98
+ summary: 'The configured Git SSH private key is unavailable.',
99
+ details: error instanceof Error ? error.message : String(error),
100
+ fixes: [
101
+ 'Restore the configured private key file, or run `tela setup` again and choose another SSH key.',
102
+ ],
103
+ cause: error,
104
+ })
105
+ }
106
+
107
+ return null
108
+ }
109
+ }
110
+
111
+ function buildGitEnv(requireConfiguredSsh: boolean): Record<string, string> | undefined {
112
+ const gitSshKey = resolveConfiguredGitSshKey(requireConfiguredSsh)
113
+ if (!gitSshKey) {
114
+ return undefined
115
+ }
116
+
117
+ return {
118
+ GIT_SSH_COMMAND: buildGitSshCommand(gitSshKey.privateKeyPath),
119
+ }
120
+ }
121
+
122
+ async function runGitCommand(
123
+ args: string[],
124
+ options: GitRunOptions = {},
125
+ ): Promise<CommandResult> {
126
+ const env = buildGitEnv(Boolean(options.requireConfiguredSsh))
127
+
128
+ return await runCommand(
129
+ ['git', ...args],
130
+ {
131
+ ...options,
132
+ env: env ? env : undefined,
133
+ },
134
+ )
135
+ }
136
+
137
+ async function getRemoteUrlInternal(cwd: string, remote: string): Promise<string | null> {
138
+ const result = await runGitCommand(['remote', 'get-url', remote], { cwd, allowFailure: true })
139
+ if (result.exitCode !== 0) {
140
+ return null
141
+ }
142
+
143
+ return result.stdout.trim() || null
144
+ }
145
+
146
+ async function remoteRequiresConfiguredSsh(cwd: string, remote: string): Promise<boolean> {
147
+ const remoteUrl = await getRemoteUrlInternal(cwd, remote)
148
+ return Boolean(remoteUrl && isSshRemoteUrl(remoteUrl))
149
+ }
150
+
151
+ async function anyRemoteRequiresConfiguredSsh(cwd: string): Promise<boolean> {
152
+ const result = await runGitCommand(['remote'], { cwd, allowFailure: true })
153
+ if (result.exitCode !== 0) {
154
+ return false
155
+ }
156
+
157
+ const remotes = result.stdout
158
+ .split(/\r?\n/)
159
+ .map(line => line.trim())
160
+ .filter(Boolean)
161
+
162
+ for (const remote of remotes) {
163
+ if (await remoteRequiresConfiguredSsh(cwd, remote)) {
164
+ return true
165
+ }
166
+ }
167
+
168
+ return false
169
+ }
170
+
171
+ async function upstreamRequiresConfiguredSsh(cwd: string): Promise<boolean> {
172
+ const upstream = await getUpstreamBranch(cwd)
173
+ if (!upstream) {
174
+ return false
175
+ }
176
+
177
+ const separator = upstream.indexOf('/')
178
+ if (separator <= 0) {
179
+ return false
180
+ }
181
+
182
+ return await remoteRequiresConfiguredSsh(cwd, upstream.slice(0, separator))
183
+ }
184
+
18
185
  export async function findRepositoryRoot(cwd: string): Promise<string | null> {
19
- const result = await runCommand(['git', 'rev-parse', '--show-toplevel'], { cwd, allowFailure: true })
186
+ const result = await runGitCommand(['rev-parse', '--show-toplevel'], { cwd, allowFailure: true })
20
187
  if (result.exitCode !== 0) {
21
188
  return null
22
189
  }
@@ -35,7 +202,7 @@ export async function getRepositoryRoot(cwd: string): Promise<string> {
35
202
 
36
203
  export async function assertGitRepository(cwd: string): Promise<void> {
37
204
  try {
38
- await runCommand(['git', 'rev-parse', '--is-inside-work-tree'], { cwd })
205
+ await runGitCommand(['rev-parse', '--is-inside-work-tree'], { cwd })
39
206
  }
40
207
  catch (error) {
41
208
  if (error instanceof CommandError) {
@@ -46,24 +213,32 @@ export async function assertGitRepository(cwd: string): Promise<void> {
46
213
  }
47
214
 
48
215
  export async function cloneRepository(remoteUrl: string, targetDir: string): Promise<void> {
49
- await runCommand(['git', 'clone', remoteUrl, targetDir])
216
+ await runGitCommand(['clone', remoteUrl, targetDir], {
217
+ requireConfiguredSsh: isSshRemoteUrl(remoteUrl),
218
+ })
50
219
  }
51
220
 
52
221
  export async function getOriginUrl(cwd: string): Promise<string> {
53
- const result = await runCommand(['git', 'remote', 'get-url', 'origin'], { cwd })
222
+ const result = await runGitCommand(['remote', 'get-url', 'origin'], { cwd })
54
223
  return result.stdout.trim()
55
224
  }
56
225
 
57
226
  export async function fetchOrigin(cwd: string): Promise<void> {
58
- await runCommand(['git', 'fetch', 'origin', '--prune'], { cwd })
227
+ await runGitCommand(['fetch', 'origin', '--prune'], {
228
+ cwd,
229
+ requireConfiguredSsh: await remoteRequiresConfiguredSsh(cwd, 'origin'),
230
+ })
59
231
  }
60
232
 
61
233
  export async function fetchAllRemotes(cwd: string): Promise<void> {
62
- await runCommand(['git', 'fetch', '--all', '--prune'], { cwd })
234
+ await runGitCommand(['fetch', '--all', '--prune'], {
235
+ cwd,
236
+ requireConfiguredSsh: await anyRemoteRequiresConfiguredSsh(cwd),
237
+ })
63
238
  }
64
239
 
65
240
  export async function getWorktreeStatus(cwd: string): Promise<string> {
66
- const result = await runCommand(['git', 'status', '--short'], { cwd })
241
+ const result = await runGitCommand(['status', '--short'], { cwd })
67
242
  return result.stdout.trim()
68
243
  }
69
244
 
@@ -72,12 +247,12 @@ export async function isWorktreeDirty(cwd: string): Promise<boolean> {
72
247
  }
73
248
 
74
249
  export async function stageAll(cwd: string): Promise<void> {
75
- await runCommand(['git', 'add', '-A'], { cwd })
250
+ await runGitCommand(['add', '-A'], { cwd })
76
251
  }
77
252
 
78
253
  export async function getCurrentBranch(cwd: string): Promise<string> {
79
254
  try {
80
- const result = await runCommand(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd })
255
+ const result = await runGitCommand(['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd })
81
256
  return result.stdout.trim()
82
257
  }
83
258
  catch (error) {
@@ -89,8 +264,8 @@ export async function getCurrentBranch(cwd: string): Promise<string> {
89
264
  }
90
265
 
91
266
  export async function getUpstreamBranch(cwd: string): Promise<string | null> {
92
- const result = await runCommand(
93
- ['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
267
+ const result = await runGitCommand(
268
+ ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
94
269
  { cwd, allowFailure: true },
95
270
  )
96
271
 
@@ -102,15 +277,21 @@ export async function getUpstreamBranch(cwd: string): Promise<string | null> {
102
277
  }
103
278
 
104
279
  export async function pullCurrentBranch(cwd: string): Promise<void> {
105
- await runCommand(['git', 'pull', '--ff-only'], { cwd })
280
+ await runGitCommand(['pull', '--ff-only'], {
281
+ cwd,
282
+ requireConfiguredSsh: await upstreamRequiresConfiguredSsh(cwd),
283
+ })
106
284
  }
107
285
 
108
286
  export async function pullDefaultBranch(cwd: string, defaultBranch: string): Promise<void> {
109
- await runCommand(['git', 'pull', '--ff-only', 'origin', defaultBranch], { cwd })
287
+ await runGitCommand(['pull', '--ff-only', 'origin', defaultBranch], {
288
+ cwd,
289
+ requireConfiguredSsh: await remoteRequiresConfiguredSsh(cwd, 'origin'),
290
+ })
110
291
  }
111
292
 
112
293
  export async function getShortStatus(cwd: string): Promise<string> {
113
- const result = await runCommand(['git', 'status', '--short'], { cwd })
294
+ const result = await runGitCommand(['status', '--short'], { cwd })
114
295
  return result.stdout
115
296
  }
116
297
 
@@ -156,22 +337,22 @@ export async function getStatusCounts(cwd: string): Promise<GitStatusCounts> {
156
337
  }
157
338
 
158
339
  export async function getStagedDiff(cwd: string): Promise<string> {
159
- const result = await runCommand(['git', 'diff', '--cached'], { cwd })
340
+ const result = await runGitCommand(['diff', '--cached'], { cwd })
160
341
  return result.stdout
161
342
  }
162
343
 
163
344
  export async function getDiffAgainstHead(cwd: string): Promise<string> {
164
- const result = await runCommand(['git', 'diff', 'HEAD', '--'], { cwd })
345
+ const result = await runGitCommand(['diff', 'HEAD', '--'], { cwd })
165
346
  return result.stdout
166
347
  }
167
348
 
168
349
  export async function getRecentLog(cwd: string, count = 10): Promise<string> {
169
- const result = await runCommand(['git', 'log', '--oneline', `-${count}`], { cwd, allowFailure: true })
350
+ const result = await runGitCommand(['log', '--oneline', `-${count}`], { cwd, allowFailure: true })
170
351
  return result.stdout
171
352
  }
172
353
 
173
354
  export async function getStagedFiles(cwd: string): Promise<string[]> {
174
- const result = await runCommand(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACMRTUXB'], { cwd })
355
+ const result = await runGitCommand(['diff', '--cached', '--name-only', '--diff-filter=ACMRTUXB'], { cwd })
175
356
  return result.stdout
176
357
  .split(/\r?\n/)
177
358
  .map(line => line.trim())
@@ -179,7 +360,7 @@ export async function getStagedFiles(cwd: string): Promise<string[]> {
179
360
  }
180
361
 
181
362
  export async function getChangedFilesAgainstHead(cwd: string): Promise<string[]> {
182
- const result = await runCommand(['git', 'diff', '--name-only', 'HEAD', '--diff-filter=ACMRTUXB'], { cwd })
363
+ const result = await runGitCommand(['diff', '--name-only', 'HEAD', '--diff-filter=ACMRTUXB'], { cwd })
183
364
  return result.stdout
184
365
  .split(/\r?\n/)
185
366
  .map(line => line.trim())
@@ -187,7 +368,7 @@ export async function getChangedFilesAgainstHead(cwd: string): Promise<string[]>
187
368
  }
188
369
 
189
370
  export async function getDiffStatAgainstHead(cwd: string): Promise<string> {
190
- const result = await runCommand(['git', 'diff', '--stat', 'HEAD'], { cwd, allowFailure: true })
371
+ const result = await runGitCommand(['diff', '--stat', 'HEAD'], { cwd, allowFailure: true })
191
372
  return result.stdout.trim()
192
373
  }
193
374
 
@@ -203,48 +384,43 @@ export function buildFallbackCommitMessage(files: string[]): string {
203
384
  return `Update ${basename(files[0]!) || files[0]} and ${files.length - 1} other files`
204
385
  }
205
386
 
206
- function buildCommitDelimiter(message: string): string {
207
- let delimiter = `__TELA_COMMIT_${randomUUID().replace(/-/g, '_')}__`
208
- while (message.includes(delimiter)) {
209
- delimiter = `__TELA_COMMIT_${randomUUID().replace(/-/g, '_')}__`
210
- }
211
- return delimiter
212
- }
213
-
214
387
  export async function commitWithMessage(cwd: string, message: string): Promise<void> {
215
- const delimiter = buildCommitDelimiter(message)
216
- const script = [
217
- 'git commit -F - <<\'__TELA_COMMIT__\'',
218
- message,
219
- '__TELA_COMMIT__',
220
- ].join('\n').replaceAll('__TELA_COMMIT__', delimiter)
221
-
222
- await runShell(script, { cwd })
388
+ await runGitCommand(['commit', '-F', '-'], {
389
+ cwd,
390
+ input: message,
391
+ })
223
392
  }
224
393
 
225
394
  export async function pushBranch(cwd: string, branch: string, setUpstream: boolean): Promise<void> {
226
395
  const args = setUpstream
227
- ? ['git', 'push', '--set-upstream', 'origin', branch]
228
- : ['git', 'push']
229
- await runCommand(args, { cwd })
396
+ ? ['push', '--set-upstream', 'origin', branch]
397
+ : ['push']
398
+ const requireConfiguredSsh = setUpstream
399
+ ? await remoteRequiresConfiguredSsh(cwd, 'origin')
400
+ : await upstreamRequiresConfiguredSsh(cwd)
401
+
402
+ await runGitCommand(args, {
403
+ cwd,
404
+ requireConfiguredSsh,
405
+ })
230
406
  }
231
407
 
232
408
  export async function getHeadShortSha(cwd: string): Promise<string> {
233
- const result = await runCommand(['git', 'rev-parse', '--short', 'HEAD'], { cwd })
409
+ const result = await runGitCommand(['rev-parse', '--short', 'HEAD'], { cwd })
234
410
  return result.stdout.trim()
235
411
  }
236
412
 
237
413
  export async function getHeadCommitHash(cwd: string): Promise<string> {
238
- const result = await runCommand(['git', 'rev-parse', 'HEAD'], { cwd })
414
+ const result = await runGitCommand(['rev-parse', 'HEAD'], { cwd })
239
415
  return result.stdout.trim()
240
416
  }
241
417
 
242
418
  export async function getHeadCommitMessage(cwd: string): Promise<string> {
243
- const result = await runCommand(['git', 'log', '-1', '--pretty=%B'], { cwd })
419
+ const result = await runGitCommand(['log', '-1', '--pretty=%B'], { cwd })
244
420
  return result.stdout.trim()
245
421
  }
246
422
 
247
423
  export async function getHeadShortMessage(cwd: string): Promise<string> {
248
- const result = await runCommand(['git', 'log', '-1', '--pretty=%s'], { cwd })
424
+ const result = await runGitCommand(['log', '-1', '--pretty=%s'], { cwd })
249
425
  return result.stdout.trim()
250
426
  }
@@ -2,7 +2,9 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { useInput } from 'ink'
3
3
  import { ENVIRONMENT_PRESET_OPTIONS, resolveEnvironmentConfigForPreset } from '../../../config.ts'
4
4
  import {
5
+ inferSshPrivateKeyPath,
5
6
  loadSshPublicKey,
7
+ validateSshPrivateKeyPath,
6
8
  validateSshPublicKeyPath,
7
9
  } from '../../../forgejo.ts'
8
10
  import type { TelaCliAuthState, TelaCliForgejoOrganization } from '../../../types.ts'
@@ -67,6 +69,9 @@ export function SetupScreen(props: SetupScreenProps): React.ReactElement {
67
69
  cloneRoot: props.defaults.cloneRoot,
68
70
  sshKeySelection: props.sshPublicKeys[0]?.path ?? CUSTOM_SSH_KEY_OPTION,
69
71
  sshKeyPath: '',
72
+ sshPrivateKeyPath: props.sshPublicKeys[0]
73
+ ? inferSshPrivateKeyPath(props.sshPublicKeys[0].path)
74
+ : '',
70
75
  })
71
76
  const [errors, setErrors] = useState<Partial<Record<SetupStepId, string>>>({})
72
77
  const [createCloneRoot, setCreateCloneRoot] = useState(true)
@@ -82,6 +87,7 @@ export function SetupScreen(props: SetupScreenProps): React.ReactElement {
82
87
  const [result, setResult] = useState<SetupScreenResult | null>(null)
83
88
  const authStartedRef = useRef(false)
84
89
  const provisionStartedRef = useRef(false)
90
+ const previousSuggestedPrivateKeyPathRef = useRef(values.sshPrivateKeyPath)
85
91
 
86
92
  const visibleSteps = useMemo(() => getVisibleEditSteps(values), [values])
87
93
  const safeStepIndex = Math.min(stepIndex, Math.max(visibleSteps.length - 1, 0))
@@ -117,6 +123,10 @@ export function SetupScreen(props: SetupScreenProps): React.ReactElement {
117
123
 
118
124
  return props.sshPublicKeys.find(key => key.path === values.sshKeySelection)
119
125
  }, [props.sshPublicKeys, values.sshKeyPath, values.sshKeySelection])
126
+ const suggestedPrivateKeyPath = useMemo(
127
+ () => selectedSshKey ? inferSshPrivateKeyPath(selectedSshKey.path) : '',
128
+ [selectedSshKey],
129
+ )
120
130
  const cloneRootState = useMemo(() => props.inspectCloneRoot(values.cloneRoot), [props, values.cloneRoot])
121
131
 
122
132
  const breadcrumb = useMemo<BreadcrumbEntry[]>(() => {
@@ -247,6 +257,7 @@ export function SetupScreen(props: SetupScreenProps): React.ReactElement {
247
257
  cloneRoot: values.cloneRoot,
248
258
  createCloneRoot,
249
259
  sshKey: selectedSshKey,
260
+ sshPrivateKeyPath: values.sshPrivateKeyPath,
250
261
  auth: authenticated,
251
262
  organization: selectedOrganization,
252
263
  }, (event) => {
@@ -267,6 +278,7 @@ export function SetupScreen(props: SetupScreenProps): React.ReactElement {
267
278
  resolvedConfig,
268
279
  selectedOrganization,
269
280
  selectedSshKey,
281
+ values.sshPrivateKeyPath,
270
282
  values.cloneRoot,
271
283
  ])
272
284
 
@@ -276,6 +288,29 @@ export function SetupScreen(props: SetupScreenProps): React.ReactElement {
276
288
  }
277
289
  }, [phase, props, result])
278
290
 
291
+ useEffect(() => {
292
+ setValues((current) => {
293
+ const previousSuggested = previousSuggestedPrivateKeyPathRef.current
294
+ previousSuggestedPrivateKeyPathRef.current = suggestedPrivateKeyPath
295
+
296
+ if (
297
+ current.sshPrivateKeyPath
298
+ && current.sshPrivateKeyPath !== previousSuggested
299
+ ) {
300
+ return current
301
+ }
302
+
303
+ if (current.sshPrivateKeyPath === suggestedPrivateKeyPath) {
304
+ return current
305
+ }
306
+
307
+ return {
308
+ ...current,
309
+ sshPrivateKeyPath: suggestedPrivateKeyPath,
310
+ }
311
+ })
312
+ }, [suggestedPrivateKeyPath])
313
+
279
314
  useInput((input, key) => {
280
315
  if (phase === 'authenticating' || phase === 'provisioning' || phase === 'done') {
281
316
  return
@@ -570,5 +605,7 @@ function validateStep(
570
605
  return selectedSshKey ? undefined : 'SSH public key is required'
571
606
  case 'sshKeyPath':
572
607
  return validateSshPublicKeyPath(values.sshKeyPath)
608
+ case 'sshPrivateKeyPath':
609
+ return validateSshPrivateKeyPath(values.sshPrivateKeyPath)
573
610
  }
574
611
  }
@@ -57,6 +57,7 @@ export function ReviewStep(props: {
57
57
  ? `${props.selectedSshKey.title} (${props.selectedSshKey.path})`
58
58
  : '<missing>'}
59
59
  </Text>
60
+ <Text>SSH private key: {props.values.sshPrivateKeyPath || '<missing>'}</Text>
60
61
  <Text>
61
62
  Clone root state:
62
63
  {' '}
@@ -12,6 +12,7 @@ export type SetupStepId =
12
12
  | 'cloneRoot'
13
13
  | 'sshKey'
14
14
  | 'sshKeyPath'
15
+ | 'sshPrivateKeyPath'
15
16
 
16
17
  export type SetupStepKind = 'select-environment' | 'select-ssh-key' | 'text'
17
18
 
@@ -103,6 +104,14 @@ export const EDIT_STEPS: readonly SetupStepDescriptor[] = [
103
104
  placeholder: '~/.ssh/id_ed25519.pub',
104
105
  visible: values => values.sshKeySelection === CUSTOM_SSH_KEY_OPTION,
105
106
  },
107
+ {
108
+ id: 'sshPrivateKeyPath',
109
+ label: 'Private Key Path',
110
+ question: 'Path to your SSH private key',
111
+ kind: 'text',
112
+ placeholder: '~/.ssh/id_ed25519',
113
+ visible: () => true,
114
+ },
106
115
  ]
107
116
 
108
117
  export function getVisibleEditSteps(values: SetupFormValues): SetupStepDescriptor[] {
@@ -141,5 +150,7 @@ export function summarizeStep(
141
150
  : (sshKeyTitle ?? values.sshKeySelection)
142
151
  case 'sshKeyPath':
143
152
  return values.sshKeyPath
153
+ case 'sshPrivateKeyPath':
154
+ return values.sshPrivateKeyPath
144
155
  }
145
156
  }
@@ -13,6 +13,7 @@ export const CUSTOM_SSH_KEY_OPTION = '__custom__'
13
13
  export type SetupFormValues = SetupConfigInput & {
14
14
  sshKeySelection: string
15
15
  sshKeyPath: string
16
+ sshPrivateKeyPath: string
16
17
  }
17
18
 
18
19
  export interface SetupScreenResult {
@@ -55,6 +56,7 @@ export interface SetupScreenProps {
55
56
  input: SetupConfigInput & {
56
57
  createCloneRoot: boolean
57
58
  sshKey: SelectedSshPublicKey
59
+ sshPrivateKeyPath: string
58
60
  auth: TelaCliAuthState
59
61
  organization: TelaCliForgejoOrganization
60
62
  },
package/src/program.ts CHANGED
@@ -82,6 +82,7 @@ import {
82
82
  } from './config.ts'
83
83
  import { findRepositoryRoot, getDiffStatAgainstHead } from './git.ts'
84
84
  import {
85
+ createGitSshKey,
85
86
  ensureForgejoUserAccess,
86
87
  listDiscoveredSshPublicKeys,
87
88
  type SelectedSshPublicKey,
@@ -376,6 +377,7 @@ function summarizeConfigInspection(inspection: ConfigInspection): string {
376
377
  { label: 'Workspace', value: inspection.config.auth?.workspace.title ?? '<signed out>' },
377
378
  { label: 'Organization', value: inspection.config.forgejoOrganization?.name ?? '<not selected>' },
378
379
  { label: 'Forgejo user', value: inspection.config.forgejoUserRegistered ? 'registered' : 'pending' },
380
+ { label: 'Git SSH key', value: inspection.config.gitSshKey ? `${inspection.config.gitSshKey.title} (${inspection.config.gitSshKey.privateKeyPath})` : '<missing>' },
379
381
  ])
380
382
  }
381
383
 
@@ -406,6 +408,7 @@ async function executeSetupFlow(
406
408
  input: SetupConfigInput & {
407
409
  createCloneRoot: boolean
408
410
  sshKey: SelectedSshPublicKey
411
+ sshPrivateKeyPath: string
409
412
  auth: TelaCliAuthState
410
413
  organization: TelaCliForgejoOrganization
411
414
  },
@@ -427,6 +430,7 @@ async function executeSetupFlow(
427
430
  auth: input.auth,
428
431
  forgejoOrganization: input.organization,
429
432
  forgejoUserRegistered: false,
433
+ gitSshKey: createGitSshKey(input.sshKey, input.sshPrivateKeyPath),
430
434
  }
431
435
  const forgejoProvision = await ensureForgejoUserAccess({
432
436
  config: baseConfig,
@@ -567,6 +571,7 @@ async function runSetupInk(
567
571
  auth: input.auth,
568
572
  forgejoOrganization: null,
569
573
  forgejoUserRegistered: false,
574
+ gitSshKey: inspection.config?.gitSshKey ?? null,
570
575
  }
571
576
  const organizations = await listOrganizations(config, deps.fetchFn ?? fetch)
572
577
  if (organizations.length === 0) {
@@ -705,6 +710,7 @@ export async function runSetup(
705
710
  { label: 'Workspace', value: config.auth?.workspace.title ?? '<missing>' },
706
711
  { label: 'Organization', value: config.forgejoOrganization?.name ?? '<missing>' },
707
712
  { label: 'Forgejo user', value: config.forgejoUserRegistered ? 'registered' : 'pending' },
713
+ { label: 'Git SSH key', value: config.gitSshKey ? `${config.gitSshKey.title} (${config.gitSshKey.privateKeyPath})` : '<missing>' },
708
714
  { label: 'Session', value: config.auth ? `${redactSecret(config.auth.session.accessToken)} (expires ${new Date(config.auth.session.expiresAt).toISOString()})` : '<missing>' },
709
715
  ]), { decorate: runtime.decorate }),
710
716
  section('Authentication', DEVICE_FLOW_BROWSER_HELP, { decorate: runtime.decorate }),
@@ -714,6 +720,7 @@ export async function runSetup(
714
720
  { label: 'auth', status: 'authenticated', detail: `${config.auth?.user.email ?? '<missing>'} @ ${config.auth?.workspace.title ?? '<missing>'}` },
715
721
  { label: 'organization', status: config.forgejoOrganization ? 'selected' : 'missing', detail: config.forgejoOrganization?.name ?? 'not selected' },
716
722
  { label: 'forgejo-user', status: config.forgejoUserRegistered ? 'registered' : 'pending', detail: config.forgejoUserRegistered ? 'SSH-key provisioning completed.' : 'SSH-key provisioning is pending.' },
723
+ { label: 'git-ssh-key', status: config.gitSshKey ? 'configured' : 'missing', detail: config.gitSshKey?.privateKeyPath ?? 'rerun setup to choose an SSH key' },
717
724
  { label: 'clone-root', status: cloneRootCreated ? 'created' : (existsSync(config.cloneRoot) ? 'ready' : 'missing'), detail: config.cloneRoot },
718
725
  { label: 'git', status: gitAvailable ? 'available' : 'missing', detail: gitAvailable ? 'ready' : 'install git' },
719
726
  { label: 'claude', status: claudeAvailable ? 'available' : 'missing', detail: claudeAvailable ? 'ready' : 'install Claude CLI' },
package/src/types.ts CHANGED
@@ -22,6 +22,12 @@ export interface TelaCliForgejoOrganization {
22
22
  name: string
23
23
  }
24
24
 
25
+ export interface TelaCliGitSshKey {
26
+ title: string
27
+ publicKeyPath: string
28
+ privateKeyPath: string
29
+ }
30
+
25
31
  export interface TelaCliAuthState {
26
32
  user: TelaCliUser
27
33
  workspace: TelaCliWorkspace
@@ -40,6 +46,7 @@ export interface TelaCliConfig {
40
46
  auth: TelaCliAuthState | null
41
47
  forgejoOrganization: TelaCliForgejoOrganization | null
42
48
  forgejoUserRegistered: boolean
49
+ gitSshKey: TelaCliGitSshKey | null
43
50
  }
44
51
 
45
52
  export interface OrganizationSummary {