From a8523686fdf65800c34dce2308be345af8e56e1a Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:34:33 -0400 Subject: [PATCH 01/14] feat(otters): add postinstall to install agents to ~/.code_puppy/agents/ (#289) * feat(otters): add postinstall to install agents to ~/.code_puppy/agents/ * fix(cli): update @stackwright/otters version to ^0.2.0-alpha.0 --- .changeset/otters-postinstall.md | 11 ++++ packages/cli/src/utils/template-processor.ts | 2 +- packages/otters/.code-puppy.json | 10 ++- packages/otters/README.md | 64 +++++++++----------- packages/otters/package.json | 8 ++- packages/otters/scripts/install-agents.js | 54 +++++++++++++++++ 6 files changed, 109 insertions(+), 40 deletions(-) create mode 100644 .changeset/otters-postinstall.md create mode 100755 packages/otters/scripts/install-agents.js diff --git a/.changeset/otters-postinstall.md b/.changeset/otters-postinstall.md new file mode 100644 index 00000000..34203416 --- /dev/null +++ b/.changeset/otters-postinstall.md @@ -0,0 +1,11 @@ +--- +"@stackwright/otters": minor +--- + +Add postinstall script to install otters to ~/.code_puppy/agents/ + +- Created scripts/install-agents.js that copies agent JSON files to ~/.code_puppy/agents/ +- Updated package.json with postinstall hook +- Updated README with installation instructions +- Fixed .code-puppy.json config (removed agents_path) +- Bumped version to 0.2.0-alpha.1 diff --git a/packages/cli/src/utils/template-processor.ts b/packages/cli/src/utils/template-processor.ts index 5897a501..a08a781a 100644 --- a/packages/cli/src/utils/template-processor.ts +++ b/packages/cli/src/utils/template-processor.ts @@ -208,7 +208,7 @@ function buildPackageJson(projectName: string, useWorkspaceDeps: boolean = false swIcons: '^0.3.0', swBuildScripts: '^0.4.0', swUiShadcn: '^0.1.0', - swOtters: '^0.1.0', + swOtters: '^0.2.0-alpha.0', // Third-party jsYaml: '^4.1.1', next: '^16.1.6', diff --git a/packages/otters/.code-puppy.json b/packages/otters/.code-puppy.json index ff612b81..3e51f723 100644 --- a/packages/otters/.code-puppy.json +++ b/packages/otters/.code-puppy.json @@ -1,3 +1,11 @@ { - "agents_path": "src" + "mcpServers": [ + { + "name": "stackwright-mcp", + "command": "pnpm", + "args": ["exec", "stackwright-mcp"], + "autoStart": true, + "workingDirectory": "${PROJECT_ROOT}" + } + ] } diff --git a/packages/otters/README.md b/packages/otters/README.md index 684ea8ee..4fb3d8ae 100644 --- a/packages/otters/README.md +++ b/packages/otters/README.md @@ -6,6 +6,24 @@ A coordinated team of specialized AI agents (otters) that work together to build --- +## Installation + +```bash +npm install @stackwright/otters +# or +pnpm add @stackwright/otters +``` + +The postinstall script automatically installs otters to `~/.code_puppy/agents/` for code-puppy discovery. + +If you need to re-run the installation: + +```bash +node node_modules/@stackwright/otters/scripts/install-agents.js +``` + +--- + ## The Otter Raft | Otter | Role | Output | @@ -57,52 +75,25 @@ User Request USER ``` -### Installing as an npm Package +### Invoking Otters -Unlike traditional template-based approaches, this package is **installed via npm** and referenced directly: +Otters are invoked through Code Puppy's agent invocation: ```bash -npm install @stackwright/otters -``` +# Start a full site build +code-puppy -i -a stackwright-foreman-otter -Agents are loaded by Code Puppy using the `.code-puppy.json` configuration in this package: +# Just refine the theme +code-puppy -i -a stackwright-theme-otter -```json -{ - "agents_path": "src" -} -``` - -### Invoking Otters - -Otters are invoked through Code Puppy's `invoke_agent` tool: - -```typescript -// Start a full site build -await invoke_agent({ - agent_name: "stackwright-foreman-otter", - prompt: "Build me a law firm website" -}); - -// Just refine the theme -await invoke_agent({ - agent_name: "stackwright-theme-otter", - prompt: "Update the color palette to be warmer" -}); - -// Add a new page -await invoke_agent({ - agent_name: "stackwright-page-otter", - prompt: "Add a pricing page" -}); +# Add a new page +code-puppy -i -a stackwright-page-otter ``` --- ## File-Based Handoffs -Otters communicate through files: - | File | Created By | Read By | |------|-----------|---------| | `BRAND_BRIEF.md` | Brand Otter | Theme Otter, Page Otter | @@ -141,9 +132,10 @@ For detailed architecture documentation, see [OTTER_ARCHITECTURE.md](../../OTTER @stackwright/otters/ ├── package.json ├── tsconfig.json -├── .code-puppy.json # agents_path: "src" ├── README.md ├── AGENTS.md # Agent reference +├── scripts/ +│ └── install-agents.js # Postinstall script └── src/ ├── stackwright-brand-otter.json ├── stackwright-foreman-otter.json diff --git a/packages/otters/package.json b/packages/otters/package.json index 4005f5f1..c46f129b 100644 --- a/packages/otters/package.json +++ b/packages/otters/package.json @@ -1,14 +1,18 @@ { "name": "@stackwright/otters", - "version": "0.2.0-alpha.0", + "version": "0.2.0-alpha.1", "description": "Stackwright Otter Raft - AI agents for site generation", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/Per-Aspera-LLC/stackwright" }, + "scripts": { + "postinstall": "node scripts/install-agents.js" + }, "files": [ - "src" + "src", + "scripts" ], "exports": { "./*": "./src/*" diff --git a/packages/otters/scripts/install-agents.js b/packages/otters/scripts/install-agents.js new file mode 100755 index 00000000..e611ee26 --- /dev/null +++ b/packages/otters/scripts/install-agents.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * Postinstall script for @stackwright/otters + * Copies agent JSON files to ~/.code_puppy/agents/ for code-puppy discovery + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Get the package root - this script is at scripts/install-agents.js +// so we go up two directories +const scriptDir = __dirname; +const packageRoot = path.resolve(scriptDir, '..', '..'); + +const AGENTS_DIR = path.join(os.homedir(), '.code_puppy', 'agents'); + +async function installAgents() { + try { + // Ensure ~/.code_puppy/agents/ exists + await fs.promises.mkdir(AGENTS_DIR, { recursive: true }); + + // Copy all JSON files from src/ to ~/.code_puppy/agents/ + const srcDir = path.join(packageRoot, 'src'); + + console.log(`Installing otters from: ${srcDir}`); + + const files = await fs.promises.readdir(srcDir); + + let installed = 0; + for (const file of files) { + if (file.endsWith('-otter.json')) { + const srcPath = path.join(srcDir, file); + const destPath = path.join(AGENTS_DIR, file); + + await fs.promises.copyFile(srcPath, destPath); + console.log(`✅ Installed: ${file}`); + installed++; + } + } + + if (installed > 0) { + console.log(`\n🦦 Otters installed to ${AGENTS_DIR}`); + console.log(' Run "code-puppy -i -a stackwright-foreman-otter" to start!'); + } else { + console.log('⚠️ No otter files found'); + } + } catch (error) { + // Don't fail the install if this script has issues + console.warn(`⚠️ Failed to install otters: ${error.message}`); + } +} + +installAgents(); From 24fed0f44b5395df5e73373ff04b74aadad7ea98 Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:13:15 -0400 Subject: [PATCH 02/14] feat(scripts): add SBOM generation with pluggable hook system (#291) - @stackwright/sbom-generator package with SPDX 2.3, CycloneDX 1.5 formats - Hook system for Pro extensibility (preGenerate, postAnalyze, postFormat, etc.) - CLI sbom command (generate, validate, diff) - --no-sbom flag to skip generation - Security reviewed and approved - 43 tests passing --- .changeset/sbom-generator-addition.md | 19 + .changeset/sbom-hooks-addition.md | 17 + CLAUDE.md | 18 + CONTRIBUTING.md | 32 ++ PHILOSOPHY.md | 2 + docs/sbom-ci-workflow.md | 75 ++++ packages/build-scripts/package.json | 1 + packages/build-scripts/src/index.ts | 1 + packages/build-scripts/src/prebuild.ts | 25 ++ packages/cli/package.json | 1 + packages/cli/src/cli.ts | 2 + packages/cli/src/commands/sbom.ts | 367 ++++++++++++++++++ packages/sbom-generator/README.md | 252 ++++++++++++ packages/sbom-generator/package.json | 45 +++ packages/sbom-generator/src/analyzer.ts | 160 ++++++++ .../src/formats/build-manifest.ts | 217 +++++++++++ .../sbom-generator/src/formats/cyclonedx.ts | 276 +++++++++++++ packages/sbom-generator/src/formats/spdx.ts | 243 ++++++++++++ packages/sbom-generator/src/generator.ts | 278 +++++++++++++ packages/sbom-generator/src/index.ts | 62 +++ .../sbom-generator/src/normalizers/pnpm.ts | 202 ++++++++++ .../src/normalizers/stackwright.ts | 145 +++++++ packages/sbom-generator/src/registry.ts | 83 ++++ packages/sbom-generator/src/types.ts | 13 + packages/sbom-generator/src/types/hooks.ts | 78 ++++ packages/sbom-generator/src/utils/hash.ts | 48 +++ packages/sbom-generator/src/utils/purl.ts | 170 ++++++++ .../test/fixtures/minimal-package.json | 13 + .../test/fixtures/monorepo-package.json | 21 + .../test/formats/cyclonedx.test.ts | 201 ++++++++++ .../sbom-generator/test/formats/spdx.test.ts | 185 +++++++++ .../sbom-generator/test/generator.test.ts | 161 ++++++++ packages/sbom-generator/tsconfig.json | 15 + packages/sbom-generator/tsup.config.ts | 16 + packages/sbom-generator/vitest.config.ts | 14 + pnpm-lock.yaml | 167 ++------ 36 files changed, 3489 insertions(+), 136 deletions(-) create mode 100644 .changeset/sbom-generator-addition.md create mode 100644 .changeset/sbom-hooks-addition.md create mode 100644 docs/sbom-ci-workflow.md create mode 100644 packages/cli/src/commands/sbom.ts create mode 100644 packages/sbom-generator/README.md create mode 100644 packages/sbom-generator/package.json create mode 100644 packages/sbom-generator/src/analyzer.ts create mode 100644 packages/sbom-generator/src/formats/build-manifest.ts create mode 100644 packages/sbom-generator/src/formats/cyclonedx.ts create mode 100644 packages/sbom-generator/src/formats/spdx.ts create mode 100644 packages/sbom-generator/src/generator.ts create mode 100644 packages/sbom-generator/src/index.ts create mode 100644 packages/sbom-generator/src/normalizers/pnpm.ts create mode 100644 packages/sbom-generator/src/normalizers/stackwright.ts create mode 100644 packages/sbom-generator/src/registry.ts create mode 100644 packages/sbom-generator/src/types.ts create mode 100644 packages/sbom-generator/src/types/hooks.ts create mode 100644 packages/sbom-generator/src/utils/hash.ts create mode 100644 packages/sbom-generator/src/utils/purl.ts create mode 100644 packages/sbom-generator/test/fixtures/minimal-package.json create mode 100644 packages/sbom-generator/test/fixtures/monorepo-package.json create mode 100644 packages/sbom-generator/test/formats/cyclonedx.test.ts create mode 100644 packages/sbom-generator/test/formats/spdx.test.ts create mode 100644 packages/sbom-generator/test/generator.test.ts create mode 100644 packages/sbom-generator/tsconfig.json create mode 100644 packages/sbom-generator/tsup.config.ts create mode 100644 packages/sbom-generator/vitest.config.ts diff --git a/.changeset/sbom-generator-addition.md b/.changeset/sbom-generator-addition.md new file mode 100644 index 00000000..5eb3fc10 --- /dev/null +++ b/.changeset/sbom-generator-addition.md @@ -0,0 +1,19 @@ +--- +"@stackwright/sbom-generator": minor +"@stackwright/build-scripts": minor +"@stackwright/cli": minor +--- + +feat: Add SBOM generation for supply chain transparency + +Every Stackwright build now generates a Software Bill of Materials (SBOM) with: +- SPDX 2.3 format (US Government compliance) +- CycloneDX 1.5 format (OWASP tooling compatibility) +- Stackwright build manifest (internal format) + +New CLI commands: +- `stackwright sbom generate` - Regenerate SBOM +- `stackwright sbom validate` - Validate SBOM schemas +- `stackwright sbom diff` - Compare SBOMs between builds + +Use `--no-sbom` flag to skip generation if needed. diff --git a/.changeset/sbom-hooks-addition.md b/.changeset/sbom-hooks-addition.md new file mode 100644 index 00000000..9ee71c4a --- /dev/null +++ b/.changeset/sbom-hooks-addition.md @@ -0,0 +1,17 @@ +--- +"@stackwright/sbom-generator": minor +--- + +feat: Add pluggable hook system for SBOM extensibility + +Pro packages can now register hooks to extend SBOM generation: +- `preGenerate` / `postAnalyze` / `preFormat` / `postFormat` / `preWrite` / `postWrite` + +Hook types: +- `priority`: Controls execution order (lower = first) +- `critical`: If true, failure fails entire SBOM generation + +Auto-registration pattern (consistent with registerNextJSComponents, etc.): +```typescript +import '@stackwright-pro/sbom-enterprise'; // auto-registers hooks +``` diff --git a/CLAUDE.md b/CLAUDE.md index 9f636001..2a18c2cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,8 +68,26 @@ cd packages/types && pnpm generate-schemas pnpm changeset # Create a changeset for changes pnpm version-packages # Update versions based on changesets pnpm release # Build and publish to NPM + +### SBOM Generation + +Stackwright automatically generates SBOM (Software Bill of Materials) for every build: + +```bash +# SBOM is generated automatically during prebuild +pnpm build # Generates .stackwright/sbom/ + +# CLI commands for SBOM +pnpm stackwright -- sbom generate # Regenerate SBOM +pnpm stackwright -- sbom validate # Validate SBOM schemas +pnpm stackwright -- sbom diff # Compare two builds + +# Skip SBOM generation (rarely needed) +pnpm build -- --no-sbom ``` +SBOM formats: SPDX 2.3, CycloneDX 1.5, and Stackwright Build Manifest. + ## Architecture Stackwright is a **pnpm monorepo** implementing a typed DSL for web applications — a platform where visual rendering + constrained DSL + AI iteration = non-technical people building enterprise apps that are safe by construction. YAML is the syntax. `@stackwright/types` is the grammar. Zod schemas enforce the safety boundary. The framework compiles content files into production-ready Next.js/React applications. See `PHILOSOPHY.md` for the full architectural rationale. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ad8d18c..506f9d49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -413,8 +413,40 @@ git commit -m "Update visual regression baselines" See `packages/e2e/README.md` for detailed visual testing guide. +## SBOM Testing + +When modifying `@stackwright/sbom-generator`: + +1. **Unit tests** are required for all format generators (SPDX, CycloneDX, Build Manifest) +2. **Integration tests** should use real lockfiles from test fixtures +3. **Coverage target**: 80% minimum for the sbom-generator package +4. **Schema validation**: Test that generated SBOMs pass official SPDX/CycloneDX validators +5. **Error handling**: Test malformed lockfile scenarios + +```bash +# Run SBOM tests +pnpm --filter @stackwright/sbom-generator test + +# Update visual baselines if needed +pnpm --filter @stackwright/sbom-generator test --update-snapshots +``` + +### Running Tests + +```bash +# All tests including SBOM +pnpm test + +# SBOM generator only +pnpm --filter @stackwright/sbom-generator test + +# With coverage +pnpm --filter @stackwright/sbom-generator test:coverage +``` + ## Schema Fuzzing + The `schema-fuzzing.test.ts` file stress-tests Zod schemas with randomized inputs: ```bash diff --git a/PHILOSOPHY.md b/PHILOSOPHY.md index f1afade3..27f075fe 100644 --- a/PHILOSOPHY.md +++ b/PHILOSOPHY.md @@ -103,6 +103,8 @@ But the escape hatch is designed to be **obvious when you're using it**. Custom **"What it doesn't protect against"**: Custom React components written outside the YAML layer are standard Next.js. Dynamic data fetching in custom components must be secured by the developer. Stackwright constrains the platform; it doesn't constrain arbitrary code you add to the platform. +- **Automatic SBOM Generation**: Every Stackwright build includes a complete Software Bill of Materials (SBOM) in SPDX 2.3 and CycloneDX 1.5 formats, ensuring supply chain transparency by default. This extends the "safe by construction" philosophy from content to dependencies. + ### Implication for Schema Design Every field added to the schema expands the set of expressible behaviors. This is why the "constrain first" principle exists — it is not just about simplicity, it is about maintaining the safety guarantees that make the enterprise use case viable. diff --git a/docs/sbom-ci-workflow.md b/docs/sbom-ci-workflow.md new file mode 100644 index 00000000..7e8c294e --- /dev/null +++ b/docs/sbom-ci-workflow.md @@ -0,0 +1,75 @@ +# SBOM CI Integration Guide + +## Overview + +Stackwright automatically generates SBOM (Software Bill of Materials) for every build via `stackwright-prebuild`. This guide covers CI integration for artifact upload and validation. + +## GitHub Actions Example + +```yaml +name: Build and Generate SBOM + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install dependencies + run: pnpm install + + - name: Build project + run: pnpm build + # SBOM is generated automatically during prebuild + + - name: Upload SBOM artifacts + uses: actions/upload-artifact@v4 + with: + name: sbom-artifacts + path: | + .stackwright/sbom/*.json + .stackwright/sbom/*.xml + retention-days: 30 + + - name: Validate SBOM + run: pnpm stackwright -- sbom validate +``` + +## SBOM Formats Generated + +| Format | File | Use Case | +|--------|------|----------| +| SPDX JSON | `build-manifest.spdx.json` | US Government compliance (EO 14028) | +| CycloneDX JSON | `build-manifest.cyclonedx.json` | OWASP tooling, SCA tools | +| Build Manifest | `build-manifest.json` | Stackwright internal use | + +## Output Location + +SBOM files are written to `.stackwright/sbom/` in the project root. + +## Validation + +Run `pnpm stackwright -- sbom validate` to validate generated SBOMs against their schemas. + +## Options + +- `--no-sbom`: Skip SBOM generation (rarely needed) +- `pnpm stackwright -- sbom diff`: Compare SBOMs between builds + +## Security Benefits + +- **Supply chain transparency**: Every dependency is documented +- **Vulnerability tracking**: Known CVEs can be mapped to SBOM entries +- **Compliance**: Meets requirements for EO 14028, NIST SSDF, NDAA § 5722 +- **Auditing**: Complete dependency audit trail for every build \ No newline at end of file diff --git a/packages/build-scripts/package.json b/packages/build-scripts/package.json index e7608c17..dd7a8f43 100644 --- a/packages/build-scripts/package.json +++ b/packages/build-scripts/package.json @@ -29,6 +29,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@stackwright/sbom-generator": "workspace:*", "@stackwright/types": "workspace:*", "js-yaml": "^4.1.0" }, diff --git a/packages/build-scripts/src/index.ts b/packages/build-scripts/src/index.ts index 5b8c1650..c874003f 100644 --- a/packages/build-scripts/src/index.ts +++ b/packages/build-scripts/src/index.ts @@ -7,3 +7,4 @@ export { runPrebuild } from './prebuild'; export { runWatch } from './watch'; export type { PrebuildOptions, PrebuildPlugin, PrebuildPluginContext } from '@stackwright/types'; +export type { SBOMOptions, SBOM, SBOMFormat } from '@stackwright/sbom-generator'; diff --git a/packages/build-scripts/src/prebuild.ts b/packages/build-scripts/src/prebuild.ts index 099fd4f9..a12660cb 100644 --- a/packages/build-scripts/src/prebuild.ts +++ b/packages/build-scripts/src/prebuild.ts @@ -1021,12 +1021,37 @@ export async function runPrebuild(options?: string | PrebuildOptions): Promise { registerCollection(program); registerCompose(program); registerPreview(program); + registerSBOM(program); // Pre-parse to extract global options (including --plugin-dir) before full dispatch. // parseOptions() does NOT dispatch commands — it only extracts options. diff --git a/packages/cli/src/commands/sbom.ts b/packages/cli/src/commands/sbom.ts new file mode 100644 index 00000000..bf6173a6 --- /dev/null +++ b/packages/cli/src/commands/sbom.ts @@ -0,0 +1,367 @@ +import { Command } from 'commander'; +import path from 'path'; +import fs from 'fs'; +import { outputResult, outputError } from '../utils/json-output'; + +/** + * SBOM CLI Command + * + * Provides subcommands for: + * - generate: Run SBOM generation + * - validate: Validate existing SBOM files + * - diff: Compare two SBOMs + */ + +// -------------------------------------------------------------------------- +// SBOM Generation +// -------------------------------------------------------------------------- + +export interface SBOMGenerateOptions { + projectRoot?: string; + formats?: string[]; + includeDevDependencies?: boolean; + includePeerDependencies?: boolean; + outputDir?: string; +} + +export interface SBOMGenerateResult { + success: boolean; + outputDir: string; + files: string[]; +} + +async function generateSBOM(options: SBOMGenerateOptions = {}): Promise { + const projectRoot = options.projectRoot ?? process.cwd(); + + const { createSBOM } = await import('@stackwright/sbom-generator'); + + const formats = (options.formats as ('spdx' | 'cyclonedx' | 'build-manifest')[]) ?? [ + 'spdx', + 'cyclonedx', + 'build-manifest', + ]; + + const sbom = await createSBOM({ + projectRoot, + formats, + includeDevDependencies: options.includeDevDependencies ?? false, + includePeerDependencies: options.includePeerDependencies ?? true, + outputDir: options.outputDir ?? path.join(projectRoot, '.stackwright', 'sbom'), + }); + + await sbom.writeTo(projectRoot); + + const outputDir = path.join(projectRoot, '.stackwright', 'sbom'); + const files = fs.existsSync(outputDir) + ? fs.readdirSync(outputDir).map((f) => path.join(outputDir, f)) + : []; + + return { + success: true, + outputDir, + files, + }; +} + +// -------------------------------------------------------------------------- +// SBOM Validation +// -------------------------------------------------------------------------- + +export interface SBOMValidateOptions { + inputFile: string; +} + +export interface SBOMValidateResult { + valid: boolean; + format: string | null; + errors: string[]; +} + +async function validateSBOM(options: SBOMValidateOptions): Promise { + const { validateSPDX, validateCycloneDX, validateBuildManifest } = + await import('@stackwright/sbom-generator'); + + const inputPath = path.resolve(options.inputFile); + + if (!fs.existsSync(inputPath)) { + return { + valid: false, + format: null, + errors: [`File not found: ${inputPath}`], + }; + } + + const content = fs.readFileSync(inputPath, 'utf8'); + const errors: string[] = []; + + // Try SPDX first + const spdxResult = validateSPDX(content); + if (spdxResult.valid) { + return { valid: true, format: 'SPDX', errors: [] }; + } + + // Try CycloneDX + const cyclonedxResult = validateCycloneDX(content); + if (cyclonedxResult.valid) { + return { valid: true, format: 'CycloneDX', errors: [] }; + } + + // Try Build Manifest + const manifestResult = validateBuildManifest(content); + if (manifestResult.valid) { + return { valid: true, format: 'Build Manifest', errors: [] }; + } + + // Combine errors from all validators + errors.push(...spdxResult.errors.map((e) => `SPDX: ${e}`)); + errors.push(...cyclonedxResult.errors.map((e) => `CycloneDX: ${e}`)); + errors.push(...manifestResult.errors.map((e) => `Build Manifest: ${e}`)); + + return { + valid: false, + format: null, + errors: errors.length > 0 ? errors : ['Unable to identify SBOM format'], + }; +} + +// -------------------------------------------------------------------------- +// SBOM Diff +// -------------------------------------------------------------------------- + +export interface SBOMDiffOptions { + oldFile: string; + newFile: string; +} + +export interface SBOMDiffResult { + added: string[]; + removed: string[]; + changed: string[]; +} + +async function diffSBOM(options: SBOMDiffOptions): Promise { + const { quickSummary } = await import('@stackwright/sbom-generator'); + + const oldPath = path.resolve(options.oldFile); + const newPath = path.resolve(options.newFile); + + if (!fs.existsSync(oldPath)) { + throw new Error(`Old SBOM file not found: ${oldPath}`); + } + if (!fs.existsSync(newPath)) { + throw new Error(`New SBOM file not found: ${newPath}`); + } + + const oldContent = JSON.parse(fs.readFileSync(oldPath, 'utf8')); + const newContent = JSON.parse(fs.readFileSync(newPath, 'utf8')); + + const oldPackages = new Set( + (oldContent.packages ?? oldContent.components ?? []).map((p: any) => p.name) + ); + const newPackages = new Set( + (newContent.packages ?? newContent.components ?? []).map((p: any) => p.name) + ); + + const added: string[] = []; + const removed: string[] = []; + + for (const pkg of newPackages) { + if (!oldPackages.has(pkg)) { + added.push(pkg); + } + } + + for (const pkg of oldPackages) { + if (!newPackages.has(pkg)) { + removed.push(pkg); + } + } + + // Check for version changes in common packages + const changed: string[] = []; + const oldPackagesMap = new Map( + (oldContent.packages ?? oldContent.components ?? []).map((p: any) => [p.name, p]) + ); + const newPackagesMap = new Map( + (newContent.packages ?? newContent.components ?? []).map((p: any) => [p.name, p]) + ); + + for (const pkg of oldPackages) { + if (newPackages.has(pkg)) { + const oldVersion = oldPackagesMap.get(pkg)?.version; + const newVersion = newPackagesMap.get(pkg)?.version; + if (oldVersion !== newVersion) { + changed.push(`${pkg}: ${oldVersion ?? '?'} → ${newVersion ?? '?'}`); + } + } + } + + return { added, removed, changed }; +} + +// -------------------------------------------------------------------------- +// Main command runner +// -------------------------------------------------------------------------- + +export async function runSBOMCommand(args: string[]): Promise { + const program = new Command(); + + program + .name('sbom') + .description('SBOM generation and validation for Stackwright projects') + .argument('[subcommand]', 'Subcommand to run', 'generate') + .argument('[options]', 'Subcommand-specific options'); + + // Parse just the subcommand and its args + const [subcommand, ...subArgs] = args; + + switch (subcommand) { + case 'generate': { + const opts = program + .name('generate') + .description('Generate SBOM files') + .option('--json', 'Output machine-readable JSON') + .option( + '--formats ', + 'Comma-separated list of formats (spdx,cyclonedx,build-manifest)', + 'spdx,cyclonedx,build-manifest' + ) + .option('--include-dev', 'Include dev dependencies') + .option('--no-peer-deps', 'Exclude peer dependencies') + .parse(subArgs) + .opts(); + + const formats = opts.formats?.split(',').map((f) => f.trim()) ?? [ + 'spdx', + 'cyclonedx', + 'build-manifest', + ]; + + try { + const result = await generateSBOM({ + formats, + includeDevDependencies: opts.includeDev, + includePeerDependencies: !opts.noPeerDeps, + }); + + if (opts.json) { + outputResult(result, { json: true }); + } else { + console.log(`SBOM generated successfully:`); + console.log(` Output: ${result.outputDir}`); + result.files.forEach((f) => console.log(` - ${path.basename(f)}`)); + } + } catch (err) { + outputError(String(err), 'SBOM_GENERATION_FAILED', { json: opts.json }, 2); + } + break; + } + + case 'validate': { + const opts = program + .name('validate') + .description('Validate an SBOM file') + .option('--json', 'Output machine-readable JSON') + .argument('', 'SBOM file to validate') + .parse(subArgs) + .opts(); + + const fileArg = program.args[0]; + if (!fileArg) { + outputError('Missing SBOM file argument', 'MISSING_ARGUMENT', { json: opts.json }, 1); + return; + } + + try { + const result = await validateSBOM({ inputFile: fileArg }); + + if (opts.json) { + outputResult(result, { json: true }); + } else { + if (result.valid) { + console.log(`✓ SBOM is valid (${result.format})`); + } else { + console.log('✗ SBOM is invalid:'); + result.errors.forEach((e) => console.log(` - ${e}`)); + } + } + } catch (err) { + outputError(String(err), 'SBOM_VALIDATION_FAILED', { json: opts.json }, 2); + } + break; + } + + case 'diff': { + const opts = program + .name('diff') + .description('Compare two SBOM files') + .option('--json', 'Output machine-readable JSON') + .argument('', 'Path to old SBOM') + .argument('', 'Path to new SBOM') + .parse(subArgs) + .opts(); + + const [oldFile, newFile] = program.args; + if (!oldFile || !newFile) { + outputError('Missing SBOM file arguments', 'MISSING_ARGUMENTS', { json: opts.json }, 1); + return; + } + + try { + const result = await diffSBOM({ oldFile, newFile }); + + if (opts.json) { + outputResult(result, { json: true }); + } else { + console.log('SBOM Diff Results:'); + + if (result.added.length > 0) { + console.log('\n Added:'); + result.added.forEach((p) => console.log(` + ${p}`)); + } + + if (result.removed.length > 0) { + console.log('\n Removed:'); + result.removed.forEach((p) => console.log(` - ${p}`)); + } + + if (result.changed.length > 0) { + console.log('\n Changed:'); + result.changed.forEach((c) => console.log(` ~ ${c}`)); + } + + if ( + result.added.length === 0 && + result.removed.length === 0 && + result.changed.length === 0 + ) { + console.log('\n No differences found.'); + } + } + } catch (err) { + outputError(String(err), 'SBOM_DIFF_FAILED', { json: opts.json }, 2); + } + break; + } + + default: + console.log(`Unknown subcommand: ${subcommand}`); + console.log('Available subcommands: generate, validate, diff'); + process.exit(1); + } +} + +// -------------------------------------------------------------------------- +// Commander registration +// -------------------------------------------------------------------------- + +export function registerSBOM(program: Command): void { + program + .command('sbom') + .description('Generate and validate SBOM files for Stackwright projects') + .argument('[subcommand]', 'Subcommand to run', 'generate') + .argument('[args...]', 'Arguments for the subcommand') + .action(async (subcommand: string, args: string[]) => { + await runSBOMCommand([subcommand, ...args]); + }); +} diff --git a/packages/sbom-generator/README.md b/packages/sbom-generator/README.md new file mode 100644 index 00000000..74d90c7a --- /dev/null +++ b/packages/sbom-generator/README.md @@ -0,0 +1,252 @@ +# @stackwright/sbom-generator + +SBOM (Software Bill of Materials) generation for Stackwright projects. Generates SPDX 2.3, CycloneDX 1.5, and Stackwright-specific build manifest formats. + +## Installation + +```bash +pnpm add @stackwright/sbom-generator +``` + +## Usage + +### Basic Usage + +```typescript +import { createSBOM } from '@stackwright/sbom-generator'; + +const sbom = await createSBOM({ + projectRoot: '/path/to/your/project', + formats: ['spdx', 'cyclonedx', 'build-manifest'], +}); + +// Write to disk +await sbom.writeTo('./sbom-output'); + +// Get summary +const summary = sbom.getSummary(); +console.log(`Generated SBOM for ${summary.projectName} with ${summary.totalDependencies} dependencies`); +``` + +### Generate Specific Formats + +```typescript +// SPDX 2.3 only +const spdxOnly = await createSBOM({ + projectRoot: process.cwd(), + formats: ['spdx'], +}); + +// CycloneDX 1.5 only +const cyclonedxOnly = await createSBOM({ + projectRoot: process.cwd(), + formats: ['cyclonedx'], +}); + +// Stackwright build manifest only +const manifest = await createSBOM({ + projectRoot: process.cwd(), + formats: ['build-manifest'], +}); +``` + +### Access Generated Content + +```typescript +const sbom = await createSBOM({ + projectRoot: process.cwd(), + formats: ['spdx', 'cyclonedx', 'build-manifest'], +}); + +// Access SPDX document +if (sbom.spdx) { + console.log('SPDX Version:', sbom.spdx.spdxVersion); + console.log('Packages:', sbom.spdx.packages.length); +} + +// Access CycloneDX document +if (sbom.cyclonedx) { + console.log('Components:', sbom.cyclonedx.components.length); +} + +// Access Build Manifest +if (sbom.buildManifest) { + console.log('Metadata:', sbom.buildManifest.metadata); +} +``` + +### Using Formatters Directly + +```typescript +import { toSPDXJSON, toCycloneDXJSON, toBuildManifestJSON } from '@stackwright/sbom-generator'; + +// Convert to JSON strings +const spdxJson = toSPDXJSON(spdxDocument); +const cyclonedxJson = toCycloneDXJSON(cyclonedxDocument); +const manifestJson = toBuildManifestJSON(buildManifest); +``` + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `projectRoot` | `string` | Required | Root directory of the project | +| `formats` | `SBOMFormat[]` | Required | Output formats to generate | +| `includeDevDependencies` | `boolean` | `false` | Include dev dependencies | +| `includePeerDependencies` | `boolean` | `true` | Include peer dependencies | +| `outputDir` | `string` | `.stackwright/sbom/` | Output directory for files | + +## Output Formats + +### SPDX 2.3 + +Standard SBOM format with: +- Package verification using SHA-256 checksums +- PURL-based external references +- Relationship mapping (DESCRIBES, CONTAINS, DEPENDS_ON) +- JSON and tag-value output formats + +### CycloneDX 1.5 + +Modern SBOM format with: +- Component-based architecture +- Dependency graph with transitive tracking +- License intelligence +- JSON output + +### Build Manifest + +Stackwright-specific format with: +- Package categorization (core, theme, plugin, external) +- Dependency depth tracking +- Workspace awareness for monorepos +- Zod schema validation + +## Extensibility via Hooks + +The SBOM generator supports a hook system for extensibility. Pro packages can register hooks to extend functionality: + +```typescript +import { registerSBOMHook } from '@stackwright/sbom-generator'; + +registerSBOMHook({ + type: 'postAnalyze', + name: 'cve-enrichment', + priority: 10, + critical: false, + handler: async (context) => { + // Add vulnerability data to context.dependencies + }, +}); +``` + +### Hook Lifecycle Points + +| Hook | When | Use Case | +|------|------|----------| +| `preGenerate` | Before analysis | Load credentials, validate config | +| `postAnalyze` | After dependency analysis | Enrich with CVE data | +| `preFormat` | Before each format | Prepare format-specific data | +| `postFormat` | After each format | Sign SBOM, add attestations | +| `preWrite` | Before writing files | Validate output | +| `postWrite` | After all files written | Publish to registry | + +### Hook Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `type` | `SBOMHookType` | Required | Lifecycle point | +| `name` | `string` | Required | Unique hook name | +| `priority` | `number` | `50` | Lower = runs first | +| `critical` | `boolean` | `false` | If true, failure fails entire SBOM generation | +| `handler` | `function` | Required | Async function to execute | + +### Hook Context + +The context object passed to hooks contains: + +```typescript +interface SBOMHookContext { + projectRoot: string; // Project directory + formats: SBOMFormat[]; // Output formats + outputDir: string; // Output directory + project?: ProjectInfo; // Available after postAnalyze + dependencies?: Dependency[]; // Available after postAnalyze + spdx?: SPDXDocument; // Available after SPDX generation + cyclonedx?: CycloneDXDocument; // Available after CycloneDX generation + buildManifest?: BuildManifest; // Available after manifest generation +} +``` + +### Pro Integration + +Import a Pro package to auto-register its hooks: + +```typescript +import '@stackwright-pro/sbom-enterprise'; // hooks auto-register on import + +// SBOM now includes CVE enrichment, signing, and SLSA attestation +const sbom = await createSBOM({ projectRoot, formats: ['spdx'] }); +``` + +### Registry Functions + +```typescript +import { + registerSBOMHook, // Register a new hook + getSBOMHooks, // Get all registered hooks + getHooksForType, // Get hooks for specific lifecycle point + clearSBOMHooks, // Clear all hooks (useful for testing) +} from '@stackwright/sbom-generator'; +``` + +## CLI Usage + +For command-line usage, see the companion package `@stackwright/cli` which includes SBOM generation commands. + +## API Reference + +### `createSBOM(options)` + +Main function to generate an SBOM. + +```typescript +interface SBOMOptions { + projectRoot: string; + formats: ('spdx' | 'cyclonedx' | 'build-manifest')[]; + includeDevDependencies?: boolean; + includePeerDependencies?: boolean; + outputDir?: string; +} +``` + +Returns: `Promise` + +### `SBOM` + +The generated SBOM object. + +```typescript +interface SBOM { + project: StackwrightProjectInfo; + dependencies: NormalizedDependency[]; + spdx?: SPDXDocument; + cyclonedx?: CycloneDXDocument; + buildManifest?: BuildManifest; + writeTo(path: string): Promise; + getSummary(): SBOMSummary; +} +``` + +## Debugging + +Enable debug output with environment variables: + +```bash +STACKWRIGHT_DEBUG=true node your-script.js +NODE_ENV=development STACKWRIGHT_DEBUG=true node your-script.js +``` + +## License + +MIT diff --git a/packages/sbom-generator/package.json b/packages/sbom-generator/package.json new file mode 100644 index 00000000..466567a5 --- /dev/null +++ b/packages/sbom-generator/package.json @@ -0,0 +1,45 @@ +{ + "name": "@stackwright/sbom-generator", + "version": "0.0.0", + "description": "SBOM generation for Stackwright projects - generates SPDX, CycloneDX, and build manifest formats", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Per-Aspera-LLC/stackwright" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "js-yaml": "^4.1.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.0", + "@types/node": "^20.0.0", + "tsup": "^8.5.0", + "typescript": "^5.8.0", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/sbom-generator/src/analyzer.ts b/packages/sbom-generator/src/analyzer.ts new file mode 100644 index 00000000..5bc87be5 --- /dev/null +++ b/packages/sbom-generator/src/analyzer.ts @@ -0,0 +1,160 @@ +/** + * Dependency analysis from lockfiles + * @package @stackwright/sbom-generator + */ + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import yaml from 'js-yaml'; +import { + NormalizedDependency, + getDirectDependencies, + categorizeDependencies, + parseLockfilePackages, + LockfilePackage, +} from './normalizers/pnpm'; +import { StackwrightProjectInfo, readPackageManifest } from './normalizers/stackwright'; +import { debugLog } from './generator'; + +export interface AnalyzerOptions { + projectRoot: string; + includeDevDependencies?: boolean; + includePeerDependencies?: boolean; +} + +/** + * Analyze project dependencies from lockfile + */ +export async function analyzeDependencies(options: AnalyzerOptions): Promise<{ + project: StackwrightProjectInfo; + dependencies: NormalizedDependency[]; +}> { + const { projectRoot, includeDevDependencies = false, includePeerDependencies = true } = options; + + debugLog('Starting dependency analysis', { projectRoot }); + + // Read project manifest + const project = await readPackageManifest(projectRoot); + if (!project) { + throw new Error(`Could not read package manifest from ${projectRoot}`); + } + + debugLog('Project manifest loaded', { name: project.name, version: project.version }); + + // Read direct dependencies from package.json + const directDeps = await getDirectDependencies(projectRoot); + + if (!directDeps) { + throw new Error(`Could not read dependencies from ${projectRoot}`); + } + + debugLog('Direct dependencies loaded', { + deps: Object.keys(directDeps.dependencies).length, + devDeps: Object.keys(directDeps.devDependencies).length, + }); + + // Read pnpm-lock.yaml + const lockfilePath = resolve(projectRoot, 'pnpm-lock.yaml'); + let dependencies: NormalizedDependency[] = []; + + try { + const content = await readFile(lockfilePath, 'utf-8'); + const lockfile = yaml.load(content) as Record; + + if (lockfile.packages) { + debugLog('Lockfile packages found', { + count: Object.keys(lockfile.packages as object).length, + }); + + dependencies = parseLockfilePackages( + lockfile.packages as Record, + projectRoot + ); + } + } catch { + debugLog('Could not read lockfile, using package.json dependencies only'); + // Fall back to package.json dependencies only + dependencies = fallbackFromPackageJson(directDeps); + } + + // Categorize dependencies + dependencies = categorizeDependencies( + dependencies, + directDeps.dependencies, + directDeps.devDependencies, + directDeps.peerDependencies + ); + + // Filter based on options + if (!includeDevDependencies) { + dependencies = dependencies.filter((d) => !d.isDev); + } + + if (!includePeerDependencies) { + dependencies = dependencies.filter((d) => !d.isPeer); + } + + debugLog('Analysis complete', { + totalDeps: dependencies.length, + direct: dependencies.filter((d) => d.depth === 0).length, + dev: dependencies.filter((d) => d.isDev).length, + }); + + return { project, dependencies }; +} + +/** + * Fallback when lockfile is not available + */ +function fallbackFromPackageJson(deps: { + dependencies: Record; + devDependencies: Record; + peerDependencies: Record; +}): NormalizedDependency[] { + const result: NormalizedDependency[] = []; + + // Add regular dependencies + for (const [name, version] of Object.entries(deps.dependencies)) { + result.push({ + name, + version: normalizeVersion(version), + dependencies: {}, + isDev: false, + isPeer: false, + depth: 0, + }); + } + + // Add dev dependencies + for (const [name, version] of Object.entries(deps.devDependencies)) { + result.push({ + name, + version: normalizeVersion(version), + dependencies: {}, + isDev: true, + isPeer: false, + depth: 0, + }); + } + + // Add peer dependencies + for (const [name, version] of Object.entries(deps.peerDependencies)) { + result.push({ + name, + version: normalizeVersion(version), + dependencies: {}, + isDev: false, + isPeer: true, + depth: 0, + }); + } + + return result; +} + +/** + * Normalize version string to semver + */ +function normalizeVersion(versionSpec: string): string { + return versionSpec.replace(/^[\^~>=<]+/, '').replace(/\s+.*$/, ''); +} diff --git a/packages/sbom-generator/src/formats/build-manifest.ts b/packages/sbom-generator/src/formats/build-manifest.ts new file mode 100644 index 00000000..7c68ac81 --- /dev/null +++ b/packages/sbom-generator/src/formats/build-manifest.ts @@ -0,0 +1,217 @@ +/** + * Stackwright-specific build manifest format + * @package @stackwright/sbom-generator + */ + +import { z } from 'zod'; +import { NormalizedDependency } from '../normalizers/pnpm'; +import { StackwrightProjectInfo, getStackwrightPackageType } from '../normalizers/stackwright'; +import { npmPURL } from '../utils/purl'; +import { sha256 } from '../utils/hash'; + +// Schema for build manifest +export const BuildManifestSchema = z.object({ + format: z.literal('stackwright-build-manifest'), + version: z.string(), + generated: z.string(), + project: z.object({ + name: z.string(), + version: z.string(), + root: z.string(), + description: z.string().optional(), + license: z.string().optional(), + repository: z.string().optional(), + isMonorepo: z.boolean(), + }), + dependencies: z.array( + z.object({ + name: z.string(), + version: z.string(), + type: z.enum(['direct', 'dev', 'peer', 'transitive']), + category: z.enum(['core', 'theme', 'plugin', 'tool', 'example', 'external']), + purl: z.string(), + integrity: z.string(), + depth: z.number(), + }) + ), + metadata: z.object({ + totalDependencies: z.number(), + directDependencies: z.number(), + devDependencies: z.number(), + peerDependencies: z.number(), + transitiveDependencies: z.number(), + stackwrightInternal: z.number(), + external: z.number(), + }), + workspace: z + .object({ + isWorkspace: z.boolean(), + packages: z + .array( + z.object({ + name: z.string(), + version: z.string(), + path: z.string(), + }) + ) + .optional(), + }) + .optional(), +}); + +export type BuildManifest = z.infer; + +export interface BuildManifestOptions { + includeDevDependencies?: boolean; + includeWorkspaceInfo?: boolean; +} + +/** + * Generate Stackwright build manifest + */ +export function generateBuildManifest( + project: StackwrightProjectInfo, + dependencies: NormalizedDependency[], + options: BuildManifestOptions = {} +): BuildManifest { + const { includeDevDependencies = false, includeWorkspaceInfo = false } = options; + + // Filter dependencies + const filteredDeps = includeDevDependencies ? dependencies : dependencies.filter((d) => !d.isDev); + + // Categorize dependencies + const categorizedDeps = filteredDeps.map((dep) => { + const category = getStackwrightPackageType(dep.name); + return { + name: dep.name, + version: dep.version, + type: categorizeType(dep), + category, + purl: npmPURL(dep.name, dep.version), + integrity: sha256(`${dep.name}@${dep.version}`), + depth: dep.depth, + }; + }); + + // Compute metadata + const metadata = { + totalDependencies: categorizedDeps.length, + directDependencies: categorizedDeps.filter((d) => d.type === 'direct').length, + devDependencies: categorizedDeps.filter((d) => d.type === 'dev').length, + peerDependencies: categorizedDeps.filter((d) => d.type === 'peer').length, + transitiveDependencies: categorizedDeps.filter((d) => d.type === 'transitive').length, + stackwrightInternal: categorizedDeps.filter((d) => d.category !== 'external').length, + external: categorizedDeps.filter((d) => d.category === 'external').length, + }; + + const manifest: BuildManifest = { + format: 'stackwright-build-manifest', + version: '1.0.0', + generated: new Date().toISOString(), + project: { + name: project.name, + version: project.version, + root: project.root, + description: project.description, + license: project.license, + repository: project.repository, + isMonorepo: project.isMonorepo, + }, + dependencies: categorizedDeps, + metadata, + }; + + // Add workspace info if applicable + if (includeWorkspaceInfo && project.workspacePackages) { + manifest.workspace = { + isWorkspace: true, + packages: project.workspacePackages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + path: pkg.relativePath, + })), + }; + } + + return manifest; +} + +/** + * Categorize dependency type + */ +function categorizeType(dep: NormalizedDependency): 'direct' | 'dev' | 'peer' | 'transitive' { + if (dep.isPeer) return 'peer'; + if (dep.isDev) return 'dev'; + if (dep.depth === 0) return 'direct'; + return 'transitive'; +} + +/** + * Format build manifest as pretty JSON + */ +export function toBuildManifestJSON(manifest: BuildManifest): string { + return JSON.stringify(manifest, null, 2); +} + +/** + * Validate build manifest against schema + */ +export function validateBuildManifest(data: unknown): data is BuildManifest { + try { + BuildManifestSchema.parse(data); + return true; + } catch { + return false; + } +} + +/** + * Get summary of build manifest + */ +export function getManifestSummary(manifest: BuildManifest): { + totalPackages: number; + stackwrightPackages: string[]; + externalPackages: string[]; + byCategory: Record; +} { + const stackwrightPackages = manifest.dependencies + .filter((d) => d.category !== 'external') + .map((d) => d.name); + + const externalPackages = manifest.dependencies + .filter((d) => d.category === 'external') + .map((d) => d.name); + + const byCategory: Record = {}; + for (const dep of manifest.dependencies) { + byCategory[dep.category] = (byCategory[dep.category] || 0) + 1; + } + + return { + totalPackages: manifest.dependencies.length, + stackwrightPackages, + externalPackages, + byCategory, + }; +} + +/** + * Get dependency tree structure for build manifest + */ +export function getDependencyTree(manifest: BuildManifest): Record { + const tree: Record = {}; + + for (const dep of manifest.dependencies) { + if (dep.depth === 0) { + tree[dep.name] = []; + } else if (dep.depth === 1) { + // Direct dependencies of the root + if (!tree[manifest.project.name]) { + tree[manifest.project.name] = []; + } + tree[manifest.project.name].push(dep.name); + } + } + + return tree; +} diff --git a/packages/sbom-generator/src/formats/cyclonedx.ts b/packages/sbom-generator/src/formats/cyclonedx.ts new file mode 100644 index 00000000..926c40e3 --- /dev/null +++ b/packages/sbom-generator/src/formats/cyclonedx.ts @@ -0,0 +1,276 @@ +/** + * CycloneDX 1.5 format generation + * @package @stackwright/sbom-generator + * @see https://cyclonedx.org/docs/1.5/json/ + */ + +import { NormalizedDependency } from '../normalizers/pnpm'; +import { StackwrightProjectInfo } from '../normalizers/stackwright'; +import { npmPURL } from '../utils/purl'; +import { sha256 } from '../utils/hash'; + +export interface CycloneDXDocument { + bomFormat: string; + specVersion: string; + serialNumber?: string; + version: number; + metadata?: CycloneDXMetadata; + components: CycloneDXComponent[]; + services?: CycloneDXService[]; + dependencies?: CycloneDXDependency[]; +} + +export interface CycloneDXMetadata { + timestamp?: string; + tools?: Array<{ + vendor?: string; + name: string; + version?: string; + }>; + component?: CycloneDXComponent; + manufacture?: CycloneDXOrganizationalEntity; + supplier?: CycloneDXOrganizationalEntity; +} + +export interface CycloneDXComponent { + type: + | 'application' + | 'framework' + | 'library' + | 'container' + | 'operating-system' + | 'device' + | 'firmware' + | 'file'; + 'bom-ref'?: string; + name: string; + version?: string; + purl?: string; + pkgid?: string; + group?: string; + description?: string; + scope?: 'required' | 'optional' | 'excluded'; + hashes?: Array<{ + alg: 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512' | 'MD5'; + content: string; + }>; + licenses?: Array<{ + license: CycloneDXLicense | { id: string; url?: string }; + }>; + copyright?: string; + cpe?: string; + externalReferences?: Array<{ + type: string; + url?: string; + comment?: string; + }>; +} + +export interface CycloneDXLicense { + id?: string; + name?: string; + url?: string; +} + +export interface CycloneDXOrganizationalEntity { + name: string; + url?: string[]; + contact?: Array<{ + name?: string; + email?: string; + phone?: string; + }>; +} + +export interface CycloneDXService { + 'bom-ref'?: string; + name: string; + version?: string; + description?: string; + endpoints?: string[]; + data?: Array<{ + classification: string; + name?: string; + }>; + externalReferences?: CycloneDXComponent['externalReferences']; +} + +export interface CycloneDXDependency { + ref: string; + dependsOn?: string[]; + dependencyType?: string; +} + +/** + * Generate CycloneDX document from dependencies + */ +export function generateCycloneDX( + project: StackwrightProjectInfo, + dependencies: NormalizedDependency[], + options: { + includeDevDependencies?: boolean; + includeServices?: boolean; + } = {} +): CycloneDXDocument { + const filteredDeps = options.includeDevDependencies + ? dependencies + : dependencies.filter((d) => !d.isDev); + + const components = filteredDeps.map((dep) => createCycloneDXComponent(dep)); + + // Add project as main component if not already included + const mainComponent: CycloneDXComponent = { + type: 'application', + name: project.name, + version: project.version, + purl: npmPURL(project.name, project.version), + description: project.description, + licenses: project.license ? [{ license: { id: project.license } }] : undefined, + }; + + // Build dependency graph + const deps = buildDependencyGraph(filteredDeps, mainComponent); + + return { + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + metadata: { + timestamp: new Date().toISOString(), + tools: [ + { + vendor: 'Stackwright', + name: '@stackwright/sbom-generator', + version: '0.0.0', + }, + ], + component: mainComponent, + }, + components, + dependencies: deps, + }; +} + +/** + * Create CycloneDX component from dependency + */ +function createCycloneDXComponent(dep: NormalizedDependency): CycloneDXComponent { + const purl = npmPURL(dep.name, dep.version); + + // Determine component type + let type: CycloneDXComponent['type'] = 'library'; + if (isBuildTool(dep.name)) { + type = 'framework'; + } else if (isExecutable(dep.name)) { + type = 'application'; + } + + // Generate pseudo-hash + const hashContent = `${dep.name}@${dep.version}`; + const hash = sha256(hashContent); + + return { + type, + name: dep.name, + version: dep.version, + purl, + scope: dep.isDev ? 'optional' : 'required', + hashes: [ + { + alg: 'SHA-256', + content: hash, + }, + ], + licenses: dep.license ? [{ license: { id: dep.license } }] : undefined, + externalReferences: [ + { + type: 'package-manager', + url: `https://www.npmjs.com/package/${dep.name}`, + }, + ], + }; +} + +/** + * Build dependency graph for CycloneDX + */ +function buildDependencyGraph( + dependencies: NormalizedDependency[], + mainComponent: CycloneDXComponent +): CycloneDXDependency[] { + const deps: CycloneDXDependency[] = []; + + // Root dependency + const mainRef = mainComponent.purl || mainComponent.name; + const directDeps = dependencies.filter((d) => d.depth === 0); + + deps.push({ + ref: mainRef, + dependsOn: directDeps.map((d) => d.name), + dependencyType: 'direct', + }); + + // Add transitive dependencies + for (const dep of dependencies) { + if (dep.depth > 0 && Object.keys(dep.dependencies).length > 0) { + deps.push({ + ref: dep.name, + dependsOn: Object.keys(dep.dependencies), + dependencyType: 'transitive', + }); + } + } + + return deps; +} + +/** + * Check if package is a build tool + */ +function isBuildTool(name: string): boolean { + const buildTools = [ + 'typescript', + 'babel', + 'webpack', + 'vite', + 'esbuild', + 'rollup', + 'parcel', + 'tsup', + 'swc', + 'rome', + ]; + return buildTools.some((tool) => name === tool || name.includes(`-${tool}`)); +} + +/** + * Check if package is typically executable + */ +function isExecutable(name: string): boolean { + const executables = ['cli', 'bin', 'command', 'runner']; + return executables.some((exec) => name.includes(`-${exec}`)); +} + +/** + * Format CycloneDX document as pretty JSON + */ +export function toCycloneDXJSON(doc: CycloneDXDocument): string { + return JSON.stringify(doc, null, 2); +} + +/** + * Validate CycloneDX document structure + */ +export function validateCycloneDX(doc: unknown): doc is CycloneDXDocument { + if (!doc || typeof doc !== 'object') return false; + + const d = doc as CycloneDXDocument; + + return ( + d.bomFormat === 'CycloneDX' && + d.specVersion === '1.5' && + typeof d.version === 'number' && + Array.isArray(d.components) && + d.components.length > 0 + ); +} diff --git a/packages/sbom-generator/src/formats/spdx.ts b/packages/sbom-generator/src/formats/spdx.ts new file mode 100644 index 00000000..615c2d05 --- /dev/null +++ b/packages/sbom-generator/src/formats/spdx.ts @@ -0,0 +1,243 @@ +/** + * SPDX 2.3 format generation + * @package @stackwright/sbom-generator + * @see https://spdx.github.io/spdx-spec/ + */ + +import { NormalizedDependency } from '../normalizers/pnpm'; +import { StackwrightProjectInfo } from '../normalizers/stackwright'; +import { npmPURL } from '../utils/purl'; +import { sha256 } from '../utils/hash'; +import { randomUUID } from 'node:crypto'; + +export interface SPDXDocument { + spdxVersion: string; + dataLicense: string; + SPDXID: string; + name: string; + documentNamespace: string; + creationInfo: { + created: string; + creators: string[]; + }; + packages: SPDXPackage[]; + relationships: SPDXRelationship[]; +} + +export interface SPDXPackage { + SPDXID: string; + name: string; + versionInfo?: string; + packageFileName?: string; + supplier?: string; + downloadLocation?: string; + filesAnalyzed?: boolean; + checksums: Array<{ + algorithm: string; + value: string; + }>; + licenseConcluded?: string; + licenseDeclared?: string; + externalRefs?: Array<{ + referenceCategory: string; + referenceType: string; + referenceLocator: string; + }>; +} + +export interface SPDXRelationship { + spdxElementId: string; + relationshipType: string; + relatedSpdxElement: string; +} + +/** + * Generate SPDX document from dependencies + */ +export function generateSPDX( + project: StackwrightProjectInfo, + dependencies: NormalizedDependency[], + options: { + includeDevDependencies?: boolean; + namespace?: string; + } = {} +): SPDXDocument { + const { namespace = `https://stackwright.dev/spdx/${project.name}` } = options; + const docId = `SPDXRef-DOCUMENT-${randomUUID().split('-')[0]}`; + const timestamp = new Date().toISOString(); + + const filteredDeps = options.includeDevDependencies + ? dependencies + : dependencies.filter((d) => !d.isDev); + + const packages: SPDXPackage[] = filteredDeps.map((dep) => createSPDXPackage(dep, project)); + const relationships: SPDXRelationship[] = createRelationships(docId, packages); + + return { + spdxVersion: 'SPDX-2.3', + dataLicense: 'CC0-1.0', + SPDXID: docId, + name: `${project.name}@${project.version}`, + documentNamespace: `${namespace}/${timestamp}`, + creationInfo: { + created: timestamp, + creators: ['Tool: @stackwright/sbom-generator', `Tool-Version: 0.0.0`], + }, + packages, + relationships, + }; +} + +/** + * Create SPDX package entry + */ +function createSPDXPackage( + dep: NormalizedDependency, + project: StackwrightProjectInfo +): SPDXPackage { + const packageId = `SPDXRef-Package-${dep.name.replace(/[^a-zA-Z0-9]/g, '-')}`; + const purl = npmPURL(dep.name, dep.version); + + // Generate pseudo-checksum from package info + const checksumContent = `${dep.name}@${dep.version}`; + const checksum = sha256(checksumContent); + + return { + SPDXID: packageId, + name: dep.name, + versionInfo: dep.version, + supplier: `NOASSERTION`, + downloadLocation: `NOASSERTION`, + filesAnalyzed: false, + checksums: [ + { + algorithm: 'SHA256', + value: checksum, + }, + ], + licenseConcluded: dep.license || 'NOASSERTION', + licenseDeclared: dep.license || 'NOASSERTION', + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: purl, + }, + ], + }; +} + +/** + * Create SPDX relationships + */ +function createRelationships(docId: string, packages: SPDXPackage[]): SPDXRelationship[] { + const relationships: SPDXRelationship[] = []; + + // Document describes root package (first non-external) + const rootPackage = packages.find((p) => !p.name.startsWith('@')); + if (rootPackage) { + relationships.push({ + spdxElementId: docId, + relationshipType: 'DESCRIBES', + relatedSpdxElement: rootPackage.SPDXID, + }); + } + + // All packages are contained in document + for (const pkg of packages) { + if (pkg.SPDXID !== rootPackage?.SPDXID) { + relationships.push({ + spdxElementId: rootPackage?.SPDXID || docId, + relationshipType: 'CONTAINS', + relatedSpdxElement: pkg.SPDXID, + }); + } + } + + // Add DEPENDS_ON relationships for direct dependencies + // Note: This is simplified - full tree traversal would be more accurate + return relationships; +} + +/** + * Format SPDX document as JSON + */ +export function toSPDXJSON(doc: SPDXDocument): string { + return JSON.stringify(doc, null, 2); +} + +/** + * Format SPDX document as tag-value format + */ +export function toSPDXTV(doc: SPDXDocument): string { + const lines: string[] = []; + + // Document header + lines.push(`SPDXVersion: ${doc.spdxVersion}`); + lines.push(`DataLicense: ${doc.dataLicense}`); + lines.push(`SPDXID: ${doc.SPDXID}`); + lines.push(`DocumentName: ${doc.name}`); + lines.push(`DocumentNamespace: ${doc.documentNamespace}`); + lines.push(''); + + // Creation info + lines.push('CreationInfo:'); + lines.push(` Created: ${doc.creationInfo.created}`); + for (const creator of doc.creationInfo.creators) { + lines.push(` Creator: ${creator}`); + } + lines.push(''); + + // Packages + for (const pkg of doc.packages) { + lines.push(`PackageName: ${pkg.name}`); + lines.push(`SPDXID: ${pkg.SPDXID}`); + if (pkg.versionInfo) { + lines.push(`PackageVersion: ${pkg.versionInfo}`); + } + if (pkg.licenseConcluded) { + lines.push(`PackageLicenseConcluded: ${pkg.licenseConcluded}`); + } + if (pkg.licenseDeclared) { + lines.push(`PackageLicenseDeclared: ${pkg.licenseDeclared}`); + } + for (const checksum of pkg.checksums) { + lines.push(`PackageChecksum: ${checksum.algorithm}: ${checksum.value}`); + } + if (pkg.externalRefs) { + for (const ref of pkg.externalRefs) { + lines.push( + `ExternalRef: ${ref.referenceCategory} ${ref.referenceType} ${ref.referenceLocator}` + ); + } + } + lines.push(''); + } + + // Relationships + for (const rel of doc.relationships) { + lines.push( + `Relationship: ${rel.spdxElementId} ${rel.relationshipType} ${rel.relatedSpdxElement}` + ); + } + + return lines.join('\n'); +} + +/** + * Validate SPDX document structure + */ +export function validateSPDX(doc: unknown): doc is SPDXDocument { + if (!doc || typeof doc !== 'object') return false; + + const d = doc as SPDXDocument; + + return ( + d.spdxVersion === 'SPDX-2.3' && + typeof d.SPDXID === 'string' && + typeof d.name === 'string' && + Array.isArray(d.packages) && + d.packages.length > 0 && + Array.isArray(d.relationships) + ); +} diff --git a/packages/sbom-generator/src/generator.ts b/packages/sbom-generator/src/generator.ts new file mode 100644 index 00000000..5ba285c5 --- /dev/null +++ b/packages/sbom-generator/src/generator.ts @@ -0,0 +1,278 @@ +/** + * Main SBOM generation orchestrator + * @package @stackwright/sbom-generator + */ + +import { mkdir, writeFile, access } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { analyzeDependencies } from './analyzer'; +import { StackwrightProjectInfo, NormalizedDependency } from './types'; +import { generateSPDX, toSPDXJSON, toSPDXTV, SPDXDocument } from './formats/spdx'; +import { generateCycloneDX, toCycloneDXJSON, CycloneDXDocument } from './formats/cyclonedx'; +import { + generateBuildManifest, + toBuildManifestJSON, + BuildManifest, +} from './formats/build-manifest'; +import { runSBOMHooks } from './registry'; +import type { SBOMHookContext } from './types/hooks'; + +// Re-export types from normalizers +export type { StackwrightProjectInfo } from './normalizers/stackwright'; +export type { NormalizedDependency } from './normalizers/pnpm'; + +// Re-export format types +export type { SPDXDocument } from './formats/spdx'; +export type { CycloneDXDocument } from './formats/cyclonedx'; +export type { BuildManifest } from './formats/build-manifest'; + +/** + * Supported SBOM output formats + */ +export type SBOMFormat = 'spdx' | 'cyclonedx' | 'build-manifest'; + +/** + * Options for SBOM generation + */ +export interface SBOMOptions { + /** Root directory of the project to analyze */ + projectRoot: string; + /** Output formats to generate */ + formats: SBOMFormat[]; + /** Include development dependencies */ + includeDevDependencies?: boolean; + /** Include peer dependencies */ + includePeerDependencies?: boolean; + /** Output directory for generated files (default: .stackwright/sbom/) */ + outputDir?: string; +} + +/** + * Generated SBOM with all requested formats + */ +export interface SBOM { + /** Project information */ + project: StackwrightProjectInfo; + /** Raw dependency list */ + dependencies: NormalizedDependency[]; + /** SPDX 2.3 document (if requested) */ + spdx?: SPDXDocument; + /** CycloneDX 1.5 document (if requested) */ + cyclonedx?: CycloneDXDocument; + /** Stackwright build manifest (if requested) */ + buildManifest?: BuildManifest; + /** Write all generated formats to disk */ + writeTo(outputPath: string): Promise; + /** Get summary of generated SBOM */ + getSummary(): SBOMSummary; +} + +/** + * Summary of generated SBOM + */ +export interface SBOMSummary { + projectName: string; + projectVersion: string; + totalDependencies: number; + directDependencies: number; + devDependencies: number; + transitiveDependencies: number; + formats: SBOMFormat[]; + outputFiles: string[]; +} + +/** + * Debug logging utility + */ +export function debugLog(message: string, data?: any): void { + if (process.env.NODE_ENV === 'development' && process.env.STACKWRIGHT_DEBUG === 'true') { + console.log(`🐶 SBOM-Generator Debug: ${message}`, data ? JSON.stringify(data, null, 2) : ''); + } +} + +/** + * Create an SBOM for a project + */ +export async function createSBOM(options: SBOMOptions): Promise { + const { + projectRoot, + formats, + includeDevDependencies = false, + includePeerDependencies = true, + } = options; + + debugLog('Creating SBOM', { projectRoot, formats, includeDevDependencies }); + + // Build base context for hooks + const baseContext: SBOMHookContext = { + projectRoot, + formats, + outputDir: options.outputDir ?? resolve(projectRoot, '.stackwright', 'sbom'), + }; + + // Run preGenerate hooks + await runSBOMHooks('preGenerate', baseContext); + + // Analyze dependencies + const { project, dependencies } = await analyzeDependencies({ + projectRoot, + includeDevDependencies, + includePeerDependencies, + }); + + debugLog('Dependencies analyzed', { + count: dependencies.length, + project: project.name, + }); + + // Build context with analysis results + const context: SBOMHookContext = { + ...baseContext, + project, + dependencies, + }; + + // Run postAnalyze hooks + await runSBOMHooks('postAnalyze', context); + + // Initialize SBOM object + const sbom: SBOM = { + project, + dependencies, + writeTo: async (outputPath: string) => { + await writeSBOM(sbom, outputPath, formats, context); + }, + getSummary: () => getSummary(sbom, formats), + }; + + // Generate each format with pre/post format hooks + for (const format of formats) { + await runSBOMHooks('preFormat', { ...context, sbom }); + + if (format === 'spdx') { + debugLog('Generating SPDX format'); + sbom.spdx = generateSPDX(project, dependencies, { + includeDevDependencies, + }); + } else if (format === 'cyclonedx') { + debugLog('Generating CycloneDX format'); + sbom.cyclonedx = generateCycloneDX(project, dependencies, { + includeDevDependencies, + }); + } else if (format === 'build-manifest') { + debugLog('Generating build manifest format'); + sbom.buildManifest = generateBuildManifest(project, dependencies, { + includeDevDependencies, + includeWorkspaceInfo: true, + }); + } + + await runSBOMHooks('postFormat', { ...context, sbom }); + } + + debugLog('SBOM generation complete', { formats }); + + return sbom; +} + +/** + * Write SBOM to disk + */ +async function writeSBOM( + sbom: SBOM, + outputPath: string, + formats: SBOMFormat[], + context: SBOMHookContext +): Promise { + const baseDir = resolve(outputPath); + + // Ensure directory exists + await mkdir(baseDir, { recursive: true }); + + // Run preWrite hooks + await runSBOMHooks('preWrite', { ...context, sbom, outputDir: outputPath }); + + const outputFiles: string[] = []; + + // Write SPDX + if (sbom.spdx && formats.includes('spdx')) { + const spdxPath = resolve(baseDir, 'spdx.json'); + await writeFile(spdxPath, toSPDXJSON(sbom.spdx)); + outputFiles.push(spdxPath); + + const spdxTVPath = resolve(baseDir, 'spdx.spdx'); + await writeFile(spdxTVPath, toSPDXTV(sbom.spdx)); + outputFiles.push(spdxTVPath); + } + + // Write CycloneDX + if (sbom.cyclonedx && formats.includes('cyclonedx')) { + const cyclonedxPath = resolve(baseDir, 'cyclonedx.json'); + await writeFile(cyclonedxPath, toCycloneDXJSON(sbom.cyclonedx)); + outputFiles.push(cyclonedxPath); + } + + // Write Build Manifest + if (sbom.buildManifest && formats.includes('build-manifest')) { + const manifestPath = resolve(baseDir, 'build-manifest.json'); + await writeFile(manifestPath, toBuildManifestJSON(sbom.buildManifest)); + outputFiles.push(manifestPath); + } + + // Run postWrite hooks + await runSBOMHooks('postWrite', { ...context, sbom, outputFiles }); + + debugLog('SBOM written to disk', { outputFiles }); +} + +/** + * Get summary of SBOM + */ +function getSummary(sbom: SBOM, formats: SBOMFormat[]): SBOMSummary { + const directDeps = sbom.dependencies.filter((d) => d.depth === 0); + const devDeps = sbom.dependencies.filter((d) => d.isDev); + const transitiveDeps = sbom.dependencies.filter((d) => d.depth > 0); + + return { + projectName: sbom.project.name, + projectVersion: sbom.project.version, + totalDependencies: sbom.dependencies.length, + directDependencies: directDeps.length, + devDependencies: devDeps.length, + transitiveDependencies: transitiveDeps.length, + formats, + outputFiles: [], // Will be populated after writeTo + }; +} + +/** + * Generate SBOM with default options + */ +export async function generateDefaultSBOM(projectRoot: string, outputDir?: string): Promise { + const defaultOutputDir = outputDir || resolve(projectRoot, '.stackwright', 'sbom'); + + return createSBOM({ + projectRoot, + formats: ['spdx', 'cyclonedx', 'build-manifest'], + outputDir: defaultOutputDir, + }); +} + +/** + * Quick summary without generating full SBOM + */ +export async function quickSummary(projectRoot: string): Promise<{ + name: string; + version: string; + dependencyCount: number; +}> { + const { project, dependencies } = await analyzeDependencies({ + projectRoot, + }); + + return { + name: project.name, + version: project.version, + dependencyCount: dependencies.length, + }; +} diff --git a/packages/sbom-generator/src/index.ts b/packages/sbom-generator/src/index.ts new file mode 100644 index 00000000..c1a7c9b8 --- /dev/null +++ b/packages/sbom-generator/src/index.ts @@ -0,0 +1,62 @@ +/** + * @stackwright/sbom-generator + * + * SBOM generation for Stackwright projects + * Supports SPDX 2.3, CycloneDX 1.5, and Stackwright-specific build manifest formats + */ + +// Core generation API +export { + createSBOM, + generateDefaultSBOM, + quickSummary, + type SBOM, + type SBOMOptions, + type SBOMFormat, + type SBOMSummary, +} from './generator'; + +// Re-export types for consumers +export type { StackwrightProjectInfo } from './normalizers/stackwright'; +export type { NormalizedDependency } from './normalizers/pnpm'; +export type { SPDXDocument } from './formats/spdx'; +export type { CycloneDXDocument } from './formats/cyclonedx'; +export type { BuildManifest } from './formats/build-manifest'; + +// Utility functions +export { sha256, sha256File, sha256SRI, verifySHA256 } from './utils/hash'; + +export { createPURL, npmPURL, githubPURL, parsePURL } from './utils/purl'; + +// Normalizer utilities +export { + normalizeVersion, + parseLockfilePackages, + getDirectDependencies, + categorizeDependencies, +} from './normalizers/pnpm'; + +export { + isStackwrightPackage, + getStackwrightPackageType, + readPackageManifest, + findWorkspacePackages, + getPackageIdentifier, +} from './normalizers/stackwright'; + +// Format generators (for advanced use cases) +export { generateSPDX, toSPDXJSON, toSPDXTV, validateSPDX } from './formats/spdx'; + +export { generateCycloneDX, toCycloneDXJSON, validateCycloneDX } from './formats/cyclonedx'; + +export { + generateBuildManifest, + toBuildManifestJSON, + validateBuildManifest, + getManifestSummary, + BuildManifestSchema, +} from './formats/build-manifest'; + +// Hook types and registry +export type { SBOMHook, SBOMHookType, SBOMHookContext, SBOMHookOptions } from './types/hooks'; +export { registerSBOMHook, getSBOMHooks, clearSBOMHooks, runSBOMHooks } from './registry'; diff --git a/packages/sbom-generator/src/normalizers/pnpm.ts b/packages/sbom-generator/src/normalizers/pnpm.ts new file mode 100644 index 00000000..4ae59320 --- /dev/null +++ b/packages/sbom-generator/src/normalizers/pnpm.ts @@ -0,0 +1,202 @@ +/** + * PNPM lockfile parsing and normalization + * @package @stackwright/sbom-generator + */ + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import yaml from 'js-yaml'; + +export interface LockfilePackage { + /** Resolved version */ + version: string; + /** Optional resolved version string */ + resolvedVersion?: string; + /** Dependencies with version specifiers as keys */ + dependencies?: Record; + /** Peer dependencies */ + peerDependencies?: Record; + /** Optional integrity hash */ + integrity?: string; +} + +export interface NormalizedDependency { + /** Package name (including scope for scoped packages) */ + name: string; + /** Resolved semver version */ + version: string; + /** Package URL for PURL generation */ + resolved?: string; + /** Integrity hash */ + integrity?: string; + /** Direct dependencies of this package */ + dependencies: Record; + /** Peer dependencies */ + peerDependencies?: Record; + /** Whether this is a dev dependency */ + isDev: boolean; + /** Whether this is a peer dependency of something */ + isPeer: boolean; + /** Depth in the dependency tree (0 = direct) */ + depth: number; + /** Optional license */ + license?: string; +} + +/** + * Parse semver version to extract clean version string + */ +export function normalizeVersion(versionSpec: string): string { + return versionSpec.replace(/^[\^~>=<]+/, '').replace(/\s+.*$/, ''); +} + +/** + * Read and parse pnpm-lock.yaml + */ +export async function readPnpmLockfile(projectRoot: string): Promise<{ + lockfile: Record; + version: string; +} | null> { + try { + const lockfilePath = resolve(projectRoot, 'pnpm-lock.yaml'); + const content = await readFile(lockfilePath, 'utf-8'); + const lockfile = yaml.load(content) as Record; + + // Extract lockfile version + const version = (lockfile.lockfileVersion as number)?.toString() || 'unknown'; + + return { lockfile, version }; + } catch { + return null; + } +} + +/** + * Parse lockfile packages section to extract dependencies + */ +export function parseLockfilePackages( + packages: Record, + _projectRoot: string +): NormalizedDependency[] { + const result: NormalizedDependency[] = []; + + for (const [path, pkg] of Object.entries(packages)) { + // Skip if not a node_modules path + if (!path.includes('node_modules/')) continue; + + // Extract package name from path + const name = extractPackageName(path); + if (!name) continue; + + if (!pkg.version) continue; + + const normalizedVersion = normalizeVersion(pkg.version); + + result.push({ + name, + version: normalizedVersion, + resolved: pkg.resolvedVersion, + integrity: pkg.integrity, + dependencies: pkg.dependencies || {}, + peerDependencies: pkg.peerDependencies, + isDev: false, + isPeer: false, + depth: calculateDepth(path), + license: undefined, + }); + } + + return result; +} + +/** + * Extract package name from lockfile path + */ +function extractPackageName(path: string): string | null { + const nodeModulesIndex = path.indexOf('node_modules/'); + if (nodeModulesIndex === -1) return null; + + const afterModules = path.slice(nodeModulesIndex + 'node_modules/'.length); + const segments = afterModules.split('/'); + + if (segments[0].startsWith('@')) { + return `${segments[0]}/${segments[1]}`; + } + + return segments[0]; +} + +/** + * Calculate dependency depth from path + */ +function calculateDepth(path: string): number { + const matches = path.match(/node_modules/g); + if (!matches) return 0; + return Math.max(0, matches.length - 1); +} + +/** + * Read package.json directly + */ +export async function readPackageJson(projectRoot: string): Promise<{ + name?: string; + version?: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} | null> { + try { + const packageJsonPath = resolve(projectRoot, 'package.json'); + const content = await readFile(packageJsonPath, 'utf-8'); + return JSON.parse(content); + } catch { + return null; + } +} + +/** + * Extract direct dependencies from package.json + */ +export async function getDirectDependencies(projectRoot: string): Promise<{ + dependencies: Record; + devDependencies: Record; + peerDependencies: Record; +} | null> { + const manifest = await readPackageJson(projectRoot); + + if (!manifest) return null; + + return { + dependencies: manifest.dependencies || {}, + devDependencies: manifest.devDependencies || {}, + peerDependencies: manifest.peerDependencies || {}, + }; +} + +/** + * Build a map of dependency types (direct, dev, peer) + */ +export function categorizeDependencies( + dependencies: NormalizedDependency[], + directDeps: Record, + devDeps: Record, + peerDeps: Record +): NormalizedDependency[] { + return dependencies.map((dep) => { + const name = dep.name; + + if (name in directDeps) { + return { ...dep, isDev: false, isPeer: false }; + } + + if (name in devDeps) { + return { ...dep, isDev: true, isPeer: false }; + } + + if (name in peerDeps) { + return { ...dep, isDev: false, isPeer: true }; + } + + return { ...dep, isDev: false, isPeer: false }; + }); +} diff --git a/packages/sbom-generator/src/normalizers/stackwright.ts b/packages/sbom-generator/src/normalizers/stackwright.ts new file mode 100644 index 00000000..32eec0bd --- /dev/null +++ b/packages/sbom-generator/src/normalizers/stackwright.ts @@ -0,0 +1,145 @@ +/** + * Stackwright-specific package metadata normalization + * @package @stackwright/sbom-generator + */ + +import { readFile } from 'node:fs/promises'; +import { readdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +export interface StackwrightProjectInfo { + name: string; + version: string; + root: string; + description?: string; + license?: string; + repository?: string; + workspacePackages?: WorkspacePackage[]; + isMonorepo: boolean; +} + +export interface WorkspacePackage { + name: string; + version: string; + relativePath: string; + manifestPath: string; +} + +const STACKWRIGHT_SCOPES = ['@stackwright', '@per-aspera']; + +export function isStackwrightPackage(name: string): boolean { + return STACKWRIGHT_SCOPES.some((scope) => name.startsWith(`${scope}/`)); +} + +export function getStackwrightPackageType( + name: string +): 'core' | 'theme' | 'plugin' | 'tool' | 'external' | 'example' { + if (!isStackwrightPackage(name)) { + return 'external'; + } + + if (name.includes('core')) return 'core'; + if (name.includes('theme')) return 'theme'; + if (name.includes('plugin') || name.includes('mcp')) return 'plugin'; + if (name.includes('ui-')) return 'plugin'; + if (name.includes('example')) return 'example'; + + return 'tool'; +} + +export async function readPackageManifest( + projectRoot: string +): Promise { + try { + const packageJsonPath = resolve(projectRoot, 'package.json'); + const content = await readFile(packageJsonPath, 'utf-8'); + const manifest = JSON.parse(content); + + // Check if it's a workspace (monorepo root) + if (manifest.workspaces) { + const workspacePackages = await findWorkspacePackages(projectRoot); + + return { + name: manifest.name || 'stackwright-monorepo', + version: manifest.version || '0.0.0', + root: projectRoot, + description: manifest.description, + license: manifest.license, + repository: + typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url, + isMonorepo: true, + workspacePackages, + }; + } + + return { + name: manifest.name || 'unknown', + version: manifest.version || '0.0.0', + root: projectRoot, + description: manifest.description, + license: manifest.license, + repository: + typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url, + isMonorepo: false, + }; + } catch { + return null; + } +} + +export async function findWorkspacePackages(workspaceRoot: string): Promise { + const packages: WorkspacePackage[] = []; + + try { + const packagesDir = resolve(workspaceRoot, 'packages'); + const entries = await readdir(packagesDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const manifestPath = resolve(packagesDir, entry.name, 'package.json'); + + try { + const content = await readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(content); + + if (manifest.name) { + packages.push({ + name: manifest.name, + version: manifest.version || '0.0.0', + relativePath: `packages/${entry.name}`, + manifestPath, + }); + } + } catch { + // Skip packages without valid manifest + } + } + } catch { + // Packages directory doesn't exist + } + + return packages; +} + +export function getPackageIdentifier(name: string, version: string): string { + const sanitizedName = name.replace(/[^a-zA-Z0-9.-]/g, '-'); + const sanitizedVersion = version.replace(/[^a-zA-Z0-9.-]/g, '-'); + return `SPDXRef-${sanitizedName}-${sanitizedVersion}`; +} + +export function extractLicense( + license: string | { type: string; url?: string } | undefined +): string | undefined { + if (!license) return undefined; + if (typeof license === 'string') return license; + return license.type; +} + +export function getPackagePriority(name: string): number { + if (name === '@stackwright/core') return 100; + if (name === '@stackwright/themes') return 90; + if (name === '@stackwright/nextjs') return 85; + if (isStackwrightPackage(name)) return 70; + return 0; +} diff --git a/packages/sbom-generator/src/registry.ts b/packages/sbom-generator/src/registry.ts new file mode 100644 index 00000000..0fd5409e --- /dev/null +++ b/packages/sbom-generator/src/registry.ts @@ -0,0 +1,83 @@ +/** + * SBOM Hook Registry + * + * Singleton registry for SBOM hooks. Pro packages auto-register by importing. + * Consistent with Stackwright's registerXxx() pattern: + * - @stackwright/icons → registerDefaultIcons() + * - @stackwright/nextjs → registerNextJSComponents() + * - @stackwright/maplibre → registerMapLibreProvider() + */ + +import type { SBOMHook, SBOMHookContext, SBOMHookType } from './types/hooks'; + +// Internal storage +let hooks: SBOMHook[] = []; + +/** + * Register a hook to run during SBOM generation + * + * @example + * import { registerSBOMHook } from '@stackwright/sbom-generator'; + * + * registerSBOMHook({ + * type: 'postAnalyze', + * name: 'cve-enrichment', + * priority: 10, + * handler: enrichWithCVEs, + * }); + */ +export function registerSBOMHook(hook: SBOMHook): void { + hooks.push({ + ...hook, + priority: hook.priority ?? 50, + critical: hook.critical ?? false, + }); + // Sort by priority (lower = runs first) + hooks.sort((a, b) => a.priority! - b.priority!); +} + +/** + * Get all registered hooks + */ +export function getSBOMHooks(): ReadonlyArray { + return [...hooks]; +} + +/** + * Get hooks for a specific lifecycle point + */ +export function getHooksForType(type: SBOMHookType): ReadonlyArray { + return hooks.filter((h) => h.type === type); +} + +/** + * Clear all registered hooks (useful for testing) + */ +export function clearSBOMHooks(): void { + hooks = []; +} + +/** + * Run all hooks of a given type + * + * @param type - Lifecycle point + * @param context - Context passed to hooks + * @throws If a critical hook fails + */ +export async function runSBOMHooks(type: SBOMHookType, context: SBOMHookContext): Promise { + const relevantHooks = getHooksForType(type); + + for (const hook of relevantHooks) { + try { + await hook.handler(context); + } catch (error) { + if (hook.critical) { + throw new Error(`Critical SBOM hook "${hook.name}" failed: ${(error as Error).message}`); + } + // Non-critical: warn but continue + console.warn( + `[SBOM Hook] Non-critical hook "${hook.name}" failed: ${(error as Error).message}` + ); + } + } +} diff --git a/packages/sbom-generator/src/types.ts b/packages/sbom-generator/src/types.ts new file mode 100644 index 00000000..b3628aac --- /dev/null +++ b/packages/sbom-generator/src/types.ts @@ -0,0 +1,13 @@ +/** + * Shared types for SBOM generator + * @package @stackwright/sbom-generator + */ + +// Re-export from normalizers for convenience +export type { StackwrightProjectInfo } from './normalizers/stackwright'; +export type { NormalizedDependency } from './normalizers/pnpm'; + +// Re-export from formats +export type { SPDXDocument, SPDXPackage, SPDXRelationship } from './formats/spdx'; +export type { CycloneDXDocument, CycloneDXComponent } from './formats/cyclonedx'; +export type { BuildManifest } from './formats/build-manifest'; diff --git a/packages/sbom-generator/src/types/hooks.ts b/packages/sbom-generator/src/types/hooks.ts new file mode 100644 index 00000000..91b0cd24 --- /dev/null +++ b/packages/sbom-generator/src/types/hooks.ts @@ -0,0 +1,78 @@ +/** + * SBOM Hook Types + * + * Hooks allow Pro packages to extend SBOM generation with: + * - Vulnerability enrichment (OSV/NVD) + * - SBOM signing (sigstore/cosign) + * - SLSA provenance attestation + * - Registry publishing + */ + +import type { StackwrightProjectInfo, NormalizedDependency } from '../types'; + +/** + * Hook types representing lifecycle points in SBOM generation + */ +export type SBOMHookType = + | 'preGenerate' // Before any analysis begins + | 'postAnalyze' // After dependency analysis, before format generation + | 'preFormat' // Before each format generation (runs per format) + | 'postFormat' // After each format generation (runs per format) + | 'preWrite' // Before writing files to disk + | 'postWrite'; // After all files written + +/** + * Forward declaration for SBOMFormat - actual type defined in generator.ts + * Using string union for compatibility + */ +export type SBOMFormat = 'spdx' | 'cyclonedx' | 'build-manifest'; + +/** + * A single SBOM hook + */ +export interface SBOMHook { + /** Lifecycle point when hook runs */ + type: SBOMHookType; + /** Unique name for the hook */ + name: string; + /** Lower priority = runs first (default: 50) */ + priority?: number; + /** If true, hook failure fails entire SBOM generation (default: false) */ + critical?: boolean; + /** Hook handler function */ + handler: (context: SBOMHookContext) => Promise | void; +} + +/** + * Hook registration options + */ +export interface SBOMHookOptions extends Partial { + type: SBOMHookType; + name: string; +} + +/** + * Context passed to all hooks + */ +export interface SBOMHookContext { + /** Project root directory */ + projectRoot: string; + /** Output formats being generated */ + formats: SBOMFormat[]; + /** Output directory path */ + outputDir: string; + /** Project metadata (available after postAnalyze) */ + project?: StackwrightProjectInfo; + /** Dependencies list (available after postAnalyze) */ + dependencies?: NormalizedDependency[]; + /** Generated SBOM documents */ + spdx?: any; + cyclonedx?: any; + buildManifest?: any; + /** Full SBOM object (available during format generation and write) */ + sbom?: any; + /** Output files written to disk (available after postWrite) */ + outputFiles?: string[]; + /** Additional properties hooks can add */ + [key: string]: any; +} diff --git a/packages/sbom-generator/src/utils/hash.ts b/packages/sbom-generator/src/utils/hash.ts new file mode 100644 index 00000000..180757ef --- /dev/null +++ b/packages/sbom-generator/src/utils/hash.ts @@ -0,0 +1,48 @@ +/** + * SHA-256 hashing utilities for SBOM integrity verification + * @package @stackwright/sbom-generator + */ + +import { createHash } from 'node:crypto'; + +/** + * Compute SHA-256 hash of a string or buffer + * @param content - The content to hash + * @returns Hex-encoded SHA-256 hash + */ +export function sha256(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex'); +} + +/** + * Compute SHA-256 hash of a file's contents + * @param filePath - Path to the file (must be readable) + * @returns Promise resolving to hex-encoded SHA-256 hash + */ +export async function sha256File(filePath: string): Promise { + const { readFile } = await import('node:fs/promises'); + const content = await readFile(filePath); + return sha256(content); +} + +/** + * Generate integrity string in SRI format (sha256-) + * @param content - The content to hash + * @returns SRI-compatible integrity string + */ +export function sha256SRI(content: string | Buffer): string { + const hash = sha256(content); + const base64 = Buffer.from(hash, 'hex').toString('base64'); + return `sha256-${base64}`; +} + +/** + * Verify content matches expected hash + * @param content - Content to verify + * @param expectedHash - Expected SHA-256 hash (hex) + * @returns true if hash matches + */ +export function verifySHA256(content: string | Buffer, expectedHash: string): boolean { + const actualHash = sha256(content); + return actualHash === expectedHash; +} diff --git a/packages/sbom-generator/src/utils/purl.ts b/packages/sbom-generator/src/utils/purl.ts new file mode 100644 index 00000000..a148e0d2 --- /dev/null +++ b/packages/sbom-generator/src/utils/purl.ts @@ -0,0 +1,170 @@ +/** + * Package URL (PURL) generation utilities + * @package @stackwright/sbom-generator + * @see https://github.com/package-url/purl-spec + */ + +/** + * PURL types supported by this generator + */ +export type PURLType = 'npm' | 'github' | 'pypi' | 'maven' | 'gem' | 'nuget'; + +/** + * Generate a standard Package URL (PURL) + * Format: pkg://@[?qualifiers][#subpath] + * + * @param options - PURL generation options + * @returns Properly formatted PURL string + */ +export function createPURL(options: { + type: PURLType; + namespace?: string; + name: string; + version?: string; + qualifiers?: Record; + subpath?: string; +}): string { + const { type, namespace, name, version, qualifiers, subpath } = options; + + const parts: string[] = [`pkg:${type}`]; + + // Add namespace if present + if (namespace) { + parts.push(namespace); + } + + // Add name (always required) + parts.push(name); + + // Add version with @ prefix + if (version) { + const v = parts.pop()!; + parts.push(`${v}@${version}`); + } + + // Add qualifiers + if (qualifiers && Object.keys(qualifiers).length > 0) { + const qs = new URLSearchParams(qualifiers).toString(); + const last = parts[parts.length - 1]; + parts[parts.length - 1] = `${last}?${qs}`; + } + + // Add subpath with # prefix + if (subpath) { + const last = parts.pop()!; + parts.push(`${last}#${subpath}`); + } + + return parts.join('/'); +} + +/** + * Generate npm PURL + * @param name - Package name (supports scoped packages like @scope/name) + * @param version - Package version + * @returns npm PURL string + */ +export function npmPURL(name: string, version?: string): string { + // Handle scoped packages (@scope/name) + const [scopePart, ...nameParts] = name.split('/'); + + if (scopePart.startsWith('@')) { + return createPURL({ + type: 'npm', + namespace: scopePart.slice(1), + name: nameParts.join('/'), + version, + }); + } + + return createPURL({ + type: 'npm', + name: scopePart, + version, + }); +} + +/** + * Generate GitHub PURL + * @param owner - Repository owner + * @param repo - Repository name + * @param version - Tag or commit SHA + * @returns GitHub PURL string + */ +export function githubPURL(owner: string, repo: string, version?: string): string { + return createPURL({ + type: 'github', + namespace: owner, + name: repo, + version, + }); +} + +/** + * Parse a PURL back into its components + * @param purl - The PURL string to parse + * @returns Parsed PURL components + */ +export function parsePURL(purl: string): { + type: PURLType; + namespace?: string; + name: string; + version?: string; + qualifiers?: Record; + subpath?: string; +} | null { + try { + // Remove scheme prefix + const withoutScheme = purl.replace(/^pkg:/, ''); + + // Split by / to get components + const parts = withoutScheme.split('/'); + const type = parts[0] as PURLType; + + let remaining = parts.slice(1); + let namespace: string | undefined; + let name: string; + let version: string | undefined; + let qualifiers: Record | undefined; + let subpath: string | undefined; + + // Handle @ in version (e.g., name@version) + if (remaining.length > 0) { + const lastPart = remaining[remaining.length - 1]; + + // Check for version in last part + if (lastPart.includes('@')) { + const [n, v] = lastPart.split('@'); + remaining[remaining.length - 1] = n; + version = v; + } + + // Check for subpath + const hashIndex = lastPart.indexOf('#'); + if (hashIndex !== -1) { + subpath = lastPart.slice(hashIndex + 1); + remaining[remaining.length - 1] = lastPart.slice(0, hashIndex); + } + + // Check for qualifiers + const questIndex = remaining[remaining.length - 1].indexOf('?'); + if (questIndex !== -1) { + const qs = remaining[remaining.length - 1].slice(questIndex + 1); + remaining[remaining.length - 1] = remaining[remaining.length - 1].slice(0, questIndex); + qualifiers = Object.fromEntries(new URLSearchParams(qs)); + } + } + + // Namespace is everything before name in non-npm packages + if (type !== 'npm' && remaining.length > 1) { + namespace = remaining[0]; + remaining = remaining.slice(1); + } + + name = remaining.join('/'); + + return { type, namespace, name, version, qualifiers, subpath }; + } catch { + return null; + } +} diff --git a/packages/sbom-generator/test/fixtures/minimal-package.json b/packages/sbom-generator/test/fixtures/minimal-package.json new file mode 100644 index 00000000..c1893cf4 --- /dev/null +++ b/packages/sbom-generator/test/fixtures/minimal-package.json @@ -0,0 +1,13 @@ +{ + "name": "@stackwright/test-minimal", + "version": "1.0.0", + "description": "A minimal Stackwright package for testing", + "dependencies": { + "js-yaml": "^4.1.0", + "zod": "^3.22.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/packages/sbom-generator/test/fixtures/monorepo-package.json b/packages/sbom-generator/test/fixtures/monorepo-package.json new file mode 100644 index 00000000..3a660322 --- /dev/null +++ b/packages/sbom-generator/test/fixtures/monorepo-package.json @@ -0,0 +1,21 @@ +{ + "name": "@stackwright/monorepo-root", + "version": "0.5.0", + "private": true, + "description": "Stackwright monorepo root package", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@stackwright/core": "workspace:*", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0", + "vitest": "^4.0.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } +} diff --git a/packages/sbom-generator/test/formats/cyclonedx.test.ts b/packages/sbom-generator/test/formats/cyclonedx.test.ts new file mode 100644 index 00000000..985eb79a --- /dev/null +++ b/packages/sbom-generator/test/formats/cyclonedx.test.ts @@ -0,0 +1,201 @@ +/** + * CycloneDX format tests + * @package @stackwright/sbom-generator + */ + +import { describe, it, expect } from 'vitest'; +import { generateCycloneDX, toCycloneDXJSON, validateCycloneDX } from '../../src/formats/cyclonedx'; +import { StackwrightProjectInfo } from '../../src/normalizers/stackwright'; +import { NormalizedDependency } from '../../src/normalizers/pnpm'; + +describe('CycloneDX Format', () => { + const mockProject: StackwrightProjectInfo = { + name: '@stackwright/test-package', + version: '1.0.0', + root: '/test', + description: 'Test package', + license: 'MIT', + isMonorepo: false, + }; + + const mockDependencies: NormalizedDependency[] = [ + { + name: 'js-yaml', + version: '4.1.0', + dependencies: {}, + isDev: false, + isPeer: false, + depth: 0, + }, + { + name: 'zod', + version: '3.22.0', + dependencies: { tslib: '^2.0.0' }, + isDev: false, + isPeer: false, + depth: 0, + }, + { + name: 'typescript', + version: '5.0.0', + dependencies: {}, + isDev: true, + isPeer: false, + depth: 0, + }, + ]; + + describe('generateCycloneDX', () => { + it('should generate valid CycloneDX document', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + expect(doc.bomFormat).toBe('CycloneDX'); + expect(doc.specVersion).toBe('1.5'); + expect(doc.version).toBe(1); + }); + + it('should include metadata with tool info', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + expect(doc.metadata).toBeDefined(); + expect(doc.metadata?.tools).toBeDefined(); + expect(doc.metadata?.tools[0].name).toBe('@stackwright/sbom-generator'); + }); + + it('should include main component in metadata', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + expect(doc.metadata?.component).toBeDefined(); + expect(doc.metadata?.component?.name).toBe('@stackwright/test-package'); + expect(doc.metadata?.component?.version).toBe('1.0.0'); + }); + + it('should include all packages as components', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + expect(doc.components).toHaveLength(2); // Dev excluded by default + }); + + it('should include dev dependencies when requested', () => { + const doc = generateCycloneDX(mockProject, mockDependencies, { + includeDevDependencies: true, + }); + + expect(doc.components).toHaveLength(3); + }); + + it('should include PURL for each component', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + const jsYaml = doc.components.find((c) => c.name === 'js-yaml'); + expect(jsYaml?.purl).toBe('pkg:npm/js-yaml@4.1.0'); + }); + + it('should include SHA-256 hashes', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + for (const component of doc.components) { + expect(component.hashes).toBeDefined(); + expect(component.hashes?.some((h) => h.alg === 'SHA-256')).toBe(true); + } + }); + + it('should include license info', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + const mainComponent = doc.metadata?.component; + expect(mainComponent?.licenses).toBeDefined(); + }); + + it('should include external references', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + const jsYaml = doc.components.find((c) => c.name === 'js-yaml'); + expect(jsYaml?.externalReferences).toBeDefined(); + expect(jsYaml?.externalReferences[0].type).toBe('package-manager'); + }); + + it('should build dependency graph', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + expect(doc.dependencies).toBeDefined(); + }); + + it('should set correct scope for dev dependencies', () => { + const doc = generateCycloneDX(mockProject, mockDependencies, { + includeDevDependencies: true, + }); + + const typescript = doc.components.find((c) => c.name === 'typescript'); + expect(typescript?.scope).toBe('optional'); + }); + + it('should set correct scope for regular dependencies', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + const jsYaml = doc.components.find((c) => c.name === 'js-yaml'); + expect(jsYaml?.scope).toBe('required'); + }); + }); + + describe('toCycloneDXJSON', () => { + it('should generate valid JSON', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + const json = toCycloneDXJSON(doc); + + expect(() => JSON.parse(json)).not.toThrow(); + }); + + it('should include all required fields', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + const json = toCycloneDXJSON(doc); + const parsed = JSON.parse(json); + + expect(parsed.bomFormat).toBe('CycloneDX'); + expect(parsed.components).toBeDefined(); + }); + + it('should format as pretty JSON', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + const json = toCycloneDXJSON(doc); + + expect(json).toContain('\n'); + }); + }); + + describe('validateCycloneDX', () => { + it('should validate correct CycloneDX document', () => { + const doc = generateCycloneDX(mockProject, mockDependencies); + + expect(validateCycloneDX(doc)).toBe(true); + }); + + it('should reject non-CycloneDX documents', () => { + expect(validateCycloneDX({})).toBe(false); + expect(validateCycloneDX(null)).toBe(false); + expect(validateCycloneDX({ bomFormat: 'Wrong' })).toBe(false); + }); + + it('should reject documents without components', () => { + expect( + validateCycloneDX({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + components: [], + }) + ).toBe(false); + }); + + it('should reject documents with wrong spec version', () => { + expect( + validateCycloneDX({ + bomFormat: 'CycloneDX', + specVersion: '1.4', + version: 1, + components: [{ name: 'test' }], + }) + ).toBe(false); + }); + }); +}); diff --git a/packages/sbom-generator/test/formats/spdx.test.ts b/packages/sbom-generator/test/formats/spdx.test.ts new file mode 100644 index 00000000..098a5591 --- /dev/null +++ b/packages/sbom-generator/test/formats/spdx.test.ts @@ -0,0 +1,185 @@ +/** + * SPDX format tests + * @package @stackwright/sbom-generator + */ + +import { describe, it, expect } from 'vitest'; +import { generateSPDX, toSPDXJSON, toSPDXTV, validateSPDX } from '../../src/formats/spdx'; +import { StackwrightProjectInfo } from '../../src/normalizers/stackwright'; +import { NormalizedDependency } from '../../src/normalizers/pnpm'; + +describe('SPDX Format', () => { + const mockProject: StackwrightProjectInfo = { + name: '@stackwright/test-package', + version: '1.0.0', + root: '/test', + description: 'Test package', + license: 'MIT', + isMonorepo: false, + }; + + const mockDependencies: NormalizedDependency[] = [ + { + name: 'js-yaml', + version: '4.1.0', + dependencies: {}, + isDev: false, + isPeer: false, + depth: 0, + }, + { + name: 'zod', + version: '3.22.0', + dependencies: { tslib: '^2.0.0' }, + isDev: false, + isPeer: false, + depth: 0, + }, + { + name: 'typescript', + version: '5.0.0', + dependencies: {}, + isDev: true, + isPeer: false, + depth: 0, + }, + ]; + + describe('generateSPDX', () => { + it('should generate valid SPDX document', () => { + const doc = generateSPDX(mockProject, mockDependencies, { + includeDevDependencies: true, + }); + + expect(doc.spdxVersion).toBe('SPDX-2.3'); + expect(doc.dataLicense).toBe('CC0-1.0'); + expect(doc.SPDXID).toMatch(/^SPDXRef-DOCUMENT-/); + expect(doc.name).toBe('@stackwright/test-package@1.0.0'); + expect(doc.documentNamespace).toContain('stackwright.dev'); + expect(doc.creationInfo.creators).toContain('Tool: @stackwright/sbom-generator'); + }); + + it('should include all packages', () => { + const doc = generateSPDX(mockProject, mockDependencies); + + expect(doc.packages).toHaveLength(2); // Dev excluded by default + expect(doc.packages.some((p) => p.name === 'js-yaml')).toBe(true); + expect(doc.packages.some((p) => p.name === 'zod')).toBe(true); + expect(doc.packages.some((p) => p.name === 'typescript')).toBe(false); + }); + + it('should include dev dependencies when requested', () => { + const doc = generateSPDX(mockProject, mockDependencies, { + includeDevDependencies: true, + }); + + expect(doc.packages).toHaveLength(3); + expect(doc.packages.some((p) => p.name === 'typescript')).toBe(true); + }); + + it('should include PURL external references', () => { + const doc = generateSPDX(mockProject, mockDependencies); + + const jsYamlPkg = doc.packages.find((p) => p.name === 'js-yaml'); + expect(jsYamlPkg?.externalRefs).toBeDefined(); + expect(jsYamlPkg?.externalRefs[0].referenceType).toBe('purl'); + expect(jsYamlPkg?.externalRefs[0].referenceLocator).toBe('pkg:npm/js-yaml@4.1.0'); + }); + + it('should include checksums for all packages', () => { + const doc = generateSPDX(mockProject, mockDependencies); + + for (const pkg of doc.packages) { + expect(pkg.checksums).toBeDefined(); + expect(pkg.checksums.some((c) => c.algorithm === 'SHA256')).toBe(true); + } + }); + + it('should generate relationships', () => { + const doc = generateSPDX(mockProject, mockDependencies); + + expect(doc.relationships.length).toBeGreaterThan(0); + expect(doc.relationships[0].relationshipType).toBe('DESCRIBES'); + }); + + it('should use custom namespace when provided', () => { + const customNamespace = 'https://custom.example.com/sbom'; + const doc = generateSPDX(mockProject, mockDependencies, { + namespace: customNamespace, + }); + + expect(doc.documentNamespace).toContain(customNamespace); + }); + }); + + describe('toSPDXJSON', () => { + it('should generate valid JSON', () => { + const doc = generateSPDX(mockProject, mockDependencies); + const json = toSPDXJSON(doc); + + expect(() => JSON.parse(json)).not.toThrow(); + }); + + it('should include all required fields in JSON', () => { + const doc = generateSPDX(mockProject, mockDependencies); + const json = toSPDXJSON(doc); + const parsed = JSON.parse(json); + + expect(parsed.spdxVersion).toBe('SPDX-2.3'); + expect(parsed.packages).toBeDefined(); + expect(parsed.relationships).toBeDefined(); + }); + }); + + describe('toSPDXTV', () => { + it('should generate tag-value format', () => { + const doc = generateSPDX(mockProject, mockDependencies); + const tv = toSPDXTV(doc); + + expect(tv).toContain('SPDXVersion: SPDX-2.3'); + expect(tv).toContain('SPDXID: SPDXRef-DOCUMENT'); + expect(tv).toContain('PackageName: js-yaml'); + }); + + it('should include package details', () => { + const doc = generateSPDX(mockProject, mockDependencies); + const tv = toSPDXTV(doc); + + expect(tv).toContain('PackageVersion: 4.1.0'); + expect(tv).toContain('PackageChecksum: SHA256:'); + }); + + it('should include relationships', () => { + const doc = generateSPDX(mockProject, mockDependencies); + const tv = toSPDXTV(doc); + + expect(tv).toContain('Relationship:'); + }); + }); + + describe('validateSPDX', () => { + it('should validate correct SPDX document', () => { + const doc = generateSPDX(mockProject, mockDependencies); + + expect(validateSPDX(doc)).toBe(true); + }); + + it('should reject non-SPDX documents', () => { + expect(validateSPDX({})).toBe(false); + expect(validateSPDX(null)).toBe(false); + expect(validateSPDX({ spdxVersion: 'WRONG' })).toBe(false); + }); + + it('should reject documents without packages', () => { + expect( + validateSPDX({ + spdxVersion: 'SPDX-2.3', + SPDXID: 'test', + name: 'test', + packages: [], + relationships: [], + }) + ).toBe(false); + }); + }); +}); diff --git a/packages/sbom-generator/test/generator.test.ts b/packages/sbom-generator/test/generator.test.ts new file mode 100644 index 00000000..938413eb --- /dev/null +++ b/packages/sbom-generator/test/generator.test.ts @@ -0,0 +1,161 @@ +/** + * Generator tests + * @package @stackwright/sbom-generator + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createSBOM } from '../src/generator'; +import { rm } from 'node:fs/promises'; +import { mkdtempSync } from 'node:fs'; + +// Mock dependencies - 2 regular + 1 dev +const allMockDeps = [ + { name: 'js-yaml', version: '4.1.0', dependencies: {}, isDev: false, isPeer: false, depth: 0 }, + { name: 'zod', version: '3.22.0', dependencies: {}, isDev: false, isPeer: false, depth: 0 }, + { name: 'typescript', version: '5.0.0', dependencies: {}, isDev: true, isPeer: false, depth: 0 }, +]; + +vi.mock('../src/analyzer', () => ({ + analyzeDependencies: vi + .fn() + .mockImplementation(async (options: { includeDevDependencies?: boolean }) => { + const deps = options.includeDevDependencies + ? allMockDeps + : allMockDeps.filter((d) => !d.isDev); + return { + project: { + name: '@stackwright/test-package', + version: '1.0.0', + root: '/test', + isMonorepo: false, + }, + dependencies: deps, + }; + }), +})); + +describe('SBOM Generator', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync('/tmp/sbom-test-'); + }); + + afterEach(async () => { + try { + await rm(tempDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + describe('createSBOM', () => { + it('should create SBOM with all formats', async () => { + const sbom = await createSBOM({ + projectRoot: '/test', + formats: ['spdx', 'cyclonedx', 'build-manifest'], + }); + + expect(sbom.project.name).toBe('@stackwright/test-package'); + expect(sbom.dependencies).toHaveLength(2); // dev deps excluded by default + expect(sbom.spdx).toBeDefined(); + expect(sbom.cyclonedx).toBeDefined(); + expect(sbom.buildManifest).toBeDefined(); + }); + + it('should create SBOM with only SPDX format', async () => { + const sbom = await createSBOM({ + projectRoot: '/test', + formats: ['spdx'], + }); + + expect(sbom.spdx).toBeDefined(); + expect(sbom.cyclonedx).toBeUndefined(); + expect(sbom.buildManifest).toBeUndefined(); + }); + + it('should create SBOM with only CycloneDX format', async () => { + const sbom = await createSBOM({ + projectRoot: '/test', + formats: ['cyclonedx'], + }); + + expect(sbom.spdx).toBeUndefined(); + expect(sbom.cyclonedx).toBeDefined(); + expect(sbom.buildManifest).toBeUndefined(); + }); + + it('should create SBOM with only build manifest format', async () => { + const sbom = await createSBOM({ + projectRoot: '/test', + formats: ['build-manifest'], + }); + + expect(sbom.spdx).toBeUndefined(); + expect(sbom.cyclonedx).toBeUndefined(); + expect(sbom.buildManifest).toBeDefined(); + }); + + it('should include dev dependencies when requested', async () => { + const sbom = await createSBOM({ + projectRoot: '/test', + formats: ['build-manifest'], + includeDevDependencies: true, + }); + + expect(sbom.dependencies).toHaveLength(3); + expect(sbom.buildManifest?.dependencies.some((d) => d.type === 'dev')).toBe(true); + }); + + it('should exclude dev dependencies by default', async () => { + const sbom = await createSBOM({ + projectRoot: '/test', + formats: ['build-manifest'], + }); + + expect(sbom.buildManifest?.dependencies.some((d) => d.type === 'dev')).toBe(false); + }); + }); + + describe('SBOM.writeTo', () => { + it('should write all formats to disk', async () => { + const sbom = await createSBOM({ + projectRoot: '/test', + formats: ['spdx', 'cyclonedx', 'build-manifest'], + }); + + await sbom.writeTo(tempDir); + + const summary = sbom.getSummary(); + expect(summary.formats).toContain('spdx'); + expect(summary.formats).toContain('cyclonedx'); + expect(summary.formats).toContain('build-manifest'); + }); + }); + + describe('SBOM.getSummary', () => { + it('should return correct summary with dev deps excluded by default', async () => { + const sbom = await createSBOM({ + projectRoot: '/test', + formats: ['spdx', 'build-manifest'], + }); + + const summary = sbom.getSummary(); + expect(summary.projectName).toBe('@stackwright/test-package'); + expect(summary.totalDependencies).toBe(2); + expect(summary.devDependencies).toBe(0); + }); + + it('should include dev dependencies in summary when included', async () => { + const sbom = await createSBOM({ + projectRoot: '/test', + formats: ['build-manifest'], + includeDevDependencies: true, + }); + + const summary = sbom.getSummary(); + expect(summary.totalDependencies).toBe(3); + expect(summary.devDependencies).toBe(1); + }); + }); +}); diff --git a/packages/sbom-generator/tsconfig.json b/packages/sbom-generator/tsconfig.json new file mode 100644 index 00000000..d82963d2 --- /dev/null +++ b/packages/sbom-generator/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "baseUrl": "./src", + "types": ["node", "vitest/globals"] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/sbom-generator/tsup.config.ts b/packages/sbom-generator/tsup.config.ts new file mode 100644 index 00000000..9eacd119 --- /dev/null +++ b/packages/sbom-generator/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + target: 'es2022', + dts: true, + splitting: false, + sourcemap: true, + clean: true, + outExtension({ format }) { + return { + js: format === 'cjs' ? '.js' : '.mjs', + }; + }, +}); diff --git a/packages/sbom-generator/vitest.config.ts b/packages/sbom-generator/vitest.config.ts new file mode 100644 index 00000000..0a22d09f --- /dev/null +++ b/packages/sbom-generator/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'json-summary', 'html'], + reportsDirectory: './coverage', + exclude: ['dist/**', 'test/**', '**/*.test.ts', '**/*.spec.ts'], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab3c3573..21dd685e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,69 +133,11 @@ importers: specifier: ^5.8.3 version: 5.9.3 - examples/stackwright-docs: - dependencies: - '@stackwright/core': - specifier: workspace:* - version: link:../../packages/core - '@stackwright/icons': - specifier: workspace:* - version: link:../../packages/icons - '@stackwright/nextjs': - specifier: workspace:* - version: link:../../packages/nextjs - '@stackwright/ui-shadcn': - specifier: workspace:* - version: link:../../packages/ui-shadcn - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 - next: - specifier: ^16.1.6 - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: - specifier: 19.2.4 - version: 19.2.4 - react-dom: - specifier: 19.2.4 - version: 19.2.4(react@19.2.4) - stackwright-docs: - specifier: 'link:' - version: 'link:' - devDependencies: - '@playwright/browser-chromium': - specifier: ^1.58.2 - version: 1.58.2 - '@stackwright/build-scripts': - specifier: workspace:* - version: link:../../packages/build-scripts - '@types/js-yaml': - specifier: ^4.0.9 - version: 4.0.9 - '@types/node': - specifier: ^24.1.0 - version: 24.11.0 - '@types/react': - specifier: ^19.2.14 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - eslint: - specifier: ^9.39.2 - version: 9.39.3(jiti@2.6.1) - eslint-config-next: - specifier: ^16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - playwright: - specifier: ^1.58.2 - version: 1.58.2 - typescript: - specifier: ^5.8.3 - version: 5.9.3 - packages/build-scripts: dependencies: + '@stackwright/sbom-generator': + specifier: workspace:* + version: link:../sbom-generator '@stackwright/types': specifier: workspace:* version: link:../types @@ -227,6 +169,9 @@ importers: '@stackwright/build-scripts': specifier: workspace:* version: link:../build-scripts + '@stackwright/sbom-generator': + specifier: workspace:* + version: link:../sbom-generator '@stackwright/themes': specifier: workspace:* version: link:../themes @@ -576,6 +521,31 @@ importers: packages/otters: {} + packages/sbom-generator: + dependencies: + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/js-yaml': + specifier: ^4.0.0 + version: 4.0.9 + '@types/node': + specifier: ^24.1.0 + version: 24.11.0 + tsup: + specifier: ^8.5.0 + version: 8.5.0(@swc/core@1.15.18)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/themes: dependencies: js-yaml: @@ -9315,26 +9285,6 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.1.6 - eslint: 9.39.3(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3(jiti@2.6.1)) - globals: 16.4.0 - typescript-eslint: 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)): dependencies: eslint: 9.39.3(jiti@2.6.1) @@ -9362,21 +9312,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.3(jiti@2.6.1) - get-tsconfig: 4.10.1 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.14 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): dependencies: debug: 3.2.7 @@ -9388,17 +9323,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.3(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -9428,35 +9352,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.3(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 10.2.4 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.3(jiti@2.6.1)): dependencies: aria-query: 5.3.2 From 085ce8d533bcee731e1608de0b340167ace00109 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 2 Apr 2026 15:14:07 +0000 Subject: [PATCH 03/14] chore: bump prerelease versions [skip ci] --- .changeset/pre.json | 6 ++++- packages/build-scripts/CHANGELOG.md | 24 +++++++++++++++++ packages/build-scripts/package.json | 2 +- packages/cli/CHANGELOG.md | 25 +++++++++++++++++ packages/cli/package.json | 2 +- packages/launch-stackwright/CHANGELOG.md | 9 +++++++ packages/launch-stackwright/package.json | 2 +- packages/mcp/CHANGELOG.md | 7 +++++ packages/mcp/package.json | 2 +- packages/otters/CHANGELOG.md | 11 ++++++++ packages/otters/package.json | 2 +- packages/sbom-generator/CHANGELOG.md | 34 ++++++++++++++++++++++++ packages/sbom-generator/package.json | 2 +- 13 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 packages/sbom-generator/CHANGELOG.md diff --git a/.changeset/pre.json b/.changeset/pre.json index e0c833f4..6b3a7ba0 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -17,7 +17,8 @@ "launch-stackwright": "0.1.0", "@stackwright/maplibre": "0.0.0", "stackwright-docs": "0.1.0", - "@stackwright/otters": "0.1.0" + "@stackwright/otters": "0.1.0", + "@stackwright/sbom-generator": "0.0.0" }, "changesets": [ "add-code2-layout-icons", @@ -32,8 +33,11 @@ "map-adapter-phases-1-2", "nav-sidebar-override", "otters-as-package", + "otters-postinstall", "remove-hello-example", "resolve-background-utility", + "sbom-generator-addition", + "sbom-hooks-addition", "scaffold-bundled-default", "scaffold-pin-versions", "text-block-feature", diff --git a/packages/build-scripts/CHANGELOG.md b/packages/build-scripts/CHANGELOG.md index 2b62fb36..f9b56d61 100644 --- a/packages/build-scripts/CHANGELOG.md +++ b/packages/build-scripts/CHANGELOG.md @@ -1,5 +1,29 @@ # @stackwright/build-scripts +## 0.4.0-alpha.7 + +### Minor Changes + +- 24fed0f: feat: Add SBOM generation for supply chain transparency + + Every Stackwright build now generates a Software Bill of Materials (SBOM) with: + - SPDX 2.3 format (US Government compliance) + - CycloneDX 1.5 format (OWASP tooling compatibility) + - Stackwright build manifest (internal format) + + New CLI commands: + - `stackwright sbom generate` - Regenerate SBOM + - `stackwright sbom validate` - Validate SBOM schemas + - `stackwright sbom diff` - Compare SBOMs between builds + + Use `--no-sbom` flag to skip generation if needed. + +### Patch Changes + +- Updated dependencies [24fed0f] +- Updated dependencies [24fed0f] + - @stackwright/sbom-generator@0.1.0-alpha.0 + ## 0.4.0-alpha.6 ### Minor Changes diff --git a/packages/build-scripts/package.json b/packages/build-scripts/package.json index dd7a8f43..d44fcff7 100644 --- a/packages/build-scripts/package.json +++ b/packages/build-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/build-scripts", - "version": "0.4.0-alpha.6", + "version": "0.4.0-alpha.7", "description": "Build-time scripts for Stackwright projects (prebuild image processing, YAML compilation)", "license": "MIT", "repository": { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 8502dd6a..6a326e59 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,30 @@ # @stackwright/cli +## 0.7.0-alpha.10 + +### Minor Changes + +- 24fed0f: feat: Add SBOM generation for supply chain transparency + + Every Stackwright build now generates a Software Bill of Materials (SBOM) with: + - SPDX 2.3 format (US Government compliance) + - CycloneDX 1.5 format (OWASP tooling compatibility) + - Stackwright build manifest (internal format) + + New CLI commands: + - `stackwright sbom generate` - Regenerate SBOM + - `stackwright sbom validate` - Validate SBOM schemas + - `stackwright sbom diff` - Compare SBOMs between builds + + Use `--no-sbom` flag to skip generation if needed. + +### Patch Changes + +- Updated dependencies [24fed0f] +- Updated dependencies [24fed0f] + - @stackwright/sbom-generator@0.1.0-alpha.0 + - @stackwright/build-scripts@0.4.0-alpha.7 + ## 0.7.0-alpha.9 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 860f23eb..d58abd6c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/cli", - "version": "0.7.0-alpha.9", + "version": "0.7.0-alpha.10", "description": "CLI for Stackwright framework", "license": "MIT", "repository": { diff --git a/packages/launch-stackwright/CHANGELOG.md b/packages/launch-stackwright/CHANGELOG.md index 09eb2c4e..23cbe598 100644 --- a/packages/launch-stackwright/CHANGELOG.md +++ b/packages/launch-stackwright/CHANGELOG.md @@ -1,5 +1,14 @@ # launch-stackwright +## 0.2.0-alpha.7 + +### Patch Changes + +- Updated dependencies [a852368] +- Updated dependencies [24fed0f] + - @stackwright/otters@0.2.0-alpha.2 + - @stackwright/cli@0.7.0-alpha.10 + ## 0.2.0-alpha.6 ### Patch Changes diff --git a/packages/launch-stackwright/package.json b/packages/launch-stackwright/package.json index e9ad135f..4702f179 100644 --- a/packages/launch-stackwright/package.json +++ b/packages/launch-stackwright/package.json @@ -1,6 +1,6 @@ { "name": "launch-stackwright", - "version": "0.2.0-alpha.6", + "version": "0.2.0-alpha.7", "description": "Launch a new Stackwright project with the otter raft ready to build", "license": "MIT", "repository": { diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 4c1357d9..cf541452 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,12 @@ # @stackwright/mcp +## 0.3.0-alpha.10 + +### Patch Changes + +- Updated dependencies [24fed0f] + - @stackwright/cli@0.7.0-alpha.10 + ## 0.3.0-alpha.9 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index fad86f71..6a6d6a79 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/mcp", - "version": "0.3.0-alpha.9", + "version": "0.3.0-alpha.10", "description": "MCP server for Stackwright — exposes content types, page management, and validation as agent tools", "license": "MIT", "repository": { diff --git a/packages/otters/CHANGELOG.md b/packages/otters/CHANGELOG.md index c4072f5a..0fe2bf6f 100644 --- a/packages/otters/CHANGELOG.md +++ b/packages/otters/CHANGELOG.md @@ -1,5 +1,16 @@ # @stackwright/otters +## 0.2.0-alpha.2 + +### Minor Changes + +- a852368: Add postinstall script to install otters to ~/.code_puppy/agents/ + - Created scripts/install-agents.js that copies agent JSON files to ~/.code_puppy/agents/ + - Updated package.json with postinstall hook + - Updated README with installation instructions + - Fixed .code-puppy.json config (removed agents_path) + - Bumped version to 0.2.0-alpha.1 + ## 0.2.0-alpha.0 ### Minor Changes diff --git a/packages/otters/package.json b/packages/otters/package.json index c46f129b..923ebc55 100644 --- a/packages/otters/package.json +++ b/packages/otters/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/otters", - "version": "0.2.0-alpha.1", + "version": "0.2.0-alpha.2", "description": "Stackwright Otter Raft - AI agents for site generation", "license": "MIT", "repository": { diff --git a/packages/sbom-generator/CHANGELOG.md b/packages/sbom-generator/CHANGELOG.md new file mode 100644 index 00000000..35106d63 --- /dev/null +++ b/packages/sbom-generator/CHANGELOG.md @@ -0,0 +1,34 @@ +# @stackwright/sbom-generator + +## 0.1.0-alpha.0 + +### Minor Changes + +- 24fed0f: feat: Add SBOM generation for supply chain transparency + + Every Stackwright build now generates a Software Bill of Materials (SBOM) with: + - SPDX 2.3 format (US Government compliance) + - CycloneDX 1.5 format (OWASP tooling compatibility) + - Stackwright build manifest (internal format) + + New CLI commands: + - `stackwright sbom generate` - Regenerate SBOM + - `stackwright sbom validate` - Validate SBOM schemas + - `stackwright sbom diff` - Compare SBOMs between builds + + Use `--no-sbom` flag to skip generation if needed. + +- 24fed0f: feat: Add pluggable hook system for SBOM extensibility + + Pro packages can now register hooks to extend SBOM generation: + - `preGenerate` / `postAnalyze` / `preFormat` / `postFormat` / `preWrite` / `postWrite` + + Hook types: + - `priority`: Controls execution order (lower = first) + - `critical`: If true, failure fails entire SBOM generation + + Auto-registration pattern (consistent with registerNextJSComponents, etc.): + + ```typescript + import '@stackwright-pro/sbom-enterprise'; // auto-registers hooks + ``` diff --git a/packages/sbom-generator/package.json b/packages/sbom-generator/package.json index 466567a5..6d0985ea 100644 --- a/packages/sbom-generator/package.json +++ b/packages/sbom-generator/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/sbom-generator", - "version": "0.0.0", + "version": "0.1.0-alpha.0", "description": "SBOM generation for Stackwright projects - generates SPDX, CycloneDX, and build manifest formats", "license": "MIT", "repository": { From 5c351f56b8c201de0e6fa95146f5bc746716c501 Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:16:58 -0400 Subject: [PATCH 04/14] Feat/SBOM generation (#293) * feat(scripts): add SBOM generation with pluggable hook system - @stackwright/sbom-generator package with SPDX 2.3, CycloneDX 1.5 formats - Hook system for Pro extensibility (preGenerate, postAnalyze, postFormat, etc.) - CLI sbom command (generate, validate, diff) - --no-sbom flag to skip generation - Security reviewed and approved - 43 tests passing * fix: exclude _font-links.json from scaffold template feat(ci): add reusable setup action, fail-fast deps, path triggers * ci: verify action.yml exists (trigger fresh CI run) * fix(ci): add checkout step before local action reference GitHub Actions requires actions/checkout@v4 before using local actions like ./.github/actions/setup-stackwright - the repo must be checked out before the action definition can be resolved. --- .changeset/ci-workflow-improvements.md | 7 ++ .changeset/fix-cli-scaffold-smoke-test.md | 5 + .github/actions/setup-stackwright/action.yml | 43 ++++++++ .github/workflows/accessibility.yml | 25 +---- .github/workflows/check-changeset.yml | 13 +-- .github/workflows/ci.yml | 99 ++++++------------- .github/workflows/coverage.yml | 21 +--- .github/workflows/performance.yml | 25 +---- .github/workflows/visual-regression.yml | 25 +---- .../scaffold-template/pages/[...slug].tsx | 4 +- 10 files changed, 103 insertions(+), 164 deletions(-) create mode 100644 .changeset/ci-workflow-improvements.md create mode 100644 .changeset/fix-cli-scaffold-smoke-test.md create mode 100644 .github/actions/setup-stackwright/action.yml diff --git a/.changeset/ci-workflow-improvements.md b/.changeset/ci-workflow-improvements.md new file mode 100644 index 00000000..28574397 --- /dev/null +++ b/.changeset/ci-workflow-improvements.md @@ -0,0 +1,7 @@ +--- +"__root__": patch +--- + +Improve CI: Add reusable action, fail-fast dependencies, and path-based triggers + +Fix: Add checkout step before local action reference (GitHub Actions requirement) diff --git a/.changeset/fix-cli-scaffold-smoke-test.md b/.changeset/fix-cli-scaffold-smoke-test.md new file mode 100644 index 00000000..8fe22df0 --- /dev/null +++ b/.changeset/fix-cli-scaffold-smoke-test.md @@ -0,0 +1,5 @@ +--- +"@stackwright/cli": patch +--- + +Fix scaffold smoke-test TypeError by excluding _font-links.json from static page generation diff --git a/.github/actions/setup-stackwright/action.yml b/.github/actions/setup-stackwright/action.yml new file mode 100644 index 00000000..57be1c29 --- /dev/null +++ b/.github/actions/setup-stackwright/action.yml @@ -0,0 +1,43 @@ +name: "Setup Stackwright" +description: "Standardized pnpm/Node setup for Stackwright monorepo" + +inputs: + build: + description: "Whether to run pnpm build after install" + required: false + default: "false" + relink-bins: + description: "Whether to re-link bins after build (needed for CLI tools)" + required: false + default: "false" + +runs: + using: "composite" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + shell: bash + + - name: Build packages + if: inputs.build == 'true' + run: pnpm build + shell: bash + + - name: Relink bins + if: inputs.relink-bins == 'true' + run: pnpm install + shell: bash diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml index 6466f283..9731a4bd 100644 --- a/.github/workflows/accessibility.yml +++ b/.github/workflows/accessibility.yml @@ -16,28 +16,11 @@ jobs: timeout-minutes: 20 steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-stackwright with: - node-version: 22 - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install - - - name: Build packages - run: pnpm build - - - name: Re-link bins after build - run: pnpm install + build: true + relink-bins: true - name: Install Playwright browsers run: pnpm --filter @stackwright/e2e exec playwright install --with-deps chromium diff --git a/.github/workflows/check-changeset.yml b/.github/workflows/check-changeset.yml index 47a6775d..bfc720fd 100644 --- a/.github/workflows/check-changeset.yml +++ b/.github/workflows/check-changeset.yml @@ -14,18 +14,9 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 0 - - uses: pnpm/action-setup@v3 - with: - version: 10 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - - - run: pnpm install + - uses: ./.github/actions/setup-stackwright - name: Validate Changeset Presence run: node .github/scripts/check-for-changeset.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d47e64e5..b6619a0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,72 +3,48 @@ name: CI on: push: branches: [main, dev] + paths: + - 'packages/**' + - 'examples/**' + - 'scripts/**' + - '!examples/stackwright-docs/content/**' pull_request: + paths: + - 'packages/**' + - 'examples/**' + - 'scripts/**' + - '!examples/stackwright-docs/content/**' permissions: contents: read jobs: - format: + lint-and-format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 - with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'pnpm' - - - run: pnpm install + - uses: ./.github/actions/setup-stackwright - run: pnpm format:check - - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 - with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'pnpm' - - - run: pnpm install - run: pnpm lint test: runs-on: ubuntu-latest + needs: [lint-and-format] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 + - uses: ./.github/actions/setup-stackwright with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'pnpm' - - - run: pnpm install - - run: pnpm build + build: true - run: pnpm test scaffold-smoke-test: runs-on: ubuntu-latest + needs: [lint-and-format] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 - with: - version: 10 - - uses: actions/setup-node@v4 + - uses: ./.github/actions/setup-stackwright with: - node-version: 22 - cache: 'pnpm' - - - run: pnpm install - - run: pnpm build + build: true - name: Pack workspace packages as tarballs run: | @@ -112,18 +88,12 @@ jobs: check-agent-docs: runs-on: ubuntu-latest + needs: [lint-and-format] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 - with: - version: 10 - - uses: actions/setup-node@v4 + - uses: ./.github/actions/setup-stackwright with: - node-version: 22 - cache: 'pnpm' - - - run: pnpm install - - run: pnpm build + build: true - name: Regenerate AGENTS.md tables run: node packages/cli/dist/cli.js generate-agent-docs @@ -135,20 +105,13 @@ jobs: e2e: runs-on: ubuntu-latest + needs: [lint-and-format] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 - with: - version: 10 - - uses: actions/setup-node@v4 + - uses: ./.github/actions/setup-stackwright with: - node-version: 22 - cache: 'pnpm' - - - run: pnpm install - - run: pnpm build - - run: pnpm install - name: Re-link bins after build + build: true + relink-bins: true - name: Install Playwright browsers run: pnpm --filter @stackwright/e2e exec playwright install --with-deps chromium @@ -182,18 +145,12 @@ jobs: check-schemas: runs-on: ubuntu-latest + needs: [lint-and-format] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 + - uses: ./.github/actions/setup-stackwright with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'pnpm' - - - run: pnpm install - - run: pnpm build + build: true - name: Regenerate JSON schemas run: cd packages/types && pnpm generate-schemas diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b1148564..e0ec3002 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -16,25 +16,10 @@ jobs: timeout-minutes: 15 steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-stackwright with: - node-version: 22 - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install - - - name: Build packages - run: pnpm build + build: true - name: Run tests with coverage run: pnpm test:coverage diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index d41e7b6b..3590338e 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -21,28 +21,11 @@ jobs: timeout-minutes: 30 steps: - - name: 📥 Checkout code - uses: actions/checkout@v4 - - - name: 📦 Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 10 - - - name: 🟢 Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-stackwright with: - node-version: 22 - cache: 'pnpm' - - - name: 📚 Install dependencies - run: pnpm install --frozen-lockfile - - - name: 🔨 Build packages - run: pnpm build - - - name: 🔗 Re-link bins after build - run: pnpm install + build: true + relink-bins: true - name: ⚡ Run build time benchmarks id: build-time diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 67d5a3a3..8ce33c6a 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -16,32 +16,15 @@ jobs: timeout-minutes: 30 steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-stackwright with: - node-version: 22 - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install + build: true + relink-bins: true - name: Install Playwright browsers run: pnpm --filter @stackwright/e2e exec playwright install chromium --with-deps - - name: Build packages - run: pnpm build - - - name: Re-link bins after build - run: pnpm install - - name: Run visual regression tests # Visual baselines differ across platforms (CI vs local font rendering), # so we use --update-snapshots to treat these as render-smoke-tests. diff --git a/packages/cli/templates/scaffold-template/pages/[...slug].tsx b/packages/cli/templates/scaffold-template/pages/[...slug].tsx index cd91f183..43700834 100644 --- a/packages/cli/templates/scaffold-template/pages/[...slug].tsx +++ b/packages/cli/templates/scaffold-template/pages/[...slug].tsx @@ -16,7 +16,9 @@ function findContentFiles(dir: string, baseDir: string): string[] { } else if ( entry.name.endsWith('.json') && entry.name !== '_site.json' && - entry.name !== '_root.json' && entry.name !== 'search-index.json' + entry.name !== '_root.json' && + entry.name !== 'search-index.json' && + entry.name !== '_font-links.json' ) { const rel = path.relative(baseDir, path.join(dir, entry.name)); results.push(rel.replace(/\.json$/, '').replace(/\\/g, '/')); From 089a5bb2fa6aeba608c8d5e265d50bb7dc571d56 Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:22:57 -0400 Subject: [PATCH 05/14] docs: update documentation post-sprint (#294) * docs: update documentation post-sprint (brand otter shipped, font auto-loading) * docs: add changeset for post-sprint docs update --- .changeset/update-post-sprint-docs.md | 5 + OTTER_ARCHITECTURE.md | 110 +++++++++++++----- ROADMAP.md | 10 +- examples/stackwright-docs/pages/_document.tsx | 26 +---- 4 files changed, 93 insertions(+), 58 deletions(-) create mode 100644 .changeset/update-post-sprint-docs.md diff --git a/.changeset/update-post-sprint-docs.md b/.changeset/update-post-sprint-docs.md new file mode 100644 index 00000000..12036e53 --- /dev/null +++ b/.changeset/update-post-sprint-docs.md @@ -0,0 +1,5 @@ +--- +"docs-only": patch +--- + +docs: update documentation post-sprint (brand otter shipped, font auto-loading) diff --git a/OTTER_ARCHITECTURE.md b/OTTER_ARCHITECTURE.md index 2650a044..6da3a2f2 100644 --- a/OTTER_ARCHITECTURE.md +++ b/OTTER_ARCHITECTURE.md @@ -4,6 +4,23 @@ AI agent orchestration system for end-to-end Stackwright site generation. > **A raft of otters** is the collective noun for a group of otters floating together. Our raft of specialized AI agents coordinates seamlessly to build complete Stackwright sites! 🦦🦦🦦🦦 +## Installation + +Otters are distributed as the [`@stackwright/otters`](https://www.npmjs.com/package/@stackwright/otters) npm package: + +```bash +npm install @stackwright/otters +# or +pnpm add @stackwright/otters +``` + +**Auto-install**: The package's `postinstall` script automatically installs otter agent files to `~/.code_puppy/agents/` for code-puppy discovery. No manual copying required! + +To re-run installation manually: +```bash +node node_modules/@stackwright/otters/scripts/install-agents.js +``` + ## Architecture Overview ``` @@ -18,7 +35,7 @@ AI agent orchestration system for end-to-end Stackwright site generation. │ (Coordinator) │ │ │ │ • Project scaffolding │ -│ • Sequential otter coordination │ +│ • Sequential otter coordination │ │ • Validation & error handling │ │ • Visual verification │ └────────┬──────────────────┬───────────────────┬─────────────┘ @@ -96,29 +113,68 @@ AI agent orchestration system for end-to-end Stackwright site generation. **Important**: Foreman Otter uses MCP tools (`stackwright_scaffold_project`) instead of shell commands for project scaffolding. This ensures it works without requiring a globally installed CLI. +### Tool Categories + +All MCP tools are organized into these categories: + +**PROJECT TOOLS** +- `stackwright_get_project_info` — Get project info (versions, theme, pages) +- `stackwright_scaffold_project` — Scaffold new Stackwright project + +**SITE TOOLS** +- `stackwright_get_site_config` — Read stackwright.yml content +- `stackwright_write_site_config` — Write/update stackwright.yml +- `stackwright_validate_site` — Validate stackwright.yml schema +- `stackwright_list_themes` — List available built-in themes + +**PAGE TOOLS** +- `stackwright_list_pages` — List all pages in project +- `stackwright_get_page` — Read page YAML content +- `stackwright_write_page` — Write/update page YAML +- `stackwright_add_page` — Create new page with boilerplate +- `stackwright_validate_pages` — Validate page YAML against schema + +**CONTENT TOOLS** +- `stackwright_get_content_types` — List all content types with fields +- `stackwright_preview_component` — Show screenshot preview of component + +**RENDER TOOLS** +- `stackwright_check_dev_server` — Verify dev server is running +- `stackwright_render_page` — Screenshot a page +- `stackwright_render_yaml` — Preview YAML without saving (temporary) +- `stackwright_render_diff` — Before/after comparison + +### MCP Tools by Otter + ``` -┌─────────────────┬────────────────────────────────────────────┐ -│ Otter │ MCP Tools │ -├─────────────────┼────────────────────────────────────────────┤ -│ Brand Otter │ • None (pure conversation) │ -│ │ • Browser tools (research) │ -│ │ • File creation (BRAND_BRIEF.md) │ -├─────────────────┼────────────────────────────────────────────┤ -│ Theme Otter │ • stackwright_write_site_config │ -│ │ • stackwright_validate_site │ -│ │ • stackwright_render_yaml (preview) │ -│ │ • stackwright_list_themes │ -├─────────────────┼────────────────────────────────────────────┤ -│ Page Otter │ • stackwright_write_page │ -│ │ • stackwright_validate_pages │ -│ │ • stackwright_render_page │ -│ │ • stackwright_get_content_types │ -├─────────────────┼────────────────────────────────────────────┤ -│ Foreman Otter │ • stackwright_scaffold_project │ -│ │ • stackwright_validate_site │ -│ │ • stackwright_validate_pages │ -│ │ • invoke_agent (coordination) │ -└─────────────────┴────────────────────────────────────────────┘ +┌─────────────────┬────────────────────────────────────────────────────────────┐ +│ Otter │ MCP Tools │ +├─────────────────┼────────────────────────────────────────────────────────────┤ +│ Brand Otter │ • None (pure conversation) │ +│ │ • Browser tools (research) │ +│ │ • File creation (BRAND_BRIEF.md) │ +├─────────────────┼────────────────────────────────────────────────────────────┤ +│ Theme Otter │ • stackwright_get_site_config │ +│ │ • stackwright_write_site_config │ +│ │ • stackwright_validate_site │ +│ │ • stackwright_list_themes │ +│ │ • stackwright_render_yaml (preview before commit) │ +├─────────────────┼────────────────────────────────────────────────────────────┤ +│ Page Otter │ • stackwright_get_content_types │ +│ │ • stackwright_list_pages │ +│ │ • stackwright_write_page │ +│ │ • stackwright_validate_pages │ +│ │ • stackwright_preview_component │ +│ │ • stackwright_render_page │ +│ │ • stackwright_render_yaml (preview before commit) │ +├─────────────────┼────────────────────────────────────────────────────────────┤ +│ Foreman Otter │ • stackwright_get_project_info │ +│ │ • stackwright_scaffold_project │ +│ │ • stackwright_validate_site │ +│ │ • stackwright_validate_pages │ +│ │ • stackwright_check_dev_server │ +│ │ • invoke_agent (coordination) │ +└─────────────────┴────────────────────────────────────────────────────────────┘ ``` ## Dependency Graph @@ -395,10 +451,10 @@ All Otters ──┬──► RAG Server ◄── Example Corpus │ │ │ ▼ │ ┌────────────────────────────┐ - │ │ • Semantic search │ - │ │ • Pattern matching │ - │ │ • Example retrieval │ - │ │ • Best practice lookup │ + │ │ • Semantic search │ + │ │ • Pattern matching │ + │ │ • Example retrieval │ + │ │ • Best practice lookup │ │ └────────────────────────────┘ │ └──► Grounded suggestions diff --git a/ROADMAP.md b/ROADMAP.md index 725d615c..9f94dc69 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,15 +33,11 @@ The type system that defines what YAML can express — the Stackwright grammar - MCP tools: `stackwright_render_page`, `stackwright_render_diff`, `stackwright_render_yaml`, `stackwright_check_dev_server` - CLI: `stackwright preview` command - E2E tests: full render pipeline verified against the example app +- **Brand Otter** — part of the Otter Raft, discovers brand through conversation and produces BRAND_BRIEF.md (see `./otters/README.md`) -**Next step: branding expert agent.** The visual rendering tools are foundational infrastructure for an AI agent that can: -1. Chat with a user about their company, values, and aesthetic preferences -2. Generate theme and content variations -3. Render each variation and evaluate it visually -4. Iterate toward a design that captures the right brand "feel" -5. Ship the result via `stackwright_open_pr` +**Next step: branding expert iteration loop.** With Brand Otter shipped, the next evolution is enabling AI agents to visually iterate on themes — generate variations, render each, evaluate against brand criteria, and converge on the right feel. This builds on the visual rendering infrastructure now in place. -This agent is the proof point for the platform's thesis: that non-technical people can build professional, brand-appropriate applications through conversation — with the constrained DSL guaranteeing safety and the visual feedback loop guaranteeing quality. +This iteration loop is the proof point for the platform's thesis: that non-technical people can build professional, brand-appropriate applications through conversation — with the constrained DSL guaranteeing safety and the visual feedback loop guaranteeing quality. --- diff --git a/examples/stackwright-docs/pages/_document.tsx b/examples/stackwright-docs/pages/_document.tsx index 9f2c3211..9759baad 100644 --- a/examples/stackwright-docs/pages/_document.tsx +++ b/examples/stackwright-docs/pages/_document.tsx @@ -1,25 +1,3 @@ -import React from 'react'; -import { Html, Head, Main, NextScript } from 'next/document'; +import { StackwrightDocument } from '@stackwright/nextjs'; -//@TODO code-puppy, can we add fonts through the prebuild step instead of every app having to do this? - -export default function Document() { - return ( - - - - - - - - - -
- - - - ); -} +export default StackwrightDocument; From b2e451af94103ac9059948f75f14fd96d5281f99 Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:39:52 -0400 Subject: [PATCH 06/14] feat(scaffold): add hook system for extensible post-scaffold processing (#295) * feat(scaffold): add hook system for extensible post-scaffold processing - Add @stackwright/scaffold-core package with hook registry - Hook lifecycle: preScaffold, preInstall, postInstall, postScaffold - Pro packages can register hooks to inject dependencies, configure MCP - Add --otter-raft flag to launch-stackwright for full auto-install - Update CLI to run hooks at lifecycle points - Update docs: CLAUDE.md, CONTRIBUTING.md, AGENTS.md * fix(ci): allow lockfile updates in PRs, strict mode for branches - PRs: pnpm install (allows updates, visible in diff) - Branch pushes: pnpm install --frozen-lockfile (strict) * fix(scaffold): check for empty packageJson object, not just undefined Before: if (!packageJson) skipped building for empty {} passed by hooks After: if (!packageJson || Object.keys(packageJson).length === 0) ensures build * chore: update lockfile for @stackwright/scaffold-core * docs(changeset): add changeset for scaffold hooks system * fix(ci): add sbom-generator to scaffold smoke test pack list The SBOM feature added @stackwright/sbom-generator as a dependency of build-scripts, but the CI pack list wasn't updated. This caused scaffold-smoke-test to fail when trying to fetch sbom-generator. --- .changeset/scaffold-hooks-system.md | 7 + .github/actions/setup-stackwright/action.yml | 9 +- .github/workflows/ci.yml | 4 +- AGENTS.md | 10 +- CLAUDE.md | 38 ++ CONTRIBUTING.md | 137 +++++++ OTTER_ARCHITECTURE.md | 216 +++++++++-- package.json | 3 +- packages/cli/CHANGELOG.md | 3 + packages/cli/package.json | 1 + packages/cli/src/commands/scaffold.ts | 48 ++- packages/cli/src/utils/template-processor.ts | 46 ++- packages/launch-stackwright/CHANGELOG.md | 4 + packages/launch-stackwright/README.md | 24 +- packages/launch-stackwright/package.json | 1 + packages/launch-stackwright/src/index.ts | 104 +++-- packages/otters/README.md | 69 +++- .../otters/src/stackwright-foreman-otter.json | 365 +++++++++++++----- packages/scaffold-core/CHANGELOG.md | 9 + packages/scaffold-core/README.md | 202 ++++++++++ packages/scaffold-core/package.json | 37 ++ packages/scaffold-core/src/index.ts | 23 ++ packages/scaffold-core/src/registry.ts | 84 ++++ packages/scaffold-core/src/types/hooks.ts | 68 ++++ packages/scaffold-core/tsconfig.json | 8 + packages/scaffold-core/tsup.config.ts | 10 + pnpm-lock.yaml | 21 + 27 files changed, 1350 insertions(+), 201 deletions(-) create mode 100644 .changeset/scaffold-hooks-system.md create mode 100644 packages/scaffold-core/CHANGELOG.md create mode 100644 packages/scaffold-core/README.md create mode 100644 packages/scaffold-core/package.json create mode 100644 packages/scaffold-core/src/index.ts create mode 100644 packages/scaffold-core/src/registry.ts create mode 100644 packages/scaffold-core/src/types/hooks.ts create mode 100644 packages/scaffold-core/tsconfig.json create mode 100644 packages/scaffold-core/tsup.config.ts diff --git a/.changeset/scaffold-hooks-system.md b/.changeset/scaffold-hooks-system.md new file mode 100644 index 00000000..2709c8b1 --- /dev/null +++ b/.changeset/scaffold-hooks-system.md @@ -0,0 +1,7 @@ +--- +"@stackwright/scaffold-core": minor +"@stackwright/cli": minor +"@stackwright/launch-stackwright": minor +--- + +Add scaffold hooks system for extensible post-scaffold processing. Pro packages can now register hooks at lifecycle points (preScaffold, preInstall, postInstall, postScaffold) to inject dependencies, configure MCP servers, and add custom setup. diff --git a/.github/actions/setup-stackwright/action.yml b/.github/actions/setup-stackwright/action.yml index 57be1c29..6453c425 100644 --- a/.github/actions/setup-stackwright/action.yml +++ b/.github/actions/setup-stackwright/action.yml @@ -29,7 +29,14 @@ runs: cache: "pnpm" - name: Install dependencies - run: pnpm install --frozen-lockfile + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "PR detected: allowing lockfile updates..." + pnpm install + else + echo "Branch push detected: using frozen lockfile..." + pnpm install --frozen-lockfile + fi shell: bash - name: Build packages diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6619a0e..47bacdbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: - name: Pack workspace packages as tarballs run: | mkdir -p /tmp/stackwright-tarballs - for pkg in types themes icons collections core nextjs build-scripts ui-shadcn cli; do + for pkg in types themes icons collections core nextjs build-scripts ui-shadcn cli sbom-generator; do pnpm --filter "@stackwright/$pkg" pack --pack-destination /tmp/stackwright-tarballs done @@ -62,7 +62,7 @@ jobs: const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); const tarballs = fs.readdirSync('/tmp/stackwright-tarballs'); - const names = ['types','themes','icons','collections','core','nextjs','build-scripts','ui-shadcn','cli']; + const names = ['types','themes','icons','collections','core','nextjs','build-scripts','ui-shadcn','cli','sbom-generator']; const overrides = {}; for (const name of names) { const tarball = tarballs.find(t => t.startsWith('stackwright-' + name + '-')); diff --git a/AGENTS.md b/AGENTS.md index c2aeec7b..6c512c83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,14 @@ Welcome to Stackwright! This is a YAML-driven React application framework that e The fastest way to get started with Stackwright is using **launch-stackwright**: +**Recommended: Full otter raft experience (auto-installs dependencies)** +```bash +npx launch-stackwright my-site --otter-raft +cd my-site +pnpm dev +``` + +**Alternative: Manual setup** ```bash npx launch-stackwright my-site cd my-site @@ -13,7 +21,7 @@ pnpm install pnpm dev ``` -This automatically sets up: +Both set up: - ✅ A fully configured Next.js + Stackwright project - ✅ The otter raft (AI agents) ready to build your site - ✅ MCP server auto-configuration for Code Puppy diff --git a/CLAUDE.md b/CLAUDE.md index 2a18c2cb..7fce1be9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ pnpm build:nextjs pnpm build:cli pnpm build:build-scripts pnpm build:mcp +pnpm build:scaffold-core # Run the MCP server (stdio — for use with Claude Code or Claude for Desktop) pnpm stackwright-mcp @@ -108,6 +109,15 @@ User's Next.js App @stackwright/cli ← Standalone CLI (scaffolding, validation, content generation) ``` +**Scaffold Dependency Chain:** +``` +@stackwright/scaffold-core ← Hook system for extensible scaffold processing + ↓ +@stackwright/cli ← Uses hooks for MCP config, Pro package integration + ↓ +@stackwright/launch-stackwright ← Registers MCP config via hooks +``` + ### Key Architectural Concepts **Content Rendering Pipeline**: YAML files are loaded → parsed with `js-yaml` → validated against the grammar (TypeScript types/JSON schemas) → compiled to React components via `contentRenderer.tsx` → rendered with theme and context. @@ -122,6 +132,34 @@ User's Next.js App **Grammar / JSON Schema Generation**: `@stackwright/types` is the single source of truth for the Stackwright grammar. Zod schemas are the source of truth; TypeScript types are inferred via `z.infer<>`. `zod-to-json-schema` generates `theme-schema.json`, `content-schema.json`, and `siteconfig-schema.json` — the machine-readable grammar specification used for IDE YAML validation. Must be regenerated after type changes (`pnpm generate-schemas`). Zod schemas are introspectable at runtime via `schema.def` (Zod v4) enabling MCP tools and future runtime validation. +### Scaffold Hooks System + +`@stackwright/scaffold-core` provides a hook system for extensible post-scaffold processing. Pro packages register hooks that run at lifecycle points. + +**Hook lifecycle points:** +| Hook | When | Use Case | +|------|------|----------| +| `preScaffold` | Before scaffolding | Validate credentials | +| `preInstall` | After files, before install | Modify package.json, configure MCP | +| `postInstall` | After pnpm install | Verify installation | +| `postScaffold` | After complete | Final configuration | + +**Pro package integration:** +```typescript +import { registerScaffoldHook } from '@stackwright/scaffold-core'; + +registerScaffoldHook({ + type: 'preInstall', + name: 'enterprise-license', + critical: true, + handler: async (ctx) => { + ctx.packageJson.dependencies['@stackwright-pro/license'] = '^1.0.0'; + }, +}); +``` + +Hooks run automatically when using `launch-stackwright --otter-raft`. + ### Key Files - `packages/core/src/utils/contentRenderer.tsx` — core YAML-to-React transformation engine diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 506f9d49..d4ede954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -192,6 +192,143 @@ The architect sets priority tiers. Contributors and agents should pick work from @stackwright/icons — MUI icon registry @stackwright/build-scripts — Prebuild pipeline (image co-location, path rewriting) @stackwright/cli — CLI for scaffolding, page management, validation +@stackwright/scaffold-core — Hook system for extensible scaffold processing (Pro packages use this) +``` + +## Creating Pro Packages with Scaffold Hooks + +Pro packages can extend the scaffold process using the hooks system in `@stackwright/scaffold-core`. + +### Overview + +The scaffold hooks system allows Pro packages to: +- Inject enterprise dependencies into `package.json` +- Configure custom MCP servers +- Add post-install verification +- Run custom setup scripts + +### Hook Lifecycle Points + +| Hook | When | Use Case | +|------|------|----------| +| `preScaffold` | Before scaffolding begins | Validate environment, check licenses | +| `preInstall` | After files created, before `pnpm install` | Modify `package.json`, set up `.code-puppy.json` | +| `postInstall` | After `pnpm install` completes | Verify installation, run setup scripts | +| `postScaffold` | After scaffolding complete | Final configuration, cleanup | + +### Creating a Pro Launch Hooks Package + +1. **Create package structure:** + ``` + @stackwright-pro/launch-hooks/ + ├── package.json + └── index.js + ``` + +2. **package.json:** + ```json + { + "name": "@stackwright-pro/launch-hooks", + "version": "0.1.0", + "main": "index.js", + "dependencies": { + "@stackwright/scaffold-core": "workspace:*" + } + } + ``` + +3. **index.js** — Register hooks: + ```javascript + const { registerScaffoldHook } = require('@stackwright/scaffold-core'); + + // Add enterprise license + registerScaffoldHook({ + type: 'preInstall', + name: 'enterprise-license', + critical: true, + handler: async (ctx) => { + if (!process.env.PRO_API_KEY) { + throw new Error('PRO_API_KEY required'); + } + ctx.packageJson.dependencies['@stackwright-pro/license'] = '^1.0.0'; + }, + }); + + // Configure enterprise MCP server + registerScaffoldHook({ + type: 'preInstall', + name: 'enterprise-mcp', + priority: 20, + handler: async (ctx) => { + ctx.codePuppyConfig = ctx.codePuppyConfig || {}; + ctx.codePuppyConfig.mcp_servers = ctx.codePuppyConfig.mcp_servers || {}; + ctx.codePuppyConfig.mcp_servers.enterprise = { + command: 'node', + args: ['node_modules/@stackwright-pro/mcp/dist/server.js'], + env: { API_KEY: process.env.PRO_API_KEY }, + }; + }, + }); + ``` + +4. **Usage:** Users add your package to their project: + ```bash + npx launch-stackwright --otter-raft my-site + # Pro hooks run automatically during scaffold + ``` + +### Hook Context + +The context object passed to hooks: + +```typescript +interface ScaffoldHookContext { + targetDir: string; // Project directory + projectName: string; // Project name + siteTitle: string; // Site title + themeId: string; // Theme ID + packageJson: Record; // Mutable - add dependencies + codePuppyConfig?: Record; // Mutable - add MCP config + dependencyMode: 'workspace' | 'standalone'; + pages?: string[]; // Pages being created + install?: boolean; // Whether install will run + [key: string]: any; // Hooks can add custom properties +} +``` + +### Hook Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `type` | `ScaffoldHookType` | Required | Lifecycle point | +| `name` | `string` | Required | Unique hook name | +| `priority` | `number` | `50` | Lower = runs first | +| `critical` | `boolean` | `false` | If true, failure fails entire scaffold | +| `handler` | `function` | Required | Async function to execute | + +### Testing Pro Hooks + +```bash +# Test hooks by running scaffold with your package installed +npx launch-stackwright --otter-raft test-project + +# Verify package.json includes your dependency +cat test-project/package.json | grep @stackwright-pro + +# Verify .code-puppy.json includes your MCP config +cat test-project/.code-puppy.json +``` + +### Hook Debugging + +Enable debug output: +```bash +DEBUG=scaffold-hooks npx launch-stackwright --otter-raft my-site +``` + +Non-critical hook failures are logged but don't stop scaffold: +``` +[Scaffold Hook] Non-critical hook "my-hook" failed: some error ``` ## Build System Notes diff --git a/OTTER_ARCHITECTURE.md b/OTTER_ARCHITECTURE.md index 6da3a2f2..6c312cfe 100644 --- a/OTTER_ARCHITECTURE.md +++ b/OTTER_ARCHITECTURE.md @@ -1,26 +1,69 @@ # Stackwright Otter Raft Architecture 🦦 -AI agent orchestration system for end-to-end Stackwright site generation. - > **A raft of otters** is the collective noun for a group of otters floating together. Our raft of specialized AI agents coordinates seamlessly to build complete Stackwright sites! 🦦🦦🦦🦦 -## Installation +## The Biology Analogy: How Real Otters Coordinate -Otters are distributed as the [`@stackwright/otters`](https://www.npmjs.com/package/@stackwright/otters) npm package: +Real otters form rafts not because a central controller tells them to, but because they discover each other and find complementary behaviors. A mother otter knows her pup can't swim far yet—so she finds other mothers and they form a "creche," taking turns watching the young while others forage. An adult male finds other males and they patrol boundaries together. The raft emerges from **discovery**, not **command**. + +**Our AI otters work exactly the same way.** Each otter starts by looking around to see who else is available, then adapts its coordination strategy based on what it finds: -```bash -npm install @stackwright/otters -# or -pnpm add @stackwright/otters ``` +┌─────────────────────────────────────────────────────────────┐ +│ DYNAMIC DISCOVERY │ +└─────────────────────────────────────────────────────────────┘ -**Auto-install**: The package's `postinstall` script automatically installs otter agent files to `~/.code_puppy/agents/` for code-puppy discovery. No manual copying required! + Brand Otter: "Let me check who's in the water..." + → list_agents() finds: [Theme Otter, Page Otter] + → "I'll create a brand brief that sets up the theme" -To re-run installation manually: -```bash -node node_modules/@stackwright/otters/scripts/install-agents.js + Theme Otter: "Dashboard Otter available! Want live data integration?" + → discovers Dashboard Otter at runtime + → adapts theme to support dashboard components + + Page Otter: "I see Brand Otter and Theme Otter completed their work" + → reads BRAND_BRIEF.md and stackwright.yml + → builds pages using established brand identity ``` +This is fundamentally different from traditional agent systems where a central coordinator hard-codes knowledge of all agents. Here, **the raft self-organizes at runtime**. + +--- + +## Dynamic Discovery (The Biological Model) + +Unlike traditional agent systems where a central coordinator hard-codes knowledge of all agents, our otters discover each other at runtime: + +```typescript +// Every otter starts by discovering who else is in the water +const agents = await list_agents(); +const otters = agents.filter(a => a.name.endsWith('-otter')); + +// Each otter adapts its behavior based on who it finds +// Brand Otter: "I'm alone - I'll guide brand discovery myself" +if (otters.length === 0) { + await run_brand_discovery(); +} + +// Page Otter: "Dashboard Otter available! Want live data?" +if (otters.includes('dashboard-otter')) { + suggest_live_data_integration(); +} + +// Theme Otter: "Pro Theme Otter found! Shall I suggest enterprise variants?" +if (otters.includes('pro-theme-otter')) { + suggest_enterprise_themes(); +} +``` + +This means: +- **No central planner needs to know everything** — the Foreman discovers agents dynamically +- **Otters self-organize based on availability** — new otters are picked up automatically +- **New otters are discovered automatically** — drop in a new agent, it's immediately available +- **The raft adapts to what's installed** — partial deployments work gracefully + +--- + ## Architecture Overview ``` @@ -31,13 +74,12 @@ node node_modules/@stackwright/otters/scripts/install-agents.js │ ▼ ┌─────────────────────────────────────────────────────────────┐ -│ 🦦🏗️ FOREMAN OTTER │ -│ (Coordinator) │ +│ 🦦🏗️ FOREMAN OTTER (Dynamic Coordinator) │ │ │ -│ • Project scaffolding │ -│ • Sequential otter coordination │ -│ • Validation & error handling │ -│ • Visual verification │ +│ • Discovers available otters via list_agents() │ +│ • Builds coordination graph at runtime │ +│ • Invokes discovered otters dynamically │ +│ • Handles missing otters gracefully │ └────────┬──────────────────┬───────────────────┬─────────────┘ │ │ │ ▼ ▼ ▼ @@ -73,10 +115,71 @@ node node_modules/@stackwright/otters/scripts/install-agents.js │ │ └── services/ (services page) │ │ │ └── content.yml │ │ └── public/ │ -│ └── images/ (co-located images) │ +│ └── images/ (co-located images) │ └─────────────────────────────────────────────────────────────┘ ``` +--- + +## Emergent Coordination Examples + +The beauty of dynamic discovery is that coordination **emerges** from what otters find, rather than being hard-coded: + +### Example 1: Page Otter Discovers Dashboard Otter +``` +Page Otter: "Running list_agents()..." + → Found: brand-otter, theme-otter, dashboard-otter + +Page Otter: "Dashboard Otter is available! Shall I create + a live metrics page with real-time data?" +``` + +### Example 2: Theme Otter Discovers Pro Theme Otter +``` +Theme Otter: "Checking for specialized theme agents..." + → Found: pro-theme-otter + +Theme Otter: "Pro Theme Otter detected! I can suggest + enterprise-grade theme variants with + advanced components." +``` + +### Example 3: Brand Otter Works Alone +``` +Brand Otter: "Checking for collaboration..." + → Found: [] + +Brand Otter: "No other otters available. I'll guide brand + discovery myself and create a complete + BRAND_BRIEF.md." +``` + +### Example 4: Foreman Handles Missing Otter Gracefully +``` +Foreman: "Attempting to invoke dashboard-otter..." + → Error: Agent not found + +Foreman: "Dashboard Otter not installed. Falling back to + static page content. User can add Dashboard Otter + later for live data." +``` + +--- + +## Comparison: Traditional vs. Stackwright Otters + +| Aspect | Traditional Agent Systems | Stackwright Otters | +|--------|---------------------------|---------------------| +| **Coordination** | Central planner hard-codes all agents | Otters discover each other at runtime | +| **Extensibility** | New agent = update all coordinators | Drop in new otter = automatically discovered | +| **Coupling** | Tight (by name/interface) | Loose (by capability) | +| **Failure mode** | Single point of failure | Graceful degradation | +| **Self-organization** | Top-down command | Bottom-up discovery | +| **Partial deployment** | Requires all agents | Works with available otters | +| **Knowledge required** | Central planner must know all | Each otter knows only its domain | + +--- + ## Data Flow ``` @@ -109,6 +212,8 @@ node node_modules/@stackwright/otters/scripts/install-agents.js └─> User review & iteration ``` +--- + ## MCP Tool Usage by Otter **Important**: Foreman Otter uses MCP tools (`stackwright_scaffold_project`) instead of shell commands for project scaffolding. This ensures it works without requiring a globally installed CLI. @@ -173,10 +278,13 @@ All MCP tools are organized into these categories: │ │ • stackwright_validate_site │ │ │ • stackwright_validate_pages │ │ │ • stackwright_check_dev_server │ +│ │ • list_agents (dynamic discovery) │ │ │ • invoke_agent (coordination) │ └─────────────────┴────────────────────────────────────────────────────────────┘ ``` +--- + ## Dependency Graph ``` @@ -189,6 +297,7 @@ Level 0: User Input Level 1: Foreman Otter └─> Scaffolds project (if needed) + └─> Discovers available otters dynamically Level 2: Brand Otter └─> Creates BRAND_BRIEF.md @@ -209,16 +318,19 @@ Level 5: Verification **CRITICAL**: Otters must be invoked SEQUENTIALLY, never in parallel. +--- + ## Handoff Protocol ``` User Request │ ▼ -┌────────────────────────┐ -│ Foreman Otter │ ◄── Entry point -│ "Starting full build" │ -└───────┬────────────────┘ +┌────────────────────────────────────────────────────────────┐ +│ Foreman Otter (Dynamic Coordinator) │ +│ "Starting full build" │ +│ → list_agents() to discover available otters │ +└───────┬────────────────────────────────────────────────────┘ │ invoke ▼ ┌────────────────────────┐ @@ -251,23 +363,25 @@ User Request ▼ ┌────────────────────────┐ │ Page Otter │ ◄── Phase 3: Content -│ "Building pages..." │ +│ "Building pages..." │ └───────┬────────────────┘ │ creates pages/*.yml ▼ ┌────────────────────────┐ │ Foreman Otter │ ◄── Verification -│ "Rendering pages..." │ +│ "Rendering pages..." │ └───────┬────────────────┘ │ visual verification ▼ ┌────────────────────────┐ │ User │ ◄── Handoff -│ "Site is ready! │ -│ Run pnpm dev" │ +│ "Site is ready! │ +│ Run pnpm dev" │ └────────────────────────┘ ``` +--- + ## State Machine ``` @@ -319,6 +433,8 @@ User Request └──────► (back to VERIFYING) ``` +--- + ## Error Handling Flow ``` @@ -359,6 +475,8 @@ Otter Invocation └────────────────┘ ``` +--- + ## Comparison: Legacy vs. New Architecture ``` @@ -381,7 +499,7 @@ Otter Invocation └────────────────────────────────────────────────────────────┘ ┌────────────────────────────────────────────────────────────┐ -│ NEW: Specialized Otters (Modular) │ +│ NEW: Specialized Otters (Modular, Self-Discovering) │ ├────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ @@ -395,16 +513,21 @@ Otter Invocation │ │ │ │ ┌────────────────┐ │ │ │ Foreman Otter │ │ -│ │ (Coordinator) │ │ +│ │ (Dynamic │ │ +│ │ Discovery) │ │ │ └────────────────┘ │ │ │ │ ✅ Single Responsibility Principle │ │ ✅ Reusable phases (skip brand if brief exists) │ │ ✅ Pausable workflows │ │ ✅ Easy to test each phase independently │ +│ ✅ Self-organizing via runtime discovery │ +│ ✅ Graceful degradation when otters are missing │ └────────────────────────────────────────────────────────────┘ ``` +--- + ## Future Extensions ### Phase 2 (Planned) @@ -462,15 +585,36 @@ All Otters ──┬──► RAG Server ◄── Example Corpus --- +## Installation + +Otters are distributed as the [`@stackwright/otters`](https://www.npmjs.com/package/@stackwright/otters) npm package: + +```bash +npm install @stackwright/otters +# or +pnpm add @stackwright/otters +``` + +**Auto-install**: The package's `postinstall` script automatically installs otter agent files to `~/.code_puppy/agents/` for code-puppy discovery. No manual copying required! + +To re-run installation manually: +```bash +node node_modules/@stackwright/otters/scripts/install-agents.js +``` + +--- + ## Key Architectural Principles -1. **Separation of Concerns** — Each otter owns one domain -2. **Sequential Execution** — Dependencies enforced by Foreman -3. **File-Based Handoffs** — BRAND_BRIEF.md, stackwright.yml, pages/*.yml -4. **Validation at Every Step** — No invalid YAML proceeds to next phase -5. **Visual Verification** — Screenshots close the feedback loop -6. **Pausable Workflows** — Can stop after brand, resume later for theme -7. **Reusability** — Skip phases if artifacts already exist +1. **Dynamic Discovery** — Otters find each other at runtime, not by hard-coded coordination +2. **Separation of Concerns** — Each otter owns one domain +3. **Sequential Execution** — Dependencies enforced by Foreman +4. **File-Based Handoffs** — BRAND_BRIEF.md, stackwright.yml, pages/*.yml +5. **Validation at Every Step** — No invalid YAML proceeds to next phase +6. **Visual Verification** — Screenshots close the feedback loop +7. **Pausable Workflows** — Can stop after brand, resume later for theme +8. **Reusability** — Skip phases if artifacts already exist +9. **Emergent Coordination** — The raft organizes itself based on available agents --- diff --git a/package.json b/package.json index 63aec62d..9b5915fe 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:themes": "pnpm --filter @stackwright/themes build", "build:nextjs": "pnpm --filter @stackwright/nextjs build", "build:build-scripts": "pnpm --filter @stackwright/build-scripts build", + "build:scaffold-core": "pnpm --filter @stackwright/scaffold-core build", "build:collections": "pnpm --filter @stackwright/collections build", "build:mcp": "pnpm --filter @stackwright/mcp build", "build:launch-stackwright": "pnpm --filter launch-stackwright build", @@ -21,7 +22,7 @@ "changeset": "changeset", "version-packages": "changeset version", "release": "pnpm build && changeset publish", - "test": "pnpm -r --filter='!@stackwright/e2e' --filter='!launch-stackwright' test", + "test": "pnpm -r --filter='!@stackwright/e2e' --filter='!launch-stackwright' --filter='!@stackwright/scaffold-core' test", "test:core": "pnpm --filter @stackwright/core test", "test:e2e": "pnpm --filter @stackwright/e2e test", "test:coverage": "pnpm -r --filter='!@stackwright/e2e' --filter='!launch-stackwright' test:coverage && node scripts/merge-coverage.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 6a326e59..794b5d29 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -6,6 +6,9 @@ - 24fed0f: feat: Add SBOM generation for supply chain transparency +- Added scaffold hooks system via `@stackwright/scaffold-core` for extensible post-scaffold processing + - Pro packages can now register hooks at lifecycle points: preScaffold, preInstall, postInstall, postScaffold + Every Stackwright build now generates a Software Bill of Materials (SBOM) with: - SPDX 2.3 format (US Government compliance) - CycloneDX 1.5 format (OWASP tooling compatibility) diff --git a/packages/cli/package.json b/packages/cli/package.json index d58abd6c..024eb52d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "@inquirer/prompts": "^8.3.0", "@stackwright/build-scripts": "workspace:*", "@stackwright/sbom-generator": "workspace:*", + "@stackwright/scaffold-core": "workspace:*", "@stackwright/themes": "workspace:*", "@stackwright/types": "workspace:*", "chalk": "^5.6.2", diff --git a/packages/cli/src/commands/scaffold.ts b/packages/cli/src/commands/scaffold.ts index e3a862c3..918a8c27 100644 --- a/packages/cli/src/commands/scaffold.ts +++ b/packages/cli/src/commands/scaffold.ts @@ -6,6 +6,8 @@ import chalk from 'chalk'; import { promptThemeSelection } from '../utils/theme-selector'; import { processTemplate } from '../utils/template-processor'; import { outputResult, outputError, getErrorCode, formatError } from '../utils/json-output'; +import { runScaffoldHooks } from '@stackwright/scaffold-core'; +import type { ScaffoldHookContext } from '@stackwright/scaffold-core'; export interface ScaffoldOptions { name?: string; @@ -18,6 +20,8 @@ export interface ScaffoldOptions { monorepo?: boolean; standalone?: boolean; pages?: string; + /** Install dependencies after scaffolding (default: false) */ + install?: boolean; } export interface ScaffoldResult { @@ -84,19 +88,60 @@ export async function scaffold(targetDir: string, opts: ScaffoldOptions): Promis theme = theme ?? 'corporate'; } + // Initialize context for hooks + const packageJson: Record = {}; + const codePuppyConfig: Record = {}; + + // Run preScaffold hooks + await runScaffoldHooks('preScaffold', { + targetDir, + projectName: name!, + siteTitle: title!, + themeId: theme!, + packageJson, + codePuppyConfig, + dependencyMode: determineDependencyMode(targetDir, opts), + }); + const pages = await processTemplate({ projectName: name!, siteTitle: title!, themeId: theme!, targetDir, - offline: !opts.online, // was: offline: opts.offline + offline: !opts.online, monorepo: opts.monorepo, standalone: opts.standalone, pages: opts.pages, + packageJson, + codePuppyConfig, }); const dependencyMode = determineDependencyMode(targetDir, opts); + // If install is requested, run postInstall hooks + if (opts.install) { + await runScaffoldHooks('postInstall', { + targetDir, + projectName: name!, + siteTitle: title!, + themeId: theme!, + packageJson, + codePuppyConfig, + dependencyMode, + }); + } + + // Run postScaffold hooks + await runScaffoldHooks('postScaffold', { + targetDir, + projectName: name!, + siteTitle: title!, + themeId: theme!, + packageJson, + codePuppyConfig, + dependencyMode, + }); + return { path: targetDir, pages, @@ -128,6 +173,7 @@ export function registerScaffold(program: Command): void { '--pages ', 'Comma-separated list of page slugs to create (e.g., about,contact,pricing)' ) + .option('--install', 'Install dependencies after scaffolding') .option('--json', 'Output machine-readable JSON') .action(async (dir: string | undefined, opts: ScaffoldOptions) => { const targetDir = path.resolve(dir ?? process.cwd()); diff --git a/packages/cli/src/utils/template-processor.ts b/packages/cli/src/utils/template-processor.ts index a08a781a..563d5d38 100644 --- a/packages/cli/src/utils/template-processor.ts +++ b/packages/cli/src/utils/template-processor.ts @@ -12,6 +12,8 @@ import { getGenericPageHints, } from './scaffold-hints'; import { fetchTemplate } from './template-fetcher'; +import { runScaffoldHooks } from '@stackwright/scaffold-core'; +import type { ScaffoldHookContext } from '@stackwright/scaffold-core'; /** * Walk up from the given directory looking for a pnpm-workspace.yaml. @@ -54,6 +56,10 @@ export interface TemplateConfig { standalone?: boolean; /** Comma-separated list of page slugs to create in addition to defaults. */ pages?: string; + /** Pre-built package.json to modify (from hooks) */ + packageJson?: Record; + /** Pre-built code-puppy config (from hooks) */ + codePuppyConfig?: Record; } /** @@ -65,6 +71,10 @@ export async function processTemplate(config: TemplateConfig): Promise const written: string[] = []; const year = new Date().getFullYear(); + // Mutable configs that hooks can modify + let packageJson = config.packageJson; + let codePuppyConfig = config.codePuppyConfig; + // Fetch static template files from GitHub repo (falls back to bundled copy) await fetchTemplate(targetDir, { offline }); @@ -147,16 +157,40 @@ export async function processTemplate(config: TemplateConfig): Promise useWorkspaceDeps = detectMonorepoRoot(targetDir) !== null; } - // Generate package.json with proper formatting + // Generate package.json if not provided by hooks + if (!packageJson || Object.keys(packageJson).length === 0) { + packageJson = buildPackageJson(projectName, useWorkspaceDeps) as Record; + } + + // Initialize codePuppyConfig if empty + if (!codePuppyConfig || Object.keys(codePuppyConfig).length === 0) { + codePuppyConfig = {}; + } + + // Run preInstall hooks - hooks can modify packageJson + await runScaffoldHooks('preInstall', { + targetDir, + projectName, + siteTitle, + themeId, + packageJson, + codePuppyConfig, + dependencyMode: useWorkspaceDeps ? 'workspace' : 'standalone', + }); + + // Write package.json with all hook modifications const packageJsonPath = path.join(targetDir, 'package.json'); await fs.ensureDir(path.dirname(packageJsonPath)); - await fs.writeFile( - packageJsonPath, - JSON.stringify(buildPackageJson(projectName, useWorkspaceDeps), null, 2) + '\n', - 'utf8' - ); + await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8'); written.push('package.json'); + // Write .code-puppy.json if hooks populated it + if (codePuppyConfig && Object.keys(codePuppyConfig).length > 0) { + const codePuppyPath = path.join(targetDir, '.code-puppy.json'); + await fs.writeFile(codePuppyPath, JSON.stringify(codePuppyConfig, null, 2) + '\n', 'utf8'); + written.push('.code-puppy.json'); + } + // Generate tsconfig.json const tsconfigPath = path.join(targetDir, 'tsconfig.json'); await fs.writeFile(tsconfigPath, JSON.stringify(buildTsConfig(), null, 2) + '\n', 'utf8'); diff --git a/packages/launch-stackwright/CHANGELOG.md b/packages/launch-stackwright/CHANGELOG.md index 23cbe598..9faba99b 100644 --- a/packages/launch-stackwright/CHANGELOG.md +++ b/packages/launch-stackwright/CHANGELOG.md @@ -8,6 +8,10 @@ - Updated dependencies [24fed0f] - @stackwright/otters@0.2.0-alpha.2 - @stackwright/cli@0.7.0-alpha.10 +- Added `--otter-raft` flag for one-command project setup with all dependencies installed +- Run `npx launch-stackwright my-site --otter-raft` to scaffold and install in one step +- Updated to use scaffold hooks system for MCP configuration +- Hooks run automatically when using --otter-raft flag ## 0.2.0-alpha.6 diff --git a/packages/launch-stackwright/README.md b/packages/launch-stackwright/README.md index ed4b614f..77d8f545 100644 --- a/packages/launch-stackwright/README.md +++ b/packages/launch-stackwright/README.md @@ -5,12 +5,13 @@ The fastest way to get started with Stackwright — launch a new project with th ## Quick Start ```bash -npx launch-stackwright my-awesome-site +npx launch-stackwright my-awesome-site --otter-raft cd my-awesome-site -pnpm install pnpm dev ``` +> 💡 **Tip:** The `--otter-raft` flag sets up everything including AI agents and installs all dependencies in one go. Prefer manual control? Just run without it: `npx launch-stackwright my-site`, then `pnpm install` separately. + ## What You Get When you run `launch-stackwright`, you get: @@ -45,6 +46,7 @@ Options: --name Project name (used in package.json) --title Site title shown in app bar and browser tab --theme <themeId> Theme ID (corporate, creative, minimal, etc.) + --otter-raft Setup the otter raft AI agents and install all dependencies --force Launch even if directory is not empty --skip-otters Skip copying otter raft configs -y, --yes Skip all prompts, use defaults @@ -55,6 +57,9 @@ Options: ## Examples ```bash +# One-command setup with otter raft (recommended) +npx launch-stackwright my-site --otter-raft + # Interactive mode (default) npx launch-stackwright my-site @@ -90,6 +95,21 @@ my-awesome-site/ └── tsconfig.json # TypeScript config ``` +## Next Steps + +### With `--otter-raft` +After launching, you're ready to go! Run: +```bash +pnpm dev +``` + +### Without `--otter-raft` +You'll need to install dependencies first: +```bash +pnpm install +pnpm dev +``` + ## Prerequisites - **Node.js** 18+ and **pnpm** diff --git a/packages/launch-stackwright/package.json b/packages/launch-stackwright/package.json index 4702f179..5d26232b 100644 --- a/packages/launch-stackwright/package.json +++ b/packages/launch-stackwright/package.json @@ -28,6 +28,7 @@ "dependencies": { "@stackwright/cli": "workspace:*", "@stackwright/otters": "workspace:*", + "@stackwright/scaffold-core": "workspace:*", "chalk": "^5.6.2", "commander": "^14.0.3", "fs-extra": "^11.3", diff --git a/packages/launch-stackwright/src/index.ts b/packages/launch-stackwright/src/index.ts index fe2ffe47..32e9729e 100644 --- a/packages/launch-stackwright/src/index.ts +++ b/packages/launch-stackwright/src/index.ts @@ -1,10 +1,15 @@ #!/usr/bin/env node import { Command } from 'commander'; import path from 'path'; -import fs from 'fs-extra'; import chalk from 'chalk'; +import { execSync } from 'child_process'; import { scaffold, ScaffoldOptions } from '@stackwright/cli'; +import { + registerScaffoldHook, + clearScaffoldHooks, + type ScaffoldHookContext, +} from '@stackwright/scaffold-core'; const { version } = require('../package.json') as { version: string }; @@ -14,32 +19,53 @@ interface LaunchOptions { theme?: string; force?: boolean; skipOtters?: boolean; + otterRaft?: boolean; yes?: boolean; } -async function setupCodePuppyConfig(targetDir: string): Promise<void> { - // Generate .code-puppy.json for MCP auto-configuration - // Otters are now installed as a package: @stackwright/otters - const codePuppyConfig = { - mcp_servers: { - stackwright: { - command: 'node', - args: ['node_modules/@stackwright/mcp/dist/server.js'], - env: { - NODE_ENV: 'development', +/** + * Register the MCP config hook for Code Puppy integration. + * This hook runs during scaffold's preInstall phase and populates + * ctx.codePuppyConfig, which processTemplate writes to .code-puppy.json. + */ +function registerLaunchHooks(): void { + registerScaffoldHook({ + type: 'preInstall', + name: 'code-puppy-mcp-config', + priority: 10, // Run early + handler: async (ctx: ScaffoldHookContext) => { + ctx.codePuppyConfig = { + mcp_servers: { + stackwright: { + command: 'node', + args: ['node_modules/@stackwright/mcp/dist/server.js'], + env: { + NODE_ENV: 'development', + }, + }, }, - }, + agents_path: 'node_modules/@stackwright/otters', + }; }, - agents_path: 'node_modules/@stackwright/otters', - }; + }); +} - await fs.writeFile( - path.join(targetDir, '.code-puppy.json'), - JSON.stringify(codePuppyConfig, null, 2) - ); +function setupOtterRaft(targetDir: string): void { + console.log(chalk.cyan('\n🦦 Installing the otter raft...\n')); - console.log(chalk.green('✅ @stackwright/otters installed as dependency! 🦪🦪🦪🦪')); - console.log(chalk.dim(' MCP auto-config ready in .code-puppy.json')); + try { + execSync('pnpm install', { + cwd: targetDir, + stdio: 'inherit', + }); + console.log(chalk.green('\n✅ Otter raft is ready!')); + } catch { + console.log(chalk.yellow('\n⚠️ Could not run pnpm install automatically.')); + console.log(chalk.white(`\nPlease run these commands manually:`)); + console.log(chalk.cyan(` cd ${path.relative(process.cwd(), targetDir) || '.'}`)); + console.log(chalk.cyan(` pnpm install`)); + throw new Error('Otter raft setup failed'); + } } async function launch(targetDir: string, options: LaunchOptions): Promise<void> { @@ -57,31 +83,50 @@ async function launch(targetDir: string, options: LaunchOptions): Promise<void> }; try { - const result = await scaffold(targetDir, scaffoldOptions); + // Register launch-specific hooks BEFORE scaffold runs + if (!options.skipOtters) { + console.log(chalk.cyan('\n🦪 Setting up the otter raft...\n')); + registerLaunchHooks(); + } + + // Scaffold with install=true so preInstall/postInstall hooks run + const result = await scaffold(targetDir, { + ...scaffoldOptions, + install: true, + }); console.log(chalk.green(`\n✅ Project scaffolded successfully!`)); console.log(chalk.dim(` Location: ${result.path}`)); console.log(chalk.dim(` Theme: ${result.theme}`)); console.log(chalk.dim(` Pages: ${result.pages.join(', ')}`)); - // Setup Code Puppy MCP config unless --skip-otters + // Confirm MCP config was written if (!options.skipOtters) { - console.log(chalk.cyan('\n🦪 Setting up the otter raft...\n')); - await setupCodePuppyConfig(targetDir); + console.log(chalk.green('✅ @stackwright/otters installed as dependency! 🦪🦪🦪🦪')); + console.log(chalk.dim(' MCP auto-config written to .code-puppy.json')); } + // Clean up hooks after use + clearScaffoldHooks(); + // Print next steps console.log(chalk.cyan.bold("\n🎉 All set! Here's what to do next:\n")); - console.log(chalk.white(` 1. cd ${path.relative(process.cwd(), targetDir) || '.'}`)); - console.log(chalk.white(` 2. pnpm install`)); - console.log(chalk.white(` 3. pnpm dev`)); - if (!options.skipOtters) { - console.log(chalk.cyan.bold('\n🦦 Want the otter raft to build your site for you?\n')); + console.log(chalk.green.bold('Your project is ready for the otter raft!\n')); + console.log(chalk.white(` cd ${path.relative(process.cwd(), targetDir) || '.'}`)); + console.log(chalk.white(` pnpm dev`)); + console.log(chalk.cyan.bold('\n🦦 Start building:')); console.log(chalk.white(` code-puppy invoke stackwright-foreman-otter`)); console.log(chalk.dim(` Then say: "Build me a [type] website for [name]"\n`)); + } else { + console.log(chalk.white(` 1. cd ${path.relative(process.cwd(), targetDir) || '.'}`)); + console.log(chalk.white(` 2. pnpm install`)); + console.log(chalk.white(` 3. pnpm dev`)); + console.log(chalk.cyan.bold('\n🦦 Want the otter raft too? Run with --skip-otters=false')); + console.log(chalk.dim(' or manually install @stackwright/otters later.\n')); } } catch (err) { + clearScaffoldHooks(); // Clean up on error too console.error(chalk.red('\n❌ Launch failed:'), err); process.exit(1); } @@ -100,6 +145,7 @@ async function main(): Promise<void> { .option('--theme <themeId>', 'Theme ID (e.g., corporate, creative, minimal)') .option('--force', 'Launch even if the target directory is not empty') .option('--skip-otters', 'Skip setting up Code Puppy MCP config') + .option('--otter-raft', 'Setup the otter raft AI agents and install all dependencies') .option('-y, --yes', 'Skip all prompts, use defaults') .action(async (directory: string, options: LaunchOptions) => { const targetDir = path.resolve(directory); diff --git a/packages/otters/README.md b/packages/otters/README.md index 4fb3d8ae..b0be178a 100644 --- a/packages/otters/README.md +++ b/packages/otters/README.md @@ -1,8 +1,10 @@ # @stackwright/otters -🦦 **Stackwright Otter Raft** — AI agents for end-to-end Stackwright site generation. +🦝 **Otter Raft Architecture** — AI agents that discover each other and self-organize. -A coordinated team of specialized AI agents (otters) that work together to build complete Stackwright websites from brand discovery to deployed pages. +Just like real otters, our AI otters don't wait for instructions. They discover +who's in the water and adapt their behavior accordingly. A Page Otter that finds +a Dashboard Otter nearby will offer to connect live API data. No central planner required. --- @@ -26,12 +28,37 @@ node node_modules/@stackwright/otters/scripts/install-agents.js ## The Otter Raft -| Otter | Role | Output | -|-------|------|--------| -| 🦦🏗️ **Foreman Otter** | Project coordinator | Orchestrates the pipeline | -| 🦦🎨 **Brand Otter** | Brand discovery | `BRAND_BRIEF.md` | -| 🦦🌈 **Theme Otter** | Visual design | `stackwright.yml` theme | -| 🦦📄 **Page Otter** | Content composition | `pages/*.yml` | +| Otter | Role | Discovery | +|-------|------|-----------| +| 🦦🏗️ **Foreman Otter** | Dynamic coordinator | Uses `list_agents()` to discover otters | +| 🦦🎨 **Brand Otter** | Brand discovery | May guide discovery itself if alone | +| 🦦🌈 **Theme Otter** | Visual design | Adapts to available Brand output | +| 🦦📄 **Page Otter** | Content composition | Offers Pro features if Dashboard Otter found | + +--- + +## How Otters Discover Each Other + +Every otter starts by asking: "Who's out there?" + +```bash +code-puppy -i -a stackwright-foreman-otter +# Foreman: "Discovering available otters..." +# Found: Brand Otter ✓, Theme Otter ✓, Page Otter ✓ +# Pro detected: API Otter, Dashboard Otter ✓ +``` + +The raft adapts based on what's installed: + +| Install Type | Default Otters | Optional Pro Otters | +|--------------|----------------|---------------------| +| OSS only | Brand → Theme → Page | — | +| OSS + Pro | Brand → Theme → Page | Dashboard, API | + +When a Page Otter finds a Dashboard Otter in the raft, it: +- Offers to connect live API data to pages +- Suggests dashboard components +- Enables real-time data visualization --- @@ -44,20 +71,20 @@ User Request │ ▼ ┌────────────────────────┐ -│ Foreman Otter │ ◄── Entry point +│ Foreman Otter │ ◄── Entry point, discovers otters │ "Starting build..." │ └───────┬────────────────┘ │ ▼ ┌────────────────────────┐ -│ Brand Otter │ ◄── Phase 1: Discovery +│ Brand Otter │ ◄── Phase 1: Discovery │ (conversational) │ └───────┬────────────────┘ │ creates BRAND_BRIEF.md ▼ ┌────────────────────────┐ -│ Theme Otter │ ◄── Phase 2: Design -│ (colors, fonts) │ +│ Theme Otter │ ◄── Phase 2: Design +│ (colors, fonts) │ └───────┬────────────────┘ │ creates stackwright.yml ▼ @@ -68,7 +95,7 @@ User Request │ creates pages/ ▼ ┌────────────────────────┐ -│ Visual Verification │ ◄── Screenshots +│ Visual Verification │ ◄── Screenshots └───────┬────────────────┘ │ ▼ @@ -80,13 +107,13 @@ User Request Otters are invoked through Code Puppy's agent invocation: ```bash -# Start a full site build +# Start a full site build (Foreman discovers available otters) code-puppy -i -a stackwright-foreman-otter # Just refine the theme code-puppy -i -a stackwright-theme-otter -# Add a new page +# Add a new page (offers Pro features if Dashboard Otter found) code-puppy -i -a stackwright-page-otter ``` @@ -118,11 +145,13 @@ For detailed architecture documentation, see [OTTER_ARCHITECTURE.md](../../OTTER ### Key Principles -1. **Separation of Concerns** — Each otter owns one domain -2. **Sequential Execution** — Dependencies enforced by Foreman -3. **File-Based Handoffs** — BRAND_BRIEF.md, stackwright.yml, pages/*.yml -4. **Validation at Every Step** — No invalid YAML proceeds -5. **Visual Verification** — Screenshots close the feedback loop +1. **Self-Discovery** — Otters discover each other at runtime via `list_agents()` +2. **Dynamic Adaptation** — Behavior changes based on available otters +3. **Separation of Concerns** — Each otter owns one domain +4. **Sequential Execution** — Dependencies enforced by Foreman +5. **File-Based Handoffs** — BRAND_BRIEF.md, stackwright.yml, pages/*.yml +6. **Validation at Every Step** — No invalid YAML proceeds +7. **Visual Verification** — Screenshots close the feedback loop --- diff --git a/packages/otters/src/stackwright-foreman-otter.json b/packages/otters/src/stackwright-foreman-otter.json index 7fa8f988..4819b7b4 100644 --- a/packages/otters/src/stackwright-foreman-otter.json +++ b/packages/otters/src/stackwright-foreman-otter.json @@ -23,18 +23,65 @@ "stackwright_check_dev_server", "stackwright_render_page" ], - "user_prompt": "Hey there! 🦦🏗️ I'm the Foreman Otter — your Stackwright project coordinator.\n\nI orchestrate the entire site-building pipeline from brand discovery to deployed pages. I coordinate three specialist otters:\n- 🎨 **Brand Otter** — discovers your brand through conversation\n- 🌈 **Theme Otter** — translates brand into visual design (colors, fonts, spacing)\n- 📄 **Page Otter** — builds pages in your brand voice\n\nYou can work with me in two modes:\n\n**Full Build Mode**: \"Build me a law firm website\"\n→ I'll coordinate all three otters end-to-end\n\n**Single Phase Mode**: \"I already have a brand brief, just build the theme\"\n→ I'll invoke the specific otter you need\n\nI handle project scaffolding, handoffs between otters, validation, and visual verification. You just describe what you want — I manage the pipeline.\n\nWhat would you like to build today?", + "user_prompt": "Hey there! 🦦🏗️ I'm the Foreman Otter — your Stackwright project coordinator.\n\nI orchestrate the entire site-building pipeline from brand discovery to deployed pages. I dynamically discover available specialist otters and coordinate them based on your needs.\n\nYou can work with me in two modes:\n\n**Full Build Mode**: \"Build me a law firm website\"\n→ I'll discover available otters and coordinate them end-to-end\n\n**Single Phase Mode**: \"I already have a brand brief, just build the theme\"\n→ I'll invoke the specific otter you need\n\nI handle project scaffolding, handoffs between otters, validation, and visual verification. You just describe what you want — I manage the pipeline.\n\nWhat would you like to build today?", "system_prompt": [ "You are the Stackwright Foreman Otter 🦦🏗️ — the coordinator and project manager.", "", "## YOUR ROLE", "", - "You orchestrate the **end-to-end Stackwright site building pipeline** by coordinating specialist otters:", - "- **Brand Otter** (`stackwright-brand-otter`) — brand discovery → BRAND_BRIEF.md", - "- **Theme Otter** (`stackwright-theme-otter`) — visual design → stackwright.yml theme", - "- **Page Otter** (`stackwright-page-otter`) — content composition → pages/*.yml", + "You orchestrate the **end-to-end Stackwright site building pipeline** by dynamically discovering and coordinating specialist otters.", "", - "You are the USER-FACING interface. Users don't need to know which otter does what. They tell YOU what they want, and YOU coordinate the specialists.", + "**You are the USER-FACING interface.** Users don't need to know which otters exist or what they do. They tell YOU what they want, and YOU discover and coordinate the available specialists.", + "", + "---", + "", + "## DYNAMIC OTTER DISCOVERY", + "", + "**At startup, discover all available otters using `list_agents`:**", + "", + "```typescript", + "// Step 1: Call list_agents to get all available agents", + "const agents = await list_agents();", + "", + "// Step 2: Filter for otters - agents whose names end with '-otter'", + "const availableOtters = agents.filter(agent => agent.name.endsWith('-otter'));", + "", + "// Step 3: Build capability map based on otter names", + "const otterCapabilities = {", + " brand: availableOtters.find(a => a.name.includes('brand-otter')),", + " theme: availableOtters.find(a => a.name.includes('theme-otter')),", + " page: availableOtters.find(a => a.name.includes('page-otter')),", + " api: availableOtters.find(a => a.name.includes('api-otter')),", + " data: availableOtters.find(a => a.name.includes('data-otter')),", + " dashboard: availableOtters.find(a => a.name.includes('dashboard-otter')),", + "};", + "```", + "", + "**Known otter name patterns and their capabilities:**", + "", + "| Pattern | Capability | Output | Notes |", + "|---------|------------|--------|-------|", + "| `*-brand-otter` | Brand discovery | BRAND_BRIEF.md | Core - needed for full builds |", + "| `*-theme-otter` | Theme design | stackwright.yml customTheme | Core - needs BRAND_BRIEF.md |", + "| `*-page-otter` | Page building | pages/*.yml | Core - needs stackwright.yml |", + "| `*-api-otter` | API discovery | Pro feature | Optional |", + "| `*-data-otter` | Data configuration | Pro feature | Optional |", + "| `*-dashboard-otter` | Dashboard building | Pro feature | Optional |", + "", + "**Pro feature detection:**", + "```typescript", + "// Check if Pro features are available", + "if (!otterCapabilities.api) {", + " // Gracefully inform user", + " \"API Otter not found - Pro features unavailable\"", + "}", + "```", + "", + "**Handling missing otters:**", + "- If no brand otter: Ask user for brand brief manually, create BRAND_BRIEF.md yourself", + "- If no theme otter: Use a built-in theme, skip custom theme phase", + "- If no page otter: Explain that page building requires the Page Otter plugin", + "- If no Pro otters: Don't mention Pro features, proceed with core build", "", "---", "", @@ -73,15 +120,18 @@ "", "### Mode 1: Full Build (Brand → Theme → Pages)", "", - "When the user says: \"Build me a [type of] website\"", + "When the user says: \"Build me a [type of site]\"", "", "**Pipeline:**", "1. **Scaffold project** (if needed)", - "2. **Invoke Brand Otter** → BRAND_BRIEF.md", - "3. **Invoke Theme Otter** → stackwright.yml", - "4. **Invoke Page Otter** → pages/", - "5. **Visual verification** (render home page)", - "6. **Handoff to user** with next steps", + "2. **Discover otters** using `list_agents`", + "3. **Invoke Brand Otter** (if available) → BRAND_BRIEF.md", + "4. **Invoke Theme Otter** (if available) → stackwright.yml", + "5. **Invoke Page Otter** (if available) → pages/", + "6. **Visual verification** (render home page)", + "7. **Handoff to user** with next steps", + "", + "**Note:** If any core otter is missing, gracefully adapt (see \"Handling missing otters\" above)", "", "### Mode 2: Partial Build (Skip Completed Phases)", "", @@ -97,7 +147,8 @@ "When the user says: \"Refine my theme\" or \"Add an about page\"", "", "**Action:**", - "- Invoke the specific otter (Theme or Page)", + "- Discover the appropriate otter using `list_agents`", + "- Invoke it dynamically (no hard-coded names)", "- No full pipeline needed", "", "---", @@ -119,22 +170,11 @@ "I'll need:", "- Project name (e.g., \"my-law-firm-site\")", "- Where to create it (default: ./my-law-firm-site)", - "```", + "Then scaffold using the MCP tool or CLI with --otter-raft flag:", "", - "Then scaffold using the MCP tool:", - "```typescript", - "// Use the stackwright_scaffold_project MCP tool", - "// This works without requiring global CLI installation", - "invoke_mcp_tool({", - " tool_name: \"stackwright_scaffold_project\",", - " arguments: {", - " targetDir: \"/absolute/path/to/<target-dir>\",", - " name: \"<name>\",", - " theme: \"default\",", - " force: true,", - " pages: \"home,about,contact\"", - " }", - "});", + "I'll run: npx launch-stackwright --otter-raft <name> to scaffold and install everything.", + "```bash", + "npx launch-stackwright --otter-raft <target-dir> --name <name> --theme default", "```", "", "**Note**: The MCP server must be running (`pnpm stackwright-mcp` from the stackwright repo). If the MCP server is not available, fall back to the CLI approach but explain this requirement to the user.", @@ -142,27 +182,57 @@ "**If project exists:**", "Proceed with the existing project structure.", "", - "### Step 2: Invoke Brand Otter", + "### Step 2: Invoke Brand Otter (Dynamically)", + "", + "**Discover brand otter first:**", + "```typescript", + "// Discover available otters", + "const agents = await list_agents();", + "const brandOtter = agents.find(a => a.name.includes('brand-otter'));", + "", + "if (!brandOtter) {", + " // Graceful fallback: ask user for brand details", + " // Create BRAND_BRIEF.md manually or inform user", + " \"Brand Otter not available. Please provide your brand brief manually.\"", + "} else {", + " // Invoke the discovered brand otter", + " invoke_agent({", + " agent_name: brandOtter.name,", + " prompt: \"The user wants to build a [type of site]. Lead them through brand discovery and create BRAND_BRIEF.md.\"", + " });", + "}", + "```", "", - "**Check if BRAND_BRIEF.md exists:**", + "**Check if BRAND_BRIEF.md already exists:**", "```bash", "ls -la BRAND_BRIEF.md", "```", "", + "**If brand brief exists:**", + "Skip to Step 3.", + "", "**If NO brand brief:**", - "```typescript", - "invoke_agent({", - " agent_name: \"stackwright-brand-otter\",", - " prompt: \"The user wants to build a [type of site]. Please lead them through brand discovery and create a BRAND_BRIEF.md in the project directory.\"", - "});", - "```", + "Invoke the discovered Brand Otter (or ask user to provide brief if unavailable).", "", - "Wait for Brand Otter to complete. It will signal completion by creating BRAND_BRIEF.md.", + "Wait for Brand Otter to complete. It signals completion by creating BRAND_BRIEF.md.", "", - "**If brand brief exists:**", - "Skip to Step 3.", + "### Step 3: Invoke Theme Otter (Dynamically)", "", - "### Step 3: Invoke Theme Otter", + "**Discover theme otter first:**", + "```typescript", + "const agents = await list_agents();", + "const themeOtter = agents.find(a => a.name.includes('theme-otter'));", + "", + "if (!themeOtter) {", + " // Graceful fallback: proceed with default theme", + " \"Theme Otter not available. Using default theme.\"", + "} else {", + " invoke_agent({", + " agent_name: themeOtter.name,", + " prompt: \"Read BRAND_BRIEF.md and create a custom theme in stackwright.yml. Validate when done.\"", + " });", + "}", + "```", "", "**Check if stackwright.yml has a customTheme:**", "```bash", @@ -170,32 +240,35 @@ "```", "", "**If NO custom theme:**", - "```typescript", - "invoke_agent({", - " agent_name: \"stackwright-theme-otter\",", - " prompt: \"Read BRAND_BRIEF.md in this project and create a custom theme in stackwright.yml that matches the brand. Validate the theme when done.\"", - "});", - "```", + "Invoke the discovered Theme Otter (or use default theme if unavailable).", "", "**If custom theme exists:**", "Ask user: \"A custom theme already exists. Should I refine it or skip to page building?\"", "", - "### Step 4: Invoke Page Otter", + "### Step 4: Invoke Page Otter (Dynamically)", + "", + "**Discover page otter first:**", + "```typescript", + "const agents = await list_agents();", + "const pageOtter = agents.find(a => a.name.includes('page-otter'));", + "", + "if (!pageOtter) {", + " // Graceful fallback: explain limitation", + " \"Page Otter not available. Please install the Page Otter plugin to build pages.\"", + "} else {", + " invoke_agent({", + " agent_name: pageOtter.name,", + " prompt: \"Build pages for this project. Read BRAND_BRIEF.md for voice, stackwright.yml for theme.\"", + " });", + "}", + "```", "", "**Determine which pages to build:**", "- Read BRAND_BRIEF.md → extract \"Pages Needed\" section", "- OR ask user: \"Which pages should I build first? (home, about, services, contact, pricing, etc.)\"", "", - "**For each page:", - "```typescript", - "invoke_agent({", - " agent_name: \"stackwright-page-otter\",", - " prompt: \"Build the [page name] page for this project. Read BRAND_BRIEF.md for voice and tone, and stackwright.yml for theme colors. Write the page YAML, validate it, and render it if a dev server is running.\"", - "});", - "```", - "", - "**Batch mode (recommended):", - "Ask Page Otter to build ALL pages in one invocation:", + "**For each page (or batch):**", + "Invoke the discovered Page Otter with specific page requests:", "```", "Build these pages following the BRAND_BRIEF.md:", "1. Home page (hero + value prop + services + CTA)", @@ -218,9 +291,8 @@ "", "To see your site:", "1. cd <project-dir>", - "2. pnpm install", - "3. pnpm dev", - "4. Open http://localhost:3000", + "2. pnpm dev", + "3. Open http://localhost:3000", "", "Want me to render screenshots so you can preview without running the dev server?", "```", @@ -252,64 +324,104 @@ "", "---", "", - "## OTTER INVOCATION GUIDE", + "## OTTER INVOCATION GUIDE (DYNAMIC)", + "", + "**Always discover otters before invoking:**", + "```typescript", + "const agents = await list_agents();", + "const brandOtter = agents.find(a => a.name.includes('brand-otter'));", + "const themeOtter = agents.find(a => a.name.includes('theme-otter'));", + "const pageOtter = agents.find(a => a.name.includes('page-otter'));", + "```", "", "### When to Invoke Brand Otter", "", "✅ **Invoke when:**", - "- No BRAND_BRIEF.md exists", + "- Brand Otter is discovered AND no BRAND_BRIEF.md exists", "- User says \"I want to refine my brand\"", "- User is starting a new project from scratch", "", "❌ **Skip when:**", "- BRAND_BRIEF.md already exists and user is happy with it", - "- User has their own brand guidelines document (ask them to create BRAND_BRIEF.md manually)", + "- Brand Otter is NOT available (ask user for brand brief manually)", "", "### When to Invoke Theme Otter", "", "✅ **Invoke when:**", - "- stackwright.yml has no `customTheme` section", + "- Theme Otter is discovered AND stackwright.yml has no `customTheme` section", "- User says \"I want to change the colors/fonts\"", "- User says \"The theme doesn't match my brand\"", "", "❌ **Skip when:**", "- User is happy with an existing built-in theme", "- User only wants to add pages, not change design", + "- Theme Otter is NOT available (use default theme)", "", "### When to Invoke Page Otter", "", "✅ **Invoke when:**", - "- Pages need to be created", + "- Page Otter is discovered AND pages need to be created", "- User says \"Add a pricing page\"", "- User says \"Rewrite the about page in a different voice\"", "", "❌ **Skip when:**", "- User is only refining theme/brand, not content", + "- Page Otter is NOT available (inform user this feature requires Page Otter)", + "", + "### Graceful Degradation Patterns", + "", + "| Missing Otter | Fallback Behavior |", + "|---------------|-------------------|", + "| No Brand Otter | Ask user for brand brief; create BRAND_BRIEF.md yourself |", + "| No Theme Otter | Use default theme; skip custom theme phase |", + "| No Page Otter | Explain feature requires Page Otter plugin |", + "| No Pro Otters | Don't mention Pro features; proceed with core build |", "", "---", "", - "## COORDINATION PATTERNS", + "## COORDINATION PATTERNS (DYNAMIC)", "", "### Sequential Invocation (Full Build)", "", "```typescript", - "// 1. Brand discovery", - "const brandResult = await invoke_agent({", - " agent_name: \"stackwright-brand-otter\",", - " prompt: \"Lead brand discovery for a law firm website\"", - "});", - "", - "// 2. Theme design (reads BRAND_BRIEF.md)", - "const themeResult = await invoke_agent({", - " agent_name: \"stackwright-theme-otter\",", - " prompt: \"Build a custom theme from BRAND_BRIEF.md\"", - "});", - "", - "// 3. Page building (reads BRAND_BRIEF.md + stackwright.yml)", - "const pageResult = await invoke_agent({", - " agent_name: \"stackwright-page-otter\",", - " prompt: \"Build home, about, services, and contact pages\"", - "});", + "// 1. Discover available otters", + "const agents = await list_agents();", + "const otterMap = {", + " brand: agents.find(a => a.name.includes('brand-otter')),", + " theme: agents.find(a => a.name.includes('theme-otter')),", + " page: agents.find(a => a.name.includes('page-otter')),", + "};", + "", + "// 2. Brand discovery (if available)", + "if (otterMap.brand) {", + " const brandResult = await invoke_agent({", + " agent_name: otterMap.brand.name,", + " prompt: \"Lead brand discovery for a law firm website\"", + " });", + "} else {", + " // Graceful fallback", + " \"Brand Otter not found. Please provide your brand brief.\"", + "}", + "", + "// 3. Theme design (if available, reads BRAND_BRIEF.md)", + "if (otterMap.theme) {", + " const themeResult = await invoke_agent({", + " agent_name: otterMap.theme.name,", + " prompt: \"Build a custom theme from BRAND_BRIEF.md\"", + " });", + "} else {", + " \"Using default theme.\"", + "}", + "", + "// 4. Page building (if available, reads BRAND_BRIEF.md + stackwright.yml)", + "if (otterMap.page) {", + " const pageResult = await invoke_agent({", + " agent_name: otterMap.page.name,", + " prompt: \"Build home, about, services, and contact pages\"", + " });", + "} else {", + " \"Page Otter not found. Page building requires Page Otter plugin.\"", + "}", "```", "", "### Parallel Invocation (NOT RECOMMENDED)", @@ -325,7 +437,7 @@ "When user says \"The home page feels too corporate\":", "", "1. **Ask clarifying questions**: \"Should I adjust the brand voice, the theme colors, or the page copy?\"", - "2. **Invoke the appropriate otter**:", + "2. **Discover and invoke the appropriate otter**:", " - Brand voice → Brand Otter", " - Colors/fonts → Theme Otter", " - Copy/layout → Page Otter", @@ -338,12 +450,13 @@ "### After Each Otter Completes", "", "**Check for completion signals:**", - "- Brand Otter → BRAND_BRIEF.md file exists", - "- Theme Otter → stackwright.yml has `customTheme` section", - "- Page Otter → pages/*.yml files exist and validate", + "- Brand Otter (if invoked) → BRAND_BRIEF.md file exists", + "- Theme Otter (if invoked) → stackwright.yml has `customTheme` section", + "- Page Otter (if invoked) → pages/*.yml files exist and validate", "", "**If an otter reports errors:**", "- Read the error message", + "- Re-discover otters (in case of dynamic loading)", "- Determine if you can fix it (e.g., validation error) or need to re-invoke", "- Re-invoke with specific instructions: \"Fix the validation error in stackwright.yml: missing 'textSecondary' color\"", "", @@ -370,6 +483,33 @@ "", "---", "", + "## DYNAMIC DISCOVERY CHECKLIST", + "", + "Before each invocation, verify the otter is still available:", + "", + "```typescript", + "async function discoverAndInvokeOtter(type: 'brand' | 'theme' | 'page') {", + " const agents = await list_agents();", + " const otter = agents.find(a => a.name.includes(`${type}-otter`));", + " ", + " if (!otter) {", + " return { found: false, action: getFallbackAction(type) };", + " }", + " ", + " return { found: true, agent: otter };", + "}", + "", + "function getFallbackAction(type: string): string {", + " switch (type) {", + " case 'brand': return 'Ask user for brand brief';", + " case 'theme': return 'Use default theme';", + " case 'page': return 'Inform user Page Otter is required';", + " }", + "}", + "```", + "", + "---", + "", "## PERSONALITY & VOICE", "", "- **Project manager, not builder** — you coordinate, you don't write YAML yourself", @@ -384,13 +524,14 @@ "", "✅ **You DO:**", "- Scaffold projects", - "- Invoke specialist otters (Brand, Theme, Page)", + "- Dynamically discover and invoke specialist otters", "- Coordinate handoffs between otters", "- Validate site config and pages", "- Run visual rendering tools", "- Explain what each otter is doing", "- Handle errors and retry failed steps", - "- Use MCP tools (stackwright_scaffold_project, stackwright_validate_site, stackwright_validate_pages) as the primary interface", + "- Use `list_agents` to dynamically find available otters", + "- Use MCP tools as the primary interface", "- Fall back to CLI shell commands only if MCP server is unavailable", "", "❌ **You DON'T:**", @@ -398,17 +539,19 @@ "- Conduct brand discovery (that's Brand Otter)", "- Design color palettes (that's Theme Otter)", "- Write page copy (that's Page Otter)", + "- Hard-code otter names (always use `list_agents` to discover)", "", "---", "", "## IMPORTANT RULES", "", - "1. **Always invoke otters SEQUENTIALLY** — never in parallel due to dependencies", - "2. **Check for existing assets** before invoking otters (don't redo work)", - "3. **Validate after each phase** — catch errors early", - "4. **Explain what's happening** — don't just silently invoke otters", - "5. **Handle otter errors gracefully** — re-invoke with fixes, don't dump errors on the user", - "6. **You are the user's SINGLE interface** — they shouldn't need to know otter names", + "1. **Always discover otters with `list_agents` before invoking** — never assume a specific otter exists", + "2. **Invoke otters SEQUENTIALLY** — never in parallel due to dependencies", + "3. **Check for existing assets** before invoking otters (don't redo work)", + "4. **Validate after each phase** — catch errors early", + "5. **Handle missing otters gracefully** — provide fallbacks, don't error out", + "6. **Explain what's happening** — don't just silently invoke otters", + "7. **You are the user's SINGLE interface** — they shouldn't need to know otter names", "", "---", "", @@ -416,14 +559,30 @@ "", "### \"Build me a law firm website\"", "```", - "Great! I'll coordinate a full site build:", + "Great! Let me discover what otters are available and coordinate a full site build.", + "", + "[list_agents to discover available otters]", + "", + "I found: Brand Otter, Theme Otter, Page Otter ✓", "", "1. Brand Otter will discover your brand (5-10 min conversation)", "2. Theme Otter will design a custom theme (colors, fonts)", "3. Page Otter will build your pages (home, about, services, contact)", "", "Let's start with brand discovery. I'm handing you off to Brand Otter now.", - "[invoke Brand Otter]", + "[invoke discovered Brand Otter]", + "```", + "", + "### \"Build me a law firm website\" (with missing otters)", + "```", + "I found: Theme Otter, Page Otter ✓ (Brand Otter not available)", + "", + "I can build the site, but I need a brand brief to ensure the design matches your vision.", + "", + "Could you share your brand guidelines? Or I can ask you some quick questions to create one.", + "[create BRAND_BRIEF.md from user input]", + "[invoke discovered Theme Otter]", + "[invoke discovered Page Otter]", "```", "", "### \"I already have a brand brief, just build the site\"", @@ -433,7 +592,7 @@ "Can you share your brand brief? I'll save it as BRAND_BRIEF.md and hand off to Theme Otter.", "[wait for user to provide brief]", "[create BRAND_BRIEF.md]", - "[invoke Theme Otter]", + "[invoke discovered Theme Otter]", "```", "", "### \"Change the home page to feel warmer\"", @@ -443,7 +602,7 @@ "- Warmer COPY (friendlier voice) → Page Otter", "", "Which do you mean, or both?", - "[based on response, invoke appropriate otter]", + "[based on response, discover and invoke appropriate otter]", "```", "", "### \"Add a blog\"", @@ -454,6 +613,7 @@ "3. Update navigation to include the blog link", "", "I'll invoke Page Otter to handle this.", + "[discover Page Otter]", "[invoke Page Otter with specific instructions]", "```", "", @@ -462,11 +622,12 @@ "## SUCCESS CRITERIA", "", "A successful build includes:", - "- ✅ BRAND_BRIEF.md (if starting from scratch)", - "- ✅ stackwright.yml with customTheme (validated)", - "- ✅ pages/*.yml for all requested pages (validated)", + "- ✅ BRAND_BRIEF.md (if starting from scratch AND Brand Otter available)", + "- ✅ stackwright.yml with customTheme (validated, if Theme Otter available)", + "- ✅ pages/*.yml for all requested pages (validated, if Page Otter available)", "- ✅ Home page rendered as screenshot (desktop + mobile)", "- ✅ User understands how to run `pnpm dev` and deploy", + "- ✅ Graceful handling of missing otters with appropriate fallbacks", "", "---", "", diff --git a/packages/scaffold-core/CHANGELOG.md b/packages/scaffold-core/CHANGELOG.md new file mode 100644 index 00000000..b5457567 --- /dev/null +++ b/packages/scaffold-core/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.1.0-alpha.0 + +- Initial release +- Hook types: `preScaffold`, `preInstall`, `postInstall`, `postScaffold` +- Registry functions: `registerScaffoldHook`, `getScaffoldHooks`, `clearScaffoldHooks` +- Context with mutable `packageJson` and `codePuppyConfig` +- Priority and critical hook options diff --git a/packages/scaffold-core/README.md b/packages/scaffold-core/README.md new file mode 100644 index 00000000..b6f72337 --- /dev/null +++ b/packages/scaffold-core/README.md @@ -0,0 +1,202 @@ +# @stackwright/scaffold-core + +Scaffold hooks system for Stackwright - enables extensible post-scaffold processing for Pro packages. + +## Overview + +This package provides a hook system that Pro packages use to extend scaffold functionality: +- Enterprise license injection +- Custom MCP server configuration +- Additional project setup +- Post-install verification + +## Installation + +```bash +pnpm add @stackwright/scaffold-core +``` + +**Note**: This package is typically used by `@stackwright/cli` internally. Pro packages import it to register hooks. + +## Hook Lifecycle + +Hooks run at these points during scaffolding: + +| Hook | When | Use Case | +|------|------|----------| +| `preScaffold` | Before scaffolding begins | Validate credentials, check environment | +| `preInstall` | After files created, before install | Modify package.json, configure MCP | +| `postInstall` | After pnpm install completes | Run additional setup, verify installation | +| `postScaffold` | After scaffolding complete | Final configuration, cleanup | + +## Usage + +### Registering a Hook + +```typescript +import { registerScaffoldHook } from '@stackwright/scaffold-core'; + +registerScaffoldHook({ + type: 'preInstall', + name: 'enterprise-license', + priority: 10, + handler: async (ctx) => { + ctx.packageJson.dependencies['@stackwright-pro/license'] = '^1.0.0'; + }, +}); +``` + +### Hook Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `type` | `ScaffoldHookType` | Required | Lifecycle point | +| `name` | `string` | Required | Unique hook name | +| `priority` | `number` | `50` | Lower = runs first | +| `critical` | `boolean` | `false` | If true, failure fails entire scaffold | +| `handler` | `function` | Required | Async function to execute | + +### Hook Context + +The context object passed to hooks: + +```typescript +interface ScaffoldHookContext { + targetDir: string; // Project directory + projectName: string; // Project name + siteTitle: string; // Site title + themeId: string; // Theme ID + packageJson: Record<string, any>; // Mutable - add dependencies + codePuppyConfig?: Record<string, any>; // Mutable - add MCP config + dependencyMode: 'workspace' | 'standalone'; + pages?: string[]; // Pages being created + install?: boolean; // Whether install will run + [key: string]: any; // Hooks can add properties +} +``` + +## Pro Integration Example + +Create `packages/pro-launch-hooks/index.ts`: + +```typescript +import { registerScaffoldHook } from '@stackwright/scaffold-core'; + +// Add enterprise license during preInstall +registerScaffoldHook({ + type: 'preInstall', + name: 'enterprise-license', + priority: 10, + critical: true, + handler: async (ctx) => { + if (!process.env.PRO_API_KEY) { + throw new Error('PRO_API_KEY environment variable required'); + } + ctx.packageJson.dependencies['@stackwright-pro/license'] = '^1.0.0'; + ctx.packageJson.dependencies['@stackwright-pro/sso'] = '^1.0.0'; + }, +}); + +// Configure enterprise MCP server +registerScaffoldHook({ + type: 'preInstall', + name: 'enterprise-mcp', + priority: 20, + handler: async (ctx) => { + ctx.codePuppyConfig = ctx.codePuppyConfig || {}; + ctx.codePuppyConfig.mcp_servers = ctx.codePuppyConfig.mcp_servers || {}; + ctx.codePuppyConfig.mcp_servers.enterprise = { + command: 'node', + args: ['node_modules/@stackwright-pro/mcp/dist/server.js'], + env: { + API_KEY: process.env.PRO_API_KEY, + }, + }; + }, +}); + +// Post-install verification +registerScaffoldHook({ + type: 'postInstall', + name: 'verify-license', + priority: 50, + handler: async (ctx) => { + // Verify license is valid + const response = await fetch('https://api.stackwright.pro/verify', { + headers: { 'Authorization': `Bearer ${process.env.PRO_API_KEY}` } + }); + if (!response.ok) { + throw new Error('License verification failed'); + } + }, +}); +``` + +In Pro package `package.json`: + +```json +{ + "name": "@stackwright-pro/launch-hooks", + "main": "index.js", + "dependencies": { + "@stackwright/scaffold-core": "workspace:*" + } +} +``` + +## Registry Functions + +```typescript +import { + registerScaffoldHook, // Register a new hook + getScaffoldHooks, // Get all registered hooks + getScaffoldHooksForType, // Get hooks for specific lifecycle + clearScaffoldHooks, // Clear all hooks (testing) +} from '@stackwright/scaffold-core'; +``` + +## CLI Usage + +The hooks run automatically when using: + +```bash +# These commands trigger hook execution +npx @stackwright/cli scaffold my-site +npx launch-stackwright my-site --otter-raft +``` + +## API Reference + +### `registerScaffoldHook(hook)` + +Register a hook to run during scaffolding. + +```typescript +registerScaffoldHook({ + type: 'preInstall', + name: 'my-hook', + priority: 50, + critical: false, + handler: async (ctx) => { /* ... */ }, +}); +``` + +### `runScaffoldHooks(type, context)` + +Internal - called by `@stackwright/cli` during scaffold. + +```typescript +await runScaffoldHooks('preInstall', context); +``` + +## Debugging + +Enable debug output: + +```bash +DEBUG=scaffold-hooks npx launch-stackwright my-site --otter-raft +``` + +## License + +MIT diff --git a/packages/scaffold-core/package.json b/packages/scaffold-core/package.json new file mode 100644 index 00000000..7a84a8af --- /dev/null +++ b/packages/scaffold-core/package.json @@ -0,0 +1,37 @@ +{ + "name": "@stackwright/scaffold-core", + "version": "0.1.0-alpha.0", + "description": "Scaffold hooks system for Stackwright - enables extensible post-scaffold processing", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Per-Aspera-LLC/stackwright" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsup": "^8.5.0", + "typescript": "^5.8.0", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/scaffold-core/src/index.ts b/packages/scaffold-core/src/index.ts new file mode 100644 index 00000000..f553eb42 --- /dev/null +++ b/packages/scaffold-core/src/index.ts @@ -0,0 +1,23 @@ +/** + * @stackwright/scaffold-core + * + * Hook system for extensible scaffold processing. + * Pro packages register hooks that run at lifecycle points. + */ + +// Types +export type { + ScaffoldHookType, + ScaffoldHook, + ScaffoldHookOptions, + ScaffoldHookContext, +} from './types/hooks'; + +// Registry functions +export { + registerScaffoldHook, + getScaffoldHooks, + getScaffoldHooksForType, + clearScaffoldHooks, + runScaffoldHooks, +} from './registry'; diff --git a/packages/scaffold-core/src/registry.ts b/packages/scaffold-core/src/registry.ts new file mode 100644 index 00000000..16577f78 --- /dev/null +++ b/packages/scaffold-core/src/registry.ts @@ -0,0 +1,84 @@ +/** + * Scaffold Hook Registry + * + * Singleton registry for scaffold hooks. Pro packages auto-register by importing. + */ + +import type { ScaffoldHook, ScaffoldHookContext, ScaffoldHookType } from './types/hooks'; + +// Internal storage +let hooks: ScaffoldHook[] = []; + +/** + * Register a hook to run during scaffolding + * + * @example + * import { registerScaffoldHook } from '@stackwright/scaffold-core'; + * + * registerScaffoldHook({ + * type: 'preInstall', + * name: 'enterprise-license', + * priority: 10, + * handler: addEnterpriseLicense, + * }); + */ +export function registerScaffoldHook(hook: ScaffoldHook): void { + hooks.push({ + ...hook, + priority: hook.priority ?? 50, + critical: hook.critical ?? false, + }); + // Sort by priority (lower = runs first) + hooks.sort((a, b) => a.priority! - b.priority!); +} + +/** + * Get all registered hooks + */ +export function getScaffoldHooks(): ReadonlyArray<ScaffoldHook> { + return [...hooks]; +} + +/** + * Get hooks for a specific lifecycle point + */ +export function getScaffoldHooksForType(type: ScaffoldHookType): ReadonlyArray<ScaffoldHook> { + return hooks.filter((h) => h.type === type); +} + +/** + * Clear all registered hooks (useful for testing) + */ +export function clearScaffoldHooks(): void { + hooks = []; +} + +/** + * Run all hooks of a given type + * + * @param type - Lifecycle point + * @param context - Context passed to hooks + * @throws If a critical hook fails + */ +export async function runScaffoldHooks( + type: ScaffoldHookType, + context: ScaffoldHookContext +): Promise<void> { + const relevantHooks = getScaffoldHooksForType(type); + + for (const hook of relevantHooks) { + try { + await hook.handler(context); + } catch (error) { + if (hook.critical) { + throw new Error( + `Critical scaffold hook "${hook.name}" failed: ${(error as Error).message}` + ); + } + // Non-critical: warn but continue + console.warn( + `[Scaffold Hook] Non-critical hook "${hook.name}" failed: ${(error as Error).message}` + ); + } + } +} diff --git a/packages/scaffold-core/src/types/hooks.ts b/packages/scaffold-core/src/types/hooks.ts new file mode 100644 index 00000000..1829f0e9 --- /dev/null +++ b/packages/scaffold-core/src/types/hooks.ts @@ -0,0 +1,68 @@ +/** + * Scaffold Hook Types + * + * Hooks allow Pro packages to extend scaffold with: + * - Enterprise license injection + * - Custom MCP server configuration + * - Additional project setup + * - Post-install verification + */ + +/** + * Hook types representing lifecycle points in scaffolding + */ +export type ScaffoldHookType = + | 'preScaffold' // Before scaffolding begins + | 'preInstall' // After files created, before pnpm install + | 'postInstall' // After pnpm install completes + | 'postScaffold'; // After scaffolding complete + +/** + * A single scaffold hook + */ +export interface ScaffoldHook { + /** Lifecycle point when hook runs */ + type: ScaffoldHookType; + /** Unique name for the hook */ + name: string; + /** Lower priority = runs first (default: 50) */ + priority?: number; + /** If true, hook failure fails entire scaffold (default: false) */ + critical?: boolean; + /** Hook handler function */ + handler: (context: ScaffoldHookContext) => Promise<void> | void; +} + +/** + * Hook registration options + */ +export interface ScaffoldHookOptions extends Partial<ScaffoldHook> { + type: ScaffoldHookType; + name: string; +} + +/** + * Context passed to all hooks + */ +export interface ScaffoldHookContext { + /** Target directory for the new project */ + targetDir: string; + /** Project name */ + projectName: string; + /** Site title */ + siteTitle: string; + /** Theme ID */ + themeId: string; + /** Mutable package.json - hooks can add dependencies */ + packageJson: Record<string, any>; + /** Mutable .code-puppy.json config - hooks can add MCP servers */ + codePuppyConfig?: Record<string, any>; + /** Dependency mode: workspace:* or versioned */ + dependencyMode: 'workspace' | 'standalone'; + /** Pages that will be created */ + pages?: string[]; + /** Whether to install dependencies automatically */ + install?: boolean; + /** Additional properties hooks can add */ + [key: string]: any; +} diff --git a/packages/scaffold-core/tsconfig.json b/packages/scaffold-core/tsconfig.json new file mode 100644 index 00000000..0b697b69 --- /dev/null +++ b/packages/scaffold-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/scaffold-core/tsup.config.ts b/packages/scaffold-core/tsup.config.ts new file mode 100644 index 00000000..f69c8f61 --- /dev/null +++ b/packages/scaffold-core/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + splitting: false, + sourcemap: true, + clean: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21dd685e..3e0bab38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@stackwright/sbom-generator': specifier: workspace:* version: link:../sbom-generator + '@stackwright/scaffold-core': + specifier: workspace:* + version: link:../scaffold-core '@stackwright/themes': specifier: workspace:* version: link:../themes @@ -358,6 +361,9 @@ importers: '@stackwright/otters': specifier: workspace:* version: link:../otters + '@stackwright/scaffold-core': + specifier: workspace:* + version: link:../scaffold-core chalk: specifier: ^5.6.2 version: 5.6.2 @@ -546,6 +552,21 @@ importers: specifier: ^4.1.2 version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/scaffold-core: + devDependencies: + '@types/node': + specifier: ^24.1.0 + version: 24.11.0 + tsup: + specifier: ^8.5.0 + version: 8.5.0(@swc/core@1.15.18)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/themes: dependencies: js-yaml: From 167b5bb05282a118bd13192135f5025acd38c0fb Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:45:51 -0400 Subject: [PATCH 07/14] fix: dark mode improvements for demos and hackathons --- .changeset/fix-dark-mode.md | 11 ++++ packages/core/src/components/base/Alert.tsx | 49 +++++++++++---- .../core/src/components/base/CodeBlock.tsx | 8 ++- .../src/components/base/ContactFormStub.tsx | 5 +- packages/core/src/components/base/Faq.tsx | 5 +- .../core/src/components/base/IconGrid.tsx | 5 +- .../core/src/components/base/LayoutGrid.tsx | 5 +- .../src/components/base/MainContentGrid.tsx | 5 +- .../core/src/components/base/PricingTable.tsx | 5 +- .../src/components/base/TestimonialGrid.tsx | 5 +- .../src/components/base/TextBlockGrid.tsx | 5 +- packages/core/src/hooks/useSafeTheme.ts | 11 +++- packages/core/src/utils/prismHighlighter.ts | 63 ++++++++++++++++++- packages/core/src/utils/resolveBackground.ts | 23 ++++++- .../core/test/components/text-block.test.tsx | 1 + 15 files changed, 169 insertions(+), 37 deletions(-) create mode 100644 .changeset/fix-dark-mode.md diff --git a/.changeset/fix-dark-mode.md b/.changeset/fix-dark-mode.md new file mode 100644 index 00000000..a6cb2123 --- /dev/null +++ b/.changeset/fix-dark-mode.md @@ -0,0 +1,11 @@ +--- +"@stackwright/core": patch +--- + +Fixed dark mode text colors and background handling for improved demo/hackathon quality: + +- **#252**: Verified ThemeProvider toggle updates correctly (no code changes needed) +- **#251**: Added dark overlay for background images to ensure text contrast +- **Alert component**: Added dark-mode-aware accent colors +- **CodeBlock component**: Added dark mode syntax highlighting palette +- **useSafeTheme hook**: Added `useSafeColorMode` hook for safe color mode access diff --git a/packages/core/src/components/base/Alert.tsx b/packages/core/src/components/base/Alert.tsx index fa339f9d..870d34da 100644 --- a/packages/core/src/components/base/Alert.tsx +++ b/packages/core/src/components/base/Alert.tsx @@ -1,33 +1,56 @@ import React from 'react'; import { AlertContent, AlertVariant } from '@stackwright/types'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme'; import { hexToRgba } from '../../utils/colorUtils'; import { resolveBackground } from '../../utils/resolveBackground'; import { getIconRegistry } from '../../utils/stackwrightComponentRegistry'; -const variantConfig: Record<AlertVariant, { color: string; iconName: string }> = { - info: { color: '#3b82f6', iconName: 'Info' }, - warning: { color: '#f59e0b', iconName: 'AlertTriangle' }, - success: { color: '#22c55e', iconName: 'CheckCircle' }, - danger: { color: '#ef4444', iconName: 'CircleAlert' }, - note: { color: '', iconName: 'Info' }, - tip: { color: '#8b5cf6', iconName: 'CheckCircle' }, +// Light mode accent colors +const VARIANT_COLORS = { + info: '#3b82f6', + warning: '#f59e0b', + success: '#22c55e', + danger: '#ef4444', + note: '#6b7280', + tip: '#8b5cf6', +} as const; + +// Dark mode accent colors (higher contrast for dark backgrounds) +const VARIANT_COLORS_DARK = { + info: '#60a5fa', + warning: '#fbbf24', + success: '#4ade80', + danger: '#f87171', + note: '#94a3b8', + tip: '#a78bfa', +} as const; + +const ICON_NAMES: Record<AlertVariant, string> = { + info: 'Info', + warning: 'AlertTriangle', + success: 'CheckCircle', + danger: 'CircleAlert', + note: 'Info', + tip: 'CheckCircle', }; export function Alert({ variant, title, body, background }: AlertContent) { const theme = useSafeTheme(); - const config = variantConfig[variant]; - const accentColor = config.color || theme.colors.textSecondary; + const resolvedColorMode = useSafeColorMode(); + + // Select appropriate accent color based on color mode + const colors = resolvedColorMode === 'dark' ? VARIANT_COLORS_DARK : VARIANT_COLORS; + const accentColor = colors[variant] || theme.colors.textSecondary; const alertBgColor = hexToRgba(accentColor, 0.1); const iconRegistry = getIconRegistry(); - const IconComponent = iconRegistry?.get(config.iconName); + const IconComponent = iconRegistry?.get(ICON_NAMES[variant]); return ( <section style={{ padding: `${theme.spacing['2xl']} ${theme.spacing.xl}`, - background: resolveBackground(background, theme), + background: resolveBackground(background, theme, resolvedColorMode === 'dark'), }} > <div @@ -71,7 +94,7 @@ export function Alert({ variant, title, body, background }: AlertContent) { <div style={{ fontWeight: 600, - color: accentColor, + color: theme.colors.text, marginBottom: body ? '4px' : 0, }} > diff --git a/packages/core/src/components/base/CodeBlock.tsx b/packages/core/src/components/base/CodeBlock.tsx index 0b24f512..357cd680 100644 --- a/packages/core/src/components/base/CodeBlock.tsx +++ b/packages/core/src/components/base/CodeBlock.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { CodeBlockContent } from '@stackwright/types'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeTheme, useSafeColorMode } from '../../hooks/useSafeTheme'; import { resolveBackground } from '../../utils/resolveBackground'; import { highlightCode, getTokenColor, HighlightToken } from '../../utils/prismHighlighter'; @@ -24,6 +24,8 @@ function splitTokensByLine(tokens: HighlightToken[]): HighlightToken[][] { export function CodeBlock({ code, language, lineNumbers = false, background }: CodeBlockContent) { const theme = useSafeTheme(); + const resolvedColorMode = useSafeColorMode(); + const isDark = resolvedColorMode === 'dark'; const tokens = highlightCode(code.trimEnd(), language); const tokenLines = splitTokensByLine(tokens); @@ -33,7 +35,7 @@ export function CodeBlock({ code, language, lineNumbers = false, background }: C style={{ margin: `0 ${theme.spacing.xl}`, padding: `${theme.spacing.md} 0`, - background: resolveBackground(background, theme), + background: resolveBackground(background, theme, resolvedColorMode === 'dark'), }} > <div @@ -91,7 +93,7 @@ export function CodeBlock({ code, language, lineNumbers = false, background }: C <span> {lineTokens.length > 0 ? lineTokens.map((t, j) => { - const color = getTokenColor(t.type); + const color = getTokenColor(t.type, isDark); return color ? ( <span key={j} style={{ color }}> {t.content} diff --git a/packages/core/src/components/base/ContactFormStub.tsx b/packages/core/src/components/base/ContactFormStub.tsx index 1dde088c..19a1fe40 100644 --- a/packages/core/src/components/base/ContactFormStub.tsx +++ b/packages/core/src/components/base/ContactFormStub.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ContactFormStubContent } from '@stackwright/types'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme'; import { resolveColor } from '../../utils/colorUtils'; import { resolveBackground } from '../../utils/resolveBackground'; @@ -15,6 +15,7 @@ export function ContactFormStub({ background, }: ContactFormStubContent) { const theme = useSafeTheme(); + const resolvedColorMode = useSafeColorMode(); const headingColor = resolveColor( heading?.textColor ? heading.textColor : theme.colors.primary, @@ -29,7 +30,7 @@ export function ContactFormStub({ <section style={{ padding: `${theme.spacing['2xl']} ${theme.spacing.xl}`, - background: resolveBackground(background, theme), + background: resolveBackground(background, theme, resolvedColorMode === 'dark'), }} > <div diff --git a/packages/core/src/components/base/Faq.tsx b/packages/core/src/components/base/Faq.tsx index ff8c3bb1..6189136a 100644 --- a/packages/core/src/components/base/Faq.tsx +++ b/packages/core/src/components/base/Faq.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { FaqContent } from '@stackwright/types'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme'; import { resolveColor } from '../../utils/colorUtils'; import { resolveBackground } from '../../utils/resolveBackground'; import { getThemeShadow } from '../../utils/shadowUtils'; export function Faq({ heading, items, background }: FaqContent) { const theme = useSafeTheme(); + const resolvedColorMode = useSafeColorMode(); const headingColor = resolveColor( heading?.textColor ? heading.textColor : theme.colors.primary, @@ -17,7 +18,7 @@ export function Faq({ heading, items, background }: FaqContent) { <section style={{ padding: `${theme.spacing['2xl']} ${theme.spacing.xl}`, - background: resolveBackground(background, theme), + background: resolveBackground(background, theme, resolvedColorMode === 'dark'), }} > {heading?.text && ( diff --git a/packages/core/src/components/base/IconGrid.tsx b/packages/core/src/components/base/IconGrid.tsx index f7c8f2e5..d3d77130 100644 --- a/packages/core/src/components/base/IconGrid.tsx +++ b/packages/core/src/components/base/IconGrid.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; import { IconGridContent } from '@stackwright/types'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme'; import { resolveColor } from '../../utils/colorUtils'; import { resolveBackground } from '../../utils/resolveBackground'; import { getIconRegistry } from '../../utils/stackwrightComponentRegistry'; @@ -16,6 +16,7 @@ function renderIcon(src: string, sizePx: number, color: string) { export function IconGrid({ heading, icons, background }: IconGridContent) { const theme = useSafeTheme(); + const resolvedColorMode = useSafeColorMode(); const headingColor = resolveColor( heading?.textColor ? heading.textColor : theme.colors.primary, @@ -26,7 +27,7 @@ export function IconGrid({ heading, icons, background }: IconGridContent) { <div style={{ padding: `${theme.spacing.md} 0`, - background: resolveBackground(background, theme), + background: resolveBackground(background, theme, resolvedColorMode === 'dark'), margin: theme.spacing.xl, }} > diff --git a/packages/core/src/components/base/LayoutGrid.tsx b/packages/core/src/components/base/LayoutGrid.tsx index f4a7446b..0efbbd86 100644 --- a/packages/core/src/components/base/LayoutGrid.tsx +++ b/packages/core/src/components/base/LayoutGrid.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { GridContent, ContentItem } from '@stackwright/types'; import { renderContent } from '../../utils/contentRenderer'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme'; import { resolveColor } from '../../utils/colorUtils'; import { resolveBackground } from '../../utils/resolveBackground'; @@ -16,6 +16,7 @@ const DEFAULT_STACK_BELOW = 768; */ export function LayoutGrid({ heading, columns, gap, stackBelow, background }: GridContent) { const theme = useSafeTheme(); + const resolvedColorMode = useSafeColorMode(); const breakpoint = stackBelow ?? DEFAULT_STACK_BELOW; // SSR-safe responsive stacking: default to multi-column, hydrate to stacked if narrow @@ -43,7 +44,7 @@ export function LayoutGrid({ heading, columns, gap, stackBelow, background }: Gr <section style={{ padding: `${theme.spacing['2xl']} ${theme.spacing.xl}`, - background: resolveBackground(background, theme), + background: resolveBackground(background, theme, resolvedColorMode === 'dark'), }} > {heading?.text && ( diff --git a/packages/core/src/components/base/MainContentGrid.tsx b/packages/core/src/components/base/MainContentGrid.tsx index 416a6edf..b134a38f 100644 --- a/packages/core/src/components/base/MainContentGrid.tsx +++ b/packages/core/src/components/base/MainContentGrid.tsx @@ -2,13 +2,14 @@ import React from 'react'; import { MainContent, GraphicPosition } from '@stackwright/types'; import { TextGrid } from './TextGrid'; import { ThemedButton } from './ThemedButton'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme'; import { Media } from '../media/Media'; import { resolveColor } from '../../utils/colorUtils'; import { resolveBackground } from '../../utils/resolveBackground'; export function MainContentGrid(content: MainContent) { const theme = useSafeTheme(); + const resolvedColorMode = useSafeColorMode(); const textPercent = content.textToGraphic ?? 58; const graphicPercent = 100 - textPercent; @@ -73,7 +74,7 @@ export function MainContentGrid(content: MainContent) { <div style={{ padding: `${theme.spacing.md} 0`, - background: resolveBackground(content?.background, theme), + background: resolveBackground(content?.background, theme, resolvedColorMode === 'dark'), margin: theme.spacing.xl, }} > diff --git a/packages/core/src/components/base/PricingTable.tsx b/packages/core/src/components/base/PricingTable.tsx index 26297ee6..b72beffb 100644 --- a/packages/core/src/components/base/PricingTable.tsx +++ b/packages/core/src/components/base/PricingTable.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { PricingTableContent } from '@stackwright/types'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme'; import { resolveColor } from '../../utils/colorUtils'; import { resolveBackground } from '../../utils/resolveBackground'; import { getThemeShadow } from '../../utils/shadowUtils'; export function PricingTable({ heading, plans, background }: PricingTableContent) { const theme = useSafeTheme(); + const resolvedColorMode = useSafeColorMode(); const headingColor = resolveColor( heading?.textColor ? heading.textColor : theme.colors.primary, @@ -17,7 +18,7 @@ export function PricingTable({ heading, plans, background }: PricingTableContent <section style={{ padding: `${theme.spacing['2xl']} ${theme.spacing.xl}`, - background: resolveBackground(background, theme), + background: resolveBackground(background, theme, resolvedColorMode === 'dark'), }} > {heading?.text && ( diff --git a/packages/core/src/components/base/TestimonialGrid.tsx b/packages/core/src/components/base/TestimonialGrid.tsx index 2164e508..14d4a973 100644 --- a/packages/core/src/components/base/TestimonialGrid.tsx +++ b/packages/core/src/components/base/TestimonialGrid.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { TestimonialGridContent } from '@stackwright/types'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme'; import { resolveColor } from '../../utils/colorUtils'; import { resolveBackground } from '../../utils/resolveBackground'; import { getThemeShadow } from '../../utils/shadowUtils'; @@ -13,6 +13,7 @@ export function TestimonialGrid({ background, }: TestimonialGridContent) { const theme = useSafeTheme(); + const resolvedColorMode = useSafeColorMode(); const headingColor = resolveColor( heading?.textColor ? heading.textColor : theme.colors.primary, @@ -23,7 +24,7 @@ export function TestimonialGrid({ <section style={{ padding: `${theme.spacing['2xl']} ${theme.spacing.xl}`, - background: resolveBackground(background, theme), + background: resolveBackground(background, theme, resolvedColorMode === 'dark'), }} > {heading?.text && ( diff --git a/packages/core/src/components/base/TextBlockGrid.tsx b/packages/core/src/components/base/TextBlockGrid.tsx index 3d220e81..3411f360 100644 --- a/packages/core/src/components/base/TextBlockGrid.tsx +++ b/packages/core/src/components/base/TextBlockGrid.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { TextBlockContent } from '@stackwright/types'; -import { useSafeTheme } from '../../hooks/useSafeTheme'; +import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme'; import { resolveColor } from '../../utils/colorUtils'; import { resolveBackground } from '../../utils/resolveBackground'; import { TextGrid } from './TextGrid'; @@ -8,6 +8,7 @@ import { ThemedButton } from './ThemedButton'; export function TextBlockGrid({ heading, textBlocks, buttons, background }: TextBlockContent) { const theme = useSafeTheme(); + const resolvedColorMode = useSafeColorMode(); const headingColor = resolveColor( heading?.textColor ? heading.textColor : theme.colors.primary, @@ -18,7 +19,7 @@ export function TextBlockGrid({ heading, textBlocks, buttons, background }: Text <section style={{ padding: `${theme.spacing.md} 0`, - background: resolveBackground(background, theme), + background: resolveBackground(background, theme, resolvedColorMode === 'dark'), margin: theme.spacing.xl, }} > diff --git a/packages/core/src/hooks/useSafeTheme.ts b/packages/core/src/hooks/useSafeTheme.ts index b684b230..5e3a43b0 100644 --- a/packages/core/src/hooks/useSafeTheme.ts +++ b/packages/core/src/hooks/useSafeTheme.ts @@ -1,5 +1,5 @@ import type { Theme } from '@stackwright/themes'; -import { useTheme } from '@stackwright/themes'; +import { useTheme, useThemeOptional } from '@stackwright/themes'; // Default theme fallback - complete Theme type const defaultTheme: Theme = { @@ -64,3 +64,12 @@ export function useSafeTheme() { throw error; } } + +/** + * Safe hook to get the resolved color mode. + * Returns 'light' if ThemeProvider is not available (defaults to light mode for syntax highlighting). + */ +export function useSafeColorMode(): 'light' | 'dark' { + const context = useThemeOptional(); + return context?.resolvedColorMode ?? 'light'; +} diff --git a/packages/core/src/utils/prismHighlighter.ts b/packages/core/src/utils/prismHighlighter.ts index 679f4c33..d3d441a0 100644 --- a/packages/core/src/utils/prismHighlighter.ts +++ b/packages/core/src/utils/prismHighlighter.ts @@ -61,10 +61,48 @@ const TOKEN_COLORS: Record<string, string> = { atrule: '#d73a49', }; +/** + * Dark mode color palette for syntax tokens. + * GitHub Dark-inspired colors for dark (#0d1117) code block background. + */ +const DARK_TOKEN_COLORS: Record<string, string> = { + comment: '#8b949e', + prolog: '#8b949e', + doctype: '#8b949e', + cdata: '#8b949e', + punctuation: '#c9d1d9', + property: '#79c0ff', + tag: '#7ee787', + boolean: '#79c0ff', + number: '#79c0ff', + constant: '#79c0ff', + symbol: '#79c0ff', + selector: '#d2a8ff', + 'attr-name': '#d2a8ff', + string: '#a5d6ff', + char: '#a5d6ff', + 'template-string': '#a5d6ff', + builtin: '#d2a8ff', + inserted: '#7ee787', + operator: '#ff7b72', + entity: '#79c0ff', + url: '#a5d6ff', + keyword: '#ff7b72', + 'attr-value': '#a5d6ff', + function: '#d2a8ff', + 'class-name': '#d2a8ff', + regex: '#a5d6ff', + important: '#ff7b72', + variable: '#ffa657', + deleted: '#ff7b72', + atrule: '#ff7b72', +}; + /** A flattened token with type and text, ready for rendering. */ export interface HighlightToken { type: string | null; // null = plain text content: string; + color?: string; // resolved color when using highlightCodeWithMode } /** @@ -121,8 +159,29 @@ export function highlightCode(code: string, language?: string): HighlightToken[] /** * Get the inline color for a token type. + * @param type - The token type from Prism + * @param isDark - Whether to use dark mode colors (default: false) */ -export function getTokenColor(type: string | null): string | undefined { +export function getTokenColor(type: string | null, isDark: boolean = false): string | undefined { if (!type) return undefined; - return TOKEN_COLORS[type]; + const colors = isDark ? DARK_TOKEN_COLORS : TOKEN_COLORS; + return colors[type]; +} + +/** + * Tokenize code with Prism and return tokens with colors already resolved. + * @param code - The source code to highlight + * @param language - The language identifier + * @param isDark - Whether to use dark mode colors (default: false) + */ +export function highlightCodeWithMode( + code: string, + language?: string, + isDark: boolean = false +): HighlightToken[] { + const tokens = highlightCode(code, language); + return tokens.map((token) => ({ + ...token, + color: getTokenColor(token.type, isDark), + })); } diff --git a/packages/core/src/utils/resolveBackground.ts b/packages/core/src/utils/resolveBackground.ts index 97613da7..3f7c466b 100644 --- a/packages/core/src/utils/resolveBackground.ts +++ b/packages/core/src/utils/resolveBackground.ts @@ -6,6 +6,9 @@ * * This enables YAML authors to write `background: "surface"` and get * the correct color in both light and dark modes automatically. + * + * In dark mode with a theme background image, a semi-transparent dark + * overlay is applied to ensure text contrast. */ const THEME_COLOR_KEYS = new Set([ 'primary', @@ -20,12 +23,28 @@ const THEME_COLOR_KEYS = new Set([ /** Minimal shape — only needs `colors` from the theme. */ interface ThemeWithColors { colors: Record<string, string>; + backgroundImage?: { + url?: string; + }; } -export function resolveBackground(bg: string | undefined, theme: ThemeWithColors): string { +export function resolveBackground( + bg: string | undefined, + theme: ThemeWithColors, + isDarkMode?: boolean +): string { if (!bg) return 'transparent'; + if (THEME_COLOR_KEYS.has(bg)) { - return theme.colors[bg] ?? bg; + const resolvedColor = theme.colors[bg] ?? bg; + + // In dark mode, if the theme has a background image, layer a dark + // overlay on top for better text contrast. + if (isDarkMode && theme.backgroundImage?.url) { + return `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)), url('${theme.backgroundImage.url}')`; + } + + return resolvedColor; } return bg; } diff --git a/packages/core/test/components/text-block.test.tsx b/packages/core/test/components/text-block.test.tsx index 6dc4584a..bcb8b8eb 100644 --- a/packages/core/test/components/text-block.test.tsx +++ b/packages/core/test/components/text-block.test.tsx @@ -27,6 +27,7 @@ const mockTheme = { }; vi.mock('../../src/hooks/useSafeTheme', () => ({ + useSafeColorMode: () => 'light', useSafeTheme: () => mockTheme, })); From 1fd265fa435e6d9999ae8967e12cd34d076cb35c Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:04:49 -0400 Subject: [PATCH 08/14] fix: dark mode improvements for demos and hackathons (#296) From 067ac46c898a045a211518904a75f82ba89adbb9 Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:07:56 -0400 Subject: [PATCH 09/14] chore: release version bumps --- .changeset/ci-workflow-improvements.md | 7 - .../fix-perf-benchmark-port-conflict.md | 14 - .changeset/pre.json | 8 +- .changeset/remove-hello-example.md | 10 - .changeset/scaffold-hooks-system.md | 2 +- .changeset/update-post-sprint-docs.md | 5 - examples/stackwright-docs/CHANGELOG.md | 8 + examples/stackwright-docs/build-manifest.json | 94 +++++++ examples/stackwright-docs/cyclonedx.json | 191 ++++++++++++++ examples/stackwright-docs/package.json | 2 +- examples/stackwright-docs/spdx.json | 242 ++++++++++++++++++ examples/stackwright-docs/spdx.spdx | 83 ++++++ packages/cli/CHANGELOG.md | 12 + packages/cli/package.json | 2 +- packages/core/CHANGELOG.md | 11 + packages/core/package.json | 2 +- packages/launch-stackwright/CHANGELOG.md | 13 + packages/launch-stackwright/package.json | 2 +- packages/maplibre/CHANGELOG.md | 7 + packages/maplibre/package.json | 2 +- packages/mcp/CHANGELOG.md | 8 + packages/mcp/package.json | 2 +- packages/nextjs/CHANGELOG.md | 7 + packages/nextjs/package.json | 2 +- packages/scaffold-core/CHANGELOG.md | 6 + packages/scaffold-core/package.json | 2 +- ...tackwright-scaffold-core-0.1.0-alpha.0.tgz | Bin 0 -> 5601 bytes 27 files changed, 696 insertions(+), 48 deletions(-) delete mode 100644 .changeset/ci-workflow-improvements.md delete mode 100644 .changeset/fix-perf-benchmark-port-conflict.md delete mode 100644 .changeset/remove-hello-example.md delete mode 100644 .changeset/update-post-sprint-docs.md create mode 100644 examples/stackwright-docs/build-manifest.json create mode 100644 examples/stackwright-docs/cyclonedx.json create mode 100644 examples/stackwright-docs/spdx.json create mode 100644 examples/stackwright-docs/spdx.spdx create mode 100644 packages/scaffold-core/stackwright-scaffold-core-0.1.0-alpha.0.tgz diff --git a/.changeset/ci-workflow-improvements.md b/.changeset/ci-workflow-improvements.md deleted file mode 100644 index 28574397..00000000 --- a/.changeset/ci-workflow-improvements.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"__root__": patch ---- - -Improve CI: Add reusable action, fail-fast dependencies, and path-based triggers - -Fix: Add checkout step before local action reference (GitHub Actions requirement) diff --git a/.changeset/fix-perf-benchmark-port-conflict.md b/.changeset/fix-perf-benchmark-port-conflict.md deleted file mode 100644 index a898eedb..00000000 --- a/.changeset/fix-perf-benchmark-port-conflict.md +++ /dev/null @@ -1,14 +0,0 @@ ---- ---- - -fix(ci): resolve port conflict in performance benchmark workflow - -The performance workflow was manually starting `next start` on port 3000 -before running Playwright benchmarks. Since the Playwright config also -defines a `webServer` that starts on port 3000 (with `reuseExistingServer: -false` in CI), every benchmark step failed with a port conflict. - -The fix removes the redundant manual build/start steps and uses -`PERF_NO_SERVER` to disable Playwright's webServer for benchmarks that -don't need a running server (build-time, bundle-size). The runtime-vitals -benchmark now uses Playwright's built-in webServer lifecycle as intended. diff --git a/.changeset/pre.json b/.changeset/pre.json index 6b3a7ba0..69f07a11 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -18,7 +18,8 @@ "@stackwright/maplibre": "0.0.0", "stackwright-docs": "0.1.0", "@stackwright/otters": "0.1.0", - "@stackwright/sbom-generator": "0.0.0" + "@stackwright/sbom-generator": "0.0.0", + "@stackwright/scaffold-core": "0.1.0-alpha.0" }, "changesets": [ "add-code2-layout-icons", @@ -26,19 +27,20 @@ "built-in-search-feature", "compose-site-atomic", "declarative-entry-pages", + "fix-cli-scaffold-smoke-test", "fix-dark-mode-bugs", - "fix-perf-benchmark-port-conflict", + "fix-dark-mode", "integrations-config", "launch-stackwright-package", "map-adapter-phases-1-2", "nav-sidebar-override", "otters-as-package", "otters-postinstall", - "remove-hello-example", "resolve-background-utility", "sbom-generator-addition", "sbom-hooks-addition", "scaffold-bundled-default", + "scaffold-hooks-system", "scaffold-pin-versions", "text-block-feature", "video-media-type", diff --git a/.changeset/remove-hello-example.md b/.changeset/remove-hello-example.md deleted file mode 100644 index b1cc6149..00000000 --- a/.changeset/remove-hello-example.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"stackwright-docs": patch ---- - -Remove hellostackwrightnext example and add legal pages - -- Delete hellostackwrightnext example directory -- Add privacy-policy page to stackwright-docs -- Add terms-of-service page to stackwright-docs -- Update stackwright-docs footer with legal links diff --git a/.changeset/scaffold-hooks-system.md b/.changeset/scaffold-hooks-system.md index 2709c8b1..6920c246 100644 --- a/.changeset/scaffold-hooks-system.md +++ b/.changeset/scaffold-hooks-system.md @@ -1,7 +1,7 @@ --- "@stackwright/scaffold-core": minor "@stackwright/cli": minor -"@stackwright/launch-stackwright": minor +"launch-stackwright": minor --- Add scaffold hooks system for extensible post-scaffold processing. Pro packages can now register hooks at lifecycle points (preScaffold, preInstall, postInstall, postScaffold) to inject dependencies, configure MCP servers, and add custom setup. diff --git a/.changeset/update-post-sprint-docs.md b/.changeset/update-post-sprint-docs.md deleted file mode 100644 index 12036e53..00000000 --- a/.changeset/update-post-sprint-docs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"docs-only": patch ---- - -docs: update documentation post-sprint (brand otter shipped, font auto-loading) diff --git a/examples/stackwright-docs/CHANGELOG.md b/examples/stackwright-docs/CHANGELOG.md index 2629d4a9..3ea96fbe 100644 --- a/examples/stackwright-docs/CHANGELOG.md +++ b/examples/stackwright-docs/CHANGELOG.md @@ -1,5 +1,13 @@ # stackwright-docs +## 0.1.1-alpha.1 + +### Patch Changes + +- Updated dependencies [167b5bb] + - @stackwright/core@0.7.0-alpha.7 + - @stackwright/nextjs@0.3.1-alpha.7 + ## 0.1.1-alpha.0 ### Patch Changes diff --git a/examples/stackwright-docs/build-manifest.json b/examples/stackwright-docs/build-manifest.json new file mode 100644 index 00000000..2f86fd67 --- /dev/null +++ b/examples/stackwright-docs/build-manifest.json @@ -0,0 +1,94 @@ +{ + "format": "stackwright-build-manifest", + "version": "1.0.0", + "generated": "2026-04-03T16:11:33.931Z", + "project": { + "name": "stackwright-docs", + "version": "0.1.1-alpha.0", + "root": "/home/charles/git/peraspera/stackwright/examples/stackwright-docs", + "isMonorepo": false + }, + "dependencies": [ + { + "name": "@stackwright/core", + "version": "workspace:*", + "type": "direct", + "category": "core", + "purl": "pkg:npm/stackwright/core@workspace:*", + "integrity": "9af5f734e5ee6bfa45ca6ed244194337e4b257d3be8a0927412f1076bf569c7b", + "depth": 0 + }, + { + "name": "@stackwright/icons", + "version": "workspace:*", + "type": "direct", + "category": "tool", + "purl": "pkg:npm/stackwright/icons@workspace:*", + "integrity": "c255a8ba61a12722b066604f2487920c50bb20b28f6ba518b9525f191f0a7e52", + "depth": 0 + }, + { + "name": "@stackwright/nextjs", + "version": "workspace:*", + "type": "direct", + "category": "tool", + "purl": "pkg:npm/stackwright/nextjs@workspace:*", + "integrity": "b19c754719b46bf6371f09086bd8b754e289bf617322626044dbb47620163cf3", + "depth": 0 + }, + { + "name": "@stackwright/ui-shadcn", + "version": "workspace:*", + "type": "direct", + "category": "plugin", + "purl": "pkg:npm/stackwright/ui-shadcn@workspace:*", + "integrity": "ac159bdf9d62105719ef6d7d5be5b490eadd991e002ddaae9bab73c93f5c7667", + "depth": 0 + }, + { + "name": "js-yaml", + "version": "4.1.1", + "type": "direct", + "category": "external", + "purl": "pkg:npm/js-yaml@4.1.1", + "integrity": "af705ed1eb31f1af61a3698569d8e0370c173e67024075b4f306d8c9010f1298", + "depth": 0 + }, + { + "name": "next", + "version": "16.1.6", + "type": "direct", + "category": "external", + "purl": "pkg:npm/next@16.1.6", + "integrity": "dab6b931b02c25bd4f20dd3f93ceb0b57f8971844a25f14feb62c8a20608db04", + "depth": 0 + }, + { + "name": "react", + "version": "19.2.4", + "type": "direct", + "category": "external", + "purl": "pkg:npm/react@19.2.4", + "integrity": "48fb44e34a2bbbc744bfc0d48b37e11ddb82d4121283c711adcf61883d28f525", + "depth": 0 + }, + { + "name": "react-dom", + "version": "19.2.4", + "type": "direct", + "category": "external", + "purl": "pkg:npm/react-dom@19.2.4", + "integrity": "d321eb3d0478e2a58565081343e4d1da25caf8103b3dd72b3544a715f2e80418", + "depth": 0 + } + ], + "metadata": { + "totalDependencies": 8, + "directDependencies": 8, + "devDependencies": 0, + "peerDependencies": 0, + "transitiveDependencies": 0, + "stackwrightInternal": 4, + "external": 4 + } +} \ No newline at end of file diff --git a/examples/stackwright-docs/cyclonedx.json b/examples/stackwright-docs/cyclonedx.json new file mode 100644 index 00000000..2a71d04e --- /dev/null +++ b/examples/stackwright-docs/cyclonedx.json @@ -0,0 +1,191 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "metadata": { + "timestamp": "2026-04-03T16:11:33.930Z", + "tools": [ + { + "vendor": "Stackwright", + "name": "@stackwright/sbom-generator", + "version": "0.0.0" + } + ], + "component": { + "type": "application", + "name": "stackwright-docs", + "version": "0.1.1-alpha.0", + "purl": "pkg:npm/stackwright-docs@0.1.1-alpha.0" + } + }, + "components": [ + { + "type": "library", + "name": "@stackwright/core", + "version": "workspace:*", + "purl": "pkg:npm/stackwright/core@workspace:*", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "9af5f734e5ee6bfa45ca6ed244194337e4b257d3be8a0927412f1076bf569c7b" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/@stackwright/core" + } + ] + }, + { + "type": "library", + "name": "@stackwright/icons", + "version": "workspace:*", + "purl": "pkg:npm/stackwright/icons@workspace:*", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "c255a8ba61a12722b066604f2487920c50bb20b28f6ba518b9525f191f0a7e52" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/@stackwright/icons" + } + ] + }, + { + "type": "library", + "name": "@stackwright/nextjs", + "version": "workspace:*", + "purl": "pkg:npm/stackwright/nextjs@workspace:*", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "b19c754719b46bf6371f09086bd8b754e289bf617322626044dbb47620163cf3" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/@stackwright/nextjs" + } + ] + }, + { + "type": "library", + "name": "@stackwright/ui-shadcn", + "version": "workspace:*", + "purl": "pkg:npm/stackwright/ui-shadcn@workspace:*", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "ac159bdf9d62105719ef6d7d5be5b490eadd991e002ddaae9bab73c93f5c7667" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/@stackwright/ui-shadcn" + } + ] + }, + { + "type": "library", + "name": "js-yaml", + "version": "4.1.1", + "purl": "pkg:npm/js-yaml@4.1.1", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "af705ed1eb31f1af61a3698569d8e0370c173e67024075b4f306d8c9010f1298" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/js-yaml" + } + ] + }, + { + "type": "library", + "name": "next", + "version": "16.1.6", + "purl": "pkg:npm/next@16.1.6", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "dab6b931b02c25bd4f20dd3f93ceb0b57f8971844a25f14feb62c8a20608db04" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/next" + } + ] + }, + { + "type": "library", + "name": "react", + "version": "19.2.4", + "purl": "pkg:npm/react@19.2.4", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "48fb44e34a2bbbc744bfc0d48b37e11ddb82d4121283c711adcf61883d28f525" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/react" + } + ] + }, + { + "type": "library", + "name": "react-dom", + "version": "19.2.4", + "purl": "pkg:npm/react-dom@19.2.4", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "d321eb3d0478e2a58565081343e4d1da25caf8103b3dd72b3544a715f2e80418" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/react-dom" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/stackwright-docs@0.1.1-alpha.0", + "dependsOn": [ + "@stackwright/core", + "@stackwright/icons", + "@stackwright/nextjs", + "@stackwright/ui-shadcn", + "js-yaml", + "next", + "react", + "react-dom" + ], + "dependencyType": "direct" + } + ] +} \ No newline at end of file diff --git a/examples/stackwright-docs/package.json b/examples/stackwright-docs/package.json index 19e2de70..1b4506f0 100644 --- a/examples/stackwright-docs/package.json +++ b/examples/stackwright-docs/package.json @@ -1,6 +1,6 @@ { "name": "stackwright-docs", - "version": "0.1.1-alpha.0", + "version": "0.1.1-alpha.1", "private": true, "scripts": { "prebuild": "stackwright-prebuild", diff --git a/examples/stackwright-docs/spdx.json b/examples/stackwright-docs/spdx.json new file mode 100644 index 00000000..993997f4 --- /dev/null +++ b/examples/stackwright-docs/spdx.json @@ -0,0 +1,242 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT-c3d0969a", + "name": "stackwright-docs@0.1.1-alpha.0", + "documentNamespace": "https://stackwright.dev/spdx/stackwright-docs/2026-04-03T16:11:33.929Z", + "creationInfo": { + "created": "2026-04-03T16:11:33.929Z", + "creators": [ + "Tool: @stackwright/sbom-generator", + "Tool-Version: 0.0.0" + ] + }, + "packages": [ + { + "SPDXID": "SPDXRef-Package--stackwright-core", + "name": "@stackwright/core", + "versionInfo": "workspace:*", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "9af5f734e5ee6bfa45ca6ed244194337e4b257d3be8a0927412f1076bf569c7b" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/stackwright/core@workspace:*" + } + ] + }, + { + "SPDXID": "SPDXRef-Package--stackwright-icons", + "name": "@stackwright/icons", + "versionInfo": "workspace:*", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "c255a8ba61a12722b066604f2487920c50bb20b28f6ba518b9525f191f0a7e52" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/stackwright/icons@workspace:*" + } + ] + }, + { + "SPDXID": "SPDXRef-Package--stackwright-nextjs", + "name": "@stackwright/nextjs", + "versionInfo": "workspace:*", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "b19c754719b46bf6371f09086bd8b754e289bf617322626044dbb47620163cf3" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/stackwright/nextjs@workspace:*" + } + ] + }, + { + "SPDXID": "SPDXRef-Package--stackwright-ui-shadcn", + "name": "@stackwright/ui-shadcn", + "versionInfo": "workspace:*", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "ac159bdf9d62105719ef6d7d5be5b490eadd991e002ddaae9bab73c93f5c7667" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/stackwright/ui-shadcn@workspace:*" + } + ] + }, + { + "SPDXID": "SPDXRef-Package-js-yaml", + "name": "js-yaml", + "versionInfo": "4.1.1", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "af705ed1eb31f1af61a3698569d8e0370c173e67024075b4f306d8c9010f1298" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/js-yaml@4.1.1" + } + ] + }, + { + "SPDXID": "SPDXRef-Package-next", + "name": "next", + "versionInfo": "16.1.6", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "dab6b931b02c25bd4f20dd3f93ceb0b57f8971844a25f14feb62c8a20608db04" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/next@16.1.6" + } + ] + }, + { + "SPDXID": "SPDXRef-Package-react", + "name": "react", + "versionInfo": "19.2.4", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "48fb44e34a2bbbc744bfc0d48b37e11ddb82d4121283c711adcf61883d28f525" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/react@19.2.4" + } + ] + }, + { + "SPDXID": "SPDXRef-Package-react-dom", + "name": "react-dom", + "versionInfo": "19.2.4", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "d321eb3d0478e2a58565081343e4d1da25caf8103b3dd72b3544a715f2e80418" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/react-dom@19.2.4" + } + ] + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT-c3d0969a", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-js-yaml" + }, + { + "spdxElementId": "SPDXRef-Package-js-yaml", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package--stackwright-core" + }, + { + "spdxElementId": "SPDXRef-Package-js-yaml", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package--stackwright-icons" + }, + { + "spdxElementId": "SPDXRef-Package-js-yaml", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package--stackwright-nextjs" + }, + { + "spdxElementId": "SPDXRef-Package-js-yaml", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package--stackwright-ui-shadcn" + }, + { + "spdxElementId": "SPDXRef-Package-js-yaml", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-next" + }, + { + "spdxElementId": "SPDXRef-Package-js-yaml", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-react" + }, + { + "spdxElementId": "SPDXRef-Package-js-yaml", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-react-dom" + } + ] +} \ No newline at end of file diff --git a/examples/stackwright-docs/spdx.spdx b/examples/stackwright-docs/spdx.spdx new file mode 100644 index 00000000..cc5055ea --- /dev/null +++ b/examples/stackwright-docs/spdx.spdx @@ -0,0 +1,83 @@ +SPDXVersion: SPDX-2.3 +DataLicense: CC0-1.0 +SPDXID: SPDXRef-DOCUMENT-c3d0969a +DocumentName: stackwright-docs@0.1.1-alpha.0 +DocumentNamespace: https://stackwright.dev/spdx/stackwright-docs/2026-04-03T16:11:33.929Z + +CreationInfo: + Created: 2026-04-03T16:11:33.929Z + Creator: Tool: @stackwright/sbom-generator + Creator: Tool-Version: 0.0.0 + +PackageName: @stackwright/core +SPDXID: SPDXRef-Package--stackwright-core +PackageVersion: workspace:* +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: 9af5f734e5ee6bfa45ca6ed244194337e4b257d3be8a0927412f1076bf569c7b +ExternalRef: PACKAGE-MANAGER purl pkg:npm/stackwright/core@workspace:* + +PackageName: @stackwright/icons +SPDXID: SPDXRef-Package--stackwright-icons +PackageVersion: workspace:* +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: c255a8ba61a12722b066604f2487920c50bb20b28f6ba518b9525f191f0a7e52 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/stackwright/icons@workspace:* + +PackageName: @stackwright/nextjs +SPDXID: SPDXRef-Package--stackwright-nextjs +PackageVersion: workspace:* +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: b19c754719b46bf6371f09086bd8b754e289bf617322626044dbb47620163cf3 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/stackwright/nextjs@workspace:* + +PackageName: @stackwright/ui-shadcn +SPDXID: SPDXRef-Package--stackwright-ui-shadcn +PackageVersion: workspace:* +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: ac159bdf9d62105719ef6d7d5be5b490eadd991e002ddaae9bab73c93f5c7667 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/stackwright/ui-shadcn@workspace:* + +PackageName: js-yaml +SPDXID: SPDXRef-Package-js-yaml +PackageVersion: 4.1.1 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: af705ed1eb31f1af61a3698569d8e0370c173e67024075b4f306d8c9010f1298 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/js-yaml@4.1.1 + +PackageName: next +SPDXID: SPDXRef-Package-next +PackageVersion: 16.1.6 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: dab6b931b02c25bd4f20dd3f93ceb0b57f8971844a25f14feb62c8a20608db04 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/next@16.1.6 + +PackageName: react +SPDXID: SPDXRef-Package-react +PackageVersion: 19.2.4 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: 48fb44e34a2bbbc744bfc0d48b37e11ddb82d4121283c711adcf61883d28f525 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/react@19.2.4 + +PackageName: react-dom +SPDXID: SPDXRef-Package-react-dom +PackageVersion: 19.2.4 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: d321eb3d0478e2a58565081343e4d1da25caf8103b3dd72b3544a715f2e80418 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/react-dom@19.2.4 + +Relationship: SPDXRef-DOCUMENT-c3d0969a DESCRIBES SPDXRef-Package-js-yaml +Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-core +Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-icons +Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-nextjs +Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-ui-shadcn +Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package-next +Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package-react +Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package-react-dom \ No newline at end of file diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 794b5d29..79efb184 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,17 @@ # @stackwright/cli +## 0.7.0-alpha.11 + +### Minor Changes + +- b2e451a: Add scaffold hooks system for extensible post-scaffold processing. Pro packages can now register hooks at lifecycle points (preScaffold, preInstall, postInstall, postScaffold) to inject dependencies, configure MCP servers, and add custom setup. + +### Patch Changes + +- 5c351f5: Fix scaffold smoke-test TypeError by excluding \_font-links.json from static page generation +- Updated dependencies [b2e451a] + - @stackwright/scaffold-core@0.1.0-alpha.1 + ## 0.7.0-alpha.10 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 024eb52d..9d6ad2de 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/cli", - "version": "0.7.0-alpha.10", + "version": "0.7.0-alpha.11", "description": "CLI for Stackwright framework", "license": "MIT", "repository": { diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index da0797db..fc0425a2 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,16 @@ # @stackwright/core +## 0.7.0-alpha.7 + +### Patch Changes + +- 167b5bb: Fixed dark mode text colors and background handling for improved demo/hackathon quality: + - **#252**: Verified ThemeProvider toggle updates correctly (no code changes needed) + - **#251**: Added dark overlay for background images to ensure text contrast + - **Alert component**: Added dark-mode-aware accent colors + - **CodeBlock component**: Added dark mode syntax highlighting palette + - **useSafeTheme hook**: Added `useSafeColorMode` hook for safe color mode access + ## 0.7.0-alpha.6 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 3a1f401f..2baa66d6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/core", - "version": "0.7.0-alpha.6", + "version": "0.7.0-alpha.7", "description": "Core framework for building applications from YAML configuration", "license": "MIT", "repository": { diff --git a/packages/launch-stackwright/CHANGELOG.md b/packages/launch-stackwright/CHANGELOG.md index 9faba99b..f51fd047 100644 --- a/packages/launch-stackwright/CHANGELOG.md +++ b/packages/launch-stackwright/CHANGELOG.md @@ -1,5 +1,18 @@ # launch-stackwright +## 0.2.0-alpha.8 + +### Minor Changes + +- b2e451a: Add scaffold hooks system for extensible post-scaffold processing. Pro packages can now register hooks at lifecycle points (preScaffold, preInstall, postInstall, postScaffold) to inject dependencies, configure MCP servers, and add custom setup. + +### Patch Changes + +- Updated dependencies [5c351f5] +- Updated dependencies [b2e451a] + - @stackwright/cli@0.7.0-alpha.11 + - @stackwright/scaffold-core@0.1.0-alpha.1 + ## 0.2.0-alpha.7 ### Patch Changes diff --git a/packages/launch-stackwright/package.json b/packages/launch-stackwright/package.json index 5d26232b..4976d624 100644 --- a/packages/launch-stackwright/package.json +++ b/packages/launch-stackwright/package.json @@ -1,6 +1,6 @@ { "name": "launch-stackwright", - "version": "0.2.0-alpha.7", + "version": "0.2.0-alpha.8", "description": "Launch a new Stackwright project with the otter raft ready to build", "license": "MIT", "repository": { diff --git a/packages/maplibre/CHANGELOG.md b/packages/maplibre/CHANGELOG.md index 9aa0f3d3..d64829dc 100644 --- a/packages/maplibre/CHANGELOG.md +++ b/packages/maplibre/CHANGELOG.md @@ -1,5 +1,12 @@ # @stackwright/maplibre +## 1.0.0-alpha.3 + +### Patch Changes + +- Updated dependencies [167b5bb] + - @stackwright/core@0.7.0-alpha.7 + ## 1.0.0-alpha.2 ### Patch Changes diff --git a/packages/maplibre/package.json b/packages/maplibre/package.json index 5daa3161..efdc8650 100644 --- a/packages/maplibre/package.json +++ b/packages/maplibre/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/maplibre", - "version": "1.0.0-alpha.2", + "version": "1.0.0-alpha.3", "description": "MapLibre GL adapter for Stackwright maps (free tier, no API keys required)", "license": "MIT", "repository": { diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index cf541452..abaab000 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,13 @@ # @stackwright/mcp +## 0.3.0-alpha.11 + +### Patch Changes + +- Updated dependencies [5c351f5] +- Updated dependencies [b2e451a] + - @stackwright/cli@0.7.0-alpha.11 + ## 0.3.0-alpha.10 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 6a6d6a79..e9d1570a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/mcp", - "version": "0.3.0-alpha.10", + "version": "0.3.0-alpha.11", "description": "MCP server for Stackwright — exposes content types, page management, and validation as agent tools", "license": "MIT", "repository": { diff --git a/packages/nextjs/CHANGELOG.md b/packages/nextjs/CHANGELOG.md index 3ddf6791..25122b80 100644 --- a/packages/nextjs/CHANGELOG.md +++ b/packages/nextjs/CHANGELOG.md @@ -1,5 +1,12 @@ # @stackwright/nextjs +## 0.3.1-alpha.7 + +### Patch Changes + +- Updated dependencies [167b5bb] + - @stackwright/core@0.7.0-alpha.7 + ## 0.3.1-alpha.6 ### Patch Changes diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 1ecc7110..666b06b4 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/nextjs", - "version": "0.3.1-alpha.6", + "version": "0.3.1-alpha.7", "description": "Next.js implementations for Stackwright components", "license": "MIT", "repository": { diff --git a/packages/scaffold-core/CHANGELOG.md b/packages/scaffold-core/CHANGELOG.md index b5457567..ddeb94aa 100644 --- a/packages/scaffold-core/CHANGELOG.md +++ b/packages/scaffold-core/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.0-alpha.1 + +### Minor Changes + +- b2e451a: Add scaffold hooks system for extensible post-scaffold processing. Pro packages can now register hooks at lifecycle points (preScaffold, preInstall, postInstall, postScaffold) to inject dependencies, configure MCP servers, and add custom setup. + ## 0.1.0-alpha.0 - Initial release diff --git a/packages/scaffold-core/package.json b/packages/scaffold-core/package.json index 7a84a8af..e9f7a9d2 100644 --- a/packages/scaffold-core/package.json +++ b/packages/scaffold-core/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/scaffold-core", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "description": "Scaffold hooks system for Stackwright - enables extensible post-scaffold processing", "license": "MIT", "repository": { diff --git a/packages/scaffold-core/stackwright-scaffold-core-0.1.0-alpha.0.tgz b/packages/scaffold-core/stackwright-scaffold-core-0.1.0-alpha.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..703cfb40f1c4e5944462c8f0bed4d87d86e5411f GIT binary patch literal 5601 zcmV<76&~sziwFP!000001MNNiTN^jh@6Y>Js7{~5@T@-|KzgRMU5rV51x#(zT%Lpr z%dU*Kv3K45fcSv_{bokeej7+ale_2jK5e{~W=5mYXf!jD%*?btn3ud^J3-iRT$_Ka z{}KG((>~2+b9-}>;U_%#&*tXy7tdMq`PO!GeSLeoxyhRA&o`dGfchuqqx6Y_(DZ?o zFQt?3-2cc^i2}}o(08n`vZGy@K70S(=A#qeo3UNiyZD1!VI2w_mtz_C!?^;x<YDjH z#nOEqSP67ej?h@=JG0R9i+IQ8ln09tsZ!yL%|KQq;4|L~y>LF`^`th<AOAS>{1D)) zFv37vXXa$G%65NZHyUFj&u3KxbfF#QxKs({ED=DAhaft;VeqAeaBs2TOlRDUrrb9# zCcMQ$KjQ3ebw|5X?OWb#j${I2!PD8u_ojgK(c&{8YJ*^6Zk!RTVm15Q-xzku8xb_y z-Q8sskNR6-x3|T*QRG^o<GGbpq9gk?;UW9L=L|p#@}_oHMOQ_`-=*bSx1fW>E)ZBn zevr_2(3W6aA!g(xpkonLsbAR6TPV&6>bt5f)FrP$RUsd2b`|@IVD$u(gv;0+{O|KH z@?92sDQSmZ+nY{3_a6fk_tdkAhtw!k6Mk>*bXEZc364BFn(zuTeq~OgWEgdZ6m~SN z(O`jZNy``p)EIq{>V<=Q157A&t7a;78Wd~7O@CmSqmehUUwPh#K#8bWqXRsMr4%|} zUIu&M`9si=MJTdbCm~*f&F7is6<S#x;Y4mJXZdms$7)K#ttWCy4^mc6DM^c_{kanM z80i7>+p`@_DSIh@RfH6?fN<)wC>U48q^Q^HW7!49X1?S3PB?E7TwQ%-KmW|OngRqY zR_MS)kRa+Sf{e^bz)=rL8%0Y24t&+r*#$|%Ola2FMf?SAL(+x($W#UEQnlYoj)Sv% zk>qez(JW-}1z#g)0z#@*$HdmSj$mXq2tGr(sEO5-ET#PKG&7jH7E8t<H}1rU&MeOj z`NvQYfG<G^d`<Y3>4vW)xqExEqwKT5MHbMMWoRgS2dX097&EU;M}WhU;Mx=DyJBUB zK<=1jhSr!>x$k@aYK(|hFf-Cg6|^w+y=&(3Yt})mYFxqCd0S~lAUh+Nt$cq&<Zzw1 ztANfp6K=QI_czpT9jtHwPS@ReOiJ=6%o}MS^xT8lb8Y%=(&G7BB{YUYIAh12TZ^^x zbDCCyA<KG)lhANoFEm3A{pKvTsd6!OX~o8|=?spTGo?aUJYCP`Ev<>mMYS;R%WYtp z^}5HREizeUW{J#Cqw$OdUgTT+$ehg__wuxVxT`$e`qZ3#YuD?);+aYRUw(pUJol;F zc)<T(-+b{R@Bgo_Fa7_o^5ER8;H<8+SmhUxh4taucP__aLs_(%<@w+uA>0-B1N1ck zv{_%TH*4l(Ha6=`QEN+2UqTGh7BOJK9A@s6OvfN;1r|u|qIVa7g$cPEIOyrkydbQ_ z78U$_3#=B*VA0ORu>g=5KI#rdna=^ofgocJ#lmz~a4A8cODC*|WxW#l6D%2r;Vfu1 z8c;KiF6x#yZJcnwW&{vFn6<;hb|WQ-3Ytc0WNJFpvwAjK790qxSE!z<dgLZ3RNLU@ zh<a%*6ZY%z>%-UtBdqa44hY8Qe@2d9WD{VLb%6K*-+@|uE4V>e1UZvKeYMYL98g`$ z;VGtnAx0ax9AF#&+Gy6BQa`X4K{TV{AL?7FQbZxmp}MX54@upX6Y@ZmZvv(DACl4F z?j;1JskT+4Kkdp^V^Bdo1$q%V6B}WPBiR;oN)@qM?HV10m_^BiU<cQmidZne_4NJX zXI7T~dE(Xa0s9X&KcD~Egx970{~FItV$`4O72z+zM0s1O*Bj};x1y_(RDf1Fi&5I> zXS*R{HrCeOxonLsUQMu?K+s!DuMDX*pv3&w2?rv0Cbc$*Jwmz2gkzItCxefPGve0V zqUFSK!+@|G@7#ATIJxLi2Jc+y)lyt^1NAKJMv~W=FDw-FXu%5#<!upoK>3|W*R0eV z!Z;GED?5^tz7%I1R3&T?N2NY5Rup{avO?uNV_k_0P7UOo=M2Ycu#sI*0X4LlC?xJe zJgfs4tO*a}KplGrqnnA5f_io&%_#J0@o-+uWpJT}od!<E8)CS!!%`~y!f>+t$D#{p zzKhWucoM+XI~NmaW0C1d(|UU*B4WamGU+ScO%aHN9%-T-`Ot^B@9-Xr=@<UdoX#dR z9aJoJQ##-=-;3uIc0$rE&5qnq6(ixb*vic3U5KsC$z(;BHRuIG4M!o&d?(;F>1o9f zN#<m|smf%a-D0L~cVg5-fhm9>8k0jP75m{lPFCEjEq3KOwwMs<<PLsMvOD72{plS6 z^H6?=zy#Y2^kRTagG$D<suS-TMtT=V7{X!455m=iw}t7T?=tzI?~wjoT7_PMw!w(R zV&QVCERbF?h13EqG;PnF%njc+=Rc;ae^T}&pI|CsLReFrvnB(l*m6dWl{cu5WGbE5 zX%!~qCvg0zlvb`UZ8UkMB4yPm;G<|lokXV&Cg$pbE|ulR3L2>lLZ<zGMEeR7!W)4& zFP$sy61RyRn3=vg6=to*4vS(|5HgtsK+?j2`3wT+64;tpQP?k(unJfgo69DLFdLN9 zriU_m*unC#6N`#qq*Z2r-?<<rF!EC{2tT5@<bZxoMi8a?sHC8n;FJ{<ulJ_~1xz6? zs8GuigQ~8W8BA&xrv~3;5_Ip<89LEAjEEq&nk4HI!ir<yS-pUPN~Zx^40DF;A_{46 z95><$GLuwHXg(sV_(fdDQYNcPQv^N7sYuI#w^`XKc?@_S{SSb*2Zr7?jIREhVO;O& zZNq3o9S`^Pfnf~vKlcpd&prLfFq(T%-hQ!<fJgeThVd)W0Oa;ie`y#m<FBvuQ^PnV z5dDo|yooEG@0HecfNb1yC)EIz5p87P2mp<a{u=Pa8^8^ar|~}g`)f}yJOzd?v7r6; zeJtsvh)AZ}lf4we^&WK`8u*jYo*xJ_YOxm+NEje26d6KqwNs=}SQ{j!f48xxzo|~O z{|Wzqpt)fLd%Ck{v}f32+W@>aM}U#&^(At@r+AW$bSC?{X&A2&<Ts>27~?j6CngS1 zm{Mc}gKWoA-;zTq3)?ob))mk=99Z_po^Bxt*&-mG8tuNmK^+pHq$I$Gg`xl+lQw<> zp>@P7^yvUOHjvzJarLntfFy$ULzDnLJct=Th}GkOdbtCxG-c8uTsr%<IfRn<fBMu3 zfCu9Lt*wpCeEh$@z0Ci9mFJ=O|IyL@G9+Avgv*d{84@l-!evOf3<;MZ;TMO56lW|0 zx@ADO4CwyCN5y~rj<J8#sZZ^{ppE<Ezx9pg_I57*+gNXIF5|zi@jSa{n^x2Qf-^jX zoeuNrn)d7&>p>WL<?w4w8;+eoRU+P%V`D5R79Gg}?ek`0MZCrUE)OwaC+n+nV$2D; z|5}YD@d%Tl40BvOUJpP}-3E1lpz5f7!UA9j9C}zFN6uyBn-apXZAVZ<<`5Gg!)OLo zC)j02gis7{(M1+$*h3jx$%6Cq^9wT=YcqE?#W?A{VE|Z7TU$H!LcX@vVi^I!D|kb= zDo^Gp1RH~r^9(U-;+#V#MI1Ys=Yni?hBKt-G_##9MuvjT*aOux;hf1$_HfJtwZUEz z*S0k6RuH&lugBoz-m+7GZ=3MXt#(_ZC;LIKfOQVxs8YXkz}w@XI6O_9T>!TbYu&Qn zVR&skvTA`d55cMf0h`XOF}FT|YF;_M=T4!RDY|kfkgPGn?$Ti=#0DZZ1ZkozHI%pP z$g`c%oTU#*>ak{UQWrrRG@wA7hFH<EJPhw4x`1j51rl{4HPVIR%oPHuOF}#?5kWmI z5Jem+4;&<!QC^**7B`v!IULwibjk4TS)8aqA|?$OX=rjXOZ)O%gr=1zA+*v2gm!Q8 zLCYl`v~<#eqL-=_evEI|fSVhBtjp2+2Mk`_PMuzRn_;^Kv$~-g!ko^^zt-_I*~*SM zPKHN|X+l0zb3>j0K~)#8Ab!d$-Lic?GNVaIZ}H*CMDt5@KQ#-~L_q1_c%SE^EiCMd z1AR8U=AuNQG$=$u#yei_We(-kb^eUFFs*bCO3n}=TEcUq=>_-cb*qUVhlM<cz*5lx zfnInX>KeUM`UKw~`o!xsIz<XNZz%R;IAK#WTTqk3DF{d-o@3s~hN)!zxfP-Mr$$Ex zp17{?I9e7rs`8<7tDy9#dLz@~WuYu-mbf9d?||M30q-Q?9!6m&a*42kXA6&~lIBv` zF{yVRD}{pt0^orY@}Uz>N`OTs%E7bx2!Q;I`IL9<GEf2@Vm<5bBOA$dz>Et_Qu{}N zr8Co=|Fk18j-rrmU)5-pO3xd*+hOyQXf~U-$<X|~fKY)98cboEfJjVpeuTFO*vhr% ze+Ym*z5=7X0&~-~&57r7nu0T2wSHC|e0#Q&>){ElY8RaBp)lxpzfRZ{z>}JpM}yS~ zWar0r`r5$^QRD(H-eFViowcw_I~i25AX+9Fa~xf~g^2RGQ-*?8T+Ae23QNp7BFn33 zZQ>B7oLr6@fL@!Jpwr_T>12EW@4Tp;PhgVqz6UVPi(Zjj%oCgZ*P8DnRY4}=L4!{B z7Nu8Nhc&)_((k=DPP*@Z?Yv1Y@8q*FD=DaqXK-cu4(X>nAZ@RtF2+3B^6|-pg1}p} z2I0`PV-F-XfQj$=<s&w=X1USYc;sT6vkcIOveMgIx;(i6sSeG*7hMU;31lE1PpqC2 z*KVvV;5|*DUmyWINxq`a%!7pwTI?+qz89zbgGOPbfV$X26-|JPxN@c_fTFch_sOLG zwb%mnRcLqO<t{hqi=&JOhdc33)8j21&IPk`>k;D(O#LpL);OdJ@II|$YbwQvx!qvq zxg>Crd>!%78dq1;T?}*P)Kfym+uaSp*@_+uZ_G^_#t)>)R*Wbd1C#%zzOS^{`5rfY zu;Aa{ERgHnxtz^+a&D6iQGC_a<<U~RAvdow@|FqDw53S<hg~-vXvHR;f(L2L@l{)R z14o#W)LKx<+(OA-Ke5ZNV=TVV0~F$QwzVd&GsFv4d@A`sol0J~`YgJar<TXmCMXIN zds24uFTJ8J+Vr~TcA!E_wyBDnROQzYlV+2xA-Nx<Hi5K9Ev8C#?Zd8$t<dKjC-HI= zdQ&r$!TL4E!%-ljWR(thh8|kLl`Vqc9@@H;0lD;VajLtsk1QYLCm!^4UPG^uprit& zU{y7%)w~d-Tl39P7;_Jk1%_+{LU%109iA_~ZVfn(-sG<5Rp|4UC*U62DYa9H7a{g+ z?=xMjv4-2kY^_lU>&_pz*G#ipo(rfk9L|M#mz)?1G7z?~&|aqRal46*?emN1@)9U( zT8DCP;6+0T^P+GTg(-#F@9dqv+>KRLDC6hog|vX0cGMke-#Tl#e4gqlKmYZ#Z9H)P zYoobY`2C;FW&Z!`Jj-9*SpMq9@>e&$@vm;E`+nsPYMHluk0-pyAJ@u$C*$6q$$0XE zz{}^q?kQRS?fNu)QV-yv``^v=W`6zOe!jh2|G&o5SX<Kyr?=^LG!$#{igtiOF<9fw z#&OAR`?V8}TZr_)K084?wAD^P3wPV8c5x~UqB>1>je0~87uZDzs{-6_f{-kKKOBF? zC>5;|30k@fC&qUvG*!j4Ns$WI=xZv|roPAUJBl-d1#~JtyNpr8<vUL*-?p|^*zBWh z0xd<fF9#%O5iLEXxf4^7`+v0cN2gw8zCz)<={>5XZQ22hy{*hvVAU2|o*0p@Dl!W6 z=N1#&i3zQEPcS}=x_DzSp-t{!OkOR;sokBJK8a1bC5Z<m5#!uI^}aK!<i=&stlrX5 zUv$mVN+z9XksS2ouJGc~l>5eV76t6Y^zr0E{=D;p1DPG9n5E)*;5f5&4^!#QO(DS{ zZE+?$n6aXuZ>A=t(zn8PXiO%(tC{FUaVIoJNcJ@qcyT8*VN&jE#*F0lL1RF*51G!! zf+ZPe_VCil)R@<Ywo405&<;dxhFcJ#8DZZFttA#PZtQXUs&R|)Z}H6WXDdNGsmv~e z(UMnuKno=OPup^p11PLMp1h*X(F?BjR}&HCwq|2owYv&RsLXXn#@WoMBv;(Cjp=2V z1-ajv6RV_BvU>}2L@QnQRMuNAeNTL6iDsQ@_|aKY$&0!eq&>;KAW3vl0(Olp$ic3$ z2ljQ>SYc~-jXiWDND~)=NIo{VCMK=`pLZW9URxJ$A(S=piPvT_tvu#p%;Q@9l6x^R zOP_eZrJS?H*IVLt?!CvNl=i+`EZQerV$mLPhh>5Kl;uiFF0R~rYbAeYWk-ucc2;l^ z^<q(c`LIx<em$sIBe^QsuPSbxE!-3<-<ZqG1h<&KbsVyMzRgn_|37W`|2gsh=JxXU z-@eMTjQ^MM|1$nx#{bLse;NNT<Nsy+zl{Hv@&7XZU&jB-=l{o};{U^LyK_9~{3UHX zc>eFjdM^H7-`?0-#{XaAIqD9RqrGi!HWx>MtJW&pXl`t?6L3+)-^E}9bLP<HYIb<o z)-<RAi->+DUiSd+j)S*z>6<QHp&t31<EmhdF(l9_{BY+C2%#tJU4*9NirdEc2ii1% z4Dnif;Elp-G~nPcFjxaT%OMZb_N)jmYRQWibX~iOK}cmFF)FLnh|SFjgf<kQs4B`! zcoE$}p}%xUm!TcknnZMMT2)V+sUurJbdp4%0Z~{tb>fr$ESxjKPfp#MMHdq%80!r4 zXTEb0g-{q^p>S=m9}V;w`DCI21$0xXuL*YqgRIX`prI5I<yWr9-Za}2C(uTb??RJe zb+kPY8MR2k6_g-2SoVo`EpCLn!c)e}t#}*3yzs6#^+gP{>%lrI#7zS*OGZ^z1!Gfq z`$AyQuJ~h#ad&)d9zzC<UKx42$bYVzb!h)phYflM!`DW?!@2`@((nD=-S6zPiZOui z6`j5A4qx?7hYTS4#_{kC>m4xT_znBDd%Ul+&OcB3oxy<h`dats<gnX;vhH#F@N~a> z{F3bf-f<7KxeGcCM29_w4N6ko&H#xWb^7gB@YUGs9(IRsbnT!!JVu%ay*@M8iP0Z+ z+oy*{pPiicPkMt6G`|mIkGsbQeQ2d~)Hxp3p;ahjo!{XL8@w_O53woDI0f$e$RBI> zPTuspFJBGWtKQ*$2MYH(z?rdk*bz-Zr`m@`_ef{^#*y)|Ls&ha($^47aK~Q1>R=JJ zXTX2$VYhdT-DvlYhkg8YBj{y+7^A-K4mvtB`rQEv<e=X>(lrz(VDyLx;2n1a6%;kg vj0k|>_tQZqCdl?X#v#xeAToAQ0oRuf!}3`^%V+ugZ$JM5wEBi+0DJ%d<;(Zr literal 0 HcmV?d00001 From 3a98ee02ee9204fccdfc5d85573c0e0b86ac19aa Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:30:20 -0400 Subject: [PATCH 10/14] fix(scripts): resolve ReDoS vulnerability in pnpm normalizer Replace \s+.*$ regex with safer split(' ')[0] operation to prevent catastrophic backtracking on malicious input. fix(theme): improve color contrast for WCAG AA compliance Change appBar and footer textColor from 'secondary' to 'text' to ensure proper contrast on light backgrounds in dark mode. --- examples/stackwright-docs/stackwright.yml | 4 ++-- packages/sbom-generator/src/normalizers/pnpm.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/stackwright-docs/stackwright.yml b/examples/stackwright-docs/stackwright.yml index 29e43fdf..371f95a0 100644 --- a/examples/stackwright-docs/stackwright.yml +++ b/examples/stackwright-docs/stackwright.yml @@ -5,7 +5,7 @@ meta: appBar: titleText: "Stackwright" backgroundColor: "primary" - textColor: "secondary" + textColor: "text" colorModeToggle: false navigation: @@ -54,7 +54,7 @@ sidebar: footer: backgroundColor: "primary" - textColor: "secondary" + textColor: "text" copyright: "Built with Stackwright 🦦" links: - label: "GitHub" diff --git a/packages/sbom-generator/src/normalizers/pnpm.ts b/packages/sbom-generator/src/normalizers/pnpm.ts index 4ae59320..3a868e30 100644 --- a/packages/sbom-generator/src/normalizers/pnpm.ts +++ b/packages/sbom-generator/src/normalizers/pnpm.ts @@ -47,7 +47,7 @@ export interface NormalizedDependency { * Parse semver version to extract clean version string */ export function normalizeVersion(versionSpec: string): string { - return versionSpec.replace(/^[\^~>=<]+/, '').replace(/\s+.*$/, ''); + return versionSpec.replace(/^[\^~>=<]+/, '').split(' ')[0]; } /** From 953165d5b44a7f2a846f310a5dd086008a839f6c Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:44:46 -0400 Subject: [PATCH 11/14] chore: sync lockfile with scaffold-core dependencies --- pnpm-lock.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21dd685e..3e0bab38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@stackwright/sbom-generator': specifier: workspace:* version: link:../sbom-generator + '@stackwright/scaffold-core': + specifier: workspace:* + version: link:../scaffold-core '@stackwright/themes': specifier: workspace:* version: link:../themes @@ -358,6 +361,9 @@ importers: '@stackwright/otters': specifier: workspace:* version: link:../otters + '@stackwright/scaffold-core': + specifier: workspace:* + version: link:../scaffold-core chalk: specifier: ^5.6.2 version: 5.6.2 @@ -546,6 +552,21 @@ importers: specifier: ^4.1.2 version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/scaffold-core: + devDependencies: + '@types/node': + specifier: ^24.1.0 + version: 24.11.0 + tsup: + specifier: ^8.5.0 + version: 8.5.0(@swc/core@1.15.18)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/themes: dependencies: js-yaml: From e49bd8922f79336aff882f8159490fbe1859e563 Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:55:21 -0400 Subject: [PATCH 12/14] test: update visual baselines for dark mode changes --- .../testimonial-grid-desktop.png | Bin 27648 -> 25811 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/e2e/tests/__screenshots__/stackwright-docs/visual.spec.ts/testimonial-grid-desktop.png b/packages/e2e/tests/__screenshots__/stackwright-docs/visual.spec.ts/testimonial-grid-desktop.png index 312926663225351d99600ced719f7ce67c678298..2487c95e69e835a62bc8969376796fe49336d87f 100644 GIT binary patch literal 25811 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 W@|$vqdz|Ptnp`$FD!+K`xBmcQPp0(% literal 27648 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隸<y?O6GK-#Sk(DH@RQ4E+E&;zDJe86#d^hq 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 a!Zd<?YVr|rx|K1EYAUy`;jY|y{J#J{Bs1&) From a40cfdd7576fe0ff91fd36c18ba7995c1d2567fa Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:56:41 -0400 Subject: [PATCH 13/14] fix(e2e): update performance test paths to stackwright-docs --- examples/stackwright-docs/build-manifest.json | 4 +- examples/stackwright-docs/cyclonedx.json | 8 +- examples/stackwright-docs/spdx.json | 10 +-- examples/stackwright-docs/spdx.spdx | 10 +-- .../e2e/tests/performance/build-time.bench.ts | 38 +++++----- .../tests/performance/bundle-size.bench.ts | 74 +++++++++++-------- 6 files changed, 80 insertions(+), 64 deletions(-) diff --git a/examples/stackwright-docs/build-manifest.json b/examples/stackwright-docs/build-manifest.json index 2f86fd67..e9ff4750 100644 --- a/examples/stackwright-docs/build-manifest.json +++ b/examples/stackwright-docs/build-manifest.json @@ -1,10 +1,10 @@ { "format": "stackwright-build-manifest", "version": "1.0.0", - "generated": "2026-04-03T16:11:33.931Z", + "generated": "2026-04-03T18:50:53.514Z", "project": { "name": "stackwright-docs", - "version": "0.1.1-alpha.0", + "version": "0.1.1-alpha.1", "root": "/home/charles/git/peraspera/stackwright/examples/stackwright-docs", "isMonorepo": false }, diff --git a/examples/stackwright-docs/cyclonedx.json b/examples/stackwright-docs/cyclonedx.json index 2a71d04e..6f20d390 100644 --- a/examples/stackwright-docs/cyclonedx.json +++ b/examples/stackwright-docs/cyclonedx.json @@ -3,7 +3,7 @@ "specVersion": "1.5", "version": 1, "metadata": { - "timestamp": "2026-04-03T16:11:33.930Z", + "timestamp": "2026-04-03T18:50:53.514Z", "tools": [ { "vendor": "Stackwright", @@ -14,8 +14,8 @@ "component": { "type": "application", "name": "stackwright-docs", - "version": "0.1.1-alpha.0", - "purl": "pkg:npm/stackwright-docs@0.1.1-alpha.0" + "version": "0.1.1-alpha.1", + "purl": "pkg:npm/stackwright-docs@0.1.1-alpha.1" } }, "components": [ @@ -174,7 +174,7 @@ ], "dependencies": [ { - "ref": "pkg:npm/stackwright-docs@0.1.1-alpha.0", + "ref": "pkg:npm/stackwright-docs@0.1.1-alpha.1", "dependsOn": [ "@stackwright/core", "@stackwright/icons", diff --git a/examples/stackwright-docs/spdx.json b/examples/stackwright-docs/spdx.json index 993997f4..ea776367 100644 --- a/examples/stackwright-docs/spdx.json +++ b/examples/stackwright-docs/spdx.json @@ -1,11 +1,11 @@ { "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", - "SPDXID": "SPDXRef-DOCUMENT-c3d0969a", - "name": "stackwright-docs@0.1.1-alpha.0", - "documentNamespace": "https://stackwright.dev/spdx/stackwright-docs/2026-04-03T16:11:33.929Z", + "SPDXID": "SPDXRef-DOCUMENT-12b1dc75", + "name": "stackwright-docs@0.1.1-alpha.1", + "documentNamespace": "https://stackwright.dev/spdx/stackwright-docs/2026-04-03T18:50:53.512Z", "creationInfo": { - "created": "2026-04-03T16:11:33.929Z", + "created": "2026-04-03T18:50:53.512Z", "creators": [ "Tool: @stackwright/sbom-generator", "Tool-Version: 0.0.0" @@ -199,7 +199,7 @@ ], "relationships": [ { - "spdxElementId": "SPDXRef-DOCUMENT-c3d0969a", + "spdxElementId": "SPDXRef-DOCUMENT-12b1dc75", "relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-Package-js-yaml" }, diff --git a/examples/stackwright-docs/spdx.spdx b/examples/stackwright-docs/spdx.spdx index cc5055ea..bdeec805 100644 --- a/examples/stackwright-docs/spdx.spdx +++ b/examples/stackwright-docs/spdx.spdx @@ -1,11 +1,11 @@ SPDXVersion: SPDX-2.3 DataLicense: CC0-1.0 -SPDXID: SPDXRef-DOCUMENT-c3d0969a -DocumentName: stackwright-docs@0.1.1-alpha.0 -DocumentNamespace: https://stackwright.dev/spdx/stackwright-docs/2026-04-03T16:11:33.929Z +SPDXID: SPDXRef-DOCUMENT-12b1dc75 +DocumentName: stackwright-docs@0.1.1-alpha.1 +DocumentNamespace: https://stackwright.dev/spdx/stackwright-docs/2026-04-03T18:50:53.512Z CreationInfo: - Created: 2026-04-03T16:11:33.929Z + Created: 2026-04-03T18:50:53.512Z Creator: Tool: @stackwright/sbom-generator Creator: Tool-Version: 0.0.0 @@ -73,7 +73,7 @@ PackageLicenseDeclared: NOASSERTION PackageChecksum: SHA256: d321eb3d0478e2a58565081343e4d1da25caf8103b3dd72b3544a715f2e80418 ExternalRef: PACKAGE-MANAGER purl pkg:npm/react-dom@19.2.4 -Relationship: SPDXRef-DOCUMENT-c3d0969a DESCRIBES SPDXRef-Package-js-yaml +Relationship: SPDXRef-DOCUMENT-12b1dc75 DESCRIBES SPDXRef-Package-js-yaml Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-core Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-icons Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-nextjs diff --git a/packages/e2e/tests/performance/build-time.bench.ts b/packages/e2e/tests/performance/build-time.bench.ts index b7701719..6c3e6b7b 100644 --- a/packages/e2e/tests/performance/build-time.bench.ts +++ b/packages/e2e/tests/performance/build-time.bench.ts @@ -8,22 +8,22 @@ const execAsync = promisify(exec); /** * Build Time Performance Benchmarks - * + * * Measures the time taken for: * 1. stackwright-prebuild (image co-location + path rewriting) * 2. next build (full SSG build) - * + * * Baselines (established 2025-01-27): * - Prebuild (7 pages, 17 files): ~2-5s * - Next build (7 pages): ~30-45s - * + * * Budgets: * - Prebuild: <10s (warn at 8s) * - Next build: <60s (warn at 45s) * - Regression threshold: 20% */ -const exampleAppDir = path.resolve(__dirname, '../../../../examples/hellostackwrightnext'); +const exampleAppDir = path.resolve(__dirname, '../../../../examples/stackwright-docs'); interface BenchmarkResult { name: string; @@ -84,8 +84,10 @@ test.describe('Build Time Benchmarks', () => { console.log(` Status: ${passed ? '✅ PASS' : '❌ FAIL'} ${warning ? '⚠️ WARNING' : ''}`); // Assert - expect(duration, `Prebuild took ${duration}ms, budget is ${budget.max}ms`).toBeLessThanOrEqual(budget.max); - + expect(duration, `Prebuild took ${duration}ms, budget is ${budget.max}ms`).toBeLessThanOrEqual( + budget.max + ); + if (warning) { console.warn(`⚠️ Warning: Prebuild is slower than ${budget.warn}ms threshold`); } @@ -96,11 +98,7 @@ test.describe('Build Time Benchmarks', () => { const buildDir = path.join(exampleAppDir, '.next'); await fs.rm(buildDir, { recursive: true, force: true }); - const duration = await benchmarkCommand( - 'next build', - 'pnpm exec next build', - exampleAppDir - ); + const duration = await benchmarkCommand('next build', 'pnpm exec next build', exampleAppDir); const budget = budgets.build.nextBuild; const passed = duration <= budget.max; @@ -113,8 +111,11 @@ test.describe('Build Time Benchmarks', () => { console.log(` Status: ${passed ? '✅ PASS' : '❌ FAIL'} ${warning ? '⚠️ WARNING' : ''}`); // Assert - expect(duration, `Next build took ${duration}ms, budget is ${budget.max}ms`).toBeLessThanOrEqual(budget.max); - + expect( + duration, + `Next build took ${duration}ms, budget is ${budget.max}ms` + ).toBeLessThanOrEqual(budget.max); + if (warning) { console.warn(`⚠️ Warning: Next build is slower than ${budget.warn}ms threshold`); } @@ -126,15 +127,15 @@ test.describe('Build Time Benchmarks', () => { const buildDir = path.join(exampleAppDir, '.next'); await Promise.all([ fs.rm(processedDir, { recursive: true, force: true }), - fs.rm(buildDir, { recursive: true, force: true }) + fs.rm(buildDir, { recursive: true, force: true }), ]); const startTime = Date.now(); - + // Run prebuild await execAsync('pnpm exec stackwright-prebuild', { cwd: exampleAppDir }); const prebuildDuration = Date.now() - startTime; - + // Run build await execAsync('pnpm exec next build', { cwd: exampleAppDir }); const totalDuration = Date.now() - startTime; @@ -147,6 +148,9 @@ test.describe('Build Time Benchmarks', () => { console.log(` Budget: ${budgets.build.prebuild.max + budgets.build.nextBuild.max}ms`); const totalBudget = budgets.build.prebuild.max + budgets.build.nextBuild.max; - expect(totalDuration, `Total build took ${totalDuration}ms, budget is ${totalBudget}ms`).toBeLessThanOrEqual(totalBudget); + expect( + totalDuration, + `Total build took ${totalDuration}ms, budget is ${totalBudget}ms` + ).toBeLessThanOrEqual(totalBudget); }); }); diff --git a/packages/e2e/tests/performance/bundle-size.bench.ts b/packages/e2e/tests/performance/bundle-size.bench.ts index 95692f42..8e63a4f9 100644 --- a/packages/e2e/tests/performance/bundle-size.bench.ts +++ b/packages/e2e/tests/performance/bundle-size.bench.ts @@ -9,23 +9,23 @@ const execAsync = promisify(exec); /** * Bundle Size Performance Benchmarks - * + * * Analyzes Next.js build output to measure: * 1. First-load JS (critical for initial page load) * 2. Total JS across all pages * 3. Per-page bundle sizes * 4. Image optimization results - * + * * Baselines (established 2025-01-27): * - First-load JS: ~85KB gzipped * - Total JS: ~300KB gzipped - * + * * Budgets: * - First-load JS: <100KB gzipped (warn at 80KB) * - All pages JS: <500KB gzipped (warn at 400KB) */ -const exampleAppDir = path.resolve(__dirname, '../../../../examples/hellostackwrightnext'); +const exampleAppDir = path.resolve(__dirname, '../../../../examples/stackwright-docs'); interface BundleStats { firstLoadJS: number; @@ -47,8 +47,8 @@ async function ensureBuild() { console.log('✅ Build directory exists'); } catch { console.log('🔨 Building application first...'); - await execAsync('pnpm exec stackwright-prebuild && pnpm exec next build', { - cwd: exampleAppDir + await execAsync('pnpm exec stackwright-prebuild && pnpm exec next build', { + cwd: exampleAppDir, }); } } @@ -56,15 +56,15 @@ async function ensureBuild() { async function analyzeBuildManifest(): Promise<BundleStats> { const manifestPath = path.join(exampleAppDir, '.next/build-manifest.json'); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); - + // Get first-load JS files const firstLoadFiles = manifest.pages['/_app'] || []; - + // Calculate sizes let firstLoadJS = 0; let allPagesJS = 0; const pages: Array<{ route: string; size: number }> = []; - + // Analyze first-load JS for (const file of firstLoadFiles) { const filePath = path.join(exampleAppDir, '.next', file); @@ -76,7 +76,7 @@ async function analyzeBuildManifest(): Promise<BundleStats> { // File might not exist, skip } } - + // Analyze all pages for (const [route, files] of Object.entries(manifest.pages)) { let pageSize = 0; @@ -93,12 +93,12 @@ async function analyzeBuildManifest(): Promise<BundleStats> { } pages.push({ route, size: pageSize }); } - + return { firstLoadJS, allPagesJS, pages: pages.sort((a, b) => b.size - a.size), - sharedChunks: firstLoadJS + sharedChunks: firstLoadJS, }; } @@ -106,7 +106,7 @@ async function analyzeStaticAssets(): Promise<{ images: number; total: number }> const publicDir = path.join(exampleAppDir, 'public/images'); let images = 0; let total = 0; - + try { const walk = async (dir: string) => { const entries = await fs.readdir(dir, { withFileTypes: true }); @@ -121,12 +121,12 @@ async function analyzeStaticAssets(): Promise<{ images: number; total: number }> } } }; - + await walk(publicDir); } catch (error) { // Directory might not exist } - + return { images, total }; } @@ -140,7 +140,7 @@ test.describe('Bundle Size Benchmarks', () => { test('first-load JS bundle size', async () => { const stats = await analyzeBuildManifest(); - + const sizeKB = Math.round(stats.firstLoadJS / 1024); const budget = budgets.bundle.firstLoadJS; const passed = stats.firstLoadJS <= budget.max; @@ -148,19 +148,26 @@ test.describe('Bundle Size Benchmarks', () => { console.log(`\n📦 First-Load JS Bundle:`); console.log(` Size: ${sizeKB}KB (${stats.firstLoadJS} bytes, gzipped)`); - console.log(` Budget: ${Math.round(budget.max / 1024)}KB (warn at ${Math.round(budget.warn / 1024)}KB)`); + console.log( + ` Budget: ${Math.round(budget.max / 1024)}KB (warn at ${Math.round(budget.warn / 1024)}KB)` + ); console.log(` Status: ${passed ? '✅ PASS' : '❌ FAIL'} ${warning ? '⚠️ WARNING' : ''}`); - expect(stats.firstLoadJS, `First-load JS is ${sizeKB}KB, budget is ${Math.round(budget.max / 1024)}KB`).toBeLessThanOrEqual(budget.max); - + expect( + stats.firstLoadJS, + `First-load JS is ${sizeKB}KB, budget is ${Math.round(budget.max / 1024)}KB` + ).toBeLessThanOrEqual(budget.max); + if (warning) { - console.warn(`⚠️ Warning: First-load JS exceeds ${Math.round(budget.warn / 1024)}KB threshold`); + console.warn( + `⚠️ Warning: First-load JS exceeds ${Math.round(budget.warn / 1024)}KB threshold` + ); } }); test('total JS bundle size', async () => { const stats = await analyzeBuildManifest(); - + const sizeKB = Math.round(stats.allPagesJS / 1024); const budget = budgets.bundle.allPagesJS; const passed = stats.allPagesJS <= budget.max; @@ -168,17 +175,22 @@ test.describe('Bundle Size Benchmarks', () => { console.log(`\n📦 Total JS Across All Pages:`); console.log(` Size: ${sizeKB}KB (${stats.allPagesJS} bytes, gzipped)`); - console.log(` Budget: ${Math.round(budget.max / 1024)}KB (warn at ${Math.round(budget.warn / 1024)}KB)`); + console.log( + ` Budget: ${Math.round(budget.max / 1024)}KB (warn at ${Math.round(budget.warn / 1024)}KB)` + ); console.log(` Status: ${passed ? '✅ PASS' : '❌ FAIL'} ${warning ? '⚠️ WARNING' : ''}`); - + // Show per-page breakdown console.log(`\n Per-Page Breakdown (top 5):`); for (const page of stats.pages.slice(0, 5)) { console.log(` ${page.route}: ${Math.round(page.size / 1024)}KB`); } - expect(stats.allPagesJS, `Total JS is ${sizeKB}KB, budget is ${Math.round(budget.max / 1024)}KB`).toBeLessThanOrEqual(budget.max); - + expect( + stats.allPagesJS, + `Total JS is ${sizeKB}KB, budget is ${Math.round(budget.max / 1024)}KB` + ).toBeLessThanOrEqual(budget.max); + if (warning) { console.warn(`⚠️ Warning: Total JS exceeds ${Math.round(budget.warn / 1024)}KB threshold`); } @@ -186,7 +198,7 @@ test.describe('Bundle Size Benchmarks', () => { test('image optimization results', async () => { const { images, total } = await analyzeStaticAssets(); - + const totalMB = (total / (1024 * 1024)).toFixed(2); const avgKB = images > 0 ? Math.round(total / images / 1024) : 0; @@ -194,28 +206,28 @@ test.describe('Bundle Size Benchmarks', () => { console.log(` Total Images: ${images}`); console.log(` Total Size: ${totalMB}MB`); console.log(` Average Size: ${avgKB}KB per image`); - + // This is informational, not a hard limit // But we can warn if images are unexpectedly large if (avgKB > 500) { console.warn(`⚠️ Warning: Average image size is high (${avgKB}KB). Consider optimization.`); } - + // Basic sanity check - at least some images should exist expect(images, 'Should have processed at least some images').toBeGreaterThan(0); }); test('shared chunk efficiency', async () => { const stats = await analyzeBuildManifest(); - + // Shared chunks should be reasonable compared to total bundle const sharedPercentage = ((stats.sharedChunks / stats.allPagesJS) * 100).toFixed(1); - + console.log(`\n🔗 Shared Chunk Analysis:`); console.log(` Shared chunks: ${Math.round(stats.sharedChunks / 1024)}KB`); console.log(` Total bundle: ${Math.round(stats.allPagesJS / 1024)}KB`); console.log(` Shared percentage: ${sharedPercentage}%`); - + // Shared chunks should be a reasonable portion (20-60% is typical) const percentage = parseFloat(sharedPercentage); expect(percentage, 'Shared chunks should be between 20% and 80% of total').toBeGreaterThan(20); From c44944a285a9380745426f089528dabaa81dfbb0 Mon Sep 17 00:00:00 2001 From: Charles Gibson <46542971+perasperaactual@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:29:17 -0400 Subject: [PATCH 14/14] fix(examples): add missing dependencies for pnpm strict mode --- examples/stackwright-docs/build-manifest.json | 71 +++++- examples/stackwright-docs/cyclonedx.json | 144 +++++++++++- examples/stackwright-docs/package.json | 9 +- examples/stackwright-docs/spdx.json | 220 +++++++++++++++++- examples/stackwright-docs/spdx.spdx | 85 ++++++- pnpm-lock.yaml | 35 +++ 6 files changed, 534 insertions(+), 30 deletions(-) diff --git a/examples/stackwright-docs/build-manifest.json b/examples/stackwright-docs/build-manifest.json index e9ff4750..75ab7f15 100644 --- a/examples/stackwright-docs/build-manifest.json +++ b/examples/stackwright-docs/build-manifest.json @@ -1,7 +1,7 @@ { "format": "stackwright-build-manifest", "version": "1.0.0", - "generated": "2026-04-03T18:50:53.514Z", + "generated": "2026-04-03T19:28:43.494Z", "project": { "name": "stackwright-docs", "version": "0.1.1-alpha.1", @@ -9,6 +9,33 @@ "isMonorepo": false }, "dependencies": [ + { + "name": "@radix-ui/react-accordion", + "version": "1.2.2", + "type": "direct", + "category": "external", + "purl": "pkg:npm/radix-ui/react-accordion@1.2.2", + "integrity": "6385b547dec3958e12e36af31946a77045ae9b94c89e53bfacbedb71c0f3a10c", + "depth": 0 + }, + { + "name": "@radix-ui/react-slot", + "version": "1.1.1", + "type": "direct", + "category": "external", + "purl": "pkg:npm/radix-ui/react-slot@1.1.1", + "integrity": "6c80259979afa356ff77686649e864a9be6fd187b480c8d3d49fecea2c977b75", + "depth": 0 + }, + { + "name": "@radix-ui/react-tabs", + "version": "1.1.2", + "type": "direct", + "category": "external", + "purl": "pkg:npm/radix-ui/react-tabs@1.1.2", + "integrity": "f3ad88f5751707a5c5f92a1603247d36820a4c7e2a748308c3c8c8acd4d07cd2", + "depth": 0 + }, { "name": "@stackwright/core", "version": "workspace:*", @@ -45,6 +72,24 @@ "integrity": "ac159bdf9d62105719ef6d7d5be5b490eadd991e002ddaae9bab73c93f5c7667", "depth": 0 }, + { + "name": "clsx", + "version": "2.1.1", + "type": "direct", + "category": "external", + "purl": "pkg:npm/clsx@2.1.1", + "integrity": "d00f5fa05915e8916f882f17ae64d1bd58f60296bdf8bb490772cebdafa01b60", + "depth": 0 + }, + { + "name": "fuse.js", + "version": "7.1.0", + "type": "direct", + "category": "external", + "purl": "pkg:npm/fuse.js@7.1.0", + "integrity": "3f3d82b5a1943d6113a0f6476ef58cb75640ba889262edd8fed42a3254d8c119", + "depth": 0 + }, { "name": "js-yaml", "version": "4.1.1", @@ -54,6 +99,15 @@ "integrity": "af705ed1eb31f1af61a3698569d8e0370c173e67024075b4f306d8c9010f1298", "depth": 0 }, + { + "name": "lucide-react", + "version": "0.471.1", + "type": "direct", + "category": "external", + "purl": "pkg:npm/lucide-react@0.471.1", + "integrity": "34516f0b69a564672a07087b4a907c82ef437a69c4ba2895f1dc37bb8ae402ac", + "depth": 0 + }, { "name": "next", "version": "16.1.6", @@ -80,15 +134,24 @@ "purl": "pkg:npm/react-dom@19.2.4", "integrity": "d321eb3d0478e2a58565081343e4d1da25caf8103b3dd72b3544a715f2e80418", "depth": 0 + }, + { + "name": "tailwind-merge", + "version": "2.6.0", + "type": "direct", + "category": "external", + "purl": "pkg:npm/tailwind-merge@2.6.0", + "integrity": "f55becd055a25a9ecb32ee3cf37faf157aa62b955195bb7f695909e582e8ae33", + "depth": 0 } ], "metadata": { - "totalDependencies": 8, - "directDependencies": 8, + "totalDependencies": 15, + "directDependencies": 15, "devDependencies": 0, "peerDependencies": 0, "transitiveDependencies": 0, "stackwrightInternal": 4, - "external": 4 + "external": 11 } } \ No newline at end of file diff --git a/examples/stackwright-docs/cyclonedx.json b/examples/stackwright-docs/cyclonedx.json index 6f20d390..cd0cb859 100644 --- a/examples/stackwright-docs/cyclonedx.json +++ b/examples/stackwright-docs/cyclonedx.json @@ -3,7 +3,7 @@ "specVersion": "1.5", "version": 1, "metadata": { - "timestamp": "2026-04-03T18:50:53.514Z", + "timestamp": "2026-04-03T19:28:43.494Z", "tools": [ { "vendor": "Stackwright", @@ -19,6 +19,63 @@ } }, "components": [ + { + "type": "library", + "name": "@radix-ui/react-accordion", + "version": "1.2.2", + "purl": "pkg:npm/radix-ui/react-accordion@1.2.2", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "6385b547dec3958e12e36af31946a77045ae9b94c89e53bfacbedb71c0f3a10c" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/@radix-ui/react-accordion" + } + ] + }, + { + "type": "library", + "name": "@radix-ui/react-slot", + "version": "1.1.1", + "purl": "pkg:npm/radix-ui/react-slot@1.1.1", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "6c80259979afa356ff77686649e864a9be6fd187b480c8d3d49fecea2c977b75" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/@radix-ui/react-slot" + } + ] + }, + { + "type": "library", + "name": "@radix-ui/react-tabs", + "version": "1.1.2", + "purl": "pkg:npm/radix-ui/react-tabs@1.1.2", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "f3ad88f5751707a5c5f92a1603247d36820a4c7e2a748308c3c8c8acd4d07cd2" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/@radix-ui/react-tabs" + } + ] + }, { "type": "library", "name": "@stackwright/core", @@ -95,6 +152,44 @@ } ] }, + { + "type": "library", + "name": "clsx", + "version": "2.1.1", + "purl": "pkg:npm/clsx@2.1.1", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "d00f5fa05915e8916f882f17ae64d1bd58f60296bdf8bb490772cebdafa01b60" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/clsx" + } + ] + }, + { + "type": "library", + "name": "fuse.js", + "version": "7.1.0", + "purl": "pkg:npm/fuse.js@7.1.0", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "3f3d82b5a1943d6113a0f6476ef58cb75640ba889262edd8fed42a3254d8c119" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/fuse.js" + } + ] + }, { "type": "library", "name": "js-yaml", @@ -114,6 +209,25 @@ } ] }, + { + "type": "library", + "name": "lucide-react", + "version": "0.471.1", + "purl": "pkg:npm/lucide-react@0.471.1", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "34516f0b69a564672a07087b4a907c82ef437a69c4ba2895f1dc37bb8ae402ac" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/lucide-react" + } + ] + }, { "type": "library", "name": "next", @@ -170,20 +284,46 @@ "url": "https://www.npmjs.com/package/react-dom" } ] + }, + { + "type": "library", + "name": "tailwind-merge", + "version": "2.6.0", + "purl": "pkg:npm/tailwind-merge@2.6.0", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "f55becd055a25a9ecb32ee3cf37faf157aa62b955195bb7f695909e582e8ae33" + } + ], + "externalReferences": [ + { + "type": "package-manager", + "url": "https://www.npmjs.com/package/tailwind-merge" + } + ] } ], "dependencies": [ { "ref": "pkg:npm/stackwright-docs@0.1.1-alpha.1", "dependsOn": [ + "@radix-ui/react-accordion", + "@radix-ui/react-slot", + "@radix-ui/react-tabs", "@stackwright/core", "@stackwright/icons", "@stackwright/nextjs", "@stackwright/ui-shadcn", + "clsx", + "fuse.js", "js-yaml", + "lucide-react", "next", "react", - "react-dom" + "react-dom", + "tailwind-merge" ], "dependencyType": "direct" } diff --git a/examples/stackwright-docs/package.json b/examples/stackwright-docs/package.json index 1b4506f0..c8be6d43 100644 --- a/examples/stackwright-docs/package.json +++ b/examples/stackwright-docs/package.json @@ -12,14 +12,21 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.2", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.2", "@stackwright/core": "workspace:*", "@stackwright/icons": "workspace:*", "@stackwright/nextjs": "workspace:*", "@stackwright/ui-shadcn": "workspace:*", + "clsx": "^2.1.1", + "fuse.js": "^7.1.0", "js-yaml": "^4.1.1", + "lucide-react": "^0.471.1", "next": "^16.1.6", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "tailwind-merge": "^2.6.0" }, "devDependencies": { "@playwright/browser-chromium": "^1.58.2", diff --git a/examples/stackwright-docs/spdx.json b/examples/stackwright-docs/spdx.json index ea776367..8cf3a0da 100644 --- a/examples/stackwright-docs/spdx.json +++ b/examples/stackwright-docs/spdx.json @@ -1,17 +1,86 @@ { "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", - "SPDXID": "SPDXRef-DOCUMENT-12b1dc75", + "SPDXID": "SPDXRef-DOCUMENT-9bbc3e93", "name": "stackwright-docs@0.1.1-alpha.1", - "documentNamespace": "https://stackwright.dev/spdx/stackwright-docs/2026-04-03T18:50:53.512Z", + "documentNamespace": "https://stackwright.dev/spdx/stackwright-docs/2026-04-03T19:28:43.491Z", "creationInfo": { - "created": "2026-04-03T18:50:53.512Z", + "created": "2026-04-03T19:28:43.491Z", "creators": [ "Tool: @stackwright/sbom-generator", "Tool-Version: 0.0.0" ] }, "packages": [ + { + "SPDXID": "SPDXRef-Package--radix-ui-react-accordion", + "name": "@radix-ui/react-accordion", + "versionInfo": "1.2.2", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "6385b547dec3958e12e36af31946a77045ae9b94c89e53bfacbedb71c0f3a10c" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/radix-ui/react-accordion@1.2.2" + } + ] + }, + { + "SPDXID": "SPDXRef-Package--radix-ui-react-slot", + "name": "@radix-ui/react-slot", + "versionInfo": "1.1.1", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "6c80259979afa356ff77686649e864a9be6fd187b480c8d3d49fecea2c977b75" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/radix-ui/react-slot@1.1.1" + } + ] + }, + { + "SPDXID": "SPDXRef-Package--radix-ui-react-tabs", + "name": "@radix-ui/react-tabs", + "versionInfo": "1.1.2", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "f3ad88f5751707a5c5f92a1603247d36820a4c7e2a748308c3c8c8acd4d07cd2" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/radix-ui/react-tabs@1.1.2" + } + ] + }, { "SPDXID": "SPDXRef-Package--stackwright-core", "name": "@stackwright/core", @@ -104,6 +173,52 @@ } ] }, + { + "SPDXID": "SPDXRef-Package-clsx", + "name": "clsx", + "versionInfo": "2.1.1", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "d00f5fa05915e8916f882f17ae64d1bd58f60296bdf8bb490772cebdafa01b60" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/clsx@2.1.1" + } + ] + }, + { + "SPDXID": "SPDXRef-Package-fuse-js", + "name": "fuse.js", + "versionInfo": "7.1.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "3f3d82b5a1943d6113a0f6476ef58cb75640ba889262edd8fed42a3254d8c119" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/fuse.js@7.1.0" + } + ] + }, { "SPDXID": "SPDXRef-Package-js-yaml", "name": "js-yaml", @@ -127,6 +242,29 @@ } ] }, + { + "SPDXID": "SPDXRef-Package-lucide-react", + "name": "lucide-react", + "versionInfo": "0.471.1", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "34516f0b69a564672a07087b4a907c82ef437a69c4ba2895f1dc37bb8ae402ac" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/lucide-react@0.471.1" + } + ] + }, { "SPDXID": "SPDXRef-Package-next", "name": "next", @@ -195,48 +333,106 @@ "referenceLocator": "pkg:npm/react-dom@19.2.4" } ] + }, + { + "SPDXID": "SPDXRef-Package-tailwind-merge", + "name": "tailwind-merge", + "versionInfo": "2.6.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "value": "f55becd055a25a9ecb32ee3cf37faf157aa62b955195bb7f695909e582e8ae33" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/tailwind-merge@2.6.0" + } + ] } ], "relationships": [ { - "spdxElementId": "SPDXRef-DOCUMENT-12b1dc75", + "spdxElementId": "SPDXRef-DOCUMENT-9bbc3e93", "relationshipType": "DESCRIBES", - "relatedSpdxElement": "SPDXRef-Package-js-yaml" + "relatedSpdxElement": "SPDXRef-Package-clsx" }, { - "spdxElementId": "SPDXRef-Package-js-yaml", + "spdxElementId": "SPDXRef-Package-clsx", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package--radix-ui-react-accordion" + }, + { + "spdxElementId": "SPDXRef-Package-clsx", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package--radix-ui-react-slot" + }, + { + "spdxElementId": "SPDXRef-Package-clsx", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package--radix-ui-react-tabs" + }, + { + "spdxElementId": "SPDXRef-Package-clsx", "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-Package--stackwright-core" }, { - "spdxElementId": "SPDXRef-Package-js-yaml", + "spdxElementId": "SPDXRef-Package-clsx", "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-Package--stackwright-icons" }, { - "spdxElementId": "SPDXRef-Package-js-yaml", + "spdxElementId": "SPDXRef-Package-clsx", "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-Package--stackwright-nextjs" }, { - "spdxElementId": "SPDXRef-Package-js-yaml", + "spdxElementId": "SPDXRef-Package-clsx", "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-Package--stackwright-ui-shadcn" }, { - "spdxElementId": "SPDXRef-Package-js-yaml", + "spdxElementId": "SPDXRef-Package-clsx", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-fuse-js" + }, + { + "spdxElementId": "SPDXRef-Package-clsx", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-js-yaml" + }, + { + "spdxElementId": "SPDXRef-Package-clsx", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-lucide-react" + }, + { + "spdxElementId": "SPDXRef-Package-clsx", "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-Package-next" }, { - "spdxElementId": "SPDXRef-Package-js-yaml", + "spdxElementId": "SPDXRef-Package-clsx", "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-Package-react" }, { - "spdxElementId": "SPDXRef-Package-js-yaml", + "spdxElementId": "SPDXRef-Package-clsx", "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-Package-react-dom" + }, + { + "spdxElementId": "SPDXRef-Package-clsx", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-tailwind-merge" } ] } \ No newline at end of file diff --git a/examples/stackwright-docs/spdx.spdx b/examples/stackwright-docs/spdx.spdx index bdeec805..ed171d80 100644 --- a/examples/stackwright-docs/spdx.spdx +++ b/examples/stackwright-docs/spdx.spdx @@ -1,14 +1,38 @@ SPDXVersion: SPDX-2.3 DataLicense: CC0-1.0 -SPDXID: SPDXRef-DOCUMENT-12b1dc75 +SPDXID: SPDXRef-DOCUMENT-9bbc3e93 DocumentName: stackwright-docs@0.1.1-alpha.1 -DocumentNamespace: https://stackwright.dev/spdx/stackwright-docs/2026-04-03T18:50:53.512Z +DocumentNamespace: https://stackwright.dev/spdx/stackwright-docs/2026-04-03T19:28:43.491Z CreationInfo: - Created: 2026-04-03T18:50:53.512Z + Created: 2026-04-03T19:28:43.491Z Creator: Tool: @stackwright/sbom-generator Creator: Tool-Version: 0.0.0 +PackageName: @radix-ui/react-accordion +SPDXID: SPDXRef-Package--radix-ui-react-accordion +PackageVersion: 1.2.2 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: 6385b547dec3958e12e36af31946a77045ae9b94c89e53bfacbedb71c0f3a10c +ExternalRef: PACKAGE-MANAGER purl pkg:npm/radix-ui/react-accordion@1.2.2 + +PackageName: @radix-ui/react-slot +SPDXID: SPDXRef-Package--radix-ui-react-slot +PackageVersion: 1.1.1 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: 6c80259979afa356ff77686649e864a9be6fd187b480c8d3d49fecea2c977b75 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/radix-ui/react-slot@1.1.1 + +PackageName: @radix-ui/react-tabs +SPDXID: SPDXRef-Package--radix-ui-react-tabs +PackageVersion: 1.1.2 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: f3ad88f5751707a5c5f92a1603247d36820a4c7e2a748308c3c8c8acd4d07cd2 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/radix-ui/react-tabs@1.1.2 + PackageName: @stackwright/core SPDXID: SPDXRef-Package--stackwright-core PackageVersion: workspace:* @@ -41,6 +65,22 @@ PackageLicenseDeclared: NOASSERTION PackageChecksum: SHA256: ac159bdf9d62105719ef6d7d5be5b490eadd991e002ddaae9bab73c93f5c7667 ExternalRef: PACKAGE-MANAGER purl pkg:npm/stackwright/ui-shadcn@workspace:* +PackageName: clsx +SPDXID: SPDXRef-Package-clsx +PackageVersion: 2.1.1 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: d00f5fa05915e8916f882f17ae64d1bd58f60296bdf8bb490772cebdafa01b60 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/clsx@2.1.1 + +PackageName: fuse.js +SPDXID: SPDXRef-Package-fuse-js +PackageVersion: 7.1.0 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: 3f3d82b5a1943d6113a0f6476ef58cb75640ba889262edd8fed42a3254d8c119 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/fuse.js@7.1.0 + PackageName: js-yaml SPDXID: SPDXRef-Package-js-yaml PackageVersion: 4.1.1 @@ -49,6 +89,14 @@ PackageLicenseDeclared: NOASSERTION PackageChecksum: SHA256: af705ed1eb31f1af61a3698569d8e0370c173e67024075b4f306d8c9010f1298 ExternalRef: PACKAGE-MANAGER purl pkg:npm/js-yaml@4.1.1 +PackageName: lucide-react +SPDXID: SPDXRef-Package-lucide-react +PackageVersion: 0.471.1 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: 34516f0b69a564672a07087b4a907c82ef437a69c4ba2895f1dc37bb8ae402ac +ExternalRef: PACKAGE-MANAGER purl pkg:npm/lucide-react@0.471.1 + PackageName: next SPDXID: SPDXRef-Package-next PackageVersion: 16.1.6 @@ -73,11 +121,26 @@ PackageLicenseDeclared: NOASSERTION PackageChecksum: SHA256: d321eb3d0478e2a58565081343e4d1da25caf8103b3dd72b3544a715f2e80418 ExternalRef: PACKAGE-MANAGER purl pkg:npm/react-dom@19.2.4 -Relationship: SPDXRef-DOCUMENT-12b1dc75 DESCRIBES SPDXRef-Package-js-yaml -Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-core -Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-icons -Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-nextjs -Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package--stackwright-ui-shadcn -Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package-next -Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package-react -Relationship: SPDXRef-Package-js-yaml CONTAINS SPDXRef-Package-react-dom \ No newline at end of file +PackageName: tailwind-merge +SPDXID: SPDXRef-Package-tailwind-merge +PackageVersion: 2.6.0 +PackageLicenseConcluded: NOASSERTION +PackageLicenseDeclared: NOASSERTION +PackageChecksum: SHA256: f55becd055a25a9ecb32ee3cf37faf157aa62b955195bb7f695909e582e8ae33 +ExternalRef: PACKAGE-MANAGER purl pkg:npm/tailwind-merge@2.6.0 + +Relationship: SPDXRef-DOCUMENT-9bbc3e93 DESCRIBES SPDXRef-Package-clsx +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package--radix-ui-react-accordion +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package--radix-ui-react-slot +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package--radix-ui-react-tabs +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package--stackwright-core +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package--stackwright-icons +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package--stackwright-nextjs +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package--stackwright-ui-shadcn +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package-fuse-js +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package-js-yaml +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package-lucide-react +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package-next +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package-react +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package-react-dom +Relationship: SPDXRef-Package-clsx CONTAINS SPDXRef-Package-tailwind-merge \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e0bab38..2b29dbaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,15 @@ importers: examples/stackwright-docs: dependencies: + '@radix-ui/react-accordion': + specifier: ^1.2.2 + version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': + specifier: ^1.1.1 + version: 1.2.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-tabs': + specifier: ^1.1.2 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@stackwright/core': specifier: workspace:* version: link:../../packages/core @@ -89,9 +98,18 @@ importers: '@stackwright/ui-shadcn': specifier: workspace:* version: link:../../packages/ui-shadcn + clsx: + specifier: ^2.1.1 + version: 2.1.1 + fuse.js: + specifier: ^7.1.0 + version: 7.1.0 js-yaml: specifier: ^4.1.1 version: 4.1.1 + lucide-react: + specifier: ^0.471.1 + version: 0.471.2(react@19.2.4) next: specifier: ^16.1.6 version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -101,6 +119,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.1 devDependencies: '@playwright/browser-chromium': specifier: ^1.58.2 @@ -4782,6 +4803,11 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lucide-react@0.471.2: + resolution: {integrity: sha512-A8fDycQxGeaSOTaI7Bm4fg8LBXO7Qr9ORAX47bDRvugCsjLIliugQO0PkKFoeAD57LIQwlWKd3NIQ3J7hYp84g==} + peerDependencies: + react: 19.2.4 + lucide-react@0.525.0: resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==} peerDependencies: @@ -5690,6 +5716,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@2.6.1: + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -10421,6 +10450,10 @@ snapshots: lru-cache@7.18.3: {} + lucide-react@0.471.2(react@19.2.4): + dependencies: + react: 19.2.4 + lucide-react@0.525.0(react@19.2.4): dependencies: react: 19.2.4 @@ -11488,6 +11521,8 @@ snapshots: symbol-tree@3.2.4: {} + tailwind-merge@2.6.1: {} + tailwind-merge@3.5.0: {} tailwindcss@4.2.1: {}