Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
1 change: 0 additions & 1 deletion docs/packkit-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,6 @@ var meta_default = {
}
};
function libraryEntry(cfg) {
if (cfg.isReact) return reactEntry(cfg);
if (cfg.isTs) {
return [
`/** Greet someone by name. */`,
Expand Down
61 changes: 47 additions & 14 deletions src/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/core/features/meta.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */`,
Expand Down
45 changes: 40 additions & 5 deletions src/embedded/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions test/embedded.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
2 changes: 2 additions & 0 deletions types/embedded.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down