@@ -6,6 +6,7 @@ import { loadConfig } from '../config.js';
66export interface MigrationOptions {
77 inputPath : string ;
88 aiProvider ?: 'copilot' | 'claude' | 'gemini' ;
9+ auto ?: boolean ;
910 dryRun ?: boolean ;
1011 batchSize ?: number ;
1112 skipValidation ?: boolean ;
@@ -16,8 +17,11 @@ export interface DocumentInfo {
1617 path : string ;
1718 name : string ;
1819 size : number ;
20+ format ?: 'spec-kit' | 'openspec' | 'adr' | 'generic' ;
1921}
2022
23+ export type SourceFormat = 'spec-kit' | 'openspec' | 'generic' ;
24+
2125export function migrateCommand ( ) : Command ;
2226export function migrateCommand ( inputPath : string , options ?: Partial < MigrationOptions > ) : Promise < void > ;
2327export function migrateCommand ( inputPath ?: string , options : Partial < MigrationOptions > = { } ) : Command | Promise < void > {
@@ -26,14 +30,16 @@ export function migrateCommand(inputPath?: string, options: Partial<MigrationOpt
2630 }
2731
2832 return new Command ( 'migrate' )
29- . description ( 'Migrate specs from other SDD tools (ADR, RFC, OpenSpec, spec-kit, etc.)' )
33+ . description ( 'Migrate specs from other SDD tools (OpenSpec, spec-kit, etc.)' )
3034 . argument ( '<input-path>' , 'Path to directory containing specs to migrate' )
35+ . option ( '--auto' , 'Automatic migration: detect format, restructure, and backfill in one shot' )
3136 . option ( '--with <provider>' , 'AI-assisted migration (copilot, claude, gemini)' )
3237 . option ( '--dry-run' , 'Preview without making changes' )
3338 . option ( '--batch-size <n>' , 'Process N docs at a time' , parseInt )
3439 . option ( '--skip-validation' , "Don't validate after migration" )
3540 . option ( '--backfill' , 'Auto-run backfill after migration' )
3641 . action ( async ( target : string , opts : {
42+ auto ?: boolean ;
3743 with ?: string ;
3844 dryRun ?: boolean ;
3945 batchSize ?: number ;
@@ -45,6 +51,7 @@ export function migrateCommand(inputPath?: string, options: Partial<MigrationOpt
4551 process . exit ( 1 ) ;
4652 }
4753 await migrateSpecs ( target , {
54+ auto : opts . auto ,
4855 aiProvider : opts . with as 'copilot' | 'claude' | 'gemini' | undefined ,
4956 dryRun : opts . dryRun ,
5057 batchSize : opts . batchSize ,
@@ -84,12 +91,22 @@ export async function migrateSpecs(inputPath: string, options: Partial<Migration
8491
8592 console . log ( `\x1b[32m✓\x1b[0m Found ${ documents . length } document${ documents . length === 1 ? '' : 's' } \n` ) ;
8693
94+ // Detect source format
95+ const format = await detectSourceFormat ( inputPath , documents ) ;
96+ console . log ( `\x1b[36mDetected format:\x1b[0m ${ format } \n` ) ;
97+
98+ // Auto mode: one-shot migration
99+ if ( options . auto ) {
100+ await migrateAuto ( inputPath , documents , format , config , options ) ;
101+ return ;
102+ }
103+
87104 // If AI provider specified, verify and execute
88105 if ( options . aiProvider ) {
89106 await migrateWithAI ( inputPath , documents , options as MigrationOptions ) ;
90107 } else {
91108 // Default: Output manual migration instructions
92- await outputManualInstructions ( inputPath , documents , config ) ;
109+ await outputManualInstructions ( inputPath , documents , config , format ) ;
93110 }
94111}
95112
@@ -128,13 +145,253 @@ export async function scanDocuments(dirPath: string): Promise<DocumentInfo[]> {
128145 return documents ;
129146}
130147
148+ /**
149+ * Detect source format based on directory structure and file patterns
150+ */
151+ export async function detectSourceFormat ( inputPath : string , documents : DocumentInfo [ ] ) : Promise < SourceFormat > {
152+ // Check for spec-kit pattern: .specify/specs/ with spec.md files
153+ const hasSpecKit = documents . some ( d =>
154+ d . path . includes ( '.specify' ) || d . name === 'spec.md'
155+ ) ;
156+ if ( hasSpecKit ) {
157+ return 'spec-kit' ;
158+ }
159+
160+ // Check for OpenSpec pattern: openspec/ directory with specs/ and changes/
161+ const hasOpenSpec = documents . some ( d => d . path . includes ( 'openspec/' ) ) ;
162+ if ( hasOpenSpec ) {
163+ return 'openspec' ;
164+ }
165+
166+ // Default to generic markdown
167+ return 'generic' ;
168+ }
169+
170+ /**
171+ * Auto migration - one-shot migration with format detection
172+ */
173+ async function migrateAuto (
174+ inputPath : string ,
175+ documents : DocumentInfo [ ] ,
176+ format : SourceFormat ,
177+ config : any ,
178+ options : Partial < MigrationOptions >
179+ ) : Promise < void > {
180+ const specsDir = path . join ( process . cwd ( ) , config . specsDir || 'specs' ) ;
181+ const startTime = Date . now ( ) ;
182+
183+ console . log ( '═' . repeat ( 70 ) ) ;
184+ console . log ( '\x1b[1m\x1b[36m🚀 Auto Migration\x1b[0m' ) ;
185+ console . log ( '═' . repeat ( 70 ) ) ;
186+ console . log ( ) ;
187+
188+ if ( options . dryRun ) {
189+ console . log ( '\x1b[33m⚠️ DRY RUN - No changes will be made\x1b[0m\n' ) ;
190+ }
191+
192+ // Ensure specs directory exists
193+ if ( ! options . dryRun ) {
194+ await fs . mkdir ( specsDir , { recursive : true } ) ;
195+ }
196+
197+ let migratedCount = 0 ;
198+ let skippedCount = 0 ;
199+
200+ // Get existing spec numbers for sequencing
201+ let nextSeq = 1 ;
202+ try {
203+ const existingSpecs = await fs . readdir ( specsDir ) ;
204+ const seqNumbers = existingSpecs
205+ . map ( name => {
206+ const match = name . match ( / ^ ( \d + ) - / ) ;
207+ return match ? parseInt ( match [ 1 ] , 10 ) : 0 ;
208+ } )
209+ . filter ( n => n > 0 ) ;
210+ if ( seqNumbers . length > 0 ) {
211+ nextSeq = Math . max ( ...seqNumbers ) + 1 ;
212+ }
213+ } catch {
214+ // specs dir doesn't exist yet
215+ }
216+
217+ // Batch operations based on format
218+ if ( format === 'spec-kit' ) {
219+ // spec-kit: Folders already structured, just rename spec.md -> README.md
220+ console . log ( '\x1b[36mMigrating spec-kit format...\x1b[0m\n' ) ;
221+
222+ // Find all spec.md files and their parent directories
223+ const specMdFiles = documents . filter ( d => d . name === 'spec.md' ) ;
224+
225+ for ( const doc of specMdFiles ) {
226+ const sourceDir = path . dirname ( doc . path ) ;
227+ const dirName = path . basename ( sourceDir ) ;
228+
229+ // Check if already has sequence number
230+ const hasSeq = / ^ \d { 3 } - / . test ( dirName ) ;
231+ const targetDirName = hasSeq ? dirName : `${ String ( nextSeq ) . padStart ( 3 , '0' ) } -${ dirName } ` ;
232+ const targetDir = path . join ( specsDir , targetDirName ) ;
233+
234+ if ( ! options . dryRun ) {
235+ // Copy entire directory
236+ await copyDirectory ( sourceDir , targetDir ) ;
237+
238+ // Rename spec.md to README.md
239+ const oldPath = path . join ( targetDir , 'spec.md' ) ;
240+ const newPath = path . join ( targetDir , 'README.md' ) ;
241+ try {
242+ await fs . rename ( oldPath , newPath ) ;
243+ } catch {
244+ // spec.md might not exist if already README.md
245+ }
246+ }
247+
248+ console . log ( ` \x1b[32m✓\x1b[0m ${ dirName } → ${ targetDirName } /` ) ;
249+ migratedCount ++ ;
250+ if ( ! hasSeq ) nextSeq ++ ;
251+ }
252+ } else if ( format === 'openspec' ) {
253+ // OpenSpec: Merge specs/ and changes/archive/, restructure
254+ console . log ( '\x1b[36mMigrating OpenSpec format...\x1b[0m\n' ) ;
255+
256+ // Group documents by their containing folder
257+ const folders = new Map < string , DocumentInfo [ ] > ( ) ;
258+ for ( const doc of documents ) {
259+ const parentDir = path . dirname ( doc . path ) ;
260+ const folderName = path . basename ( parentDir ) ;
261+ if ( ! folders . has ( folderName ) ) {
262+ folders . set ( folderName , [ ] ) ;
263+ }
264+ folders . get ( folderName ) ! . push ( doc ) ;
265+ }
266+
267+ for ( const [ folderName , docs ] of folders ) {
268+ // Skip if it's a container folder
269+ if ( [ 'specs' , 'archive' , 'changes' , 'openspec' ] . includes ( folderName ) ) {
270+ continue ;
271+ }
272+
273+ const targetDirName = `${ String ( nextSeq ) . padStart ( 3 , '0' ) } -${ folderName } ` ;
274+ const targetDir = path . join ( specsDir , targetDirName ) ;
275+
276+ if ( ! options . dryRun ) {
277+ await fs . mkdir ( targetDir , { recursive : true } ) ;
278+
279+ for ( const doc of docs ) {
280+ const targetName = doc . name === 'spec.md' ? 'README.md' : doc . name ;
281+ const targetPath = path . join ( targetDir , targetName ) ;
282+ await fs . copyFile ( doc . path , targetPath ) ;
283+ }
284+ }
285+
286+ console . log ( ` \x1b[32m✓\x1b[0m ${ folderName } → ${ targetDirName } /` ) ;
287+ migratedCount ++ ;
288+ nextSeq ++ ;
289+ }
290+ } else {
291+ // Generic: Create folder for each markdown file
292+ console . log ( '\x1b[36mMigrating generic markdown files...\x1b[0m\n' ) ;
293+
294+ for ( const doc of documents ) {
295+ // Extract name from filename (remove extension and leading numbers)
296+ const baseName = path . basename ( doc . name , path . extname ( doc . name ) )
297+ . replace ( / ^ \d + - / , '' ) // Remove leading numbers
298+ . replace ( / ^ [ _ - ] + / , '' ) // Remove leading separators
299+ . toLowerCase ( )
300+ . replace ( / [ ^ a - z 0 - 9 ] + / g, '-' ) // Normalize to kebab-case
301+ . replace ( / - + $ / , '' ) ; // Remove trailing dashes
302+
303+ if ( ! baseName ) {
304+ console . log ( ` \x1b[33m⚠\x1b[0m Skipped: ${ doc . name } (invalid name)` ) ;
305+ skippedCount ++ ;
306+ continue ;
307+ }
308+
309+ const targetDirName = `${ String ( nextSeq ) . padStart ( 3 , '0' ) } -${ baseName } ` ;
310+ const targetDir = path . join ( specsDir , targetDirName ) ;
311+ const targetPath = path . join ( targetDir , 'README.md' ) ;
312+
313+ if ( ! options . dryRun ) {
314+ await fs . mkdir ( targetDir , { recursive : true } ) ;
315+ await fs . copyFile ( doc . path , targetPath ) ;
316+ }
317+
318+ console . log ( ` \x1b[32m✓\x1b[0m ${ doc . name } → ${ targetDirName } /README.md` ) ;
319+ migratedCount ++ ;
320+ nextSeq ++ ;
321+ }
322+ }
323+
324+ console . log ( ) ;
325+
326+ // Run backfill if requested or in auto mode
327+ if ( ( options . backfill || options . auto ) && ! options . dryRun ) {
328+ console . log ( '\x1b[36mRunning backfill...\x1b[0m' ) ;
329+ try {
330+ const { backfillCommand } = await import ( './backfill.js' ) ;
331+ // Create command and run with options
332+ const cmd = backfillCommand ( ) ;
333+ await cmd . parseAsync ( [ 'node' , 'lean-spec' , '--all' , '--assignee' ] , { from : 'user' } ) ;
334+ console . log ( '\x1b[32m✓\x1b[0m Backfill complete\n' ) ;
335+ } catch ( error ) {
336+ console . log ( '\x1b[33m⚠\x1b[0m Backfill failed, run manually: lean-spec backfill --all\n' ) ;
337+ }
338+ }
339+
340+ // Run validation if not skipped
341+ if ( ! options . skipValidation && ! options . dryRun ) {
342+ console . log ( '\x1b[36mValidating...\x1b[0m' ) ;
343+ try {
344+ const { validateSpecs } = await import ( './validate.js' ) ;
345+ await validateSpecs ( { } ) ;
346+ console . log ( '\x1b[32m✓\x1b[0m Validation complete\n' ) ;
347+ } catch {
348+ console . log ( '\x1b[33m⚠\x1b[0m Validation had issues, run: lean-spec validate\n' ) ;
349+ }
350+ }
351+
352+ const elapsed = ( ( Date . now ( ) - startTime ) / 1000 ) . toFixed ( 1 ) ;
353+
354+ console . log ( '═' . repeat ( 70 ) ) ;
355+ console . log ( `\x1b[32m✓ Migration complete!\x1b[0m` ) ;
356+ console . log ( ` Migrated: ${ migratedCount } specs` ) ;
357+ if ( skippedCount > 0 ) {
358+ console . log ( ` Skipped: ${ skippedCount } files` ) ;
359+ }
360+ console . log ( ` Time: ${ elapsed } s` ) ;
361+ console . log ( '═' . repeat ( 70 ) ) ;
362+ console . log ( ) ;
363+ console . log ( 'Next steps:' ) ;
364+ console . log ( ' lean-spec board # View your specs' ) ;
365+ console . log ( ' lean-spec validate # Check for issues' ) ;
366+ }
367+
368+ /**
369+ * Copy directory recursively
370+ */
371+ async function copyDirectory ( src : string , dest : string ) : Promise < void > {
372+ await fs . mkdir ( dest , { recursive : true } ) ;
373+ const entries = await fs . readdir ( src , { withFileTypes : true } ) ;
374+
375+ for ( const entry of entries ) {
376+ const srcPath = path . join ( src , entry . name ) ;
377+ const destPath = path . join ( dest , entry . name ) ;
378+
379+ if ( entry . isDirectory ( ) ) {
380+ await copyDirectory ( srcPath , destPath ) ;
381+ } else {
382+ await fs . copyFile ( srcPath , destPath ) ;
383+ }
384+ }
385+ }
386+
131387/**
132388 * Output manual migration instructions (default mode)
133389 */
134390async function outputManualInstructions (
135391 inputPath : string ,
136392 documents : DocumentInfo [ ] ,
137- config : any
393+ config : any ,
394+ format : SourceFormat
138395) : Promise < void > {
139396 const specsDir = config . specsDir || 'specs' ;
140397
@@ -144,6 +401,11 @@ async function outputManualInstructions(
144401 console . log ( ) ;
145402 console . log ( '\x1b[1mSource Location:\x1b[0m' ) ;
146403 console . log ( ` ${ inputPath } (${ documents . length } documents found)` ) ;
404+ console . log ( ` Detected format: ${ format } ` ) ;
405+ console . log ( ) ;
406+ console . log ( '\x1b[1m💡 Quick Option:\x1b[0m' ) ;
407+ console . log ( ` \x1b[36mlean-spec migrate ${ inputPath } --auto\x1b[0m` ) ;
408+ console . log ( ' This will automatically restructure and backfill in one shot.' ) ;
147409 console . log ( ) ;
148410 console . log ( '\x1b[1mMigration Prompt:\x1b[0m' ) ;
149411 console . log ( ' Copy this prompt to your AI assistant (Copilot, Claude, ChatGPT, etc.):' ) ;
0 commit comments