ziku 0.20.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.
package/dist/index.mjs ADDED
@@ -0,0 +1,3788 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import * as p from "@clack/prompts";
4
+ import { defineCommand, runMain } from "citty";
5
+ import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
7
+ import { dirname, join, resolve } from "pathe";
8
+ import { parse, stringify } from "yaml";
9
+ import { z } from "zod";
10
+ import { downloadTemplate } from "giget";
11
+ import { applyEdits, modify, parse as parse$1 } from "jsonc-parser";
12
+ import { applyPatch, createPatch, diffWords, structuredPatch } from "diff";
13
+ import pc, { default as pc$1 } from "picocolors";
14
+ import ignore from "ignore";
15
+ import { glob, globSync } from "tinyglobby";
16
+ import { match } from "ts-pattern";
17
+ import { execFileSync } from "node:child_process";
18
+ import { Octokit } from "@octokit/rest";
19
+ import { createHash } from "node:crypto";
20
+
21
+ //#region rolldown:runtime
22
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
23
+
24
+ //#endregion
25
+ //#region package.json
26
+ var version$2 = "0.20.0";
27
+
28
+ //#endregion
29
+ //#region src/modules/schemas.ts
30
+ /** 非負整数(行数カウント用) */
31
+ const nonNegativeIntSchema = z.number().int().nonnegative().brand();
32
+ /** ファイルパス */
33
+ const filePathSchema = z.string().min(1).brand();
34
+ const overwriteStrategySchema = z.enum([
35
+ "overwrite",
36
+ "skip",
37
+ "prompt"
38
+ ]);
39
+ const fileActionSchema = z.enum([
40
+ "copied",
41
+ "created",
42
+ "overwritten",
43
+ "skipped",
44
+ "skipped_ignored"
45
+ ]);
46
+ const fileOperationResultSchema = z.object({
47
+ action: fileActionSchema,
48
+ path: z.string()
49
+ });
50
+ const moduleSchema = z.object({
51
+ id: z.string(),
52
+ name: z.string(),
53
+ description: z.string(),
54
+ setupDescription: z.string().optional(),
55
+ patterns: z.array(z.string())
56
+ });
57
+ const configSchema = z.object({
58
+ version: z.string(),
59
+ installedAt: z.string().datetime({ offset: true }),
60
+ modules: z.array(z.string()),
61
+ source: z.object({
62
+ owner: z.string(),
63
+ repo: z.string(),
64
+ ref: z.string().optional()
65
+ }),
66
+ excludePatterns: z.array(z.string()).optional(),
67
+ baseRef: z.string().optional(),
68
+ baseHashes: z.record(z.string(), z.string()).optional(),
69
+ pendingMerge: z.object({
70
+ conflicts: z.array(z.string()),
71
+ templateHashes: z.record(z.string(), z.string()),
72
+ latestRef: z.string().optional()
73
+ }).optional()
74
+ });
75
+ const answersSchema = z.object({
76
+ modules: z.array(z.string()).min(1, "少なくとも1つのモジュールを選択してください"),
77
+ overwriteStrategy: overwriteStrategySchema
78
+ });
79
+ const diffTypeSchema = z.enum([
80
+ "added",
81
+ "modified",
82
+ "deleted",
83
+ "unchanged"
84
+ ]);
85
+ const fileDiffSchema = z.object({
86
+ path: z.string(),
87
+ type: diffTypeSchema,
88
+ localContent: z.string().optional(),
89
+ templateContent: z.string().optional()
90
+ });
91
+ const diffResultSchema = z.object({
92
+ files: z.array(fileDiffSchema),
93
+ summary: z.object({
94
+ added: z.number(),
95
+ modified: z.number(),
96
+ deleted: z.number(),
97
+ unchanged: z.number()
98
+ })
99
+ });
100
+ const prResultSchema = z.object({
101
+ url: z.string(),
102
+ number: z.number(),
103
+ branch: z.string()
104
+ });
105
+ /** マニフェスト内のファイルエントリ */
106
+ const manifestFileSchema = z.object({
107
+ path: z.string(),
108
+ type: diffTypeSchema,
109
+ selected: z.boolean(),
110
+ lines_added: z.number().optional(),
111
+ lines_removed: z.number().optional()
112
+ });
113
+ /** マニフェスト内の未追跡ファイルエントリ */
114
+ const manifestUntrackedFileSchema = z.object({
115
+ path: z.string(),
116
+ module_id: z.string(),
117
+ selected: z.boolean()
118
+ });
119
+ /** GitHub設定 */
120
+ const manifestGitHubSchema = z.object({ token: z.string().optional() });
121
+ /** PR設定 */
122
+ const manifestPrSchema = z.object({
123
+ title: z.string(),
124
+ body: z.string().optional()
125
+ });
126
+ /** サマリー(読み取り専用) */
127
+ const manifestSummarySchema = z.object({
128
+ added: z.number(),
129
+ modified: z.number(),
130
+ deleted: z.number()
131
+ });
132
+ /** Push マニフェスト全体 */
133
+ const pushManifestSchema = z.object({
134
+ version: z.literal(1),
135
+ generated_at: z.string().datetime({ offset: true }),
136
+ github: manifestGitHubSchema,
137
+ pr: manifestPrSchema,
138
+ files: z.array(manifestFileSchema),
139
+ untracked_files: z.array(manifestUntrackedFileSchema).optional(),
140
+ summary: manifestSummarySchema
141
+ });
142
+
143
+ //#endregion
144
+ //#region src/utils/manifest.ts
145
+ /** マニフェストファイル名 */
146
+ const MANIFEST_FILENAME = ".devenv-push-manifest.yaml";
147
+ /** マニフェストファイルのヘッダーコメント */
148
+ const MANIFEST_HEADER = `# ================================================================================
149
+ # ziku push manifest
150
+ # ================================================================================
151
+ #
152
+ # This file was generated by \`ziku push --prepare\`
153
+ #
154
+ # USAGE (for AI agents and humans):
155
+ # 1. Review and edit this file:
156
+ # - Set \`selected: true\` for files you want to include in the PR
157
+ # - Set \`selected: false\` for files you want to exclude
158
+ # - Edit \`pr.title\` and \`pr.body\` as needed
159
+ # 2. Run \`ziku push --execute\` to create the PR
160
+ #
161
+ # GITHUB TOKEN:
162
+ # Set the GITHUB_TOKEN or GH_TOKEN environment variable, or
163
+ # add the token directly in the github.token field below.
164
+ #
165
+ # ================================================================================
166
+
167
+ `;
168
+ /**
169
+ * 差分の行数を計算(localContent と templateContent から)
170
+ */
171
+ function countDiffLines(localContent, templateContent, type) {
172
+ if (type === "added" && localContent) return {
173
+ added: localContent.split("\n").length,
174
+ removed: 0
175
+ };
176
+ if (type === "deleted" && templateContent) return {
177
+ added: 0,
178
+ removed: templateContent.split("\n").length
179
+ };
180
+ if (type === "modified" && localContent && templateContent) {
181
+ const localLines = localContent.split("\n");
182
+ const templateLines = templateContent.split("\n");
183
+ const added = Math.max(0, localLines.length - templateLines.length);
184
+ const removed = Math.max(0, templateLines.length - localLines.length);
185
+ if (added === 0 && removed === 0 && localContent !== templateContent) return {
186
+ added: 1,
187
+ removed: 1
188
+ };
189
+ return {
190
+ added,
191
+ removed
192
+ };
193
+ }
194
+ return {
195
+ added: 0,
196
+ removed: 0
197
+ };
198
+ }
199
+ /**
200
+ * マニフェストファイルを生成
201
+ */
202
+ function generateManifest(options) {
203
+ const { diff, pushableFiles, untrackedByFolder, defaultTitle, modulesFileChange } = options;
204
+ return {
205
+ version: 1,
206
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
207
+ github: { token: void 0 },
208
+ pr: {
209
+ title: defaultTitle || "feat: update template configuration",
210
+ body: void 0
211
+ },
212
+ files: [...pushableFiles.map((file) => {
213
+ const { added, removed } = countDiffLines(file.localContent, file.templateContent, file.type);
214
+ return {
215
+ path: file.path,
216
+ type: file.type,
217
+ selected: true,
218
+ lines_added: added > 0 ? added : void 0,
219
+ lines_removed: removed > 0 ? removed : void 0
220
+ };
221
+ }), ...modulesFileChange ? [{
222
+ path: modulesFileChange,
223
+ type: "modified",
224
+ selected: true
225
+ }] : []],
226
+ untracked_files: untrackedByFolder?.flatMap((folder) => folder.files.map((file) => ({
227
+ path: file.path,
228
+ module_id: file.moduleId,
229
+ selected: false
230
+ }))),
231
+ summary: {
232
+ added: diff.summary.added,
233
+ modified: diff.summary.modified,
234
+ deleted: diff.summary.deleted
235
+ }
236
+ };
237
+ }
238
+ /**
239
+ * マニフェストをYAML文字列に変換
240
+ */
241
+ function serializeManifest(manifest) {
242
+ return MANIFEST_HEADER + stringify(manifest, {
243
+ indent: 2,
244
+ lineWidth: 120
245
+ });
246
+ }
247
+ /**
248
+ * マニフェストファイルを保存
249
+ */
250
+ async function saveManifest(targetDir, manifest) {
251
+ const manifestPath = join(targetDir, MANIFEST_FILENAME);
252
+ await writeFile(manifestPath, serializeManifest(manifest), "utf-8");
253
+ return manifestPath;
254
+ }
255
+ /**
256
+ * マニフェストファイルを読み込み
257
+ */
258
+ async function loadManifest(targetDir) {
259
+ const manifestPath = join(targetDir, MANIFEST_FILENAME);
260
+ if (!existsSync(manifestPath)) throw new Error(`Manifest file not found: ${manifestPath}\nRun 'ziku push --prepare' first to generate the manifest.`);
261
+ const parsed = parse(await readFile(manifestPath, "utf-8"));
262
+ const result = pushManifestSchema.safeParse(parsed);
263
+ if (!result.success) throw new Error(`Invalid manifest file format: ${result.error.message}`);
264
+ return result.data;
265
+ }
266
+ /**
267
+ * マニフェストファイルを削除する。
268
+ *
269
+ * 背景: --execute でPR作成が成功した後、マニフェストはもう不要なので自動的にクリーンアップする。
270
+ * ファイルが存在しない場合は何もしない。
271
+ */
272
+ async function deleteManifest(targetDir) {
273
+ const manifestPath = join(targetDir, MANIFEST_FILENAME);
274
+ if (existsSync(manifestPath)) await rm(manifestPath);
275
+ }
276
+ /**
277
+ * 選択されたファイルのパスを取得
278
+ */
279
+ function getSelectedFilePaths(manifest) {
280
+ return manifest.files.filter((f) => f.selected).map((f) => f.path);
281
+ }
282
+ /**
283
+ * 選択された未追跡ファイルを取得(moduleId別)
284
+ */
285
+ function getSelectedUntrackedFiles(manifest) {
286
+ const result = /* @__PURE__ */ new Map();
287
+ if (!manifest.untracked_files) return result;
288
+ for (const file of manifest.untracked_files) if (file.selected) {
289
+ const existing = result.get(file.module_id) || [];
290
+ existing.push(file.path);
291
+ result.set(file.module_id, existing);
292
+ }
293
+ return result;
294
+ }
295
+
296
+ //#endregion
297
+ //#region src/docs/ai-guide.ts
298
+ /**
299
+ * AI Agent Guide
300
+ *
301
+ * This file serves as the single source of truth for AI-facing documentation.
302
+ * It is used both for:
303
+ * - `ziku ai-docs` command output
304
+ * - README.md "For AI Agents" section generation
305
+ */
306
+ /**
307
+ * Generate the complete AI agent guide as markdown
308
+ */
309
+ function generateAiGuide() {
310
+ return getDocSections().map((s) => `## ${s.title}\n\n${s.content}`).join("\n\n");
311
+ }
312
+ /**
313
+ * Generate the AI agent guide with header for CLI output
314
+ */
315
+ function generateAiGuideWithHeader() {
316
+ return `# ziku v${version$2} - AI Agent Guide
317
+
318
+ This guide explains how AI coding agents (Claude Code, Cursor, etc.) can use this tool effectively.
319
+
320
+ ` + generateAiGuide();
321
+ }
322
+ /**
323
+ * Get individual documentation sections
324
+ * Used by both CLI output and README generation
325
+ */
326
+ function getDocSections() {
327
+ return [
328
+ {
329
+ title: "Quick Reference",
330
+ content: `\`\`\`bash
331
+ # Non-interactive init for AI agents
332
+ npx ziku init --yes # All modules, overwrite strategy
333
+ npx ziku init --modules .,devcontainer # Specific modules only
334
+ npx ziku init --modules .github -s skip # Specific modules with skip strategy
335
+ npx ziku init --yes --overwrite-strategy skip # All modules with skip strategy
336
+
337
+ # Non-interactive push workflow for AI agents (simple)
338
+ npx ziku push --yes --files "path1,path2" -m "feat: ..." # Push specific files only
339
+
340
+ # Non-interactive push workflow for AI agents (manifest-based)
341
+ npx ziku push --prepare # Generate manifest
342
+ # Edit ${MANIFEST_FILENAME} # Select files
343
+ npx ziku push --execute # Create PR
344
+
345
+ # Add files to tracking (non-interactive)
346
+ npx ziku track ".cloud/rules/*.md" # Add pattern (auto-detect module)
347
+ npx ziku track ".cloud/config.json" -m .cloud # Specify module explicitly
348
+ npx ziku track --list # List tracked modules/patterns
349
+
350
+ # Show differences and detect untracked files
351
+ npx ziku diff # Show differences (also reports untracked files)
352
+
353
+ # Other commands
354
+ npx ziku init [dir] # Apply template (interactive)
355
+ npx ziku ai-docs # Show this guide
356
+ \`\`\``
357
+ },
358
+ {
359
+ title: "Init Command for AI Agents",
360
+ content: `The \`init\` command supports non-interactive options for AI agents:
361
+
362
+ ### Options
363
+
364
+ | Option | Alias | Description |
365
+ |--------|-------|-------------|
366
+ | \`--yes\` | \`-y\` | Select all modules with overwrite strategy |
367
+ | \`--modules <ids>\` | \`-m\` | Comma-separated module IDs to apply |
368
+ | \`--overwrite-strategy <strategy>\` | \`-s\` | Strategy for existing files: \`overwrite\`, \`skip\`, or \`prompt\` |
369
+ | \`--force\` | | Force overwrite (overrides strategy to \`overwrite\`) |
370
+
371
+ ### Examples
372
+
373
+ \`\`\`bash
374
+ # Apply only specific modules (skips module selection prompt)
375
+ npx ziku init --modules .github,.claude
376
+
377
+ # Apply specific modules and skip existing files
378
+ npx ziku init --modules devcontainer -s skip
379
+
380
+ # Apply all modules but skip existing files
381
+ npx ziku init --yes --overwrite-strategy skip
382
+
383
+ # Re-init when .devenv.json exists, replacing only specific modules
384
+ npx ziku init --modules . -s overwrite
385
+ \`\`\`
386
+
387
+ ### Behavior
388
+
389
+ - \`--modules\` or \`--yes\`: Skips the module selection prompt entirely
390
+ - \`--overwrite-strategy\`: Sets how to handle existing files (default: \`overwrite\` in non-interactive mode)
391
+ - When neither is provided, interactive prompts are shown
392
+ - \`.devenv.json\` is always updated regardless of strategy`
393
+ },
394
+ {
395
+ title: "Important: Untracked Files and the Track Command",
396
+ content: `**The \`push\` and \`diff\` commands only operate on files that are in the sync whitelist (tracked patterns).**
397
+ If you create new files or directories that don't match any existing pattern, they will appear as **untracked** and will NOT be included in diffs or push operations.
398
+
399
+ To sync these files to the template, you **must** first add them to tracking:
400
+
401
+ \`\`\`bash
402
+ # 1. Check for untracked files
403
+ npx ziku diff
404
+
405
+ # 2. Add untracked files to the whitelist
406
+ npx ziku track "<file-or-glob-pattern>"
407
+
408
+ # 3. Now push will include these files
409
+ npx ziku push --prepare
410
+ \`\`\`
411
+
412
+ **Key points:**
413
+ - \`diff\` will report untracked files and suggest using \`track\`
414
+ - \`push --prepare\` will list untracked files in the manifest (with \`selected: false\` by default)
415
+ - \`track\` is non-interactive and designed for AI agents — no prompts required
416
+ - After running \`track\`, re-run \`push --prepare\` to include the newly tracked files`
417
+ },
418
+ {
419
+ title: "Push Workflow for AI Agents",
420
+ content: `When contributing template improvements, use one of these workflows:
421
+
422
+ ### Simple: Direct Push with \`--files\`
423
+
424
+ For quick, targeted pushes where you know exactly which files to include:
425
+
426
+ \`\`\`bash
427
+ # Push only specific files (non-interactive, no manifest needed)
428
+ npx ziku push --yes --files ".claude/statusline.sh,.claude/settings.json" -m "feat: add statusline"
429
+ \`\`\`
430
+
431
+ - \`--files\`: Comma-separated list of file paths to include (only files with actual changes are eligible)
432
+ - \`--yes\`: Skips confirmation prompts
433
+ - \`-m\`: Sets the PR title
434
+ - Files not found in pushable changes will be warned but won't block the push
435
+ - This is the recommended approach for AI agents when you know which files to push
436
+
437
+ ### Manifest-based: \`--prepare\` + \`--execute\`
438
+
439
+ For complex pushes where you need to review changes first:
440
+
441
+ ### Phase 1: Prepare
442
+
443
+ \`\`\`bash
444
+ npx ziku push --prepare
445
+ \`\`\`
446
+
447
+ This generates \`${MANIFEST_FILENAME}\` containing:
448
+ - List of changed files with \`selected: true/false\`
449
+ - PR title and body fields
450
+ - Summary of changes
451
+
452
+ ### Phase 2: Edit Manifest
453
+
454
+ Edit the generated \`${MANIFEST_FILENAME}\`:
455
+
456
+ \`\`\`yaml
457
+ pr:
458
+ title: "feat: add new workflow for CI"
459
+ body: |
460
+ ## Summary
461
+ Added new CI workflow for automated testing.
462
+
463
+ ## Changes
464
+ - Added .github/workflows/ci.yml
465
+
466
+ files:
467
+ - path: .github/workflows/ci.yml
468
+ type: added
469
+ selected: true # Include this file
470
+ - path: .github/labeler.yml
471
+ type: modified
472
+ selected: false # Exclude this file
473
+ \`\`\`
474
+
475
+ ### Phase 3: Execute
476
+
477
+ \`\`\`bash
478
+ # Set GitHub token (required)
479
+ export GITHUB_TOKEN="your-token"
480
+
481
+ # Create the PR
482
+ npx ziku push --execute
483
+ \`\`\``
484
+ },
485
+ {
486
+ title: "Manifest File Reference",
487
+ content: `The manifest file (\`${MANIFEST_FILENAME}\`) structure:
488
+
489
+ | Field | Description |
490
+ |-------|-------------|
491
+ | \`version\` | Manifest format version (always \`1\`) |
492
+ | \`generated_at\` | ISO 8601 timestamp |
493
+ | \`github.token\` | GitHub token (prefer env var) |
494
+ | \`pr.title\` | PR title (editable) |
495
+ | \`pr.body\` | PR description (editable) |
496
+ | \`files[].path\` | File path |
497
+ | \`files[].type\` | \`added\` / \`modified\` / \`deleted\` |
498
+ | \`files[].selected\` | Include in PR (\`true\`/\`false\`) |
499
+ | \`untracked_files[]\` | Files outside whitelist (default: \`selected: false\`) |
500
+ | \`summary\` | Change statistics |`
501
+ },
502
+ {
503
+ title: "Environment Variables",
504
+ content: `| Variable | Description |
505
+ |----------|-------------|
506
+ | \`GITHUB_TOKEN\` | GitHub personal access token (required for push) |
507
+ | \`GH_TOKEN\` | Alternative to GITHUB_TOKEN |
508
+
509
+ The token needs \`repo\` scope for creating PRs.`
510
+ },
511
+ {
512
+ title: "Track Command for AI Agents",
513
+ content: `The \`track\` command allows AI agents to add files or patterns to the sync whitelist non-interactively.
514
+ This is useful when you create new files or directories that should be part of the template.
515
+
516
+ ### Add patterns to an existing module
517
+
518
+ \`\`\`bash
519
+ # Auto-detects module from path (.claude module)
520
+ npx ziku track ".claude/commands/*.md"
521
+
522
+ # Explicit module
523
+ npx ziku track ".devcontainer/new-script.sh" --module .devcontainer
524
+ \`\`\`
525
+
526
+ ### Create a new module with patterns
527
+
528
+ When the module doesn't exist yet, it is automatically created:
529
+
530
+ \`\`\`bash
531
+ # Creates ".cloud" module and adds the pattern
532
+ npx ziku track ".cloud/rules/*.md"
533
+
534
+ # With custom name and description
535
+ npx ziku track ".cloud/rules/*.md" \\
536
+ --module .cloud \\
537
+ --name "Cloud Rules" \\
538
+ --description "Cloud configuration and rule files"
539
+ \`\`\`
540
+
541
+ ### List current tracking configuration
542
+
543
+ \`\`\`bash
544
+ npx ziku track --list
545
+ \`\`\`
546
+
547
+ ### Options
548
+
549
+ | Option | Alias | Description |
550
+ |--------|-------|-------------|
551
+ | \`--module <id>\` | \`-m\` | Module ID to add patterns to (auto-detected if omitted) |
552
+ | \`--name <name>\` | | Module display name (for new modules) |
553
+ | \`--description <desc>\` | | Module description (for new modules) |
554
+ | \`--dir <path>\` | \`-d\` | Project directory (default: current directory) |
555
+ | \`--list\` | \`-l\` | List all tracked modules and patterns |`
556
+ },
557
+ {
558
+ title: "Best Practices for AI Agents",
559
+ content: `1. **Use \`--modules\` and \`--overwrite-strategy\`** for granular non-interactive init (e.g., \`init --modules .github,.claude -s skip\`)
560
+ 2. **Use \`--files\` for simple pushes** — specify exactly which files to include (e.g., \`push --yes --files "path1,path2" -m "feat: ..."\`)
561
+ 3. **Use \`--prepare\` then \`--execute\`** for complex pushes where you need to review the full diff first
562
+ 4. **Review the diff first** with \`npx ziku diff\` — this also reports untracked files
563
+ 5. **Check for untracked files** — if \`diff\` or \`push --prepare\` reports untracked files, use \`track\` to add them before pushing
564
+ 6. **Use \`track\` command** to add new files to the sync whitelist (non-interactive, no prompts)
565
+ 7. **Set meaningful PR titles** that follow conventional commits (e.g., \`feat:\`, \`fix:\`, \`docs:\`)
566
+ 8. **Deselect unrelated changes** by using \`--files\` or setting \`selected: false\` in manifest
567
+ 9. **Use environment variables** for tokens instead of hardcoding in manifest`
568
+ },
569
+ {
570
+ title: "Track + Push: Adding New Files to Template",
571
+ content: `When you create new files that should be part of the template, use \`track\` then \`push\`.
572
+ The \`push\` command **automatically detects** changes made by \`track\` to the local \`modules.jsonc\`
573
+ and includes them in the PR.
574
+
575
+ ### Workflow
576
+
577
+ \`\`\`bash
578
+ # 1. Create files locally
579
+ mkdir -p .cloud/rules
580
+ echo "naming conventions..." > .cloud/rules/naming.md
581
+
582
+ # 2. Add to tracking (updates local .devenv/modules.jsonc)
583
+ npx ziku track ".cloud/rules/*.md"
584
+
585
+ # 3. Push detects local module additions automatically
586
+ npx ziku push --prepare
587
+ \`\`\`
588
+
589
+ ### What happens internally
590
+
591
+ 1. \`track\` adds patterns to **local** \`.devenv/modules.jsonc\` (creates new modules if needed)
592
+ 2. \`push --prepare\` downloads the template and compares its \`modules.jsonc\` with local
593
+ 3. New modules and patterns are detected and merged into the detection scope
594
+ 4. The manifest includes:
595
+ - New files (\`.cloud/rules/naming.md\`) as \`type: added\` with \`selected: true\`
596
+ - \`.devenv/modules.jsonc\` as \`type: modified\` with \`selected: true\`
597
+ 5. \`push --execute\` creates a PR that adds both the files AND the updated module definitions
598
+
599
+ ### Key behavior
600
+
601
+ - **No need to manually edit modules.jsonc** — \`track\` handles it
602
+ - **push detects local changes** — no extra flags needed; just run \`push --prepare\` after \`track\`
603
+ - **New modules are auto-created** — if \`.cloud\` doesn't exist in the template, it's added
604
+ - **Existing module patterns can also be extended** — \`track\` works for both new and existing modules`
605
+ }
606
+ ];
607
+ }
608
+
609
+ //#endregion
610
+ //#region src/commands/ai-docs.ts
611
+ const aiDocsCommand = defineCommand({
612
+ meta: {
613
+ name: "ai-docs",
614
+ description: "Show documentation for AI coding agents"
615
+ },
616
+ args: {},
617
+ run() {
618
+ console.log(generateAiGuideWithHeader());
619
+ }
620
+ });
621
+
622
+ //#endregion
623
+ //#region src/errors.ts
624
+ /**
625
+ * ユーザー向けエラー。hint でリカバリ方法を提示する。
626
+ *
627
+ * 背景: process.exit(1) が各コマンドに散在していたのを解消するため導入。
628
+ * 各コマンドは BermError を throw し、cli.ts のトップレベルで catch して
629
+ * @clack/prompts の log.error() で統一的に表示する。
630
+ * process.exit(1) は cli.ts の 1 箇所のみに制限。
631
+ */
632
+ var BermError = class extends Error {
633
+ constructor(message, hint) {
634
+ super(message);
635
+ this.hint = hint;
636
+ this.name = "BermError";
637
+ }
638
+ };
639
+
640
+ //#endregion
641
+ //#region src/modules/loader.ts
642
+ const MODULES_FILE = ".devenv/modules.jsonc";
643
+ /**
644
+ * modules.jsonc のスキーマ
645
+ */
646
+ const modulesFileSchema = z.object({
647
+ $schema: z.string().optional(),
648
+ modules: z.array(moduleSchema)
649
+ });
650
+ /**
651
+ * modules.jsonc ファイルを読み込み
652
+ */
653
+ async function loadModulesFile(baseDir) {
654
+ const filePath = join(baseDir, MODULES_FILE);
655
+ if (!existsSync(filePath)) throw new Error(`${MODULES_FILE} が見つかりません: ${filePath}`);
656
+ const content = await readFile(filePath, "utf-8");
657
+ const parsed = parse$1(content);
658
+ return {
659
+ modules: modulesFileSchema.parse(parsed).modules,
660
+ rawContent: content
661
+ };
662
+ }
663
+ /**
664
+ * modules.jsonc にパターンを追加(コメントを保持)
665
+ * @returns 更新後の JSONC 文字列
666
+ */
667
+ function addPatternToModulesFile(rawContent, moduleId, patterns) {
668
+ const parsed = parse$1(rawContent);
669
+ const moduleIndex = parsed.modules.findIndex((m) => m.id === moduleId);
670
+ if (moduleIndex === -1) throw new Error(`モジュール ${moduleId} が見つかりません`);
671
+ const existingPatterns = parsed.modules[moduleIndex].patterns;
672
+ const newPatterns = patterns.filter((p$1) => !existingPatterns.includes(p$1));
673
+ if (newPatterns.length === 0) return rawContent;
674
+ const updatedPatterns = [...existingPatterns, ...newPatterns];
675
+ return applyEdits(rawContent, modify(rawContent, [
676
+ "modules",
677
+ moduleIndex,
678
+ "patterns"
679
+ ], updatedPatterns, { formattingOptions: {
680
+ tabSize: 2,
681
+ insertSpaces: true
682
+ } }));
683
+ }
684
+ /**
685
+ * modules.jsonc にパターンを追加(モジュールが存在しない場合は作成)
686
+ * @returns 更新後の JSONC 文字列
687
+ */
688
+ function addPatternToModulesFileWithCreate(rawContent, moduleId, patterns, moduleOptions) {
689
+ const parsed = parse$1(rawContent);
690
+ if (parsed.modules.findIndex((m) => m.id === moduleId) !== -1) return addPatternToModulesFile(rawContent, moduleId, patterns);
691
+ const newModule = {
692
+ id: moduleId,
693
+ name: moduleOptions?.name || (moduleId === "." ? "Root" : moduleId.replace(/^\./, "").replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())),
694
+ description: moduleOptions?.description || `Files in ${moduleId === "." ? "root" : moduleId} directory`,
695
+ patterns
696
+ };
697
+ return applyEdits(rawContent, modify(rawContent, ["modules"], [...parsed.modules, newModule], { formattingOptions: {
698
+ tabSize: 2,
699
+ insertSpaces: true
700
+ } }));
701
+ }
702
+ /**
703
+ * modules.jsonc を保存
704
+ */
705
+ async function saveModulesFile(baseDir, content) {
706
+ await writeFile(join(baseDir, MODULES_FILE), content);
707
+ }
708
+ /**
709
+ * モジュールファイルのパスを取得
710
+ */
711
+ function getModulesFilePath(baseDir) {
712
+ return join(baseDir, MODULES_FILE);
713
+ }
714
+ /**
715
+ * modules.jsonc が存在するか確認
716
+ */
717
+ function modulesFileExists(baseDir) {
718
+ return existsSync(join(baseDir, MODULES_FILE));
719
+ }
720
+
721
+ //#endregion
722
+ //#region src/modules/index.ts
723
+ /**
724
+ * デフォルトモジュール(modules.jsonc がない場合のフォールバック)
725
+ * モジュール ID = ディレクトリパス(ルートは ".")
726
+ */
727
+ const defaultModules = [
728
+ {
729
+ id: ".",
730
+ name: "ルート設定",
731
+ description: "MCP、mise などのルート設定ファイル",
732
+ setupDescription: "プロジェクトルートの設定ファイルが適用されます",
733
+ patterns: [".mcp.json", ".mise.toml"]
734
+ },
735
+ {
736
+ id: ".devcontainer",
737
+ name: "DevContainer",
738
+ description: "VS Code DevContainer、Docker-in-Docker",
739
+ setupDescription: "VS Code で DevContainer を開くと自動でセットアップされます",
740
+ patterns: [
741
+ ".devcontainer/devcontainer.json",
742
+ ".devcontainer/.gitignore",
743
+ ".devcontainer/setup-*.sh",
744
+ ".devcontainer/test-*.sh",
745
+ ".devcontainer/.env.devcontainer.example"
746
+ ]
747
+ },
748
+ {
749
+ id: ".github",
750
+ name: "GitHub",
751
+ description: "GitHub Actions、labeler ワークフロー",
752
+ setupDescription: "PR 作成時に自動でラベル付け、Issue リンクが行われます",
753
+ patterns: [
754
+ ".github/workflows/issue-link.yml",
755
+ ".github/workflows/label.yml",
756
+ ".github/labeler.yml"
757
+ ]
758
+ },
759
+ {
760
+ id: ".claude",
761
+ name: "Claude",
762
+ description: "Claude Code のプロジェクト共通設定",
763
+ setupDescription: "Claude Code のプロジェクト設定が適用されます",
764
+ patterns: [".claude/settings.json"]
765
+ }
766
+ ];
767
+ /**
768
+ * モジュールリストから ID でモジュールを取得
769
+ */
770
+ function getModuleById(id, moduleList = defaultModules) {
771
+ return moduleList.find((m) => m.id === id);
772
+ }
773
+ /**
774
+ * 指定モジュールIDのパターンを取得
775
+ */
776
+ function getPatternsByModuleIds(moduleIds, moduleList = defaultModules) {
777
+ return moduleList.filter((m) => moduleIds.includes(m.id)).flatMap((m) => m.patterns);
778
+ }
779
+
780
+ //#endregion
781
+ //#region src/utils/gitignore.ts
782
+ /**
783
+ * 複数ディレクトリの .gitignore をマージして読み込み
784
+ * ローカルとテンプレートの両方の .gitignore を考慮することで、
785
+ * クレデンシャル等の機密情報の誤流出を防止する
786
+ */
787
+ async function loadMergedGitignore(dirs) {
788
+ const ig = ignore();
789
+ for (const dir of dirs) {
790
+ const gitignorePath = join(dir, ".gitignore");
791
+ if (existsSync(gitignorePath)) {
792
+ const content = await readFile(gitignorePath, "utf-8");
793
+ ig.add(content);
794
+ }
795
+ }
796
+ return ig;
797
+ }
798
+ /**
799
+ * gitignore ルールでファイルをフィルタリング
800
+ * gitignore に該当しないファイルのみを返す
801
+ */
802
+ function filterByGitignore(files, ig) {
803
+ return ig.filter(files);
804
+ }
805
+ function separateByGitignore(files, ig) {
806
+ const tracked = [];
807
+ const ignored = [];
808
+ for (const file of files) if (ig.ignores(file)) ignored.push(file);
809
+ else tracked.push(file);
810
+ return {
811
+ tracked,
812
+ ignored
813
+ };
814
+ }
815
+
816
+ //#endregion
817
+ //#region src/utils/patterns.ts
818
+ /**
819
+ * パターンにマッチするファイル一覧を取得
820
+ */
821
+ function resolvePatterns(baseDir, patterns) {
822
+ return globSync(patterns, {
823
+ cwd: baseDir,
824
+ dot: true,
825
+ onlyFiles: true
826
+ }).sort();
827
+ }
828
+ /**
829
+ * ファイルがパターンにマッチするか判定
830
+ */
831
+ function matchesPatterns(filePath, patterns) {
832
+ for (const pattern of patterns) {
833
+ if (filePath === pattern) return true;
834
+ if (isGlobPattern(pattern)) {
835
+ if (globSync([pattern], {
836
+ cwd: ".",
837
+ expandDirectories: false
838
+ }).includes(filePath)) return true;
839
+ }
840
+ }
841
+ return false;
842
+ }
843
+ /**
844
+ * パターンが glob パターンかどうかを判定
845
+ */
846
+ function isGlobPattern(pattern) {
847
+ return /[*?[\]{}!]/.test(pattern);
848
+ }
849
+ /**
850
+ * 設定からモジュールの有効パターンを取得
851
+ */
852
+ function getEffectivePatterns(_moduleId, modulePatterns, config) {
853
+ let patterns = [...modulePatterns];
854
+ if (config?.excludePatterns) {
855
+ const excludePatterns = config.excludePatterns;
856
+ patterns = patterns.filter((p$1) => !matchesPatterns(p$1, excludePatterns));
857
+ }
858
+ return patterns;
859
+ }
860
+
861
+ //#endregion
862
+ //#region src/ui/renderer.ts
863
+ /**
864
+ * 統一出力インターフェース — @clack/prompts のラッパー
865
+ *
866
+ * 背景: showHeader(), box(), showNextSteps(), log, withSpinner() 等の
867
+ * 散在した UI 関数を @clack/prompts ベースで統一するために導入。
868
+ * 全コマンドはこのモジュール経由で出力する。
869
+ *
870
+ * 削除条件: ziku が別の UI フレームワーク(ink 等)に移行する場合。
871
+ */
872
+ const version$1 = "0.20.0";
873
+ /** CLI の開始表示 */
874
+ function intro(command) {
875
+ const title = command ? `ziku ${command}` : "ziku";
876
+ p.intro(`${pc$1.bgCyan(pc$1.black(` ${title} `))} ${pc$1.dim(`v${version$1}`)}`);
877
+ }
878
+ /** CLI の終了表示 */
879
+ function outro(message) {
880
+ p.outro(message);
881
+ }
882
+ /** 構造化ログ — @clack/prompts の log を re-export */
883
+ const log = {
884
+ info: (message) => p.log.info(message),
885
+ success: (message) => p.log.success(message),
886
+ warn: (message) => p.log.warn(message),
887
+ error: (message) => p.log.error(message),
888
+ step: (message) => p.log.step(message),
889
+ message: (message) => p.log.message(message)
890
+ };
891
+ /** スピナー付きで非同期タスクを実行 */
892
+ async function withSpinner(message, task) {
893
+ const s = p.spinner();
894
+ s.start(message);
895
+ try {
896
+ const result = await task();
897
+ s.stop(message);
898
+ return result;
899
+ } catch (error) {
900
+ s.stop(pc$1.red(`Failed: ${message}`));
901
+ throw error;
902
+ }
903
+ }
904
+ /** ファイル操作結果を表示(init コマンド用) */
905
+ function logFileResults(results) {
906
+ let added = 0;
907
+ let updated = 0;
908
+ let skipped = 0;
909
+ const lines = [];
910
+ for (const r of results) switch (r.action) {
911
+ case "copied":
912
+ case "created":
913
+ lines.push(`${pc$1.green("+")} ${r.path}`);
914
+ added++;
915
+ break;
916
+ case "overwritten":
917
+ lines.push(`${pc$1.yellow("~")} ${r.path}`);
918
+ updated++;
919
+ break;
920
+ default:
921
+ lines.push(`${pc$1.dim("-")} ${pc$1.dim(r.path)}`);
922
+ skipped++;
923
+ break;
924
+ }
925
+ const summary = [
926
+ added > 0 ? pc$1.green(`${added} added`) : null,
927
+ updated > 0 ? pc$1.yellow(`${updated} updated`) : null,
928
+ skipped > 0 ? pc$1.dim(`${skipped} skipped`) : null
929
+ ].filter(Boolean).join(", ");
930
+ p.log.message([
931
+ ...lines,
932
+ "",
933
+ summary
934
+ ].join("\n"));
935
+ return {
936
+ added,
937
+ updated,
938
+ skipped
939
+ };
940
+ }
941
+ /** diff サマリーを表示(push/diff コマンド用) */
942
+ function logDiffSummary(files) {
943
+ const changed = files.filter((f) => f.type !== "unchanged");
944
+ if (changed.length === 0) {
945
+ p.log.info("No changes detected");
946
+ return;
947
+ }
948
+ const lines = changed.map((f) => {
949
+ switch (f.type) {
950
+ case "added": return `${pc$1.green("+")} ${pc$1.green(f.path)}`;
951
+ case "modified": return `${pc$1.yellow("~")} ${pc$1.yellow(f.path)}`;
952
+ case "deleted": return `${pc$1.red("-")} ${pc$1.red(f.path)}`;
953
+ default: return ` ${pc$1.dim(f.path)}`;
954
+ }
955
+ });
956
+ const summary = files.reduce((acc, f) => {
957
+ if (f.type === "added") acc.added++;
958
+ else if (f.type === "modified") acc.modified++;
959
+ else if (f.type === "deleted") acc.deleted++;
960
+ return acc;
961
+ }, {
962
+ added: 0,
963
+ modified: 0,
964
+ deleted: 0
965
+ });
966
+ const summaryParts = [
967
+ summary.added > 0 ? pc$1.green(`+${summary.added} added`) : null,
968
+ summary.modified > 0 ? pc$1.yellow(`~${summary.modified} modified`) : null,
969
+ summary.deleted > 0 ? pc$1.red(`-${summary.deleted} deleted`) : null
970
+ ].filter(Boolean).join(pc$1.dim(" | "));
971
+ p.log.message([
972
+ ...lines,
973
+ "",
974
+ summaryParts
975
+ ].join("\n"));
976
+ }
977
+ /** BermError を整形表示 */
978
+ function logBermError(error) {
979
+ p.log.error(error.message);
980
+ if (error.hint) p.log.message(pc$1.dim(error.hint));
981
+ }
982
+
983
+ //#endregion
984
+ //#region src/utils/diff.ts
985
+ /**
986
+ * ローカルとテンプレート間の差分を検出
987
+ */
988
+ async function detectDiff(options) {
989
+ const { targetDir, templateDir, moduleIds, config, moduleList = defaultModules } = options;
990
+ const files = [];
991
+ let added = 0;
992
+ let modified = 0;
993
+ let deleted = 0;
994
+ let unchanged = 0;
995
+ const gitignore = await loadMergedGitignore([targetDir, templateDir]);
996
+ for (const moduleId of moduleIds) {
997
+ const mod = getModuleById(moduleId, moduleList);
998
+ if (!mod) {
999
+ log.warn(`Module "${pc.cyan(moduleId)}" not found`);
1000
+ continue;
1001
+ }
1002
+ const patterns = getEffectivePatterns(moduleId, mod.patterns, config);
1003
+ const templateFiles = filterByGitignore(resolvePatterns(templateDir, patterns), gitignore);
1004
+ const localFiles = filterByGitignore(resolvePatterns(targetDir, patterns), gitignore);
1005
+ const allFiles = new Set([...templateFiles, ...localFiles]);
1006
+ for (const filePath of allFiles) {
1007
+ const localPath = join(targetDir, filePath);
1008
+ const templatePath = join(templateDir, filePath);
1009
+ const localExists = existsSync(localPath);
1010
+ const templateExists = existsSync(templatePath);
1011
+ let type;
1012
+ let localContent;
1013
+ let templateContent;
1014
+ if (localExists) localContent = await readFile(localPath, "utf-8");
1015
+ if (templateExists) templateContent = await readFile(templatePath, "utf-8");
1016
+ if (localExists && templateExists) if (localContent === templateContent) {
1017
+ type = "unchanged";
1018
+ unchanged++;
1019
+ } else {
1020
+ type = "modified";
1021
+ modified++;
1022
+ }
1023
+ else if (localExists && !templateExists) {
1024
+ type = "added";
1025
+ added++;
1026
+ } else {
1027
+ type = "deleted";
1028
+ deleted++;
1029
+ }
1030
+ files.push({
1031
+ path: filePath,
1032
+ type,
1033
+ localContent,
1034
+ templateContent
1035
+ });
1036
+ }
1037
+ }
1038
+ return {
1039
+ files: files.sort((a, b) => a.path.localeCompare(b.path)),
1040
+ summary: {
1041
+ added,
1042
+ modified,
1043
+ deleted,
1044
+ unchanged
1045
+ }
1046
+ };
1047
+ }
1048
+ /**
1049
+ * push 対象のファイルのみをフィルタリング
1050
+ * (ローカルで追加・変更されたファイル)
1051
+ */
1052
+ function getPushableFiles(diff) {
1053
+ return diff.files.filter((f) => f.type === "added" || f.type === "modified");
1054
+ }
1055
+ /**
1056
+ * 差分があるかどうかを判定
1057
+ */
1058
+ function hasDiff(diff) {
1059
+ return diff.summary.added > 0 || diff.summary.modified > 0 || diff.summary.deleted > 0;
1060
+ }
1061
+ /**
1062
+ * FileDiff から unified diff 形式の文字列を生成
1063
+ */
1064
+ function generateUnifiedDiff(fileDiff) {
1065
+ const { path, type, localContent, templateContent } = fileDiff;
1066
+ switch (type) {
1067
+ case "added": return createPatch(path, "", localContent || "", "template", "local");
1068
+ case "modified": return createPatch(path, templateContent || "", localContent || "", "template", "local");
1069
+ default: return "";
1070
+ }
1071
+ }
1072
+
1073
+ //#endregion
1074
+ //#region src/ui/diff-view.ts
1075
+ /**
1076
+ * Diff 表示コンポーネント
1077
+ *
1078
+ * 背景: utils/diff-viewer.ts (682行) を再構築。
1079
+ * cli-highlight を削除し picocolors のみで表示。
1080
+ * readline raw mode のインタラクティブビューアを削除し、
1081
+ * 単純な出力に変更(less にパイプ可能)。
1082
+ * 統計計算・word diff ロジックは維持。
1083
+ *
1084
+ * 削除条件: ziku が TUI フレームワーク(ink 等)に移行する場合。
1085
+ */
1086
+ /** ファイルの差分統計を計算 */
1087
+ function calculateDiffStats(fileDiff) {
1088
+ switch (fileDiff.type) {
1089
+ case "unchanged": return {
1090
+ additions: 0,
1091
+ deletions: 0
1092
+ };
1093
+ case "deleted": return {
1094
+ additions: 0,
1095
+ deletions: fileDiff.templateContent?.split("\n").length ?? 0
1096
+ };
1097
+ case "added": return {
1098
+ additions: fileDiff.localContent?.split("\n").length ?? 0,
1099
+ deletions: 0
1100
+ };
1101
+ case "modified": {
1102
+ const diff = generateUnifiedDiff(fileDiff);
1103
+ let additions = 0;
1104
+ let deletions = 0;
1105
+ for (const line of diff.split("\n")) if (line.startsWith("+") && !line.startsWith("+++")) additions++;
1106
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
1107
+ return {
1108
+ additions,
1109
+ deletions
1110
+ };
1111
+ }
1112
+ }
1113
+ }
1114
+ /** 統計フォーマット (+10 -5) */
1115
+ function formatStats(stats) {
1116
+ const parts = [];
1117
+ if (stats.additions > 0) parts.push(pc.green(`+${stats.additions}`));
1118
+ if (stats.deletions > 0) parts.push(pc.red(`-${stats.deletions}`));
1119
+ return parts.length === 0 ? pc.dim("(no changes)") : parts.join(" ");
1120
+ }
1121
+ /** 単一ファイルの diff を表示 */
1122
+ function renderFileDiff(file) {
1123
+ const stats = calculateDiffStats(file);
1124
+ const typeLabel = file.type === "added" ? pc.green("added") : file.type === "modified" ? pc.yellow("modified") : pc.red("deleted");
1125
+ p.log.step(`${pc.bold(file.path)} ${pc.dim("—")} ${typeLabel} ${formatStats(stats)}`);
1126
+ if (file.type === "unchanged") return;
1127
+ const diff = generateUnifiedDiff(file);
1128
+ if (!diff) return;
1129
+ const rendered = applyWordDiffAndColorize(diff.split("\n").filter((l) => !l.startsWith("Index:") && !l.startsWith("===") && !l.startsWith("---") && !l.startsWith("+++")));
1130
+ p.log.message(rendered.join("\n"));
1131
+ }
1132
+ /**
1133
+ * Diff 行に word diff + 色を適用
1134
+ *
1135
+ * 隣接する deletion/addition ペアを検出し、diffWords で
1136
+ * 変更箇所を背景色でハイライトする。それ以外の行は通常の色付け。
1137
+ */
1138
+ function applyWordDiffAndColorize(lines) {
1139
+ const result = [];
1140
+ let i = 0;
1141
+ while (i < lines.length) {
1142
+ const line = lines[i];
1143
+ if (line.startsWith("-") && !line.startsWith("---") && i + 1 < lines.length && lines[i + 1].startsWith("+") && !lines[i + 1].startsWith("+++")) {
1144
+ const changes = diffWords(line.slice(1), lines[i + 1].slice(1));
1145
+ let oldLine = pc.red("-");
1146
+ let newLine = pc.green("+");
1147
+ for (const change of changes) if (change.added) newLine += pc.bgGreen(pc.black(change.value));
1148
+ else if (change.removed) oldLine += pc.bgRed(pc.white(change.value));
1149
+ else {
1150
+ oldLine += change.value;
1151
+ newLine += change.value;
1152
+ }
1153
+ result.push(oldLine, newLine);
1154
+ i += 2;
1155
+ continue;
1156
+ }
1157
+ if (line.startsWith("@@")) result.push(pc.cyan(line));
1158
+ else if (line.startsWith("+")) result.push(pc.green(line));
1159
+ else if (line.startsWith("-")) result.push(pc.red(line));
1160
+ else result.push(line);
1161
+ i++;
1162
+ }
1163
+ return result;
1164
+ }
1165
+
1166
+ //#endregion
1167
+ //#region src/utils/template.ts
1168
+ const TEMPLATE_SOURCE = "gh:tktcorporation/.github";
1169
+ /**
1170
+ * DevEnvConfig の source フィールドから giget 用のテンプレートソース文字列を構築する。
1171
+ *
1172
+ * 背景: giget は "gh:owner/repo" または "gh:owner/repo#ref" 形式を期待する。
1173
+ * .devenv.json の source: { owner, repo, ref? } をこの形式に変換する。
1174
+ */
1175
+ function buildTemplateSource(source) {
1176
+ const base = `gh:${source.owner}/${source.repo}`;
1177
+ return source.ref ? `${base}#${source.ref}` : base;
1178
+ }
1179
+ /**
1180
+ * テンプレートをダウンロードして一時ディレクトリのパスを返す。
1181
+ *
1182
+ * @param targetDir - テンプレートを展開するベースディレクトリ
1183
+ * @param source - giget 形式のテンプレートソース (例: "gh:owner/repo")。
1184
+ * 未指定時はデフォルトの TEMPLATE_SOURCE を使用。
1185
+ */
1186
+ async function downloadTemplateToTemp(targetDir, source) {
1187
+ const tempDir = join(targetDir, ".devenv-temp");
1188
+ const { dir: templateDir } = await downloadTemplate(source ?? TEMPLATE_SOURCE, {
1189
+ dir: tempDir,
1190
+ force: true
1191
+ });
1192
+ const cleanup = () => {
1193
+ if (existsSync(tempDir)) rmSync(tempDir, {
1194
+ recursive: true,
1195
+ force: true
1196
+ });
1197
+ };
1198
+ return {
1199
+ templateDir,
1200
+ cleanup
1201
+ };
1202
+ }
1203
+ /**
1204
+ * 上書き戦略に従ってファイルを書き込む
1205
+ */
1206
+ async function writeFileWithStrategy(options) {
1207
+ const { destPath, content, strategy, relativePath } = options;
1208
+ if (!existsSync(destPath)) {
1209
+ const destDir = dirname(destPath);
1210
+ if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
1211
+ writeFileSync(destPath, content);
1212
+ return {
1213
+ action: "created",
1214
+ path: relativePath
1215
+ };
1216
+ }
1217
+ return match(strategy).with("overwrite", () => {
1218
+ writeFileSync(destPath, content);
1219
+ return {
1220
+ action: "overwritten",
1221
+ path: relativePath
1222
+ };
1223
+ }).with("skip", () => {
1224
+ return {
1225
+ action: "skipped",
1226
+ path: relativePath
1227
+ };
1228
+ }).with("prompt", async () => {
1229
+ const shouldOverwrite = await p.confirm({
1230
+ message: `${relativePath} already exists. Overwrite?`,
1231
+ initialValue: false
1232
+ });
1233
+ if (p.isCancel(shouldOverwrite) || !shouldOverwrite) return {
1234
+ action: "skipped",
1235
+ path: relativePath
1236
+ };
1237
+ writeFileSync(destPath, content);
1238
+ return {
1239
+ action: "overwritten",
1240
+ path: relativePath
1241
+ };
1242
+ }).exhaustive();
1243
+ }
1244
+ /**
1245
+ * テンプレートを取得してパターンベースでコピー
1246
+ */
1247
+ async function fetchTemplates(options) {
1248
+ const { targetDir, modules, overwriteStrategy, config, moduleList, templateDir: preDownloadedDir } = options;
1249
+ const allResults = [];
1250
+ const shouldDownload = !preDownloadedDir;
1251
+ const tempDir = join(targetDir, ".devenv-temp");
1252
+ let templateDir;
1253
+ try {
1254
+ if (shouldDownload) templateDir = (await downloadTemplate(TEMPLATE_SOURCE, {
1255
+ dir: tempDir,
1256
+ force: true
1257
+ })).dir;
1258
+ else templateDir = preDownloadedDir;
1259
+ const gitignore = await loadMergedGitignore([targetDir, templateDir]);
1260
+ for (const moduleId of modules) {
1261
+ const moduleDef = moduleList ? moduleList.find((m) => m.id === moduleId) : getModuleById(moduleId);
1262
+ if (!moduleDef) continue;
1263
+ const patterns = getEffectivePatterns(moduleId, moduleDef.patterns, config);
1264
+ const { tracked, ignored } = separateByGitignore(resolvePatterns(templateDir, patterns), gitignore);
1265
+ if (tracked.length === 0 && ignored.length === 0) {
1266
+ log.warn(`No files matched for module "${pc.cyan(moduleId)}"`);
1267
+ continue;
1268
+ }
1269
+ for (const relativePath of tracked) {
1270
+ const result = await copyFile(join(templateDir, relativePath), join(targetDir, relativePath), overwriteStrategy, relativePath);
1271
+ allResults.push(result);
1272
+ }
1273
+ for (const relativePath of ignored) {
1274
+ const srcPath = join(templateDir, relativePath);
1275
+ const destPath = join(targetDir, relativePath);
1276
+ if (existsSync(destPath)) {
1277
+ const result = {
1278
+ action: "skipped_ignored",
1279
+ path: relativePath
1280
+ };
1281
+ allResults.push(result);
1282
+ } else {
1283
+ const result = await copyFile(srcPath, destPath, overwriteStrategy, relativePath);
1284
+ allResults.push(result);
1285
+ }
1286
+ }
1287
+ }
1288
+ } finally {
1289
+ if (shouldDownload && existsSync(tempDir)) rmSync(tempDir, {
1290
+ recursive: true,
1291
+ force: true
1292
+ });
1293
+ }
1294
+ return allResults;
1295
+ }
1296
+ /**
1297
+ * 単一ファイルをコピー
1298
+ */
1299
+ async function copyFile(srcPath, destPath, strategy, relativePath) {
1300
+ if (!existsSync(destPath)) {
1301
+ const destDir = dirname(destPath);
1302
+ if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
1303
+ copyFileSync(srcPath, destPath);
1304
+ return {
1305
+ action: "copied",
1306
+ path: relativePath
1307
+ };
1308
+ }
1309
+ switch (strategy) {
1310
+ case "overwrite":
1311
+ copyFileSync(srcPath, destPath);
1312
+ return {
1313
+ action: "overwritten",
1314
+ path: relativePath
1315
+ };
1316
+ case "skip": return {
1317
+ action: "skipped",
1318
+ path: relativePath
1319
+ };
1320
+ case "prompt": {
1321
+ const shouldOverwrite = await p.confirm({
1322
+ message: `${relativePath} already exists. Overwrite?`,
1323
+ initialValue: false
1324
+ });
1325
+ if (p.isCancel(shouldOverwrite) || !shouldOverwrite) return {
1326
+ action: "skipped",
1327
+ path: relativePath
1328
+ };
1329
+ copyFileSync(srcPath, destPath);
1330
+ return {
1331
+ action: "overwritten",
1332
+ path: relativePath
1333
+ };
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ //#endregion
1339
+ //#region src/utils/untracked.ts
1340
+ /**
1341
+ * ファイルパスからモジュール ID を取得
1342
+ * モジュール ID = ディレクトリパス(ルートは ".")
1343
+ *
1344
+ * 例:
1345
+ * ".devcontainer/file.json" → ".devcontainer"
1346
+ * ".mcp.json" → "."
1347
+ * ".github/workflows/ci.yml" → ".github"
1348
+ */
1349
+ function getModuleIdFromPath(filePath) {
1350
+ const parts = filePath.split("/");
1351
+ if (parts.length === 1) return ".";
1352
+ return parts[0];
1353
+ }
1354
+ /**
1355
+ * 後方互換性のため: フォルダ名を表示用に取得
1356
+ * "." は "root" として表示
1357
+ */
1358
+ function getDisplayFolder(moduleId) {
1359
+ return moduleId === "." ? "root" : moduleId;
1360
+ }
1361
+ /**
1362
+ * モジュールのベースディレクトリを取得
1363
+ * モジュール ID がそのままディレクトリパスになる
1364
+ */
1365
+ function getModuleBaseDir(moduleId) {
1366
+ if (moduleId === ".") return null;
1367
+ return moduleId;
1368
+ }
1369
+ /**
1370
+ * ディレクトリ内の全ファイルを取得
1371
+ */
1372
+ function getAllFilesInDirs(baseDir, dirs) {
1373
+ if (dirs.length === 0) return [];
1374
+ return globSync(dirs.map((d) => `${d}/**/*`), {
1375
+ cwd: baseDir,
1376
+ dot: true,
1377
+ onlyFiles: true
1378
+ }).sort();
1379
+ }
1380
+ /**
1381
+ * ルート直下の隠しファイルを取得
1382
+ */
1383
+ function getRootDotFiles(baseDir) {
1384
+ return globSync([".*"], {
1385
+ cwd: baseDir,
1386
+ dot: true,
1387
+ onlyFiles: true
1388
+ }).sort();
1389
+ }
1390
+ /**
1391
+ * 複数ディレクトリの .gitignore をマージして読み込み
1392
+ * サブディレクトリの .gitignore も含める
1393
+ */
1394
+ async function loadAllGitignores(baseDir, dirs) {
1395
+ const ig = ignore();
1396
+ const rootGitignore = join(baseDir, ".gitignore");
1397
+ if (existsSync(rootGitignore)) {
1398
+ const content = await readFile(rootGitignore, "utf-8");
1399
+ ig.add(content);
1400
+ }
1401
+ for (const dir of dirs) {
1402
+ const gitignorePath = join(baseDir, dir, ".gitignore");
1403
+ if (existsSync(gitignorePath)) {
1404
+ const prefixedContent = (await readFile(gitignorePath, "utf-8")).split("\n").map((line) => {
1405
+ const trimmed = line.trim();
1406
+ if (!trimmed || trimmed.startsWith("#")) return line;
1407
+ if (trimmed.startsWith("!")) return `!${dir}/${trimmed.slice(1)}`;
1408
+ return `${dir}/${trimmed}`;
1409
+ }).join("\n");
1410
+ ig.add(prefixedContent);
1411
+ }
1412
+ }
1413
+ return ig;
1414
+ }
1415
+ /**
1416
+ * ホワイトリスト外のファイルをフォルダごとに検出
1417
+ */
1418
+ async function detectUntrackedFiles(options) {
1419
+ const { targetDir, moduleIds, config, moduleList = defaultModules } = options;
1420
+ const installedModuleIds = new Set(moduleIds);
1421
+ const allBaseDirs = [];
1422
+ const allTrackedFiles = /* @__PURE__ */ new Set();
1423
+ let hasRootModule = false;
1424
+ for (const moduleId of moduleIds) {
1425
+ const mod = getModuleById(moduleId, moduleList);
1426
+ if (!mod) continue;
1427
+ const baseDir = getModuleBaseDir(moduleId);
1428
+ if (baseDir) allBaseDirs.push(baseDir);
1429
+ else hasRootModule = true;
1430
+ const trackedFiles = resolvePatterns(targetDir, getEffectivePatterns(moduleId, mod.patterns, config));
1431
+ for (const file of trackedFiles) allTrackedFiles.add(file);
1432
+ }
1433
+ const gitignore = await loadAllGitignores(targetDir, allBaseDirs);
1434
+ const allDirFiles = getAllFilesInDirs(targetDir, allBaseDirs);
1435
+ const filteredDirFiles = gitignore.filter(allDirFiles);
1436
+ const filteredRootFiles = hasRootModule ? gitignore.filter(getRootDotFiles(targetDir)) : [];
1437
+ const allFiles = new Set([...filteredDirFiles, ...filteredRootFiles]);
1438
+ const filesByFolder = /* @__PURE__ */ new Map();
1439
+ for (const filePath of allFiles) {
1440
+ if (allTrackedFiles.has(filePath)) continue;
1441
+ const moduleId = getModuleIdFromPath(filePath);
1442
+ if (!installedModuleIds.has(moduleId)) continue;
1443
+ const displayFolder = getDisplayFolder(moduleId);
1444
+ const file = {
1445
+ path: filePath,
1446
+ folder: displayFolder,
1447
+ moduleId
1448
+ };
1449
+ const existing = filesByFolder.get(displayFolder) || [];
1450
+ existing.push(file);
1451
+ filesByFolder.set(displayFolder, existing);
1452
+ }
1453
+ const result = [];
1454
+ const sortedFolders = Array.from(filesByFolder.keys()).sort((a, b) => {
1455
+ if (a === "root") return 1;
1456
+ if (b === "root") return -1;
1457
+ return a.localeCompare(b);
1458
+ });
1459
+ for (const folder of sortedFolders) {
1460
+ const files = filesByFolder.get(folder) || [];
1461
+ if (files.length > 0) result.push({
1462
+ folder,
1463
+ files: files.sort((a, b) => a.path.localeCompare(b.path))
1464
+ });
1465
+ }
1466
+ return result;
1467
+ }
1468
+ /**
1469
+ * 全フォルダの未追跡ファイル数を取得
1470
+ */
1471
+ function getTotalUntrackedCount(untrackedByFolder) {
1472
+ return untrackedByFolder.reduce((sum, f) => sum + f.files.length, 0);
1473
+ }
1474
+
1475
+ //#endregion
1476
+ //#region src/commands/diff.ts
1477
+ const diffCommand = defineCommand({
1478
+ meta: {
1479
+ name: "diff",
1480
+ description: "Show differences between local and template"
1481
+ },
1482
+ args: {
1483
+ dir: {
1484
+ type: "positional",
1485
+ description: "Project directory",
1486
+ default: "."
1487
+ },
1488
+ verbose: {
1489
+ type: "boolean",
1490
+ alias: "v",
1491
+ description: "Show detailed diff",
1492
+ default: false
1493
+ }
1494
+ },
1495
+ async run({ args }) {
1496
+ intro("diff");
1497
+ const targetDir = resolve(args.dir);
1498
+ const configPath = join(targetDir, ".devenv.json");
1499
+ if (!existsSync(configPath)) throw new BermError(".devenv.json not found.", "Run 'ziku init' first.");
1500
+ const configContent = await readFile(configPath, "utf-8");
1501
+ const configData = JSON.parse(configContent);
1502
+ const parseResult = configSchema.safeParse(configData);
1503
+ if (!parseResult.success) throw new BermError("Invalid .devenv.json format", parseResult.error.message);
1504
+ const config = parseResult.data;
1505
+ if (config.modules.length === 0) {
1506
+ log.warn("No modules installed");
1507
+ return;
1508
+ }
1509
+ log.step("Fetching template...");
1510
+ const templateSource = buildTemplateSource(config.source);
1511
+ const tempDir = join(targetDir, ".devenv-temp");
1512
+ try {
1513
+ const { dir: templateDir } = await withSpinner("Downloading template from GitHub...", () => downloadTemplate(templateSource, {
1514
+ dir: tempDir,
1515
+ force: true
1516
+ }));
1517
+ let moduleList;
1518
+ if (modulesFileExists(templateDir)) moduleList = (await loadModulesFile(templateDir)).modules;
1519
+ else moduleList = defaultModules;
1520
+ log.step("Detecting changes...");
1521
+ const diff = await withSpinner("Analyzing differences...", () => detectDiff({
1522
+ targetDir,
1523
+ templateDir,
1524
+ moduleIds: config.modules,
1525
+ config,
1526
+ moduleList
1527
+ }));
1528
+ const untrackedByFolder = await detectUntrackedFiles({
1529
+ targetDir,
1530
+ moduleIds: config.modules,
1531
+ config,
1532
+ moduleList
1533
+ });
1534
+ const untrackedCount = getTotalUntrackedCount(untrackedByFolder);
1535
+ if (hasDiff(diff)) {
1536
+ logDiffSummary(diff.files);
1537
+ if (args.verbose) for (const file of diff.files.filter((f) => f.type !== "unchanged")) renderFileDiff(file);
1538
+ if (untrackedCount > 0) {
1539
+ log.warn(`${untrackedCount} untracked file(s) found outside the sync whitelist:`);
1540
+ const untrackedLines = untrackedByFolder.flatMap((group) => group.files.map((file) => ` ${pc$1.dim("•")} ${file.path}`));
1541
+ log.message(untrackedLines.join("\n"));
1542
+ log.info(`To include these files in sync, add them to tracking with the ${pc$1.cyan("track")} command:`);
1543
+ log.message(pc$1.dim(` npx ziku track "<pattern>"`));
1544
+ log.message(pc$1.dim(` Example: npx ziku track "${untrackedByFolder[0]?.files[0]?.path || ".cloud/rules/*.md"}"`));
1545
+ }
1546
+ outro("Run 'ziku push' to push changes.");
1547
+ } else if (untrackedCount > 0) {
1548
+ log.success("Tracked files are in sync.");
1549
+ log.warn(`However, ${untrackedCount} untracked file(s) exist outside the sync whitelist:`);
1550
+ const untrackedLines = untrackedByFolder.flatMap((group) => group.files.map((file) => ` ${pc$1.dim("•")} ${file.path}`));
1551
+ log.message(untrackedLines.join("\n"));
1552
+ log.info(`Use ${pc$1.cyan("npx ziku track <pattern>")} to add them, then ${pc$1.cyan("push")} to sync.`);
1553
+ outro("Tracked files are in sync, but untracked files exist.");
1554
+ } else outro("No changes — in sync with template.");
1555
+ } finally {
1556
+ if (existsSync(tempDir)) await rm(tempDir, {
1557
+ recursive: true,
1558
+ force: true
1559
+ });
1560
+ }
1561
+ }
1562
+ });
1563
+
1564
+ //#endregion
1565
+ //#region src/ui/prompts.ts
1566
+ /** ユーザーが Ctrl+C でキャンセルした場合の統一処理 */
1567
+ function handleCancel(value) {
1568
+ if (p.isCancel(value)) {
1569
+ p.cancel("Cancelled.");
1570
+ process.exit(0);
1571
+ }
1572
+ }
1573
+ /** モジュール選択 */
1574
+ async function selectModules(moduleList) {
1575
+ const selected = await p.multiselect({
1576
+ message: "Select modules to install",
1577
+ options: moduleList.map((m) => ({
1578
+ value: m.id,
1579
+ label: m.name,
1580
+ hint: m.description
1581
+ })),
1582
+ initialValues: moduleList.map((m) => m.id),
1583
+ required: true
1584
+ });
1585
+ handleCancel(selected);
1586
+ return selected;
1587
+ }
1588
+ /**
1589
+ * 上書き戦略の選択(プロジェクト状態に応じたスマートデフォルト付き)
1590
+ *
1591
+ * 背景: 新規プロジェクトでは overwrite が自然だが、再実行時(.devenv.json 既存)は
1592
+ * カスタマイズ済みファイルを誤って上書きしないよう skip をデフォルトにする。
1593
+ * ユーザーが毎回3択を読んで判断する必要をなくす。
1594
+ */
1595
+ async function selectOverwriteStrategy(options) {
1596
+ const isReinit = options?.isReinit ?? false;
1597
+ const strategy = await p.select({
1598
+ message: isReinit ? "How to handle existing files? (re-init detected → Skip recommended)" : "How to handle existing files?",
1599
+ initialValue: isReinit ? "skip" : "overwrite",
1600
+ options: [
1601
+ {
1602
+ value: "overwrite",
1603
+ label: "Overwrite all"
1604
+ },
1605
+ {
1606
+ value: "skip",
1607
+ label: "Skip (keep existing)"
1608
+ },
1609
+ {
1610
+ value: "prompt",
1611
+ label: "Ask for each file"
1612
+ }
1613
+ ]
1614
+ });
1615
+ handleCancel(strategy);
1616
+ return strategy;
1617
+ }
1618
+ /**
1619
+ * ファイルの行数統計を "+N -M" 形式で返す(hint テキスト用)。
1620
+ * git push の出力に合わせ、変更規模をひと目で把握できるようにする。
1621
+ */
1622
+ function fileStatHint(file) {
1623
+ let additions = 0;
1624
+ let deletions = 0;
1625
+ if (file.type === "added" && file.localContent) additions = file.localContent.split("\n").length;
1626
+ else if (file.type === "deleted" && file.templateContent) deletions = file.templateContent.split("\n").length;
1627
+ else if (file.type === "modified") {
1628
+ const local = file.localContent?.split("\n").length ?? 0;
1629
+ const tmpl = file.templateContent?.split("\n").length ?? 0;
1630
+ additions = Math.max(0, local - tmpl);
1631
+ deletions = Math.max(0, tmpl - local);
1632
+ if (additions === 0 && deletions === 0 && file.localContent !== file.templateContent) {
1633
+ additions = 1;
1634
+ deletions = 1;
1635
+ }
1636
+ }
1637
+ const parts = [];
1638
+ if (additions > 0) parts.push(pc.green(`+${additions}`));
1639
+ if (deletions > 0) parts.push(pc.red(`-${deletions}`));
1640
+ return parts.join(" ");
1641
+ }
1642
+ /**
1643
+ * push 対象ファイルの選択(+N -M 統計付き)
1644
+ *
1645
+ * 背景: git の `git add -p` に相当するファイル選択 UI。
1646
+ * 変更規模(行数増減)をヒントとして表示し、何を同期するか判断しやすくする。
1647
+ */
1648
+ async function selectPushFiles(files) {
1649
+ const typeIcon = (type) => {
1650
+ switch (type) {
1651
+ case "added": return pc.green("+");
1652
+ case "modified": return pc.yellow("~");
1653
+ case "deleted": return pc.red("-");
1654
+ default: return " ";
1655
+ }
1656
+ };
1657
+ const selected = await p.multiselect({
1658
+ message: "Select files to include in PR",
1659
+ options: files.map((f) => {
1660
+ const hint = fileStatHint(f);
1661
+ return {
1662
+ value: f.path,
1663
+ label: `${typeIcon(f.type)} ${f.path}`,
1664
+ hint: hint || void 0
1665
+ };
1666
+ }),
1667
+ initialValues: files.map((f) => f.path),
1668
+ required: false
1669
+ });
1670
+ handleCancel(selected);
1671
+ const selectedPaths = new Set(selected);
1672
+ return files.filter((f) => selectedPaths.has(f.path));
1673
+ }
1674
+ /**
1675
+ * PR タイトル入力(変更内容からスマートなデフォルトを生成)
1676
+ *
1677
+ * 背景: ユーザーが空欄からタイトルを考える手間を省く。
1678
+ * 変更ファイルのパスからモジュール名を推測し、自動生成したタイトルを
1679
+ * デフォルト値として表示する。Enter でそのまま採用可能。
1680
+ */
1681
+ async function inputPrTitle(defaultTitle) {
1682
+ const title = await p.text({
1683
+ message: "PR title",
1684
+ defaultValue: defaultTitle,
1685
+ placeholder: defaultTitle ? void 0 : "feat: update template config",
1686
+ validate: (value) => {
1687
+ if (!value?.trim()) return "Title is required";
1688
+ }
1689
+ });
1690
+ handleCancel(title);
1691
+ return title;
1692
+ }
1693
+ /**
1694
+ * 変更ファイル一覧から PR タイトルを自動生成する。
1695
+ *
1696
+ * 背景: "feat: add .devcontainer config" のような具体的なタイトルを
1697
+ * ファイルのパスと変更種別から推測し、ユーザーの入力負担を減らす。
1698
+ */
1699
+ function generatePrTitle(files) {
1700
+ const added = files.filter((f) => f.type === "added");
1701
+ const modified = files.filter((f) => f.type === "modified");
1702
+ const prefix = added.length > 0 && modified.length === 0 ? "feat" : "chore";
1703
+ const moduleNames = /* @__PURE__ */ new Set();
1704
+ for (const f of files) {
1705
+ const firstSegment = f.path.split("/")[0];
1706
+ moduleNames.add(firstSegment);
1707
+ }
1708
+ const names = [...moduleNames];
1709
+ if (names.length === 1) return `${prefix}: ${added.length > 0 && modified.length === 0 ? "add" : "update"} ${names[0]} config`;
1710
+ if (names.length <= 3) return `${prefix}: update ${names.join(", ")} config`;
1711
+ return `${prefix}: update template configuration`;
1712
+ }
1713
+ /**
1714
+ * PR 本文入力(変更一覧から自動生成したデフォルト付き)
1715
+ *
1716
+ * 背景: 以前は「追加する?」→「入力して」の2ステップだったが、
1717
+ * 変更ファイル一覧を自動生成してデフォルト表示することで
1718
+ * 1ステップ(Enter で採用 or 編集)に短縮する。
1719
+ */
1720
+ async function inputPrBody(defaultBody) {
1721
+ const body = await p.text({
1722
+ message: "PR description (Enter to accept, or edit)",
1723
+ defaultValue: defaultBody,
1724
+ placeholder: defaultBody ? void 0 : "Optional description"
1725
+ });
1726
+ handleCancel(body);
1727
+ return body?.trim() || void 0;
1728
+ }
1729
+ /**
1730
+ * 変更ファイル一覧から PR 本文を自動生成する。
1731
+ *
1732
+ * 背景: ユーザーが本文を一から書く手間を省く。
1733
+ * 変更種別ごとにファイルを分類し、箇条書きで表示する。
1734
+ */
1735
+ function generatePrBody$1(files) {
1736
+ const added = files.filter((f) => f.type === "added");
1737
+ const modified = files.filter((f) => f.type === "modified");
1738
+ const sections = ["## Changes", ""];
1739
+ if (added.length > 0) {
1740
+ sections.push("**Added:**");
1741
+ for (const f of added) sections.push(`- \`${f.path}\``);
1742
+ sections.push("");
1743
+ }
1744
+ if (modified.length > 0) {
1745
+ sections.push("**Modified:**");
1746
+ for (const f of modified) sections.push(`- \`${f.path}\``);
1747
+ sections.push("");
1748
+ }
1749
+ sections.push("---");
1750
+ sections.push("Generated by [ziku](https://github.com/tktcorporation/.github/tree/main/packages/ziku)");
1751
+ return sections.join("\n");
1752
+ }
1753
+ /** GitHub トークン入力 */
1754
+ async function inputGitHubToken() {
1755
+ p.log.warn("GitHub token not found.");
1756
+ p.log.message([
1757
+ "Set one of these environment variables:",
1758
+ ` ${pc.cyan("GITHUB_TOKEN")} or ${pc.cyan("GH_TOKEN")}`,
1759
+ "",
1760
+ "Or enter it below:"
1761
+ ].join("\n"));
1762
+ const token = await p.password({
1763
+ message: "GitHub Personal Access Token",
1764
+ validate: (value) => {
1765
+ if (!value?.trim()) return "Token is required";
1766
+ if (!value.startsWith("ghp_") && !value.startsWith("gho_") && !value.startsWith("github_pat_")) return "Invalid GitHub token format";
1767
+ }
1768
+ });
1769
+ handleCancel(token);
1770
+ return token;
1771
+ }
1772
+ /**
1773
+ * 確認プロンプト
1774
+ *
1775
+ * 背景: デフォルト値をコンテキストに応じて変更可能にする。
1776
+ * ファイル選択後の確認では true(ユーザーは既にレビュー済み)、
1777
+ * 破壊的操作の確認では false が適切。
1778
+ */
1779
+ async function confirmAction(message, options) {
1780
+ const confirmed = await p.confirm({
1781
+ message,
1782
+ initialValue: options?.initialValue ?? false
1783
+ });
1784
+ handleCancel(confirmed);
1785
+ return confirmed;
1786
+ }
1787
+ /**
1788
+ * テンプレートで削除されたファイルの中から、ローカルでも削除するものを選択する。
1789
+ * 背景: テンプレートから削除されたファイルを自動削除すると意図しないデータ損失の
1790
+ * リスクがあるため、ユーザーに明示的に選ばせる。
1791
+ */
1792
+ async function selectDeletedFiles(files) {
1793
+ const result = await p.multiselect({
1794
+ message: "These files were deleted in template. Select to delete locally:",
1795
+ options: files.map((f) => ({
1796
+ value: f,
1797
+ label: f
1798
+ })),
1799
+ required: false
1800
+ });
1801
+ if (p.isCancel(result)) {
1802
+ p.cancel("Operation cancelled.");
1803
+ process.exit(0);
1804
+ }
1805
+ return result;
1806
+ }
1807
+
1808
+ //#endregion
1809
+ //#region src/utils/git-remote.ts
1810
+ /** フォールバック用のデフォルトテンプレートオーナー */
1811
+ const DEFAULT_TEMPLATE_OWNER = "tktcorporation";
1812
+ /** フォールバック用のデフォルトテンプレートリポジトリ名 */
1813
+ const DEFAULT_TEMPLATE_REPO = ".github";
1814
+ /**
1815
+ * GitHub URL からオーナー名を抽出する。
1816
+ *
1817
+ * 背景: `ziku init` で --from が未指定の場合、git remote URL から
1818
+ * オーナーを推定し `{owner}/.github` をテンプレートソースとする。
1819
+ * テスト容易性のため detectGitHubOwner から分離した純粋関数。
1820
+ *
1821
+ * 対応形式:
1822
+ * - https://github.com/{owner}/{repo}(.git)?
1823
+ * - git@github.com:{owner}/{repo}(.git)?
1824
+ */
1825
+ function parseGitHubOwner(url) {
1826
+ const httpsMatch = url.match(/github\.com\/([^/]+)\//);
1827
+ if (httpsMatch) return httpsMatch[1];
1828
+ const sshMatch = url.match(/github\.com:([^/]+)\//);
1829
+ if (sshMatch) return sshMatch[1];
1830
+ return null;
1831
+ }
1832
+ /**
1833
+ * git remote origin の URL から GitHub オーナー名を検出する。
1834
+ *
1835
+ * 背景: テンプレートソースの自動解決に使用。
1836
+ * git リポジトリでない場合や origin が未設定の場合は null を返す。
1837
+ */
1838
+ function detectGitHubOwner(cwd) {
1839
+ try {
1840
+ return parseGitHubOwner(execFileSync("git", [
1841
+ "remote",
1842
+ "get-url",
1843
+ "origin"
1844
+ ], {
1845
+ encoding: "utf-8",
1846
+ cwd,
1847
+ stdio: [
1848
+ "pipe",
1849
+ "pipe",
1850
+ "pipe"
1851
+ ]
1852
+ }).trim());
1853
+ } catch {
1854
+ return null;
1855
+ }
1856
+ }
1857
+
1858
+ //#endregion
1859
+ //#region src/utils/github.ts
1860
+ /**
1861
+ * GitHub API を使って PR を作成
1862
+ */
1863
+ async function createPullRequest(token, options) {
1864
+ const octokit = new Octokit({ auth: token });
1865
+ const { owner, repo, files, title, body, baseBranch = "main" } = options;
1866
+ const { data: user } = await octokit.users.getAuthenticated();
1867
+ const forkOwner = user.login;
1868
+ let forkRepo;
1869
+ try {
1870
+ const { data: fork } = await octokit.repos.get({
1871
+ owner: forkOwner,
1872
+ repo
1873
+ });
1874
+ forkRepo = fork.name;
1875
+ } catch {
1876
+ const { data: fork } = await octokit.repos.createFork({
1877
+ owner,
1878
+ repo
1879
+ });
1880
+ forkRepo = fork.name;
1881
+ await sleep(3e3);
1882
+ }
1883
+ const { data: baseBranchRef } = await octokit.repos.getBranch({
1884
+ owner,
1885
+ repo,
1886
+ branch: baseBranch
1887
+ });
1888
+ const baseSha = baseBranchRef.commit.sha;
1889
+ const branchName = `devenv-sync-${Date.now()}`;
1890
+ await octokit.git.createRef({
1891
+ owner: forkOwner,
1892
+ repo: forkRepo,
1893
+ ref: `refs/heads/${branchName}`,
1894
+ sha: baseSha
1895
+ });
1896
+ for (const file of files) {
1897
+ let existingSha;
1898
+ try {
1899
+ const { data: existingFile } = await octokit.repos.getContent({
1900
+ owner: forkOwner,
1901
+ repo: forkRepo,
1902
+ path: file.path,
1903
+ ref: branchName
1904
+ });
1905
+ if (!Array.isArray(existingFile) && existingFile.type === "file") existingSha = existingFile.sha;
1906
+ } catch {}
1907
+ await octokit.repos.createOrUpdateFileContents({
1908
+ owner: forkOwner,
1909
+ repo: forkRepo,
1910
+ path: file.path,
1911
+ message: `Update ${file.path}`,
1912
+ content: Buffer.from(file.content).toString("base64"),
1913
+ branch: branchName,
1914
+ sha: existingSha
1915
+ });
1916
+ }
1917
+ const { data: pr } = await octokit.pulls.create({
1918
+ owner,
1919
+ repo,
1920
+ title,
1921
+ body: body || generatePrBody(files),
1922
+ head: `${forkOwner}:${branchName}`,
1923
+ base: baseBranch
1924
+ });
1925
+ return {
1926
+ url: pr.html_url,
1927
+ number: pr.number,
1928
+ branch: branchName
1929
+ };
1930
+ }
1931
+ /**
1932
+ * PR の本文を生成
1933
+ */
1934
+ function generatePrBody(files) {
1935
+ return `## Summary
1936
+
1937
+ Auto-generated PR by ziku push command.
1938
+
1939
+ ## Changed files
1940
+
1941
+ ${files.map((f) => `- \`${f.path}\``).join("\n")}
1942
+
1943
+ ---
1944
+ Generated by [ziku](https://github.com/tktcorporation/.github/tree/main/packages/ziku)
1945
+ `;
1946
+ }
1947
+ /**
1948
+ * GitHub トークンを環境変数または gh CLI から取得
1949
+ *
1950
+ * 優先順位:
1951
+ * 1. GITHUB_TOKEN 環境変数
1952
+ * 2. GH_TOKEN 環境変数
1953
+ * 3. `gh auth token` コマンド出力(gh CLI がインストール済みの場合)
1954
+ *
1955
+ * 背景: gh CLI でログイン済みなのにトークンを手動入力させるのは不親切。
1956
+ * 多くの開発者は `gh auth login` 済みなので、そのトークンを自動取得する。
1957
+ */
1958
+ function getGitHubToken() {
1959
+ const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
1960
+ if (envToken) return envToken;
1961
+ return getGhCliToken();
1962
+ }
1963
+ /**
1964
+ * gh CLI の `gh auth token` からトークンを取得する。
1965
+ * gh CLI が未インストール or 未ログインの場合は undefined を返す。
1966
+ */
1967
+ function getGhCliToken() {
1968
+ try {
1969
+ const { execSync } = __require("node:child_process");
1970
+ const token = execSync("gh auth token", {
1971
+ encoding: "utf-8",
1972
+ timeout: 5e3,
1973
+ stdio: [
1974
+ "pipe",
1975
+ "pipe",
1976
+ "pipe"
1977
+ ]
1978
+ }).trim();
1979
+ if (token && (token.startsWith("ghp_") || token.startsWith("gho_") || token.startsWith("github_pat_"))) return token;
1980
+ } catch {}
1981
+ }
1982
+ /**
1983
+ * テンプレートリポジトリの最新コミット SHA を取得する。
1984
+ *
1985
+ * 背景: init/pull 時に baseRef として保存し、後で 3-way マージのベース取得に使用する。
1986
+ * GitHub API の `Accept: application/vnd.github.sha` を使い、SHA 文字列のみを取得する。
1987
+ * 認証不要(公開リポジトリの場合)。
1988
+ */
1989
+ async function resolveLatestCommitSha(owner, repo, ref = "main") {
1990
+ try {
1991
+ const url = `https://api.github.com/repos/${owner}/${repo}/commits/${ref}`;
1992
+ const res = await fetch(url, { headers: { Accept: "application/vnd.github.sha" } });
1993
+ if (!res.ok) return void 0;
1994
+ return (await res.text()).trim();
1995
+ } catch {
1996
+ return;
1997
+ }
1998
+ }
1999
+ /**
2000
+ * sleep utility
2001
+ */
2002
+ function sleep(ms) {
2003
+ return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2004
+ }
2005
+
2006
+ //#endregion
2007
+ //#region src/utils/hash.ts
2008
+ /**
2009
+ * ファイル内容の SHA-256 ハッシュを計算する。
2010
+ *
2011
+ * 背景: pull 時に「ローカルが変更されたか」「テンプレートが更新されたか」を
2012
+ * ファイル全体のコピーを保持せずに判定するため、ハッシュで比較する。
2013
+ */
2014
+ function hashContent(content) {
2015
+ return createHash("sha256").update(content, "utf-8").digest("hex");
2016
+ }
2017
+ /**
2018
+ * ディレクトリ内のファイル群を glob パターンでマッチし、
2019
+ * 各ファイルの SHA-256 ハッシュを計算してマップを返す。
2020
+ *
2021
+ * 背景: init/pull 時に適用したテンプレートファイルのハッシュを
2022
+ * .devenv.json に記録し、次回 pull 時の差分検出に使用する。
2023
+ *
2024
+ * @param dir - 対象ディレクトリのルートパス
2025
+ * @param patterns - glob パターンの配列(例: [".devcontainer/**"])
2026
+ * @returns パス(dir からの相対パス)-> SHA-256 ハッシュのマップ
2027
+ */
2028
+ async function hashFiles(dir, patterns) {
2029
+ const files = await glob(patterns, {
2030
+ cwd: dir,
2031
+ dot: true
2032
+ });
2033
+ const hashes = {};
2034
+ for (const file of files) hashes[file] = hashContent(await readFile(join(dir, file), "utf-8"));
2035
+ return hashes;
2036
+ }
2037
+
2038
+ //#endregion
2039
+ //#region src/commands/init.ts
2040
+ const version = "0.20.0";
2041
+ const initCommand = defineCommand({
2042
+ meta: {
2043
+ name: "ziku",
2044
+ version,
2045
+ description: "Apply dev environment template to your project"
2046
+ },
2047
+ args: {
2048
+ dir: {
2049
+ type: "positional",
2050
+ description: "Target directory",
2051
+ default: "."
2052
+ },
2053
+ force: {
2054
+ type: "boolean",
2055
+ description: "Overwrite existing files",
2056
+ default: false
2057
+ },
2058
+ yes: {
2059
+ type: "boolean",
2060
+ alias: "y",
2061
+ description: "Select all modules (non-interactive mode)",
2062
+ default: false
2063
+ },
2064
+ modules: {
2065
+ type: "string",
2066
+ alias: "m",
2067
+ description: "Comma-separated module IDs to apply (non-interactive)"
2068
+ },
2069
+ "overwrite-strategy": {
2070
+ type: "string",
2071
+ alias: "s",
2072
+ description: "Overwrite strategy: overwrite, skip, or prompt (non-interactive)"
2073
+ },
2074
+ from: {
2075
+ type: "string",
2076
+ description: "Template source as owner/repo (e.g., my-org/my-templates)"
2077
+ }
2078
+ },
2079
+ async run({ args }) {
2080
+ intro();
2081
+ const targetDir = resolve(args.dir === "init" ? "." : args.dir);
2082
+ log.info(`Target: ${pc$1.cyan(targetDir)}`);
2083
+ if (!existsSync(targetDir)) {
2084
+ mkdirSync(targetDir, { recursive: true });
2085
+ log.message(pc$1.dim(`Created directory: ${targetDir}`));
2086
+ }
2087
+ const { sourceOwner, sourceRepo } = resolveTemplateSource(args.from);
2088
+ const templateSourceStr = buildTemplateSource({
2089
+ owner: sourceOwner,
2090
+ repo: sourceRepo
2091
+ });
2092
+ log.info(`Template: ${pc$1.cyan(`${sourceOwner}/${sourceRepo}`)}`);
2093
+ log.step("Fetching template...");
2094
+ const { templateDir, cleanup } = await withSpinner("Downloading template from GitHub...", () => downloadTemplateToTemp(targetDir, templateSourceStr));
2095
+ try {
2096
+ let moduleList;
2097
+ if (modulesFileExists(templateDir)) {
2098
+ const { modules: loadedModules } = await loadModulesFile(templateDir);
2099
+ moduleList = loadedModules;
2100
+ } else moduleList = defaultModules;
2101
+ log.step("Selecting modules...");
2102
+ let answers;
2103
+ const hasModulesArg = typeof args.modules === "string" && args.modules.length > 0;
2104
+ const hasStrategyArg = typeof args["overwrite-strategy"] === "string" && args["overwrite-strategy"].length > 0;
2105
+ if (args.yes || hasModulesArg) {
2106
+ let selectedModules;
2107
+ if (hasModulesArg) {
2108
+ const requestedIds = args.modules.split(",").map((s) => s.trim());
2109
+ const validIds = moduleList.map((m) => m.id);
2110
+ const invalidIds = requestedIds.filter((id) => !validIds.includes(id));
2111
+ if (invalidIds.length > 0) throw new BermError(`Unknown module(s): ${invalidIds.join(", ")}`, `Available modules: ${validIds.join(", ")}`);
2112
+ selectedModules = requestedIds;
2113
+ } else selectedModules = moduleList.map((m) => m.id);
2114
+ let overwriteStrategy = "overwrite";
2115
+ if (hasStrategyArg) {
2116
+ const strategy = args["overwrite-strategy"];
2117
+ if (strategy !== "overwrite" && strategy !== "skip" && strategy !== "prompt") throw new BermError(`Invalid overwrite strategy: ${strategy}`, "Must be: overwrite, skip, or prompt");
2118
+ overwriteStrategy = strategy;
2119
+ }
2120
+ answers = {
2121
+ modules: selectedModules,
2122
+ overwriteStrategy
2123
+ };
2124
+ log.info(`Selected ${pc$1.cyan(selectedModules.length.toString())} modules`);
2125
+ } else if (hasStrategyArg) {
2126
+ const strategy = args["overwrite-strategy"];
2127
+ if (strategy !== "overwrite" && strategy !== "skip" && strategy !== "prompt") throw new BermError(`Invalid overwrite strategy: ${strategy}`, "Must be: overwrite, skip, or prompt");
2128
+ answers = {
2129
+ modules: await selectModules(moduleList),
2130
+ overwriteStrategy: strategy
2131
+ };
2132
+ } else answers = {
2133
+ modules: await selectModules(moduleList),
2134
+ overwriteStrategy: await selectOverwriteStrategy({ isReinit: existsSync(resolve(targetDir, ".devenv.json")) })
2135
+ };
2136
+ if (answers.modules.length === 0) {
2137
+ log.warn("No modules selected");
2138
+ return;
2139
+ }
2140
+ log.step("Applying templates...");
2141
+ const effectiveStrategy = args.force ? "overwrite" : answers.overwriteStrategy;
2142
+ const allResults = [...await fetchTemplates({
2143
+ targetDir,
2144
+ modules: answers.modules,
2145
+ overwriteStrategy: effectiveStrategy,
2146
+ moduleList,
2147
+ templateDir
2148
+ })];
2149
+ if (answers.modules.includes("devcontainer")) {
2150
+ const envResult = await createEnvExample(targetDir, effectiveStrategy);
2151
+ allResults.push(envResult);
2152
+ }
2153
+ const modulesJsoncResult = await copyModulesJsonc(templateDir, targetDir, effectiveStrategy);
2154
+ allResults.push(modulesJsoncResult);
2155
+ const baseHashes = await hashFiles(templateDir, getPatternsByModuleIds(answers.modules, moduleList));
2156
+ const baseRef = await resolveLatestCommitSha(sourceOwner, sourceRepo);
2157
+ const configResult = await createDevEnvConfig(targetDir, answers.modules, {
2158
+ owner: sourceOwner,
2159
+ repo: sourceRepo,
2160
+ baseHashes,
2161
+ baseRef
2162
+ });
2163
+ allResults.push(configResult);
2164
+ const summary = logFileResults(allResults);
2165
+ if (summary.added === 0 && summary.updated === 0) {
2166
+ log.info("No changes were made");
2167
+ return;
2168
+ }
2169
+ displayModuleDescriptions(answers.modules, allResults, moduleList);
2170
+ outro([
2171
+ "Setup complete!",
2172
+ "",
2173
+ `${pc$1.bold("Next steps:")}`,
2174
+ ` ${pc$1.cyan("git add . && git commit -m 'chore: add devenv config'")}`,
2175
+ ` ${pc$1.dim("Commit the changes")}`,
2176
+ ` ${pc$1.cyan("npx ziku diff")}`,
2177
+ ` ${pc$1.dim("Check for updates from upstream")}`
2178
+ ].join("\n"));
2179
+ } finally {
2180
+ cleanup();
2181
+ }
2182
+ }
2183
+ });
2184
+ const ENV_EXAMPLE_CONTENT = `# 環境変数サンプル
2185
+ # このファイルを devcontainer.env にコピーして値を設定してください
2186
+
2187
+ # GitHub Personal Access Token
2188
+ GH_TOKEN=
2189
+
2190
+ # AWS Credentials (optional)
2191
+ AWS_ACCESS_KEY_ID=
2192
+ AWS_SECRET_ACCESS_KEY=
2193
+ AWS_DEFAULT_REGION=ap-northeast-1
2194
+
2195
+ # WakaTime API Key (optional)
2196
+ WAKATIME_API_KEY=
2197
+ `;
2198
+ async function createEnvExample(targetDir, strategy) {
2199
+ return writeFileWithStrategy({
2200
+ destPath: resolve(targetDir, ".devcontainer/devcontainer.env.example"),
2201
+ content: ENV_EXAMPLE_CONTENT,
2202
+ strategy,
2203
+ relativePath: ".devcontainer/devcontainer.env.example"
2204
+ });
2205
+ }
2206
+ /**
2207
+ * 設定ファイル (.devenv.json) を生成する。常に上書き。
2208
+ *
2209
+ * 背景: baseHashes を記録することで、pull 時に「ユーザーがローカルで変更したか」を
2210
+ * ファイル全体のコピーを保持せずに判定できる。
2211
+ */
2212
+ async function createDevEnvConfig(targetDir, selectedModules, source) {
2213
+ const config = {
2214
+ version: "0.1.0",
2215
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2216
+ modules: selectedModules,
2217
+ source: {
2218
+ owner: source.owner,
2219
+ repo: source.repo
2220
+ }
2221
+ };
2222
+ if (source.baseRef) config.baseRef = source.baseRef;
2223
+ if (source.baseHashes && Object.keys(source.baseHashes).length > 0) config.baseHashes = source.baseHashes;
2224
+ return writeFileWithStrategy({
2225
+ destPath: resolve(targetDir, ".devenv.json"),
2226
+ content: JSON.stringify(config, null, 2),
2227
+ strategy: "overwrite",
2228
+ relativePath: ".devenv.json"
2229
+ });
2230
+ }
2231
+ /**
2232
+ * テンプレートから modules.jsonc をコピー
2233
+ */
2234
+ async function copyModulesJsonc(templateDir, targetDir, strategy) {
2235
+ const modulesRelPath = ".devenv/modules.jsonc";
2236
+ const srcPath = join(templateDir, modulesRelPath);
2237
+ const destPath = getModulesFilePath(targetDir);
2238
+ if (!existsSync(srcPath)) return {
2239
+ action: "skipped",
2240
+ path: modulesRelPath
2241
+ };
2242
+ return copyFile(srcPath, destPath, strategy, modulesRelPath);
2243
+ }
2244
+ /**
2245
+ * モジュール別の説明を表示
2246
+ *
2247
+ * 背景: 選択されたモジュールが実際に変更を加えた場合のみ、
2248
+ * 各モジュールの名前と説明を一覧表示する。ユーザーが何がインストールされたかを確認できる。
2249
+ */
2250
+ function displayModuleDescriptions(selectedModules, fileResults, moduleList) {
2251
+ if (!fileResults.some((r) => r.action === "copied" || r.action === "created" || r.action === "overwritten")) return;
2252
+ log.info(pc$1.bold("Installed modules:"));
2253
+ const lines = [];
2254
+ for (const moduleId of selectedModules) {
2255
+ const mod = getModuleById(moduleId, moduleList);
2256
+ if (mod) {
2257
+ const description = mod.setupDescription || mod.description;
2258
+ lines.push(` ${pc$1.cyan("◆")} ${pc$1.bold(mod.name)}`);
2259
+ if (description) lines.push(` ${pc$1.dim(description)}`);
2260
+ }
2261
+ }
2262
+ if (lines.length > 0) log.message(lines.join("\n"));
2263
+ }
2264
+ /**
2265
+ * テンプレートソースを解決する。
2266
+ *
2267
+ * 優先順位:
2268
+ * 1. --from owner/repo が指定されていればそのまま使用
2269
+ * 2. git remote origin から owner を検出 → {owner}/.github
2270
+ * 3. フォールバック: tktcorporation/.github
2271
+ */
2272
+ function resolveTemplateSource(from) {
2273
+ if (from) {
2274
+ const slashIndex = from.indexOf("/");
2275
+ if (slashIndex === -1 || slashIndex === 0 || slashIndex === from.length - 1) throw new BermError(`Invalid --from format: "${from}"`, "Expected: owner/repo (e.g., my-org/my-templates)");
2276
+ return {
2277
+ sourceOwner: from.slice(0, slashIndex),
2278
+ sourceRepo: from.slice(slashIndex + 1)
2279
+ };
2280
+ }
2281
+ const detectedOwner = detectGitHubOwner();
2282
+ if (detectedOwner) return {
2283
+ sourceOwner: detectedOwner,
2284
+ sourceRepo: DEFAULT_TEMPLATE_REPO
2285
+ };
2286
+ return {
2287
+ sourceOwner: DEFAULT_TEMPLATE_OWNER,
2288
+ sourceRepo: DEFAULT_TEMPLATE_REPO
2289
+ };
2290
+ }
2291
+
2292
+ //#endregion
2293
+ //#region src/utils/config.ts
2294
+ /**
2295
+ * .devenv.json を読み込み
2296
+ */
2297
+ async function loadConfig(targetDir) {
2298
+ const content = await readFile(join(targetDir, ".devenv.json"), "utf-8");
2299
+ const data = JSON.parse(content);
2300
+ return configSchema.parse(data);
2301
+ }
2302
+ /**
2303
+ * .devenv.json を保存
2304
+ */
2305
+ async function saveConfig(targetDir, config) {
2306
+ await writeFile(join(targetDir, ".devenv.json"), `${JSON.stringify(config, null, 2)}\n`);
2307
+ }
2308
+
2309
+ //#endregion
2310
+ //#region src/utils/merge.ts
2311
+ /**
2312
+ * base/local/template のハッシュを比較し、各ファイルを分類する。
2313
+ *
2314
+ * 背景: pull/push 時にファイルごとの処理方法(自動上書き・マージ・スキップ等)を
2315
+ * 決定するために使用する。3つのハッシュマップの差分パターンで分類を行う。
2316
+ */
2317
+ function classifyFiles(opts) {
2318
+ const { baseHashes, localHashes, templateHashes } = opts;
2319
+ const result = {
2320
+ autoUpdate: [],
2321
+ localOnly: [],
2322
+ conflicts: [],
2323
+ newFiles: [],
2324
+ deletedFiles: [],
2325
+ unchanged: []
2326
+ };
2327
+ const allFiles = new Set([
2328
+ ...Object.keys(baseHashes),
2329
+ ...Object.keys(localHashes),
2330
+ ...Object.keys(templateHashes)
2331
+ ]);
2332
+ for (const file of allFiles) {
2333
+ const base = baseHashes[file];
2334
+ const local = localHashes[file];
2335
+ const template = templateHashes[file];
2336
+ if (base === void 0 && template !== void 0 && local === void 0) result.newFiles.push(file);
2337
+ else if (base !== void 0 && template === void 0) result.deletedFiles.push(file);
2338
+ else if (base === void 0 && template === void 0 && local !== void 0) result.localOnly.push(file);
2339
+ else if (base === void 0 && template !== void 0 && local !== void 0) if (local === template) result.unchanged.push(file);
2340
+ else result.conflicts.push(file);
2341
+ else {
2342
+ const localChanged = local !== base;
2343
+ const templateChanged = template !== base;
2344
+ if (!localChanged && !templateChanged) result.unchanged.push(file);
2345
+ else if (!localChanged && templateChanged) result.autoUpdate.push(file);
2346
+ else if (localChanged && !templateChanged) result.localOnly.push(file);
2347
+ else if (local === template) result.unchanged.push(file);
2348
+ else result.conflicts.push(file);
2349
+ }
2350
+ }
2351
+ return result;
2352
+ }
2353
+ /**
2354
+ * ファイルパスに応じた最適な 3-way マージを実行する。
2355
+ *
2356
+ * 背景: ファイルの種類によって最適なマージ戦略が異なる。
2357
+ * JSON/JSONC はキーレベルの構造マージが可能で、コンフリクトマーカーで
2358
+ * ファイル構造を壊さずにマージできる。テキストファイルは fuzz factor や
2359
+ * hunk 単位のマーカーで精度を上げる。
2360
+ *
2361
+ * @param filePath ファイルパス(拡張子でマージ戦略を選択)
2362
+ */
2363
+ function threeWayMerge(base, local, template, filePath) {
2364
+ if (local === template) return {
2365
+ content: local,
2366
+ hasConflicts: false,
2367
+ conflictDetails: []
2368
+ };
2369
+ if (filePath && isJsonFile(filePath)) {
2370
+ const jsonResult = mergeJsonContent(base, local, template);
2371
+ if (jsonResult !== null) return jsonResult;
2372
+ }
2373
+ return textThreeWayMerge(base, local, template);
2374
+ }
2375
+ /**
2376
+ * JSON/JSONC ファイルをキーレベルで 3-way マージする。
2377
+ *
2378
+ * 背景: JSON ファイルにコンフリクトマーカーを挿入するとパーサーが壊れるため、
2379
+ * キーレベルで変更を検出し、非コンフリクト部分を自動マージする。
2380
+ * コンフリクトがあるキーはローカル値を採用し、conflictDetails で報告する。
2381
+ * jsonc-parser の modify/applyEdits を使い、ローカルのフォーマットとコメントを保持する。
2382
+ *
2383
+ * @returns マージ結果。JSON パースに失敗した場合は null(テキストマージにフォールバック)。
2384
+ */
2385
+ function mergeJsonContent(base, local, template) {
2386
+ let baseObj;
2387
+ let localObj;
2388
+ let templateObj;
2389
+ try {
2390
+ baseObj = parse$1(base);
2391
+ localObj = parse$1(local);
2392
+ templateObj = parse$1(template);
2393
+ } catch {
2394
+ return null;
2395
+ }
2396
+ if (baseObj == null || localObj == null || templateObj == null) return null;
2397
+ const templateDiffs = getJsonDiffs(baseObj, templateObj);
2398
+ const localDiffs = getJsonDiffs(baseObj, localObj);
2399
+ let result = local;
2400
+ const conflictDetails = [];
2401
+ for (const diff of templateDiffs) {
2402
+ if (localDiffs.some((ld) => pathsOverlap(ld.path, diff.path))) {
2403
+ const localVal = getValueAtPath(localObj, diff.path);
2404
+ const templateVal = diff.type === "remove" ? void 0 : diff.value;
2405
+ if (deepEqual(localVal, templateVal)) continue;
2406
+ conflictDetails.push({
2407
+ path: diff.path,
2408
+ localValue: localVal,
2409
+ templateValue: templateVal
2410
+ });
2411
+ continue;
2412
+ }
2413
+ if (diff.type === "remove") {
2414
+ const edits = modify(result, diff.path, void 0, { formattingOptions: {
2415
+ tabSize: 2,
2416
+ insertSpaces: true
2417
+ } });
2418
+ result = applyEdits(result, edits);
2419
+ } else {
2420
+ const edits = modify(result, diff.path, diff.value, { formattingOptions: {
2421
+ tabSize: 2,
2422
+ insertSpaces: true
2423
+ } });
2424
+ result = applyEdits(result, edits);
2425
+ }
2426
+ }
2427
+ return {
2428
+ content: result,
2429
+ hasConflicts: conflictDetails.length > 0,
2430
+ conflictDetails
2431
+ };
2432
+ }
2433
+ /**
2434
+ * 2つの JSON 値の差分をパス単位で検出する。
2435
+ * オブジェクトはキーレベルで再帰比較し、配列はアトミックに扱う。
2436
+ */
2437
+ function getJsonDiffs(base, target, path = []) {
2438
+ if (deepEqual(base, target)) return [];
2439
+ if (typeof base !== typeof target || base === null || target === null || typeof base !== "object" || typeof target !== "object" || Array.isArray(base) !== Array.isArray(target)) return [{
2440
+ path,
2441
+ type: "replace",
2442
+ value: target
2443
+ }];
2444
+ if (Array.isArray(base)) return [{
2445
+ path,
2446
+ type: "replace",
2447
+ value: target
2448
+ }];
2449
+ const diffs = [];
2450
+ const baseObj = base;
2451
+ const targetObj = target;
2452
+ const allKeys = new Set([...Object.keys(baseObj), ...Object.keys(targetObj)]);
2453
+ for (const key of allKeys) {
2454
+ const childPath = [...path, key];
2455
+ if (!(key in baseObj)) diffs.push({
2456
+ path: childPath,
2457
+ type: "add",
2458
+ value: targetObj[key]
2459
+ });
2460
+ else if (!(key in targetObj)) diffs.push({
2461
+ path: childPath,
2462
+ type: "remove"
2463
+ });
2464
+ else diffs.push(...getJsonDiffs(baseObj[key], targetObj[key], childPath));
2465
+ }
2466
+ return diffs;
2467
+ }
2468
+ /**
2469
+ * 2つのパスが重複(祖先/子孫/同一)するかを判定する。
2470
+ * 例: ["a", "b"] と ["a", "b", "c"] → true(祖先と子孫)
2471
+ * ["a", "b"] と ["a", "x"] → false
2472
+ */
2473
+ function pathsOverlap(pathA, pathB) {
2474
+ const minLen = Math.min(pathA.length, pathB.length);
2475
+ for (let i = 0; i < minLen; i++) if (pathA[i] !== pathB[i]) return false;
2476
+ return true;
2477
+ }
2478
+ /** ネストされたオブジェクトからパスを辿って値を取得する */
2479
+ function getValueAtPath(obj, path) {
2480
+ let current = obj;
2481
+ for (const key of path) {
2482
+ if (current == null || typeof current !== "object") return void 0;
2483
+ current = current[key];
2484
+ }
2485
+ return current;
2486
+ }
2487
+ /** 2つの値を深い比較で等しいか判定する */
2488
+ function deepEqual(a, b) {
2489
+ if (a === b) return true;
2490
+ if (a == null || b == null) return false;
2491
+ if (typeof a !== typeof b) return false;
2492
+ if (typeof a !== "object") return false;
2493
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
2494
+ if (Array.isArray(a) && Array.isArray(b)) {
2495
+ if (a.length !== b.length) return false;
2496
+ return a.every((val, i) => deepEqual(val, b[i]));
2497
+ }
2498
+ const aObj = a;
2499
+ const bObj = b;
2500
+ const aKeys = Object.keys(aObj);
2501
+ const bKeys = Object.keys(bObj);
2502
+ if (aKeys.length !== bKeys.length) return false;
2503
+ return aKeys.every((key) => key in bObj && deepEqual(aObj[key], bObj[key]));
2504
+ }
2505
+ function isJsonFile(filePath) {
2506
+ const lower = filePath.toLowerCase();
2507
+ return lower.endsWith(".json") || lower.endsWith(".jsonc");
2508
+ }
2509
+ /**
2510
+ * テキストファイルの 3-way マージ。fuzz factor によるパッチ適用と
2511
+ * hunk 単位のコンフリクトマーカーで、従来のファイル全体マーカーを改善。
2512
+ *
2513
+ * 背景: TOML 等の構造ファイルにファイル全体のコンフリクトマーカーを入れると
2514
+ * パーサーが壊れる。hunk 単位にすることで影響範囲を最小化する。
2515
+ *
2516
+ * 戦略:
2517
+ * 1. 標準パッチ適用(fuzz=0)
2518
+ * 2. fuzz factor を上げてリトライ(fuzz=2)
2519
+ * 3. 失敗時: hunk 単位で適用を試み、失敗した hunk のみにマーカーを付与
2520
+ */
2521
+ function textThreeWayMerge(base, local, template) {
2522
+ const patch = createPatch("file", base, template);
2523
+ const result = applyPatch(local, patch);
2524
+ if (typeof result === "string") return {
2525
+ content: result,
2526
+ hasConflicts: false,
2527
+ conflictDetails: []
2528
+ };
2529
+ const resultFuzzy = applyPatch(local, patch, { fuzzFactor: 2 });
2530
+ if (typeof resultFuzzy === "string") return {
2531
+ content: resultFuzzy,
2532
+ hasConflicts: false,
2533
+ conflictDetails: []
2534
+ };
2535
+ return mergeWithPerHunkMarkers(base, local, template);
2536
+ }
2537
+ /**
2538
+ * hunk 単位でパッチを適用し、失敗した hunk のみにコンフリクトマーカーを付与する。
2539
+ *
2540
+ * 背景: ファイル全体をマーカーで囲むとファイルが完全に壊れる。
2541
+ * hunk 単位にすることで、成功した部分は正常なまま保持され、
2542
+ * コンフリクト箇所だけがマーカー付きになる。
2543
+ */
2544
+ function mergeWithPerHunkMarkers(base, local, template) {
2545
+ const patchObj = structuredPatch("file", "file", base, template);
2546
+ const localLines = local.split("\n");
2547
+ const resultLines = [];
2548
+ let localIdx = 0;
2549
+ let hasConflicts = false;
2550
+ for (const hunk of patchObj.hunks) {
2551
+ const hunkLocalStart = hunk.oldStart - 1;
2552
+ while (localIdx < hunkLocalStart && localIdx < localLines.length) {
2553
+ resultLines.push(localLines[localIdx]);
2554
+ localIdx++;
2555
+ }
2556
+ const hunkApplied = tryApplyHunk(localLines, hunk);
2557
+ if (hunkApplied !== null) {
2558
+ resultLines.push(...hunkApplied);
2559
+ localIdx = hunkLocalStart + hunk.oldLines;
2560
+ } else {
2561
+ hasConflicts = true;
2562
+ const localSection = [];
2563
+ for (let i = 0; i < hunk.oldLines && hunkLocalStart + i < localLines.length; i++) localSection.push(localLines[hunkLocalStart + i]);
2564
+ const templateSection = [];
2565
+ for (const line of hunk.lines) if (line.startsWith("+")) templateSection.push(line.slice(1));
2566
+ else if (line.startsWith(" ")) templateSection.push(line.slice(1));
2567
+ resultLines.push("<<<<<<< LOCAL");
2568
+ resultLines.push(...localSection);
2569
+ resultLines.push("=======");
2570
+ resultLines.push(...templateSection);
2571
+ resultLines.push(">>>>>>> TEMPLATE");
2572
+ localIdx = hunkLocalStart + hunk.oldLines;
2573
+ }
2574
+ }
2575
+ while (localIdx < localLines.length) {
2576
+ resultLines.push(localLines[localIdx]);
2577
+ localIdx++;
2578
+ }
2579
+ return {
2580
+ content: resultLines.join("\n"),
2581
+ hasConflicts,
2582
+ conflictDetails: []
2583
+ };
2584
+ }
2585
+ /**
2586
+ * 単一の hunk をローカル行に適用する。
2587
+ * コンテキスト行が一致し、削除行が存在する場合に適用成功として新しい行を返す。
2588
+ * 一致しない場合は null を返す。
2589
+ */
2590
+ function tryApplyHunk(localLines, hunk) {
2591
+ const startIdx = hunk.oldStart - 1;
2592
+ const expectedOldLines = [];
2593
+ const newLines = [];
2594
+ for (const line of hunk.lines) {
2595
+ const op = line[0];
2596
+ const content = line.slice(1);
2597
+ if (op === " ") {
2598
+ expectedOldLines.push(content);
2599
+ newLines.push(content);
2600
+ } else if (op === "-") expectedOldLines.push(content);
2601
+ else if (op === "+") newLines.push(content);
2602
+ }
2603
+ if (startIdx + expectedOldLines.length > localLines.length) return null;
2604
+ for (let i = 0; i < expectedOldLines.length; i++) if (localLines[startIdx + i] !== expectedOldLines[i]) return null;
2605
+ return newLines;
2606
+ }
2607
+ /**
2608
+ * base/local/template の3つの内容から 3-way マージを実行する。
2609
+ * (後方互換性のためのラッパー)
2610
+ *
2611
+ * @deprecated filePath を渡す新しい threeWayMerge を使用してください
2612
+ */
2613
+ /**
2614
+ * ファイル内容にコンフリクトマーカーが含まれるかを検出する。
2615
+ *
2616
+ * 背景: マージ後のファイルにユーザーが手動解決すべきコンフリクトが
2617
+ * 残っているかを判定するために使用する。
2618
+ */
2619
+ function hasConflictMarkers(content) {
2620
+ const lines = [];
2621
+ const contentLines = content.split("\n");
2622
+ for (let i = 0; i < contentLines.length; i++) {
2623
+ const line = contentLines[i];
2624
+ if (line.startsWith("<<<<<<<") || line.startsWith("=======") || line.startsWith(">>>>>>>")) lines.push(i + 1);
2625
+ }
2626
+ return {
2627
+ found: lines.length > 0,
2628
+ lines
2629
+ };
2630
+ }
2631
+
2632
+ //#endregion
2633
+ //#region src/commands/pull.ts
2634
+ /**
2635
+ * テンプレートの最新更新をローカルに反映するコマンド。
2636
+ *
2637
+ * 背景: init 後にテンプレートが更新された場合、ローカルの変更を保持しつつ
2638
+ * テンプレートの変更を取り込むために使用する。base/local/template の
2639
+ * 3-way マージにより、コンフリクトを最小限に抑える。
2640
+ *
2641
+ * 呼び出し元: CLI から `ziku pull` で実行
2642
+ */
2643
+ const pullCommand = defineCommand({
2644
+ meta: {
2645
+ name: "pull",
2646
+ description: "Pull latest template updates"
2647
+ },
2648
+ args: {
2649
+ dir: {
2650
+ type: "positional",
2651
+ description: "Project directory",
2652
+ default: "."
2653
+ },
2654
+ force: {
2655
+ type: "boolean",
2656
+ alias: "f",
2657
+ description: "Skip confirmations",
2658
+ default: false
2659
+ },
2660
+ continue: {
2661
+ type: "boolean",
2662
+ description: "Continue after resolving merge conflicts",
2663
+ default: false
2664
+ }
2665
+ },
2666
+ async run({ args }) {
2667
+ intro("pull");
2668
+ const targetDir = resolve(args.dir);
2669
+ let config;
2670
+ try {
2671
+ config = await loadConfig(targetDir);
2672
+ } catch {
2673
+ throw new BermError("Not initialized", "Run `ziku init` first");
2674
+ }
2675
+ if (args.continue) {
2676
+ await runContinue(targetDir, config);
2677
+ return;
2678
+ }
2679
+ if (config.modules.length === 0) {
2680
+ log.warn("No modules installed");
2681
+ return;
2682
+ }
2683
+ log.step("Fetching template...");
2684
+ const { templateDir, cleanup } = await withSpinner("Downloading template from GitHub...", () => downloadTemplateToTemp(targetDir, `gh:${config.source.owner}/${config.source.repo}`));
2685
+ try {
2686
+ let moduleList;
2687
+ if (modulesFileExists(templateDir)) moduleList = (await loadModulesFile(templateDir)).modules;
2688
+ else moduleList = defaultModules;
2689
+ log.step("Analyzing changes...");
2690
+ const patterns = getInstalledModulePatterns(config.modules, moduleList, config);
2691
+ const [templateHashes, localHashes] = await Promise.all([hashFiles(templateDir, patterns), hashFiles(targetDir, patterns)]);
2692
+ const classification = classifyFiles({
2693
+ baseHashes: config.baseHashes ?? {},
2694
+ localHashes,
2695
+ templateHashes
2696
+ });
2697
+ if (classification.autoUpdate.length + classification.newFiles.length + classification.conflicts.length + classification.deletedFiles.length === 0) {
2698
+ log.success("Already up to date");
2699
+ outro("No changes needed");
2700
+ return;
2701
+ }
2702
+ logPullSummary(classification);
2703
+ for (const file of classification.autoUpdate) {
2704
+ const content = await readFile(join(templateDir, file), "utf-8");
2705
+ const destPath = join(targetDir, file);
2706
+ const destDir = dirname(destPath);
2707
+ if (!existsSync(destDir)) await mkdir(destDir, { recursive: true });
2708
+ await writeFile(destPath, content, "utf-8");
2709
+ }
2710
+ if (classification.autoUpdate.length > 0) log.success(`Updated ${classification.autoUpdate.length} file(s)`);
2711
+ for (const file of classification.newFiles) {
2712
+ const content = await readFile(join(templateDir, file), "utf-8");
2713
+ const destPath = join(targetDir, file);
2714
+ const destDir = dirname(destPath);
2715
+ if (!existsSync(destDir)) await mkdir(destDir, { recursive: true });
2716
+ await writeFile(destPath, content, "utf-8");
2717
+ }
2718
+ if (classification.newFiles.length > 0) log.success(`Added ${classification.newFiles.length} new file(s)`);
2719
+ let hasUnresolvedConflicts = false;
2720
+ if (classification.conflicts.length > 0) {
2721
+ let baseTemplateDir;
2722
+ let baseCleanup;
2723
+ if (config.baseRef) try {
2724
+ log.info(`Downloading base version (${config.baseRef.slice(0, 7)}...) for merge...`);
2725
+ const baseResult = await downloadTemplateToTemp(targetDir, `gh:${config.source.owner}/${config.source.repo}#${config.baseRef}`);
2726
+ baseTemplateDir = baseResult.templateDir;
2727
+ baseCleanup = baseResult.cleanup;
2728
+ } catch {
2729
+ log.warn("Could not download base version. Falling back to 2-way conflict markers.");
2730
+ }
2731
+ try {
2732
+ for (const file of classification.conflicts) {
2733
+ const localContent = await readFile(join(targetDir, file), "utf-8");
2734
+ const templateContent = await readFile(join(templateDir, file), "utf-8");
2735
+ let baseContent = "";
2736
+ if (baseTemplateDir && existsSync(join(baseTemplateDir, file))) baseContent = await readFile(join(baseTemplateDir, file), "utf-8");
2737
+ const result = threeWayMerge(baseContent, localContent, templateContent, file);
2738
+ await writeFile(join(targetDir, file), result.content, "utf-8");
2739
+ if (result.hasConflicts) {
2740
+ hasUnresolvedConflicts = true;
2741
+ logMergeConflict(file, result);
2742
+ }
2743
+ }
2744
+ if (hasUnresolvedConflicts) log.warn("Some files have conflicts. Resolve them, then run `ziku pull --continue`");
2745
+ } finally {
2746
+ baseCleanup?.();
2747
+ }
2748
+ if (hasUnresolvedConflicts) {
2749
+ const latestRef$1 = await resolveLatestCommitSha(config.source.owner, config.source.repo);
2750
+ await saveConfig(targetDir, {
2751
+ ...config,
2752
+ pendingMerge: {
2753
+ conflicts: classification.conflicts,
2754
+ templateHashes,
2755
+ ...latestRef$1 ? { latestRef: latestRef$1 } : {}
2756
+ }
2757
+ });
2758
+ outro("Merge paused — resolve conflicts then run `ziku pull --continue`");
2759
+ return;
2760
+ }
2761
+ }
2762
+ if (classification.deletedFiles.length > 0) {
2763
+ let filesToDelete;
2764
+ if (args.force) {
2765
+ filesToDelete = classification.deletedFiles;
2766
+ log.info(`Deleting ${filesToDelete.length} file(s) removed from template...`);
2767
+ } else filesToDelete = await selectDeletedFiles(classification.deletedFiles);
2768
+ for (const file of filesToDelete) try {
2769
+ await rm(join(targetDir, file), { force: true });
2770
+ log.success(`Deleted: ${file}`);
2771
+ } catch {
2772
+ log.warn(`Could not delete: ${file}`);
2773
+ }
2774
+ }
2775
+ const latestRef = await resolveLatestCommitSha(config.source.owner, config.source.repo);
2776
+ await saveConfig(targetDir, {
2777
+ ...config,
2778
+ baseHashes: templateHashes,
2779
+ ...latestRef ? { baseRef: latestRef } : {}
2780
+ });
2781
+ outro("Pull complete");
2782
+ } finally {
2783
+ cleanup();
2784
+ }
2785
+ }
2786
+ });
2787
+ /**
2788
+ * `--continue` モードの処理: コンフリクト解決後に baseHashes/baseRef を更新する。
2789
+ *
2790
+ * 背景: `ziku pull` でコンフリクトが発生した際、ユーザーが手動解決した後に
2791
+ * `ziku pull --continue` を実行することで状態更新が完了する。
2792
+ * git の `git merge --continue` / `git rebase --continue` パターンを踏襲。
2793
+ *
2794
+ * 対になる操作: pull.ts の pendingMerge 保存ロジック(コンフリクト発生時)
2795
+ */
2796
+ async function runContinue(targetDir, config) {
2797
+ if (!config.pendingMerge) throw new BermError("No pending merge found", "Run `ziku pull` first to start a merge");
2798
+ const { conflicts, templateHashes, latestRef } = config.pendingMerge;
2799
+ const stillConflicted = [];
2800
+ for (const file of conflicts) try {
2801
+ if (hasConflictMarkers(await readFile(join(targetDir, file), "utf-8")).found) stillConflicted.push(file);
2802
+ } catch {}
2803
+ if (stillConflicted.length > 0) {
2804
+ for (const file of stillConflicted) log.warn(`Still has conflict markers: ${pc$1.cyan(file)}`);
2805
+ throw new BermError("Unresolved conflicts remain", "Resolve all conflict markers then run `ziku pull --continue` again");
2806
+ }
2807
+ await saveConfig(targetDir, {
2808
+ ...config,
2809
+ baseHashes: templateHashes,
2810
+ ...latestRef ? { baseRef: latestRef } : {},
2811
+ pendingMerge: void 0
2812
+ });
2813
+ log.success("All conflicts resolved");
2814
+ outro("Pull complete");
2815
+ }
2816
+ /**
2817
+ * 1ファイルのマージ結果をユーザーに報告する。
2818
+ *
2819
+ * 背景: JSON/JSONC の構造マージ(conflictDetails あり)とテキストマージ(conflictMarkers あり)で
2820
+ * メッセージが異なるため、このヘルパーに集約する。
2821
+ * Step 8 と他の呼び出し元でログ出力のロジックを共有する。
2822
+ */
2823
+ function logMergeConflict(file, result) {
2824
+ if (result.conflictDetails.length > 0) {
2825
+ log.warn(`Conflict in ${pc$1.cyan(file)} — review these keys:`);
2826
+ for (const detail of result.conflictDetails) {
2827
+ const pathStr = detail.path.join(".");
2828
+ log.message(` ${pc$1.dim("•")} ${pc$1.yellow(pathStr)} — kept local value`);
2829
+ }
2830
+ } else log.warn(`Conflict in ${pc$1.cyan(file)} — manual resolution needed`);
2831
+ }
2832
+ /**
2833
+ * インストール済みモジュールの有効パターンを全て取得する。
2834
+ *
2835
+ * 背景: pull 時にハッシュ計算対象のファイルを特定するため、
2836
+ * 各モジュールの patterns に excludePatterns を適用した結果を集約する。
2837
+ */
2838
+ function getInstalledModulePatterns(moduleIds, moduleList, config) {
2839
+ const patterns = [];
2840
+ for (const moduleId of moduleIds) {
2841
+ const mod = moduleList.find((m) => m.id === moduleId);
2842
+ if (!mod) continue;
2843
+ patterns.push(...getEffectivePatterns(moduleId, mod.patterns, config));
2844
+ }
2845
+ return patterns;
2846
+ }
2847
+ /**
2848
+ * pull のサマリーを表示する。
2849
+ *
2850
+ * 背景: ユーザーが pull の影響範囲を把握できるよう、
2851
+ * 分類結果を色分けして一覧表示する。diff.ts の表示スタイルに合わせる。
2852
+ */
2853
+ function logPullSummary(classification) {
2854
+ const lines = [];
2855
+ for (const file of classification.autoUpdate) lines.push(`${pc$1.cyan("↓")} ${pc$1.cyan(file)}`);
2856
+ for (const file of classification.newFiles) lines.push(`${pc$1.green("+")} ${pc$1.green(file)}`);
2857
+ for (const file of classification.conflicts) lines.push(`${pc$1.yellow("!")} ${pc$1.yellow(file)}`);
2858
+ for (const file of classification.deletedFiles) lines.push(`${pc$1.red("-")} ${pc$1.red(file)}`);
2859
+ const summaryParts = [
2860
+ classification.autoUpdate.length > 0 ? pc$1.cyan(`↓${classification.autoUpdate.length} updated`) : null,
2861
+ classification.newFiles.length > 0 ? pc$1.green(`+${classification.newFiles.length} new`) : null,
2862
+ classification.conflicts.length > 0 ? pc$1.yellow(`!${classification.conflicts.length} conflicts`) : null,
2863
+ classification.deletedFiles.length > 0 ? pc$1.red(`-${classification.deletedFiles.length} deleted`) : null
2864
+ ].filter(Boolean).join(pc$1.dim(" | "));
2865
+ log.message([
2866
+ ...lines,
2867
+ "",
2868
+ summaryParts
2869
+ ].join("\n"));
2870
+ }
2871
+
2872
+ //#endregion
2873
+ //#region src/utils/readme.ts
2874
+ /**
2875
+ * README.md の自動生成ユーティリティ
2876
+ */
2877
+ const MARKERS = {
2878
+ features: {
2879
+ start: "<!-- FEATURES:START -->",
2880
+ end: "<!-- FEATURES:END -->"
2881
+ },
2882
+ commands: {
2883
+ start: "<!-- COMMANDS:START -->",
2884
+ end: "<!-- COMMANDS:END -->"
2885
+ },
2886
+ files: {
2887
+ start: "<!-- FILES:START -->",
2888
+ end: "<!-- FILES:END -->"
2889
+ }
2890
+ };
2891
+ /**
2892
+ * modules.jsonc を読み込み
2893
+ */
2894
+ async function loadModulesFromFile(modulesPath) {
2895
+ if (!existsSync(modulesPath)) return [];
2896
+ return parse$1(await readFile(modulesPath, "utf-8")).modules;
2897
+ }
2898
+ /**
2899
+ * 機能セクションを生成
2900
+ */
2901
+ function generateFeaturesSection(modules) {
2902
+ const lines = [];
2903
+ lines.push("## 機能\n");
2904
+ for (const mod of modules) lines.push(`- **${mod.name}** - ${mod.description}`);
2905
+ lines.push("");
2906
+ return lines.join("\n");
2907
+ }
2908
+ /**
2909
+ * 生成されるファイルセクションを生成
2910
+ */
2911
+ function generateFilesSection(modules) {
2912
+ const lines = [];
2913
+ lines.push("## 生成されるファイル\n");
2914
+ lines.push("選択したモジュールに応じて以下のファイルが生成されます:\n");
2915
+ for (const mod of modules) {
2916
+ const dirName = mod.id === "." ? "ルート" : `\`${mod.id}/\``;
2917
+ lines.push(`### ${dirName}\n`);
2918
+ lines.push(`${mod.description}\n`);
2919
+ for (const pattern of mod.patterns) {
2920
+ const displayPattern = pattern.includes("*") ? `\`${pattern}\` (パターン)` : `\`${pattern}\``;
2921
+ lines.push(`- ${displayPattern}`);
2922
+ }
2923
+ lines.push("");
2924
+ }
2925
+ lines.push("### 設定ファイル\n");
2926
+ lines.push("- `.devenv.json` - このツールの設定(適用したモジュール情報)\n");
2927
+ return lines.join("\n");
2928
+ }
2929
+ /**
2930
+ * README のマーカー間を更新
2931
+ */
2932
+ function updateSection(content, startMarker, endMarker, newSection) {
2933
+ const startIndex = content.indexOf(startMarker);
2934
+ const endIndex = content.indexOf(endMarker);
2935
+ if (startIndex === -1 || endIndex === -1) return {
2936
+ content,
2937
+ updated: false
2938
+ };
2939
+ const newContent = `${content.slice(0, startIndex + startMarker.length)}\n\n${newSection}\n${content.slice(endIndex)}`;
2940
+ return {
2941
+ content: newContent,
2942
+ updated: newContent !== content
2943
+ };
2944
+ }
2945
+ /**
2946
+ * README を生成
2947
+ */
2948
+ async function generateReadme(options) {
2949
+ const { readmePath, modulesPath, generateCommandsSection } = options;
2950
+ if (!existsSync(readmePath)) return {
2951
+ updated: false,
2952
+ content: "",
2953
+ readmePath
2954
+ };
2955
+ const modules = await loadModulesFromFile(modulesPath);
2956
+ let readme = await readFile(readmePath, "utf-8");
2957
+ let anyUpdated = false;
2958
+ if (modules.length > 0) {
2959
+ const featuresSection = generateFeaturesSection(modules);
2960
+ const result = updateSection(readme, MARKERS.features.start, MARKERS.features.end, featuresSection);
2961
+ readme = result.content;
2962
+ anyUpdated = anyUpdated || result.updated;
2963
+ }
2964
+ if (generateCommandsSection) {
2965
+ const commandsSection = await generateCommandsSection();
2966
+ const result = updateSection(readme, MARKERS.commands.start, MARKERS.commands.end, commandsSection);
2967
+ readme = result.content;
2968
+ anyUpdated = anyUpdated || result.updated;
2969
+ }
2970
+ if (modules.length > 0) {
2971
+ const filesSection = generateFilesSection(modules);
2972
+ const result = updateSection(readme, MARKERS.files.start, MARKERS.files.end, filesSection);
2973
+ readme = result.content;
2974
+ anyUpdated = anyUpdated || result.updated;
2975
+ }
2976
+ return {
2977
+ updated: anyUpdated,
2978
+ content: readme,
2979
+ readmePath
2980
+ };
2981
+ }
2982
+ /**
2983
+ * README を更新して保存
2984
+ */
2985
+ async function updateReadmeFile(options) {
2986
+ const result = await generateReadme(options);
2987
+ if (result.updated) await writeFile(result.readmePath, result.content);
2988
+ return result;
2989
+ }
2990
+ /**
2991
+ * プロジェクトディレクトリ内の README を検出して更新
2992
+ * @param targetDir プロジェクトのルートディレクトリ
2993
+ * @param templateDir テンプレートディレクトリ(modules.jsonc の場所)
2994
+ */
2995
+ async function detectAndUpdateReadme(targetDir, templateDir) {
2996
+ const readmePath = join(targetDir, "README.md");
2997
+ const modulesPath = join(templateDir, ".devenv/modules.jsonc");
2998
+ if (!existsSync(readmePath)) return null;
2999
+ const readmeContent = await readFile(readmePath, "utf-8");
3000
+ if (!(readmeContent.includes(MARKERS.features.start) || readmeContent.includes(MARKERS.files.start))) return null;
3001
+ return updateReadmeFile({
3002
+ readmePath,
3003
+ modulesPath
3004
+ });
3005
+ }
3006
+
3007
+ //#endregion
3008
+ //#region src/commands/push.ts
3009
+ /**
3010
+ * FileDiff の差分統計を "+N -M" 形式でフォーマットする。
3011
+ *
3012
+ * 背景: git push の出力に合わせ、変更行数を可視化する。
3013
+ * "modified" の場合は行数の差を表示(詳細な unified diff は ziku diff で確認可能)。
3014
+ */
3015
+ function formatFileStat(file) {
3016
+ let additions = 0;
3017
+ let deletions = 0;
3018
+ if (file.type === "added" && file.localContent) additions = file.localContent.split("\n").length;
3019
+ else if (file.type === "deleted" && file.templateContent) deletions = file.templateContent.split("\n").length;
3020
+ else if (file.type === "modified") {
3021
+ const local = file.localContent?.split("\n").length ?? 0;
3022
+ const tmpl = file.templateContent?.split("\n").length ?? 0;
3023
+ additions = Math.max(0, local - tmpl);
3024
+ deletions = Math.max(0, tmpl - local);
3025
+ if (additions === 0 && deletions === 0 && file.localContent !== file.templateContent) {
3026
+ additions = 1;
3027
+ deletions = 1;
3028
+ }
3029
+ }
3030
+ const parts = [];
3031
+ if (additions > 0) parts.push(pc$1.green(`+${additions}`));
3032
+ if (deletions > 0) parts.push(pc$1.red(`-${deletions}`));
3033
+ return parts.length > 0 ? parts.join(" ") : pc$1.dim("~");
3034
+ }
3035
+ /**
3036
+ * process.exit() でも確実に一時ディレクトリを削除するための同期クリーンアップを登録する。
3037
+ *
3038
+ * 背景: handleCancel() が process.exit(0) を呼ぶため async finally ブロックが
3039
+ * スキップされ、.devenv-temp が残る問題への対策。process.on('exit') は
3040
+ * process.exit() でも発火するが同期処理のみ実行可能なため rmSync を使用。
3041
+ *
3042
+ * 削除条件: handleCancel() が process.exit() を使わなくなった場合。
3043
+ */
3044
+ function registerSyncCleanup(tempDir) {
3045
+ const cleanup = () => {
3046
+ try {
3047
+ if (existsSync(tempDir)) rmSync(tempDir, {
3048
+ recursive: true,
3049
+ force: true
3050
+ });
3051
+ } catch {}
3052
+ };
3053
+ process.on("exit", cleanup);
3054
+ return () => {
3055
+ process.removeListener("exit", cleanup);
3056
+ };
3057
+ }
3058
+ const MODULES_FILE_PATH = ".devenv/modules.jsonc";
3059
+ const README_PATH = "README.md";
3060
+ /**
3061
+ * ローカルの modules.jsonc とテンプレートの modules.jsonc を比較し、
3062
+ * ローカルにのみ存在するモジュール(track コマンドで追加されたもの等)を検出してマージする。
3063
+ * テンプレートの raw content をベースに新モジュールを追加した内容を返す。
3064
+ */
3065
+ async function detectLocalModuleAdditions(targetDir, templateModules, templateRawContent) {
3066
+ if (!modulesFileExists(targetDir)) return {
3067
+ mergedModuleList: templateModules,
3068
+ newModuleIds: [],
3069
+ updatedModulesContent: void 0
3070
+ };
3071
+ const local = await loadModulesFile(targetDir);
3072
+ const templateModuleIds = new Set(templateModules.map((m) => m.id));
3073
+ const newModules = local.modules.filter((m) => !templateModuleIds.has(m.id));
3074
+ if (newModules.length === 0) {
3075
+ let updatedContent$1 = templateRawContent;
3076
+ let hasPatternAdditions = false;
3077
+ for (const localMod of local.modules) {
3078
+ const templateMod = templateModules.find((m) => m.id === localMod.id);
3079
+ if (!templateMod) continue;
3080
+ const newPatterns = localMod.patterns.filter((p$1) => !templateMod.patterns.includes(p$1));
3081
+ if (newPatterns.length > 0) {
3082
+ updatedContent$1 = addPatternToModulesFileWithCreate(updatedContent$1, localMod.id, newPatterns);
3083
+ hasPatternAdditions = true;
3084
+ }
3085
+ }
3086
+ if (hasPatternAdditions) return {
3087
+ mergedModuleList: parse$1(updatedContent$1).modules,
3088
+ newModuleIds: [],
3089
+ updatedModulesContent: updatedContent$1
3090
+ };
3091
+ return {
3092
+ mergedModuleList: templateModules,
3093
+ newModuleIds: [],
3094
+ updatedModulesContent: void 0
3095
+ };
3096
+ }
3097
+ let updatedContent = templateRawContent;
3098
+ for (const mod of newModules) updatedContent = addPatternToModulesFileWithCreate(updatedContent, mod.id, mod.patterns, {
3099
+ name: mod.name,
3100
+ description: mod.description
3101
+ });
3102
+ for (const localMod of local.modules) {
3103
+ const templateMod = templateModules.find((m) => m.id === localMod.id);
3104
+ if (!templateMod) continue;
3105
+ const newPatterns = localMod.patterns.filter((p$1) => !templateMod.patterns.includes(p$1));
3106
+ if (newPatterns.length > 0) updatedContent = addPatternToModulesFileWithCreate(updatedContent, localMod.id, newPatterns);
3107
+ }
3108
+ return {
3109
+ mergedModuleList: parse$1(updatedContent).modules,
3110
+ newModuleIds: newModules.map((m) => m.id),
3111
+ updatedModulesContent: updatedContent
3112
+ };
3113
+ }
3114
+ /**
3115
+ * --execute モード: マニフェストファイルを使ってPRを作成
3116
+ */
3117
+ async function runExecuteMode(targetDir, config, messageOverride) {
3118
+ log.step("Loading manifest...");
3119
+ let manifest;
3120
+ try {
3121
+ manifest = await loadManifest(targetDir);
3122
+ } catch (error) {
3123
+ throw new BermError(error.message);
3124
+ }
3125
+ const selectedFilePaths = getSelectedFilePaths(manifest);
3126
+ const selectedUntracked = getSelectedUntrackedFiles(manifest);
3127
+ if (selectedFilePaths.length === 0 && selectedUntracked.size === 0) {
3128
+ log.info("No files selected in manifest. Nothing to push.");
3129
+ log.message(pc$1.dim(`Edit ${MANIFEST_FILENAME} and set 'selected: true' for files you want to include.`));
3130
+ return;
3131
+ }
3132
+ log.success(`${selectedFilePaths.length} files selected from manifest`);
3133
+ if (selectedUntracked.size > 0) {
3134
+ const untrackedCount = Array.from(selectedUntracked.values()).reduce((sum, files) => sum + files.length, 0);
3135
+ log.success(`${untrackedCount} untracked files will be added to patterns`);
3136
+ }
3137
+ log.step("Fetching template...");
3138
+ const templateSource = buildTemplateSource(config.source);
3139
+ const tempDir = join(targetDir, ".devenv-temp");
3140
+ const unregisterCleanup = registerSyncCleanup(tempDir);
3141
+ try {
3142
+ const { dir: templateDir } = await withSpinner("Downloading template from GitHub...", () => downloadTemplate(templateSource, {
3143
+ dir: tempDir,
3144
+ force: true
3145
+ }));
3146
+ let moduleList;
3147
+ let modulesRawContent;
3148
+ if (modulesFileExists(templateDir)) {
3149
+ const loaded = await loadModulesFile(templateDir);
3150
+ moduleList = loaded.modules;
3151
+ modulesRawContent = loaded.rawContent;
3152
+ } else moduleList = defaultModules;
3153
+ const effectiveModuleIds = [...config.modules];
3154
+ let updatedModulesContent;
3155
+ if (modulesRawContent) {
3156
+ const localAdditions = await detectLocalModuleAdditions(targetDir, moduleList, modulesRawContent);
3157
+ moduleList = localAdditions.mergedModuleList;
3158
+ updatedModulesContent = localAdditions.updatedModulesContent;
3159
+ for (const id of localAdditions.newModuleIds) if (!effectiveModuleIds.includes(id)) effectiveModuleIds.push(id);
3160
+ }
3161
+ if (selectedUntracked.size > 0 && modulesRawContent) {
3162
+ let currentContent = updatedModulesContent || modulesRawContent;
3163
+ for (const [moduleId, filePaths] of selectedUntracked) currentContent = addPatternToModulesFileWithCreate(currentContent, moduleId, filePaths);
3164
+ updatedModulesContent = currentContent;
3165
+ moduleList = parse$1(updatedModulesContent).modules;
3166
+ }
3167
+ log.step("Preparing files...");
3168
+ const diff = await withSpinner("Analyzing differences...", () => detectDiff({
3169
+ targetDir,
3170
+ templateDir,
3171
+ moduleIds: effectiveModuleIds,
3172
+ config,
3173
+ moduleList
3174
+ }));
3175
+ const currentPushableFiles = getPushableFiles(diff);
3176
+ const currentFilePaths = new Set(currentPushableFiles.map((f) => f.path));
3177
+ const manifestFilePaths = new Set(manifest.files.map((f) => f.path));
3178
+ const missingFiles = selectedFilePaths.filter((p$1) => !currentFilePaths.has(p$1));
3179
+ const newFiles = currentPushableFiles.filter((f) => !manifestFilePaths.has(f.path)).map((f) => f.path);
3180
+ if (missingFiles.length > 0 || newFiles.length > 0) {
3181
+ log.warn("Manifest is out of sync with current changes:");
3182
+ if (missingFiles.length > 0) log.message(pc$1.dim(` Missing files (in manifest but no longer changed): ${missingFiles.join(", ")}`));
3183
+ if (newFiles.length > 0) log.message(pc$1.dim(` New files (changed but not in manifest): ${newFiles.join(", ")}`));
3184
+ log.message(pc$1.dim(" Consider running 'ziku push --prepare' to regenerate the manifest."));
3185
+ }
3186
+ const pushableFiles = getPushableFiles(diff);
3187
+ const allSelectedPaths = [...selectedFilePaths, ...Array.from(selectedUntracked.values()).flat()];
3188
+ const files = pushableFiles.filter((f) => allSelectedPaths.includes(f.path)).map((f) => ({
3189
+ path: f.path,
3190
+ content: f.localContent || ""
3191
+ }));
3192
+ if (updatedModulesContent) {
3193
+ if (selectedFilePaths.includes(MODULES_FILE_PATH) || selectedUntracked.size > 0) {
3194
+ const existingIdx = files.findIndex((f) => f.path === MODULES_FILE_PATH);
3195
+ if (existingIdx !== -1) files[existingIdx].content = updatedModulesContent;
3196
+ else files.push({
3197
+ path: MODULES_FILE_PATH,
3198
+ content: updatedModulesContent
3199
+ });
3200
+ }
3201
+ }
3202
+ const readmeResult = await detectAndUpdateReadme(targetDir, templateDir);
3203
+ if (readmeResult?.updated) files.push({
3204
+ path: README_PATH,
3205
+ content: readmeResult.content
3206
+ });
3207
+ if (files.length === 0) {
3208
+ log.info("No files to push after processing.");
3209
+ return;
3210
+ }
3211
+ let token = manifest.github.token || getGitHubToken();
3212
+ if (!token) throw new BermError("GitHub token not found.", "Set GITHUB_TOKEN or GH_TOKEN environment variable, or add token to manifest.");
3213
+ const title = messageOverride || manifest.pr.title;
3214
+ const body = manifest.pr.body;
3215
+ log.step("Creating pull request...");
3216
+ const result = await withSpinner("Creating PR on GitHub...", () => createPullRequest(token, {
3217
+ owner: config.source.owner,
3218
+ repo: config.source.repo,
3219
+ files,
3220
+ title,
3221
+ body,
3222
+ baseBranch: config.source.ref || "main"
3223
+ }));
3224
+ await deleteManifest(targetDir);
3225
+ log.success("Pull request created!");
3226
+ log.message([
3227
+ `${pc$1.dim("To")} ${pc$1.bold(`${config.source.owner}/${config.source.repo}`)}`,
3228
+ ` ${pc$1.green(result.branch)} ${pc$1.dim(`(${files.length} file${files.length !== 1 ? "s" : ""} changed)`)}`,
3229
+ "",
3230
+ ` ${pc$1.bold(`PR #${result.number}`)} ${pc$1.cyan(result.url)}`
3231
+ ].join("\n"));
3232
+ log.message(pc$1.dim(`Cleaned up ${MANIFEST_FILENAME}`));
3233
+ outro(`Review and merge at ${pc$1.cyan(result.url)}`);
3234
+ } finally {
3235
+ unregisterCleanup();
3236
+ if (existsSync(tempDir)) await rm(tempDir, {
3237
+ recursive: true,
3238
+ force: true
3239
+ });
3240
+ }
3241
+ }
3242
+ const pushCommand = defineCommand({
3243
+ meta: {
3244
+ name: "push",
3245
+ description: "Push local changes to the template repository as a PR"
3246
+ },
3247
+ args: {
3248
+ dir: {
3249
+ type: "positional",
3250
+ description: "Project directory",
3251
+ default: "."
3252
+ },
3253
+ dryRun: {
3254
+ type: "boolean",
3255
+ alias: "n",
3256
+ description: "Preview only, don't create PR",
3257
+ default: false
3258
+ },
3259
+ message: {
3260
+ type: "string",
3261
+ alias: "m",
3262
+ description: "PR title"
3263
+ },
3264
+ yes: {
3265
+ type: "boolean",
3266
+ alias: ["y", "f"],
3267
+ description: "Skip confirmation prompts",
3268
+ default: false
3269
+ },
3270
+ edit: {
3271
+ type: "boolean",
3272
+ description: "Edit PR title and description before creating",
3273
+ default: false
3274
+ },
3275
+ prepare: {
3276
+ type: "boolean",
3277
+ alias: "p",
3278
+ description: "Generate a manifest file for AI-agent friendly workflow (no PR created)",
3279
+ default: false
3280
+ },
3281
+ execute: {
3282
+ type: "boolean",
3283
+ alias: "e",
3284
+ description: "Execute push using the manifest file generated by --prepare",
3285
+ default: false
3286
+ },
3287
+ files: {
3288
+ type: "string",
3289
+ description: "Comma-separated file paths to include in PR (non-interactive file selection for AI agents)"
3290
+ }
3291
+ },
3292
+ async run({ args }) {
3293
+ intro("push");
3294
+ if (args.prepare && args.execute) throw new BermError("Cannot use --prepare and --execute together.", "Use --prepare to generate a manifest, then --execute to create the PR.");
3295
+ if (args.prepare && args.dryRun) log.warn("--dry-run is ignored with --prepare (--prepare doesn't create a PR).");
3296
+ if (args.execute && args.edit) log.message(pc$1.dim("Note: --edit is ignored in --execute mode (uses manifest)."));
3297
+ const targetDir = resolve(args.dir);
3298
+ const configPath = join(targetDir, ".devenv.json");
3299
+ if (!existsSync(configPath)) throw new BermError(".devenv.json not found.", "Run 'ziku init' first.");
3300
+ const configContent = await readFile(configPath, "utf-8");
3301
+ const configData = JSON.parse(configContent);
3302
+ const parseResult = configSchema.safeParse(configData);
3303
+ if (!parseResult.success) throw new BermError("Invalid .devenv.json format", parseResult.error.message);
3304
+ const config = parseResult.data;
3305
+ if (config.modules.length === 0) {
3306
+ log.warn("No modules installed");
3307
+ return;
3308
+ }
3309
+ if (config.pendingMerge) throw new BermError("Unresolved merge conflicts from `ziku pull`", "Resolve conflicts in these files, then run `ziku pull --continue`:\n" + config.pendingMerge.conflicts.map((f) => ` • ${f}`).join("\n"));
3310
+ if (args.execute) {
3311
+ await runExecuteMode(targetDir, config, args.message);
3312
+ return;
3313
+ }
3314
+ log.step("Fetching template...");
3315
+ const templateSource = buildTemplateSource(config.source);
3316
+ const tempDir = join(targetDir, ".devenv-temp");
3317
+ const unregisterCleanup = registerSyncCleanup(tempDir);
3318
+ try {
3319
+ const { dir: templateDir } = await withSpinner("Downloading template from GitHub...", () => downloadTemplate(templateSource, {
3320
+ dir: tempDir,
3321
+ force: true
3322
+ }));
3323
+ let moduleList;
3324
+ let modulesRawContent;
3325
+ if (modulesFileExists(templateDir)) {
3326
+ const loaded = await loadModulesFile(templateDir);
3327
+ moduleList = loaded.modules;
3328
+ modulesRawContent = loaded.rawContent;
3329
+ } else moduleList = defaultModules;
3330
+ const effectiveModuleIds = [...config.modules];
3331
+ let updatedModulesContent;
3332
+ if (modulesRawContent) {
3333
+ const localAdditions = await detectLocalModuleAdditions(targetDir, moduleList, modulesRawContent);
3334
+ moduleList = localAdditions.mergedModuleList;
3335
+ updatedModulesContent = localAdditions.updatedModulesContent;
3336
+ for (const id of localAdditions.newModuleIds) if (!effectiveModuleIds.includes(id)) effectiveModuleIds.push(id);
3337
+ if (localAdditions.newModuleIds.length > 0) log.info(`Detected ${localAdditions.newModuleIds.length} new module(s) from local: ${localAdditions.newModuleIds.join(", ")}`);
3338
+ }
3339
+ const mergedContents = /* @__PURE__ */ new Map();
3340
+ if (config.baseHashes) {
3341
+ const { hashFiles: hashFiles$1 } = await import("./hash-DonjAgHQ.mjs");
3342
+ const { classifyFiles: classifyFiles$1 } = await import("./merge-DFEjeYIq.mjs");
3343
+ const { getModuleById: getModuleById$1 } = await import("./modules-DkQe-r1d.mjs");
3344
+ const { getEffectivePatterns: getEffectivePatterns$1 } = await import("./patterns-C3oCRYgX.mjs");
3345
+ const allPatterns = [];
3346
+ for (const moduleId of effectiveModuleIds) {
3347
+ const mod = getModuleById$1(moduleId, moduleList);
3348
+ if (mod) {
3349
+ const patterns = getEffectivePatterns$1(moduleId, mod.patterns, config);
3350
+ allPatterns.push(...patterns);
3351
+ }
3352
+ }
3353
+ const templateHashes = await hashFiles$1(templateDir, allPatterns);
3354
+ const localHashes = await hashFiles$1(targetDir, allPatterns);
3355
+ const classification = classifyFiles$1({
3356
+ baseHashes: config.baseHashes,
3357
+ localHashes,
3358
+ templateHashes
3359
+ });
3360
+ if (classification.conflicts.length > 0) {
3361
+ const { threeWayMerge: threeWayMerge$1 } = await import("./merge-DFEjeYIq.mjs");
3362
+ const baseInfo = config.baseRef ? `since ${pc$1.bold(config.baseRef.slice(0, 7))} (your last sync)` : "since your last pull/init";
3363
+ log.warn(`Template updated ${baseInfo} — ${classification.conflicts.length} conflict(s) detected, attempting auto-merge...`);
3364
+ let baseTemplateDir;
3365
+ let baseCleanup;
3366
+ if (config.baseRef) try {
3367
+ log.info(`Downloading base version (${config.baseRef.slice(0, 7)}...) for merge...`);
3368
+ const { downloadTemplateToTemp: downloadBase } = await import("./template-BLwXZEZq.mjs");
3369
+ const baseResult = await downloadBase(targetDir, `gh:${config.source.owner}/${config.source.repo}#${config.baseRef}`);
3370
+ baseTemplateDir = baseResult.templateDir;
3371
+ baseCleanup = baseResult.cleanup;
3372
+ } catch {
3373
+ log.warn("Could not download base version. Falling back to local content for conflicts.");
3374
+ }
3375
+ try {
3376
+ const autoMerged = [];
3377
+ const unresolved = [];
3378
+ for (const file of classification.conflicts) {
3379
+ const localContent = await readFile(join(targetDir, file), "utf-8");
3380
+ const templateContent = await readFile(join(templateDir, file), "utf-8");
3381
+ let baseContent;
3382
+ if (baseTemplateDir && existsSync(join(baseTemplateDir, file))) baseContent = await readFile(join(baseTemplateDir, file), "utf-8");
3383
+ if (baseContent) {
3384
+ const result$1 = threeWayMerge$1(baseContent, templateContent, localContent, file);
3385
+ if (!result$1.hasConflicts) {
3386
+ mergedContents.set(file, result$1.content);
3387
+ autoMerged.push(file);
3388
+ continue;
3389
+ }
3390
+ }
3391
+ unresolved.push(file);
3392
+ }
3393
+ if (autoMerged.length > 0) {
3394
+ log.success(`Auto-merged ${autoMerged.length} file(s):`);
3395
+ for (const file of autoMerged) log.message(` ${pc$1.green("✓")} ${file}`);
3396
+ }
3397
+ if (unresolved.length > 0) {
3398
+ log.warn(`${unresolved.length} file(s) could not be auto-merged:`);
3399
+ for (const file of unresolved) log.message(` ${pc$1.yellow("!")} ${file}`);
3400
+ log.message([pc$1.dim("Your local changes will be included in the PR."), pc$1.dim(`hint: Run ${pc$1.cyan("ziku pull")} to sync changes first, then push again.`)].join("\n"));
3401
+ if (!args.yes) {
3402
+ if (!await confirmAction("Continue with unresolved conflicts?", { initialValue: true })) {
3403
+ log.info("Run `ziku pull` first to sync template changes, then push again.");
3404
+ return;
3405
+ }
3406
+ }
3407
+ }
3408
+ } finally {
3409
+ baseCleanup?.();
3410
+ }
3411
+ }
3412
+ }
3413
+ if (!args.yes && !args.prepare && modulesRawContent) {
3414
+ const untrackedByFolder = await detectUntrackedFiles({
3415
+ targetDir,
3416
+ moduleIds: effectiveModuleIds,
3417
+ config,
3418
+ moduleList
3419
+ });
3420
+ if (untrackedByFolder.length > 0) {
3421
+ const untrackedCount = untrackedByFolder.reduce((sum, f) => sum + f.files.length, 0);
3422
+ log.info(`${untrackedCount} untracked file(s) detected (not included in push)`);
3423
+ }
3424
+ }
3425
+ log.step("Detecting changes...");
3426
+ const diff = await withSpinner("Analyzing differences...", () => detectDiff({
3427
+ targetDir,
3428
+ templateDir,
3429
+ moduleIds: effectiveModuleIds,
3430
+ config,
3431
+ moduleList
3432
+ }));
3433
+ let pushableFiles = getPushableFiles(diff);
3434
+ if (pushableFiles.length === 0 && !updatedModulesContent) {
3435
+ log.info("No changes to push");
3436
+ log.step("Current status:");
3437
+ logDiffSummary(diff.files);
3438
+ return;
3439
+ }
3440
+ if (args.dryRun) {
3441
+ log.info("Dry run mode");
3442
+ log.step("Files that would be included in PR:");
3443
+ logDiffSummary(diff.files);
3444
+ if (updatedModulesContent) log.message(`${pc$1.green("+")} ${MODULES_FILE_PATH} ${pc$1.dim("(pattern additions)")}`);
3445
+ log.info("No PR was created (dry run)");
3446
+ return;
3447
+ }
3448
+ if (args.prepare) {
3449
+ const untrackedByFolder = !args.yes && modulesRawContent ? await detectUntrackedFiles({
3450
+ targetDir,
3451
+ moduleIds: effectiveModuleIds,
3452
+ config,
3453
+ moduleList
3454
+ }) : [];
3455
+ const manifestPath = await saveManifest(targetDir, generateManifest({
3456
+ targetDir,
3457
+ diff,
3458
+ pushableFiles,
3459
+ untrackedByFolder,
3460
+ defaultTitle: args.message,
3461
+ modulesFileChange: updatedModulesContent ? MODULES_FILE_PATH : void 0
3462
+ }));
3463
+ log.success("Manifest file generated!");
3464
+ log.message([
3465
+ `File: ${pc$1.cyan(manifestPath)}`,
3466
+ `Files: ${pushableFiles.length} files ready to push`,
3467
+ ...updatedModulesContent ? ["Modules: modules.jsonc will be updated (new modules/patterns detected)"] : [],
3468
+ ...untrackedByFolder.length > 0 ? (() => {
3469
+ return [`Untracked: ${untrackedByFolder.reduce((sum, f) => sum + f.files.length, 0)} files detected (not selected by default)`];
3470
+ })() : []
3471
+ ].join("\n"));
3472
+ if (untrackedByFolder.length > 0) {
3473
+ log.info(`${pc$1.bold("Hint:")} To sync untracked files to the template, first add them to tracking:`);
3474
+ log.message(pc$1.dim([
3475
+ ` npx ziku track "<pattern>" # Add file patterns to the sync whitelist`,
3476
+ ` npx ziku track --list # List currently tracked patterns`,
3477
+ ` Then re-run 'push --prepare' to include them in the manifest.`
3478
+ ].join("\n")));
3479
+ }
3480
+ outro(`Edit ${MANIFEST_FILENAME}, then run 'ziku push --execute' to create the PR`);
3481
+ return;
3482
+ }
3483
+ if (args.files) {
3484
+ const requestedPaths = args.files.split(",").map((p$1) => p$1.trim()).filter(Boolean);
3485
+ const availablePaths = new Set(pushableFiles.map((f) => f.path));
3486
+ const notFound = requestedPaths.filter((p$1) => !availablePaths.has(p$1));
3487
+ if (notFound.length > 0) log.warn(`Files not found in pushable changes: ${notFound.join(", ")}`);
3488
+ const requestedSet = new Set(requestedPaths);
3489
+ pushableFiles = pushableFiles.filter((f) => requestedSet.has(f.path));
3490
+ if (pushableFiles.length === 0 && !updatedModulesContent) {
3491
+ log.info("No matching files found. Cancelled.");
3492
+ return;
3493
+ }
3494
+ log.info(`${pushableFiles.length} file(s) selected via --files`);
3495
+ } else {
3496
+ log.step("Selecting files...");
3497
+ pushableFiles = await selectPushFiles(pushableFiles);
3498
+ if (pushableFiles.length === 0 && !updatedModulesContent) {
3499
+ log.info("No files selected. Cancelled.");
3500
+ return;
3501
+ }
3502
+ }
3503
+ let token = getGitHubToken();
3504
+ if (!token) token = await inputGitHubToken();
3505
+ const suggestedTitle = generatePrTitle(pushableFiles);
3506
+ const suggestedBody = generatePrBody$1(pushableFiles);
3507
+ let title;
3508
+ let body;
3509
+ if (args.message) {
3510
+ title = args.message;
3511
+ body = suggestedBody;
3512
+ } else if (args.edit) {
3513
+ title = await inputPrTitle(suggestedTitle);
3514
+ body = await inputPrBody(suggestedBody);
3515
+ } else {
3516
+ title = suggestedTitle;
3517
+ body = suggestedBody;
3518
+ }
3519
+ const readmeResult = await detectAndUpdateReadme(targetDir, templateDir);
3520
+ const files = pushableFiles.map((f) => ({
3521
+ path: f.path,
3522
+ content: mergedContents.get(f.path) ?? f.localContent ?? ""
3523
+ }));
3524
+ if (updatedModulesContent) files.push({
3525
+ path: MODULES_FILE_PATH,
3526
+ content: updatedModulesContent
3527
+ });
3528
+ if (readmeResult?.updated) files.push({
3529
+ path: README_PATH,
3530
+ content: readmeResult.content
3531
+ });
3532
+ const destination = `${config.source.owner}/${config.source.repo}`;
3533
+ const baseBranch = config.source.ref || "main";
3534
+ const baseHashStr = config.baseRef ? ` ${pc$1.dim(`since ${config.baseRef.slice(0, 7)}`)}` : "";
3535
+ const fileLines = [];
3536
+ for (const pf of pushableFiles) {
3537
+ if (!files.some((f) => f.path === pf.path)) continue;
3538
+ const stat = formatFileStat(pf);
3539
+ const icon = pf.type === "added" ? pc$1.green("+") : pf.type === "modified" ? pc$1.yellow("~") : pc$1.red("-");
3540
+ fileLines.push(` ${icon} ${pf.path.padEnd(50)} ${stat}`);
3541
+ }
3542
+ for (const f of files) if (!pushableFiles.some((pf) => pf.path === f.path)) fileLines.push(` ${pc$1.green("+")} ${f.path.padEnd(50)} ${pc$1.dim("(auto-updated)")}`);
3543
+ log.message([
3544
+ `${pc$1.dim("To")} ${pc$1.bold(destination)} ${pc$1.dim(`→ ${baseBranch}`)}${baseHashStr}`,
3545
+ pc$1.dim("─".repeat(62)),
3546
+ ...fileLines,
3547
+ pc$1.dim("─".repeat(62)),
3548
+ ` ${pc$1.dim("PR:")} ${title}`
3549
+ ].join("\n"));
3550
+ if (!args.yes) {
3551
+ if (!await confirmAction("Create PR?", { initialValue: true })) {
3552
+ log.info("Cancelled. Use --edit to customize title/body, or --files to specify files.");
3553
+ return;
3554
+ }
3555
+ }
3556
+ log.step("Creating pull request...");
3557
+ const result = await withSpinner("Creating PR on GitHub...", () => createPullRequest(token, {
3558
+ owner: config.source.owner,
3559
+ repo: config.source.repo,
3560
+ files,
3561
+ title,
3562
+ body,
3563
+ baseBranch: config.source.ref || "main"
3564
+ }));
3565
+ log.success("Pull request created!");
3566
+ log.message([
3567
+ `${pc$1.dim("To")} ${pc$1.bold(`${config.source.owner}/${config.source.repo}`)}`,
3568
+ ` ${config.baseRef ? `${pc$1.dim(config.baseRef.slice(0, 7))}..` : ""}${pc$1.green(result.branch)} ${pc$1.dim(`(${files.length} file${files.length !== 1 ? "s" : ""} changed)`)}`,
3569
+ "",
3570
+ ` ${pc$1.bold(`PR #${result.number}`)} ${pc$1.cyan(result.url)}`
3571
+ ].join("\n"));
3572
+ outro(`Review and merge at ${pc$1.cyan(result.url)}`);
3573
+ } finally {
3574
+ unregisterCleanup();
3575
+ if (existsSync(tempDir)) await rm(tempDir, {
3576
+ recursive: true,
3577
+ force: true
3578
+ });
3579
+ }
3580
+ }
3581
+ });
3582
+
3583
+ //#endregion
3584
+ //#region src/commands/track.ts
3585
+ /**
3586
+ * パターン文字列からモジュール ID を推定
3587
+ * 例: ".cloud/rules/*.md" → ".cloud"
3588
+ * ".mcp.json" → "."
3589
+ * ".github/workflows/ci.yml" → ".github"
3590
+ */
3591
+ function inferModuleId(pattern) {
3592
+ return getModuleIdFromPath(pattern.replace(/\*.*$/, "").replace(/\{.*$/, "") || pattern);
3593
+ }
3594
+ const trackCommand = defineCommand({
3595
+ meta: {
3596
+ name: "track",
3597
+ description: "Add file patterns to the tracking whitelist in modules.jsonc"
3598
+ },
3599
+ args: {
3600
+ patterns: {
3601
+ type: "positional",
3602
+ description: "File paths or glob patterns to track (e.g., .cloud/rules/*.md)",
3603
+ required: false
3604
+ },
3605
+ dir: {
3606
+ type: "string",
3607
+ alias: "d",
3608
+ description: "Project directory (default: current directory)",
3609
+ default: "."
3610
+ },
3611
+ module: {
3612
+ type: "string",
3613
+ alias: "m",
3614
+ description: "Module ID to add patterns to (auto-detected from path if omitted)"
3615
+ },
3616
+ name: {
3617
+ type: "string",
3618
+ description: "Module name (used when creating a new module)"
3619
+ },
3620
+ description: {
3621
+ type: "string",
3622
+ description: "Module description (used when creating a new module)"
3623
+ },
3624
+ list: {
3625
+ type: "boolean",
3626
+ alias: "l",
3627
+ description: "List all currently tracked modules and patterns",
3628
+ default: false
3629
+ }
3630
+ },
3631
+ async run({ args }) {
3632
+ intro("track");
3633
+ const targetDir = resolve(args.dir);
3634
+ if (!modulesFileExists(targetDir)) throw new BermError(".devenv/modules.jsonc not found.", "Run 'ziku init' first to set up the project.");
3635
+ if (args.list) {
3636
+ const { modules } = await loadModulesFile(targetDir);
3637
+ log.info("Tracked modules and patterns:");
3638
+ for (const mod of modules) {
3639
+ const lines = [];
3640
+ lines.push(`${pc$1.cyan(mod.id)} ${pc$1.dim(`(${mod.name})`)}`);
3641
+ if (mod.description) lines.push(` ${pc$1.dim(mod.description)}`);
3642
+ for (const pattern of mod.patterns) lines.push(` ${pc$1.dim("→")} ${pattern}`);
3643
+ log.message(lines.join("\n"));
3644
+ }
3645
+ outro("Done.");
3646
+ return;
3647
+ }
3648
+ const rawArgs = process.argv.slice(2);
3649
+ const trackIdx = rawArgs.indexOf("track");
3650
+ const argsAfterTrack = trackIdx !== -1 ? rawArgs.slice(trackIdx + 1) : rawArgs;
3651
+ const patterns = [];
3652
+ let i = 0;
3653
+ while (i < argsAfterTrack.length) {
3654
+ const arg = argsAfterTrack[i];
3655
+ if (arg === "--list" || arg === "-l" || arg === "--help" || arg === "-h") {
3656
+ i++;
3657
+ continue;
3658
+ }
3659
+ if (arg === "--dir" || arg === "-d" || arg === "--module" || arg === "-m" || arg === "--name" || arg === "--description") {
3660
+ i += 2;
3661
+ continue;
3662
+ }
3663
+ if (!arg.startsWith("-")) patterns.push(arg);
3664
+ i++;
3665
+ }
3666
+ if (patterns.length === 0) throw new BermError("No patterns specified.", "Usage: ziku track <patterns...> [--module <id>]\nExample: ziku track '.cloud/rules/*.md' '.cloud/config.json'");
3667
+ const moduleId = args.module || inferModuleId(patterns[0]);
3668
+ const { rawContent } = await loadModulesFile(targetDir);
3669
+ const updatedContent = addPatternToModulesFileWithCreate(rawContent, moduleId, patterns, {
3670
+ name: args.name,
3671
+ description: args.description
3672
+ });
3673
+ if (updatedContent === rawContent) {
3674
+ log.info("All patterns are already tracked. No changes needed.");
3675
+ return;
3676
+ }
3677
+ await saveModulesFile(targetDir, updatedContent);
3678
+ log.success("Patterns added!");
3679
+ const details = [
3680
+ `Module: ${pc$1.cyan(moduleId)}`,
3681
+ "Added:",
3682
+ ...patterns.map((p$1) => ` ${pc$1.green("+")} ${p$1}`)
3683
+ ];
3684
+ log.message(details.join("\n"));
3685
+ outro("Updated .devenv/modules.jsonc");
3686
+ }
3687
+ });
3688
+
3689
+ //#endregion
3690
+ //#region src/index.ts
3691
+ const main = defineCommand({
3692
+ meta: {
3693
+ name: "ziku",
3694
+ version: version$2,
3695
+ description: "Dev environment template manager"
3696
+ },
3697
+ subCommands: {
3698
+ init: initCommand,
3699
+ push: pushCommand,
3700
+ pull: pullCommand,
3701
+ diff: diffCommand,
3702
+ track: trackCommand,
3703
+ "ai-docs": aiDocsCommand
3704
+ }
3705
+ });
3706
+ const commandMap = {
3707
+ init: initCommand,
3708
+ push: pushCommand,
3709
+ pull: pullCommand,
3710
+ diff: diffCommand
3711
+ };
3712
+ /**
3713
+ * コマンド選択プロンプト
3714
+ *
3715
+ * 背景: 引数なしで実行された場合に、ユーザーにコマンドを選択してもらう。
3716
+ * @inquirer/prompts の select を @clack/prompts に置き換え。
3717
+ */
3718
+ async function promptCommand() {
3719
+ intro();
3720
+ p.log.message(pc$1.dim(`Are you an AI agent? Run ${pc$1.cyan("npx ziku ai-docs")} for non-interactive usage guide.`));
3721
+ const command = await p.select({
3722
+ message: "What would you like to do?",
3723
+ options: [
3724
+ {
3725
+ value: "init",
3726
+ label: "init",
3727
+ hint: "Apply template to your project"
3728
+ },
3729
+ {
3730
+ value: "push",
3731
+ label: "push",
3732
+ hint: "Push local changes as a PR"
3733
+ },
3734
+ {
3735
+ value: "pull",
3736
+ label: "pull",
3737
+ hint: "Pull latest template updates"
3738
+ },
3739
+ {
3740
+ value: "diff",
3741
+ label: "diff",
3742
+ hint: "Show differences from template"
3743
+ }
3744
+ ]
3745
+ });
3746
+ if (p.isCancel(command)) {
3747
+ p.cancel("Cancelled.");
3748
+ process.exit(0);
3749
+ }
3750
+ const selectedCommand = commandMap[command];
3751
+ await runMain(selectedCommand);
3752
+ }
3753
+ /**
3754
+ * トップレベルエラーハンドラ
3755
+ *
3756
+ * 背景: 各コマンドで throw された BermError をここでキャッチし、
3757
+ * @clack/prompts で統一的に表示する。process.exit(1) はこの 1 箇所のみ。
3758
+ */
3759
+ async function run() {
3760
+ try {
3761
+ const args = process.argv.slice(2);
3762
+ const hasSubCommand = args.length > 0 && [
3763
+ "init",
3764
+ "push",
3765
+ "pull",
3766
+ "diff",
3767
+ "track",
3768
+ "ai-docs",
3769
+ "--help",
3770
+ "-h",
3771
+ "--version",
3772
+ "-v"
3773
+ ].includes(args[0]);
3774
+ if (!hasSubCommand && args.length > 0 && !args[0].startsWith("-")) await runMain(initCommand);
3775
+ else if (!hasSubCommand && args.length === 0) await promptCommand();
3776
+ else await runMain(main);
3777
+ } catch (error) {
3778
+ if (error instanceof BermError) {
3779
+ logBermError(error);
3780
+ process.exit(1);
3781
+ }
3782
+ throw error;
3783
+ }
3784
+ }
3785
+ run();
3786
+
3787
+ //#endregion
3788
+ export { modulesFileExists as C, loadModulesFile as S, getModuleById as _, hashContent as a, addPatternToModulesFileWithCreate as b, buildTemplateSource as c, fetchTemplates as d, writeFileWithStrategy as f, defaultModules as g, resolvePatterns as h, threeWayMerge as i, copyFile as l, matchesPatterns as m, hasConflictMarkers as n, hashFiles as o, getEffectivePatterns as p, mergeJsonContent as r, TEMPLATE_SOURCE as s, classifyFiles as t, downloadTemplateToTemp as u, getPatternsByModuleIds as v, saveModulesFile as w, getModulesFilePath as x, addPatternToModulesFile as y };