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/README.md +188 -0
- package/dist/hash-DonjAgHQ.mjs +3 -0
- package/dist/index.mjs +3788 -0
- package/dist/merge-DFEjeYIq.mjs +3 -0
- package/dist/modules-DkQe-r1d.mjs +3 -0
- package/dist/patterns-C3oCRYgX.mjs +3 -0
- package/dist/template-BLwXZEZq.mjs +3 -0
- package/package.json +68 -0
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 };
|