Skip to content

Embedded project-generation API (3.0.0)#17

Merged
DanMat merged 4 commits into
mainfrom
feat/embedded-api
Jul 26, 2026
Merged

Embedded project-generation API (3.0.0)#17
DanMat merged 4 commits into
mainfrom
feat/embedded-api

Conversation

@DanMat

@DanMat DanMat commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Adds a stable, typed, side-effect-free API so a Node application can use Packkit as a project-generation engine. Generate in memory, layer on your own deployment files, write to disk when ready — no prompts, installs, git, network, or command execution anywhere in the path.

This is the 3.0.0 feature. It's additive; nothing existing changes behavior.

The flow (acceptance criteria, verified)

import { createProject, extendProject, writeGeneratedProject } from 'create-packkit/embedded';
import { exportProjectDefinition, createProjectFromDefinition, calculateProjectDigest } from 'create-packkit/embedded';

const project   = createProject({ preset: 'react-app', name: 'example-app' });
const extended  = extendProject(project, { files: { '.github/workflows/deploy.yml': deployYaml } });
const recreated = createProjectFromDefinition(exportProjectDefinition(extended));

assert.equal(calculateProjectDigest(extended), calculateProjectDigest(recreated)); // ✓
await writeGeneratedProject({ project: recreated, destination: tmp });            // ✓

The written project then installs, builds, tests, and lints green — confirmed for ts-lib, cli, react-app, and node-service.

What's in it

Concern API
Generate in memory createProject(input)GeneratedProject
Add host files (immutable) extendProject(project, ext) — collision policy, default error
Write to disk (only side effect) writeGeneratedProject({project, destination})
Reproduce later exportProjectDefinition / createProjectFromDefinition
Change detection calculateProjectDigest
Provider-neutral deploy info deriveDeploymentContract

Design decisions worth a look

Normalization stops being silent. normalizeConfig now optionally records every coercion — Storybook disabled for a non-component-library, a size budget dropped for an unpublished package — as a Diagnostic with previousValue/resolvedValue. This is one of the architecture-review P1 items, landed here because the embedded API needed it. Fully backward compatible: callers that pass no collector get byte-identical behavior, which is why all 49 existing tests are untouched.

One generation path, with provenance. Rather than fork generation, generate() now delegates to a new assemble() that tracks which feature produced each file and fragment. generate()'s output is unchanged; the embedded layer reads the provenance to report file- and package.json-field conflicts.

Path safety is enforced twice — when a project is assembled and again at the writer boundary — so a project that reached the writer from an untrusted source still can't escape the destination. Rejects absolute, .., null-byte, Windows-reserved, and case-insensitive-colliding paths. An invalid path fails the whole write before anything lands.

Root export moved to src/index.js (core + embedded) so import { createProject } from 'create-packkit' works, per the brief's primary example. ./core stays the browser-safe core-only entry, and the web bundle builds from src/core directly, so browser compatibility is unaffected. Additive for existing consumers.

Types

Full .d.ts for the public surface (types/), a types entry in package.json, and per-export types. Packed-tarball tests confirm every export resolves from a clean install, the declarations ship, and the CLI still works.

Tests

23 new (unit + filesystem): resolution, diagnostics, unknown/invalid options, extension immutability, collisions, path traversal, determinism, definition round-trip, deployment contracts, and the writer (nested paths, Unicode/space filenames, collision policies, traversal rejection, no-install/no-git). Integration harness gains --embedded, wired into CI for four presets. 72 tests pass locally.

Explicitly out of scope (by the brief)

No cloud provisioning, repo creation, auth, infra, or provider SDKs. The deployment contract is provider-neutral — no AWS/Vercel/Netlify/Cloudflare/GitHub fields. The core generation path stays free of network calls.

Docs: docs/EMBEDDING.md.

🤖 Generated with Claude Code

DanMat and others added 2 commits July 26, 2026 11:50
Packkit can now run inside another Node application as a
project-generation engine. A host resolves a preset, generates the
project entirely in memory, layers its own deployment files on, and
writes to disk when ready — no prompts, installs, git, network, or
command execution anywhere in the path.

New public API (create-packkit, /embedded, /writer):
- createProject(input) — preset + overrides → GeneratedProject in memory
- extendProject(project, ext) — immutable; add files/package.json fields
  with a collision policy (default error)
