@meistrari/tela-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/git.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { basename } from 'node:path'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { CommandError, runCommand, runShell } from './process.ts'
4
+
5
+ export interface GitStatusEntry {
6
+ index: string
7
+ worktree: string
8
+ path: string
9
+ }
10
+
11
+ export interface GitStatusCounts {
12
+ staged: number
13
+ unstaged: number
14
+ untracked: number
15
+ total: number
16
+ }
17
+
18
+ export async function findRepositoryRoot(cwd: string): Promise<string | null> {
19
+ const result = await runCommand(['git', 'rev-parse', '--show-toplevel'], { cwd, allowFailure: true })
20
+ if (result.exitCode !== 0) {
21
+ return null
22
+ }
23
+
24
+ return result.stdout.trim() || null
25
+ }
26
+
27
+ export async function getRepositoryRoot(cwd: string): Promise<string> {
28
+ const repoRoot = await findRepositoryRoot(cwd)
29
+ if (!repoRoot) {
30
+ throw new Error(`Not a git repository: ${cwd}`)
31
+ }
32
+
33
+ return repoRoot
34
+ }
35
+
36
+ export async function assertGitRepository(cwd: string): Promise<void> {
37
+ try {
38
+ await runCommand(['git', 'rev-parse', '--is-inside-work-tree'], { cwd })
39
+ }
40
+ catch (error) {
41
+ if (error instanceof CommandError) {
42
+ throw new Error(`Not a git repository: ${cwd}`)
43
+ }
44
+ throw error
45
+ }
46
+ }
47
+
48
+ export async function cloneRepository(remoteUrl: string, targetDir: string): Promise<void> {
49
+ await runCommand(['git', 'clone', remoteUrl, targetDir])
50
+ }
51
+
52
+ export async function getOriginUrl(cwd: string): Promise<string> {
53
+ const result = await runCommand(['git', 'remote', 'get-url', 'origin'], { cwd })
54
+ return result.stdout.trim()
55
+ }
56
+
57
+ export async function fetchOrigin(cwd: string): Promise<void> {
58
+ await runCommand(['git', 'fetch', 'origin', '--prune'], { cwd })
59
+ }
60
+
61
+ export async function fetchAllRemotes(cwd: string): Promise<void> {
62
+ await runCommand(['git', 'fetch', '--all', '--prune'], { cwd })
63
+ }
64
+
65
+ export async function getWorktreeStatus(cwd: string): Promise<string> {
66
+ const result = await runCommand(['git', 'status', '--short'], { cwd })
67
+ return result.stdout.trim()
68
+ }
69
+
70
+ export async function isWorktreeDirty(cwd: string): Promise<boolean> {
71
+ return Boolean(await getWorktreeStatus(cwd))
72
+ }
73
+
74
+ export async function stageAll(cwd: string): Promise<void> {
75
+ await runCommand(['git', 'add', '-A'], { cwd })
76
+ }
77
+
78
+ export async function getCurrentBranch(cwd: string): Promise<string> {
79
+ try {
80
+ const result = await runCommand(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd })
81
+ return result.stdout.trim()
82
+ }
83
+ catch (error) {
84
+ if (error instanceof CommandError) {
85
+ throw new Error('Detached HEAD; check out a branch before syncing or pushing.')
86
+ }
87
+ throw error
88
+ }
89
+ }
90
+
91
+ export async function getUpstreamBranch(cwd: string): Promise<string | null> {
92
+ const result = await runCommand(
93
+ ['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
94
+ { cwd, allowFailure: true },
95
+ )
96
+
97
+ if (result.exitCode !== 0) {
98
+ return null
99
+ }
100
+
101
+ return result.stdout.trim() || null
102
+ }
103
+
104
+ export async function pullCurrentBranch(cwd: string): Promise<void> {
105
+ await runCommand(['git', 'pull', '--ff-only'], { cwd })
106
+ }
107
+
108
+ export async function pullDefaultBranch(cwd: string, defaultBranch: string): Promise<void> {
109
+ await runCommand(['git', 'pull', '--ff-only', 'origin', defaultBranch], { cwd })
110
+ }
111
+
112
+ export async function getShortStatus(cwd: string): Promise<string> {
113
+ const result = await runCommand(['git', 'status', '--short'], { cwd })
114
+ return result.stdout
115
+ }
116
+
117
+ export function parseShortStatus(output: string): GitStatusEntry[] {
118
+ return output
119
+ .split(/\r?\n/)
120
+ .map(line => line.trimEnd())
121
+ .filter(Boolean)
122
+ .map((line) => {
123
+ const normalized = line.padEnd(3, ' ')
124
+ return {
125
+ index: normalized[0] ?? ' ',
126
+ worktree: normalized[1] ?? ' ',
127
+ path: normalized.slice(3).trim(),
128
+ }
129
+ })
130
+ }
131
+
132
+ export async function getStatusCounts(cwd: string): Promise<GitStatusCounts> {
133
+ const entries = parseShortStatus(await getShortStatus(cwd))
134
+ const counts: GitStatusCounts = {
135
+ staged: 0,
136
+ unstaged: 0,
137
+ untracked: 0,
138
+ total: entries.length,
139
+ }
140
+
141
+ for (const entry of entries) {
142
+ if (entry.index !== ' ' && entry.index !== '?') {
143
+ counts.staged += 1
144
+ }
145
+
146
+ if (entry.worktree !== ' ' && entry.worktree !== '?') {
147
+ counts.unstaged += 1
148
+ }
149
+
150
+ if (entry.index === '?' || entry.worktree === '?') {
151
+ counts.untracked += 1
152
+ }
153
+ }
154
+
155
+ return counts
156
+ }
157
+
158
+ export async function getStagedDiff(cwd: string): Promise<string> {
159
+ const result = await runCommand(['git', 'diff', '--cached'], { cwd })
160
+ return result.stdout
161
+ }
162
+
163
+ export async function getDiffAgainstHead(cwd: string): Promise<string> {
164
+ const result = await runCommand(['git', 'diff', 'HEAD', '--'], { cwd })
165
+ return result.stdout
166
+ }
167
+
168
+ export async function getRecentLog(cwd: string, count = 10): Promise<string> {
169
+ const result = await runCommand(['git', 'log', '--oneline', `-${count}`], { cwd, allowFailure: true })
170
+ return result.stdout
171
+ }
172
+
173
+ export async function getStagedFiles(cwd: string): Promise<string[]> {
174
+ const result = await runCommand(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACMRTUXB'], { cwd })
175
+ return result.stdout
176
+ .split(/\r?\n/)
177
+ .map(line => line.trim())
178
+ .filter(Boolean)
179
+ }
180
+
181
+ export async function getChangedFilesAgainstHead(cwd: string): Promise<string[]> {
182
+ const result = await runCommand(['git', 'diff', '--name-only', 'HEAD', '--diff-filter=ACMRTUXB'], { cwd })
183
+ return result.stdout
184
+ .split(/\r?\n/)
185
+ .map(line => line.trim())
186
+ .filter(Boolean)
187
+ }
188
+
189
+ export async function getDiffStatAgainstHead(cwd: string): Promise<string> {
190
+ const result = await runCommand(['git', 'diff', '--stat', 'HEAD'], { cwd, allowFailure: true })
191
+ return result.stdout.trim()
192
+ }
193
+
194
+ export function buildFallbackCommitMessage(files: string[]): string {
195
+ if (files.length === 0) {
196
+ return 'Update repository files'
197
+ }
198
+
199
+ if (files.length === 1) {
200
+ return `Update ${basename(files[0]!) || files[0]}`
201
+ }
202
+
203
+ return `Update ${basename(files[0]!) || files[0]} and ${files.length - 1} other files`
204
+ }
205
+
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
+ 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 })
223
+ }
224
+
225
+ export async function pushBranch(cwd: string, branch: string, setUpstream: boolean): Promise<void> {
226
+ const args = setUpstream
227
+ ? ['git', 'push', '--set-upstream', 'origin', branch]
228
+ : ['git', 'push']
229
+ await runCommand(args, { cwd })
230
+ }
231
+
232
+ export async function getHeadShortSha(cwd: string): Promise<string> {
233
+ const result = await runCommand(['git', 'rev-parse', '--short', 'HEAD'], { cwd })
234
+ return result.stdout.trim()
235
+ }
236
+
237
+ export async function getHeadCommitHash(cwd: string): Promise<string> {
238
+ const result = await runCommand(['git', 'rev-parse', 'HEAD'], { cwd })
239
+ return result.stdout.trim()
240
+ }
241
+
242
+ export async function getHeadCommitMessage(cwd: string): Promise<string> {
243
+ const result = await runCommand(['git', 'log', '-1', '--pretty=%B'], { cwd })
244
+ return result.stdout.trim()
245
+ }
246
+
247
+ export async function getHeadShortMessage(cwd: string): Promise<string> {
248
+ const result = await runCommand(['git', 'log', '-1', '--pretty=%s'], { cwd })
249
+ return result.stdout.trim()
250
+ }
@@ -0,0 +1,288 @@
1
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import React, { useEffect, useMemo, useState } from 'react'
5
+ import { Text } from 'ink'
6
+ import { getBootstrapPixelRows } from '../ui.ts'
7
+ import { AppFrame } from './components.tsx'
8
+ import {
9
+ renderCommandScreen,
10
+ type CommandScreenController,
11
+ } from './runtime.tsx'
12
+
13
+ export const BARK_FRAME_DURATION_MS = 500
14
+ export const BARK_FRAME_COUNT = 2
15
+ export const BARK_ANIMATION_DURATION_MS = BARK_FRAME_DURATION_MS * BARK_FRAME_COUNT
16
+
17
+ export const BARKING_PIXEL_ROWS = [
18
+ '3222222223344553222222233222',
19
+ '2223332224557888853233333222',
20
+ '2233433336688889a98534433222',
21
+ '2333433368877688aa9853433233',
22
+ '3333333499754477a89754333234',
23
+ '3433333685333455579755433344',
24
+ '3333324654136764238545643344',
25
+ '3333316443063136716542653334',
26
+ '3334214434671124964531554333',
27
+ '3334123235864246995431345443',
28
+ '3343112157743224786521335543',
29
+ '3452112245524023687520333543',
30
+ '3452111244424006276630323244',
31
+ '3342111354221000135420222344',
32
+ '3343143444411000036421223544',
33
+ '3444244511411223136322434544',
34
+ '4444444400221526155313245554',
35
+ '4443343421133546253553135555',
36
+ '44433344111218b9362991014555',
37
+ '3444333214113111627b41101355',
38
+ '333332111652233339c911111134',
39
+ '23342111169722236cb411111113',
40
+ '3333111117a51248ccb211111113',
41
+ '3333111129931117cc5111111113',
42
+ '333211112a9a2278971111111112',
43
+ '2231111119b912ab721111111102',
44
+ '2231111116b51299321111101101',
45
+ '2221111112721176111111101001',
46
+ ] as const
47
+
48
+
49
+ const BARK_FRAME_ROWS = [
50
+ [...getBootstrapPixelRows()],
51
+ [...BARKING_PIXEL_ROWS],
52
+ ] as const
53
+
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
+ export interface BarkAudioWriteResult {
60
+ directory: string
61
+ filePath: string
62
+ }
63
+
64
+ export interface BarkAudioPlaybackResult {
65
+ attempted: boolean
66
+ }
67
+
68
+ export interface BarkAudioPlaybackOptions {
69
+ silentBark?: boolean
70
+ env?: Record<string, string | undefined>
71
+ platform?: string
72
+ afplayPath?: string
73
+ audioPlayerExists?: (path: string) => boolean
74
+ writeAudioFile?: () => BarkAudioWriteResult
75
+ spawnAudio?: (command: string[]) => { exited?: Promise<unknown> } | void
76
+ }
77
+
78
+ export interface BarkIntroSessionOptions {
79
+ commandTitle: string
80
+ enabled: boolean
81
+ silentBark?: boolean
82
+ env?: Record<string, string | undefined>
83
+ platform?: string
84
+ afplayPath?: string
85
+ renderIntro?: (
86
+ build: (controller: CommandScreenController<void>) => React.ReactElement,
87
+ ) => Promise<void>
88
+ playAudio?: (options: BarkAudioPlaybackOptions) => Promise<BarkAudioPlaybackResult>
89
+ }
90
+
91
+ export interface BarkIntroSession {
92
+ playOnce(): Promise<void>
93
+ }
94
+
95
+ export function getBarkFrameRows(): string[][] {
96
+ return BARK_FRAME_ROWS.map(frame => [...frame])
97
+ }
98
+
99
+ export function getBarkAnimationFrameIndex(elapsedMs: number): number {
100
+ const clamped = Math.max(0, Math.min(elapsedMs, BARK_ANIMATION_DURATION_MS - 1))
101
+ return Math.floor(clamped / BARK_FRAME_DURATION_MS)
102
+ }
103
+
104
+ export function shouldShowBarkIntro(options: {
105
+ json?: boolean
106
+ stdinIsTTY?: boolean
107
+ stdoutIsTTY?: boolean
108
+ }): boolean {
109
+ return !options.json && Boolean(options.stdinIsTTY && options.stdoutIsTTY)
110
+ }
111
+
112
+ export function shouldPlayBarkAudio(options: {
113
+ silentBark?: boolean
114
+ env?: Record<string, string | undefined>
115
+ platform?: string
116
+ afplayPath?: string
117
+ audioPlayerExists?: (path: string) => boolean
118
+ }): boolean {
119
+ if (options.silentBark || options.env?.TELA_SILENT_BARK === '1') {
120
+ return false
121
+ }
122
+
123
+ if ((options.platform ?? process.platform) !== 'darwin') {
124
+ return false
125
+ }
126
+
127
+ const afplayPath = options.afplayPath ?? '/usr/bin/afplay'
128
+ const audioPlayerExists = options.audioPlayerExists ?? existsSync
129
+ return audioPlayerExists(afplayPath)
130
+ }
131
+
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 {
181
+ const directory = mkdtempSync(join(baseDirectory, 'tela-bark-'))
182
+ const filePath = join(directory, 'bark.wav')
183
+ writeFileSync(filePath, buildBarkWavBuffer())
184
+ return { directory, filePath }
185
+ }
186
+
187
+ function spawnAudioCommand(command: string[]): { exited?: Promise<unknown> } {
188
+ const proc = Bun.spawn(command, {
189
+ stdin: 'ignore',
190
+ stdout: 'ignore',
191
+ stderr: 'ignore',
192
+ })
193
+
194
+ return { exited: proc.exited }
195
+ }
196
+
197
+ export async function playBarkAudio(options: BarkAudioPlaybackOptions = {}): Promise<BarkAudioPlaybackResult> {
198
+ if (!shouldPlayBarkAudio(options)) {
199
+ return { attempted: false }
200
+ }
201
+
202
+ const writeAudioFile = options.writeAudioFile ?? writeBarkAudioFile
203
+ const spawnAudio = options.spawnAudio ?? spawnAudioCommand
204
+ const afplayPath = options.afplayPath ?? '/usr/bin/afplay'
205
+ const audioFile = writeAudioFile()
206
+
207
+ try {
208
+ const result = spawnAudio([afplayPath, audioFile.filePath])
209
+ void Promise.resolve(result?.exited)
210
+ .catch(() => {})
211
+ .finally(() => {
212
+ rmSync(audioFile.directory, { recursive: true, force: true })
213
+ })
214
+ return { attempted: true }
215
+ }
216
+ catch {
217
+ rmSync(audioFile.directory, { recursive: true, force: true })
218
+ return { attempted: false }
219
+ }
220
+ }
221
+
222
+ export function createInkBarkSession(options: BarkIntroSessionOptions): BarkIntroSession {
223
+ let played = false
224
+
225
+ return {
226
+ async playOnce(): Promise<void> {
227
+ if (played || !options.enabled) {
228
+ return
229
+ }
230
+
231
+ played = true
232
+
233
+ const playAudio = options.playAudio ?? playBarkAudio
234
+ void playAudio({
235
+ silentBark: options.silentBark,
236
+ env: options.env,
237
+ platform: options.platform,
238
+ afplayPath: options.afplayPath,
239
+ }).catch(() => {})
240
+
241
+ const renderIntro = options.renderIntro ?? (async (build) => {
242
+ await renderCommandScreen<void>(build, { clearOnExit: true })
243
+ })
244
+
245
+ await renderIntro((controller) => React.createElement(BarkIntroScreen, {
246
+ controller,
247
+ title: options.commandTitle,
248
+ }))
249
+ },
250
+ }
251
+ }
252
+
253
+ export function BarkIntroScreen(props: {
254
+ controller: CommandScreenController<void>
255
+ title: string
256
+ }): React.ReactElement {
257
+ const frames = useMemo(() => getBarkFrameRows(), [])
258
+ const [frameIndex, setFrameIndex] = useState(0)
259
+
260
+ useEffect(() => {
261
+ const interval = setInterval(() => {
262
+ setFrameIndex((current) => {
263
+ if (current >= frames.length - 1) {
264
+ clearInterval(interval)
265
+ props.controller.resolve(undefined, 20)
266
+ return current
267
+ }
268
+
269
+ return current + 1
270
+ })
271
+ }, BARK_FRAME_DURATION_MS)
272
+
273
+ return () => {
274
+ clearInterval(interval)
275
+ }
276
+ }, [frames.length, props.controller])
277
+
278
+ return (
279
+ <AppFrame
280
+ title={props.title}
281
+ subtitle="Starting interactive command"
282
+ status="Bark intro"
283
+ mascotRows={frames[frameIndex]}
284
+ >
285
+ <Text dimColor>Preparing the workflow…</Text>
286
+ </AppFrame>
287
+ )
288
+ }