@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/README.md +95 -0
- package/cli.ts +25 -0
- package/package.json +30 -0
- package/src/agent-api.ts +124 -0
- package/src/auth.ts +16 -0
- package/src/commands/doctor.ts +35 -0
- package/src/commands/feedback.ts +701 -0
- package/src/commands/metadata.ts +208 -0
- package/src/commands/push.ts +208 -0
- package/src/commands/run.ts +1601 -0
- package/src/commands/status.ts +87 -0
- package/src/commands/sync.ts +245 -0
- package/src/commit-message.ts +69 -0
- package/src/config.ts +384 -0
- package/src/diagnostics.ts +578 -0
- package/src/errors.ts +122 -0
- package/src/git.ts +250 -0
- package/src/ink/bark.tsx +288 -0
- package/src/ink/components.tsx +175 -0
- package/src/ink/runtime.tsx +89 -0
- package/src/ink/screens/feedback-screen.tsx +394 -0
- package/src/ink/screens/push-screen.tsx +231 -0
- package/src/ink/screens/run-screen.tsx +291 -0
- package/src/ink/screens/setup-screen.tsx +352 -0
- package/src/ink/screens/sync-screen.tsx +282 -0
- package/src/process.ts +251 -0
- package/src/program.ts +1872 -0
- package/src/runtime.ts +147 -0
- package/src/types.ts +80 -0
- package/src/ui.ts +297 -0
|
@@ -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
|
+
}
|