@birdie_moblie/open_spec 2.1.7 → 2.2.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/ext.js +140 -21
- package/dist/cli/commands/init.js +22 -12
- package/dist/config/load.d.ts +4 -0
- package/dist/config/load.js +23 -5
- package/dist/config/preset-repo-tools.d.ts +36 -0
- package/dist/config/preset-repo-tools.js +114 -0
- package/dist/config/preset-repo.d.ts +164 -0
- package/dist/config/preset-repo.js +180 -0
- package/dist/config/preset-use.d.ts +79 -0
- package/dist/config/preset-use.js +282 -0
- package/dist/config/presets.d.ts +38 -113
- package/dist/config/presets.js +159 -84
- package/dist/config/schema.d.ts +136 -9
- package/dist/config/schema.js +30 -2
- package/dist/core/source-operations.js +6 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.js +22 -28
- package/dist/marketplace/registry.d.ts +2 -2
- package/dist/marketplace/registry.js +6 -55
- package/dist/marketplace/upgrade.js +5 -3
- package/dist/shared/fs.d.ts +2 -0
- package/dist/shared/fs.js +6 -2
- package/dist/shared/git-snapshot.d.ts +18 -0
- package/dist/shared/git-snapshot.js +75 -0
- package/dist/shared/pkg.js +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/guides/configuration.md +9 -2
- package/docs/guides/plugin-migration.md +5 -5
- package/docs/guides/ui-conventions-template.md +2 -2
- package/package.json +1 -2
- package/plugin.json +1 -1
- package/presets/backend-service.yaml +0 -19
- package/presets/flutter-mobile/plugin-hooks.json +0 -68
- package/presets/flutter-mobile/ui-conventions.md +0 -122
- package/presets/flutter-mobile.yaml +0 -205
- package/presets/web-product.yaml +0 -23
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ pnpm 11 已移除 `pnpm link --global`。把本地 CLI 注册成全局命令的
|
|
|
59
59
|
不想注册全局时,继续用 `node bin/openspec.js <命令>`。
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
openspec init --tools claude,codex,cursor --preset flutter-mobile --yes
|
|
62
|
+
openspec init --tools claude,codex,cursor --preset flutter-mobile --preset-repo <preset 仓库> --preset-ref refs/tags/<tag> --yes
|
|
63
63
|
openspec new change improve-checkout
|
|
64
64
|
openspec sources add --change improve-checkout --notes "简化结算确认" --json
|
|
65
65
|
```
|
package/dist/cli/commands/ext.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { Option } from 'commander';
|
|
2
3
|
import { costDoctor } from '../../usage/doctor.js';
|
|
3
|
-
import { addMarketplace, assertPluginsPresent, BUILTIN_MARKETPLACE, installPlugin, inspectHookEntries, inspectMarketplaceDependencies, listMarketplaces,
|
|
4
|
-
import {
|
|
4
|
+
import { addMarketplace, assertPluginsPresent, BUILTIN_MARKETPLACE, installPlugin, inspectHookEntries, inspectMarketplaceDependencies, listMarketplaces, project, verifyDelivery, verifyInstallCli, VERSION, PACKAGE_NAME, } from '../../index.js';
|
|
5
|
+
import { describeOrigins } from '../../config/presets.js';
|
|
5
6
|
import { upgradeMarketplace } from '../../marketplace/upgrade.js';
|
|
6
|
-
import {
|
|
7
|
+
import { findProjectRoot, loadProjectConfig } from '../../config/load.js';
|
|
8
|
+
import { fetchPresetRepo, inspectPreset, listRepoPresets, loadPresetBaseline, presetRootForProject, readPresetLock, readRepoPreset } from '../../config/preset-repo.js';
|
|
9
|
+
import { migratePreset, upgradePreset, usePreset } from '../../config/preset-use.js';
|
|
10
|
+
import { validatePresetRepo, writePresetIndex } from '../../config/preset-repo-tools.js';
|
|
7
11
|
import { emitJson, jsonMode } from '../output.js';
|
|
8
12
|
import { AppError } from '../../shared/errors.js';
|
|
9
13
|
import { readInstallReceipt, writeInstallReceipt } from '../../marketplace/install.js';
|
|
@@ -28,7 +32,17 @@ export function registerExt(program) {
|
|
|
28
32
|
process.exitCode = 2;
|
|
29
33
|
return;
|
|
30
34
|
}
|
|
31
|
-
const
|
|
35
|
+
const projectRoot = findProjectRoot();
|
|
36
|
+
const layer = loadProjectConfig(projectRoot);
|
|
37
|
+
const presetCheck = layer.preset ? inspectPreset(projectRoot, layer.preset) : null;
|
|
38
|
+
if (presetCheck && !presetCheck.ok) {
|
|
39
|
+
// 生效配置依赖 preset 基线;基线不可用时其余检查没有可靠输入,只报告 preset。
|
|
40
|
+
const payload = { ok: false, projectRoot, preset: presetCheck };
|
|
41
|
+
emitJson(jsonMode(command), payload, () => `doctor found issues: preset ${presetCheck.code}: ${presetCheck.message}`);
|
|
42
|
+
process.exitCode = presetCheck.code === 'preset_not_locked' || presetCheck.code === 'preset_cache_missing' ? 2 : 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const { config } = project();
|
|
32
46
|
const hosts = (config.tools ?? []);
|
|
33
47
|
const delivery = hosts.map((host) => ({ host, ...verifyDelivery(projectRoot, host) }));
|
|
34
48
|
const installer = verifyInstallCli(projectRoot, VERSION, PACKAGE_NAME);
|
|
@@ -60,6 +74,7 @@ export function registerExt(program) {
|
|
|
60
74
|
&& entries.every((item) => item.ok),
|
|
61
75
|
projectRoot,
|
|
62
76
|
schema: config.schema,
|
|
77
|
+
...(presetCheck ? { preset: presetCheck } : {}),
|
|
63
78
|
builtinMarketplace: BUILTIN_MARKETPLACE,
|
|
64
79
|
delivery,
|
|
65
80
|
installer,
|
|
@@ -81,28 +96,132 @@ export function registerExt(program) {
|
|
|
81
96
|
void options;
|
|
82
97
|
});
|
|
83
98
|
const preset = program.command('preset').description('团队 preset');
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
/** --repo/--ref 指定时读该仓库快照,否则读项目引用并锁定的仓库。 */
|
|
100
|
+
const presetRepoRoot = (opts) => {
|
|
101
|
+
if (Boolean(opts.repo) !== Boolean(opts.ref))
|
|
102
|
+
throw new AppError('invalid_arguments', '--repo 与 --ref 需要同时指定');
|
|
103
|
+
if (opts.repo && opts.ref)
|
|
104
|
+
return fetchPresetRepo(opts.repo, opts.ref).cachePath;
|
|
105
|
+
const projectRoot = findProjectRoot();
|
|
106
|
+
const layer = loadProjectConfig(projectRoot);
|
|
107
|
+
if (!layer.preset)
|
|
108
|
+
throw new AppError('preset_repo_required', '项目未引用 preset;用 --repo 与 --ref 指定 preset 仓库');
|
|
109
|
+
return presetRootForProject(projectRoot, layer.preset).root;
|
|
110
|
+
};
|
|
111
|
+
preset.command('list')
|
|
112
|
+
.description('列出 preset:指定 --repo/--ref 时读该仓库,否则读项目引用的仓库')
|
|
113
|
+
.option('--repo <repo>', 'preset 仓库地址')
|
|
114
|
+
.option('--ref <ref>', '不可变 ref:完整 Git SHA 或 refs/tags/<tag>')
|
|
115
|
+
.action((opts, command) => {
|
|
116
|
+
const items = listRepoPresets(presetRepoRoot(opts));
|
|
117
|
+
emitJson(jsonMode(command), { presets: items }, () => items.map((item) => `${item.id}\t${item.title}`).join('\n'));
|
|
88
118
|
});
|
|
89
|
-
preset.command('
|
|
90
|
-
|
|
91
|
-
|
|
119
|
+
preset.command('upgrade')
|
|
120
|
+
.description('把项目引用的 preset 升级到新 ref,列出变化与被项目覆盖遮住的更新')
|
|
121
|
+
.requiredOption('--ref <ref>', '不可变 ref:完整 Git SHA 或 refs/tags/<tag>')
|
|
122
|
+
.option('--allow-active-changes', '存在进行中的 change 时仍然升级')
|
|
123
|
+
.action((opts, command) => {
|
|
124
|
+
const result = upgradePreset({ projectRoot: findProjectRoot(), ref: opts.ref, allowActiveChanges: opts.allowActiveChanges });
|
|
125
|
+
emitJson(jsonMode(command), result, () => {
|
|
126
|
+
if (result.status === 'unchanged')
|
|
127
|
+
return `preset ${result.id} 已是 ${result.to.ref}`;
|
|
128
|
+
const lines = [`preset ${result.id}: ${result.from.ref} → ${result.to.ref}(${result.to.resolvedSha.slice(0, 12)})`];
|
|
129
|
+
for (const [section, diff] of Object.entries(result.changes)) {
|
|
130
|
+
for (const [kind, ids] of Object.entries(diff))
|
|
131
|
+
if (ids.length > 0)
|
|
132
|
+
lines.push(` ${section} ${kind}: ${ids.join(', ')}`);
|
|
133
|
+
}
|
|
134
|
+
if (result.shadowed.length > 0) {
|
|
135
|
+
lines.push(' 以下更新被项目层覆盖,不会生效,请核对是否同步修改:');
|
|
136
|
+
lines.push(...result.shadowed.map((item) => ` ${item.section}.${item.id}`));
|
|
137
|
+
}
|
|
138
|
+
lines.push(...result.scaffold.filter((item) => item.status === 'created').map((item) => ` 生成 ${item.path}`));
|
|
139
|
+
lines.push('运行 openspec init --yes 安装或更新 preset 声明的插件');
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
});
|
|
92
142
|
});
|
|
93
|
-
preset.command('
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
143
|
+
preset.command('migrate <id>')
|
|
144
|
+
.description('把拷贝式配置迁移为引用 preset + 项目层;生效配置必须与迁移前一致')
|
|
145
|
+
.requiredOption('--repo <repo>', 'preset 仓库地址')
|
|
146
|
+
.requiredOption('--ref <ref>', '不可变 ref:完整 Git SHA 或 refs/tags/<tag>')
|
|
147
|
+
.option('--dry-run', '只预览迁移结果,不写文件')
|
|
148
|
+
.action((id, opts, command) => {
|
|
149
|
+
const result = migratePreset({ projectRoot: findProjectRoot(), id, repo: opts.repo, ref: opts.ref, dryRun: opts.dryRun });
|
|
150
|
+
emitJson(jsonMode(command), result, () => [
|
|
151
|
+
`${result.written ? '已迁移' : '预览'} ${id}@${opts.ref}(${result.resolvedSha.slice(0, 12)}),生效配置与迁移前一致`,
|
|
152
|
+
` 改为继承 preset:hooks ${result.inherited.hooks.length}、分类 ${result.inherited.taskCategories.length}、插件 ${result.inherited.plugins.length}、marketplace ${result.inherited.marketplaces.length}`,
|
|
153
|
+
` 项目层保留:hooks ${(result.layer.hooks ?? []).map((hook) => hook.id).join(', ') || '无'}`,
|
|
154
|
+
` 禁用 preset hook:${(result.layer.disableHooks ?? []).join(', ') || '无'}`,
|
|
155
|
+
...Object.entries(result.layer.hookOrder ?? {}).map(([stage, ids]) => ` 固定 ${stage} 阶段 hook 顺序:${ids.join(', ')}`),
|
|
156
|
+
...result.raisedMinVersions.map((item) => ` 插件最低版本提高:${item.plugin} ${item.from ?? '未声明'} → ${item.to}(preset 要求,迁移后安装与 doctor 会检查)`),
|
|
157
|
+
...(result.removedLegacyLock ? [` ${result.written ? '已删除' : '将删除'} openspec/preset-lock.yaml(由 preset-lock.json 取代)`] : []),
|
|
158
|
+
].join('\n'));
|
|
159
|
+
});
|
|
160
|
+
preset.command('index')
|
|
161
|
+
.description('在 preset 仓库中生成索引 openspec-presets.json')
|
|
162
|
+
.option('--dir <path>', 'preset 仓库根目录,默认当前目录')
|
|
163
|
+
.action((opts, command) => {
|
|
164
|
+
const index = writePresetIndex(path.resolve(opts.dir ?? process.cwd()));
|
|
165
|
+
emitJson(jsonMode(command), index, () => index.presets.map((item) => `${item.id}\t${item.digest.slice(0, 12)}`).join('\n'));
|
|
166
|
+
});
|
|
167
|
+
preset.command('validate')
|
|
168
|
+
.description('校验 preset 仓库:规则、模板、插件引用与索引一致性(供 CI 使用)')
|
|
169
|
+
.option('--dir <path>', 'preset 仓库根目录,默认当前目录')
|
|
170
|
+
.action((opts, command) => {
|
|
171
|
+
const result = validatePresetRepo(path.resolve(opts.dir ?? process.cwd()));
|
|
172
|
+
emitJson(jsonMode(command), result, () => [
|
|
173
|
+
result.ok ? 'preset repo ok' : 'preset repo has issues',
|
|
174
|
+
...result.presets.flatMap((item) => item.issues.map((issue) => ` ${item.id}: ${issue.code} ${issue.message}`)),
|
|
175
|
+
...(result.index.ok ? [] : [` index: ${result.index.code} ${result.index.message}`]),
|
|
103
176
|
].join('\n'));
|
|
177
|
+
if (!result.ok)
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
});
|
|
180
|
+
preset.command('status')
|
|
181
|
+
.description('查看项目引用的 preset 与生效配置中每项的来源')
|
|
182
|
+
.action((opts, command) => {
|
|
183
|
+
const projectRoot = findProjectRoot();
|
|
184
|
+
const layer = loadProjectConfig(projectRoot);
|
|
185
|
+
const baseline = loadPresetBaseline(projectRoot, layer);
|
|
186
|
+
const lock = layer.preset ? readPresetLock(projectRoot) : null;
|
|
187
|
+
const origins = describeOrigins(layer, baseline);
|
|
188
|
+
const payload = {
|
|
189
|
+
preset: layer.preset && lock ? { ...layer.preset, resolvedSha: lock.resolvedSha, digest: lock.digest } : null,
|
|
190
|
+
origins,
|
|
191
|
+
};
|
|
192
|
+
emitJson(jsonMode(command), payload, () => {
|
|
193
|
+
const lines = [payload.preset ? `preset ${payload.preset.id}@${payload.preset.ref}(${payload.preset.resolvedSha.slice(0, 12)})` : '未引用 preset'];
|
|
194
|
+
for (const [section, items] of Object.entries(origins)) {
|
|
195
|
+
for (const [id, origin] of Object.entries(items))
|
|
196
|
+
lines.push(` ${section}.${id}: ${origin}`);
|
|
197
|
+
}
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
});
|
|
104
200
|
void opts;
|
|
105
201
|
});
|
|
202
|
+
preset.command('show <id>')
|
|
203
|
+
.description('查看 preset 内容:指定 --repo/--ref 时读该仓库,否则读项目引用的仓库')
|
|
204
|
+
.option('--repo <repo>', 'preset 仓库地址')
|
|
205
|
+
.option('--ref <ref>', '不可变 ref:完整 Git SHA 或 refs/tags/<tag>')
|
|
206
|
+
.action((id, opts, command) => {
|
|
207
|
+
const preset = readRepoPreset(presetRepoRoot(opts), id);
|
|
208
|
+
emitJson(jsonMode(command), preset, () => `${preset.id}\t${preset.title}`);
|
|
209
|
+
});
|
|
210
|
+
preset.command('use <id>')
|
|
211
|
+
.description('引用 preset 仓库中的 preset')
|
|
212
|
+
.requiredOption('--repo <repo>', 'preset 仓库地址')
|
|
213
|
+
.requiredOption('--ref <ref>', '不可变 ref:完整 Git SHA 或 refs/tags/<tag>')
|
|
214
|
+
.action((id, opts, command) => {
|
|
215
|
+
const projectRoot = findProjectRoot();
|
|
216
|
+
const result = usePreset({ projectRoot, id, repo: opts.repo, ref: opts.ref });
|
|
217
|
+
emitJson(jsonMode(command), result, () => [
|
|
218
|
+
`using ${id}@${opts.ref}(${result.resolvedSha.slice(0, 12)})`,
|
|
219
|
+
...result.scaffold.map((item) => ` ${item.status === 'created' ? '生成' : '保留已有'} ${item.path}`),
|
|
220
|
+
...result.warnings.map((item) => ` 注意:${item}`),
|
|
221
|
+
...result.notes.map((item) => ` 说明:${item}`),
|
|
222
|
+
'运行 openspec init --yes 安装 preset 声明的插件',
|
|
223
|
+
].join('\n'));
|
|
224
|
+
});
|
|
106
225
|
const marketplace = program.command('marketplace').description('插件市场');
|
|
107
226
|
marketplace.command('list').action((opts, command) => {
|
|
108
227
|
const { projectRoot } = project();
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { initProject
|
|
2
|
+
import { initProject } from '../../index.js';
|
|
3
3
|
import { AppError } from '../../shared/errors.js';
|
|
4
4
|
import { emitJson, jsonMode } from '../output.js';
|
|
5
|
-
import { configPathOf,
|
|
5
|
+
import { configPathOf, loadProjectConfig } from '../../config/load.js';
|
|
6
6
|
export function registerInit(program) {
|
|
7
7
|
program
|
|
8
8
|
.command('init [path]')
|
|
9
9
|
.description('初始化 OpenSpec 项目')
|
|
10
10
|
.option('--tools <list>', '宿主工具,逗号分隔:claude,codex,cursor')
|
|
11
|
-
.option('--preset <id>', '
|
|
11
|
+
.option('--preset <id>', '引用 preset 仓库中的团队 preset(需同时指定 --preset-repo 与 --preset-ref)')
|
|
12
|
+
.option('--preset-repo <repo>', 'preset 仓库地址')
|
|
13
|
+
.option('--preset-ref <ref>', 'preset 的不可变 ref:完整 Git SHA 或 refs/tags/<tag>')
|
|
12
14
|
.option('--yes', '非交互确认(安装 preset 插件)')
|
|
13
15
|
.option('--skip-plugins', '跳过 marketplace 插件安装')
|
|
14
16
|
.action(async (targetPath, options, command) => {
|
|
15
17
|
const tools = parseTools(options.tools);
|
|
18
|
+
const preset = parsePreset(options.preset, options.presetRepo, options.presetRef);
|
|
16
19
|
let confirmed = Boolean(options.yes);
|
|
17
20
|
const projectRoot = path.resolve(targetPath ?? process.cwd());
|
|
18
21
|
if (!confirmed
|
|
19
22
|
&& !options.skipPlugins
|
|
20
|
-
&& needsConfirmation(projectRoot, tools,
|
|
23
|
+
&& needsConfirmation(projectRoot, tools, Boolean(preset))
|
|
21
24
|
&& process.stdin.isTTY
|
|
22
25
|
&& process.stdout.isTTY) {
|
|
23
26
|
const { confirm } = await import('@inquirer/prompts');
|
|
@@ -28,7 +31,7 @@ export function registerInit(program) {
|
|
|
28
31
|
const result = initProject({
|
|
29
32
|
cwd: targetPath,
|
|
30
33
|
tools,
|
|
31
|
-
preset
|
|
34
|
+
preset,
|
|
32
35
|
yes: confirmed,
|
|
33
36
|
skipPlugins: Boolean(options.skipPlugins),
|
|
34
37
|
onProgress: json ? undefined : (message) => process.stderr.write(`${message}\n`),
|
|
@@ -49,14 +52,21 @@ export function registerInit(program) {
|
|
|
49
52
|
process.exitCode = 2;
|
|
50
53
|
});
|
|
51
54
|
}
|
|
52
|
-
function needsConfirmation(projectRoot, tools,
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
return
|
|
55
|
+
function needsConfirmation(projectRoot, tools, withPreset) {
|
|
56
|
+
// preset 在拉取前不知道声明了哪些插件,引用了 preset 就按需要确认处理。
|
|
57
|
+
const existing = configPathOf(projectRoot) ? loadProjectConfig(projectRoot) : null;
|
|
58
|
+
return withPreset
|
|
59
|
+
|| Boolean(existing?.preset)
|
|
60
|
+
|| (tools ?? existing?.tools ?? []).length > 0
|
|
56
61
|
|| (existing?.plugins ?? []).length > 0
|
|
57
|
-
|| (existing?.marketplaces ?? []).length > 0
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
|| (existing?.marketplaces ?? []).length > 0;
|
|
63
|
+
}
|
|
64
|
+
function parsePreset(id, repo, ref) {
|
|
65
|
+
if (!id && !repo && !ref)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (!id || !repo || !ref)
|
|
68
|
+
throw new AppError('invalid_arguments', '--preset、--preset-repo 与 --preset-ref 需要同时指定');
|
|
69
|
+
return { id, repo, ref };
|
|
60
70
|
}
|
|
61
71
|
function parseTools(raw) {
|
|
62
72
|
if (!raw)
|
package/dist/config/load.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type ProjectConfig } from './schema.js';
|
|
2
2
|
export declare function findProjectRoot(start?: string): string;
|
|
3
3
|
export declare function configPathOf(projectRoot: string): string | null;
|
|
4
|
+
/** 项目层:config.yaml 的原样内容,只做结构与重复校验。会写配置的命令只读写这一层。 */
|
|
5
|
+
export declare function loadProjectConfig(projectRoot: string): ProjectConfig;
|
|
6
|
+
/** 生效配置:preset 基线与项目层合成后的结果,工作流一律读取这一份。 */
|
|
4
7
|
export declare function loadConfig(projectRoot: string): ProjectConfig;
|
|
8
|
+
/** 只写项目层;写入前确认它与基线合成后仍是合法的生效配置。 */
|
|
5
9
|
export declare function saveConfig(projectRoot: string, config: ProjectConfig): void;
|
|
6
10
|
export declare function defaultConfig(): ProjectConfig;
|
|
7
11
|
//# sourceMappingURL=load.d.ts.map
|
package/dist/config/load.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
3
3
|
import { exists, readText, writeText } from '../shared/fs.js';
|
|
4
|
-
import { AppError } from '../shared/errors.js';
|
|
4
|
+
import { AppError, isAppError } from '../shared/errors.js';
|
|
5
5
|
import { CONFIG_FILES, OPENSPEC_DIR, resolveInside } from '../shared/paths.js';
|
|
6
|
-
import {
|
|
6
|
+
import { ProjectConfigLayerSchema } from './schema.js';
|
|
7
|
+
import { resolveEffectiveConfig } from './presets.js';
|
|
8
|
+
import { loadPresetBaseline } from './preset-repo.js';
|
|
7
9
|
export function findProjectRoot(start = process.cwd()) {
|
|
8
10
|
let dir = path.resolve(start);
|
|
9
11
|
while (true) {
|
|
@@ -24,7 +26,8 @@ export function configPathOf(projectRoot) {
|
|
|
24
26
|
}
|
|
25
27
|
return null;
|
|
26
28
|
}
|
|
27
|
-
|
|
29
|
+
/** 项目层:config.yaml 的原样内容,只做结构与重复校验。会写配置的命令只读写这一层。 */
|
|
30
|
+
export function loadProjectConfig(projectRoot) {
|
|
28
31
|
const file = configPathOf(projectRoot);
|
|
29
32
|
if (!file)
|
|
30
33
|
throw new AppError('project_uninitialized', '缺少 openspec/config.yaml');
|
|
@@ -35,16 +38,31 @@ export function loadConfig(projectRoot) {
|
|
|
35
38
|
catch (error) {
|
|
36
39
|
throw new AppError('invalid_config', '项目配置 YAML 无法解析', error instanceof Error ? error.message : String(error));
|
|
37
40
|
}
|
|
38
|
-
const result =
|
|
41
|
+
const result = ProjectConfigLayerSchema.safeParse(parsed);
|
|
39
42
|
if (!result.success) {
|
|
40
43
|
throw new AppError('invalid_config', '项目配置不合法', result.error.issues);
|
|
41
44
|
}
|
|
42
45
|
return result.data;
|
|
43
46
|
}
|
|
47
|
+
/** 生效配置:preset 基线与项目层合成后的结果,工作流一律读取这一份。 */
|
|
48
|
+
export function loadConfig(projectRoot) {
|
|
49
|
+
const layer = loadProjectConfig(projectRoot);
|
|
50
|
+
return resolveEffectiveConfig(layer, loadPresetBaseline(projectRoot, layer));
|
|
51
|
+
}
|
|
52
|
+
/** 只写项目层;写入前确认它与基线合成后仍是合法的生效配置。 */
|
|
44
53
|
export function saveConfig(projectRoot, config) {
|
|
45
|
-
const parsed =
|
|
54
|
+
const parsed = ProjectConfigLayerSchema.safeParse(config);
|
|
46
55
|
if (!parsed.success)
|
|
47
56
|
throw new AppError('invalid_config', '拒绝写入不合法的项目配置', parsed.error.issues);
|
|
57
|
+
try {
|
|
58
|
+
resolveEffectiveConfig(parsed.data, loadPresetBaseline(projectRoot, parsed.data));
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (isAppError(error) && error.code === 'invalid_config') {
|
|
62
|
+
throw new AppError('invalid_config', '拒绝写入不合法的项目配置', error.details);
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
48
66
|
const file = configPathOf(projectRoot)
|
|
49
67
|
?? resolveInside(projectRoot, path.posix.join(OPENSPEC_DIR, 'config.yaml'), 'config_path_outside_project');
|
|
50
68
|
writeText(file, stringifyYaml(parsed.data, { lineWidth: 0 }));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type RepoPreset } from './preset-repo.js';
|
|
2
|
+
/** preset 仓库根部的索引:由 `openspec preset index` 生成,CI 用 `openspec preset validate` 核对与内容一致。 */
|
|
3
|
+
export declare const PRESET_INDEX_FILE = "openspec-presets.json";
|
|
4
|
+
export interface PresetIndex {
|
|
5
|
+
schemaVersion: 'openspec.preset-index.v1';
|
|
6
|
+
presets: Array<{
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
requires?: RepoPreset['requires'];
|
|
11
|
+
digest: string;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildPresetIndex(root: string): PresetIndex;
|
|
15
|
+
export declare function writePresetIndex(root: string): PresetIndex;
|
|
16
|
+
type Issue = {
|
|
17
|
+
code: string;
|
|
18
|
+
message: string;
|
|
19
|
+
details?: unknown;
|
|
20
|
+
};
|
|
21
|
+
/** 校验整个 preset 仓库:每个 preset 的检查,加上索引存在且与当前内容一致。 */
|
|
22
|
+
export declare function validatePresetRepo(root: string): {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
presets: Array<{
|
|
25
|
+
id: string;
|
|
26
|
+
ok: boolean;
|
|
27
|
+
issues: Issue[];
|
|
28
|
+
}>;
|
|
29
|
+
index: {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
code?: string;
|
|
32
|
+
message?: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=preset-repo-tools.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { exists, listDirs, readJson, readText, writeJson } from '../shared/fs.js';
|
|
4
|
+
import { digestDirectory } from '../shared/hash.js';
|
|
5
|
+
import { AppError } from '../shared/errors.js';
|
|
6
|
+
import { resolveInside } from '../shared/paths.js';
|
|
7
|
+
import { TEMPLATE_MARKER } from '../hooks/engine.js';
|
|
8
|
+
import { defaultConfig } from './load.js';
|
|
9
|
+
import { resolveEffectiveConfig } from './presets.js';
|
|
10
|
+
import { listRepoPresets, presetBaseline, readRepoPreset, REPO_PRESET_FILE } from './preset-repo.js';
|
|
11
|
+
/** preset 仓库根部的索引:由 `openspec preset index` 生成,CI 用 `openspec preset validate` 核对与内容一致。 */
|
|
12
|
+
export const PRESET_INDEX_FILE = 'openspec-presets.json';
|
|
13
|
+
export function buildPresetIndex(root) {
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: 'openspec.preset-index.v1',
|
|
16
|
+
presets: listRepoPresets(root).map((item) => {
|
|
17
|
+
const preset = readRepoPreset(root, item.id);
|
|
18
|
+
return {
|
|
19
|
+
...item,
|
|
20
|
+
...(preset.requires ? { requires: preset.requires } : {}),
|
|
21
|
+
digest: digestDirectory(path.join(root, item.id)),
|
|
22
|
+
};
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function writePresetIndex(root) {
|
|
27
|
+
const index = buildPresetIndex(root);
|
|
28
|
+
writeJson(resolveInside(root, PRESET_INDEX_FILE, 'preset_index_outside_repo'), index);
|
|
29
|
+
return index;
|
|
30
|
+
}
|
|
31
|
+
/** 单个 preset 的发布前检查:规则能与空项目合成为合法配置、模板存在且带待填写标记、插件入口的 marketplace 已声明。 */
|
|
32
|
+
function checkPreset(root, id) {
|
|
33
|
+
let preset;
|
|
34
|
+
try {
|
|
35
|
+
preset = readRepoPreset(root, id);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof AppError)
|
|
39
|
+
return [{ code: error.code, message: error.message, details: error.details }];
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
const issues = [];
|
|
43
|
+
try {
|
|
44
|
+
resolveEffectiveConfig(defaultConfig(), presetBaseline(preset));
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!(error instanceof AppError))
|
|
48
|
+
throw error;
|
|
49
|
+
issues.push({ code: error.code, message: error.message, details: error.details });
|
|
50
|
+
}
|
|
51
|
+
const presetDir = path.join(root, id);
|
|
52
|
+
for (const entry of preset.scaffold ?? []) {
|
|
53
|
+
let template;
|
|
54
|
+
try {
|
|
55
|
+
template = resolveInside(presetDir, entry.template, 'preset_template_outside_presets');
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!(error instanceof AppError))
|
|
59
|
+
throw error;
|
|
60
|
+
issues.push({ code: error.code, message: error.message });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!exists(template))
|
|
64
|
+
issues.push({ code: 'preset_template_missing', message: `缺少模板文件: ${entry.template}` });
|
|
65
|
+
else if (!readText(template).startsWith(TEMPLATE_MARKER)) {
|
|
66
|
+
issues.push({ code: 'preset_template_unmarked', message: `模板首行必须是 ${TEMPLATE_MARKER}: ${entry.template}` });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const markets = new Set((preset.marketplaces ?? []).map((item) => item.name));
|
|
70
|
+
for (const plugin of preset.plugins ?? []) {
|
|
71
|
+
if (!markets.has(plugin.marketplace)) {
|
|
72
|
+
issues.push({ code: 'preset_marketplace_undeclared', message: `插件 ${plugin.name} 的 marketplace 未在 preset 中声明: ${plugin.marketplace}` });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const hook of preset.configPatch?.hooks ?? []) {
|
|
76
|
+
const marketplace = hook.use?.split('@')[1];
|
|
77
|
+
if (marketplace && !markets.has(marketplace)) {
|
|
78
|
+
issues.push({ code: 'preset_marketplace_undeclared', message: `hook ${hook.id} 引用的 marketplace 未在 preset 中声明: ${marketplace}` });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return issues;
|
|
82
|
+
}
|
|
83
|
+
/** 校验整个 preset 仓库:每个 preset 的检查,加上索引存在且与当前内容一致。 */
|
|
84
|
+
export function validatePresetRepo(root) {
|
|
85
|
+
const ids = listPresetDirs(root);
|
|
86
|
+
const presets = ids.map((id) => {
|
|
87
|
+
const issues = checkPreset(root, id);
|
|
88
|
+
return { id, ok: issues.length === 0, issues };
|
|
89
|
+
});
|
|
90
|
+
let index;
|
|
91
|
+
const indexFile = path.join(root, PRESET_INDEX_FILE);
|
|
92
|
+
if (!exists(indexFile))
|
|
93
|
+
index = { ok: false, code: 'preset_index_missing', message: `缺少 ${PRESET_INDEX_FILE},运行 openspec preset index` };
|
|
94
|
+
else if (presets.some((item) => !item.ok))
|
|
95
|
+
index = { ok: false, code: 'preset_index_unchecked', message: '存在不合法的 preset,未核对索引' };
|
|
96
|
+
else if (!isDeepStrictEqual(readJson(indexFile), buildPresetIndex(root))) {
|
|
97
|
+
index = { ok: false, code: 'preset_index_stale', message: `${PRESET_INDEX_FILE} 与内容不一致,运行 openspec preset index 后提交` };
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
index = { ok: true };
|
|
101
|
+
if (ids.length === 0) {
|
|
102
|
+
return { ok: false, presets, index: { ok: false, code: 'preset_repo_empty', message: `仓库中没有任何 <id>/${REPO_PRESET_FILE}` } };
|
|
103
|
+
}
|
|
104
|
+
return { ok: presets.every((item) => item.ok) && index.ok, presets, index };
|
|
105
|
+
}
|
|
106
|
+
/** 含 preset.yaml 的顶层目录;不解析内容,解析错误留给逐项检查报告。 */
|
|
107
|
+
function listPresetDirs(root) {
|
|
108
|
+
if (!exists(root))
|
|
109
|
+
throw new AppError('preset_repo_missing', `目录不存在: ${root}`);
|
|
110
|
+
return listDirs(root)
|
|
111
|
+
.filter((id) => !id.startsWith('.') && exists(path.join(root, id, REPO_PRESET_FILE)))
|
|
112
|
+
.sort();
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=preset-repo-tools.js.map
|