Files
BMAD-METHOD/tools/bundle-web-bundles.js
Brian 2b76d03316 feat(web-bundles): release packager + manifest for bmadcode.com/web-bundles/ (#2424)
* feat(web-bundles): add release packager + bundle manifest

Adds the infrastructure for shipping web bundles as downloadable ZIPs
attached to a GitHub Release, consumed by the upcoming
bmadcode.com/web-bundles/ page.

- web-bundles/bundles.json — manifest with persona, tagline, description,
  accent color, motif key, knowledge files, and feature flags
  (web-browsing, deep-research, stitch integration) for each of the 6
  bundles. Top-level releaseTag and downloadUrlPattern so the
  consuming page can construct download URLs without hardcoding.
- tools/bundle-web-bundles.js — packager that zips each bundle dir into
  dist/web-bundles/{slug}.zip and prints the gh release create command.
  Zero dependencies; uses system zip.
- .gitignore — exclude dist/web-bundles/ build artifacts.

The web-bundles-v1.0.0 release on GitHub is currently in draft state
with the 6 zips attached; it'll be published in coordination with the
Ghost site page going live.

* fix(web-bundles): single-source release tag, sharper bundle copy

- Remove downloadUrlPattern from bundles.json — the consuming page
  derives the URL from releaseTag, so version bumps now touch one
  field instead of two.
- product-brief-coach: drop "one-page" (briefs are whatever length
  the product earns).
- brainstorming-coach: real numbers — 60 techniques across 10
  categories — with concrete examples (SCAMPER, Drunk History
  Retelling, Nature's Solutions, Six Thinking Hats, etc.) so the
  card actually communicates the surprising breadth.

* fix(web-bundles): harden release script per PR review

- Verify the zip CLI is on PATH up front with a clear install
  hint, instead of crashing mid-zip with an opaque execSync error.
- Wrap JSON.parse in try/catch; validate the manifest shape (bundles
  array non-empty, releaseTag present, slug present per entry) before
  trying to package, so config errors fail with a targeted message.
- Catch zip failures per-bundle and surface the failing slug.
- Refuse to print the gh release command when zero bundles were
  packaged (would otherwise mislead the user into creating an empty
  release).
- Derive --title from manifest.releaseTag so the printed command can
  never drift from the actual tag (was previously hardcoded
  "Web Bundles v1" while the tag had moved to v1.0.0).
- Remove the stale `web-bundles-v1` example from the file header.

Addresses augmentcode bot review comments on PR #2424.

* docs(web-bundles): rewrite copy to actually sell what each bundle does

The JSON drives the bmadcode.com/web-bundles/ page; previous copy
was generic and undersold the actual capabilities. Rewrote each
tagline + description to lead with concrete, differentiating facts
pulled directly from each bundle's SKILL.md:

- Brainstorming Coach: 60 techniques across 10 categories with
  specific names (SCAMPER, Drunk History Retelling, Nature's
  Solutions, Shadow Work Mining, Superposition Collapse); calls
  out the 4 routes (browse, recommend, random, progressive) and
  the ~100-idea quantity-unlocks-quality target.
- Product Brief Coach: names the three intent modes (Create /
  Update / Validate) and the two working paths (Fast / Coaching);
  surfaces the [ASSUMPTION] tag system and the Addendum.
- PRFAQ Coach: details the 4 stages (Ignition / Press Release /
  Customer FAQ / Internal FAQ + Verdict), the 9 press release
  sections, the weasel-word list ("best-in-class", "seamless"),
  and that it adapts for commercial, internal, OSS, community.
- PRD Coach: spells out the two entry points (Vision+Features
  vs Journey-led), named-protagonist journeys, glossary
  discipline, stable ID system (FR-1..N, SM-C1..N), and the
  7-dimension validation rubric.
- UX Coach: leads with the two-spine contract (DESIGN.md +
  EXPERIENCE.md), Don Norman framing, named-protagonist
  journeys, surface closure as the test, and Stitch integration.
- Market & Industry Research: leads with Deep Research as the
  engine, names Porter and Christensen as anchors, lists the 6
  deliverable sections, and frames the deliverable as synthesis
  not a research dump.

* fix(web-bundles): security hardening + strict bundle validation

Two issues raised by coderabbit on the latest commit:

1. Shell injection surface: execSync was building the zip command
   with a template literal that interpolated bundle.slug from JSON.
   Even with our controlled inputs, a slug with shell metacharacters
   would break quoting. Switched to execFileSync with an argument
   array (no shell) and added a strict ^[a-z0-9][a-z0-9-]*$ slug
   regex enforced before any FS or zip call.

2. Missing bundle directories were [SKIP]-warned but the script
   still printed the release command, allowing an incomplete release
   to ship cleanly. Now treated as fatal: any missing or invalid slug
   blocks the printed gh command and exits non-zero with the offending
   slugs listed.
2026-05-25 11:43:55 -05:00

118 lines
3.8 KiB
JavaScript

/**
* Web Bundle Release Packager
*
* Zips each bundle under web-bundles/ into dist/web-bundles/{slug}.zip
* for attachment to a GitHub Release.
*
* Usage:
* node tools/bundle-web-bundles.js
*
* After running, the script prints the exact `gh release create` command
* (with the correct tag from bundles.json) for you to copy.
*/
const fs = require('node:fs');
const path = require('node:path');
const { execSync, execFileSync } = require('node:child_process');
const REPO_ROOT = path.resolve(__dirname, '..');
const BUNDLES_DIR = path.join(REPO_ROOT, 'web-bundles');
const DIST_DIR = path.join(REPO_ROOT, 'dist', 'web-bundles');
const MANIFEST = path.join(BUNDLES_DIR, 'bundles.json');
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
function fail(msg) {
console.error(`[ERROR] ${msg}`);
process.exit(1);
}
function requireZipCli() {
try {
execSync('zip -v', { stdio: 'ignore' });
} catch {
fail("'zip' CLI not found on PATH. Install zip (macOS: preinstalled; Debian/Ubuntu: apt install zip; Alpine: apk add zip) and re-run.");
}
}
function loadManifest() {
if (!fs.existsSync(MANIFEST)) {
fail(`bundles.json not found at ${MANIFEST}`);
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf-8'));
} catch (error) {
fail(`bundles.json is not valid JSON: ${error.message}`);
}
if (!Array.isArray(manifest.bundles) || manifest.bundles.length === 0) {
fail('bundles.json is missing a non-empty "bundles" array.');
}
if (typeof manifest.releaseTag !== 'string' || !manifest.releaseTag) {
fail('bundles.json is missing "releaseTag".');
}
return manifest;
}
function main() {
requireZipCli();
const manifest = loadManifest();
const releaseTag = manifest.releaseTag;
fs.mkdirSync(DIST_DIR, { recursive: true });
console.log(`Packaging ${manifest.bundles.length} bundles for release ${releaseTag}\n`);
const zipped = [];
const missing = [];
const invalid = [];
for (const bundle of manifest.bundles) {
if (!bundle.slug || !SLUG_RE.test(bundle.slug)) {
invalid.push(bundle.slug || '(no slug)');
console.error(` [INVALID] slug must match ${SLUG_RE} — got: ${bundle.slug}`);
continue;
}
const src = path.join(BUNDLES_DIR, bundle.slug);
if (!fs.existsSync(src)) {
missing.push(bundle.slug);
console.error(` [MISSING] ${bundle.slug} — directory not found`);
continue;
}
const out = path.join(DIST_DIR, `${bundle.slug}.zip`);
if (fs.existsSync(out)) fs.unlinkSync(out);
try {
execFileSync('zip', ['-r', '-X', '-q', out, bundle.slug, '-x', '*.DS_Store'], {
cwd: BUNDLES_DIR,
stdio: 'inherit',
});
} catch (error) {
fail(`zip failed for ${bundle.slug}: ${error.message}`);
}
const size = (fs.statSync(out).size / 1024).toFixed(1);
console.log(` [OK] ${bundle.slug}.zip (${size} KB)`);
zipped.push(bundle.slug);
}
if (invalid.length > 0) {
fail(`Refusing to publish: ${invalid.length} bundle(s) have invalid slugs: ${invalid.join(', ')}`);
}
if (missing.length > 0) {
fail(`Refusing to publish an incomplete release: missing directories for ${missing.join(', ')}`);
}
if (zipped.length === 0) {
fail('No bundles were packaged. Check bundles.json against web-bundles/ subdirectories.');
}
console.log(`\nWrote ${zipped.length} bundles to ${path.relative(REPO_ROOT, DIST_DIR)}/`);
console.log('\nNext step — create or update the GitHub Release:\n');
console.log(` gh release create ${releaseTag} dist/web-bundles/*.zip \\`);
console.log(` --title "${releaseTag}" \\`);
console.log(` --notes "BMad web bundles for Gemini Gems and ChatGPT Custom GPTs. See https://bmadcode.com/web-bundles/"\n`);
console.log('Or, to refresh an existing release:\n');
console.log(` gh release upload ${releaseTag} dist/web-bundles/*.zip --clobber\n`);
}
main();