@birdie_moblie/open_spec 2.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/.agents/plugins/marketplace.json +13 -0
  2. package/.claude-plugin/marketplace.json +12 -0
  3. package/.claude-plugin/plugin.json +9 -0
  4. package/.codex-plugin/plugin.json +9 -0
  5. package/LICENSE +21 -0
  6. package/README.md +111 -0
  7. package/bin/openspec.js +3 -0
  8. package/dist/cli/commands/change.d.ts +3 -0
  9. package/dist/cli/commands/change.js +37 -0
  10. package/dist/cli/commands/ext.d.ts +3 -0
  11. package/dist/cli/commands/ext.js +196 -0
  12. package/dist/cli/commands/figma.d.ts +3 -0
  13. package/dist/cli/commands/figma.js +73 -0
  14. package/dist/cli/commands/init.d.ts +3 -0
  15. package/dist/cli/commands/init.js +71 -0
  16. package/dist/cli/commands/opsx.d.ts +3 -0
  17. package/dist/cli/commands/opsx.js +324 -0
  18. package/dist/cli/commands/usage.d.ts +3 -0
  19. package/dist/cli/commands/usage.js +21 -0
  20. package/dist/cli/output.d.ts +8 -0
  21. package/dist/cli/output.js +17 -0
  22. package/dist/cli/program.d.ts +4 -0
  23. package/dist/cli/program.js +42 -0
  24. package/dist/cli/register.d.ts +3 -0
  25. package/dist/cli/register.js +13 -0
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +3 -0
  28. package/dist/config/load.d.ts +7 -0
  29. package/dist/config/load.js +55 -0
  30. package/dist/config/presets.d.ts +129 -0
  31. package/dist/config/presets.js +125 -0
  32. package/dist/config/schema.d.ts +281 -0
  33. package/dist/config/schema.js +178 -0
  34. package/dist/core/archive.d.ts +5 -0
  35. package/dist/core/archive.js +33 -0
  36. package/dist/core/change.d.ts +12 -0
  37. package/dist/core/change.js +69 -0
  38. package/dist/core/code-changes.d.ts +9 -0
  39. package/dist/core/code-changes.js +31 -0
  40. package/dist/core/flow.d.ts +6 -0
  41. package/dist/core/flow.js +99 -0
  42. package/dist/core/gates.d.ts +5 -0
  43. package/dist/core/gates.js +71 -0
  44. package/dist/core/instructions.d.ts +21 -0
  45. package/dist/core/instructions.js +102 -0
  46. package/dist/core/source-operations.d.ts +25 -0
  47. package/dist/core/source-operations.js +94 -0
  48. package/dist/core/sources.d.ts +33 -0
  49. package/dist/core/sources.js +234 -0
  50. package/dist/core/state.d.ts +15 -0
  51. package/dist/core/state.js +75 -0
  52. package/dist/core/status.d.ts +31 -0
  53. package/dist/core/status.js +22 -0
  54. package/dist/core/tasks.d.ts +15 -0
  55. package/dist/core/tasks.js +143 -0
  56. package/dist/core/todo.d.ts +31 -0
  57. package/dist/core/todo.js +114 -0
  58. package/dist/core/types.d.ts +297 -0
  59. package/dist/core/types.js +227 -0
  60. package/dist/core/verify.d.ts +229 -0
  61. package/dist/core/verify.js +391 -0
  62. package/dist/core/worker-evidence.d.ts +71 -0
  63. package/dist/core/worker-evidence.js +253 -0
  64. package/dist/core/worker.d.ts +22 -0
  65. package/dist/core/worker.js +478 -0
  66. package/dist/delivery/claude-install.d.ts +4 -0
  67. package/dist/delivery/claude-install.js +44 -0
  68. package/dist/delivery/distribution.d.ts +3 -0
  69. package/dist/delivery/distribution.js +53 -0
  70. package/dist/delivery/skills.d.ts +36 -0
  71. package/dist/delivery/skills.js +178 -0
  72. package/dist/figma/cache.d.ts +19 -0
  73. package/dist/figma/cache.js +115 -0
  74. package/dist/figma/claude.d.ts +14 -0
  75. package/dist/figma/claude.js +121 -0
  76. package/dist/figma/collect.d.ts +3 -0
  77. package/dist/figma/collect.js +57 -0
  78. package/dist/figma/index.d.ts +35 -0
  79. package/dist/figma/index.js +178 -0
  80. package/dist/figma/media.d.ts +5 -0
  81. package/dist/figma/media.js +77 -0
  82. package/dist/figma/response.d.ts +9 -0
  83. package/dist/figma/response.js +27 -0
  84. package/dist/hooks/command-context.d.ts +19 -0
  85. package/dist/hooks/command-context.js +33 -0
  86. package/dist/hooks/engine.d.ts +61 -0
  87. package/dist/hooks/engine.js +314 -0
  88. package/dist/hooks/plugin-runtime.d.ts +188 -0
  89. package/dist/hooks/plugin-runtime.js +198 -0
  90. package/dist/index.d.ts +99 -0
  91. package/dist/index.js +185 -0
  92. package/dist/marketplace/adapters.d.ts +202 -0
  93. package/dist/marketplace/adapters.js +203 -0
  94. package/dist/marketplace/builtin.d.ts +9 -0
  95. package/dist/marketplace/builtin.js +9 -0
  96. package/dist/marketplace/install.d.ts +38 -0
  97. package/dist/marketplace/install.js +108 -0
  98. package/dist/marketplace/registry.d.ts +71 -0
  99. package/dist/marketplace/registry.js +342 -0
  100. package/dist/marketplace/upgrade.d.ts +39 -0
  101. package/dist/marketplace/upgrade.js +104 -0
  102. package/dist/shared/command-log.d.ts +9 -0
  103. package/dist/shared/command-log.js +55 -0
  104. package/dist/shared/errors.d.ts +7 -0
  105. package/dist/shared/errors.js +14 -0
  106. package/dist/shared/exec.d.ts +16 -0
  107. package/dist/shared/exec.js +86 -0
  108. package/dist/shared/fs.d.ts +18 -0
  109. package/dist/shared/fs.js +93 -0
  110. package/dist/shared/git.d.ts +22 -0
  111. package/dist/shared/git.js +132 -0
  112. package/dist/shared/hash.d.ts +5 -0
  113. package/dist/shared/hash.js +29 -0
  114. package/dist/shared/paths.d.ts +16 -0
  115. package/dist/shared/paths.js +97 -0
  116. package/dist/shared/pkg.d.ts +3 -0
  117. package/dist/shared/pkg.js +23 -0
  118. package/dist/shared/version.d.ts +9 -0
  119. package/dist/shared/version.js +34 -0
  120. package/dist/usage/analyze.d.ts +5 -0
  121. package/dist/usage/analyze.js +212 -0
  122. package/dist/usage/claude.d.ts +9 -0
  123. package/dist/usage/claude.js +59 -0
  124. package/dist/usage/collect.d.ts +11 -0
  125. package/dist/usage/collect.js +71 -0
  126. package/dist/usage/doctor.d.ts +72 -0
  127. package/dist/usage/doctor.js +121 -0
  128. package/dist/usage/parse.d.ts +28 -0
  129. package/dist/usage/parse.js +345 -0
  130. package/dist/usage/types.d.ts +103 -0
  131. package/dist/usage/types.js +2 -0
  132. package/docs/getting-started.md +20 -0
  133. package/docs/guides/configuration.md +33 -0
  134. package/docs/guides/cost-benchmark.md +88 -0
  135. package/docs/guides/plugin-migration.md +65 -0
  136. package/docs/guides/smart-figma.md +51 -0
  137. package/docs/guides/source-reading.md +35 -0
  138. package/docs/guides/ui-conventions-template.md +39 -0
  139. package/docs/guides/worker-evidence.md +73 -0
  140. package/docs/guides/workflow.md +74 -0
  141. package/docs/maintainers/architecture.md +27 -0
  142. package/docs/maintainers/plugin-runtime-contract.md +55 -0
  143. package/package.json +78 -0
  144. package/plugin.json +9 -0
  145. package/presets/backend-service.yaml +19 -0
  146. package/presets/flutter-mobile/plugin-hooks.json +68 -0
  147. package/presets/flutter-mobile/ui-conventions.md +122 -0
  148. package/presets/flutter-mobile.yaml +205 -0
  149. package/presets/web-product.yaml +23 -0
  150. package/skills/opsx-apply/SKILL.md +35 -0
  151. package/skills/opsx-apply/nodes/blocked.md +7 -0
  152. package/skills/opsx-apply/nodes/code-task.md +29 -0
  153. package/skills/opsx-apply/nodes/gate-a.md +9 -0
  154. package/skills/opsx-apply/nodes/stop.md +5 -0
  155. package/skills/opsx-apply/nodes/task-build.md +50 -0
  156. package/skills/opsx-apply/nodes/task-repair.md +20 -0
  157. package/skills/opsx-apply/nodes/task-scout.md +28 -0
  158. package/skills/opsx-apply/nodes/task-verify.md +38 -0
  159. package/skills/opsx-archive/SKILL.md +24 -0
  160. package/skills/opsx-archive/nodes/archive.md +15 -0
  161. package/skills/opsx-explore/SKILL.md +20 -0
  162. package/skills/opsx-explore/nodes/explore.md +3 -0
  163. package/skills/opsx-fix-verify/SKILL.md +30 -0
  164. package/skills/opsx-fix-verify/nodes/repair.md +11 -0
  165. package/skills/opsx-fix-verify/nodes/reverify.md +3 -0
  166. package/skills/opsx-fix-verify/nodes/stop.md +3 -0
  167. package/skills/opsx-gate-decision/SKILL.md +20 -0
  168. package/skills/opsx-gate-decision/nodes/decide.md +15 -0
  169. package/skills/opsx-propose/SKILL.md +36 -0
  170. package/skills/opsx-propose/nodes/blocked.md +13 -0
  171. package/skills/opsx-propose/nodes/collect.md +35 -0
  172. package/skills/opsx-propose/nodes/parse.md +72 -0
  173. package/skills/opsx-propose/nodes/plan.md +65 -0
  174. package/skills/opsx-propose/nodes/stop.md +17 -0
  175. package/skills/opsx-propose/nodes/tech.md +77 -0
  176. package/skills/opsx-propose/scripts/read-json-source.mjs +22 -0
  177. package/skills/opsx-resume/SKILL.md +21 -0
  178. package/skills/opsx-resume/nodes/route.md +12 -0
  179. package/skills/opsx-source-update/SKILL.md +23 -0
  180. package/skills/opsx-source-update/nodes/update.md +14 -0
  181. package/skills/opsx-verify/SKILL.md +32 -0
  182. package/skills/opsx-verify/nodes/blocked.md +3 -0
  183. package/skills/opsx-verify/nodes/failed.md +3 -0
  184. package/skills/opsx-verify/nodes/gate-b.md +7 -0
  185. package/skills/opsx-verify/nodes/stop.md +5 -0
  186. package/skills/opsx-verify/nodes/verify.md +10 -0
