@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/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
+ }
@@ -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
+ }