@tsuzuku/arise 0.2.0 → 0.3.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.
@@ -6,6 +6,7 @@ const layout = require('../layout');
6
6
  const { createContext } = require('../context');
7
7
  const { resolveConfiguration } = require('../config');
8
8
  const { showUsage } = require('../cli');
9
+ const logger = require('../logger');
9
10
 
10
11
  async function executeCreate(flags, config, cwd = process.cwd()) {
11
12
  const branch = flags.branch;
@@ -65,13 +66,36 @@ async function executeCreate(flags, config, cwd = process.cwd()) {
65
66
  bareRepo: config.repo.bareRepo,
66
67
  });
67
68
  } catch (err) {
68
- console.error(`Failed to create worktree: ${err.message}`);
69
+ logger.error(`Failed to create worktree: ${err.message}`, err);
69
70
  process.exit(1);
70
71
  }
71
72
  }
72
73
 
73
- // 5. Re-resolve config using target worktree directory for branch-specific overrides
74
- const activeConfig = resolveConfiguration(flags, worktreePathToUse);
74
+ // 5. Re-resolve config using target worktree directory for branch-specific overrides with caller cwd fallback
75
+ let activeConfig = resolveConfiguration(flags, worktreePathToUse, cwd);
76
+
77
+ // If the worktree was freshly created and caller had an active configuration file in cwd, sync it into the new worktree
78
+ if (!worktreeExists && config.configFile && fs.existsSync(config.configFile)) {
79
+ try {
80
+ const targetConfigPath = path.join(worktreePathToUse, path.basename(config.configFile));
81
+ fs.copyFileSync(config.configFile, targetConfigPath);
82
+ logger.debug(`Synced configuration file from caller to new worktree: ${config.configFile} -> ${targetConfigPath}`);
83
+ activeConfig = resolveConfiguration(flags, worktreePathToUse, cwd);
84
+ } catch (err) {
85
+ logger.warn(`Could not sync caller config file to new worktree: ${err.message}`);
86
+ }
87
+ }
88
+
89
+ if (config.layout && config.layout.length > 0 && config.configFile && (!activeConfig.configFile || activeConfig.configFile.includes('.bare'))) {
90
+ activeConfig.layout = config.layout;
91
+ }
92
+
93
+ logger.debug(`Active configuration for target="${worktreePathToUse}" (caller="${cwd}"):`, {
94
+ configFile: activeConfig.configFile,
95
+ layoutCount: activeConfig.layout?.length,
96
+ panes: activeConfig.layout?.map((p) => ({ id: p.id, title: p.title, from: p.from, split: p.split })),
97
+ });
98
+
75
99
  prefix = activeConfig.workspace.labelPrefix || '';
76
100
  sessionName = (prefix && !baseWorkspace.startsWith(prefix.trim()))
77
101
  ? `${prefix}${baseWorkspace}`
@@ -6,6 +6,13 @@ const herdr = require('../herdr');
6
6
  const { createContext } = require('../context');
7
7
  const { resolveConfiguration } = require('../config');
8
8
  const { showUsage } = require('../cli');
9
+ const { promptConfirm } = require('../tui/prompt');
10
+
11
+ function getRepoDisplayName(repoPath) {
12
+ if (!repoPath) return 'unknown';
13
+ const cleaned = repoPath.replace(/[/\\]\.bare$/, '').replace(/[/\\]\.git$/, '');
14
+ return path.basename(cleaned) || repoPath;
15
+ }
9
16
 
10
17
  async function executeNuke(flags, config, cwd = process.cwd()) {
11
18
  const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
@@ -84,12 +91,46 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
84
91
  console.log(`Directory Only Mode: ${flags.dirOnly ? 'Yes (branches will be preserved)' : 'No'}`);
85
92
  console.log(`Keep Remote Branch: ${flags.keepRemote ? 'Yes' : 'No'}\n`);
86
93
 
87
- // Safety check: Never delete root repository
94
+ // Safety check 1: Never delete root repository
88
95
  if (path.resolve(targetWorktreePath) === path.resolve(repoRoot)) {
89
96
  console.error(`Error: Cannot delete the repository root directory "${repoRoot}".`);
90
97
  process.exit(1);
91
98
  }
92
99
 
100
+ // Safety check 2: Cross-repository nuke detection
101
+ const callerRepoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
102
+ const targetRepoRoot = fs.existsSync(targetWorktreePath)
103
+ ? git.getRepoRootDir(targetWorktreePath, activeConfig.repo.bareRepo)
104
+ : repoRoot;
105
+
106
+ const isCrossRepo = callerRepoRoot && targetRepoRoot &&
107
+ path.resolve(callerRepoRoot) !== path.resolve(targetRepoRoot);
108
+
109
+ if (isCrossRepo) {
110
+ const callerRepoName = getRepoDisplayName(callerRepoRoot);
111
+ const targetRepoName = getRepoDisplayName(targetRepoRoot);
112
+
113
+ console.warn(`\x1b[1m\x1b[33m⚠️ CROSS-REPOSITORY NUKE DETECTED\x1b[39m\x1b[22m`);
114
+ console.warn(`─────────────────────────────────────────────────────────────────`);
115
+ console.warn(`• Caller CWD: ${cwd} (Repo: ${callerRepoName})`);
116
+ console.warn(`• Target Worktree: ${targetWorktreePath} (Repo: ${targetRepoName})`);
117
+ console.warn(`• Associated Branch: ${targetBranch || '(None detected)'}`);
118
+ console.warn(`─────────────────────────────────────────────────────────────────`);
119
+ console.warn(`You are invoking --nuke from a different repository than the target worktree!\n`);
120
+
121
+ if (!flags.yes && !flags.force && process.stdin.isTTY && !process.env.CI) {
122
+ const confirmed = await promptConfirm({
123
+ question: `Are you sure you want to nuke this worktree from another repository?`,
124
+ defaultYes: false,
125
+ });
126
+
127
+ if (!confirmed) {
128
+ console.log(`\nAborted. No worktrees or branches were deleted.`);
129
+ return;
130
+ }
131
+ }
132
+ }
133
+
93
134
  // If cwd is inside the target worktree, switch to rootDir/baseDir
94
135
  if (path.resolve(process.cwd()).startsWith(path.resolve(targetWorktreePath))) {
95
136
  console.log(`Current working directory is inside target worktree. Switching to: ${baseDir}`);
@@ -159,7 +200,7 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
159
200
  prefix ? `${prefix}${targetDirname}` : null,
160
201
  ].filter(Boolean);
161
202
 
162
- herdr.closeWorkspacesMatching(matchTargets);
203
+ herdr.closeWorkspacesForWorktree(targetWorktreePath, matchTargets);
163
204
 
164
205
  console.log(`\nCleanup complete!`);
165
206
  };
package/lib/logger.js ADDED
@@ -0,0 +1,85 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { ANSI } = require('./tui/ansi');
5
+
6
+ let isDebugEnabled = false;
7
+ let logFilePath = null;
8
+
9
+ function initLogger(flags = {}) {
10
+ isDebugEnabled = Boolean(
11
+ flags.debug ||
12
+ flags.verbose ||
13
+ process.env.ARISE_DEBUG === '1' ||
14
+ process.env.DEBUG === 'arise' ||
15
+ process.env.DEBUG === '*'
16
+ );
17
+
18
+ try {
19
+ const logDir = path.join(os.homedir(), '.config', 'arise', 'logs');
20
+ fs.mkdirSync(logDir, { recursive: true });
21
+ logFilePath = path.join(logDir, 'arise.log');
22
+ } catch (e) {
23
+ logFilePath = path.join(os.tmpdir(), 'arise-debug.log');
24
+ }
25
+ }
26
+
27
+ function writeToFile(level, message, details = null) {
28
+ if (!logFilePath) return;
29
+ try {
30
+ const timestamp = new Date().toISOString();
31
+ let line = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
32
+ if (details) {
33
+ line += typeof details === 'object' ? JSON.stringify(details, null, 2) + '\n' : String(details) + '\n';
34
+ }
35
+ fs.appendFileSync(logFilePath, line, 'utf8');
36
+ } catch (e) {}
37
+ }
38
+
39
+ function debug(message, details = null) {
40
+ writeToFile('DEBUG', message, details);
41
+ if (isDebugEnabled) {
42
+ console.log(` ${ANSI.dim}[DEBUG] ${message}${ANSI.reset}`);
43
+ if (details) {
44
+ console.log(ANSI.dim + (typeof details === 'object' ? JSON.stringify(details, null, 2) : details) + ANSI.reset);
45
+ }
46
+ }
47
+ }
48
+
49
+ function info(message) {
50
+ writeToFile('INFO', message);
51
+ console.log(message);
52
+ }
53
+
54
+ function warn(message, details = null) {
55
+ writeToFile('WARN', message, details);
56
+ console.warn(`${ANSI.yellow}Warning: ${message}${ANSI.reset}`);
57
+ if (isDebugEnabled && details) {
58
+ console.warn(ANSI.dim + (typeof details === 'object' ? JSON.stringify(details, null, 2) : details) + ANSI.reset);
59
+ }
60
+ }
61
+
62
+ function error(message, err = null) {
63
+ writeToFile('ERROR', message, err && err.stack ? err.stack : err);
64
+ console.error(`${ANSI.red}Error: ${message}${ANSI.reset}`);
65
+ if (err) {
66
+ if (isDebugEnabled && err.stack) {
67
+ console.error(ANSI.dim + err.stack + ANSI.reset);
68
+ } else if (err.message) {
69
+ console.error(`${ANSI.dim}Details: ${err.message}${ANSI.reset}`);
70
+ }
71
+ }
72
+ if (!isDebugEnabled && logFilePath) {
73
+ console.error(`${ANSI.dim}(Detailed logs saved to ${logFilePath})${ANSI.reset}`);
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ initLogger,
79
+ isDebugEnabled: () => isDebugEnabled,
80
+ getLogFilePath: () => logFilePath,
81
+ debug,
82
+ info,
83
+ warn,
84
+ error,
85
+ };
package/lib/skill.js CHANGED
@@ -15,6 +15,12 @@ Use this skill to answer questions about \`arise\` and run worktree management c
15
15
 
16
16
  ## 1. CLI Quick Reference & Cheatsheet
17
17
 
18
+ ### Interactive Mode
19
+ \`\`\`bash
20
+ # Launch interactive menu (create, switch/open, list, nuke)
21
+ arise
22
+ \`\`\`
23
+
18
24
  ### Creating Worktrees
19
25
  \`\`\`bash
20
26
  # Create or open a worktree for a branch (auto-detects preset, boots Herdr workspace)
@@ -0,0 +1,294 @@
1
+ const ANSI = {
2
+ reset: '\x1b[0m',
3
+ bold: '\x1b[1m',
4
+ dim: '\x1b[2m',
5
+ italic: '\x1b[3m',
6
+ underline: '\x1b[4m',
7
+ inverse: '\x1b[7m',
8
+ hidden: '\x1b[8m',
9
+ strikethrough: '\x1b[9m',
10
+
11
+ // Foreground
12
+ black: '\x1b[30m',
13
+ red: '\x1b[31m',
14
+ green: '\x1b[32m',
15
+ yellow: '\x1b[33m',
16
+ blue: '\x1b[34m',
17
+ magenta: '\x1b[35m',
18
+ cyan: '\x1b[36m',
19
+ white: '\x1b[37m',
20
+ gray: '\x1b[90m',
21
+
22
+ // Bright Foreground
23
+ brightRed: '\x1b[91m',
24
+ brightGreen: '\x1b[92m',
25
+ brightYellow: '\x1b[93m',
26
+ brightBlue: '\x1b[94m',
27
+ brightMagenta: '\x1b[95m',
28
+ brightCyan: '\x1b[96m',
29
+ brightWhite: '\x1b[97m',
30
+
31
+ // Background
32
+ bgBlack: '\x1b[40m',
33
+ bgRed: '\x1b[41m',
34
+ bgGreen: '\x1b[42m',
35
+ bgYellow: '\x1b[43m',
36
+ bgBlue: '\x1b[44m',
37
+ bgMagenta: '\x1b[45m',
38
+ bgCyan: '\x1b[46m',
39
+ bgWhite: '\x1b[47m',
40
+ bgGray: '\x1b[100m',
41
+
42
+ // Cursor & Screen
43
+ clearScreen: '\x1b[2J\x1b[H',
44
+ clearLine: '\x1b[2K\r',
45
+ hideCursor: '\x1b[?25l',
46
+ showCursor: '\x1b[?25h',
47
+ altScreenEnter: '\x1b[?1049h',
48
+ altScreenExit: '\x1b[?1049l',
49
+ };
50
+
51
+ function stripAnsi(text) {
52
+ if (typeof text !== 'string') return '';
53
+ return text
54
+ // Strip OSC sequences
55
+ .replace(/\x1b\](?:[^\x07\x1b]|\x1b[^\\])*?(?:\x07|\x1b\\)/g, '')
56
+ // Strip CSI sequences
57
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
58
+ // Strip other escape sequences
59
+ .replace(/\x1b[@-Z\\-_]/g, '');
60
+ }
61
+
62
+ function visibleLength(text) {
63
+ return stripAnsi(text).length;
64
+ }
65
+
66
+ class AnsiStyleTracker {
67
+ constructor() {
68
+ this.fg = '';
69
+ this.bg = '';
70
+ this.bold = false;
71
+ this.dim = false;
72
+ this.italic = false;
73
+ this.underline = false;
74
+ this.strike = false;
75
+ }
76
+
77
+ processCode(codeStr) {
78
+ const match = codeStr.match(/^\x1b\[([0-9;?]*)m$/);
79
+ if (!match) return;
80
+ const rawCodes = match[1] ? match[1].split(';').map(Number) : [0];
81
+
82
+ for (const c of rawCodes) {
83
+ if (c === 0) {
84
+ this.fg = '';
85
+ this.bg = '';
86
+ this.bold = false;
87
+ this.dim = false;
88
+ this.italic = false;
89
+ this.underline = false;
90
+ this.strike = false;
91
+ } else if (c === 1) {
92
+ this.bold = true;
93
+ } else if (c === 2) {
94
+ this.dim = true;
95
+ } else if (c === 3) {
96
+ this.italic = true;
97
+ } else if (c === 4) {
98
+ this.underline = true;
99
+ } else if (c === 9) {
100
+ this.strike = true;
101
+ } else if (c === 22) {
102
+ this.bold = false;
103
+ this.dim = false;
104
+ } else if (c === 23) {
105
+ this.italic = false;
106
+ } else if (c === 24) {
107
+ this.underline = false;
108
+ } else if (c === 29) {
109
+ this.strike = false;
110
+ } else if (c === 39) {
111
+ this.fg = '';
112
+ } else if (c === 49) {
113
+ this.bg = '';
114
+ } else if ((c >= 30 && c <= 37) || (c >= 90 && c <= 97)) {
115
+ this.fg = `\x1b[${c}m`;
116
+ } else if ((c >= 40 && c <= 47) || (c >= 100 && c <= 107)) {
117
+ this.bg = `\x1b[${c}m`;
118
+ }
119
+ }
120
+ }
121
+
122
+ getActiveCodes() {
123
+ let result = '';
124
+ if (this.bold) result += '\x1b[1m';
125
+ if (this.dim) result += '\x1b[2m';
126
+ if (this.italic) result += '\x1b[3m';
127
+ if (this.underline) result += '\x1b[4m';
128
+ if (this.strike) result += '\x1b[9m';
129
+ if (this.fg) result += this.fg;
130
+ if (this.bg) result += this.bg;
131
+ return result;
132
+ }
133
+
134
+ hasActiveStyles() {
135
+ return this.bold || this.dim || this.italic || this.underline || this.strike || Boolean(this.fg) || Boolean(this.bg);
136
+ }
137
+ }
138
+
139
+ function wrapAnsiLine(line, maxWidth, hangingIndent = '') {
140
+ if (maxWidth <= 0) return [line];
141
+ const totalVisible = visibleLength(line);
142
+ if (totalVisible <= maxWidth) {
143
+ return [line];
144
+ }
145
+
146
+ const tokenRegex = /(?:\x1b\](?:[^\x07\x1b]|\x1b[^\\])*?(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-Z\\-_]|[^\s\x1b]+|\s+)/g;
147
+ const tokens = line.match(tokenRegex) || [line];
148
+
149
+ const result = [];
150
+ const tracker = new AnsiStyleTracker();
151
+
152
+ let currentLine = '';
153
+ let currentVisibleWidth = 0;
154
+ let isFirstLine = true;
155
+ const hangingVisibleWidth = visibleLength(hangingIndent);
156
+
157
+ for (let i = 0; i < tokens.length; i++) {
158
+ const token = tokens[i];
159
+
160
+ if (token.startsWith('\x1b')) {
161
+ tracker.processCode(token);
162
+ currentLine += token;
163
+ continue;
164
+ }
165
+
166
+ const tokenVisibleLen = stripAnsi(token).length;
167
+ const isWhitespace = /^\s+$/.test(token);
168
+
169
+ if (isWhitespace) {
170
+ if (currentVisibleWidth === hangingVisibleWidth && !isFirstLine) {
171
+ continue;
172
+ }
173
+ if (currentVisibleWidth + tokenVisibleLen <= maxWidth) {
174
+ currentLine += token;
175
+ currentVisibleWidth += tokenVisibleLen;
176
+ } else {
177
+ if (tracker.hasActiveStyles()) {
178
+ currentLine += ANSI.reset;
179
+ }
180
+ result.push(currentLine);
181
+ isFirstLine = false;
182
+ currentLine = hangingIndent + tracker.getActiveCodes();
183
+ currentVisibleWidth = hangingVisibleWidth;
184
+ }
185
+ continue;
186
+ }
187
+
188
+ // Word token
189
+ if (currentVisibleWidth + tokenVisibleLen <= maxWidth) {
190
+ currentLine += token;
191
+ currentVisibleWidth += tokenVisibleLen;
192
+ } else {
193
+ if (currentVisibleWidth > (isFirstLine ? 0 : hangingVisibleWidth)) {
194
+ if (tracker.hasActiveStyles()) {
195
+ currentLine += ANSI.reset;
196
+ }
197
+ result.push(currentLine);
198
+ isFirstLine = false;
199
+ currentLine = hangingIndent + tracker.getActiveCodes();
200
+ currentVisibleWidth = hangingVisibleWidth;
201
+ }
202
+
203
+ if (tokenVisibleLen > maxWidth - currentVisibleWidth) {
204
+ let remaining = token;
205
+ while (remaining.length > 0) {
206
+ const available = Math.max(1, maxWidth - currentVisibleWidth);
207
+ const chunk = remaining.slice(0, available);
208
+ remaining = remaining.slice(available);
209
+
210
+ currentLine += chunk;
211
+ currentVisibleWidth += chunk.length;
212
+
213
+ if (remaining.length > 0) {
214
+ if (tracker.hasActiveStyles()) {
215
+ currentLine += ANSI.reset;
216
+ }
217
+ result.push(currentLine);
218
+ isFirstLine = false;
219
+ currentLine = hangingIndent + tracker.getActiveCodes();
220
+ currentVisibleWidth = hangingVisibleWidth;
221
+ }
222
+ }
223
+ } else {
224
+ currentLine += token;
225
+ currentVisibleWidth += tokenVisibleLen;
226
+ }
227
+ }
228
+ }
229
+
230
+ if (currentLine.length > 0) {
231
+ if (tracker.hasActiveStyles()) {
232
+ currentLine += ANSI.reset;
233
+ }
234
+ result.push(currentLine);
235
+ }
236
+
237
+ return result.length > 0 ? result : [''];
238
+ }
239
+
240
+ function truncate(text, maxWidth) {
241
+ if (maxWidth <= 0) return '';
242
+ const totalVisible = visibleLength(text);
243
+ if (totalVisible <= maxWidth) return text;
244
+
245
+ const targetWidth = Math.max(1, maxWidth - 1);
246
+ const tokenRegex = /(?:\x1b\](?:[^\x07\x1b]|\x1b[^\\])*?(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-Z\\-_]|[^\x1b])/g;
247
+ const tokens = text.match(tokenRegex) || [];
248
+
249
+ let result = '';
250
+ let currentVisible = 0;
251
+
252
+ for (const token of tokens) {
253
+ if (token.startsWith('\x1b')) {
254
+ result += token;
255
+ } else {
256
+ if (currentVisible + 1 > targetWidth) {
257
+ break;
258
+ }
259
+ result += token;
260
+ currentVisible += 1;
261
+ }
262
+ }
263
+
264
+ return result + ANSI.reset + '…';
265
+ }
266
+
267
+ function drawBox(title, contentLines = [], width = 70) {
268
+ const safeWidth = Math.max(10, width);
269
+ const horizontal = '─'.repeat(Math.max(0, safeWidth - 2));
270
+ const titleFormatted = title ? ` ${ANSI.bold}${title}${ANSI.reset} ` : '';
271
+ const titleLen = visibleLength(titleFormatted);
272
+ const topBar = `┌${titleFormatted}${'─'.repeat(Math.max(0, safeWidth - 2 - titleLen))}┐`;
273
+ const bottomBar = `└${horizontal}┘`;
274
+
275
+ const maxInner = Math.max(0, safeWidth - 4);
276
+ const renderedLines = contentLines.map((line) => {
277
+ const truncatedLine = truncate(line, maxInner);
278
+ const plain = visibleLength(truncatedLine);
279
+ const padding = Math.max(0, maxInner - plain);
280
+ return `│ ${truncatedLine}${' '.repeat(padding)} │`;
281
+ });
282
+
283
+ return [topBar, ...renderedLines, bottomBar];
284
+ }
285
+
286
+ module.exports = {
287
+ ANSI,
288
+ stripAnsi,
289
+ visibleLength,
290
+ AnsiStyleTracker,
291
+ wrapAnsiLine,
292
+ truncate,
293
+ drawBox,
294
+ };