- writeGeneratedProject({project, destination}) — the only disk touch;
  re-validates every path, refuses traversal, runs nothing else
- exportProjectDefinition / createProjectFromDefinition — serialize and
  reproduce a project; schema- and version-checked
- calculateProjectDigest — stable over config + file contents
- deriveDeploymentContract — provider-neutral build/runtime description

Supporting work:
- normalizeConfig now optionally records every coercion it makes, so a
  host learns what Packkit changed (STORYBOOK_DISABLED_FOR_TARGET, etc.)
  instead of it happening silently. Backward compatible — callers that
  pass no collector get identical behavior.
- generate() delegates to a new assemble() that tracks per-file and
  per-fragment provenance, powering file- and package.json-conflict
  diagnostics without changing generated output.
- Path validation module rejects absolute, `..`-escaping, null-byte,
  Windows-reserved, and case-insensitive-colliding paths, applied both
  when a project is built and again at the writer boundary.
- TypeScript declarations for the whole public surface; package now ships
  a `types` entry and types/ directory.
- Root export is now src/index.js (core + embedded); ./core stays the
  browser-safe core-only entry. Additive for existing consumers.

Tests: 23 new (unit + filesystem) covering resolution, diagnostics,
immutability, collisions, path traversal, determinism, definition
round-trip, and the writer. Integration harness gains --embedded, wired
into CI for ts-lib, cli, react-app, and node-service — each generated
through the API, extended, written, then installed/built/tested/linted,
with the deployment contract and digest verified. Packed-tarball checks
confirm every export resolves and the CLI still works from an install.

All 49 existing tests unchanged; CLI, web configurator, MCP, and presets
behavior preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both merge blockers and every "fix before merge" / "strongly preferred"
item from the review.

Blockers:
- Writer now follows real inodes and rejects a symlink anywhere between
  the destination and target (and a symlinked destination itself), so a
  `dest/link -> /outside` can no longer redirect a write past the lexical
  containment check. Covers the final path component too, closing the
  overwrite-through-symlink hole.
- Definition replay records whether each extension file was an add or a
  replace. On replay under a newer Packkit, an original "add" that now
  collides with a generated file is surfaced as an error-severity
  diagnostic instead of silently overwriting — the stored copy still
  reproduces, but the drift is visible. (SCHEMA_VERSION -> 2.)

Also fixed:
- Dependency conflicts key by section+name, so differing ranges across
  dependencies vs peerDependencies aren't reported as a conflict.
- exists()/lstat helpers only treat ENOENT as "absent"; permission and
  I/O errors surface instead of being read as a missing file.
- Extension file contents validated as strings up front.
- Host package.json overrides now emit EXTENSION_PACKAGE_FIELD_OVERRIDE /
  EXTENSION_DEPENDENCY_VERSION_OVERRIDE — the host still wins, visibly.
- Writer preflights all collisions under the error policy and writes
  nothing if any exist (no partial output).
- Single assembly pass: generate() delegates to generateTracked(), which
  both the public result and the embedded provenance read from.
- createProjectFromDefinition validates definition structure, rejects
  prototype-pollution keys, and caps file count / total size for
  untrusted stores.
- deepMerge skips __proto__/constructor/prototype defensively.
- Deployment contract no longer marks PORT required (it has a default).
- Internal extension state moved to a WeakMap, so GeneratedProject no
  longer carries a `_extensions` field.
- Recursive protected-field flattening (array key paths, dot-safe).
- PackkitValidationError.code and PackkitWriteError.{path,destination,cause}.

12 new tests cover symlink traversal, collision preflight, replay drift,
override diagnostics, content validation, per-section dep conflicts,
prototype-pollution defense, definition limits, and state encapsulation.
84 tests pass; embedded integration (incl. a service responding on
/health) green; CLI, web, MCP unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DanMat

DanMat commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

Thanks — thorough review. Everything in Fix before merge and Strongly preferred is addressed in the latest commit. Rundown:

Blockers

  1. Symlink traversal — the writer now follows real inodes and rejects a symbolic link at any component between the destination and the target, including the final file (closing the overwrite-through-symlink case) and a symlinked destination itself. SYMLINK_PATH / PARENT_NOT_DIRECTORY. Tests: dest/link -> outside and a symlinked destination.
  2. Definition replay — each stored extension file now carries mode: 'add' | 'replace'. On replay under a newer Packkit, an original add that now collides with a generated file is surfaced as an error-severity EXTENSION_ADD_COLLIDES_WITH_NEW_BASE diagnostic rather than silently overwriting; the stored copy still reproduces, so the definition remains a faithful contract while the drift is visible. SCHEMA_VERSION → 2.

