@meistrari/tela-cli 0.1.1 → 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.
- package/README.md +6 -6
- package/package.json +4 -2
- package/src/agent-api.ts +102 -2
- package/src/assets.d.ts +4 -0
- package/src/auth.ts +423 -7
- package/src/commands/feedback.ts +1 -1
- package/src/commands/metadata.ts +18 -6
- package/src/commands/status.ts +10 -1
- package/src/config.ts +522 -147
- package/src/diagnostics.ts +127 -5
- package/src/forgejo.ts +189 -0
- package/src/ink/assets/160092__jorickhoofd__dog-bark-1.mp3 +0 -0
- package/src/ink/bark.tsx +14 -62
- package/src/ink/screens/setup/breadcrumb.tsx +23 -0
- package/src/ink/screens/setup/setup-screen.tsx +574 -0
- package/src/ink/screens/setup/step-frame.tsx +30 -0
- package/src/ink/screens/setup/steps/done-step.tsx +28 -0
- package/src/ink/screens/setup/steps/environment-step.tsx +22 -0
- package/src/ink/screens/setup/steps/organization-step.tsx +34 -0
- package/src/ink/screens/setup/steps/review-step.tsx +76 -0
- package/src/ink/screens/setup/steps/running-step.tsx +36 -0
- package/src/ink/screens/setup/steps/ssh-key-step.tsx +47 -0
- package/src/ink/screens/setup/steps/text-field-step.tsx +17 -0
- package/src/ink/screens/setup/steps.ts +145 -0
- package/src/ink/screens/setup/types.ts +63 -0
- package/src/program.ts +228 -96
- package/src/types.ts +64 -1
- package/src/ink/screens/setup-screen.tsx +0 -352
package/src/diagnostics.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
|
Binary file
|
package/src/ink/bark.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, rmSync
|
|
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
|
|
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.
|
|
183
|
-
|
|
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
|
-
|
|
153
|
+
let audioFile: BarkAudioWriteResult | undefined
|
|
206
154
|
|
|
207
155
|
try {
|
|
208
|
-
|
|
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(
|
|
162
|
+
rmSync(writtenAudioFile.directory, { recursive: true, force: true })
|
|
213
163
|
})
|
|
214
164
|
return { attempted: true }
|
|
215
165
|
}
|
|
216
166
|
catch {
|
|
217
|
-
|
|
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
|
+
}
|