@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.
@@ -0,0 +1,352 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Box, Text, useInput } from 'ink'
3
+ import type { TelaCliConfig } from '../../types.ts'
4
+ import {
5
+ AGENT_API_BROWSER_HELP,
6
+ AGENT_API_PRESET_OPTIONS,
7
+ inferAgentApiPreset,
8
+ resolveAgentApiUrlForPreset,
9
+ } from '../../config.ts'
10
+ import { AppFrame, Callout, KeyHints, Section, SelectList, SpinnerText, TextEntry } from '../components.tsx'
11
+ import type { CommandScreenController } from '../runtime.tsx'
12
+
13
+ export interface SetupScreenResult {
14
+ ok: true
15
+ config: TelaCliConfig
16
+ warnings: string[]
17
+ nextActions: string[]
18
+ checks: Array<{ label: string, status: string, detail: string }>
19
+ }
20
+
21
+ export interface SetupScreenProps {
22
+ controller: CommandScreenController<SetupScreenResult>
23
+ currentConfigSummary: string[]
24
+ defaults: {
25
+ agentApiUrl: string
26
+ cloneRoot: string
27
+ apiKey: string
28
+ }
29
+ hasExistingApiKey: boolean
30
+ validateAgentApiUrl: (value: string) => string | undefined
31
+ validateCloneRoot: (value: string) => string | undefined
32
+ validateApiKey: (value: string, existingApiKey?: string) => string | undefined
33
+ inspectCloneRoot: (path: string) => {
34
+ exists: boolean
35
+ isDirectory: boolean
36
+ }
37
+ execute: (input: {
38
+ agentApiUrl: string
39
+ cloneRoot: string
40
+ apiKey: string
41
+ createCloneRoot: boolean
42
+ }) => Promise<SetupScreenResult>
43
+ }
44
+
45
+ type SetupField = 'agentApiPreset' | 'agentApiUrl' | 'cloneRoot' | 'apiKey'
46
+ type SetupPhase = 'editing' | 'review' | 'running' | 'done'
47
+
48
+ function fieldLabel(field: SetupField): string {
49
+ switch (field) {
50
+ case 'agentApiPreset':
51
+ return 'Agent API target'
52
+ case 'agentApiUrl':
53
+ return 'Custom Agent API origin URL'
54
+ case 'cloneRoot':
55
+ return 'Clone root directory'
56
+ case 'apiKey':
57
+ return 'API key'
58
+ }
59
+ }
60
+
61
+ export function SetupScreen(props: SetupScreenProps): React.ReactElement {
62
+ const initialAgentApiPreset = useMemo(() => inferAgentApiPreset(props.defaults.agentApiUrl), [props.defaults.agentApiUrl])
63
+ const [phase, setPhase] = useState<SetupPhase>('editing')
64
+ const [fieldIndex, setFieldIndex] = useState(0)
65
+ const [values, setValues] = useState({
66
+ agentApiPreset: initialAgentApiPreset,
67
+ agentApiUrl: initialAgentApiPreset === 'custom' ? props.defaults.agentApiUrl : '',
68
+ cloneRoot: props.defaults.cloneRoot,
69
+ apiKey: '',
70
+ })
71
+ const [errors, setErrors] = useState<Partial<Record<SetupField, string>>>({})
72
+ const [createCloneRoot, setCreateCloneRoot] = useState(true)
73
+ const [result, setResult] = useState<SetupScreenResult | null>(null)
74
+ const startedRef = useRef(false)
75
+
76
+ const fieldOrder = useMemo(() => {
77
+ const order: SetupField[] = ['agentApiPreset']
78
+ if (values.agentApiPreset === 'custom') {
79
+ order.push('agentApiUrl')
80
+ }
81
+
82
+ order.push('cloneRoot', 'apiKey')
83
+ return order
84
+ }, [values.agentApiPreset])
85
+ const currentField = fieldOrder[fieldIndex]!
86
+ const cloneRootState = useMemo(() => props.inspectCloneRoot(values.cloneRoot), [props, values.cloneRoot])
87
+ const selectedPresetIndex = useMemo(
88
+ () => AGENT_API_PRESET_OPTIONS.findIndex(option => option.value === values.agentApiPreset),
89
+ [values.agentApiPreset],
90
+ )
91
+ const resolvedAgentApiUrl = useMemo(() => {
92
+ if (values.agentApiPreset === 'custom') {
93
+ return props.validateAgentApiUrl(values.agentApiUrl)
94
+ ? values.agentApiUrl || '<invalid custom origin>'
95
+ : values.agentApiUrl
96
+ }
97
+
98
+ try {
99
+ return resolveAgentApiUrlForPreset(values.agentApiPreset, values.agentApiUrl)
100
+ }
101
+ catch {
102
+ return values.agentApiUrl || '<invalid custom origin>'
103
+ }
104
+ }, [props, values.agentApiPreset, values.agentApiUrl])
105
+
106
+ useEffect(() => {
107
+ if (phase !== 'running' || startedRef.current) {
108
+ return
109
+ }
110
+
111
+ startedRef.current = true
112
+ void props.execute({
113
+ agentApiUrl: resolvedAgentApiUrl,
114
+ cloneRoot: values.cloneRoot,
115
+ apiKey: values.apiKey,
116
+ createCloneRoot,
117
+ })
118
+ .then((nextResult) => {
119
+ setResult(nextResult)
120
+ setPhase('done')
121
+ })
122
+ .catch((error) => {
123
+ props.controller.reject(error)
124
+ })
125
+ }, [createCloneRoot, phase, props, values])
126
+
127
+ useEffect(() => {
128
+ if (phase === 'done' && result) {
129
+ props.controller.resolve(result, 40)
130
+ }
131
+ }, [phase, props, result])
132
+
133
+ useInput((input, key) => {
134
+ if (phase === 'editing') {
135
+ if (key.escape) {
136
+ props.controller.reject(new Error('Setup cancelled.'))
137
+ return
138
+ }
139
+
140
+ if (currentField === 'agentApiPreset' && (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)) {
141
+ const direction = (key.upArrow || key.leftArrow) ? -1 : 1
142
+ setValues(current => {
143
+ const currentIndex = AGENT_API_PRESET_OPTIONS.findIndex(option => option.value === current.agentApiPreset)
144
+ const nextIndex = (AGENT_API_PRESET_OPTIONS.length + currentIndex + direction) % AGENT_API_PRESET_OPTIONS.length
145
+ const nextPreset = AGENT_API_PRESET_OPTIONS[nextIndex]?.value ?? current.agentApiPreset
146
+ return {
147
+ ...current,
148
+ agentApiPreset: nextPreset,
149
+ }
150
+ })
151
+ return
152
+ }
153
+
154
+ if ((key.backspace || key.delete) && currentField !== 'agentApiPreset') {
155
+ setValues(current => ({
156
+ ...current,
157
+ [currentField]: current[currentField].slice(0, -1),
158
+ }))
159
+ setErrors(current => ({
160
+ ...current,
161
+ [currentField]: undefined,
162
+ }))
163
+ return
164
+ }
165
+
166
+ if (key.return) {
167
+ const error = currentField === 'agentApiPreset'
168
+ ? undefined
169
+ : currentField === 'agentApiUrl'
170
+ ? props.validateAgentApiUrl(values.agentApiUrl)
171
+ : currentField === 'cloneRoot'
172
+ ? props.validateCloneRoot(values.cloneRoot)
173
+ : props.validateApiKey(values.apiKey, props.hasExistingApiKey ? props.defaults.apiKey : undefined)
174
+
175
+ if (error) {
176
+ setErrors(current => ({
177
+ ...current,
178
+ [currentField]: error,
179
+ }))
180
+ return
181
+ }
182
+
183
+ if (fieldIndex === fieldOrder.length - 1) {
184
+ setPhase('review')
185
+ return
186
+ }
187
+
188
+ setFieldIndex(current => current + 1)
189
+ return
190
+ }
191
+
192
+ if (key.tab) {
193
+ setFieldIndex(current => (current + 1) % fieldOrder.length)
194
+ return
195
+ }
196
+
197
+ if (currentField === 'agentApiPreset') {
198
+ return
199
+ }
200
+
201
+ if (input) {
202
+ setValues(current => ({
203
+ ...current,
204
+ [currentField]: `${current[currentField]}${input}`,
205
+ }))
206
+ setErrors(current => ({
207
+ ...current,
208
+ [currentField]: undefined,
209
+ }))
210
+ }
211
+
212
+ return
213
+ }
214
+
215
+ if (phase === 'review') {
216
+ if (key.escape) {
217
+ setPhase('editing')
218
+ return
219
+ }
220
+
221
+ if (!cloneRootState.exists && (input === ' ' || key.leftArrow || key.rightArrow)) {
222
+ setCreateCloneRoot(current => !current)
223
+ return
224
+ }
225
+
226
+ if (key.return) {
227
+ setPhase('running')
228
+ }
229
+ }
230
+ })
231
+
232
+ return (
233
+ <AppFrame
234
+ title="Setup"
235
+ subtitle="Configure the local Tela environment with an Ink-driven wizard."
236
+ footer={
237
+ phase === 'editing'
238
+ ? '↑/↓ choose target · Type to edit fields · Enter next · Tab switch field · Esc cancel'
239
+ : phase === 'review'
240
+ ? 'Enter save and run checks · Space toggle clone-root creation · Esc back'
241
+ : undefined
242
+ }
243
+ status={phase === 'running' ? 'Saving config and running checks' : undefined}
244
+ >
245
+ {phase === 'editing' ? (
246
+ <>
247
+ <Section title="Current config">
248
+ {props.currentConfigSummary.map(line => (
249
+ <Text key={line}>{line}</Text>
250
+ ))}
251
+ </Section>
252
+ <Section title="Agent API target">
253
+ <SelectList
254
+ items={AGENT_API_PRESET_OPTIONS.map(option => ({
255
+ key: option.value,
256
+ label: option.label,
257
+ hint: option.hint,
258
+ }))}
259
+ currentIndex={selectedPresetIndex}
260
+ />
261
+ </Section>
262
+ <Callout tone="info">{AGENT_API_BROWSER_HELP}</Callout>
263
+ <Section title="Fields">
264
+ <Text>Resolved Agent API origin: {resolvedAgentApiUrl}</Text>
265
+ {values.agentApiPreset === 'custom' ? (
266
+ <Box marginTop={1}>
267
+ <Text color={currentField === 'agentApiUrl' ? 'greenBright' : undefined}>
268
+ {currentField === 'agentApiUrl' ? '› ' : ' '}
269
+ Custom Agent API origin URL
270
+ </Text>
271
+ <TextEntry
272
+ label=""
273
+ value={values.agentApiUrl}
274
+ placeholder="https://agent-api.example.com"
275
+ error={currentField === 'agentApiUrl' ? errors.agentApiUrl : undefined}
276
+ />
277
+ </Box>
278
+ ) : null}
279
+ <TextEntry
280
+ label={fieldLabel('cloneRoot')}
281
+ value={values.cloneRoot}
282
+ placeholder="/path/to/Code/Tela"
283
+ error={currentField === 'cloneRoot' ? errors.cloneRoot : undefined}
284
+ />
285
+ <TextEntry
286
+ label={fieldLabel('apiKey')}
287
+ value={values.apiKey}
288
+ placeholder={props.hasExistingApiKey ? 'Leave blank to keep the current API key' : 'Enter API key'}
289
+ masked
290
+ error={currentField === 'apiKey' ? errors.apiKey : undefined}
291
+ />
292
+ </Section>
293
+ <KeyHints items={[
294
+ `Active field: ${fieldLabel(currentField)}`,
295
+ `Target: ${AGENT_API_PRESET_OPTIONS[selectedPresetIndex]?.label ?? 'Custom'}`,
296
+ ]} />
297
+ </>
298
+ ) : null}
299
+
300
+ {phase === 'review' ? (
301
+ <>
302
+ <Section title="Review">
303
+ <Text>Agent API target: {AGENT_API_PRESET_OPTIONS[selectedPresetIndex]?.label ?? 'Custom'}</Text>
304
+ <Text>Agent API origin: {resolvedAgentApiUrl}</Text>
305
+ <Text>Clone root: {values.cloneRoot}</Text>
306
+ <Text>API key: {values.apiKey ? '*'.repeat(values.apiKey.length) : (props.hasExistingApiKey ? '<keep existing>' : '<missing>')}</Text>
307
+ <Text>Clone root state: {cloneRootState.exists ? (cloneRootState.isDirectory ? 'existing directory' : 'existing non-directory path') : 'missing'}</Text>
308
+ {!cloneRootState.exists ? (
309
+ <Text>Create clone root if missing: {createCloneRoot ? 'yes' : 'no'}</Text>
310
+ ) : null}
311
+ </Section>
312
+ <Callout tone="info">{AGENT_API_BROWSER_HELP}</Callout>
313
+ {!cloneRootState.exists ? (
314
+ <Callout tone="info">
315
+ Toggle clone-root creation before saving if you want setup to leave the path untouched.
316
+ </Callout>
317
+ ) : null}
318
+ </>
319
+ ) : null}
320
+
321
+ {phase === 'running' ? (
322
+ <Section title="Running">
323
+ <SpinnerText label="Persisting configuration and checking local dependencies" />
324
+ </Section>
325
+ ) : null}
326
+
327
+ {phase === 'done' && result ? (
328
+ <>
329
+ <Section title="Saved config">
330
+ <Text>Agent API origin: {result.config.agentApiUrl}</Text>
331
+ <Text>Clone root: {result.config.cloneRoot}</Text>
332
+ </Section>
333
+ <Callout tone="info">{AGENT_API_BROWSER_HELP}</Callout>
334
+ <Section title="Checks">
335
+ {result.checks.map(check => (
336
+ <Text key={check.label}>{`${check.label}: ${check.status} — ${check.detail}`}</Text>
337
+ ))}
338
+ </Section>
339
+ {result.warnings.length > 0 ? (
340
+ <Section title="Warnings">
341
+ {result.warnings.map(warning => (
342
+ <Text key={warning} color="yellow">{warning}</Text>
343
+ ))}
344
+ </Section>
345
+ ) : (
346
+ <Callout tone="success">Setup checks completed successfully.</Callout>
347
+ )}
348
+ </>
349
+ ) : null}
350
+ </AppFrame>
351
+ )
352
+ }
@@ -0,0 +1,282 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Box, Text, useInput } from 'ink'
3
+ import type { BatchSyncFailure, SyncResult } from '../../types.ts'
4
+ import { AppFrame, Callout, KeyHints, Section, SelectList, SpinnerText, TextEntry } from '../components.tsx'
5
+ import type { CommandScreenController } from '../runtime.tsx'
6
+
7
+ export interface SyncScreenResult {
8
+ ok: boolean
9
+ mode: 'current' | 'repository' | 'all'
10
+ successes: SyncResult[]
11
+ failures: BatchSyncFailure[]
12
+ nextActions: string[]
13
+ }
14
+
15
+ export interface SyncProgressItem {
16
+ id: string
17
+ label: string
18
+ status: 'pending' | 'running' | 'success' | 'failure'
19
+ detail?: string
20
+ }
21
+
22
+ export interface SyncScreenProps {
23
+ controller: CommandScreenController<SyncScreenResult>
24
+ mode: 'current' | 'repository' | 'all'
25
+ dryRun?: boolean
26
+ targetLabel: string
27
+ cloneRoot?: string
28
+ initialRepository?: string
29
+ availableRepositories?: string[]
30
+ execute: (
31
+ repository: string | undefined,
32
+ onProgress: (update: SyncProgressItem) => void,
33
+ ) => Promise<SyncScreenResult>
34
+ }
35
+
36
+ type SyncPhase = 'pick' | 'manual' | 'preview' | 'running' | 'done'
37
+
38
+ const MANUAL_ENTRY_VALUE = '__manual__'
39
+
40
+ export function SyncScreen(props: SyncScreenProps): React.ReactElement {
41
+ const [phase, setPhase] = useState<SyncPhase>(() => {
42
+ if (props.mode === 'repository' && !props.initialRepository) {
43
+ return props.availableRepositories && props.availableRepositories.length > 0 ? 'pick' : 'manual'
44
+ }
45
+
46
+ return 'preview'
47
+ })
48
+ const [repository, setRepository] = useState(props.initialRepository ?? '')
49
+ const [manualRepository, setManualRepository] = useState('')
50
+ const [currentIndex, setCurrentIndex] = useState(0)
51
+ const [result, setResult] = useState<SyncScreenResult | null>(null)
52
+ const [progressItems, setProgressItems] = useState<SyncProgressItem[]>(() => {
53
+ if (props.mode === 'all' && props.availableRepositories) {
54
+ return props.availableRepositories.map(repo => ({
55
+ id: repo,
56
+ label: repo,
57
+ status: 'pending',
58
+ }))
59
+ }
60
+
61
+ return []
62
+ })
63
+ const startedRef = useRef(false)
64
+
65
+ const selectableRepositories = useMemo(() => {
66
+ return [
67
+ ...(props.availableRepositories ?? []).map(repo => ({
68
+ key: repo,
69
+ label: repo,
70
+ })),
71
+ { key: MANUAL_ENTRY_VALUE, label: 'Enter a repository name manually' },
72
+ ]
73
+ }, [props.availableRepositories])
74
+
75
+ useEffect(() => {
76
+ if (phase !== 'running' || startedRef.current) {
77
+ return
78
+ }
79
+
80
+ startedRef.current = true
81
+ void props.execute(
82
+ repository || undefined,
83
+ (update) => {
84
+ setProgressItems(current => {
85
+ const existingIndex = current.findIndex(item => item.id === update.id)
86
+ if (existingIndex === -1) {
87
+ return [...current, update]
88
+ }
89
+
90
+ return current.map(item => item.id === update.id ? update : item)
91
+ })
92
+ },
93
+ )
94
+ .then((nextResult) => {
95
+ setResult(nextResult)
96
+ setPhase('done')
97
+ })
98
+ .catch((error) => {
99
+ props.controller.reject(error)
100
+ })
101
+ }, [phase, props, repository])
102
+
103
+ useEffect(() => {
104
+ if (phase === 'done' && result) {
105
+ props.controller.resolve(result, 40)
106
+ }
107
+ }, [phase, props, result])
108
+
109
+ useInput((input, key) => {
110
+ if (phase === 'pick') {
111
+ if (key.downArrow || input === 'j') {
112
+ setCurrentIndex(current => Math.min(current + 1, selectableRepositories.length - 1))
113
+ return
114
+ }
115
+
116
+ if (key.upArrow || input === 'k') {
117
+ setCurrentIndex(current => Math.max(current - 1, 0))
118
+ return
119
+ }
120
+
121
+ if (key.escape) {
122
+ props.controller.reject(new Error('Sync cancelled.'))
123
+ return
124
+ }
125
+
126
+ if (key.return) {
127
+ const selected = selectableRepositories[currentIndex]?.key
128
+ if (selected === MANUAL_ENTRY_VALUE) {
129
+ setPhase('manual')
130
+ return
131
+ }
132
+
133
+ setRepository(String(selected ?? ''))
134
+ setPhase('preview')
135
+ }
136
+
137
+ return
138
+ }
139
+
140
+ if (phase === 'manual') {
141
+ if (key.escape) {
142
+ props.controller.reject(new Error('Sync cancelled.'))
143
+ return
144
+ }
145
+
146
+ if (key.backspace || key.delete) {
147
+ setManualRepository(current => current.slice(0, -1))
148
+ return
149
+ }
150
+
151
+ if (key.return) {
152
+ if (!manualRepository.trim()) {
153
+ props.controller.reject(new Error('Repository name is required when not running inside a git repository.'))
154
+ return
155
+ }
156
+
157
+ setRepository(manualRepository.trim())
158
+ setPhase('preview')
159
+ return
160
+ }
161
+
162
+ if (input) {
163
+ setManualRepository(current => `${current}${input}`)
164
+ }
165
+ return
166
+ }
167
+
168
+ if (phase === 'preview') {
169
+ if (key.escape) {
170
+ props.controller.reject(new Error('Sync cancelled.'))
171
+ return
172
+ }
173
+
174
+ if (key.return) {
175
+ if (props.dryRun) {
176
+ setResult({
177
+ ok: true,
178
+ mode: props.mode,
179
+ successes: [],
180
+ failures: [],
181
+ nextActions: [
182
+ props.mode === 'current'
183
+ ? 'Run `tela sync --current` without `--dry-run` to update this checkout.'
184
+ : props.mode === 'all'
185
+ ? 'Run `tela sync --all` without `--dry-run` to refresh all repositories.'
186
+ : `Run \`tela sync ${repository}\` without \`--dry-run\` to clone or update the repository.`,
187
+ ],
188
+ })
189
+ setPhase('done')
190
+ return
191
+ }
192
+
193
+ setPhase('running')
194
+ }
195
+ }
196
+ })
197
+
198
+ return (
199
+ <AppFrame
200
+ title="Sync"
201
+ subtitle="Refresh the current checkout, a named repository, or the whole clone root."
202
+ footer={
203
+ phase === 'pick'
204
+ ? '↑/↓ move · Enter select · Esc cancel'
205
+ : phase === 'manual'
206
+ ? 'Type repository name · Enter continue · Esc cancel'
207
+ : phase === 'preview'
208
+ ? 'Enter run · Esc cancel'
209
+ : undefined
210
+ }
211
+ status={phase === 'running' ? 'Sync in progress' : undefined}
212
+ >
213
+ {phase === 'pick' ? (
214
+ <>
215
+ <Section title="Repository picker">
216
+ <SelectList items={selectableRepositories} currentIndex={currentIndex} />
217
+ </Section>
218
+ <KeyHints items={['Select a repository from the clone root or switch to manual entry']} />
219
+ </>
220
+ ) : null}
221
+
222
+ {phase === 'manual' ? (
223
+ <Section title="Repository name">
224
+ <TextEntry label="Repository" value={manualRepository} placeholder="agent-api" />
225
+ </Section>
226
+ ) : null}
227
+
228
+ {phase === 'preview' ? (
229
+ <>
230
+ <Section title={props.dryRun ? 'Dry run' : 'Plan'}>
231
+ <Text>Mode: {props.mode}</Text>
232
+ <Text>Target: {props.mode === 'repository' ? repository : props.targetLabel}</Text>
233
+ {props.cloneRoot ? <Text>Clone root: {props.cloneRoot}</Text> : null}
234
+ </Section>
235
+ {props.availableRepositories && props.mode === 'all' ? (
236
+ <Section title="Repositories">
237
+ {props.availableRepositories.map(repo => (
238
+ <Text key={repo}>{repo}</Text>
239
+ ))}
240
+ </Section>
241
+ ) : null}
242
+ </>
243
+ ) : null}
244
+
245
+ {phase === 'running' ? (
246
+ <>
247
+ <SpinnerText label={`Syncing ${props.mode === 'repository' ? repository : props.targetLabel}`} />
248
+ {progressItems.length > 0 ? (
249
+ <Section title="Progress">
250
+ {progressItems.map(item => (
251
+ <Text key={item.id}>
252
+ {`${item.label}: ${item.status}${item.detail ? ` — ${item.detail}` : ''}`}
253
+ </Text>
254
+ ))}
255
+ </Section>
256
+ ) : null}
257
+ </>
258
+ ) : null}
259
+
260
+ {phase === 'done' && result ? (
261
+ <>
262
+ <Section title="Result">
263
+ <Text>Mode: {result.mode}</Text>
264
+ <Text>Successes: {result.successes.length}</Text>
265
+ <Text>Failures: {result.failures.length}</Text>
266
+ </Section>
267
+ {result.failures.length > 0 ? (
268
+ <Section title="Failures">
269
+ {result.failures.map(failure => (
270
+ <Text key={failure.targetDir} color="yellow">
271
+ {`${failure.targetDir}: ${failure.error}`}
272
+ </Text>
273
+ ))}
274
+ </Section>
275
+ ) : (
276
+ <Callout tone="success">Sync finished successfully.</Callout>
277
+ )}
278
+ </>
279
+ ) : null}
280
+ </AppFrame>
281
+ )
282
+ }