bmad-studio 0.2.0 → 1.0.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 +6 -0
- package/package.json +12 -3
- package/packages/client/dist/assets/index-CWL4J-eZ.css +1 -0
- package/packages/client/dist/assets/index-DBqsFqD5.js +535 -0
- package/packages/client/dist/index.html +2 -2
- package/packages/server/dist/app.d.ts +1 -0
- package/packages/server/dist/app.d.ts.map +1 -1
- package/packages/server/dist/app.js +9 -0
- package/packages/server/dist/app.js.map +1 -1
- package/packages/server/dist/core/ide-skill-generator.d.ts +58 -0
- package/packages/server/dist/core/ide-skill-generator.d.ts.map +1 -0
- package/packages/server/dist/core/ide-skill-generator.js +270 -0
- package/packages/server/dist/core/ide-skill-generator.js.map +1 -0
- package/packages/server/dist/core/ide-skill-generator.test.d.ts +2 -0
- package/packages/server/dist/core/ide-skill-generator.test.d.ts.map +1 -0
- package/packages/server/dist/core/ide-skill-generator.test.js +257 -0
- package/packages/server/dist/core/ide-skill-generator.test.js.map +1 -0
- package/packages/server/dist/core/module-installer.d.ts +165 -0
- package/packages/server/dist/core/module-installer.d.ts.map +1 -0
- package/packages/server/dist/core/module-installer.js +445 -0
- package/packages/server/dist/core/module-installer.js.map +1 -0
- package/packages/server/dist/core/module-installer.test.d.ts +2 -0
- package/packages/server/dist/core/module-installer.test.d.ts.map +1 -0
- package/packages/server/dist/core/module-installer.test.js +509 -0
- package/packages/server/dist/core/module-installer.test.js.map +1 -0
- package/packages/server/dist/core/module-registry.d.ts +5 -0
- package/packages/server/dist/core/module-registry.d.ts.map +1 -0
- package/packages/server/dist/core/module-registry.js +109 -0
- package/packages/server/dist/core/module-registry.js.map +1 -0
- package/packages/server/dist/core/module-registry.test.d.ts +2 -0
- package/packages/server/dist/core/module-registry.test.d.ts.map +1 -0
- package/packages/server/dist/core/module-registry.test.js +280 -0
- package/packages/server/dist/core/module-registry.test.js.map +1 -0
- package/packages/server/dist/core/write-service.d.ts +20 -0
- package/packages/server/dist/core/write-service.d.ts.map +1 -1
- package/packages/server/dist/core/write-service.js +113 -1
- package/packages/server/dist/core/write-service.js.map +1 -1
- package/packages/server/dist/core/write-service.test.js +93 -6
- package/packages/server/dist/core/write-service.test.js.map +1 -1
- package/packages/server/dist/index.js +46 -1
- package/packages/server/dist/index.js.map +1 -1
- package/packages/server/dist/parsers/module-yaml-parser.d.ts +16 -0
- package/packages/server/dist/parsers/module-yaml-parser.d.ts.map +1 -0
- package/packages/server/dist/parsers/module-yaml-parser.js +62 -0
- package/packages/server/dist/parsers/module-yaml-parser.js.map +1 -0
- package/packages/server/dist/parsers/module-yaml-parser.test.d.ts +2 -0
- package/packages/server/dist/parsers/module-yaml-parser.test.d.ts.map +1 -0
- package/packages/server/dist/parsers/module-yaml-parser.test.js +156 -0
- package/packages/server/dist/parsers/module-yaml-parser.test.js.map +1 -0
- package/packages/server/dist/parsers/skill-parser.d.ts.map +1 -1
- package/packages/server/dist/parsers/skill-parser.js +41 -4
- package/packages/server/dist/parsers/skill-parser.js.map +1 -1
- package/packages/server/dist/parsers/skill-parser.test.js +4 -3
- package/packages/server/dist/parsers/skill-parser.test.js.map +1 -1
- package/packages/server/dist/plugins/agents-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/agents-plugin.js +9 -1
- package/packages/server/dist/plugins/agents-plugin.js.map +1 -1
- package/packages/server/dist/plugins/modules-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/modules-plugin.js +882 -100
- package/packages/server/dist/plugins/modules-plugin.js.map +1 -1
- package/packages/server/dist/plugins/modules-plugin.test.js +1894 -3
- package/packages/server/dist/plugins/modules-plugin.test.js.map +1 -1
- package/packages/server/dist/plugins/overview-plugin.js +1 -1
- package/packages/server/dist/plugins/overview-plugin.js.map +1 -1
- package/packages/server/dist/plugins/settings-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/settings-plugin.js +25 -1
- package/packages/server/dist/plugins/settings-plugin.js.map +1 -1
- package/packages/server/dist/plugins/settings-plugin.test.js +72 -0
- package/packages/server/dist/plugins/settings-plugin.test.js.map +1 -1
- package/packages/server/dist/plugins/teams-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/teams-plugin.js +6 -6
- package/packages/server/dist/plugins/teams-plugin.js.map +1 -1
- package/packages/server/dist/plugins/teams-plugin.test.js +43 -0
- package/packages/server/dist/plugins/teams-plugin.test.js.map +1 -1
- package/packages/server/dist/plugins/workflows-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/workflows-plugin.js +14 -6
- package/packages/server/dist/plugins/workflows-plugin.js.map +1 -1
- package/packages/shared/src/config.ts +26 -0
- package/packages/shared/src/index.ts +12 -0
- package/packages/shared/src/modules.ts +42 -0
- package/packages/shared/src/registry.ts +26 -0
- package/packages/shared/src/types.test.ts +37 -1
- package/packages/client/dist/assets/index-5nXyrx_3.css +0 -1
- package/packages/client/dist/assets/index-DxN3uabX.js +0 -521
|
@@ -1,46 +1,402 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { execSync } from 'node:child_process';
|
|
4
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
5
5
|
import yaml from 'js-yaml';
|
|
6
6
|
import { ValidationError, ConflictError, NotFoundError } from '../core/errors.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
fs.writeFileSync(manifestPath, yaml.dump(manifest, { lineWidth: -1 }), 'utf-8');
|
|
13
|
-
}
|
|
7
|
+
import { fetchAndCacheRegistryIndex, isRegistryCacheStale, readCachedRegistryIndex, } from '../core/module-registry.js';
|
|
8
|
+
import { generateIdeSkillsForModule, removeIdeSkillsForModule, scanEntities, scanEntityDirs, } from '../core/ide-skill-generator.js';
|
|
9
|
+
import { copyDirThroughWriteService, downloadGithubTarball, extractZipUpload, findCrossReferences, isPlausibleModuleDir, parseGithubSource, readManifestSafe, readOutputFolder, runVariableSubstitution, validateVariables, writeManifestThroughWriteService, } from '../core/module-installer.js';
|
|
10
|
+
import { deleteDirectory, writeFile } from '../core/write-service.js';
|
|
11
|
+
import { parseModuleYaml } from '../parsers/module-yaml-parser.js';
|
|
14
12
|
const MODULE_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
export async function modulesPlugin(app) {
|
|
14
|
+
const PREVIEW_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
15
|
+
const previewCache = new Map();
|
|
16
|
+
function previewCacheKey(ghSource) {
|
|
17
|
+
return `${ghSource.owner}/${ghSource.repo}@${ghSource.branch ?? 'main'}${ghSource.subpath ? `/${ghSource.subpath}` : ''}`;
|
|
18
|
+
}
|
|
19
|
+
function getPreviewCacheEntry(key) {
|
|
20
|
+
const entry = previewCache.get(key);
|
|
21
|
+
if (!entry)
|
|
22
|
+
return null;
|
|
23
|
+
if (Date.now() - entry.fetchedAt > PREVIEW_CACHE_TTL_MS) {
|
|
24
|
+
try {
|
|
25
|
+
fs.rmSync(entry.tmpDir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* ignore cleanup errors */
|
|
29
|
+
}
|
|
30
|
+
previewCache.delete(key);
|
|
31
|
+
return null;
|
|
24
32
|
}
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
// Story 17.3 — Clean-slate remove helper. Called by each install branch when
|
|
36
|
+
// the destination directory already exists so the install can proceed as a
|
|
37
|
+
// silent upgrade rather than throwing 409.
|
|
38
|
+
// Throws ValidationError if the module is built-in (cannot be replaced) or
|
|
39
|
+
// if any I/O step fails. Safe to call when the module dir doesn't exist yet
|
|
40
|
+
// (all steps guard with existsSync / nullish checks).
|
|
41
|
+
function cleanRemoveModule(moduleCode, bmadDir, manifestPath) {
|
|
42
|
+
const moduleDir = path.join(bmadDir, moduleCode);
|
|
43
|
+
const manifest = readManifestSafe(manifestPath);
|
|
44
|
+
const entry = manifest?.modules.find((m) => m.name === moduleCode);
|
|
45
|
+
if (entry?.source === 'built-in') {
|
|
46
|
+
throw new ValidationError(`Cannot reinstall built-in module "${moduleCode}"`);
|
|
47
|
+
}
|
|
48
|
+
// 1. Remove IDE skill launchers
|
|
49
|
+
if (manifest) {
|
|
50
|
+
const r = removeIdeSkillsForModule(app.fileStore.projectRoot, moduleCode, manifest, app.fileStore.studioDir);
|
|
51
|
+
if (!r.ok)
|
|
52
|
+
throw new ValidationError(r.error);
|
|
53
|
+
}
|
|
54
|
+
// 2. Delete the module directory
|
|
55
|
+
if (fs.existsSync(moduleDir)) {
|
|
56
|
+
const r = deleteDirectory(moduleDir, app.fileStore.studioDir);
|
|
57
|
+
if (!r.ok)
|
|
58
|
+
throw new ValidationError(r.error);
|
|
59
|
+
}
|
|
60
|
+
// 3. Remove the manifest entry so the incoming install writes a fresh one
|
|
61
|
+
if (manifest) {
|
|
62
|
+
manifest.modules = manifest.modules.filter((m) => m.name !== moduleCode);
|
|
63
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
64
|
+
if (!wrote.ok)
|
|
65
|
+
throw new ValidationError(wrote.error);
|
|
27
66
|
}
|
|
28
67
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
68
|
+
// Story 15.9 — Read-only preview endpoint. Fetches a source (local or github)
|
|
69
|
+
// and returns the parsed module.yaml + entity counts WITHOUT installing anything.
|
|
70
|
+
// For github sources, the downloaded tarball is cached for 5 minutes so the
|
|
71
|
+
// subsequent install call can reuse it without re-downloading.
|
|
72
|
+
app.post('/api/modules/preview-source', async (request) => {
|
|
73
|
+
if (!('fileStore' in app)) {
|
|
74
|
+
throw new ValidationError('No project detected');
|
|
75
|
+
}
|
|
76
|
+
const body = request.body;
|
|
77
|
+
const source = body?.source;
|
|
78
|
+
if (!source || !source.type || !source.value) {
|
|
79
|
+
throw new ValidationError('Request body must include { source: { type, value } }');
|
|
80
|
+
}
|
|
81
|
+
let stagedRoot;
|
|
82
|
+
if (source.type === 'local') {
|
|
83
|
+
stagedRoot = path.isAbsolute(source.value)
|
|
84
|
+
? source.value
|
|
85
|
+
: path.join(app.fileStore.projectRoot, source.value);
|
|
86
|
+
}
|
|
87
|
+
else if (source.type === 'github') {
|
|
88
|
+
let ghSource;
|
|
89
|
+
try {
|
|
90
|
+
ghSource = parseGithubSource(source.value);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
94
|
+
}
|
|
95
|
+
const cacheKey = previewCacheKey(ghSource);
|
|
96
|
+
let entry = getPreviewCacheEntry(cacheKey);
|
|
97
|
+
if (!entry) {
|
|
98
|
+
let downloaded;
|
|
99
|
+
try {
|
|
100
|
+
downloaded = await downloadGithubTarball(ghSource);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
104
|
+
}
|
|
105
|
+
entry = {
|
|
106
|
+
extractedRoot: downloaded.extractedRoot,
|
|
107
|
+
tmpDir: downloaded.tmpDir,
|
|
108
|
+
resolvedBranch: downloaded.resolvedBranch,
|
|
109
|
+
fetchedAt: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
previewCache.set(cacheKey, entry);
|
|
112
|
+
}
|
|
113
|
+
stagedRoot = ghSource.subpath
|
|
114
|
+
? path.join(entry.extractedRoot, ghSource.subpath)
|
|
115
|
+
: entry.extractedRoot;
|
|
116
|
+
// Note: no try/finally cleanup here — the tmpDir lives in previewCache and is
|
|
117
|
+
// cleaned up on TTL expiry or when the install endpoint consumes it.
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
throw new ValidationError(`preview-source does not support source type "${source.type}". Use "local" or "github".`);
|
|
121
|
+
}
|
|
122
|
+
if (!isPlausibleModuleDir(stagedRoot)) {
|
|
123
|
+
throw new ValidationError('Source does not look like a BMAD module — expected module.yaml or one of agents/skills/workflows/tasks');
|
|
124
|
+
}
|
|
125
|
+
const parsed = parseModuleYaml(stagedRoot);
|
|
126
|
+
if (!parsed.ok)
|
|
127
|
+
throw new ValidationError(parsed.error);
|
|
128
|
+
const counts = {
|
|
129
|
+
agents: scanEntities(path.join(stagedRoot, 'agents'), '.md').length,
|
|
130
|
+
workflows: scanEntityDirs(path.join(stagedRoot, 'workflows')).length,
|
|
131
|
+
tasks: scanEntityDirs(path.join(stagedRoot, 'tasks')).length,
|
|
132
|
+
};
|
|
133
|
+
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
134
|
+
const existingDir = path.join(bmadDir, parsed.data.code);
|
|
135
|
+
const willReplace = fs.existsSync(existingDir);
|
|
136
|
+
return { ok: true, moduleYaml: parsed.data, counts, willReplace };
|
|
137
|
+
});
|
|
138
|
+
// Polymorphic install endpoint — accepts npm | local | github source types via
|
|
139
|
+
// { source: { type, value }, variables? }, plus the legacy { packageName } shape.
|
|
140
|
+
// Zip uploads go to a separate multipart route POST /api/modules/install/upload (Story 15.4).
|
|
32
141
|
app.post('/api/modules/install', async (request, reply) => {
|
|
33
142
|
if (!('fileStore' in app)) {
|
|
34
143
|
throw new ValidationError('No project detected');
|
|
35
144
|
}
|
|
36
145
|
const body = request.body;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
146
|
+
// Defensive guard: if the request body is missing (e.g. a multipart upload sent
|
|
147
|
+
// to this JSON-only route — multipart goes to /upload), fail loudly with a 422
|
|
148
|
+
// instead of an unhandled TypeError. AC-15.4.11.
|
|
149
|
+
if (!body || typeof body !== 'object') {
|
|
150
|
+
throw new ValidationError('Request body must be JSON. For zip uploads, POST to /api/modules/install/upload instead.');
|
|
151
|
+
}
|
|
152
|
+
// TD-22 — discriminate source vs legacy. Prefer `source` if both are present.
|
|
153
|
+
let source;
|
|
154
|
+
if (body.source) {
|
|
155
|
+
source = body.source;
|
|
156
|
+
if (body.packageName) {
|
|
157
|
+
request.log.warn({ packageName: body.packageName }, 'Both `source` and `packageName` provided to /api/modules/install — using `source`');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (body.packageName) {
|
|
161
|
+
const pkg = body.packageName.trim();
|
|
162
|
+
if (!pkg) {
|
|
163
|
+
throw new ValidationError('Package name is required');
|
|
164
|
+
}
|
|
165
|
+
source = { type: 'npm', value: pkg };
|
|
40
166
|
}
|
|
167
|
+
else {
|
|
168
|
+
throw new ValidationError('Either `source` or `packageName` is required');
|
|
169
|
+
}
|
|
170
|
+
// Story 15.5 — variables are now consumed (not just threaded through).
|
|
171
|
+
const variables = body.variables ?? {};
|
|
41
172
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
42
173
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
43
|
-
|
|
174
|
+
// Hard requirement (AC-15.2.9 / finding #8): every install branch needs an existing manifest.
|
|
175
|
+
// Without it, the installed module would be invisible to GET /api/modules.
|
|
176
|
+
if (!fs.existsSync(manifestPath)) {
|
|
177
|
+
throw new ValidationError('Cannot install module: missing _bmad/_config/manifest.yaml. Run `npx bmad-method install` to initialise the project first.');
|
|
178
|
+
}
|
|
179
|
+
// Story 15.5 — validate variables BEFORE any source-type branching so bad input
|
|
180
|
+
// fails fast without wasting CPU on a download/extraction.
|
|
181
|
+
const varValidation = validateVariables(variables);
|
|
182
|
+
if (!varValidation.ok)
|
|
183
|
+
throw new ValidationError(varValidation.error);
|
|
184
|
+
// Helper to build the substitution context for a given module code. Used by every branch.
|
|
185
|
+
const makeSubContext = (moduleCode) => ({
|
|
186
|
+
moduleCode,
|
|
187
|
+
projectRoot: app.fileStore.projectRoot,
|
|
188
|
+
outputFolder: readOutputFolder(app.fileStore.projectRoot),
|
|
189
|
+
variables,
|
|
190
|
+
});
|
|
191
|
+
// Helper to run the IDE skill generator for a freshly-installed module and
|
|
192
|
+
// return per-IDE counts for the response shape (Story 15.6 / AC-15.6.8).
|
|
193
|
+
// Re-reads the manifest to get the latest `ides[]` after the manifest update.
|
|
194
|
+
const runGenerator = (moduleCode) => {
|
|
195
|
+
const updatedManifest = readManifestSafe(manifestPath);
|
|
196
|
+
if (!updatedManifest)
|
|
197
|
+
return {};
|
|
198
|
+
const genResult = generateIdeSkillsForModule(app.fileStore.projectRoot, moduleCode, updatedManifest, app.fileStore.studioDir, app.fileStore);
|
|
199
|
+
if (!genResult.ok)
|
|
200
|
+
throw new ValidationError(genResult.error);
|
|
201
|
+
const counts = {};
|
|
202
|
+
for (const [ide, skills] of Object.entries(genResult.skillsByIde)) {
|
|
203
|
+
counts[ide] = skills.length;
|
|
204
|
+
}
|
|
205
|
+
return counts;
|
|
206
|
+
};
|
|
207
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
208
|
+
// local source
|
|
209
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
210
|
+
if (source.type === 'local') {
|
|
211
|
+
const sourcePath = path.isAbsolute(source.value)
|
|
212
|
+
? source.value
|
|
213
|
+
: path.join(app.fileStore.projectRoot, source.value);
|
|
214
|
+
if (!isPlausibleModuleDir(sourcePath)) {
|
|
215
|
+
throw new ValidationError(`Path "${sourcePath}" does not look like a BMAD module — expected module.yaml or one of agents/skills/workflows/tasks`);
|
|
216
|
+
}
|
|
217
|
+
const parsed = parseModuleYaml(sourcePath);
|
|
218
|
+
if (!parsed.ok)
|
|
219
|
+
throw new ValidationError(parsed.error);
|
|
220
|
+
const moduleCode = parsed.data.code;
|
|
221
|
+
const destDir = path.join(bmadDir, moduleCode);
|
|
222
|
+
// Story 17.3 — clean-slate re-install: silently remove any existing copy before writing.
|
|
223
|
+
if (fs.existsSync(destDir)) {
|
|
224
|
+
cleanRemoveModule(moduleCode, bmadDir, manifestPath);
|
|
225
|
+
}
|
|
226
|
+
const copyResult = copyDirThroughWriteService(sourcePath, destDir, app.fileStore.studioDir, app.fileStore);
|
|
227
|
+
if (!copyResult.ok)
|
|
228
|
+
throw new ValidationError(copyResult.error);
|
|
229
|
+
// Story 15.5 — substitute placeholders in installed text files BEFORE updating the manifest.
|
|
230
|
+
const subResult = runVariableSubstitution(destDir, makeSubContext(moduleCode), app.fileStore.studioDir, app.fileStore);
|
|
231
|
+
if (!subResult.ok)
|
|
232
|
+
throw new ValidationError(subResult.error);
|
|
233
|
+
// Update manifest (guaranteed to exist by the check above)
|
|
234
|
+
const manifest = readManifestSafe(manifestPath);
|
|
235
|
+
const now = new Date().toISOString();
|
|
236
|
+
const existing = manifest.modules.find((m) => m.name === moduleCode);
|
|
237
|
+
if (existing) {
|
|
238
|
+
existing.lastUpdated = now;
|
|
239
|
+
existing.source = 'local';
|
|
240
|
+
existing.npmPackage = null;
|
|
241
|
+
existing.repoUrl = sourcePath;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
manifest.modules.push({
|
|
245
|
+
name: moduleCode,
|
|
246
|
+
version: parsed.data.version ?? '1.0.0',
|
|
247
|
+
installDate: now,
|
|
248
|
+
lastUpdated: now,
|
|
249
|
+
source: 'local',
|
|
250
|
+
npmPackage: null,
|
|
251
|
+
repoUrl: sourcePath,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
255
|
+
if (!wrote.ok)
|
|
256
|
+
throw new ValidationError(wrote.error);
|
|
257
|
+
// Story 15.6 — generate IDE launchers AFTER the manifest is committed.
|
|
258
|
+
// A failure here leaves the user with an installed module + no launchers;
|
|
259
|
+
// recovery is the "Regenerate IDE skills" button (Story 15.8).
|
|
260
|
+
const skillsGenerated = runGenerator(moduleCode);
|
|
261
|
+
app.fileStore.rebuild();
|
|
262
|
+
reply.status(200);
|
|
263
|
+
return {
|
|
264
|
+
ok: true,
|
|
265
|
+
modules: [moduleCode],
|
|
266
|
+
filesCopied: { text: copyResult.textCount, binary: copyResult.binaryCount },
|
|
267
|
+
skillsGenerated,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
271
|
+
// github source
|
|
272
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
273
|
+
if (source.type === 'github') {
|
|
274
|
+
// parseGithubSource throws plain Error on invalid input — convert to ValidationError.
|
|
275
|
+
let ghSource;
|
|
276
|
+
try {
|
|
277
|
+
ghSource = parseGithubSource(source.value);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
281
|
+
}
|
|
282
|
+
// Story 15.9 — check previewCache before downloading. If the user clicked
|
|
283
|
+
// "Fetch" then "Install" in quick succession the tarball is already on disk;
|
|
284
|
+
// reuse it and skip the second download. Consume the cache entry (delete it)
|
|
285
|
+
// so a future preview call re-downloads fresh content.
|
|
286
|
+
const cacheKey = previewCacheKey(ghSource);
|
|
287
|
+
const cachedEntry = getPreviewCacheEntry(cacheKey);
|
|
288
|
+
let extractedRoot;
|
|
289
|
+
let tmpDir;
|
|
290
|
+
let resolvedBranch;
|
|
291
|
+
if (cachedEntry) {
|
|
292
|
+
previewCache.delete(cacheKey); // consumed — install now owns cleanup
|
|
293
|
+
extractedRoot = cachedEntry.extractedRoot;
|
|
294
|
+
tmpDir = cachedEntry.tmpDir;
|
|
295
|
+
resolvedBranch = cachedEntry.resolvedBranch;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// No cache hit — download fresh.
|
|
299
|
+
let downloaded;
|
|
300
|
+
try {
|
|
301
|
+
downloaded = await downloadGithubTarball(ghSource);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
305
|
+
}
|
|
306
|
+
extractedRoot = downloaded.extractedRoot;
|
|
307
|
+
tmpDir = downloaded.tmpDir;
|
|
308
|
+
resolvedBranch = downloaded.resolvedBranch;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
// Navigate to the optional subpath
|
|
312
|
+
const moduleSrc = ghSource.subpath
|
|
313
|
+
? path.join(extractedRoot, ghSource.subpath)
|
|
314
|
+
: extractedRoot;
|
|
315
|
+
if (!isPlausibleModuleDir(moduleSrc)) {
|
|
316
|
+
throw new ValidationError(`${ghSource.owner}/${ghSource.repo}${ghSource.subpath ? `/${ghSource.subpath}` : ''} does not look like a BMAD module`);
|
|
317
|
+
}
|
|
318
|
+
const parsed = parseModuleYaml(moduleSrc);
|
|
319
|
+
if (!parsed.ok)
|
|
320
|
+
throw new ValidationError(parsed.error);
|
|
321
|
+
const moduleCode = parsed.data.code;
|
|
322
|
+
const destDir = path.join(bmadDir, moduleCode);
|
|
323
|
+
// Story 17.3 — clean-slate re-install.
|
|
324
|
+
if (fs.existsSync(destDir)) {
|
|
325
|
+
cleanRemoveModule(moduleCode, bmadDir, manifestPath);
|
|
326
|
+
}
|
|
327
|
+
const copyResult = copyDirThroughWriteService(moduleSrc, destDir, app.fileStore.studioDir, app.fileStore);
|
|
328
|
+
if (!copyResult.ok)
|
|
329
|
+
throw new ValidationError(copyResult.error);
|
|
330
|
+
// Story 15.5 — substitute placeholders before manifest update
|
|
331
|
+
const subResult = runVariableSubstitution(destDir, makeSubContext(moduleCode), app.fileStore.studioDir, app.fileStore);
|
|
332
|
+
if (!subResult.ok)
|
|
333
|
+
throw new ValidationError(subResult.error);
|
|
334
|
+
// Update manifest (guaranteed to exist by the check at the top of the handler)
|
|
335
|
+
const manifest = readManifestSafe(manifestPath);
|
|
336
|
+
const now = new Date().toISOString();
|
|
337
|
+
const repoUrl = `https://github.com/${ghSource.owner}/${ghSource.repo}`;
|
|
338
|
+
const existing = manifest.modules.find((m) => m.name === moduleCode);
|
|
339
|
+
if (existing) {
|
|
340
|
+
existing.lastUpdated = now;
|
|
341
|
+
existing.source = 'github';
|
|
342
|
+
existing.repoUrl = repoUrl;
|
|
343
|
+
existing.npmPackage = null;
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
manifest.modules.push({
|
|
347
|
+
name: moduleCode,
|
|
348
|
+
version: parsed.data.version ?? '1.0.0',
|
|
349
|
+
installDate: now,
|
|
350
|
+
lastUpdated: now,
|
|
351
|
+
source: 'github',
|
|
352
|
+
npmPackage: null,
|
|
353
|
+
repoUrl,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
357
|
+
if (!wrote.ok)
|
|
358
|
+
throw new ValidationError(wrote.error);
|
|
359
|
+
// Story 15.6 — generate IDE launchers after manifest commit
|
|
360
|
+
const skillsGenerated = runGenerator(moduleCode);
|
|
361
|
+
app.fileStore.rebuild();
|
|
362
|
+
reply.status(200);
|
|
363
|
+
return {
|
|
364
|
+
ok: true,
|
|
365
|
+
modules: [moduleCode],
|
|
366
|
+
filesCopied: { text: copyResult.textCount, binary: copyResult.binaryCount },
|
|
367
|
+
source: { type: 'github', value: source.value, branch: resolvedBranch },
|
|
368
|
+
skillsGenerated,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
try {
|
|
373
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
/* ignore cleanup errors */
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
381
|
+
// Guard: reject unknown source types before falling through to npm.
|
|
382
|
+
// The InstallSource union only allows 'npm'|'local'|'github', but the body
|
|
383
|
+
// is untyped at the HTTP boundary — 'zip' or any other string would silently
|
|
384
|
+
// run `npm pack <value>` without this check.
|
|
385
|
+
// Cast to string to sidestep TS control-flow narrowing (source is already
|
|
386
|
+
// narrowed to the npm variant by this point, so source.type === 'npm' always,
|
|
387
|
+
// but this guard provides a runtime safety net for unexpected payloads).
|
|
388
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
389
|
+
const sourceTypeStr = source.type;
|
|
390
|
+
if (sourceTypeStr !== 'npm') {
|
|
391
|
+
throw new ValidationError(`Unknown source type "${sourceTypeStr}". Valid types: local, github, npm. For zip uploads use POST /api/modules/install/upload.`);
|
|
392
|
+
}
|
|
393
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
394
|
+
// npm source — refactored to route writes through WriteService
|
|
395
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
396
|
+
const packageName = source.value;
|
|
397
|
+
// TD-20 — realpathSync resolves the macOS /var → /private/var symlink so
|
|
398
|
+
// path comparisons inside the install loop are stable.
|
|
399
|
+
const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-install-')));
|
|
44
400
|
try {
|
|
45
401
|
// 1. Download the package tarball via npm pack
|
|
46
402
|
execSync(`npm pack ${packageName} --pack-destination ${tmpDir}`, {
|
|
@@ -51,7 +407,7 @@ export async function modulesPlugin(app) {
|
|
|
51
407
|
// 2. Find the tarball file
|
|
52
408
|
const tarballs = fs.readdirSync(tmpDir).filter((f) => f.endsWith('.tgz'));
|
|
53
409
|
if (tarballs.length === 0) {
|
|
54
|
-
|
|
410
|
+
throw new ValidationError('No tarball downloaded');
|
|
55
411
|
}
|
|
56
412
|
const tarballPath = path.join(tmpDir, tarballs[0]);
|
|
57
413
|
// 3. Extract the tarball
|
|
@@ -67,60 +423,95 @@ export async function modulesPlugin(app) {
|
|
|
67
423
|
const packageDir = path.join(extractDir, 'package');
|
|
68
424
|
const bmadSrcDir = path.join(packageDir, '_bmad');
|
|
69
425
|
if (!fs.existsSync(bmadSrcDir)) {
|
|
70
|
-
|
|
71
|
-
ok: false,
|
|
72
|
-
error: `Package "${packageName}" does not contain a _bmad/ directory`,
|
|
73
|
-
});
|
|
426
|
+
throw new ValidationError(`Package "${packageName}" does not contain a _bmad/ directory`);
|
|
74
427
|
}
|
|
75
428
|
// 5. Copy module directories from the package's _bmad/ into the project's _bmad/
|
|
429
|
+
// Routes through WriteService for text files, byte-copies binaries (TD-16).
|
|
76
430
|
const installedModules = [];
|
|
431
|
+
let totalText = 0;
|
|
432
|
+
let totalBinary = 0;
|
|
77
433
|
for (const entry of fs.readdirSync(bmadSrcDir, { withFileTypes: true })) {
|
|
78
434
|
if (entry.isDirectory() && entry.name !== '_config') {
|
|
79
435
|
const srcModuleDir = path.join(bmadSrcDir, entry.name);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
436
|
+
// Read module.yaml for the authoritative module code (AC-15.2.8).
|
|
437
|
+
// Falls back to the directory name if module.yaml is absent or doesn't
|
|
438
|
+
// declare a code field — consistent with local/github/zip branches.
|
|
439
|
+
const parsedMod = parseModuleYaml(srcModuleDir);
|
|
440
|
+
if (!parsedMod.ok)
|
|
441
|
+
throw new ValidationError(parsedMod.error);
|
|
442
|
+
const moduleCode = parsedMod.data.code;
|
|
443
|
+
const destModuleDir = path.join(bmadDir, moduleCode);
|
|
444
|
+
// Story 17.3 — clean-slate re-install.
|
|
445
|
+
if (fs.existsSync(destModuleDir)) {
|
|
446
|
+
cleanRemoveModule(moduleCode, bmadDir, manifestPath);
|
|
447
|
+
}
|
|
448
|
+
const copyResult = copyDirThroughWriteService(srcModuleDir, destModuleDir, app.fileStore.studioDir, app.fileStore);
|
|
449
|
+
if (!copyResult.ok)
|
|
450
|
+
throw new ValidationError(copyResult.error);
|
|
451
|
+
totalText += copyResult.textCount;
|
|
452
|
+
totalBinary += copyResult.binaryCount;
|
|
453
|
+
// Story 15.5 — substitute placeholders in each installed module dir.
|
|
454
|
+
const subResult = runVariableSubstitution(destModuleDir, makeSubContext(moduleCode), app.fileStore.studioDir, app.fileStore);
|
|
455
|
+
if (!subResult.ok)
|
|
456
|
+
throw new ValidationError(subResult.error);
|
|
457
|
+
installedModules.push(moduleCode);
|
|
83
458
|
}
|
|
84
459
|
}
|
|
85
460
|
if (installedModules.length === 0) {
|
|
86
|
-
|
|
87
|
-
ok: false,
|
|
88
|
-
error: `Package "${packageName}" has no module directories in _bmad/`,
|
|
89
|
-
});
|
|
461
|
+
throw new ValidationError(`Package "${packageName}" has no module directories in _bmad/`);
|
|
90
462
|
}
|
|
91
|
-
// 6. Update manifest.yaml with new module entries
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
463
|
+
// 6. Update manifest.yaml with new module entries (snapshot via WriteService)
|
|
464
|
+
const manifest = readManifestSafe(manifestPath);
|
|
465
|
+
const now = new Date().toISOString();
|
|
466
|
+
for (const moduleName of installedModules) {
|
|
467
|
+
const existing = manifest.modules.find((m) => m.name === moduleName);
|
|
468
|
+
if (existing) {
|
|
469
|
+
existing.lastUpdated = now;
|
|
470
|
+
existing.npmPackage = packageName;
|
|
471
|
+
existing.source = 'npm';
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
manifest.modules.push({
|
|
475
|
+
name: moduleName,
|
|
476
|
+
version: '1.0.0',
|
|
477
|
+
installDate: now,
|
|
478
|
+
lastUpdated: now,
|
|
479
|
+
source: 'npm',
|
|
480
|
+
npmPackage: packageName,
|
|
481
|
+
repoUrl: null,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
486
|
+
if (!wrote.ok)
|
|
487
|
+
throw new ValidationError(wrote.error);
|
|
488
|
+
// Story 15.6 — generate IDE launchers for each installed module.
|
|
489
|
+
// The npm branch installs MULTIPLE modules; sum the per-IDE counts.
|
|
490
|
+
const skillsGenerated = {};
|
|
491
|
+
for (const moduleName of installedModules) {
|
|
492
|
+
const counts = runGenerator(moduleName);
|
|
493
|
+
for (const [ide, count] of Object.entries(counts)) {
|
|
494
|
+
skillsGenerated[ide] = (skillsGenerated[ide] ?? 0) + count;
|
|
113
495
|
}
|
|
114
|
-
writeManifest(manifestPath, manifest);
|
|
115
496
|
}
|
|
116
497
|
// 7. Rebuild file store
|
|
117
498
|
app.fileStore.rebuild();
|
|
118
499
|
reply.status(200);
|
|
119
|
-
return {
|
|
500
|
+
return {
|
|
501
|
+
ok: true,
|
|
502
|
+
modules: installedModules,
|
|
503
|
+
filesCopied: { text: totalText, binary: totalBinary },
|
|
504
|
+
skillsGenerated,
|
|
505
|
+
};
|
|
120
506
|
}
|
|
121
507
|
catch (err) {
|
|
122
|
-
|
|
123
|
-
|
|
508
|
+
// Rethrow known error types so the global handler produces the correct
|
|
509
|
+
// status (422 for ValidationError, 409 for ConflictError). Wrap any
|
|
510
|
+
// unexpected error (e.g. execSync failure) as a ValidationError.
|
|
511
|
+
if (err instanceof ValidationError || err instanceof ConflictError || err instanceof NotFoundError) {
|
|
512
|
+
throw err;
|
|
513
|
+
}
|
|
514
|
+
throw new ValidationError(err instanceof Error ? err.message : 'Installation failed');
|
|
124
515
|
}
|
|
125
516
|
finally {
|
|
126
517
|
// Cleanup temp directory
|
|
@@ -132,6 +523,141 @@ export async function modulesPlugin(app) {
|
|
|
132
523
|
}
|
|
133
524
|
}
|
|
134
525
|
});
|
|
526
|
+
// Multipart zip upload route — Story 15.4. Separate from POST /api/modules/install
|
|
527
|
+
// because multipart bodies and JSON bodies have different shapes inside Fastify;
|
|
528
|
+
// splitting the routes is cleaner than content-type discrimination inside one handler.
|
|
529
|
+
app.post('/api/modules/install/upload', async (request, reply) => {
|
|
530
|
+
if (!('fileStore' in app)) {
|
|
531
|
+
throw new ValidationError('No project detected');
|
|
532
|
+
}
|
|
533
|
+
// Iterate all multipart parts in stream order so the `variables` field is
|
|
534
|
+
// captured regardless of whether it arrives before or after the file part.
|
|
535
|
+
// Using request.parts() instead of request.file() avoids the ordering bug
|
|
536
|
+
// where data.fields is only populated for fields that precede the file.
|
|
537
|
+
let zipBuffer = null;
|
|
538
|
+
let uploadVariables = {};
|
|
539
|
+
for await (const part of request.parts()) {
|
|
540
|
+
if (part.type === 'file') {
|
|
541
|
+
zipBuffer = await part.toBuffer();
|
|
542
|
+
}
|
|
543
|
+
else if (part.type === 'field' && part.fieldname === 'variables') {
|
|
544
|
+
const raw = part.value;
|
|
545
|
+
if (typeof raw === 'string') {
|
|
546
|
+
try {
|
|
547
|
+
const parsed = JSON.parse(raw);
|
|
548
|
+
if (parsed && typeof parsed === 'object') {
|
|
549
|
+
uploadVariables = parsed;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
throw new ValidationError('Invalid `variables` field — expected JSON object');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (!zipBuffer) {
|
|
559
|
+
throw new ValidationError('No zip file uploaded');
|
|
560
|
+
}
|
|
561
|
+
const buffer = zipBuffer;
|
|
562
|
+
const varValidation = validateVariables(uploadVariables);
|
|
563
|
+
if (!varValidation.ok)
|
|
564
|
+
throw new ValidationError(varValidation.error);
|
|
565
|
+
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
566
|
+
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
567
|
+
// Manifest existence guard (AC-15.4.9). Runs BEFORE extractZipUpload so a
|
|
568
|
+
// misconfigured project doesn't waste CPU on adm-zip extraction.
|
|
569
|
+
if (!fs.existsSync(manifestPath)) {
|
|
570
|
+
throw new ValidationError('Cannot install module: missing _bmad/_config/manifest.yaml. Run `npx bmad-method install` to initialise the project first.');
|
|
571
|
+
}
|
|
572
|
+
// extractZipUpload throws plain Error on failure (malformed zip, zip-slip, etc.)
|
|
573
|
+
// Convert to ValidationError so the user sees a 422 with a clean message.
|
|
574
|
+
let extracted;
|
|
575
|
+
try {
|
|
576
|
+
extracted = await extractZipUpload(buffer);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
580
|
+
}
|
|
581
|
+
const { extractedRoot, tmpDir } = extracted;
|
|
582
|
+
try {
|
|
583
|
+
if (!isPlausibleModuleDir(extractedRoot)) {
|
|
584
|
+
throw new ValidationError('Uploaded zip does not look like a BMAD module');
|
|
585
|
+
}
|
|
586
|
+
const parsed = parseModuleYaml(extractedRoot);
|
|
587
|
+
if (!parsed.ok)
|
|
588
|
+
throw new ValidationError(parsed.error);
|
|
589
|
+
const moduleCode = parsed.data.code;
|
|
590
|
+
const destDir = path.join(bmadDir, moduleCode);
|
|
591
|
+
// Story 17.3 — clean-slate re-install.
|
|
592
|
+
if (fs.existsSync(destDir)) {
|
|
593
|
+
cleanRemoveModule(moduleCode, bmadDir, manifestPath);
|
|
594
|
+
}
|
|
595
|
+
const copyResult = copyDirThroughWriteService(extractedRoot, destDir, app.fileStore.studioDir, app.fileStore);
|
|
596
|
+
if (!copyResult.ok)
|
|
597
|
+
throw new ValidationError(copyResult.error);
|
|
598
|
+
// Story 15.5 — substitute placeholders before manifest update
|
|
599
|
+
const subResult = runVariableSubstitution(destDir, {
|
|
600
|
+
moduleCode,
|
|
601
|
+
projectRoot: app.fileStore.projectRoot,
|
|
602
|
+
outputFolder: readOutputFolder(app.fileStore.projectRoot),
|
|
603
|
+
variables: uploadVariables,
|
|
604
|
+
}, app.fileStore.studioDir, app.fileStore);
|
|
605
|
+
if (!subResult.ok)
|
|
606
|
+
throw new ValidationError(subResult.error);
|
|
607
|
+
// Update manifest (guaranteed to exist by the check at the top of the handler)
|
|
608
|
+
const manifest = readManifestSafe(manifestPath);
|
|
609
|
+
const now = new Date().toISOString();
|
|
610
|
+
const existing = manifest.modules.find((m) => m.name === moduleCode);
|
|
611
|
+
if (existing) {
|
|
612
|
+
existing.lastUpdated = now;
|
|
613
|
+
existing.source = 'zip';
|
|
614
|
+
existing.npmPackage = null;
|
|
615
|
+
existing.repoUrl = null;
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
manifest.modules.push({
|
|
619
|
+
name: moduleCode,
|
|
620
|
+
version: parsed.data.version ?? '1.0.0',
|
|
621
|
+
installDate: now,
|
|
622
|
+
lastUpdated: now,
|
|
623
|
+
source: 'zip',
|
|
624
|
+
npmPackage: null,
|
|
625
|
+
repoUrl: null,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
629
|
+
if (!wrote.ok)
|
|
630
|
+
throw new ValidationError(wrote.error);
|
|
631
|
+
// Story 15.6 — generate IDE launchers after manifest commit
|
|
632
|
+
const updatedManifest = readManifestSafe(manifestPath);
|
|
633
|
+
const skillsGenerated = {};
|
|
634
|
+
if (updatedManifest) {
|
|
635
|
+
const genResult = generateIdeSkillsForModule(app.fileStore.projectRoot, moduleCode, updatedManifest, app.fileStore.studioDir, app.fileStore);
|
|
636
|
+
if (!genResult.ok)
|
|
637
|
+
throw new ValidationError(genResult.error);
|
|
638
|
+
for (const [ide, skills] of Object.entries(genResult.skillsByIde)) {
|
|
639
|
+
skillsGenerated[ide] = skills.length;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
app.fileStore.rebuild();
|
|
643
|
+
reply.status(200);
|
|
644
|
+
return {
|
|
645
|
+
ok: true,
|
|
646
|
+
modules: [moduleCode],
|
|
647
|
+
filesCopied: { text: copyResult.textCount, binary: copyResult.binaryCount },
|
|
648
|
+
source: { type: 'zip' },
|
|
649
|
+
skillsGenerated,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
try {
|
|
654
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
/* ignore cleanup errors */
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
});
|
|
135
661
|
app.post('/api/modules', async (request, reply) => {
|
|
136
662
|
if (!('fileStore' in app)) {
|
|
137
663
|
throw new ValidationError('No project detected');
|
|
@@ -164,10 +690,12 @@ export async function modulesPlugin(app) {
|
|
|
164
690
|
'',
|
|
165
691
|
`project_name: ${name}`,
|
|
166
692
|
].join('\n');
|
|
167
|
-
|
|
693
|
+
const wResult = writeFile(path.join(moduleDir, 'config.yaml'), configContent, app.fileStore.studioDir);
|
|
694
|
+
if (!wResult.ok)
|
|
695
|
+
throw new ValidationError(wResult.error);
|
|
168
696
|
// Update manifest
|
|
169
|
-
|
|
170
|
-
|
|
697
|
+
const manifest = readManifestSafe(manifestPath);
|
|
698
|
+
if (manifest) {
|
|
171
699
|
const now = new Date().toISOString();
|
|
172
700
|
manifest.modules.push({
|
|
173
701
|
name,
|
|
@@ -178,14 +706,20 @@ export async function modulesPlugin(app) {
|
|
|
178
706
|
npmPackage: null,
|
|
179
707
|
repoUrl: null,
|
|
180
708
|
});
|
|
181
|
-
|
|
709
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
710
|
+
if (!wrote.ok)
|
|
711
|
+
throw new ValidationError(wrote.error);
|
|
182
712
|
}
|
|
183
713
|
// Rebuild index
|
|
184
714
|
app.fileStore.rebuild();
|
|
185
715
|
reply.status(201);
|
|
186
|
-
return { ok: true, name
|
|
716
|
+
return { ok: true, name };
|
|
187
717
|
});
|
|
188
|
-
|
|
718
|
+
// Story 15.8 — Regenerate IDE skills for an installed module. Removes all
|
|
719
|
+
// prefix-matched launcher dirs and re-runs the generator against the current
|
|
720
|
+
// module contents. Used by the "Regenerate IDE skills" button on PackagesPage
|
|
721
|
+
// and as the Q9 smoke test for dept-aem.
|
|
722
|
+
app.post('/api/modules/:name/regenerate-skills', async (request) => {
|
|
189
723
|
if (!('fileStore' in app)) {
|
|
190
724
|
throw new ValidationError('No project detected');
|
|
191
725
|
}
|
|
@@ -193,27 +727,199 @@ export async function modulesPlugin(app) {
|
|
|
193
727
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
194
728
|
const moduleDir = path.join(bmadDir, name);
|
|
195
729
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
730
|
+
if (!fs.existsSync(moduleDir)) {
|
|
731
|
+
throw new NotFoundError(`Module "${name}" is not installed`);
|
|
732
|
+
}
|
|
733
|
+
const manifest = readManifestSafe(manifestPath);
|
|
734
|
+
if (!manifest)
|
|
735
|
+
throw new ValidationError('No manifest.yaml found');
|
|
736
|
+
// Remove existing launcher files first (snapshots text content via WriteService)
|
|
737
|
+
const removeResult = removeIdeSkillsForModule(app.fileStore.projectRoot, name, manifest, app.fileStore.studioDir);
|
|
738
|
+
if (!removeResult.ok)
|
|
739
|
+
throw new ValidationError(removeResult.error);
|
|
740
|
+
// Regenerate from the current module contents — picks up any manual edits.
|
|
741
|
+
const genResult = generateIdeSkillsForModule(app.fileStore.projectRoot, name, manifest, app.fileStore.studioDir, app.fileStore);
|
|
742
|
+
if (!genResult.ok)
|
|
743
|
+
throw new ValidationError(genResult.error);
|
|
744
|
+
const regenerated = {};
|
|
745
|
+
for (const [ide, skills] of Object.entries(genResult.skillsByIde)) {
|
|
746
|
+
regenerated[ide] = skills.length;
|
|
747
|
+
}
|
|
748
|
+
app.fileStore.rebuild();
|
|
749
|
+
return { ok: true, regenerated };
|
|
750
|
+
});
|
|
751
|
+
// Story 15.7 — Remove preview. Returns a structured pre-flight summary so the
|
|
752
|
+
// remove dialog (Story 15.9) can render an informed confirmation screen.
|
|
753
|
+
app.get('/api/modules/:name/remove-preview', async (request) => {
|
|
754
|
+
if (!('fileStore' in app)) {
|
|
755
|
+
throw new ValidationError('No project detected');
|
|
756
|
+
}
|
|
757
|
+
const { name } = request.params;
|
|
758
|
+
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
759
|
+
const moduleDir = path.join(bmadDir, name);
|
|
760
|
+
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
761
|
+
const manifest = readManifestSafe(manifestPath);
|
|
762
|
+
const entry = manifest?.modules.find((m) => m.name === name);
|
|
763
|
+
if (!entry)
|
|
764
|
+
throw new NotFoundError(`Module "${name}" not found in manifest`);
|
|
765
|
+
// Built-in modules: blocked
|
|
766
|
+
const removalBlocked = entry.source === 'built-in'
|
|
767
|
+
? `Module "${name}" is built-in and cannot be removed via Studio.`
|
|
768
|
+
: null;
|
|
769
|
+
// Module file count + size
|
|
770
|
+
let fileCount = 0;
|
|
771
|
+
let totalBytes = 0;
|
|
772
|
+
if (fs.existsSync(moduleDir)) {
|
|
773
|
+
const walk = (dir) => {
|
|
774
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
775
|
+
const full = path.join(dir, e.name);
|
|
776
|
+
if (e.isDirectory())
|
|
777
|
+
walk(full);
|
|
778
|
+
else {
|
|
779
|
+
fileCount++;
|
|
780
|
+
totalBytes += fs.statSync(full).size;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
walk(moduleDir);
|
|
785
|
+
}
|
|
786
|
+
// IDE skills (prefix-matched dirs under each configured IDE output dir)
|
|
787
|
+
const ideSkills = {};
|
|
788
|
+
if (manifest?.ides) {
|
|
789
|
+
for (const ide of manifest.ides) {
|
|
790
|
+
if (ide !== 'claude-code' && ide !== 'antigravity')
|
|
791
|
+
continue;
|
|
792
|
+
const dir = path.join(app.fileStore.projectRoot, ide === 'claude-code' ? '.claude/skills' : '.antigravity/skills');
|
|
793
|
+
if (!fs.existsSync(dir)) {
|
|
794
|
+
ideSkills[ide] = [];
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
const agentPrefix = `bmad-agent-${name}-`;
|
|
798
|
+
const otherPrefix = `bmad-${name}-`;
|
|
799
|
+
ideSkills[ide] = fs
|
|
800
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
801
|
+
.filter((e) => e.isDirectory() &&
|
|
802
|
+
(e.name.startsWith(agentPrefix) || e.name.startsWith(otherPrefix)))
|
|
803
|
+
.map((e) => e.name);
|
|
202
804
|
}
|
|
203
|
-
|
|
204
|
-
|
|
805
|
+
}
|
|
806
|
+
// Preserved directories from module.yaml (declared output dirs)
|
|
807
|
+
const parsed = parseModuleYaml(moduleDir);
|
|
808
|
+
const preservedDirectories = [];
|
|
809
|
+
let moduleYamlPresent = false;
|
|
810
|
+
if (parsed.ok && fs.existsSync(path.join(moduleDir, 'module.yaml'))) {
|
|
811
|
+
moduleYamlPresent = true;
|
|
812
|
+
for (const declared of parsed.data.directories ?? []) {
|
|
813
|
+
const resolved = path.isAbsolute(declared)
|
|
814
|
+
? declared
|
|
815
|
+
: path.join(app.fileStore.projectRoot, declared);
|
|
816
|
+
if (fs.existsSync(resolved)) {
|
|
817
|
+
preservedDirectories.push({ path: resolved, declared: true });
|
|
818
|
+
}
|
|
205
819
|
}
|
|
206
|
-
// Remove from manifest
|
|
207
|
-
manifest.modules = manifest.modules.filter((m) => m.name !== name);
|
|
208
|
-
writeManifest(manifestPath, manifest);
|
|
209
820
|
}
|
|
210
|
-
//
|
|
821
|
+
// Cross-references (TD-19 scope)
|
|
822
|
+
const crossReferences = findCrossReferences(app.fileStore.getIndex(), name);
|
|
823
|
+
// External-installer warning
|
|
824
|
+
const externalInstallerWarning = entry.source === 'external'
|
|
825
|
+
? `Module "${name}" was installed by the BMAD installer. Removing it via Studio will not update the upstream installation — re-running the installer will reinstall it.`
|
|
826
|
+
: null;
|
|
827
|
+
return {
|
|
828
|
+
module: { name: entry.name, version: entry.version, source: entry.source },
|
|
829
|
+
moduleFiles: { count: fileCount, totalBytes },
|
|
830
|
+
ideSkills,
|
|
831
|
+
manifestEntries: { 'manifest.yaml': true },
|
|
832
|
+
preservedDirectories,
|
|
833
|
+
moduleYamlPresent,
|
|
834
|
+
crossReferences,
|
|
835
|
+
crossReferenceScopeNotice: 'Cross-reference scanning covers teams and workflow steps. References from agent menus or skill lists are not detected — review the affected modules manually after removal.',
|
|
836
|
+
recoverableFrom: '.bmad-studio/history/',
|
|
837
|
+
removalBlocked,
|
|
838
|
+
externalInstallerWarning,
|
|
839
|
+
};
|
|
840
|
+
});
|
|
841
|
+
// Story 15.7 — Rich remove handler. Replaces the thin DELETE. Removes IDE
|
|
842
|
+
// launcher files, then deletes the module dir through WriteService (text
|
|
843
|
+
// files snapshot to history/ before unlink), then removes the manifest entry.
|
|
844
|
+
// Preserves directories declared in module.yaml.directories[].
|
|
845
|
+
app.delete('/api/modules/:name', async (request) => {
|
|
846
|
+
if (!('fileStore' in app)) {
|
|
847
|
+
throw new ValidationError('No project detected');
|
|
848
|
+
}
|
|
849
|
+
const { name } = request.params;
|
|
850
|
+
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
851
|
+
const moduleDir = path.join(bmadDir, name);
|
|
852
|
+
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
853
|
+
const manifest = readManifestSafe(manifestPath);
|
|
854
|
+
const entry = manifest?.modules.find((m) => m.name === name);
|
|
855
|
+
if (!entry)
|
|
856
|
+
throw new NotFoundError(`Module "${name}" not found in manifest`);
|
|
857
|
+
if (entry.source === 'built-in') {
|
|
858
|
+
throw new ValidationError(`Cannot remove built-in module "${name}"`);
|
|
859
|
+
}
|
|
860
|
+
// Read module.yaml BEFORE deletion so we can capture directories: to preserve.
|
|
861
|
+
const parsed = parseModuleYaml(moduleDir);
|
|
862
|
+
const preserved = parsed.ok
|
|
863
|
+
? (parsed.data.directories ?? []).map((d) => path.isAbsolute(d) ? d : path.join(app.fileStore.projectRoot, d))
|
|
864
|
+
: [];
|
|
865
|
+
// 1. Remove prefix-matched IDE skill directories (each deletion snapshots through WriteService)
|
|
866
|
+
let removedSkills = {};
|
|
867
|
+
if (manifest) {
|
|
868
|
+
const r = removeIdeSkillsForModule(app.fileStore.projectRoot, name, manifest, app.fileStore.studioDir);
|
|
869
|
+
if (!r.ok)
|
|
870
|
+
throw new ValidationError(r.error);
|
|
871
|
+
removedSkills = r.removedByIde;
|
|
872
|
+
}
|
|
873
|
+
// 2. Delete the module directory through WriteService (text files snapshot first).
|
|
874
|
+
// Count files before delete so the response summary is accurate.
|
|
875
|
+
let filesRemoved = 0;
|
|
211
876
|
if (fs.existsSync(moduleDir)) {
|
|
212
|
-
|
|
877
|
+
const walk = (dir) => {
|
|
878
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
879
|
+
const full = path.join(dir, e.name);
|
|
880
|
+
if (e.isDirectory())
|
|
881
|
+
walk(full);
|
|
882
|
+
else
|
|
883
|
+
filesRemoved++;
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
walk(moduleDir);
|
|
887
|
+
const r = deleteDirectory(moduleDir, app.fileStore.studioDir);
|
|
888
|
+
if (!r.ok)
|
|
889
|
+
throw new ValidationError(r.error);
|
|
213
890
|
}
|
|
214
|
-
//
|
|
891
|
+
// 3. Update the manifest (snapshot via WriteService)
|
|
892
|
+
if (manifest) {
|
|
893
|
+
manifest.modules = manifest.modules.filter((m) => m.name !== name);
|
|
894
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
895
|
+
if (!wrote.ok)
|
|
896
|
+
throw new ValidationError(wrote.error);
|
|
897
|
+
}
|
|
898
|
+
// 4. Rebuild index and broadcast the change
|
|
215
899
|
app.fileStore.rebuild();
|
|
216
|
-
|
|
900
|
+
if ('ws' in app) {
|
|
901
|
+
try {
|
|
902
|
+
;
|
|
903
|
+
app.ws.broadcast({
|
|
904
|
+
type: 'file:deleted',
|
|
905
|
+
path: moduleDir,
|
|
906
|
+
category: 'config',
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
/* ignore — broadcast is best-effort */
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
ok: true,
|
|
915
|
+
name,
|
|
916
|
+
removed: {
|
|
917
|
+
filesRemoved,
|
|
918
|
+
skills: removedSkills,
|
|
919
|
+
},
|
|
920
|
+
preservedDirectories: preserved,
|
|
921
|
+
recoverableFrom: '.bmad-studio/history/',
|
|
922
|
+
};
|
|
217
923
|
});
|
|
218
924
|
// Story 12.1 + 12.2: Add/upload entities to a module
|
|
219
925
|
app.post('/api/modules/:name/entities', async (request, reply) => {
|
|
@@ -221,6 +927,9 @@ export async function modulesPlugin(app) {
|
|
|
221
927
|
throw new ValidationError('No project detected');
|
|
222
928
|
}
|
|
223
929
|
const { name } = request.params;
|
|
930
|
+
if (!MODULE_NAME_RE.test(name)) {
|
|
931
|
+
throw new ValidationError('Module name must be lowercase alphanumeric with hyphens');
|
|
932
|
+
}
|
|
224
933
|
const body = request.body;
|
|
225
934
|
const entityType = body.type;
|
|
226
935
|
const entityName = body.name?.trim();
|
|
@@ -258,7 +967,9 @@ export async function modulesPlugin(app) {
|
|
|
258
967
|
'<!-- Add skill instructions here -->',
|
|
259
968
|
'',
|
|
260
969
|
].join('\n');
|
|
261
|
-
|
|
970
|
+
const wResult = writeFile(filePath, content, app.fileStore.studioDir);
|
|
971
|
+
if (!wResult.ok)
|
|
972
|
+
throw new ValidationError(wResult.error);
|
|
262
973
|
}
|
|
263
974
|
else if (entityType === 'workflow') {
|
|
264
975
|
// Workflows get their own directory with a workflow.md file
|
|
@@ -282,7 +993,9 @@ export async function modulesPlugin(app) {
|
|
|
282
993
|
'<!-- Add workflow steps here -->',
|
|
283
994
|
'',
|
|
284
995
|
].join('\n');
|
|
285
|
-
|
|
996
|
+
const wResult = writeFile(filePath, content, app.fileStore.studioDir);
|
|
997
|
+
if (!wResult.ok)
|
|
998
|
+
throw new ValidationError(wResult.error);
|
|
286
999
|
}
|
|
287
1000
|
else {
|
|
288
1001
|
// Agent: .md file in agents/ directory
|
|
@@ -305,12 +1018,14 @@ export async function modulesPlugin(app) {
|
|
|
305
1018
|
'<!-- Add agent definition here -->',
|
|
306
1019
|
'',
|
|
307
1020
|
].join('\n');
|
|
308
|
-
|
|
1021
|
+
const wResult = writeFile(filePath, content, app.fileStore.studioDir);
|
|
1022
|
+
if (!wResult.ok)
|
|
1023
|
+
throw new ValidationError(wResult.error);
|
|
309
1024
|
}
|
|
310
1025
|
// Rebuild index
|
|
311
1026
|
app.fileStore.rebuild();
|
|
312
1027
|
reply.status(201);
|
|
313
|
-
return { ok: true, type: entityType, name: entityName
|
|
1028
|
+
return { ok: true, type: entityType, name: entityName };
|
|
314
1029
|
});
|
|
315
1030
|
// Story 12.3: Export module manifest
|
|
316
1031
|
app.post('/api/modules/:name/export', async (request) => {
|
|
@@ -320,10 +1035,10 @@ export async function modulesPlugin(app) {
|
|
|
320
1035
|
const { name } = request.params;
|
|
321
1036
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
322
1037
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
323
|
-
|
|
1038
|
+
const manifest = readManifestSafe(manifestPath);
|
|
1039
|
+
if (!manifest) {
|
|
324
1040
|
throw new NotFoundError('No manifest found');
|
|
325
1041
|
}
|
|
326
|
-
const manifest = readManifest(manifestPath);
|
|
327
1042
|
const entry = manifest.modules.find((m) => m.name === name);
|
|
328
1043
|
if (!entry) {
|
|
329
1044
|
throw new NotFoundError(`Module "${name}" not found in manifest`);
|
|
@@ -361,6 +1076,9 @@ export async function modulesPlugin(app) {
|
|
|
361
1076
|
throw new ValidationError('No project detected');
|
|
362
1077
|
}
|
|
363
1078
|
const { name } = request.params;
|
|
1079
|
+
if (!MODULE_NAME_RE.test(name)) {
|
|
1080
|
+
throw new ValidationError('Module name must be lowercase alphanumeric with hyphens');
|
|
1081
|
+
}
|
|
364
1082
|
const body = request.body;
|
|
365
1083
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
366
1084
|
const moduleDir = path.join(bmadDir, name);
|
|
@@ -369,23 +1087,27 @@ export async function modulesPlugin(app) {
|
|
|
369
1087
|
throw new NotFoundError(`Module "${name}" config not found`);
|
|
370
1088
|
}
|
|
371
1089
|
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
372
|
-
const config = yaml.load(configContent);
|
|
1090
|
+
const config = yaml.load(configContent) ?? {};
|
|
373
1091
|
if (body.version !== undefined)
|
|
374
1092
|
config.version = body.version;
|
|
375
1093
|
if (body.description !== undefined)
|
|
376
1094
|
config.description = body.description;
|
|
377
1095
|
const updated = yaml.dump(config, { lineWidth: -1 });
|
|
378
|
-
|
|
1096
|
+
const wResult = writeFile(configPath, updated, app.fileStore.studioDir);
|
|
1097
|
+
if (!wResult.ok)
|
|
1098
|
+
throw new ValidationError(wResult.error);
|
|
379
1099
|
// Also update manifest.yaml
|
|
380
1100
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
381
|
-
|
|
382
|
-
|
|
1101
|
+
const manifest = readManifestSafe(manifestPath);
|
|
1102
|
+
if (manifest) {
|
|
383
1103
|
const mod = manifest.modules.find((m) => m.name === name);
|
|
384
1104
|
if (mod) {
|
|
385
1105
|
if (body.version !== undefined)
|
|
386
1106
|
mod.version = body.version;
|
|
387
|
-
mod.lastUpdated = new Date().toISOString()
|
|
388
|
-
|
|
1107
|
+
mod.lastUpdated = new Date().toISOString();
|
|
1108
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
1109
|
+
if (!wrote.ok)
|
|
1110
|
+
throw new ValidationError(wrote.error);
|
|
389
1111
|
}
|
|
390
1112
|
}
|
|
391
1113
|
app.fileStore.rebuild();
|
|
@@ -397,10 +1119,10 @@ export async function modulesPlugin(app) {
|
|
|
397
1119
|
}
|
|
398
1120
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
399
1121
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
400
|
-
|
|
1122
|
+
const manifest = readManifestSafe(manifestPath);
|
|
1123
|
+
if (!manifest) {
|
|
401
1124
|
return [];
|
|
402
1125
|
}
|
|
403
|
-
const manifest = readManifest(manifestPath);
|
|
404
1126
|
const index = app.fileStore.getIndex();
|
|
405
1127
|
return manifest.modules.map((m) => {
|
|
406
1128
|
const moduleAgents = index.agents.filter((a) => a.module === m.name);
|
|
@@ -430,6 +1152,9 @@ export async function modulesPlugin(app) {
|
|
|
430
1152
|
if (!name) {
|
|
431
1153
|
throw new ValidationError('Package name is required');
|
|
432
1154
|
}
|
|
1155
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) {
|
|
1156
|
+
throw new ValidationError('Package name must start with a letter or digit and contain only alphanumeric characters, hyphens, or underscores');
|
|
1157
|
+
}
|
|
433
1158
|
const description = body.description?.trim() || '';
|
|
434
1159
|
const version = body.version?.trim() || '1.0.0';
|
|
435
1160
|
const agentIds = body.agents ?? [];
|
|
@@ -492,7 +1217,10 @@ export async function modulesPlugin(app) {
|
|
|
492
1217
|
fs.writeFileSync(path.join(packageDir, 'package.yaml'), yaml.dump(packageManifest, { lineWidth: -1 }), 'utf-8');
|
|
493
1218
|
// Create tar.gz archive
|
|
494
1219
|
const archivePath = path.join(tmpBase, `${name}.tar.gz`);
|
|
495
|
-
|
|
1220
|
+
const tarResult = spawnSync('tar', ['-czf', archivePath, '-C', tmpBase, name], { stdio: 'pipe' });
|
|
1221
|
+
if (tarResult.status !== 0) {
|
|
1222
|
+
throw new ValidationError('Failed to create package archive');
|
|
1223
|
+
}
|
|
496
1224
|
// Send as downloadable file
|
|
497
1225
|
reply.header('Content-Disposition', `attachment; filename="${name}.tar.gz"`);
|
|
498
1226
|
reply.type('application/gzip');
|
|
@@ -527,5 +1255,59 @@ export async function modulesPlugin(app) {
|
|
|
527
1255
|
throw new ValidationError(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
528
1256
|
}
|
|
529
1257
|
});
|
|
1258
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1259
|
+
// Story 16.2 — Registry index endpoints
|
|
1260
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1261
|
+
app.get('/api/registry', async (_request, reply) => {
|
|
1262
|
+
if (!('fileStore' in app)) {
|
|
1263
|
+
return reply.send({ ok: false, configured: false, error: 'No project detected' });
|
|
1264
|
+
}
|
|
1265
|
+
const settingsPath = path.join(app.fileStore.studioDir, 'settings.json');
|
|
1266
|
+
if (!fs.existsSync(settingsPath)) {
|
|
1267
|
+
return reply.send({ ok: false, configured: false, error: 'No registry configured' });
|
|
1268
|
+
}
|
|
1269
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
1270
|
+
if (!settings.registry?.repo) {
|
|
1271
|
+
return reply.send({ ok: false, configured: false, error: 'No registry configured' });
|
|
1272
|
+
}
|
|
1273
|
+
let ghSource;
|
|
1274
|
+
try {
|
|
1275
|
+
ghSource = parseGithubSource(settings.registry.repo);
|
|
1276
|
+
}
|
|
1277
|
+
catch (err) {
|
|
1278
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
1279
|
+
}
|
|
1280
|
+
let cached = readCachedRegistryIndex(app.fileStore.studioDir, ghSource.owner, ghSource.repo);
|
|
1281
|
+
if (!cached || isRegistryCacheStale(cached)) {
|
|
1282
|
+
try {
|
|
1283
|
+
cached = await fetchAndCacheRegistryIndex(app.fileStore.studioDir, settings.registry.repo, settings.registry.branch ?? 'main');
|
|
1284
|
+
}
|
|
1285
|
+
catch (err) {
|
|
1286
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return reply.send({ ok: true, configured: true, index: cached });
|
|
1290
|
+
});
|
|
1291
|
+
app.post('/api/registry/refresh', async (_request, reply) => {
|
|
1292
|
+
if (!('fileStore' in app)) {
|
|
1293
|
+
throw new ValidationError('No project detected');
|
|
1294
|
+
}
|
|
1295
|
+
const settingsPath = path.join(app.fileStore.studioDir, 'settings.json');
|
|
1296
|
+
if (!fs.existsSync(settingsPath)) {
|
|
1297
|
+
throw new ValidationError('No registry configured');
|
|
1298
|
+
}
|
|
1299
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
1300
|
+
if (!settings.registry?.repo) {
|
|
1301
|
+
throw new ValidationError('No registry configured');
|
|
1302
|
+
}
|
|
1303
|
+
let index;
|
|
1304
|
+
try {
|
|
1305
|
+
index = await fetchAndCacheRegistryIndex(app.fileStore.studioDir, settings.registry.repo, settings.registry.branch ?? 'main');
|
|
1306
|
+
}
|
|
1307
|
+
catch (err) {
|
|
1308
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
1309
|
+
}
|
|
1310
|
+
return reply.send({ ok: true, index });
|
|
1311
|
+
});
|
|
530
1312
|
}
|
|
531
1313
|
//# sourceMappingURL=modules-plugin.js.map
|