@meistrari/tela-cli 0.1.0 → 0.2.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,6 +1,7 @@
1
1
  import { accessSync, constants, existsSync, statSync } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
- import { inspectSavedConfig, redactApiKey } from './config.ts'
3
+ import { buildAgentApiHeaders } from './auth.ts'
4
+ import { inspectSavedConfig, redactSecret } from './config.ts'
4
5
  import {
5
6
  findRepositoryRoot,
6
7
  getCurrentBranch,
@@ -47,9 +48,18 @@ export interface StatusReport {
47
48
  present: boolean
48
49
  valid: boolean
49
50
  path: string
51
+ environment?: string
50
52
  agentApiUrl?: string
53
+ authApiUrl?: string
54
+ telaApiUrl?: string
51
55
  cloneRoot?: string
52
- apiKey?: string
56
+ user?: string
57
+ workspace?: string
58
+ forgejoOrganization?: string
59
+ forgejoUserRegistered?: boolean
60
+ accessToken?: string
61
+ sessionExpiresAt?: number
62
+ hasRefreshToken?: boolean
53
63
  error?: string
54
64
  }
55
65
  repo: RepoSnapshot
@@ -65,6 +75,15 @@ export interface StatusReport {
65
75
  nextActions: string[]
66
76
  }
67
77
 
78
+ function formatSessionState(expiresAt: number | undefined, hasRefreshToken: boolean | undefined): string | undefined {
79
+ if (!expiresAt) {
80
+ return undefined
81
+ }
82
+
83
+ const relative = expiresAt <= Date.now() ? 'expired' : 'active'
84
+ return `${relative}${hasRefreshToken ? ' · refresh token saved' : ''}`
85
+ }
86
+
68
87
  export interface DoctorCheck {
69
88
  id: string
70
89
  group: string
@@ -238,12 +257,28 @@ export async function inspectCurrentRepository(cwd = process.cwd()): Promise<Rep
238
257
  }
239
258
  }
240
259
 
