依 todo.md 執行的規範治理專案:新增 spec-preflight 等 14 個共用規範(含 conventional-commit/pull-request/git-push/issue-read/todo-list/ask-user/ subagent/no-scratch-files/skill-invocation/script-path/action-scaffold/ node-src-layout/plugin-cli/model),擴充 spec-git-safety 與 spec-gitea(token 優先序、機密遮蔽、Wiki 頁名轉義規則);新增可執行 skill `models`(模型能力 查詢與標籤)與 `todo`(依指定模型產生/附加 todo.md);新增 plugin.meta.json 單一事實來源與 gen-plugin-files.mjs 樣板產生器,統一四個 repo 的 manifest/ README/AGENTS.md 並移除寫死的本機使用者路徑;新增 shared/scripts/lib 的 log/機密遮蔽三語言參考實作。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
545 lines
20 KiB
JavaScript
545 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
/**
|
||
* gen-plugin-files.mjs — 依 <repo>/plugin.meta.json 產生五份 manifest、AGENTS.md(shared/doc/code)、
|
||
* 以及 README.md 五個共通章節(目錄結構/用 CLI 直接執行 skill/新增一個 skill 三節目前為
|
||
* 原樣保留的 passthrough,只有「前綴與呼叫方式」與「安裝 / 更新 / 移除」兩節會真正改寫內容)。
|
||
*
|
||
* 用法:
|
||
* node gen-plugin-files.mjs --repo <repo目錄> --dry-run # 只印比對報告,不寫檔(本階段唯一支援的用法)
|
||
* node gen-plugin-files.mjs --repo <repo目錄> --write # 實際寫入檔案(下一階段才使用)
|
||
*
|
||
* 只用 Node.js 內建模組,不依賴任何套件。
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 小工具
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function readJSON(p) {
|
||
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||
}
|
||
|
||
function readTextOrNull(p) {
|
||
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
|
||
}
|
||
|
||
function stripTrailingPeriod(s) {
|
||
return s.replace(/。$/, '');
|
||
}
|
||
|
||
/** 在字串第一個「,」或「:」之前插入 insertText。找不到就直接接在字串尾端。 */
|
||
function insertBeforeFirstPunct(s, insertText) {
|
||
const idx = s.search(/[,:]/);
|
||
if (idx === -1) return s + insertText;
|
||
return s.slice(0, idx) + insertText + s.slice(idx);
|
||
}
|
||
|
||
function stableStringify(obj) {
|
||
return JSON.stringify(obj, null, 2) + '\n';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Manifest 產生(規則對照見 shared/templates/plugin-manifests.md)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function buildRootDescription(meta) {
|
||
const base = stripTrailingPeriod(meta.descriptionCore);
|
||
const rootAssistant = meta.callPrefixAssistants.root;
|
||
return `${base};於 ${rootAssistant} 以 ${meta.cliPrefix} 前綴呼叫。`;
|
||
}
|
||
|
||
function buildClaudeDescription(meta) {
|
||
const assistantsList = `(${meta.assistants.join(' / ')})`;
|
||
const withAssistants = insertBeforeFirstPunct(meta.descriptionCore, assistantsList);
|
||
const base = stripTrailingPeriod(withAssistants);
|
||
const claudeAssistant = meta.callPrefixAssistants.claudePlugin;
|
||
return `${base};於 ${claudeAssistant} 以 ${meta.cliPrefix} 前綴呼叫。`;
|
||
}
|
||
|
||
function buildCodexDescription(meta) {
|
||
const note = meta.codexNote || '';
|
||
if (!note) return meta.descriptionCore;
|
||
if (meta.codexNoteAnchor) {
|
||
const idx = meta.descriptionCore.indexOf(meta.codexNoteAnchor);
|
||
if (idx === -1) {
|
||
throw new Error(
|
||
`codexNoteAnchor "${meta.codexNoteAnchor}" 在 descriptionCore 中找不到(repo: ${meta.shortName})`
|
||
);
|
||
}
|
||
const insertAt = idx + meta.codexNoteAnchor.length;
|
||
return meta.descriptionCore.slice(0, insertAt) + note + meta.descriptionCore.slice(insertAt);
|
||
}
|
||
return meta.descriptionCore + note;
|
||
}
|
||
|
||
function buildRootPluginJson(meta) {
|
||
return {
|
||
name: meta.name,
|
||
version: meta.version,
|
||
description: buildRootDescription(meta),
|
||
skills: meta.skillsPath,
|
||
};
|
||
}
|
||
|
||
function buildClaudePluginJson(meta) {
|
||
return {
|
||
name: meta.name,
|
||
version: meta.version,
|
||
description: buildClaudeDescription(meta),
|
||
skills: meta.skillsPath,
|
||
author: meta.author,
|
||
homepage: meta.homepage,
|
||
repository: meta.repository,
|
||
keywords: meta.keywords,
|
||
};
|
||
}
|
||
|
||
function buildCodexPluginJson(meta) {
|
||
return {
|
||
name: meta.name,
|
||
version: meta.version,
|
||
description: buildCodexDescription(meta),
|
||
skills: meta.skillsPath,
|
||
};
|
||
}
|
||
|
||
function buildClaudeMarketplaceJson(meta) {
|
||
return {
|
||
name: meta.shortName,
|
||
description: meta.marketplace.repoDescription,
|
||
owner: { name: meta.author.name },
|
||
plugins: [
|
||
{
|
||
name: meta.name,
|
||
source: './',
|
||
description: meta.marketplace.pluginSummary,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function buildCodexMarketplaceJson(meta) {
|
||
return {
|
||
name: meta.shortName,
|
||
plugins: [
|
||
{
|
||
name: meta.name,
|
||
source: { source: 'url', url: meta.repository },
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AGENTS.md(只給 shared / doc / code;persona 不套用)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const AGENTS_APPLIES_TO = new Set(['shared', 'doc', 'code']);
|
||
|
||
function buildAgentsMd(meta) {
|
||
const tmpl = fs.readFileSync(path.join(TEMPLATES_DIR, 'AGENTS.md.tmpl'), 'utf8');
|
||
const bullets = meta.agentsExtraBullets || [];
|
||
const extraBlock = bullets.length ? bullets.map((b) => `- ${b}`).join('\n') + '\n' : '';
|
||
return tmpl
|
||
.replaceAll('{{PLUGIN_NAME}}', meta.name)
|
||
.replaceAll('{{CLI_PREFIX}}', meta.cliPrefix)
|
||
.replace('{{EXTRA_BULLETS}}', extraBlock);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// README.md 五個共通章節
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const DEFAULT_HEADINGS = {
|
||
prefixTable: '前綴與呼叫方式',
|
||
dirTree: '目錄結構',
|
||
install: '安裝 / 更新 / 移除(各助理)',
|
||
headless: '用 CLI 直接執行 skill(headless / 一次性)',
|
||
addSkill: '新增一個 skill',
|
||
};
|
||
|
||
function getHeadings(meta) {
|
||
return { ...DEFAULT_HEADINGS, ...(meta.readmeHeadings || {}) };
|
||
}
|
||
|
||
/**
|
||
* 找出「## <heading>」章節在整份文件中的字元範圍:[sectionStart, sectionEnd)。
|
||
* sectionStart 指向標題行開頭;sectionEnd 指向下一個獨立一行 `---` 或下一個 `## ` 標題(不含),或檔尾。
|
||
* 回傳 null 表示找不到這個標題(不應該發生在四個既有 repo 上,但保守處理)。
|
||
*/
|
||
function findSection(text, heading) {
|
||
const lines = text.split('\n');
|
||
const headingLine = `## ${heading}`;
|
||
let startLine = -1;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (lines[i].trim() === headingLine) {
|
||
startLine = i;
|
||
break;
|
||
}
|
||
}
|
||
if (startLine === -1) return null;
|
||
|
||
let endLine = lines.length;
|
||
for (let i = startLine + 1; i < lines.length; i++) {
|
||
const t = lines[i].trim();
|
||
if (t === '---' || t.startsWith('## ')) {
|
||
endLine = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const prefix = lines.slice(0, startLine).join('\n') + (startLine > 0 ? '\n' : '');
|
||
const suffix = lines.slice(endLine).join('\n');
|
||
const sectionText = lines.slice(startLine, endLine).join('\n');
|
||
return { startLine, endLine, prefix, suffix, sectionText };
|
||
}
|
||
|
||
/**
|
||
* 用新的 bodyLines(不含標題、不含結尾空行)取代整節內容,回傳整份新文件文字。
|
||
* 新章節格式固定為:`## <heading>` + 空行 + bodyLines + 空行(與既有四份 README 的排版慣例一致)。
|
||
*/
|
||
function replaceSection(text, heading, bodyLines) {
|
||
const sec = findSection(text, heading);
|
||
if (!sec) {
|
||
throw new Error(`找不到章節「## ${heading}」`);
|
||
}
|
||
const newSectionLines = [`## ${heading}`, '', ...bodyLines];
|
||
const newText = sec.prefix + newSectionLines.join('\n') + '\n\n' + sec.suffix;
|
||
return { newText, oldSectionText: sec.sectionText };
|
||
}
|
||
|
||
/**
|
||
* 章節文字的「可比較表示」:既有章節(sec.sectionText)永遠包含結尾那一行空行(緊接在 `---` 之前),
|
||
* 所以這裡也補上同一個結尾空行,才能跟 oldSectionText 公平比較,避免只因為少了那一行空行就誤報「有變動」。
|
||
*/
|
||
function renderSectionForCompare(heading, bodyLines) {
|
||
return [`## ${heading}`, '', ...bodyLines, ''].join('\n');
|
||
}
|
||
|
||
// ---- 章節 1/5:前綴與呼叫方式(完全模板化) ----
|
||
|
||
function buildPrefixTableBodyLines(meta) {
|
||
const tmpl = fs.readFileSync(path.join(TEMPLATES_DIR, 'readme-prefix-table.md.tmpl'), 'utf8');
|
||
const openCodeMethod = meta.readmeOpenCodeInstallMethod || 'skills 目錄(複製/clone)';
|
||
const rendered = tmpl
|
||
.replaceAll('{{CLI_PREFIX}}', meta.cliPrefix)
|
||
.replace('{{OPENCODE_INSTALL_METHOD}}', openCodeMethod);
|
||
// tmpl 檔本身已含 "## 前綴與呼叫方式\n\n" 開頭,這裡只取標題與空行之後的部分當 bodyLines。
|
||
const lines = rendered.split('\n');
|
||
// 找到第一個非空行之後(跳過標題行與其後的空行)
|
||
const headingIdx = lines.findIndex((l) => l.trim().startsWith('## '));
|
||
let bodyStart = headingIdx + 1;
|
||
while (bodyStart < lines.length && lines[bodyStart].trim() === '') bodyStart++;
|
||
let bodyEnd = lines.length;
|
||
while (bodyEnd > bodyStart && lines[bodyEnd - 1].trim() === '') bodyEnd--;
|
||
return lines.slice(bodyStart, bodyEnd);
|
||
}
|
||
|
||
// ---- 章節 3:安裝 / 更新 / 移除(改為引用 spec-plugin-cli) ----
|
||
|
||
function buildInstallBodyLines(meta) {
|
||
const host = 'gitea.jsc.idv.tw';
|
||
const pluginName = meta.name; // jsc-<shortName>
|
||
const marketplaceName = meta.shortName;
|
||
const token = `${pluginName}@${marketplaceName}`;
|
||
const url = meta.repository;
|
||
const notes = meta.readmeInstallNotes || [];
|
||
|
||
const lines = [];
|
||
if (notes.length) {
|
||
notes.forEach((note, i) => {
|
||
if (i > 0) lines.push('>');
|
||
lines.push(`> ${note}`);
|
||
});
|
||
lines.push('>');
|
||
}
|
||
lines.push(
|
||
'> 完整的安裝/更新/移除指令(Claude Code、Codex、Antigravity、OpenCode、GitHub Copilot CLI 五種助理),一律以 [`/jsc-shared:spec-plugin-cli`](https://gitea.jsc.idv.tw/plugins/shared/src/branch/master/skills/spec-plugin-cli/SKILL.md) 為唯一權威版本,套用時代入下列佔位符:',
|
||
'>',
|
||
'> | 佔位符 | 值 |',
|
||
'> | --- | --- |',
|
||
`> | \`<host>\` | \`${host}\` |`,
|
||
`> | \`<name>\` | \`${meta.shortName}\` |`,
|
||
`> | \`<plugin>\` | \`${pluginName}\` |`,
|
||
`> | \`<marketplace>\` | \`${marketplaceName}\` |`,
|
||
`> | \`<token>\`(= \`<plugin>@<marketplace>\`) | \`${token}\` |`,
|
||
`> | \`<url>\` | \`${url}\` |`
|
||
);
|
||
return lines;
|
||
}
|
||
|
||
// ---- 章節 2/4/5:目錄結構、用 CLI 直接執行 skill、新增一個 skill(passthrough) ----
|
||
// 這三節內容是各 repo 特有事實(skills 清單/目錄樹/範例指令),目前原樣保留、只重新包裝標記。
|
||
// 詳見 shared/templates/readme-passthrough-sections.md。
|
||
|
||
function buildPassthroughBodyLines(currentReadmeText, heading) {
|
||
const sec = findSection(currentReadmeText, heading);
|
||
if (!sec) return null;
|
||
const lines = sec.sectionText.split('\n');
|
||
// 去掉標題行與其後緊接的空行,其餘原樣回傳
|
||
let bodyStart = 1;
|
||
while (bodyStart < lines.length && lines[bodyStart].trim() === '') bodyStart++;
|
||
let bodyEnd = lines.length;
|
||
while (bodyEnd > bodyStart && lines[bodyEnd - 1].trim() === '') bodyEnd--;
|
||
return lines.slice(bodyStart, bodyEnd);
|
||
}
|
||
|
||
/**
|
||
* 產生整份新 README.md 文字,並回報五個章節各自「是否變動」與「舊/新內容」供 dry-run 顯示。
|
||
*/
|
||
function buildReadme(meta, currentReadmeText) {
|
||
const headings = getHeadings(meta);
|
||
const changes = [];
|
||
let text = currentReadmeText;
|
||
|
||
// 章節 1:前綴與呼叫方式 — 完全模板化
|
||
{
|
||
const bodyLines = buildPrefixTableBodyLines(meta);
|
||
const { newText, oldSectionText } = replaceSection(text, headings.prefixTable, bodyLines);
|
||
const newSectionText = renderSectionForCompare(headings.prefixTable, bodyLines);
|
||
changes.push({
|
||
key: 'prefixTable',
|
||
heading: headings.prefixTable,
|
||
changed: oldSectionText !== newSectionText,
|
||
oldText: oldSectionText,
|
||
newText: newSectionText,
|
||
});
|
||
text = newText;
|
||
}
|
||
|
||
// 章節 2:目錄結構 — passthrough(原樣保留)
|
||
{
|
||
const bodyLines = buildPassthroughBodyLines(currentReadmeText, headings.dirTree);
|
||
if (bodyLines === null) {
|
||
changes.push({ key: 'dirTree', heading: headings.dirTree, changed: false, note: '找不到此章節(略過)' });
|
||
} else {
|
||
const { newText, oldSectionText } = replaceSection(text, headings.dirTree, bodyLines);
|
||
const newSectionText = renderSectionForCompare(headings.dirTree, bodyLines);
|
||
changes.push({
|
||
key: 'dirTree',
|
||
heading: headings.dirTree,
|
||
changed: oldSectionText !== newSectionText,
|
||
oldText: oldSectionText,
|
||
newText: newSectionText,
|
||
});
|
||
text = newText;
|
||
}
|
||
}
|
||
|
||
// 章節 3:安裝 / 更新 / 移除 — 改為引用 spec-plugin-cli
|
||
{
|
||
const bodyLines = buildInstallBodyLines(meta);
|
||
const { newText, oldSectionText } = replaceSection(text, headings.install, bodyLines);
|
||
const newSectionText = renderSectionForCompare(headings.install, bodyLines);
|
||
changes.push({
|
||
key: 'install',
|
||
heading: headings.install,
|
||
changed: oldSectionText !== newSectionText,
|
||
oldText: oldSectionText,
|
||
newText: newSectionText,
|
||
});
|
||
text = newText;
|
||
}
|
||
|
||
// 章節 4:用 CLI 直接執行 skill — passthrough(原樣保留)
|
||
{
|
||
const bodyLines = buildPassthroughBodyLines(currentReadmeText, headings.headless);
|
||
if (bodyLines === null) {
|
||
changes.push({ key: 'headless', heading: headings.headless, changed: false, note: '找不到此章節(略過)' });
|
||
} else {
|
||
const { newText, oldSectionText } = replaceSection(text, headings.headless, bodyLines);
|
||
const newSectionText = renderSectionForCompare(headings.headless, bodyLines);
|
||
changes.push({
|
||
key: 'headless',
|
||
heading: headings.headless,
|
||
changed: oldSectionText !== newSectionText,
|
||
oldText: oldSectionText,
|
||
newText: newSectionText,
|
||
});
|
||
text = newText;
|
||
}
|
||
}
|
||
|
||
// 章節 5:新增一個 skill / 新增/修改 skill — passthrough(原樣保留)
|
||
{
|
||
const bodyLines = buildPassthroughBodyLines(currentReadmeText, headings.addSkill);
|
||
if (bodyLines === null) {
|
||
changes.push({ key: 'addSkill', heading: headings.addSkill, changed: false, note: '找不到此章節(略過)' });
|
||
} else {
|
||
const { newText, oldSectionText } = replaceSection(text, headings.addSkill, bodyLines);
|
||
const newSectionText = renderSectionForCompare(headings.addSkill, bodyLines);
|
||
changes.push({
|
||
key: 'addSkill',
|
||
heading: headings.addSkill,
|
||
changed: oldSectionText !== newSectionText,
|
||
oldText: oldSectionText,
|
||
newText: newSectionText,
|
||
});
|
||
text = newText;
|
||
}
|
||
}
|
||
|
||
return { text, changes };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 比對與報告
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function fileTargets(repoDir, meta) {
|
||
const targets = [
|
||
{ label: 'plugin.json(root / Antigravity)', file: path.join(repoDir, 'plugin.json'), kind: 'json', build: () => buildRootPluginJson(meta) },
|
||
{ label: '.claude-plugin/plugin.json', file: path.join(repoDir, '.claude-plugin', 'plugin.json'), kind: 'json', build: () => buildClaudePluginJson(meta) },
|
||
{ label: '.codex-plugin/plugin.json', file: path.join(repoDir, '.codex-plugin', 'plugin.json'), kind: 'json', build: () => buildCodexPluginJson(meta) },
|
||
{ label: '.claude-plugin/marketplace.json', file: path.join(repoDir, '.claude-plugin', 'marketplace.json'), kind: 'json', build: () => buildClaudeMarketplaceJson(meta) },
|
||
{ label: '.agents/plugins/marketplace.json', file: path.join(repoDir, '.agents', 'plugins', 'marketplace.json'), kind: 'json', build: () => buildCodexMarketplaceJson(meta) },
|
||
];
|
||
if (AGENTS_APPLIES_TO.has(meta.shortName)) {
|
||
targets.push({ label: 'AGENTS.md', file: path.join(repoDir, 'AGENTS.md'), kind: 'text', build: () => buildAgentsMd(meta) });
|
||
}
|
||
return targets;
|
||
}
|
||
|
||
function diffLines(oldStr, newStr) {
|
||
if (oldStr === newStr) return null;
|
||
return { old: oldStr == null ? '(不存在)' : oldStr, new: newStr };
|
||
}
|
||
|
||
function printSeparator(char = '-', len = 78) {
|
||
console.log(char.repeat(len));
|
||
}
|
||
|
||
function reportRepo(repoDir, { dryRun }) {
|
||
const shortName = path.basename(repoDir);
|
||
const metaPath = path.join(repoDir, 'plugin.meta.json');
|
||
if (!fs.existsSync(metaPath)) {
|
||
console.log(`[SKIP] ${repoDir} 沒有 plugin.meta.json`);
|
||
return { repo: shortName, ok: false };
|
||
}
|
||
const meta = readJSON(metaPath);
|
||
|
||
printSeparator('=');
|
||
console.log(`Repo: ${shortName} (${repoDir})`);
|
||
printSeparator('=');
|
||
|
||
let anyChange = false;
|
||
const fileResults = [];
|
||
|
||
for (const target of fileTargets(repoDir, meta)) {
|
||
const current = readTextOrNull(target.file);
|
||
const builtRaw = target.build();
|
||
const newContent = target.kind === 'json' ? stableStringify(builtRaw) : builtRaw;
|
||
const currentNormalized = current; // 保留原始內容比對(含尾端換行差異視為變動,提醒人工留意)
|
||
const changed = currentNormalized !== newContent;
|
||
if (changed) anyChange = true;
|
||
fileResults.push({ label: target.label, file: target.file, changed, current: currentNormalized, next: newContent });
|
||
|
||
console.log(`\n--- ${target.label} ---`);
|
||
console.log(`檔案:${target.file}`);
|
||
if (!current) {
|
||
console.log('現況:檔案不存在(將會新建)');
|
||
}
|
||
console.log(changed ? '狀態:會變動' : '狀態:無變動');
|
||
if (changed) {
|
||
console.log('[舊內容]');
|
||
console.log(current == null ? '(不存在)' : current);
|
||
console.log('[新內容]');
|
||
console.log(newContent);
|
||
}
|
||
}
|
||
|
||
// README.md 五個共通章節
|
||
const readmePath = path.join(repoDir, 'README.md');
|
||
const currentReadme = readTextOrNull(readmePath);
|
||
if (currentReadme == null) {
|
||
console.log(`\n--- README.md ---\n檔案不存在,略過五個共通章節的產生。`);
|
||
} else {
|
||
const { text: newReadme, changes } = buildReadme(meta, currentReadme);
|
||
console.log(`\n--- README.md(五個共通章節)---`);
|
||
console.log(`檔案:${readmePath}`);
|
||
for (const c of changes) {
|
||
if (c.note) {
|
||
console.log(`\n[${c.key}] ## ${c.heading} — ${c.note}`);
|
||
continue;
|
||
}
|
||
console.log(`\n[${c.key}] ## ${c.heading} — ${c.changed ? '會變動' : '無變動'}`);
|
||
if (c.changed) {
|
||
console.log(' [舊章節內容]');
|
||
console.log(indent(c.oldText, ' '));
|
||
console.log(' [新章節內容]');
|
||
console.log(indent(c.newText, ' '));
|
||
}
|
||
if (c.changed) anyChange = true;
|
||
}
|
||
fileResults.push({ label: 'README.md(五節)', file: readmePath, changed: newReadme !== currentReadme, current: currentReadme, next: newReadme });
|
||
}
|
||
|
||
console.log(`\nRepo「${shortName}」摘要:${anyChange ? '有變動待審核' : '完全無變動'}`);
|
||
|
||
if (!dryRun) {
|
||
for (const fr of fileResults) {
|
||
if (fr.changed) {
|
||
fs.mkdirSync(path.dirname(fr.file), { recursive: true });
|
||
fs.writeFileSync(fr.file, fr.next, 'utf8');
|
||
console.log(`[WRITE] ${fr.file}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { repo: shortName, anyChange };
|
||
}
|
||
|
||
function indent(text, pad) {
|
||
return text
|
||
.split('\n')
|
||
.map((l) => pad + l)
|
||
.join('\n');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CLI
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function parseArgs(argv) {
|
||
const args = { repo: null, dryRun: false, write: false };
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const a = argv[i];
|
||
if (a === '--repo') args.repo = argv[++i];
|
||
else if (a === '--dry-run') args.dryRun = true;
|
||
else if (a === '--write') args.write = true;
|
||
else {
|
||
console.error(`未知參數:${a}`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function main() {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
if (!args.repo) {
|
||
console.error('用法:node gen-plugin-files.mjs --repo <repo目錄> (--dry-run | --write)');
|
||
process.exit(1);
|
||
}
|
||
if (!args.dryRun && !args.write) {
|
||
console.error('請明確指定 --dry-run(只印比對報告)或 --write(實際寫入檔案)。');
|
||
process.exit(1);
|
||
}
|
||
const repoDir = path.resolve(args.repo);
|
||
if (!fs.existsSync(repoDir)) {
|
||
console.error(`repo 目錄不存在:${repoDir}`);
|
||
process.exit(1);
|
||
}
|
||
reportRepo(repoDir, { dryRun: args.dryRun && !args.write });
|
||
}
|
||
|
||
main();
|