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
6 changes: 6 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ jobs:
- monorepo
- fullstack
- oss
# Embedded API — generate through createProject/writeGeneratedProject,
# add a deploy file, and confirm the same install/build/test/lint pass.
- ts-lib --embedded
- cli --embedded
- react-app --embedded
- node-service --embedded
- full
- minimal
- react-lib --storybook
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,32 @@ There's also an [`llms.txt`](llms.txt) (served at [danmat.github.io/create-packk
{ "mcpServers": { "packkit": { "command": "npx", "args": ["-y", "packkit-mcp"] } } }
```

## Embed Packkit in your own app

Packkit ships a typed, side-effect-free API so a Node application can use it as a
project-generation engine — generate in memory, add your own deployment files,
and write to disk when you're ready. No prompts, installs, git, or network.

```js
import { createProject, extendProject, writeGeneratedProject } from 'create-packkit/embedded';

const project = createProject({ preset: 'react-app', name: 'weather-dashboard' });
const extended = extendProject(project, { files: { '.github/workflows/deploy.yml': deployYaml } });
await writeGeneratedProject({ project: extended, destination: '/tmp/weather-dashboard' });
```

Full guide, including diagnostics, reproducible definitions, digests, and the
provider-neutral deployment contract: **[Embedding Packkit](docs/EMBEDDING.md)**.

## How it works

Packkit is a pure `config → { files }` **core** that runs in both Node and the browser:

- the **CLI** writes the files to disk, runs `git init`, and installs dependencies;
- the **web configurator** zips the same files client-side (no server).
- the **web configurator** zips the same files client-side (no server);
- the **embedded API** ([`create-packkit/embedded`](docs/EMBEDDING.md)) hands the file map to a host application.

Both drive from one options schema ([`src/core/options.js`](src/core/options.js)), so the CLI and the web page always stay in sync.
All three drive from one options schema ([`src/core/options.js`](src/core/options.js)), so they always stay in sync.

## Staying fresh

Expand Down
19 changes: 19 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ Planned and considered features for Packkit. Not commitments — a backlog to pu
- [ ] **Vue/Svelte app scaffolds with a router** — the app targets are minimal SPAs; add vue-router / SvelteKit-style routing (plus a React Router option).
- [ ] **Multiple entry points** — `exports` subpaths (e.g. `./utils`) with per-entry builds (tsup multi-entry).

### Layering (from the 3.0 approval review)
_3.0 turned the embedded API into Packkit's public programmatic surface. Keeping the layers distinct is now an explicit design rule, not an accident of the current code._

**Architecture principles** — worth writing into a short `ARCHITECTURE.md`:
1. The core never performs side effects.
2. The embedded API is the only supported programmatic surface.
3. The CLI is implemented on top of the embedded API.
4. The web configurator is implemented on top of the embedded API (its bundled core).
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.

### 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._

- [ ] **Document the writer's threat model** — the writer is safe against hostile file maps and pre-existing symlinks, but not race-proof: a TOCTOU window exists between the symlink/collision preflight and the `mkdir`/`writeFile`, so another process mutating the destination concurrently could still redirect a write. The intended use is a private temp workspace, where this doesn't arise. State that boundary in [EMBEDDING.md](docs/EMBEDDING.md) rather than claiming protection we don't provide. (An `O_NOFOLLOW`/`openat`-style atomic write would close it, but isn't warranted for the current use.)
- [ ] **`driftPolicy` option for `createProjectFromDefinition`** — replay currently returns the project with an **error-severity** diagnostic when a stored `add` now collides with a generated file (it does not throw). That's deliberate — it preserves the original output — but a consumer treating any `severity: 'error'` as a hard failure would misread it. Add `createProjectFromDefinition(def, { driftPolicy: 'report' | 'error' })` so throwing is opt-in, and document that a returned project may carry error diagnostics until then.

## Ideas from real-world research (2026)
_From mining pain points across create-typescript-app, tsup, Changesets, create-t3-app, and current "publish an npm package" guides. Ordered by demand × fit._

Expand Down
174 changes: 174 additions & 0 deletions docs/EMBEDDING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Embedding Packkit

Packkit can run inside another Node.js application as a project-generation
engine. You give it a preset and some configuration, it produces a complete
project **in memory**, you layer on your own deployment files, and you write it
to disk when you're ready.

The embedded API performs **no authentication, cloud provisioning, repository
creation, dependency installation, or command execution**. It generates files
and hands them to you. What you do with them — deploy, commit, push, install —
is entirely yours.

```js
import {
createProject,
extendProject,
writeGeneratedProject,
} from 'create-packkit/embedded';
```

The same names are re-exported from the package root (`create-packkit`) if you
prefer a single import.

## Generate in memory

`createProject` resolves a preset, applies your overrides, normalizes and
validates the result, and generates every file — without touching the
filesystem.

```js
const project = createProject({
preset: 'react-app',
name: 'weather-dashboard',
overrides: {
packageManager: 'npm',
install: false,
gitInit: false,
},
});