@@ -0,0 +1,342 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { renameSync } from 'node:fs';
4
+ import { z } from 'zod';
5
+ import { execFile } from '../shared/exec.js';
6
+ import { copyPath, ensureDir, exists, readJson, removePath, writeJson } from '../shared/fs.js';
7
+ import { digestDirectory } from '../shared/hash.js';
8
+ import { AppError } from '../shared/errors.js';
9
+ import { resolveInside } from '../shared/paths.js';
10
+ import { compareVersions } from '../shared/version.js';
11
+ import { BUILTIN_MARKETPLACE } from './builtin.js';
12
+ import { discoverPluginDirs, marketplaceCacheDir, marketplaceCacheRoot, marketplaceLockPath, marketplaceRootForProject, readManifest, readMarketplaceLock, } from './adapters.js';
13
+ const RecordSchema = z.object({
14
+ name: z.string(),
15
+ repo: z.string().optional(),
16
+ ref: z.string(),
17
+ resolvedSha: z.string(),
18
+ digest: z.string(),
19
+ cachePath: z.string(),
20
+ updatedAt: z.string(),
21
+ }).strict();
22
+ const StateSchema = z.object({ marketplaces: z.array(RecordSchema) }).strict();
23
+ function stateFile() {
24
+ return resolveInside(marketplaceCacheRoot(), 'state.json', 'marketplace_state_outside_home');
25
+ }
26
+ /**
27
+ * 用户级缓存索引只是派生数据(项目 lock 才是权威入口)。
28
+ * 旧格式或损坏的索引不能阻塞 init:保留仍合法的记录,其余丢弃,原文件备份为 state.json.invalid-<时间戳>。
29
+ */
30
+ export function loadMarketplaceState() {
31
+ const file = stateFile();
32
+ if (!exists(file))
33
+ return { marketplaces: [] };
34
+ let raw;
35
+ try {
36
+ raw = readJson(file);
37
+ }
38
+ catch {
39
+ raw = null;
40
+ }
41
+ const parsed = StateSchema.safeParse(raw);
42
+ if (parsed.success)
43
+ return parsed.data;
44
+ const candidates = isRecordObject(raw) && Array.isArray(raw.marketplaces) ? raw.marketplaces : [];
45
+ const kept = candidates.flatMap((item) => {
46
+ const record = RecordSchema.safeParse(item);
47
+ return record.success ? [record.data] : [];
48
+ });
49
+ renameSync(file, `${file}.invalid-${Date.now()}`);
50
+ return { marketplaces: kept };
51
+ }
52
+ function isRecordObject(value) {
53
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
54
+ }
55
+ function saveMarketplaceState(state) {
56
+ writeJson(stateFile(), StateSchema.parse(state));
57
+ }
58
+ export function addMarketplace(input) {
59
+ const record = input.dir ? addLocalMarketplace(input) : addGitMarketplace(input);
60
+ validateMarketplace(record.cachePath);
61
+ const saved = upsert(record);
62
+ if (input.projectRoot)
63
+ writeProjectLock(input.projectRoot, saved);
64
+ return saved;
65
+ }
66
+ export function listMarketplaces(projectRoot) {
67
+ if (projectRoot) {
68
+ return readMarketplaceLock(projectRoot).marketplaces.map((record) => ({ ...record, updatedAt: '' }));
69
+ }
70
+ return loadMarketplaceState().marketplaces;
71
+ }
72
+ export function ensurePresetMarketplaces(projectRoot, marketplaces) {
73
+ return marketplaces.map((item) => addMarketplace({ projectRoot, ...item }));
74
+ }
75
+ /** 插件版本不低于宿主声明的 minVersion;manifest 缺版本或无法解析时视为不满足。 */
76
+ export function pluginVersionSatisfies(version, minVersion) {
77
+ if (!minVersion)
78
+ return true;
79
+ if (!version)
80
+ return false;
81
+ return (compareVersions(version, minVersion) ?? -1) >= 0;
82
+ }
83
+ export function assertPluginsPresent(projectRoot, plugins) {
84
+ const lock = readMarketplaceLock(projectRoot);
85
+ const resolved = [];
86
+ const missing = [];
87
+ const outdated = [];
88
+ for (const plugin of plugins) {
89
+ const root = marketplaceRootForProject(projectRoot, plugin.marketplace);
90
+ const matches = discoverPluginDirs(root).filter((dir) => path.basename(dir) === plugin.name || readManifest(dir).name === plugin.name);
91
+ if (matches.length === 0) {
92
+ missing.push(`${plugin.name}@${plugin.marketplace}`);
93
+ continue;
94
+ }
95
+ if (matches.length > 1)
96
+ throw new AppError('plugin_conflict', `marketplace 中插件重复: ${plugin.name}@${plugin.marketplace}`);
97
+ const pluginDir = matches[0];
98
+ const version = readManifest(pluginDir).version;
99
+ if (!pluginVersionSatisfies(version, plugin.minVersion)) {
100
+ outdated.push(`${plugin.name}@${plugin.marketplace}(实际 ${version ?? '未声明'},最低 ${plugin.minVersion})`);
101
+ continue;
102
+ }
103
+ const marketplace = lock.marketplaces.find((item) => item.name === plugin.marketplace);
104
+ if (!marketplace)
105
+ throw new AppError('marketplace_not_locked', `项目未锁定 marketplace: ${plugin.marketplace}`);
106
+ resolved.push({
107
+ name: plugin.name,
108
+ marketplace: plugin.marketplace,
109
+ resolvedSha: marketplace.resolvedSha,
110
+ digest: digestDirectory(pluginDir),
111
+ });
112
+ }
113
+ if (missing.length > 0) {
114
+ throw new AppError('plugin_missing_in_marketplace', `marketplace 缺少必需插件: ${missing.join(', ')}`);
115
+ }
116
+ if (outdated.length > 0) {
117
+ throw new AppError('plugin_version_too_low', `插件版本低于宿主要求: ${outdated.join(', ')}`);
118
+ }
119
+ for (const plugin of resolved) {
120
+ const index = lock.plugins.findIndex((item) => item.name === plugin.name && item.marketplace === plugin.marketplace);
121
+ if (index >= 0)
122
+ lock.plugins[index] = plugin;
123
+ else
124
+ lock.plugins.push(plugin);
125
+ }
126
+ writeJson(marketplaceLockPath(projectRoot), lock);
127
+ return plugins.map((plugin) => `${plugin.name}@${plugin.marketplace}`);
128
+ }
129
+ export function inspectMarketplaceDependencies(projectRoot, marketplaces, plugins) {
130
+ const lock = readMarketplaceLock(projectRoot);
131
+ const checks = [];
132
+ for (const expected of marketplaces) {
133
+ const record = lock.marketplaces.find((item) => item.name === expected.name);
134
+ if (!record) {
135
+ checks.push({ kind: 'marketplace', id: expected.name, ok: false, code: 'marketplace_not_locked' });
136
+ continue;
137
+ }
138
+ if (record.repo !== expected.repo || record.ref !== expected.ref) {
139
+ checks.push({ kind: 'marketplace', id: expected.name, ok: false, code: 'marketplace_lock_mismatch' });
140
+ continue;
141
+ }
142
+ try {
143
+ marketplaceRootForProject(projectRoot, expected.name);
144
+ checks.push({
145
+ kind: 'marketplace', id: expected.name, ok: true, digest: record.digest, resolvedSha: record.resolvedSha,
146
+ });
147
+ }
148
+ catch (error) {
149
+ checks.push({ kind: 'marketplace', id: expected.name, ok: false, code: errorCode(error) });
150
+ }
151
+ }
152
+ for (const expected of plugins) {
153
+ const id = `${expected.name}@${expected.marketplace}`;
154
+ const record = lock.plugins.find((item) => item.name === expected.name && item.marketplace === expected.marketplace);
155
+ if (!record) {
156
+ checks.push({ kind: 'plugin', id, ok: false, code: 'plugin_not_locked' });
157
+ continue;
158
+ }
159
+ try {
160
+ const root = marketplaceRootForProject(projectRoot, expected.marketplace);
161
+ const matches = discoverPluginDirs(root).filter((dir) => path.basename(dir) === expected.name || readManifest(dir).name === expected.name);
162
+ if (matches.length !== 1) {
163
+ checks.push({ kind: 'plugin', id, ok: false, code: matches.length === 0 ? 'plugin_missing_in_marketplace' : 'plugin_conflict' });
164
+ continue;
165
+ }
166
+ const pluginDir = matches[0];
167
+ const digest = digestDirectory(pluginDir);
168
+ const marketplace = lock.marketplaces.find((item) => item.name === expected.marketplace);
169
+ const identical = digest === record.digest && marketplace?.resolvedSha === record.resolvedSha;
170
+ const version = readManifest(pluginDir).version;
171
+ const satisfied = pluginVersionSatisfies(version, expected.minVersion);
172
+ const ok = identical && satisfied;
173
+ checks.push({
174
+ kind: 'plugin', id, ok, digest, resolvedSha: record.resolvedSha,
175
+ code: !identical ? 'plugin_digest_mismatch' : satisfied ? undefined : 'plugin_version_too_low',
176
+ ...(expected.minVersion ? { minVersion: expected.minVersion, ...(version ? { version } : {}) } : {}),
177
+ });
178
+ }
179
+ catch (error) {
180
+ checks.push({ kind: 'plugin', id, ok: false, code: errorCode(error) });
181
+ }
182
+ }
183
+ return checks;
184
+ }
185
+ /** 本地目录与 git 分支一致:缓存中不含 .git,digest 以缓存副本为准。 */
186
+ function addLocalMarketplace(input) {
187
+ const source = path.resolve(input.dir ?? '');
188
+ if (!exists(source))
189
+ throw new AppError('marketplace_missing', `本地 marketplace 目录不存在: ${source}`);
190
+ const name = input.name ?? path.basename(source);
191
+ ensureDir(marketplaceCacheRoot());
192
+ const staged = resolveInside(marketplaceCacheRoot(), `.local-${name}-${randomUUID()}`, 'marketplace_cache_outside_home');
193
+ try {
194
+ copyPath(source, staged, { excludeNames: ['.git'] });
195
+ const digest = digestDirectory(staged);
196
+ const resolvedSha = `local-${digest.slice(0, 16)}`;
197
+ const cachePath = marketplaceCacheDir(name, resolvedSha);
198
+ if (exists(cachePath))
199
+ removePath(staged);
200
+ else {
201
+ ensureDir(path.dirname(cachePath));
202
+ renameSync(staged, cachePath);
203
+ }
204
+ return { name, ref: input.ref ?? resolvedSha, resolvedSha, digest, cachePath, repo: input.repo };
205
+ }
206
+ finally {
207
+ if (exists(staged))
208
+ removePath(staged);
209
+ }
210
+ }
211
+ function addGitMarketplace(input) {
212
+ const repo = input.repo ?? BUILTIN_MARKETPLACE.repo;
213
+ const name = input.name ?? inferName(repo);
214
+ const ref = input.ref ?? '';
215
+ if (!ref)
216
+ throw new AppError('marketplace_ref_required', `marketplace 必须用 --ref 指定不可变 ref(完整 Git SHA 或 refs/tags/<tag>): ${name}`);
217
+ if (!isImmutableRef(ref)) {
218
+ throw new AppError('mutable_marketplace_ref', `marketplace ref 必须是完整 Git SHA 或 refs/tags/<tag>: ${ref}`);
219
+ }
220
+ if (/^[0-9a-f]{40}$/i.test(ref)) {
221
+ const cached = marketplaceCacheDir(name, ref);
222
+ if (exists(cached)) {
223
+ return { name, repo, ref, resolvedSha: ref, digest: digestDirectory(cached), cachePath: cached };
224
+ }
225
+ }
226
+ ensureDir(marketplaceCacheRoot());
227
+ const temporary = resolveInside(marketplaceCacheRoot(), `.clone-${name}-${randomUUID()}`, 'marketplace_cache_outside_home');
228
+ try {
229
+ fetchImmutableRef(repo, ref, temporary);
230
+ const checkout = execFile('git', ['checkout', '--detach', 'FETCH_HEAD'], { cwd: temporary });
231
+ if (!checkout.ok)
232
+ throw new AppError('marketplace_checkout_failed', checkout.stderr || checkout.stdout);
233
+ const resolved = execFile('git', ['rev-parse', 'HEAD'], { cwd: temporary });
234
+ if (!resolved.ok)
235
+ throw new AppError('marketplace_revision_failed', resolved.stderr || resolved.stdout);
236
+ const resolvedSha = resolved.stdout.trim();
237
+ removePath(path.join(temporary, '.git'));
238
+ const cachePath = marketplaceCacheDir(name, resolvedSha);
239
+ if (exists(cachePath))
240
+ removePath(temporary);
241
+ else {
242
+ ensureDir(path.dirname(cachePath));
243
+ renameSync(temporary, cachePath);
244
+ }
245
+ return { name, repo, ref, resolvedSha, digest: digestDirectory(cachePath), cachePath };
246
+ }
247
+ finally {
248
+ if (exists(temporary))
249
+ removePath(temporary);
250
+ }
251
+ }
252
+ /** 网络操作给足时间,且禁止 git 等待终端输入(否则会静默挂到超时)。 */
253
+ const GIT_NETWORK_OPTIONS = { timeoutMs: 300_000, env: { GIT_TERMINAL_PROMPT: '0' } };
254
+ /**
255
+ * 只拉取不可变 ref 指向的那一个提交:init + remote add + fetch --depth 1。
256
+ * 服务端不允许按 SHA 拉取时回退为完整 clone 后再 fetch。
257
+ */
258
+ function fetchImmutableRef(repo, ref, temporary) {
259
+ ensureDir(temporary);
260
+ const init = execFile('git', ['init', '-q'], { cwd: temporary });
261
+ if (!init.ok)
262
+ throw new AppError('marketplace_clone_failed', `git init 失败: ${init.stderr || init.stdout}`);
263
+ const remote = execFile('git', ['remote', 'add', 'origin', repo], { cwd: temporary });
264
+ if (!remote.ok)
265
+ throw new AppError('marketplace_clone_failed', `git remote add 失败: ${remote.stderr || remote.stdout}`);
266
+ const shallow = execFile('git', ['fetch', '--depth', '1', 'origin', ref], { cwd: temporary, ...GIT_NETWORK_OPTIONS });
267
+ if (shallow.ok)
268
+ return;
269
+ removePath(temporary);
270
+ const clone = execFile('git', ['clone', '--no-checkout', repo, temporary], { cwd: path.dirname(temporary), ...GIT_NETWORK_OPTIONS });
271
+ if (!clone.ok) {
272
+ throw new AppError('marketplace_clone_failed', `git 拉取 ${repo} 失败。浅拉取 ${ref}: ${shallow.stderr || shallow.stdout};完整 clone: ${clone.stderr || clone.stdout}`);
273
+ }
274
+ const fetch = execFile('git', ['fetch', '--depth', '1', 'origin', ref], { cwd: temporary, ...GIT_NETWORK_OPTIONS });
275
+ if (!fetch.ok)
276
+ throw new AppError('marketplace_fetch_failed', `git fetch ${ref} 失败: ${fetch.stderr || fetch.stdout}`);
277
+ }
278
+ function validateMarketplace(root) {
279
+ const ids = new Set();
280
+ const hooks = new Set();
281
+ for (const pluginDir of discoverPluginDirs(root)) {
282
+ const manifest = readManifest(pluginDir);
283
+ for (const hook of manifest.extensions?.['org.openspec']?.hooks ?? []) {
284
+ if (ids.has(hook.id))
285
+ throw new AppError('plugin_entry_conflict', `adapter 与 hook 入口重名: ${hook.id}`);
286
+ if (hooks.has(hook.id))
287
+ throw new AppError('hook_conflict', `marketplace 中存在重复 hook: ${hook.id}`);
288
+ hooks.add(hook.id);
289
+ }
290
+ for (const adapter of manifest.extensions?.['org.openspec']?.adapters ?? []) {
291
+ if (hooks.has(adapter.id))
292
+ throw new AppError('plugin_entry_conflict', `adapter 与 hook 入口重名: ${adapter.id}`);
293
+ if (ids.has(adapter.id))
294
+ throw new AppError('adapter_conflict', `marketplace 中存在重复 adapter: ${adapter.id}`);
295
+ ids.add(adapter.id);
296
+ }
297
+ }
298
+ }
299
+ function writeProjectLock(projectRoot, record) {
300
+ const lock = readMarketplaceLock(projectRoot);
301
+ const value = {
302
+ name: record.name,
303
+ repo: record.repo,
304
+ ref: record.ref,
305
+ resolvedSha: record.resolvedSha,
306
+ digest: record.digest,
307
+ cachePath: record.cachePath,
308
+ };
309
+ const index = lock.marketplaces.findIndex((item) => item.name === record.name);
310
+ if (index >= 0) {
311
+ const previous = lock.marketplaces[index];
312
+ lock.marketplaces[index] = value;
313
+ if (previous?.resolvedSha !== record.resolvedSha || previous.digest !== record.digest) {
314
+ lock.plugins = lock.plugins.filter((item) => item.marketplace !== record.name);
315
+ }
316
+ }
317
+ else
318
+ lock.marketplaces.push(value);
319
+ writeJson(marketplaceLockPath(projectRoot), lock);
320
+ }
321
+ function upsert(record) {
322
+ const state = loadMarketplaceState();
323
+ const next = { ...record, updatedAt: new Date().toISOString() };
324
+ const index = state.marketplaces.findIndex((item) => item.name === record.name && item.resolvedSha === record.resolvedSha);
325
+ if (index >= 0)
326
+ state.marketplaces[index] = next;
327
+ else
328
+ state.marketplaces.push(next);
329
+ saveMarketplaceState(state);
330
+ return next;
331
+ }
332
+ function inferName(repo) {
333
+ const base = repo.split('/').pop() ?? 'marketplace';
334
+ return base.replace(/\.git$/, '');
335
+ }
336
+ export function isImmutableRef(ref) {
337
+ return /^[0-9a-f]{40}$/i.test(ref) || /^refs\/tags\/[^/]+$/.test(ref);
338
+ }
339
+ function errorCode(error) {
340
+ return error instanceof AppError ? error.code : 'dependency_check_failed';
341
+ }
342
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1,39 @@
1
+ import { type HookEntryCheck } from '../hooks/engine.js';
2
+ import { type InstallResult } from './install.js';
3
+ export interface MarketplaceUpgradeResult {
4
+ marketplace: string;
5
+ from: {
6
+ ref: string;
7
+ resolvedSha?: string;
8
+ };
9
+ to: {
10
+ ref: string;
11
+ resolvedSha: string;
12
+ };
13
+ plugins: Array<{
14
+ name: string;
15
+ version?: string;
16
+ minVersion?: string;
17
+ }>;
18
+ entries: HookEntryCheck[];
19
+ installs: InstallResult[];
20
+ status: 'complete' | 'pending_user_action' | 'partial';
21
+ issues: Array<{
22
+ scope: string;
23
+ error: string;
24
+ }>;
25
+ }
26
+ /**
27
+ * 把宿主 config 中已声明的远端 marketplace 升级到新的不可变 ref。
28
+ *
29
+ * 拉取、插件版本(minVersion)与入口解析都在原生安装之前完成,任一失败都按原字节还原 config 与锁,宿主保持旧版本。
30
+ * 原生安装无法靠还原文件撤销,所以安装失败时保留新锁并报告 partial,由 doctor 与重装处理。
31
+ * config 只改该 marketplace 的 ref 字段,保留宿主的注释与格式。
32
+ */
33
+ export declare function upgradeMarketplace(input: {
34
+ projectRoot: string;
35
+ name: string;
36
+ ref: string;
37
+ allowActiveChanges?: boolean;
38
+ }): MarketplaceUpgradeResult;
39
+ //# sourceMappingURL=upgrade.d.ts.map
@@ -0,0 +1,104 @@
1
+ import path from 'node:path';
2
+ import { isScalar, parseDocument } from 'yaml';
3
+ import { configPathOf, loadConfig } from '../config/load.js';
4
+ import { listChangeNames } from '../core/change.js';
5
+ import { inspectHookEntries } from '../hooks/engine.js';
6
+ import { AppError } from '../shared/errors.js';
7
+ import { exists, readText, removePath, writeText } from '../shared/fs.js';
8
+ import { discoverPluginDirs, marketplaceLockPath, marketplaceRootForProject, parseSelector, readManifest, readMarketplaceLock, } from './adapters.js';
9
+ import { installPlugin, readInstallReceipt, writeInstallReceipt } from './install.js';
10
+ import { addMarketplace, assertPluginsPresent, isImmutableRef } from './registry.js';
11
+ /**
12
+ * 把宿主 config 中已声明的远端 marketplace 升级到新的不可变 ref。
13
+ *
14
+ * 拉取、插件版本(minVersion)与入口解析都在原生安装之前完成,任一失败都按原字节还原 config 与锁,宿主保持旧版本。
15
+ * 原生安装无法靠还原文件撤销,所以安装失败时保留新锁并报告 partial,由 doctor 与重装处理。
16
+ * config 只改该 marketplace 的 ref 字段,保留宿主的注释与格式。
17
+ */
18
+ export function upgradeMarketplace(input) {
19
+ const { projectRoot, name, ref } = input;
20
+ const config = loadConfig(projectRoot);
21
+ const configFile = configPathOf(projectRoot);
22
+ const index = (config.marketplaces ?? []).findIndex((item) => item.name === name);
23
+ const declared = config.marketplaces?.[index];
24
+ if (!configFile || !declared) {
25
+ throw new AppError('marketplace_not_configured', `config 未声明 marketplace: ${name};升级只针对宿主配置中的远端 marketplace`);
26
+ }
27
+ if (!isImmutableRef(ref)) {
28
+ throw new AppError('mutable_marketplace_ref', `marketplace ref 必须是完整 Git SHA 或 refs/tags/<tag>: ${ref}`);
29
+ }
30
+ const active = listChangeNames(projectRoot);
31
+ if (active.length > 0 && !input.allowActiveChanges) {
32
+ throw new AppError('active_changes_present', `存在进行中的 change:${active.join(', ')}。它们的来源与回执绑定旧插件身份,升级后需重新采集或派发;确认后加 --allow-active-changes`);
33
+ }
34
+ const lockFile = marketplaceLockPath(projectRoot);
35
+ const original = { config: readText(configFile), lock: exists(lockFile) ? readText(lockFile) : undefined };
36
+ const previous = readMarketplaceLock(projectRoot).marketplaces.find((item) => item.name === name);
37
+ const plugins = (config.plugins ?? []).filter((item) => item.marketplace === name);
38
+ let entries;
39
+ let resolvedSha;
40
+ let hosts;
41
+ try {
42
+ resolvedSha = addMarketplace({ projectRoot, name, repo: declared.repo, ref }).resolvedSha;
43
+ const document = parseDocument(original.config);
44
+ // 改原 Scalar 的值而不是替换节点,保留写在 ref 行上的注释。
45
+ const node = document.getIn(['marketplaces', index, 'ref'], true);
46
+ if (isScalar(node))
47
+ node.value = ref;
48
+ else
49
+ document.setIn(['marketplaces', index, 'ref'], ref);
50
+ writeText(configFile, document.toString());
51
+ const next = loadConfig(projectRoot);
52
+ hosts = (next.tools ?? []);
53
+ assertPluginsPresent(projectRoot, plugins);
54
+ entries = inspectHookEntries(projectRoot, next).filter((entry) => parseSelector(entry.use).marketplace === name);
55
+ const broken = entries.filter((entry) => !entry.ok);
56
+ if (broken.length > 0) {
57
+ throw new AppError('plugin_entries_incompatible', `新 ref 下插件入口缺失或不符:${broken.map((entry) => `${entry.hook}(${entry.use})${entry.code}`).join(';')}`, broken);
58
+ }
59
+ }
60
+ catch (error) {
61
+ writeText(configFile, original.config);
62
+ if (original.lock === undefined)
63
+ removePath(lockFile);
64
+ else
65
+ writeText(lockFile, original.lock);
66
+ throw error;
67
+ }
68
+ const installs = [];
69
+ const issues = [];
70
+ for (const plugin of plugins) {
71
+ for (const host of hosts) {
72
+ try {
73
+ const result = installPlugin({ plugin: plugin.name, marketplace: name, host, projectRoot });
74
+ installs.push(result);
75
+ if (result.status === 'failed')
76
+ issues.push({ scope: `install:${plugin.name}@${host}`, error: result.error ?? '安装失败' });
77
+ }
78
+ catch (error) {
79
+ issues.push({ scope: `install:${plugin.name}@${host}`, error: error instanceof Error ? error.message : String(error) });
80
+ }
81
+ }
82
+ }
83
+ if (installs.length > 0) {
84
+ const retained = readInstallReceipt(projectRoot).filter((item) => !installs.some((result) => result.plugin === item.plugin && result.marketplace === item.marketplace && result.host === item.host));
85
+ writeInstallReceipt(projectRoot, [...retained, ...installs]);
86
+ }
87
+ const root = marketplaceRootForProject(projectRoot, name);
88
+ const pluginDirs = discoverPluginDirs(root);
89
+ return {
90
+ marketplace: name,
91
+ from: { ref: declared.ref, ...(previous ? { resolvedSha: previous.resolvedSha } : {}) },
92
+ to: { ref, resolvedSha },
93
+ plugins: plugins.map((plugin) => {
94
+ const dir = pluginDirs.find((item) => path.basename(item) === plugin.name || readManifest(item).name === plugin.name);
95
+ const version = dir ? readManifest(dir).version : undefined;
96
+ return { name: plugin.name, ...(version ? { version } : {}), ...(plugin.minVersion ? { minVersion: plugin.minVersion } : {}) };
97
+ }),
98
+ entries,
99
+ installs,
100
+ status: issues.length > 0 ? 'partial' : installs.some((item) => item.status === 'pending_user_action') ? 'pending_user_action' : 'complete',
101
+ issues,
102
+ };
103
+ }
104
+ //# sourceMappingURL=upgrade.js.map
@@ -0,0 +1,9 @@
1
+ /** 完整 stdout/stderr 直接写磁盘,避免 execFileSync 的 maxBuffer 丢失长输出。 */
2
+ export declare function runLoggedCommand(bin: string, args: string[], cwd: string, logPath: string, timeoutMs?: number, env?: Record<string, string>): {
3
+ code: number;
4
+ ok: boolean;
5
+ stdout: string;
6
+ stderr: string;
7
+ truncated: boolean;
8
+ };
9
+ //# sourceMappingURL=command-log.d.ts.map
@@ -0,0 +1,55 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { closeSync, fstatSync, openSync, readSync, unlinkSync, writeSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ensureDir } from './fs.js';
5
+ /** 完整 stdout/stderr 直接写磁盘,避免 execFileSync 的 maxBuffer 丢失长输出。 */
6
+ export function runLoggedCommand(bin, args, cwd, logPath, timeoutMs = 120_000, env) {
7
+ ensureDir(path.dirname(logPath));
8
+ const out = openSync(logPath, 'w+');
9
+ const errorPath = `${logPath}.stderr`;
10
+ const err = openSync(errorPath, 'w+');
11
+ try {
12
+ const result = spawnSync(bin, args, { cwd, timeout: timeoutMs, stdio: ['ignore', out, err], env: env ? { ...process.env, ...env } : undefined });
13
+ if (result.error || result.signal) {
14
+ writeSync(err, `\n${result.error?.message ?? `命令被信号 ${result.signal} 终止`}\n`);
15
+ }
16
+ const outSize = fstatSync(out).size;
17
+ const errSize = fstatSync(err).size;
18
+ // 优先保留 stderr,两个流的摘要总计不超过 2KB。
19
+ const stderr = tail(err, errSize, 2048);
20
+ const stdout = tail(out, outSize, 2048 - Buffer.byteLength(stderr));
21
+ if (errSize) {
22
+ writeSync(out, '\n--- stderr ---\n');
23
+ const buffer = Buffer.alloc(64 * 1024);
24
+ for (let offset = 0; offset < errSize;) {
25
+ const count = readSync(err, buffer, 0, Math.min(buffer.length, errSize - offset), offset);
26
+ if (!count)
27
+ break;
28
+ writeSync(out, buffer.subarray(0, count));
29
+ offset += count;
30
+ }
31
+ }
32
+ return {
33
+ code: result.status ?? 1,
34
+ ok: result.status === 0 && !result.error && !result.signal,
35
+ stdout,
36
+ stderr,
37
+ truncated: outSize + errSize > 2048,
38
+ };
39
+ }
40
+ finally {
41
+ closeSync(out);
42
+ closeSync(err);
43
+ unlinkSync(errorPath);
44
+ }
45
+ }
46
+ function tail(fd, size, limit) {
47
+ const buffer = Buffer.alloc(Math.min(size, limit));
48
+ readSync(fd, buffer, 0, buffer.length, size - buffer.length);
49
+ let start = 0;
50
+ // 不把 UTF-8 截断在多字节字符中间。
51
+ while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
52
+ start++;
53
+ return buffer.subarray(start).toString('utf8');
54
+ }
55
+ //# sourceMappingURL=command-log.js.map
@@ -0,0 +1,7 @@
1
+ export declare class AppError extends Error {
2
+ readonly code: string;
3
+ readonly details?: unknown;
4
+ constructor(code: string, message: string, details?: unknown);
5
+ }
6
+ export declare function isAppError(error: unknown): error is AppError;
7
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1,14 @@
1
+ export class AppError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, message, details) {
5
+ super(message);
6
+ this.name = 'AppError';
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ }
11
+ export function isAppError(error) {
12
+ return error instanceof AppError;
13
+ }
14
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,16 @@
1
+ export interface ExecResult {
2
+ ok: boolean;
3
+ code: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ export declare function execFile(command: string, args: string[], options: {
8
+ cwd: string;
9
+ timeoutMs?: number;
10
+ env?: Record<string, string>;
11
+ }): ExecResult;
12
+ export declare function splitCommand(command: string): {
13
+ bin: string;
14
+ args: string[];
15
+ };
16
+ //# sourceMappingURL=exec.d.ts.map
@@ -0,0 +1,86 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { AppError } from './errors.js';
3
+ export function execFile(command, args, options) {
4
+ const timeout = options.timeoutMs ?? 120_000;
5
+ try {
6
+ const stdout = execFileSync(command, args, {
7
+ cwd: options.cwd,
8
+ encoding: 'utf8',
9
+ timeout,
10
+ stdio: ['ignore', 'pipe', 'pipe'],
11
+ env: options.env ? { ...process.env, ...options.env } : process.env,
12
+ });
13
+ return { ok: true, code: 0, stdout: String(stdout), stderr: '' };
14
+ }
15
+ catch (error) {
16
+ const err = error;
17
+ const stderr = String(err.stderr ?? '');
18
+ // 被超时终止时 status 为 null 且带 signal;此时 stderr 往往只有进度输出,必须把真实原因写出来
19
+ const timedOut = err.code === 'ETIMEDOUT' || (err.status == null && Boolean(err.signal));
20
+ const reason = timedOut
21
+ ? `命令在 ${Math.round(timeout / 1000)}s 内未完成,已终止: ${command} ${args.join(' ')}`
22
+ : '';
23
+ return {
24
+ ok: false,
25
+ code: typeof err.status === 'number' ? err.status : 1,
26
+ stdout: String(err.stdout ?? ''),
27
+ stderr: [reason, stderr || (reason ? '' : String(err.message ?? ''))].filter(Boolean).join('\n'),
28
+ };
29
+ }
30
+ }
31
+ export function splitCommand(command) {
32
+ const parts = parseArgv(command);
33
+ const bin = parts[0] ?? '';
34
+ if (!bin)
35
+ throw new AppError('invalid_command', 'command 不能为空');
36
+ return { bin, args: parts.slice(1) };
37
+ }
38
+ function parseArgv(command) {
39
+ const parts = [];
40
+ let current = '';
41
+ let started = false;
42
+ let quote = null;
43
+ let escaped = false;
44
+ const push = () => {
45
+ if (started)
46
+ parts.push(current);
47
+ current = '';
48
+ started = false;
49
+ };
50
+ for (const char of command.trim()) {
51
+ if (escaped) {
52
+ current += char;
53
+ started = true;
54
+ escaped = false;
55
+ continue;
56
+ }
57
+ if (char === '\\' && quote !== 'single') {
58
+ escaped = true;
59
+ continue;
60
+ }
61
+ if (char === "'" && quote !== 'double') {
62
+ started = true;
63
+ quote = quote === 'single' ? null : 'single';
64
+ continue;
65
+ }
66
+ if (char === '"' && quote !== 'single') {
67
+ started = true;
68
+ quote = quote === 'double' ? null : 'double';
69
+ continue;
70
+ }
71
+ if (!quote && /[|;&<>]/.test(char)) {
72
+ throw new AppError('unsupported_shell_syntax', `command 不支持 shell 运算符: ${char}`);
73
+ }
74
+ if (!quote && /\s/.test(char)) {
75
+ push();
76
+ continue;
77
+ }
78
+ current += char;
79
+ started = true;
80
+ }
81
+ if (quote || escaped)
82
+ throw new AppError('invalid_command', 'command 包含未闭合的引号或转义');
83
+ push();
84
+ return parts;
85
+ }
86
+ //# sourceMappingURL=exec.js.map