@sk8metal/michi-cli 0.8.7 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/README.md +1 -1
  3. package/dist/scripts/config/config-schema.d.ts +3 -0
  4. package/dist/scripts/config/config-schema.d.ts.map +1 -1
  5. package/dist/scripts/config/config-schema.js +18 -0
  6. package/dist/scripts/config/config-schema.js.map +1 -1
  7. package/dist/scripts/utils/multi-repo-validator.d.ts +20 -1
  8. package/dist/scripts/utils/multi-repo-validator.d.ts.map +1 -1
  9. package/dist/scripts/utils/multi-repo-validator.js +124 -1
  10. package/dist/scripts/utils/multi-repo-validator.js.map +1 -1
  11. package/docs/user-guide/guides/multi-repo-guide.md +443 -0
  12. package/docs/user-guide/guides/workflow.md +4 -14
  13. package/docs/user-guide/hands-on/workflow-walkthrough.md +173 -4
  14. package/package.json +1 -1
  15. package/scripts/__tests__/multi-repo-config-schema.test.ts +106 -0
  16. package/scripts/__tests__/multi-repo-validator.test.ts +229 -1
  17. package/scripts/config/config-schema.ts +20 -0
  18. package/scripts/utils/multi-repo-validator.ts +160 -1
  19. package/templates/claude/agents/mermaid-validator/AGENT.md +257 -0
  20. package/templates/claude/commands/michi-multi-repo/impl-all.md +264 -0
  21. package/templates/claude/commands/michi-multi-repo/propagate-specs.md +271 -0
  22. package/templates/claude/commands/michi-multi-repo/spec-design.md +66 -3
  23. package/templates/claude/commands/michi-multi-repo/spec-review.md +247 -0
  24. package/templates/claude/skills/mermaid-validator/SKILL.md +261 -0
  25. package/templates/claude-agent/agents/cross-repo-reviewer.md +194 -0
  26. package/templates/claude-agent/agents/repo-spec-executor.md +113 -0
@@ -37,6 +37,50 @@ describe('RepositorySchema', () => {
37
37
  expect(result.data.branch).toBe('main');
38
38
  }
39
39
  });
40
+
41
+ it('localPathが未指定の場合も受け入れる(オプショナル)', () => {
42
+ const repoWithoutLocalPath = {
43
+ name: 'my-repo',
44
+ url: 'https://github.com/owner/repo',
45
+ branch: 'main',
46
+ };
47
+
48
+ const result = RepositorySchema.safeParse(repoWithoutLocalPath);
49
+ expect(result.success).toBe(true);
50
+ if (result.success) {
51
+ expect(result.data.localPath).toBeUndefined();
52
+ }
53
+ });
54
+
55
+ it('Unix絶対パス(/path/to/repo)を受け入れる', () => {
56
+ const repoWithUnixPath = {
57
+ name: 'my-repo',
58
+ url: 'https://github.com/owner/repo',
59
+ branch: 'main',
60
+ localPath: '/Users/user/repos/my-repo',
61
+ };
62
+
63
+ const result = RepositorySchema.safeParse(repoWithUnixPath);
64
+ expect(result.success).toBe(true);
65
+ if (result.success) {
66
+ expect(result.data.localPath).toBe('/Users/user/repos/my-repo');
67
+ }
68
+ });
69
+
70
+ it('Windows絶対パス(C:\\path\\to\\repo)を受け入れる', () => {
71
+ const repoWithWindowsPath = {
72
+ name: 'my-repo',
73
+ url: 'https://github.com/owner/repo',
74
+ branch: 'main',
75
+ localPath: 'C:\\Users\\user\\repos\\my-repo',
76
+ };
77
+
78
+ const result = RepositorySchema.safeParse(repoWithWindowsPath);
79
+ expect(result.success).toBe(true);
80
+ if (result.success) {
81
+ expect(result.data.localPath).toBe('C:\\Users\\user\\repos\\my-repo');
82
+ }
83
+ });
40
84
  });
41
85
 