project.files; // { 'package.json': '…', 'src/main.tsx': '…', … }
project.config; // the resolved configuration
project.summary; // { name, fileCount, stack, workflows }
project.diagnostics; // see below
project.metadata; // { packkitVersion, schemaVersion, preset }
project.deploymentContract; // see "Deployment contract"
```

Precedence is **preset → config → overrides**, with `name` applied last.

## Handle diagnostics

Packkit normalizes configurations that don't quite fit — a bundle-size budget
on a project that isn't published, Storybook on something that isn't a component
library. Rather than doing that silently, it reports each change:

```js
for (const d of project.diagnostics) {
// d.severity: 'info' | 'warning' | 'error'
// d.code, d.message, d.field, d.previousValue, d.resolvedValue
console.log(`[${d.severity}] ${d.code}: ${d.message}`);
}
```

Diagnostics also cover unknown options, feature-level file collisions, and
conflicting `package.json` fields. A **fatal** problem — an out-of-range value,
an unknown preset — throws a `PackkitValidationError` whose `.diagnostics`
array explains what was wrong, so generation never produces a broken project.

## Add your own files

`extendProject` returns a **new** project with your files and `package.json`
fields layered on. It never mutates the original.

```js
const extended = extendProject(project, {
files: {
'.github/workflows/deploy.yml': deploymentWorkflow,
'.platform/project.json': JSON.stringify(
{ applicationId: 'weather-dashboard', deploymentType: 'static' },
null,
2,
),
},
packageJson: {
scripts: { deploy: 'my-platform deploy' },
},
});
```

Every extension path is validated (no absolute paths, no `..` escapes, no
case-insensitive duplicates). A file that collides with a generated one is an
**error by default**; pass `collisionPolicy: 'skip'` or `'overwrite'` to choose
otherwise. `package.json` fields deep-merge, with your values winning.

## Read the deployment contract

Every project carries a provider-neutral description of how to build and run it,
derived from the resolved config. It contains no AWS-, Vercel-, Netlify-,
Cloudflare-, or GitHub-specific fields — mapping it to a real platform is your
application's job.

```js
project.deploymentContract;
// static app: { type: 'static', buildCommand: 'npm run build', outputDirectory: 'dist' }
// node service: { type: 'node-service', buildCommand, startCommand, port, healthCheckPath }
// library / cli: { type: 'library' | 'cli', buildCommand? }
```

## Export a reproducible definition

A `PackkitProjectDefinition` is a small, serializable record — config, preset,
and the extensions you added — that reproduces the same project later. It holds
**no secrets and no machine paths**.

```js
const definition = exportProjectDefinition(extended);
// store it (a file, a database, a field on your record)…

const recreated = createProjectFromDefinition(definition);
```

Loading a definition validates its schema and warns if it was created with a
different Packkit version (output may differ across versions).

## Calculate a digest

For change detection or caching, a digest over the normalized config and file
contents is stable across regenerations of the same inputs:

```js
import { calculateProjectDigest } from 'create-packkit/embedded';

calculateProjectDigest(extended) === calculateProjectDigest(recreated); // true
```

## Write it safely

`writeGeneratedProject` is the only part that touches disk. It validates every
path again at the boundary, refuses anything that would escape the destination,
and does nothing else — no install, no git, no lifecycle scripts.

```js
import { writeGeneratedProject } from 'create-packkit/writer';

const result = await writeGeneratedProject({
project: recreated,
destination: '/tmp/weather-dashboard',
// collisionPolicy: 'error' (default) | 'skip' | 'overwrite'
});

