@avantmedia/af 0.0.1
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/LICENSE +21 -0
- package/README.md +539 -0
- package/af +2 -0
- package/bun-upgrade.ts +130 -0
- package/commands/bun.ts +55 -0
- package/commands/changes.ts +35 -0
- package/commands/e2e.ts +12 -0
- package/commands/help.ts +236 -0
- package/commands/install-extension.ts +133 -0
- package/commands/jira.ts +577 -0
- package/commands/licenses.ts +32 -0
- package/commands/npm.ts +55 -0
- package/commands/scaffold.ts +105 -0
- package/commands/setup.tsx +156 -0
- package/commands/spec.ts +405 -0
- package/commands/stop-hook.ts +90 -0
- package/commands/todo.ts +208 -0
- package/commands/versions.ts +150 -0
- package/commands/watch.ts +344 -0
- package/commands/worktree.ts +424 -0
- package/components/change-select.tsx +71 -0
- package/components/confirm.tsx +41 -0
- package/components/file-conflict.tsx +52 -0
- package/components/input.tsx +53 -0
- package/components/layout.tsx +70 -0
- package/components/messages.tsx +48 -0
- package/components/progress.tsx +71 -0
- package/components/select.tsx +90 -0
- package/components/status-display.tsx +74 -0
- package/components/table.tsx +79 -0
- package/generated/setup-manifest.ts +67 -0
- package/git-worktree.ts +184 -0
- package/main.ts +12 -0
- package/npm-upgrade.ts +117 -0
- package/package.json +83 -0
- package/resources/copy-prompt-reporter.ts +443 -0
- package/router.ts +220 -0
- package/setup/.claude/commands/commit-work.md +47 -0
- package/setup/.claude/commands/complete-work.md +34 -0
- package/setup/.claude/commands/e2e.md +29 -0
- package/setup/.claude/commands/start-work.md +51 -0
- package/setup/.claude/skills/pm/SKILL.md +294 -0
- package/setup/.claude/skills/pm/templates/api-endpoint.md +69 -0
- package/setup/.claude/skills/pm/templates/bug-fix.md +77 -0
- package/setup/.claude/skills/pm/templates/feature.md +87 -0
- package/setup/.claude/skills/pm/templates/ui-component.md +78 -0
- package/utils/change-select-render.tsx +44 -0
- package/utils/claude.ts +9 -0
- package/utils/config.ts +58 -0
- package/utils/env.ts +53 -0
- package/utils/git.ts +120 -0
- package/utils/ink-render.tsx +50 -0
- package/utils/openspec.ts +54 -0
- package/utils/output.ts +104 -0
- package/utils/proposal.ts +160 -0
- package/utils/resources.ts +64 -0
- package/utils/setup-files.ts +230 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, copyFileSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
hasUncommittedChanges,
|
|
6
|
+
listWorktrees,
|
|
7
|
+
resetWorktree,
|
|
8
|
+
type Worktree,
|
|
9
|
+
} from '../git-worktree.ts';
|
|
10
|
+
import { error, info, success } from '../utils/output.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if we're in a git repository.
|
|
14
|
+
*
|
|
15
|
+
* @returns true if in a git repository, false otherwise
|
|
16
|
+
*/
|
|
17
|
+
export function isGitRepository(): boolean {
|
|
18
|
+
try {
|
|
19
|
+
execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the root directory of the current git repository.
|
|
28
|
+
*
|
|
29
|
+
* @returns The absolute path to the git root, or null if not in a repo
|
|
30
|
+
*/
|
|
31
|
+
export function getGitRoot(): string | null {
|
|
32
|
+
try {
|
|
33
|
+
return execSync('git rev-parse --show-toplevel', { stdio: 'pipe' }).toString().trim();
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a worktree already exists at the given path.
|
|
41
|
+
*
|
|
42
|
+
* @param path - The path to check
|
|
43
|
+
* @returns true if a directory exists at the path
|
|
44
|
+
*/
|
|
45
|
+
export function worktreeExists(path: string): boolean {
|
|
46
|
+
return existsSync(path);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse .git/info/exclude file and return patterns.
|
|
51
|
+
* Filters out comments and empty lines.
|
|
52
|
+
*
|
|
53
|
+
* @param gitRoot - The root of the git repository
|
|
54
|
+
* @returns Array of exclude patterns
|
|
55
|
+
*/
|
|
56
|
+
export function parseGitInfoExclude(gitRoot: string): string[] {
|
|
57
|
+
const excludePath = join(gitRoot, '.git', 'info', 'exclude');
|
|
58
|
+
|
|
59
|
+
if (!existsSync(excludePath)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const content = readFileSync(excludePath, 'utf-8');
|
|
65
|
+
return content
|
|
66
|
+
.split('\n')
|
|
67
|
+
.map(line => line.trim())
|
|
68
|
+
.filter(line => line.length > 0 && !line.startsWith('#'));
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find files matching patterns in .git/info/exclude using git ls-files.
|
|
76
|
+
* Uses git's native pattern matching to avoid reimplementing gitignore logic.
|
|
77
|
+
*
|
|
78
|
+
* @param gitRoot - The root of the git repository
|
|
79
|
+
* @returns Array of file paths that are excluded
|
|
80
|
+
*/
|
|
81
|
+
export function findExcludedFiles(gitRoot: string): string[] {
|
|
82
|
+
const excludePath = join(gitRoot, '.git', 'info', 'exclude');
|
|
83
|
+
|
|
84
|
+
if (!existsSync(excludePath)) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Use git ls-files to find files matching exclude patterns
|
|
90
|
+
const result = execSync(`git ls-files --others --ignored --exclude-from="${excludePath}"`, {
|
|
91
|
+
cwd: gitRoot,
|
|
92
|
+
stdio: 'pipe',
|
|
93
|
+
});
|
|
94
|
+
const files = result.toString().trim().split('\n').filter(Boolean);
|
|
95
|
+
return files;
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get the list of files to copy to the new worktree.
|
|
103
|
+
* Includes .env, .env.local, and files matching .git/info/exclude patterns.
|
|
104
|
+
*
|
|
105
|
+
* @param gitRoot - The root of the git repository
|
|
106
|
+
* @returns Array of file paths to copy
|
|
107
|
+
*/
|
|
108
|
+
export function getFilesToCopy(gitRoot: string): string[] {
|
|
109
|
+
const files: string[] = [];
|
|
110
|
+
|
|
111
|
+
// Add .env files if they exist
|
|
112
|
+
const envFiles = ['.env', '.env.local'];
|
|
113
|
+
for (const envFile of envFiles) {
|
|
114
|
+
const envPath = join(gitRoot, envFile);
|
|
115
|
+
if (existsSync(envPath)) {
|
|
116
|
+
files.push(envFile);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Add files from .git/info/exclude
|
|
121
|
+
const excludedFiles = findExcludedFiles(gitRoot);
|
|
122
|
+
files.push(...excludedFiles);
|
|
123
|
+
|
|
124
|
+
// Remove duplicates
|
|
125
|
+
return [...new Set(files)];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Copy a file to the worktree, creating parent directories if needed.
|
|
130
|
+
*
|
|
131
|
+
* @param sourceRoot - The source git root directory
|
|
132
|
+
* @param targetRoot - The target worktree directory
|
|
133
|
+
* @param relativePath - The relative path of the file to copy
|
|
134
|
+
* @returns true if copy succeeded, false otherwise
|
|
135
|
+
*/
|
|
136
|
+
export function copyFileToWorktree(
|
|
137
|
+
sourceRoot: string,
|
|
138
|
+
targetRoot: string,
|
|
139
|
+
relativePath: string,
|
|
140
|
+
): boolean {
|
|
141
|
+
const sourcePath = join(sourceRoot, relativePath);
|
|
142
|
+
const targetPath = join(targetRoot, relativePath);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
// Create parent directories if needed
|
|
146
|
+
const targetDir = dirname(targetPath);
|
|
147
|
+
if (!existsSync(targetDir)) {
|
|
148
|
+
mkdirSync(targetDir, { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
copyFileSync(sourcePath, targetPath);
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create a git worktree at the specified path.
|
|
160
|
+
*
|
|
161
|
+
* @param name - The name of the worktree (used for branch name and directory)
|
|
162
|
+
* @param detach - If true, create with detached HEAD instead of new branch
|
|
163
|
+
* @returns Object with success status and optional error message
|
|
164
|
+
*/
|
|
165
|
+
export function createWorktree(
|
|
166
|
+
name: string,
|
|
167
|
+
detach: boolean = false,
|
|
168
|
+
): { success: boolean; error?: string; path?: string } {
|
|
169
|
+
const gitRoot = getGitRoot();
|
|
170
|
+
if (!gitRoot) {
|
|
171
|
+
return { success: false, error: 'Not in a git repository' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Calculate target path as sibling directory
|
|
175
|
+
const parentDir = dirname(gitRoot);
|
|
176
|
+
const targetPath = resolve(parentDir, name);
|
|
177
|
+
|
|
178
|
+
// Check if target already exists
|
|
179
|
+
if (worktreeExists(targetPath)) {
|
|
180
|
+
return { success: false, error: `Directory already exists: ${targetPath}` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Build git worktree add command
|
|
184
|
+
const args = detach
|
|
185
|
+
? ['worktree', 'add', '--detach', targetPath]
|
|
186
|
+
: ['worktree', 'add', '-b', name, targetPath];
|
|
187
|
+
|
|
188
|
+
const result = spawnSync('git', args, { cwd: gitRoot, stdio: 'pipe' });
|
|
189
|
+
|
|
190
|
+
if (result.status !== 0) {
|
|
191
|
+
const stderr = result.stderr?.toString().trim() || 'Unknown error';
|
|
192
|
+
return { success: false, error: stderr };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { success: true, path: targetPath };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Handle the 'worktree new <name>' command.
|
|
200
|
+
* Creates a new git worktree as a sibling directory and copies env files.
|
|
201
|
+
*
|
|
202
|
+
* @param name - The name for the worktree (also used as branch name)
|
|
203
|
+
* @param detach - If true, create with detached HEAD instead of new branch
|
|
204
|
+
* @returns Exit code (0 for success, 1 for error)
|
|
205
|
+
*/
|
|
206
|
+
export function handleWorktreeNew(name: string | undefined, detach: boolean = false): number {
|
|
207
|
+
// Validate name is provided
|
|
208
|
+
if (!name || name.trim() === '') {
|
|
209
|
+
error('Error: worktree new requires a name');
|
|
210
|
+
console.error('Usage: af worktree new <name> [--detach]');
|
|
211
|
+
return 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check if in a git repository
|
|
215
|
+
if (!isGitRepository()) {
|
|
216
|
+
error('Error: Not in a git repository');
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const gitRoot = getGitRoot();
|
|
221
|
+
if (!gitRoot) {
|
|
222
|
+
error('Error: Could not determine git root');
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Create the worktree
|
|
227
|
+
info(`Creating worktree '${name}'${detach ? ' (detached)' : ' with new branch'}...`);
|
|
228
|
+
const result = createWorktree(name, detach);
|
|
229
|
+
|
|
230
|
+
if (!result.success) {
|
|
231
|
+
error(`Error: ${result.error}`);
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const targetPath = result.path!;
|
|
236
|
+
success(`Worktree created at ${targetPath}`);
|
|
237
|
+
|
|
238
|
+
// Copy files
|
|
239
|
+
const filesToCopy = getFilesToCopy(gitRoot);
|
|
240
|
+
if (filesToCopy.length > 0) {
|
|
241
|
+
info(`Copying ${filesToCopy.length} file(s)...`);
|
|
242
|
+
let copied = 0;
|
|
243
|
+
for (const file of filesToCopy) {
|
|
244
|
+
if (copyFileToWorktree(gitRoot, targetPath, file)) {
|
|
245
|
+
copied++;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
success(`Copied ${copied} file(s)`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get the current worktree information if running from within a worktree.
|
|
256
|
+
*
|
|
257
|
+
* Compares the current working directory against the list of worktrees
|
|
258
|
+
* to determine if we're in a worktree (excluding the main repository).
|
|
259
|
+
*
|
|
260
|
+
* @returns The current worktree if in one, or null if in the main repository
|
|
261
|
+
*/
|
|
262
|
+
export function getCurrentWorktree(): Worktree | null {
|
|
263
|
+
const currentPath = getGitRoot();
|
|
264
|
+
if (!currentPath) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const worktrees = listWorktrees();
|
|
269
|
+
|
|
270
|
+
// The first worktree in the list is typically the main repository
|
|
271
|
+
// We need to find if current path matches any non-main worktree
|
|
272
|
+
for (let i = 1; i < worktrees.length; i++) {
|
|
273
|
+
if (worktrees[i].path === currentPath) {
|
|
274
|
+
return worktrees[i];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Find a worktree by its branch name.
|
|
283
|
+
*
|
|
284
|
+
* @param name - The branch name to search for
|
|
285
|
+
* @returns The worktree if found, or null
|
|
286
|
+
*/
|
|
287
|
+
export function findWorktreeByName(name: string): Worktree | null {
|
|
288
|
+
const worktrees = listWorktrees();
|
|
289
|
+
return worktrees.find(wt => wt.branch === name) || null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get the main repository's worktree (the first in the list).
|
|
294
|
+
*
|
|
295
|
+
* @returns The main repository worktree, or null if not in a repo
|
|
296
|
+
*/
|
|
297
|
+
export function getMainWorktree(): Worktree | null {
|
|
298
|
+
const worktrees = listWorktrees();
|
|
299
|
+
return worktrees.length > 0 ? worktrees[0] : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Handle the 'worktree reset [name]' command.
|
|
304
|
+
* Resets a worktree to the current HEAD revision.
|
|
305
|
+
*
|
|
306
|
+
* @param name - Optional worktree name (branch). If not provided, resets current worktree.
|
|
307
|
+
* @returns Exit code (0 for success, 1 for error)
|
|
308
|
+
*/
|
|
309
|
+
export function handleWorktreeReset(name: string | undefined): number {
|
|
310
|
+
// Check if in a git repository
|
|
311
|
+
if (!isGitRepository()) {
|
|
312
|
+
error('Error: Not in a git repository');
|
|
313
|
+
return 1;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let targetWorktree: Worktree | null;
|
|
317
|
+
|
|
318
|
+
if (name) {
|
|
319
|
+
// Find worktree by name
|
|
320
|
+
targetWorktree = findWorktreeByName(name);
|
|
321
|
+
if (!targetWorktree) {
|
|
322
|
+
error(`Error: Worktree '${name}' not found`);
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
// Use current worktree
|
|
327
|
+
targetWorktree = getCurrentWorktree();
|
|
328
|
+
if (!targetWorktree) {
|
|
329
|
+
error(
|
|
330
|
+
'Error: Not in a worktree. Specify a worktree name or run from within a worktree.',
|
|
331
|
+
);
|
|
332
|
+
return 1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Get the main repository to get its HEAD commit
|
|
337
|
+
const mainWorktree = getMainWorktree();
|
|
338
|
+
if (!mainWorktree) {
|
|
339
|
+
error('Error: Could not find main repository');
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Get current HEAD commit from the main repository
|
|
344
|
+
let targetRevision: string;
|
|
345
|
+
try {
|
|
346
|
+
targetRevision = execSync('git rev-parse HEAD', {
|
|
347
|
+
cwd: mainWorktree.path,
|
|
348
|
+
encoding: 'utf-8',
|
|
349
|
+
}).trim();
|
|
350
|
+
} catch (err) {
|
|
351
|
+
if (err instanceof Error) {
|
|
352
|
+
error(`Error: Failed to get current HEAD commit: ${err.message}`);
|
|
353
|
+
} else {
|
|
354
|
+
error('Error: Failed to get current HEAD commit');
|
|
355
|
+
}
|
|
356
|
+
return 1;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Check for uncommitted changes
|
|
360
|
+
try {
|
|
361
|
+
if (hasUncommittedChanges(targetWorktree.path)) {
|
|
362
|
+
error('Error: Cannot reset worktree with uncommitted changes');
|
|
363
|
+
console.error(`Worktree '${targetWorktree.branch}' at ${targetWorktree.path}`);
|
|
364
|
+
console.error('\nPlease commit or stash changes and try again.');
|
|
365
|
+
console.error('You can check the status by running: git status');
|
|
366
|
+
return 1;
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
if (err instanceof Error) {
|
|
370
|
+
error(err.message);
|
|
371
|
+
} else {
|
|
372
|
+
error(`Failed to check worktree '${targetWorktree.branch}'`);
|
|
373
|
+
}
|
|
374
|
+
return 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Reset the worktree
|
|
378
|
+
info(`Resetting worktree ${targetWorktree.branch}...`);
|
|
379
|
+
try {
|
|
380
|
+
resetWorktree(targetWorktree.path, targetRevision);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (err instanceof Error) {
|
|
383
|
+
error(err.message);
|
|
384
|
+
} else {
|
|
385
|
+
error(`Failed to reset worktree '${targetWorktree.branch}'`);
|
|
386
|
+
}
|
|
387
|
+
return 1;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
success(`Successfully reset worktree '${targetWorktree.branch}'`);
|
|
391
|
+
return 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Handle the 'worktree' command routing.
|
|
396
|
+
*
|
|
397
|
+
* @param args - Command arguments after 'worktree'
|
|
398
|
+
* @returns Exit code (0 for success, 1 for error)
|
|
399
|
+
*/
|
|
400
|
+
export async function handleWorktree(args: string[]): Promise<number> {
|
|
401
|
+
const [subcommand, ...restArgs] = args;
|
|
402
|
+
|
|
403
|
+
if (subcommand === 'new') {
|
|
404
|
+
// Parse arguments
|
|
405
|
+
const detach = restArgs.includes('--detach');
|
|
406
|
+
const name = restArgs.find(arg => !arg.startsWith('--'));
|
|
407
|
+
return handleWorktreeNew(name, detach);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (subcommand === 'reset') {
|
|
411
|
+
const name = restArgs.find(arg => !arg.startsWith('--'));
|
|
412
|
+
return handleWorktreeReset(name);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!subcommand) {
|
|
416
|
+
error('Error: worktree command requires a subcommand');
|
|
417
|
+
console.error("Run 'af help worktree' for more information.");
|
|
418
|
+
return 1;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
error(`Error: Unknown worktree subcommand: ${subcommand}`);
|
|
422
|
+
console.error("Run 'af help worktree' for available subcommands.");
|
|
423
|
+
return 1;
|
|
424
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import type { OngoingChange } from '../utils/openspec.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Props for ChangeSelect component
|
|
7
|
+
*/
|
|
8
|
+
export interface ChangeSelectProps {
|
|
9
|
+
/** Array of ongoing changes to choose from */
|
|
10
|
+
changes: OngoingChange[];
|
|
11
|
+
/** Callback when a change is selected */
|
|
12
|
+
onSelect: (changeId: string) => void;
|
|
13
|
+
/** Callback when selection is cancelled */
|
|
14
|
+
onCancel: () => void;
|
|
15
|
+
/** Custom prompt text to display (default: "Select a change:") */
|
|
16
|
+
prompt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Interactive change selection component for commands that need to select from ongoing changes.
|
|
21
|
+
* Allows users to select from multiple ongoing changes using keyboard navigation.
|
|
22
|
+
*/
|
|
23
|
+
export function ChangeSelect({
|
|
24
|
+
changes,
|
|
25
|
+
onSelect,
|
|
26
|
+
onCancel,
|
|
27
|
+
prompt = 'Select a change:',
|
|
28
|
+
}: ChangeSelectProps) {
|
|
29
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
30
|
+
const { exit } = useApp();
|
|
31
|
+
|
|
32
|
+
useInput((input, key) => {
|
|
33
|
+
if (key.upArrow) {
|
|
34
|
+
setSelectedIndex(prev => (prev > 0 ? prev - 1 : changes.length - 1));
|
|
35
|
+
} else if (key.downArrow) {
|
|
36
|
+
setSelectedIndex(prev => (prev < changes.length - 1 ? prev + 1 : 0));
|
|
37
|
+
} else if (key.return) {
|
|
38
|
+
const selectedChange = changes[selectedIndex];
|
|
39
|
+
onSelect(selectedChange.id);
|
|
40
|
+
} else if (key.escape || (key.ctrl && input === 'c')) {
|
|
41
|
+
onCancel();
|
|
42
|
+
exit();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Box flexDirection="column">
|
|
48
|
+
<Box marginBottom={1}>
|
|
49
|
+
<Text bold color="cyan">
|
|
50
|
+
{prompt}
|
|
51
|
+
</Text>
|
|
52
|
+
</Box>
|
|
53
|
+
{changes.map((change, index) => {
|
|
54
|
+
const isHighlighted = index === selectedIndex;
|
|
55
|
+
const indicator = isHighlighted ? '›' : ' ';
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Box key={change.id}>
|
|
59
|
+
<Text color={isHighlighted ? 'cyan' : undefined}>
|
|
60
|
+
{indicator} {change.id}
|
|
61
|
+
</Text>
|
|
62
|
+
<Text color="gray"> ({change.status})</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
);
|
|
65
|
+
})}
|
|
66
|
+
<Box marginTop={1}>
|
|
67
|
+
<Text color="gray">↑/↓ to navigate, Enter to select, Esc to cancel</Text>
|
|
68
|
+
</Box>
|
|
69
|
+
</Box>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Box, Text, useInput } from 'ink';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Props for Confirm component
|
|
5
|
+
*/
|
|
6
|
+
export interface ConfirmProps {
|
|
7
|
+
/** The question to ask the user */
|
|
8
|
+
message: string;
|
|
9
|
+
/** Default value (default: false) */
|
|
10
|
+
defaultValue?: boolean;
|
|
11
|
+
/** Callback when user confirms (true) or denies (false) */
|
|
12
|
+
onConfirm?: (confirmed: boolean) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Confirm component for yes/no prompts
|
|
17
|
+
* Accepts y/n or yes/no input
|
|
18
|
+
*/
|
|
19
|
+
export function Confirm({ message, defaultValue = false, onConfirm }: ConfirmProps) {
|
|
20
|
+
useInput(input => {
|
|
21
|
+
const normalized = input.toLowerCase();
|
|
22
|
+
|
|
23
|
+
if (normalized === 'y' || normalized === 'yes') {
|
|
24
|
+
onConfirm?.(true);
|
|
25
|
+
} else if (normalized === 'n' || normalized === 'no') {
|
|
26
|
+
onConfirm?.(false);
|
|
27
|
+
} else if (input === '') {
|
|
28
|
+
// Enter with no input uses default
|
|
29
|
+
onConfirm?.(defaultValue);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const hint = defaultValue ? '(Y/n)' : '(y/N)';
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Box>
|
|
37
|
+
<Text>{message} </Text>
|
|
38
|
+
<Text color="gray">{hint}</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Box, Text, useInput } from 'ink';
|
|
2
|
+
import type { ConflictResolution } from '../utils/setup-files.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for FileConflict component
|
|
6
|
+
*/
|
|
7
|
+
export interface FileConflictProps {
|
|
8
|
+
/** The file path that has a conflict */
|
|
9
|
+
filePath: string;
|
|
10
|
+
/** Callback when user makes a resolution choice */
|
|
11
|
+
onResolve: (resolution: ConflictResolution) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Interactive component for resolving file conflicts during setup.
|
|
16
|
+
* Displays the conflicting file path and accepts keyboard input:
|
|
17
|
+
* - y: Overwrite this file
|
|
18
|
+
* - n: Skip this file
|
|
19
|
+
* - a: Overwrite all remaining files
|
|
20
|
+
* - s: Skip all remaining files
|
|
21
|
+
*/
|
|
22
|
+
export function FileConflict({ filePath, onResolve }: FileConflictProps) {
|
|
23
|
+
useInput(input => {
|
|
24
|
+
const key = input.toLowerCase();
|
|
25
|
+
switch (key) {
|
|
26
|
+
case 'y':
|
|
27
|
+
onResolve('overwrite');
|
|
28
|
+
break;
|
|
29
|
+
case 'n':
|
|
30
|
+
onResolve('skip');
|
|
31
|
+
break;
|
|
32
|
+
case 'a':
|
|
33
|
+
onResolve('overwrite-all');
|
|
34
|
+
break;
|
|
35
|
+
case 's':
|
|
36
|
+
onResolve('skip-all');
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box flexDirection="column">
|
|
43
|
+
<Box>
|
|
44
|
+
<Text color="yellow">File exists: </Text>
|
|
45
|
+
<Text>{filePath}</Text>
|
|
46
|
+
</Box>
|
|
47
|
+
<Box marginTop={1}>
|
|
48
|
+
<Text color="gray">[y] Overwrite [n] Skip [a] Overwrite all [s] Skip all</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
</Box>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Box, Text, useInput } from 'ink';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for TextInput component
|
|
6
|
+
*/
|
|
7
|
+
export interface TextInputProps {
|
|
8
|
+
/** Placeholder text to display when input is empty */
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
/** Initial value of the input */
|
|
11
|
+
value?: string;
|
|
12
|
+
/** Callback when input value changes */
|
|
13
|
+
onChange?: (value: string) => void;
|
|
14
|
+
/** Callback when user presses Enter */
|
|
15
|
+
onSubmit?: (value: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* TextInput component for user text input
|
|
20
|
+
* Provides controlled text input with keyboard handling
|
|
21
|
+
*/
|
|
22
|
+
export function TextInput({
|
|
23
|
+
placeholder = '',
|
|
24
|
+
value: initialValue = '',
|
|
25
|
+
onChange,
|
|
26
|
+
onSubmit,
|
|
27
|
+
}: TextInputProps) {
|
|
28
|
+
const [value, setValue] = useState(initialValue);
|
|
29
|
+
|
|
30
|
+
useInput((input, key) => {
|
|
31
|
+
if (key.return) {
|
|
32
|
+
// Enter key pressed
|
|
33
|
+
onSubmit?.(value);
|
|
34
|
+
} else if (key.backspace || key.delete) {
|
|
35
|
+
// Backspace/Delete key pressed
|
|
36
|
+
const newValue = value.slice(0, -1);
|
|
37
|
+
setValue(newValue);
|
|
38
|
+
onChange?.(newValue);
|
|
39
|
+
} else if (!key.ctrl && !key.meta && !key.escape) {
|
|
40
|
+
// Regular character input
|
|
41
|
+
const newValue = value + input;
|
|
42
|
+
setValue(newValue);
|
|
43
|
+
onChange?.(newValue);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Box>
|
|
49
|
+
<Text>{value || <Text color="gray">{placeholder}</Text>}</Text>
|
|
50
|
+
<Text color="cyan">█</Text>
|
|
51
|
+
</Box>
|
|
52
|
+
);
|
|
53
|
+
}
|