42
86
  describe('異常ケース', () => {
@@ -73,6 +117,68 @@ describe('RepositorySchema', () => {
73
117
  expect(result.success).toBe(false);
74
118
  });
75
119
  });
120
+
121
+ describe('localPathバリデーション', () => {
122
+ it('相対パス(./repo)を含む場合はエラー', () => {
123
+ const invalidRepo = {
124
+ name: 'my-repo',
125
+ url: 'https://github.com/owner/repo',
126
+ branch: 'main',
127
+ localPath: './repos/my-repo',
128
+ };
129
+
130
+ const result = RepositorySchema.safeParse(invalidRepo);
131
+ expect(result.success).toBe(false);
132
+ });
133
+
134
+ it('相対パス(../repo)を含む場合はエラー', () => {
135
+ const invalidRepo = {
136
+ name: 'my-repo',
137
+ url: 'https://github.com/owner/repo',
138
+ branch: 'main',
139
+ localPath: '../repos/my-repo',
140
+ };
141
+
142
+ const result = RepositorySchema.safeParse(invalidRepo);
143
+ expect(result.success).toBe(false);
144
+ });
145
+
146
+ it('Windowsの相対パス(.\\repo)を含む場合はエラー', () => {
147
+ const invalidRepo = {
148
+ name: 'my-repo',
149
+ url: 'https://github.com/owner/repo',
150
+ branch: 'main',
151
+ localPath: '.\\repos\\my-repo',
152
+ };
153
+
154
+ const result = RepositorySchema.safeParse(invalidRepo);
155
+ expect(result.success).toBe(false);
156
+ });
157
+
158
+ it('空文字列の場合はエラー', () => {
159
+ const invalidRepo = {
160
+ name: 'my-repo',
161
+ url: 'https://github.com/owner/repo',
162
+ branch: 'main',
163
+ localPath: '',
164
+ };
165
+
166
+ const result = RepositorySchema.safeParse(invalidRepo);
167
+ expect(result.success).toBe(false);
168
+ });
169
+
170
+ it('相対パス(repo/subdir)を含む場合はエラー', () => {
171
+ const invalidRepo = {
172
+ name: 'my-repo',
173
+ url: 'https://github.com/owner/repo',
174
+ branch: 'main',
175
+ localPath: 'repos/my-repo',
176
+ };
177
+
178
+ const result = RepositorySchema.safeParse(invalidRepo);
179
+ expect(result.success).toBe(false);
180
+ });
181
+ });
76
182
  });
77
183
 