241
- function buildStatusNextActions(warnings: string[], repo: RepoSnapshot, hasConfig: boolean): string[] {
260
+ function buildStatusNextActions(
261
+ warnings: string[],
262
+ repo: RepoSnapshot,
263
+ hasConfig: boolean,
264
+ hasAuth: boolean,
265
+ hasForgejoOrganization: boolean,
266
+ hasForgejoAccess: boolean,
267
+ ): string[] {
242
268
  const actions: string[] = []
243
269
 
244
270
  if (!hasConfig) {
245
271
  actions.push('Run `tela setup` to configure the CLI.')
246
272
  }
273
+ else if (!hasAuth) {
274
+ actions.push('Run `tela setup` to sign in again.')
275
+ }
276
+ else if (!hasForgejoOrganization) {
277
+ actions.push('Run `tela setup` again to choose a workspace organization for Forgejo access.')
278
+ }
279
+ else if (!hasForgejoAccess) {
280
+ actions.push('Run `tela setup` again to provision Forgejo access with your SSH key.')
281
+ }
247
282
 
248
283
  if (!repo.inRepository) {
249
284
  actions.push('Run `tela sync <repo>` or change into a cloned repository before mutating commands.')
@@ -284,6 +319,17 @@ export async function createStatusReport(
284
319
  else if (!configInspection.valid) {
285
320
  warnings.push(`Tela CLI config is invalid: ${configInspection.error ?? 'unknown error'}`)
286
321
  }
322
+ else if (!configInspection.config?.auth) {
323
+ warnings.push('Tela CLI is signed out.')
324
+ }
325
+ else {
326
+ if (!configInspection.config.forgejoOrganization) {
327
+ warnings.push('Forgejo organization selection is missing.')
328
+ }
329
+ if (!configInspection.config.forgejoUserRegistered) {
330
+ warnings.push('Forgejo user access is not provisioned yet.')
331
+ }
332
+ }
287
333
 
288
334
  if (!gitAvailable) {
289
335
  warnings.push('`git` is not available on PATH.')
@@ -309,7 +355,14 @@ export async function createStatusReport(
309
355
  }
310
356
  }
311
357
 
312
- const nextActions = buildStatusNextActions(warnings, repo, Boolean(configInspection.valid))
358
+ const nextActions = buildStatusNextActions(
359
+ warnings,
360
+ repo,
361
+ Boolean(configInspection.valid),
362
+ Boolean(configInspection.config?.auth),
363
+ Boolean(configInspection.config?.forgejoOrganization),
364
+ Boolean(configInspection.config?.forgejoUserRegistered),
365
+ )
313
366
 
314
367
  return {
315
368
  ok: warnings.length === 0,
@@ -317,9 +370,18 @@ export async function createStatusReport(
317
370
  present: configInspection.exists,
318
371
  valid: configInspection.valid,
319
372
  path: configInspection.path,
373
+ environment: configInspection.config?.environment,
320
374
  agentApiUrl: configInspection.config?.agentApiUrl,
375
+ authApiUrl: configInspection.config?.authApiUrl,
376
+ telaApiUrl: configInspection.config?.telaApiUrl,
321
377
  cloneRoot: configInspection.config?.cloneRoot,
322
- apiKey: configInspection.config ? redactApiKey(configInspection.config.apiKey) : undefined,
378
+ user: configInspection.config?.auth?.user.email,
379
+ workspace: configInspection.config?.auth?.workspace.title,
380
+ forgejoOrganization: configInspection.config?.forgejoOrganization?.name,
381
+ forgejoUserRegistered: configInspection.config?.forgejoUserRegistered,
382
+ accessToken: configInspection.config?.auth ? redactSecret(configInspection.config.auth.session.accessToken) : undefined,
383
+ sessionExpiresAt: configInspection.config?.auth?.session.expiresAt,
384
+ hasRefreshToken: Boolean(configInspection.config?.auth?.session.refreshToken),
323
385
  error: configInspection.error,
324
386
  },
325
387
  repo,
@@ -566,6 +628,66 @@ export async function createDoctorReport(
566
628
  fixes: getAgentApiOriginFixes(reachability),
567
629
  evidence: reachability.reachable ? undefined : [reachability.detail],
568
630
  })
631
+
632
+ if (configInspection.config.auth) {
633
+ try {
634
+ const headers = await buildAgentApiHeaders(configInspection.config, { fetchFn })
635
+ checks.push({
636
+ id: 'auth-session',
637
+ group: 'auth',
638
+ status: 'pass',
639
+ summary: `Saved session is usable for ${configInspection.config.auth.user.email} in ${configInspection.config.auth.workspace.title}.`,
640
+ fixes: ['No action required.'],
641
+ evidence: [
642
+ `data-token header: ${redactSecret(headers['x-data-token'])}`,
643
+ ],
644
+ })
645
+ }
646
+ catch (error) {
647
+ const message = error instanceof Error ? error.message : String(error)
648
+ checks.push({
649
+ id: 'auth-session',
650
+ group: 'auth',
651
+ status: 'fail',
652
+ summary: 'Saved Tela session could not be refreshed or exchanged for a data token.',
653
+ fixes: ['Run `tela setup` in an interactive terminal to sign in again.'],
654
+ evidence: [message],
655
+ })
656
+ }
657
+ }
658
+ else {
659
+ checks.push({
660
+ id: 'auth-session',
661
+ group: 'auth',
662
+ status: 'fail',
663
+ summary: 'No saved Tela session is available.',
664
+ fixes: ['Run `tela setup` in an interactive terminal to sign in.'],
665
+ })
666
+ }
667
+
668
+ checks.push({
669
+ id: 'forgejo-organization',
670
+ group: 'access',
671
+ status: configInspection.config.forgejoOrganization ? 'pass' : 'warn',
672
+ summary: configInspection.config.forgejoOrganization
673
+ ? `Forgejo organization is selected: ${configInspection.config.forgejoOrganization.name}.`
674
+ : 'No Forgejo organization is selected.',
675
+ fixes: configInspection.config.forgejoOrganization
676
+ ? ['No action required.']
677
+ : ['Run `tela setup` again and choose a workspace organization for Forgejo access.'],
678
+ })
679
+
680
+ checks.push({
681
+ id: 'forgejo-user',
682
+ group: 'access',
683
+ status: configInspection.config.forgejoUserRegistered ? 'pass' : 'warn',
684
+ summary: configInspection.config.forgejoUserRegistered
685
+ ? 'Forgejo user access is provisioned.'
686
+ : 'Forgejo user access has not been provisioned yet.',
687
+ fixes: configInspection.config.forgejoUserRegistered
688
+ ? ['No action required.']
689
+ : ['Run `tela setup` again and complete SSH-key provisioning for Forgejo access.'],
690
+ })
569
691
  }
570
692
 
571
693
  const nextActions = buildNextActions(checks)
package/src/forgejo.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { basename, extname, join } from 'node:path'
4
+ import { provisionForgejoUser, type AgentApiError } from './agent-api.ts'
5
+ import type { FetchLike } from './auth.ts'
6
+ import { createUserFacingError } from './errors.ts'
7
+ import type {
8
+ ForgejoUserCreateResponse,
9
+ ForgejoUserSshKeyInput,
10
+ TelaCliConfig,
11
+ TelaCliForgejoOrganization,
12
+ } from './types.ts'
13
+
14
+ export interface SelectedSshPublicKey extends ForgejoUserSshKeyInput {
15
+ path: string
16
+ }
17
+
18
+ export interface EnsureForgejoUserAccessOptions {
19
+ config: TelaCliConfig
20
+ organization: TelaCliForgejoOrganization
21
+ sshKey: SelectedSshPublicKey
22
+ fetchFn?: FetchLike
23
+ onProgress?: (message: string, title?: string) => void
24
+ provisionFn?: typeof provisionForgejoUser
25
+ saveConfig: (config: TelaCliConfig) => void
26
+ }
27
+
28
+ export interface ForgejoAccessResult {
29
+ config: TelaCliConfig
30
+ detail: string
31
+ response: ForgejoUserCreateResponse
32
+ }
33
+
34
+ function getHomeDir(): string {
35
+ return process.env.HOME || homedir()
36
+ }
37
+
38
+ function expandHome(input: string): string {
39
+ if (input === '~') {
40
+ return getHomeDir()
41
+ }
42
+
43
+ if (input.startsWith('~/')) {
44
+ return join(getHomeDir(), input.slice(2))
45
+ }
46
+
47
+ return input
48
+ }
49
+
50
+ function getSshDirectory(): string {
51
+ return join(getHomeDir(), '.ssh')
52
+ }
53
+
54
+ function sshKeyTitleFromPath(filePath: string): string {
55
+ const base = basename(filePath)
56
+ return extname(base) === '.pub'
57
+ ? base.slice(0, -4)
58
+ : base
59
+ }
60
+
61
+ export function loadSshPublicKey(filePath: string): SelectedSshPublicKey {
62
+ const expandedPath = expandHome(filePath.trim())
63
+ if (!expandedPath) {
64
+ throw new Error('SSH public key path is required')
65
+ }
66
+
67
+ if (!existsSync(expandedPath)) {
68
+ throw new Error(`SSH public key file does not exist: ${expandedPath}`)
69
+ }
70
+
71
+ const stats = statSync(expandedPath)
72
+ if (!stats.isFile()) {
73
+ throw new Error(`SSH public key path is not a file: ${expandedPath}`)
74
+ }
75
+
76
+ const key = readFileSync(expandedPath, 'utf-8').trim()
77
+ if (!key) {
78
+ throw new Error(`SSH public key file is empty: ${expandedPath}`)
79
+ }
80
+
81
+ return {
82
+ path: expandedPath,
83
+ title: sshKeyTitleFromPath(expandedPath),
84
+ key,
85
+ }
86
+ }
87
+
88
+ export function validateSshPublicKeyPath(value: string): string | undefined {
89
+ try {
90
+ loadSshPublicKey(value)
91
+ return undefined
92
+ }
93
+ catch (error) {
94
+ return error instanceof Error ? error.message : 'SSH public key path is invalid'
95
+ }
96
+ }
97
+
98
+ export function listDiscoveredSshPublicKeys(): SelectedSshPublicKey[] {
99
+ const sshDirectory = getSshDirectory()
100
+ if (!existsSync(sshDirectory)) {
101
+ return []
102
+ }
103
+
104
+ return readdirSync(sshDirectory, { withFileTypes: true })
105
+ .filter(entry => entry.isFile() && entry.name.endsWith('.pub'))
106
+ .map(entry => loadSshPublicKey(join(sshDirectory, entry.name)))
107
+ .sort((left, right) => left.title.localeCompare(right.title) || left.path.localeCompare(right.path))
108
+ }
109
+
110
+ function buildProvisioningDetail(result: ForgejoUserCreateResponse, sshKey: SelectedSshPublicKey): string {
111
+ const userState = result.userCreated ? 'user created' : 'user already existed'
112
+ const keyState = result.sshKeyCreated ? 'SSH key added' : 'SSH key already present'
113
+ const teamState = result.teamCreated ? 'team created' : 'team ensured'
114
+ const username = result.username ?? '<unknown>'
115
+ const teamName = result.teamName ?? '<unknown>'
116
+
117
+ return `${username} in ${teamName}; ${userState}, ${teamState}, ${keyState} (${sshKey.title})`
118
+ }
119
+
120
+ function toProvisioningError(error: unknown): never {
121
+ const agentApiError = error as AgentApiError
122
+ if (agentApiError && typeof agentApiError.status === 'number' && typeof agentApiError.responseBody === 'string') {
123
+ throw createUserFacingError({
124
+ code: 'forgejo_user_provision_failed',
125
+ summary: 'Forgejo user provisioning failed.',
126
+ details: agentApiError.message,
127
+ fixes: [
128
+ 'Run `tela setup` again to refresh the Tela session and retry SSH-key provisioning.',
129
+ 'Use `--verbose` if you need the full Agent API response details.',
130
+ ],
131
+ evidence: [
132
+ `Agent API status: ${agentApiError.status}`,
133
+ agentApiError.responseBody ? `Response body: ${agentApiError.responseBody}` : '',
134
+ ].filter(Boolean),
135
+ cause: error,
136
+ })
137
+ }
138
+
139
+ throw createUserFacingError({
140
+ code: 'forgejo_user_provision_failed',
141
+ summary: 'Forgejo user provisioning failed.',
142
+ details: error instanceof Error ? error.message : String(error),
143
+ fixes: [
144
+ 'Run `tela setup` again to refresh the Tela session and retry SSH-key provisioning.',
145
+ ],
146
+ cause: error,
147
+ })
148
+ }
149
+
150
+ export async function ensureForgejoUserAccess(options: EnsureForgejoUserAccessOptions): Promise<ForgejoAccessResult> {
151
+ const provisionFn = options.provisionFn ?? provisionForgejoUser
152
+ const baseConfig: TelaCliConfig = {
153
+ ...options.config,
154
+ forgejoOrganization: options.organization,
155
+ forgejoUserRegistered: false,
156
+ }
157
+
158
+ options.saveConfig(baseConfig)
159
+ options.onProgress?.(
160
+ `Provisioning Forgejo access for ${options.organization.name} with ${options.sshKey.title}.`,
161
+ 'Forgejo',
162
+ )
163
+
164
+ let response: ForgejoUserCreateResponse
165
+ try {
166
+ response = await provisionFn({
167
+ organizationName: options.organization.name,
168
+ sshKey: options.sshKey,
169
+ }, baseConfig, options.fetchFn)
170
+ }
171
+ catch (error) {
172
+ toProvisioningError(error)
173
+ }
174
+
175
+ const finalConfig: TelaCliConfig = {
176
+ ...baseConfig,
177
+ forgejoUserRegistered: true,
178
+ }
179
+ options.saveConfig(finalConfig)
180
+
181
+ const detail = buildProvisioningDetail(response, options.sshKey)
182
+ options.onProgress?.(`Forgejo access ready: ${detail}`, 'Forgejo')
183
+
184
+ return {
185
+ config: finalConfig,
186
+ detail,
187
+ response,
188
+ }
189
+ }
package/src/ink/bark.tsx CHANGED
@@ -1,8 +1,9 @@
1
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import React, { useEffect, useMemo, useState } from 'react'
5
5
  import { Text } from 'ink'
6
+ import barkAudioAssetPath from './assets/160092__jorickhoofd__dog-bark-1.mp3' with { type: 'file' }
6
7
  import { getBootstrapPixelRows } from '../ui.ts'
7
8
  import { AppFrame } from './components.tsx'
8
9
  import {
@@ -51,11 +52,6 @@ const BARK_FRAME_ROWS = [
51
52
  [...BARKING_PIXEL_ROWS],
52
53
  ] as const
53
54
 
54
- function pseudoNoise(seed: number): number {
55
- const raw = Math.sin((seed + 1) * 12.9898) * 43758.5453
56
- return (raw - Math.floor(raw)) * 2 - 1
57
- }
58
-
59
55
  export interface BarkAudioWriteResult {
60
56
  directory: string
61
57
  filePath: string
@@ -71,7 +67,7 @@ export interface BarkAudioPlaybackOptions {
71
67
  platform?: string
72
68
  afplayPath?: string
73
69
  audioPlayerExists?: (path: string) => boolean
74
- writeAudioFile?: () => BarkAudioWriteResult
70
+ writeAudioFile?: () => BarkAudioWriteResult | Promise<BarkAudioWriteResult>
75
71
  spawnAudio?: (command: string[]) => { exited?: Promise<unknown> } | void
76
72
  }
77
73
 
@@ -129,58 +125,10 @@ export function shouldPlayBarkAudio(options: {
129
125
  return audioPlayerExists(afplayPath)
130
126
  }
131
127
 
132
- export function buildBarkWavBuffer(): Uint8Array {
133
- const sampleRate = 22_050
134
- const durationSeconds = 0.18
135
- const sampleCount = Math.floor(sampleRate * durationSeconds)
136
- const pcm = new Int16Array(sampleCount)
137
-
138
- for (let index = 0; index < sampleCount; index += 1) {
139
- const t = index / sampleRate
140
- const attack = Math.min(1, t * 40)
141
- const decay = Math.exp(-15 * t)
142
- const envelope = attack * decay
143
- const pitch = 760 - (420 * t)
144
- const body = Math.sin((2 * Math.PI * pitch) * t) * 0.58
145
- const growl = Math.sin((2 * Math.PI * (pitch * 0.47)) * t + 0.8) * 0.22
146
- const snap = pseudoNoise(index) * (t < 0.035 ? 0.28 : 0.08)
147
- const sample = Math.max(-1, Math.min(1, (body + growl + snap) * envelope))
148
- pcm[index] = Math.round(sample * 0x7fff)
149
- }
150
-
151
- const dataSize = pcm.byteLength
152
- const buffer = new ArrayBuffer(44 + dataSize)
153
- const view = new DataView(buffer)
154
- const bytes = new Uint8Array(buffer)
155
-
156
- const writeString = (offset: number, value: string) => {
157
- for (let index = 0; index < value.length; index += 1) {
158
- view.setUint8(offset + index, value.charCodeAt(index))
159
- }
160
- }
161
-
162
- writeString(0, 'RIFF')
163
- view.setUint32(4, 36 + dataSize, true)
164
- writeString(8, 'WAVE')
165
- writeString(12, 'fmt ')
166
- view.setUint32(16, 16, true)
167
- view.setUint16(20, 1, true)
168
- view.setUint16(22, 1, true)
169
- view.setUint32(24, sampleRate, true)
170
- view.setUint32(28, sampleRate * 2, true)
171
- view.setUint16(32, 2, true)
172
- view.setUint16(34, 16, true)
173
- writeString(36, 'data')
174
- view.setUint32(40, dataSize, true)
175
- bytes.set(new Uint8Array(pcm.buffer), 44)
176
-
177
- return bytes
178
- }
179
-
180
- export function writeBarkAudioFile(baseDirectory = tmpdir()): BarkAudioWriteResult {
128
+ export async function writeBarkAudioFile(baseDirectory = tmpdir()): Promise<BarkAudioWriteResult> {
181
129
  const directory = mkdtempSync(join(baseDirectory, 'tela-bark-'))
182
- const filePath = join(directory, 'bark.wav')
183
- writeFileSync(filePath, buildBarkWavBuffer())
130
+ const filePath = join(directory, 'bark.mp3')
131
+ await Bun.write(filePath, Bun.file(barkAudioAssetPath))
184
132
  return { directory, filePath }
185
133
  }
186
134
 
@@ -202,19 +150,23 @@ export async function playBarkAudio(options: BarkAudioPlaybackOptions = {}): Pro
202
150
  const writeAudioFile = options.writeAudioFile ?? writeBarkAudioFile
203
151
  const spawnAudio = options.spawnAudio ?? spawnAudioCommand
204
152
  const afplayPath = options.afplayPath ?? '/usr/bin/afplay'
205
- const audioFile = writeAudioFile()
153
+ let audioFile: BarkAudioWriteResult | undefined
206
154
 
207
155
  try {
208
- const result = spawnAudio([afplayPath, audioFile.filePath])
156
+ audioFile = await writeAudioFile()
157
+ const writtenAudioFile = audioFile
158
+ const result = spawnAudio([afplayPath, writtenAudioFile.filePath])
209
159
  void Promise.resolve(result?.exited)
210
160
  .catch(() => {})
211
161
  .finally(() => {
212
- rmSync(audioFile.directory, { recursive: true, force: true })
162
+ rmSync(writtenAudioFile.directory, { recursive: true, force: true })
213
163
  })
214
164
  return { attempted: true }
215
165
  }
216
166
  catch {
217
- rmSync(audioFile.directory, { recursive: true, force: true })
167
+ if (audioFile) {
168
+ rmSync(audioFile.directory, { recursive: true, force: true })
169
+ }
218
170
  return { attempted: false }
219
171
  }
220
172
  }
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ export interface BreadcrumbEntry {
5
+ label: string
6
+ value: string
7
+ }
8
+
9
+ export function Breadcrumb(props: { entries: BreadcrumbEntry[] }): React.ReactElement | null {
10
+ if (props.entries.length === 0) {
11
+ return null
12
+ }
13
+
14
+ return (
15
+ <Box flexDirection="column" marginBottom={1}>
16
+ {props.entries.map(entry => (
17
+ <Text key={entry.label} dimColor>
18
+ {entry.label}: {entry.value || '—'}
19
+ </Text>
20
+ ))}
21
+ </Box>
22
+ )
23
+ }