diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..317cc27 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,69 @@ +# Architecture + +Packkit is a project-generation engine with several front ends. The value of +that separation only holds if the layers stay distinct, so they're written down +here as rules rather than left as an accident of the current code. + +``` + ┌── CLI ──┐ ┌─ web configurator ─┐ ┌─ MCP ─┐ ┌─ host apps ─┐ + │ (bin) │ │ (docs, bundled) │ │ (mcp) │ │ (embedded) │ + └────┬────┘ └──────────┬─────────┘ └───┬───┘ └──────┬──────┘ + │ │ │ │ + └──────────────────┴────── adapters ┴────────────┘ + │ + ┌───────────▼───────────┐ + │ Embedded API │ src/embedded + │ resolve · generate · │ + │ extend · write │ + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ Core │ src/core + │ config → { files } │ + │ pure, no side │ + │ effects, browser- │ + │ safe │ + └───────────────────────┘ +``` + +## Principles + +1. **The core never performs side effects.** `src/core` is a pure + `config → { files }` function. It runs in Node and the browser, makes no + network calls, touches no filesystem, and spawns no processes. Everything it + returns is data. + +2. **The embedded API is the only supported programmatic surface.** `src/embedded` + is what other code builds on: `resolveProjectConfig`, `createProject`, + `extendProject`, `writeGeneratedProject`, and the definition/digest/contract + helpers. It's typed (`types/`) and versioned. Reaching past it into + `src/core/**` internals is unsupported and may break without a major bump. + +3. **The CLI is an adapter over the embedded API.** `bin`/`src/cli` resolves and + generates through the embedded pipeline, then adds the side effects the + embedded API deliberately never performs — `git init`, dependency install, + creating the remote. It does not generate files by itself. + +4. **The web configurator is an adapter over the core.** `docs/` bundles + `src/core` directly (`build:web`) and runs it client-side to preview and zip a + project. Same generation, no server. + +5. **MCP is an adapter over the core + scaffold helpers.** `mcp/` exposes + generation as Model Context Protocol tools, importing `create-packkit/core` + and `create-packkit/scaffold`. + +6. **Future providers never bypass the embedded API.** Any deployment or + provisioning integration (Netlify, AWS, a portal) consumes the embedded API + like any other host. Provider-specific logic lives in the host, never in + Packkit — which is why the deployment contract is provider-neutral. + +## Why this matters + +Because every surface resolves and generates through one pipeline, they all +share the same normalization diagnostics, collision handling, and path safety. +A fix to any of those benefits the CLI, the web page, MCP, and embedding hosts +at once — and no surface can quietly diverge into its own behavior. + +The only thing that legitimately differs between surfaces is what they do +*around* generation: the CLI installs and pushes, the web page zips, a host app +deploys. Those are adapters. The engine in the middle is shared. diff --git a/ROADMAP.md b/ROADMAP.md index abbd212..5757d1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,7 +61,7 @@ _3.0 turned the embedded API into Packkit's public programmatic surface. Keeping 5. MCP is implemented on top of the embedded API. 6. Future providers (Netlify, AWS, …) never bypass the embedded API. -- [ ] **CLI on the embedded API** — today the CLI calls `generate` (core) directly; only the writer differs from the embedded path. Rebase it onto `createProject`/`writeGeneratedProject` so there's exactly **one** orchestration layer and every surface (CLI, web, MCP, host apps) shares the same diagnostics, collision handling, and path safety. Adapters at the edges, one engine in the middle. Do this *after* 3.0 ships, carefully — the CLI's install/git/prompt behavior must stay identical. +- [x] ~~**CLI on the embedded API**~~ — **Shipped (3.1).** The CLI resolves + generates through `resolveProjectConfig` / `createProjectFromResolvedConfig` and writes through `writeGeneratedProject`, so every surface shares one pipeline (diagnostics, collision handling, path safety). Side effects (git, install, remote) stay in the CLI adapter. Behavior verified identical — exact file parity, merge/abort/remote/install all preserved — and the CLI now surfaces coercion warnings it used to swallow. The six layering principles are written down in [ARCHITECTURE.md](ARCHITECTURE.md). ### Embedded API follow-ups (from the 3.0 review) _Non-blocking items deferred from PR #17. The blockers (symlink safety, replay mode-preservation) shipped in 3.0._ diff --git a/docs/packkit-core.js b/docs/packkit-core.js index 81d07a8..9aa07b5 100644 --- a/docs/packkit-core.js +++ b/docs/packkit-core.js @@ -461,7 +461,6 @@ var meta_default = { } }; function libraryEntry(cfg) { - if (cfg.isReact) return reactEntry(cfg); if (cfg.isTs) { return [ `/** Greet someone by name. */`, diff --git a/src/cli/index.js b/src/cli/index.js index fb566e3..455cb89 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -2,12 +2,17 @@ import { resolve, basename } from 'node:path'; import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import * as p from '@clack/prompts'; -import { generate, fromPreset, normalizeConfig, PRESET_NAMES, OPTIONS, OPTION_HELP, PRESET_INFO, PRESET_ALIASES } from '../core/index.js'; +import { PRESET_NAMES, OPTIONS, OPTION_HELP, PRESET_INFO, PRESET_ALIASES } from '../core/index.js'; import { engineFloor, meetsNodeFloor } from '../core/node.js'; +// The CLI is an adapter over the embedded API: it resolves + generates through +// the same pipeline every other surface uses (so it shares diagnostics, +// collision handling, and path safety), and adds the side effects the embedded +// API deliberately never performs — git, install, and creating the remote. +import { resolveProjectConfig, createProjectFromResolvedConfig, PackkitValidationError } from '../embedded/index.js'; +import { writeGeneratedProject } from '../embedded/writer.js'; import { parseCliArgs } from './args.js'; import { runWizard } from './wizard.js'; import { - writeProject, existingEntries, gitInit, installDeps, @@ -125,21 +130,33 @@ export async function run(argv = process.argv.slice(2)) { const profile = loadProfile(args); const seed = { ...profile, ...args.overrides }; - let config; + // Gather raw input for the embedded resolver — don't normalize here, so the + // resolver captures the coercions it makes (they surface as warnings below). + let rawInput; + let presetName; if (interactive) { p.intro('📦 Packkit'); - config = normalizeConfig(await runWizard(seed)); + rawInput = await runWizard(seed); } else if (args.preset) { - config = fromPreset(args.preset, seed); + presetName = args.preset; + rawInput = seed; } else { - config = normalizeConfig(seed); + rawInput = seed; } + // gitInit/install are real options, so they flow through resolution. + rawInput = { ...rawInput, gitInit: args.git, install: args.install }; - config.gitInit = args.git; - config.install = args.install; - // Core is version-agnostic (it also runs in the browser), so the surface - // that knows the version supplies it for packkit.json. - config.generatorVersion = pkgVersion(); + let config; + let diagnostics; + try { + ({ config, diagnostics } = resolveProjectConfig({ preset: presetName, config: rawInput })); + } catch (err) { + if (err instanceof PackkitValidationError) { + console.error(err.diagnostics.map((d) => `✖ ${d.message}`).join('\n')); + process.exit(1); + } + throw err; + } // Node preflight: the generated project's tools (eslint, vite, vitest) hard- // require this floor. npm only *warns* on engines, so catch it here — clearly, @@ -186,9 +203,25 @@ export async function run(argv = process.argv.slice(2)) { } if (remote?.url && /^https?:/.test(remote.url)) config.repo = remote.url; - // Generate + write. - const { files, summary } = generate(config); - const { skipped } = await writeProject(targetDir, files, { merge: args.merge }); + // Generate through the embedded pipeline (carrying the resolution diagnostics) + // and write through the safe writer. --merge maps to the 'skip' policy: write + // what's absent, keep what's there — never overwrite. + const project = createProjectFromResolvedConfig(config, { diagnostics }); + const { summary } = project; + + // Surface anything Packkit changed or flagged during resolution — a coercion + // (Storybook off for a service), an unknown option — before writing. + const warnings = project.diagnostics.filter((d) => d.severity === 'warning'); + if (warnings.length) { + const lines = warnings.map((d) => d.message).join('\n'); + if (interactive) p.log.warn(lines); else console.error('\n⚠ ' + warnings.map((d) => d.message).join('\n ')); + } + + const { skippedFiles: skipped } = await writeGeneratedProject({ + project, + destination: targetDir, + collisionPolicy: args.merge ? 'skip' : 'error', + }); // Post steps. if (config.gitInit) gitInit(targetDir); diff --git a/src/core/features/meta.js b/src/core/features/meta.js index a062c8a..802cf10 100644 --- a/src/core/features/meta.js +++ b/src/core/features/meta.js @@ -54,7 +54,8 @@ export default { }; function libraryEntry(cfg) { - if (cfg.isReact) return reactEntry(cfg); + // Only reached for a plain library (the caller guards on !cfg.hasFramework), + // so framework entries never come through here — frameworks.js writes those. if (cfg.isTs) { return [ `/** Greet someone by name. */`, diff --git a/src/embedded/index.js b/src/embedded/index.js index d727240..6acb8bc 100644 --- a/src/embedded/index.js +++ b/src/embedded/index.js @@ -72,12 +72,18 @@ function packkitVersion() { } /** - * Generate a complete project in memory. No files are written, nothing is - * installed, no commands run. Returns a GeneratedProject with diagnostics. + * Resolve a preset + overrides into a complete, validated config, collecting + * the diagnostics normalization produced — without generating anything. + * + * Generation and resolution are split so a trusted caller (the CLI) can make + * decisions on the resolved config — a Node-floor check, creating the remote — + * and then generate, while a host app just uses createProject() which does both. * Throws PackkitValidationError if the config is fatally invalid. + * + * @returns {{ config: object, diagnostics: object[] }} */ -export function createProject(input = {}) { - if (!input || typeof input !== 'object') throw new TypeError('createProject expects an input object.'); +export function resolveProjectConfig(input = {}) { + if (!input || typeof input !== 'object') throw new TypeError('resolveProjectConfig expects an input object.'); const merged = { ...(input.config || {}), ...(input.overrides || {}) }; if (input.name != null) merged.name = input.name; @@ -106,7 +112,36 @@ export function createProject(input = {}) { const raw = canonicalPreset ? { ...PRESETS[canonicalPreset], ...merged, preset: canonicalPreset } : merged; - const { config, files, summary, fileSources, fragments } = generateTracked({ ...raw, generatorVersion: packkitVersion() }, diagnostics); + const config = normalizeConfig({ ...raw, generatorVersion: packkitVersion() }, diagnostics); + return { config, diagnostics }; +} + +/** + * Generate a complete project in memory. No files are written, nothing is + * installed, no commands run. Returns a GeneratedProject with diagnostics. + * Throws PackkitValidationError if the config is fatally invalid. + */ +export function createProject(input = {}) { + const { config, diagnostics } = resolveProjectConfig(input); + return assembleProject(config, diagnostics); +} + +/** + * Build a project from an already-resolved config (from resolveProjectConfig, + * possibly with a field like `repo` set since). For trusted callers only — the + * config is assumed valid; pass its resolution diagnostics through so they + * appear on the project. Re-normalizes idempotently. + */ +export function createProjectFromResolvedConfig(config, { diagnostics = [] } = {}) { + const resolved = normalizeConfig({ generatorVersion: packkitVersion(), ...config }); + return assembleProject(resolved, [...diagnostics]); +} + +// Shared generation core: turn a resolved config into a GeneratedProject, +// appending collision and package-conflict diagnostics to whatever the caller +// already collected during resolution. +function assembleProject(config, diagnostics) { + const { files, summary, fileSources, fragments } = generateTracked(config); for (const [path, sources] of Object.entries(fileSources)) { if (sources.length > 1) { diff --git a/test/embedded.test.js b/test/embedded.test.js index 8c9b842..3ef6520 100644 --- a/test/embedded.test.js +++ b/test/embedded.test.js @@ -369,3 +369,27 @@ test('definition replay preserves the original add/replace mode across a round-t const replayedReplace = createProjectFromDefinition(exportProjectDefinition(replaced)); assert.equal(exportProjectDefinition(replayedReplace).extensions.files['package.json'].mode, 'replace'); }); + +// ---- two-phase resolution (the CLI runs on this) --------------------------- + +test('resolveProjectConfig + createProjectFromResolvedConfig equals createProject', async () => { + const { resolveProjectConfig, createProjectFromResolvedConfig } = await import('../src/embedded/index.js'); + // Two-phase (what the CLI does) must match the one-shot createProject. + const { config, diagnostics } = resolveProjectConfig({ preset: 'node-service', name: 'svc', overrides: { storybook: true } }); + assert.ok(config.name === 'svc'); + assert.ok(diagnostics.some((d) => d.code === 'STORYBOOK_REQUIRES_COMPONENT_LIBRARY'), 'coercion captured at resolve time'); + + const two = createProjectFromResolvedConfig(config, { diagnostics }); + const one = createProject({ preset: 'node-service', name: 'svc', overrides: { storybook: true } }); + assert.equal(calculateProjectDigest(two), calculateProjectDigest(one)); + // The resolution diagnostics carried through to the project. + assert.ok(two.diagnostics.some((d) => d.code === 'STORYBOOK_REQUIRES_COMPONENT_LIBRARY')); +}); + +test('createProjectFromResolvedConfig honors a field set after resolution (repo)', async () => { + const { resolveProjectConfig, createProjectFromResolvedConfig } = await import('../src/embedded/index.js'); + const { config, diagnostics } = resolveProjectConfig({ preset: 'ts-lib', name: 'lib' }); + config.repo = 'https://github.com/acme/lib'; // the CLI does this after resolving the remote + const project = createProjectFromResolvedConfig(config, { diagnostics }); + assert.match(project.files['package.json'], /acme\/lib/); +}); diff --git a/types/embedded.d.ts b/types/embedded.d.ts index 20150f4..c6c07b8 100644 --- a/types/embedded.d.ts +++ b/types/embedded.d.ts @@ -85,6 +85,8 @@ export class PackkitValidationError extends Error { export const SCHEMA_VERSION: number; export function createProject(input?: CreateProjectInput): GeneratedProject; +export function resolveProjectConfig(input?: CreateProjectInput): { config: ResolvedPackkitConfig; diagnostics: Diagnostic[] }; +export function createProjectFromResolvedConfig(config: ResolvedPackkitConfig, options?: { diagnostics?: Diagnostic[] }): GeneratedProject; export function extendProject(project: GeneratedProject, extension?: ProjectExtension): GeneratedProject; export function exportProjectDefinition(project: GeneratedProject): PackkitProjectDefinition; export function createProjectFromDefinition(definition: PackkitProjectDefinition): GeneratedProject;