78
184
  describe('MultiRepoProjectSchema', () => {
@@ -3,12 +3,17 @@
3
3
  * Task 11.1: バリデーション関数の単体テスト
4
4
  */
5
5
 
6
- import { describe, it, expect } from 'vitest';
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { execSync } from 'child_process';
7
10
  import {
8
11
  validateProjectName,
9
12
  validateJiraKey,
10
13
  validateRepositoryUrl,
14
+ validateLocalPath,
11
15
  } from '../utils/multi-repo-validator.js';
16
+ import type { Repository } from '../config/config-schema.js';
12
17
 
13
18
  describe('validateProjectName', () => {
14
19
  describe('正常ケース', () => {
@@ -522,3 +527,226 @@ describe('validateRepositoryUrl', () => {
522
527
  });
523
528
  });
524
529
  });
530
+
531
+ describe('validateLocalPath', () => {
532
+ let tempDir: string;
533
+
534
+ beforeEach(() => {
535
+ // テスト用の一時ディレクトリを作成
536
+ tempDir = fs.mkdtempSync(path.join(process.cwd(), 'test-temp-'));
537
+ });
538
+
539
+ afterEach(() => {
540
+ // テスト用の一時ディレクトリを削除
541
+ if (fs.existsSync(tempDir)) {
542
+ fs.rmSync(tempDir, { recursive: true, force: true });
543
+ }
544
+ });
545
+
546
+ describe('localPath未設定ケース', () => {
547
+ it('localPathが未設定の場合、警告を返す', () => {
548
+ const repository: Repository = {
549
+ name: 'test-repo',
550
+ url: 'https://github.com/owner/repo',
551
+ branch: 'main',
552
+ // localPath: undefined (省略)
553
+ };
554
+
555
+ const result = validateLocalPath(repository);
556
+
557
+ expect(result.isValid).toBe(false);
558
+ expect(result.errors).toHaveLength(0);
559
+ expect(result.warnings).toHaveLength(1);
560
+ expect(result.warnings[0]).toContain(
561
+ "Repository 'test-repo' does not have localPath configured",
562
+ );
563
+ expect(result.exists).toBe(false);
564
+ expect(result.isGitRepository).toBe(false);
565
+ expect(result.currentBranch).toBeNull();
566
+ expect(result.branchMatches).toBe(false);
567
+ expect(result.hasUncommittedChanges).toBe(false);
568
+ });
569
+ });
570
+
571
+ describe('ディレクトリ存在確認', () => {
572
+ it('localPathが存在しない場合、エラーを返す', () => {
573
+ const nonExistentPath = path.join(tempDir, 'non-existent');
574
+ const repository: Repository = {
575
+ name: 'test-repo',
576
+ url: 'https://github.com/owner/repo',
577
+ branch: 'main',
578
+ localPath: nonExistentPath,
579
+ };
580
+
581
+ const result = validateLocalPath(repository);
582
+
583
+ expect(result.isValid).toBe(false);
584
+ expect(result.errors).toHaveLength(1);
585
+ expect(result.errors[0]).toContain('does not exist');
586
+ expect(result.exists).toBe(false);
587
+ });
588
+
589
+ it('localPathがファイルの場合、エラーを返す', () => {
590
+ const filePath = path.join(tempDir, 'test-file.txt');
591
+ fs.writeFileSync(filePath, 'test content');
592
+
593
+ const repository: Repository = {
594
+ name: 'test-repo',
595
+ url: 'https://github.com/owner/repo',
596
+ branch: 'main',
597
+ localPath: filePath,
598
+ };
599
+
600
+ const result = validateLocalPath(repository);
601
+
602
+ expect(result.isValid).toBe(false);
603
+ expect(result.errors).toHaveLength(1);
604
+ expect(result.errors[0]).toContain('is not a directory');
605
+ expect(result.exists).toBe(true);
606
+ expect(result.isGitRepository).toBe(false);
607
+ });
608
+ });
609
+
610
+ describe('Gitリポジトリ確認', () => {
611
+ it('Gitリポジトリではないディレクトリの場合、エラーを返す', () => {
612
+ const nonGitDir = path.join(tempDir, 'non-git-repo');
613
+ fs.mkdirSync(nonGitDir);
614
+
615
+ const repository: Repository = {
616
+ name: 'test-repo',
617
+ url: 'https://github.com/owner/repo',
618
+ branch: 'main',
619
+ localPath: nonGitDir,
620
+ };
621
+
622
+ const result = validateLocalPath(repository);
623
+
624
+ expect(result.isValid).toBe(false);
625
+ expect(result.errors).toHaveLength(1);
626
+ expect(result.errors[0]).toContain('is not a Git repository');
627
+ expect(result.exists).toBe(true);
628
+ expect(result.isGitRepository).toBe(false);
629
+ });
630
+
631
+ it('Gitリポジトリの場合、基本的なチェックを通過する', () => {
632
+ const gitDir = path.join(tempDir, 'git-repo');
633
+ fs.mkdirSync(gitDir);
634
+
635
+ // Gitリポジトリを初期化
636
+ execSync('git init', { cwd: gitDir });
637
+ execSync('git config user.email "test@example.com"', { cwd: gitDir });
638
+ execSync('git config user.name "Test User"', { cwd: gitDir });
639
+
640
+ // 初回コミットを作成(ブランチが作成されるため)
641
+ fs.writeFileSync(path.join(gitDir, 'README.md'), '# Test');
642
+ execSync('git add .', { cwd: gitDir });
643
+ execSync('git commit -m "Initial commit"', { cwd: gitDir });
644
+
645
+ // mainブランチに切り替え(git initでデフォルトブランチ名が異なる可能性があるため)
646
+ try {
647
+ execSync('git branch -M main', { cwd: gitDir });
648
+ } catch {
649
+ // すでにmainの場合は無視
650
+ }
651
+
652
+ const repository: Repository = {
653
+ name: 'test-repo',
654
+ url: 'https://github.com/owner/repo',
655
+ branch: 'main',
656
+ localPath: gitDir,
657
+ };
658
+
659
+ const result = validateLocalPath(repository);
660
+
661
+ expect(result.isValid).toBe(true);
662
+ expect(result.errors).toHaveLength(0);
663
+ expect(result.exists).toBe(true);
664
+ expect(result.isGitRepository).toBe(true);
665
+ expect(result.currentBranch).toBe('main');
666
+ expect(result.branchMatches).toBe(true);
667
+ expect(result.hasUncommittedChanges).toBe(false);
668
+ });
669
+
670
+ it('ブランチが一致しない場合、警告を返す', () => {
671
+ const gitDir = path.join(tempDir, 'git-repo-branch-mismatch');
672
+ fs.mkdirSync(gitDir);
673
+
674
+ // Gitリポジトリを初期化
675
+ execSync('git init', { cwd: gitDir });
676
+ execSync('git config user.email "test@example.com"', { cwd: gitDir });
677
+ execSync('git config user.name "Test User"', { cwd: gitDir });
678
+
679
+ // 初回コミットを作成
680
+ fs.writeFileSync(path.join(gitDir, 'README.md'), '# Test');
681
+ execSync('git add .', { cwd: gitDir });
682
+ execSync('git commit -m "Initial commit"', { cwd: gitDir });
683
+
684
+ // developブランチに切り替え
685
+ execSync('git checkout -b develop', { cwd: gitDir });
686
+
687
+ const repository: Repository = {
688
+ name: 'test-repo',
689
+ url: 'https://github.com/owner/repo',
690
+ branch: 'main', // 実際はdevelopブランチにいる
691
+ localPath: gitDir,
692
+ };
693
+
694
+ const result = validateLocalPath(repository);
695
+
696
+ expect(result.isValid).toBe(true); // 警告のみでエラーではない
697
+ expect(result.errors).toHaveLength(0);
698
+ expect(result.warnings.length).toBeGreaterThan(0);
699
+ expect(result.warnings.some((w) => w.includes('does not match'))).toBe(
700
+ true,
701
+ );
702
+ expect(result.exists).toBe(true);
703
+ expect(result.isGitRepository).toBe(true);
704
+ expect(result.currentBranch).toBe('develop');
705
+ expect(result.branchMatches).toBe(false);
706
+ });
707
+
708
+ it('未コミット変更がある場合、警告を返す', () => {
709
+ const gitDir = path.join(tempDir, 'git-repo-uncommitted');
710
+ fs.mkdirSync(gitDir);
711
+
712
+ // Gitリポジトリを初期化
713
+ execSync('git init', { cwd: gitDir });
714
+ execSync('git config user.email "test@example.com"', { cwd: gitDir });
715
+ execSync('git config user.name "Test User"', { cwd: gitDir });
716
+
717
+ // 初回コミットを作成
718
+ fs.writeFileSync(path.join(gitDir, 'README.md'), '# Test');
719
+ execSync('git add .', { cwd: gitDir });
720
+ execSync('git commit -m "Initial commit"', { cwd: gitDir });
721
+
722
+ // mainブランチに切り替え
723
+ try {
724
+ execSync('git branch -M main', { cwd: gitDir });
725
+ } catch {
726
+ // すでにmainの場合は無視
727
+ }
728
+
729
+ // 未コミットの変更を追加
730
+ fs.writeFileSync(path.join(gitDir, 'new-file.txt'), 'uncommitted');
731
+
732
+ const repository: Repository = {
733
+ name: 'test-repo',
734
+ url: 'https://github.com/owner/repo',
735
+ branch: 'main',
736
+ localPath: gitDir,
737
+ };
738
+
739
+ const result = validateLocalPath(repository);
740
+
741
+ expect(result.isValid).toBe(true); // 警告のみでエラーではない
742
+ expect(result.errors).toHaveLength(0);
743
+ expect(result.warnings.length).toBeGreaterThan(0);
744
+ expect(
745
+ result.warnings.some((w) => w.includes('uncommitted changes')),
746
+ ).toBe(true);
747
+ expect(result.exists).toBe(true);
748
+ expect(result.isGitRepository).toBe(true);
749
+ expect(result.hasUncommittedChanges).toBe(true);
750
+ });
751
+ });
752
+ });
@@ -183,6 +183,26 @@ export const RepositorySchema = z.object({
183
183
  message: 'GitHub URL must be in format: https://github.com/{owner}/{repo}',
184
184
  }),
185
185
  branch: z.string().default('main'),
186
+ localPath: z
187
+ .string()
188
+ .optional()
189
+ .refine(
190
+ (path) => {
191
+ // localPath が指定されていない場合は検証スキップ
192
+ if (path === undefined) return true;
193
+ // 空文字列はエラー
194
+ if (path === '') return false;
195
+ // 絶対パスであることを検証(セキュリティ考慮)
196
+ // Unix系: '/' で始まる、Windows系: 'C:\' 等で始まる
197
+ const isUnixAbsolutePath = path.startsWith('/');
198
+ const isWindowsAbsolutePath = /^[A-Za-z]:\\/.test(path);
199
+ return isUnixAbsolutePath || isWindowsAbsolutePath;
200
+ },
201
+ {
202
+ message:
203
+ 'localPath must be an absolute path (Unix: /path/to/repo, Windows: C:\\path\\to\\repo)',
204
+ },
205
+ ),
186
206
  });