Also fixed

  1. Dependency conflicts keyed by section+namedependencies.react vs peerDependencies.react at different ranges is no longer a false conflict; a real same-section disagreement still is.
  2. exists()/lstat only treat ENOENT as absent — permission/I/O errors surface as STAT_FAILED instead of masquerading as a missing file.
  3. Extension file content validated as string up front (INVALID_FILE_CONTENT).
  4. Host package overrides reportedEXTENSION_PACKAGE_FIELD_OVERRIDE / EXTENSION_DEPENDENCY_VERSION_OVERRIDE; the host still wins, visibly.
  5. Collision preflight — under the error policy the writer collects every existing target and aborts before writing anything (no partial output).
  6. Single assembly passgenerate() delegates to generateTracked(), which produces both the public files and the provenance the embedded layer reads; no project is assembled twice.
  7. Definition validation — structure, prototype-pollution keys, and file-count / total-size caps for untrusted stores.
  8. Prototype-pollution defensedeepMerge skips __proto__/constructor/prototype, with a regression test proving Object.prototype stays clean.

Follow-ups also done

  • Deployment contract no longer marks PORT required (it has a default); /health is asserted because all three service frameworks generate it — verified.
  • _extensions moved to a WeakMap; GeneratedProject is now exactly { config, files, summary, diagnostics, metadata, deploymentContract }.
  • Recursive protected-field flattening with dot-safe key paths.
  • PackkitValidationError.code and PackkitWriteError.{path,destination,cause}.

12 new tests (84 total, all green). Embedded integration covers ts-lib/cli/react-app/node-service — each generated through the API, written, then installed/built/tested/linted, with the service confirmed responding on /health. CLI, web configurator, and MCP behavior unchanged.

Replay re-derived each extension file's mode against the current base, so
an original `add` became `replace` after one export/import cycle and the
drift warning silently vanished. createProjectFromDefinition now carries
the stored modes through extendProject (internal fileModes) so the
original intent survives repeated round-trips. Regression tests cover
both add and replace.

Also: count definition size in UTF-8 bytes (Buffer.byteLength), not JS
string length, so the "bytes" limit and the value agree for multibyte
content.

Roadmapped the two non-blocking review follow-ups: documenting the
writer's TOCTOU threat model (safe against hostile file maps and existing
symlinks, not against a concurrent process mutating the destination), and
a driftPolicy: 'report' | 'error' option so treating a replay collision as
fatal is opt-in.

85 tests pass; embedded integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DanMat

DanMat commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

Fixed the mode-preservation issue — good catch, that would have quietly eroded the drift signal.

createProjectFromDefinition now threads the stored modes through extendProject (internal fileModes) instead of re-deriving them against the current base, so an original add stays add across repeated export/import cycles. Verified through a double round-trip: the mode is still add after two replays. Both regression tests you specified (add and replace) are in.

Also folded in the byte-counting fix — definition size now uses Buffer.byteLength(content, 'utf8') so the limit and the diagnostic agree for multibyte content.

The two remaining follow-ups are non-blocking and I've added them to the roadmap so they don't get lost:

  • Writer threat model — document that it's safe against hostile file maps and pre-existing symlinks but not race-proof against a concurrent process mutating the destination (a TOCTOU window between preflight and write). Intended use is a private temp workspace.
  • driftPolicy: 'report' | 'error' — make throwing on a replay collision opt-in, and until then document that a returned project may carry error-severity diagnostics without createProjectFromDefinition having thrown.

85 tests pass, CI green.

… 3.0 approval

The approval review made explicit that 3.0 turns the embedded API into
Packkit's public programmatic surface, and that the core/embedded/consumer
layers should stay distinct by rule. Recorded six architecture principles
(worth an ARCHITECTURE.md) and a post-3.0 item to rebase the CLI onto the
embedded API so there is one orchestration layer with every surface sharing
the same diagnostics, collision handling, and path safety.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DanMat
DanMat merged commit d8e3ad4 into main Jul 26, 2026
36 checks passed
@DanMat
DanMat deleted the feat/embedded-api branch July 26, 2026 18:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant