@tsuzuku/arise 1.1.1 → 1.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 +28 -4
- package/index.js +47 -9
- package/lib/cli.js +39 -2
- package/lib/config/init.js +862 -0
- package/lib/config.js +24 -8
- package/lib/git.js +29 -0
- package/lib/herdr.js +47 -6
- package/lib/interactive.js +644 -0
- package/lib/layout.js +21 -7
- package/lib/lifecycle/create.js +28 -4
- package/lib/lifecycle/nuke.js +44 -2
- package/lib/logger.js +85 -0
- package/lib/skill.js +6 -0
- package/lib/tui/ansi.js +294 -0
- package/lib/tui/prompt.js +629 -0
- package/package.json +1 -1
- package/skills/arise/SKILL.md +6 -0
- package/types.d.ts +46 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const readline = require('readline');
|
|
3
|
+
const git = require('./git');
|
|
4
|
+
const herdr = require('./herdr');
|
|
5
|
+
const { executeCreate } = require('./lifecycle/create');
|
|
6
|
+
const { executeNuke } = require('./lifecycle/nuke');
|
|
7
|
+
const pkg = require('../package.json');
|
|
8
|
+
|
|
9
|
+
const style = {
|
|
10
|
+
bold: (s) => `\x1b[1m${s}\x1b[22m`,
|
|
11
|
+
dim: (s) => `\x1b[2m${s}\x1b[22m`,
|
|
12
|
+
cyan: (s) => `\x1b[36m${s}\x1b[39m`,
|
|
13
|
+
green: (s) => `\x1b[32m${s}\x1b[39m`,
|
|
14
|
+
yellow: (s) => `\x1b[33m${s}\x1b[39m`,
|
|
15
|
+
red: (s) => `\x1b[31m${s}\x1b[39m`,
|
|
16
|
+
magenta: (s) => `\x1b[35m${s}\x1b[39m`,
|
|
17
|
+
blue: (s) => `\x1b[34m${s}\x1b[39m`,
|
|
18
|
+
gray: (s) => `\x1b[90m${s}\x1b[39m`,
|
|
19
|
+
bgCyan: (s) => `\x1b[46m\x1b[30m${s}\x1b[39m\x1b[49m`,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function promptSelect({ title, items = [], defaultIndex = 0 }) {
|
|
23
|
+
if (!items.length) return Promise.resolve(null);
|
|
24
|
+
|
|
25
|
+
if (!process.stdin.isTTY) {
|
|
26
|
+
// Non-TTY fallback
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
console.log(`\n${title}`);
|
|
29
|
+
items.forEach((item, idx) => {
|
|
30
|
+
const label = typeof item === 'object' ? (item.title || item.label || item.value) : item;
|
|
31
|
+
console.log(` ${idx + 1}) ${label}`);
|
|
32
|
+
});
|
|
33
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
34
|
+
rl.question(`Enter choice (1-${items.length}): `, (answer) => {
|
|
35
|
+
rl.close();
|
|
36
|
+
const num = parseInt(answer.trim(), 10);
|
|
37
|
+
if (!isNaN(num) && num >= 1 && num <= items.length) {
|
|
38
|
+
resolve(items[num - 1]);
|
|
39
|
+
} else {
|
|
40
|
+
resolve(items[defaultIndex] || items[0]);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let selectedIndex = Math.max(0, Math.min(defaultIndex, items.length - 1));
|
|
48
|
+
let renderedLineCount = 0;
|
|
49
|
+
|
|
50
|
+
const render = (isInitial = false) => {
|
|
51
|
+
const lines = [];
|
|
52
|
+
if (title) {
|
|
53
|
+
lines.push(`${style.bold(style.cyan('?'))} ${style.bold(title)} ${style.gray('(Use arrow keys or numbers, Enter to select)')}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
items.forEach((item, idx) => {
|
|
57
|
+
const isSelected = idx === selectedIndex;
|
|
58
|
+
const pointer = isSelected ? style.cyan('❯') : ' ';
|
|
59
|
+
const numStr = style.gray(`${idx + 1})`);
|
|
60
|
+
const label = typeof item === 'object' ? (item.title || item.label || item.value) : item;
|
|
61
|
+
const desc = typeof item === 'object' && item.description ? ` ${style.dim(item.description)}` : '';
|
|
62
|
+
|
|
63
|
+
if (isSelected) {
|
|
64
|
+
lines.push(` ${pointer} ${numStr} ${style.bold(style.cyan(label))}${desc}`);
|
|
65
|
+
} else {
|
|
66
|
+
lines.push(` ${pointer} ${numStr} ${label}${desc}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!isInitial && renderedLineCount > 0) {
|
|
71
|
+
readline.moveCursor(process.stdout, 0, -renderedLineCount);
|
|
72
|
+
readline.clearScreenDown(process.stdout);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
76
|
+
renderedLineCount = lines.length;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
readline.emitKeypressEvents(process.stdin);
|
|
80
|
+
const wasRaw = process.stdin.isRaw;
|
|
81
|
+
if (process.stdin.setRawMode) {
|
|
82
|
+
process.stdin.setRawMode(true);
|
|
83
|
+
}
|
|
84
|
+
process.stdin.resume();
|
|
85
|
+
|
|
86
|
+
render(true);
|
|
87
|
+
|
|
88
|
+
const onKeypress = (str, key) => {
|
|
89
|
+
if (key && (key.ctrl && key.name === 'c')) {
|
|
90
|
+
cleanup();
|
|
91
|
+
process.stdout.write('\n');
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (key && (key.name === 'escape' || str === 'q')) {
|
|
96
|
+
cleanup();
|
|
97
|
+
resolve(null);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (key && (key.name === 'up' || key.name === 'k')) {
|
|
102
|
+
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
|
|
103
|
+
render();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (key && (key.name === 'down' || key.name === 'j')) {
|
|
108
|
+
selectedIndex = (selectedIndex + 1) % items.length;
|
|
109
|
+
render();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Quick number select (1..9)
|
|
114
|
+
const num = parseInt(str, 10);
|
|
115
|
+
if (!isNaN(num) && num >= 1 && num <= items.length) {
|
|
116
|
+
selectedIndex = num - 1;
|
|
117
|
+
cleanup();
|
|
118
|
+
resolve(items[selectedIndex]);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (key && (key.name === 'return' || key.name === 'enter' || key.name === 'space')) {
|
|
123
|
+
cleanup();
|
|
124
|
+
resolve(items[selectedIndex]);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const cleanup = () => {
|
|
129
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
130
|
+
if (process.stdin.setRawMode) {
|
|
131
|
+
process.stdin.setRawMode(wasRaw || false);
|
|
132
|
+
}
|
|
133
|
+
process.stdin.pause();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
process.stdin.on('keypress', onKeypress);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function promptMultiSelect({ title, items = [] }) {
|
|
141
|
+
if (!items.length) return Promise.resolve([]);
|
|
142
|
+
|
|
143
|
+
if (!process.stdin.isTTY) {
|
|
144
|
+
return Promise.resolve(items);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
let cursorIndex = 0;
|
|
149
|
+
const selected = new Set();
|
|
150
|
+
let renderedLineCount = 0;
|
|
151
|
+
|
|
152
|
+
const render = (isInitial = false) => {
|
|
153
|
+
const lines = [];
|
|
154
|
+
if (title) {
|
|
155
|
+
lines.push(`${style.bold(style.cyan('?'))} ${style.bold(title)} ${style.gray('(Space to toggle, "a" to toggle all, Enter to confirm)')}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
items.forEach((item, idx) => {
|
|
159
|
+
const isCursor = idx === cursorIndex;
|
|
160
|
+
const isChecked = selected.has(idx);
|
|
161
|
+
const pointer = isCursor ? style.cyan('❯') : ' ';
|
|
162
|
+
const checkbox = isChecked ? style.green('[✔]') : style.gray('[ ]');
|
|
163
|
+
const label = typeof item === 'object' ? (item.title || item.label || item.value) : item;
|
|
164
|
+
const desc = typeof item === 'object' && item.description ? ` ${style.dim(item.description)}` : '';
|
|
165
|
+
|
|
166
|
+
if (isCursor) {
|
|
167
|
+
lines.push(` ${pointer} ${checkbox} ${style.bold(style.cyan(label))}${desc}`);
|
|
168
|
+
} else {
|
|
169
|
+
lines.push(` ${pointer} ${checkbox} ${label}${desc}`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!isInitial && renderedLineCount > 0) {
|
|
174
|
+
readline.moveCursor(process.stdout, 0, -renderedLineCount);
|
|
175
|
+
readline.clearScreenDown(process.stdout);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
179
|
+
renderedLineCount = lines.length;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
readline.emitKeypressEvents(process.stdin);
|
|
183
|
+
const wasRaw = process.stdin.isRaw;
|
|
184
|
+
if (process.stdin.setRawMode) {
|
|
185
|
+
process.stdin.setRawMode(true);
|
|
186
|
+
}
|
|
187
|
+
process.stdin.resume();
|
|
188
|
+
|
|
189
|
+
render(true);
|
|
190
|
+
|
|
191
|
+
const onKeypress = (str, key) => {
|
|
192
|
+
if (key && (key.ctrl && key.name === 'c')) {
|
|
193
|
+
cleanup();
|
|
194
|
+
process.stdout.write('\n');
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (key && (key.name === 'escape' || str === 'q')) {
|
|
199
|
+
cleanup();
|
|
200
|
+
resolve([]);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (key && (key.name === 'up' || key.name === 'k')) {
|
|
205
|
+
cursorIndex = (cursorIndex - 1 + items.length) % items.length;
|
|
206
|
+
render();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (key && (key.name === 'down' || key.name === 'j')) {
|
|
211
|
+
cursorIndex = (cursorIndex + 1) % items.length;
|
|
212
|
+
render();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (str === ' ' || (key && key.name === 'space')) {
|
|
217
|
+
if (selected.has(cursorIndex)) {
|
|
218
|
+
selected.delete(cursorIndex);
|
|
219
|
+
} else {
|
|
220
|
+
selected.add(cursorIndex);
|
|
221
|
+
}
|
|
222
|
+
render();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (str === 'a' || str === 'A') {
|
|
227
|
+
if (selected.size === items.length) {
|
|
228
|
+
selected.clear();
|
|
229
|
+
} else {
|
|
230
|
+
items.forEach((_, idx) => selected.add(idx));
|
|
231
|
+
}
|
|
232
|
+
render();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (key && (key.name === 'return' || key.name === 'enter')) {
|
|
237
|
+
cleanup();
|
|
238
|
+
// If none explicitly checked, use the item currently under the cursor
|
|
239
|
+
const results = selected.size > 0
|
|
240
|
+
? Array.from(selected).map(idx => items[idx])
|
|
241
|
+
: [items[cursorIndex]];
|
|
242
|
+
resolve(results);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const cleanup = () => {
|
|
247
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
248
|
+
if (process.stdin.setRawMode) {
|
|
249
|
+
process.stdin.setRawMode(wasRaw || false);
|
|
250
|
+
}
|
|
251
|
+
process.stdin.pause();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
process.stdin.on('keypress', onKeypress);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function promptText({ question, defaultValue = '', validate = null }) {
|
|
259
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
260
|
+
const defaultHint = defaultValue ? style.gray(` (${defaultValue})`) : '';
|
|
261
|
+
const promptStr = `${style.bold(style.cyan('?'))} ${style.bold(question)}${defaultHint}: `;
|
|
262
|
+
|
|
263
|
+
return new Promise((resolve) => {
|
|
264
|
+
const ask = () => {
|
|
265
|
+
rl.question(promptStr, (answer) => {
|
|
266
|
+
const val = answer.trim() || defaultValue;
|
|
267
|
+
if (validate) {
|
|
268
|
+
const valid = validate(val);
|
|
269
|
+
if (valid !== true) {
|
|
270
|
+
console.log(` ${style.red('✖')} ${valid || 'Invalid input'}`);
|
|
271
|
+
ask();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
rl.close();
|
|
276
|
+
resolve(val);
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
ask();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function promptConfirm({ question, defaultYes = false }) {
|
|
284
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
285
|
+
const hint = defaultYes ? '[Y/n]' : '[y/N]';
|
|
286
|
+
const promptStr = `${style.bold(style.yellow('?'))} ${style.bold(question)} ${style.dim(hint)}: `;
|
|
287
|
+
|
|
288
|
+
return new Promise((resolve) => {
|
|
289
|
+
rl.question(promptStr, (answer) => {
|
|
290
|
+
rl.close();
|
|
291
|
+
const val = answer.trim().toLowerCase();
|
|
292
|
+
if (!val) {
|
|
293
|
+
resolve(defaultYes);
|
|
294
|
+
} else {
|
|
295
|
+
resolve(val === 'y' || val === 'yes');
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function promptPause(message = 'Press Enter to continue...') {
|
|
302
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
303
|
+
return new Promise((resolve) => {
|
|
304
|
+
rl.question(`\n${style.dim(message)}`, () => {
|
|
305
|
+
rl.close();
|
|
306
|
+
resolve();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function renderHeader(config, cwd = process.cwd()) {
|
|
312
|
+
const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
|
|
313
|
+
const presetName = config.preset ? config.preset.name : 'generic';
|
|
314
|
+
const worktrees = git.getWorktrees({ repoDir: repoRoot, bareRepo: config.repo.bareRepo });
|
|
315
|
+
const workspaces = herdr.listWorkspaces();
|
|
316
|
+
|
|
317
|
+
console.log(`\n${style.bold(style.cyan(' ┌─────────────────────────────────────────────────────────────┐'))}`);
|
|
318
|
+
console.log(`${style.bold(style.cyan(' │'))} ${style.bold(style.magenta('🚀 ARISE'))} ${style.dim(`v${pkg.version}`)} — ${style.bold('Git Worktree & Herdr Orchestrator')} ${style.bold(style.cyan('│'))}`);
|
|
319
|
+
console.log(`${style.bold(style.cyan(' └─────────────────────────────────────────────────────────────┘'))}`);
|
|
320
|
+
console.log(` ${style.bold('Repository:')} ${style.cyan(repoRoot)}`);
|
|
321
|
+
console.log(` ${style.bold('Preset:')} ${style.green(presetName)} │ ${style.bold('Worktrees:')} ${style.yellow(worktrees.length)} │ ${style.bold('Herdr Sessions:')} ${style.blue(workspaces.length)}\n`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function handleCreateWorktree(flags, config, cwd) {
|
|
325
|
+
const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
|
|
326
|
+
const gitOpts = { repoDir: repoRoot, bareRepo: config.repo.bareRepo };
|
|
327
|
+
const localBranches = git.getLocalBranches(gitOpts);
|
|
328
|
+
const defaultSource = flags.source || config.repo.defaultBaseBranch || 'main';
|
|
329
|
+
|
|
330
|
+
console.log(`\n${style.bold(style.cyan('=== 🚀 Create New Git Worktree ==='))}\n`);
|
|
331
|
+
|
|
332
|
+
// 1. Select / Enter Base Source Branch
|
|
333
|
+
const branchChoices = [];
|
|
334
|
+
if (defaultSource) {
|
|
335
|
+
branchChoices.push({
|
|
336
|
+
title: `${defaultSource} ${style.green('(Preset Default)')}`,
|
|
337
|
+
value: defaultSource,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const b of localBranches) {
|
|
342
|
+
if (b !== defaultSource && !branchChoices.some(c => c.value === b)) {
|
|
343
|
+
branchChoices.push({ title: b, value: b });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
branchChoices.push({
|
|
348
|
+
title: `[ Enter custom branch or ref... ]`,
|
|
349
|
+
value: '__custom__',
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const selectedChoice = await promptSelect({
|
|
353
|
+
title: 'Select source base branch:',
|
|
354
|
+
items: branchChoices,
|
|
355
|
+
defaultIndex: 0,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (!selectedChoice) return;
|
|
359
|
+
|
|
360
|
+
let sourceBranch = selectedChoice.value;
|
|
361
|
+
if (sourceBranch === '__custom__') {
|
|
362
|
+
sourceBranch = await promptText({
|
|
363
|
+
question: 'Enter base branch or ref to branch off',
|
|
364
|
+
defaultValue: defaultSource,
|
|
365
|
+
validate: (val) => Boolean(val.trim()) || 'Base branch cannot be empty',
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 2. Enter New Branch Name
|
|
370
|
+
const branchName = await promptText({
|
|
371
|
+
question: 'Enter new branch name (e.g. feature/login, fix/api-cache)',
|
|
372
|
+
validate: (val) => {
|
|
373
|
+
const trimmed = val.trim();
|
|
374
|
+
if (!trimmed) return 'Branch name cannot be empty';
|
|
375
|
+
if (/\s/.test(trimmed)) return 'Branch name cannot contain spaces';
|
|
376
|
+
return true;
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
if (!branchName) return;
|
|
381
|
+
|
|
382
|
+
// 3. Optional Dirname
|
|
383
|
+
const defaultDirname = branchName.replace(/\//g, '-');
|
|
384
|
+
const customDir = await promptText({
|
|
385
|
+
question: 'Worktree directory name',
|
|
386
|
+
defaultValue: defaultDirname,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// 4. Optional Herdr Workspace Name
|
|
390
|
+
const prefix = config.workspace.labelPrefix || '';
|
|
391
|
+
const defaultWs = (prefix && !customDir.startsWith(prefix.trim())) ? `${prefix}${customDir}` : customDir;
|
|
392
|
+
const customWs = await promptText({
|
|
393
|
+
question: 'Herdr workspace label',
|
|
394
|
+
defaultValue: defaultWs,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
console.log(`\n${style.bold(style.green('✓'))} Ready to create worktree:`);
|
|
398
|
+
console.log(` • Branch: ${style.cyan(branchName)} (off ${style.yellow(sourceBranch)})`);
|
|
399
|
+
console.log(` • Directory: ${style.cyan(customDir)}`);
|
|
400
|
+
console.log(` • Workspace: ${style.cyan(customWs)}\n`);
|
|
401
|
+
|
|
402
|
+
await executeCreate({
|
|
403
|
+
...flags,
|
|
404
|
+
branch: branchName,
|
|
405
|
+
source: sourceBranch,
|
|
406
|
+
dirname: customDir,
|
|
407
|
+
workspaceName: customWs,
|
|
408
|
+
}, config, cwd);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function handleSwitchWorktree(flags, config, cwd) {
|
|
412
|
+
const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
|
|
413
|
+
const worktrees = git.getWorktrees({ repoDir: repoRoot, bareRepo: config.repo.bareRepo });
|
|
414
|
+
const nonBare = worktrees.filter(wt => !wt.isBare);
|
|
415
|
+
|
|
416
|
+
console.log(`\n${style.bold(style.cyan('=== 🔄 Switch / Open Existing Worktree in Herdr ==='))}\n`);
|
|
417
|
+
|
|
418
|
+
if (!nonBare.length) {
|
|
419
|
+
console.log(` ${style.yellow('No existing worktrees found.')}`);
|
|
420
|
+
await promptPause();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const items = nonBare.map(wt => {
|
|
425
|
+
const isRoot = path.resolve(wt.path) === path.resolve(repoRoot);
|
|
426
|
+
const branchLabel = wt.branch ? style.bold(wt.branch) : style.dim('(detached)');
|
|
427
|
+
const rootTag = isRoot ? style.magenta(' [repo root]') : '';
|
|
428
|
+
const dirLabel = path.basename(wt.path);
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
title: `${branchLabel}${rootTag}`,
|
|
432
|
+
description: `${wt.path}`,
|
|
433
|
+
value: wt,
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const selected = await promptSelect({
|
|
438
|
+
title: 'Select worktree to open in Herdr:',
|
|
439
|
+
items,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
if (!selected || !selected.value) return;
|
|
443
|
+
|
|
444
|
+
const wt = selected.value;
|
|
445
|
+
const branchToUse = wt.branch || path.basename(wt.path);
|
|
446
|
+
const dirToUse = path.basename(wt.path);
|
|
447
|
+
|
|
448
|
+
console.log(`\nOpening worktree "${branchToUse}" at "${wt.path}" in Herdr...`);
|
|
449
|
+
|
|
450
|
+
await executeCreate({
|
|
451
|
+
...flags,
|
|
452
|
+
branch: branchToUse,
|
|
453
|
+
dirname: dirToUse,
|
|
454
|
+
}, config, cwd);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function handleListWorktrees(flags, config, cwd) {
|
|
458
|
+
const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
|
|
459
|
+
const worktrees = git.getWorktrees({ repoDir: repoRoot, bareRepo: config.repo.bareRepo });
|
|
460
|
+
const workspaces = herdr.listWorkspaces();
|
|
461
|
+
|
|
462
|
+
console.log(`\n${style.bold(style.cyan('=== 📋 Active Git Worktrees ==='))}\n`);
|
|
463
|
+
|
|
464
|
+
if (!worktrees.length) {
|
|
465
|
+
console.log(` ${style.yellow('No worktrees detected.')}`);
|
|
466
|
+
await promptPause();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
worktrees.forEach((wt, idx) => {
|
|
471
|
+
const isRoot = path.resolve(wt.path) === path.resolve(repoRoot);
|
|
472
|
+
const dirName = path.basename(wt.path);
|
|
473
|
+
const activeWs = workspaces.find(w => w.label === dirName || (wt.branch && w.label === wt.branch) || (w.cwd && path.resolve(w.cwd) === path.resolve(wt.path)));
|
|
474
|
+
|
|
475
|
+
const branchStr = wt.branch ? style.bold(style.cyan(wt.branch)) : (wt.isBare ? style.dim('(bare repo)') : style.yellow('(detached)'));
|
|
476
|
+
const rootBadge = isRoot ? ` ${style.magenta('[root]')}` : '';
|
|
477
|
+
const wsBadge = activeWs ? ` ${style.green(`● Herdr: "${activeWs.label}"`)}` : ` ${style.dim('○ Herdr: (inactive)')}`;
|
|
478
|
+
|
|
479
|
+
console.log(` ${style.bold(`${idx + 1}.`)} ${branchStr}${rootBadge}${wsBadge}`);
|
|
480
|
+
console.log(` ${style.dim('Path:')} ${wt.path}`);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
console.log(`\n ${style.bold('Total:')} ${worktrees.length} worktree(s)`);
|
|
484
|
+
await promptPause();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function handleNukeWorktree(flags, config, cwd) {
|
|
488
|
+
const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
|
|
489
|
+
const worktrees = git.getWorktrees({ repoDir: repoRoot, bareRepo: config.repo.bareRepo });
|
|
490
|
+
|
|
491
|
+
// Safe nukable worktrees (exclude root repository)
|
|
492
|
+
const nukable = worktrees.filter(wt => {
|
|
493
|
+
if (wt.isBare) return false;
|
|
494
|
+
return path.resolve(wt.path) !== path.resolve(repoRoot);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
console.log(`\n${style.bold(style.red('=== 🧹 Nuke / Cleanup Worktree ==='))}\n`);
|
|
498
|
+
|
|
499
|
+
if (!nukable.length) {
|
|
500
|
+
console.log(` ${style.yellow('No worktrees available to nuke (cannot delete repository root).')}`);
|
|
501
|
+
await promptPause();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const items = nukable.map(wt => {
|
|
506
|
+
const branchLabel = wt.branch ? style.bold(wt.branch) : style.dim('(detached)');
|
|
507
|
+
return {
|
|
508
|
+
title: branchLabel,
|
|
509
|
+
description: wt.path,
|
|
510
|
+
value: wt,
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const selectedItems = await promptMultiSelect({
|
|
515
|
+
title: 'Select worktree(s) to cleanup / nuke:',
|
|
516
|
+
items,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (!selectedItems || !selectedItems.length) return;
|
|
520
|
+
|
|
521
|
+
const targets = selectedItems.map(s => s.value || s);
|
|
522
|
+
|
|
523
|
+
console.log(`\n${style.bold('Selected for teardown:')}`);
|
|
524
|
+
targets.forEach(t => console.log(` • ${style.cyan(t.branch || path.basename(t.path))} (${t.path})`));
|
|
525
|
+
|
|
526
|
+
const deleteBranches = await promptConfirm({
|
|
527
|
+
question: 'Delete local & remote Git branches too?',
|
|
528
|
+
defaultYes: true,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const force = await promptConfirm({
|
|
532
|
+
question: 'Force removal if uncommitted changes exist?',
|
|
533
|
+
defaultYes: false,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const confirmed = await promptConfirm({
|
|
537
|
+
question: `⚠️ Are you sure you want to permanently delete ${targets.length} worktree(s)?`,
|
|
538
|
+
defaultYes: false,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (!confirmed) {
|
|
542
|
+
console.log(`\nOperation cancelled.`);
|
|
543
|
+
await promptPause();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (const target of targets) {
|
|
548
|
+
console.log(`\n--- Nuking ${target.path} ---`);
|
|
549
|
+
await executeNuke({
|
|
550
|
+
...flags,
|
|
551
|
+
cleanupTarget: target.path,
|
|
552
|
+
branch: target.branch,
|
|
553
|
+
dirOnly: !deleteBranches,
|
|
554
|
+
force,
|
|
555
|
+
}, config, cwd);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
console.log(`\n${style.bold(style.green('✓'))} Cleanup operations complete.`);
|
|
559
|
+
await promptPause();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function startInteractiveMenu(flags, config, cwd = process.cwd()) {
|
|
563
|
+
while (true) {
|
|
564
|
+
renderHeader(config, cwd);
|
|
565
|
+
|
|
566
|
+
const menuChoices = [
|
|
567
|
+
{
|
|
568
|
+
title: '🚀 Create new worktree',
|
|
569
|
+
description: 'Branch off source, scaffold environment, and boot Herdr workspace',
|
|
570
|
+
value: 'create',
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
title: '🔄 Switch / Open existing worktree in Herdr',
|
|
574
|
+
description: 'Select an existing worktree and attach terminal workspace',
|
|
575
|
+
value: 'switch',
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
title: '📋 List worktrees',
|
|
579
|
+
description: 'View all active worktrees and Herdr workspace status',
|
|
580
|
+
value: 'list',
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
title: '🧹 Nuke / Cleanup worktree',
|
|
584
|
+
description: 'Safely tear down worktree directory and associated branches',
|
|
585
|
+
value: 'nuke',
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
title: '⚙️ Initialize / Configure Arise',
|
|
589
|
+
description: 'Run interactive setup wizard to generate or update .ariserc.json',
|
|
590
|
+
value: 'init',
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
title: '🚪 Exit',
|
|
594
|
+
description: 'Close interactive menu',
|
|
595
|
+
value: 'exit',
|
|
596
|
+
},
|
|
597
|
+
];
|
|
598
|
+
|
|
599
|
+
const action = await promptSelect({
|
|
600
|
+
title: 'What would you like to do?',
|
|
601
|
+
items: menuChoices,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
if (!action || action.value === 'exit') {
|
|
605
|
+
console.log(`\nGoodbye! 👋\n`);
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
try {
|
|
610
|
+
if (action.value === 'create') {
|
|
611
|
+
await handleCreateWorktree(flags, config, cwd);
|
|
612
|
+
break; // After creating & attaching Herdr session, finish
|
|
613
|
+
} else if (action.value === 'switch') {
|
|
614
|
+
await handleSwitchWorktree(flags, config, cwd);
|
|
615
|
+
break; // After switching & attaching Herdr session, finish
|
|
616
|
+
} else if (action.value === 'list') {
|
|
617
|
+
await handleListWorktrees(flags, config, cwd);
|
|
618
|
+
} else if (action.value === 'nuke') {
|
|
619
|
+
await handleNukeWorktree(flags, config, cwd);
|
|
620
|
+
} else if (action.value === 'init') {
|
|
621
|
+
const { ConfigInitWizard } = require('./config/init');
|
|
622
|
+
await ConfigInitWizard.run({ cwd });
|
|
623
|
+
await promptPause();
|
|
624
|
+
}
|
|
625
|
+
} catch (err) {
|
|
626
|
+
console.error(`\n${style.red('Error:')} ${err.message}`);
|
|
627
|
+
await promptPause();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
module.exports = {
|
|
633
|
+
startInteractiveMenu,
|
|
634
|
+
promptSelect,
|
|
635
|
+
promptMultiSelect,
|
|
636
|
+
promptText,
|
|
637
|
+
promptConfirm,
|
|
638
|
+
promptPause,
|
|
639
|
+
handleCreateWorktree,
|
|
640
|
+
handleSwitchWorktree,
|
|
641
|
+
handleListWorktrees,
|
|
642
|
+
handleNukeWorktree,
|
|
643
|
+
};
|
|
644
|
+
|
package/lib/layout.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const herdr = require('./herdr');
|
|
2
|
+
const logger = require('./logger');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Default standard 4-pane quadrant layout recipe
|
|
@@ -23,6 +24,12 @@ function renderLayout({ layout = DEFAULT_QUADRANT_LAYOUT, rootPaneId, cwd, focus
|
|
|
23
24
|
const paneMap = new Map(); // id -> paneId
|
|
24
25
|
let targetFocusPaneId = null;
|
|
25
26
|
|
|
27
|
+
logger.debug(`Rendering layout (${layout.length} panes) with rootPaneId="${rootPaneId}":`, {
|
|
28
|
+
cwd,
|
|
29
|
+
focusTarget,
|
|
30
|
+
panes: layout.map((p) => ({ id: p.id, title: p.title, from: p.from, split: p.split, cmd: p.cmd })),
|
|
31
|
+
});
|
|
32
|
+
|
|
26
33
|
// 1. First pass: identify which pane is targeted for focus
|
|
27
34
|
const normalizedFocus = (focusTarget || 'agy').toLowerCase().trim();
|
|
28
35
|
|
|
@@ -43,20 +50,27 @@ function renderLayout({ layout = DEFAULT_QUADRANT_LAYOUT, rootPaneId, cwd, focus
|
|
|
43
50
|
|
|
44
51
|
if (paneDef.position === 'root' || !paneDef.from) {
|
|
45
52
|
currentPaneId = rootPaneId;
|
|
53
|
+
logger.debug(`Mapped root pane "${paneDef.id}" ("${paneDef.title}") -> paneId "${currentPaneId}"`);
|
|
46
54
|
} else {
|
|
47
55
|
const parentPaneId = paneMap.get(paneDef.from);
|
|
48
56
|
if (!parentPaneId) {
|
|
49
|
-
|
|
57
|
+
logger.warn(`Layout: parent pane "${paneDef.from}" not found for "${paneDef.id}". Skipping split.`);
|
|
50
58
|
continue;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
const shouldFocus = isFocusMatch(paneDef);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
try {
|
|
63
|
+
currentPaneId = herdr.splitPane({
|
|
64
|
+
paneId: parentPaneId,
|
|
65
|
+
direction: paneDef.split || 'right',
|
|
66
|
+
cwd,
|
|
67
|
+
focus: shouldFocus,
|
|
68
|
+
});
|
|
69
|
+
logger.debug(`Split pane "${paneDef.id}" from parent "${paneDef.from}" (${parentPaneId}) dir=${paneDef.split || 'right'} -> paneId "${currentPaneId}"`);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.error(`Failed to split pane "${paneDef.id}" from "${paneDef.from}": ${err.message}`, err);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
if (currentPaneId) {
|