187
207
 
188
208
  /**
@@ -2,9 +2,14 @@
2
2
  * multi-repo-validator.ts
3
3
  * Multi-Repo機能のバリデーションユーティリティ
4
4
  *
5
- * プロジェクト名、JIRAキー、リポジトリURLのバリデーションとセキュリティチェックを行います。
5
+ * プロジェクト名、JIRAキー、リポジトリURL、localPathのバリデーションとセキュリティチェックを行います。
6
6
  */
7
7
 
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { execSync } from 'child_process';
11
+ import type { Repository } from '../config/config-schema.js';
12
+
8
13
  /**
9
14
  * バリデーション結果
10
15
  */
@@ -14,6 +19,17 @@ export interface ValidationResult {
14
19
  warnings: string[];
15
20
  }
16
21
 
22
+ /**
23
+ * LocalPathバリデーション結果(詳細情報付き)
24
+ */
25
+ export interface LocalPathValidationResult extends ValidationResult {
26
+ exists: boolean;
27
+ isGitRepository: boolean;
28
+ currentBranch: string | null;
29
+ branchMatches: boolean;
30
+ hasUncommittedChanges: boolean;
31
+ }
32
+
17
33
  /**
18
34
  * プロジェクト名のバリデーション
19
35
  * セキュリティ対策: パストラバーサル、相対パス、制御文字をチェック
@@ -139,3 +155,146 @@ export function validateRepositoryUrl(url: string): ValidationResult {
139
155
  warnings,
140
156
  };
141
157
  }
158
+
159
+ /**
160
+ * LocalPathのバリデーション
161
+ * ディレクトリ存在、Gitリポジトリ、ブランチ、未コミット変更をチェック
162
+ *
163
+ * @param repository - リポジトリ設定
164
+ * @returns バリデーション結果(詳細情報付き)
165
+ */
166
+ export function validateLocalPath(
167
+ repository: Repository,
168
+ ): LocalPathValidationResult {
169
+ const errors: string[] = [];
170
+ const warnings: string[] = [];
171
+
172
+ // 初期値
173
+ let exists = false;
174
+ let isGitRepository = false;
175
+ let currentBranch: string | null = null;
176
+ let branchMatches = false;
177
+ let hasUncommittedChanges = false;
178
+
179
+ // 1. localPath設定確認
180
+ if (!repository.localPath) {
181
+ warnings.push(
182
+ `Repository '${repository.name}' does not have localPath configured`,
183
+ );
184
+ return {
185
+ isValid: false,
186
+ errors,
187
+ warnings,
188
+ exists,
189
+ isGitRepository,
190
+ currentBranch,
191
+ branchMatches,
192
+ hasUncommittedChanges,
193
+ };
194
+ }
195
+
196
+ const localPath = repository.localPath;
197
+
198
+ // 2. ディレクトリ存在確認
199
+ try {
200
+ const stats = fs.statSync(localPath);
201
+ if (!stats.isDirectory()) {
202
+ errors.push(
203
+ `localPath '${localPath}' exists but is not a directory`,
204
+ );
205
+ return {
206
+ isValid: false,
207
+ errors,
208
+ warnings,
209
+ exists: true,
210
+ isGitRepository,
211
+ currentBranch,
212
+ branchMatches,
213
+ hasUncommittedChanges,
214
+ };
215
+ }
216
+ exists = true;
217
+ } catch (_error) {
218
+ errors.push(`localPath '${localPath}' does not exist`);
219
+ return {
220
+ isValid: false,
221
+ errors,
222
+ warnings,
223
+ exists,
224
+ isGitRepository,
225
+ currentBranch,
226
+ branchMatches,
227
+ hasUncommittedChanges,
228
+ };
229
+ }
230
+
231
+ // 3. Gitリポジトリ確認
232
+ const gitDir = path.join(localPath, '.git');
233
+ if (!fs.existsSync(gitDir)) {
234
+ errors.push(
235
+ `localPath '${localPath}' is not a Git repository (no .git directory)`,
236
+ );
237
+ return {
238
+ isValid: false,
239
+ errors,
240
+ warnings,
241
+ exists,
242
+ isGitRepository,
243
+ currentBranch,
244
+ branchMatches,
245
+ hasUncommittedChanges,
246
+ };
247
+ }
248
+ isGitRepository = true;
249
+
250
+ // 4. ブランチ確認
251
+ try {
252
+ currentBranch = execSync('git branch --show-current', {
253
+ cwd: localPath,
254
+ encoding: 'utf-8',
255
+ }).trim();
256
+
257
+ if (currentBranch !== repository.branch) {
258
+ warnings.push(
259
+ `Current branch '${currentBranch}' does not match configured branch '${repository.branch}'`,
260
+ );
261
+ branchMatches = false;
262
+ } else {
263
+ branchMatches = true;
264
+ }
265
+ } catch (error) {
266
+ warnings.push(
267
+ `Failed to get current branch: ${error instanceof Error ? error.message : String(error)}`,
268
+ );
269
+ }
270
+
271
+ // 5. 未コミット変更確認
272
+ try {
273
+ const statusOutput = execSync('git status --porcelain', {
274
+ cwd: localPath,
275
+ encoding: 'utf-8',
276
+ }).trim();
277
+
278
+ if (statusOutput.length > 0) {
279
+ warnings.push(
280
+ `Repository '${repository.name}' has uncommitted changes`,
281
+ );
282
+ hasUncommittedChanges = true;
283
+ }
284
+ } catch (error) {
285
+ warnings.push(
286
+ `Failed to check uncommitted changes: ${error instanceof Error ? error.message : String(error)}`,
287
+ );
288
+ }
289
+
290
+ return {
291
+ isValid: errors.length === 0,
292
+ errors,
293
+ warnings,
294
+ exists,
295
+ isGitRepository,
296
+ currentBranch,
297
+ branchMatches,
298
+ hasUncommittedChanges,
299
+ };
300
+ }