@oamm/textor 1.0.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.
@@ -0,0 +1,3819 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { mkdir, writeFile, readFile, unlink, rm, rmdir, rename, readdir, stat, open } from 'fs/promises';
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import path from 'path';
6
+ import { createHash } from 'crypto';
7
+ import { exec } from 'child_process';
8
+ import { promisify } from 'util';
9
+
10
+ const CONFIG_DIR$1 = '.textor';
11
+ const CONFIG_FILE = 'config.json';
12
+
13
+ /**
14
+ * @typedef {Object} TextorConfig
15
+ * @property {Object} paths
16
+ * @property {string} paths.pages
17
+ * @property {string} paths.features
18
+ * @property {string} paths.components
19
+ * @property {string} paths.layouts
20
+ * @property {Object} routing
21
+ * @property {string} routing.mode
22
+ * @property {string} routing.indexFile
23
+ * @property {Object} importAliases
24
+ * @property {string} importAliases.layouts
25
+ * @property {string} importAliases.features
26
+ * @property {Object} naming
27
+ * @property {string} naming.routeExtension
28
+ * @property {string} naming.featureExtension
29
+ * @property {string} naming.componentExtension
30
+ * @property {string} naming.hookExtension
31
+ * @property {string} naming.testExtension
32
+ * @property {Object} signatures
33
+ * @property {string} signatures.astro
34
+ * @property {string} signatures.typescript
35
+ * @property {string} signatures.javascript
36
+ * @property {Object} features
37
+ * @property {string} features.entry
38
+ * @property {boolean} features.createSubComponentsDir
39
+ * @property {boolean} features.createScriptsDir
40
+ * @property {string} features.scriptsIndexFile
41
+ * @property {Object} components
42
+ * @property {boolean} components.createSubComponentsDir
43
+ * @property {boolean} components.createContext
44
+ * @property {boolean} components.createHook
45
+ * @property {boolean} components.createTests
46
+ * @property {boolean} components.createConfig
47
+ * @property {boolean} components.createConstants
48
+ * @property {boolean} components.createTypes
49
+ * @property {Object} formatting
50
+ * @property {string} formatting.tool
51
+ * @property {Object} git
52
+ * @property {boolean} git.requireCleanRepo
53
+ * @property {boolean} git.stageChanges
54
+ * @property {Object} presets
55
+ * @property {string} defaultPreset
56
+ */
57
+
58
+ /**
59
+ * Default configuration for Textor
60
+ * @type {TextorConfig}
61
+ */
62
+ const DEFAULT_CONFIG = {
63
+ paths: {
64
+ pages: 'src/pages',
65
+ features: 'src/features',
66
+ components: 'src/components',
67
+ layouts: 'src/layouts'
68
+ },
69
+ routing: {
70
+ mode: 'flat', // 'flat' | 'nested'
71
+ indexFile: 'index.astro'
72
+ },
73
+ importAliases: {},
74
+ naming: {
75
+ routeExtension: '.astro',
76
+ featureExtension: '.astro',
77
+ componentExtension: '.tsx',
78
+ hookExtension: '.ts',
79
+ testExtension: '.test.tsx'
80
+ },
81
+ signatures: {
82
+ astro: '<!-- @generated by Textor -->',
83
+ typescript: '// @generated by Textor',
84
+ javascript: '// @generated by Textor',
85
+ tsx: '// @generated by Textor'
86
+ },
87
+ features: {
88
+ framework: 'astro',
89
+ entry: 'pascal', // 'index' | 'pascal'
90
+ createSubComponentsDir: true,
91
+ createScriptsDir: true,
92
+ scriptsIndexFile: 'scripts/index.ts',
93
+ createApi: false,
94
+ createServices: false,
95
+ createSchemas: false,
96
+ createHooks: false,
97
+ createContext: false,
98
+ createTests: false,
99
+ createTypes: false,
100
+ createReadme: false,
101
+ createStories: false,
102
+ createIndex: false
103
+ },
104
+ components: {
105
+ framework: 'react',
106
+ createSubComponentsDir: true,
107
+ createContext: true,
108
+ createHook: true,
109
+ createTests: true,
110
+ createConfig: true,
111
+ createConstants: true,
112
+ createTypes: true,
113
+ createApi: false,
114
+ createServices: false,
115
+ createSchemas: false,
116
+ createReadme: false,
117
+ createStories: false
118
+ },
119
+ formatting: {
120
+ tool: 'none' // 'prettier' | 'biome' | 'none'
121
+ },
122
+ hashing: {
123
+ normalization: 'normalizeEOL', // 'none' | 'normalizeEOL' | 'stripGeneratedRegions'
124
+ useMarkers: false
125
+ },
126
+ git: {
127
+ requireCleanRepo: false,
128
+ stageChanges: false
129
+ },
130
+ kindRules: [],
131
+ presets: {
132
+ minimal: {
133
+ features: {
134
+ createSubComponentsDir: false,
135
+ createScriptsDir: false
136
+ },
137
+ components: {
138
+ createSubComponentsDir: false,
139
+ createContext: false,
140
+ createHook: false,
141
+ createTests: false,
142
+ createConfig: false,
143
+ createConstants: false,
144
+ createTypes: false
145
+ }
146
+ },
147
+ standard: {
148
+ features: {
149
+ createSubComponentsDir: true,
150
+ createScriptsDir: true
151
+ },
152
+ components: {
153
+ createSubComponentsDir: true,
154
+ createContext: true,
155
+ createHook: true,
156
+ createTests: true,
157
+ createConfig: true,
158
+ createConstants: true,
159
+ createTypes: true
160
+ }
161
+ },
162
+ senior: {
163
+ features: {
164
+ createSubComponentsDir: true,
165
+ createScriptsDir: true,
166
+ createApi: true,
167
+ createServices: true,
168
+ createSchemas: true,
169
+ createHooks: true,
170
+ createContext: true,
171
+ createTests: true,
172
+ createTypes: true,
173
+ createReadme: true,
174
+ createStories: true,
175
+ createIndex: true
176
+ },
177
+ components: {
178
+ createSubComponentsDir: true,
179
+ createContext: true,
180
+ createHook: true,
181
+ createTests: true,
182
+ createConfig: true,
183
+ createConstants: true,
184
+ createTypes: true,
185
+ createApi: true,
186
+ createServices: true,
187
+ createSchemas: true,
188
+ createReadme: true,
189
+ createStories: true
190
+ }
191
+ }
192
+ },
193
+ defaultPreset: 'standard'
194
+ };
195
+
196
+ /**
197
+ * Gets the absolute path to the .textor directory.
198
+ * @returns {string}
199
+ */
200
+ function getConfigDir() {
201
+ return path.join(process.cwd(), CONFIG_DIR$1);
202
+ }
203
+
204
+ /**
205
+ * Gets the absolute path to the config.json file.
206
+ * @returns {string}
207
+ */
208
+ function getConfigPath() {
209
+ return path.join(getConfigDir(), CONFIG_FILE);
210
+ }
211
+
212
+ /**
213
+ * Loads the configuration from config.json and merges it with DEFAULT_CONFIG.
214
+ * @returns {Promise<TextorConfig>}
215
+ * @throws {Error} If configuration not found or invalid
216
+ */
217
+ async function loadConfig() {
218
+ const configPath = getConfigPath();
219
+
220
+ if (!existsSync(configPath)) {
221
+ throw new Error(
222
+ `Textor configuration not found at ${configPath}\n` +
223
+ `Run 'textor init' to create it.`
224
+ );
225
+ }
226
+
227
+ try {
228
+ const content = await readFile(configPath, 'utf-8');
229
+ const config = JSON.parse(content);
230
+ const merged = deepMerge(DEFAULT_CONFIG, config);
231
+ validateConfig(merged);
232
+ return merged;
233
+ } catch (error) {
234
+ if (error instanceof SyntaxError) {
235
+ throw new Error(`Failed to parse config: Invalid JSON at ${configPath}`);
236
+ }
237
+ throw new Error(`Failed to load config: ${error.message}`);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Saves the configuration to config.json.
243
+ * @param {TextorConfig} config Configuration to save
244
+ * @param {boolean} [force=false] Whether to overwrite existing config
245
+ * @returns {Promise<string>} Path to the saved config file
246
+ * @throws {Error} If config exists and force is false
247
+ */
248
+ async function saveConfig(config, force = false) {
249
+ const configPath = getConfigPath();
250
+
251
+ if (existsSync(configPath) && !force) {
252
+ throw new Error(
253
+ `Configuration already exists at ${configPath}\n` +
254
+ `Use --force to overwrite.`
255
+ );
256
+ }
257
+
258
+ validateConfig(config);
259
+
260
+ const configDir = getConfigDir();
261
+ if (!existsSync(configDir)) {
262
+ await mkdir(configDir, { recursive: true });
263
+ }
264
+
265
+ await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
266
+ return configPath;
267
+ }
268
+
269
+ /**
270
+ * Deeply merges source object into target object.
271
+ * @param {Object} target
272
+ * @param {Object} source
273
+ * @returns {Object} Merged object
274
+ */
275
+ function deepMerge(target, source) {
276
+ const result = { ...target };
277
+
278
+ for (const key in source) {
279
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
280
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
281
+ result[key] = deepMerge(target[key] || {}, source[key]);
282
+ } else {
283
+ result[key] = source[key];
284
+ }
285
+ }
286
+ }
287
+
288
+ return result;
289
+ }
290
+
291
+ /**
292
+ * Validates the configuration object structure.
293
+ * @param {any} config
294
+ * @throws {Error} If config is invalid
295
+ */
296
+ function validateConfig(config) {
297
+ if (!config || typeof config !== 'object') {
298
+ throw new Error('Invalid configuration: must be an object');
299
+ }
300
+
301
+ const requiredSections = ['paths', 'naming', 'signatures', 'importAliases'];
302
+ for (const section of requiredSections) {
303
+ if (!config[section] || typeof config[section] !== 'object') {
304
+ throw new Error(`Invalid configuration: missing or invalid "${section}" section`);
305
+ }
306
+ }
307
+
308
+ if (config.kindRules && !Array.isArray(config.kindRules)) {
309
+ throw new Error('Invalid configuration: "kindRules" must be an array');
310
+ }
311
+
312
+ // Validate paths are strings
313
+ for (const [key, value] of Object.entries(config.paths)) {
314
+ if (typeof value !== 'string') {
315
+ throw new Error(`Invalid configuration: "paths.${key}" must be a string`);
316
+ }
317
+ }
318
+
319
+ // Validate naming extensions start with dot
320
+ for (const [key, value] of Object.entries(config.naming)) {
321
+ if (typeof value === 'string' && !value.startsWith('.') && value !== '') {
322
+ throw new Error(`Invalid configuration: "naming.${key}" should start with a dot (e.g., ".astro")`);
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Resolves a configured path key to an absolute path.
329
+ * @param {TextorConfig} config
330
+ * @param {keyof TextorConfig['paths']} pathKey
331
+ * @returns {string}
332
+ */
333
+ function resolvePath(config, pathKey) {
334
+ const configuredPath = config.paths[pathKey];
335
+ if (!configuredPath) {
336
+ throw new Error(`Path "${pathKey}" not found in configuration`);
337
+ }
338
+ return path.resolve(process.cwd(), configuredPath);
339
+ }
340
+
341
+ /**
342
+ * Merges CLI options, preset defaults, and global config.
343
+ * @param {Object} cmdOptions
344
+ * @param {TextorConfig} config
345
+ * @param {'features' | 'components'} type
346
+ */
347
+ function getEffectiveOptions(cmdOptions, config, type) {
348
+ const presetName = cmdOptions.preset || config.defaultPreset || 'standard';
349
+ const preset = config.presets[presetName] || config.presets['standard'] || {};
350
+ const presetTypeOptions = preset[type] || {};
351
+ const configTypeOptions = config[type] || {};
352
+
353
+ const merged = { ...configTypeOptions, ...presetTypeOptions };
354
+
355
+ // Explicit CLI flags should override
356
+ // Commander uses camelCase for flags like --no-sub-components-dir -> subComponentsDir
357
+ for (const key in merged) {
358
+ if (cmdOptions[key] !== undefined) {
359
+ merged[key] = cmdOptions[key];
360
+ } else if (key.startsWith('create')) {
361
+ // Try mapping short flags to "create" prefix
362
+ // e.g., CLI --api (cmdOptions.api) -> config createApi
363
+ const shortKey = key.slice(6).charAt(0).toLowerCase() + key.slice(7);
364
+ if (cmdOptions[shortKey] !== undefined) {
365
+ merged[key] = cmdOptions[shortKey];
366
+ }
367
+ }
368
+ }
369
+
370
+ return merged;
371
+ }
372
+
373
+ async function initCommand(options) {
374
+ try {
375
+ const configPath = await saveConfig(DEFAULT_CONFIG, options.force);
376
+
377
+ console.log('✓ Textor configuration created at:', configPath);
378
+ console.log('\nDefault configuration:');
379
+ console.log(JSON.stringify(DEFAULT_CONFIG, null, 2));
380
+ console.log('\nYou can now use Textor commands like:');
381
+ console.log(' textor add-section /users users/catalog --layout Main');
382
+ } catch (error) {
383
+ console.error('Error:', error.message);
384
+ process.exit(1);
385
+ }
386
+ }
387
+
388
+ function toPascalCase(input) {
389
+ return input
390
+ .split(/[/\\_-]/)
391
+ .filter(Boolean)
392
+ .map(segment => {
393
+ if (segment === segment.toUpperCase() && segment.length > 1) {
394
+ segment = segment.toLowerCase();
395
+ }
396
+ return segment.charAt(0).toUpperCase() + segment.slice(1);
397
+ })
398
+ .join('');
399
+ }
400
+
401
+ function getFeatureComponentName(featurePath) {
402
+ return toPascalCase(featurePath);
403
+ }
404
+
405
+ function getHookFunctionName(componentName) {
406
+ return 'use' + componentName;
407
+ }
408
+
409
+ function getHookFileName(componentName, extension = '.ts') {
410
+ return getHookFunctionName(componentName) + extension;
411
+ }
412
+
413
+ function normalizeComponentName(name) {
414
+ return toPascalCase(name);
415
+ }
416
+
417
+ function normalizeRoute(route) {
418
+ let normalized = route.trim();
419
+
420
+ if (!normalized.startsWith('/')) {
421
+ normalized = '/' + normalized;
422
+ }
423
+
424
+ if (normalized.length > 1 && normalized.endsWith('/')) {
425
+ normalized = normalized.slice(0, -1);
426
+ }
427
+
428
+ return normalized;
429
+ }
430
+
431
+ function routeToFilePath(route, options = {}) {
432
+ const { extension = '.astro', mode = 'flat', indexFile = 'index.astro' } = options;
433
+ const normalized = normalizeRoute(route);
434
+
435
+ if (normalized === '/') {
436
+ return indexFile;
437
+ }
438
+
439
+ const routePath = normalized.slice(1);
440
+ if (mode === 'nested') {
441
+ return path.join(routePath, indexFile);
442
+ }
443
+
444
+ return routePath + extension;
445
+ }
446
+
447
+ function featureToDirectoryPath(featurePath) {
448
+ return featurePath.replace(/^\/+/, '').replace(/\/+$/, '');
449
+ }
450
+
451
+ function getFeatureFileName(featurePath, options = {}) {
452
+ const { extension = '.astro', strategy = 'index' } = options;
453
+ if (strategy === 'pascal') {
454
+ return getFeatureComponentName(featurePath) + extension;
455
+ }
456
+ return 'index' + extension;
457
+ }
458
+
459
+ /**
460
+ * Calculates a relative import path from one file to another.
461
+ * @param {string} fromFile The absolute path of the file containing the import
462
+ * @param {string} toFile The absolute path of the file being imported
463
+ * @returns {string} The relative import path
464
+ */
465
+ function getRelativeImportPath(fromFile, toFile) {
466
+ let relativePath = path.relative(path.dirname(fromFile), toFile);
467
+
468
+ // Convert backslashes to forward slashes for imports
469
+ relativePath = relativePath.split(path.sep).join('/');
470
+
471
+ // Ensure it starts with ./ or ../
472
+ if (!relativePath.startsWith('.')) {
473
+ relativePath = './' + relativePath;
474
+ }
475
+
476
+ return relativePath;
477
+ }
478
+
479
+ const execAsync$1 = promisify(exec);
480
+
481
+ function calculateHash(content, normalization = 'normalizeEOL') {
482
+ let normalizedContent = content;
483
+
484
+ if (normalization === 'stripGeneratedRegions') {
485
+ // Extract all content between :begin and :end markers
486
+ const beginMarker = /@generated by Textor:begin/g;
487
+ const endMarker = /@generated by Textor:end/g;
488
+
489
+ const regions = [];
490
+ let match;
491
+ const beginIndices = [];
492
+ while ((match = beginMarker.exec(content)) !== null) {
493
+ beginIndices.push(match.index + match[0].length);
494
+ }
495
+
496
+ const endIndices = [];
497
+ while ((match = endMarker.exec(content)) !== null) {
498
+ endIndices.push(match.index);
499
+ }
500
+
501
+ if (beginIndices.length > 0 && beginIndices.length === endIndices.length) {
502
+ for (let i = 0; i < beginIndices.length; i++) {
503
+ regions.push(content.slice(beginIndices[i], endIndices[i]));
504
+ }
505
+ normalizedContent = regions.join('\n');
506
+ }
507
+ // Fall back to full content if markers are missing or mismatched
508
+ }
509
+
510
+ if (normalization === 'normalizeEOL' || normalization === 'stripGeneratedRegions') {
511
+ normalizedContent = normalizedContent.replace(/\r\n/g, '\n');
512
+ }
513
+
514
+ return createHash('sha256').update(normalizedContent).digest('hex');
515
+ }
516
+
517
+ async function isTextorGenerated(filePath, customSignatures = []) {
518
+ if (!existsSync(filePath)) {
519
+ return false;
520
+ }
521
+
522
+ try {
523
+ const content = await readFile(filePath, 'utf-8');
524
+ const signatures = ['@generated by Textor', ...customSignatures];
525
+ return signatures.some(sig => content.includes(sig));
526
+ } catch {
527
+ return false;
528
+ }
529
+ }
530
+
531
+ async function verifyFileIntegrity(filePath, expectedHash, options = {}) {
532
+ const {
533
+ force = false,
534
+ acceptChanges = false,
535
+ normalization = 'normalizeEOL',
536
+ owner = null,
537
+ actualOwner = null
538
+ } = options;
539
+
540
+ if (force) return { valid: true };
541
+
542
+ if (owner && actualOwner && owner !== actualOwner) {
543
+ return {
544
+ valid: false,
545
+ reason: 'wrong-owner',
546
+ message: `Refusing to operate on ${filePath} - owned by ${actualOwner}, but requested by ${owner}. Use --force to override.`
547
+ };
548
+ }
549
+
550
+ const isGenerated = await isTextorGenerated(filePath);
551
+ if (!isGenerated) {
552
+ return {
553
+ valid: false,
554
+ reason: 'not-generated',
555
+ message: `Refusing to operate on ${filePath} - not generated by Textor. Use --force to override.`
556
+ };
557
+ }
558
+
559
+ if (!expectedHash) {
560
+ return {
561
+ valid: false,
562
+ reason: 'not-in-state',
563
+ message: `Refusing to operate on ${filePath} - not found in Textor state. Use --force to override.`
564
+ };
565
+ }
566
+
567
+ if (!acceptChanges) {
568
+ const content = await readFile(filePath, 'utf-8');
569
+ const currentHash = calculateHash(content, normalization);
570
+ if (currentHash !== expectedHash) {
571
+ return {
572
+ valid: false,
573
+ reason: 'hash-mismatch',
574
+ message: `Refusing to operate on ${filePath} - content has been modified. Use --accept-changes or --force to override.`
575
+ };
576
+ }
577
+ }
578
+
579
+ return { valid: true };
580
+ }
581
+
582
+ async function safeDelete(filePath, options = {}) {
583
+ const { force = false, expectedHash = null, acceptChanges = false, owner = null, actualOwner = null } = options;
584
+
585
+ if (!existsSync(filePath)) {
586
+ return { deleted: false, reason: 'not-found' };
587
+ }
588
+
589
+ const integrity = await verifyFileIntegrity(filePath, expectedHash, {
590
+ force,
591
+ acceptChanges,
592
+ owner,
593
+ actualOwner
594
+ });
595
+ if (!integrity.valid) {
596
+ return { deleted: false, reason: integrity.reason, message: integrity.message };
597
+ }
598
+
599
+ await unlink(filePath);
600
+ return { deleted: true };
601
+ }
602
+
603
+ async function ensureNotExists(filePath, force = false) {
604
+ if (existsSync(filePath)) {
605
+ if (!force) {
606
+ throw new Error(
607
+ `File already exists: ${filePath}\n` +
608
+ `Use --force to overwrite.`
609
+ );
610
+ }
611
+ }
612
+ }
613
+
614
+ async function ensureDir(dirPath) {
615
+ await mkdir(dirPath, { recursive: true });
616
+ }
617
+
618
+ async function isSafeToDeleteDir(dirPath, stateFiles = {}, options = {}) {
619
+ try {
620
+ const files = await readdir(dirPath);
621
+
622
+ const results = await Promise.all(
623
+ files.map(async file => {
624
+ const filePath = path.join(dirPath, file);
625
+ const stats = await stat(filePath);
626
+
627
+ if (stats.isDirectory()) {
628
+ return await isSafeToDeleteDir(filePath, stateFiles, options);
629
+ }
630
+
631
+ const normalizedPath = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
632
+ const fileState = stateFiles[normalizedPath];
633
+ const integrity = await verifyFileIntegrity(filePath, fileState?.hash, {
634
+ ...options,
635
+ actualOwner: fileState?.owner
636
+ });
637
+ return integrity.valid;
638
+ })
639
+ );
640
+
641
+ return results.every(Boolean);
642
+ } catch {
643
+ return false;
644
+ }
645
+ }
646
+
647
+ async function safeDeleteDir(dirPath, options = {}) {
648
+ const { force = false, stateFiles = {} } = options;
649
+ if (!existsSync(dirPath)) {
650
+ return { deleted: false, reason: 'not-found' };
651
+ }
652
+
653
+ const isSafe = force || await isSafeToDeleteDir(dirPath, stateFiles, options);
654
+
655
+ if (isSafe) {
656
+ await rm(dirPath, { recursive: true, force: true });
657
+ return { deleted: true };
658
+ }
659
+
660
+ return {
661
+ deleted: false,
662
+ reason: 'contains-non-generated-or-modified',
663
+ message: `Directory contains non-generated or modified files: ${dirPath}. Use --force to override.`
664
+ };
665
+ }
666
+
667
+ async function writeFileWithSignature(filePath, content, signature, normalization = 'normalizeEOL') {
668
+ await ensureDir(path.dirname(filePath));
669
+
670
+ let finalContent = signature + '\n' + content;
671
+
672
+ if (signature.includes(':begin')) {
673
+ const endSignature = signature.replace(':begin', ':end');
674
+ finalContent = signature + '\n' + content + '\n' + endSignature;
675
+ }
676
+
677
+ await writeFile(filePath, finalContent, 'utf-8');
678
+
679
+ return calculateHash(finalContent, normalization);
680
+ }
681
+
682
+ function getSignature(config, type) {
683
+ const base = config.signatures[type] || config.signatures.typescript;
684
+ if (config.hashing?.useMarkers) {
685
+ return base.replace('Textor', 'Textor:begin');
686
+ }
687
+ return base;
688
+ }
689
+
690
+ async function updateSignature(filePath, oldPath, newPath) {
691
+ if (!existsSync(filePath)) {
692
+ return;
693
+ }
694
+
695
+ let content = await readFile(filePath, 'utf-8');
696
+
697
+ content = content.replace(
698
+ new RegExp(oldPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
699
+ newPath
700
+ );
701
+
702
+ await writeFile(filePath, content, 'utf-8');
703
+ }
704
+
705
+ async function safeMove(fromPath, toPath, options = {}) {
706
+ const {
707
+ force = false,
708
+ expectedHash = null,
709
+ acceptChanges = false,
710
+ normalization = 'normalizeEOL',
711
+ owner = null,
712
+ actualOwner = null
713
+ } = options;
714
+
715
+ if (!existsSync(fromPath)) {
716
+ throw new Error(`Source file not found: ${fromPath}`);
717
+ }
718
+
719
+ if (existsSync(toPath) && !force) {
720
+ throw new Error(
721
+ `Destination already exists: ${toPath}\n` +
722
+ `Use --force to overwrite.`
723
+ );
724
+ }
725
+
726
+ const integrity = await verifyFileIntegrity(fromPath, expectedHash, {
727
+ force,
728
+ acceptChanges,
729
+ normalization,
730
+ owner,
731
+ actualOwner
732
+ });
733
+ if (!integrity.valid) {
734
+ throw new Error(integrity.message);
735
+ }
736
+
737
+ await ensureDir(path.dirname(toPath));
738
+ await rename(fromPath, toPath);
739
+
740
+ await updateSignature(toPath, fromPath, toPath);
741
+
742
+ // Return new hash because updateSignature might have changed it
743
+ const content = await readFile(toPath, 'utf-8');
744
+ return calculateHash(content, normalization);
745
+ }
746
+
747
+ async function isEmptyDir(dirPath) {
748
+ if (!existsSync(dirPath)) {
749
+ return true;
750
+ }
751
+
752
+ const files = await readdir(dirPath);
753
+ return files.length === 0;
754
+ }
755
+
756
+ async function scanDirectory(dir, fileSet) {
757
+ const files = await readdir(dir);
758
+ for (const file of files) {
759
+ const fullPath = path.join(dir, file);
760
+ const stats = await stat(fullPath);
761
+ if (stats.isDirectory()) {
762
+ if (file === 'node_modules' || file === '.git' || file === '.textor') continue;
763
+ await scanDirectory(fullPath, fileSet);
764
+ } else {
765
+ const relativePath = path.relative(process.cwd(), fullPath).replace(/\\/g, '/');
766
+ fileSet.add(relativePath);
767
+ }
768
+ }
769
+ }
770
+
771
+ function inferKind(filePath, config) {
772
+ const normalizedFilePath = path.resolve(filePath).replace(/\\/g, '/');
773
+ const relativeFromCwd = path.relative(process.cwd(), normalizedFilePath).replace(/\\/g, '/');
774
+
775
+ // Check kindRules first (precedence)
776
+ if (config.kindRules && Array.isArray(config.kindRules)) {
777
+ for (const rule of config.kindRules) {
778
+ if (rule.match && rule.kind) {
779
+ // Simple glob-to-regex conversion for ** and *
780
+ const regexStr = rule.match
781
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
782
+ .replace(/\*\*/g, '(.+)') // ** matches anything
783
+ .replace(/\*/g, '([^/]+)'); // * matches one segment
784
+
785
+ const regex = new RegExp(`^${regexStr}$`);
786
+ if (regex.test(relativeFromCwd) || regex.test(normalizedFilePath)) {
787
+ return rule.kind;
788
+ }
789
+ }
790
+ }
791
+ }
792
+
793
+ const pagesRoot = path.resolve(process.cwd(), config.paths.pages || 'src/pages').replace(/\\/g, '/');
794
+ const featuresRoot = path.resolve(process.cwd(), config.paths.features || 'src/features').replace(/\\/g, '/');
795
+ const componentsRoot = path.resolve(process.cwd(), config.paths.components || 'src/components').replace(/\\/g, '/');
796
+
797
+ const ext = path.extname(normalizedFilePath);
798
+
799
+ if (normalizedFilePath.startsWith(pagesRoot)) {
800
+ if (ext === '.ts' || ext === '.js') return 'endpoint';
801
+ return 'route';
802
+ }
803
+
804
+ if (normalizedFilePath.startsWith(featuresRoot)) {
805
+ const relativePath = path.relative(featuresRoot, normalizedFilePath).replace(/\\/g, '/');
806
+ const parts = relativePath.split('/');
807
+
808
+ // If it's directly in the features root
809
+ if (parts.length === 1) return 'feature';
810
+
811
+ const fileName = parts[parts.length - 1];
812
+ const featureDir = path.dirname(relativePath).replace(/\\/g, '/');
813
+
814
+ // Main feature file can be FeatureName.astro or index.astro
815
+ const featureName = toPascalCase(featureDir);
816
+ const possiblePascalName = featureName + (config.naming.featureExtension || '.astro');
817
+ const possibleIndexName = 'index' + (config.naming.featureExtension || '.astro');
818
+
819
+ if (path.dirname(relativePath) !== '.' && (fileName === possiblePascalName || fileName === possibleIndexName)) {
820
+ // It's in the top level of its feature directory
821
+ if (parts.length === (featureDir.split('/').length + 1)) {
822
+ return 'feature';
823
+ }
824
+ }
825
+
826
+ return 'feature-file';
827
+ }
828
+
829
+ if (normalizedFilePath.startsWith(componentsRoot)) {
830
+ const relativePath = path.relative(componentsRoot, normalizedFilePath).replace(/\\/g, '/');
831
+ const parts = relativePath.split('/');
832
+
833
+ if (parts.length === 1) return 'component';
834
+
835
+ const componentDir = parts[0];
836
+ const fileName = parts[parts.length - 1];
837
+ const componentName = toPascalCase(componentDir);
838
+
839
+ const possibleComponentName = componentName + (config.naming.componentExtension || '.tsx');
840
+ const possibleIndexName = 'index' + (config.naming.componentExtension || '.tsx');
841
+
842
+ if (parts.length === 2 && (fileName === possibleComponentName || fileName === possibleIndexName)) {
843
+ return 'component';
844
+ }
845
+
846
+ return 'component-file';
847
+ }
848
+
849
+ return 'unknown';
850
+ }
851
+
852
+ /**
853
+ * Safely joins path segments and ensures the result is within the basePath.
854
+ * @param {string} basePath The base directory that must contain the result
855
+ * @param {...string} segments Path segments to join
856
+ * @returns {string} The joined path
857
+ * @throws {Error} If a path traversal attempt is detected
858
+ */
859
+ function secureJoin(basePath, ...segments) {
860
+ const joinedPath = path.join(basePath, ...segments);
861
+ const resolvedBase = path.resolve(basePath);
862
+ const resolvedJoined = path.resolve(joinedPath);
863
+
864
+ const relative = path.relative(resolvedBase, resolvedJoined);
865
+
866
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
867
+ throw new Error(`Security error: Path traversal attempt detected: ${joinedPath} is outside of ${basePath}`);
868
+ }
869
+
870
+ return joinedPath;
871
+ }
872
+
873
+ async function cleanupEmptyDirs(dirPath, rootPath) {
874
+ const normalizedDir = path.resolve(dirPath);
875
+ const normalizedRoot = path.resolve(rootPath);
876
+
877
+ if (normalizedDir === normalizedRoot || !normalizedDir.startsWith(normalizedRoot)) {
878
+ return;
879
+ }
880
+
881
+ if (await isEmptyDir(normalizedDir)) {
882
+ await rmdir(normalizedDir);
883
+ await cleanupEmptyDirs(path.dirname(normalizedDir), rootPath);
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Formats a list of files using the specified tool.
889
+ * @param {string[]} filePaths
890
+ * @param {'prettier' | 'biome' | 'none'} tool
891
+ */
892
+ async function formatFiles(filePaths, tool) {
893
+ if (tool === 'none' || !filePaths.length) return;
894
+
895
+ // Quote paths to handle spaces
896
+ const paths = filePaths.map(p => `"${p}"`).join(' ');
897
+
898
+ if (tool === 'prettier') {
899
+ try {
900
+ await execAsync$1(`npx prettier --write ${paths}`);
901
+ } catch (error) {
902
+ // Silently fail if prettier is not available or fails
903
+ }
904
+ } else if (tool === 'biome') {
905
+ try {
906
+ await execAsync$1(`npx biome format --write ${paths}`);
907
+ } catch (error) {
908
+ // Silently fail
909
+ }
910
+ }
911
+ }
912
+
913
+ var filesystem = /*#__PURE__*/Object.freeze({
914
+ __proto__: null,
915
+ calculateHash: calculateHash,
916
+ cleanupEmptyDirs: cleanupEmptyDirs,
917
+ ensureDir: ensureDir,
918
+ ensureNotExists: ensureNotExists,
919
+ formatFiles: formatFiles,
920
+ getSignature: getSignature,
921
+ inferKind: inferKind,
922
+ isEmptyDir: isEmptyDir,
923
+ isTextorGenerated: isTextorGenerated,
924
+ safeDelete: safeDelete,
925
+ safeDeleteDir: safeDeleteDir,
926
+ safeMove: safeMove,
927
+ scanDirectory: scanDirectory,
928
+ secureJoin: secureJoin,
929
+ updateSignature: updateSignature,
930
+ verifyFileIntegrity: verifyFileIntegrity,
931
+ writeFileWithSignature: writeFileWithSignature
932
+ });
933
+
934
+ function getTemplateOverride(templateName, extension, data = {}) {
935
+ const overridePath = path.join(process.cwd(), '.textor', 'templates', `${templateName}${extension}`);
936
+ if (existsSync(overridePath)) {
937
+ let content = readFileSync(overridePath, 'utf-8');
938
+ for (const [key, value] of Object.entries(data)) {
939
+ content = content.replace(new RegExp(`{{${key}}}`, 'g'), () => value || '');
940
+ }
941
+ return content;
942
+ }
943
+ return null;
944
+ }
945
+
946
+ /**
947
+ * Route Template Variables:
948
+ * - layoutName: The name of the layout component
949
+ * - layoutImportPath: Path to import the layout
950
+ * - featureImportPath: Path to import the feature component
951
+ * - featureComponentName: Name of the feature component
952
+ */
953
+ function generateRouteTemplate(layoutName, layoutImportPath, featureImportPath, featureComponentName) {
954
+ const override = getTemplateOverride('route', '.astro', {
955
+ layoutName,
956
+ layoutImportPath,
957
+ featureImportPath,
958
+ featureComponentName
959
+ });
960
+ if (override) return override;
961
+
962
+ if (layoutName === 'none') {
963
+ return `---
964
+ import ${featureComponentName} from '${featureImportPath}';
965
+ ---
966
+
967
+ <${featureComponentName} />
968
+ `;
969
+ }
970
+
971
+ return `---
972
+ import ${layoutName} from '${layoutImportPath}';
973
+ import ${featureComponentName} from '${featureImportPath}';
974
+ ---
975
+
976
+ <${layoutName}>
977
+ <${featureComponentName} />
978
+ </${layoutName}>
979
+ `;
980
+ }
981
+
982
+ /**
983
+ * Feature Template Variables:
984
+ * - componentName: Name of the feature component
985
+ * - scriptImportPath: Path to the feature's client-side script
986
+ */
987
+ function generateFeatureTemplate(componentName, scriptImportPath, framework = 'astro') {
988
+ const extension = framework === 'astro' ? '.astro' : '.tsx';
989
+ const override = getTemplateOverride('feature', extension, { componentName, scriptImportPath });
990
+ if (override) return override;
991
+
992
+ if (framework === 'react') {
993
+ return `export type ${componentName}Props = {
994
+ // Add props here
995
+ }
996
+
997
+ export default function ${componentName}({ }: ${componentName}Props) {
998
+ return (
999
+ <div className="${componentName.toLowerCase()}">
1000
+ <h1>${componentName}</h1>
1001
+ </div>
1002
+ );
1003
+ }
1004
+ `;
1005
+ }
1006
+
1007
+ const scriptTag = scriptImportPath ? `\n<script src="${scriptImportPath}"></script>` : '';
1008
+
1009
+ return `---
1010
+ // Feature: ${componentName}
1011
+ ---
1012
+
1013
+ <div class="${componentName.toLowerCase()}">
1014
+ <h1>${componentName}</h1>
1015
+ </div>${scriptTag}
1016
+ `;
1017
+ }
1018
+
1019
+ /**
1020
+ * Scripts Index Template (no variables)
1021
+ */
1022
+ function generateScriptsIndexTemplate() {
1023
+ const override = getTemplateOverride('scripts-index', '.ts');
1024
+ if (override) return override;
1025
+
1026
+ return `export {};
1027
+ `;
1028
+ }
1029
+
1030
+ /**
1031
+ * Component Template Variables:
1032
+ * - componentName: Name of the component
1033
+ */
1034
+ function generateComponentTemplate(componentName, framework = 'react') {
1035
+ const extension = framework === 'astro' ? '.astro' : '.tsx';
1036
+ const override = getTemplateOverride('component', extension, { componentName });
1037
+ if (override) return override;
1038
+
1039
+ if (framework === 'react') {
1040
+ return `export type ${componentName}Props = {
1041
+ // Add props here
1042
+ }
1043
+
1044
+ export default function ${componentName}({ }: ${componentName}Props) {
1045
+ return (
1046
+ <div className="${componentName.toLowerCase()}">
1047
+ {/* ${componentName} implementation */}
1048
+ </div>
1049
+ );
1050
+ }
1051
+ `;
1052
+ }
1053
+
1054
+ return `---
1055
+ export type Props = {
1056
+ // Add props here
1057
+ }
1058
+
1059
+ const props = Astro.props;
1060
+ ---
1061
+
1062
+ <div class="${componentName.toLowerCase()}">
1063
+ <!-- ${componentName} implementation -->
1064
+ </div>
1065
+ `;
1066
+ }
1067
+
1068
+ /**
1069
+ * Hook Template Variables:
1070
+ * - componentName: Name of the component
1071
+ * - hookName: Name of the hook function (e.g., useButton)
1072
+ */
1073
+ function generateHookTemplate(componentName, hookName) {
1074
+ const override = getTemplateOverride('hook', '.ts', { componentName, hookName });
1075
+ if (override) return override;
1076
+
1077
+ return `import { useState } from 'react';
1078
+
1079
+ export function ${hookName}() {
1080
+ // Add hook logic here
1081
+
1082
+ return {
1083
+ // Return hook values
1084
+ };
1085
+ }
1086
+ `;
1087
+ }
1088
+
1089
+ /**
1090
+ * Context Template Variables:
1091
+ * - componentName: Name of the component
1092
+ */
1093
+ function generateContextTemplate(componentName) {
1094
+ const override = getTemplateOverride('context', '.tsx', { componentName });
1095
+ if (override) return override;
1096
+
1097
+ return `import { createContext, useContext } from 'react';
1098
+
1099
+ //@ts-ignore
1100
+ type ${componentName}ContextValue = {
1101
+ // Add context value types here
1102
+ }
1103
+
1104
+ const ${componentName}Context = createContext<${componentName}ContextValue | undefined>(undefined);
1105
+
1106
+ export function ${componentName}Provider({ children }: { children: React.ReactNode }) {
1107
+ const value: ${componentName}ContextValue = {
1108
+ // Provide context values
1109
+ };
1110
+
1111
+ return (
1112
+ <${componentName}Context.Provider value={value}>
1113
+ {children}
1114
+ </${componentName}Context.Provider>
1115
+ );
1116
+ }
1117
+
1118
+ export function use${componentName}Context() {
1119
+ const context = useContext(${componentName}Context);
1120
+ if (context === undefined) {
1121
+ throw new Error('use${componentName}Context must be used within ${componentName}Provider');
1122
+ }
1123
+ return context;
1124
+ }
1125
+ `;
1126
+ }
1127
+
1128
+ /**
1129
+ * Test Template Variables:
1130
+ * - componentName: Name of the component
1131
+ * - componentPath: Relative path to the component file
1132
+ */
1133
+ function generateTestTemplate(componentName, componentPath) {
1134
+ const override = getTemplateOverride('test', '.tsx', { componentName, componentPath });
1135
+ if (override) return override;
1136
+
1137
+ return `import { describe, it, expect } from 'vitest';
1138
+ import { render, screen } from '@testing-library/react';
1139
+ import ${componentName} from '${componentPath}';
1140
+
1141
+ describe('${componentName}', () => {
1142
+ it('renders without crashing', () => {
1143
+ render(<${componentName} />);
1144
+ expect(screen.getByText('${componentName}')).toBeInTheDocument();
1145
+ });
1146
+ });
1147
+ `;
1148
+ }
1149
+
1150
+ /**
1151
+ * Config Template Variables:
1152
+ * - componentName: Name of the component
1153
+ */
1154
+ function generateConfigTemplate(componentName) {
1155
+ const override = getTemplateOverride('config', '.ts', { componentName });
1156
+ if (override) return override;
1157
+
1158
+ return `export const ${componentName}Config = {
1159
+ // Add configuration here
1160
+ };
1161
+ `;
1162
+ }
1163
+
1164
+ /**
1165
+ * Constants Template Variables:
1166
+ * - componentName: Name of the component
1167
+ */
1168
+ function generateConstantsTemplate(componentName) {
1169
+ const override = getTemplateOverride('constants', '.ts', { componentName });
1170
+ if (override) return override;
1171
+
1172
+ return `export const ${componentName.toUpperCase()}_CONSTANTS = {
1173
+ // Add constants here
1174
+ };
1175
+ `;
1176
+ }
1177
+
1178
+ /**
1179
+ * Index Template Variables:
1180
+ * - componentName: Name of the component
1181
+ * - componentExtension: File extension of the component
1182
+ */
1183
+ function generateIndexTemplate(componentName, componentExtension) {
1184
+ const override = getTemplateOverride('index', '.ts', { componentName, componentExtension });
1185
+ if (override) return override;
1186
+
1187
+ return `export { default as ${componentName} } from './${componentName}${componentExtension}';
1188
+ export * from './types';
1189
+ `;
1190
+ }
1191
+
1192
+ /**
1193
+ * Types Template Variables:
1194
+ * - componentName: Name of the component
1195
+ */
1196
+ function generateTypesTemplate(componentName) {
1197
+ const override = getTemplateOverride('types', '.ts', { componentName });
1198
+ if (override) return override;
1199
+
1200
+ return `export type ${componentName}Props = {
1201
+ // Add props types here
1202
+ };
1203
+ `;
1204
+ }
1205
+
1206
+ /**
1207
+ * API Template Variables:
1208
+ * - componentName: Name of the component
1209
+ */
1210
+ function generateApiTemplate(componentName) {
1211
+ const override = getTemplateOverride('api', '.ts', { componentName });
1212
+ if (override) return override;
1213
+
1214
+ return `export function GET({ params, request }) {
1215
+ return new Response(
1216
+ JSON.stringify({
1217
+ name: "${componentName}",
1218
+ url: "https://astro.build/",
1219
+ }),
1220
+ );
1221
+ }
1222
+ `;
1223
+ }
1224
+
1225
+ /**
1226
+ * Endpoint Template Variables:
1227
+ * - componentName: Name of the component
1228
+ */
1229
+ function generateEndpointTemplate(componentName) {
1230
+ const override = getTemplateOverride('endpoint', '.ts', { componentName });
1231
+ if (override) return override;
1232
+
1233
+ return `export function GET({ params, request }) {
1234
+ return new Response(
1235
+ JSON.stringify({
1236
+ name: "${componentName}",
1237
+ url: "https://astro.build/",
1238
+ }),
1239
+ );
1240
+ }
1241
+ `;
1242
+ }
1243
+
1244
+ /**
1245
+ * Service Template Variables:
1246
+ * - componentName: Name of the component
1247
+ */
1248
+ function generateServiceTemplate(componentName) {
1249
+ const override = getTemplateOverride('service', '.ts', { componentName });
1250
+ if (override) return override;
1251
+
1252
+ return `// ${componentName} business logic and transformers
1253
+ export async function get${componentName}Data() {
1254
+ // Encapsulated logic for data processing
1255
+ return [];
1256
+ }
1257
+
1258
+ export function transform${componentName}Data(data: any) {
1259
+ // Domain-specific data transformations
1260
+ return data;
1261
+ }
1262
+ `;
1263
+ }
1264
+
1265
+ /**
1266
+ * Schema Template Variables:
1267
+ * - componentName: Name of the component
1268
+ */
1269
+ function generateSchemaTemplate(componentName) {
1270
+ const override = getTemplateOverride('schema', '.ts', { componentName });
1271
+ if (override) return override;
1272
+
1273
+ return `import { z } from 'zod';
1274
+
1275
+ export const ${componentName}Schema = z.object({
1276
+ id: z.string().uuid(),
1277
+ createdAt: z.string().datetime(),
1278
+ updatedAt: z.string().datetime().optional(),
1279
+ });
1280
+
1281
+ export type ${componentName} = z.infer<typeof ${componentName}Schema>;
1282
+ `;
1283
+ }
1284
+
1285
+ /**
1286
+ * Readme Template Variables:
1287
+ * - componentName: Name of the component
1288
+ */
1289
+ function generateReadmeTemplate(componentName) {
1290
+ const override = getTemplateOverride('readme', '.md', { componentName });
1291
+ if (override) return override;
1292
+
1293
+ return `# ${componentName}
1294
+
1295
+ ## Description
1296
+ Brief description of what this feature/component does.
1297
+
1298
+ ## Props/Usage
1299
+ How to use this and what are its requirements.
1300
+ `;
1301
+ }
1302
+
1303
+ /**
1304
+ * Stories Template Variables:
1305
+ * - componentName: Name of the component
1306
+ * - componentPath: Relative path to the component file
1307
+ */
1308
+ function generateStoriesTemplate(componentName, componentPath) {
1309
+ const override = getTemplateOverride('stories', '.tsx', { componentName, componentPath });
1310
+ if (override) return override;
1311
+
1312
+ return `import type { Meta, StoryObj } from '@storybook/react';
1313
+ import ${componentName} from '${componentPath}';
1314
+
1315
+ const meta: Meta<typeof ${componentName}> = {
1316
+ title: 'Components/${componentName}',
1317
+ component: ${componentName},
1318
+ };
1319
+
1320
+ export default meta;
1321
+ type Story = StoryObj<typeof ${componentName}>;
1322
+
1323
+ export const Default: Story = {
1324
+ args: {
1325
+ // Default props
1326
+ },
1327
+ };
1328
+ `;
1329
+ }
1330
+
1331
+ const CONFIG_DIR = '.textor';
1332
+ const STATE_FILE = 'state.json';
1333
+
1334
+ function getStatePath() {
1335
+ return path.join(process.cwd(), CONFIG_DIR, STATE_FILE);
1336
+ }
1337
+
1338
+ async function loadState() {
1339
+ const statePath = getStatePath();
1340
+ if (!existsSync(statePath)) {
1341
+ return { sections: [], components: [], files: {} };
1342
+ }
1343
+
1344
+ try {
1345
+ const content = await readFile(statePath, 'utf-8');
1346
+ const state = JSON.parse(content);
1347
+ if (!state.files) state.files = {};
1348
+ return state;
1349
+ } catch (error) {
1350
+ return { sections: [], components: [], files: {} };
1351
+ }
1352
+ }
1353
+
1354
+ let saveQueue = Promise.resolve();
1355
+
1356
+ async function saveState(state) {
1357
+ const result = saveQueue.then(async () => {
1358
+ const statePath = getStatePath();
1359
+ const dir = path.dirname(statePath);
1360
+ if (!existsSync(dir)) {
1361
+ await mkdir(dir, { recursive: true });
1362
+ }
1363
+
1364
+ const tempPath = statePath + '.' + Math.random().toString(36).slice(2) + '.tmp';
1365
+ const content = JSON.stringify(state, null, 2);
1366
+
1367
+ const handle = await open(tempPath, 'w');
1368
+ await handle.writeFile(content, 'utf-8');
1369
+ await handle.sync();
1370
+ await handle.close();
1371
+
1372
+ await rename(tempPath, statePath);
1373
+ });
1374
+
1375
+ saveQueue = result.catch(() => {});
1376
+ return result;
1377
+ }
1378
+
1379
+ async function registerFile(filePath, { kind, template, hash, templateVersion = '1.0.0', owner = null }) {
1380
+ const state = await loadState();
1381
+ const normalizedPath = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
1382
+
1383
+ state.files[normalizedPath] = {
1384
+ kind,
1385
+ template,
1386
+ hash,
1387
+ templateVersion,
1388
+ owner,
1389
+ timestamp: new Date().toISOString()
1390
+ };
1391
+
1392
+ await saveState(state);
1393
+ }
1394
+
1395
+ async function addSectionToState(section) {
1396
+ const state = await loadState();
1397
+ // Avoid duplicates by route
1398
+ state.sections = state.sections.filter(s => s.route !== section.route);
1399
+ state.sections.push(section);
1400
+ await saveState(state);
1401
+ }
1402
+
1403
+ async function addComponentToState(component) {
1404
+ const state = await loadState();
1405
+ // Avoid duplicates by name
1406
+ state.components = state.components.filter(c => c.name !== component.name);
1407
+ state.components.push(component);
1408
+ await saveState(state);
1409
+ }
1410
+
1411
+ function findSection(state, identifier) {
1412
+ return state.sections.find(s => s.route === identifier || s.name === identifier || s.featurePath === identifier);
1413
+ }
1414
+
1415
+ function findComponent(state, name) {
1416
+ return state.components.find(c => c.name === name);
1417
+ }
1418
+
1419
+ function reconstructComponents(files, config) {
1420
+ const componentsRoot = (config.paths.components || 'src/components').replace(/\\/g, '/');
1421
+ const components = new Map();
1422
+
1423
+ for (const filePath in files) {
1424
+ const normalizedPath = filePath.replace(/\\/g, '/');
1425
+ if (normalizedPath === componentsRoot || normalizedPath.startsWith(componentsRoot + '/')) {
1426
+ const relativePath = normalizedPath === componentsRoot ? '' : normalizedPath.slice(componentsRoot.length + 1);
1427
+ if (relativePath === '') continue; // skip the root itself if it's in files for some reason
1428
+
1429
+ const parts = relativePath.split('/');
1430
+ if (parts.length >= 1) {
1431
+ const componentName = parts[0];
1432
+ const componentPath = `${componentsRoot}/${componentName}`;
1433
+ if (!components.has(componentName)) {
1434
+ components.set(componentName, {
1435
+ name: componentName,
1436
+ path: componentPath
1437
+ });
1438
+ }
1439
+ }
1440
+ }
1441
+ }
1442
+
1443
+ return Array.from(components.values());
1444
+ }
1445
+
1446
+ function reconstructSections(state, config) {
1447
+ const pagesRoot = (config.paths.pages || 'src/pages').replace(/\\/g, '/');
1448
+ const featuresRoot = (config.paths.features || 'src/features').replace(/\\/g, '/');
1449
+ const files = state.files;
1450
+
1451
+ // Keep existing sections if their files still exist
1452
+ const validSections = (state.sections || []).filter(section => {
1453
+ // Check if route file exists in state.files
1454
+ const routeFile = Object.keys(files).find(f => {
1455
+ const normalizedF = f.replace(/\\/g, '/');
1456
+ const routePath = section.route === '/' ? 'index' : section.route.slice(1);
1457
+ return normalizedF.startsWith(pagesRoot + '/' + routePath + '.') ||
1458
+ normalizedF === pagesRoot + '/' + routePath + '/index.astro'; // nested mode
1459
+ });
1460
+
1461
+ // Check if feature directory has at least one file in state.files
1462
+ const hasFeatureFiles = Object.keys(files).some(f =>
1463
+ f.replace(/\\/g, '/').startsWith(section.featurePath.replace(/\\/g, '/') + '/')
1464
+ );
1465
+
1466
+ return routeFile && hasFeatureFiles;
1467
+ });
1468
+
1469
+ const sections = new Map();
1470
+ validSections.forEach(s => sections.set(s.route, s));
1471
+
1472
+ // Try to discover new sections
1473
+ for (const filePath in files) {
1474
+ const normalizedPath = filePath.replace(/\\/g, '/');
1475
+ if (normalizedPath.startsWith(pagesRoot + '/')) {
1476
+ const relativePath = normalizedPath.slice(pagesRoot.length + 1);
1477
+ const route = '/' + relativePath.replace(/\.(astro|ts|js|tsx|jsx)$/, '').replace(/\/index$/, '');
1478
+ const finalRoute = route === '' ? '/' : route;
1479
+
1480
+ if (!sections.has(finalRoute)) {
1481
+ // Try to find a matching feature by name
1482
+ const routeName = path.basename(finalRoute === '/' ? 'index' : finalRoute);
1483
+ // Look for a directory in features with same name or similar
1484
+ const possibleFeaturePath = Object.keys(files).find(f => {
1485
+ const nf = f.replace(/\\/g, '/');
1486
+ return nf.startsWith(featuresRoot + '/') && nf.includes('/' + routeName + '/');
1487
+ });
1488
+
1489
+ if (possibleFeaturePath) {
1490
+ const featurePathParts = possibleFeaturePath.replace(/\\/g, '/').split('/');
1491
+ const featuresBase = path.basename(featuresRoot);
1492
+ const featureIndex = featurePathParts.indexOf(featuresBase) + 1;
1493
+
1494
+ if (featureIndex > 0 && featureIndex < featurePathParts.length) {
1495
+ const featureName = featurePathParts[featureIndex];
1496
+ const featurePath = `${featuresRoot}/${featureName}`;
1497
+
1498
+ sections.set(finalRoute, {
1499
+ name: featureName,
1500
+ route: finalRoute,
1501
+ featurePath: featurePath,
1502
+ extension: path.extname(filePath)
1503
+ });
1504
+ }
1505
+ }
1506
+ }
1507
+ }
1508
+ }
1509
+
1510
+ return Array.from(sections.values());
1511
+ }
1512
+
1513
+ const execAsync = promisify(exec);
1514
+
1515
+ async function isRepoClean() {
1516
+ try {
1517
+ const { stdout } = await execAsync('git status --porcelain');
1518
+ return stdout.trim() === '';
1519
+ } catch (error) {
1520
+ // If not a git repo, we consider it clean or at least we can't check
1521
+ return true;
1522
+ }
1523
+ }
1524
+
1525
+ async function stageFiles(filePaths) {
1526
+ if (!filePaths.length) return;
1527
+ try {
1528
+ const paths = filePaths.map(p => `"${p}"`).join(' ');
1529
+ await execAsync(`git add ${paths}`);
1530
+ } catch (error) {
1531
+ // Ignore errors if git is not available
1532
+ }
1533
+ }
1534
+
1535
+ async function addSectionCommand(route, featurePath, options) {
1536
+ try {
1537
+ const config = await loadConfig();
1538
+ const effectiveOptions = getEffectiveOptions(options, config, 'features');
1539
+
1540
+ const normalizedRoute = normalizeRoute(route);
1541
+ const normalizedFeaturePath = featureToDirectoryPath(featurePath);
1542
+
1543
+ const pagesRoot = resolvePath(config, 'pages');
1544
+ const featuresRoot = resolvePath(config, 'features');
1545
+ const layoutsRoot = resolvePath(config, 'layouts');
1546
+
1547
+ const routeExtension = options.endpoint ? '.ts' : config.naming.routeExtension;
1548
+
1549
+ // Check if we should use nested mode even if config says flat
1550
+ // (because the directory already exists, suggesting it should be an index file)
1551
+ let effectiveRoutingMode = config.routing.mode;
1552
+ if (effectiveRoutingMode === 'flat') {
1553
+ const routeDirName = routeToFilePath(normalizedRoute, {
1554
+ extension: '',
1555
+ mode: 'flat'
1556
+ });
1557
+ const routeDirPath = secureJoin(pagesRoot, routeDirName);
1558
+ if (existsSync(routeDirPath)) {
1559
+ effectiveRoutingMode = 'nested';
1560
+ }
1561
+ }
1562
+
1563
+ const routeFileName = routeToFilePath(normalizedRoute, {
1564
+ extension: routeExtension,
1565
+ mode: effectiveRoutingMode,
1566
+ indexFile: config.routing.indexFile
1567
+ });
1568
+
1569
+ const featureComponentName = getFeatureComponentName(normalizedFeaturePath);
1570
+ const featureFileName = getFeatureFileName(normalizedFeaturePath, {
1571
+ extension: config.naming.featureExtension,
1572
+ strategy: effectiveOptions.entry
1573
+ });
1574
+
1575
+ const routeFilePath = secureJoin(pagesRoot, routeFileName);
1576
+ const featureDirPath = secureJoin(featuresRoot, normalizedFeaturePath);
1577
+ const featureFilePath = secureJoin(featureDirPath, featureFileName);
1578
+ const scriptsIndexPath = secureJoin(featureDirPath, config.features.scriptsIndexFile);
1579
+ const subComponentsDir = secureJoin(featureDirPath, 'sub-components');
1580
+ const testsDir = secureJoin(featureDirPath, '__tests__');
1581
+ const contextDirInside = secureJoin(featureDirPath, 'context');
1582
+ const hooksDirInside = secureJoin(featureDirPath, 'hooks');
1583
+ const typesDirInside = secureJoin(featureDirPath, 'types');
1584
+ const apiDirInside = secureJoin(featureDirPath, 'api');
1585
+ const servicesDirInside = secureJoin(featureDirPath, 'services');
1586
+ const schemasDirInside = secureJoin(featureDirPath, 'schemas');
1587
+
1588
+ const {
1589
+ framework,
1590
+ createSubComponentsDir: shouldCreateSubComponentsDir,
1591
+ createScriptsDir: shouldCreateScriptsDir,
1592
+ createApi: shouldCreateApi,
1593
+ createServices: shouldCreateServices,
1594
+ createSchemas: shouldCreateSchemas,
1595
+ createHooks: shouldCreateHooks,
1596
+ createContext: shouldCreateContext,
1597
+ createTests: shouldCreateTests,
1598
+ createTypes: shouldCreateTypes,
1599
+ createReadme: shouldCreateReadme,
1600
+ createStories: shouldCreateStories,
1601
+ createIndex: shouldCreateIndex
1602
+ } = effectiveOptions;
1603
+
1604
+ const indexFilePath = path.join(featureDirPath, 'index.ts');
1605
+ const contextFilePath = path.join(contextDirInside, `${featureComponentName}Context.tsx`);
1606
+ const hookFilePath = path.join(hooksDirInside, getHookFileName(featureComponentName, config.naming.hookExtension));
1607
+ const testFilePath = path.join(testsDir, `${featureComponentName}${config.naming.testExtension}`);
1608
+ const typesFilePath = path.join(typesDirInside, 'index.ts');
1609
+ const apiFilePath = path.join(apiDirInside, 'index.ts');
1610
+ const servicesFilePath = path.join(servicesDirInside, 'index.ts');
1611
+ const schemasFilePath = path.join(schemasDirInside, 'index.ts');
1612
+ const readmeFilePath = path.join(featureDirPath, 'README.md');
1613
+ const storiesFilePath = path.join(featureDirPath, `${featureComponentName}.stories.tsx`);
1614
+
1615
+ const routeParts = normalizedRoute.split('/').filter(Boolean);
1616
+ const reorganizations = [];
1617
+
1618
+ if (routeParts.length > 1 && config.routing.mode === 'flat') {
1619
+ const possibleExtensions = ['.astro', '.ts', '.js', '.md', '.mdx', '.html'];
1620
+ for (let i = 1; i < routeParts.length; i++) {
1621
+ const parentRoute = '/' + routeParts.slice(0, i).join('/');
1622
+
1623
+ for (const ext of possibleExtensions) {
1624
+ const parentRouteFileName = routeToFilePath(parentRoute, {
1625
+ extension: ext,
1626
+ mode: 'flat'
1627
+ });
1628
+ const parentRouteFilePath = secureJoin(pagesRoot, parentRouteFileName);
1629
+
1630
+ if (existsSync(parentRouteFilePath)) {
1631
+ const indexFile = ext === '.astro' ? config.routing.indexFile : `index${ext}`;
1632
+ const newParentRouteFileName = routeToFilePath(parentRoute, {
1633
+ extension: ext,
1634
+ mode: 'nested',
1635
+ indexFile: indexFile
1636
+ });
1637
+ const newParentRouteFilePath = secureJoin(pagesRoot, newParentRouteFileName);
1638
+
1639
+ if (!existsSync(newParentRouteFilePath)) {
1640
+ reorganizations.push({
1641
+ from: parentRouteFilePath,
1642
+ to: newParentRouteFilePath,
1643
+ route: parentRoute
1644
+ });
1645
+ }
1646
+ }
1647
+ }
1648
+ }
1649
+ }
1650
+
1651
+ if (options.dryRun) {
1652
+ console.log('Dry run - would create:');
1653
+ console.log(` Route: ${routeFilePath}`);
1654
+ console.log(` Feature: ${featureFilePath}`);
1655
+
1656
+ for (const reorg of reorganizations) {
1657
+ console.log(` Reorganize: ${reorg.from} -> ${reorg.to}`);
1658
+ }
1659
+
1660
+ if (shouldCreateIndex) console.log(` Index: ${indexFilePath}`);
1661
+ if (shouldCreateSubComponentsDir) console.log(` Sub-components: ${subComponentsDir}/`);
1662
+ if (shouldCreateScriptsDir) console.log(` Scripts: ${scriptsIndexPath}`);
1663
+ if (shouldCreateApi) console.log(` Api: ${apiFilePath}`);
1664
+ if (shouldCreateServices) console.log(` Services: ${servicesFilePath}`);
1665
+ if (shouldCreateSchemas) console.log(` Schemas: ${schemasFilePath}`);
1666
+ if (shouldCreateHooks) console.log(` Hooks: ${hookFilePath}`);
1667
+ if (shouldCreateContext) console.log(` Context: ${contextFilePath}`);
1668
+ if (shouldCreateTests) console.log(` Tests: ${testFilePath}`);
1669
+ if (shouldCreateTypes) console.log(` Types: ${typesFilePath}`);
1670
+ if (shouldCreateReadme) console.log(` Readme: ${readmeFilePath}`);
1671
+ if (shouldCreateStories) console.log(` Stories: ${storiesFilePath}`);
1672
+
1673
+ return;
1674
+ }
1675
+
1676
+ if (reorganizations.length > 0) {
1677
+ const state = await loadState();
1678
+ for (const reorg of reorganizations) {
1679
+ await ensureDir(path.dirname(reorg.to));
1680
+ await rename(reorg.from, reorg.to);
1681
+
1682
+ const oldRelative = path.relative(process.cwd(), reorg.from).replace(/\\/g, '/');
1683
+ const newRelative = path.relative(process.cwd(), reorg.to).replace(/\\/g, '/');
1684
+
1685
+ if (state.files[oldRelative]) {
1686
+ state.files[newRelative] = { ...state.files[oldRelative] };
1687
+ delete state.files[oldRelative];
1688
+ }
1689
+
1690
+ // Update imports in the moved file
1691
+ await updateImportsInFile(reorg.to, reorg.from, reorg.to);
1692
+
1693
+ console.log(`✓ Reorganized ${oldRelative} to ${newRelative}`);
1694
+ }
1695
+ await saveState(state);
1696
+ }
1697
+
1698
+ await ensureNotExists(routeFilePath, options.force);
1699
+ await ensureNotExists(featureFilePath, options.force);
1700
+
1701
+ if (shouldCreateIndex) await ensureNotExists(indexFilePath, options.force);
1702
+ if (shouldCreateContext) await ensureNotExists(contextFilePath, options.force);
1703
+ if (shouldCreateHooks) await ensureNotExists(hookFilePath, options.force);
1704
+ if (shouldCreateTests) await ensureNotExists(testFilePath, options.force);
1705
+ if (shouldCreateTypes) await ensureNotExists(typesFilePath, options.force);
1706
+ if (shouldCreateApi) await ensureNotExists(apiFilePath, options.force);
1707
+ if (shouldCreateServices) await ensureNotExists(servicesFilePath, options.force);
1708
+ if (shouldCreateSchemas) await ensureNotExists(schemasFilePath, options.force);
1709
+ if (shouldCreateReadme) await ensureNotExists(readmeFilePath, options.force);
1710
+ if (shouldCreateStories) await ensureNotExists(storiesFilePath, options.force);
1711
+ if (shouldCreateScriptsDir) await ensureNotExists(scriptsIndexPath, options.force);
1712
+
1713
+ let layoutImportPath = null;
1714
+ if (options.layout !== 'none') {
1715
+ if (config.importAliases.layouts) {
1716
+ layoutImportPath = `${config.importAliases.layouts}/${options.layout}.astro`;
1717
+ } else {
1718
+ const layoutFilePath = secureJoin(layoutsRoot, `${options.layout}.astro`);
1719
+ layoutImportPath = getRelativeImportPath(routeFilePath, layoutFilePath);
1720
+ }
1721
+ }
1722
+
1723
+ let featureImportPath;
1724
+ if (config.importAliases.features) {
1725
+ const entryPart = effectiveOptions.entry === 'index' ? '' : `/${featureComponentName}`;
1726
+ // In Astro, we can often omit the extension for .tsx files, but not for .astro files if using aliases sometimes.
1727
+ // However, to be safe, we use the configured extension.
1728
+ featureImportPath = `${config.importAliases.features}/${normalizedFeaturePath}${entryPart}${config.naming.featureExtension}`;
1729
+ } else {
1730
+ const relativeFeatureFile = getRelativeImportPath(routeFilePath, featureFilePath);
1731
+ // Remove extension for import
1732
+ featureImportPath = relativeFeatureFile.replace(/\.[^/.]+$/, '');
1733
+ }
1734
+
1735
+ let scriptImportPath;
1736
+ if (shouldCreateScriptsDir) {
1737
+ scriptImportPath = getRelativeImportPath(featureFilePath, scriptsIndexPath);
1738
+ }
1739
+
1740
+ let routeContent;
1741
+ let routeSignature;
1742
+
1743
+ if (options.endpoint) {
1744
+ routeContent = generateEndpointTemplate(featureComponentName);
1745
+ routeSignature = getSignature(config, 'typescript');
1746
+ } else {
1747
+ routeContent = generateRouteTemplate(
1748
+ options.layout,
1749
+ layoutImportPath,
1750
+ featureImportPath,
1751
+ featureComponentName
1752
+ );
1753
+ routeSignature = getSignature(config, 'astro');
1754
+ }
1755
+
1756
+ const featureContent = generateFeatureTemplate(featureComponentName, scriptImportPath, framework);
1757
+
1758
+ const routeHash = await writeFileWithSignature(
1759
+ routeFilePath,
1760
+ routeContent,
1761
+ routeSignature,
1762
+ config.hashing?.normalization
1763
+ );
1764
+ await registerFile(routeFilePath, {
1765
+ kind: 'route',
1766
+ template: options.endpoint ? 'endpoint' : 'route',
1767
+ hash: routeHash,
1768
+ owner: normalizedRoute
1769
+ });
1770
+
1771
+ const writtenFiles = [routeFilePath];
1772
+
1773
+ await ensureDir(featureDirPath);
1774
+
1775
+ if (shouldCreateSubComponentsDir) await ensureDir(subComponentsDir);
1776
+ if (shouldCreateApi) await ensureDir(apiDirInside);
1777
+ if (shouldCreateServices) await ensureDir(servicesDirInside);
1778
+ if (shouldCreateSchemas) await ensureDir(schemasDirInside);
1779
+ if (shouldCreateHooks) await ensureDir(hooksDirInside);
1780
+ if (shouldCreateContext) await ensureDir(contextDirInside);
1781
+ if (shouldCreateTests) await ensureDir(testsDir);
1782
+ if (shouldCreateTypes) await ensureDir(typesDirInside);
1783
+
1784
+ const featureSignature = getSignature(config, config.naming.featureExtension === '.astro' ? 'astro' : 'tsx');
1785
+
1786
+ const featureHash = await writeFileWithSignature(
1787
+ featureFilePath,
1788
+ featureContent,
1789
+ featureSignature,
1790
+ config.hashing?.normalization
1791
+ );
1792
+ await registerFile(featureFilePath, {
1793
+ kind: 'feature',
1794
+ template: 'feature',
1795
+ hash: featureHash,
1796
+ owner: normalizedRoute
1797
+ });
1798
+ writtenFiles.push(featureFilePath);
1799
+
1800
+ if (shouldCreateScriptsDir) {
1801
+ const hash = await writeFileWithSignature(
1802
+ scriptsIndexPath,
1803
+ generateScriptsIndexTemplate(),
1804
+ getSignature(config, 'typescript'),
1805
+ config.hashing?.normalization
1806
+ );
1807
+ await registerFile(scriptsIndexPath, {
1808
+ kind: 'feature-file',
1809
+ template: 'scripts-index',
1810
+ hash,
1811
+ owner: normalizedRoute
1812
+ });
1813
+ writtenFiles.push(scriptsIndexPath);
1814
+ }
1815
+
1816
+ if (shouldCreateIndex) {
1817
+ const indexContent = generateIndexTemplate(featureComponentName, config.naming.featureExtension);
1818
+ const hash = await writeFileWithSignature(
1819
+ indexFilePath,
1820
+ indexContent,
1821
+ getSignature(config, 'typescript'),
1822
+ config.hashing?.normalization
1823
+ );
1824
+ await registerFile(indexFilePath, {
1825
+ kind: 'feature-file',
1826
+ template: 'index',
1827
+ hash,
1828
+ owner: normalizedRoute
1829
+ });
1830
+ writtenFiles.push(indexFilePath);
1831
+ }
1832
+
1833
+ if (shouldCreateApi) {
1834
+ const apiContent = generateApiTemplate(featureComponentName);
1835
+ const hash = await writeFileWithSignature(
1836
+ apiFilePath,
1837
+ apiContent,
1838
+ getSignature(config, 'typescript'),
1839
+ config.hashing?.normalization
1840
+ );
1841
+ await registerFile(apiFilePath, {
1842
+ kind: 'feature-file',
1843
+ template: 'api',
1844
+ hash,
1845
+ owner: normalizedRoute
1846
+ });
1847
+ writtenFiles.push(apiFilePath);
1848
+ }
1849
+
1850
+ if (shouldCreateServices) {
1851
+ const servicesContent = generateServiceTemplate(featureComponentName);
1852
+ const hash = await writeFileWithSignature(
1853
+ servicesFilePath,
1854
+ servicesContent,
1855
+ getSignature(config, 'typescript'),
1856
+ config.hashing?.normalization
1857
+ );
1858
+ await registerFile(servicesFilePath, {
1859
+ kind: 'feature-file',
1860
+ template: 'service',
1861
+ hash,
1862
+ owner: normalizedRoute
1863
+ });
1864
+ writtenFiles.push(servicesFilePath);
1865
+ }
1866
+
1867
+ if (shouldCreateSchemas) {
1868
+ const schemasContent = generateSchemaTemplate(featureComponentName);
1869
+ const hash = await writeFileWithSignature(
1870
+ schemasFilePath,
1871
+ schemasContent,
1872
+ getSignature(config, 'typescript'),
1873
+ config.hashing?.normalization
1874
+ );
1875
+ await registerFile(schemasFilePath, {
1876
+ kind: 'feature-file',
1877
+ template: 'schema',
1878
+ hash,
1879
+ owner: normalizedRoute
1880
+ });
1881
+ writtenFiles.push(schemasFilePath);
1882
+ }
1883
+
1884
+ if (shouldCreateHooks) {
1885
+ const hookName = getHookFunctionName(featureComponentName);
1886
+ const hookContent = generateHookTemplate(featureComponentName, hookName);
1887
+ const hash = await writeFileWithSignature(
1888
+ hookFilePath,
1889
+ hookContent,
1890
+ getSignature(config, 'typescript'),
1891
+ config.hashing?.normalization
1892
+ );
1893
+ await registerFile(hookFilePath, {
1894
+ kind: 'feature-file',
1895
+ template: 'hook',
1896
+ hash,
1897
+ owner: normalizedRoute
1898
+ });
1899
+ writtenFiles.push(hookFilePath);
1900
+ }
1901
+
1902
+ if (shouldCreateContext) {
1903
+ const contextContent = generateContextTemplate(featureComponentName);
1904
+ const hash = await writeFileWithSignature(
1905
+ contextFilePath,
1906
+ contextContent,
1907
+ getSignature(config, 'typescript'),
1908
+ config.hashing?.normalization
1909
+ );
1910
+ await registerFile(contextFilePath, {
1911
+ kind: 'feature-file',
1912
+ template: 'context',
1913
+ hash,
1914
+ owner: normalizedRoute
1915
+ });
1916
+ writtenFiles.push(contextFilePath);
1917
+ }
1918
+
1919
+ if (shouldCreateTests) {
1920
+ const relativeFeaturePath = `./${path.basename(featureFilePath)}`;
1921
+ const testContent = generateTestTemplate(featureComponentName, relativeFeaturePath);
1922
+ const hash = await writeFileWithSignature(
1923
+ testFilePath,
1924
+ testContent,
1925
+ getSignature(config, 'typescript'),
1926
+ config.hashing?.normalization
1927
+ );
1928
+ await registerFile(testFilePath, {
1929
+ kind: 'feature-file',
1930
+ template: 'test',
1931
+ hash,
1932
+ owner: normalizedRoute
1933
+ });
1934
+ writtenFiles.push(testFilePath);
1935
+ }
1936
+
1937
+ if (shouldCreateTypes) {
1938
+ const typesContent = generateTypesTemplate(featureComponentName);
1939
+ const hash = await writeFileWithSignature(
1940
+ typesFilePath,
1941
+ typesContent,
1942
+ getSignature(config, 'typescript'),
1943
+ config.hashing?.normalization
1944
+ );
1945
+ await registerFile(typesFilePath, {
1946
+ kind: 'feature-file',
1947
+ template: 'types',
1948
+ hash,
1949
+ owner: normalizedRoute
1950
+ });
1951
+ writtenFiles.push(typesFilePath);
1952
+ }
1953
+
1954
+ if (shouldCreateReadme) {
1955
+ const readmeContent = generateReadmeTemplate(featureComponentName);
1956
+ const hash = await writeFileWithSignature(
1957
+ readmeFilePath,
1958
+ readmeContent,
1959
+ getSignature(config, 'astro'),
1960
+ config.hashing?.normalization
1961
+ );
1962
+ await registerFile(readmeFilePath, {
1963
+ kind: 'feature-file',
1964
+ template: 'readme',
1965
+ hash,
1966
+ owner: normalizedRoute
1967
+ });
1968
+ writtenFiles.push(readmeFilePath);
1969
+ }
1970
+
1971
+ if (shouldCreateStories) {
1972
+ const relativePath = `./${path.basename(featureFilePath)}`;
1973
+ const storiesContent = generateStoriesTemplate(featureComponentName, relativePath);
1974
+ const hash = await writeFileWithSignature(
1975
+ storiesFilePath,
1976
+ storiesContent,
1977
+ getSignature(config, 'typescript'),
1978
+ config.hashing?.normalization
1979
+ );
1980
+ await registerFile(storiesFilePath, {
1981
+ kind: 'feature-file',
1982
+ template: 'stories',
1983
+ hash,
1984
+ owner: normalizedRoute
1985
+ });
1986
+ writtenFiles.push(storiesFilePath);
1987
+ }
1988
+
1989
+ // Formatting
1990
+ if (config.formatting.tool !== 'none') {
1991
+ await formatFiles(writtenFiles, config.formatting.tool);
1992
+ }
1993
+
1994
+ console.log('✓ Section created successfully:');
1995
+ console.log(` Route: ${routeFilePath}`);
1996
+ console.log(` Feature: ${featureFilePath}`);
1997
+
1998
+ if (shouldCreateIndex) console.log(` Index: ${indexFilePath}`);
1999
+ if (shouldCreateSubComponentsDir) console.log(` Sub-components: ${subComponentsDir}/`);
2000
+ if (shouldCreateScriptsDir) console.log(` Scripts: ${scriptsIndexPath}`);
2001
+ if (shouldCreateApi) console.log(` Api: ${apiFilePath}`);
2002
+ if (shouldCreateServices) console.log(` Services: ${servicesFilePath}`);
2003
+ if (shouldCreateSchemas) console.log(` Schemas: ${schemasFilePath}`);
2004
+ if (shouldCreateHooks) console.log(` Hooks: ${hookFilePath}`);
2005
+ if (shouldCreateContext) console.log(` Context: ${contextFilePath}`);
2006
+ if (shouldCreateTests) console.log(` Tests: ${testFilePath}`);
2007
+ if (shouldCreateTypes) console.log(` Types: ${typesFilePath}`);
2008
+ if (shouldCreateReadme) console.log(` Readme: ${readmeFilePath}`);
2009
+ if (shouldCreateStories) console.log(` Stories: ${storiesFilePath}`);
2010
+
2011
+ await addSectionToState({
2012
+ name: options.name || featureComponentName,
2013
+ route: normalizedRoute,
2014
+ featurePath: normalizedFeaturePath,
2015
+ layout: options.layout,
2016
+ extension: routeExtension
2017
+ });
2018
+
2019
+ if (config.git?.stageChanges) {
2020
+ await stageFiles(writtenFiles);
2021
+ }
2022
+
2023
+ } catch (error) {
2024
+ console.error('Error:', error.message);
2025
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
2026
+ process.exit(1);
2027
+ }
2028
+ throw error;
2029
+ }
2030
+ }
2031
+
2032
+ async function updateImportsInFile(filePath, oldFilePath, newFilePath) {
2033
+ if (!existsSync(filePath)) return;
2034
+
2035
+ let content = await readFile(filePath, 'utf-8');
2036
+ const oldDir = path.dirname(oldFilePath);
2037
+ const newDir = path.dirname(newFilePath);
2038
+
2039
+ if (oldDir === newDir) return;
2040
+
2041
+ // Find all relative imports
2042
+ const relativeImportRegex = /from\s+['"](\.\.?\/[^'"]+)['"]/g;
2043
+ let match;
2044
+ const replacements = [];
2045
+
2046
+ while ((match = relativeImportRegex.exec(content)) !== null) {
2047
+ const relativePath = match[1];
2048
+ const absoluteTarget = path.resolve(oldDir, relativePath);
2049
+ const newRelativePath = getRelativeImportPath(newFilePath, absoluteTarget);
2050
+
2051
+ replacements.push({
2052
+ full: match[0],
2053
+ oldRel: relativePath,
2054
+ newRel: newRelativePath
2055
+ });
2056
+ }
2057
+
2058
+ for (const repl of replacements) {
2059
+ content = content.replace(repl.full, `from '${repl.newRel}'`);
2060
+ }
2061
+
2062
+ await writeFile(filePath, content, 'utf-8');
2063
+ }
2064
+
2065
+ async function removeSectionCommand(route, featurePath, options) {
2066
+ try {
2067
+ const config = await loadConfig();
2068
+
2069
+ if (config.git?.requireCleanRepo && !await isRepoClean()) {
2070
+ throw new Error('Git repository is not clean. Please commit or stash your changes before proceeding.');
2071
+ }
2072
+
2073
+ const state = await loadState();
2074
+
2075
+ let targetRoute = route;
2076
+ let targetFeaturePath = featurePath;
2077
+ let section = findSection(state, route);
2078
+
2079
+ if (!targetFeaturePath) {
2080
+ if (section) {
2081
+ targetRoute = section.route;
2082
+ targetFeaturePath = section.featurePath;
2083
+ } else {
2084
+ throw new Error(`Section not found for identifier: ${route}. Please provide both route and featurePath.`);
2085
+ }
2086
+ }
2087
+
2088
+ const normalizedRoute = normalizeRoute(targetRoute);
2089
+ const normalizedFeaturePath = featureToDirectoryPath(targetFeaturePath);
2090
+
2091
+ const routeExtension = (section && section.extension) || config.naming.routeExtension;
2092
+ const routeFileName = routeToFilePath(normalizedRoute, {
2093
+ extension: routeExtension,
2094
+ mode: config.routing.mode,
2095
+ indexFile: config.routing.indexFile
2096
+ });
2097
+
2098
+ const pagesRoot = resolvePath(config, 'pages');
2099
+ const featuresRoot = resolvePath(config, 'features');
2100
+
2101
+ const routeFilePath = secureJoin(pagesRoot, routeFileName);
2102
+ const featureDirPath = secureJoin(featuresRoot, normalizedFeaturePath);
2103
+
2104
+ const deletedFiles = [];
2105
+ const skippedFiles = [];
2106
+ const deletedDirs = [];
2107
+
2108
+ if (options.dryRun) {
2109
+ console.log('Dry run - would delete:');
2110
+
2111
+ if (!options.keepRoute) {
2112
+ console.log(` Route: ${routeFilePath}`);
2113
+ }
2114
+
2115
+ if (!options.keepFeature) {
2116
+ console.log(` Feature: ${featureDirPath}/`);
2117
+ }
2118
+
2119
+ return;
2120
+ }
2121
+
2122
+ if (!options.keepRoute) {
2123
+ const normalizedPath = path.relative(process.cwd(), routeFilePath).replace(/\\/g, '/');
2124
+ const fileState = state.files[normalizedPath];
2125
+ const result = await safeDelete(routeFilePath, {
2126
+ force: options.force,
2127
+ expectedHash: fileState?.hash,
2128
+ acceptChanges: options.acceptChanges,
2129
+ normalization: config.hashing?.normalization,
2130
+ owner: normalizedRoute,
2131
+ actualOwner: fileState?.owner
2132
+ });
2133
+
2134
+ if (result.deleted) {
2135
+ deletedFiles.push(routeFilePath);
2136
+ delete state.files[normalizedPath];
2137
+ } else if (result.message) {
2138
+ skippedFiles.push({ path: routeFilePath, reason: result.message });
2139
+ }
2140
+ }
2141
+
2142
+ if (!options.keepFeature) {
2143
+ const result = await safeDeleteDir(featureDirPath, {
2144
+ force: options.force,
2145
+ stateFiles: state.files,
2146
+ acceptChanges: options.acceptChanges,
2147
+ normalization: config.hashing?.normalization,
2148
+ owner: normalizedRoute
2149
+ });
2150
+
2151
+ if (result.deleted) {
2152
+ deletedDirs.push(featureDirPath);
2153
+ // Unregister all files that were in this directory
2154
+ const dirPrefix = path.relative(process.cwd(), featureDirPath).replace(/\\/g, '/') + '/';
2155
+ for (const f in state.files) {
2156
+ if (f.startsWith(dirPrefix)) {
2157
+ delete state.files[f];
2158
+ }
2159
+ }
2160
+ } else if (result.message) {
2161
+ skippedFiles.push({ path: featureDirPath, reason: result.message });
2162
+ }
2163
+ }
2164
+
2165
+ if (!options.keepRoute && deletedFiles.includes(routeFilePath)) {
2166
+ await cleanupEmptyDirs(path.dirname(routeFilePath), pagesRoot);
2167
+ }
2168
+
2169
+ if (!options.keepFeature && deletedDirs.includes(featureDirPath)) {
2170
+ await cleanupEmptyDirs(path.dirname(featureDirPath), featuresRoot);
2171
+ }
2172
+
2173
+ if (deletedFiles.length > 0 || deletedDirs.length > 0) {
2174
+ console.log('✓ Deleted:');
2175
+ deletedFiles.forEach(file => console.log(` ${file}`));
2176
+ deletedDirs.forEach(dir => console.log(` ${dir}/`));
2177
+ }
2178
+
2179
+ if (skippedFiles.length > 0) {
2180
+ console.log('\n⚠ Skipped:');
2181
+ skippedFiles.forEach(item => {
2182
+ console.log(` ${item.path}`);
2183
+ console.log(` Reason: ${item.reason}`);
2184
+ });
2185
+ }
2186
+
2187
+ if (deletedFiles.length === 0 && deletedDirs.length === 0 && skippedFiles.length === 0) {
2188
+ console.log('No files to delete.');
2189
+ } else {
2190
+ state.sections = state.sections.filter(s => s.route !== normalizedRoute);
2191
+ await saveState(state);
2192
+ }
2193
+
2194
+ } catch (error) {
2195
+ console.error('Error:', error.message);
2196
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
2197
+ process.exit(1);
2198
+ }
2199
+ throw error;
2200
+ }
2201
+ }
2202
+
2203
+ /**
2204
+ * Move a section (route + feature).
2205
+ *
2206
+ * SCOPE GUARANTEES:
2207
+ * - Automatically updates imports in the moved route file.
2208
+ * - Automatically updates internal imports/references within the moved feature directory.
2209
+ * - Repo-wide scan for import updates is available via the --scan flag.
2210
+ *
2211
+ * NON-GOALS (What it won't rewrite):
2212
+ * - String literals (unless they match the component name exactly in a JSX context).
2213
+ * - Markdown documentation (except for those registered in state).
2214
+ * - Dynamic imports with complex template literals.
2215
+ */
2216
+ async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, options) {
2217
+ try {
2218
+ const config = await loadConfig();
2219
+
2220
+ if (config.git?.requireCleanRepo && !await isRepoClean()) {
2221
+ throw new Error('Git repository is not clean. Please commit or stash your changes before proceeding.');
2222
+ }
2223
+
2224
+ const state = await loadState();
2225
+
2226
+ let actualFromRoute = fromRoute;
2227
+ let actualFromFeature = fromFeature;
2228
+ let actualToRoute = toRoute;
2229
+ let actualToFeature = toFeature;
2230
+
2231
+ // Shift arguments if using state
2232
+ if (!toRoute && fromRoute && fromFeature) {
2233
+ // textor move-section /old-route /new-route
2234
+ const section = findSection(state, fromRoute);
2235
+ if (section) {
2236
+ actualFromRoute = section.route;
2237
+ actualFromFeature = section.featurePath;
2238
+ actualToRoute = fromFeature; // the second argument was actually the new route
2239
+ actualToFeature = toRoute; // which is null
2240
+
2241
+ // If toFeature is not provided, try to derive it from the new route
2242
+ if (!actualToFeature && actualToRoute) {
2243
+ const oldRouteParts = actualFromRoute.split('/').filter(Boolean);
2244
+ const newRouteParts = actualToRoute.split('/').filter(Boolean);
2245
+ const oldFeatureParts = actualFromFeature.split('/').filter(Boolean);
2246
+
2247
+ // If the feature path starts with the old route parts, replace them
2248
+ // We compare case-insensitively or via PascalCase to be more helpful
2249
+ let match = true;
2250
+ for (let i = 0; i < oldRouteParts.length; i++) {
2251
+ const routePart = oldRouteParts[i].toLowerCase();
2252
+ const featurePart = oldFeatureParts[i] ? oldFeatureParts[i].toLowerCase() : null;
2253
+
2254
+ if (featurePart !== routePart) {
2255
+ match = false;
2256
+ break;
2257
+ }
2258
+ }
2259
+
2260
+ if (match && oldRouteParts.length > 0) {
2261
+ actualToFeature = [...newRouteParts, ...oldFeatureParts.slice(oldRouteParts.length)].join('/');
2262
+ } else {
2263
+ // Otherwise just keep it the same
2264
+ actualToFeature = actualFromFeature;
2265
+ }
2266
+ }
2267
+ }
2268
+ }
2269
+
2270
+ const isRouteOnly = options.keepFeature || (!actualToFeature && actualToRoute && !actualFromFeature);
2271
+
2272
+ if (isRouteOnly && !actualToRoute) {
2273
+ throw new Error('Destination route required for route-only move');
2274
+ }
2275
+
2276
+ const normalizedFromRoute = normalizeRoute(actualFromRoute);
2277
+ const normalizedToRoute = normalizeRoute(actualToRoute);
2278
+ const normalizedFromFeature = actualFromFeature ? featureToDirectoryPath(actualFromFeature) : null;
2279
+ const normalizedToFeature = actualToFeature ? featureToDirectoryPath(actualToFeature) : null;
2280
+
2281
+ const pagesRoot = resolvePath(config, 'pages');
2282
+ const featuresRoot = resolvePath(config, 'features');
2283
+
2284
+ const fromSection = findSection(state, actualFromRoute);
2285
+ const routeExtension = (fromSection && fromSection.extension) || config.naming.routeExtension;
2286
+
2287
+ const fromRouteFile = routeToFilePath(normalizedFromRoute, {
2288
+ extension: routeExtension,
2289
+ mode: config.routing.mode,
2290
+ indexFile: config.routing.indexFile
2291
+ });
2292
+ const toRouteFile = routeToFilePath(normalizedToRoute, {
2293
+ extension: routeExtension,
2294
+ mode: config.routing.mode,
2295
+ indexFile: config.routing.indexFile
2296
+ });
2297
+
2298
+ const fromRoutePath = secureJoin(pagesRoot, fromRouteFile);
2299
+ const toRoutePath = secureJoin(pagesRoot, toRouteFile);
2300
+
2301
+ const movedFiles = [];
2302
+
2303
+ if (options.dryRun) {
2304
+ console.log('Dry run - would move:');
2305
+ console.log(` Route: ${fromRoutePath} -> ${toRoutePath}`);
2306
+
2307
+ if (!isRouteOnly && normalizedFromFeature && normalizedToFeature) {
2308
+ const fromFeaturePath = secureJoin(featuresRoot, normalizedFromFeature);
2309
+ const toFeaturePath = secureJoin(featuresRoot, normalizedToFeature);
2310
+ console.log(` Feature: ${fromFeaturePath} -> ${toFeaturePath}`);
2311
+ }
2312
+
2313
+ return;
2314
+ }
2315
+
2316
+ const normalizedFromRouteRelative = path.relative(process.cwd(), fromRoutePath).replace(/\\/g, '/');
2317
+ const routeFileState = state.files[normalizedFromRouteRelative];
2318
+
2319
+ const newRouteHash = await safeMove(fromRoutePath, toRoutePath, {
2320
+ force: options.force,
2321
+ expectedHash: routeFileState?.hash,
2322
+ acceptChanges: options.acceptChanges,
2323
+ owner: normalizedFromRoute,
2324
+ actualOwner: routeFileState?.owner
2325
+ });
2326
+ movedFiles.push({ from: fromRoutePath, to: toRoutePath });
2327
+
2328
+ // Update state for moved route file
2329
+ const normalizedToRouteRelative = path.relative(process.cwd(), toRoutePath).replace(/\\/g, '/');
2330
+ if (routeFileState) {
2331
+ state.files[normalizedToRouteRelative] = { ...routeFileState, hash: newRouteHash };
2332
+ delete state.files[normalizedFromRouteRelative];
2333
+ }
2334
+
2335
+ // Update imports in the moved route file
2336
+ const targetFeature = normalizedToFeature || normalizedFromFeature;
2337
+ if (targetFeature) {
2338
+ const fromFeatureDirPath = secureJoin(featuresRoot, normalizedFromFeature);
2339
+ const toFeatureDirPath = secureJoin(featuresRoot, targetFeature);
2340
+ const fromFeatureComponentName = getFeatureComponentName(normalizedFromFeature);
2341
+ const toFeatureComponentName = getFeatureComponentName(targetFeature);
2342
+
2343
+ // Update component name in JSX
2344
+ if (fromFeatureComponentName !== toFeatureComponentName) {
2345
+ let content = await readFile(toRoutePath, 'utf-8');
2346
+ content = content.replace(
2347
+ new RegExp(`<${fromFeatureComponentName}`, 'g'),
2348
+ `<${toFeatureComponentName}`
2349
+ );
2350
+ content = content.replace(
2351
+ new RegExp(`</${fromFeatureComponentName}`, 'g'),
2352
+ `</${toFeatureComponentName}`
2353
+ );
2354
+ await writeFile(toRoutePath, content, 'utf-8');
2355
+ }
2356
+
2357
+ if (config.importAliases.features) {
2358
+ if (normalizedFromFeature !== targetFeature) {
2359
+ const oldAliasPath = `${config.importAliases.features}/${normalizedFromFeature}`;
2360
+ const newAliasPath = `${config.importAliases.features}/${targetFeature}`;
2361
+
2362
+ // Replace both the path and the component name if they are different
2363
+ await updateSignature(toRoutePath,
2364
+ `import ${fromFeatureComponentName} from '${oldAliasPath}/${fromFeatureComponentName}'`,
2365
+ `import ${toFeatureComponentName} from '${newAliasPath}/${toFeatureComponentName}'`
2366
+ );
2367
+
2368
+ // Fallback for prefix only replacement
2369
+ await updateSignature(toRoutePath, oldAliasPath, newAliasPath);
2370
+ } else if (fromFeatureComponentName !== toFeatureComponentName) {
2371
+ // Name changed but path didn't
2372
+ const aliasPath = `${config.importAliases.features}/${targetFeature}`;
2373
+ await updateSignature(toRoutePath,
2374
+ `import ${fromFeatureComponentName} from '${aliasPath}/${fromFeatureComponentName}'`,
2375
+ `import ${toFeatureComponentName} from '${aliasPath}/${toFeatureComponentName}'`
2376
+ );
2377
+ }
2378
+ } else {
2379
+ const oldRelativeDir = getRelativeImportPath(fromRoutePath, fromFeatureDirPath);
2380
+ const newRelativeDir = getRelativeImportPath(toRoutePath, toFeatureDirPath);
2381
+
2382
+ const oldImportPath = `import ${fromFeatureComponentName} from '${oldRelativeDir}/${fromFeatureComponentName}'`;
2383
+ const newImportPath = `import ${toFeatureComponentName} from '${newRelativeDir}/${toFeatureComponentName}'`;
2384
+
2385
+ if (oldImportPath !== newImportPath) {
2386
+ await updateSignature(toRoutePath, oldImportPath, newImportPath);
2387
+ }
2388
+ }
2389
+ }
2390
+
2391
+ if (!isRouteOnly && normalizedFromFeature && normalizedToFeature && normalizedFromFeature !== normalizedToFeature) {
2392
+ const fromFeaturePath = secureJoin(featuresRoot, normalizedFromFeature);
2393
+ const toFeaturePath = secureJoin(featuresRoot, normalizedToFeature);
2394
+
2395
+ const fromFeatureComponentName = getFeatureComponentName(normalizedFromFeature);
2396
+ const toFeatureComponentName = getFeatureComponentName(normalizedToFeature);
2397
+
2398
+ if (existsSync(fromFeaturePath)) {
2399
+ await moveDirectory(fromFeaturePath, toFeaturePath, state, config, {
2400
+ ...options,
2401
+ fromName: fromFeatureComponentName,
2402
+ toName: toFeatureComponentName,
2403
+ owner: normalizedFromRoute
2404
+ });
2405
+ movedFiles.push({ from: fromFeaturePath, to: toFeaturePath });
2406
+
2407
+ await cleanupEmptyDirs(path.dirname(fromFeaturePath), featuresRoot);
2408
+ }
2409
+ }
2410
+
2411
+ if (options.scan && (normalizedFromFeature || normalizedToFeature)) {
2412
+ await scanAndReplaceImports(config, state, {
2413
+ fromFeaturePath: normalizedFromFeature,
2414
+ fromComponentName: getFeatureComponentName(normalizedFromFeature)
2415
+ }, {
2416
+ toFeaturePath: normalizedToFeature || normalizedFromFeature,
2417
+ toComponentName: getFeatureComponentName(normalizedToFeature || normalizedFromFeature)
2418
+ }, options);
2419
+ }
2420
+
2421
+ await cleanupEmptyDirs(path.dirname(fromRoutePath), pagesRoot);
2422
+
2423
+ console.log('✓ Moved:');
2424
+ movedFiles.forEach(item => {
2425
+ console.log(` ${item.from}`);
2426
+ console.log(` -> ${item.to}`);
2427
+ });
2428
+
2429
+ if (movedFiles.length > 0) {
2430
+ const existingSection = fromSection;
2431
+
2432
+ // Update section data in state
2433
+ state.sections = state.sections.filter(s => s.route !== normalizedFromRoute);
2434
+ state.sections.push({
2435
+ name: existingSection ? existingSection.name : getFeatureComponentName(normalizedToFeature || normalizedFromFeature),
2436
+ route: normalizedToRoute,
2437
+ featurePath: normalizedToFeature || normalizedFromFeature,
2438
+ layout: existingSection ? existingSection.layout : 'Main',
2439
+ extension: routeExtension
2440
+ });
2441
+
2442
+ await saveState(state);
2443
+ }
2444
+
2445
+ } catch (error) {
2446
+ console.error('Error:', error.message);
2447
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
2448
+ process.exit(1);
2449
+ }
2450
+ throw error;
2451
+ }
2452
+ }
2453
+
2454
+ async function scanAndReplaceImports(config, state, fromInfo, toInfo, options) {
2455
+ const { fromFeaturePath, fromComponentName } = fromInfo;
2456
+ const { toFeaturePath, toComponentName } = toInfo;
2457
+
2458
+ const allFiles = new Set();
2459
+ const { scanDirectory, calculateHash } = await Promise.resolve().then(function () { return filesystem; });
2460
+ await scanDirectory(process.cwd(), allFiles);
2461
+
2462
+ const featuresRoot = resolvePath(config, 'features');
2463
+
2464
+ for (const relPath of allFiles) {
2465
+ const fullPath = path.join(process.cwd(), relPath);
2466
+
2467
+ // Skip the moved directory itself as it was already handled
2468
+ if (fullPath.startsWith(path.resolve(toFeaturePath))) continue;
2469
+
2470
+ let content = await readFile(fullPath, 'utf-8');
2471
+ let changed = false;
2472
+
2473
+ // Handle Aliases
2474
+ if (config.importAliases.features) {
2475
+ const oldAlias = `${config.importAliases.features}/${fromFeaturePath}`;
2476
+ const newAlias = `${config.importAliases.features}/${toFeaturePath}`;
2477
+
2478
+ // Update component name and path if both changed
2479
+ const oldFullImport = `from '${oldAlias}/${fromComponentName}'`;
2480
+ const newFullImport = `from '${newAlias}/${toComponentName}'`;
2481
+
2482
+ if (content.includes(oldFullImport)) {
2483
+ content = content.replace(new RegExp(oldFullImport.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newFullImport);
2484
+ changed = true;
2485
+ } else if (content.includes(oldAlias)) {
2486
+ content = content.replace(new RegExp(oldAlias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newAlias);
2487
+ changed = true;
2488
+ }
2489
+ } else {
2490
+ // Handle Relative Imports (more complex)
2491
+ // This is best-effort: we look for imports that resolve to the old feature path
2492
+ const fromFeatureDir = secureJoin(featuresRoot, fromFeaturePath);
2493
+ const toFeatureDir = secureJoin(featuresRoot, toFeaturePath);
2494
+
2495
+ const oldRelPath = getRelativeImportPath(fullPath, fromFeatureDir);
2496
+ const newRelPath = getRelativeImportPath(fullPath, toFeatureDir);
2497
+
2498
+ const oldImport = `'${oldRelPath}/${fromComponentName}'`;
2499
+ const newImport = `'${newRelPath}/${toComponentName}'`;
2500
+
2501
+ if (content.includes(oldImport)) {
2502
+ content = content.replace(new RegExp(oldImport.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newImport);
2503
+ changed = true;
2504
+ }
2505
+ }
2506
+
2507
+ // Update component name in JSX and imports if it changed
2508
+ if (fromComponentName !== toComponentName && changed) {
2509
+ content = content.replace(new RegExp(`\\b${fromComponentName}\\b`, 'g'), toComponentName);
2510
+ }
2511
+
2512
+ if (changed) {
2513
+ if (options.dryRun) {
2514
+ console.log(` [Scan] Would update imports in ${relPath}`);
2515
+ } else {
2516
+ await writeFile(fullPath, content, 'utf-8');
2517
+ console.log(` [Scan] Updated imports in ${relPath}`);
2518
+
2519
+ // Update state hash if this file is managed
2520
+ if (state.files[relPath]) {
2521
+ state.files[relPath].hash = calculateHash(content, config.hashing?.normalization);
2522
+ }
2523
+ }
2524
+ }
2525
+ }
2526
+ }
2527
+
2528
+ async function moveDirectory(fromPath, toPath, state, config, options = {}) {
2529
+ const { fromName, toName, owner = null } = options;
2530
+
2531
+ if (!existsSync(fromPath)) {
2532
+ throw new Error(`Source directory not found: ${fromPath}`);
2533
+ }
2534
+
2535
+ if (existsSync(toPath) && !options.force) {
2536
+ throw new Error(
2537
+ `Destination already exists: ${toPath}\n` +
2538
+ `Use --force to overwrite.`
2539
+ );
2540
+ }
2541
+
2542
+ await ensureDir(toPath);
2543
+
2544
+ const entries = await readdir(fromPath);
2545
+
2546
+ for (const entry of entries) {
2547
+ let targetEntry = entry;
2548
+
2549
+ // Rename files if they match the component name
2550
+ if (fromName && toName && fromName !== toName) {
2551
+ if (entry.includes(fromName)) {
2552
+ targetEntry = entry.replace(fromName, toName);
2553
+ }
2554
+ }
2555
+
2556
+ const fromEntryPath = path.join(fromPath, entry);
2557
+ const toEntryPath = path.join(toPath, targetEntry);
2558
+
2559
+ const stats = await stat(fromEntryPath);
2560
+
2561
+ if (stats.isDirectory()) {
2562
+ await moveDirectory(fromEntryPath, toEntryPath, state, config, options);
2563
+ } else {
2564
+ const normalizedFromRelative = path.relative(process.cwd(), fromEntryPath).replace(/\\/g, '/');
2565
+ const fileState = state.files[normalizedFromRelative];
2566
+
2567
+ const newHash = await safeMove(fromEntryPath, toEntryPath, {
2568
+ force: options.force,
2569
+ expectedHash: fileState?.hash,
2570
+ acceptChanges: options.acceptChanges,
2571
+ normalization: config.hashing?.normalization,
2572
+ owner,
2573
+ actualOwner: fileState?.owner
2574
+ });
2575
+
2576
+ // Update internal content (signatures, component names) if renaming
2577
+ if (fromName && toName && fromName !== toName) {
2578
+ let content = await readFile(toEntryPath, 'utf-8');
2579
+ let hasChanged = false;
2580
+
2581
+ // Simple replacement of component names
2582
+ if (content.includes(fromName)) {
2583
+ content = content.replace(new RegExp(fromName, 'g'), toName);
2584
+ hasChanged = true;
2585
+ }
2586
+
2587
+ // Also handle lowercase class names if any
2588
+ const fromLower = fromName.toLowerCase();
2589
+ const toLower = toName.toLowerCase();
2590
+ if (content.includes(fromLower)) {
2591
+ content = content.replace(new RegExp(fromLower, 'g'), toLower);
2592
+ hasChanged = true;
2593
+ }
2594
+
2595
+ if (hasChanged) {
2596
+ await writeFile(toEntryPath, content, 'utf-8');
2597
+ // Re-calculate hash after content update
2598
+ const { calculateHash } = await Promise.resolve().then(function () { return filesystem; });
2599
+ const updatedHash = calculateHash(content, config.hashing?.normalization);
2600
+
2601
+ const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
2602
+ if (fileState) {
2603
+ state.files[normalizedToRelative] = { ...fileState, hash: updatedHash };
2604
+ delete state.files[normalizedFromRelative];
2605
+ }
2606
+ } else {
2607
+ // Update state for each file moved normally
2608
+ const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
2609
+ if (fileState) {
2610
+ state.files[normalizedToRelative] = { ...fileState, hash: newHash };
2611
+ delete state.files[normalizedFromRelative];
2612
+ }
2613
+ }
2614
+ } else {
2615
+ // Update state for each file moved normally
2616
+ const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
2617
+ if (fileState) {
2618
+ state.files[normalizedToRelative] = { ...fileState, hash: newHash };
2619
+ delete state.files[normalizedFromRelative];
2620
+ }
2621
+ }
2622
+ }
2623
+ }
2624
+
2625
+ const remainingFiles = await readdir(fromPath);
2626
+ if (remainingFiles.length === 0) {
2627
+ await rmdir(fromPath);
2628
+ }
2629
+ }
2630
+
2631
+ async function createComponentCommand(componentName, options) {
2632
+ try {
2633
+ const config = await loadConfig();
2634
+ const effectiveOptions = getEffectiveOptions(options, config, 'components');
2635
+
2636
+ const normalizedName = normalizeComponentName(componentName);
2637
+
2638
+ const componentsRoot = resolvePath(config, 'components');
2639
+
2640
+ const componentDir = secureJoin(componentsRoot, normalizedName);
2641
+ const subComponentsDir = secureJoin(componentDir, 'sub-components');
2642
+ const testsDir = secureJoin(componentDir, '__tests__');
2643
+ const configDirInside = secureJoin(componentDir, 'config');
2644
+ const constantsDirInside = secureJoin(componentDir, 'constants');
2645
+ const contextDirInside = secureJoin(componentDir, 'context');
2646
+ const hooksDirInside = secureJoin(componentDir, 'hooks');
2647
+ const typesDirInside = secureJoin(componentDir, 'types');
2648
+ const apiDirInside = secureJoin(componentDir, 'api');
2649
+ const servicesDirInside = secureJoin(componentDir, 'services');
2650
+ const schemasDirInside = secureJoin(componentDir, 'schemas');
2651
+
2652
+ const {
2653
+ framework,
2654
+ createContext: shouldCreateContext,
2655
+ createHook: shouldCreateHook,
2656
+ createTests: shouldCreateTests,
2657
+ createConfig: shouldCreateConfig,
2658
+ createConstants: shouldCreateConstants,
2659
+ createTypes: shouldCreateTypes,
2660
+ createSubComponentsDir: shouldCreateSubComponentsDir,
2661
+ createApi: shouldCreateApi,
2662
+ createServices: shouldCreateServices,
2663
+ createSchemas: shouldCreateSchemas,
2664
+ createReadme: shouldCreateReadme,
2665
+ createStories: shouldCreateStories
2666
+ } = effectiveOptions;
2667
+
2668
+ const componentFilePath = path.join(componentDir, `${normalizedName}${config.naming.componentExtension}`);
2669
+ const indexFilePath = path.join(componentDir, 'index.ts');
2670
+ const contextFilePath = path.join(contextDirInside, `${normalizedName}Context.tsx`);
2671
+ const hookFilePath = path.join(hooksDirInside, getHookFileName(normalizedName, config.naming.hookExtension));
2672
+ const testFilePath = path.join(testsDir, `${normalizedName}${config.naming.testExtension}`);
2673
+ const configFilePath = path.join(configDirInside, 'index.ts');
2674
+ const constantsFilePath = path.join(constantsDirInside, 'index.ts');
2675
+ const typesFilePath = path.join(typesDirInside, 'index.ts');
2676
+ const apiFilePath = path.join(apiDirInside, 'index.ts');
2677
+ const servicesFilePath = path.join(servicesDirInside, 'index.ts');
2678
+ const schemasFilePath = path.join(schemasDirInside, 'index.ts');
2679
+ const readmeFilePath = path.join(componentDir, 'README.md');
2680
+ const storiesFilePath = path.join(componentDir, `${normalizedName}.stories.tsx`);
2681
+
2682
+ if (options.dryRun) {
2683
+ console.log('Dry run - would create:');
2684
+ console.log(` Component: ${componentFilePath}`);
2685
+ console.log(` Index: ${indexFilePath}`);
2686
+
2687
+ if (shouldCreateContext) console.log(` Context: ${contextFilePath}`);
2688
+ if (shouldCreateHook) console.log(` Hook: ${hookFilePath}`);
2689
+ if (shouldCreateTests) console.log(` Tests: ${testFilePath}`);
2690
+ if (shouldCreateConfig) console.log(` Config: ${configFilePath}`);
2691
+ if (shouldCreateConstants) console.log(` Constants: ${constantsFilePath}`);
2692
+ if (shouldCreateTypes) console.log(` Types: ${typesFilePath}`);
2693
+ if (shouldCreateApi) console.log(` Api: ${apiFilePath}`);
2694
+ if (shouldCreateServices) console.log(` Services: ${servicesFilePath}`);
2695
+ if (shouldCreateSchemas) console.log(` Schemas: ${schemasFilePath}`);
2696
+ if (shouldCreateReadme) console.log(` Readme: ${readmeFilePath}`);
2697
+ if (shouldCreateStories) console.log(` Stories: ${storiesFilePath}`);
2698
+ if (shouldCreateSubComponentsDir) console.log(` Sub-components: ${subComponentsDir}/`);
2699
+
2700
+ return;
2701
+ }
2702
+
2703
+ await ensureNotExists(componentFilePath, options.force);
2704
+ await ensureNotExists(indexFilePath, options.force);
2705
+
2706
+ if (shouldCreateContext) await ensureNotExists(contextFilePath, options.force);
2707
+ if (shouldCreateHook) await ensureNotExists(hookFilePath, options.force);
2708
+ if (shouldCreateTests) await ensureNotExists(testFilePath, options.force);
2709
+ if (shouldCreateConfig) await ensureNotExists(configFilePath, options.force);
2710
+ if (shouldCreateConstants) await ensureNotExists(constantsFilePath, options.force);
2711
+ if (shouldCreateTypes) await ensureNotExists(typesFilePath, options.force);
2712
+ if (shouldCreateApi) await ensureNotExists(apiFilePath, options.force);
2713
+ if (shouldCreateServices) await ensureNotExists(servicesFilePath, options.force);
2714
+ if (shouldCreateSchemas) await ensureNotExists(schemasFilePath, options.force);
2715
+ if (shouldCreateReadme) await ensureNotExists(readmeFilePath, options.force);
2716
+ if (shouldCreateStories) await ensureNotExists(storiesFilePath, options.force);
2717
+
2718
+ await ensureDir(componentDir);
2719
+
2720
+ if (shouldCreateSubComponentsDir) await ensureDir(subComponentsDir);
2721
+ if (shouldCreateContext) await ensureDir(contextDirInside);
2722
+ if (shouldCreateHook) await ensureDir(hooksDirInside);
2723
+ if (shouldCreateTests) await ensureDir(testsDir);
2724
+ if (shouldCreateConfig) await ensureDir(configDirInside);
2725
+ if (shouldCreateConstants) await ensureDir(constantsDirInside);
2726
+ if (shouldCreateTypes) await ensureDir(typesDirInside);
2727
+ if (shouldCreateApi) await ensureDir(apiDirInside);
2728
+ if (shouldCreateServices) await ensureDir(servicesDirInside);
2729
+ if (shouldCreateSchemas) await ensureDir(schemasDirInside);
2730
+
2731
+ const componentContent = generateComponentTemplate(normalizedName, framework);
2732
+ const signature = getSignature(config, config.naming.componentExtension === '.astro' ? 'astro' : 'tsx');
2733
+
2734
+ const componentHash = await writeFileWithSignature(
2735
+ componentFilePath,
2736
+ componentContent,
2737
+ signature,
2738
+ config.hashing?.normalization
2739
+ );
2740
+ await registerFile(componentFilePath, {
2741
+ kind: 'component',
2742
+ template: 'component',
2743
+ hash: componentHash,
2744
+ owner: normalizedName
2745
+ });
2746
+
2747
+ const writtenFiles = [componentFilePath];
2748
+
2749
+ const indexContent = generateIndexTemplate(normalizedName, config.naming.componentExtension);
2750
+ const indexHash = await writeFileWithSignature(
2751
+ indexFilePath,
2752
+ indexContent,
2753
+ getSignature(config, 'typescript'),
2754
+ config.hashing?.normalization
2755
+ );
2756
+ await registerFile(indexFilePath, {
2757
+ kind: 'component-file',
2758
+ template: 'index',
2759
+ hash: indexHash,
2760
+ owner: normalizedName
2761
+ });
2762
+ writtenFiles.push(indexFilePath);
2763
+
2764
+ if (shouldCreateTypes) {
2765
+ const typesContent = generateTypesTemplate(normalizedName);
2766
+ const hash = await writeFileWithSignature(
2767
+ typesFilePath,
2768
+ typesContent,
2769
+ getSignature(config, 'typescript'),
2770
+ config.hashing?.normalization
2771
+ );
2772
+ await registerFile(typesFilePath, {
2773
+ kind: 'component-file',
2774
+ template: 'types',
2775
+ hash,
2776
+ owner: normalizedName
2777
+ });
2778
+ writtenFiles.push(typesFilePath);
2779
+ }
2780
+
2781
+ if (shouldCreateContext) {
2782
+ const contextContent = generateContextTemplate(normalizedName);
2783
+ const hash = await writeFileWithSignature(
2784
+ contextFilePath,
2785
+ contextContent,
2786
+ getSignature(config, 'typescript'),
2787
+ config.hashing?.normalization
2788
+ );
2789
+ await registerFile(contextFilePath, {
2790
+ kind: 'component-file',
2791
+ template: 'context',
2792
+ hash,
2793
+ owner: normalizedName
2794
+ });
2795
+ writtenFiles.push(contextFilePath);
2796
+ }
2797
+
2798
+ if (shouldCreateHook) {
2799
+ const hookName = getHookFunctionName(normalizedName);
2800
+ const hookContent = generateHookTemplate(normalizedName, hookName);
2801
+ const hash = await writeFileWithSignature(
2802
+ hookFilePath,
2803
+ hookContent,
2804
+ getSignature(config, 'typescript'),
2805
+ config.hashing?.normalization
2806
+ );
2807
+ await registerFile(hookFilePath, {
2808
+ kind: 'component-file',
2809
+ template: 'hook',
2810
+ hash,
2811
+ owner: normalizedName
2812
+ });
2813
+ writtenFiles.push(hookFilePath);
2814
+ }
2815
+
2816
+ if (shouldCreateTests) {
2817
+ const relativeComponentPath = `../${normalizedName}${config.naming.componentExtension}`;
2818
+ const testContent = generateTestTemplate(normalizedName, relativeComponentPath);
2819
+ const hash = await writeFileWithSignature(
2820
+ testFilePath,
2821
+ testContent,
2822
+ getSignature(config, 'typescript'),
2823
+ config.hashing?.normalization
2824
+ );
2825
+ await registerFile(testFilePath, {
2826
+ kind: 'component-file',
2827
+ template: 'test',
2828
+ hash,
2829
+ owner: normalizedName
2830
+ });
2831
+ writtenFiles.push(testFilePath);
2832
+ }
2833
+
2834
+ if (shouldCreateConfig) {
2835
+ const configContent = generateConfigTemplate(normalizedName);
2836
+ const hash = await writeFileWithSignature(
2837
+ configFilePath,
2838
+ configContent,
2839
+ getSignature(config, 'typescript'),
2840
+ config.hashing?.normalization
2841
+ );
2842
+ await registerFile(configFilePath, {
2843
+ kind: 'component-file',
2844
+ template: 'config',
2845
+ hash,
2846
+ owner: normalizedName
2847
+ });
2848
+ writtenFiles.push(configFilePath);
2849
+ }
2850
+
2851
+ if (shouldCreateConstants) {
2852
+ const constantsContent = generateConstantsTemplate(normalizedName);
2853
+ const hash = await writeFileWithSignature(
2854
+ constantsFilePath,
2855
+ constantsContent,
2856
+ getSignature(config, 'typescript'),
2857
+ config.hashing?.normalization
2858
+ );
2859
+ await registerFile(constantsFilePath, {
2860
+ kind: 'component-file',
2861
+ template: 'constants',
2862
+ hash,
2863
+ owner: normalizedName
2864
+ });
2865
+ writtenFiles.push(constantsFilePath);
2866
+ }
2867
+
2868
+ if (shouldCreateApi) {
2869
+ const apiContent = generateApiTemplate(normalizedName);
2870
+ const hash = await writeFileWithSignature(
2871
+ apiFilePath,
2872
+ apiContent,
2873
+ getSignature(config, 'typescript'),
2874
+ config.hashing?.normalization
2875
+ );
2876
+ await registerFile(apiFilePath, {
2877
+ kind: 'component-file',
2878
+ template: 'api',
2879
+ hash,
2880
+ owner: normalizedName
2881
+ });
2882
+ writtenFiles.push(apiFilePath);
2883
+ }
2884
+
2885
+ if (shouldCreateServices) {
2886
+ const servicesContent = generateServiceTemplate(normalizedName);
2887
+ const hash = await writeFileWithSignature(
2888
+ servicesFilePath,
2889
+ servicesContent,
2890
+ getSignature(config, 'typescript'),
2891
+ config.hashing?.normalization
2892
+ );
2893
+ await registerFile(servicesFilePath, {
2894
+ kind: 'component-file',
2895
+ template: 'service',
2896
+ hash,
2897
+ owner: normalizedName
2898
+ });
2899
+ writtenFiles.push(servicesFilePath);
2900
+ }
2901
+
2902
+ if (shouldCreateSchemas) {
2903
+ const schemasContent = generateSchemaTemplate(normalizedName);
2904
+ const hash = await writeFileWithSignature(
2905
+ schemasFilePath,
2906
+ schemasContent,
2907
+ getSignature(config, 'typescript'),
2908
+ config.hashing?.normalization
2909
+ );
2910
+ await registerFile(schemasFilePath, {
2911
+ kind: 'component-file',
2912
+ template: 'schema',
2913
+ hash,
2914
+ owner: normalizedName
2915
+ });
2916
+ writtenFiles.push(schemasFilePath);
2917
+ }
2918
+
2919
+ if (shouldCreateReadme) {
2920
+ const readmeContent = generateReadmeTemplate(normalizedName);
2921
+ const hash = await writeFileWithSignature(
2922
+ readmeFilePath,
2923
+ readmeContent,
2924
+ getSignature(config, 'astro'),
2925
+ config.hashing?.normalization
2926
+ );
2927
+ await registerFile(readmeFilePath, {
2928
+ kind: 'component-file',
2929
+ template: 'readme',
2930
+ hash,
2931
+ owner: normalizedName
2932
+ });
2933
+ writtenFiles.push(readmeFilePath);
2934
+ }
2935
+
2936
+ if (shouldCreateStories) {
2937
+ const relativePath = `./${normalizedName}${config.naming.componentExtension}`;
2938
+ const storiesContent = generateStoriesTemplate(normalizedName, relativePath);
2939
+ const hash = await writeFileWithSignature(
2940
+ storiesFilePath,
2941
+ storiesContent,
2942
+ getSignature(config, 'typescript'),
2943
+ config.hashing?.normalization
2944
+ );
2945
+ await registerFile(storiesFilePath, {
2946
+ kind: 'component-file',
2947
+ template: 'stories',
2948
+ hash,
2949
+ owner: normalizedName
2950
+ });
2951
+ writtenFiles.push(storiesFilePath);
2952
+ }
2953
+
2954
+ // Formatting
2955
+ if (config.formatting.tool !== 'none') {
2956
+ await formatFiles(writtenFiles, config.formatting.tool);
2957
+ }
2958
+
2959
+ console.log('✓ Component created successfully:');
2960
+ console.log(` Component: ${componentFilePath}`);
2961
+ console.log(` Index: ${indexFilePath}`);
2962
+
2963
+ if (shouldCreateContext) console.log(` Context: ${contextFilePath}`);
2964
+ if (shouldCreateHook) console.log(` Hook: ${hookFilePath}`);
2965
+ if (shouldCreateTests) console.log(` Tests: ${testFilePath}`);
2966
+ if (shouldCreateConfig) console.log(` Config: ${configFilePath}`);
2967
+ if (shouldCreateConstants) console.log(` Constants: ${constantsFilePath}`);
2968
+ if (shouldCreateTypes) console.log(` Types: ${typesFilePath}`);
2969
+ if (shouldCreateApi) console.log(` Api: ${apiFilePath}`);
2970
+ if (shouldCreateServices) console.log(` Services: ${servicesFilePath}`);
2971
+ if (shouldCreateSchemas) console.log(` Schemas: ${schemasFilePath}`);
2972
+ if (shouldCreateReadme) console.log(` Readme: ${readmeFilePath}`);
2973
+ if (shouldCreateStories) console.log(` Stories: ${storiesFilePath}`);
2974
+ if (shouldCreateSubComponentsDir) console.log(` Sub-components: ${subComponentsDir}/`);
2975
+
2976
+ await addComponentToState({
2977
+ name: normalizedName,
2978
+ path: componentDir
2979
+ });
2980
+
2981
+ if (config.git?.stageChanges) {
2982
+ await stageFiles(writtenFiles);
2983
+ }
2984
+
2985
+ } catch (error) {
2986
+ console.error('Error:', error.message);
2987
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
2988
+ process.exit(1);
2989
+ }
2990
+ throw error;
2991
+ }
2992
+ }
2993
+
2994
+ async function removeComponentCommand(identifier, options) {
2995
+ try {
2996
+ const config = await loadConfig();
2997
+
2998
+ if (config.git?.requireCleanRepo && !await isRepoClean()) {
2999
+ throw new Error('Git repository is not clean. Please commit or stash your changes before proceeding.');
3000
+ }
3001
+
3002
+ const state = await loadState();
3003
+
3004
+ const component = findComponent(state, identifier);
3005
+ let componentDir;
3006
+
3007
+ if (component) {
3008
+ componentDir = component.path;
3009
+ } else {
3010
+ // Fallback: try to guess path if not in state
3011
+ const componentsRoot = path.resolve(process.cwd(), config.paths.components);
3012
+ componentDir = secureJoin(componentsRoot, identifier);
3013
+ }
3014
+
3015
+ if (options.dryRun) {
3016
+ console.log('Dry run - would delete:');
3017
+ console.log(` Component directory: ${componentDir}/`);
3018
+ return;
3019
+ }
3020
+
3021
+ const result = await safeDeleteDir(componentDir, {
3022
+ force: options.force,
3023
+ stateFiles: state.files,
3024
+ acceptChanges: options.acceptChanges,
3025
+ normalization: config.hashing?.normalization,
3026
+ owner: identifier
3027
+ });
3028
+
3029
+ if (result.deleted) {
3030
+ console.log(`✓ Deleted component: ${componentDir}/`);
3031
+ await cleanupEmptyDirs(path.dirname(componentDir), path.join(process.cwd(), config.paths.components));
3032
+
3033
+ // Unregister files
3034
+ const dirPrefix = path.relative(process.cwd(), componentDir).replace(/\\/g, '/') + '/';
3035
+ for (const f in state.files) {
3036
+ if (f.startsWith(dirPrefix)) {
3037
+ delete state.files[f];
3038
+ }
3039
+ }
3040
+ state.components = state.components.filter(c => c.name !== identifier && c.path !== componentDir);
3041
+ await saveState(state);
3042
+ } else if (result.message) {
3043
+ console.log(`⚠ Skipped: ${componentDir}`);
3044
+ console.log(` Reason: ${result.message}`);
3045
+ } else {
3046
+ console.log(`Component not found at ${componentDir}`);
3047
+ }
3048
+
3049
+ } catch (error) {
3050
+ console.error('Error:', error.message);
3051
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
3052
+ process.exit(1);
3053
+ }
3054
+ throw error;
3055
+ }
3056
+ }
3057
+
3058
+ async function listSectionsCommand() {
3059
+ try {
3060
+ const config = await loadConfig();
3061
+ const state = await loadState();
3062
+ const pagesRoot = resolvePath(config, 'pages');
3063
+
3064
+ console.log('Managed Sections:');
3065
+
3066
+ if (!existsSync(pagesRoot)) {
3067
+ console.log(' No pages directory found.');
3068
+ } else {
3069
+ const extensions = [config.naming.routeExtension, '.ts', '.js'];
3070
+ const sections = await findGeneratedFiles(pagesRoot, extensions);
3071
+
3072
+ if (sections.length === 0) {
3073
+ console.log(' No Textor-managed sections found.');
3074
+ } else {
3075
+ for (const section of sections) {
3076
+ const relativePath = path.relative(pagesRoot, section).replace(/\\/g, '/');
3077
+ const route = '/' + relativePath
3078
+ .replace(/\.[^/.]+$/, ''); // Remove extension
3079
+
3080
+ const stateSection = state.sections.find(s => s.route === route);
3081
+ const name = stateSection ? stateSection.name : route;
3082
+
3083
+ console.log(` - ${name} [${route}] (${relativePath})`);
3084
+
3085
+ if (stateSection) {
3086
+ console.log(` Feature: ${stateSection.featurePath}`);
3087
+ console.log(` Layout: ${stateSection.layout}`);
3088
+
3089
+ // Check for senior architecture folders/files
3090
+ const featuresRoot = resolvePath(config, 'features');
3091
+ const featureDir = path.join(featuresRoot, stateSection.featurePath);
3092
+ const capabilities = [];
3093
+
3094
+ const checkDir = (subDir, label) => {
3095
+ if (existsSync(path.join(featureDir, subDir))) capabilities.push(label);
3096
+ };
3097
+
3098
+ checkDir('api', 'API');
3099
+ checkDir('services', 'Services');
3100
+ checkDir('schemas', 'Schemas');
3101
+ checkDir('hooks', 'Hooks');
3102
+ checkDir('context', 'Context');
3103
+ checkDir('types', 'Types');
3104
+ checkDir('scripts', 'Scripts');
3105
+ checkDir('sub-components', 'Sub-components');
3106
+ checkDir('__tests__', 'Tests');
3107
+
3108
+ if (existsSync(path.join(featureDir, 'README.md'))) capabilities.push('Docs');
3109
+
3110
+ const storiesFile = (await readdir(featureDir).catch(() => []))
3111
+ .find(f => f.endsWith('.stories.tsx') || f.endsWith('.stories.jsx'));
3112
+ if (storiesFile) capabilities.push('Stories');
3113
+
3114
+ if (capabilities.length > 0) {
3115
+ console.log(` Architecture: ${capabilities.join(', ')}`);
3116
+ }
3117
+ } else {
3118
+ // Try to extract feature path from the file content
3119
+ const content = await readFile(section, 'utf-8');
3120
+ const featureImportMatch = content.match(/import\s+\w+\s+from\s+'([^']+)'/g);
3121
+ if (featureImportMatch) {
3122
+ for (const match of featureImportMatch) {
3123
+ const pathMatch = match.match(/'([^']+)'/);
3124
+ if (pathMatch) {
3125
+ console.log(` Import: ${pathMatch[1]}`);
3126
+ }
3127
+ }
3128
+ }
3129
+ }
3130
+ }
3131
+ }
3132
+ }
3133
+
3134
+ if (state.components && state.components.length > 0) {
3135
+ console.log('\nManaged Components:');
3136
+ for (const component of state.components) {
3137
+ console.log(` - ${component.name} (${path.relative(process.cwd(), component.path)})`);
3138
+
3139
+ const componentDir = component.path;
3140
+ const capabilities = [];
3141
+
3142
+ const checkDir = (subDir, label) => {
3143
+ if (existsSync(path.join(componentDir, subDir))) capabilities.push(label);
3144
+ };
3145
+
3146
+ checkDir('api', 'API');
3147
+ checkDir('services', 'Services');
3148
+ checkDir('schemas', 'Schemas');
3149
+ checkDir('hooks', 'Hooks');
3150
+ checkDir('context', 'Context');
3151
+ checkDir('types', 'Types');
3152
+ checkDir('sub-components', 'Sub-components');
3153
+ checkDir('__tests__', 'Tests');
3154
+ checkDir('config', 'Config');
3155
+ checkDir('constants', 'Constants');
3156
+
3157
+ if (existsSync(path.join(componentDir, 'README.md'))) capabilities.push('Docs');
3158
+
3159
+ const storiesFile = (await readdir(componentDir).catch(() => []))
3160
+ .find(f => f.endsWith('.stories.tsx') || f.endsWith('.stories.jsx'));
3161
+ if (storiesFile) capabilities.push('Stories');
3162
+
3163
+ if (capabilities.length > 0) {
3164
+ console.log(` Architecture: ${capabilities.join(', ')}`);
3165
+ }
3166
+ }
3167
+ }
3168
+ } catch (error) {
3169
+ console.error('Error:', error.message);
3170
+ process.exit(1);
3171
+ }
3172
+ }
3173
+
3174
+ async function findGeneratedFiles(dir, extensions) {
3175
+ const results = [];
3176
+ const entries = await readdir(dir);
3177
+ const exts = Array.isArray(extensions) ? extensions : [extensions];
3178
+
3179
+ for (const entry of entries) {
3180
+ const fullPath = path.join(dir, entry);
3181
+ const stats = await stat(fullPath);
3182
+
3183
+ if (stats.isDirectory()) {
3184
+ results.push(...await findGeneratedFiles(fullPath, exts));
3185
+ } else if (exts.some(ext => entry.endsWith(ext))) {
3186
+ if (await isTextorGenerated(fullPath)) {
3187
+ results.push(fullPath);
3188
+ }
3189
+ }
3190
+ }
3191
+
3192
+ return results;
3193
+ }
3194
+
3195
+ async function validateStateCommand(options) {
3196
+ try {
3197
+ const config = await loadConfig();
3198
+ const state = await loadState();
3199
+ const results = {
3200
+ missing: [],
3201
+ modified: [],
3202
+ valid: 0
3203
+ };
3204
+
3205
+ const files = Object.keys(state.files);
3206
+
3207
+ for (const relativePath of files) {
3208
+ const fullPath = path.join(process.cwd(), relativePath);
3209
+ const fileData = state.files[relativePath];
3210
+
3211
+ if (!existsSync(fullPath)) {
3212
+ results.missing.push(relativePath);
3213
+ continue;
3214
+ }
3215
+
3216
+ const content = await readFile(fullPath, 'utf-8');
3217
+ const currentHash = calculateHash(content, config.hashing?.normalization);
3218
+
3219
+ if (currentHash !== fileData.hash) {
3220
+ results.modified.push({
3221
+ path: relativePath,
3222
+ newHash: currentHash
3223
+ });
3224
+ } else {
3225
+ results.valid++;
3226
+ }
3227
+ }
3228
+
3229
+ console.log('State Validation Results:');
3230
+ console.log(` Valid files: ${results.valid}`);
3231
+
3232
+ if (results.missing.length > 0) {
3233
+ console.log(`\n Missing files: ${results.missing.length}`);
3234
+ results.missing.forEach(f => console.log(` - ${f}`));
3235
+ }
3236
+
3237
+ if (results.modified.length > 0) {
3238
+ console.log(`\n Modified files: ${results.modified.length}`);
3239
+ results.modified.forEach(f => console.log(` - ${f.path}`));
3240
+ }
3241
+
3242
+ if (options.fix) {
3243
+ let fixedCount = 0;
3244
+
3245
+ // Fix modified files if they still have the Textor signature
3246
+ for (const mod of results.modified) {
3247
+ const fullPath = path.join(process.cwd(), mod.path);
3248
+ if (await isTextorGenerated(fullPath)) {
3249
+ state.files[mod.path].hash = mod.newHash;
3250
+ fixedCount++;
3251
+ }
3252
+ }
3253
+
3254
+ // Remove missing files from state
3255
+ for (const miss of results.missing) {
3256
+ delete state.files[miss];
3257
+ fixedCount++;
3258
+ }
3259
+
3260
+ if (fixedCount > 0) {
3261
+ await saveState(state);
3262
+ console.log(`\n✓ Fixed ${fixedCount} entries in state.`);
3263
+ } else {
3264
+ console.log('\nNothing to fix or missing signatures on modified files.');
3265
+ }
3266
+ } else if (results.missing.length > 0 || results.modified.length > 0) {
3267
+ console.log('\nRun with --fix to synchronize state with reality (requires Textor signature to be present).');
3268
+ } else {
3269
+ console.log('\n✓ State is perfectly in sync.');
3270
+ }
3271
+
3272
+ } catch (error) {
3273
+ console.error('Error:', error.message);
3274
+ process.exit(1);
3275
+ }
3276
+ }
3277
+
3278
+ async function statusCommand() {
3279
+ try {
3280
+ const config = await loadConfig();
3281
+ const state = await loadState();
3282
+
3283
+ const results = {
3284
+ missing: [],
3285
+ modified: [],
3286
+ untracked: [], // Has signature, not in state
3287
+ orphaned: [], // No signature, not in state
3288
+ synced: 0
3289
+ };
3290
+
3291
+ const roots = [
3292
+ resolvePath(config, 'pages'),
3293
+ resolvePath(config, 'features'),
3294
+ resolvePath(config, 'components')
3295
+ ].map(p => path.resolve(p));
3296
+
3297
+ const diskFiles = new Set();
3298
+ const configSignatures = Object.values(config.signatures || {});
3299
+
3300
+ for (const root of roots) {
3301
+ if (existsSync(root)) {
3302
+ await scanDirectory(root, diskFiles);
3303
+ }
3304
+ }
3305
+
3306
+ // 1. Check state files against disk
3307
+ for (const relativePath in state.files) {
3308
+ const fullPath = path.join(process.cwd(), relativePath);
3309
+
3310
+ if (!existsSync(fullPath)) {
3311
+ results.missing.push(relativePath);
3312
+ continue;
3313
+ }
3314
+
3315
+ diskFiles.delete(relativePath);
3316
+
3317
+ const content = await readFile(fullPath, 'utf-8');
3318
+ const currentHash = calculateHash(content, config.hashing?.normalization);
3319
+ const fileData = state.files[relativePath];
3320
+
3321
+ if (currentHash !== fileData.hash) {
3322
+ results.modified.push(relativePath);
3323
+ } else {
3324
+ results.synced++;
3325
+ }
3326
+ }
3327
+
3328
+ // 2. Check remaining disk files
3329
+ for (const relativePath of diskFiles) {
3330
+ const fullPath = path.join(process.cwd(), relativePath);
3331
+ const isGenerated = await isTextorGenerated(fullPath, configSignatures);
3332
+
3333
+ if (isGenerated) {
3334
+ results.untracked.push(relativePath);
3335
+ } else {
3336
+ results.orphaned.push(relativePath);
3337
+ }
3338
+ }
3339
+
3340
+ // Reporting
3341
+ console.log('Textor Status Report:');
3342
+ console.log(` Synced files: ${results.synced}`);
3343
+
3344
+ if (results.modified.length > 0) {
3345
+ console.log(`\n MODIFIED (In state, but content changed): ${results.modified.length}`);
3346
+ results.modified.forEach(f => console.log(` ~ ${f}`));
3347
+ }
3348
+
3349
+ if (results.missing.length > 0) {
3350
+ console.log(`\n MISSING (In state, but not on disk): ${results.missing.length}`);
3351
+ results.missing.forEach(f => console.log(` - ${f}`));
3352
+ }
3353
+
3354
+ if (results.untracked.length > 0) {
3355
+ console.log(`\n UNTRACKED (On disk with signature, not in state): ${results.untracked.length}`);
3356
+ results.untracked.forEach(f => console.log(` + ${f}`));
3357
+ }
3358
+
3359
+ if (results.orphaned.length > 0) {
3360
+ console.log(`\n ORPHANED (On disk without signature, in managed folder): ${results.orphaned.length}`);
3361
+ results.orphaned.forEach(f => console.log(` ? ${f}`));
3362
+ }
3363
+
3364
+ if (results.modified.length === 0 && results.missing.length === 0 && results.untracked.length === 0) {
3365
+ console.log('\n✓ Project is perfectly synchronized with state.');
3366
+ } else {
3367
+ console.log('\nUse "textor sync" to reconcile state with disk.');
3368
+ }
3369
+
3370
+ } catch (error) {
3371
+ console.error('Error:', error.message);
3372
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
3373
+ process.exit(1);
3374
+ }
3375
+ throw error;
3376
+ }
3377
+ }
3378
+
3379
+ async function syncCommand(options) {
3380
+ try {
3381
+ const config = await loadConfig();
3382
+ const state = await loadState();
3383
+ const results = {
3384
+ added: [],
3385
+ updated: [],
3386
+ missing: [],
3387
+ untouched: 0
3388
+ };
3389
+
3390
+ const roots = [
3391
+ resolvePath(config, 'pages'),
3392
+ resolvePath(config, 'features'),
3393
+ resolvePath(config, 'components')
3394
+ ].map(p => path.resolve(p));
3395
+
3396
+ const managedFiles = new Set();
3397
+ const configSignatures = Object.values(config.signatures || {});
3398
+
3399
+ for (const root of roots) {
3400
+ if (existsSync(root)) {
3401
+ await scanDirectory(root, managedFiles);
3402
+ } else {
3403
+ const relativeRoot = path.relative(process.cwd(), root).replace(/\\/g, '/');
3404
+ console.log(` Warning: Managed directory not found: ${relativeRoot}`);
3405
+ }
3406
+ }
3407
+
3408
+ const stateFiles = Object.keys(state.files);
3409
+ let changed = false;
3410
+
3411
+ // 1. Check files in state
3412
+ for (const relativePath of stateFiles) {
3413
+ const fullPath = path.join(process.cwd(), relativePath);
3414
+
3415
+ if (!existsSync(fullPath)) {
3416
+ results.missing.push(relativePath);
3417
+ continue;
3418
+ }
3419
+
3420
+ managedFiles.delete(relativePath); // Remove from managedFiles so we don't process it again in step 2
3421
+
3422
+ const content = await readFile(fullPath, 'utf-8');
3423
+ const currentHash = calculateHash(content, config.hashing?.normalization);
3424
+ const fileData = state.files[relativePath];
3425
+
3426
+ if (currentHash !== fileData.hash) {
3427
+ const isGenerated = await isTextorGenerated(fullPath, configSignatures);
3428
+ if (isGenerated || options.force) {
3429
+ results.updated.push({ path: relativePath, newHash: currentHash });
3430
+ }
3431
+ } else {
3432
+ results.untouched++;
3433
+ }
3434
+ }
3435
+
3436
+ // 2. Check files on disk not in state
3437
+ let ignoredCount = 0;
3438
+ for (const relativePath of managedFiles) {
3439
+ const fullPath = path.join(process.cwd(), relativePath);
3440
+ const isGenerated = await isTextorGenerated(fullPath, configSignatures);
3441
+
3442
+ if (isGenerated || options.includeAll) {
3443
+ const content = await readFile(fullPath, 'utf-8');
3444
+ const hash = calculateHash(content, config.hashing?.normalization);
3445
+ results.added.push({ path: relativePath, hash });
3446
+ } else {
3447
+ ignoredCount++;
3448
+ }
3449
+ }
3450
+
3451
+ if (results.added.length > 0 || results.updated.length > 0 || results.missing.length > 0) {
3452
+ changed = true;
3453
+ }
3454
+
3455
+ // Reporting
3456
+ console.log('Sync Analysis:');
3457
+ console.log(` Untouched files: ${results.untouched}`);
3458
+
3459
+ if (ignoredCount > 0 && !options.includeAll) {
3460
+ console.log(` Ignored non-generated files: ${ignoredCount} (use --include-all to track them)`);
3461
+ }
3462
+
3463
+ if (results.added.length > 0) {
3464
+ console.log(`\n New files to track: ${results.added.length}`);
3465
+ results.added.forEach(f => console.log(` + ${f.path}`));
3466
+ }
3467
+
3468
+ if (results.updated.length > 0) {
3469
+ console.log(`\n Modified files to update: ${results.updated.length}`);
3470
+ results.updated.forEach(f => console.log(` ~ ${f.path}`));
3471
+ }
3472
+
3473
+ if (results.missing.length > 0) {
3474
+ console.log(`\n Missing files to remove from state: ${results.missing.length}`);
3475
+ results.missing.forEach(f => console.log(` - ${f}`));
3476
+ }
3477
+
3478
+ if (options.dryRun) {
3479
+ console.log('\nDry run: no changes applied.');
3480
+ return;
3481
+ }
3482
+
3483
+ if (results.added.length > 0) {
3484
+ for (const file of results.added) {
3485
+ state.files[file.path] = {
3486
+ kind: inferKind(file.path, config),
3487
+ hash: file.hash,
3488
+ timestamp: new Date().toISOString(),
3489
+ synced: true
3490
+ };
3491
+ }
3492
+ }
3493
+
3494
+ if (results.updated.length > 0) {
3495
+ for (const file of results.updated) {
3496
+ state.files[file.path].hash = file.newHash;
3497
+ state.files[file.path].timestamp = new Date().toISOString();
3498
+ state.files[file.path].synced = true;
3499
+ }
3500
+ }
3501
+
3502
+ if (results.missing.length > 0) {
3503
+ for (const relPath of results.missing) {
3504
+ delete state.files[relPath];
3505
+ }
3506
+ }
3507
+
3508
+ if (changed) {
3509
+ // 3. Reconstruct components and sections
3510
+ state.components = reconstructComponents(state.files, config);
3511
+ state.sections = reconstructSections(state, config);
3512
+
3513
+ await saveState(state);
3514
+ console.log(`\n✓ State synchronized successfully (${results.added.length} added, ${results.updated.length} updated, ${results.missing.length} removed).`);
3515
+ } else {
3516
+ // Even if no files changed, check if metadata needs reconstruction
3517
+ const newComponents = reconstructComponents(state.files, config);
3518
+ const newSections = reconstructSections(state, config);
3519
+
3520
+ const componentsEqual = JSON.stringify(newComponents) === JSON.stringify(state.components || []);
3521
+ const sectionsEqual = JSON.stringify(newSections) === JSON.stringify(state.sections || []);
3522
+
3523
+ if (!componentsEqual || !sectionsEqual) {
3524
+ state.components = newComponents;
3525
+ state.sections = newSections;
3526
+ await saveState(state);
3527
+ console.log('\n✓ Metadata (components/sections) reconstructed.');
3528
+ } else {
3529
+ console.log('\n✓ Everything is already in sync.');
3530
+ }
3531
+ }
3532
+
3533
+ } catch (error) {
3534
+ console.error('Error:', error.message);
3535
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
3536
+ process.exit(1);
3537
+ }
3538
+ throw error;
3539
+ }
3540
+ }
3541
+
3542
+ async function adoptCommand(identifier, options) {
3543
+ try {
3544
+ const config = await loadConfig();
3545
+ const state = await loadState();
3546
+
3547
+ const roots = {
3548
+ pages: resolvePath(config, 'pages'),
3549
+ features: resolvePath(config, 'features'),
3550
+ components: resolvePath(config, 'components')
3551
+ };
3552
+
3553
+ let filesToAdopt = [];
3554
+
3555
+ if (!identifier && options.all) {
3556
+ // Adopt all untracked files in all roots
3557
+ const managedFiles = new Set();
3558
+ for (const root of Object.values(roots)) {
3559
+ if (existsSync(root)) {
3560
+ await scanDirectory(root, managedFiles);
3561
+ }
3562
+ }
3563
+ filesToAdopt = Array.from(managedFiles).filter(f => !state.files[f]);
3564
+ } else if (identifier) {
3565
+ const untrackedFiles = new Set();
3566
+
3567
+ // 1. Try as direct path
3568
+ const fullPath = path.resolve(process.cwd(), identifier);
3569
+ if (existsSync(fullPath)) {
3570
+ await scanDirectoryOrFile(fullPath, untrackedFiles, state);
3571
+ }
3572
+
3573
+ // 2. Try as component name
3574
+ const compPath = path.join(roots.components, identifier);
3575
+ if (existsSync(compPath)) {
3576
+ await scanDirectoryOrFile(compPath, untrackedFiles, state);
3577
+ }
3578
+
3579
+ // 3. Try as feature name
3580
+ const featPath = path.join(roots.features, identifier);
3581
+ if (existsSync(featPath)) {
3582
+ await scanDirectoryOrFile(featPath, untrackedFiles, state);
3583
+ }
3584
+
3585
+ // 4. Try as route or page name
3586
+ const cleanRoute = identifier.startsWith('/') ? identifier.slice(1) : identifier;
3587
+ const pagePath = path.join(roots.pages, cleanRoute + (config.naming?.routeExtension || '.astro'));
3588
+ if (existsSync(pagePath)) {
3589
+ await scanDirectoryOrFile(pagePath, untrackedFiles, state);
3590
+ }
3591
+ const nestedPagePath = path.join(roots.pages, cleanRoute, config.routing?.indexFile || 'index.astro');
3592
+ if (existsSync(nestedPagePath)) {
3593
+ await scanDirectoryOrFile(nestedPagePath, untrackedFiles, state);
3594
+ }
3595
+
3596
+ filesToAdopt = Array.from(untrackedFiles);
3597
+
3598
+ if (filesToAdopt.length === 0 && !existsSync(fullPath)) {
3599
+ throw new Error(`Could not find any untracked files for identifier: ${identifier}`);
3600
+ }
3601
+ } else {
3602
+ throw new Error('Please provide a path/identifier or use --all');
3603
+ }
3604
+
3605
+ // Filter to ensure all files are within managed roots
3606
+ const rootPaths = Object.values(roots).map(p => path.resolve(p));
3607
+ filesToAdopt = filesToAdopt.filter(relPath => {
3608
+ const fullPath = path.resolve(process.cwd(), relPath);
3609
+ return rootPaths.some(root => fullPath.startsWith(root));
3610
+ });
3611
+
3612
+ if (filesToAdopt.length === 0) {
3613
+ console.log('No untracked files found to adopt.');
3614
+ return;
3615
+ }
3616
+
3617
+ console.log(`Found ${filesToAdopt.length} files to adopt...`);
3618
+ let adoptedCount = 0;
3619
+
3620
+ for (const relPath of filesToAdopt) {
3621
+ const success = await adoptFile(relPath, config, state, options);
3622
+ if (success) adoptedCount++;
3623
+ }
3624
+
3625
+ if (adoptedCount > 0 && !options.dryRun) {
3626
+ state.components = reconstructComponents(state.files, config);
3627
+ state.sections = reconstructSections(state, config);
3628
+ await saveState(state);
3629
+ console.log(`\n✓ Successfully adopted ${adoptedCount} files.`);
3630
+ } else if (options.dryRun) {
3631
+ console.log(`\nDry run: would adopt ${adoptedCount} files.`);
3632
+ }
3633
+
3634
+ } catch (error) {
3635
+ console.error('Error:', error.message);
3636
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
3637
+ process.exit(1);
3638
+ }
3639
+ throw error;
3640
+ }
3641
+ }
3642
+
3643
+ async function adoptFile(relPath, config, state, options) {
3644
+ const fullPath = path.join(process.cwd(), relPath);
3645
+ const content = await readFile(fullPath, 'utf-8');
3646
+
3647
+ const ext = path.extname(relPath);
3648
+ let signature = '';
3649
+ if (ext === '.astro') signature = config.signatures.astro;
3650
+ else if (ext === '.ts' || ext === '.tsx') signature = config.signatures.typescript;
3651
+ else if (ext === '.js' || ext === '.jsx') signature = config.signatures.javascript;
3652
+
3653
+ let finalContent = content;
3654
+
3655
+ if (signature && !content.includes(signature)) {
3656
+ if (options.dryRun) {
3657
+ console.log(` ~ Would add signature to ${relPath}`);
3658
+ } else {
3659
+ finalContent = signature + '\n' + content;
3660
+ await writeFile(fullPath, finalContent, 'utf-8');
3661
+ console.log(` + Added signature and adopting: ${relPath}`);
3662
+ }
3663
+ } else {
3664
+ if (options.dryRun) {
3665
+ console.log(` + Would adopt (already has signature or no signature for ext): ${relPath}`);
3666
+ } else {
3667
+ console.log(` + Adopting: ${relPath}`);
3668
+ }
3669
+ }
3670
+
3671
+ if (!options.dryRun) {
3672
+ const hash = calculateHash(finalContent, config.hashing?.normalization);
3673
+ state.files[relPath] = {
3674
+ kind: inferKind(relPath, config),
3675
+ hash: hash,
3676
+ timestamp: new Date().toISOString(),
3677
+ synced: true
3678
+ };
3679
+ }
3680
+
3681
+ return true;
3682
+ }
3683
+
3684
+ async function scanDirectoryOrFile(fullPath, fileSet, state) {
3685
+ if ((await stat(fullPath)).isDirectory()) {
3686
+ const dirFiles = new Set();
3687
+ await scanDirectory(fullPath, dirFiles);
3688
+ for (const f of dirFiles) {
3689
+ if (!state.files[f]) fileSet.add(f);
3690
+ }
3691
+ } else {
3692
+ const relPath = path.relative(process.cwd(), fullPath).replace(/\\/g, '/');
3693
+ if (!state.files[relPath]) {
3694
+ fileSet.add(relPath);
3695
+ }
3696
+ }
3697
+ }
3698
+
3699
+ const program = new Command();
3700
+
3701
+ program
3702
+ .name('textor')
3703
+ .description('Safe, deterministic scaffolding and refactoring tool for Astro projects')
3704
+ .version('1.0.0');
3705
+
3706
+ program
3707
+ .command('init')
3708
+ .description('Initialize Textor configuration')
3709
+ .option('--force', 'Overwrite existing configuration')
3710
+ .action(initCommand);
3711
+
3712
+ program
3713
+ .command('add-section <route> <featurePath>')
3714
+ .description('Create a route + feature binding')
3715
+ .option('--preset <name>', 'Scaffolding preset (minimal, standard, senior)')
3716
+ .option('--layout <name>', 'Layout component name (use "none" for no layout)', 'Main')
3717
+ .option('--name <name>', 'Section name for state tracking')
3718
+ .option('--endpoint', 'Create an API endpoint (.ts) instead of an Astro page')
3719
+ .option('--api', 'Create api directory')
3720
+ .option('--services', 'Create services directory')
3721
+ .option('--schemas', 'Create schemas directory')
3722
+ .option('--hooks', 'Create hooks directory')
3723
+ .option('--context', 'Create context directory')
3724
+ .option('--tests', 'Create tests directory')
3725
+ .option('--types', 'Create types directory')
3726
+ .option('--readme', 'Create README.md')
3727
+ .option('--stories', 'Create Storybook stories')
3728
+ .option('--index', 'Create index.ts')
3729
+ .option('--no-sub-components-dir', 'Skip creating sub-components directory')
3730
+ .option('--no-scripts-dir', 'Skip creating scripts directory')
3731
+ .option('--dry-run', 'Show what would be created without creating')
3732
+ .option('--force', 'Overwrite existing files')
3733
+ .action(addSectionCommand);
3734
+
3735
+ program
3736
+ .command('remove-section <route> [featurePath]')
3737
+ .description('Remove a section (featurePath optional if using state)')
3738
+ .option('--keep-feature', 'Keep the feature module')
3739
+ .option('--keep-route', 'Keep the route file')
3740
+ .option('--accept-changes', 'Allow removal of modified files')
3741
+ .option('--dry-run', 'Show what would be deleted without deleting')
3742
+ .option('--force', 'Delete files even if not generated by Textor')
3743
+ .action(removeSectionCommand);
3744
+
3745
+ program
3746
+ .command('move-section')
3747
+ .description('Move a section')
3748
+ .argument('<fromRoute>', 'Source route or section name')
3749
+ .argument('<fromFeatureOrToRoute>', 'Source feature path OR Destination route (if using state)')
3750
+ .argument('[toRoute]', 'Destination route')
3751
+ .argument('[toFeature]', 'Destination feature path')
3752
+ .option('--keep-feature', 'Only move route, not feature')
3753
+ .option('--accept-changes', 'Allow moving of modified files')
3754
+ .option('--scan', 'Enable repo-wide import updates')
3755
+ .option('--force', 'Overwrite existing files')
3756
+ .option('--dry-run', 'Show what would be moved without moving')
3757
+ .action(moveSectionCommand);
3758
+
3759
+ program
3760
+ .command('create-component <componentName>')
3761
+ .description('Create a reusable UI component')
3762
+ .option('--preset <name>', 'Scaffolding preset (minimal, standard, senior)')
3763
+ .option('--api', 'Create api directory')
3764
+ .option('--services', 'Create services directory')
3765
+ .option('--schemas', 'Create schemas directory')
3766
+ .option('--readme', 'Create README.md')
3767
+ .option('--stories', 'Create Storybook stories')
3768
+ .option('--no-context', 'Skip creating context file')
3769
+ .option('--no-hook', 'Skip creating hook file')
3770
+ .option('--no-tests', 'Skip creating test file')
3771
+ .option('--no-types', 'Skip creating types file')
3772
+ .option('--no-config', 'Skip creating config file')
3773
+ .option('--no-constants', 'Skip creating constants file')
3774
+ .option('--no-sub-components-dir', 'Skip creating sub-components directory')
3775
+ .option('--dry-run', 'Show what would be created without creating')
3776
+ .option('--force', 'Overwrite existing files')
3777
+ .action(createComponentCommand);
3778
+
3779
+ program
3780
+ .command('remove-component <name>')
3781
+ .description('Remove a reusable UI component')
3782
+ .option('--accept-changes', 'Allow removal of modified files')
3783
+ .option('--dry-run', 'Show what would be deleted without deleting')
3784
+ .option('--force', 'Delete files even if not generated by Textor')
3785
+ .action(removeComponentCommand);
3786
+
3787
+ program
3788
+ .command('list-sections')
3789
+ .description('List all Textor-managed sections')
3790
+ .action(listSectionsCommand);
3791
+
3792
+ program
3793
+ .command('status')
3794
+ .description('Show drift between state and disk')
3795
+ .action(statusCommand);
3796
+
3797
+ program
3798
+ .command('validate-state')
3799
+ .description('Validate that the state file matches the project files')
3800
+ .option('--fix', 'Try to fix state by re-hashing modified files (requires signatures to match)')
3801
+ .action(validateStateCommand);
3802
+
3803
+ program
3804
+ .command('sync')
3805
+ .description('Synchronize the state with the actual files in managed directories')
3806
+ .option('--include-all', 'Include all files in managed directories, even without Textor signature')
3807
+ .option('--force', 'Update hashes for modified files even without Textor signature')
3808
+ .option('--dry-run', 'Show what would be changed without applying')
3809
+ .action(syncCommand);
3810
+
3811
+ program
3812
+ .command('adopt [path]')
3813
+ .description('Adopt untracked files into Textor state, adding signatures')
3814
+ .option('--all', 'Adopt all untracked files in managed directories')
3815
+ .option('--dry-run', 'Show what would be adopted without applying')
3816
+ .action(adoptCommand);
3817
+
3818
+ program.parse();
3819
+ //# sourceMappingURL=textor.js.map