bmad-studio 0.2.0 → 1.1.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 +163 -17
- package/package.json +12 -3
- package/packages/client/dist/assets/index-81ZKe-R8.css +1 -0
- package/packages/client/dist/assets/index-DyjtzhqN.js +641 -0
- package/packages/client/dist/index.html +2 -2
- package/packages/server/dist/app.d.ts +2 -1
- package/packages/server/dist/app.d.ts.map +1 -1
- package/packages/server/dist/app.js +68 -3
- package/packages/server/dist/app.js.map +1 -1
- package/packages/server/dist/core/file-store.d.ts +8 -1
- package/packages/server/dist/core/file-store.d.ts.map +1 -1
- package/packages/server/dist/core/file-store.js +26 -3
- package/packages/server/dist/core/file-store.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 +85 -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 +60 -1
- package/packages/server/dist/plugins/agents-plugin.js.map +1 -1
- package/packages/server/dist/plugins/commands-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/commands-plugin.js +37 -10
- package/packages/server/dist/plugins/commands-plugin.js.map +1 -1
- package/packages/server/dist/plugins/datasources-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/datasources-plugin.js +101 -0
- package/packages/server/dist/plugins/datasources-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 +905 -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/outputs-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/outputs-plugin.js +111 -0
- package/packages/server/dist/plugins/outputs-plugin.js.map +1 -1
- package/packages/server/dist/plugins/overview-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/overview-plugin.js +35 -2
- package/packages/server/dist/plugins/overview-plugin.js.map +1 -1
- package/packages/server/dist/plugins/search-plugin.d.ts.map +1 -1
- package/packages/server/dist/plugins/search-plugin.js +19 -2
- package/packages/server/dist/plugins/search-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 +38 -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/events.ts +7 -0
- package/packages/shared/src/index.ts +13 -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/shared/src/workflows.ts +27 -0
- package/packages/client/dist/assets/index-5nXyrx_3.css +0 -1
- package/packages/client/dist/assets/index-DxN3uabX.js +0 -521
|
@@ -1,46 +1,425 @@
|
|
|
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
|
+
const collisions = [];
|
|
137
|
+
const incomingModuleCode = parsed.data.code;
|
|
138
|
+
const index = app.fileStore.getIndex();
|
|
139
|
+
// Incoming agent names (from agents/*.md filenames)
|
|
140
|
+
const agentFiles = scanEntities(path.join(stagedRoot, 'agents'), '.md');
|
|
141
|
+
for (const agentFile of agentFiles) {
|
|
142
|
+
const name = path.basename(agentFile, path.extname(agentFile)).replace(/\.agent$/, '');
|
|
143
|
+
const existing = index.agents.find((a) => (a.name === name || a.id === name) && a.module && a.module !== incomingModuleCode);
|
|
144
|
+
if (existing)
|
|
145
|
+
collisions.push({ type: 'agent', name, existingModule: existing.module });
|
|
146
|
+
}
|
|
147
|
+
// Incoming skill names (from skills dir)
|
|
148
|
+
const skillsDir = path.join(stagedRoot, 'skills');
|
|
149
|
+
if (fs.existsSync(skillsDir)) {
|
|
150
|
+
const skillNames = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
151
|
+
.filter((e) => e.isDirectory() || (e.isFile() && e.name.endsWith('.md')))
|
|
152
|
+
.map((e) => e.isDirectory() ? e.name : path.basename(e.name, '.md'));
|
|
153
|
+
for (const name of skillNames) {
|
|
154
|
+
const existing = index.skills.find((s) => s.id === name && s.module && s.module !== incomingModuleCode);
|
|
155
|
+
if (existing)
|
|
156
|
+
collisions.push({ type: 'skill', name, existingModule: existing.module });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, moduleYaml: parsed.data, counts, willReplace, collisions };
|
|
160
|
+
});
|
|
161
|
+
// Polymorphic install endpoint — accepts npm | local | github source types via
|
|
162
|
+
// { source: { type, value }, variables? }, plus the legacy { packageName } shape.
|
|
163
|
+
// Zip uploads go to a separate multipart route POST /api/modules/install/upload (Story 15.4).
|
|
32
164
|
app.post('/api/modules/install', async (request, reply) => {
|
|
33
165
|
if (!('fileStore' in app)) {
|
|
34
166
|
throw new ValidationError('No project detected');
|
|
35
167
|
}
|
|
36
168
|
const body = request.body;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
169
|
+
// Defensive guard: if the request body is missing (e.g. a multipart upload sent
|
|
170
|
+
// to this JSON-only route — multipart goes to /upload), fail loudly with a 422
|
|
171
|
+
// instead of an unhandled TypeError. AC-15.4.11.
|
|
172
|
+
if (!body || typeof body !== 'object') {
|
|
173
|
+
throw new ValidationError('Request body must be JSON. For zip uploads, POST to /api/modules/install/upload instead.');
|
|
40
174
|
}
|
|
175
|
+
// TD-22 — discriminate source vs legacy. Prefer `source` if both are present.
|
|
176
|
+
let source;
|
|
177
|
+
if (body.source) {
|
|
178
|
+
source = body.source;
|
|
179
|
+
if (body.packageName) {
|
|
180
|
+
request.log.warn({ packageName: body.packageName }, 'Both `source` and `packageName` provided to /api/modules/install — using `source`');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (body.packageName) {
|
|
184
|
+
const pkg = body.packageName.trim();
|
|
185
|
+
if (!pkg) {
|
|
186
|
+
throw new ValidationError('Package name is required');
|
|
187
|
+
}
|
|
188
|
+
source = { type: 'npm', value: pkg };
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
throw new ValidationError('Either `source` or `packageName` is required');
|
|
192
|
+
}
|
|
193
|
+
// Story 15.5 — variables are now consumed (not just threaded through).
|
|
194
|
+
const variables = body.variables ?? {};
|
|
41
195
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
42
196
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
43
|
-
|
|
197
|
+
// Hard requirement (AC-15.2.9 / finding #8): every install branch needs an existing manifest.
|
|
198
|
+
// Without it, the installed module would be invisible to GET /api/modules.
|
|
199
|
+
if (!fs.existsSync(manifestPath)) {
|
|
200
|
+
throw new ValidationError('Cannot install module: missing _bmad/_config/manifest.yaml. Run `npx bmad-method install` to initialise the project first.');
|
|
201
|
+
}
|
|
202
|
+
// Story 15.5 — validate variables BEFORE any source-type branching so bad input
|
|
203
|
+
// fails fast without wasting CPU on a download/extraction.
|
|
204
|
+
const varValidation = validateVariables(variables);
|
|
205
|
+
if (!varValidation.ok)
|
|
206
|
+
throw new ValidationError(varValidation.error);
|
|
207
|
+
// Helper to build the substitution context for a given module code. Used by every branch.
|
|
208
|
+
const makeSubContext = (moduleCode) => ({
|
|
209
|
+
moduleCode,
|
|
210
|
+
projectRoot: app.fileStore.projectRoot,
|
|
211
|
+
outputFolder: readOutputFolder(app.fileStore.projectRoot),
|
|
212
|
+
variables,
|
|
213
|
+
});
|
|
214
|
+
// Helper to run the IDE skill generator for a freshly-installed module and
|
|
215
|
+
// return per-IDE counts for the response shape (Story 15.6 / AC-15.6.8).
|
|
216
|
+
// Re-reads the manifest to get the latest `ides[]` after the manifest update.
|
|
217
|
+
const runGenerator = (moduleCode) => {
|
|
218
|
+
const updatedManifest = readManifestSafe(manifestPath);
|
|
219
|
+
if (!updatedManifest)
|
|
220
|
+
return {};
|
|
221
|
+
const genResult = generateIdeSkillsForModule(app.fileStore.projectRoot, moduleCode, updatedManifest, app.fileStore.studioDir, app.fileStore);
|
|
222
|
+
if (!genResult.ok)
|
|
223
|
+
throw new ValidationError(genResult.error);
|
|
224
|
+
const counts = {};
|
|
225
|
+
for (const [ide, skills] of Object.entries(genResult.skillsByIde)) {
|
|
226
|
+
counts[ide] = skills.length;
|
|
227
|
+
}
|
|
228
|
+
return counts;
|
|
229
|
+
};
|
|
230
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
231
|
+
// local source
|
|
232
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
233
|
+
if (source.type === 'local') {
|
|
234
|
+
const sourcePath = path.isAbsolute(source.value)
|
|
235
|
+
? source.value
|
|
236
|
+
: path.join(app.fileStore.projectRoot, source.value);
|
|
237
|
+
if (!isPlausibleModuleDir(sourcePath)) {
|
|
238
|
+
throw new ValidationError(`Path "${sourcePath}" does not look like a BMAD module — expected module.yaml or one of agents/skills/workflows/tasks`);
|
|
239
|
+
}
|
|
240
|
+
const parsed = parseModuleYaml(sourcePath);
|
|
241
|
+
if (!parsed.ok)
|
|
242
|
+
throw new ValidationError(parsed.error);
|
|
243
|
+
const moduleCode = parsed.data.code;
|
|
244
|
+
const destDir = path.join(bmadDir, moduleCode);
|
|
245
|
+
// Story 17.3 — clean-slate re-install: silently remove any existing copy before writing.
|
|
246
|
+
if (fs.existsSync(destDir)) {
|
|
247
|
+
cleanRemoveModule(moduleCode, bmadDir, manifestPath);
|
|
248
|
+
}
|
|
249
|
+
const copyResult = copyDirThroughWriteService(sourcePath, destDir, app.fileStore.studioDir, app.fileStore);
|
|
250
|
+
if (!copyResult.ok)
|
|
251
|
+
throw new ValidationError(copyResult.error);
|
|
252
|
+
// Story 15.5 — substitute placeholders in installed text files BEFORE updating the manifest.
|
|
253
|
+
const subResult = runVariableSubstitution(destDir, makeSubContext(moduleCode), app.fileStore.studioDir, app.fileStore);
|
|
254
|
+
if (!subResult.ok)
|
|
255
|
+
throw new ValidationError(subResult.error);
|
|
256
|
+
// Update manifest (guaranteed to exist by the check above)
|
|
257
|
+
const manifest = readManifestSafe(manifestPath);
|
|
258
|
+
const now = new Date().toISOString();
|
|
259
|
+
const existing = manifest.modules.find((m) => m.name === moduleCode);
|
|
260
|
+
if (existing) {
|
|
261
|
+
existing.lastUpdated = now;
|
|
262
|
+
existing.source = 'local';
|
|
263
|
+
existing.npmPackage = null;
|
|
264
|
+
existing.repoUrl = sourcePath;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
manifest.modules.push({
|
|
268
|
+
name: moduleCode,
|
|
269
|
+
version: parsed.data.version ?? '1.0.0',
|
|
270
|
+
installDate: now,
|
|
271
|
+
lastUpdated: now,
|
|
272
|
+
source: 'local',
|
|
273
|
+
npmPackage: null,
|
|
274
|
+
repoUrl: sourcePath,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
278
|
+
if (!wrote.ok)
|
|
279
|
+
throw new ValidationError(wrote.error);
|
|
280
|
+
// Story 15.6 — generate IDE launchers AFTER the manifest is committed.
|
|
281
|
+
// A failure here leaves the user with an installed module + no launchers;
|
|
282
|
+
// recovery is the "Regenerate IDE skills" button (Story 15.8).
|
|
283
|
+
const skillsGenerated = runGenerator(moduleCode);
|
|
284
|
+
app.fileStore.rebuild();
|
|
285
|
+
reply.status(200);
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
modules: [moduleCode],
|
|
289
|
+
filesCopied: { text: copyResult.textCount, binary: copyResult.binaryCount },
|
|
290
|
+
skillsGenerated,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
294
|
+
// github source
|
|
295
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
296
|
+
if (source.type === 'github') {
|
|
297
|
+
// parseGithubSource throws plain Error on invalid input — convert to ValidationError.
|
|
298
|
+
let ghSource;
|
|
299
|
+
try {
|
|
300
|
+
ghSource = parseGithubSource(source.value);
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
304
|
+
}
|
|
305
|
+
// Story 15.9 — check previewCache before downloading. If the user clicked
|
|
306
|
+
// "Fetch" then "Install" in quick succession the tarball is already on disk;
|
|
307
|
+
// reuse it and skip the second download. Consume the cache entry (delete it)
|
|
308
|
+
// so a future preview call re-downloads fresh content.
|
|
309
|
+
const cacheKey = previewCacheKey(ghSource);
|
|
310
|
+
const cachedEntry = getPreviewCacheEntry(cacheKey);
|
|
311
|
+
let extractedRoot;
|
|
312
|
+
let tmpDir;
|
|
313
|
+
let resolvedBranch;
|
|
314
|
+
if (cachedEntry) {
|
|
315
|
+
previewCache.delete(cacheKey); // consumed — install now owns cleanup
|
|
316
|
+
extractedRoot = cachedEntry.extractedRoot;
|
|
317
|
+
tmpDir = cachedEntry.tmpDir;
|
|
318
|
+
resolvedBranch = cachedEntry.resolvedBranch;
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
// No cache hit — download fresh.
|
|
322
|
+
let downloaded;
|
|
323
|
+
try {
|
|
324
|
+
downloaded = await downloadGithubTarball(ghSource);
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
328
|
+
}
|
|
329
|
+
extractedRoot = downloaded.extractedRoot;
|
|
330
|
+
tmpDir = downloaded.tmpDir;
|
|
331
|
+
resolvedBranch = downloaded.resolvedBranch;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
// Navigate to the optional subpath
|
|
335
|
+
const moduleSrc = ghSource.subpath
|
|
336
|
+
? path.join(extractedRoot, ghSource.subpath)
|
|
337
|
+
: extractedRoot;
|
|
338
|
+
if (!isPlausibleModuleDir(moduleSrc)) {
|
|
339
|
+
throw new ValidationError(`${ghSource.owner}/${ghSource.repo}${ghSource.subpath ? `/${ghSource.subpath}` : ''} does not look like a BMAD module`);
|
|
340
|
+
}
|
|
341
|
+
const parsed = parseModuleYaml(moduleSrc);
|
|
342
|
+
if (!parsed.ok)
|
|
343
|
+
throw new ValidationError(parsed.error);
|
|
344
|
+
const moduleCode = parsed.data.code;
|
|
345
|
+
const destDir = path.join(bmadDir, moduleCode);
|
|
346
|
+
// Story 17.3 — clean-slate re-install.
|
|
347
|
+
if (fs.existsSync(destDir)) {
|
|
348
|
+
cleanRemoveModule(moduleCode, bmadDir, manifestPath);
|
|
349
|
+
}
|
|
350
|
+
const copyResult = copyDirThroughWriteService(moduleSrc, destDir, app.fileStore.studioDir, app.fileStore);
|
|
351
|
+
if (!copyResult.ok)
|
|
352
|
+
throw new ValidationError(copyResult.error);
|
|
353
|
+
// Story 15.5 — substitute placeholders before manifest update
|
|
354
|
+
const subResult = runVariableSubstitution(destDir, makeSubContext(moduleCode), app.fileStore.studioDir, app.fileStore);
|
|
355
|
+
if (!subResult.ok)
|
|
356
|
+
throw new ValidationError(subResult.error);
|
|
357
|
+
// Update manifest (guaranteed to exist by the check at the top of the handler)
|
|
358
|
+
const manifest = readManifestSafe(manifestPath);
|
|
359
|
+
const now = new Date().toISOString();
|
|
360
|
+
const repoUrl = `https://github.com/${ghSource.owner}/${ghSource.repo}`;
|
|
361
|
+
const existing = manifest.modules.find((m) => m.name === moduleCode);
|
|
362
|
+
if (existing) {
|
|
363
|
+
existing.lastUpdated = now;
|
|
364
|
+
existing.source = 'github';
|
|
365
|
+
existing.repoUrl = repoUrl;
|
|
366
|
+
existing.npmPackage = null;
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
manifest.modules.push({
|
|
370
|
+
name: moduleCode,
|
|
371
|
+
version: parsed.data.version ?? '1.0.0',
|
|
372
|
+
installDate: now,
|
|
373
|
+
lastUpdated: now,
|
|
374
|
+
source: 'github',
|
|
375
|
+
npmPackage: null,
|
|
376
|
+
repoUrl,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
380
|
+
if (!wrote.ok)
|
|
381
|
+
throw new ValidationError(wrote.error);
|
|
382
|
+
// Story 15.6 — generate IDE launchers after manifest commit
|
|
383
|
+
const skillsGenerated = runGenerator(moduleCode);
|
|
384
|
+
app.fileStore.rebuild();
|
|
385
|
+
reply.status(200);
|
|
386
|
+
return {
|
|
387
|
+
ok: true,
|
|
388
|
+
modules: [moduleCode],
|
|
389
|
+
filesCopied: { text: copyResult.textCount, binary: copyResult.binaryCount },
|
|
390
|
+
source: { type: 'github', value: source.value, branch: resolvedBranch },
|
|
391
|
+
skillsGenerated,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
try {
|
|
396
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
/* ignore cleanup errors */
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
404
|
+
// Guard: reject unknown source types before falling through to npm.
|
|
405
|
+
// The InstallSource union only allows 'npm'|'local'|'github', but the body
|
|
406
|
+
// is untyped at the HTTP boundary — 'zip' or any other string would silently
|
|
407
|
+
// run `npm pack <value>` without this check.
|
|
408
|
+
// Cast to string to sidestep TS control-flow narrowing (source is already
|
|
409
|
+
// narrowed to the npm variant by this point, so source.type === 'npm' always,
|
|
410
|
+
// but this guard provides a runtime safety net for unexpected payloads).
|
|
411
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
412
|
+
const sourceTypeStr = source.type;
|
|
413
|
+
if (sourceTypeStr !== 'npm') {
|
|
414
|
+
throw new ValidationError(`Unknown source type "${sourceTypeStr}". Valid types: local, github, npm. For zip uploads use POST /api/modules/install/upload.`);
|
|
415
|
+
}
|
|
416
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
417
|
+
// npm source — refactored to route writes through WriteService
|
|
418
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
419
|
+
const packageName = source.value;
|
|
420
|
+
// TD-20 — realpathSync resolves the macOS /var → /private/var symlink so
|
|
421
|
+
// path comparisons inside the install loop are stable.
|
|
422
|
+
const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-install-')));
|
|
44
423
|
try {
|
|
45
424
|
// 1. Download the package tarball via npm pack
|
|
46
425
|
execSync(`npm pack ${packageName} --pack-destination ${tmpDir}`, {
|
|
@@ -51,7 +430,7 @@ export async function modulesPlugin(app) {
|
|
|
51
430
|
// 2. Find the tarball file
|
|
52
431
|
const tarballs = fs.readdirSync(tmpDir).filter((f) => f.endsWith('.tgz'));
|
|
53
432
|
if (tarballs.length === 0) {
|
|
54
|
-
|
|
433
|
+
throw new ValidationError('No tarball downloaded');
|
|
55
434
|
}
|
|
56
435
|
const tarballPath = path.join(tmpDir, tarballs[0]);
|
|
57
436
|
// 3. Extract the tarball
|
|
@@ -67,60 +446,95 @@ export async function modulesPlugin(app) {
|
|
|
67
446
|
const packageDir = path.join(extractDir, 'package');
|
|
68
447
|
const bmadSrcDir = path.join(packageDir, '_bmad');
|
|
69
448
|
if (!fs.existsSync(bmadSrcDir)) {
|
|
70
|
-
|
|
71
|
-
ok: false,
|
|
72
|
-
error: `Package "${packageName}" does not contain a _bmad/ directory`,
|
|
73
|
-
});
|
|
449
|
+
throw new ValidationError(`Package "${packageName}" does not contain a _bmad/ directory`);
|
|
74
450
|
}
|
|
75
451
|
// 5. Copy module directories from the package's _bmad/ into the project's _bmad/
|
|
452
|
+
// Routes through WriteService for text files, byte-copies binaries (TD-16).
|
|
76
453
|
const installedModules = [];
|
|
454
|
+
let totalText = 0;
|
|
455
|
+
let totalBinary = 0;
|
|
77
456
|
for (const entry of fs.readdirSync(bmadSrcDir, { withFileTypes: true })) {
|
|
78
457
|
if (entry.isDirectory() && entry.name !== '_config') {
|
|
79
458
|
const srcModuleDir = path.join(bmadSrcDir, entry.name);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
459
|
+
// Read module.yaml for the authoritative module code (AC-15.2.8).
|
|
460
|
+
// Falls back to the directory name if module.yaml is absent or doesn't
|
|
461
|
+
// declare a code field — consistent with local/github/zip branches.
|
|
462
|
+
const parsedMod = parseModuleYaml(srcModuleDir);
|
|
463
|
+
if (!parsedMod.ok)
|
|
464
|
+
throw new ValidationError(parsedMod.error);
|
|
465
|
+
const moduleCode = parsedMod.data.code;
|
|
466
|
+
const destModuleDir = path.join(bmadDir, moduleCode);
|
|
467
|
+
// Story 17.3 — clean-slate re-install.
|
|
468
|
+
if (fs.existsSync(destModuleDir)) {
|
|
469
|
+
cleanRemoveModule(moduleCode, bmadDir, manifestPath);
|
|
470
|
+
}
|
|
471
|
+
const copyResult = copyDirThroughWriteService(srcModuleDir, destModuleDir, app.fileStore.studioDir, app.fileStore);
|
|
472
|
+
if (!copyResult.ok)
|
|
473
|
+
throw new ValidationError(copyResult.error);
|
|
474
|
+
totalText += copyResult.textCount;
|
|
475
|
+
totalBinary += copyResult.binaryCount;
|
|
476
|
+
// Story 15.5 — substitute placeholders in each installed module dir.
|
|
477
|
+
const subResult = runVariableSubstitution(destModuleDir, makeSubContext(moduleCode), app.fileStore.studioDir, app.fileStore);
|
|
478
|
+
if (!subResult.ok)
|
|
479
|
+
throw new ValidationError(subResult.error);
|
|
480
|
+
installedModules.push(moduleCode);
|
|
83
481
|
}
|
|
84
482
|
}
|
|
85
483
|
if (installedModules.length === 0) {
|
|
86
|
-
|
|
87
|
-
ok: false,
|
|
88
|
-
error: `Package "${packageName}" has no module directories in _bmad/`,
|
|
89
|
-
});
|
|
484
|
+
throw new ValidationError(`Package "${packageName}" has no module directories in _bmad/`);
|
|
90
485
|
}
|
|
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
|
-
|
|
486
|
+
// 6. Update manifest.yaml with new module entries (snapshot via WriteService)
|
|
487
|
+
const manifest = readManifestSafe(manifestPath);
|
|
488
|
+
const now = new Date().toISOString();
|
|
489
|
+
for (const moduleName of installedModules) {
|
|
490
|
+
const existing = manifest.modules.find((m) => m.name === moduleName);
|
|
491
|
+
if (existing) {
|
|
492
|
+
existing.lastUpdated = now;
|
|
493
|
+
existing.npmPackage = packageName;
|
|
494
|
+
existing.source = 'npm';
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
manifest.modules.push({
|
|
498
|
+
name: moduleName,
|
|
499
|
+
version: '1.0.0',
|
|
500
|
+
installDate: now,
|
|
501
|
+
lastUpdated: now,
|
|
502
|
+
source: 'npm',
|
|
503
|
+
npmPackage: packageName,
|
|
504
|
+
repoUrl: null,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
509
|
+
if (!wrote.ok)
|
|
510
|
+
throw new ValidationError(wrote.error);
|
|
511
|
+
// Story 15.6 — generate IDE launchers for each installed module.
|
|
512
|
+
// The npm branch installs MULTIPLE modules; sum the per-IDE counts.
|
|
513
|
+
const skillsGenerated = {};
|
|
514
|
+
for (const moduleName of installedModules) {
|
|
515
|
+
const counts = runGenerator(moduleName);
|
|
516
|
+
for (const [ide, count] of Object.entries(counts)) {
|
|
517
|
+
skillsGenerated[ide] = (skillsGenerated[ide] ?? 0) + count;
|
|
113
518
|
}
|
|
114
|
-
writeManifest(manifestPath, manifest);
|
|
115
519
|
}
|
|
116
520
|
// 7. Rebuild file store
|
|
117
521
|
app.fileStore.rebuild();
|
|
118
522
|
reply.status(200);
|
|
119
|
-
return {
|
|
523
|
+
return {
|
|
524
|
+
ok: true,
|
|
525
|
+
modules: installedModules,
|
|
526
|
+
filesCopied: { text: totalText, binary: totalBinary },
|
|
527
|
+
skillsGenerated,
|
|
528
|
+
};
|
|
120
529
|
}
|
|
121
530
|
catch (err) {
|
|
122
|
-
|
|
123
|
-
|
|
531
|
+
// Rethrow known error types so the global handler produces the correct
|
|
532
|
+
// status (422 for ValidationError, 409 for ConflictError). Wrap any
|
|
533
|
+
// unexpected error (e.g. execSync failure) as a ValidationError.
|
|
534
|
+
if (err instanceof ValidationError || err instanceof ConflictError || err instanceof NotFoundError) {
|
|
535
|
+
throw err;
|
|
536
|
+
}
|
|
537
|
+
throw new ValidationError(err instanceof Error ? err.message : 'Installation failed');
|
|
124
538
|
}
|
|
125
539
|
finally {
|
|
126
540
|
// Cleanup temp directory
|
|
@@ -132,6 +546,141 @@ export async function modulesPlugin(app) {
|
|
|
132
546
|
}
|
|
133
547
|
}
|
|
134
548
|
});
|
|
549
|
+
// Multipart zip upload route — Story 15.4. Separate from POST /api/modules/install
|
|
550
|
+
// because multipart bodies and JSON bodies have different shapes inside Fastify;
|
|
551
|
+
// splitting the routes is cleaner than content-type discrimination inside one handler.
|
|
552
|
+
app.post('/api/modules/install/upload', async (request, reply) => {
|
|
553
|
+
if (!('fileStore' in app)) {
|
|
554
|
+
throw new ValidationError('No project detected');
|
|
555
|
+
}
|
|
556
|
+
// Iterate all multipart parts in stream order so the `variables` field is
|
|
557
|
+
// captured regardless of whether it arrives before or after the file part.
|
|
558
|
+
// Using request.parts() instead of request.file() avoids the ordering bug
|
|
559
|
+
// where data.fields is only populated for fields that precede the file.
|
|
560
|
+
let zipBuffer = null;
|
|
561
|
+
let uploadVariables = {};
|
|
562
|
+
for await (const part of request.parts()) {
|
|
563
|
+
if (part.type === 'file') {
|
|
564
|
+
zipBuffer = await part.toBuffer();
|
|
565
|
+
}
|
|
566
|
+
else if (part.type === 'field' && part.fieldname === 'variables') {
|
|
567
|
+
const raw = part.value;
|
|
568
|
+
if (typeof raw === 'string') {
|
|
569
|
+
try {
|
|
570
|
+
const parsed = JSON.parse(raw);
|
|
571
|
+
if (parsed && typeof parsed === 'object') {
|
|
572
|
+
uploadVariables = parsed;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
throw new ValidationError('Invalid `variables` field — expected JSON object');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (!zipBuffer) {
|
|
582
|
+
throw new ValidationError('No zip file uploaded');
|
|
583
|
+
}
|
|
584
|
+
const buffer = zipBuffer;
|
|
585
|
+
const varValidation = validateVariables(uploadVariables);
|
|
586
|
+
if (!varValidation.ok)
|
|
587
|
+
throw new ValidationError(varValidation.error);
|
|
588
|
+
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
589
|
+
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
590
|
+
// Manifest existence guard (AC-15.4.9). Runs BEFORE extractZipUpload so a
|
|
591
|
+
// misconfigured project doesn't waste CPU on adm-zip extraction.
|
|
592
|
+
if (!fs.existsSync(manifestPath)) {
|
|
593
|
+
throw new ValidationError('Cannot install module: missing _bmad/_config/manifest.yaml. Run `npx bmad-method install` to initialise the project first.');
|
|
594
|
+
}
|
|
595
|
+
// extractZipUpload throws plain Error on failure (malformed zip, zip-slip, etc.)
|
|
596
|
+
// Convert to ValidationError so the user sees a 422 with a clean message.
|
|
597
|
+
let extracted;
|
|
598
|
+
try {
|
|
599
|
+
extracted = await extractZipUpload(buffer);
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
603
|
+
}
|
|
604
|
+
const { extractedRoot, tmpDir } = extracted;
|
|
605
|
+
try {
|
|
606
|
+
if (!isPlausibleModuleDir(extractedRoot)) {
|
|
607
|
+
throw new ValidationError('Uploaded zip does not look like a BMAD module');
|
|
608
|
+
}
|
|
609
|
+
const parsed = parseModuleYaml(extractedRoot);
|
|
610
|
+
if (!parsed.ok)
|
|
611
|
+
throw new ValidationError(parsed.error);
|
|
612
|
+
const moduleCode = parsed.data.code;
|
|
613
|
+
const destDir = path.join(bmadDir, moduleCode);
|
|
614
|
+
// Story 17.3 — clean-slate re-install.
|
|
615
|
+
if (fs.existsSync(destDir)) {
|
|
616
|
+
cleanRemoveModule(moduleCode, bmadDir, manifestPath);
|
|
617
|
+
}
|
|
618
|
+
const copyResult = copyDirThroughWriteService(extractedRoot, destDir, app.fileStore.studioDir, app.fileStore);
|
|
619
|
+
if (!copyResult.ok)
|
|
620
|
+
throw new ValidationError(copyResult.error);
|
|
621
|
+
// Story 15.5 — substitute placeholders before manifest update
|
|
622
|
+
const subResult = runVariableSubstitution(destDir, {
|
|
623
|
+
moduleCode,
|
|
624
|
+
projectRoot: app.fileStore.projectRoot,
|
|
625
|
+
outputFolder: readOutputFolder(app.fileStore.projectRoot),
|
|
626
|
+
variables: uploadVariables,
|
|
627
|
+
}, app.fileStore.studioDir, app.fileStore);
|
|
628
|
+
if (!subResult.ok)
|
|
629
|
+
throw new ValidationError(subResult.error);
|
|
630
|
+
// Update manifest (guaranteed to exist by the check at the top of the handler)
|
|
631
|
+
const manifest = readManifestSafe(manifestPath);
|
|
632
|
+
const now = new Date().toISOString();
|
|
633
|
+
const existing = manifest.modules.find((m) => m.name === moduleCode);
|
|
634
|
+
if (existing) {
|
|
635
|
+
existing.lastUpdated = now;
|
|
636
|
+
existing.source = 'zip';
|
|
637
|
+
existing.npmPackage = null;
|
|
638
|
+
existing.repoUrl = null;
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
manifest.modules.push({
|
|
642
|
+
name: moduleCode,
|
|
643
|
+
version: parsed.data.version ?? '1.0.0',
|
|
644
|
+
installDate: now,
|
|
645
|
+
lastUpdated: now,
|
|
646
|
+
source: 'zip',
|
|
647
|
+
npmPackage: null,
|
|
648
|
+
repoUrl: null,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
652
|
+
if (!wrote.ok)
|
|
653
|
+
throw new ValidationError(wrote.error);
|
|
654
|
+
// Story 15.6 — generate IDE launchers after manifest commit
|
|
655
|
+
const updatedManifest = readManifestSafe(manifestPath);
|
|
656
|
+
const skillsGenerated = {};
|
|
657
|
+
if (updatedManifest) {
|
|
658
|
+
const genResult = generateIdeSkillsForModule(app.fileStore.projectRoot, moduleCode, updatedManifest, app.fileStore.studioDir, app.fileStore);
|
|
659
|
+
if (!genResult.ok)
|
|
660
|
+
throw new ValidationError(genResult.error);
|
|
661
|
+
for (const [ide, skills] of Object.entries(genResult.skillsByIde)) {
|
|
662
|
+
skillsGenerated[ide] = skills.length;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
app.fileStore.rebuild();
|
|
666
|
+
reply.status(200);
|
|
667
|
+
return {
|
|
668
|
+
ok: true,
|
|
669
|
+
modules: [moduleCode],
|
|
670
|
+
filesCopied: { text: copyResult.textCount, binary: copyResult.binaryCount },
|
|
671
|
+
source: { type: 'zip' },
|
|
672
|
+
skillsGenerated,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
try {
|
|
677
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
/* ignore cleanup errors */
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
});
|
|
135
684
|
app.post('/api/modules', async (request, reply) => {
|
|
136
685
|
if (!('fileStore' in app)) {
|
|
137
686
|
throw new ValidationError('No project detected');
|
|
@@ -164,10 +713,12 @@ export async function modulesPlugin(app) {
|
|
|
164
713
|
'',
|
|
165
714
|
`project_name: ${name}`,
|
|
166
715
|
].join('\n');
|
|
167
|
-
|
|
716
|
+
const wResult = writeFile(path.join(moduleDir, 'config.yaml'), configContent, app.fileStore.studioDir);
|
|
717
|
+
if (!wResult.ok)
|
|
718
|
+
throw new ValidationError(wResult.error);
|
|
168
719
|
// Update manifest
|
|
169
|
-
|
|
170
|
-
|
|
720
|
+
const manifest = readManifestSafe(manifestPath);
|
|
721
|
+
if (manifest) {
|
|
171
722
|
const now = new Date().toISOString();
|
|
172
723
|
manifest.modules.push({
|
|
173
724
|
name,
|
|
@@ -178,14 +729,20 @@ export async function modulesPlugin(app) {
|
|
|
178
729
|
npmPackage: null,
|
|
179
730
|
repoUrl: null,
|
|
180
731
|
});
|
|
181
|
-
|
|
732
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
733
|
+
if (!wrote.ok)
|
|
734
|
+
throw new ValidationError(wrote.error);
|
|
182
735
|
}
|
|
183
736
|
// Rebuild index
|
|
184
737
|
app.fileStore.rebuild();
|
|
185
738
|
reply.status(201);
|
|
186
|
-
return { ok: true, name
|
|
739
|
+
return { ok: true, name };
|
|
187
740
|
});
|
|
188
|
-
|
|
741
|
+
// Story 15.8 — Regenerate IDE skills for an installed module. Removes all
|
|
742
|
+
// prefix-matched launcher dirs and re-runs the generator against the current
|
|
743
|
+
// module contents. Used by the "Regenerate IDE skills" button on PackagesPage
|
|
744
|
+
// and as the Q9 smoke test for dept-aem.
|
|
745
|
+
app.post('/api/modules/:name/regenerate-skills', async (request) => {
|
|
189
746
|
if (!('fileStore' in app)) {
|
|
190
747
|
throw new ValidationError('No project detected');
|
|
191
748
|
}
|
|
@@ -193,27 +750,199 @@ export async function modulesPlugin(app) {
|
|
|
193
750
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
194
751
|
const moduleDir = path.join(bmadDir, name);
|
|
195
752
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
753
|
+
if (!fs.existsSync(moduleDir)) {
|
|
754
|
+
throw new NotFoundError(`Module "${name}" is not installed`);
|
|
755
|
+
}
|
|
756
|
+
const manifest = readManifestSafe(manifestPath);
|
|
757
|
+
if (!manifest)
|
|
758
|
+
throw new ValidationError('No manifest.yaml found');
|
|
759
|
+
// Remove existing launcher files first (snapshots text content via WriteService)
|
|
760
|
+
const removeResult = removeIdeSkillsForModule(app.fileStore.projectRoot, name, manifest, app.fileStore.studioDir);
|
|
761
|
+
if (!removeResult.ok)
|
|
762
|
+
throw new ValidationError(removeResult.error);
|
|
763
|
+
// Regenerate from the current module contents — picks up any manual edits.
|
|
764
|
+
const genResult = generateIdeSkillsForModule(app.fileStore.projectRoot, name, manifest, app.fileStore.studioDir, app.fileStore);
|
|
765
|
+
if (!genResult.ok)
|
|
766
|
+
throw new ValidationError(genResult.error);
|
|
767
|
+
const regenerated = {};
|
|
768
|
+
for (const [ide, skills] of Object.entries(genResult.skillsByIde)) {
|
|
769
|
+
regenerated[ide] = skills.length;
|
|
770
|
+
}
|
|
771
|
+
app.fileStore.rebuild();
|
|
772
|
+
return { ok: true, regenerated };
|
|
773
|
+
});
|
|
774
|
+
// Story 15.7 — Remove preview. Returns a structured pre-flight summary so the
|
|
775
|
+
// remove dialog (Story 15.9) can render an informed confirmation screen.
|
|
776
|
+
app.get('/api/modules/:name/remove-preview', async (request) => {
|
|
777
|
+
if (!('fileStore' in app)) {
|
|
778
|
+
throw new ValidationError('No project detected');
|
|
779
|
+
}
|
|
780
|
+
const { name } = request.params;
|
|
781
|
+
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
782
|
+
const moduleDir = path.join(bmadDir, name);
|
|
783
|
+
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
784
|
+
const manifest = readManifestSafe(manifestPath);
|
|
785
|
+
const entry = manifest?.modules.find((m) => m.name === name);
|
|
786
|
+
if (!entry)
|
|
787
|
+
throw new NotFoundError(`Module "${name}" not found in manifest`);
|
|
788
|
+
// Built-in modules: blocked
|
|
789
|
+
const removalBlocked = entry.source === 'built-in'
|
|
790
|
+
? `Module "${name}" is built-in and cannot be removed via Studio.`
|
|
791
|
+
: null;
|
|
792
|
+
// Module file count + size
|
|
793
|
+
let fileCount = 0;
|
|
794
|
+
let totalBytes = 0;
|
|
795
|
+
if (fs.existsSync(moduleDir)) {
|
|
796
|
+
const walk = (dir) => {
|
|
797
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
798
|
+
const full = path.join(dir, e.name);
|
|
799
|
+
if (e.isDirectory())
|
|
800
|
+
walk(full);
|
|
801
|
+
else {
|
|
802
|
+
fileCount++;
|
|
803
|
+
totalBytes += fs.statSync(full).size;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
walk(moduleDir);
|
|
808
|
+
}
|
|
809
|
+
// IDE skills (prefix-matched dirs under each configured IDE output dir)
|
|
810
|
+
const ideSkills = {};
|
|
811
|
+
if (manifest?.ides) {
|
|
812
|
+
for (const ide of manifest.ides) {
|
|
813
|
+
if (ide !== 'claude-code' && ide !== 'antigravity')
|
|
814
|
+
continue;
|
|
815
|
+
const dir = path.join(app.fileStore.projectRoot, ide === 'claude-code' ? '.claude/skills' : '.antigravity/skills');
|
|
816
|
+
if (!fs.existsSync(dir)) {
|
|
817
|
+
ideSkills[ide] = [];
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const agentPrefix = `bmad-agent-${name}-`;
|
|
821
|
+
const otherPrefix = `bmad-${name}-`;
|
|
822
|
+
ideSkills[ide] = fs
|
|
823
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
824
|
+
.filter((e) => e.isDirectory() &&
|
|
825
|
+
(e.name.startsWith(agentPrefix) || e.name.startsWith(otherPrefix)))
|
|
826
|
+
.map((e) => e.name);
|
|
202
827
|
}
|
|
203
|
-
|
|
204
|
-
|
|
828
|
+
}
|
|
829
|
+
// Preserved directories from module.yaml (declared output dirs)
|
|
830
|
+
const parsed = parseModuleYaml(moduleDir);
|
|
831
|
+
const preservedDirectories = [];
|
|
832
|
+
let moduleYamlPresent = false;
|
|
833
|
+
if (parsed.ok && fs.existsSync(path.join(moduleDir, 'module.yaml'))) {
|
|
834
|
+
moduleYamlPresent = true;
|
|
835
|
+
for (const declared of parsed.data.directories ?? []) {
|
|
836
|
+
const resolved = path.isAbsolute(declared)
|
|
837
|
+
? declared
|
|
838
|
+
: path.join(app.fileStore.projectRoot, declared);
|
|
839
|
+
if (fs.existsSync(resolved)) {
|
|
840
|
+
preservedDirectories.push({ path: resolved, declared: true });
|
|
841
|
+
}
|
|
205
842
|
}
|
|
206
|
-
// Remove from manifest
|
|
207
|
-
manifest.modules = manifest.modules.filter((m) => m.name !== name);
|
|
208
|
-
writeManifest(manifestPath, manifest);
|
|
209
843
|
}
|
|
210
|
-
//
|
|
844
|
+
// Cross-references (TD-19 scope)
|
|
845
|
+
const crossReferences = findCrossReferences(app.fileStore.getIndex(), name);
|
|
846
|
+
// External-installer warning
|
|
847
|
+
const externalInstallerWarning = entry.source === 'external'
|
|
848
|
+
? `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.`
|
|
849
|
+
: null;
|
|
850
|
+
return {
|
|
851
|
+
module: { name: entry.name, version: entry.version, source: entry.source },
|
|
852
|
+
moduleFiles: { count: fileCount, totalBytes },
|
|
853
|
+
ideSkills,
|
|
854
|
+
manifestEntries: { 'manifest.yaml': true },
|
|
855
|
+
preservedDirectories,
|
|
856
|
+
moduleYamlPresent,
|
|
857
|
+
crossReferences,
|
|
858
|
+
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.',
|
|
859
|
+
recoverableFrom: '.bmad-studio/history/',
|
|
860
|
+
removalBlocked,
|
|
861
|
+
externalInstallerWarning,
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
// Story 15.7 — Rich remove handler. Replaces the thin DELETE. Removes IDE
|
|
865
|
+
// launcher files, then deletes the module dir through WriteService (text
|
|
866
|
+
// files snapshot to history/ before unlink), then removes the manifest entry.
|
|
867
|
+
// Preserves directories declared in module.yaml.directories[].
|
|
868
|
+
app.delete('/api/modules/:name', async (request) => {
|
|
869
|
+
if (!('fileStore' in app)) {
|
|
870
|
+
throw new ValidationError('No project detected');
|
|
871
|
+
}
|
|
872
|
+
const { name } = request.params;
|
|
873
|
+
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
874
|
+
const moduleDir = path.join(bmadDir, name);
|
|
875
|
+
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
876
|
+
const manifest = readManifestSafe(manifestPath);
|
|
877
|
+
const entry = manifest?.modules.find((m) => m.name === name);
|
|
878
|
+
if (!entry)
|
|
879
|
+
throw new NotFoundError(`Module "${name}" not found in manifest`);
|
|
880
|
+
if (entry.source === 'built-in') {
|
|
881
|
+
throw new ValidationError(`Cannot remove built-in module "${name}"`);
|
|
882
|
+
}
|
|
883
|
+
// Read module.yaml BEFORE deletion so we can capture directories: to preserve.
|
|
884
|
+
const parsed = parseModuleYaml(moduleDir);
|
|
885
|
+
const preserved = parsed.ok
|
|
886
|
+
? (parsed.data.directories ?? []).map((d) => path.isAbsolute(d) ? d : path.join(app.fileStore.projectRoot, d))
|
|
887
|
+
: [];
|
|
888
|
+
// 1. Remove prefix-matched IDE skill directories (each deletion snapshots through WriteService)
|
|
889
|
+
let removedSkills = {};
|
|
890
|
+
if (manifest) {
|
|
891
|
+
const r = removeIdeSkillsForModule(app.fileStore.projectRoot, name, manifest, app.fileStore.studioDir);
|
|
892
|
+
if (!r.ok)
|
|
893
|
+
throw new ValidationError(r.error);
|
|
894
|
+
removedSkills = r.removedByIde;
|
|
895
|
+
}
|
|
896
|
+
// 2. Delete the module directory through WriteService (text files snapshot first).
|
|
897
|
+
// Count files before delete so the response summary is accurate.
|
|
898
|
+
let filesRemoved = 0;
|
|
211
899
|
if (fs.existsSync(moduleDir)) {
|
|
212
|
-
|
|
900
|
+
const walk = (dir) => {
|
|
901
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
902
|
+
const full = path.join(dir, e.name);
|
|
903
|
+
if (e.isDirectory())
|
|
904
|
+
walk(full);
|
|
905
|
+
else
|
|
906
|
+
filesRemoved++;
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
walk(moduleDir);
|
|
910
|
+
const r = deleteDirectory(moduleDir, app.fileStore.studioDir);
|
|
911
|
+
if (!r.ok)
|
|
912
|
+
throw new ValidationError(r.error);
|
|
213
913
|
}
|
|
214
|
-
//
|
|
914
|
+
// 3. Update the manifest (snapshot via WriteService)
|
|
915
|
+
if (manifest) {
|
|
916
|
+
manifest.modules = manifest.modules.filter((m) => m.name !== name);
|
|
917
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
918
|
+
if (!wrote.ok)
|
|
919
|
+
throw new ValidationError(wrote.error);
|
|
920
|
+
}
|
|
921
|
+
// 4. Rebuild index and broadcast the change
|
|
215
922
|
app.fileStore.rebuild();
|
|
216
|
-
|
|
923
|
+
if ('ws' in app) {
|
|
924
|
+
try {
|
|
925
|
+
;
|
|
926
|
+
app.ws.broadcast({
|
|
927
|
+
type: 'file:deleted',
|
|
928
|
+
path: moduleDir,
|
|
929
|
+
category: 'config',
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
/* ignore — broadcast is best-effort */
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return {
|
|
937
|
+
ok: true,
|
|
938
|
+
name,
|
|
939
|
+
removed: {
|
|
940
|
+
filesRemoved,
|
|
941
|
+
skills: removedSkills,
|
|
942
|
+
},
|
|
943
|
+
preservedDirectories: preserved,
|
|
944
|
+
recoverableFrom: '.bmad-studio/history/',
|
|
945
|
+
};
|
|
217
946
|
});
|
|
218
947
|
// Story 12.1 + 12.2: Add/upload entities to a module
|
|
219
948
|
app.post('/api/modules/:name/entities', async (request, reply) => {
|
|
@@ -221,6 +950,9 @@ export async function modulesPlugin(app) {
|
|
|
221
950
|
throw new ValidationError('No project detected');
|
|
222
951
|
}
|
|
223
952
|
const { name } = request.params;
|
|
953
|
+
if (!MODULE_NAME_RE.test(name)) {
|
|
954
|
+
throw new ValidationError('Module name must be lowercase alphanumeric with hyphens');
|
|
955
|
+
}
|
|
224
956
|
const body = request.body;
|
|
225
957
|
const entityType = body.type;
|
|
226
958
|
const entityName = body.name?.trim();
|
|
@@ -258,7 +990,9 @@ export async function modulesPlugin(app) {
|
|
|
258
990
|
'<!-- Add skill instructions here -->',
|
|
259
991
|
'',
|
|
260
992
|
].join('\n');
|
|
261
|
-
|
|
993
|
+
const wResult = writeFile(filePath, content, app.fileStore.studioDir);
|
|
994
|
+
if (!wResult.ok)
|
|
995
|
+
throw new ValidationError(wResult.error);
|
|
262
996
|
}
|
|
263
997
|
else if (entityType === 'workflow') {
|
|
264
998
|
// Workflows get their own directory with a workflow.md file
|
|
@@ -282,7 +1016,9 @@ export async function modulesPlugin(app) {
|
|
|
282
1016
|
'<!-- Add workflow steps here -->',
|
|
283
1017
|
'',
|
|
284
1018
|
].join('\n');
|
|
285
|
-
|
|
1019
|
+
const wResult = writeFile(filePath, content, app.fileStore.studioDir);
|
|
1020
|
+
if (!wResult.ok)
|
|
1021
|
+
throw new ValidationError(wResult.error);
|
|
286
1022
|
}
|
|
287
1023
|
else {
|
|
288
1024
|
// Agent: .md file in agents/ directory
|
|
@@ -305,12 +1041,14 @@ export async function modulesPlugin(app) {
|
|
|
305
1041
|
'<!-- Add agent definition here -->',
|
|
306
1042
|
'',
|
|
307
1043
|
].join('\n');
|
|
308
|
-
|
|
1044
|
+
const wResult = writeFile(filePath, content, app.fileStore.studioDir);
|
|
1045
|
+
if (!wResult.ok)
|
|
1046
|
+
throw new ValidationError(wResult.error);
|
|
309
1047
|
}
|
|
310
1048
|
// Rebuild index
|
|
311
1049
|
app.fileStore.rebuild();
|
|
312
1050
|
reply.status(201);
|
|
313
|
-
return { ok: true, type: entityType, name: entityName
|
|
1051
|
+
return { ok: true, type: entityType, name: entityName };
|
|
314
1052
|
});
|
|
315
1053
|
// Story 12.3: Export module manifest
|
|
316
1054
|
app.post('/api/modules/:name/export', async (request) => {
|
|
@@ -320,10 +1058,10 @@ export async function modulesPlugin(app) {
|
|
|
320
1058
|
const { name } = request.params;
|
|
321
1059
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
322
1060
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
323
|
-
|
|
1061
|
+
const manifest = readManifestSafe(manifestPath);
|
|
1062
|
+
if (!manifest) {
|
|
324
1063
|
throw new NotFoundError('No manifest found');
|
|
325
1064
|
}
|
|
326
|
-
const manifest = readManifest(manifestPath);
|
|
327
1065
|
const entry = manifest.modules.find((m) => m.name === name);
|
|
328
1066
|
if (!entry) {
|
|
329
1067
|
throw new NotFoundError(`Module "${name}" not found in manifest`);
|
|
@@ -361,6 +1099,9 @@ export async function modulesPlugin(app) {
|
|
|
361
1099
|
throw new ValidationError('No project detected');
|
|
362
1100
|
}
|
|
363
1101
|
const { name } = request.params;
|
|
1102
|
+
if (!MODULE_NAME_RE.test(name)) {
|
|
1103
|
+
throw new ValidationError('Module name must be lowercase alphanumeric with hyphens');
|
|
1104
|
+
}
|
|
364
1105
|
const body = request.body;
|
|
365
1106
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
366
1107
|
const moduleDir = path.join(bmadDir, name);
|
|
@@ -369,23 +1110,27 @@ export async function modulesPlugin(app) {
|
|
|
369
1110
|
throw new NotFoundError(`Module "${name}" config not found`);
|
|
370
1111
|
}
|
|
371
1112
|
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
372
|
-
const config = yaml.load(configContent);
|
|
1113
|
+
const config = yaml.load(configContent) ?? {};
|
|
373
1114
|
if (body.version !== undefined)
|
|
374
1115
|
config.version = body.version;
|
|
375
1116
|
if (body.description !== undefined)
|
|
376
1117
|
config.description = body.description;
|
|
377
1118
|
const updated = yaml.dump(config, { lineWidth: -1 });
|
|
378
|
-
|
|
1119
|
+
const wResult = writeFile(configPath, updated, app.fileStore.studioDir);
|
|
1120
|
+
if (!wResult.ok)
|
|
1121
|
+
throw new ValidationError(wResult.error);
|
|
379
1122
|
// Also update manifest.yaml
|
|
380
1123
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
381
|
-
|
|
382
|
-
|
|
1124
|
+
const manifest = readManifestSafe(manifestPath);
|
|
1125
|
+
if (manifest) {
|
|
383
1126
|
const mod = manifest.modules.find((m) => m.name === name);
|
|
384
1127
|
if (mod) {
|
|
385
1128
|
if (body.version !== undefined)
|
|
386
1129
|
mod.version = body.version;
|
|
387
|
-
mod.lastUpdated = new Date().toISOString()
|
|
388
|
-
|
|
1130
|
+
mod.lastUpdated = new Date().toISOString();
|
|
1131
|
+
const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
|
|
1132
|
+
if (!wrote.ok)
|
|
1133
|
+
throw new ValidationError(wrote.error);
|
|
389
1134
|
}
|
|
390
1135
|
}
|
|
391
1136
|
app.fileStore.rebuild();
|
|
@@ -397,10 +1142,10 @@ export async function modulesPlugin(app) {
|
|
|
397
1142
|
}
|
|
398
1143
|
const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
|
|
399
1144
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
400
|
-
|
|
1145
|
+
const manifest = readManifestSafe(manifestPath);
|
|
1146
|
+
if (!manifest) {
|
|
401
1147
|
return [];
|
|
402
1148
|
}
|
|
403
|
-
const manifest = readManifest(manifestPath);
|
|
404
1149
|
const index = app.fileStore.getIndex();
|
|
405
1150
|
return manifest.modules.map((m) => {
|
|
406
1151
|
const moduleAgents = index.agents.filter((a) => a.module === m.name);
|
|
@@ -430,6 +1175,9 @@ export async function modulesPlugin(app) {
|
|
|
430
1175
|
if (!name) {
|
|
431
1176
|
throw new ValidationError('Package name is required');
|
|
432
1177
|
}
|
|
1178
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) {
|
|
1179
|
+
throw new ValidationError('Package name must start with a letter or digit and contain only alphanumeric characters, hyphens, or underscores');
|
|
1180
|
+
}
|
|
433
1181
|
const description = body.description?.trim() || '';
|
|
434
1182
|
const version = body.version?.trim() || '1.0.0';
|
|
435
1183
|
const agentIds = body.agents ?? [];
|
|
@@ -492,7 +1240,10 @@ export async function modulesPlugin(app) {
|
|
|
492
1240
|
fs.writeFileSync(path.join(packageDir, 'package.yaml'), yaml.dump(packageManifest, { lineWidth: -1 }), 'utf-8');
|
|
493
1241
|
// Create tar.gz archive
|
|
494
1242
|
const archivePath = path.join(tmpBase, `${name}.tar.gz`);
|
|
495
|
-
|
|
1243
|
+
const tarResult = spawnSync('tar', ['-czf', archivePath, '-C', tmpBase, name], { stdio: 'pipe' });
|
|
1244
|
+
if (tarResult.status !== 0) {
|
|
1245
|
+
throw new ValidationError('Failed to create package archive');
|
|
1246
|
+
}
|
|
496
1247
|
// Send as downloadable file
|
|
497
1248
|
reply.header('Content-Disposition', `attachment; filename="${name}.tar.gz"`);
|
|
498
1249
|
reply.type('application/gzip');
|
|
@@ -527,5 +1278,59 @@ export async function modulesPlugin(app) {
|
|
|
527
1278
|
throw new ValidationError(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
528
1279
|
}
|
|
529
1280
|
});
|
|
1281
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1282
|
+
// Story 16.2 — Registry index endpoints
|
|
1283
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1284
|
+
app.get('/api/registry', async (_request, reply) => {
|
|
1285
|
+
if (!('fileStore' in app)) {
|
|
1286
|
+
return reply.send({ ok: false, configured: false, error: 'No project detected' });
|
|
1287
|
+
}
|
|
1288
|
+
const settingsPath = path.join(app.fileStore.studioDir, 'settings.json');
|
|
1289
|
+
if (!fs.existsSync(settingsPath)) {
|
|
1290
|
+
return reply.send({ ok: false, configured: false, error: 'No registry configured' });
|
|
1291
|
+
}
|
|
1292
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
1293
|
+
if (!settings.registry?.repo) {
|
|
1294
|
+
return reply.send({ ok: false, configured: false, error: 'No registry configured' });
|
|
1295
|
+
}
|
|
1296
|
+
let ghSource;
|
|
1297
|
+
try {
|
|
1298
|
+
ghSource = parseGithubSource(settings.registry.repo);
|
|
1299
|
+
}
|
|
1300
|
+
catch (err) {
|
|
1301
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
1302
|
+
}
|
|
1303
|
+
let cached = readCachedRegistryIndex(app.fileStore.studioDir, ghSource.owner, ghSource.repo);
|
|
1304
|
+
if (!cached || isRegistryCacheStale(cached)) {
|
|
1305
|
+
try {
|
|
1306
|
+
cached = await fetchAndCacheRegistryIndex(app.fileStore.studioDir, settings.registry.repo, settings.registry.branch ?? 'main');
|
|
1307
|
+
}
|
|
1308
|
+
catch (err) {
|
|
1309
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return reply.send({ ok: true, configured: true, index: cached });
|
|
1313
|
+
});
|
|
1314
|
+
app.post('/api/registry/refresh', async (_request, reply) => {
|
|
1315
|
+
if (!('fileStore' in app)) {
|
|
1316
|
+
throw new ValidationError('No project detected');
|
|
1317
|
+
}
|
|
1318
|
+
const settingsPath = path.join(app.fileStore.studioDir, 'settings.json');
|
|
1319
|
+
if (!fs.existsSync(settingsPath)) {
|
|
1320
|
+
throw new ValidationError('No registry configured');
|
|
1321
|
+
}
|
|
1322
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
1323
|
+
if (!settings.registry?.repo) {
|
|
1324
|
+
throw new ValidationError('No registry configured');
|
|
1325
|
+
}
|
|
1326
|
+
let index;
|
|
1327
|
+
try {
|
|
1328
|
+
index = await fetchAndCacheRegistryIndex(app.fileStore.studioDir, settings.registry.repo, settings.registry.branch ?? 'main');
|
|
1329
|
+
}
|
|
1330
|
+
catch (err) {
|
|
1331
|
+
throw new ValidationError(err instanceof Error ? err.message : String(err));
|
|
1332
|
+
}
|
|
1333
|
+
return reply.send({ ok: true, index });
|
|
1334
|
+
});
|
|
530
1335
|
}
|
|
531
1336
|
//# sourceMappingURL=modules-plugin.js.map
|