result.writtenFiles; // string[]
result.skippedFiles; // string[]
result.diagnostics; // per-file write outcomes
```

An invalid path anywhere in the project makes the whole write fail before
anything is written, so you never get half-escaped output.

## Stable vs internal

Stable, versioned API (import from `create-packkit`, `create-packkit/embedded`,
or `create-packkit/writer`):

- `createProject`, `extendProject`
- `exportProjectDefinition`, `createProjectFromDefinition`
- `calculateProjectDigest`, `deriveDeploymentContract`
- `writeGeneratedProject`
- `PackkitValidationError`, `PackkitWriteError`, `SCHEMA_VERSION`

Everything under `src/core/**` beyond the documented core exports
(`generate`, `fromPreset`, `normalizeConfig`, `OPTIONS`, …) is internal and may
change without a major bump. Import through the package exports above rather
than reaching into implementation paths.
80 changes: 58 additions & 22 deletions docs/packkit-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,20 +301,32 @@ function defaultConfig() {
}
return cfg;
}
function normalizeConfig(input = {}) {
function normalizeConfig(input = {}, diagnostics = null) {
const cfg = { ...defaultConfig(), ...input };
if (!Array.isArray(cfg.target) || cfg.target.length === 0) cfg.target = ["library"];
if (!Array.isArray(cfg.workflows)) cfg.workflows = [];
if (cfg.bundler === "none") cfg.minify = false;
if (cfg.test === "none" || cfg.test === "node") cfg.coverage = false;
if (cfg.workflows.includes("codecov")) cfg.coverage = true;
const requested = new Set(Object.keys(input));
const coerce = (field, value, code, message, severity = "warning") => {
const same = JSON.stringify(cfg[field]) === JSON.stringify(value);
if (same) return;
const previousValue = cfg[field];
cfg[field] = value;
if (diagnostics && requested.has(field)) {
diagnostics.push({ severity, code, field, previousValue, resolvedValue: value, message, source: "normalize" });
}
};
if (!Array.isArray(cfg.target) || cfg.target.length === 0) {
coerce("target", ["library"], "TARGET_DEFAULTED", 'No target was given, so "library" was used.', "info");
}
if (!Array.isArray(cfg.workflows)) coerce("workflows", [], "WORKFLOWS_DEFAULTED", "workflows was not a list, so it was reset to none.", "info");
if (cfg.bundler === "none") coerce("minify", false, "MINIFY_REQUIRES_BUNDLER", "Minify was disabled because no bundler produces output to minify.");
if (cfg.test === "none" || cfg.test === "node") coerce("coverage", false, "COVERAGE_UNSUPPORTED_RUNNER", `Coverage was disabled because the "${cfg.test}" test runner does not report it.`);
if (cfg.workflows.includes("codecov")) coerce("coverage", true, "COVERAGE_FORCED_BY_CODECOV", "Coverage was enabled because the Codecov workflow requires it.", "info");
cfg.isReact = cfg.framework === "react";
cfg.isVue = cfg.framework === "vue";
cfg.isSvelte = cfg.framework === "svelte";
cfg.hasFramework = cfg.framework !== "none";
cfg.hasApp = cfg.target.includes("app");
if (cfg.hasFramework && !cfg.hasApp && !cfg.target.includes("library")) {
cfg.target = ["library", ...cfg.target];
coerce("target", ["library", ...cfg.target], "TARGET_LIBRARY_ADDED", 'A "library" target was added because a component framework needs something to build.', "info");
}
cfg.isTs = cfg.language === "ts";
cfg.ext = cfg.isTs ? "ts" : "js";
Expand All @@ -327,30 +339,32 @@ function normalizeConfig(input = {}) {
cfg.customBuild = cfg.viteBuild || cfg.svelteLib;
cfg.usesVite = cfg.viteBuild || cfg.isSvelte;
cfg.hasBuild = cfg.viteBuild || !cfg.svelteLib && (cfg.bundler !== "none" || cfg.isTs);
if (cfg.hasApp) cfg.moduleFormat = "esm";
if (cfg.hasApp) coerce("moduleFormat", "esm", "MODULE_FORMAT_FORCED_FOR_APP", "An app is bundled, not published, so its module format is ESM.", "info");
cfg.hasEsm = cfg.moduleFormat === "esm" || cfg.moduleFormat === "dual";
cfg.hasCjs = cfg.moduleFormat === "cjs" || cfg.moduleFormat === "dual";
if (!cfg.hasFramework || cfg.hasApp || !cfg.hasLibrary) cfg.storybook = false;
if (!cfg.hasApp) cfg.e2e = false;
if (!cfg.hasFramework || cfg.hasApp || !cfg.hasLibrary) coerce("storybook", false, "STORYBOOK_REQUIRES_COMPONENT_LIBRARY", "Storybook was disabled because it only applies to a component library.");
if (!cfg.hasApp) coerce("e2e", false, "E2E_REQUIRES_APP", "End-to-end tests were disabled because they only apply to an app target.");
if (cfg.monorepo) cfg.hasBuild = true;
cfg.publishable = (cfg.hasLibrary || cfg.hasCli) && !cfg.hasApp && !cfg.hasService;
if (!cfg.publishable) cfg.pkgChecks = false;
if (!cfg.publishable) cfg.sourcemaps = false;
if (!(cfg.publishable && cfg.hasBuild)) cfg.sizeLimit = false;
if (!(cfg.hasService || cfg.hasCli)) cfg.env = false;
if (cfg.release !== "changesets") cfg.canary = false;
if (!(cfg.isTs && cfg.hasLibrary && !cfg.hasFramework && !cfg.hasApp)) cfg.jsr = false;
if (!cfg.publishable) coerce("pkgChecks", false, "PKG_CHECKS_REQUIRES_PUBLISHABLE", "Package-correctness checks were disabled because this project is not published to npm.");
if (!cfg.publishable) coerce("sourcemaps", false, "SOURCEMAPS_REQUIRES_PUBLISHABLE", "Sourcemaps were disabled because this project is not published to npm.");
if (!(cfg.publishable && cfg.hasBuild)) coerce("sizeLimit", false, "SIZE_LIMIT_REQUIRES_BUILT_LIBRARY", "The bundle-size budget was disabled because it needs a published package with a build.");
if (!(cfg.hasService || cfg.hasCli)) coerce("env", false, "ENV_REQUIRES_SERVICE_OR_CLI", "Env validation was disabled because it only applies to a service or CLI.");
if (cfg.release !== "changesets") coerce("canary", false, "CANARY_REQUIRES_CHANGESETS", "Canary releases were disabled because they require the Changesets release flow.");
if (!(cfg.isTs && cfg.hasLibrary && !cfg.hasFramework && !cfg.hasApp)) coerce("jsr", false, "JSR_REQUIRES_PLAIN_TS_LIBRARY", "JSR publishing was disabled because it applies only to a plain TypeScript library.");
return cfg;
}

// src/core/render.js
var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
function deepMerge(target, source) {
if (Array.isArray(target) && Array.isArray(source)) {
return [.../* @__PURE__ */ new Set([...target, ...source])];
}
if (isPlainObject(target) && isPlainObject(source)) {
const out = { ...target };
for (const [k, v] of Object.entries(source)) {
if (UNSAFE_KEYS.has(k)) continue;
out[k] = k in target ? deepMerge(target[k], v) : v;
}
return out;
Expand Down Expand Up @@ -3052,28 +3066,48 @@ function fromPreset(name, overrides = {}) {
cfg.preset = canonical;
return cfg;
}
function generate(input) {
const cfg = normalizeConfig(input);
if (cfg.monorepo) return buildMonorepo(cfg);
function assemble(cfg) {
const files = {};
const fileSources = {};
const fragments = [];
let pkg = {};
for (const feat of features_default) {
if (!feat.active(cfg)) continue;
const out = feat.apply(cfg) || {};
if (out.files) {
for (const [path, contents] of Object.entries(out.files)) files[path] = contents;
for (const [path, contents] of Object.entries(out.files)) {
(fileSources[path] ||= []).push(feat.id);
files[path] = contents;
}
}
if (out.pkg) pkg = deepMerge(pkg, out.pkg);
if (out.pkg) {
fragments.push({ source: feat.id, pkg: out.pkg });
pkg = deepMerge(pkg, out.pkg);
}
}
return { files, fileSources, fragments, pkg };
}
function generateTracked(input, diagnostics = null) {
const cfg = normalizeConfig(input, diagnostics);
if (cfg.monorepo) {
return { ...buildMonorepo(cfg), fileSources: {}, fragments: [] };
}
const { files, fileSources, fragments, pkg } = assemble(cfg);
files["package.json"] = toJson(finalizePackageJson(pkg));
files["packkit.json"] = provenance(cfg);
return {
config: cfg,
files,
postCommands: postCommands(cfg),
summary: summarize(cfg, files)
summary: summarize(cfg, files),
fileSources,
fragments
};
}
function generate(input) {
const { config, files, postCommands: postCommands2, summary } = generateTracked(input);
return { config, files, postCommands: postCommands2, summary };
}
function postCommands(cfg) {
const install = {
npm: "npm install",
Expand Down Expand Up @@ -3110,9 +3144,11 @@ export {
PRESET_ALIASES,
PRESET_INFO,
PRESET_NAMES,
assemble,
defaultConfig,
fromPreset,
generate,
generateTracked,
normalizeConfig,
resolvePreset
};
Loading