@tsuzuku/arise 1.1.0 → 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 +49 -10
- package/lib/cli.js +39 -2
- package/lib/config/init.js +862 -0
- package/lib/config.js +75 -6
- package/lib/git.js +37 -0
- package/lib/herdr.js +47 -6
- package/lib/interactive.js +644 -0
- package/lib/layout.js +21 -7
- package/lib/lifecycle/create.js +47 -15
- package/lib/lifecycle/nuke.js +60 -13
- 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 -2
- package/skills/arise/SKILL.md +6 -0
- package/types.d.ts +46 -0
- package/worktree.schema.json +0 -130
package/lib/lifecycle/create.js
CHANGED
|
@@ -4,7 +4,9 @@ const git = require('../git');
|
|
|
4
4
|
const herdr = require('../herdr');
|
|
5
5
|
const layout = require('../layout');
|
|
6
6
|
const { createContext } = require('../context');
|
|
7
|
+
const { resolveConfiguration } = require('../config');
|
|
7
8
|
const { showUsage } = require('../cli');
|
|
9
|
+
const logger = require('../logger');
|
|
8
10
|
|
|
9
11
|
async function executeCreate(flags, config, cwd = process.cwd()) {
|
|
10
12
|
const branch = flags.branch;
|
|
@@ -19,13 +21,13 @@ async function executeCreate(flags, config, cwd = process.cwd()) {
|
|
|
19
21
|
const dirname = flags.dirname || sanitizedBranch;
|
|
20
22
|
|
|
21
23
|
const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
|
|
22
|
-
const baseDir = config.repo.worktreesBase || repoRoot;
|
|
24
|
+
const baseDir = config.repo.worktreesBase || (git.isBareRepo(repoRoot) ? path.dirname(repoRoot) : repoRoot);
|
|
23
25
|
const worktreePath = path.resolve(baseDir, dirname);
|
|
24
26
|
|
|
25
27
|
// 2. Resolve Session / Workspace Name
|
|
26
28
|
const baseWorkspace = flags.workspaceName || dirname.replace(/[\s.:]/g, '_');
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
let prefix = config.workspace.labelPrefix || '';
|
|
30
|
+
let sessionName = (prefix && !baseWorkspace.startsWith(prefix.trim()))
|
|
29
31
|
? `${prefix}${baseWorkspace}`
|
|
30
32
|
: baseWorkspace;
|
|
31
33
|
|
|
@@ -64,30 +66,60 @@ async function executeCreate(flags, config, cwd = process.cwd()) {
|
|
|
64
66
|
bareRepo: config.repo.bareRepo,
|
|
65
67
|
});
|
|
66
68
|
} catch (err) {
|
|
67
|
-
|
|
69
|
+
logger.error(`Failed to create worktree: ${err.message}`, err);
|
|
68
70
|
process.exit(1);
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
// 5.
|
|
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
|
+
|
|
99
|
+
prefix = activeConfig.workspace.labelPrefix || '';
|
|
100
|
+
sessionName = (prefix && !baseWorkspace.startsWith(prefix.trim()))
|
|
101
|
+
? `${prefix}${baseWorkspace}`
|
|
102
|
+
: baseWorkspace;
|
|
103
|
+
|
|
104
|
+
// Initialize Context for Hooks
|
|
73
105
|
const ctx = createContext({
|
|
74
106
|
worktreePath: worktreePathToUse,
|
|
75
107
|
repoRoot,
|
|
76
|
-
bareRepo:
|
|
108
|
+
bareRepo: activeConfig.repo.bareRepo,
|
|
77
109
|
branch,
|
|
78
|
-
source: flags.source ||
|
|
110
|
+
source: flags.source || activeConfig.repo.defaultBaseBranch,
|
|
79
111
|
flags,
|
|
80
|
-
preset:
|
|
81
|
-
config,
|
|
112
|
+
preset: activeConfig.preset,
|
|
113
|
+
config: activeConfig,
|
|
82
114
|
});
|
|
83
115
|
|
|
84
116
|
// 6. Run Preset / Config Hooks
|
|
85
|
-
if (typeof
|
|
86
|
-
await
|
|
117
|
+
if (typeof activeConfig.hooks.onSyncPrimary === 'function') {
|
|
118
|
+
await activeConfig.hooks.onSyncPrimary(ctx);
|
|
87
119
|
}
|
|
88
120
|
|
|
89
|
-
if (!worktreeExists && typeof
|
|
90
|
-
await
|
|
121
|
+
if (!worktreeExists && typeof activeConfig.hooks.onScaffold === 'function') {
|
|
122
|
+
await activeConfig.hooks.onScaffold(ctx);
|
|
91
123
|
}
|
|
92
124
|
|
|
93
125
|
// 7. Orchestrate Herdr Workspace
|
|
@@ -105,10 +137,10 @@ async function executeCreate(flags, config, cwd = process.cwd()) {
|
|
|
105
137
|
// 8. Render Terminal Layout
|
|
106
138
|
console.log(`==> Configuring terminal panes...`);
|
|
107
139
|
layout.renderLayout({
|
|
108
|
-
layout:
|
|
140
|
+
layout: activeConfig.layout,
|
|
109
141
|
rootPaneId: ws.rootPaneId,
|
|
110
142
|
cwd: worktreePathToUse,
|
|
111
|
-
focusTarget: flags.focusTarget ||
|
|
143
|
+
focusTarget: flags.focusTarget || activeConfig.workspace.defaultFocus,
|
|
112
144
|
});
|
|
113
145
|
|
|
114
146
|
// Focus the workspace
|
package/lib/lifecycle/nuke.js
CHANGED
|
@@ -4,7 +4,15 @@ const { execSync } = require('child_process');
|
|
|
4
4
|
const git = require('../git');
|
|
5
5
|
const herdr = require('../herdr');
|
|
6
6
|
const { createContext } = require('../context');
|
|
7
|
+
const { resolveConfiguration } = require('../config');
|
|
7
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
|
+
}
|
|
8
16
|
|
|
9
17
|
async function executeNuke(flags, config, cwd = process.cwd()) {
|
|
10
18
|
const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
|
|
@@ -16,6 +24,7 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
|
|
|
16
24
|
if (!target) {
|
|
17
25
|
const currentCwd = path.resolve(cwd);
|
|
18
26
|
const matchingWt = worktrees.find(wt => {
|
|
27
|
+
if (wt.isBare) return false;
|
|
19
28
|
const resolvedWtPath = path.resolve(wt.path);
|
|
20
29
|
return resolvedWtPath !== path.resolve(repoRoot) && (
|
|
21
30
|
currentCwd === resolvedWtPath ||
|
|
@@ -50,6 +59,10 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
|
|
|
50
59
|
const targetDirname = path.basename(targetWorktreePath);
|
|
51
60
|
const targetSessionName = targetDirname.replace(/[\s.:]/g, '_');
|
|
52
61
|
|
|
62
|
+
const activeConfig = fs.existsSync(targetWorktreePath)
|
|
63
|
+
? resolveConfiguration(flags, targetWorktreePath)
|
|
64
|
+
: config;
|
|
65
|
+
|
|
53
66
|
let targetBranch = flags.branch;
|
|
54
67
|
if (!targetBranch && existingWorktree && existingWorktree.branch) {
|
|
55
68
|
targetBranch = existingWorktree.branch;
|
|
@@ -64,26 +77,60 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
|
|
|
64
77
|
}
|
|
65
78
|
if (!targetBranch) {
|
|
66
79
|
try {
|
|
67
|
-
const gitCmd = git.getGitPrefix({ repoDir: repoRoot, bareRepo:
|
|
80
|
+
const gitCmd = git.getGitPrefix({ repoDir: repoRoot, bareRepo: activeConfig.repo.bareRepo });
|
|
68
81
|
execSync(`${gitCmd} rev-parse --verify "${target}"`, { stdio: 'ignore' });
|
|
69
82
|
targetBranch = target;
|
|
70
83
|
} catch (e) {}
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
console.log(`\n=== Starting Worktree Nuke ===`);
|
|
74
|
-
console.log(`Active Preset: ${
|
|
87
|
+
console.log(`Active Preset: ${activeConfig.preset ? activeConfig.preset.name : 'generic'}`);
|
|
75
88
|
console.log(`Repository Root: ${repoRoot}`);
|
|
76
89
|
console.log(`Target Worktree Path: ${targetWorktreePath}`);
|
|
77
90
|
console.log(`Associated Branch: ${targetBranch || '(None detected)'}`);
|
|
78
91
|
console.log(`Directory Only Mode: ${flags.dirOnly ? 'Yes (branches will be preserved)' : 'No'}`);
|
|
79
92
|
console.log(`Keep Remote Branch: ${flags.keepRemote ? 'Yes' : 'No'}\n`);
|
|
80
93
|
|
|
81
|
-
// Safety check: Never delete root repository
|
|
94
|
+
// Safety check 1: Never delete root repository
|
|
82
95
|
if (path.resolve(targetWorktreePath) === path.resolve(repoRoot)) {
|
|
83
96
|
console.error(`Error: Cannot delete the repository root directory "${repoRoot}".`);
|
|
84
97
|
process.exit(1);
|
|
85
98
|
}
|
|
86
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
|
+
|
|
87
134
|
// If cwd is inside the target worktree, switch to rootDir/baseDir
|
|
88
135
|
if (path.resolve(process.cwd()).startsWith(path.resolve(targetWorktreePath))) {
|
|
89
136
|
console.log(`Current working directory is inside target worktree. Switching to: ${baseDir}`);
|
|
@@ -96,16 +143,16 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
|
|
|
96
143
|
const ctx = createContext({
|
|
97
144
|
worktreePath: targetWorktreePath,
|
|
98
145
|
repoRoot,
|
|
99
|
-
bareRepo:
|
|
146
|
+
bareRepo: activeConfig.repo.bareRepo,
|
|
100
147
|
branch: targetBranch,
|
|
101
148
|
flags,
|
|
102
|
-
preset:
|
|
103
|
-
config,
|
|
149
|
+
preset: activeConfig.preset,
|
|
150
|
+
config: activeConfig,
|
|
104
151
|
});
|
|
105
152
|
|
|
106
153
|
// Pre-Nuke Hook
|
|
107
|
-
if (typeof
|
|
108
|
-
await
|
|
154
|
+
if (typeof activeConfig.hooks.onPreNuke === 'function') {
|
|
155
|
+
await activeConfig.hooks.onPreNuke(ctx);
|
|
109
156
|
}
|
|
110
157
|
|
|
111
158
|
// 1. Remove Worktree & Directory
|
|
@@ -139,12 +186,12 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
|
|
|
139
186
|
// Helper for final cleanup steps (Herdr workspace close + Post-Nuke hook)
|
|
140
187
|
const finishCleanup = async () => {
|
|
141
188
|
// Post-Nuke Hook
|
|
142
|
-
if (typeof
|
|
143
|
-
await
|
|
189
|
+
if (typeof activeConfig.hooks.onPostNuke === 'function') {
|
|
190
|
+
await activeConfig.hooks.onPostNuke(ctx);
|
|
144
191
|
}
|
|
145
192
|
|
|
146
193
|
// Close Herdr Workspace if running (performed last so active pane is not terminated mid-cleanup)
|
|
147
|
-
const prefix =
|
|
194
|
+
const prefix = activeConfig.workspace.labelPrefix || '';
|
|
148
195
|
const matchTargets = [
|
|
149
196
|
targetSessionName,
|
|
150
197
|
targetDirname,
|
|
@@ -153,7 +200,7 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
|
|
|
153
200
|
prefix ? `${prefix}${targetDirname}` : null,
|
|
154
201
|
].filter(Boolean);
|
|
155
202
|
|
|
156
|
-
herdr.
|
|
203
|
+
herdr.closeWorkspacesForWorktree(targetWorktreePath, matchTargets);
|
|
157
204
|
|
|
158
205
|
console.log(`\nCleanup complete!`);
|
|
159
206
|
};
|
|
@@ -165,7 +212,7 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
|
|
|
165
212
|
return;
|
|
166
213
|
}
|
|
167
214
|
|
|
168
|
-
const protectedBranches =
|
|
215
|
+
const protectedBranches = activeConfig.repo.protectedBranches || ['main', 'master', 'develop', 'prod', 'staging', 'production'];
|
|
169
216
|
if (targetBranch && protectedBranches.includes(targetBranch)) {
|
|
170
217
|
console.warn(`\n==> WARNING: "${targetBranch}" is a protected branch. Skipping local and remote branch deletion.`);
|
|
171
218
|
await finishCleanup();
|
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)
|
package/lib/tui/ansi.js
ADDED
|
@@ -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
|
+
};
|