@localsummer/incspec 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 +15 -0
- package/README.md +540 -0
- package/commands/analyze.mjs +133 -0
- package/commands/apply.mjs +111 -0
- package/commands/archive.mjs +340 -0
- package/commands/collect-dep.mjs +85 -0
- package/commands/collect-req.mjs +80 -0
- package/commands/cursor-sync.mjs +116 -0
- package/commands/design.mjs +131 -0
- package/commands/help.mjs +235 -0
- package/commands/init.mjs +127 -0
- package/commands/list.mjs +111 -0
- package/commands/merge.mjs +112 -0
- package/commands/status.mjs +117 -0
- package/commands/update.mjs +189 -0
- package/commands/validate.mjs +181 -0
- package/index.mjs +236 -0
- package/lib/agents.mjs +163 -0
- package/lib/config.mjs +343 -0
- package/lib/cursor.mjs +307 -0
- package/lib/spec.mjs +300 -0
- package/lib/terminal.mjs +292 -0
- package/lib/workflow.mjs +563 -0
- package/package.json +40 -0
- package/templates/AGENTS.md +610 -0
- package/templates/INCSPEC_BLOCK.md +19 -0
- package/templates/WORKFLOW.md +22 -0
- package/templates/cursor-commands/analyze-codeflow.md +341 -0
- package/templates/cursor-commands/analyze-increment-codeflow.md +246 -0
- package/templates/cursor-commands/apply-increment-code.md +392 -0
- package/templates/cursor-commands/inc-archive.md +278 -0
- package/templates/cursor-commands/merge-to-baseline.md +329 -0
- package/templates/cursor-commands/structured-requirements-collection.md +123 -0
- package/templates/cursor-commands/ui-dependency-collection.md +143 -0
- package/templates/project.md +24 -0
package/lib/spec.mjs
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Specification file operations for incspec
|
|
3
|
+
* - File version management
|
|
4
|
+
* - CRUD for spec files
|
|
5
|
+
* - Archive management
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { INCSPEC_DIR, DIRS, parseFrontmatter, serializeFrontmatter } from './config.mjs';
|
|
11
|
+
|
|
12
|
+
function escapeRegExp(value) {
|
|
13
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ensureUniqueArchivePath(filePath) {
|
|
17
|
+
if (!fs.existsSync(filePath)) {
|
|
18
|
+
return filePath;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const dir = path.dirname(filePath);
|
|
22
|
+
const ext = path.extname(filePath);
|
|
23
|
+
const base = path.basename(filePath, ext);
|
|
24
|
+
let counter = 1;
|
|
25
|
+
let candidate = filePath;
|
|
26
|
+
|
|
27
|
+
while (fs.existsSync(candidate)) {
|
|
28
|
+
candidate = path.join(dir, `${base}-copy${counter}${ext}`);
|
|
29
|
+
counter += 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* List spec files in a directory
|
|
37
|
+
* @param {string} projectRoot
|
|
38
|
+
* @param {string} type - 'baselines' | 'requirements' | 'increments' | 'archives'
|
|
39
|
+
* @returns {Array<{name: string, path: string, mtime: Date}>}
|
|
40
|
+
*/
|
|
41
|
+
export function listSpecs(projectRoot, type) {
|
|
42
|
+
const dir = path.join(projectRoot, INCSPEC_DIR, DIRS[type] || type);
|
|
43
|
+
|
|
44
|
+
if (!fs.existsSync(dir)) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const files = fs.readdirSync(dir);
|
|
49
|
+
return files
|
|
50
|
+
.filter(f => f.endsWith('.md'))
|
|
51
|
+
.map(f => {
|
|
52
|
+
const filePath = path.join(dir, f);
|
|
53
|
+
const stats = fs.statSync(filePath);
|
|
54
|
+
return {
|
|
55
|
+
name: f,
|
|
56
|
+
path: filePath,
|
|
57
|
+
mtime: stats.mtime,
|
|
58
|
+
};
|
|
59
|
+
})
|
|
60
|
+
.sort((a, b) => b.mtime - a.mtime); // Sort by modification time, newest first
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get next version number for a spec type
|
|
65
|
+
* @param {string} projectRoot
|
|
66
|
+
* @param {string} type - 'baselines' | 'increments'
|
|
67
|
+
* @param {string} prefix - file name prefix (e.g., module name)
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
export function getNextVersion(projectRoot, type, prefix) {
|
|
71
|
+
const specs = listSpecs(projectRoot, type);
|
|
72
|
+
const safePrefix = escapeRegExp(prefix);
|
|
73
|
+
const pattern = new RegExp(`^${safePrefix}.*-v(\\d+)\\.md$`);
|
|
74
|
+
|
|
75
|
+
const versions = specs
|
|
76
|
+
.map(s => {
|
|
77
|
+
const match = s.name.match(pattern);
|
|
78
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
79
|
+
})
|
|
80
|
+
.filter(v => v > 0);
|
|
81
|
+
|
|
82
|
+
return versions.length > 0 ? Math.max(...versions) + 1 : 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get latest spec file for a given prefix
|
|
87
|
+
* @param {string} projectRoot
|
|
88
|
+
* @param {string} type
|
|
89
|
+
* @param {string} prefix
|
|
90
|
+
* @returns {Object|null}
|
|
91
|
+
*/
|
|
92
|
+
export function getLatestSpec(projectRoot, type, prefix) {
|
|
93
|
+
const specs = listSpecs(projectRoot, type);
|
|
94
|
+
const safePrefix = escapeRegExp(prefix);
|
|
95
|
+
const pattern = new RegExp(`^${safePrefix}.*-v(\\d+)\\.md$`);
|
|
96
|
+
|
|
97
|
+
const versioned = specs
|
|
98
|
+
.map(s => {
|
|
99
|
+
const match = s.name.match(pattern);
|
|
100
|
+
return match ? { ...s, version: parseInt(match[1], 10) } : null;
|
|
101
|
+
})
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.sort((a, b) => b.version - a.version);
|
|
104
|
+
|
|
105
|
+
return versioned.length > 0 ? versioned[0] : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create a baseline spec file
|
|
110
|
+
* @param {string} projectRoot
|
|
111
|
+
* @param {string} moduleName
|
|
112
|
+
* @param {string} content
|
|
113
|
+
* @returns {string} Created file path
|
|
114
|
+
*/
|
|
115
|
+
export function createBaseline(projectRoot, moduleName, content) {
|
|
116
|
+
const version = getNextVersion(projectRoot, 'baselines', moduleName);
|
|
117
|
+
const fileName = `${moduleName}-baseline-v${version}.md`;
|
|
118
|
+
const filePath = path.join(projectRoot, INCSPEC_DIR, DIRS.baselines, fileName);
|
|
119
|
+
|
|
120
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
121
|
+
return filePath;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create an increment spec file
|
|
126
|
+
* @param {string} projectRoot
|
|
127
|
+
* @param {string} featureName
|
|
128
|
+
* @param {string} content
|
|
129
|
+
* @returns {string} Created file path
|
|
130
|
+
*/
|
|
131
|
+
export function createIncrement(projectRoot, featureName, content) {
|
|
132
|
+
const version = getNextVersion(projectRoot, 'increments', featureName);
|
|
133
|
+
const fileName = `${featureName}-increment-v${version}.md`;
|
|
134
|
+
const filePath = path.join(projectRoot, INCSPEC_DIR, DIRS.increments, fileName);
|
|
135
|
+
|
|
136
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
137
|
+
return filePath;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a requirements file
|
|
142
|
+
* @param {string} projectRoot
|
|
143
|
+
* @param {string} type - 'structured-requirements' | 'ui-dependencies'
|
|
144
|
+
* @param {string} content
|
|
145
|
+
* @returns {string} Created file path
|
|
146
|
+
*/
|
|
147
|
+
export function createRequirement(projectRoot, type, content) {
|
|
148
|
+
const fileName = `${type}.md`;
|
|
149
|
+
const filePath = path.join(projectRoot, INCSPEC_DIR, DIRS.requirements, fileName);
|
|
150
|
+
|
|
151
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
152
|
+
return filePath;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read a spec file
|
|
157
|
+
* @param {string} filePath
|
|
158
|
+
* @returns {{frontmatter: Object, body: string, raw: string}}
|
|
159
|
+
*/
|
|
160
|
+
export function readSpec(filePath) {
|
|
161
|
+
if (!fs.existsSync(filePath)) {
|
|
162
|
+
throw new Error(`文件不存在: ${filePath}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
166
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
167
|
+
|
|
168
|
+
return { frontmatter, body, raw };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Archive a spec file
|
|
173
|
+
* @param {string} projectRoot
|
|
174
|
+
* @param {string} filePath
|
|
175
|
+
* @param {boolean} deleteOriginal - Default true (move mode) to keep workflow clean
|
|
176
|
+
* @param {string} [module] - Optional module name for subdirectory grouping
|
|
177
|
+
* @returns {string} Archive path
|
|
178
|
+
*/
|
|
179
|
+
export function archiveSpec(projectRoot, filePath, deleteOriginal = true, module = null) {
|
|
180
|
+
if (!fs.existsSync(filePath)) {
|
|
181
|
+
throw new Error(`文件不存在: ${filePath}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const now = new Date();
|
|
185
|
+
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
186
|
+
|
|
187
|
+
// Build archive directory: archives/YYYY-MM[/module]
|
|
188
|
+
let archiveDir = path.join(projectRoot, INCSPEC_DIR, DIRS.archives, yearMonth);
|
|
189
|
+
if (module) {
|
|
190
|
+
archiveDir = path.join(archiveDir, module);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Create archive directory if needed
|
|
194
|
+
if (!fs.existsSync(archiveDir)) {
|
|
195
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const fileName = path.basename(filePath);
|
|
199
|
+
const archivePath = path.join(archiveDir, fileName);
|
|
200
|
+
const finalArchivePath = ensureUniqueArchivePath(archivePath);
|
|
201
|
+
|
|
202
|
+
// Copy or move file
|
|
203
|
+
fs.copyFileSync(filePath, finalArchivePath);
|
|
204
|
+
if (deleteOriginal) {
|
|
205
|
+
fs.unlinkSync(filePath);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return finalArchivePath;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get spec file info from path
|
|
213
|
+
* @param {string} filePath
|
|
214
|
+
* @returns {Object}
|
|
215
|
+
*/
|
|
216
|
+
export function getSpecInfo(filePath) {
|
|
217
|
+
const fileName = path.basename(filePath);
|
|
218
|
+
const dir = path.basename(path.dirname(filePath));
|
|
219
|
+
|
|
220
|
+
// Parse file name pattern: {name}-{type}-v{version}.md
|
|
221
|
+
const baselineMatch = fileName.match(/^(.+)-baseline-v(\d+)\.md$/);
|
|
222
|
+
const incrementMatch = fileName.match(/^(.+)-increment-v(\d+)\.md$/);
|
|
223
|
+
const requirementMatch = fileName.match(/^(structured-requirements|ui-dependencies)\.md$/);
|
|
224
|
+
|
|
225
|
+
if (baselineMatch) {
|
|
226
|
+
return {
|
|
227
|
+
type: 'baseline',
|
|
228
|
+
name: baselineMatch[1],
|
|
229
|
+
version: parseInt(baselineMatch[2], 10),
|
|
230
|
+
fileName,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (incrementMatch) {
|
|
235
|
+
return {
|
|
236
|
+
type: 'increment',
|
|
237
|
+
name: incrementMatch[1],
|
|
238
|
+
version: parseInt(incrementMatch[2], 10),
|
|
239
|
+
fileName,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (requirementMatch) {
|
|
244
|
+
return {
|
|
245
|
+
type: 'requirement',
|
|
246
|
+
name: requirementMatch[1],
|
|
247
|
+
version: null,
|
|
248
|
+
fileName,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
type: 'unknown',
|
|
254
|
+
name: fileName.replace('.md', ''),
|
|
255
|
+
version: null,
|
|
256
|
+
fileName,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Generate spec file path
|
|
262
|
+
* @param {string} projectRoot
|
|
263
|
+
* @param {string} type - 'baselines' | 'requirements' | 'increments'
|
|
264
|
+
* @param {string} fileName
|
|
265
|
+
* @returns {string}
|
|
266
|
+
*/
|
|
267
|
+
export function getSpecPath(projectRoot, type, fileName) {
|
|
268
|
+
return path.join(projectRoot, INCSPEC_DIR, DIRS[type] || type, fileName);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Check if spec file exists
|
|
273
|
+
* @param {string} projectRoot
|
|
274
|
+
* @param {string} type
|
|
275
|
+
* @param {string} fileName
|
|
276
|
+
* @returns {boolean}
|
|
277
|
+
*/
|
|
278
|
+
export function specExists(projectRoot, type, fileName) {
|
|
279
|
+
const filePath = getSpecPath(projectRoot, type, fileName);
|
|
280
|
+
return fs.existsSync(filePath);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Delete a spec file
|
|
285
|
+
* @param {string} filePath
|
|
286
|
+
* @param {boolean} archive - Whether to archive before deleting
|
|
287
|
+
* @param {string} projectRoot - Required if archive is true
|
|
288
|
+
* @param {string} [module] - Optional module name for archive subdirectory
|
|
289
|
+
*/
|
|
290
|
+
export function deleteSpec(filePath, archive = true, projectRoot = null, module = null) {
|
|
291
|
+
if (!fs.existsSync(filePath)) {
|
|
292
|
+
throw new Error(`文件不存在: ${filePath}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (archive && projectRoot) {
|
|
296
|
+
archiveSpec(projectRoot, filePath, true, module);
|
|
297
|
+
} else {
|
|
298
|
+
fs.unlinkSync(filePath);
|
|
299
|
+
}
|
|
300
|
+
}
|
package/lib/terminal.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal utilities for CLI
|
|
3
|
+
* - ANSI color codes
|
|
4
|
+
* - Interactive prompts
|
|
5
|
+
* - Table formatting
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as readline from 'readline';
|
|
9
|
+
|
|
10
|
+
// ANSI color codes
|
|
11
|
+
export const colors = {
|
|
12
|
+
reset: '\x1b[0m',
|
|
13
|
+
bold: '\x1b[1m',
|
|
14
|
+
dim: '\x1b[2m',
|
|
15
|
+
underline: '\x1b[4m',
|
|
16
|
+
|
|
17
|
+
// Foreground colors
|
|
18
|
+
black: '\x1b[30m',
|
|
19
|
+
red: '\x1b[31m',
|
|
20
|
+
green: '\x1b[32m',
|
|
21
|
+
yellow: '\x1b[33m',
|
|
22
|
+
blue: '\x1b[34m',
|
|
23
|
+
magenta: '\x1b[35m',
|
|
24
|
+
cyan: '\x1b[36m',
|
|
25
|
+
white: '\x1b[37m',
|
|
26
|
+
gray: '\x1b[90m',
|
|
27
|
+
|
|
28
|
+
// Background colors
|
|
29
|
+
bgBlack: '\x1b[40m',
|
|
30
|
+
bgRed: '\x1b[41m',
|
|
31
|
+
bgGreen: '\x1b[42m',
|
|
32
|
+
bgYellow: '\x1b[43m',
|
|
33
|
+
bgBlue: '\x1b[44m',
|
|
34
|
+
bgMagenta: '\x1b[45m',
|
|
35
|
+
bgCyan: '\x1b[46m',
|
|
36
|
+
bgWhite: '\x1b[47m',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply color to text
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @param {...string} colorCodes
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function colorize(text, ...colorCodes) {
|
|
46
|
+
return `${colorCodes.join('')}${text}${colors.reset}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Print colored text
|
|
51
|
+
* @param {string} text
|
|
52
|
+
* @param {...string} colorCodes
|
|
53
|
+
*/
|
|
54
|
+
export function print(text, ...colorCodes) {
|
|
55
|
+
if (colorCodes.length > 0) {
|
|
56
|
+
console.log(colorize(text, ...colorCodes));
|
|
57
|
+
} else {
|
|
58
|
+
console.log(text);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Print error message
|
|
64
|
+
* @param {string} message
|
|
65
|
+
*/
|
|
66
|
+
export function printError(message) {
|
|
67
|
+
console.error(colorize(`Error: ${message}`, colors.red));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Print success message
|
|
72
|
+
* @param {string} message
|
|
73
|
+
*/
|
|
74
|
+
export function printSuccess(message) {
|
|
75
|
+
console.log(colorize(`✓ ${message}`, colors.green));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Print warning message
|
|
80
|
+
* @param {string} message
|
|
81
|
+
*/
|
|
82
|
+
export function printWarning(message) {
|
|
83
|
+
console.log(colorize(`⚠ ${message}`, colors.yellow));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Print info message
|
|
88
|
+
* @param {string} message
|
|
89
|
+
*/
|
|
90
|
+
export function printInfo(message) {
|
|
91
|
+
console.log(colorize(`ℹ ${message}`, colors.cyan));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Print a section header
|
|
96
|
+
* @param {string} title
|
|
97
|
+
*/
|
|
98
|
+
export function printHeader(title) {
|
|
99
|
+
console.log();
|
|
100
|
+
console.log(colorize(title, colors.bold, colors.cyan));
|
|
101
|
+
console.log(colorize('─'.repeat(title.length + 4), colors.dim));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Print a step status
|
|
106
|
+
* @param {number} step
|
|
107
|
+
* @param {string} name
|
|
108
|
+
* @param {string} status - 'completed' | 'in_progress' | 'pending'
|
|
109
|
+
*/
|
|
110
|
+
export function printStep(step, name, status) {
|
|
111
|
+
const statusIcons = {
|
|
112
|
+
completed: colorize('✓', colors.green),
|
|
113
|
+
in_progress: colorize('●', colors.yellow),
|
|
114
|
+
pending: colorize('○', colors.dim),
|
|
115
|
+
};
|
|
116
|
+
const statusColors = {
|
|
117
|
+
completed: colors.green,
|
|
118
|
+
in_progress: colors.yellow,
|
|
119
|
+
pending: colors.dim,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const icon = statusIcons[status] || statusIcons.pending;
|
|
123
|
+
const color = statusColors[status] || colors.dim;
|
|
124
|
+
|
|
125
|
+
console.log(` ${icon} ${colorize(`步骤 ${step}:`, colors.bold)} ${colorize(name, color)}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Simple confirm prompt
|
|
130
|
+
* @param {string} message
|
|
131
|
+
* @returns {Promise<boolean>}
|
|
132
|
+
*/
|
|
133
|
+
export async function confirm(message) {
|
|
134
|
+
const rl = readline.createInterface({
|
|
135
|
+
input: process.stdin,
|
|
136
|
+
output: process.stdout,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
rl.question(colorize(`${message} (y/N): `, colors.cyan), (answer) => {
|
|
141
|
+
rl.close();
|
|
142
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Prompt for text input
|
|
149
|
+
* @param {string} message
|
|
150
|
+
* @param {string} defaultValue
|
|
151
|
+
* @returns {Promise<string>}
|
|
152
|
+
*/
|
|
153
|
+
export async function prompt(message, defaultValue = '') {
|
|
154
|
+
const rl = readline.createInterface({
|
|
155
|
+
input: process.stdin,
|
|
156
|
+
output: process.stdout,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const defaultHint = defaultValue ? colorize(` [${defaultValue}]`, colors.dim) : '';
|
|
160
|
+
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
rl.question(colorize(`${message}${defaultHint}: `, colors.cyan), (answer) => {
|
|
163
|
+
rl.close();
|
|
164
|
+
resolve(answer.trim() || defaultValue);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Interactive single select prompt
|
|
171
|
+
* @param {Object} options
|
|
172
|
+
* @param {string} options.message
|
|
173
|
+
* @param {Array<{name: string, value: any, description?: string}>} options.choices
|
|
174
|
+
* @returns {Promise<any>}
|
|
175
|
+
*/
|
|
176
|
+
export async function select({ message, choices }) {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
let cursor = 0;
|
|
179
|
+
|
|
180
|
+
const rl = readline.createInterface({
|
|
181
|
+
input: process.stdin,
|
|
182
|
+
output: process.stdout,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (process.stdin.isTTY) {
|
|
186
|
+
process.stdin.setRawMode(true);
|
|
187
|
+
}
|
|
188
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
189
|
+
|
|
190
|
+
const render = () => {
|
|
191
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
192
|
+
|
|
193
|
+
console.log(colorize(message, colors.bold, colors.cyan));
|
|
194
|
+
console.log(colorize('(Use arrow keys to navigate, enter to select)', colors.dim));
|
|
195
|
+
console.log();
|
|
196
|
+
|
|
197
|
+
choices.forEach((choice, index) => {
|
|
198
|
+
const isCursor = cursor === index;
|
|
199
|
+
const pointer = isCursor ? colorize('❯', colors.cyan) : ' ';
|
|
200
|
+
const name = isCursor ? colorize(choice.name, colors.bold) : choice.name;
|
|
201
|
+
|
|
202
|
+
console.log(`${pointer} ${name}`);
|
|
203
|
+
|
|
204
|
+
if (choice.description && isCursor) {
|
|
205
|
+
console.log(colorize(` ${choice.description}`, colors.gray));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const cleanup = () => {
|
|
211
|
+
if (process.stdin.isTTY) {
|
|
212
|
+
process.stdin.setRawMode(false);
|
|
213
|
+
}
|
|
214
|
+
rl.close();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const handleKeypress = (str, key) => {
|
|
218
|
+
if (!key) return;
|
|
219
|
+
|
|
220
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
221
|
+
cursor = cursor > 0 ? cursor - 1 : choices.length - 1;
|
|
222
|
+
render();
|
|
223
|
+
} else if (key.name === 'down' || key.name === 'j') {
|
|
224
|
+
cursor = cursor < choices.length - 1 ? cursor + 1 : 0;
|
|
225
|
+
render();
|
|
226
|
+
} else if (key.name === 'return') {
|
|
227
|
+
cleanup();
|
|
228
|
+
process.stdin.removeListener('keypress', handleKeypress);
|
|
229
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
230
|
+
resolve(choices[cursor].value);
|
|
231
|
+
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
232
|
+
cleanup();
|
|
233
|
+
process.stdin.removeListener('keypress', handleKeypress);
|
|
234
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
process.stdin.on('keypress', handleKeypress);
|
|
240
|
+
render();
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Format a table for terminal output
|
|
246
|
+
* @param {string[]} headers
|
|
247
|
+
* @param {string[][]} rows
|
|
248
|
+
* @param {Object} options
|
|
249
|
+
*/
|
|
250
|
+
export function printTable(headers, rows, options = {}) {
|
|
251
|
+
const { padding = 2 } = options;
|
|
252
|
+
|
|
253
|
+
// Calculate column widths
|
|
254
|
+
const colWidths = headers.map((h, i) => {
|
|
255
|
+
const maxRowWidth = Math.max(...rows.map(r => (r[i] || '').length));
|
|
256
|
+
return Math.max(h.length, maxRowWidth);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Print header
|
|
260
|
+
const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(' '.repeat(padding));
|
|
261
|
+
console.log(colorize(headerRow, colors.bold));
|
|
262
|
+
console.log(colorize('─'.repeat(headerRow.length), colors.dim));
|
|
263
|
+
|
|
264
|
+
// Print rows
|
|
265
|
+
rows.forEach(row => {
|
|
266
|
+
const rowStr = row.map((cell, i) => (cell || '').padEnd(colWidths[i])).join(' '.repeat(padding));
|
|
267
|
+
console.log(rowStr);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Create a simple spinner
|
|
273
|
+
* @param {string} message
|
|
274
|
+
* @returns {{stop: (success?: boolean, finalMessage?: string) => void}}
|
|
275
|
+
*/
|
|
276
|
+
export function spinner(message) {
|
|
277
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
278
|
+
let i = 0;
|
|
279
|
+
|
|
280
|
+
const interval = setInterval(() => {
|
|
281
|
+
process.stdout.write(`\r${colorize(frames[i], colors.cyan)} ${message}`);
|
|
282
|
+
i = (i + 1) % frames.length;
|
|
283
|
+
}, 80);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
stop: (success = true, finalMessage = message) => {
|
|
287
|
+
clearInterval(interval);
|
|
288
|
+
const icon = success ? colorize('✓', colors.green) : colorize('✗', colors.red);
|
|
289
|
+
process.stdout.write(`\r${icon} ${finalMessage}\n`);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|